Skip to main content

max / makenotwork

server: a JSON API for git notes Five endpoints under /api/git/{owner}/{repo}/notes: the namespaces a repository carries, one note, put, delete, and search. A creator's own tooling can write build metadata or review state onto a commit without going through the browser form. Reads answer from the repository rather than from the index, which is the program's load-bearing rule applied to an endpoint: nothing is returned that the repository does not hold. Search is the exception, since a tree walk cannot serve full text; it reports whether the index has seen this repository at all, so an empty result is distinguishable from a cold index. A write is the fourth path that moves refs/notes/* server-side, and it reindexes like the other three. It also calls the browser path's rules rather than restating them: the namespace and content validators, the owner-or-push-collaborator check, and the @users.makenot.work identity, which grew two parameters so a token-authenticated caller can use it and one account cannot end up with two addresses in a repository. Writes take a push-scoped personal access token and refuse a session cookie, the same gate receive-pack applies. A script holds no CSRF token, so these routes are registered CSRF-skipped, and a cookie behind an unsealed mutation would be an ambient credential. Reads accept either.
Author: Max Johnson <me@maxj.phd> · 2026-08-09 02:40 UTC
Signed with PGP, not checked
Commit: f1146e99506c84ba2c032d26fd0929d06fb17e06
Parent: 1d4b864
7 files changed, +1044 insertions, -12 deletions
@@ -9,6 +9,332 @@
9 9 "version": "0.11.12"
10 10 },
11 11 "paths": {
12 + "/api/git/{owner}/{repo}/notes": {
13 + "get": {
14 + "tags": [
15 + "Git Notes"
16 + ],
17 + "summary": "`GET /api/git/{owner}/{repo}/notes`: the namespaces this repository carries.",
18 + "operationId": "list_namespaces",
19 + "parameters": [
20 + {
21 + "name": "owner",
22 + "in": "path",
23 + "description": "Repository owner's username",
24 + "required": true,
25 + "schema": {
26 + "type": "string"
27 + }
28 + },
29 + {
30 + "name": "repo",
31 + "in": "path",
32 + "description": "Repository name",
33 + "required": true,
34 + "schema": {
35 + "type": "string"
36 + }
37 + }
38 + ],
39 + "responses": {
40 + "200": {
41 + "description": "Namespaces, with a note count each",
42 + "content": {
43 + "application/json": {
44 + "schema": {
45 + "$ref": "#/components/schemas/NamespacesResponse"
46 + }
47 + }
48 + }
49 + },
50 + "404": {
51 + "description": "No such repository, or not visible to the caller"
52 + }
53 + }
54 + }
55 + },
56 + "/api/git/{owner}/{repo}/notes/search": {
57 + "get": {
58 + "tags": [
59 + "Git Notes"
60 + ],
61 + "summary": "`GET /api/git/{owner}/{repo}/notes/search`: full-text search over the index.",
62 + "description": "`search` is a static segment and an object id is 40 or 64 hex characters, so\nit can never be shadowed by, or shadow, a real target on `/notes/{target}`.",
63 + "operationId": "search_notes",
64 + "parameters": [
65 + {
66 + "name": "owner",
67 + "in": "path",
68 + "description": "Repository owner's username",
69 + "required": true,
70 + "schema": {
71 + "type": "string"
72 + }
73 + },
74 + {
75 + "name": "repo",
76 + "in": "path",
77 + "description": "Repository name",
78 + "required": true,
79 + "schema": {
80 + "type": "string"
81 + }
82 + },
83 + {
84 + "name": "q",
85 + "in": "query",
86 + "description": "Query: bare words, quoted phrases, `or`, `-excluded`",
87 + "required": true,
88 + "schema": {
89 + "type": "string"
90 + }
91 + },
92 + {
93 + "name": "namespace",
94 + "in": "query",
95 + "description": "Restrict to one namespace",
96 + "required": false,
97 + "schema": {
98 + "type": "string"
99 + }
100 + },
101 + {
102 + "name": "commits_only",
103 + "in": "query",
104 + "description": "Drop notes on blobs and trees",
105 + "required": false,
106 + "schema": {
107 + "type": "boolean"
108 + }
109 + },
110 + {
111 + "name": "limit",
112 + "in": "query",
113 + "description": "Maximum hits, default 50, capped at 200",
114 + "required": false,
115 + "schema": {
116 + "type": "integer",
117 + "format": "int64"
118 + }
119 + }
120 + ],
121 + "responses": {
122 + "200": {
123 + "description": "Matching notes, and whether the index has seen this repository",
124 + "content": {
125 + "application/json": {
126 + "schema": {
127 + "$ref": "#/components/schemas/SearchResponse"
128 + }
129 + }
130 + }
131 + },
132 + "404": {
133 + "description": "No such repository, or not visible to the caller"
134 + }
135 + }
136 + }
137 + },
138 + "/api/git/{owner}/{repo}/notes/{target}": {
139 + "get": {
140 + "tags": [
141 + "Git Notes"
142 + ],
143 + "summary": "`GET /api/git/{owner}/{repo}/notes/{target}`: one note.",
144 + "operationId": "get_note",
145 + "parameters": [
146 + {
147 + "name": "owner",
148 + "in": "path",
149 + "description": "Repository owner's username",
150 + "required": true,
151 + "schema": {
152 + "type": "string"
153 + }
154 + },
155 + {
156 + "name": "repo",
157 + "in": "path",
158 + "description": "Repository name",
159 + "required": true,
160 + "schema": {
161 + "type": "string"
162 + }
163 + },
164 + {
165 + "name": "target",
166 + "in": "path",
167 + "description": "Full object id of the annotated object",
168 + "required": true,
169 + "schema": {
170 + "type": "string"
171 + }
172 + },
173 + {
174 + "name": "namespace",
175 + "in": "query",
176 + "description": "Notes namespace, default `commits`",
177 + "required": false,
178 + "schema": {
179 + "type": "string"
180 + }
181 + },
182 + {
183 + "name": "attribution",
184 + "in": "query",
185 + "description": "Include who wrote the note; costs a bounded walk of the notes ref",
186 + "required": false,
187 + "schema": {
188 + "type": "boolean"
189 + }
190 + }
191 + ],
192 + "responses": {
193 + "200": {
194 + "description": "The note",
195 + "content": {
196 + "application/json": {
197 + "schema": {
198 + "$ref": "#/components/schemas/NoteResponse"
199 + }
200 + }
201 + }
202 + },
203 + "404": {
204 + "description": "No such repository, namespace, or note"
205 + }
206 + }
207 + },
208 + "put": {
209 + "tags": [
210 + "Git Notes"
211 + ],
212 + "summary": "`PUT /api/git/{owner}/{repo}/notes/{target}`: add or replace a note.",
213 + "operationId": "put_note",
214 + "parameters": [
215 + {
216 + "name": "owner",
217 + "in": "path",
218 + "description": "Repository owner's username",
219 + "required": true,
220 + "schema": {
221 + "type": "string"
222 + }
223 + },
224 + {
225 + "name": "repo",
226 + "in": "path",
227 + "description": "Repository name",
228 + "required": true,
229 + "schema": {
230 + "type": "string"
231 + }
232 + },
233 + {
234 + "name": "target",
235 + "in": "path",
236 + "description": "Full object id of the commit to annotate",
237 + "required": true,
238 + "schema": {
239 + "type": "string"
240 + }
241 + }
242 + ],
243 + "requestBody": {
244 + "content": {
245 + "application/json": {
246 + "schema": {
247 + "$ref": "#/components/schemas/PutNoteRequest"
248 + }
249 + }
250 + },
251 + "required": true
252 + },
253 + "responses": {
254 + "200": {
255 + "description": "What the write did",
256 + "content": {
257 + "application/json": {
258 + "schema": {
259 + "$ref": "#/components/schemas/WriteResponse"
260 + }
261 + }
262 + }
263 + },
264 + "401": {
265 + "description": "No credential; writes need a push-scoped personal access token"
266 + },
267 + "403": {
268 + "description": "A session cookie, a read-only token, or an account that cannot push here"
269 + },
270 + "404": {
271 + "description": "No such repository, or no such commit in it"
272 + },
273 + "422": {
274 + "description": "Reserved namespace, empty or oversized content, or sustained write contention"
275 + }
276 + }
277 + },
278 + "delete": {
279 + "tags": [
280 + "Git Notes"
281 + ],
282 + "summary": "`DELETE /api/git/{owner}/{repo}/notes/{target}`: remove a note.",
283 + "operationId": "delete_note",
284 + "parameters": [
285 + {
286 + "name": "owner",
287 + "in": "path",
288 + "description": "Repository owner's username",
289 + "required": true,
290 + "schema": {
291 + "type": "string"
292 + }
293 + },
294 + {
295 + "name": "repo",
296 + "in": "path",
297 + "description": "Repository name",
298 + "required": true,
299 + "schema": {
300 + "type": "string"
301 + }
302 + },
303 + {
304 + "name": "target",
305 + "in": "path",
306 + "description": "Full object id of the annotated commit",
307 + "required": true,
308 + "schema": {
309 + "type": "string"
310 + }
311 + },
312 + {
313 + "name": "namespace",
314 + "in": "query",
315 + "description": "Notes namespace, default `commits`",
316 + "required": false,
317 + "schema": {
318 + "type": "string"
319 + }
320 + }
321 + ],
322 + "responses": {
323 + "204": {
324 + "description": "The note is gone, whether or not it was there"
325 + },
326 + "401": {
327 + "description": "No credential; writes need a push-scoped personal access token"
328 + },
329 + "403": {
330 + "description": "A session cookie, a read-only token, or an account that cannot push here"
331 + },
332 + "404": {
333 + "description": "No such repository, or no such commit in it"
334 + }
335 + }
336 + }
337 + },
12 338 "/api/v1/items/{item_id}/license.txt": {
13 339 "get": {
14 340 "tags": [
@@ -1729,6 +2055,117 @@
1729 2055 }
1730 2056 }
1731 2057 },
2058 + "NamespaceEntry": {
2059 + "type": "object",
2060 + "description": "One notes namespace as the repository holds it.",
2061 + "required": [
2062 + "name",
2063 + "git_ref",
2064 + "tip",
2065 + "notes"
2066 + ],
2067 + "properties": {
2068 + "git_ref": {
2069 + "type": "string",
2070 + "description": "The ref it lives on, for a caller assembling a fetch refspec."
2071 + },
2072 + "name": {
2073 + "type": "string",
2074 + "description": "Namespace as a person says it: `commits`, `review/security`."
2075 + },
2076 + "notes": {
2077 + "type": "integer",
2078 + "format": "int64",
2079 + "description": "Notes in the namespace."
2080 + },
2081 + "tip": {
2082 + "type": "string",
2083 + "description": "Object id the ref points at."
2084 + }
2085 + }
2086 + },
2087 + "NamespacesResponse": {
2088 + "type": "object",
2089 + "required": [
2090 + "data"
2091 + ],
2092 + "properties": {
2093 + "data": {
2094 + "type": "array",
2095 + "items": {
2096 + "$ref": "#/components/schemas/NamespaceEntry"
2097 + }
2098 + }
2099 + }
2100 + },
2101 + "NoteAttribution": {
2102 + "type": "object",
2103 + "description": "Who wrote the note and when, from the notes ref's own history.",
2104 + "required": [
2105 + "commit",
2106 + "name",
2107 + "email",
2108 + "at",
2109 + "exact"
2110 + ],
2111 + "properties": {
2112 + "at": {
2113 + "type": "string",
2114 + "format": "date-time"
2115 + },
2116 + "commit": {
2117 + "type": "string",
2118 + "description": "The notes commit that set the note to what it says now."
2119 + },
2120 + "email": {
2121 + "type": "string"
2122 + },
2123 + "exact": {
2124 + "type": "boolean",
2125 + "description": "False when the bounded walk ran out before finding the change, so the\ncommit named is as far back as it looked rather than the one responsible."
2126 + },
2127 + "name": {
2128 + "type": "string"
2129 + }
2130 + }
2131 + },
2132 + "NoteResponse": {
2133 + "type": "object",
2134 + "required": [
2135 + "namespace",
2136 + "target",
2137 + "blob",
2138 + "content"
2139 + ],
2140 + "properties": {
2141 + "attribution": {
2142 + "oneOf": [
2143 + {
2144 + "type": "null"
2145 + },
2146 + {
2147 + "$ref": "#/components/schemas/NoteAttribution",
2148 + "description": "Present only when the request asked for it."
2149 + }
2150 + ]
2151 + },
2152 + "blob": {
2153 + "type": "string",
2154 + "description": "The blob holding the content."
2155 + },
2156 + "content": {
2157 + "type": "string",
2158 + "description": "Note content. Note bodies are bytes, not text; anything that is not UTF-8\nis replaced rather than rejected, since a note git accepted has to be\nreadable here."
2159 + },
2160 + "namespace": {
2161 + "type": "string"
2162 + },
2163 + "target": {
2164 + "type": "string",
2165 + "description": "The annotated object. Need not be a commit: notes on blobs and trees are\nlegal and this returns them."
2166 + }
2167 + }
2168 + },
1732 2169 "PendingKeyInfo": {
1733 2170 "type": "object",
1734 2171 "required": [
@@ -1903,6 +2340,24 @@
1903 2340 }
1904 2341 }
1905 2342 },
2343 + "PutNoteRequest": {
2344 + "type": "object",
2345 + "required": [
2346 + "content"
2347 + ],
2348 + "properties": {
2349 + "content": {
2350 + "type": "string",
2351 + "description": "The note body. Trailing whitespace is trimmed and a newline appended, the\nsame shape git's own notes carry."
2352 + },
2353 + "namespace": {
2354 + "type": [
2355 + "string",
2356 + "null"
2357 + ]
2358 + }
2359 + }
2360 + },
1906 2361 "RegisterDeviceRequest": {
1907 2362 "type": "object",
1908 2363 "required": [
@@ -2020,6 +2475,74 @@
2020 2475 }
2021 2476 }
2022 2477 },
2478 + "SearchHit": {
2479 + "type": "object",
2480 + "description": "One search hit, out of the index.",
2481 + "required": [
2482 + "namespace",
2483 + "target",
2484 + "blob",
2485 + "content",
2486 + "target_is_commit",
2487 + "summary",
2488 + "updated_at",
2489 + "updated_by"
2490 + ],
2491 + "properties": {
2492 + "blob": {
2493 + "type": "string"
2494 + },
2495 + "content": {
2496 + "type": "string"
2497 + },
2498 + "namespace": {
2499 + "type": "string"
2500 + },
2501 + "summary": {
Lines truncated
@@ -27,6 +27,12 @@
27 27 crate::routes::api::license_keys::license_verify,
28 28 crate::routes::api::license_keys::license_deactivate,
29 29 crate::routes::api::license_keys::license_text,
30 + // Git Notes
31 + crate::routes::api::git_notes::list_namespaces,
32 + crate::routes::api::git_notes::get_note,
33 + crate::routes::api::git_notes::put_note,
34 + crate::routes::api::git_notes::delete_note,
35 + crate::routes::api::git_notes::search_notes,
30 36 // SyncKit, Auth
31 37 crate::routes::synckit::auth::sync_auth,
32 38 crate::routes::synckit::auth::validate_app,
@@ -74,6 +80,15 @@
74 80 crate::routes::api::license_keys::LicenseVerifyRequest,
75 81 crate::routes::api::license_keys::LicenseVerifyResponse,
76 82 crate::routes::api::license_keys::LicenseDeactivateRequest,
83 + // Git Notes
84 + crate::routes::api::git_notes::NamespaceEntry,
85 + crate::routes::api::git_notes::NamespacesResponse,
86 + crate::routes::api::git_notes::NoteAttribution,
87 + crate::routes::api::git_notes::NoteResponse,
88 + crate::routes::api::git_notes::PutNoteRequest,
89 + crate::routes::api::git_notes::WriteResponse,
90 + crate::routes::api::git_notes::SearchHit,
91 + crate::routes::api::git_notes::SearchResponse,
77 92 // SyncKit
78 93 crate::routes::synckit::SyncAuthRequest,
79 94 crate::routes::synckit::SyncAuthResponse,
@@ -126,6 +141,7 @@
126 141 tags(
127 142 (name = "License Keys", description = "Public license key validation, activation, and deactivation. Stable API: response shapes are frozen."),
128 143 (name = "SyncKit", description = "E2E encrypted cloud sync for indie apps. JWT auth via /api/v1/sync/auth, then Bearer token on all other endpoints."),
144 + (name = "Git Notes", description = "Read and write refs/notes/* on a repository. Reads answer from the repository and take a session or a personal access token; writes take a push-scoped personal access token as HTTP Basic auth, the same credential git push uses."),
129 145 ),
130 146 security(
131 147 ("bearer" = []),
@@ -25,6 +25,7 @@
25 25 mod domains;
26 26 mod exports;
27 27 mod follows;
28 + pub(crate) mod git_notes;
28 29 pub(crate) mod git_tokens;
29 30 mod guest_checkout;
30 31 mod imports;
@@ -61,7 +62,10 @@
61 62
62 63 use crate::{
63 64 AppState, constants,
64 - csrf::{CsrfRouter, delete_csrf, post_csrf, post_csrf_skip, put_csrf},
65 + csrf::{
66 + CsrfRouter, delete_csrf, delete_csrf_skip, post_csrf, post_csrf_skip, put_csrf,
67 + put_csrf_skip,
68 + },
65 69 db::{self, BlogPostId, ItemId, ProjectId, ProjectType, UserId},
66 70 error::{ApiErrorMessage, AppError, Result},
67 71 };
@@ -69,6 +73,8 @@
69 73 const LICENSE_BEARER_SKIP: &str = "license API: bearer license key, no session";
70 74 const GUEST_CHECKOUT_SKIP: &str = "guest checkout: pre-auth, no session";
71 75 const CSP_REPORT_SKIP: &str = "CSP report: browser-posted, no session";
76 + const GIT_NOTES_SKIP: &str =
77 + "git notes API: push-scoped personal access token, session cookie rejected";
72 78
73 79 /// Fetch a project and verify the user owns it. Shared by all ownership checks
74 80 /// that go through a project (items, blog posts, direct project access).
@@ -539,6 +545,19 @@
539 545 .route("/api/domains/{id}", delete_csrf(domains::remove_domain))
540 546 // Invite codes
541 547 .route("/api/invites/create", post_csrf(users::create_invite))
548 + // Git notes (write). CSRF-skipped because the caller is a script rather
549 + // than a page MNW rendered, so it holds no token to send; the handlers
550 + // require a push-scoped personal access token and reject a session
551 + // cookie, which is the same seal `receive-pack` uses for the same
552 + // reason. The `origin_gate` still wraps these.
553 + .route(
554 + "/api/git/{owner}/{repo}/notes/{target}",
555 + put_csrf_skip(GIT_NOTES_SKIP, git_notes::put_note),
556 + )
557 + .route(
558 + "/api/git/{owner}/{repo}/notes/{target}",
559 + delete_csrf_skip(GIT_NOTES_SKIP, git_notes::delete_note),
560 + )
542 561 // Per-repository issue notifications (mailing-list step 6)
543 562 .route(
544 563 "/api/repos/notifications",
@@ -678,6 +697,20 @@
678 697 .route_get("/api/domains", get(domains::get_domain))
679 698 .route_get("/api/domains/caddy-ask", get(domains::caddy_ask))
680 699 .route_get("/api/restart-status", get(internal::restart_status))
700 + // Git notes (read). `search` is a static segment and an object id is 40
701 + // or 64 hex characters, so the two never shadow each other.
702 + .route_get(
703 + "/api/git/{owner}/{repo}/notes",
704 + get(git_notes::list_namespaces),
705 + )
706 + .route_get(
707 + "/api/git/{owner}/{repo}/notes/search",
708 + get(git_notes::search_notes),
709 + )
710 + .route_get(
711 + "/api/git/{owner}/{repo}/notes/{target}",
712 + get(git_notes::get_note),
713 + )
681 714 // Cart (read)
682 715 .route_get("/api/cart/count", get(cart::cart_count))
683 716 // Import system (read)
@@ -4,7 +4,7 @@
4 4 mod notes_inbox;
5 5 pub mod notes_index;
6 6 pub mod notes_view;
7 - mod notes_write;
7 + pub(crate) mod notes_write;
8 8 mod raw;
9 9
10 10 use std::path::PathBuf;
@@ -365,7 +365,7 @@
365 365 /// could not be updated is a stale projection, which the read paths tolerate;
366 366 /// turning it into an error the writer sees would be the projection vetoing the
367 367 /// thing it is a projection of.
368 - pub(super) async fn reindex_after_write(
368 + pub(crate) async fn reindex_after_write(
369 369 db_pool: &PgPool,
370 370 config: &Config,
371 371 repo_id: GitRepoId,
@@ -125,7 +125,7 @@
125 125 // a 404 before any repository work happens.
126 126 let target = notes::Oid::from_hex(oid_str.as_bytes()).map_err(|_| AppError::NotFound)?;
127 127 let gix_target = gix::ObjectId::from_hex(oid_str.as_bytes()).map_err(|_| AppError::NotFound)?;
128 - let who = identity(user);
128 + let who = identity(user.display_name.as_deref(), user.username.as_str());
129 129 let written_namespace = namespace.clone();
130 130
131 131 let merged = resolved
@@ -187,7 +187,7 @@
187 187 /// anyone who could push one over SSH may write one here. Whether a signed-in
188 188 /// stranger may annotate a public repo is a moderation question with its own
189 189 /// decision, deliberately not answered by this function.
190 - pub(super) async fn can_write_notes(
190 + pub(crate) async fn can_write_notes(
191 191 db: &PgPool,
192 192 resolved: &super::ResolvedRepo,
193 193 user_id: crate::db::UserId,
@@ -198,23 +198,27 @@
198 198 db::repo_collaborators::can_user_push(db, resolved.db_repo.id, user_id).await
199 199 }
200 200
201 - /// The signature a web-written note carries.
201 + /// The signature a note MNW writes carries, whoever asked for it.
202 + ///
203 + /// Takes the two fields rather than a `SessionUser` because the JSON API
204 + /// authenticates by personal access token and has a `DbUser` instead. The
205 + /// identity is a property of the account, not of how it signed in, and the API
206 + /// writing a different email than the browser would put two addresses for one
207 + /// person into a repository nobody can rewrite.
202 208 ///
203 209 /// Name and email are both scrubbed of the characters git's commit format uses
204 210 /// as delimiters. A display name holding an angle bracket or a newline would
205 211 /// otherwise write a commit object that parses as something other than what was
206 212 /// intended, which is a malformed-object bug at best.
207 - fn identity(user: &crate::auth::SessionUser) -> notes::Signature {
208 - let name = user
209 - .display_name
210 - .as_deref()
213 + pub(crate) fn identity(display_name: Option<&str>, username: &str) -> notes::Signature {
214 + let name = display_name
211 215 .map(str::trim)
212 216 .filter(|n| !n.is_empty())
213 - .unwrap_or(user.username.as_str());
217 + .unwrap_or(username);
214 218
215 219 notes::Signature {
216 220 name: scrub(name),
217 - email: format!("{}@{NOTE_EMAIL_DOMAIN}", scrub(user.username.as_str())),
221 + email: format!("{}@{NOTE_EMAIL_DOMAIN}", scrub(username)),
218 222 time: chrono::Utc::now(),
219 223 }
220 224 }
@@ -1,0 +1,685 @@
1 + //! JSON API for `refs/notes/*`.
2 + //!
3 + //! <!-- wiki: mnw-server-git-notes -->
4 + //!
5 + //! The point of this surface is tooling: a build script writing its result onto
6 + //! the commit it built, a review bot recording a verdict. It adds no semantics.
7 + //! Reads come from the repository through `crate::git::notes`, writes go through
8 + //! the same `write_note` the browser form uses, and the authorization, the
9 + //! namespace rules and the committer identity are `notes_write`'s, called rather
10 + //! than restated.
11 + //!
12 + //! **Reads answer from the repository, not from the index.** The one exception
13 + //! is search, which is a full-text query the tree cannot serve; it reports
14 + //! whether the index has ever seen this repository so a caller can tell "no
15 + //! matches" from "nothing indexed yet" (`indexed` in the response).
16 + //!
17 + //! **Writes authenticate by push-scoped personal access token only.** A session
18 + //! cookie is refused, exactly as `receive-pack` refuses one (`raw.rs`
19 + //! `authorize_push`). These routes cannot carry a CSRF token, since the caller
20 + //! is a script rather than a page MNW rendered, so they are registered
21 + //! CSRF-skipped; a cookie would then be an ambient credential behind a route
22 + //! with no CSRF seal. `Authorization: Basic` cannot be set by a cross-site form,
23 + //! so requiring it is the seal. Reads accept either, which is what the browse
24 + //! path already does and costs nothing: a GET changes nothing and no CORS policy
25 + //! lets another origin read the response.
26 +
27 + use axum::{
28 + Json,
29 + extract::{Path, Query, State},
30 + http::{HeaderMap, StatusCode},
31 + response::IntoResponse,
32 + };
33 + use chrono::{DateTime, Utc};
34 + use serde::{Deserialize, Serialize};
35 + use sqlx::PgPool;
36 + use utoipa::ToSchema;
37 +
38 + use crate::{
39 + auth::MaybeUserVerified,
40 + config::Config,
41 + db::{self, GitRepoId},
42 + error::{AppError, Result},
43 + git::notes::{self, GixEngine, Oid},
44 + routes::git::{GitHttpPrincipal, ResolvedRepo, notes_index, notes_write, resolve_repo},
45 + validation,
46 + };
47 +
48 + /// How far back the attribution walk may look, matching the commit page's
49 + /// bound. It is off by default here: it costs a second history walk per note,
50 + /// and a script reading notes in bulk has no use for who typed them.
51 + const ATTRIBUTION_MAX_COMMITS: usize = 50;
52 +
53 + /// Default and ceiling for `limit` on search. The ceiling exists because the
54 + /// index holds whole note bodies, so a large page is a large response.
55 + const SEARCH_LIMIT_DEFAULT: i64 = 50;
56 + const SEARCH_LIMIT_MAX: i64 = 200;
57 +
58 + // ── Response shapes ──
59 +
60 + /// One notes namespace as the repository holds it.
61 + #[derive(Serialize, ToSchema)]
62 + pub(crate) struct NamespaceEntry {
63 + /// Namespace as a person says it: `commits`, `review/security`.
64 + pub name: String,
65 + /// The ref it lives on, for a caller assembling a fetch refspec.
66 + pub git_ref: String,
67 + /// Object id the ref points at.
68 + pub tip: String,
69 + /// Notes in the namespace.
70 + pub notes: i64,
71 + }
72 +
73 + #[derive(Serialize, ToSchema)]
74 + pub(crate) struct NamespacesResponse {
75 + pub data: Vec<NamespaceEntry>,
76 + }
77 +
78 + /// Who wrote the note and when, from the notes ref's own history.
79 + #[derive(Serialize, ToSchema)]
80 + pub(crate) struct NoteAttribution {
81 + /// The notes commit that set the note to what it says now.
82 + pub commit: String,
83 + pub name: String,
84 + pub email: String,
85 + pub at: DateTime<Utc>,
86 + /// False when the bounded walk ran out before finding the change, so the
87 + /// commit named is as far back as it looked rather than the one responsible.
88 + pub exact: bool,
89 + }
90 +
91 + #[derive(Serialize, ToSchema)]
92 + pub(crate) struct NoteResponse {
93 + pub namespace: String,
94 + /// The annotated object. Need not be a commit: notes on blobs and trees are
95 + /// legal and this returns them.
96 + pub target: String,
97 + /// The blob holding the content.
98 + pub blob: String,
99 + /// Note content. Note bodies are bytes, not text; anything that is not UTF-8
100 + /// is replaced rather than rejected, since a note git accepted has to be
101 + /// readable here.
102 + pub content: String,
103 + /// Present only when the request asked for it.
104 + pub attribution: Option<NoteAttribution>,
105 + }
106 +
107 + /// What a write did.
108 + #[derive(Serialize, ToSchema)]
109 + pub(crate) struct WriteResponse {
110 + pub namespace: String,
111 + pub target: String,
112 + /// `written` when the ref moved, `unchanged` when the namespace already said
113 + /// exactly this. Re-putting an identical note is not an error and costs no
114 + /// commit.
115 + pub status: &'static str,
116 + /// Somebody else annotated the same target while this write was in flight
117 + /// and the two were merged, so the stored note is not byte-for-byte what was
118 + /// sent. Re-read it if that matters.
119 + pub merged: bool,
120 + /// Where the namespace points now, absent when nothing was written.
121 + pub tip: Option<String>,
122 + }
123 +
124 + /// One search hit, out of the index.
125 + #[derive(Serialize, ToSchema)]
126 + pub(crate) struct SearchHit {
127 + pub namespace: String,
128 + pub target: String,
129 + pub blob: String,
130 + pub content: String,
131 + /// Whether the annotated object is a commit. False for a note on a blob or
132 + /// a tree, where `summary` and `time` are empty.
133 + pub target_is_commit: bool,
134 + pub summary: String,
135 + pub time: Option<DateTime<Utc>>,
136 + pub updated_at: DateTime<Utc>,
137 + pub updated_by: String,
138 + }
139 +
140 + #[derive(Serialize, ToSchema)]
141 + pub(crate) struct SearchResponse {
142 + pub data: Vec<SearchHit>,
143 + /// False when the index has never seen this repository, which makes an
144 + /// empty `data` mean "not searchable yet" rather than "no matches". The
145 + /// repository still holds its notes and every other endpoint here returns
146 + /// them; only search needs the index.
147 + pub indexed: bool,
148 + }
149 +
150 + // ── Request shapes ──
151 +
152 + #[derive(Deserialize, ToSchema)]
153 + pub(crate) struct NamespaceQuery {
154 + /// Defaults to `commits`, git's own default namespace.
155 + pub namespace: Option<String>,
156 + }
157 +
158 + #[derive(Deserialize, ToSchema)]
159 + pub(crate) struct GetNoteQuery {
160 + pub namespace: Option<String>,
161 + /// Ask for the attribution walk. Off by default because it costs a walk of
162 + /// the notes ref per note.
163 + #[serde(default)]
164 + pub attribution: bool,
165 + }
166 +
167 + #[derive(Deserialize, ToSchema)]
168 + pub(crate) struct PutNoteRequest {
169 + pub namespace: Option<String>,
170 + /// The note body. Trailing whitespace is trimmed and a newline appended, the
171 + /// same shape git's own notes carry.
172 + pub content: String,
173 + }
174 +
175 + #[derive(Deserialize, ToSchema)]
176 + pub(crate) struct SearchQuery {
177 + /// The query, in `websearch_to_tsquery` syntax: bare words, `"quoted
178 + /// phrases"`, `or`, and `-excluded`.
179 + pub q: String,
180 + pub namespace: Option<String>,
181 + /// Restrict to notes on commits, dropping notes on blobs and trees.
182 + #[serde(default)]
183 + pub commits_only: bool,
184 + pub limit: Option<i64>,
185 + }
186 +
187 + // ── Handlers ──
188 +
189 + /// `GET /api/git/{owner}/{repo}/notes`: the namespaces this repository carries.
190 + #[utoipa::path(
191 + get,
192 + path = "/api/git/{owner}/{repo}/notes",
193 + tag = "Git Notes",
194 + params(
195 + ("owner" = String, Path, description = "Repository owner's username"),
196 + ("repo" = String, Path, description = "Repository name"),
197 + ),
198 + responses(
199 + (status = 200, description = "Namespaces, with a note count each", body = NamespacesResponse),
200 + (status = 404, description = "No such repository, or not visible to the caller"),
201 + ),
202 + )]
203 + #[tracing::instrument(skip_all, name = "api::git_notes::list_namespaces")]
204 + pub(crate) async fn list_namespaces(
205 + State(db): State<PgPool>,
206 + State(config): State<Config>,
207 + MaybeUserVerified(maybe_user): MaybeUserVerified,
208 + Path((owner, repo_name)): Path<(String, String)>,
209 + headers: HeaderMap,
210 + ) -> Result<impl IntoResponse> {
211 + let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?;
212 +
213 + let data = resolved
214 + .with_repo(|gix_repo| {
215 + let engine = GixEngine::new(gix_repo);
216 + let namespaces = notes::list_namespaces(&engine).map_err(crate::git::GitError::from)?;
217 + let mut out = Vec::with_capacity(namespaces.len());
218 + for ns in namespaces {
219 + // Counted from the tree rather than from the index: the index
220 + // may be cold or behind, and a count that disagrees with what
221 + // the next request returns is worse than a slightly dearer one.
222 + let count =
223 + notes::count_notes(&engine, ns.tip).map_err(crate::git::GitError::from)?;
224 + out.push(NamespaceEntry {
225 + name: ns.name,
226 + git_ref: ns.full_ref,
227 + tip: ns.tip.to_hex(),
228 + notes: count as i64,
229 + });
230 + }
231 + Ok(out)
232 + })
233 + .await?;
234 +
235 + Ok(Json(NamespacesResponse { data }))
236 + }
237 +
238 + /// `GET /api/git/{owner}/{repo}/notes/{target}`: one note.
239 + #[utoipa::path(
240 + get,
241 + path = "/api/git/{owner}/{repo}/notes/{target}",
242 + tag = "Git Notes",
243 + params(
244 + ("owner" = String, Path, description = "Repository owner's username"),
245 + ("repo" = String, Path, description = "Repository name"),
246 + ("target" = String, Path, description = "Full object id of the annotated object"),
247 + ("namespace" = Option<String>, Query, description = "Notes namespace, default `commits`"),
248 + ("attribution" = Option<bool>, Query, description = "Include who wrote the note; costs a bounded walk of the notes ref"),
249 + ),
250 + responses(
251 + (status = 200, description = "The note", body = NoteResponse),
252 + (status = 404, description = "No such repository, namespace, or note"),
253 + ),
254 + )]
255 + #[tracing::instrument(skip_all, name = "api::git_notes::get_note")]
256 + pub(crate) async fn get_note(
257 + State(db): State<PgPool>,
258 + State(config): State<Config>,
259 + MaybeUserVerified(maybe_user): MaybeUserVerified,
260 + Path((owner, repo_name, target_hex)): Path<(String, String, String)>,
261 + Query(query): Query<GetNoteQuery>,
262 + headers: HeaderMap,
263 + ) -> Result<impl IntoResponse> {
264 + let namespace = namespace_or_default(query.namespace.as_deref());
265 + let target = parse_target(&target_hex)?;
266 + let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?;
267 +
268 + let want_attribution = query.attribution;
269 + let ns_for_repo = namespace.clone();
270 + let note = resolved
271 + .with_repo(move |gix_repo| {
272 + let engine = GixEngine::new(gix_repo);
273 + // A namespace that does not exist and a namespace with no note on
274 + // this target are the same 404: both mean the repository holds
275 + // nothing here, and distinguishing them would tell an anonymous
276 + // caller which namespaces a repository carries by another route.
277 + let Some(ns) = notes::resolve_namespace(&engine, &ns_for_repo)
278 + .map_err(crate::git::GitError::from)?
279 + else {
280 + return Err(AppError::NotFound);
281 + };
282 + let Some(note) =
283 + notes::note_for(&engine, ns.tip, target).map_err(crate::git::GitError::from)?
284 + else {
285 + return Err(AppError::NotFound);
286 + };
287 +
288 + let attribution = if want_attribution {
289 + notes::attribution(&engine, ns.tip, target, ATTRIBUTION_MAX_COMMITS)
290 + .map_err(crate::git::GitError::from)?
291 + .map(|a| NoteAttribution {
292 + commit: a.note_commit.to_hex(),
293 + name: a.by.name,
294 + email: a.by.email,
295 + at: a.by.time,
296 + exact: a.exact,
297 + })
298 + } else {
299 + None
300 + };
301 +
302 + Ok(NoteResponse {
303 + namespace: ns.name,
304 + target: note.target.to_hex(),
305 + blob: note.blob.to_hex(),
306 + content: note.content_lossy().into_owned(),
307 + attribution,
308 + })
309 + })
310 + .await?;
311 +
312 + Ok(Json(note))
313 + }
314 +
315 + /// `PUT /api/git/{owner}/{repo}/notes/{target}`: add or replace a note.
316 + #[utoipa::path(
317 + put,
318 + path = "/api/git/{owner}/{repo}/notes/{target}",
319 + tag = "Git Notes",
320 + params(
321 + ("owner" = String, Path, description = "Repository owner's username"),
322 + ("repo" = String, Path, description = "Repository name"),
323 + ("target" = String, Path, description = "Full object id of the commit to annotate"),
324 + ),
325 + request_body = PutNoteRequest,
326 + responses(
327 + (status = 200, description = "What the write did", body = WriteResponse),
328 + (status = 401, description = "No credential; writes need a push-scoped personal access token"),
329 + (status = 403, description = "A session cookie, a read-only token, or an account that cannot push here"),
330 + (status = 404, description = "No such repository, or no such commit in it"),
331 + (status = 422, description = "Reserved namespace, empty or oversized content, or sustained write contention"),
332 + ),
333 + )]
334 + #[tracing::instrument(skip_all, name = "api::git_notes::put_note")]
335 + pub(crate) async fn put_note(
336 + State(db): State<PgPool>,
337 + State(config): State<Config>,
338 + Path((owner, repo_name, target_hex)): Path<(String, String, String)>,
339 + headers: HeaderMap,
340 + Json(request): Json<PutNoteRequest>,
341 + ) -> Result<impl IntoResponse> {
342 + let namespace = namespace_or_default(request.namespace.as_deref());
343 + validation::validate_note_namespace(&namespace)?;
344 + validation::validate_note_content(&request.content)?;
345 +
346 + let mut content = request.content.trim_end().to_string();
347 + content.push('\n');
348 +
349 + write(
350 + &db,
351 + &config,
352 + &owner,
353 + &repo_name,
354 + &target_hex,
355 + &headers,
356 + namespace,
357 + Some(content),
358 + )
359 + .await
360 + .map(Json)
361 + }
362 +
363 + /// `DELETE /api/git/{owner}/{repo}/notes/{target}`: remove a note.
364 + #[utoipa::path(
365 + delete,
366 + path = "/api/git/{owner}/{repo}/notes/{target}",
367 + tag = "Git Notes",
368 + params(
369 + ("owner" = String, Path, description = "Repository owner's username"),
370 + ("repo" = String, Path, description = "Repository name"),
371 + ("target" = String, Path, description = "Full object id of the annotated commit"),
372 + ("namespace" = Option<String>, Query, description = "Notes namespace, default `commits`"),
373 + ),
374 + responses(
375 + (status = 204, description = "The note is gone, whether or not it was there"),
376 + (status = 401, description = "No credential; writes need a push-scoped personal access token"),
377 + (status = 403, description = "A session cookie, a read-only token, or an account that cannot push here"),
378 + (status = 404, description = "No such repository, or no such commit in it"),
379 + ),
380 + )]
381 + #[tracing::instrument(skip_all, name = "api::git_notes::delete_note")]
382 + pub(crate) async fn delete_note(
383 + State(db): State<PgPool>,
384 + State(config): State<Config>,
385 + Path((owner, repo_name, target_hex)): Path<(String, String, String)>,
386 + Query(query): Query<NamespaceQuery>,
387 + headers: HeaderMap,
388 + ) -> Result<impl IntoResponse> {
389 + let namespace = namespace_or_default(query.namespace.as_deref());
390 + validation::validate_note_namespace(&namespace)?;
391 +
392 + write(
393 + &db,
394 + &config,
395 + &owner,
396 + &repo_name,
397 + &target_hex,
398 + &headers,
399 + namespace,
400 + None,
401 + )
402 + .await?;
403 +
404 + // Deleting a note that was not there is a no-op rather than a 404: the
405 + // caller asked for a state, and the state holds.
406 + Ok(StatusCode::NO_CONTENT)
407 + }
408 +
409 + /// `GET /api/git/{owner}/{repo}/notes/search`: full-text search over the index.
410 + ///
411 + /// `search` is a static segment and an object id is 40 or 64 hex characters, so
412 + /// it can never be shadowed by, or shadow, a real target on `/notes/{target}`.
413 + #[utoipa::path(
414 + get,
415 + path = "/api/git/{owner}/{repo}/notes/search",
416 + tag = "Git Notes",
417 + params(
418 + ("owner" = String, Path, description = "Repository owner's username"),
419 + ("repo" = String, Path, description = "Repository name"),
420 + ("q" = String, Query, description = "Query: bare words, quoted phrases, `or`, `-excluded`"),
421 + ("namespace" = Option<String>, Query, description = "Restrict to one namespace"),
422 + ("commits_only" = Option<bool>, Query, description = "Drop notes on blobs and trees"),
423 + ("limit" = Option<i64>, Query, description = "Maximum hits, default 50, capped at 200"),
424 + ),
425 + responses(
426 + (status = 200, description = "Matching notes, and whether the index has seen this repository", body = SearchResponse),
427 + (status = 404, description = "No such repository, or not visible to the caller"),
428 + ),
429 + )]
430 + #[tracing::instrument(skip_all, name = "api::git_notes::search_notes")]
431 + pub(crate) async fn search_notes(
432 + State(db): State<PgPool>,
433 + State(config): State<Config>,
434 + MaybeUserVerified(maybe_user): MaybeUserVerified,
435 + Path((owner, repo_name)): Path<(String, String)>,
436 + Query(query): Query<SearchQuery>,
437 + headers: HeaderMap,
438 + ) -> Result<impl IntoResponse> {
439 + let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?;
440 + let repo_id: GitRepoId = resolved.db_repo.id;
441 +
442 + let indexed = db::git_notes::is_indexed(&db, repo_id).await?;
443 + let limit = query
444 + .limit
445 + .unwrap_or(SEARCH_LIMIT_DEFAULT)
446 + .clamp(1, SEARCH_LIMIT_MAX);
447 + let term = query.q.trim();
448 +
449 + // An empty query matches everything in `websearch_to_tsquery`, which is not
450 + // what an empty search box means. Answer it as no hits rather than as the
451 + // whole index.
452 + let rows = if term.is_empty() {
453 + Vec::new()
454 + } else {
455 + db::git_notes::search(
456 + &db,
457 + repo_id,
458 + term,
459 + query.namespace.as_deref(),
460 + query.commits_only,
461 + limit,
462 + )
463 + .await?
464 + };
465 +
466 + let data = rows
467 + .into_iter()
468 + .map(|n| SearchHit {
469 + namespace: n.namespace,
470 + target: n.target_oid,
471 + blob: n.blob_oid,
472 + content: n.content,
473 + target_is_commit: n.target_is_commit,
474 + summary: n.target_summary,
475 + time: n.target_time,
476 + updated_at: n.updated_at,
477 + updated_by: n.updated_by,
478 + })
479 + .collect();
480 +
481 + Ok(Json(SearchResponse { data, indexed }))
482 + }
483 +
484 + // ── Shared halves ──
485 +
486 + /// Resolve a repository for a read, honouring either credential.
487 + ///
488 + /// Visibility is decided by `resolve_repo` on every request rather than
489 + /// remembered anywhere: a note inherits the visibility of the repository holding
490 + /// it, and that can change between two calls.
491 + async fn read_repo(
492 + db: &PgPool,
493 + config: &Config,
494 + owner: &str,
495 + repo_name: &str,
496 + headers: &HeaderMap,
497 + maybe_user: Option<crate::auth::SessionUser>,
498 + ) -> Result<ResolvedRepo> {
499 + let principal =
500 + crate::routes::git::resolve_git_http_principal(db, headers, maybe_user.map(|u| u.id)).await;
Lines truncated