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
18const ALLOWED_ASSOCIATIONS: &[&str] = &["OWNER", "COLLABORATOR"];
19
20/// Check if an issue author is trusted
21fn 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) // missing field = untrusted
27}
28
29// Check if a comment's author is trusted. The
30// GitHub REST comment payload includes an `author_association` field, so
31// prefer that when present. Missing association is treated as untrusted.
32fn 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    // Guard: open issues tagged as done should never be re-ingested as new.
55    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
82/// Returns `true` if `id` is a valid GitHub issue number (positive integer).
83///
84/// Internal tasks use IDs like `"internal:63857"` which are not valid GitHub
85/// issue numbers. Backend methods that call the GitHub API must skip these IDs
86/// to avoid spurious 404 errors.
87fn 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            // Preserve existing ingestion membership behavior. The reconciler
107            // owns the field value and reads the current labels on the next sync.
108            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    // update_status uses the default trait implementation (retry loop with
151    // get_task → remove_label → set_labels). Only ensure_status_label is
152    // overridden to auto-create labels on the GitHub repo.
153
154    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    /// Sync a task to the GitHub Project board (if configured).
167    ///
168    /// Called when a task is first ingested to ensure it appears on the
169    /// project board immediately, not just when status changes later.
170    /// Returns `Some(node_id)` if the item was added to the project board.
171    async fn sync_to_project(
172        &self,
173        id: &ExternalId,
174        status: Status,
175    ) -> anyhow::Result<Option<String>> {
176        // Only sync if project integration is configured
177        let project = match ProjectSync::from_config() {
178            Some(p) => p,
179            None => return Ok(None),
180        };
181
182        // Fetch the issue to get its node_id
183        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
184
185        // Get the node_id (required for project board operations)
186        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        // Add to project board and set initial status
193        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        // If the estimate field ID is missing from config, try to discover it
214        // automatically and persist it for future calls. This handles the case
215        // where the service was set up before the estimate sync feature existed.
216        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        // Fetch the issue to get its node_id.
253        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    /// Override default update_status to add project board sync after label update.
287    async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
288        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
289        if !is_github_issue_id(id) {
290            return Ok(());
291        }
292
293        // Run the standard label-based status update (default trait impl logic)
294        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            // Capture node_id for project sync
322            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                    // Label update succeeded — now sync project board (non-fatal)
347                    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                    // Auto-close issue when status → Done (if auto_close enabled)
362                    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        // Done issues are auto-closed by the engine, so we must query state=all
393        // to find them. All other statuses only live on open issues.
394        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()) // Exclude PRs
406            .filter(is_trusted_author) // Only trusted authors
407            .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    /// List all tasks (open and closed) in a single API call.
422    ///
423    /// This fetches all issues with state=all and filters by status labels locally.
424    /// More efficient than making 7 separate calls to `list_by_status`.
425    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()) // Exclude PRs
430            .filter(is_trusted_author) // Only trusted authors
431            .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()) // Exclude PRs
452            .filter(is_trusted_author) // Only trusted authors
453            .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    /// List open issues that have no `status:*` label (unprocessed) or have `status:new`.
468    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()) // Exclude PRs
473            .filter(is_trusted_author) // Only trusted authors
474            .filter(|issue| {
475                let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
476                // Include if: has status:new, OR has no status:* label at all
477                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    /// Single-call override: fetches all open issues once and partitions by status label locally,
495    /// replacing the 1 (list_routable) + 5 (list_by_status per active status) fan-out with a
496    /// single `list_all_open_issues` request.
497    /// When `since` is `Some(ts)`, uses GitHub's `?since` filter for efficiency.
498    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()) // Exclude PRs
507            .filter(is_trusted_author) // Only trusted authors
508            .filter_map(classify_active_open_issue)
509            .collect())
510    }
511
512    async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
513        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
514        if !is_github_issue_id(id) {
515            return Ok(());
516        }
517        self.gh.add_comment(&self.repo, &id.0, body).await
518    }
519
520    /// Additive: uses POST (gh.add_labels), so existing labels like bug, priority:high are preserved.
521    async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
522        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
523        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        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
531        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        // Add parent label for metadata
553        let mut all_labels = labels.to_vec();
554        all_labels.push(format!("parent:{}", parent_id.0));
555
556        // Create the child issue
557        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        // Try to establish native sub-issue relationship via GraphQL
565        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        // Check open issues first
586        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        // Also check issues closed within the last 24h — prevents re-creating a bug
595        // that was just fixed and merged (the PR close event removes the open issue).
596        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        // Only include mentions authored by trusted associations. The GitHub
633        // REST issue comment payload does not include author_association, so
634        // we conservatively check the parent issue's author_association when
635        // an issue_url is available. If we cannot resolve association, skip
636        // the comment.
637        let mut out = Vec::new();
638        for c in comments.into_iter() {
639            // Prefer the comment's own author_association when present. If the
640            // comment payload doesn't include it (older endpoints), fall back to
641            // inspecting the parent issue's association when an issue_url is
642            // available. If neither yields a trusted association, skip the
643            // comment.
644            let trusted = if is_trusted_comment_author(&c) {
645                true
646            } else if let Some(ref issue_url) = c.issue_url {
647                // extract issue number and fetch issue to inspect author_association
648                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        // Filter comments to only include those from trusted associations.
691        let mut out = Vec::new();
692        for c in comments.into_iter() {
693            // Prefer the comment's own association when present.
694            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            // Fall back to parent issue association when necessary.
706            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                // If we cannot fetch the issue, skip the comment conservatively
722                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
861/// Latest substantive review wins. Comments and dismissed reviews are ignored.
862///
863/// `CHANGES_REQUESTED` after `APPROVED` means the pull request is not approved
864pub(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
881/// One issue comment considered for automated review approval.
882pub(crate) struct AutomatedReviewComment<'a> {
883    pub login: &'a str,
884    pub body: &'a str,
885    pub created_at: &'a str,
886}
887
888/// First-line verdict of a `## Automated Review` comment.
889///
890/// Orch writes an em dash. The review gate also accepts a hyphen
891pub(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
906/// Latest `## Automated Review` comment from `reviewer` wins.
907///
908/// An empty reviewer matches nothing. A later changes-requested comment
909/// clears an earlier approve
910pub(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    // --- is_github_issue_id guard ---
1022
1023    #[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    // ---
1046
1047    #[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    /// Open issues carrying `status:done` must be excluded from active-open ingest.
1208    #[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}