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