//! Commit-message issue reference parser and process-push endpoint. use axum::{Json, extract::State, http::StatusCode}; use gix::bstr::ByteSlice; use regex::Regex; use serde::Deserialize; use std::collections::HashMap; use std::sync::LazyLock; use sqlx::PgPool; use crate::{ config::Config, db::{self, IssueStatus}, error::{AppError, Result, ResultExt}, }; use super::repos_root; // --- Commit message parsing --- #[derive(Debug, PartialEq, Eq)] enum IssueRefAction { Close, Reopen, Reference, } #[derive(Debug, PartialEq, Eq)] struct IssueRef { number: i32, action: IssueRefAction, } /// Parse issue references from a commit message. /// /// Recognizes: /// - Close: `fix(es|ed|s|d)? #N`, `close(s|d)? #N`, `resolve(s|d)? #N` /// - Reference: `ref(s)? #N`, `reference(s)? #N` /// /// Deduplicates by issue number (close wins over reference). fn parse_issue_refs(message: &str) -> Vec { static CLOSE_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?i)(?:fix|close|resolve)(?:es|ed|s|d)?\s+#(\d+)") .expect("static issue-close regex compiles") }); static REOPEN_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?i)reopen(?:s|ed)?\s+#(\d+)").expect("static issue-reopen regex compiles") }); static REF_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?i)(?:ref|reference)s?\s+#(\d+)").expect("static issue-ref regex compiles") }); let mut by_number: HashMap = HashMap::new(); for cap in CLOSE_RE.captures_iter(message) { if let Ok(n) = cap[1].parse::() { // Close always wins over reference by_number.insert(n, IssueRefAction::Close); } } for cap in REOPEN_RE.captures_iter(message) { if let Ok(n) = cap[1].parse::() { // Reopen wins over reference but not close by_number.entry(n).or_insert(IssueRefAction::Reopen); } } for cap in REF_RE.captures_iter(message) { if let Ok(n) = cap[1].parse::() { by_number.entry(n).or_insert(IssueRefAction::Reference); } } let mut refs: Vec = by_number .into_iter() .map(|(number, action)| IssueRef { number, action }) .collect(); refs.sort_by_key(|r| r.number); refs } // --- Process push endpoint --- #[derive(Deserialize)] pub(super) struct ProcessPushRequest { repo_owner: String, repo_name: String, ref_name: String, before: String, after: String, } /// `POST /api/internal/issues/process-push` /// /// Called by the post-receive hook to process commit messages for issue references. /// Auth: Bearer token (same BUILD_TRIGGER_TOKEN as the build pipeline). #[tracing::instrument(skip_all, name = "git_issues::process_push")] pub(super) async fn process_push( State(db): State, State(config): State, headers: axum::http::HeaderMap, Json(req): Json, ) -> Result { // Validate per-repo HMAC (derived from BUILD_TRIGGER_TOKEN, never stored raw) let trigger_token = config .build .trigger_token .as_deref() .ok_or(AppError::Internal(anyhow::anyhow!( "BUILD_TRIGGER_TOKEN not configured" )))?; let auth_header = headers .get("authorization") .and_then(|v| v.to_str().ok()) .unwrap_or(""); let provided_hmac = auth_header.strip_prefix("Bearer ").unwrap_or(""); let expected_hmac = crate::build_runner::repo_hmac(trigger_token, &req.repo_owner, &req.repo_name); if provided_hmac.is_empty() || !crate::helpers::constant_time_compare(provided_hmac, &expected_hmac) { return Err(AppError::Forbidden); } // Look up repo owner + repo let owner_user = db::users::get_user_by_username(&db, &db::Username::from_trusted(req.repo_owner.clone())) .await? .ok_or(AppError::NotFound)?; let repo = db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name) .await? .ok_or(AppError::NotFound)?; // Open the bare repo and collect commit refs on a blocking thread. The walk // is synchronous disk I/O, so it runs in spawn_blocking (every other git // path does the same via crate::git); doing it inline parks a tokio worker // thread for the duration (Run #2 Perf SERIOUS). The walk returns only the // owned, Send `Vec` of collected refs. let root = repos_root(&config)?; let repo_owner = req.repo_owner.clone(); let repo_name = req.repo_name.clone(); let after = req.after.clone(); let before = req.before.clone(); let commit_refs = tokio::task::spawn_blocking(move || -> Result)>> { let git_repo = crate::git::open_repo(&root, &repo_owner, &repo_name).context("open git repo")?; let after_oid = gix::ObjectId::from_hex(after.as_bytes()) .map_err(|e| AppError::BadRequest(format!("invalid 'after' oid: {e}")))?; let is_new_branch = before.chars().all(|c| c == '0'); let hidden = if is_new_branch { None } else { gix::ObjectId::from_hex(before.as_bytes()).ok() }; let walk = git_repo .rev_walk([after_oid]) .with_hidden(hidden) .all() .context("revwalk init")?; let max_commits = if is_new_branch { 1 } else { 50 }; let mut collected: Vec<(String, String, Vec)> = Vec::new(); for info in walk.take(max_commits) { let Ok(info) = info else { continue; }; let Ok(commit) = git_repo.find_commit(info.id) else { continue; }; let Ok(message) = commit.message_raw() else { continue; }; let refs = parse_issue_refs(&message.to_str_lossy()); if !refs.is_empty() { let oid_str = info.id.to_string(); let short_oid = oid_str[..7.min(oid_str.len())].to_string(); collected.push((oid_str, short_oid, refs)); } } Ok(collected) }) .await .context("git push-refs walk task")??; // Batch-fetch every referenced issue once (deduped across commits) so a push // touching many commits doesn't run one get_issue_by_number per reference. // The local map is mutated as statuses change, so repeated references to the // same issue see its current state without re-querying. let mut numbers: Vec = commit_refs .iter() .flat_map(|(_, _, refs)| refs.iter().map(|r| r.number)) .collect(); numbers.sort_unstable(); numbers.dedup(); let mut issues = db::issues::get_issues_by_numbers(&db, repo.id, &numbers).await?; // Collect the DB writes and flush them in two bulk statements at the end // rather than one status UPDATE + one comment INSERT per referenced commit // (ultra-fuzz Run 6 S6). The local `issue.status` is still mutated as we go, // so repeated references to the same issue within one push observe the // running state; `status_changes` holds the final status to persist. let mut processed = 0u32; let mut status_changes: HashMap = HashMap::new(); let mut comments: Vec<(db::IssueId, String, String)> = Vec::new(); // What each commit claimed, for the `mnw/issues` note. Collected from the // same pass rather than re-derived, so the note and the issue comments can // never disagree about what a message said. A reference to an issue that // does not exist is skipped below and so is absent from both. let mut per_commit: Vec<(String, Vec, Vec, Vec)> = Vec::new(); for (oid_str, short_oid, refs) in &commit_refs { let (mut closed, mut reopened, mut referenced) = (Vec::new(), Vec::new(), Vec::new()); for issue_ref in refs { let Some(issue) = issues.get_mut(&issue_ref.number) else { continue; }; match issue_ref.action { IssueRefAction::Close => closed.push(issue_ref.number), IssueRefAction::Reopen => reopened.push(issue_ref.number), IssueRefAction::Reference => referenced.push(issue_ref.number), } let lead = match issue_ref.action { IssueRefAction::Close => { if issue.status == IssueStatus::Open { issue.status = IssueStatus::Closed; status_changes.insert(issue.id, IssueStatus::Closed); } "Closed via" } IssueRefAction::Reopen => { if issue.status == IssueStatus::Closed { issue.status = IssueStatus::Open; status_changes.insert(issue.id, IssueStatus::Open); } "Reopened via" } IssueRefAction::Reference => "Referenced in", }; let body_md = format!( "{lead} commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name, ); let body_html = docengine::render_permissive(&body_md); comments.push((issue.id, body_md, body_html)); processed += 1; } if !(closed.is_empty() && reopened.is_empty() && referenced.is_empty()) { per_commit.push((oid_str.clone(), closed, reopened, referenced)); } } // Flush: final status per changed issue, then every comment, in two queries. let status_updates: Vec<(db::IssueId, IssueStatus)> = status_changes.into_iter().collect(); if let Err(e) = db::issues::update_issue_statuses(&db, &status_updates).await { tracing::warn!(error = ?e, "failed to bulk-update issue statuses via push"); } if let Err(e) = db::issues::create_comments(&db, owner_user.id, &comments).await { tracing::warn!(error = ?e, "failed to bulk-create issue comments via push"); } // Mirror the commit-to-issue mapping into `refs/notes/mnw/issues`, so it // leaves with the repository rather than staying in a table only we read. // One note per commit, rewritten whole, and none of it can fail this // handler: the issues are already updated by here. let owned = crate::routes::git::notes_server::OwnedRepo { db: &db, config: &config, id: repo.id, owner: &req.repo_owner, name: &req.repo_name, }; for (oid_str, closed, reopened, referenced) in &per_commit { let Ok(target) = crate::git::notes::Oid::from_hex(oid_str.as_bytes()) else { continue; }; crate::routes::git::notes_server::note_issue_refs( &owned, target, closed, reopened, referenced, ) .await; } Ok(( StatusCode::OK, Json(serde_json::json!({ "processed": processed })), )) } // --- Unit Tests --- #[cfg(test)] mod tests { use super::*; #[test] fn parse_issue_refs_fixes() { assert_eq!( parse_issue_refs("Fixes #123"), vec![IssueRef { number: 123, action: IssueRefAction::Close }] ); } #[test] fn parse_issue_refs_closes() { assert_eq!( parse_issue_refs("Closes #456"), vec![IssueRef { number: 456, action: IssueRefAction::Close }] ); } #[test] fn parse_issue_refs_resolves() { assert_eq!( parse_issue_refs("Resolves #7"), vec![IssueRef { number: 7, action: IssueRefAction::Close }] ); } #[test] fn parse_issue_refs_refs() { assert_eq!( parse_issue_refs("Refs #10"), vec![IssueRef { number: 10, action: IssueRefAction::Reference }] ); } #[test] fn parse_issue_refs_references() { assert_eq!( parse_issue_refs("References #42"), vec![IssueRef { number: 42, action: IssueRefAction::Reference }] ); } #[test] fn parse_issue_refs_case_insensitive() { assert_eq!( parse_issue_refs("fixes #1"), vec![IssueRef { number: 1, action: IssueRefAction::Close }] ); } #[test] fn parse_issue_refs_multiple() { let refs = parse_issue_refs("Fixes #1, refs #2"); assert_eq!(refs.len(), 2); assert!(refs.contains(&IssueRef { number: 1, action: IssueRefAction::Close })); assert!(refs.contains(&IssueRef { number: 2, action: IssueRefAction::Reference })); } #[test] fn parse_issue_refs_dedup_close_wins() { let refs = parse_issue_refs("Refs #1\nFixes #1"); assert_eq!( refs, vec![IssueRef { number: 1, action: IssueRefAction::Close }] ); } #[test] fn parse_issue_refs_no_match() { assert!(parse_issue_refs("No issues here").is_empty()); } #[test] fn parse_issue_refs_in_sentence() { let refs = parse_issue_refs("This fixes #5 and refs #6"); assert_eq!(refs.len(), 2); assert!(refs.contains(&IssueRef { number: 5, action: IssueRefAction::Close })); assert!(refs.contains(&IssueRef { number: 6, action: IssueRefAction::Reference })); } #[test] fn parse_issue_refs_reopens() { assert_eq!( parse_issue_refs("Reopens #5"), vec![IssueRef { number: 5, action: IssueRefAction::Reopen }] ); } #[test] fn parse_issue_refs_reopened() { assert_eq!( parse_issue_refs("Reopened #3"), vec![IssueRef { number: 3, action: IssueRefAction::Reopen }] ); } #[test] fn parse_issue_refs_reopen_bare() { assert_eq!( parse_issue_refs("reopen #7"), vec![IssueRef { number: 7, action: IssueRefAction::Reopen }] ); } #[test] fn parse_issue_refs_close_wins_over_reopen() { let refs = parse_issue_refs("Reopens #1\nFixes #1"); assert_eq!( refs, vec![IssueRef { number: 1, action: IssueRefAction::Close }] ); } #[test] fn parse_issue_refs_reopen_wins_over_ref() { let refs = parse_issue_refs("Refs #2\nReopens #2"); assert_eq!( refs, vec![IssueRef { number: 2, action: IssueRefAction::Reopen }] ); } }