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