Skip to main content

orch/backends/
github.rs

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