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::{GhHttp, status_label_color};
8use crate::github::projects::ProjectSync;
9use crate::github::projects::label_status::LabelSyncConfig;
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        .is_some_and(|a| ALLOWED_ASSOCIATIONS.contains(&a)) // missing field = untrusted
28}
29
30fn is_trusted_comment_author(c: &crate::github::types::GitHubComment) -> bool {
31    c.author_association
32        .as_deref()
33        .is_some_and(|a| ALLOWED_ASSOCIATIONS.contains(&a))
34}
35
36fn trusted_mentions(
37    comments: impl IntoIterator<Item = crate::github::types::GitHubComment>,
38) -> Vec<Mention> {
39    comments
40        .into_iter()
41        .filter_map(|c| {
42            if !is_trusted_comment_author(&c) {
43                tracing::debug!(
44                    comment_id = c.id,
45                    author = %c.user.login,
46                    "ignoring comment from untrusted author"
47                );
48                return None;
49            }
50            Some(Mention {
51                id: c.id.to_string(),
52                body: c.body,
53                author: c.user.login,
54                created_at: c.created_at,
55                issue_url: c.issue_url,
56            })
57        })
58        .collect()
59}
60
61fn active_status_from_label(label: &str) -> Option<Status> {
62    match label {
63        "status:routed" => Some(Status::Routed),
64        "status:in_progress" => Some(Status::InProgress),
65        "status:needs_review" => Some(Status::NeedsReview),
66        "status:in_review" => Some(Status::InReview),
67        "status:approved" => Some(Status::Approved),
68        "status:blocked" => Some(Status::Blocked),
69        "status:waiting" => Some(Status::Waiting),
70        _ => None,
71    }
72}
73
74fn classify_active_open_issue(
75    issue: crate::github::types::GitHubIssue,
76) -> Option<(ExternalTask, Option<Status>)> {
77    // Guard: open issues tagged as done should never be re-ingested as new.
78    if issue.labels.iter().any(|l| l.name == "status:done") {
79        return None;
80    }
81
82    let status = issue
83        .labels
84        .iter()
85        .find_map(|l| active_status_from_label(&l.name));
86    let task = ExternalTask {
87        id: ExternalId(issue.number.to_string()),
88        title: issue.title,
89        body: issue.body.unwrap_or_default(),
90        state: issue.state,
91        labels: issue.labels.into_iter().map(|l| l.name).collect(),
92        author: issue.user.login,
93        created_at: issue.created_at,
94        updated_at: issue.updated_at,
95        url: issue.html_url,
96    };
97    Some((task, status))
98}
99
100#[derive(Debug)]
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) -> &'static 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 Some(project) = ProjectSync::from_config() else {
210            return Ok(None);
211        };
212
213        // Fetch the issue to get its node_id
214        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
215
216        // Get the node_id (required for project board operations)
217        let node_id = issue
218            .node_id
219            .as_deref()
220            .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?
221            .to_string();
222
223        // Add to project board and set initial status
224        self.sync_project_status(&project, &node_id, &status)
225            .await
226            .map_err(|e| anyhow::anyhow!("project board sync failed: {e}"))?;
227
228        Ok(Some(node_id))
229    }
230
231    async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
232        // Projects V2 on a personal account is out of reach for an installation token
233        LabelSyncConfig::from_config()?
234            .reconcile(&GhHttp::owner()?, &self.repo)
235            .await
236            .map(|_| ())
237    }
238
239    async fn sync_estimate_to_project(&self, id: &ExternalId, estimate: u8) -> anyhow::Result<()> {
240        let Some(project) = ProjectSync::from_config() else {
241            return Ok(());
242        };
243
244        // If the estimate field ID is missing from config, try to discover it
245        // automatically and persist it for future calls. This handles the case
246        // where the service was set up before the estimate sync feature existed.
247        let project = if project.estimate_field_id().is_none() {
248            match ProjectSync::discover_fields(project.project_id()).await {
249                Ok(discovered) => {
250                    if discovered.estimate_field_id().is_some() {
251                        match crate::github::projects::write_project_config(&discovered).await {
252                            Err(e) => {
253                                tracing::warn!(
254                                    err = %e,
255                                    "failed to persist discovered estimate field ID to config"
256                                );
257                            }
258                            _ => {
259                                tracing::info!(
260                                    "auto-discovered and persisted project_estimate_field_id to config"
261                                );
262                            }
263                        }
264                        discovered
265                    } else {
266                        tracing::debug!(
267                            "project has no 'Estimate' number field — skipping estimate sync"
268                        );
269                        return Ok(());
270                    }
271                }
272                Err(e) => {
273                    tracing::debug!(
274                        err = %e,
275                        "estimate field auto-discovery failed — skipping estimate sync"
276                    );
277                    return Ok(());
278                }
279            }
280        } else {
281            project
282        };
283
284        // Fetch the issue to get its node_id.
285        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
286        let node_id = issue
287            .node_id
288            .as_deref()
289            .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?;
290
291        project
292            .sync_item_estimate(node_id, estimate)
293            .await
294            .map_err(|e| anyhow::anyhow!("project estimate sync failed: {e}"))?;
295
296        Ok(())
297    }
298
299    async fn get_project_item_estimates(
300        &self,
301        issue_node_ids: &[String],
302    ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
303        let Some(project) = ProjectSync::from_config() else {
304            return Ok(std::collections::HashMap::new());
305        };
306
307        if issue_node_ids.is_empty() {
308            return Ok(std::collections::HashMap::new());
309        }
310
311        project
312            .get_estimates_for_issues(issue_node_ids)
313            .await
314            .map_err(|e| anyhow::anyhow!("project estimate fetch failed: {e}"))
315    }
316
317    /// Override default update_status to add project board sync after label update.
318    async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
319        const MAX_RETRIES: u32 = 3;
320        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
321        if !is_github_issue_id(id) {
322            return Ok(());
323        }
324
325        // Run the standard label-based status update (default trait impl logic)
326        let label = status.as_label();
327
328        if let Err(e) = self.ensure_status_label(label).await {
329            tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
330        }
331
332        let mut last_err =
333            anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
334
335        let mut issue_node_id: Option<String> = None;
336
337        for attempt in 0..MAX_RETRIES {
338            if attempt > 0 {
339                tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
340                    .await;
341                tracing::debug!(attempt, label, "retrying update_status");
342            }
343
344            let issue = match self.gh.get_issue(&self.repo, &id.0).await {
345                Ok(i) => i,
346                Err(e) => {
347                    last_err = e;
348                    continue;
349                }
350            };
351
352            // Capture node_id for project sync
353            if issue_node_id.is_none() {
354                issue_node_id = issue.node_id.clone();
355            }
356
357            let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
358
359            let mut remove_failed = false;
360            for old in labels
361                .iter()
362                .filter(|l| l.starts_with("status:") && l.as_str() != label)
363            {
364                if let Err(e) = self.remove_label(id, old).await {
365                    tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
366                    last_err = e;
367                    remove_failed = true;
368                    break;
369                }
370            }
371            if remove_failed {
372                continue;
373            }
374
375            match self.set_labels(id, &[label.to_string()]).await {
376                Ok(()) => {
377                    // Label update succeeded — now sync project board (non-fatal)
378                    if let Some(ref node_id) = issue_node_id
379                        && let Some(project) = ProjectSync::from_config()
380                        && let Err(e) = self.sync_project_status(&project, node_id, &status).await
381                    {
382                        tracing::warn!(
383                            task_id = id.0,
384                            err = %e,
385                            "project board sync failed (non-fatal)"
386                        );
387                    }
388
389                    // Auto-close issue when status → Done (if auto_close enabled)
390                    if status == Status::Done {
391                        let auto_close =
392                            crate::config::get("workflow.auto_close").map_or(true, |v| v == "true");
393                        if auto_close && let Err(e) = self.gh.close_issue(&self.repo, &id.0).await {
394                            tracing::warn!(
395                                task_id = id.0,
396                                err = %e,
397                                "auto-close issue failed (non-fatal)"
398                            );
399                        }
400                    }
401
402                    return Ok(());
403                }
404                Err(e) => {
405                    tracing::warn!(label, err = %e, attempt, "set_labels failed");
406                    last_err = e;
407                }
408            }
409        }
410
411        Err(last_err.context(format!(
412            "update_status({label}) failed after {MAX_RETRIES} attempts"
413        )))
414    }
415
416    async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
417        // Done issues are auto-closed by the engine, so we must query state=all
418        // to find them. All other statuses only live on open issues.
419        let state = if status == Status::Done {
420            "all"
421        } else {
422            "open"
423        };
424        let issues = self
425            .gh
426            .list_issues_with_state(&self.repo, status.as_label(), state)
427            .await?;
428        Ok(issues
429            .into_iter()
430            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
431            .filter(is_trusted_author) // Only trusted authors
432            .map(|issue| ExternalTask {
433                id: ExternalId(issue.number.to_string()),
434                title: issue.title,
435                body: issue.body.unwrap_or_default(),
436                state: issue.state,
437                labels: issue.labels.into_iter().map(|l| l.name).collect(),
438                author: issue.user.login,
439                created_at: issue.created_at,
440                updated_at: issue.updated_at,
441                url: issue.html_url,
442            })
443            .collect())
444    }
445
446    /// List all tasks (open and closed) in a single API call.
447    ///
448    /// This fetches all issues with state=all and filters by status labels locally.
449    /// More efficient than making 7 separate calls to `list_by_status`.
450    async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
451        let issues = self.gh.list_all_issues(&self.repo).await?;
452        Ok(issues
453            .into_iter()
454            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
455            .filter(is_trusted_author) // Only trusted authors
456            .map(|issue| ExternalTask {
457                id: ExternalId(issue.number.to_string()),
458                title: issue.title,
459                body: issue.body.unwrap_or_default(),
460                state: issue.state,
461                labels: issue.labels.into_iter().map(|l| l.name).collect(),
462                author: issue.user.login,
463                created_at: issue.created_at,
464                updated_at: issue.updated_at,
465                url: issue.html_url,
466            })
467            .collect())
468    }
469
470    async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
471        let since = (Utc::now() - Duration::days(30)).to_rfc3339();
472        let closed = self.gh.list_closed_issues_since(&self.repo, &since).await?;
473
474        Ok(closed
475            .into_iter()
476            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
477            .filter(is_trusted_author) // Only trusted authors
478            .map(|issue| ExternalTask {
479                id: ExternalId(issue.number.to_string()),
480                title: issue.title,
481                body: issue.body.unwrap_or_default(),
482                state: issue.state,
483                labels: issue.labels.into_iter().map(|l| l.name).collect(),
484                author: issue.user.login,
485                created_at: issue.created_at,
486                updated_at: issue.updated_at,
487                url: issue.html_url,
488            })
489            .collect())
490    }
491
492    /// List open issues that have no `status:*` label (unprocessed) or have `status:new`.
493    async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
494        let issues = self.gh.list_all_open_issues(&self.repo).await?;
495        Ok(issues
496            .into_iter()
497            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
498            .filter(is_trusted_author) // Only trusted authors
499            .filter(|issue| {
500                let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
501                // Include if: has status:new, OR has no status:* label at all
502                let has_status = labels.iter().any(|l| l.starts_with("status:"));
503                !has_status || labels.contains(&"status:new")
504            })
505            .map(|issue| ExternalTask {
506                id: ExternalId(issue.number.to_string()),
507                title: issue.title,
508                body: issue.body.unwrap_or_default(),
509                state: issue.state,
510                labels: issue.labels.into_iter().map(|l| l.name).collect(),
511                author: issue.user.login,
512                created_at: issue.created_at,
513                updated_at: issue.updated_at,
514                url: issue.html_url,
515            })
516            .collect())
517    }
518
519    /// Single-call override: fetches all open issues once and partitions by status label locally,
520    /// replacing the 1 (list_routable) + 5 (list_by_status per active status) fan-out with a
521    /// single `list_all_open_issues` request.
522    async fn list_active_open_issues(
523        &self,
524    ) -> anyhow::Result<Vec<(ExternalTask, Option<crate::backends::Status>)>> {
525        let issues = self.gh.list_all_open_issues(&self.repo).await?;
526
527        Ok(issues
528            .into_iter()
529            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
530            .filter(is_trusted_author) // Only trusted authors
531            .filter_map(classify_active_open_issue)
532            .collect())
533    }
534
535    async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
536        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
537        if !is_github_issue_id(id) {
538            return Ok(());
539        }
540        self.gh.add_comment(&self.repo, &id.0, body).await
541    }
542
543    /// Additive: uses POST (gh.add_labels), so existing labels like bug, priority:high are preserved.
544    async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
545        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
546        if !is_github_issue_id(id) {
547            return Ok(());
548        }
549        self.gh.add_labels(&self.repo, &id.0, labels).await
550    }
551
552    async fn remove_label(&self, id: &ExternalId, label: &str) -> 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.remove_label(&self.repo, &id.0, label).await
558    }
559
560    async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
561        let sub_issue_numbers = self.gh.get_sub_issues(&self.repo, &id.0).await?;
562        Ok(sub_issue_numbers
563            .into_iter()
564            .map(|n| ExternalId(n.to_string()))
565            .collect())
566    }
567
568    async fn create_sub_task(
569        &self,
570        parent_id: &ExternalId,
571        title: &str,
572        body: &str,
573        labels: &[String],
574    ) -> anyhow::Result<ExternalId> {
575        // Add parent label for metadata
576        let mut all_labels = labels.to_vec();
577        all_labels.push(format!("parent:{}", parent_id.0));
578
579        // Create the child issue
580        let child_issue = self
581            .gh
582            .create_issue(&self.repo, title, body, &all_labels)
583            .await?;
584
585        let child_id = ExternalId(child_issue.number.to_string());
586
587        // Try to establish native sub-issue relationship via GraphQL
588        if let (Some(child_node_id), Ok(parent_issue)) = (
589            child_issue.node_id,
590            self.gh.get_issue(&self.repo, &parent_id.0).await,
591        ) && let Some(parent_node_id) = parent_issue.node_id
592            && let Err(e) = self.gh.add_sub_issue(&parent_node_id, &child_node_id).await
593        {
594            tracing::warn!(
595                parent = parent_id.0,
596                child = child_id.0,
597                err = %e,
598                "failed to add native sub-issue link (parent label still set)"
599            );
600        }
601
602        Ok(child_id)
603    }
604
605    async fn has_open_issue_with_title(&self, title: &str, label: &str) -> anyhow::Result<bool> {
606        // Check open issues first
607        let open_issues = if label.is_empty() {
608            self.gh.list_all_open_issues(&self.repo).await?
609        } else {
610            self.gh.list_issues(&self.repo, label).await?
611        };
612        if open_issues.iter().any(|i| i.title == title) {
613            return Ok(true);
614        }
615        // Also check issues closed within the last 24h — prevents re-creating a bug
616        // that was just fixed and merged (the PR close event removes the open issue).
617        let since = (chrono::Utc::now() - chrono::Duration::hours(24))
618            .format("%Y-%m-%dT%H:%M:%SZ")
619            .to_string();
620        let closed = if label.is_empty() {
621            self.gh.list_closed_issues_since(&self.repo, &since).await?
622        } else {
623            self.gh
624                .list_issues_closed_since(&self.repo, label, &since)
625                .await?
626        };
627        Ok(closed.iter().any(|i| i.title == title))
628    }
629
630    async fn health_check(&self) -> anyhow::Result<()> {
631        self.gh.auth_status().await
632    }
633
634    async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
635        self.gh.is_pr_merged(&self.repo, branch).await
636    }
637
638    async fn batch_is_pr_merged(
639        &self,
640        branches: &[String],
641    ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
642        self.gh
643            .batch_is_pr_merged_by_branch(&self.repo, branches)
644            .await
645    }
646
647    async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
648        self.gh.get_whoami().await.map(Some)
649    }
650
651    async fn get_mentions(&self, since: &str) -> anyhow::Result<Vec<Mention>> {
652        let comments = self.gh.get_mentions(&self.repo, since).await?;
653        Ok(trusted_mentions(comments))
654    }
655
656    async fn list_issue_comments(&self, id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
657        let issue_number: u64 =
658            id.0.parse()
659                .map_err(|_| anyhow::anyhow!("invalid issue number: {}", id.0))?;
660        let comments = self.gh.get_issue_comments(&self.repo, issue_number).await?;
661        Ok(trusted_mentions(comments))
662    }
663
664    async fn acknowledge_issue(&self, id: &ExternalId) -> anyhow::Result<()> {
665        self.gh.add_reaction(&self.repo, &id.0, "eyes").await
666    }
667
668    async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
669        self.gh
670            .add_comment_reaction(&self.repo, comment_id, "eyes")
671            .await
672    }
673
674    async fn blocked_by(
675        &self,
676        repo: &str,
677        issue_number: i64,
678    ) -> anyhow::Result<crate::backends::BlockedByFetch> {
679        if issue_number <= 0 {
680            return Ok(crate::backends::BlockedByFetch::Unavailable);
681        }
682        self.gh
683            .list_blocked_by(repo, issue_number.cast_unsigned())
684            .await
685    }
686
687    async fn issue_close_state(
688        &self,
689        repo: &str,
690        issue_number: i64,
691    ) -> anyhow::Result<Option<(String, Option<String>)>> {
692        if issue_number <= 0 {
693            return Ok(None);
694        }
695        match self.gh.get_issue(repo, &issue_number.to_string()).await {
696            Ok(issue) => Ok(Some((issue.state, issue.state_reason))),
697            Err(err) if is_missing_issue(&err) => Ok(None),
698            Err(err) => Err(err),
699        }
700    }
701
702    async fn post_comment_in_repo(
703        &self,
704        repo: &str,
705        id: &ExternalId,
706        body: &str,
707    ) -> anyhow::Result<()> {
708        if !is_github_issue_id(id) {
709            return Ok(());
710        }
711        self.gh.add_comment(repo, &id.0, body).await
712    }
713
714    async fn pull_request_completion(
715        &self,
716        repo: &str,
717        pr_number: i64,
718    ) -> anyhow::Result<Option<crate::backends::PullRequestCompletion>> {
719        if pr_number <= 0 {
720            return Ok(None);
721        }
722        let pr = match self.gh.get_pr(repo, pr_number.cast_unsigned()).await {
723            Ok(pr) => pr,
724            Err(err) if is_missing_issue(&err) => return Ok(None),
725            Err(err) => return Err(err),
726        };
727        let default_branch = self.gh.get_default_branch(repo).await.unwrap_or_default();
728        let app_login = crate::github::app::bot_login().await;
729        let reviews = match self
730            .gh
731            .get_pr_reviews(repo, pr_number.cast_unsigned())
732            .await
733        {
734            Ok(reviews) => {
735                crate::github::app::reviews_for_gate(&reviews, &pr.head.sha, app_login.as_deref())
736            }
737            Err(err) => {
738                tracing::warn!(repo, pr_number, err = %err, "pull request reviews unreadable");
739                Vec::new()
740            }
741        };
742        let comments = match self.gh.list_comments(repo, &pr_number.to_string()).await {
743            Ok(comments) => comments,
744            Err(err) => {
745                tracing::warn!(repo, pr_number, err = %err, "pull request comments unreadable");
746                Vec::new()
747            }
748        };
749        let reviewer = match self.gh.get_whoami().await {
750            Ok(login) => Some(login),
751            Err(err) => {
752                tracing::warn!(repo, pr_number, err = %err, "reviewer login unreadable");
753                None
754            }
755        };
756        let comment_refs: Vec<AutomatedReviewComment<'_>> = comments
757            .iter()
758            .map(|comment| AutomatedReviewComment {
759                login: &comment.user.login,
760                body: &comment.body,
761                created_at: &comment.created_at,
762            })
763            .collect();
764        let comment_approved = reviewer
765            .iter()
766            .chain(app_login.iter())
767            .any(|login| latest_automated_review_approves(&comment_refs, login));
768        // An App review on the head replaces the comment, as in review_poll, so a stale
769        // approving comment cannot outvote an App change request
770        let (comment_approved, _) = crate::github::app::effective_comment_flags(
771            &reviews,
772            &pr.head.sha,
773            app_login.as_deref(),
774            comment_approved,
775            false,
776        );
777        let merged = pr.merged.unwrap_or(false);
778        Ok(Some(crate::backends::PullRequestCompletion {
779            merged,
780            base_ref: pr.base.ref_,
781            default_branch,
782            approved: latest_review_approves(&reviews) || comment_approved,
783            closed: pr.state.eq_ignore_ascii_case("closed"),
784        }))
785    }
786
787    async fn add_dependency(
788        &self,
789        issue_number: i64,
790        blocking_repo: &str,
791        blocking_number: i64,
792    ) -> anyhow::Result<()> {
793        let blocker = self
794            .gh
795            .get_issue(blocking_repo, &blocking_number.to_string())
796            .await?;
797        if blocker.id == 0 {
798            anyhow::bail!("blocking issue {blocking_repo}#{blocking_number} has no database id");
799        }
800        self.gh
801            .add_blocked_by(
802                &self.repo,
803                issue_number.cast_unsigned(),
804                blocker.id.cast_signed(),
805            )
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(String::new())));
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: String::new(),
1019            updated_at: String::new(),
1020            html_url: String::new(),
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: String::new(),
1042            updated_at: String::new(),
1043            html_url: String::new(),
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: String::new(),
1065            updated_at: String::new(),
1066            html_url: String::new(),
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: String::new(),
1088            updated_at: String::new(),
1089            html_url: String::new(),
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: String::new(),
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: String::new(),
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: String::new(),
1171            updated_at: String::new(),
1172            html_url: String::new(),
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    #[expect(
1185        clippy::await_holding_lock,
1186        reason = "the env lock stays held across the awaits so tests do not race on GH_TOKEN"
1187    )]
1188    #[tokio::test]
1189    async fn get_mentions_and_list_issue_comments_exclude_contributor_on_owner_issue() {
1190        use crate::github::http::GhHttp;
1191        use wiremock::matchers::{method, path};
1192        use wiremock::{Mock, MockServer, ResponseTemplate};
1193
1194        let _env = crate::github::token::ENV_LOCK.lock().unwrap();
1195        let prev = std::env::var_os("GH_TOKEN");
1196        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1197        unsafe { std::env::set_var("GH_TOKEN", "test_token") };
1198
1199        let server = MockServer::start().await;
1200        let comments = serde_json::json!([
1201            comment_json(42, "stranger", "CONTRIBUTOR"),
1202            comment_json(43, "owner", "OWNER"),
1203        ]);
1204        Mock::given(method("GET"))
1205            .and(path("/repos/o/r/issues/comments"))
1206            .respond_with(ResponseTemplate::new(200).set_body_json(comments.clone()))
1207            .expect(1)
1208            .mount(&server)
1209            .await;
1210        Mock::given(method("GET"))
1211            .and(path("/repos/o/r/issues/1/comments"))
1212            .respond_with(ResponseTemplate::new(200).set_body_json(comments))
1213            .expect(1)
1214            .mount(&server)
1215            .await;
1216        Mock::given(method("GET"))
1217            .and(path("/repos/o/r/issues/1"))
1218            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1219                "number": 1,
1220                "id": 1,
1221                "title": "t",
1222                "state": "open",
1223                "labels": [],
1224                "user": { "login": "owner" },
1225                "created_at": "2026-01-01T00:00:00Z",
1226                "updated_at": "2026-01-01T00:00:00Z",
1227                "html_url": "https://github.com/o/r/issues/1",
1228                "author_association": "OWNER",
1229            })))
1230            .expect(0)
1231            .mount(&server)
1232            .await;
1233
1234        let backend = GitHubBackend::for_test("o/r", GhHttp::for_test(server.uri(), None));
1235        let mentions = backend
1236            .get_mentions("2026-01-01T00:00:00Z")
1237            .await
1238            .expect("get_mentions");
1239        let listed = backend
1240            .list_issue_comments(&ExternalId("1".to_string()))
1241            .await
1242            .expect("list_issue_comments");
1243
1244        match prev {
1245            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1246            Some(value) => unsafe { std::env::set_var("GH_TOKEN", value) },
1247            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
1248            None => unsafe { std::env::remove_var("GH_TOKEN") },
1249        }
1250
1251        assert_eq!(mentions.len(), 1);
1252        assert_eq!(mentions[0].id, "43");
1253        assert_eq!(mentions[0].author, "owner");
1254        assert_eq!(listed.len(), 1);
1255        assert_eq!(listed[0].id, "43");
1256        assert_eq!(listed[0].author, "owner");
1257    }
1258
1259    fn make_test_issue(labels: &[&str]) -> GitHubIssue {
1260        GitHubIssue {
1261            number: 1,
1262            id: 0,
1263            title: "t".to_string(),
1264            body: None,
1265            state: "open".to_string(),
1266            labels: labels
1267                .iter()
1268                .map(|label| crate::github::types::GitHubLabel {
1269                    name: (*label).to_string(),
1270                    color: None,
1271                })
1272                .collect(),
1273            user: GitHubUser {
1274                login: "u".to_string(),
1275            },
1276            created_at: String::new(),
1277            updated_at: String::new(),
1278            html_url: String::new(),
1279            node_id: None,
1280            pull_request: None,
1281            state_reason: None,
1282            author_association: Some("OWNER".to_string()),
1283        }
1284    }
1285
1286    /// Open issues carrying `status:done` must be excluded from active-open ingest.
1287    #[test]
1288    fn classify_active_open_issue_excludes_status_done() {
1289        assert!(classify_active_open_issue(make_test_issue(&["status:done"])).is_none());
1290        assert!(
1291            classify_active_open_issue(make_test_issue(&["status:routed", "status:done"]))
1292                .is_none()
1293        );
1294    }
1295
1296    #[test]
1297    fn classify_active_open_issue_preserves_unlabeled_and_new_as_none_status() {
1298        let unlabeled = classify_active_open_issue(make_test_issue(&[]))
1299            .expect("unlabeled issue should still be ingested");
1300        assert_eq!(unlabeled.1, None);
1301
1302        let status_new = classify_active_open_issue(make_test_issue(&["status:new"]))
1303            .expect("status:new issue should still be ingested");
1304        assert_eq!(status_new.1, None);
1305    }
1306
1307    #[test]
1308    fn classify_active_open_issue_maps_active_status() {
1309        let routed = classify_active_open_issue(make_test_issue(&["status:routed"]))
1310            .expect("active status issue should be ingested");
1311        assert_eq!(routed.1, Some(Status::Routed));
1312    }
1313}