Skip to main content

orch/backends/
github.rs

1//! GitHub Issues backend — native HTTP via `reqwest` with connection pooling.
2//!
3//! Auth is resolved once at startup using the centralized [`TokenResolver`]
4//! (`GH_TOKEN` → `GITHUB_TOKEN` environment variables by default).
5
6use 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
13/// Author associations that are allowed to create tasks.
14/// Only repository OWNER or COLLABORATOR authors are considered trusted for
15/// ingestion. Other associations (members, collaborators, first-time
16/// contributors, etc.) are ignored per the new sync policy.
17/// Available values: COLLABORATOR, CONTRIBUTOR, FIRST_TIMER, FIRST_TIME_CONTRIBUTOR, MANNEQUIN, MEMBER, NONE, OWNER
18// MEMBER is a member of the organization that owns the repo, as the operator is after a
19// transfer to an org. Without it every new issue and comment there was dropped
20const ALLOWED_ASSOCIATIONS: &[&str] = &["OWNER", "MEMBER", "COLLABORATOR"];
21
22/// Check if an issue author is trusted
23fn 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) // missing field = untrusted
29}
30
31// Check if a comment's author is trusted. The
32// GitHub REST comment payload includes an `author_association` field, so
33// prefer that when present. Missing association is treated as untrusted.
34fn 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    // Guard: open issues tagged as done should never be re-ingested as new.
57    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
84/// Returns `true` if `id` is a valid GitHub issue number (positive integer).
85///
86/// Internal tasks use IDs like `"internal:63857"` which are not valid GitHub
87/// issue numbers. Backend methods that call the GitHub API must skip these IDs
88/// to avoid spurious 404 errors.
89fn 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            // Preserve existing ingestion membership behavior. The reconciler
109            // owns the field value and reads the current labels on the next sync.
110            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    // update_status uses the default trait implementation (retry loop with
153    // get_task → remove_label → set_labels). Only ensure_status_label is
154    // overridden to auto-create labels on the GitHub repo.
155
156    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    /// Sync a task to the GitHub Project board (if configured).
169    ///
170    /// Called when a task is first ingested to ensure it appears on the
171    /// project board immediately, not just when status changes later.
172    /// Returns `Some(node_id)` if the item was added to the project board.
173    async fn sync_to_project(
174        &self,
175        id: &ExternalId,
176        status: Status,
177    ) -> anyhow::Result<Option<String>> {
178        // Only sync if project integration is configured
179        let project = match ProjectSync::from_config() {
180            Some(p) => p,
181            None => return Ok(None),
182        };
183
184        // Fetch the issue to get its node_id
185        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
186
187        // Get the node_id (required for project board operations)
188        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        // Add to project board and set initial status
195        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        // Projects V2 on a personal account is out of reach for an installation token
204        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        // If the estimate field ID is missing from config, try to discover it
217        // automatically and persist it for future calls. This handles the case
218        // where the service was set up before the estimate sync feature existed.
219        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        // Fetch the issue to get its node_id.
256        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    /// Override default update_status to add project board sync after label update.
290    async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
291        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
292        if !is_github_issue_id(id) {
293            return Ok(());
294        }
295
296        // Run the standard label-based status update (default trait impl logic)
297        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            // Capture node_id for project sync
325            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                    // Label update succeeded — now sync project board (non-fatal)
350                    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                    // Auto-close issue when status → Done (if auto_close enabled)
365                    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        // Done issues are auto-closed by the engine, so we must query state=all
396        // to find them. All other statuses only live on open issues.
397        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()) // Exclude PRs
409            .filter(is_trusted_author) // Only trusted authors
410            .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    /// List all tasks (open and closed) in a single API call.
425    ///
426    /// This fetches all issues with state=all and filters by status labels locally.
427    /// More efficient than making 7 separate calls to `list_by_status`.
428    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()) // Exclude PRs
433            .filter(is_trusted_author) // Only trusted authors
434            .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()) // Exclude PRs
455            .filter(is_trusted_author) // Only trusted authors
456            .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    /// List open issues that have no `status:*` label (unprocessed) or have `status:new`.
471    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()) // Exclude PRs
476            .filter(is_trusted_author) // Only trusted authors
477            .filter(|issue| {
478                let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
479                // Include if: has status:new, OR has no status:* label at all
480                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    /// Single-call override: fetches all open issues once and partitions by status label locally,
498    /// replacing the 1 (list_routable) + 5 (list_by_status per active status) fan-out with a
499    /// single `list_all_open_issues` request.
500    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()) // Exclude PRs
508            .filter(is_trusted_author) // Only trusted authors
509            .filter_map(classify_active_open_issue)
510            .collect())
511    }
512
513    async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
514        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
515        if !is_github_issue_id(id) {
516            return Ok(());
517        }
518        self.gh.add_comment(&self.repo, &id.0, body).await
519    }
520
521    /// Additive: uses POST (gh.add_labels), so existing labels like bug, priority:high are preserved.
522    async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
523        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
524        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        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
532        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        // Add parent label for metadata
554        let mut all_labels = labels.to_vec();
555        all_labels.push(format!("parent:{}", parent_id.0));
556
557        // Create the child issue
558        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        // Try to establish native sub-issue relationship via GraphQL
566        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        // Check open issues first
587        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        // Also check issues closed within the last 24h — prevents re-creating a bug
596        // that was just fixed and merged (the PR close event removes the open issue).
597        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        // Only include mentions authored by trusted associations. The GitHub
634        // REST issue comment payload does not include author_association, so
635        // we conservatively check the parent issue's author_association when
636        // an issue_url is available. If we cannot resolve association, skip
637        // the comment.
638        let mut out = Vec::new();
639        for c in comments.into_iter() {
640            // Prefer the comment's own author_association when present. If the
641            // comment payload doesn't include it (older endpoints), fall back to
642            // inspecting the parent issue's association when an issue_url is
643            // available. If neither yields a trusted association, skip the
644            // comment.
645            let trusted = if is_trusted_comment_author(&c) {
646                true
647            } else if let Some(ref issue_url) = c.issue_url {
648                // extract issue number and fetch issue to inspect author_association
649                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        // Filter comments to only include those from trusted associations.
692        let mut out = Vec::new();
693        for c in comments.into_iter() {
694            // Prefer the comment's own association when present.
695            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            // Fall back to parent issue association when necessary.
707            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                // If we cannot fetch the issue, skip the comment conservatively
723                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        // An App review on the head replaces the comment, as in review_poll, so a stale
833        // approving comment cannot outvote an App change request
834        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
875/// Latest substantive review wins. Comments and dismissed reviews are ignored.
876///
877/// `CHANGES_REQUESTED` after `APPROVED` means the pull request is not approved
878pub(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
895/// One issue comment considered for automated review approval.
896pub(crate) struct AutomatedReviewComment<'a> {
897    pub login: &'a str,
898    pub body: &'a str,
899    pub created_at: &'a str,
900}
901
902/// First-line verdict of a `## Automated Review` comment.
903///
904/// Orch writes an em dash. The review gate also accepts a hyphen
905pub(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
920/// Latest `## Automated Review` comment from `reviewer` wins.
921///
922/// An empty reviewer matches nothing. A later changes-requested comment
923/// clears an earlier approve
924pub(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    // --- is_github_issue_id guard ---
1036
1037    #[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    // ---
1060
1061    #[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    /// Open issues carrying `status:done` must be excluded from active-open ingest.
1222    #[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}