1use std::collections::HashMap;
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9
10use crate::domain::*;
11use crate::error::{Error, Result};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct Connection {
19 pub id: String,
20 pub provider_type: ProviderType,
21 pub display_name: String,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub base_url: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub organization: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub project: Option<String>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub repository: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub username: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub credential_ref: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub repo_scope: Option<Vec<String>>,
47}
48
49impl Connection {
50 pub fn new_id(provider: ProviderType) -> String {
51 format!("{}-{}", provider.as_str().to_lowercase(), uuid::Uuid::new_v4().simple())
52 }
53
54 pub fn resolve_repo_scope(&self, legacy: impl FnOnce() -> Option<String>) -> Vec<String> {
60 match &self.repo_scope {
61 Some(scope) => scope.clone(),
62 None => legacy().into_iter().collect(),
63 }
64 }
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ItemRef {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub repo: Option<String>,
78 pub id: String,
79}
80
81impl ItemRef {
82 pub fn new(id: impl Into<String>) -> Self {
84 ItemRef { repo: None, id: id.into() }
85 }
86
87 pub fn in_repo(repo: impl Into<String>, id: impl Into<String>) -> Self {
89 ItemRef { repo: Some(repo.into()), id: id.into() }
90 }
91
92 pub fn maybe(repo: Option<String>, id: impl Into<String>) -> Self {
94 ItemRef { repo, id: id.into() }
95 }
96}
97
98#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
100pub struct RepositoryPage {
101 pub repositories: Vec<String>,
103 pub truncated: bool,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111pub enum VoteStyle {
112 BinaryApprove,
113 NumericVotes,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Terminology {
118 pub pull_requests: String,
119 pub work_items: String,
120 pub pipelines: String,
121}
122
123impl Default for Terminology {
124 fn default() -> Self {
125 Self {
126 pull_requests: "Pull Requests".into(),
127 work_items: "Work Items".into(),
128 pipelines: "Pipelines".into(),
129 }
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct Capabilities {
135 pub supports_pull_requests: bool,
136 pub supports_work_items: bool,
137 pub supports_pipelines: bool,
138 pub vote_style: VoteStyle,
139 pub supports_merge: bool,
140 pub supports_inline_comments: bool,
141 pub supports_pipeline_trigger: bool,
142 pub supports_pipeline_discovery: bool,
143 pub supports_notifications: bool,
145 pub terminology: Terminology,
146}
147
148impl Default for Capabilities {
149 fn default() -> Self {
150 Self {
151 supports_pull_requests: false,
152 supports_work_items: false,
153 supports_pipelines: false,
154 vote_style: VoteStyle::BinaryApprove,
155 supports_merge: false,
156 supports_inline_comments: false,
157 supports_pipeline_trigger: false,
158 supports_pipeline_discovery: false,
159 supports_notifications: false,
160 terminology: Terminology::default(),
161 }
162 }
163}
164
165impl Capabilities {
166 pub fn supports(&self, section: Section) -> bool {
167 match section {
168 Section::PullRequests => self.supports_pull_requests,
169 Section::WorkItems => self.supports_work_items,
170 Section::Pipelines => self.supports_pipelines,
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
178pub enum PullRequestFilter {
179 #[default]
180 All,
181 Mine,
182 ReviewRequested,
183}
184
185#[derive(Debug, Clone)]
186pub struct PullRequestQuery {
187 pub filter: PullRequestFilter,
188 pub include_completed: bool,
189 pub limit: Option<u32>,
190 pub decorate: bool,
198}
199
200impl Default for PullRequestQuery {
201 fn default() -> Self {
202 Self { filter: PullRequestFilter::default(), include_completed: false, limit: None, decorate: true }
203 }
204}
205
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct PrDecoration {
209 pub mergeable: MergeableState,
210 pub changed_files: i64,
211 pub additions: i64,
212 pub deletions: i64,
213 pub checks: CheckStatus,
214 pub check_summary: Option<CheckSummary>,
215}
216
217impl PrDecoration {
218 pub fn from_pull_request(pr: &PullRequest) -> Self {
220 Self {
221 mergeable: pr.mergeable,
222 changed_files: pr.changed_files,
223 additions: pr.additions,
224 deletions: pr.deletions,
225 checks: pr.checks,
226 check_summary: pr.check_summary.clone(),
227 }
228 }
229
230 pub fn apply_to(&self, pr: &mut PullRequest) {
234 pr.mergeable = self.mergeable;
235 pr.changed_files = self.changed_files;
236 pr.additions = self.additions;
237 pr.deletions = self.deletions;
238 pr.checks = self.checks;
239 pr.check_summary = self.check_summary.clone();
240 }
241}
242
243#[derive(Debug, Clone, Default)]
244pub struct WorkItemQuery {
245 pub mine_only: bool,
246 pub include_completed: bool,
247 pub limit: Option<u32>,
248}
249
250#[derive(Debug, Clone, Default)]
251pub struct PipelineRunQuery {
252 pub definition_id: Option<String>,
253 pub repository: Option<String>,
256 pub branch: Option<String>,
257 pub limit: Option<u32>,
258}
259
260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
261pub enum MergeStrategy {
262 #[default]
263 Merge,
264 Squash,
265 Rebase,
266}
267
268#[derive(Debug, Clone, Default)]
269pub struct MergeOptions {
270 pub strategy: MergeStrategy,
271 pub delete_source_ref: bool,
272}
273
274#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
279pub struct PrWriteSupport {
280 pub resolve_threads: bool,
282 pub draft: bool,
284 pub close: bool,
286 pub reopen: bool,
288 pub request_reviewer: bool,
290}
291
292impl PrWriteSupport {
293 pub const ALL: PrWriteSupport = PrWriteSupport { resolve_threads: true, draft: true, close: true, reopen: true, request_reviewer: true };
295}
296
297#[async_trait]
304pub trait PullRequestSource: Send + Sync {
305 async fn list(&self, query: &PullRequestQuery) -> Result<Vec<PullRequest>>;
306 async fn current_user(&self) -> Result<Option<String>> {
320 Ok(None)
321 }
322 async fn get(&self, item: &ItemRef) -> Result<PullRequest>;
323 async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
324 async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
327 Ok(Vec::new())
328 }
329 async fn changes(&self, item: &ItemRef) -> Result<Vec<FileChange>>;
330 async fn checks(&self, _item: &ItemRef) -> Result<Vec<CheckRun>> {
332 Ok(Vec::new())
333 }
334 async fn commits(&self, _item: &ItemRef) -> Result<Vec<Commit>> {
336 Ok(Vec::new())
337 }
338 fn list_omits_decoration(&self) -> bool {
347 false
348 }
349 fn list_targets_filter(&self) -> bool {
361 false
362 }
363 fn review_clears_request(&self) -> bool {
372 false
373 }
374 async fn decorate(&self, item: &ItemRef) -> Result<PrDecoration> {
377 Ok(PrDecoration::from_pull_request(&self.get(item).await?))
378 }
379 async fn commit_changes(&self, _item: &ItemRef, _sha: &str) -> Result<Vec<FileChange>> {
382 Ok(Vec::new())
383 }
384 async fn file_text(&self, _item: &ItemRef, _path: &str, _sha: Option<&str>) -> Result<Option<String>> {
389 Ok(None)
390 }
391 async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
392 async fn reply_to_thread(&self, item: &ItemRef, _thread_id: &str, body: &str) -> Result<()> {
398 self.add_comment(item, body).await
399 }
400 async fn vote(&self, item: &ItemRef, vote: ReviewVote) -> Result<()>;
401 async fn merge(&self, item: &ItemRef, options: &MergeOptions) -> Result<()>;
402 async fn revert(&self, _item: &ItemRef) -> Result<()> {
405 Err(Error::Provider("this provider has no revert API — revert it from the provider's web UI".into()))
406 }
407 async fn submit_review(&self, _item: &ItemRef, _event: ReviewVote, _comments: &[LineComment]) -> Result<()> {
410 Err(Error::Provider("this provider doesn't support line-comment reviews".into()))
411 }
412 fn pr_writes(&self) -> PrWriteSupport {
415 PrWriteSupport::default()
416 }
417 async fn resolve_thread(&self, _item: &ItemRef, _thread_id: &str, _resolved: bool) -> Result<()> {
420 Err(Error::Provider("this provider doesn't support resolving comment threads".into()))
421 }
422 async fn set_draft(&self, _item: &ItemRef, _draft: bool) -> Result<()> {
425 Err(Error::Provider("this provider doesn't support changing a pull request's draft state".into()))
426 }
427 async fn set_closed(&self, _item: &ItemRef, _closed: bool) -> Result<()> {
430 Err(Error::Provider("this provider doesn't support closing or reopening pull requests".into()))
431 }
432 async fn reviewable_users(&self, _item: &ItemRef) -> Result<Vec<User>> {
436 Ok(Vec::new())
437 }
438 async fn request_reviewer(&self, _item: &ItemRef, _user_id: &str) -> Result<()> {
441 Err(Error::Provider("this provider doesn't support requesting reviewers".into()))
442 }
443}
444
445#[async_trait]
446pub trait WorkItemSource: Send + Sync {
447 async fn list(&self, query: &WorkItemQuery) -> Result<Vec<WorkItem>>;
448 async fn get(&self, item: &ItemRef) -> Result<WorkItem>;
449 async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
450 async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
452 Ok(Vec::new())
453 }
454 async fn set_state(&self, item: &ItemRef, state: &str) -> Result<()>;
455 async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
456 async fn available_states(&self, _item: &ItemRef) -> Result<Vec<String>> {
459 Ok(Vec::new())
460 }
461 async fn assignable_users(&self, _item: &ItemRef) -> Result<Vec<User>> {
465 Ok(Vec::new())
466 }
467 async fn set_assignee(&self, _item: &ItemRef, _assignee_id: Option<&str>) -> Result<()> {
470 Err(Error::Provider("set_assignee not supported by this provider".into()))
471 }
472 async fn update_fields(&self, _item: &ItemRef, _title: Option<&str>, _description: Option<&str>) -> Result<()> {
475 Err(Error::Provider("update_fields not supported by this provider".into()))
476 }
477}
478
479#[async_trait]
480pub trait PipelineSource: Send + Sync {
481 async fn discover(&self) -> Result<Vec<PipelineDefinition>>;
482 async fn list_runs(&self, query: &PipelineRunQuery) -> Result<Vec<PipelineRun>>;
483 async fn get_run(&self, run: &ItemRef) -> Result<PipelineRun>;
484 async fn current_user(&self) -> Result<Option<String>> {
492 Ok(None)
493 }
494 async fn logs(&self, run: &ItemRef, job_id: Option<&str>) -> Result<String>;
495 async fn trigger(&self, definition: &ItemRef, branch: Option<&str>) -> Result<()>;
498 async fn cancel_run(&self, _run: &ItemRef) -> Result<()> {
500 Err(Error::Provider("cancel_run not supported by this provider".into()))
501 }
502 fn supports_rerun(&self) -> bool {
504 false
505 }
506 fn supports_rerun_failed(&self) -> bool {
509 false
510 }
511 fn rerun_starts_new_run(&self, _failed_only: bool) -> bool {
515 false
516 }
517 async fn rerun_run(&self, _run: &ItemRef, _failed_only: bool) -> Result<Option<String>> {
522 Err(Error::Provider("rerun not supported by this provider".into()))
523 }
524 fn supports_artifacts(&self) -> bool {
527 false
528 }
529 async fn artifacts(&self, _run: &ItemRef) -> Result<Vec<PipelineArtifact>> {
531 Ok(Vec::new())
532 }
533 async fn annotations(&self, _run: &ItemRef) -> Result<Vec<PipelineAnnotation>> {
536 Ok(Vec::new())
537 }
538 fn supports_approvals(&self) -> bool {
541 false
542 }
543 fn can_respond_to_approvals(&self) -> bool {
548 self.supports_approvals()
549 }
550 async fn pending_approvals(&self, _run: &ItemRef) -> Result<Vec<PipelineApproval>> {
552 Ok(Vec::new())
553 }
554 async fn respond_approval(
556 &self,
557 _run: &ItemRef,
558 _approval_id: &str,
559 _decision: ApprovalDecision,
560 _comment: Option<&str>,
561 ) -> Result<()> {
562 Err(Error::Provider("this provider doesn't support pipeline approvals".into()))
563 }
564}
565
566#[async_trait]
569pub trait NotificationSource: Send + Sync {
570 async fn list(&self) -> Result<Vec<Notification>>;
572 async fn mark_read(&self, id: &str) -> Result<()>;
574 async fn mark_all_read(&self) -> Result<()> {
577 for n in self.list().await? {
578 let _ = self.mark_read(&n.id).await;
579 }
580 Ok(())
581 }
582}
583
584#[async_trait]
586pub trait ProviderConnection: Send + Sync {
587 fn connection_id(&self) -> &str;
588 fn provider_type(&self) -> ProviderType;
589 fn display_name(&self) -> &str;
590 fn capabilities(&self) -> &Capabilities;
591 fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>>;
592 fn work_items(&self) -> Option<Arc<dyn WorkItemSource>>;
593 fn pipelines(&self) -> Option<Arc<dyn PipelineSource>>;
594 fn notifications(&self) -> Option<Arc<dyn NotificationSource>> {
596 None
597 }
598 async fn discover_repositories(&self) -> Result<RepositoryPage> {
606 Ok(RepositoryPage::default())
607 }
608 async fn check(&self) -> bool;
610}
611
612pub trait ProviderFactory: Send + Sync {
614 fn provider_type(&self) -> ProviderType;
615 fn describe_capabilities(&self) -> Capabilities;
616 fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>>;
617}
618
619pub struct ProviderRegistry {
621 factories: HashMap<ProviderType, Arc<dyn ProviderFactory>>,
622}
623
624impl ProviderRegistry {
625 pub fn new(factories: Vec<Arc<dyn ProviderFactory>>) -> Self {
626 let factories = factories.into_iter().map(|f| (f.provider_type(), f)).collect();
627 Self { factories }
628 }
629
630 pub fn available(&self) -> Vec<ProviderType> {
631 self.factories.keys().copied().collect()
632 }
633
634 pub fn supports(&self, provider: ProviderType) -> bool {
635 self.factories.contains_key(&provider)
636 }
637
638 pub fn describe(&self, provider: ProviderType) -> Option<Capabilities> {
639 self.factories.get(&provider).map(|f| f.describe_capabilities())
640 }
641
642 pub fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
643 let factory = self
644 .factories
645 .get(&connection.provider_type)
646 .ok_or_else(|| Error::Config(format!("no provider registered for '{}'", connection.provider_type.as_str())))?;
647 factory.create(connection, secret)
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use async_trait::async_trait;
655
656 struct FakeConn {
657 caps: Capabilities,
658 }
659
660 #[async_trait]
661 impl ProviderConnection for FakeConn {
662 fn connection_id(&self) -> &str {
663 "fake"
664 }
665 fn provider_type(&self) -> ProviderType {
666 ProviderType::GitHub
667 }
668 fn display_name(&self) -> &str {
669 "Fake"
670 }
671 fn capabilities(&self) -> &Capabilities {
672 &self.caps
673 }
674 fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> {
675 None
676 }
677 fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> {
678 None
679 }
680 fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> {
681 None
682 }
683 async fn check(&self) -> bool {
684 true
685 }
686 }
687
688 struct FakeFactory {
689 provider: ProviderType,
690 caps: Capabilities,
691 }
692
693 impl ProviderFactory for FakeFactory {
694 fn provider_type(&self) -> ProviderType {
695 self.provider
696 }
697 fn describe_capabilities(&self) -> Capabilities {
698 self.caps.clone()
699 }
700 fn create(&self, _connection: &Connection, _secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
701 Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
702 }
703 }
704
705 fn conn(provider: ProviderType) -> Connection {
706 Connection {
707 id: "c".into(),
708 provider_type: provider,
709 display_name: "c".into(),
710 base_url: None,
711 organization: None,
712 project: None,
713 repository: None,
714 username: None,
715 credential_ref: None,
716 repo_scope: None,
717 }
718 }
719
720 #[test]
721 fn capabilities_supports_reflects_flags() {
722 let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
723 assert!(caps.supports(Section::PullRequests));
724 assert!(!caps.supports(Section::WorkItems));
725 }
726
727 #[test]
728 fn registry_dispatches_and_rejects_unknown() {
729 let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
730 let registry = ProviderRegistry::new(vec![Arc::new(FakeFactory { provider: ProviderType::GitHub, caps })]);
731
732 assert!(registry.supports(ProviderType::GitHub));
733 assert!(!registry.supports(ProviderType::Linear));
734
735 let live = registry.create(&conn(ProviderType::GitHub), Some("pat".into())).unwrap();
736 assert_eq!(live.provider_type(), ProviderType::GitHub);
737 assert!(registry.create(&conn(ProviderType::Linear), None).is_err());
738 }
739
740 #[test]
741 fn scope_falls_back_only_when_absent_never_when_empty() {
742 let mut c = conn(ProviderType::GitHub);
743
744 c.repository = Some("acme/pay".into());
747 assert_eq!(c.resolve_repo_scope(|| c.repository.clone()), vec!["acme/pay".to_string()]);
748
749 c.repo_scope = Some(vec![]);
753 assert!(c.resolve_repo_scope(|| c.repository.clone()).is_empty());
754
755 c.repo_scope = Some(vec!["acme/pay".into(), "acme/ledger".into()]);
757 assert_eq!(c.resolve_repo_scope(|| c.repository.clone()).len(), 2);
758 }
759
760 #[test]
761 fn item_ref_carries_the_connection_relative_repo() {
762 let r = ItemRef::in_repo("acme/pay", "7");
763 assert_eq!(r.repo.as_deref(), Some("acme/pay"));
764 assert_eq!(ItemRef::new("7").repo, None);
765 assert_eq!(ItemRef::maybe(None, "7"), ItemRef::new("7"));
766 }
767
768 #[test]
769 fn pull_request_query_decorates_by_default() {
770 assert!(PullRequestQuery::default().decorate);
772 }
773
774 #[test]
775 fn new_id_is_prefixed() {
776 assert!(Connection::new_id(ProviderType::GitHub).starts_with("github-"));
777 }
778
779 struct BarePrs;
782
783 #[async_trait]
784 impl PullRequestSource for BarePrs {
785 async fn list(&self, _q: &PullRequestQuery) -> Result<Vec<PullRequest>> {
786 Ok(Vec::new())
787 }
788 async fn get(&self, item: &ItemRef) -> Result<PullRequest> {
789 Err(Error::NotFound(item.id.clone()))
790 }
791 async fn threads(&self, _item: &ItemRef) -> Result<Vec<CommentThread>> {
792 Ok(Vec::new())
793 }
794 async fn changes(&self, _item: &ItemRef) -> Result<Vec<FileChange>> {
795 Ok(Vec::new())
796 }
797 async fn add_comment(&self, _item: &ItemRef, _body: &str) -> Result<()> {
798 Ok(())
799 }
800 async fn vote(&self, _item: &ItemRef, _vote: ReviewVote) -> Result<()> {
801 Ok(())
802 }
803 async fn merge(&self, _item: &ItemRef, _options: &MergeOptions) -> Result<()> {
804 Ok(())
805 }
806 }
807
808 #[tokio::test]
809 async fn optional_pull_request_writes_default_to_unsupported() {
810 let src = BarePrs;
811 let item = ItemRef::new("1");
812 assert_eq!(src.pr_writes(), PrWriteSupport::default(), "nothing advertised");
813 assert!(!PrWriteSupport::default().resolve_threads && !PrWriteSupport::default().request_reviewer);
814 assert!(src.resolve_thread(&item, "t1", true).await.is_err());
815 assert!(src.set_draft(&item, false).await.is_err());
816 assert!(src.set_closed(&item, true).await.is_err());
817 assert!(src.request_reviewer(&item, "u1").await.is_err());
818 assert!(src.reviewable_users(&item).await.unwrap().is_empty(), "no people rather than an error, so a picker opens empty");
819 let all = PrWriteSupport::ALL;
820 assert!(all.resolve_threads && all.draft && all.close && all.reopen && all.request_reviewer);
821 }
822}