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