1use 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
13const ALLOWED_ASSOCIATIONS: &[&str] = &["OWNER", "COLLABORATOR"];
19
20fn 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) }
28
29fn 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 "status:waiting" => Some(Status::Waiting),
47 _ => None,
48 }
49}
50
51fn classify_active_open_issue(
52 issue: crate::github::types::GitHubIssue,
53) -> Option<(ExternalTask, Option<Status>)> {
54 if issue.labels.iter().any(|l| l.name == "status:done") {
56 return None;
57 }
58
59 let status = issue
60 .labels
61 .iter()
62 .find_map(|l| active_status_from_label(&l.name));
63 let task = ExternalTask {
64 id: ExternalId(issue.number.to_string()),
65 title: issue.title,
66 body: issue.body.unwrap_or_default(),
67 state: issue.state,
68 labels: issue.labels.into_iter().map(|l| l.name).collect(),
69 author: issue.user.login,
70 created_at: issue.created_at,
71 updated_at: issue.updated_at,
72 url: issue.html_url,
73 };
74 Some((task, status))
75}
76
77pub struct GitHubBackend {
78 repo: String,
79 gh: GhHttp,
80}
81
82fn is_github_issue_id(id: &ExternalId) -> bool {
88 id.0.parse::<u64>().is_ok()
89}
90
91impl GitHubBackend {
92 pub fn new(repo: String) -> anyhow::Result<Self> {
93 Ok(Self {
94 repo,
95 gh: GhHttp::new()?,
96 })
97 }
98
99 async fn sync_project_status(
100 &self,
101 project: &ProjectSync,
102 node_id: &str,
103 status: &Status,
104 ) -> anyhow::Result<()> {
105 if LabelSyncConfig::from_config()?.manages(&self.repo, project.project_id()) {
106 project.add_item(node_id).await?;
109 Ok(())
110 } else {
111 project.sync_item_status(node_id, status).await
112 }
113 }
114}
115
116#[async_trait]
117impl ExternalBackend for GitHubBackend {
118 fn name(&self) -> &str {
119 "github"
120 }
121
122 async fn create_task(
123 &self,
124 title: &str,
125 body: &str,
126 labels: &[String],
127 ) -> anyhow::Result<ExternalId> {
128 let issue = self
129 .gh
130 .create_issue(&self.repo, title, body, labels)
131 .await?;
132 Ok(ExternalId(issue.number.to_string()))
133 }
134
135 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
136 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
137 Ok(ExternalTask {
138 id: id.clone(),
139 title: issue.title,
140 body: issue.body.unwrap_or_default(),
141 state: issue.state,
142 labels: issue.labels.into_iter().map(|l| l.name).collect(),
143 author: issue.user.login,
144 created_at: issue.created_at,
145 updated_at: issue.updated_at,
146 url: issue.html_url,
147 })
148 }
149
150 async fn ensure_status_label(&self, label: &str) -> anyhow::Result<()> {
155 let status_name = &label["status:".len()..];
156 self.gh
157 .ensure_label(
158 &self.repo,
159 label,
160 status_label_color(label),
161 &format!("Task status: {status_name}"),
162 )
163 .await
164 }
165
166 async fn sync_to_project(
172 &self,
173 id: &ExternalId,
174 status: Status,
175 ) -> anyhow::Result<Option<String>> {
176 let project = match ProjectSync::from_config() {
178 Some(p) => p,
179 None => return Ok(None),
180 };
181
182 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
184
185 let node_id = issue
187 .node_id
188 .as_deref()
189 .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?
190 .to_string();
191
192 self.sync_project_status(&project, &node_id, &status)
194 .await
195 .map_err(|e| anyhow::anyhow!("project board sync failed: {e}"))?;
196
197 Ok(Some(node_id))
198 }
199
200 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
201 LabelSyncConfig::from_config()?
202 .reconcile(&self.gh, &self.repo)
203 .await
204 .map(|_| ())
205 }
206
207 async fn sync_estimate_to_project(&self, id: &ExternalId, estimate: u8) -> anyhow::Result<()> {
208 let project = match ProjectSync::from_config() {
209 Some(p) => p,
210 None => return Ok(()),
211 };
212
213 let project = if project.estimate_field_id().is_none() {
217 match ProjectSync::discover_fields(project.project_id()).await {
218 Ok(discovered) => {
219 if discovered.estimate_field_id().is_some() {
220 if let Err(e) =
221 crate::github::projects::write_project_config(&discovered).await
222 {
223 tracing::warn!(
224 err = %e,
225 "failed to persist discovered estimate field ID to config"
226 );
227 } else {
228 tracing::info!(
229 "auto-discovered and persisted project_estimate_field_id to config"
230 );
231 }
232 discovered
233 } else {
234 tracing::debug!(
235 "project has no 'Estimate' number field — skipping estimate sync"
236 );
237 return Ok(());
238 }
239 }
240 Err(e) => {
241 tracing::debug!(
242 err = %e,
243 "estimate field auto-discovery failed — skipping estimate sync"
244 );
245 return Ok(());
246 }
247 }
248 } else {
249 project
250 };
251
252 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
254 let node_id = issue
255 .node_id
256 .as_deref()
257 .ok_or_else(|| anyhow::anyhow!("issue missing node_id"))?;
258
259 project
260 .sync_item_estimate(node_id, estimate)
261 .await
262 .map_err(|e| anyhow::anyhow!("project estimate sync failed: {e}"))?;
263
264 Ok(())
265 }
266
267 async fn get_project_item_estimates(
268 &self,
269 issue_node_ids: &[String],
270 ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
271 let project = match ProjectSync::from_config() {
272 Some(p) => p,
273 None => return Ok(std::collections::HashMap::new()),
274 };
275
276 if issue_node_ids.is_empty() {
277 return Ok(std::collections::HashMap::new());
278 }
279
280 project
281 .get_estimates_for_issues(issue_node_ids)
282 .await
283 .map_err(|e| anyhow::anyhow!("project estimate fetch failed: {e}"))
284 }
285
286 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
288 if !is_github_issue_id(id) {
290 return Ok(());
291 }
292
293 let label = status.as_label();
295
296 if let Err(e) = self.ensure_status_label(label).await {
297 tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
298 }
299
300 const MAX_RETRIES: u32 = 3;
301 let mut last_err =
302 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
303
304 let mut issue_node_id: Option<String> = None;
305
306 for attempt in 0..MAX_RETRIES {
307 if attempt > 0 {
308 tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
309 .await;
310 tracing::debug!(attempt, label, "retrying update_status");
311 }
312
313 let issue = match self.gh.get_issue(&self.repo, &id.0).await {
314 Ok(i) => i,
315 Err(e) => {
316 last_err = e;
317 continue;
318 }
319 };
320
321 if issue_node_id.is_none() {
323 issue_node_id = issue.node_id.clone();
324 }
325
326 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
327
328 let mut remove_failed = false;
329 for old in labels
330 .iter()
331 .filter(|l| l.starts_with("status:") && l.as_str() != label)
332 {
333 if let Err(e) = self.remove_label(id, old).await {
334 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
335 last_err = e;
336 remove_failed = true;
337 break;
338 }
339 }
340 if remove_failed {
341 continue;
342 }
343
344 match self.set_labels(id, &[label.to_string()]).await {
345 Ok(()) => {
346 if let Some(ref node_id) = issue_node_id {
348 if let Some(project) = ProjectSync::from_config() {
349 if let Err(e) =
350 self.sync_project_status(&project, node_id, &status).await
351 {
352 tracing::warn!(
353 task_id = id.0,
354 err = %e,
355 "project board sync failed (non-fatal)"
356 );
357 }
358 }
359 }
360
361 if status == Status::Done {
363 let auto_close = crate::config::get("workflow.auto_close")
364 .map(|v| v == "true")
365 .unwrap_or(true);
366 if auto_close {
367 if let Err(e) = self.gh.close_issue(&self.repo, &id.0).await {
368 tracing::warn!(
369 task_id = id.0,
370 err = %e,
371 "auto-close issue failed (non-fatal)"
372 );
373 }
374 }
375 }
376
377 return Ok(());
378 }
379 Err(e) => {
380 tracing::warn!(label, err = %e, attempt, "set_labels failed");
381 last_err = e;
382 }
383 }
384 }
385
386 Err(last_err.context(format!(
387 "update_status({label}) failed after {MAX_RETRIES} attempts"
388 )))
389 }
390
391 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
392 let state = if status == Status::Done {
395 "all"
396 } else {
397 "open"
398 };
399 let issues = self
400 .gh
401 .list_issues_with_state(&self.repo, status.as_label(), state)
402 .await?;
403 Ok(issues
404 .into_iter()
405 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
408 id: ExternalId(issue.number.to_string()),
409 title: issue.title,
410 body: issue.body.unwrap_or_default(),
411 state: issue.state,
412 labels: issue.labels.into_iter().map(|l| l.name).collect(),
413 author: issue.user.login,
414 created_at: issue.created_at,
415 updated_at: issue.updated_at,
416 url: issue.html_url,
417 })
418 .collect())
419 }
420
421 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
426 let issues = self.gh.list_all_issues(&self.repo).await?;
427 Ok(issues
428 .into_iter()
429 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
432 id: ExternalId(issue.number.to_string()),
433 title: issue.title,
434 body: issue.body.unwrap_or_default(),
435 state: issue.state,
436 labels: issue.labels.into_iter().map(|l| l.name).collect(),
437 author: issue.user.login,
438 created_at: issue.created_at,
439 updated_at: issue.updated_at,
440 url: issue.html_url,
441 })
442 .collect())
443 }
444
445 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
446 let since = (Utc::now() - Duration::days(30)).to_rfc3339();
447 let closed = self.gh.list_closed_issues_since(&self.repo, &since).await?;
448
449 Ok(closed
450 .into_iter()
451 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .map(|issue| ExternalTask {
454 id: ExternalId(issue.number.to_string()),
455 title: issue.title,
456 body: issue.body.unwrap_or_default(),
457 state: issue.state,
458 labels: issue.labels.into_iter().map(|l| l.name).collect(),
459 author: issue.user.login,
460 created_at: issue.created_at,
461 updated_at: issue.updated_at,
462 url: issue.html_url,
463 })
464 .collect())
465 }
466
467 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
469 let issues = self.gh.list_all_open_issues(&self.repo, None).await?;
470 Ok(issues
471 .into_iter()
472 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .filter(|issue| {
475 let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
476 let has_status = labels.iter().any(|l| l.starts_with("status:"));
478 !has_status || labels.contains(&"status:new")
479 })
480 .map(|issue| ExternalTask {
481 id: ExternalId(issue.number.to_string()),
482 title: issue.title,
483 body: issue.body.unwrap_or_default(),
484 state: issue.state,
485 labels: issue.labels.into_iter().map(|l| l.name).collect(),
486 author: issue.user.login,
487 created_at: issue.created_at,
488 updated_at: issue.updated_at,
489 url: issue.html_url,
490 })
491 .collect())
492 }
493
494 async fn list_active_open_issues(
499 &self,
500 since: Option<&str>,
501 ) -> anyhow::Result<Vec<(ExternalTask, Option<crate::backends::Status>)>> {
502 let issues = self.gh.list_all_open_issues(&self.repo, since).await?;
503
504 Ok(issues
505 .into_iter()
506 .filter(|issue| issue.pull_request.is_none()) .filter(is_trusted_author) .filter_map(classify_active_open_issue)
509 .collect())
510 }
511
512 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
513 if !is_github_issue_id(id) {
515 return Ok(());
516 }
517 self.gh.add_comment(&self.repo, &id.0, body).await
518 }
519
520 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
522 if !is_github_issue_id(id) {
524 return Ok(());
525 }
526 self.gh.add_labels(&self.repo, &id.0, labels).await
527 }
528
529 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()> {
530 if !is_github_issue_id(id) {
532 return Ok(());
533 }
534 self.gh.remove_label(&self.repo, &id.0, label).await
535 }
536
537 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
538 let sub_issue_numbers = self.gh.get_sub_issues(&self.repo, &id.0).await?;
539 Ok(sub_issue_numbers
540 .into_iter()
541 .map(|n| ExternalId(n.to_string()))
542 .collect())
543 }
544
545 async fn create_sub_task(
546 &self,
547 parent_id: &ExternalId,
548 title: &str,
549 body: &str,
550 labels: &[String],
551 ) -> anyhow::Result<ExternalId> {
552 let mut all_labels = labels.to_vec();
554 all_labels.push(format!("parent:{}", parent_id.0));
555
556 let child_issue = self
558 .gh
559 .create_issue(&self.repo, title, body, &all_labels)
560 .await?;
561
562 let child_id = ExternalId(child_issue.number.to_string());
563
564 if let (Some(child_node_id), Ok(parent_issue)) = (
566 child_issue.node_id,
567 self.gh.get_issue(&self.repo, &parent_id.0).await,
568 ) {
569 if let Some(parent_node_id) = parent_issue.node_id {
570 if let Err(e) = self.gh.add_sub_issue(&parent_node_id, &child_node_id).await {
571 tracing::warn!(
572 parent = parent_id.0,
573 child = child_id.0,
574 err = %e,
575 "failed to add native sub-issue link (parent label still set)"
576 );
577 }
578 }
579 }
580
581 Ok(child_id)
582 }
583
584 async fn has_open_issue_with_title(&self, title: &str, label: &str) -> anyhow::Result<bool> {
585 let open_issues = if label.is_empty() {
587 self.gh.list_all_open_issues(&self.repo, None).await?
588 } else {
589 self.gh.list_issues(&self.repo, label).await?
590 };
591 if open_issues.iter().any(|i| i.title == title) {
592 return Ok(true);
593 }
594 let since = (chrono::Utc::now() - chrono::Duration::hours(24))
597 .format("%Y-%m-%dT%H:%M:%SZ")
598 .to_string();
599 let closed = if label.is_empty() {
600 self.gh.list_closed_issues_since(&self.repo, &since).await?
601 } else {
602 self.gh
603 .list_issues_closed_since(&self.repo, label, &since)
604 .await?
605 };
606 Ok(closed.iter().any(|i| i.title == title))
607 }
608
609 async fn health_check(&self) -> anyhow::Result<()> {
610 self.gh.auth_status().await
611 }
612
613 async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
614 self.gh.is_pr_merged(&self.repo, branch).await
615 }
616
617 async fn batch_is_pr_merged(
618 &self,
619 branches: &[String],
620 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
621 self.gh
622 .batch_is_pr_merged_by_branch(&self.repo, branches)
623 .await
624 }
625
626 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
627 self.gh.get_whoami().await.map(Some)
628 }
629
630 async fn get_mentions(&self, since: &str) -> anyhow::Result<Vec<Mention>> {
631 let comments = self.gh.get_mentions(&self.repo, since).await?;
632 let mut out = Vec::new();
638 for c in comments.into_iter() {
639 let trusted = if is_trusted_comment_author(&c) {
645 true
646 } else if let Some(ref issue_url) = c.issue_url {
647 if let Some(n) = issue_url.rsplit('/').next() {
649 if let Ok(issue) = self.gh.get_issue(&self.repo, n).await {
650 is_trusted_author(&issue)
651 } else {
652 tracing::warn!(
653 repo = %self.repo,
654 issue_url = %issue_url,
655 comment_id = c.id,
656 author = %c.user.login,
657 "get_mentions: failed to fetch parent issue to check author trust, treating as untrusted — mention may be silently dropped"
658 );
659 false
660 }
661 } else {
662 false
663 }
664 } else {
665 false
666 };
667
668 if !trusted {
669 tracing::debug!(comment_id = c.id, author = %c.user.login, "ignoring mention from untrusted author");
670 continue;
671 }
672
673 out.push(Mention {
674 id: c.id.to_string(),
675 body: c.body,
676 author: c.user.login,
677 created_at: c.created_at,
678 issue_url: c.issue_url,
679 });
680 }
681
682 Ok(out)
683 }
684
685 async fn list_issue_comments(&self, id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
686 let issue_number: u64 =
687 id.0.parse()
688 .map_err(|_| anyhow::anyhow!("invalid issue number: {}", id.0))?;
689 let comments = self.gh.get_issue_comments(&self.repo, issue_number).await?;
690 let mut out = Vec::new();
692 for c in comments.into_iter() {
693 if is_trusted_comment_author(&c) {
695 out.push(Mention {
696 id: c.id.to_string(),
697 body: c.body,
698 author: c.user.login,
699 created_at: c.created_at,
700 issue_url: c.issue_url,
701 });
702 continue;
703 }
704
705 if let Ok(issue) = self.gh.get_issue(&self.repo, &id.0).await {
707 if is_trusted_author(&issue) {
708 out.push(Mention {
709 id: c.id.to_string(),
710 body: c.body,
711 author: c.user.login,
712 created_at: c.created_at,
713 issue_url: c.issue_url,
714 });
715 continue;
716 } else {
717 tracing::debug!(comment_id = c.id, author = %c.user.login, "ignoring issue comment from untrusted author");
718 continue;
719 }
720 } else {
721 tracing::debug!(
723 comment_id = c.id,
724 "failed to fetch parent issue, skipping comment"
725 );
726 continue;
727 }
728 }
729
730 Ok(out)
731 }
732
733 async fn acknowledge_issue(&self, id: &ExternalId) -> anyhow::Result<()> {
734 self.gh.add_reaction(&self.repo, &id.0, "eyes").await
735 }
736
737 async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
738 self.gh
739 .add_comment_reaction(&self.repo, comment_id, "eyes")
740 .await
741 }
742
743 async fn blocked_by(
744 &self,
745 repo: &str,
746 issue_number: i64,
747 ) -> anyhow::Result<crate::backends::BlockedByFetch> {
748 if issue_number <= 0 {
749 return Ok(crate::backends::BlockedByFetch::Unavailable);
750 }
751 self.gh.list_blocked_by(repo, issue_number as u64).await
752 }
753
754 async fn issue_close_state(
755 &self,
756 repo: &str,
757 issue_number: i64,
758 ) -> anyhow::Result<Option<(String, Option<String>)>> {
759 if issue_number <= 0 {
760 return Ok(None);
761 }
762 match self.gh.get_issue(repo, &issue_number.to_string()).await {
763 Ok(issue) => Ok(Some((issue.state, issue.state_reason))),
764 Err(err) if is_missing_issue(&err) => Ok(None),
765 Err(err) => Err(err),
766 }
767 }
768
769 async fn post_comment_in_repo(
770 &self,
771 repo: &str,
772 id: &ExternalId,
773 body: &str,
774 ) -> anyhow::Result<()> {
775 if !is_github_issue_id(id) {
776 return Ok(());
777 }
778 self.gh.add_comment(repo, &id.0, body).await
779 }
780
781 async fn pull_request_completion(
782 &self,
783 repo: &str,
784 pr_number: i64,
785 ) -> anyhow::Result<Option<crate::backends::PullRequestCompletion>> {
786 if pr_number <= 0 {
787 return Ok(None);
788 }
789 let pr = match self.gh.get_pr(repo, pr_number as u64).await {
790 Ok(pr) => pr,
791 Err(err) if is_missing_issue(&err) => return Ok(None),
792 Err(err) => return Err(err),
793 };
794 let default_branch = self.gh.get_default_branch(repo).await.unwrap_or_default();
795 let reviews = match self.gh.get_pr_reviews(repo, pr_number as u64).await {
796 Ok(reviews) => reviews,
797 Err(err) => {
798 tracing::warn!(repo, pr_number, err = %err, "pull request reviews unreadable");
799 Vec::new()
800 }
801 };
802 let merged = pr.merged.unwrap_or(false);
803 Ok(Some(crate::backends::PullRequestCompletion {
804 merged,
805 base_ref: pr.base.ref_,
806 default_branch,
807 approved: latest_review_approves(&reviews),
808 closed: pr.state.eq_ignore_ascii_case("closed"),
809 }))
810 }
811
812 async fn add_dependency(
813 &self,
814 issue_number: i64,
815 blocking_repo: &str,
816 blocking_number: i64,
817 ) -> anyhow::Result<()> {
818 let blocker = self
819 .gh
820 .get_issue(blocking_repo, &blocking_number.to_string())
821 .await?;
822 if blocker.id == 0 {
823 anyhow::bail!("blocking issue {blocking_repo}#{blocking_number} has no database id");
824 }
825 self.gh
826 .add_blocked_by(&self.repo, issue_number as u64, blocker.id as i64)
827 .await
828 }
829}
830
831fn is_missing_issue(err: &anyhow::Error) -> bool {
832 let text = err.to_string().to_ascii_lowercase();
833 text.contains("404") || text.contains("not found")
834}
835
836pub(crate) fn latest_review_approves(reviews: &[crate::github::types::GitHubReview]) -> bool {
840 let mut latest: Option<(&str, &str)> = None;
841 for review in reviews {
842 let state = review.state.as_str();
843 let substantive = state.eq_ignore_ascii_case("APPROVED")
844 || state.eq_ignore_ascii_case("CHANGES_REQUESTED");
845 if !substantive {
846 continue;
847 }
848 match latest {
849 Some((at, _)) if review.submitted_at.as_str() < at => {}
850 _ => latest = Some((review.submitted_at.as_str(), state)),
851 }
852 }
853 latest.is_some_and(|(_, state)| state.eq_ignore_ascii_case("APPROVED"))
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859 use crate::github::types::{GitHubIssue, GitHubReview, GitHubUser};
860
861 fn review(state: &str, at: &str) -> GitHubReview {
862 GitHubReview {
863 id: 1,
864 user: GitHubUser {
865 login: "owner".into(),
866 },
867 body: None,
868 state: state.into(),
869 html_url: None,
870 submitted_at: at.into(),
871 commit_id: None,
872 }
873 }
874
875 #[test]
876 fn later_changes_requested_clears_an_earlier_approval() {
877 let reviews = vec![
878 review("APPROVED", "2026-01-01T00:00:00Z"),
879 review("COMMENTED", "2026-01-02T00:00:00Z"),
880 review("CHANGES_REQUESTED", "2026-01-03T00:00:00Z"),
881 ];
882 assert!(!latest_review_approves(&reviews));
883 let approved = vec![
884 review("CHANGES_REQUESTED", "2026-01-01T00:00:00Z"),
885 review("APPROVED", "2026-01-02T00:00:00Z"),
886 ];
887 assert!(latest_review_approves(&approved));
888 }
889
890 #[test]
893 fn is_github_issue_id_numeric() {
894 assert!(is_github_issue_id(&ExternalId("42".to_string())));
895 assert!(is_github_issue_id(&ExternalId("1".to_string())));
896 assert!(is_github_issue_id(&ExternalId("99999".to_string())));
897 }
898
899 #[test]
900 fn is_github_issue_id_rejects_internal_prefix() {
901 assert!(!is_github_issue_id(&ExternalId(
902 "internal:63857".to_string()
903 )));
904 assert!(!is_github_issue_id(&ExternalId("internal:1".to_string())));
905 }
906
907 #[test]
908 fn is_github_issue_id_rejects_empty_and_non_numeric() {
909 assert!(!is_github_issue_id(&ExternalId("".to_string())));
910 assert!(!is_github_issue_id(&ExternalId("abc".to_string())));
911 assert!(!is_github_issue_id(&ExternalId("1.0".to_string())));
912 }
913
914 #[test]
917 fn allowed_associations_are_owner_and_contributor() {
918 assert_eq!(ALLOWED_ASSOCIATIONS, &["OWNER", "COLLABORATOR"]);
919 }
920
921 #[test]
922 fn is_trusted_author_owner() {
923 let issue = GitHubIssue {
924 number: 1,
925 id: 0,
926 title: "t".to_string(),
927 body: None,
928 state: "open".to_string(),
929 labels: vec![],
930 user: GitHubUser {
931 login: "u".to_string(),
932 },
933 created_at: "".to_string(),
934 updated_at: "".to_string(),
935 html_url: "".to_string(),
936 node_id: None,
937 pull_request: None,
938 state_reason: None,
939 author_association: Some("OWNER".to_string()),
940 };
941 assert!(is_trusted_author(&issue));
942 }
943
944 #[test]
945 fn is_trusted_author_collaborator() {
946 let issue = GitHubIssue {
947 number: 1,
948 id: 0,
949 title: "t".to_string(),
950 body: None,
951 state: "open".to_string(),
952 labels: vec![],
953 user: GitHubUser {
954 login: "u".to_string(),
955 },
956 created_at: "".to_string(),
957 updated_at: "".to_string(),
958 html_url: "".to_string(),
959 node_id: None,
960 pull_request: None,
961 state_reason: None,
962 author_association: Some("COLLABORATOR".to_string()),
963 };
964 assert!(is_trusted_author(&issue));
965 }
966
967 #[test]
968 fn is_not_trusted_author_contributor() {
969 let issue = GitHubIssue {
970 number: 1,
971 id: 0,
972 title: "t".to_string(),
973 body: None,
974 state: "open".to_string(),
975 labels: vec![],
976 user: GitHubUser {
977 login: "u".to_string(),
978 },
979 created_at: "".to_string(),
980 updated_at: "".to_string(),
981 html_url: "".to_string(),
982 node_id: None,
983 pull_request: None,
984 state_reason: None,
985 author_association: Some("CONTRIBUTOR".to_string()),
986 };
987 assert!(!is_trusted_author(&issue));
988 }
989
990 #[test]
991 fn is_not_trusted_author_member() {
992 let issue = GitHubIssue {
993 number: 1,
994 id: 0,
995 title: "t".to_string(),
996 body: None,
997 state: "open".to_string(),
998 labels: vec![],
999 user: GitHubUser {
1000 login: "u".to_string(),
1001 },
1002 created_at: "".to_string(),
1003 updated_at: "".to_string(),
1004 html_url: "".to_string(),
1005 node_id: None,
1006 pull_request: None,
1007 state_reason: None,
1008 author_association: Some("MEMBER".to_string()),
1009 };
1010 assert!(!is_trusted_author(&issue));
1011 }
1012
1013 #[test]
1014 fn is_trusted_comment_author_direct() {
1015 use crate::github::types::GitHubComment;
1016 let c = GitHubComment {
1017 id: 1,
1018 body: "hi".to_string(),
1019 user: crate::github::types::GitHubUser {
1020 login: "u".to_string(),
1021 },
1022 created_at: "".to_string(),
1023 updated_at: None,
1024 html_url: None,
1025 issue_url: None,
1026 author_association: Some("OWNER".to_string()),
1027 };
1028 assert!(is_trusted_comment_author(&c));
1029 }
1030
1031 #[test]
1032 fn is_not_trusted_comment_author_direct() {
1033 use crate::github::types::GitHubComment;
1034 let c = GitHubComment {
1035 id: 1,
1036 body: "hi".to_string(),
1037 user: crate::github::types::GitHubUser {
1038 login: "u".to_string(),
1039 },
1040 created_at: "".to_string(),
1041 updated_at: None,
1042 html_url: None,
1043 issue_url: None,
1044 author_association: Some("MEMBER".to_string()),
1045 };
1046 assert!(!is_trusted_comment_author(&c));
1047 }
1048
1049 fn make_test_issue(labels: &[&str]) -> GitHubIssue {
1050 GitHubIssue {
1051 number: 1,
1052 id: 0,
1053 title: "t".to_string(),
1054 body: None,
1055 state: "open".to_string(),
1056 labels: labels
1057 .iter()
1058 .map(|label| crate::github::types::GitHubLabel {
1059 name: (*label).to_string(),
1060 color: None,
1061 })
1062 .collect(),
1063 user: GitHubUser {
1064 login: "u".to_string(),
1065 },
1066 created_at: "".to_string(),
1067 updated_at: "".to_string(),
1068 html_url: "".to_string(),
1069 node_id: None,
1070 pull_request: None,
1071 state_reason: None,
1072 author_association: Some("OWNER".to_string()),
1073 }
1074 }
1075
1076 #[test]
1078 fn classify_active_open_issue_excludes_status_done() {
1079 assert!(classify_active_open_issue(make_test_issue(&["status:done"])).is_none());
1080 assert!(
1081 classify_active_open_issue(make_test_issue(&["status:routed", "status:done"]))
1082 .is_none()
1083 );
1084 }
1085
1086 #[test]
1087 fn classify_active_open_issue_preserves_unlabeled_and_new_as_none_status() {
1088 let unlabeled = classify_active_open_issue(make_test_issue(&[]))
1089 .expect("unlabeled issue should still be ingested");
1090 assert_eq!(unlabeled.1, None);
1091
1092 let status_new = classify_active_open_issue(make_test_issue(&["status:new"]))
1093 .expect("status:new issue should still be ingested");
1094 assert_eq!(status_new.1, None);
1095 }
1096
1097 #[test]
1098 fn classify_active_open_issue_maps_active_status() {
1099 let routed = classify_active_open_issue(make_test_issue(&["status:routed"]))
1100 .expect("active status issue should be ingested");
1101 assert_eq!(routed.1, Some(Status::Routed));
1102 }
1103}