1use super::{ExternalBackend, ExternalId, ExternalTask, Mention, Status};
7use crate::github::http::{status_label_color, GhHttp};
8use crate::github::projects::label_status::LabelSyncConfig;
9use crate::github::projects::ProjectSync;
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 .map(|a| ALLOWED_ASSOCIATIONS.contains(&a))
28 .unwrap_or(false) }
30
31fn is_trusted_comment_author(c: &crate::github::types::GitHubComment) -> bool {
32 c.author_association
33 .as_deref()
34 .map(|a| ALLOWED_ASSOCIATIONS.contains(&a))
35 .unwrap_or(false)
36}
37
38fn trusted_mentions(
39 comments: impl IntoIterator<Item = crate::github::types::GitHubComment>,
40) -> Vec<Mention> {
41 comments
42 .into_iter()
43 .filter_map(|c| {
44 if !is_trusted_comment_author(&c) {
45 tracing::debug!(
46 comment_id = c.id,
47 author = %c.user.login,
48 "ignoring comment from untrusted author"
49 );
50 return None;
51 }
52 Some(Mention {
53 id: c.id.to_string(),
54 body: c.body,
55 author: c.user.login,
56 created_at: c.created_at,
57 issue_url: c.issue_url,
58 })
59 })
60 .collect()
61}
62
63fn active_status_from_label(label: &str) -> Option<Status> {
64 match label {
65 "status:routed" => Some(Status::Routed),
66 "status:in_progress" => Some(Status::InProgress),
67 "status:needs_review" => Some(Status::NeedsReview),
68 "status:in_review" => Some(Status::InReview),
69 "status:blocked" => Some(Status::Blocked),
70 "status:waiting" => Some(Status::Waiting),
71 _ => None,
72 }
73}
74
75fn classify_active_open_issue(
76 issue: crate::github::types::GitHubIssue,
77) -> Option<(ExternalTask, Option<Status>)> {
78 if issue.labels.iter().any(|l| l.name == "status:done") {
80 return None;
81 }
82
83 let status = issue
84 .labels
85 .iter()
86 .find_map(|l| active_status_from_label(&l.name));
87 let task = ExternalTask {
88 id: ExternalId(issue.number.to_string()),
89 title: issue.title,
90 body: issue.body.unwrap_or_default(),
91 state: issue.state,
92 labels: issue.labels.into_iter().map(|l| l.name).collect(),
93 author: issue.user.login,
94 created_at: issue.created_at,
95 updated_at: issue.updated_at,
96 url: issue.html_url,
97 };
98 Some((task, status))
99}
100
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) -> &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 project = match ProjectSync::from_config() {
210 Some(p) => p,
211 None => return Ok(None),
212 };
213
214 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
216
217 let node_id = issue
219 .node_id
220 .as_deref()
221 .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?
222 .to_string();
223
224 self.sync_project_status(&project, &node_id, &status)
226 .await
227 .map_err(|e| anyhow::anyhow!("project board sync failed: {e}"))?;
228
229 Ok(Some(node_id))
230 }
231
232 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
233 LabelSyncConfig::from_config()?
235 .reconcile(&GhHttp::owner()?, &self.repo)
236 .await
237 .map(|_| ())
238 }
239
240 async fn sync_estimate_to_project(&self, id: &ExternalId, estimate: u8) -> anyhow::Result<()> {
241 let project = match ProjectSync::from_config() {
242 Some(p) => p,
243 None => return Ok(()),
244 };
245
246 let project = if project.estimate_field_id().is_none() {
250 match ProjectSync::discover_fields(project.project_id()).await {
251 Ok(discovered) => {
252 if discovered.estimate_field_id().is_some() {
253 if let Err(e) =
254 crate::github::projects::write_project_config(&discovered).await
255 {
256 tracing::warn!(
257 err = %e,
258 "failed to persist discovered estimate field ID to config"
259 );
260 } else {
261 tracing::info!(
262 "auto-discovered and persisted project_estimate_field_id to config"
263 );
264 }
265 discovered
266 } else {
267 tracing::debug!(
268 "project has no 'Estimate' number field — skipping estimate sync"
269 );
270 return Ok(());
271 }
272 }
273 Err(e) => {
274 tracing::debug!(
275 err = %e,
276 "estimate field auto-discovery failed — skipping estimate sync"
277 );
278 return Ok(());
279 }
280 }
281 } else {
282 project
283 };
284
285 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
287 let node_id = issue
288 .node_id
289 .as_deref()
290 .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?;
291
292 project
293 .sync_item_estimate(node_id, estimate)
294 .await
295 .map_err(|e| anyhow::anyhow!("project estimate sync failed: {e}"))?;
296
297 Ok(())
298 }
299
300 async fn get_project_item_estimates(
301 &self,
302 issue_node_ids: &[String],
303 ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
304 let project = match ProjectSync::from_config() {
305 Some(p) => p,
306 None => return Ok(std::collections::HashMap::new()),
307 };
308
309 if issue_node_ids.is_empty() {
310 return Ok(std::collections::HashMap::new());
311 }
312
313 project
314 .get_estimates_for_issues(issue_node_ids)
315 .await
316 .map_err(|e| anyhow::anyhow!("project estimate fetch failed: {e}"))
317 }
318
319 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
321 if !is_github_issue_id(id) {
323 return Ok(());
324 }
325
326 let label = status.as_label();
328
329 if let Err(e) = self.ensure_status_label(label).await {
330 tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
331 }
332
333 const MAX_RETRIES: u32 = 3;
334 let mut last_err =
335 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
336
337 let mut issue_node_id: Option<String> = None;
338
339 for attempt in 0..MAX_RETRIES {
340 if attempt > 0 {
341 tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
342 .await;
343 tracing::debug!(attempt, label, "retrying update_status");
344 }
345
346 let issue = match self.gh.get_issue(&self.repo, &id.0).await {
347 Ok(i) => i,
348 Err(e) => {
349 last_err = e;
350 continue;
351 }
352 };
353
354 if issue_node_id.is_none() {
356 issue_node_id = issue.node_id.clone();
357 }
358
359 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
360
361 let mut remove_failed = false;
362 for old in labels
363 .iter()
364 .filter(|l| l.starts_with("status:") && l.as_str() != label)
365 {
366 if let Err(e) = self.remove_label(id, old).await {
367 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
368 last_err = e;
369 remove_failed = true;
370 break;
371 }
372 }
373 if remove_failed {
374 continue;
375 }
376
377 match self.set_labels(id, &[label.to_string()]).await {
378 Ok(()) => {
379 if let Some(ref node_id) = issue_node_id {
381 if let Some(project) = ProjectSync::from_config() {
382 if let Err(e) =
383 self.sync_project_status(&project, node_id, &status).await
384 {
385 tracing::warn!(
386 task_id = id.0,
387 err = %e,
388 "project board sync failed (non-fatal)"
389 );
390 }
391 }
392 }
393
394 if status == Status::Done {
396 let auto_close = crate::config::get("workflow.auto_close")
397 .map(|v| v == "true")
398 .unwrap_or(true);
399 if auto_close {
400 if let Err(e) = self.gh.close_issue(&self.repo, &id.0).await {
401 tracing::warn!(
402 task_id = id.0,
403 err = %e,
404 "auto-close issue failed (non-fatal)"
405 );
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<crate::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 ) {
600 if let Some(parent_node_id) = parent_issue.node_id {
601 if let Err(e) = self.gh.add_sub_issue(&parent_node_id, &child_node_id).await {
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 }
611
612 Ok(child_id)
613 }
614
615 async fn has_open_issue_with_title(&self, title: &str, label: &str) -> anyhow::Result<bool> {
616 let open_issues = if label.is_empty() {
618 self.gh.list_all_open_issues(&self.repo).await?
619 } else {
620 self.gh.list_issues(&self.repo, label).await?
621 };
622 if open_issues.iter().any(|i| i.title == title) {
623 return Ok(true);
624 }
625 let since = (chrono::Utc::now() - chrono::Duration::hours(24))
628 .format("%Y-%m-%dT%H:%M:%SZ")
629 .to_string();
630 let closed = if label.is_empty() {
631 self.gh.list_closed_issues_since(&self.repo, &since).await?
632 } else {
633 self.gh
634 .list_issues_closed_since(&self.repo, label, &since)
635 .await?
636 };
637 Ok(closed.iter().any(|i| i.title == title))
638 }
639
640 async fn health_check(&self) -> anyhow::Result<()> {
641 self.gh.auth_status().await
642 }
643
644 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
645 self.gh.is_pr_merged(&self.repo, branch).await
646 }
647
648 async fn batch_is_pr_merged(
649 &self,
650 branches: &[String],
651 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
652 self.gh
653 .batch_is_pr_merged_by_branch(&self.repo, branches)
654 .await
655 }
656
657 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
658 self.gh.get_whoami().await.map(Some)
659 }
660
661 async fn get_mentions(&self, since: &str) -> anyhow::Result<Vec<Mention>> {
662 let comments = self.gh.get_mentions(&self.repo, since).await?;
663 Ok(trusted_mentions(comments))
664 }
665
666 async fn list_issue_comments(&self, id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
667 let issue_number: u64 =
668 id.0.parse()
669 .map_err(|_| anyhow::anyhow!("invalid issue number: {}", id.0))?;
670 let comments = self.gh.get_issue_comments(&self.repo, issue_number).await?;
671 Ok(trusted_mentions(comments))
672 }
673
674 async fn acknowledge_issue(&self, id: &ExternalId) -> anyhow::Result<()> {
675 self.gh.add_reaction(&self.repo, &id.0, "eyes").await
676 }
677
678 async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
679 self.gh
680 .add_comment_reaction(&self.repo, comment_id, "eyes")
681 .await
682 }
683
684 async fn blocked_by(
685 &self,
686 repo: &str,
687 issue_number: i64,
688 ) -> anyhow::Result<crate::backends::BlockedByFetch> {
689 if issue_number <= 0 {
690 return Ok(crate::backends::BlockedByFetch::Unavailable);
691 }
692 self.gh.list_blocked_by(repo, issue_number as u64).await
693 }
694
695 async fn issue_close_state(
696 &self,
697 repo: &str,
698 issue_number: i64,
699 ) -> anyhow::Result<Option<(String, Option<String>)>> {
700 if issue_number <= 0 {
701 return Ok(None);
702 }
703 match self.gh.get_issue(repo, &issue_number.to_string()).await {
704 Ok(issue) => Ok(Some((issue.state, issue.state_reason))),
705 Err(err) if is_missing_issue(&err) => Ok(None),
706 Err(err) => Err(err),
707 }
708 }
709
710 async fn post_comment_in_repo(
711 &self,
712 repo: &str,
713 id: &ExternalId,
714 body: &str,
715 ) -> anyhow::Result<()> {
716 if !is_github_issue_id(id) {
717 return Ok(());
718 }
719 self.gh.add_comment(repo, &id.0, body).await
720 }
721
722 async fn pull_request_completion(
723 &self,
724 repo: &str,
725 pr_number: i64,
726 ) -> anyhow::Result<Option<crate::backends::PullRequestCompletion>> {
727 if pr_number <= 0 {
728 return Ok(None);
729 }
730 let pr = match self.gh.get_pr(repo, pr_number as u64).await {
731 Ok(pr) => pr,
732 Err(err) if is_missing_issue(&err) => return Ok(None),
733 Err(err) => return Err(err),
734 };
735 let default_branch = self.gh.get_default_branch(repo).await.unwrap_or_default();
736 let app_login = crate::github::app::bot_login().await;
737 let reviews = match self.gh.get_pr_reviews(repo, pr_number as u64).await {
738 Ok(reviews) => {
739 crate::github::app::reviews_for_gate(&reviews, &pr.head.sha, app_login.as_deref())
740 }
741 Err(err) => {
742 tracing::warn!(repo, pr_number, err = %err, "pull request reviews unreadable");
743 Vec::new()
744 }
745 };
746 let comments = match self.gh.list_comments(repo, &pr_number.to_string()).await {
747 Ok(comments) => comments,
748 Err(err) => {
749 tracing::warn!(repo, pr_number, err = %err, "pull request comments unreadable");
750 Vec::new()
751 }
752 };
753 let reviewer = match self.gh.get_whoami().await {
754 Ok(login) => Some(login),
755 Err(err) => {
756 tracing::warn!(repo, pr_number, err = %err, "reviewer login unreadable");
757 None
758 }
759 };
760 let comment_refs: Vec<AutomatedReviewComment<'_>> = comments
761 .iter()
762 .map(|comment| AutomatedReviewComment {
763 login: &comment.user.login,
764 body: &comment.body,
765 created_at: &comment.created_at,
766 })
767 .collect();
768 let comment_approved = reviewer
769 .iter()
770 .chain(app_login.iter())
771 .any(|login| latest_automated_review_approves(&comment_refs, login));
772 let (comment_approved, _) = crate::github::app::effective_comment_flags(
775 &reviews,
776 &pr.head.sha,
777 app_login.as_deref(),
778 comment_approved,
779 false,
780 );
781 let merged = pr.merged.unwrap_or(false);
782 Ok(Some(crate::backends::PullRequestCompletion {
783 merged,
784 base_ref: pr.base.ref_,
785 default_branch,
786 approved: latest_review_approves(&reviews) || comment_approved,
787 closed: pr.state.eq_ignore_ascii_case("closed"),
788 }))
789 }
790
791 async fn add_dependency(
792 &self,
793 issue_number: i64,
794 blocking_repo: &str,
795 blocking_number: i64,
796 ) -> anyhow::Result<()> {
797 let blocker = self
798 .gh
799 .get_issue(blocking_repo, &blocking_number.to_string())
800 .await?;
801 if blocker.id == 0 {
802 anyhow::bail!("blocking issue {blocking_repo}#{blocking_number} has no database id");
803 }
804 self.gh
805 .add_blocked_by(&self.repo, issue_number as u64, blocker.id as i64)
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("".to_string())));
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: "".to_string(),
1019 updated_at: "".to_string(),
1020 html_url: "".to_string(),
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: "".to_string(),
1042 updated_at: "".to_string(),
1043 html_url: "".to_string(),
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: "".to_string(),
1065 updated_at: "".to_string(),
1066 html_url: "".to_string(),
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: "".to_string(),
1088 updated_at: "".to_string(),
1089 html_url: "".to_string(),
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: "".to_string(),
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: "".to_string(),
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: "".to_string(),
1171 updated_at: "".to_string(),
1172 html_url: "".to_string(),
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 #[allow(clippy::await_holding_lock)]
1185 #[tokio::test]
1186 async fn get_mentions_and_list_issue_comments_exclude_contributor_on_owner_issue() {
1187 use crate::github::http::GhHttp;
1188 use wiremock::matchers::{method, path};
1189 use wiremock::{Mock, MockServer, ResponseTemplate};
1190
1191 let _env = crate::github::token::ENV_LOCK.lock().unwrap();
1192 let prev = std::env::var_os("GH_TOKEN");
1193 std::env::set_var("GH_TOKEN", "test_token");
1194
1195 let server = MockServer::start().await;
1196 let comments = serde_json::json!([
1197 comment_json(42, "stranger", "CONTRIBUTOR"),
1198 comment_json(43, "owner", "OWNER"),
1199 ]);
1200 Mock::given(method("GET"))
1201 .and(path("/repos/o/r/issues/comments"))
1202 .respond_with(ResponseTemplate::new(200).set_body_json(comments.clone()))
1203 .expect(1)
1204 .mount(&server)
1205 .await;
1206 Mock::given(method("GET"))
1207 .and(path("/repos/o/r/issues/1/comments"))
1208 .respond_with(ResponseTemplate::new(200).set_body_json(comments))
1209 .expect(1)
1210 .mount(&server)
1211 .await;
1212 Mock::given(method("GET"))
1213 .and(path("/repos/o/r/issues/1"))
1214 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1215 "number": 1,
1216 "id": 1,
1217 "title": "t",
1218 "state": "open",
1219 "labels": [],
1220 "user": { "login": "owner" },
1221 "created_at": "2026-01-01T00:00:00Z",
1222 "updated_at": "2026-01-01T00:00:00Z",
1223 "html_url": "https://github.com/o/r/issues/1",
1224 "author_association": "OWNER",
1225 })))
1226 .expect(0)
1227 .mount(&server)
1228 .await;
1229
1230 let backend = GitHubBackend::for_test("o/r", GhHttp::for_test(server.uri(), None));
1231 let mentions = backend
1232 .get_mentions("2026-01-01T00:00:00Z")
1233 .await
1234 .expect("get_mentions");
1235 let listed = backend
1236 .list_issue_comments(&ExternalId("1".to_string()))
1237 .await
1238 .expect("list_issue_comments");
1239
1240 match prev {
1241 Some(value) => std::env::set_var("GH_TOKEN", value),
1242 None => std::env::remove_var("GH_TOKEN"),
1243 }
1244
1245 assert_eq!(mentions.len(), 1);
1246 assert_eq!(mentions[0].id, "43");
1247 assert_eq!(mentions[0].author, "owner");
1248 assert_eq!(listed.len(), 1);
1249 assert_eq!(listed[0].id, "43");
1250 assert_eq!(listed[0].author, "owner");
1251 }
1252
1253 fn make_test_issue(labels: &[&str]) -> GitHubIssue {
1254 GitHubIssue {
1255 number: 1,
1256 id: 0,
1257 title: "t".to_string(),
1258 body: None,
1259 state: "open".to_string(),
1260 labels: labels
1261 .iter()
1262 .map(|label| crate::github::types::GitHubLabel {
1263 name: (*label).to_string(),
1264 color: None,
1265 })
1266 .collect(),
1267 user: GitHubUser {
1268 login: "u".to_string(),
1269 },
1270 created_at: "".to_string(),
1271 updated_at: "".to_string(),
1272 html_url: "".to_string(),
1273 node_id: None,
1274 pull_request: None,
1275 state_reason: None,
1276 author_association: Some("OWNER".to_string()),
1277 }
1278 }
1279
1280 #[test]
1282 fn classify_active_open_issue_excludes_status_done() {
1283 assert!(classify_active_open_issue(make_test_issue(&["status:done"])).is_none());
1284 assert!(
1285 classify_active_open_issue(make_test_issue(&["status:routed", "status:done"]))
1286 .is_none()
1287 );
1288 }
1289
1290 #[test]
1291 fn classify_active_open_issue_preserves_unlabeled_and_new_as_none_status() {
1292 let unlabeled = classify_active_open_issue(make_test_issue(&[]))
1293 .expect("unlabeled issue should still be ingested");
1294 assert_eq!(unlabeled.1, None);
1295
1296 let status_new = classify_active_open_issue(make_test_issue(&["status:new"]))
1297 .expect("status:new issue should still be ingested");
1298 assert_eq!(status_new.1, None);
1299 }
1300
1301 #[test]
1302 fn classify_active_open_issue_maps_active_status() {
1303 let routed = classify_active_open_issue(make_test_issue(&["status:routed"]))
1304 .expect("active status issue should be ingested");
1305 assert_eq!(routed.1, Some(Status::Routed));
1306 }
1307}