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#[async_trait]
281pub trait PullRequestSource: Send + Sync {
282 async fn list(&self, query: &PullRequestQuery) -> Result<Vec<PullRequest>>;
283 async fn current_user(&self) -> Result<Option<String>> {
297 Ok(None)
298 }
299 async fn get(&self, item: &ItemRef) -> Result<PullRequest>;
300 async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
301 async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
304 Ok(Vec::new())
305 }
306 async fn changes(&self, item: &ItemRef) -> Result<Vec<FileChange>>;
307 async fn checks(&self, _item: &ItemRef) -> Result<Vec<CheckRun>> {
309 Ok(Vec::new())
310 }
311 async fn commits(&self, _item: &ItemRef) -> Result<Vec<Commit>> {
313 Ok(Vec::new())
314 }
315 fn list_omits_decoration(&self) -> bool {
324 false
325 }
326 async fn decorate(&self, item: &ItemRef) -> Result<PrDecoration> {
329 Ok(PrDecoration::from_pull_request(&self.get(item).await?))
330 }
331 async fn commit_changes(&self, _item: &ItemRef, _sha: &str) -> Result<Vec<FileChange>> {
334 Ok(Vec::new())
335 }
336 async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
337 async fn reply_to_thread(&self, item: &ItemRef, _thread_id: &str, body: &str) -> Result<()> {
343 self.add_comment(item, body).await
344 }
345 async fn vote(&self, item: &ItemRef, vote: ReviewVote) -> Result<()>;
346 async fn merge(&self, item: &ItemRef, options: &MergeOptions) -> Result<()>;
347 async fn revert(&self, _item: &ItemRef) -> Result<()> {
350 Err(Error::Provider("this provider has no revert API — revert it from the provider's web UI".into()))
351 }
352 async fn submit_review(&self, _item: &ItemRef, _event: ReviewVote, _comments: &[LineComment]) -> Result<()> {
355 Err(Error::Provider("this provider doesn't support line-comment reviews".into()))
356 }
357}
358
359#[async_trait]
360pub trait WorkItemSource: Send + Sync {
361 async fn list(&self, query: &WorkItemQuery) -> Result<Vec<WorkItem>>;
362 async fn get(&self, item: &ItemRef) -> Result<WorkItem>;
363 async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
364 async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
366 Ok(Vec::new())
367 }
368 async fn set_state(&self, item: &ItemRef, state: &str) -> Result<()>;
369 async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
370 async fn available_states(&self, _item: &ItemRef) -> Result<Vec<String>> {
373 Ok(Vec::new())
374 }
375 async fn assignable_users(&self, _item: &ItemRef) -> Result<Vec<User>> {
379 Ok(Vec::new())
380 }
381 async fn set_assignee(&self, _item: &ItemRef, _assignee_id: Option<&str>) -> Result<()> {
384 Err(Error::Provider("set_assignee not supported by this provider".into()))
385 }
386 async fn update_fields(&self, _item: &ItemRef, _title: Option<&str>, _description: Option<&str>) -> Result<()> {
389 Err(Error::Provider("update_fields not supported by this provider".into()))
390 }
391}
392
393#[async_trait]
394pub trait PipelineSource: Send + Sync {
395 async fn discover(&self) -> Result<Vec<PipelineDefinition>>;
396 async fn list_runs(&self, query: &PipelineRunQuery) -> Result<Vec<PipelineRun>>;
397 async fn get_run(&self, run: &ItemRef) -> Result<PipelineRun>;
398 async fn current_user(&self) -> Result<Option<String>> {
406 Ok(None)
407 }
408 async fn logs(&self, run: &ItemRef, job_id: Option<&str>) -> Result<String>;
409 async fn trigger(&self, definition: &ItemRef, branch: Option<&str>) -> Result<()>;
412 async fn cancel_run(&self, _run: &ItemRef) -> Result<()> {
414 Err(Error::Provider("cancel_run not supported by this provider".into()))
415 }
416 fn supports_rerun(&self) -> bool {
418 false
419 }
420 fn supports_rerun_failed(&self) -> bool {
423 false
424 }
425 fn rerun_starts_new_run(&self, _failed_only: bool) -> bool {
429 false
430 }
431 async fn rerun_run(&self, _run: &ItemRef, _failed_only: bool) -> Result<Option<String>> {
436 Err(Error::Provider("rerun not supported by this provider".into()))
437 }
438 fn supports_artifacts(&self) -> bool {
441 false
442 }
443 async fn artifacts(&self, _run: &ItemRef) -> Result<Vec<PipelineArtifact>> {
445 Ok(Vec::new())
446 }
447 async fn annotations(&self, _run: &ItemRef) -> Result<Vec<PipelineAnnotation>> {
450 Ok(Vec::new())
451 }
452 fn supports_approvals(&self) -> bool {
455 false
456 }
457 fn can_respond_to_approvals(&self) -> bool {
462 self.supports_approvals()
463 }
464 async fn pending_approvals(&self, _run: &ItemRef) -> Result<Vec<PipelineApproval>> {
466 Ok(Vec::new())
467 }
468 async fn respond_approval(
470 &self,
471 _run: &ItemRef,
472 _approval_id: &str,
473 _decision: ApprovalDecision,
474 _comment: Option<&str>,
475 ) -> Result<()> {
476 Err(Error::Provider("this provider doesn't support pipeline approvals".into()))
477 }
478}
479
480#[async_trait]
483pub trait NotificationSource: Send + Sync {
484 async fn list(&self) -> Result<Vec<Notification>>;
486 async fn mark_read(&self, id: &str) -> Result<()>;
488 async fn mark_all_read(&self) -> Result<()> {
491 for n in self.list().await? {
492 let _ = self.mark_read(&n.id).await;
493 }
494 Ok(())
495 }
496}
497
498#[async_trait]
500pub trait ProviderConnection: Send + Sync {
501 fn connection_id(&self) -> &str;
502 fn provider_type(&self) -> ProviderType;
503 fn display_name(&self) -> &str;
504 fn capabilities(&self) -> &Capabilities;
505 fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>>;
506 fn work_items(&self) -> Option<Arc<dyn WorkItemSource>>;
507 fn pipelines(&self) -> Option<Arc<dyn PipelineSource>>;
508 fn notifications(&self) -> Option<Arc<dyn NotificationSource>> {
510 None
511 }
512 async fn discover_repositories(&self) -> Result<RepositoryPage> {
520 Ok(RepositoryPage::default())
521 }
522 async fn check(&self) -> bool;
524}
525
526pub trait ProviderFactory: Send + Sync {
528 fn provider_type(&self) -> ProviderType;
529 fn describe_capabilities(&self) -> Capabilities;
530 fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>>;
531}
532
533pub struct ProviderRegistry {
535 factories: HashMap<ProviderType, Arc<dyn ProviderFactory>>,
536}
537
538impl ProviderRegistry {
539 pub fn new(factories: Vec<Arc<dyn ProviderFactory>>) -> Self {
540 let factories = factories.into_iter().map(|f| (f.provider_type(), f)).collect();
541 Self { factories }
542 }
543
544 pub fn available(&self) -> Vec<ProviderType> {
545 self.factories.keys().copied().collect()
546 }
547
548 pub fn supports(&self, provider: ProviderType) -> bool {
549 self.factories.contains_key(&provider)
550 }
551
552 pub fn describe(&self, provider: ProviderType) -> Option<Capabilities> {
553 self.factories.get(&provider).map(|f| f.describe_capabilities())
554 }
555
556 pub fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
557 let factory = self
558 .factories
559 .get(&connection.provider_type)
560 .ok_or_else(|| Error::Config(format!("no provider registered for '{}'", connection.provider_type.as_str())))?;
561 factory.create(connection, secret)
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568 use async_trait::async_trait;
569
570 struct FakeConn {
571 caps: Capabilities,
572 }
573
574 #[async_trait]
575 impl ProviderConnection for FakeConn {
576 fn connection_id(&self) -> &str {
577 "fake"
578 }
579 fn provider_type(&self) -> ProviderType {
580 ProviderType::GitHub
581 }
582 fn display_name(&self) -> &str {
583 "Fake"
584 }
585 fn capabilities(&self) -> &Capabilities {
586 &self.caps
587 }
588 fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> {
589 None
590 }
591 fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> {
592 None
593 }
594 fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> {
595 None
596 }
597 async fn check(&self) -> bool {
598 true
599 }
600 }
601
602 struct FakeFactory {
603 provider: ProviderType,
604 caps: Capabilities,
605 }
606
607 impl ProviderFactory for FakeFactory {
608 fn provider_type(&self) -> ProviderType {
609 self.provider
610 }
611 fn describe_capabilities(&self) -> Capabilities {
612 self.caps.clone()
613 }
614 fn create(&self, _connection: &Connection, _secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
615 Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
616 }
617 }
618
619 fn conn(provider: ProviderType) -> Connection {
620 Connection {
621 id: "c".into(),
622 provider_type: provider,
623 display_name: "c".into(),
624 base_url: None,
625 organization: None,
626 project: None,
627 repository: None,
628 username: None,
629 credential_ref: None,
630 repo_scope: None,
631 }
632 }
633
634 #[test]
635 fn capabilities_supports_reflects_flags() {
636 let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
637 assert!(caps.supports(Section::PullRequests));
638 assert!(!caps.supports(Section::WorkItems));
639 }
640
641 #[test]
642 fn registry_dispatches_and_rejects_unknown() {
643 let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
644 let registry = ProviderRegistry::new(vec![Arc::new(FakeFactory { provider: ProviderType::GitHub, caps })]);
645
646 assert!(registry.supports(ProviderType::GitHub));
647 assert!(!registry.supports(ProviderType::Linear));
648
649 let live = registry.create(&conn(ProviderType::GitHub), Some("pat".into())).unwrap();
650 assert_eq!(live.provider_type(), ProviderType::GitHub);
651 assert!(registry.create(&conn(ProviderType::Linear), None).is_err());
652 }
653
654 #[test]
655 fn scope_falls_back_only_when_absent_never_when_empty() {
656 let mut c = conn(ProviderType::GitHub);
657
658 c.repository = Some("acme/pay".into());
661 assert_eq!(c.resolve_repo_scope(|| c.repository.clone()), vec!["acme/pay".to_string()]);
662
663 c.repo_scope = Some(vec![]);
667 assert!(c.resolve_repo_scope(|| c.repository.clone()).is_empty());
668
669 c.repo_scope = Some(vec!["acme/pay".into(), "acme/ledger".into()]);
671 assert_eq!(c.resolve_repo_scope(|| c.repository.clone()).len(), 2);
672 }
673
674 #[test]
675 fn item_ref_carries_the_connection_relative_repo() {
676 let r = ItemRef::in_repo("acme/pay", "7");
677 assert_eq!(r.repo.as_deref(), Some("acme/pay"));
678 assert_eq!(ItemRef::new("7").repo, None);
679 assert_eq!(ItemRef::maybe(None, "7"), ItemRef::new("7"));
680 }
681
682 #[test]
683 fn pull_request_query_decorates_by_default() {
684 assert!(PullRequestQuery::default().decorate);
686 }
687
688 #[test]
689 fn new_id_is_prefixed() {
690 assert!(Connection::new_id(ProviderType::GitHub).starts_with("github-"));
691 }
692}