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
18const ALLOWED_ASSOCIATIONS: &[&str] = &["OWNER", "COLLABORATOR"];
19
20/// Check if an issue author is trusted
21fn is_trusted_author(issue: &crate::github::types::GitHubIssue) -> bool {
22    issue
23        .author_association
24        .as_deref()
25        .map(|a| ALLOWED_ASSOCIATIONS.contains(&a))
26        .unwrap_or(false) // missing field = untrusted
27}
28
29// Check if a comment's author is trusted. The
30// GitHub REST comment payload includes an `author_association` field, so
31// prefer that when present. Missing association is treated as untrusted.
32fn is_trusted_comment_author(c: &crate::github::types::GitHubComment) -> bool {
33    c.author_association
34        .as_deref()
35        .map(|a| ALLOWED_ASSOCIATIONS.contains(&a))
36        .unwrap_or(false)
37}
38
39fn active_status_from_label(label: &str) -> Option<Status> {
40    match label {
41        "status:routed" => Some(Status::Routed),
42        "status:in_progress" => Some(Status::InProgress),
43        "status:needs_review" => Some(Status::NeedsReview),
44        "status:in_review" => Some(Status::InReview),
45        "status:blocked" => Some(Status::Blocked),
46        _ => None,
47    }
48}
49
50fn classify_active_open_issue(
51    issue: crate::github::types::GitHubIssue,
52) -> Option<(ExternalTask, Option<Status>)> {
53    // Guard: open issues tagged as done should never be re-ingested as new.
54    if issue.labels.iter().any(|l| l.name == "status:done") {
55        return None;
56    }
57
58    let status = issue
59        .labels
60        .iter()
61        .find_map(|l| active_status_from_label(&l.name));
62    let task = ExternalTask {
63        id: ExternalId(issue.number.to_string()),
64        title: issue.title,
65        body: issue.body.unwrap_or_default(),
66        state: issue.state,
67        labels: issue.labels.into_iter().map(|l| l.name).collect(),
68        author: issue.user.login,
69        created_at: issue.created_at,
70        updated_at: issue.updated_at,
71        url: issue.html_url,
72    };
73    Some((task, status))
74}
75
76pub struct GitHubBackend {
77    repo: String,
78    gh: GhHttp,
79}
80
81/// Returns `true` if `id` is a valid GitHub issue number (positive integer).
82///
83/// Internal tasks use IDs like `"internal:63857"` which are not valid GitHub
84/// issue numbers. Backend methods that call the GitHub API must skip these IDs
85/// to avoid spurious 404 errors.
86fn is_github_issue_id(id: &ExternalId) -> bool {
87    id.0.parse::<u64>().is_ok()
88}
89
90impl GitHubBackend {
91    pub fn new(repo: String) -> anyhow::Result<Self> {
92        Ok(Self {
93            repo,
94            gh: GhHttp::new()?,
95        })
96    }
97
98    async fn sync_project_status(
99        &self,
100        project: &ProjectSync,
101        node_id: &str,
102        status: &Status,
103    ) -> anyhow::Result<()> {
104        if LabelSyncConfig::from_config()?.manages(&self.repo, project.project_id()) {
105            // Preserve existing ingestion membership behavior. The reconciler
106            // owns the field value and reads the current labels on the next sync.
107            project.add_item(node_id).await?;
108            Ok(())
109        } else {
110            project.sync_item_status(node_id, status).await
111        }
112    }
113}
114
115#[async_trait]
116impl ExternalBackend for GitHubBackend {
117    fn name(&self) -> &str {
118        "github"
119    }
120
121    async fn create_task(
122        &self,
123        title: &str,
124        body: &str,
125        labels: &[String],
126    ) -> anyhow::Result<ExternalId> {
127        let issue = self
128            .gh
129            .create_issue(&self.repo, title, body, labels)
130            .await?;
131        Ok(ExternalId(issue.number.to_string()))
132    }
133
134    async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
135        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
136        Ok(ExternalTask {
137            id: id.clone(),
138            title: issue.title,
139            body: issue.body.unwrap_or_default(),
140            state: issue.state,
141            labels: issue.labels.into_iter().map(|l| l.name).collect(),
142            author: issue.user.login,
143            created_at: issue.created_at,
144            updated_at: issue.updated_at,
145            url: issue.html_url,
146        })
147    }
148
149    // update_status uses the default trait implementation (retry loop with
150    // get_task → remove_label → set_labels). Only ensure_status_label is
151    // overridden to auto-create labels on the GitHub repo.
152
153    async fn ensure_status_label(&self, label: &str) -> anyhow::Result<()> {
154        let status_name = &label["status:".len()..];
155        self.gh
156            .ensure_label(
157                &self.repo,
158                label,
159                status_label_color(label),
160                &format!("Task status: {status_name}"),
161            )
162            .await
163    }
164
165    /// Sync a task to the GitHub Project board (if configured).
166    ///
167    /// Called when a task is first ingested to ensure it appears on the
168    /// project board immediately, not just when status changes later.
169    /// Returns `Some(node_id)` if the item was added to the project board.
170    async fn sync_to_project(
171        &self,
172        id: &ExternalId,
173        status: Status,
174    ) -> anyhow::Result<Option<String>> {
175        // Only sync if project integration is configured
176        let project = match ProjectSync::from_config() {
177            Some(p) => p,
178            None => return Ok(None),
179        };
180
181        // Fetch the issue to get its node_id
182        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
183
184        // Get the node_id (required for project board operations)
185        let node_id = issue
186            .node_id
187            .as_deref()
188            .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?
189            .to_string();
190
191        // Add to project board and set initial status
192        self.sync_project_status(&project, &node_id, &status)
193            .await
194            .map_err(|e| anyhow::anyhow!("project board sync failed: {e}"))?;
195
196        Ok(Some(node_id))
197    }
198
199    async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
200        LabelSyncConfig::from_config()?
201            .reconcile(&self.gh, &self.repo)
202            .await
203            .map(|_| ())
204    }
205
206    async fn sync_estimate_to_project(&self, id: &ExternalId, estimate: u8) -> anyhow::Result<()> {
207        let project = match ProjectSync::from_config() {
208            Some(p) => p,
209            None => return Ok(()),
210        };
211
212        // If the estimate field ID is missing from config, try to discover it
213        // automatically and persist it for future calls. This handles the case
214        // where the service was set up before the estimate sync feature existed.
215        let project = if project.estimate_field_id().is_none() {
216            match ProjectSync::discover_fields(project.project_id()).await {
217                Ok(discovered) => {
218                    if discovered.estimate_field_id().is_some() {
219                        if let Err(e) =
220                            crate::github::projects::write_project_config(&discovered).await
221                        {
222                            tracing::warn!(
223                                err = %e,
224                                "failed to persist discovered estimate field ID to config"
225                            );
226                        } else {
227                            tracing::info!(
228                                "auto-discovered and persisted project_estimate_field_id to config"
229                            );
230                        }
231                        discovered
232                    } else {
233                        tracing::debug!(
234                            "project has no 'Estimate' number field — skipping estimate sync"
235                        );
236                        return Ok(());
237                    }
238                }
239                Err(e) => {
240                    tracing::debug!(
241                        err = %e,
242                        "estimate field auto-discovery failed — skipping estimate sync"
243                    );
244                    return Ok(());
245                }
246            }
247        } else {
248            project
249        };
250
251        // Fetch the issue to get its node_id.
252        let issue = self.gh.get_issue(&self.repo, &id.0).await?;
253        let node_id = issue
254            .node_id
255            .as_deref()
256            .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?;
257
258        project
259            .sync_item_estimate(node_id, estimate)
260            .await
261            .map_err(|e| anyhow::anyhow!("project estimate sync failed: {e}"))?;
262
263        Ok(())
264    }
265
266    async fn get_project_item_estimates(
267        &self,
268        issue_node_ids: &[String],
269    ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
270        let project = match ProjectSync::from_config() {
271            Some(p) => p,
272            None => return Ok(std::collections::HashMap::new()),
273        };
274
275        if issue_node_ids.is_empty() {
276            return Ok(std::collections::HashMap::new());
277        }
278
279        project
280            .get_estimates_for_issues(issue_node_ids)
281            .await
282            .map_err(|e| anyhow::anyhow!("project estimate fetch failed: {e}"))
283    }
284
285    /// Override default update_status to add project board sync after label update.
286    async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
287        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
288        if !is_github_issue_id(id) {
289            return Ok(());
290        }
291
292        // Run the standard label-based status update (default trait impl logic)
293        let label = status.as_label();
294
295        if let Err(e) = self.ensure_status_label(label).await {
296            tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
297        }
298
299        const MAX_RETRIES: u32 = 3;
300        let mut last_err =
301            anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
302
303        let mut issue_node_id: Option<String> = None;
304
305        for attempt in 0..MAX_RETRIES {
306            if attempt > 0 {
307                tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
308                    .await;
309                tracing::debug!(attempt, label, "retrying update_status");
310            }
311
312            let issue = match self.gh.get_issue(&self.repo, &id.0).await {
313                Ok(i) => i,
314                Err(e) => {
315                    last_err = e;
316                    continue;
317                }
318            };
319
320            // Capture node_id for project sync
321            if issue_node_id.is_none() {
322                issue_node_id = issue.node_id.clone();
323            }
324
325            let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
326
327            let mut remove_failed = false;
328            for old in labels
329                .iter()
330                .filter(|l| l.starts_with("status:") && l.as_str() != label)
331            {
332                if let Err(e) = self.remove_label(id, old).await {
333                    tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
334                    last_err = e;
335                    remove_failed = true;
336                    break;
337                }
338            }
339            if remove_failed {
340                continue;
341            }
342
343            match self.set_labels(id, &[label.to_string()]).await {
344                Ok(()) => {
345                    // Label update succeeded — now sync project board (non-fatal)
346                    if let Some(ref node_id) = issue_node_id {
347                        if let Some(project) = ProjectSync::from_config() {
348                            if let Err(e) =
349                                self.sync_project_status(&project, node_id, &status).await
350                            {
351                                tracing::warn!(
352                                    task_id = id.0,
353                                    err = %e,
354                                    "project board sync failed (non-fatal)"
355                                );
356                            }
357                        }
358                    }
359
360                    // Auto-close issue when status → Done (if auto_close enabled)
361                    if status == Status::Done {
362                        let auto_close = crate::config::get("workflow.auto_close")
363                            .map(|v| v == "true")
364                            .unwrap_or(true);
365                        if auto_close {
366                            if let Err(e) = self.gh.close_issue(&self.repo, &id.0).await {
367                                tracing::warn!(
368                                    task_id = id.0,
369                                    err = %e,
370                                    "auto-close issue failed (non-fatal)"
371                                );
372                            }
373                        }
374                    }
375
376                    return Ok(());
377                }
378                Err(e) => {
379                    tracing::warn!(label, err = %e, attempt, "set_labels failed");
380                    last_err = e;
381                }
382            }
383        }
384
385        Err(last_err.context(format!(
386            "update_status({label}) failed after {MAX_RETRIES} attempts"
387        )))
388    }
389
390    async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
391        // Done issues are auto-closed by the engine, so we must query state=all
392        // to find them. All other statuses only live on open issues.
393        let state = if status == Status::Done {
394            "all"
395        } else {
396            "open"
397        };
398        let issues = self
399            .gh
400            .list_issues_with_state(&self.repo, status.as_label(), state)
401            .await?;
402        Ok(issues
403            .into_iter()
404            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
405            .filter(is_trusted_author) // Only trusted authors
406            .map(|issue| ExternalTask {
407                id: ExternalId(issue.number.to_string()),
408                title: issue.title,
409                body: issue.body.unwrap_or_default(),
410                state: issue.state,
411                labels: issue.labels.into_iter().map(|l| l.name).collect(),
412                author: issue.user.login,
413                created_at: issue.created_at,
414                updated_at: issue.updated_at,
415                url: issue.html_url,
416            })
417            .collect())
418    }
419
420    /// List all tasks (open and closed) in a single API call.
421    ///
422    /// This fetches all issues with state=all and filters by status labels locally.
423    /// More efficient than making 7 separate calls to `list_by_status`.
424    async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
425        let issues = self.gh.list_all_issues(&self.repo).await?;
426        Ok(issues
427            .into_iter()
428            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
429            .filter(is_trusted_author) // Only trusted authors
430            .map(|issue| ExternalTask {
431                id: ExternalId(issue.number.to_string()),
432                title: issue.title,
433                body: issue.body.unwrap_or_default(),
434                state: issue.state,
435                labels: issue.labels.into_iter().map(|l| l.name).collect(),
436                author: issue.user.login,
437                created_at: issue.created_at,
438                updated_at: issue.updated_at,
439                url: issue.html_url,
440            })
441            .collect())
442    }
443
444    async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
445        let since = (Utc::now() - Duration::days(30)).to_rfc3339();
446        let closed = self.gh.list_closed_issues_since(&self.repo, &since).await?;
447
448        Ok(closed
449            .into_iter()
450            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
451            .filter(is_trusted_author) // Only trusted authors
452            .map(|issue| ExternalTask {
453                id: ExternalId(issue.number.to_string()),
454                title: issue.title,
455                body: issue.body.unwrap_or_default(),
456                state: issue.state,
457                labels: issue.labels.into_iter().map(|l| l.name).collect(),
458                author: issue.user.login,
459                created_at: issue.created_at,
460                updated_at: issue.updated_at,
461                url: issue.html_url,
462            })
463            .collect())
464    }
465
466    /// List open issues that have no `status:*` label (unprocessed) or have `status:new`.
467    async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
468        let issues = self.gh.list_all_open_issues(&self.repo, None).await?;
469        Ok(issues
470            .into_iter()
471            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
472            .filter(is_trusted_author) // Only trusted authors
473            .filter(|issue| {
474                let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
475                // Include if: has status:new, OR has no status:* label at all
476                let has_status = labels.iter().any(|l| l.starts_with("status:"));
477                !has_status || labels.contains(&"status:new")
478            })
479            .map(|issue| ExternalTask {
480                id: ExternalId(issue.number.to_string()),
481                title: issue.title,
482                body: issue.body.unwrap_or_default(),
483                state: issue.state,
484                labels: issue.labels.into_iter().map(|l| l.name).collect(),
485                author: issue.user.login,
486                created_at: issue.created_at,
487                updated_at: issue.updated_at,
488                url: issue.html_url,
489            })
490            .collect())
491    }
492
493    /// Single-call override: fetches all open issues once and partitions by status label locally,
494    /// replacing the 1 (list_routable) + 5 (list_by_status per active status) fan-out with a
495    /// single `list_all_open_issues` request.
496    /// When `since` is `Some(ts)`, uses GitHub's `?since` filter for efficiency.
497    async fn list_active_open_issues(
498        &self,
499        since: Option<&str>,
500    ) -> anyhow::Result<Vec<(ExternalTask, Option<crate::backends::Status>)>> {
501        let issues = self.gh.list_all_open_issues(&self.repo, since).await?;
502
503        Ok(issues
504            .into_iter()
505            .filter(|issue| issue.pull_request.is_none()) // Exclude PRs
506            .filter(is_trusted_author) // Only trusted authors
507            .filter_map(classify_active_open_issue)
508            .collect())
509    }
510
511    async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
512        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
513        if !is_github_issue_id(id) {
514            return Ok(());
515        }
516        self.gh.add_comment(&self.repo, &id.0, body).await
517    }
518
519    /// Additive: uses POST (gh.add_labels), so existing labels like bug, priority:high are preserved.
520    async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
521        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
522        if !is_github_issue_id(id) {
523            return Ok(());
524        }
525        self.gh.add_labels(&self.repo, &id.0, labels).await
526    }
527
528    async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()> {
529        // Internal tasks (e.g. "internal:63857") are not GitHub issues — skip silently.
530        if !is_github_issue_id(id) {
531            return Ok(());
532        }
533        self.gh.remove_label(&self.repo, &id.0, label).await
534    }
535
536    async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
537        let sub_issue_numbers = self.gh.get_sub_issues(&self.repo, &id.0).await?;
538        Ok(sub_issue_numbers
539            .into_iter()
540            .map(|n| ExternalId(n.to_string()))
541            .collect())
542    }
543
544    async fn create_sub_task(
545        &self,
546        parent_id: &ExternalId,
547        title: &str,
548        body: &str,
549        labels: &[String],
550    ) -> anyhow::Result<ExternalId> {
551        // Add parent label for metadata
552        let mut all_labels = labels.to_vec();
553        all_labels.push(format!("parent:{}", parent_id.0));
554
555        // Create the child issue
556        let child_issue = self
557            .gh
558            .create_issue(&self.repo, title, body, &all_labels)
559            .await?;
560
561        let child_id = ExternalId(child_issue.number.to_string());
562
563        // Try to establish native sub-issue relationship via GraphQL
564        if let (Some(child_node_id), Ok(parent_issue)) = (
565            child_issue.node_id,
566            self.gh.get_issue(&self.repo, &parent_id.0).await,
567        ) {
568            if let Some(parent_node_id) = parent_issue.node_id {
569                if let Err(e) = self.gh.add_sub_issue(&parent_node_id, &child_node_id).await {
570                    tracing::warn!(
571                        parent = parent_id.0,
572                        child = child_id.0,
573                        err = %e,
574                        "failed to add native sub-issue link (parent label still set)"
575                    );
576                }
577            }
578        }
579
580        Ok(child_id)
581    }
582
583    async fn has_open_issue_with_title(&self, title: &str, label: &str) -> anyhow::Result<bool> {
584        // Check open issues first
585        let open_issues = if label.is_empty() {
586            self.gh.list_all_open_issues(&self.repo, None).await?
587        } else {
588            self.gh.list_issues(&self.repo, label).await?
589        };
590        if open_issues.iter().any(|i| i.title == title) {
591            return Ok(true);
592        }
593        // Also check issues closed within the last 24h — prevents re-creating a bug
594        // that was just fixed and merged (the PR close event removes the open issue).
595        let since = (chrono::Utc::now() - chrono::Duration::hours(24))
596            .format("%Y-%m-%dT%H:%M:%SZ")
597            .to_string();
598        let closed = if label.is_empty() {
599            self.gh.list_closed_issues_since(&self.repo, &since).await?
600        } else {
601            self.gh
602                .list_issues_closed_since(&self.repo, label, &since)
603                .await?
604        };
605        Ok(closed.iter().any(|i| i.title == title))
606    }
607
608    async fn health_check(&self) -> anyhow::Result<()> {
609        self.gh.auth_status().await
610    }
611
612    async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
613        self.gh.is_pr_merged(&self.repo, branch).await
614    }
615
616    async fn batch_is_pr_merged(
617        &self,
618        branches: &[String],
619    ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
620        self.gh
621            .batch_is_pr_merged_by_branch(&self.repo, branches)
622            .await
623    }
624
625    async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
626        self.gh.get_whoami().await.map(Some)
627    }
628
629    async fn get_mentions(&self, since: &str) -> anyhow::Result<Vec<Mention>> {
630        let comments = self.gh.get_mentions(&self.repo, since).await?;
631        // Only include mentions authored by trusted associations. The GitHub
632        // REST issue comment payload does not include author_association, so
633        // we conservatively check the parent issue's author_association when
634        // an issue_url is available. If we cannot resolve association, skip
635        // the comment.
636        let mut out = Vec::new();
637        for c in comments.into_iter() {
638            // Prefer the comment's own author_association when present. If the
639            // comment payload doesn't include it (older endpoints), fall back to
640            // inspecting the parent issue's association when an issue_url is
641            // available. If neither yields a trusted association, skip the
642            // comment.
643            let trusted = if is_trusted_comment_author(&c) {
644                true
645            } else if let Some(ref issue_url) = c.issue_url {
646                // extract issue number and fetch issue to inspect author_association
647                if let Some(n) = issue_url.rsplit('/').next() {
648                    if let Ok(issue) = self.gh.get_issue(&self.repo, n).await {
649                        is_trusted_author(&issue)
650                    } else {
651                        tracing::warn!(
652                            repo = %self.repo,
653                            issue_url = %issue_url,
654                            comment_id = c.id,
655                            author = %c.user.login,
656                            "get_mentions: failed to fetch parent issue to check author trust, treating as untrusted — mention may be silently dropped"
657                        );
658                        false
659                    }
660                } else {
661                    false
662                }
663            } else {
664                false
665            };
666
667            if !trusted {
668                tracing::debug!(comment_id = c.id, author = %c.user.login, "ignoring mention from untrusted author");
669                continue;
670            }
671
672            out.push(Mention {
673                id: c.id.to_string(),
674                body: c.body,
675                author: c.user.login,
676                created_at: c.created_at,
677                issue_url: c.issue_url,
678            });
679        }
680
681        Ok(out)
682    }
683
684    async fn list_issue_comments(&self, id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
685        let issue_number: u64 =
686            id.0.parse()
687                .map_err(|_| anyhow::anyhow!("invalid issue number: {}", id.0))?;
688        let comments = self.gh.get_issue_comments(&self.repo, issue_number).await?;
689        // Filter comments to only include those from trusted associations.
690        let mut out = Vec::new();
691        for c in comments.into_iter() {
692            // Prefer the comment's own association when present.
693            if is_trusted_comment_author(&c) {
694                out.push(Mention {
695                    id: c.id.to_string(),
696                    body: c.body,
697                    author: c.user.login,
698                    created_at: c.created_at,
699                    issue_url: c.issue_url,
700                });
701                continue;
702            }
703
704            // Fall back to parent issue association when necessary.
705            if let Ok(issue) = self.gh.get_issue(&self.repo, &id.0).await {
706                if is_trusted_author(&issue) {
707                    out.push(Mention {
708                        id: c.id.to_string(),
709                        body: c.body,
710                        author: c.user.login,
711                        created_at: c.created_at,
712                        issue_url: c.issue_url,
713                    });
714                    continue;
715                } else {
716                    tracing::debug!(comment_id = c.id, author = %c.user.login, "ignoring issue comment from untrusted author");
717                    continue;
718                }
719            } else {
720                // If we cannot fetch the issue, skip the comment conservatively
721                tracing::debug!(
722                    comment_id = c.id,
723                    "failed to fetch parent issue, skipping comment"
724                );
725                continue;
726            }
727        }
728
729        Ok(out)
730    }
731
732    async fn acknowledge_issue(&self, id: &ExternalId) -> anyhow::Result<()> {
733        self.gh.add_reaction(&self.repo, &id.0, "eyes").await
734    }
735
736    async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
737        self.gh
738            .add_comment_reaction(&self.repo, comment_id, "eyes")
739            .await
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use crate::github::types::{GitHubIssue, GitHubUser};
747
748    // --- is_github_issue_id guard ---
749
750    #[test]
751    fn is_github_issue_id_numeric() {
752        assert!(is_github_issue_id(&ExternalId("42".to_string())));
753        assert!(is_github_issue_id(&ExternalId("1".to_string())));
754        assert!(is_github_issue_id(&ExternalId("99999".to_string())));
755    }
756
757    #[test]
758    fn is_github_issue_id_rejects_internal_prefix() {
759        assert!(!is_github_issue_id(&ExternalId(
760            "internal:63857".to_string()
761        )));
762        assert!(!is_github_issue_id(&ExternalId("internal:1".to_string())));
763    }
764
765    #[test]
766    fn is_github_issue_id_rejects_empty_and_non_numeric() {
767        assert!(!is_github_issue_id(&ExternalId("".to_string())));
768        assert!(!is_github_issue_id(&ExternalId("abc".to_string())));
769        assert!(!is_github_issue_id(&ExternalId("1.0".to_string())));
770    }
771
772    // ---
773
774    #[test]
775    fn allowed_associations_are_owner_and_contributor() {
776        assert_eq!(ALLOWED_ASSOCIATIONS, &["OWNER", "COLLABORATOR"]);
777    }
778
779    #[test]
780    fn is_trusted_author_owner() {
781        let issue = GitHubIssue {
782            number: 1,
783            title: "t".to_string(),
784            body: None,
785            state: "open".to_string(),
786            labels: vec![],
787            user: GitHubUser {
788                login: "u".to_string(),
789            },
790            created_at: "".to_string(),
791            updated_at: "".to_string(),
792            html_url: "".to_string(),
793            node_id: None,
794            pull_request: None,
795            author_association: Some("OWNER".to_string()),
796        };
797        assert!(is_trusted_author(&issue));
798    }
799
800    #[test]
801    fn is_trusted_author_collaborator() {
802        let issue = GitHubIssue {
803            number: 1,
804            title: "t".to_string(),
805            body: None,
806            state: "open".to_string(),
807            labels: vec![],
808            user: GitHubUser {
809                login: "u".to_string(),
810            },
811            created_at: "".to_string(),
812            updated_at: "".to_string(),
813            html_url: "".to_string(),
814            node_id: None,
815            pull_request: None,
816            author_association: Some("COLLABORATOR".to_string()),
817        };
818        assert!(is_trusted_author(&issue));
819    }
820
821    #[test]
822    fn is_not_trusted_author_contributor() {
823        let issue = GitHubIssue {
824            number: 1,
825            title: "t".to_string(),
826            body: None,
827            state: "open".to_string(),
828            labels: vec![],
829            user: GitHubUser {
830                login: "u".to_string(),
831            },
832            created_at: "".to_string(),
833            updated_at: "".to_string(),
834            html_url: "".to_string(),
835            node_id: None,
836            pull_request: None,
837            author_association: Some("CONTRIBUTOR".to_string()),
838        };
839        assert!(!is_trusted_author(&issue));
840    }
841
842    #[test]
843    fn is_not_trusted_author_member() {
844        let issue = GitHubIssue {
845            number: 1,
846            title: "t".to_string(),
847            body: None,
848            state: "open".to_string(),
849            labels: vec![],
850            user: GitHubUser {
851                login: "u".to_string(),
852            },
853            created_at: "".to_string(),
854            updated_at: "".to_string(),
855            html_url: "".to_string(),
856            node_id: None,
857            pull_request: None,
858            author_association: Some("MEMBER".to_string()),
859        };
860        assert!(!is_trusted_author(&issue));
861    }
862
863    #[test]
864    fn is_trusted_comment_author_direct() {
865        use crate::github::types::GitHubComment;
866        let c = GitHubComment {
867            id: 1,
868            body: "hi".to_string(),
869            user: crate::github::types::GitHubUser {
870                login: "u".to_string(),
871            },
872            created_at: "".to_string(),
873            updated_at: None,
874            html_url: None,
875            issue_url: None,
876            author_association: Some("OWNER".to_string()),
877        };
878        assert!(is_trusted_comment_author(&c));
879    }
880
881    #[test]
882    fn is_not_trusted_comment_author_direct() {
883        use crate::github::types::GitHubComment;
884        let c = GitHubComment {
885            id: 1,
886            body: "hi".to_string(),
887            user: crate::github::types::GitHubUser {
888                login: "u".to_string(),
889            },
890            created_at: "".to_string(),
891            updated_at: None,
892            html_url: None,
893            issue_url: None,
894            author_association: Some("MEMBER".to_string()),
895        };
896        assert!(!is_trusted_comment_author(&c));
897    }
898
899    fn make_test_issue(labels: &[&str]) -> GitHubIssue {
900        GitHubIssue {
901            number: 1,
902            title: "t".to_string(),
903            body: None,
904            state: "open".to_string(),
905            labels: labels
906                .iter()
907                .map(|label| crate::github::types::GitHubLabel {
908                    name: (*label).to_string(),
909                    color: None,
910                })
911                .collect(),
912            user: GitHubUser {
913                login: "u".to_string(),
914            },
915            created_at: "".to_string(),
916            updated_at: "".to_string(),
917            html_url: "".to_string(),
918            node_id: None,
919            pull_request: None,
920            author_association: Some("OWNER".to_string()),
921        }
922    }
923
924    /// Open issues carrying `status:done` must be excluded from active-open ingest.
925    #[test]
926    fn classify_active_open_issue_excludes_status_done() {
927        assert!(classify_active_open_issue(make_test_issue(&["status:done"])).is_none());
928        assert!(
929            classify_active_open_issue(make_test_issue(&["status:routed", "status:done"]))
930                .is_none()
931        );
932    }
933
934    #[test]
935    fn classify_active_open_issue_preserves_unlabeled_and_new_as_none_status() {
936        let unlabeled = classify_active_open_issue(make_test_issue(&[]))
937            .expect("unlabeled issue should still be ingested");
938        assert_eq!(unlabeled.1, None);
939
940        let status_new = classify_active_open_issue(make_test_issue(&["status:new"]))
941            .expect("status:new issue should still be ingested");
942        assert_eq!(status_new.1, None);
943    }
944
945    #[test]
946    fn classify_active_open_issue_maps_active_status() {
947        let routed = classify_active_open_issue(make_test_issue(&["status:routed"]))
948            .expect("active status issue should be ingested");
949        assert_eq!(routed.1, Some(Status::Routed));
950    }
951}