Skip to main content

max / makenotwork

12.1 KB · 360 lines History Blame Raw
1 //! Commit-message issue reference parser and process-push endpoint.
2
3 use axum::{
4 extract::State,
5 http::StatusCode,
6 Json,
7 };
8 use regex::Regex;
9 use serde::Deserialize;
10 use std::collections::HashMap;
11 use std::sync::LazyLock;
12
13 use crate::{
14 db::{self, IssueStatus},
15 error::{AppError, Result, ResultExt},
16 AppState,
17 };
18
19 use super::repos_root;
20
21 // ── Commit message parsing ──
22
23 #[derive(Debug, PartialEq, Eq)]
24 enum IssueRefAction {
25 Close,
26 Reopen,
27 Reference,
28 }
29
30 #[derive(Debug, PartialEq, Eq)]
31 struct IssueRef {
32 number: i32,
33 action: IssueRefAction,
34 }
35
36 /// Parse issue references from a commit message.
37 ///
38 /// Recognizes:
39 /// - Close: `fix(es|ed|s|d)? #N`, `close(s|d)? #N`, `resolve(s|d)? #N`
40 /// - Reference: `ref(s)? #N`, `reference(s)? #N`
41 ///
42 /// Deduplicates by issue number (close wins over reference).
43 fn parse_issue_refs(message: &str) -> Vec<IssueRef> {
44 static CLOSE_RE: LazyLock<Regex> = LazyLock::new(|| {
45 Regex::new(r"(?i)(?:fix|close|resolve)(?:es|ed|s|d)?\s+#(\d+)")
46 .expect("static issue-close regex compiles")
47 });
48 static REOPEN_RE: LazyLock<Regex> = LazyLock::new(|| {
49 Regex::new(r"(?i)reopen(?:s|ed)?\s+#(\d+)")
50 .expect("static issue-reopen regex compiles")
51 });
52 static REF_RE: LazyLock<Regex> = LazyLock::new(|| {
53 Regex::new(r"(?i)(?:ref|reference)s?\s+#(\d+)")
54 .expect("static issue-ref regex compiles")
55 });
56
57 let mut by_number: HashMap<i32, IssueRefAction> = HashMap::new();
58
59 for cap in CLOSE_RE.captures_iter(message) {
60 if let Ok(n) = cap[1].parse::<i32>() {
61 // Close always wins over reference
62 by_number.insert(n, IssueRefAction::Close);
63 }
64 }
65
66 for cap in REOPEN_RE.captures_iter(message) {
67 if let Ok(n) = cap[1].parse::<i32>() {
68 // Reopen wins over reference but not close
69 by_number.entry(n).or_insert(IssueRefAction::Reopen);
70 }
71 }
72
73 for cap in REF_RE.captures_iter(message) {
74 if let Ok(n) = cap[1].parse::<i32>() {
75 by_number.entry(n).or_insert(IssueRefAction::Reference);
76 }
77 }
78
79 let mut refs: Vec<IssueRef> = by_number
80 .into_iter()
81 .map(|(number, action)| IssueRef { number, action })
82 .collect();
83 refs.sort_by_key(|r| r.number);
84 refs
85 }
86
87 // ── Process push endpoint ──
88
89 #[derive(Deserialize)]
90 pub(super) struct ProcessPushRequest {
91 repo_owner: String,
92 repo_name: String,
93 ref_name: String,
94 before: String,
95 after: String,
96 }
97
98 /// `POST /api/internal/issues/process-push`
99 ///
100 /// Called by the post-receive hook to process commit messages for issue references.
101 /// Auth: Bearer token (same BUILD_TRIGGER_TOKEN as the build pipeline).
102 #[tracing::instrument(skip_all, name = "git_issues::process_push")]
103 pub(super) async fn process_push(
104 State(state): State<AppState>,
105 headers: axum::http::HeaderMap,
106 Json(req): Json<ProcessPushRequest>,
107 ) -> Result<impl axum::response::IntoResponse> {
108 // Validate per-repo HMAC (derived from BUILD_TRIGGER_TOKEN, never stored raw)
109 let trigger_token = state.config.build_trigger_token.as_deref()
110 .ok_or(AppError::Internal(anyhow::anyhow!("BUILD_TRIGGER_TOKEN not configured")))?;
111
112 let auth_header = headers
113 .get("authorization")
114 .and_then(|v| v.to_str().ok())
115 .unwrap_or("");
116 let provided_hmac = auth_header.strip_prefix("Bearer ").unwrap_or("");
117
118 let expected_hmac = crate::build_runner::repo_hmac(trigger_token, &req.repo_owner, &req.repo_name);
119 if provided_hmac.is_empty() || !crate::helpers::constant_time_compare(provided_hmac, &expected_hmac) {
120 return Err(AppError::Forbidden);
121 }
122
123 // Look up repo owner + repo
124 let owner_user = db::users::get_user_by_username(
125 &state.db,
126 &db::Username::from_trusted(req.repo_owner.clone()),
127 )
128 .await?
129 .ok_or(AppError::NotFound)?;
130
131 let repo = db::git_repos::get_repo_by_user_and_name(&state.db, owner_user.id, &req.repo_name)
132 .await?
133 .ok_or(AppError::NotFound)?;
134
135 // Open the bare repo and collect commit refs synchronously (git2 types are !Send)
136 let commit_refs = {
137 let root = repos_root(&state)?;
138 let git_repo = crate::git::open_repo(&root, &req.repo_owner, &req.repo_name)
139 .context("open git repo")?;
140
141 let after_oid = git2::Oid::from_str(&req.after)
142 .map_err(|e| AppError::BadRequest(format!("invalid 'after' oid: {}", e)))?;
143
144 let mut revwalk = git_repo.revwalk()
145 .context("revwalk init")?;
146 revwalk.push(after_oid)
147 .context("revwalk push")?;
148
149 let is_new_branch = req.before.chars().all(|c| c == '0');
150 if !is_new_branch
151 && let Ok(before_oid) = git2::Oid::from_str(&req.before)
152 {
153 let _ = revwalk.hide(before_oid);
154 }
155
156 let max_commits = if is_new_branch { 1 } else { 50 };
157 let mut collected: Vec<(String, String, Vec<IssueRef>)> = Vec::new();
158
159 for oid_result in revwalk.take(max_commits) {
160 let oid = match oid_result {
161 Ok(o) => o,
162 Err(_) => continue,
163 };
164 let commit = match git_repo.find_commit(oid) {
165 Ok(c) => c,
166 Err(_) => continue,
167 };
168 let message = commit.message().unwrap_or("");
169 let refs = parse_issue_refs(message);
170 if !refs.is_empty() {
171 let oid_str = oid.to_string();
172 let short_oid = oid_str[..7.min(oid_str.len())].to_string();
173 collected.push((oid_str, short_oid, refs));
174 }
175 }
176 collected
177 };
178
179 // Now process DB operations (async-safe, git2 types are dropped)
180 let mut processed = 0u32;
181 for (oid_str, short_oid, refs) in &commit_refs {
182 for issue_ref in refs {
183 let issue = match db::issues::get_issue_by_number(&state.db, repo.id, issue_ref.number).await {
184 Ok(Some(i)) => i,
185 _ => continue,
186 };
187
188 match issue_ref.action {
189 IssueRefAction::Close => {
190 if issue.status == IssueStatus::Open
191 && let Err(e) = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Closed).await
192 {
193 tracing::warn!(issue_id = %issue.id, error = ?e, "failed to close issue via push");
194 }
195 let body_md = format!(
196 "Closed via commit [`{}`](/git/{}/{}/commit/{}) on `{}`.",
197 short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name,
198 );
199 let body_html = docengine::render_permissive(&body_md);
200 if let Err(e) = db::issues::create_comment(
201 &state.db, issue.id, owner_user.id, &body_md, &body_html,
202 ).await {
203 tracing::warn!(issue_id = %issue.id, error = ?e, "failed to create close comment via push");
204 }
205 }
206 IssueRefAction::Reopen => {
207 if issue.status == IssueStatus::Closed
208 && let Err(e) = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Open).await
209 {
210 tracing::warn!(issue_id = %issue.id, error = ?e, "failed to reopen issue via push");
211 }
212 let body_md = format!(
213 "Reopened via commit [`{}`](/git/{}/{}/commit/{}) on `{}`.",
214 short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name,
215 );
216 let body_html = docengine::render_permissive(&body_md);
217 if let Err(e) = db::issues::create_comment(
218 &state.db, issue.id, owner_user.id, &body_md, &body_html,
219 ).await {
220 tracing::warn!(issue_id = %issue.id, error = ?e, "failed to create reopen comment via push");
221 }
222 }
223 IssueRefAction::Reference => {
224 let body_md = format!(
225 "Referenced in commit [`{}`](/git/{}/{}/commit/{}) on `{}`.",
226 short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name,
227 );
228 let body_html = docengine::render_permissive(&body_md);
229 if let Err(e) = db::issues::create_comment(
230 &state.db, issue.id, owner_user.id, &body_md, &body_html,
231 ).await {
232 tracing::warn!(issue_id = %issue.id, error = ?e, "failed to create reference comment via push");
233 }
234 }
235 }
236 processed += 1;
237 }
238 }
239
240 Ok((StatusCode::OK, Json(serde_json::json!({ "processed": processed }))))
241 }
242
243 // ── Unit Tests ──
244
245 #[cfg(test)]
246 mod tests {
247 use super::*;
248
249 #[test]
250 fn parse_issue_refs_fixes() {
251 assert_eq!(
252 parse_issue_refs("Fixes #123"),
253 vec![IssueRef { number: 123, action: IssueRefAction::Close }]
254 );
255 }
256
257 #[test]
258 fn parse_issue_refs_closes() {
259 assert_eq!(
260 parse_issue_refs("Closes #456"),
261 vec![IssueRef { number: 456, action: IssueRefAction::Close }]
262 );
263 }
264
265 #[test]
266 fn parse_issue_refs_resolves() {
267 assert_eq!(
268 parse_issue_refs("Resolves #7"),
269 vec![IssueRef { number: 7, action: IssueRefAction::Close }]
270 );
271 }
272
273 #[test]
274 fn parse_issue_refs_refs() {
275 assert_eq!(
276 parse_issue_refs("Refs #10"),
277 vec![IssueRef { number: 10, action: IssueRefAction::Reference }]
278 );
279 }
280
281 #[test]
282 fn parse_issue_refs_references() {
283 assert_eq!(
284 parse_issue_refs("References #42"),
285 vec![IssueRef { number: 42, action: IssueRefAction::Reference }]
286 );
287 }
288
289 #[test]
290 fn parse_issue_refs_case_insensitive() {
291 assert_eq!(
292 parse_issue_refs("fixes #1"),
293 vec![IssueRef { number: 1, action: IssueRefAction::Close }]
294 );
295 }
296
297 #[test]
298 fn parse_issue_refs_multiple() {
299 let refs = parse_issue_refs("Fixes #1, refs #2");
300 assert_eq!(refs.len(), 2);
301 assert!(refs.contains(&IssueRef { number: 1, action: IssueRefAction::Close }));
302 assert!(refs.contains(&IssueRef { number: 2, action: IssueRefAction::Reference }));
303 }
304
305 #[test]
306 fn parse_issue_refs_dedup_close_wins() {
307 let refs = parse_issue_refs("Refs #1\nFixes #1");
308 assert_eq!(refs, vec![IssueRef { number: 1, action: IssueRefAction::Close }]);
309 }
310
311 #[test]
312 fn parse_issue_refs_no_match() {
313 assert!(parse_issue_refs("No issues here").is_empty());
314 }
315
316 #[test]
317 fn parse_issue_refs_in_sentence() {
318 let refs = parse_issue_refs("This fixes #5 and refs #6");
319 assert_eq!(refs.len(), 2);
320 assert!(refs.contains(&IssueRef { number: 5, action: IssueRefAction::Close }));
321 assert!(refs.contains(&IssueRef { number: 6, action: IssueRefAction::Reference }));
322 }
323
324 #[test]
325 fn parse_issue_refs_reopens() {
326 assert_eq!(
327 parse_issue_refs("Reopens #5"),
328 vec![IssueRef { number: 5, action: IssueRefAction::Reopen }]
329 );
330 }
331
332 #[test]
333 fn parse_issue_refs_reopened() {
334 assert_eq!(
335 parse_issue_refs("Reopened #3"),
336 vec![IssueRef { number: 3, action: IssueRefAction::Reopen }]
337 );
338 }
339
340 #[test]
341 fn parse_issue_refs_reopen_bare() {
342 assert_eq!(
343 parse_issue_refs("reopen #7"),
344 vec![IssueRef { number: 7, action: IssueRefAction::Reopen }]
345 );
346 }
347
348 #[test]
349 fn parse_issue_refs_close_wins_over_reopen() {
350 let refs = parse_issue_refs("Reopens #1\nFixes #1");
351 assert_eq!(refs, vec![IssueRef { number: 1, action: IssueRefAction::Close }]);
352 }
353
354 #[test]
355 fn parse_issue_refs_reopen_wins_over_ref() {
356 let refs = parse_issue_refs("Refs #2\nReopens #2");
357 assert_eq!(refs, vec![IssueRef { number: 2, action: IssueRefAction::Reopen }]);
358 }
359 }
360