Skip to main content

max / makenotwork

15.1 KB · 474 lines History Blame Raw
1 //! Commit-message issue reference parser and process-push endpoint.
2
3 use axum::{Json, extract::State, http::StatusCode};
4 use gix::bstr::ByteSlice;
5 use regex::Regex;
6 use serde::Deserialize;
7 use std::collections::HashMap;
8 use std::sync::LazyLock;
9
10 use sqlx::PgPool;
11
12 use crate::{
13 config::Config,
14 db::{self, IssueStatus},
15 error::{AppError, Result, ResultExt},
16 };
17
18 use super::repos_root;
19
20 // --- Commit message parsing ---
21
22 #[derive(Debug, PartialEq, Eq)]
23 enum IssueRefAction {
24 Close,
25 Reopen,
26 Reference,
27 }
28
29 #[derive(Debug, PartialEq, Eq)]
30 struct IssueRef {
31 number: i32,
32 action: IssueRefAction,
33 }
34
35 /// Parse issue references from a commit message.
36 ///
37 /// Recognizes:
38 /// - Close: `fix(es|ed|s|d)? #N`, `close(s|d)? #N`, `resolve(s|d)? #N`
39 /// - Reference: `ref(s)? #N`, `reference(s)? #N`
40 ///
41 /// Deduplicates by issue number (close wins over reference).
42 fn parse_issue_refs(message: &str) -> Vec<IssueRef> {
43 static CLOSE_RE: LazyLock<Regex> = LazyLock::new(|| {
44 Regex::new(r"(?i)(?:fix|close|resolve)(?:es|ed|s|d)?\s+#(\d+)")
45 .expect("static issue-close regex compiles")
46 });
47 static REOPEN_RE: LazyLock<Regex> = LazyLock::new(|| {
48 Regex::new(r"(?i)reopen(?:s|ed)?\s+#(\d+)").expect("static issue-reopen regex compiles")
49 });
50 static REF_RE: LazyLock<Regex> = LazyLock::new(|| {
51 Regex::new(r"(?i)(?:ref|reference)s?\s+#(\d+)").expect("static issue-ref regex compiles")
52 });
53
54 let mut by_number: HashMap<i32, IssueRefAction> = HashMap::new();
55
56 for cap in CLOSE_RE.captures_iter(message) {
57 if let Ok(n) = cap[1].parse::<i32>() {
58 // Close always wins over reference
59 by_number.insert(n, IssueRefAction::Close);
60 }
61 }
62
63 for cap in REOPEN_RE.captures_iter(message) {
64 if let Ok(n) = cap[1].parse::<i32>() {
65 // Reopen wins over reference but not close
66 by_number.entry(n).or_insert(IssueRefAction::Reopen);
67 }
68 }
69
70 for cap in REF_RE.captures_iter(message) {
71 if let Ok(n) = cap[1].parse::<i32>() {
72 by_number.entry(n).or_insert(IssueRefAction::Reference);
73 }
74 }
75
76 let mut refs: Vec<IssueRef> = by_number
77 .into_iter()
78 .map(|(number, action)| IssueRef { number, action })
79 .collect();
80 refs.sort_by_key(|r| r.number);
81 refs
82 }
83
84 // --- Process push endpoint ---
85
86 #[derive(Deserialize)]
87 pub(super) struct ProcessPushRequest {
88 repo_owner: String,
89 repo_name: String,
90 ref_name: String,
91 before: String,
92 after: String,
93 }
94
95 /// `POST /api/internal/issues/process-push`
96 ///
97 /// Called by the post-receive hook to process commit messages for issue references.
98 /// Auth: Bearer token (same BUILD_TRIGGER_TOKEN as the build pipeline).
99 #[tracing::instrument(skip_all, name = "git_issues::process_push")]
100 pub(super) async fn process_push(
101 State(db): State<PgPool>,
102 State(config): State<Config>,
103 headers: axum::http::HeaderMap,
104 Json(req): Json<ProcessPushRequest>,
105 ) -> Result<impl axum::response::IntoResponse> {
106 // Validate per-repo HMAC (derived from BUILD_TRIGGER_TOKEN, never stored raw)
107 let trigger_token = config
108 .build
109 .trigger_token
110 .as_deref()
111 .ok_or(AppError::Internal(anyhow::anyhow!(
112 "BUILD_TRIGGER_TOKEN not configured"
113 )))?;
114
115 let auth_header = headers
116 .get("authorization")
117 .and_then(|v| v.to_str().ok())
118 .unwrap_or("");
119 let provided_hmac = auth_header.strip_prefix("Bearer ").unwrap_or("");
120
121 let expected_hmac =
122 crate::build_runner::repo_hmac(trigger_token, &req.repo_owner, &req.repo_name);
123 if provided_hmac.is_empty()
124 || !crate::helpers::constant_time_compare(provided_hmac, &expected_hmac)
125 {
126 return Err(AppError::Forbidden);
127 }
128
129 // Look up repo owner + repo
130 let owner_user =
131 db::users::get_user_by_username(&db, &db::Username::from_trusted(req.repo_owner.clone()))
132 .await?
133 .ok_or(AppError::NotFound)?;
134
135 let repo = db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
136 .await?
137 .ok_or(AppError::NotFound)?;
138
139 // Open the bare repo and collect commit refs on a blocking thread. The walk
140 // is synchronous disk I/O, so it runs in spawn_blocking (every other git
141 // path does the same via crate::git); doing it inline parks a tokio worker
142 // thread for the duration (Run #2 Perf SERIOUS). The walk returns only the
143 // owned, Send `Vec` of collected refs.
144 let root = repos_root(&config)?;
145 let repo_owner = req.repo_owner.clone();
146 let repo_name = req.repo_name.clone();
147 let after = req.after.clone();
148 let before = req.before.clone();
149 let commit_refs =
150 tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Vec<IssueRef>)>> {
151 let git_repo =
152 crate::git::open_repo(&root, &repo_owner, &repo_name).context("open git repo")?;
153
154 let after_oid = gix::ObjectId::from_hex(after.as_bytes())
155 .map_err(|e| AppError::BadRequest(format!("invalid 'after' oid: {e}")))?;
156
157 let is_new_branch = before.chars().all(|c| c == '0');
158 let hidden = if is_new_branch {
159 None
160 } else {
161 gix::ObjectId::from_hex(before.as_bytes()).ok()
162 };
163
164 let walk = git_repo
165 .rev_walk([after_oid])
166 .with_hidden(hidden)
167 .all()
168 .context("revwalk init")?;
169
170 let max_commits = if is_new_branch { 1 } else { 50 };
171 let mut collected: Vec<(String, String, Vec<IssueRef>)> = Vec::new();
172
173 for info in walk.take(max_commits) {
174 let Ok(info) = info else {
175 continue;
176 };
177 let Ok(commit) = git_repo.find_commit(info.id) else {
178 continue;
179 };
180 let Ok(message) = commit.message_raw() else {
181 continue;
182 };
183 let refs = parse_issue_refs(&message.to_str_lossy());
184 if !refs.is_empty() {
185 let oid_str = info.id.to_string();
186 let short_oid = oid_str[..7.min(oid_str.len())].to_string();
187 collected.push((oid_str, short_oid, refs));
188 }
189 }
190 Ok(collected)
191 })
192 .await
193 .context("git push-refs walk task")??;
194
195 // Batch-fetch every referenced issue once (deduped across commits) so a push
196 // touching many commits doesn't run one get_issue_by_number per reference.
197 // The local map is mutated as statuses change, so repeated references to the
198 // same issue see its current state without re-querying.
199 let mut numbers: Vec<i32> = commit_refs
200 .iter()
201 .flat_map(|(_, _, refs)| refs.iter().map(|r| r.number))
202 .collect();
203 numbers.sort_unstable();
204 numbers.dedup();
205 let mut issues = db::issues::get_issues_by_numbers(&db, repo.id, &numbers).await?;
206
207 // Collect the DB writes and flush them in two bulk statements at the end
208 // rather than one status UPDATE + one comment INSERT per referenced commit
209 // (ultra-fuzz Run 6 S6). The local `issue.status` is still mutated as we go,
210 // so repeated references to the same issue within one push observe the
211 // running state; `status_changes` holds the final status to persist.
212 let mut processed = 0u32;
213 let mut status_changes: HashMap<db::IssueId, IssueStatus> = HashMap::new();
214 let mut comments: Vec<(db::IssueId, String, String)> = Vec::new();
215 // What each commit claimed, for the `mnw/issues` note. Collected from the
216 // same pass rather than re-derived, so the note and the issue comments can
217 // never disagree about what a message said. A reference to an issue that
218 // does not exist is skipped below and so is absent from both.
219 let mut per_commit: Vec<(String, Vec<i32>, Vec<i32>, Vec<i32>)> = Vec::new();
220
221 for (oid_str, short_oid, refs) in &commit_refs {
222 let (mut closed, mut reopened, mut referenced) = (Vec::new(), Vec::new(), Vec::new());
223 for issue_ref in refs {
224 let Some(issue) = issues.get_mut(&issue_ref.number) else {
225 continue;
226 };
227 match issue_ref.action {
228 IssueRefAction::Close => closed.push(issue_ref.number),
229 IssueRefAction::Reopen => reopened.push(issue_ref.number),
230 IssueRefAction::Reference => referenced.push(issue_ref.number),
231 }
232
233 let lead = match issue_ref.action {
234 IssueRefAction::Close => {
235 if issue.status == IssueStatus::Open {
236 issue.status = IssueStatus::Closed;
237 status_changes.insert(issue.id, IssueStatus::Closed);
238 }
239 "Closed via"
240 }
241 IssueRefAction::Reopen => {
242 if issue.status == IssueStatus::Closed {
243 issue.status = IssueStatus::Open;
244 status_changes.insert(issue.id, IssueStatus::Open);
245 }
246 "Reopened via"
247 }
248 IssueRefAction::Reference => "Referenced in",
249 };
250
251 let body_md = format!(
252 "{lead} commit [`{}`](/git/{}/{}/commit/{}) on `{}`.",
253 short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name,
254 );
255 let body_html = docengine::render_permissive(&body_md);
256 comments.push((issue.id, body_md, body_html));
257 processed += 1;
258 }
259 if !(closed.is_empty() && reopened.is_empty() && referenced.is_empty()) {
260 per_commit.push((oid_str.clone(), closed, reopened, referenced));
261 }
262 }
263
264 // Flush: final status per changed issue, then every comment, in two queries.
265 let status_updates: Vec<(db::IssueId, IssueStatus)> = status_changes.into_iter().collect();
266 if let Err(e) = db::issues::update_issue_statuses(&db, &status_updates).await {
267 tracing::warn!(error = ?e, "failed to bulk-update issue statuses via push");
268 }
269 if let Err(e) = db::issues::create_comments(&db, owner_user.id, &comments).await {
270 tracing::warn!(error = ?e, "failed to bulk-create issue comments via push");
271 }
272
273 // Mirror the commit-to-issue mapping into `refs/notes/mnw/issues`, so it
274 // leaves with the repository rather than staying in a table only we read.
275 // One note per commit, rewritten whole, and none of it can fail this
276 // handler: the issues are already updated by here.
277 let owned = crate::routes::git::notes_server::OwnedRepo {
278 db: &db,
279 config: &config,
280 id: repo.id,
281 owner: &req.repo_owner,
282 name: &req.repo_name,
283 };
284 for (oid_str, closed, reopened, referenced) in &per_commit {
285 let Ok(target) = crate::git::notes::Oid::from_hex(oid_str.as_bytes()) else {
286 continue;
287 };
288 crate::routes::git::notes_server::note_issue_refs(
289 &owned, target, closed, reopened, referenced,
290 )
291 .await;
292 }
293
294 Ok((
295 StatusCode::OK,
296 Json(serde_json::json!({ "processed": processed })),
297 ))
298 }
299
300 // --- Unit Tests ---
301
302 #[cfg(test)]
303 mod tests {
304 use super::*;
305
306 #[test]
307 fn parse_issue_refs_fixes() {
308 assert_eq!(
309 parse_issue_refs("Fixes #123"),
310 vec![IssueRef {
311 number: 123,
312 action: IssueRefAction::Close
313 }]
314 );
315 }
316
317 #[test]
318 fn parse_issue_refs_closes() {
319 assert_eq!(
320 parse_issue_refs("Closes #456"),
321 vec![IssueRef {
322 number: 456,
323 action: IssueRefAction::Close
324 }]
325 );
326 }
327
328 #[test]
329 fn parse_issue_refs_resolves() {
330 assert_eq!(
331 parse_issue_refs("Resolves #7"),
332 vec![IssueRef {
333 number: 7,
334 action: IssueRefAction::Close
335 }]
336 );
337 }
338
339 #[test]
340 fn parse_issue_refs_refs() {
341 assert_eq!(
342 parse_issue_refs("Refs #10"),
343 vec![IssueRef {
344 number: 10,
345 action: IssueRefAction::Reference
346 }]
347 );
348 }
349
350 #[test]
351 fn parse_issue_refs_references() {
352 assert_eq!(
353 parse_issue_refs("References #42"),
354 vec![IssueRef {
355 number: 42,
356 action: IssueRefAction::Reference
357 }]
358 );
359 }
360
361 #[test]
362 fn parse_issue_refs_case_insensitive() {
363 assert_eq!(
364 parse_issue_refs("fixes #1"),
365 vec![IssueRef {
366 number: 1,
367 action: IssueRefAction::Close
368 }]
369 );
370 }
371
372 #[test]
373 fn parse_issue_refs_multiple() {
374 let refs = parse_issue_refs("Fixes #1, refs #2");
375 assert_eq!(refs.len(), 2);
376 assert!(refs.contains(&IssueRef {
377 number: 1,
378 action: IssueRefAction::Close
379 }));
380 assert!(refs.contains(&IssueRef {
381 number: 2,
382 action: IssueRefAction::Reference
383 }));
384 }
385
386 #[test]
387 fn parse_issue_refs_dedup_close_wins() {
388 let refs = parse_issue_refs("Refs #1\nFixes #1");
389 assert_eq!(
390 refs,
391 vec![IssueRef {
392 number: 1,
393 action: IssueRefAction::Close
394 }]
395 );
396 }
397
398 #[test]
399 fn parse_issue_refs_no_match() {
400 assert!(parse_issue_refs("No issues here").is_empty());
401 }
402
403 #[test]
404 fn parse_issue_refs_in_sentence() {
405 let refs = parse_issue_refs("This fixes #5 and refs #6");
406 assert_eq!(refs.len(), 2);
407 assert!(refs.contains(&IssueRef {
408 number: 5,
409 action: IssueRefAction::Close
410 }));
411 assert!(refs.contains(&IssueRef {
412 number: 6,
413 action: IssueRefAction::Reference
414 }));
415 }
416
417 #[test]
418 fn parse_issue_refs_reopens() {
419 assert_eq!(
420 parse_issue_refs("Reopens #5"),
421 vec![IssueRef {
422 number: 5,
423 action: IssueRefAction::Reopen
424 }]
425 );
426 }
427
428 #[test]
429 fn parse_issue_refs_reopened() {
430 assert_eq!(
431 parse_issue_refs("Reopened #3"),
432 vec![IssueRef {
433 number: 3,
434 action: IssueRefAction::Reopen
435 }]
436 );
437 }
438
439 #[test]
440 fn parse_issue_refs_reopen_bare() {
441 assert_eq!(
442 parse_issue_refs("reopen #7"),
443 vec![IssueRef {
444 number: 7,
445 action: IssueRefAction::Reopen
446 }]
447 );
448 }
449
450 #[test]
451 fn parse_issue_refs_close_wins_over_reopen() {
452 let refs = parse_issue_refs("Reopens #1\nFixes #1");
453 assert_eq!(
454 refs,
455 vec![IssueRef {
456 number: 1,
457 action: IssueRefAction::Close
458 }]
459 );
460 }
461
462 #[test]
463 fn parse_issue_refs_reopen_wins_over_ref() {
464 let refs = parse_issue_refs("Refs #2\nReopens #2");
465 assert_eq!(
466 refs,
467 vec![IssueRef {
468 number: 2,
469 action: IssueRefAction::Reopen
470 }]
471 );
472 }
473 }
474