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 _ => None,
47 }
48}
49
50fn classify_active_open_issue(
51 issue: crate::github::types::GitHubIssue,
52) -> Option<(ExternalTask, Option<Status>)> {
53 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
81fn 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 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 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 async fn sync_to_project(
171 &self,
172 id: &ExternalId,
173 status: Status,
174 ) -> anyhow::Result<Option<String>> {
175 let project = match ProjectSync::from_config() {
177 Some(p) => p,
178 None => return Ok(None),
179 };
180
181 let issue = self.gh.get_issue(&self.repo, &id.0).await?;
183
184 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 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 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 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 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
287 if !is_github_issue_id(id) {
289 return Ok(());
290 }
291
292 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 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 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 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 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()) .filter(is_trusted_author) .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 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()) .filter(is_trusted_author) .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()) .filter(is_trusted_author) .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 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()) .filter(is_trusted_author) .filter(|issue| {
474 let labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
475 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 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()) .filter(is_trusted_author) .filter_map(classify_active_open_issue)
508 .collect())
509 }
510
511 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
512 if !is_github_issue_id(id) {
514 return Ok(());
515 }
516 self.gh.add_comment(&self.repo, &id.0, body).await
517 }
518
519 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
521 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 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 let mut all_labels = labels.to_vec();
553 all_labels.push(format!("parent:{}", parent_id.0));
554
555 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 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 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 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 let mut out = Vec::new();
637 for c in comments.into_iter() {
638 let trusted = if is_trusted_comment_author(&c) {
644 true
645 } else if let Some(ref issue_url) = c.issue_url {
646 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 let mut out = Vec::new();
691 for c in comments.into_iter() {
692 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 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 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 #[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 #[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 #[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}