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