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", "COLLABORATOR"];
19
20fn is_trusted_author(issue: &crate::github::types::GitHubIssue) -> bool {
22 issue
23 .author_association
24 .as_deref()
25 .map(|a| ALLOWED_ASSOCIATIONS.contains(&a))
26 .unwrap_or(false) }
28
29fn is_trusted_comment_author(c: &crate::github::types::GitHubComment) -> bool {
33 c.author_association
34 .as_deref()
35 .map(|a| ALLOWED_ASSOCIATIONS.contains(&a))
36 .unwrap_or(false)
37}
38
39fn active_status_from_label(label: &str) -> Option<Status> {
40 match label {
41 "status:routed" => Some(Status::Routed),
42 "status:in_progress" => Some(Status::InProgress),
43 "status:needs_review" => Some(Status::NeedsReview),
44 "status:in_review" => Some(Status::InReview),
45 "status:blocked" => Some(Status::Blocked),
46 "status:waiting" => Some(Status::Waiting),
47 _ => None,
48 }
49}
50
51fn classify_active_open_issue(
52 issue: crate::github::types::GitHubIssue,
53) -> Option<(ExternalTask, Option<Status>)> {
54 if issue.labels.iter().any(|l| l.name == "status:done") {
56 return None;
57 }
58
59 let status = issue
60 .labels
61 .iter()
62 .find_map(|l| active_status_from_label(&l.name));
63 let task = ExternalTask {
64 id: ExternalId(issue.number.to_string()),
65 title: issue.title,
66 body: issue.body.unwrap_or_default(),
67 state: issue.state,
68 labels: issue.labels.into_iter().map(|l| l.name).collect(),
69 author: issue.user.login,
70 created_at: issue.created_at,
71 updated_at: issue.updated_at,
72 url: issue.html_url,
73 };
74 Some((task, status))
75}
76
77pub struct GitHubBackend {
78 repo: String,
79 gh: GhHttp,
80}
81
82fn is_github_issue_id(id: &ExternalId) -> bool {
88 id.0.parse::<u64>().is_ok()
89}
90
91impl GitHubBackend {
92 pub fn new(repo: String) -> anyhow::Result<Self> {
93 Ok(Self {
94 repo,
95 gh: GhHttp::new()?,
96 })
97 }
98
99 async fn sync_project_status(
100 &self,
101 project: &ProjectSync,
102 node_id: &str,
103 status: &Status,
104 ) -> anyhow::Result<()> {
105 if LabelSyncConfig::from_config()?.manages(&self.repo, project.project_id()) {
106 project.add_item(node_id).await?;
109 Ok(())
110 } else {
111 project.sync_item_status(node_id, status).await
112 }
113 }
114}
115
116#[async_trait]
117impl ExternalBackend for GitHubBackend {
118 fn name(&self) -> &str {
119 "github"
120 }
121
122 async fn create_task(
123 &self,
124 title: &str,
125 body: &str,
126 labels: &[String],
127 ) -> anyhow::Result<ExternalId> {
128 let issue = self
129 .gh
130 .create_issue(&self.repo, title, body, labels)
131 .await?;
132 Ok(ExternalId(issue.number.to_string()))
133 }
134
135 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
136 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
137 Ok(ExternalTask {
138 id: id.clone(),
139 title: issue.title,
140 body: issue.body.unwrap_or_default(),
141 state: issue.state,
142 labels: issue.labels.into_iter().map(|l| l.name).collect(),
143 author: issue.user.login,
144 created_at: issue.created_at,
145 updated_at: issue.updated_at,
146 url: issue.html_url,
147 })
148 }
149
150 async fn ensure_status_label(&self, label: &str) -> anyhow::Result<()> {
155 let status_name = &label["status:".len()..];
156 self.gh
157 .ensure_label(
158 &self.repo,
159 label,
160 status_label_color(label),
161 &format!("Task status: {status_name}"),
162 )
163 .await
164 }
165
166 async fn sync_to_project(
172 &self,
173 id: &ExternalId,
174 status: Status,
175 ) -> anyhow::Result<Option<String>> {
176 let project = match ProjectSync::from_config() {
178 Some(p) => p,
179 None => return Ok(None),
180 };
181
182 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
184
185 let node_id = issue
187 .node_id
188 .as_deref()
189 .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?
190 .to_string();
191
192 self.sync_project_status(&project, &node_id, &status)
194 .await
195 .map_err(|e| anyhow::anyhow!("project board sync failed: {e}"))?;
196
197 Ok(Some(node_id))
198 }
199
200 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
201 LabelSyncConfig::from_config()?
202 .reconcile(&self.gh, &self.repo)
203 .await
204 .map(|_| ())
205 }
206
207 async fn sync_estimate_to_project(&self, id: &ExternalId, estimate: u8) -> anyhow::Result<()> {
208 let project = match ProjectSync::from_config() {
209 Some(p) => p,
210 None => return Ok(()),
211 };
212
213 let project = if project.estimate_field_id().is_none() {
217 match ProjectSync::discover_fields(project.project_id()).await {
218 Ok(discovered) => {
219 if discovered.estimate_field_id().is_some() {
220 if let Err(e) =
221 crate::github::projects::write_project_config(&discovered).await
222 {
223 tracing::warn!(
224 err = %e,
225 "failed to persist discovered estimate field ID to config"
226 );
227 } else {
228 tracing::info!(
229 "auto-discovered and persisted project_estimate_field_id to config"
230 );
231 }
232 discovered
233 } else {
234 tracing::debug!(
235 "project has no 'Estimate' number field — skipping estimate sync"
236 );
237 return Ok(());
238 }
239 }
240 Err(e) => {
241 tracing::debug!(
242 err = %e,
243 "estimate field auto-discovery failed — skipping estimate sync"
244 );
245 return Ok(());
246 }
247 }
248 } else {
249 project
250 };
251
252 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
254 let node_id = issue
255 .node_id
256 .as_deref()
257 .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?;
258
259 project
260 .sync_item_estimate(node_id, estimate)
261 .await
262 .map_err(|e| anyhow::anyhow!("project estimate sync failed: {e}"))?;
263
264 Ok(())
265 }
266
267 async fn get_project_item_estimates(
268 &self,
269 issue_node_ids: &[String],
270 ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
271 let project = match ProjectSync::from_config() {
272 Some(p) => p,
273 None => return Ok(std::collections::HashMap::new()),
274 };
275
276 if issue_node_ids.is_empty() {
277 return Ok(std::collections::HashMap::new());
278 }
279
280 project
281 .get_estimates_for_issues(issue_node_ids)
282 .await
283 .map_err(|e| anyhow::anyhow!("project estimate fetch failed: {e}"))
284 }
285
286 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
288 if !is_github_issue_id(id) {
290 return Ok(());
291 }
292
293 let label = status.as_label();
295
296 if let Err(e) = self.ensure_status_label(label).await {
297 tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
298 }
299
300 const MAX_RETRIES: u32 = 3;
301 let mut last_err =
302 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
303
304 let mut issue_node_id: Option<String> = None;
305
306 for attempt in 0..MAX_RETRIES {
307 if attempt > 0 {
308 tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
309 .await;
310 tracing::debug!(attempt, label, "retrying update_status");
311 }
312
313 let issue = match self.gh.get_issue(&self.repo, &id.0).await {
314 Ok(i) => i,
315 Err(e) => {
316 last_err = e;
317 continue;
318 }
319 };
320
321 if issue_node_id.is_none() {
323 issue_node_id = issue.node_id.clone();
324 }
325
326 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
327
328 let mut remove_failed = false;
329 for old in labels
330 .iter()
331 .filter(|l| l.starts_with("status:") && l.as_str() != label)
332 {
333 if let Err(e) = self.remove_label(id, old).await {
334 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
335 last_err = e;
336 remove_failed = true;
337 break;
338 }
339 }
340 if remove_failed {
341 continue;
342 }
343
344 match self.set_labels(id, &[label.to_string()]).await {
345 Ok(()) => {
346 if let Some(ref node_id) = issue_node_id {
348 if let Some(project) = ProjectSync::from_config() {
349 if let Err(e) =
350 self.sync_project_status(&project, node_id, &status).await
351 {
352 tracing::warn!(
353 task_id = id.0,
354 err = %e,
355 "project board sync failed (non-fatal)"
356 );
357 }
358 }
359 }
360
361 if status == Status::Done {
363 let auto_close = crate::config::get("workflow.auto_close")
364 .map(|v| v == "true")
365 .unwrap_or(true);
366 if auto_close {
367 if let Err(e) = self.gh.close_issue(&self.repo, &id.0).await {
368 tracing::warn!(
369 task_id = id.0,
370 err = %e,
371 "auto-close issue failed (non-fatal)"
372 );
373 }
374 }
375 }
376
377 return Ok(());
378 }
379 Err(e) => {
380 tracing::warn!(label, err = %e, attempt, "set_labels failed");
381 last_err = e;
382 }
383 }
384 }
385
386 Err(last_err.context(format!(
387 "update_status({label}) failed after {MAX_RETRIES} attempts"
388 )))
389 }
390
391 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
392 let state = if status == Status::Done {
395 "all"
396 } else {
397 "open"
398 };
399 let issues = self
400 .gh
401 .list_issues_with_state(&self.repo, status.as_label(), state)
402 .await?;
403 Ok(issues
404 .into_iter()
405 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
408 id: ExternalId(issue.number.to_string()),
409 title: issue.title,
410 body: issue.body.unwrap_or_default(),
411 state: issue.state,
412 labels: issue.labels.into_iter().map(|l| l.name).collect(),
413 author: issue.user.login,
414 created_at: issue.created_at,
415 updated_at: issue.updated_at,
416 url: issue.html_url,
417 })
418 .collect())
419 }
420
421 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
426 let issues = self.gh.list_all_issues(&self.repo).await?;
427 Ok(issues
428 .into_iter()
429 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
432 id: ExternalId(issue.number.to_string()),
433 title: issue.title,
434 body: issue.body.unwrap_or_default(),
435 state: issue.state,
436 labels: issue.labels.into_iter().map(|l| l.name).collect(),
437 author: issue.user.login,
438 created_at: issue.created_at,
439 updated_at: issue.updated_at,
440 url: issue.html_url,
441 })
442 .collect())
443 }
444
445 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
446 let since = (Utc::now() - Duration::days(30)).to_rfc3339();
447 let closed = self.gh.list_closed_issues_since(&self.repo, &since).await?;
448
449 Ok(closed
450 .into_iter()
451 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
454 id: ExternalId(issue.number.to_string()),
455 title: issue.title,
456 body: issue.body.unwrap_or_default(),
457 state: issue.state,
458 labels: issue.labels.into_iter().map(|l| l.name).collect(),
459 author: issue.user.login,
460 created_at: issue.created_at,
461 updated_at: issue.updated_at,
462 url: issue.html_url,
463 })
464 .collect())
465 }
466
467 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
469 let issues = self.gh.list_all_open_issues(&self.repo, None).await?;
470 Ok(issues
471 .into_iter()
472 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .filter(|issue| {
475 let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
476 let has_status = labels.iter().any(|l| l.starts_with("status:"));
478 !has_status || labels.contains(&"status:new")
479 })
480 .map(|issue| ExternalTask {
481 id: ExternalId(issue.number.to_string()),
482 title: issue.title,
483 body: issue.body.unwrap_or_default(),
484 state: issue.state,
485 labels: issue.labels.into_iter().map(|l| l.name).collect(),
486 author: issue.user.login,
487 created_at: issue.created_at,
488 updated_at: issue.updated_at,
489 url: issue.html_url,
490 })
491 .collect())
492 }
493
494 async fn list_active_open_issues(
499 &self,
500 since: Option<&str>,
501 ) -> anyhow::Result<Vec<(ExternalTask, Option<crate::backends::Status>)>> {
502 let issues = self.gh.list_all_open_issues(&self.repo, since).await?;
503
504 Ok(issues
505 .into_iter()
506 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .filter_map(classify_active_open_issue)
509 .collect())
510 }
511
512 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
513 if !is_github_issue_id(id) {
515 return Ok(());
516 }
517 self.gh.add_comment(&self.repo, &id.0, body).await
518 }
519
520 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
522 if !is_github_issue_id(id) {
524 return Ok(());
525 }
526 self.gh.add_labels(&self.repo, &id.0, labels).await
527 }
528
529 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()> {
530 if !is_github_issue_id(id) {
532 return Ok(());
533 }
534 self.gh.remove_label(&self.repo, &id.0, label).await
535 }
536
537 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
538 let sub_issue_numbers = self.gh.get_sub_issues(&self.repo, &id.0).await?;
539 Ok(sub_issue_numbers
540 .into_iter()
541 .map(|n| ExternalId(n.to_string()))
542 .collect())
543 }
544
545 async fn create_sub_task(
546 &self,
547 parent_id: &ExternalId,
548 title: &str,
549 body: &str,
550 labels: &[String],
551 ) -> anyhow::Result<ExternalId> {
552 let mut all_labels = labels.to_vec();
554 all_labels.push(format!("parent:{}", parent_id.0));
555
556 let child_issue = self
558 .gh
559 .create_issue(&self.repo, title, body, &all_labels)
560 .await?;
561
562 let child_id = ExternalId(child_issue.number.to_string());
563
564 if let (Some(child_node_id), Ok(parent_issue)) = (
566 child_issue.node_id,
567 self.gh.get_issue(&self.repo, &parent_id.0).await,
568 ) {
569 if let Some(parent_node_id) = parent_issue.node_id {
570 if let Err(e) = self.gh.add_sub_issue(&parent_node_id, &child_node_id).await {
571 tracing::warn!(
572 parent = parent_id.0,
573 child = child_id.0,
574 err = %e,
575 "failed to add native sub-issue link (parent label still set)"
576 );
577 }
578 }
579 }
580
581 Ok(child_id)
582 }
583
584 async fn has_open_issue_with_title(&self, title: &str, label: &str) -> anyhow::Result<bool> {
585 let open_issues = if label.is_empty() {
587 self.gh.list_all_open_issues(&self.repo, None).await?
588 } else {
589 self.gh.list_issues(&self.repo, label).await?
590 };
591 if open_issues.iter().any(|i| i.title == title) {
592 return Ok(true);
593 }
594 let since = (chrono::Utc::now() - chrono::Duration::hours(24))
597 .format("%Y-%m-%dT%H:%M:%SZ")
598 .to_string();
599 let closed = if label.is_empty() {
600 self.gh.list_closed_issues_since(&self.repo, &since).await?
601 } else {
602 self.gh
603 .list_issues_closed_since(&self.repo, label, &since)
604 .await?
605 };
606 Ok(closed.iter().any(|i| i.title == title))
607 }
608
609 async fn health_check(&self) -> anyhow::Result<()> {
610 self.gh.auth_status().await
611 }
612
613 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
614 self.gh.is_pr_merged(&self.repo, branch).await
615 }
616
617 async fn batch_is_pr_merged(
618 &self,
619 branches: &[String],
620 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
621 self.gh
622 .batch_is_pr_merged_by_branch(&self.repo, branches)
623 .await
624 }
625
626 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
627 self.gh.get_whoami().await.map(Some)
628 }
629
630 async fn get_mentions(&self, since: &str) -> anyhow::Result<Vec<Mention>> {
631 let comments = self.gh.get_mentions(&self.repo, since).await?;
632 let mut out = Vec::new();
638 for c in comments.into_iter() {
639 let trusted = if is_trusted_comment_author(&c) {
645 true
646 } else if let Some(ref issue_url) = c.issue_url {
647 if let Some(n) = issue_url.rsplit('/').next() {
649 if let Ok(issue) = self.gh.get_issue(&self.repo, n).await {
650 is_trusted_author(&issue)
651 } else {
652 tracing::warn!(
653 repo = %self.repo,
654 issue_url = %issue_url,
655 comment_id = c.id,
656 author = %c.user.login,
657 "get_mentions: failed to fetch parent issue to check author trust, treating as untrusted — mention may be silently dropped"
658 );
659 false
660 }
661 } else {
662 false
663 }
664 } else {
665 false
666 };
667
668 if !trusted {
669 tracing::debug!(comment_id = c.id, author = %c.user.login, "ignoring mention from untrusted author");
670 continue;
671 }
672
673 out.push(Mention {
674 id: c.id.to_string(),
675 body: c.body,
676 author: c.user.login,
677 created_at: c.created_at,
678 issue_url: c.issue_url,
679 });
680 }
681
682 Ok(out)
683 }
684
685 async fn list_issue_comments(&self, id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
686 let issue_number: u64 =
687 id.0.parse()
688 .map_err(|_| anyhow::anyhow!("invalid issue number: {}", id.0))?;
689 let comments = self.gh.get_issue_comments(&self.repo, issue_number).await?;
690 let mut out = Vec::new();
692 for c in comments.into_iter() {
693 if is_trusted_comment_author(&c) {
695 out.push(Mention {
696 id: c.id.to_string(),
697 body: c.body,
698 author: c.user.login,
699 created_at: c.created_at,
700 issue_url: c.issue_url,
701 });
702 continue;
703 }
704
705 if let Ok(issue) = self.gh.get_issue(&self.repo, &id.0).await {
707 if is_trusted_author(&issue) {
708 out.push(Mention {
709 id: c.id.to_string(),
710 body: c.body,
711 author: c.user.login,
712 created_at: c.created_at,
713 issue_url: c.issue_url,
714 });
715 continue;
716 } else {
717 tracing::debug!(comment_id = c.id, author = %c.user.login, "ignoring issue comment from untrusted author");
718 continue;
719 }
720 } else {
721 tracing::debug!(
723 comment_id = c.id,
724 "failed to fetch parent issue, skipping comment"
725 );
726 continue;
727 }
728 }
729
730 Ok(out)
731 }
732
733 async fn acknowledge_issue(&self, id: &ExternalId) -> anyhow::Result<()> {
734 self.gh.add_reaction(&self.repo, &id.0, "eyes").await
735 }
736
737 async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
738 self.gh
739 .add_comment_reaction(&self.repo, comment_id, "eyes")
740 .await
741 }
742
743 async fn blocked_by(
744 &self,
745 repo: &str,
746 issue_number: i64,
747 ) -> anyhow::Result<crate::backends::BlockedByFetch> {
748 if issue_number <= 0 {
749 return Ok(crate::backends::BlockedByFetch::Unavailable);
750 }
751 self.gh.list_blocked_by(repo, issue_number as u64).await
752 }
753
754 async fn issue_close_state(
755 &self,
756 repo: &str,
757 issue_number: i64,
758 ) -> anyhow::Result<Option<(String, Option<String>)>> {
759 if issue_number <= 0 {
760 return Ok(None);
761 }
762 match self.gh.get_issue(repo, &issue_number.to_string()).await {
763 Ok(issue) => Ok(Some((issue.state, issue.state_reason))),
764 Err(err) if is_missing_issue(&err) => Ok(None),
765 Err(err) => Err(err),
766 }
767 }
768
769 async fn post_comment_in_repo(
770 &self,
771 repo: &str,
772 id: &ExternalId,
773 body: &str,
774 ) -> anyhow::Result<()> {
775 if !is_github_issue_id(id) {
776 return Ok(());
777 }
778 self.gh.add_comment(repo, &id.0, body).await
779 }
780
781 async fn pull_request_completion(
782 &self,
783 repo: &str,
784 pr_number: i64,
785 ) -> anyhow::Result<Option<crate::backends::PullRequestCompletion>> {
786 if pr_number <= 0 {
787 return Ok(None);
788 }
789 let pr = match self.gh.get_pr(repo, pr_number as u64).await {
790 Ok(pr) => pr,
791 Err(err) if is_missing_issue(&err) => return Ok(None),
792 Err(err) => return Err(err),
793 };
794 let default_branch = self.gh.get_default_branch(repo).await.unwrap_or_default();
795 let reviews = match self.gh.get_pr_reviews(repo, pr_number as u64).await {
796 Ok(reviews) => reviews,
797 Err(err) => {
798 tracing::warn!(repo, pr_number, err = %err, "pull request reviews unreadable");
799 Vec::new()
800 }
801 };
802 let comments = match self.gh.list_comments(repo, &pr_number.to_string()).await {
803 Ok(comments) => comments,
804 Err(err) => {
805 tracing::warn!(repo, pr_number, err = %err, "pull request comments unreadable");
806 Vec::new()
807 }
808 };
809 let reviewer = match self.gh.get_whoami().await {
810 Ok(login) => Some(login),
811 Err(err) => {
812 tracing::warn!(repo, pr_number, err = %err, "reviewer login unreadable");
813 None
814 }
815 };
816 let comment_refs: Vec<AutomatedReviewComment<'_>> = comments
817 .iter()
818 .map(|comment| AutomatedReviewComment {
819 login: &comment.user.login,
820 body: &comment.body,
821 created_at: &comment.created_at,
822 })
823 .collect();
824 let comment_approved = reviewer
825 .as_deref()
826 .is_some_and(|login| latest_automated_review_approves(&comment_refs, login));
827 let merged = pr.merged.unwrap_or(false);
828 Ok(Some(crate::backends::PullRequestCompletion {
829 merged,
830 base_ref: pr.base.ref_,
831 default_branch,
832 approved: latest_review_approves(&reviews) || comment_approved,
833 closed: pr.state.eq_ignore_ascii_case("closed"),
834 }))
835 }
836
837 async fn add_dependency(
838 &self,
839 issue_number: i64,
840 blocking_repo: &str,
841 blocking_number: i64,
842 ) -> anyhow::Result<()> {
843 let blocker = self
844 .gh
845 .get_issue(blocking_repo, &blocking_number.to_string())
846 .await?;
847 if blocker.id == 0 {
848 anyhow::bail!("blocking issue {blocking_repo}#{blocking_number} has no database id");
849 }
850 self.gh
851 .add_blocked_by(&self.repo, issue_number as u64, blocker.id as i64)
852 .await
853 }
854}
855
856fn is_missing_issue(err: &anyhow::Error) -> bool {
857 let text = err.to_string().to_ascii_lowercase();
858 text.contains("404") || text.contains("not found")
859}
860
861pub(crate) fn latest_review_approves(reviews: &[crate::github::types::GitHubReview]) -> bool {
865 let mut latest: Option<(&str, &str)> = None;
866 for review in reviews {
867 let state = review.state.as_str();
868 let substantive = state.eq_ignore_ascii_case("APPROVED")
869 || state.eq_ignore_ascii_case("CHANGES_REQUESTED");
870 if !substantive {
871 continue;
872 }
873 match latest {
874 Some((at, _)) if review.submitted_at.as_str() < at => {}
875 _ => latest = Some((review.submitted_at.as_str(), state)),
876 }
877 }
878 latest.is_some_and(|(_, state)| state.eq_ignore_ascii_case("APPROVED"))
879}
880
881pub(crate) struct AutomatedReviewComment<'a> {
883 pub login: &'a str,
884 pub body: &'a str,
885 pub created_at: &'a str,
886}
887
888pub(crate) fn automated_review_verdict(body: &str) -> Option<&'static str> {
892 let first = body.lines().next().unwrap_or("").trim().replace('\r', "");
893 if !first.starts_with("## Automated Review") {
894 return None;
895 }
896 let normalized = first.replace(['\u{2014}', '\u{2013}'], "-");
897 if normalized.contains("Automated Review - Approve") {
898 Some("approve")
899 } else if normalized.contains("Automated Review - Changes Requested") {
900 Some("changes_requested")
901 } else {
902 None
903 }
904}
905
906pub(crate) fn latest_automated_review_approves(
911 comments: &[AutomatedReviewComment<'_>],
912 reviewer: &str,
913) -> bool {
914 if reviewer.is_empty() {
915 return false;
916 }
917 let mut newest: Option<&AutomatedReviewComment<'_>> = None;
918 for comment in comments {
919 if automated_review_verdict(comment.body).is_none() {
920 continue;
921 }
922 if !comment.login.eq_ignore_ascii_case(reviewer) {
923 continue;
924 }
925 match newest {
926 Some(prev) if comment.created_at <= prev.created_at => {}
927 _ => newest = Some(comment),
928 }
929 }
930 newest.is_some_and(|comment| automated_review_verdict(comment.body) == Some("approve"))
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936 use crate::github::types::{GitHubIssue, GitHubReview, GitHubUser};
937
938 fn review(state: &str, at: &str) -> GitHubReview {
939 GitHubReview {
940 id: 1,
941 user: GitHubUser {
942 login: "owner".into(),
943 },
944 body: None,
945 state: state.into(),
946 html_url: None,
947 submitted_at: at.into(),
948 commit_id: None,
949 }
950 }
951
952 #[test]
953 fn later_changes_requested_clears_an_earlier_approval() {
954 let reviews = vec![
955 review("APPROVED", "2026-01-01T00:00:00Z"),
956 review("COMMENTED", "2026-01-02T00:00:00Z"),
957 review("CHANGES_REQUESTED", "2026-01-03T00:00:00Z"),
958 ];
959 assert!(!latest_review_approves(&reviews));
960 let approved = vec![
961 review("CHANGES_REQUESTED", "2026-01-01T00:00:00Z"),
962 review("APPROVED", "2026-01-02T00:00:00Z"),
963 ];
964 assert!(latest_review_approves(&approved));
965 }
966
967 fn review_comment<'a>(
968 login: &'a str,
969 body: &'a str,
970 at: &'a str,
971 ) -> AutomatedReviewComment<'a> {
972 AutomatedReviewComment {
973 login,
974 body,
975 created_at: at,
976 }
977 }
978
979 #[test]
980 fn automated_review_comment_counts_as_approval_without_formal_reviews() {
981 assert!(!latest_review_approves(&[]));
982 let comments = [review_comment(
983 "orch-bot",
984 "## Automated Review \u{2014} Approve\n\nLooks good.",
985 "2026-01-02T00:00:00Z",
986 )];
987 assert!(latest_automated_review_approves(&comments, "orch-bot"));
988 assert!(latest_automated_review_approves(&comments, "Orch-Bot"));
989 assert!(!latest_automated_review_approves(&comments, "mallory"));
990 assert!(!latest_automated_review_approves(&comments, ""));
991 let hyphen = [review_comment(
992 "orch-bot",
993 "## Automated Review - Approve\n\nok",
994 "2026-01-02T00:00:00Z",
995 )];
996 assert!(latest_automated_review_approves(&hyphen, "orch-bot"));
997 }
998
999 #[test]
1000 fn later_automated_changes_requested_clears_comment_approval() {
1001 let comments = [
1002 review_comment(
1003 "orch-bot",
1004 "## Automated Review \u{2014} Approve\n\nok",
1005 "2026-01-01T00:00:00Z",
1006 ),
1007 review_comment(
1008 "mallory",
1009 "## Automated Review \u{2014} Changes Requested\n\nnope",
1010 "2026-01-03T00:00:00Z",
1011 ),
1012 review_comment(
1013 "orch-bot",
1014 "## Automated Review \u{2014} Changes Requested\n\nfix",
1015 "2026-01-02T00:00:00Z",
1016 ),
1017 ];
1018 assert!(!latest_automated_review_approves(&comments, "orch-bot"));
1019 }
1020
1021 #[test]
1024 fn is_github_issue_id_numeric() {
1025 assert!(is_github_issue_id(&ExternalId("42".to_string())));
1026 assert!(is_github_issue_id(&ExternalId("1".to_string())));
1027 assert!(is_github_issue_id(&ExternalId("99999".to_string())));
1028 }
1029
1030 #[test]
1031 fn is_github_issue_id_rejects_internal_prefix() {
1032 assert!(!is_github_issue_id(&ExternalId(
1033 "internal:63857".to_string()
1034 )));
1035 assert!(!is_github_issue_id(&ExternalId("internal:1".to_string())));
1036 }
1037
1038 #[test]
1039 fn is_github_issue_id_rejects_empty_and_non_numeric() {
1040 assert!(!is_github_issue_id(&ExternalId("".to_string())));
1041 assert!(!is_github_issue_id(&ExternalId("abc".to_string())));
1042 assert!(!is_github_issue_id(&ExternalId("1.0".to_string())));
1043 }
1044
1045 #[test]
1048 fn allowed_associations_are_owner_and_contributor() {
1049 assert_eq!(ALLOWED_ASSOCIATIONS, &["OWNER", "COLLABORATOR"]);
1050 }
1051
1052 #[test]
1053 fn is_trusted_author_owner() {
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("OWNER".to_string()),
1071 };
1072 assert!(is_trusted_author(&issue));
1073 }
1074
1075 #[test]
1076 fn is_trusted_author_collaborator() {
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("COLLABORATOR".to_string()),
1094 };
1095 assert!(is_trusted_author(&issue));
1096 }
1097
1098 #[test]
1099 fn is_not_trusted_author_contributor() {
1100 let issue = GitHubIssue {
1101 number: 1,
1102 id: 0,
1103 title: "t".to_string(),
1104 body: None,
1105 state: "open".to_string(),
1106 labels: vec![],
1107 user: GitHubUser {
1108 login: "u".to_string(),
1109 },
1110 created_at: "".to_string(),
1111 updated_at: "".to_string(),
1112 html_url: "".to_string(),
1113 node_id: None,
1114 pull_request: None,
1115 state_reason: None,
1116 author_association: Some("CONTRIBUTOR".to_string()),
1117 };
1118 assert!(!is_trusted_author(&issue));
1119 }
1120
1121 #[test]
1122 fn is_not_trusted_author_member() {
1123 let issue = GitHubIssue {
1124 number: 1,
1125 id: 0,
1126 title: "t".to_string(),
1127 body: None,
1128 state: "open".to_string(),
1129 labels: vec![],
1130 user: GitHubUser {
1131 login: "u".to_string(),
1132 },
1133 created_at: "".to_string(),
1134 updated_at: "".to_string(),
1135 html_url: "".to_string(),
1136 node_id: None,
1137 pull_request: None,
1138 state_reason: None,
1139 author_association: Some("MEMBER".to_string()),
1140 };
1141 assert!(!is_trusted_author(&issue));
1142 }
1143
1144 #[test]
1145 fn is_trusted_comment_author_direct() {
1146 use crate::github::types::GitHubComment;
1147 let c = GitHubComment {
1148 id: 1,
1149 body: "hi".to_string(),
1150 user: crate::github::types::GitHubUser {
1151 login: "u".to_string(),
1152 },
1153 created_at: "".to_string(),
1154 updated_at: None,
1155 html_url: None,
1156 issue_url: None,
1157 author_association: Some("OWNER".to_string()),
1158 };
1159 assert!(is_trusted_comment_author(&c));
1160 }
1161
1162 #[test]
1163 fn is_not_trusted_comment_author_direct() {
1164 use crate::github::types::GitHubComment;
1165 let c = GitHubComment {
1166 id: 1,
1167 body: "hi".to_string(),
1168 user: crate::github::types::GitHubUser {
1169 login: "u".to_string(),
1170 },
1171 created_at: "".to_string(),
1172 updated_at: None,
1173 html_url: None,
1174 issue_url: None,
1175 author_association: Some("MEMBER".to_string()),
1176 };
1177 assert!(!is_trusted_comment_author(&c));
1178 }
1179
1180 fn make_test_issue(labels: &[&str]) -> GitHubIssue {
1181 GitHubIssue {
1182 number: 1,
1183 id: 0,
1184 title: "t".to_string(),
1185 body: None,
1186 state: "open".to_string(),
1187 labels: labels
1188 .iter()
1189 .map(|label| crate::github::types::GitHubLabel {
1190 name: (*label).to_string(),
1191 color: None,
1192 })
1193 .collect(),
1194 user: GitHubUser {
1195 login: "u".to_string(),
1196 },
1197 created_at: "".to_string(),
1198 updated_at: "".to_string(),
1199 html_url: "".to_string(),
1200 node_id: None,
1201 pull_request: None,
1202 state_reason: None,
1203 author_association: Some("OWNER".to_string()),
1204 }
1205 }
1206
1207 #[test]
1209 fn classify_active_open_issue_excludes_status_done() {
1210 assert!(classify_active_open_issue(make_test_issue(&["status:done"])).is_none());
1211 assert!(
1212 classify_active_open_issue(make_test_issue(&["status:routed", "status:done"]))
1213 .is_none()
1214 );
1215 }
1216
1217 #[test]
1218 fn classify_active_open_issue_preserves_unlabeled_and_new_as_none_status() {
1219 let unlabeled = classify_active_open_issue(make_test_issue(&[]))
1220 .expect("unlabeled issue should still be ingested");
1221 assert_eq!(unlabeled.1, None);
1222
1223 let status_new = classify_active_open_issue(make_test_issue(&["status:new"]))
1224 .expect("status:new issue should still be ingested");
1225 assert_eq!(status_new.1, None);
1226 }
1227
1228 #[test]
1229 fn classify_active_open_issue_maps_active_status() {
1230 let routed = classify_active_open_issue(make_test_issue(&["status:routed"]))
1231 .expect("active status issue should be ingested");
1232 assert_eq!(routed.1, Some(Status::Routed));
1233 }
1234}