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
31fn is_trusted_comment_author(c: &crate::github::types::GitHubComment) -> bool {
32    c.author_association
33        .as_deref()
34        .map(|a| ALLOWED_ASSOCIATIONS.contains(&a))
35        .unwrap_or(false)
36}
37
38fn trusted_mentions(
39    comments: impl IntoIterator<Item = crate::github::types::GitHubComment>,
40) -> Vec<Mention> {
41    comments
42        .into_iter()
43        .filter_map(|c| {
44            if !is_trusted_comment_author(&c) {
45                tracing::debug!(
46                    comment_id = c.id,
47                    author = %c.user.login,
48                    "ignoring comment from untrusted author"
49                );
50                return None;
51            }
52            Some(Mention {
53                id: c.id.to_string(),
54                body: c.body,
55                author: c.user.login,
56                created_at: c.created_at,
57                issue_url: c.issue_url,
58            })
59        })
60        .collect()
61}
62
63fn active_status_from_label(label: &str) -> Option<Status> {
64    match label {
65        "status:routed" => Some(Status::Routed),
66        "status:in_progress" => Some(Status::InProgress),
67        "status:needs_review" => Some(Status::NeedsReview),
68        "status:in_review" => Some(Status::InReview),
69        "status:blocked" => Some(Status::Blocked),
70        "status:waiting" => Some(Status::Waiting),
71        _ => None,
72    }
73}
74
75fn classify_active_open_issue(
76    issue: crate::github::types::GitHubIssue,
77) -> Option<(ExternalTask, Option<Status>)> {
78    // Guard: open issues tagged as done should never be re-ingested as new.
79    if issue.labels.iter().any(|l| l.name == "status:done") {
80        return None;
81    }
82
83    let status = issue
84        .labels
85        .iter()
86        .find_map(|l| active_status_from_label(&l.name));
87    let task = ExternalTask {
88        id: ExternalId(issue.number.to_string()),
89        title: issue.title,
90        body: issue.body.unwrap_or_default(),
91        state: issue.state,
92        labels: issue.labels.into_iter().map(|l| l.name).collect(),
93        author: issue.user.login,
94        created_at: issue.created_at,
95        updated_at: issue.updated_at,
96        url: issue.html_url,
97    };
98    Some((task, status))
99}
100
101pub struct GitHubBackend {
102    repo: String,
103    gh: GhHttp,
104}
105
106/// Returns `true` if `id` is a valid GitHub issue number (positive integer).
107///
108/// Internal tasks use IDs like `"internal:63857"` which are not valid GitHub
109/// issue numbers. Backend methods that call the GitHub API must skip these IDs
110/// to avoid spurious 404 errors.
111fn is_github_issue_id(id: &ExternalId) -> bool {
112    id.0.parse::<u64>().is_ok()
113}
114
115impl GitHubBackend {
116    pub fn new(repo: String) -> anyhow::Result<Self> {
117        Ok(Self {
118            repo,
119            gh: GhHttp::new()?,
120        })
121    }
122
123    #[cfg(test)]
124    fn for_test(repo: impl Into<String>, gh: GhHttp) -> Self {
125        Self {
126            repo: repo.into(),
127            gh,
128        }
129    }
130
131    async fn sync_project_status(
132        &self,
133        project: &ProjectSync,
134        node_id: &str,
135        status: &Status,
136    ) -> anyhow::Result<()> {
137        if LabelSyncConfig::from_config()?.manages(&self.repo, project.project_id()) {
138            // Preserve existing ingestion membership behavior. The reconciler
139            // owns the field value and reads the current labels on the next sync.
140            project.add_item(node_id).await?;
141            Ok(())
142        } else {
143            project.sync_item_status(node_id, status).await
144        }
145    }
146}
147
148#[async_trait]
149impl ExternalBackend for GitHubBackend {
150    fn name(&self) -> &str {
151        "github"
152    }
153
154    async fn create_task(
155        &self,
156        title: &str,
157        body: &str,
158        labels: &[String],
159    ) -> anyhow::Result<ExternalId> {
160        let issue = self
161            .gh
162            .create_issue(&self.repo, title, body, labels)
163            .await?;
164        Ok(ExternalId(issue.number.to_string()))
165    }
166
167    async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
168        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
169        Ok(ExternalTask {
170            id: id.clone(),
171            title: issue.title,
172            body: issue.body.unwrap_or_default(),
173            state: issue.state,
174            labels: issue.labels.into_iter().map(|l| l.name).collect(),
175            author: issue.user.login,
176            created_at: issue.created_at,
177            updated_at: issue.updated_at,
178            url: issue.html_url,
179        })
180    }
181
182    // update_status uses the default trait implementation (retry loop with
183    // get_task → remove_label → set_labels). Only ensure_status_label is
184    // overridden to auto-create labels on the GitHub repo.
185
186    async fn ensure_status_label(&self, label: &str) -> anyhow::Result<()> {
187        let status_name = &label["status:".len()..];
188        self.gh
189            .ensure_label(
190                &self.repo,
191                label,
192                status_label_color(label),
193                &format!("Task status: {status_name}"),
194            )
195            .await
196    }
197
198    /// Sync a task to the GitHub Project board (if configured).
199    ///
200    /// Called when a task is first ingested to ensure it appears on the
201    /// project board immediately, not just when status changes later.
202    /// Returns `Some(node_id)` if the item was added to the project board.
203    async fn sync_to_project(
204        &self,
205        id: &ExternalId,
206        status: Status,
207    ) -> anyhow::Result<Option<String>> {
208        // Only sync if project integration is configured
209        let project = match ProjectSync::from_config() {
210            Some(p) => p,
211            None => return Ok(None),
212        };
213
214        // Fetch the issue to get its node_id
215        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
216
217        // Get the node_id (required for project board operations)
218        let node_id = issue
219            .node_id
220            .as_deref()
221            .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?
222            .to_string();
223
224        // Add to project board and set initial status
225        self.sync_project_status(&project, &node_id, &status)
226            .await
227            .map_err(|e| anyhow::anyhow!("project board sync failed: {e}"))?;
228
229        Ok(Some(node_id))
230    }
231
232    async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
233        // Projects V2 on a personal account is out of reach for an installation token
234        LabelSyncConfig::from_config()?
235            .reconcile(&GhHttp::owner()?, &self.repo)
236            .await
237            .map(|_| ())
238    }
239
240    async fn sync_estimate_to_project(&self, id: &ExternalId, estimate: u8) -> anyhow::Result<()> {
241        let project = match ProjectSync::from_config() {
242            Some(p) => p,
243            None => return Ok(()),
244        };
245
246        // If the estimate field ID is missing from config, try to discover it
247        // automatically and persist it for future calls. This handles the case
248        // where the service was set up before the estimate sync feature existed.
249        let project = if project.estimate_field_id().is_none() {
250            match ProjectSync::discover_fields(project.project_id()).await {
251                Ok(discovered) => {
252                    if discovered.estimate_field_id().is_some() {
253                        if let Err(e) =
254                            crate::github::projects::write_project_config(&discovered).await
255                        {
256                            tracing::warn!(
257                                err = %e,
258                                "failed to persist discovered estimate field ID to config"
259                            );
260                        } else {
261                            tracing::info!(
262                                "auto-discovered and persisted project_estimate_field_id to config"
263                            );
264                        }
265                        discovered
266                    } else {
267                        tracing::debug!(
268                            "project has no 'Estimate' number field — skipping estimate sync"
269                        );
270                        return Ok(());
271                    }
272                }
273                Err(e) => {
274                    tracing::debug!(
275                        err = %e,
276                        "estimate field auto-discovery failed — skipping estimate sync"
277                    );
278                    return Ok(());
279                }
280            }
281        } else {
282            project
283        };
284
285        // Fetch the issue to get its node_id.
286        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
287        let node_id = issue
288            .node_id
289            .as_deref()
290            .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?;
291
292        project
293            .sync_item_estimate(node_id, estimate)
294            .await
295            .map_err(|e| anyhow::anyhow!("project estimate sync failed: {e}"))?;
296
297        Ok(())
298    }
299
300    async fn get_project_item_estimates(
301        &self,
302        issue_node_ids: &[String],
303    ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
304        let project = match ProjectSync::from_config() {
305            Some(p) => p,
306            None => return Ok(std::collections::HashMap::new()),
307        };
308
309        if issue_node_ids.is_empty() {
310            return Ok(std::collections::HashMap::new());
311        }
312
313        project
314            .get_estimates_for_issues(issue_node_ids)
315            .await
316            .map_err(|e| anyhow::anyhow!("project estimate fetch failed: {e}"))
317    }
318
319    /// Override default update_status to add project board sync after label update.
320    async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
321        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
322        if !is_github_issue_id(id) {
323            return Ok(());
324        }
325
326        // Run the standard label-based status update (default trait impl logic)
327        let label = status.as_label();
328
329        if let Err(e) = self.ensure_status_label(label).await {
330            tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
331        }
332
333        const MAX_RETRIES: u32 = 3;
334        let mut last_err =
335            anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
336
337        let mut issue_node_id: Option<String> = None;
338
339        for attempt in 0..MAX_RETRIES {
340            if attempt > 0 {
341                tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
342                    .await;
343                tracing::debug!(attempt, label, "retrying update_status");
344            }
345
346            let issue = match self.gh.get_issue(&self.repo, &id.0).await {
347                Ok(i) => i,
348                Err(e) => {
349                    last_err = e;
350                    continue;
351                }
352            };
353
354            // Capture node_id for project sync
355            if issue_node_id.is_none() {
356                issue_node_id = issue.node_id.clone();
357            }
358
359            let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
360
361            let mut remove_failed = false;
362            for old in labels
363                .iter()
364                .filter(|l| l.starts_with("status:") && l.as_str() != label)
365            {
366                if let Err(e) = self.remove_label(id, old).await {
367                    tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
368                    last_err = e;
369                    remove_failed = true;
370                    break;
371                }
372            }
373            if remove_failed {
374                continue;
375            }
376
377            match self.set_labels(id, &[label.to_string()]).await {
378                Ok(()) => {
379                    // Label update succeeded — now sync project board (non-fatal)
380                    if let Some(ref node_id) = issue_node_id {
381                        if let Some(project) = ProjectSync::from_config() {
382                            if let Err(e) =
383                                self.sync_project_status(&project, node_id, &status).await
384                            {
385                                tracing::warn!(
386                                    task_id = id.0,
387                                    err = %e,
388                                    "project board sync failed (non-fatal)"
389                                );
390                            }
391                        }
392                    }
393
394                    // Auto-close issue when status → Done (if auto_close enabled)
395                    if status == Status::Done {
396                        let auto_close = crate::config::get("workflow.auto_close")
397                            .map(|v| v == "true")
398                            .unwrap_or(true);
399                        if auto_close {
400                            if let Err(e) = self.gh.close_issue(&self.repo, &id.0).await {
401                                tracing::warn!(
402                                    task_id = id.0,
403                                    err = %e,
404                                    "auto-close issue failed (non-fatal)"
405                                );
406                            }
407                        }
408                    }
409
410                    return Ok(());
411                }
412                Err(e) => {
413                    tracing::warn!(label, err = %e, attempt, "set_labels failed");
414                    last_err = e;
415                }
416            }
417        }
418
419        Err(last_err.context(format!(
420            "update_status({label}) failed after {MAX_RETRIES} attempts"
421        )))
422    }
423
424    async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
425        // Done issues are auto-closed by the engine, so we must query state=all
426        // to find them. All other statuses only live on open issues.
427        let state = if status == Status::Done {
428            "all"
429        } else {
430            "open"
431        };
432        let issues = self
433            .gh
434            .list_issues_with_state(&self.repo, status.as_label(), state)
435            .await?;
436        Ok(issues
437            .into_iter()
438            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
439            .filter(is_trusted_author) // Only trusted authors
440            .map(|issue| ExternalTask {
441                id: ExternalId(issue.number.to_string()),
442                title: issue.title,
443                body: issue.body.unwrap_or_default(),
444                state: issue.state,
445                labels: issue.labels.into_iter().map(|l| l.name).collect(),
446                author: issue.user.login,
447                created_at: issue.created_at,
448                updated_at: issue.updated_at,
449                url: issue.html_url,
450            })
451            .collect())
452    }
453
454    /// List all tasks (open and closed) in a single API call.
455    ///
456    /// This fetches all issues with state=all and filters by status labels locally.
457    /// More efficient than making 7 separate calls to `list_by_status`.
458    async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
459        let issues = self.gh.list_all_issues(&self.repo).await?;
460        Ok(issues
461            .into_iter()
462            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
463            .filter(is_trusted_author) // Only trusted authors
464            .map(|issue| ExternalTask {
465                id: ExternalId(issue.number.to_string()),
466                title: issue.title,
467                body: issue.body.unwrap_or_default(),
468                state: issue.state,
469                labels: issue.labels.into_iter().map(|l| l.name).collect(),
470                author: issue.user.login,
471                created_at: issue.created_at,
472                updated_at: issue.updated_at,
473                url: issue.html_url,
474            })
475            .collect())
476    }
477
478    async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
479        let since = (Utc::now() - Duration::days(30)).to_rfc3339();
480        let closed = self.gh.list_closed_issues_since(&self.repo, &since).await?;
481
482        Ok(closed
483            .into_iter()
484            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
485            .filter(is_trusted_author) // Only trusted authors
486            .map(|issue| ExternalTask {
487                id: ExternalId(issue.number.to_string()),
488                title: issue.title,
489                body: issue.body.unwrap_or_default(),
490                state: issue.state,
491                labels: issue.labels.into_iter().map(|l| l.name).collect(),
492                author: issue.user.login,
493                created_at: issue.created_at,
494                updated_at: issue.updated_at,
495                url: issue.html_url,
496            })
497            .collect())
498    }
499
500    /// List open issues that have no `status:*` label (unprocessed) or have `status:new`.
501    async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
502        let issues = self.gh.list_all_open_issues(&self.repo).await?;
503        Ok(issues
504            .into_iter()
505            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
506            .filter(is_trusted_author) // Only trusted authors
507            .filter(|issue| {
508                let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
509                // Include if: has status:new, OR has no status:* label at all
510                let has_status = labels.iter().any(|l| l.starts_with("status:"));
511                !has_status || labels.contains(&"status:new")
512            })
513            .map(|issue| ExternalTask {
514                id: ExternalId(issue.number.to_string()),
515                title: issue.title,
516                body: issue.body.unwrap_or_default(),
517                state: issue.state,
518                labels: issue.labels.into_iter().map(|l| l.name).collect(),
519                author: issue.user.login,
520                created_at: issue.created_at,
521                updated_at: issue.updated_at,
522                url: issue.html_url,
523            })
524            .collect())
525    }
526
527    /// Single-call override: fetches all open issues once and partitions by status label locally,
528    /// replacing the 1 (list_routable) + 5 (list_by_status per active status) fan-out with a
529    /// single `list_all_open_issues` request.
530    async fn list_active_open_issues(
531        &self,
532    ) -> anyhow::Result<Vec<(ExternalTask, Option<crate::backends::Status>)>> {
533        let issues = self.gh.list_all_open_issues(&self.repo).await?;
534
535        Ok(issues
536            .into_iter()
537            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
538            .filter(is_trusted_author) // Only trusted authors
539            .filter_map(classify_active_open_issue)
540            .collect())
541    }
542
543    async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
544        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
545        if !is_github_issue_id(id) {
546            return Ok(());
547        }
548        self.gh.add_comment(&self.repo, &id.0, body).await
549    }
550
551    /// Additive: uses POST (gh.add_labels), so existing labels like bug, priority:high are preserved.
552    async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
553        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
554        if !is_github_issue_id(id) {
555            return Ok(());
556        }
557        self.gh.add_labels(&self.repo, &id.0, labels).await
558    }
559
560    async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()> {
561        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
562        if !is_github_issue_id(id) {
563            return Ok(());
564        }
565        self.gh.remove_label(&self.repo, &id.0, label).await
566    }
567
568    async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
569        let sub_issue_numbers = self.gh.get_sub_issues(&self.repo, &id.0).await?;
570        Ok(sub_issue_numbers
571            .into_iter()
572            .map(|n| ExternalId(n.to_string()))
573            .collect())
574    }
575
576    async fn create_sub_task(
577        &self,
578        parent_id: &ExternalId,
579        title: &str,
580        body: &str,
581        labels: &[String],
582    ) -> anyhow::Result<ExternalId> {
583        // Add parent label for metadata
584        let mut all_labels = labels.to_vec();
585        all_labels.push(format!("parent:{}", parent_id.0));
586
587        // Create the child issue
588        let child_issue = self
589            .gh
590            .create_issue(&self.repo, title, body, &all_labels)
591            .await?;
592
593        let child_id = ExternalId(child_issue.number.to_string());
594
595        // Try to establish native sub-issue relationship via GraphQL
596        if let (Some(child_node_id), Ok(parent_issue)) = (
597            child_issue.node_id,
598            self.gh.get_issue(&self.repo, &parent_id.0).await,
599        ) {
600            if let Some(parent_node_id) = parent_issue.node_id {
601                if let Err(e) = self.gh.add_sub_issue(&parent_node_id, &child_node_id).await {
602                    tracing::warn!(
603                        parent = parent_id.0,
604                        child = child_id.0,
605                        err = %e,
606                        "failed to add native sub-issue link (parent label still set)"
607                    );
608                }
609            }
610        }
611
612        Ok(child_id)
613    }
614
615    async fn has_open_issue_with_title(&self, title: &str, label: &str) -> anyhow::Result<bool> {
616        // Check open issues first
617        let open_issues = if label.is_empty() {
618            self.gh.list_all_open_issues(&self.repo).await?
619        } else {
620            self.gh.list_issues(&self.repo, label).await?
621        };
622        if open_issues.iter().any(|i| i.title == title) {
623            return Ok(true);
624        }
625        // Also check issues closed within the last 24h — prevents re-creating a bug
626        // that was just fixed and merged (the PR close event removes the open issue).
627        let since = (chrono::Utc::now() - chrono::Duration::hours(24))
628            .format("%Y-%m-%dT%H:%M:%SZ")
629            .to_string();
630        let closed = if label.is_empty() {
631            self.gh.list_closed_issues_since(&self.repo, &since).await?
632        } else {
633            self.gh
634                .list_issues_closed_since(&self.repo, label, &since)
635                .await?
636        };
637        Ok(closed.iter().any(|i| i.title == title))
638    }
639
640    async fn health_check(&self) -> anyhow::Result<()> {
641        self.gh.auth_status().await
642    }
643
644    async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
645        self.gh.is_pr_merged(&self.repo, branch).await
646    }
647
648    async fn batch_is_pr_merged(
649        &self,
650        branches: &[String],
651    ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
652        self.gh
653            .batch_is_pr_merged_by_branch(&self.repo, branches)
654            .await
655    }
656
657    async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
658        self.gh.get_whoami().await.map(Some)
659    }
660
661    async fn get_mentions(&self, since: &str) -> anyhow::Result<Vec<Mention>> {
662        let comments = self.gh.get_mentions(&self.repo, since).await?;
663        Ok(trusted_mentions(comments))
664    }
665
666    async fn list_issue_comments(&self, id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
667        let issue_number: u64 =
668            id.0.parse()
669                .map_err(|_| anyhow::anyhow!("invalid issue number: {}", id.0))?;
670        let comments = self.gh.get_issue_comments(&self.repo, issue_number).await?;
671        Ok(trusted_mentions(comments))
672    }
673
674    async fn acknowledge_issue(&self, id: &ExternalId) -> anyhow::Result<()> {
675        self.gh.add_reaction(&self.repo, &id.0, "eyes").await
676    }
677
678    async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
679        self.gh
680            .add_comment_reaction(&self.repo, comment_id, "eyes")
681            .await
682    }
683
684    async fn blocked_by(
685        &self,
686        repo: &str,
687        issue_number: i64,
688    ) -> anyhow::Result<crate::backends::BlockedByFetch> {
689        if issue_number <= 0 {
690            return Ok(crate::backends::BlockedByFetch::Unavailable);
691        }
692        self.gh.list_blocked_by(repo, issue_number as u64).await
693    }
694
695    async fn issue_close_state(
696        &self,
697        repo: &str,
698        issue_number: i64,
699    ) -> anyhow::Result<Option<(String, Option<String>)>> {
700        if issue_number <= 0 {
701            return Ok(None);
702        }
703        match self.gh.get_issue(repo, &issue_number.to_string()).await {
704            Ok(issue) => Ok(Some((issue.state, issue.state_reason))),
705            Err(err) if is_missing_issue(&err) => Ok(None),
706            Err(err) => Err(err),
707        }
708    }
709
710    async fn post_comment_in_repo(
711        &self,
712        repo: &str,
713        id: &ExternalId,
714        body: &str,
715    ) -> anyhow::Result<()> {
716        if !is_github_issue_id(id) {
717            return Ok(());
718        }
719        self.gh.add_comment(repo, &id.0, body).await
720    }
721
722    async fn pull_request_completion(
723        &self,
724        repo: &str,
725        pr_number: i64,
726    ) -> anyhow::Result<Option<crate::backends::PullRequestCompletion>> {
727        if pr_number <= 0 {
728            return Ok(None);
729        }
730        let pr = match self.gh.get_pr(repo, pr_number as u64).await {
731            Ok(pr) => pr,
732            Err(err) if is_missing_issue(&err) => return Ok(None),
733            Err(err) => return Err(err),
734        };
735        let default_branch = self.gh.get_default_branch(repo).await.unwrap_or_default();
736        let app_login = crate::github::app::bot_login().await;
737        let reviews = match self.gh.get_pr_reviews(repo, pr_number as u64).await {
738            Ok(reviews) => {
739                crate::github::app::reviews_for_gate(&reviews, &pr.head.sha, app_login.as_deref())
740            }
741            Err(err) => {
742                tracing::warn!(repo, pr_number, err = %err, "pull request reviews unreadable");
743                Vec::new()
744            }
745        };
746        let comments = match self.gh.list_comments(repo, &pr_number.to_string()).await {
747            Ok(comments) => comments,
748            Err(err) => {
749                tracing::warn!(repo, pr_number, err = %err, "pull request comments unreadable");
750                Vec::new()
751            }
752        };
753        let reviewer = match self.gh.get_whoami().await {
754            Ok(login) => Some(login),
755            Err(err) => {
756                tracing::warn!(repo, pr_number, err = %err, "reviewer login unreadable");
757                None
758            }
759        };
760        let comment_refs: Vec<AutomatedReviewComment<'_>> = comments
761            .iter()
762            .map(|comment| AutomatedReviewComment {
763                login: &comment.user.login,
764                body: &comment.body,
765                created_at: &comment.created_at,
766            })
767            .collect();
768        let comment_approved = reviewer
769            .iter()
770            .chain(app_login.iter())
771            .any(|login| latest_automated_review_approves(&comment_refs, login));
772        // An App review on the head replaces the comment, as in review_poll, so a stale
773        // approving comment cannot outvote an App change request
774        let (comment_approved, _) = crate::github::app::effective_comment_flags(
775            &reviews,
776            &pr.head.sha,
777            app_login.as_deref(),
778            comment_approved,
779            false,
780        );
781        let merged = pr.merged.unwrap_or(false);
782        Ok(Some(crate::backends::PullRequestCompletion {
783            merged,
784            base_ref: pr.base.ref_,
785            default_branch,
786            approved: latest_review_approves(&reviews) || comment_approved,
787            closed: pr.state.eq_ignore_ascii_case("closed"),
788        }))
789    }
790
791    async fn add_dependency(
792        &self,
793        issue_number: i64,
794        blocking_repo: &str,
795        blocking_number: i64,
796    ) -> anyhow::Result<()> {
797        let blocker = self
798            .gh
799            .get_issue(blocking_repo, &blocking_number.to_string())
800            .await?;
801        if blocker.id == 0 {
802            anyhow::bail!("blocking issue {blocking_repo}#{blocking_number} has no database id");
803        }
804        self.gh
805            .add_blocked_by(&self.repo, issue_number as u64, blocker.id as i64)
806            .await
807    }
808}
809
810fn is_missing_issue(err: &anyhow::Error) -> bool {
811    let text = err.to_string().to_ascii_lowercase();
812    text.contains("404") || text.contains("not found")
813}
814
815/// Latest substantive review wins. Comments and dismissed reviews are ignored.
816///
817/// `CHANGES_REQUESTED` after `APPROVED` means the pull request is not approved
818pub(crate) fn latest_review_approves(reviews: &[crate::github::types::GitHubReview]) -> bool {
819    let mut latest: Option<(&str, &str)> = None;
820    for review in reviews {
821        let state = review.state.as_str();
822        let substantive = state.eq_ignore_ascii_case("APPROVED")
823            || state.eq_ignore_ascii_case("CHANGES_REQUESTED");
824        if !substantive {
825            continue;
826        }
827        match latest {
828            Some((at, _)) if review.submitted_at.as_str() < at => {}
829            _ => latest = Some((review.submitted_at.as_str(), state)),
830        }
831    }
832    latest.is_some_and(|(_, state)| state.eq_ignore_ascii_case("APPROVED"))
833}
834
835/// One issue comment considered for automated review approval.
836pub(crate) struct AutomatedReviewComment<'a> {
837    pub login: &'a str,
838    pub body: &'a str,
839    pub created_at: &'a str,
840}
841
842/// First-line verdict of a `## Automated Review` comment.
843///
844/// Orch writes an em dash. The review gate also accepts a hyphen
845pub(crate) fn automated_review_verdict(body: &str) -> Option<&'static str> {
846    let first = body.lines().next().unwrap_or("").trim().replace('\r', "");
847    if !first.starts_with("## Automated Review") {
848        return None;
849    }
850    let normalized = first.replace(['\u{2014}', '\u{2013}'], "-");
851    if normalized.contains("Automated Review - Approve") {
852        Some("approve")
853    } else if normalized.contains("Automated Review - Changes Requested") {
854        Some("changes_requested")
855    } else {
856        None
857    }
858}
859
860/// Latest `## Automated Review` comment from `reviewer` wins.
861///
862/// An empty reviewer matches nothing. A later changes-requested comment
863/// clears an earlier approve
864pub(crate) fn latest_automated_review_approves(
865    comments: &[AutomatedReviewComment<'_>],
866    reviewer: &str,
867) -> bool {
868    if reviewer.is_empty() {
869        return false;
870    }
871    let mut newest: Option<&AutomatedReviewComment<'_>> = None;
872    for comment in comments {
873        if automated_review_verdict(comment.body).is_none() {
874            continue;
875        }
876        if !comment.login.eq_ignore_ascii_case(reviewer) {
877            continue;
878        }
879        match newest {
880            Some(prev) if comment.created_at <= prev.created_at => {}
881            _ => newest = Some(comment),
882        }
883    }
884    newest.is_some_and(|comment| automated_review_verdict(comment.body) == Some("approve"))
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890    use crate::github::types::{GitHubIssue, GitHubReview, GitHubUser};
891
892    fn review(state: &str, at: &str) -> GitHubReview {
893        GitHubReview {
894            id: 1,
895            user: GitHubUser {
896                login: "owner".into(),
897            },
898            body: None,
899            state: state.into(),
900            html_url: None,
901            submitted_at: at.into(),
902            commit_id: None,
903        }
904    }
905
906    #[test]
907    fn later_changes_requested_clears_an_earlier_approval() {
908        let reviews = vec![
909            review("APPROVED", "2026-01-01T00:00:00Z"),
910            review("COMMENTED", "2026-01-02T00:00:00Z"),
911            review("CHANGES_REQUESTED", "2026-01-03T00:00:00Z"),
912        ];
913        assert!(!latest_review_approves(&reviews));
914        let approved = vec![
915            review("CHANGES_REQUESTED", "2026-01-01T00:00:00Z"),
916            review("APPROVED", "2026-01-02T00:00:00Z"),
917        ];
918        assert!(latest_review_approves(&approved));
919    }
920
921    fn review_comment<'a>(
922        login: &'a str,
923        body: &'a str,
924        at: &'a str,
925    ) -> AutomatedReviewComment<'a> {
926        AutomatedReviewComment {
927            login,
928            body,
929            created_at: at,
930        }
931    }
932
933    #[test]
934    fn automated_review_comment_counts_as_approval_without_formal_reviews() {
935        assert!(!latest_review_approves(&[]));
936        let comments = [review_comment(
937            "orch-bot",
938            "## Automated Review \u{2014} Approve\n\nLooks good.",
939            "2026-01-02T00:00:00Z",
940        )];
941        assert!(latest_automated_review_approves(&comments, "orch-bot"));
942        assert!(latest_automated_review_approves(&comments, "Orch-Bot"));
943        assert!(!latest_automated_review_approves(&comments, "mallory"));
944        assert!(!latest_automated_review_approves(&comments, ""));
945        let hyphen = [review_comment(
946            "orch-bot",
947            "## Automated Review - Approve\n\nok",
948            "2026-01-02T00:00:00Z",
949        )];
950        assert!(latest_automated_review_approves(&hyphen, "orch-bot"));
951    }
952
953    #[test]
954    fn later_automated_changes_requested_clears_comment_approval() {
955        let comments = [
956            review_comment(
957                "orch-bot",
958                "## Automated Review \u{2014} Approve\n\nok",
959                "2026-01-01T00:00:00Z",
960            ),
961            review_comment(
962                "mallory",
963                "## Automated Review \u{2014} Changes Requested\n\nnope",
964                "2026-01-03T00:00:00Z",
965            ),
966            review_comment(
967                "orch-bot",
968                "## Automated Review \u{2014} Changes Requested\n\nfix",
969                "2026-01-02T00:00:00Z",
970            ),
971        ];
972        assert!(!latest_automated_review_approves(&comments, "orch-bot"));
973    }
974
975    // --- is_github_issue_id guard ---
976
977    #[test]
978    fn is_github_issue_id_numeric() {
979        assert!(is_github_issue_id(&ExternalId("42".to_string())));
980        assert!(is_github_issue_id(&ExternalId("1".to_string())));
981        assert!(is_github_issue_id(&ExternalId("99999".to_string())));
982    }
983
984    #[test]
985    fn is_github_issue_id_rejects_internal_prefix() {
986        assert!(!is_github_issue_id(&ExternalId(
987            "internal:63857".to_string()
988        )));
989        assert!(!is_github_issue_id(&ExternalId("internal:1".to_string())));
990    }
991
992    #[test]
993    fn is_github_issue_id_rejects_empty_and_non_numeric() {
994        assert!(!is_github_issue_id(&ExternalId("".to_string())));
995        assert!(!is_github_issue_id(&ExternalId("abc".to_string())));
996        assert!(!is_github_issue_id(&ExternalId("1.0".to_string())));
997    }
998
999    // ---
1000
1001    #[test]
1002    fn allowed_associations_are_owner_member_and_collaborator() {
1003        assert_eq!(ALLOWED_ASSOCIATIONS, &["OWNER", "MEMBER", "COLLABORATOR"]);
1004    }
1005
1006    #[test]
1007    fn is_trusted_author_owner() {
1008        let issue = GitHubIssue {
1009            number: 1,
1010            id: 0,
1011            title: "t".to_string(),
1012            body: None,
1013            state: "open".to_string(),
1014            labels: vec![],
1015            user: GitHubUser {
1016                login: "u".to_string(),
1017            },
1018            created_at: "".to_string(),
1019            updated_at: "".to_string(),
1020            html_url: "".to_string(),
1021            node_id: None,
1022            pull_request: None,
1023            state_reason: None,
1024            author_association: Some("OWNER".to_string()),
1025        };
1026        assert!(is_trusted_author(&issue));
1027    }
1028
1029    #[test]
1030    fn is_trusted_author_collaborator() {
1031        let issue = GitHubIssue {
1032            number: 1,
1033            id: 0,
1034            title: "t".to_string(),
1035            body: None,
1036            state: "open".to_string(),
1037            labels: vec![],
1038            user: GitHubUser {
1039                login: "u".to_string(),
1040            },
1041            created_at: "".to_string(),
1042            updated_at: "".to_string(),
1043            html_url: "".to_string(),
1044            node_id: None,
1045            pull_request: None,
1046            state_reason: None,
1047            author_association: Some("COLLABORATOR".to_string()),
1048        };
1049        assert!(is_trusted_author(&issue));
1050    }
1051
1052    #[test]
1053    fn is_not_trusted_author_contributor() {
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("CONTRIBUTOR".to_string()),
1071        };
1072        assert!(!is_trusted_author(&issue));
1073    }
1074
1075    #[test]
1076    fn is_trusted_author_member() {
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("MEMBER".to_string()),
1094        };
1095        assert!(is_trusted_author(&issue));
1096    }
1097
1098    #[test]
1099    fn is_trusted_comment_author_direct() {
1100        use crate::github::types::GitHubComment;
1101        let c = GitHubComment {
1102            id: 1,
1103            body: "hi".to_string(),
1104            user: crate::github::types::GitHubUser {
1105                login: "u".to_string(),
1106            },
1107            created_at: "".to_string(),
1108            updated_at: None,
1109            html_url: None,
1110            issue_url: None,
1111            author_association: Some("OWNER".to_string()),
1112        };
1113        assert!(is_trusted_comment_author(&c));
1114    }
1115
1116    #[test]
1117    fn is_trusted_comment_author_member() {
1118        use crate::github::types::GitHubComment;
1119        let c = GitHubComment {
1120            id: 1,
1121            body: "hi".to_string(),
1122            user: crate::github::types::GitHubUser {
1123                login: "u".to_string(),
1124            },
1125            created_at: "".to_string(),
1126            updated_at: None,
1127            html_url: None,
1128            issue_url: None,
1129            author_association: Some("MEMBER".to_string()),
1130        };
1131        assert!(is_trusted_comment_author(&c));
1132    }
1133
1134    fn comment_json(id: u64, login: &str, association: &str) -> serde_json::Value {
1135        serde_json::json!({
1136            "id": id,
1137            "body": format!("from {login}"),
1138            "user": { "login": login },
1139            "created_at": "2026-01-01T00:00:00Z",
1140            "issue_url": "https://api.github.com/repos/o/r/issues/1",
1141            "author_association": association,
1142        })
1143    }
1144
1145    #[test]
1146    fn trusted_mentions_drops_contributor_on_owner_issue() {
1147        use crate::github::types::GitHubComment;
1148        let comment = GitHubComment {
1149            id: 42,
1150            body: "@orch please look".to_string(),
1151            user: GitHubUser {
1152                login: "stranger".to_string(),
1153            },
1154            created_at: "2026-01-01T00:00:00Z".to_string(),
1155            updated_at: None,
1156            html_url: None,
1157            issue_url: Some("https://api.github.com/repos/o/r/issues/1".to_string()),
1158            author_association: Some("CONTRIBUTOR".to_string()),
1159        };
1160        let issue = GitHubIssue {
1161            number: 1,
1162            id: 0,
1163            title: "t".to_string(),
1164            body: None,
1165            state: "open".to_string(),
1166            labels: vec![],
1167            user: GitHubUser {
1168                login: "owner".to_string(),
1169            },
1170            created_at: "".to_string(),
1171            updated_at: "".to_string(),
1172            html_url: "".to_string(),
1173            node_id: None,
1174            pull_request: None,
1175            state_reason: None,
1176            author_association: Some("OWNER".to_string()),
1177        };
1178        assert!(is_trusted_author(&issue));
1179        assert!(!is_trusted_comment_author(&comment));
1180        let mentions = trusted_mentions([comment]);
1181        assert!(mentions.is_empty());
1182    }
1183
1184    #[allow(clippy::await_holding_lock)]
1185    #[tokio::test]
1186    async fn get_mentions_and_list_issue_comments_exclude_contributor_on_owner_issue() {
1187        use crate::github::http::GhHttp;
1188        use wiremock::matchers::{method, path};
1189        use wiremock::{Mock, MockServer, ResponseTemplate};
1190
1191        let _env = crate::github::token::ENV_LOCK.lock().unwrap();
1192        let prev = std::env::var_os("GH_TOKEN");
1193        std::env::set_var("GH_TOKEN", "test_token");
1194
1195        let server = MockServer::start().await;
1196        let comments = serde_json::json!([
1197            comment_json(42, "stranger", "CONTRIBUTOR"),
1198            comment_json(43, "owner", "OWNER"),
1199        ]);
1200        Mock::given(method("GET"))
1201            .and(path("/repos/o/r/issues/comments"))
1202            .respond_with(ResponseTemplate::new(200).set_body_json(comments.clone()))
1203            .expect(1)
1204            .mount(&server)
1205            .await;
1206        Mock::given(method("GET"))
1207            .and(path("/repos/o/r/issues/1/comments"))
1208            .respond_with(ResponseTemplate::new(200).set_body_json(comments))
1209            .expect(1)
1210            .mount(&server)
1211            .await;
1212        Mock::given(method("GET"))
1213            .and(path("/repos/o/r/issues/1"))
1214            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1215                "number": 1,
1216                "id": 1,
1217                "title": "t",
1218                "state": "open",
1219                "labels": [],
1220                "user": { "login": "owner" },
1221                "created_at": "2026-01-01T00:00:00Z",
1222                "updated_at": "2026-01-01T00:00:00Z",
1223                "html_url": "https://github.com/o/r/issues/1",
1224                "author_association": "OWNER",
1225            })))
1226            .expect(0)
1227            .mount(&server)
1228            .await;
1229
1230        let backend = GitHubBackend::for_test("o/r", GhHttp::for_test(server.uri(), None));
1231        let mentions = backend
1232            .get_mentions("2026-01-01T00:00:00Z")
1233            .await
1234            .expect("get_mentions");
1235        let listed = backend
1236            .list_issue_comments(&ExternalId("1".to_string()))
1237            .await
1238            .expect("list_issue_comments");
1239
1240        match prev {
1241            Some(value) => std::env::set_var("GH_TOKEN", value),
1242            None => std::env::remove_var("GH_TOKEN"),
1243        }
1244
1245        assert_eq!(mentions.len(), 1);
1246        assert_eq!(mentions[0].id, "43");
1247        assert_eq!(mentions[0].author, "owner");
1248        assert_eq!(listed.len(), 1);
1249        assert_eq!(listed[0].id, "43");
1250        assert_eq!(listed[0].author, "owner");
1251    }
1252
1253    fn make_test_issue(labels: &[&str]) -> GitHubIssue {
1254        GitHubIssue {
1255            number: 1,
1256            id: 0,
1257            title: "t".to_string(),
1258            body: None,
1259            state: "open".to_string(),
1260            labels: labels
1261                .iter()
1262                .map(|label| crate::github::types::GitHubLabel {
1263                    name: (*label).to_string(),
1264                    color: None,
1265                })
1266                .collect(),
1267            user: GitHubUser {
1268                login: "u".to_string(),
1269            },
1270            created_at: "".to_string(),
1271            updated_at: "".to_string(),
1272            html_url: "".to_string(),
1273            node_id: None,
1274            pull_request: None,
1275            state_reason: None,
1276            author_association: Some("OWNER".to_string()),
1277        }
1278    }
1279
1280    /// Open issues carrying `status:done` must be excluded from active-open ingest.
1281    #[test]
1282    fn classify_active_open_issue_excludes_status_done() {
1283        assert!(classify_active_open_issue(make_test_issue(&["status:done"])).is_none());
1284        assert!(
1285            classify_active_open_issue(make_test_issue(&["status:routed", "status:done"]))
1286                .is_none()
1287        );
1288    }
1289
1290    #[test]
1291    fn classify_active_open_issue_preserves_unlabeled_and_new_as_none_status() {
1292        let unlabeled = classify_active_open_issue(make_test_issue(&[]))
1293            .expect("unlabeled issue should still be ingested");
1294        assert_eq!(unlabeled.1, None);
1295
1296        let status_new = classify_active_open_issue(make_test_issue(&["status:new"]))
1297            .expect("status:new issue should still be ingested");
1298        assert_eq!(status_new.1, None);
1299    }
1300
1301    #[test]
1302    fn classify_active_open_issue_maps_active_status() {
1303        let routed = classify_active_open_issue(make_test_issue(&["status:routed"]))
1304            .expect("active status issue should be ingested");
1305        assert_eq!(routed.1, Some(Status::Routed));
1306    }
1307}