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        LabelSyncConfig::from_config()?
204            .reconcile(&self.gh, &self.repo)
205            .await
206            .map(|_| ())
207    }
208
209    async fn sync_estimate_to_project(&self, id: &ExternalId, estimate: u8) -> anyhow::Result<()> {
210        let project = match ProjectSync::from_config() {
211            Some(p) => p,
212            None => return Ok(()),
213        };
214
215        // If the estimate field ID is missing from config, try to discover it
216        // automatically and persist it for future calls. This handles the case
217        // where the service was set up before the estimate sync feature existed.
218        let project = if project.estimate_field_id().is_none() {
219            match ProjectSync::discover_fields(project.project_id()).await {
220                Ok(discovered) => {
221                    if discovered.estimate_field_id().is_some() {
222                        if let Err(e) =
223                            crate::github::projects::write_project_config(&discovered).await
224                        {
225                            tracing::warn!(
226                                err = %e,
227                                "failed to persist discovered estimate field ID to config"
228                            );
229                        } else {
230                            tracing::info!(
231                                "auto-discovered and persisted project_estimate_field_id to config"
232                            );
233                        }
234                        discovered
235                    } else {
236                        tracing::debug!(
237                            "project has no 'Estimate' number field — skipping estimate sync"
238                        );
239                        return Ok(());
240                    }
241                }
242                Err(e) => {
243                    tracing::debug!(
244                        err = %e,
245                        "estimate field auto-discovery failed — skipping estimate sync"
246                    );
247                    return Ok(());
248                }
249            }
250        } else {
251            project
252        };
253
254        // Fetch the issue to get its node_id.
255        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
256        let node_id = issue
257            .node_id
258            .as_deref()
259            .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?;
260
261        project
262            .sync_item_estimate(node_id, estimate)
263            .await
264            .map_err(|e| anyhow::anyhow!("project estimate sync failed: {e}"))?;
265
266        Ok(())
267    }
268
269    async fn get_project_item_estimates(
270        &self,
271        issue_node_ids: &[String],
272    ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
273        let project = match ProjectSync::from_config() {
274            Some(p) => p,
275            None => return Ok(std::collections::HashMap::new()),
276        };
277
278        if issue_node_ids.is_empty() {
279            return Ok(std::collections::HashMap::new());
280        }
281
282        project
283            .get_estimates_for_issues(issue_node_ids)
284            .await
285            .map_err(|e| anyhow::anyhow!("project estimate fetch failed: {e}"))
286    }
287
288    /// Override default update_status to add project board sync after label update.
289    async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
290        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
291        if !is_github_issue_id(id) {
292            return Ok(());
293        }
294
295        // Run the standard label-based status update (default trait impl logic)
296        let label = status.as_label();
297
298        if let Err(e) = self.ensure_status_label(label).await {
299            tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
300        }
301
302        const MAX_RETRIES: u32 = 3;
303        let mut last_err =
304            anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
305
306        let mut issue_node_id: Option<String> = None;
307
308        for attempt in 0..MAX_RETRIES {
309            if attempt > 0 {
310                tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
311                    .await;
312                tracing::debug!(attempt, label, "retrying update_status");
313            }
314
315            let issue = match self.gh.get_issue(&self.repo, &id.0).await {
316                Ok(i) => i,
317                Err(e) => {
318                    last_err = e;
319                    continue;
320                }
321            };
322
323            // Capture node_id for project sync
324            if issue_node_id.is_none() {
325                issue_node_id = issue.node_id.clone();
326            }
327
328            let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
329
330            let mut remove_failed = false;
331            for old in labels
332                .iter()
333                .filter(|l| l.starts_with("status:") && l.as_str() != label)
334            {
335                if let Err(e) = self.remove_label(id, old).await {
336                    tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
337                    last_err = e;
338                    remove_failed = true;
339                    break;
340                }
341            }
342            if remove_failed {
343                continue;
344            }
345
346            match self.set_labels(id, &[label.to_string()]).await {
347                Ok(()) => {
348                    // Label update succeeded — now sync project board (non-fatal)
349                    if let Some(ref node_id) = issue_node_id {
350                        if let Some(project) = ProjectSync::from_config() {
351                            if let Err(e) =
352                                self.sync_project_status(&project, node_id, &status).await
353                            {
354                                tracing::warn!(
355                                    task_id = id.0,
356                                    err = %e,
357                                    "project board sync failed (non-fatal)"
358                                );
359                            }
360                        }
361                    }
362
363                    // Auto-close issue when status → Done (if auto_close enabled)
364                    if status == Status::Done {
365                        let auto_close = crate::config::get("workflow.auto_close")
366                            .map(|v| v == "true")
367                            .unwrap_or(true);
368                        if auto_close {
369                            if let Err(e) = self.gh.close_issue(&self.repo, &id.0).await {
370                                tracing::warn!(
371                                    task_id = id.0,
372                                    err = %e,
373                                    "auto-close issue failed (non-fatal)"
374                                );
375                            }
376                        }
377                    }
378
379                    return Ok(());
380                }
381                Err(e) => {
382                    tracing::warn!(label, err = %e, attempt, "set_labels failed");
383                    last_err = e;
384                }
385            }
386        }
387
388        Err(last_err.context(format!(
389            "update_status({label}) failed after {MAX_RETRIES} attempts"
390        )))
391    }
392
393    async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
394        // Done issues are auto-closed by the engine, so we must query state=all
395        // to find them. All other statuses only live on open issues.
396        let state = if status == Status::Done {
397            "all"
398        } else {
399            "open"
400        };
401        let issues = self
402            .gh
403            .list_issues_with_state(&self.repo, status.as_label(), state)
404            .await?;
405        Ok(issues
406            .into_iter()
407            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
408            .filter(is_trusted_author) // Only trusted authors
409            .map(|issue| ExternalTask {
410                id: ExternalId(issue.number.to_string()),
411                title: issue.title,
412                body: issue.body.unwrap_or_default(),
413                state: issue.state,
414                labels: issue.labels.into_iter().map(|l| l.name).collect(),
415                author: issue.user.login,
416                created_at: issue.created_at,
417                updated_at: issue.updated_at,
418                url: issue.html_url,
419            })
420            .collect())
421    }
422
423    /// List all tasks (open and closed) in a single API call.
424    ///
425    /// This fetches all issues with state=all and filters by status labels locally.
426    /// More efficient than making 7 separate calls to `list_by_status`.
427    async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
428        let issues = self.gh.list_all_issues(&self.repo).await?;
429        Ok(issues
430            .into_iter()
431            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
432            .filter(is_trusted_author) // Only trusted authors
433            .map(|issue| ExternalTask {
434                id: ExternalId(issue.number.to_string()),
435                title: issue.title,
436                body: issue.body.unwrap_or_default(),
437                state: issue.state,
438                labels: issue.labels.into_iter().map(|l| l.name).collect(),
439                author: issue.user.login,
440                created_at: issue.created_at,
441                updated_at: issue.updated_at,
442                url: issue.html_url,
443            })
444            .collect())
445    }
446
447    async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
448        let since = (Utc::now() - Duration::days(30)).to_rfc3339();
449        let closed = self.gh.list_closed_issues_since(&self.repo, &since).await?;
450
451        Ok(closed
452            .into_iter()
453            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
454            .filter(is_trusted_author) // Only trusted authors
455            .map(|issue| ExternalTask {
456                id: ExternalId(issue.number.to_string()),
457                title: issue.title,
458                body: issue.body.unwrap_or_default(),
459                state: issue.state,
460                labels: issue.labels.into_iter().map(|l| l.name).collect(),
461                author: issue.user.login,
462                created_at: issue.created_at,
463                updated_at: issue.updated_at,
464                url: issue.html_url,
465            })
466            .collect())
467    }
468
469    /// List open issues that have no `status:*` label (unprocessed) or have `status:new`.
470    async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
471        let issues = self.gh.list_all_open_issues(&self.repo).await?;
472        Ok(issues
473            .into_iter()
474            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
475            .filter(is_trusted_author) // Only trusted authors
476            .filter(|issue| {
477                let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
478                // Include if: has status:new, OR has no status:* label at all
479                let has_status = labels.iter().any(|l| l.starts_with("status:"));
480                !has_status || labels.contains(&"status:new")
481            })
482            .map(|issue| ExternalTask {
483                id: ExternalId(issue.number.to_string()),
484                title: issue.title,
485                body: issue.body.unwrap_or_default(),
486                state: issue.state,
487                labels: issue.labels.into_iter().map(|l| l.name).collect(),
488                author: issue.user.login,
489                created_at: issue.created_at,
490                updated_at: issue.updated_at,
491                url: issue.html_url,
492            })
493            .collect())
494    }
495
496    /// Single-call override: fetches all open issues once and partitions by status label locally,
497    /// replacing the 1 (list_routable) + 5 (list_by_status per active status) fan-out with a
498    /// single `list_all_open_issues` request.
499    async fn list_active_open_issues(
500        &self,
501    ) -> anyhow::Result<Vec<(ExternalTask, Option<crate::backends::Status>)>> {
502        let issues = self.gh.list_all_open_issues(&self.repo).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).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_member_and_collaborator() {
1049        assert_eq!(ALLOWED_ASSOCIATIONS, &["OWNER", "MEMBER", "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_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_trusted_comment_author_member() {
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}