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