1use std::collections::HashMap;
7use std::time as std_time;
8
9use super::{ExternalBackend, ExternalId, ExternalTask, Mention, Status};
10use crate::backends;
11use crate::backends::BlockedByFetch;
12use crate::backends::PullRequestCompletion;
13use crate::config;
14use crate::github::app;
15use crate::github::http::{GhHttp, status_label_color};
16use crate::github::projects;
17use crate::github::projects::ProjectSync;
18use crate::github::projects::label_status::LabelSyncConfig;
19use crate::github::types::GitHubComment;
20use crate::github::types::GitHubIssue;
21use crate::github::types::GitHubReview;
22use async_trait::async_trait;
23use chrono::{Duration, Utc};
24use tokio::time;
25
26const ALLOWED_ASSOCIATIONS: &[&str] = &["OWNER", "MEMBER", "COLLABORATOR"];
34
35fn is_trusted_author(issue: &GitHubIssue) -> bool {
37 issue
38 .author_association
39 .as_deref()
40 .is_some_and(|a| ALLOWED_ASSOCIATIONS.contains(&a)) }
42
43fn is_trusted_comment_author(c: &GitHubComment) -> bool {
44 c.author_association
45 .as_deref()
46 .is_some_and(|a| ALLOWED_ASSOCIATIONS.contains(&a))
47}
48
49fn trusted_mentions(comments: impl IntoIterator<Item = GitHubComment>) -> Vec<Mention> {
50 comments
51 .into_iter()
52 .filter_map(|c| {
53 if !is_trusted_comment_author(&c) {
54 tracing::debug!(
55 comment_id = c.id,
56 author = %c.user.login,
57 "ignoring comment from untrusted author"
58 );
59 return None;
60 }
61 Some(Mention {
62 id: c.id.to_string(),
63 body: c.body,
64 author: c.user.login,
65 created_at: c.created_at,
66 issue_url: c.issue_url,
67 })
68 })
69 .collect()
70}
71
72fn active_status_from_label(label: &str) -> Option<Status> {
73 match label {
74 "status:routed" => Some(Status::Routed),
75 "status:in_progress" => Some(Status::InProgress),
76 "status:needs_review" => Some(Status::NeedsReview),
77 "status:in_review" => Some(Status::InReview),
78 "status:approved" => Some(Status::Approved),
79 "status:blocked" => Some(Status::Blocked),
80 "status:waiting" => Some(Status::Waiting),
81 _ => None,
82 }
83}
84
85fn classify_active_open_issue(issue: GitHubIssue) -> Option<(ExternalTask, Option<Status>)> {
86 if issue.labels.iter().any(|l| l.name == "status:done") {
88 return None;
89 }
90
91 let status = issue
92 .labels
93 .iter()
94 .find_map(|l| active_status_from_label(&l.name));
95 let task = ExternalTask {
96 id: ExternalId(issue.number.to_string()),
97 title: issue.title,
98 body: issue.body.unwrap_or_default(),
99 state: issue.state,
100 labels: issue.labels.into_iter().map(|l| l.name).collect(),
101 author: issue.user.login,
102 created_at: issue.created_at,
103 updated_at: issue.updated_at,
104 url: issue.html_url,
105 };
106 Some((task, status))
107}
108
109#[derive(Debug)]
110pub struct GitHubBackend {
111 repo: String,
112 gh: GhHttp,
113}
114
115fn is_github_issue_id(id: &ExternalId) -> bool {
121 id.0.parse::<u64>().is_ok()
122}
123
124impl GitHubBackend {
125 pub fn new(repo: String) -> anyhow::Result<Self> {
126 Ok(Self {
127 repo,
128 gh: GhHttp::new()?,
129 })
130 }
131
132 #[cfg(test)]
133 fn for_test(repo: impl Into<String>, gh: GhHttp) -> Self {
134 Self {
135 repo: repo.into(),
136 gh,
137 }
138 }
139
140 async fn sync_project_status(
141 &self,
142 project: &ProjectSync,
143 node_id: &str,
144 status: &Status,
145 ) -> anyhow::Result<()> {
146 if LabelSyncConfig::from_config()?.manages(&self.repo, project.project_id()) {
147 project.add_item(node_id).await?;
150 Ok(())
151 } else {
152 project.sync_item_status(node_id, status).await
153 }
154 }
155}
156
157#[async_trait]
158impl ExternalBackend for GitHubBackend {
159 fn name(&self) -> &'static str {
160 "github"
161 }
162
163 async fn create_task(
164 &self,
165 title: &str,
166 body: &str,
167 labels: &[String],
168 ) -> anyhow::Result<ExternalId> {
169 let issue = self
170 .gh
171 .create_issue(&self.repo, title, body, labels)
172 .await?;
173 Ok(ExternalId(issue.number.to_string()))
174 }
175
176 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
177 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
178 Ok(ExternalTask {
179 id: id.clone(),
180 title: issue.title,
181 body: issue.body.unwrap_or_default(),
182 state: issue.state,
183 labels: issue.labels.into_iter().map(|l| l.name).collect(),
184 author: issue.user.login,
185 created_at: issue.created_at,
186 updated_at: issue.updated_at,
187 url: issue.html_url,
188 })
189 }
190
191 async fn ensure_status_label(&self, label: &str) -> anyhow::Result<()> {
196 let status_name = &label["status:".len()..];
197 self.gh
198 .ensure_label(
199 &self.repo,
200 label,
201 status_label_color(label),
202 &format!("Task status: {status_name}"),
203 )
204 .await
205 }
206
207 async fn sync_to_project(
213 &self,
214 id: &ExternalId,
215 status: Status,
216 ) -> anyhow::Result<Option<String>> {
217 let Some(project) = ProjectSync::from_config() else {
219 return Ok(None);
220 };
221
222 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
224
225 let node_id = issue
227 .node_id
228 .as_deref()
229 .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?
230 .to_string();
231
232 self.sync_project_status(&project, &node_id, &status)
234 .await
235 .map_err(|e| anyhow::anyhow!("project board sync failed: {e}"))?;
236
237 Ok(Some(node_id))
238 }
239
240 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
241 LabelSyncConfig::from_config()?
243 .reconcile(&GhHttp::owner()?, &self.repo)
244 .await
245 .map(|_| ())
246 }
247
248 async fn sync_estimate_to_project(&self, id: &ExternalId, estimate: u8) -> anyhow::Result<()> {
249 let Some(project) = ProjectSync::from_config() else {
250 return Ok(());
251 };
252
253 let project = if project.estimate_field_id().is_none() {
257 match ProjectSync::discover_fields(project.project_id()).await {
258 Ok(discovered) => {
259 if discovered.estimate_field_id().is_some() {
260 match projects::write_project_config(&discovered).await {
261 Err(e) => {
262 tracing::warn!(
263 err = %e,
264 "failed to persist discovered estimate field ID to config"
265 );
266 }
267 _ => {
268 tracing::info!(
269 "auto-discovered and persisted project_estimate_field_id to config"
270 );
271 }
272 }
273 discovered
274 } else {
275 tracing::debug!(
276 "project has no 'Estimate' number field — skipping estimate sync"
277 );
278 return Ok(());
279 }
280 }
281 Err(e) => {
282 tracing::debug!(
283 err = %e,
284 "estimate field auto-discovery failed — skipping estimate sync"
285 );
286 return Ok(());
287 }
288 }
289 } else {
290 project
291 };
292
293 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
295 let node_id = issue
296 .node_id
297 .as_deref()
298 .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?;
299
300 project
301 .sync_item_estimate(node_id, estimate)
302 .await
303 .map_err(|e| anyhow::anyhow!("project estimate sync failed: {e}"))?;
304
305 Ok(())
306 }
307
308 async fn get_project_item_estimates(
309 &self,
310 issue_node_ids: &[String],
311 ) -> anyhow::Result<HashMap<String, u8>> {
312 let Some(project) = ProjectSync::from_config() else {
313 return Ok(HashMap::new());
314 };
315
316 if issue_node_ids.is_empty() {
317 return Ok(HashMap::new());
318 }
319
320 project
321 .get_estimates_for_issues(issue_node_ids)
322 .await
323 .map_err(|e| anyhow::anyhow!("project estimate fetch failed: {e}"))
324 }
325
326 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
328 const MAX_RETRIES: u32 = 3;
329 if !is_github_issue_id(id) {
331 return Ok(());
332 }
333
334 let label = status.as_label();
336
337 if let Err(e) = self.ensure_status_label(label).await {
338 tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
339 }
340
341 let mut last_err =
342 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
343
344 let mut issue_node_id: Option<String> = None;
345
346 for attempt in 0..MAX_RETRIES {
347 if attempt > 0 {
348 time::sleep(std_time::Duration::from_millis(500 * u64::from(attempt))).await;
349 tracing::debug!(attempt, label, "retrying update_status");
350 }
351
352 let issue = match self.gh.get_issue(&self.repo, &id.0).await {
353 Ok(i) => i,
354 Err(e) => {
355 last_err = e;
356 continue;
357 }
358 };
359
360 if issue_node_id.is_none() {
362 issue_node_id = issue.node_id.clone();
363 }
364
365 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
366
367 let mut remove_failed = false;
368 for old in labels
369 .iter()
370 .filter(|l| l.starts_with("status:") && l.as_str() != label)
371 {
372 if let Err(e) = self.remove_label(id, old).await {
373 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
374 last_err = e;
375 remove_failed = true;
376 break;
377 }
378 }
379 if remove_failed {
380 continue;
381 }
382
383 match self.set_labels(id, &[label.to_string()]).await {
384 Ok(()) => {
385 if let Some(ref node_id) = issue_node_id
387 && let Some(project) = ProjectSync::from_config()
388 && let Err(e) = self.sync_project_status(&project, node_id, &status).await
389 {
390 tracing::warn!(
391 task_id = id.0,
392 err = %e,
393 "project board sync failed (non-fatal)"
394 );
395 }
396
397 if status == Status::Done {
399 let auto_close =
400 config::get("workflow.auto_close").map_or(true, |v| v == "true");
401 if auto_close && let Err(e) = self.gh.close_issue(&self.repo, &id.0).await {
402 tracing::warn!(
403 task_id = id.0,
404 err = %e,
405 "auto-close issue failed (non-fatal)"
406 );
407 }
408 }
409
410 return Ok(());
411 }
412 Err(e) => {
413 tracing::warn!(label, err = %e, attempt, "set_labels failed");
414 last_err = e;
415 }
416 }
417 }
418
419 Err(last_err.context(format!(
420 "update_status({label}) failed after {MAX_RETRIES} attempts"
421 )))
422 }
423
424 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
425 let state = if status == Status::Done {
428 "all"
429 } else {
430 "open"
431 };
432 let issues = self
433 .gh
434 .list_issues_with_state(&self.repo, status.as_label(), state)
435 .await?;
436 Ok(issues
437 .into_iter()
438 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
441 id: ExternalId(issue.number.to_string()),
442 title: issue.title,
443 body: issue.body.unwrap_or_default(),
444 state: issue.state,
445 labels: issue.labels.into_iter().map(|l| l.name).collect(),
446 author: issue.user.login,
447 created_at: issue.created_at,
448 updated_at: issue.updated_at,
449 url: issue.html_url,
450 })
451 .collect())
452 }
453
454 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
459 let issues = self.gh.list_all_issues(&self.repo).await?;
460 Ok(issues
461 .into_iter()
462 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
465 id: ExternalId(issue.number.to_string()),
466 title: issue.title,
467 body: issue.body.unwrap_or_default(),
468 state: issue.state,
469 labels: issue.labels.into_iter().map(|l| l.name).collect(),
470 author: issue.user.login,
471 created_at: issue.created_at,
472 updated_at: issue.updated_at,
473 url: issue.html_url,
474 })
475 .collect())
476 }
477
478 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
479 let since = (Utc::now() - Duration::days(30)).to_rfc3339();
480 let closed = self.gh.list_closed_issues_since(&self.repo, &since).await?;
481
482 Ok(closed
483 .into_iter()
484 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
487 id: ExternalId(issue.number.to_string()),
488 title: issue.title,
489 body: issue.body.unwrap_or_default(),
490 state: issue.state,
491 labels: issue.labels.into_iter().map(|l| l.name).collect(),
492 author: issue.user.login,
493 created_at: issue.created_at,
494 updated_at: issue.updated_at,
495 url: issue.html_url,
496 })
497 .collect())
498 }
499
500 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
502 let issues = self.gh.list_all_open_issues(&self.repo).await?;
503 Ok(issues
504 .into_iter()
505 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .filter(|issue| {
508 let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
509 let has_status = labels.iter().any(|l| l.starts_with("status:"));
511 !has_status || labels.contains(&"status:new")
512 })
513 .map(|issue| ExternalTask {
514 id: ExternalId(issue.number.to_string()),
515 title: issue.title,
516 body: issue.body.unwrap_or_default(),
517 state: issue.state,
518 labels: issue.labels.into_iter().map(|l| l.name).collect(),
519 author: issue.user.login,
520 created_at: issue.created_at,
521 updated_at: issue.updated_at,
522 url: issue.html_url,
523 })
524 .collect())
525 }
526
527 async fn list_active_open_issues(
531 &self,
532 ) -> anyhow::Result<Vec<(ExternalTask, Option<backends::Status>)>> {
533 let issues = self.gh.list_all_open_issues(&self.repo).await?;
534
535 Ok(issues
536 .into_iter()
537 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .filter_map(classify_active_open_issue)
540 .collect())
541 }
542
543 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
544 if !is_github_issue_id(id) {
546 return Ok(());
547 }
548 self.gh.add_comment(&self.repo, &id.0, body).await
549 }
550
551 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
553 if !is_github_issue_id(id) {
555 return Ok(());
556 }
557 self.gh.add_labels(&self.repo, &id.0, labels).await
558 }
559
560 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()> {
561 if !is_github_issue_id(id) {
563 return Ok(());
564 }
565 self.gh.remove_label(&self.repo, &id.0, label).await
566 }
567
568 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
569 let sub_issue_numbers = self.gh.get_sub_issues(&self.repo, &id.0).await?;
570 Ok(sub_issue_numbers
571 .into_iter()
572 .map(|n| ExternalId(n.to_string()))
573 .collect())
574 }
575
576 async fn create_sub_task(
577 &self,
578 parent_id: &ExternalId,
579 title: &str,
580 body: &str,
581 labels: &[String],
582 ) -> anyhow::Result<ExternalId> {
583 let mut all_labels = labels.to_vec();
585 all_labels.push(format!("parent:{}", parent_id.0));
586
587 let child_issue = self
589 .gh
590 .create_issue(&self.repo, title, body, &all_labels)
591 .await?;
592
593 let child_id = ExternalId(child_issue.number.to_string());
594
595 if let (Some(child_node_id), Ok(parent_issue)) = (
597 child_issue.node_id,
598 self.gh.get_issue(&self.repo, &parent_id.0).await,
599 ) && let Some(parent_node_id) = parent_issue.node_id
600 && let Err(e) = self.gh.add_sub_issue(&parent_node_id, &child_node_id).await
601 {
602 tracing::warn!(
603 parent = parent_id.0,
604 child = child_id.0,
605 err = %e,
606 "failed to add native sub-issue link (parent label still set)"
607 );
608 }
609
610 Ok(child_id)
611 }
612
613 async fn has_open_issue_with_title(&self, title: &str, label: &str) -> anyhow::Result<bool> {
614 let open_issues = if label.is_empty() {
616 self.gh.list_all_open_issues(&self.repo).await?
617 } else {
618 self.gh.list_issues(&self.repo, label).await?
619 };
620 if open_issues.iter().any(|i| i.title == title) {
621 return Ok(true);
622 }
623 let since = (chrono::Utc::now() - chrono::Duration::hours(24))
626 .format("%Y-%m-%dT%H:%M:%SZ")
627 .to_string();
628 let closed = if label.is_empty() {
629 self.gh.list_closed_issues_since(&self.repo, &since).await?
630 } else {
631 self.gh
632 .list_issues_closed_since(&self.repo, label, &since)
633 .await?
634 };
635 Ok(closed.iter().any(|i| i.title == title))
636 }
637
638 async fn health_check(&self) -> anyhow::Result<()> {
639 self.gh.auth_status().await
640 }
641
642 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
643 self.gh.is_pr_merged(&self.repo, branch).await
644 }
645
646 async fn batch_is_pr_merged(
647 &self,
648 branches: &[String],
649 ) -> anyhow::Result<HashMap<String, bool>> {
650 self.gh
651 .batch_is_pr_merged_by_branch(&self.repo, branches)
652 .await
653 }
654
655 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
656 self.gh.get_whoami().await.map(Some)
657 }
658
659 async fn get_mentions(&self, since: &str) -> anyhow::Result<Vec<Mention>> {
660 let comments = self.gh.get_mentions(&self.repo, since).await?;
661 Ok(trusted_mentions(comments))
662 }
663
664 async fn list_issue_comments(&self, id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
665 let issue_number: u64 =
666 id.0.parse()
667 .map_err(|_| anyhow::anyhow!("invalid issue number: {}", id.0))?;
668 let comments = self.gh.get_issue_comments(&self.repo, issue_number).await?;
669 Ok(trusted_mentions(comments))
670 }
671
672 async fn acknowledge_issue(&self, id: &ExternalId) -> anyhow::Result<()> {
673 self.gh.add_reaction(&self.repo, &id.0, "eyes").await
674 }
675
676 async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
677 self.gh
678 .add_comment_reaction(&self.repo, comment_id, "eyes")
679 .await
680 }
681
682 async fn blocked_by(&self, repo: &str, issue_number: i64) -> anyhow::Result<BlockedByFetch> {
683 if issue_number <= 0 {
684 return Ok(BlockedByFetch::Unavailable);
685 }
686 self.gh
687 .list_blocked_by(repo, issue_number.cast_unsigned())
688 .await
689 }
690
691 async fn issue_close_state(
692 &self,
693 repo: &str,
694 issue_number: i64,
695 ) -> anyhow::Result<Option<(String, Option<String>)>> {
696 if issue_number <= 0 {
697 return Ok(None);
698 }
699 match self.gh.get_issue(repo, &issue_number.to_string()).await {
700 Ok(issue) => Ok(Some((issue.state, issue.state_reason))),
701 Err(err) if is_missing_issue(&err) => Ok(None),
702 Err(err) => Err(err),
703 }
704 }
705
706 async fn post_comment_in_repo(
707 &self,
708 repo: &str,
709 id: &ExternalId,
710 body: &str,
711 ) -> anyhow::Result<()> {
712 if !is_github_issue_id(id) {
713 return Ok(());
714 }
715 self.gh.add_comment(repo, &id.0, body).await
716 }
717
718 async fn pull_request_completion(
719 &self,
720 repo: &str,
721 pr_number: i64,
722 ) -> anyhow::Result<Option<PullRequestCompletion>> {
723 if pr_number <= 0 {
724 return Ok(None);
725 }
726 let pr = match self.gh.get_pr(repo, pr_number.cast_unsigned()).await {
727 Ok(pr) => pr,
728 Err(err) if is_missing_issue(&err) => return Ok(None),
729 Err(err) => return Err(err),
730 };
731 let default_branch = self.gh.get_default_branch(repo).await.unwrap_or_default();
732 let app_login = app::bot_login().await;
733 let reviews = match self
734 .gh
735 .get_pr_reviews(repo, pr_number.cast_unsigned())
736 .await
737 {
738 Ok(reviews) => app::reviews_for_gate(&reviews, &pr.head.sha, app_login.as_deref()),
739 Err(err) => {
740 tracing::warn!(repo, pr_number, err = %err, "pull request reviews unreadable");
741 Vec::new()
742 }
743 };
744 let comments = match self.gh.list_comments(repo, &pr_number.to_string()).await {
745 Ok(comments) => comments,
746 Err(err) => {
747 tracing::warn!(repo, pr_number, err = %err, "pull request comments unreadable");
748 Vec::new()
749 }
750 };
751 let reviewer = match self.gh.get_whoami().await {
752 Ok(login) => Some(login),
753 Err(err) => {
754 tracing::warn!(repo, pr_number, err = %err, "reviewer login unreadable");
755 None
756 }
757 };
758 let comment_refs: Vec<AutomatedReviewComment<'_>> = comments
759 .iter()
760 .map(|comment| AutomatedReviewComment {
761 login: &comment.user.login,
762 body: &comment.body,
763 created_at: &comment.created_at,
764 })
765 .collect();
766 let comment_approved = reviewer
767 .iter()
768 .chain(app_login.iter())
769 .any(|login| latest_automated_review_approves(&comment_refs, login));
770 let (comment_approved, _) = app::effective_comment_flags(
773 &reviews,
774 &pr.head.sha,
775 app_login.as_deref(),
776 comment_approved,
777 false,
778 );
779 let merged = pr.merged.unwrap_or(false);
780 Ok(Some(PullRequestCompletion {
781 merged,
782 base_ref: pr.base.ref_,
783 default_branch,
784 approved: latest_review_approves(&reviews) || comment_approved,
785 closed: pr.state.eq_ignore_ascii_case("closed"),
786 }))
787 }
788
789 async fn add_dependency(
790 &self,
791 issue_number: i64,
792 blocking_repo: &str,
793 blocking_number: i64,
794 ) -> anyhow::Result<()> {
795 let blocker = self
796 .gh
797 .get_issue(blocking_repo, &blocking_number.to_string())
798 .await?;
799 if blocker.id == 0 {
800 anyhow::bail!("blocking issue {blocking_repo}#{blocking_number} has no database id");
801 }
802 self.gh
803 .add_blocked_by(
804 &self.repo,
805 issue_number.cast_unsigned(),
806 blocker.id.cast_signed(),
807 )
808 .await
809 }
810}
811
812fn is_missing_issue(err: &anyhow::Error) -> bool {
813 let text = err.to_string().to_ascii_lowercase();
814 text.contains("404") || text.contains("not found")
815}
816
817pub(crate) fn latest_review_approves(reviews: &[GitHubReview]) -> bool {
821 let mut latest: Option<(&str, &str)> = None;
822 for review in reviews {
823 let state = review.state.as_str();
824 let substantive = state.eq_ignore_ascii_case("APPROVED")
825 || state.eq_ignore_ascii_case("CHANGES_REQUESTED");
826 if !substantive {
827 continue;
828 }
829 match latest {
830 Some((at, _)) if review.submitted_at.as_str() < at => {}
831 _ => latest = Some((review.submitted_at.as_str(), state)),
832 }
833 }
834 latest.is_some_and(|(_, state)| state.eq_ignore_ascii_case("APPROVED"))
835}
836
837pub(crate) struct AutomatedReviewComment<'a> {
839 pub login: &'a str,
840 pub body: &'a str,
841 pub created_at: &'a str,
842}
843
844pub(crate) fn automated_review_verdict(body: &str) -> Option<&'static str> {
848 let first = body.lines().next().unwrap_or("").trim().replace('\r', "");
849 if !first.starts_with("## Automated Review") {
850 return None;
851 }
852 let normalized = first.replace(['\u{2014}', '\u{2013}'], "-");
853 if normalized.contains("Automated Review - Approve") {
854 Some("approve")
855 } else if normalized.contains("Automated Review - Changes Requested") {
856 Some("changes_requested")
857 } else {
858 None
859 }
860}
861
862pub(crate) fn latest_automated_review_approves(
867 comments: &[AutomatedReviewComment<'_>],
868 reviewer: &str,
869) -> bool {
870 if reviewer.is_empty() {
871 return false;
872 }
873 let mut newest: Option<&AutomatedReviewComment<'_>> = None;
874 for comment in comments {
875 if automated_review_verdict(comment.body).is_none() {
876 continue;
877 }
878 if !comment.login.eq_ignore_ascii_case(reviewer) {
879 continue;
880 }
881 match newest {
882 Some(prev) if comment.created_at <= prev.created_at => {}
883 _ => newest = Some(comment),
884 }
885 }
886 newest.is_some_and(|comment| automated_review_verdict(comment.body) == Some("approve"))
887}
888
889#[cfg(test)]
890mod tests {
891 use std::env;
892
893 use wiremock::Mock;
894 use wiremock::MockServer;
895 use wiremock::ResponseTemplate;
896 use wiremock::matchers::method;
897 use wiremock::matchers::path;
898
899 use super::*;
900 use crate::github::http::GhHttp;
901 use crate::github::token;
902 use crate::github::types::GitHubComment;
903 use crate::github::types::GitHubLabel;
904 use crate::github::types::{GitHubIssue, GitHubReview, GitHubUser};
905
906 fn review(state: &str, at: &str) -> GitHubReview {
907 GitHubReview {
908 id: 1,
909 user: GitHubUser {
910 login: "owner".into(),
911 },
912 body: None,
913 state: state.into(),
914 html_url: None,
915 submitted_at: at.into(),
916 commit_id: None,
917 }
918 }
919
920 #[test]
921 fn later_changes_requested_clears_an_earlier_approval() {
922 let reviews = vec![
923 review("APPROVED", "2026-01-01T00:00:00Z"),
924 review("COMMENTED", "2026-01-02T00:00:00Z"),
925 review("CHANGES_REQUESTED", "2026-01-03T00:00:00Z"),
926 ];
927 assert!(!latest_review_approves(&reviews));
928 let approved = vec![
929 review("CHANGES_REQUESTED", "2026-01-01T00:00:00Z"),
930 review("APPROVED", "2026-01-02T00:00:00Z"),
931 ];
932 assert!(latest_review_approves(&approved));
933 }
934
935 fn review_comment<'a>(
936 login: &'a str,
937 body: &'a str,
938 at: &'a str,
939 ) -> AutomatedReviewComment<'a> {
940 AutomatedReviewComment {
941 login,
942 body,
943 created_at: at,
944 }
945 }
946
947 #[test]
948 fn automated_review_comment_counts_as_approval_without_formal_reviews() {
949 assert!(!latest_review_approves(&[]));
950 let comments = [review_comment(
951 "orch-bot",
952 "## Automated Review \u{2014} Approve\n\nLooks good.",
953 "2026-01-02T00:00:00Z",
954 )];
955 assert!(latest_automated_review_approves(&comments, "orch-bot"));
956 assert!(latest_automated_review_approves(&comments, "Orch-Bot"));
957 assert!(!latest_automated_review_approves(&comments, "mallory"));
958 assert!(!latest_automated_review_approves(&comments, ""));
959 let hyphen = [review_comment(
960 "orch-bot",
961 "## Automated Review - Approve\n\nok",
962 "2026-01-02T00:00:00Z",
963 )];
964 assert!(latest_automated_review_approves(&hyphen, "orch-bot"));
965 }
966
967 #[test]
968 fn later_automated_changes_requested_clears_comment_approval() {
969 let comments = [
970 review_comment(
971 "orch-bot",
972 "## Automated Review \u{2014} Approve\n\nok",
973 "2026-01-01T00:00:00Z",
974 ),
975 review_comment(
976 "mallory",
977 "## Automated Review \u{2014} Changes Requested\n\nnope",
978 "2026-01-03T00:00:00Z",
979 ),
980 review_comment(
981 "orch-bot",
982 "## Automated Review \u{2014} Changes Requested\n\nfix",
983 "2026-01-02T00:00:00Z",
984 ),
985 ];
986 assert!(!latest_automated_review_approves(&comments, "orch-bot"));
987 }
988
989 #[test]
992 fn is_github_issue_id_numeric() {
993 assert!(is_github_issue_id(&ExternalId("42".to_string())));
994 assert!(is_github_issue_id(&ExternalId("1".to_string())));
995 assert!(is_github_issue_id(&ExternalId("99999".to_string())));
996 }
997
998 #[test]
999 fn is_github_issue_id_rejects_internal_prefix() {
1000 assert!(!is_github_issue_id(&ExternalId(
1001 "internal:63857".to_string()
1002 )));
1003 assert!(!is_github_issue_id(&ExternalId("internal:1".to_string())));
1004 }
1005
1006 #[test]
1007 fn is_github_issue_id_rejects_empty_and_non_numeric() {
1008 assert!(!is_github_issue_id(&ExternalId(String::new())));
1009 assert!(!is_github_issue_id(&ExternalId("abc".to_string())));
1010 assert!(!is_github_issue_id(&ExternalId("1.0".to_string())));
1011 }
1012
1013 #[test]
1016 fn allowed_associations_are_owner_member_and_collaborator() {
1017 assert_eq!(ALLOWED_ASSOCIATIONS, &["OWNER", "MEMBER", "COLLABORATOR"]);
1018 }
1019
1020 #[test]
1021 fn is_trusted_author_owner() {
1022 let issue = GitHubIssue {
1023 number: 1,
1024 id: 0,
1025 title: "t".to_string(),
1026 body: None,
1027 state: "open".to_string(),
1028 labels: vec![],
1029 user: GitHubUser {
1030 login: "u".to_string(),
1031 },
1032 created_at: String::new(),
1033 updated_at: String::new(),
1034 html_url: String::new(),
1035 node_id: None,
1036 pull_request: None,
1037 state_reason: None,
1038 author_association: Some("OWNER".to_string()),
1039 };
1040 assert!(is_trusted_author(&issue));
1041 }
1042
1043 #[test]
1044 fn is_trusted_author_collaborator() {
1045 let issue = GitHubIssue {
1046 number: 1,
1047 id: 0,
1048 title: "t".to_string(),
1049 body: None,
1050 state: "open".to_string(),
1051 labels: vec![],
1052 user: GitHubUser {
1053 login: "u".to_string(),
1054 },
1055 created_at: String::new(),
1056 updated_at: String::new(),
1057 html_url: String::new(),
1058 node_id: None,
1059 pull_request: None,
1060 state_reason: None,
1061 author_association: Some("COLLABORATOR".to_string()),
1062 };
1063 assert!(is_trusted_author(&issue));
1064 }
1065
1066 #[test]
1067 fn is_not_trusted_author_contributor() {
1068 let issue = GitHubIssue {
1069 number: 1,
1070 id: 0,
1071 title: "t".to_string(),
1072 body: None,
1073 state: "open".to_string(),
1074 labels: vec![],
1075 user: GitHubUser {
1076 login: "u".to_string(),
1077 },
1078 created_at: String::new(),
1079 updated_at: String::new(),
1080 html_url: String::new(),
1081 node_id: None,
1082 pull_request: None,
1083 state_reason: None,
1084 author_association: Some("CONTRIBUTOR".to_string()),
1085 };
1086 assert!(!is_trusted_author(&issue));
1087 }
1088
1089 #[test]
1090 fn is_trusted_author_member() {
1091 let issue = GitHubIssue {
1092 number: 1,
1093 id: 0,
1094 title: "t".to_string(),
1095 body: None,
1096 state: "open".to_string(),
1097 labels: vec![],
1098 user: GitHubUser {
1099 login: "u".to_string(),
1100 },
1101 created_at: String::new(),
1102 updated_at: String::new(),
1103 html_url: String::new(),
1104 node_id: None,
1105 pull_request: None,
1106 state_reason: None,
1107 author_association: Some("MEMBER".to_string()),
1108 };
1109 assert!(is_trusted_author(&issue));
1110 }
1111
1112 #[test]
1113 fn is_trusted_comment_author_direct() {
1114 let c = GitHubComment {
1115 id: 1,
1116 body: "hi".to_string(),
1117 user: GitHubUser {
1118 login: "u".to_string(),
1119 },
1120 created_at: String::new(),
1121 updated_at: None,
1122 html_url: None,
1123 issue_url: None,
1124 author_association: Some("OWNER".to_string()),
1125 };
1126 assert!(is_trusted_comment_author(&c));
1127 }
1128
1129 #[test]
1130 fn is_trusted_comment_author_member() {
1131 let c = GitHubComment {
1132 id: 1,
1133 body: "hi".to_string(),
1134 user: GitHubUser {
1135 login: "u".to_string(),
1136 },
1137 created_at: String::new(),
1138 updated_at: None,
1139 html_url: None,
1140 issue_url: None,
1141 author_association: Some("MEMBER".to_string()),
1142 };
1143 assert!(is_trusted_comment_author(&c));
1144 }
1145
1146 fn comment_json(id: u64, login: &str, association: &str) -> serde_json::Value {
1147 serde_json::json!({
1148 "id": id,
1149 "body": format!("from {login}"),
1150 "user": { "login": login },
1151 "created_at": "2026-01-01T00:00:00Z",
1152 "issue_url": "https://api.github.com/repos/o/r/issues/1",
1153 "author_association": association,
1154 })
1155 }
1156
1157 #[test]
1158 fn trusted_mentions_drops_contributor_on_owner_issue() {
1159 let comment = GitHubComment {
1160 id: 42,
1161 body: "@orch please look".to_string(),
1162 user: GitHubUser {
1163 login: "stranger".to_string(),
1164 },
1165 created_at: "2026-01-01T00:00:00Z".to_string(),
1166 updated_at: None,
1167 html_url: None,
1168 issue_url: Some("https://api.github.com/repos/o/r/issues/1".to_string()),
1169 author_association: Some("CONTRIBUTOR".to_string()),
1170 };
1171 let issue = GitHubIssue {
1172 number: 1,
1173 id: 0,
1174 title: "t".to_string(),
1175 body: None,
1176 state: "open".to_string(),
1177 labels: vec![],
1178 user: GitHubUser {
1179 login: "owner".to_string(),
1180 },
1181 created_at: String::new(),
1182 updated_at: String::new(),
1183 html_url: String::new(),
1184 node_id: None,
1185 pull_request: None,
1186 state_reason: None,
1187 author_association: Some("OWNER".to_string()),
1188 };
1189 assert!(is_trusted_author(&issue));
1190 assert!(!is_trusted_comment_author(&comment));
1191 let mentions = trusted_mentions([comment]);
1192 assert!(mentions.is_empty());
1193 }
1194
1195 #[expect(
1196 clippy::await_holding_lock,
1197 reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
1198 )]
1199 #[tokio::test]
1200 async fn get_mentions_and_list_issue_comments_exclude_contributor_on_owner_issue() {
1201 let _env = token::ENV_LOCK.lock().unwrap();
1202 let prev = env::var_os("GH_TOKEN");
1203 unsafe { env::set_var("GH_TOKEN", "test_token") };
1205
1206 let server = MockServer::start().await;
1207 let comments = serde_json::json!([
1208 comment_json(42, "stranger", "CONTRIBUTOR"),
1209 comment_json(43, "owner", "OWNER"),
1210 ]);
1211 Mock::given(method("GET"))
1212 .and(path("/repos/o/r/issues/comments"))
1213 .respond_with(ResponseTemplate::new(200).set_body_json(comments.clone()))
1214 .expect(1)
1215 .mount(&server)
1216 .await;
1217 Mock::given(method("GET"))
1218 .and(path("/repos/o/r/issues/1/comments"))
1219 .respond_with(ResponseTemplate::new(200).set_body_json(comments))
1220 .expect(1)
1221 .mount(&server)
1222 .await;
1223 Mock::given(method("GET"))
1224 .and(path("/repos/o/r/issues/1"))
1225 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1226 "number": 1,
1227 "id": 1,
1228 "title": "t",
1229 "state": "open",
1230 "labels": [],
1231 "user": { "login": "owner" },
1232 "created_at": "2026-01-01T00:00:00Z",
1233 "updated_at": "2026-01-01T00:00:00Z",
1234 "html_url": "https://github.com/o/r/issues/1",
1235 "author_association": "OWNER",
1236 })))
1237 .expect(0)
1238 .mount(&server)
1239 .await;
1240
1241 let backend = GitHubBackend::for_test("o/r", GhHttp::for_test(server.uri(), None));
1242 let mentions = backend
1243 .get_mentions("2026-01-01T00:00:00Z")
1244 .await
1245 .expect("get_mentions");
1246 let listed = backend
1247 .list_issue_comments(&ExternalId("1".to_string()))
1248 .await
1249 .expect("list_issue_comments");
1250
1251 match prev {
1252 Some(value) => unsafe { env::set_var("GH_TOKEN", value) },
1254 None => unsafe { env::remove_var("GH_TOKEN") },
1256 }
1257
1258 assert_eq!(mentions.len(), 1);
1259 assert_eq!(mentions[0].id, "43");
1260 assert_eq!(mentions[0].author, "owner");
1261 assert_eq!(listed.len(), 1);
1262 assert_eq!(listed[0].id, "43");
1263 assert_eq!(listed[0].author, "owner");
1264 }
1265
1266 fn make_test_issue(labels: &[&str]) -> GitHubIssue {
1267 GitHubIssue {
1268 number: 1,
1269 id: 0,
1270 title: "t".to_string(),
1271 body: None,
1272 state: "open".to_string(),
1273 labels: labels
1274 .iter()
1275 .map(|label| GitHubLabel {
1276 name: (*label).to_string(),
1277 color: None,
1278 })
1279 .collect(),
1280 user: GitHubUser {
1281 login: "u".to_string(),
1282 },
1283 created_at: String::new(),
1284 updated_at: String::new(),
1285 html_url: String::new(),
1286 node_id: None,
1287 pull_request: None,
1288 state_reason: None,
1289 author_association: Some("OWNER".to_string()),
1290 }
1291 }
1292
1293 #[test]
1295 fn classify_active_open_issue_excludes_status_done() {
1296 assert!(classify_active_open_issue(make_test_issue(&["status:done"])).is_none());
1297 assert!(
1298 classify_active_open_issue(make_test_issue(&["status:routed", "status:done"]))
1299 .is_none()
1300 );
1301 }
1302
1303 #[test]
1304 fn classify_active_open_issue_preserves_unlabeled_and_new_as_none_status() {
1305 let unlabeled = classify_active_open_issue(make_test_issue(&[]))
1306 .expect("unlabeled issue should still be ingested");
1307 assert_eq!(unlabeled.1, None);
1308
1309 let status_new = classify_active_open_issue(make_test_issue(&["status:new"]))
1310 .expect("status:new issue should still be ingested");
1311 assert_eq!(status_new.1, None);
1312 }
1313
1314 #[test]
1315 fn classify_active_open_issue_maps_active_status() {
1316 let routed = classify_active_open_issue(make_test_issue(&["status:routed"]))
1317 .expect("active status issue should be ingested");
1318 assert_eq!(routed.1, Some(Status::Routed));
1319 }
1320}