1use 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
12const ALLOWED_ASSOCIATIONS: &[&str] = &["OWNER", "COLLABORATOR"];
18
19fn 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) }
27
28fn 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 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
80fn 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 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 async fn sync_to_project(
154 &self,
155 id: &ExternalId,
156 status: Status,
157 ) -> anyhow::Result<Option<String>> {
158 let project = match ProjectSync::from_config() {
160 Some(p) => p,
161 None => return Ok(None),
162 };
163
164 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
166
167 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 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 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 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 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
264 if !is_github_issue_id(id) {
266 return Ok(());
267 }
268
269 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 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 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 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 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()) .filter(is_trusted_author) .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 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()) .filter(is_trusted_author) .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()) .filter(is_trusted_author) .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 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()) .filter(is_trusted_author) .filter(|issue| {
449 let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
450 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 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()) .filter(is_trusted_author) .filter_map(classify_active_open_issue)
483 .collect())
484 }
485
486 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
487 if !is_github_issue_id(id) {
489 return Ok(());
490 }
491 self.gh.add_comment(&self.repo, &id.0, body).await
492 }
493
494 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
496 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 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 let mut all_labels = labels.to_vec();
528 all_labels.push(format!("parent:{}", parent_id.0));
529
530 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 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 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 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 let mut out = Vec::new();
612 for c in comments.into_iter() {
613 let trusted = if is_trusted_comment_author(&c) {
619 true
620 } else if let Some(ref issue_url) = c.issue_url {
621 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 let mut out = Vec::new();
666 for c in comments.into_iter() {
667 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 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 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 #[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 #[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 #[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}