Skip to main content

max / makenotwork

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