//! Commit-message issue reference parser and process-push endpoint. use axum::{ extract::State, http::StatusCode, Json, }; use regex::Regex; use serde::Deserialize; use std::collections::HashMap; use std::sync::LazyLock; use crate::{ db::{self, IssueStatus}, error::{AppError, Result, ResultExt}, AppState, }; 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(state): State, headers: axum::http::HeaderMap, Json(req): Json, ) -> Result { // Validate per-repo HMAC (derived from BUILD_TRIGGER_TOKEN, never stored raw) let trigger_token = state.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( &state.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(&state.db, owner_user.id, &req.repo_name) .await? .ok_or(AppError::NotFound)?; // Open the bare repo and collect commit refs synchronously (git2 types are !Send) let commit_refs = { let root = repos_root(&state)?; let git_repo = crate::git::open_repo(&root, &req.repo_owner, &req.repo_name) .context("open git repo")?; let after_oid = git2::Oid::from_str(&req.after) .map_err(|e| AppError::BadRequest(format!("invalid 'after' oid: {}", e)))?; let mut revwalk = git_repo.revwalk() .context("revwalk init")?; revwalk.push(after_oid) .context("revwalk push")?; let is_new_branch = req.before.chars().all(|c| c == '0'); if !is_new_branch && let Ok(before_oid) = git2::Oid::from_str(&req.before) { let _ = revwalk.hide(before_oid); } let max_commits = if is_new_branch { 1 } else { 50 }; let mut collected: Vec<(String, String, Vec)> = Vec::new(); for oid_result in revwalk.take(max_commits) { let oid = match oid_result { Ok(o) => o, Err(_) => continue, }; let commit = match git_repo.find_commit(oid) { Ok(c) => c, Err(_) => continue, }; let message = commit.message().unwrap_or(""); let refs = parse_issue_refs(message); if !refs.is_empty() { let oid_str = oid.to_string(); let short_oid = oid_str[..7.min(oid_str.len())].to_string(); collected.push((oid_str, short_oid, refs)); } } collected }; // Now process DB operations (async-safe, git2 types are dropped) let mut processed = 0u32; for (oid_str, short_oid, refs) in &commit_refs { for issue_ref in refs { let issue = match db::issues::get_issue_by_number(&state.db, repo.id, issue_ref.number).await { Ok(Some(i)) => i, _ => continue, }; match issue_ref.action { IssueRefAction::Close => { if issue.status == IssueStatus::Open && let Err(e) = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Closed).await { tracing::warn!(issue_id = %issue.id, error = ?e, "failed to close issue via push"); } let body_md = format!( "Closed via 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); if let Err(e) = db::issues::create_comment( &state.db, issue.id, owner_user.id, &body_md, &body_html, ).await { tracing::warn!(issue_id = %issue.id, error = ?e, "failed to create close comment via push"); } } IssueRefAction::Reopen => { if issue.status == IssueStatus::Closed && let Err(e) = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Open).await { tracing::warn!(issue_id = %issue.id, error = ?e, "failed to reopen issue via push"); } let body_md = format!( "Reopened via 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); if let Err(e) = db::issues::create_comment( &state.db, issue.id, owner_user.id, &body_md, &body_html, ).await { tracing::warn!(issue_id = %issue.id, error = ?e, "failed to create reopen comment via push"); } } IssueRefAction::Reference => { let body_md = format!( "Referenced in 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); if let Err(e) = db::issues::create_comment( &state.db, issue.id, owner_user.id, &body_md, &body_html, ).await { tracing::warn!(issue_id = %issue.id, error = ?e, "failed to create reference comment via push"); } } } processed += 1; } } 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 }]); } }