use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::domain::*;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Connection {
pub id: String,
pub provider_type: ProviderType,
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub organization: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_scope: Option<Vec<String>>,
}
impl Connection {
pub fn new_id(provider: ProviderType) -> String {
format!("{}-{}", provider.as_str().to_lowercase(), uuid::Uuid::new_v4().simple())
}
pub fn resolve_repo_scope(&self, legacy: impl FnOnce() -> Option<String>) -> Vec<String> {
match &self.repo_scope {
Some(scope) => scope.clone(),
None => legacy().into_iter().collect(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ItemRef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
pub id: String,
}
impl ItemRef {
pub fn new(id: impl Into<String>) -> Self {
ItemRef { repo: None, id: id.into() }
}
pub fn in_repo(repo: impl Into<String>, id: impl Into<String>) -> Self {
ItemRef { repo: Some(repo.into()), id: id.into() }
}
pub fn maybe(repo: Option<String>, id: impl Into<String>) -> Self {
ItemRef { repo, id: id.into() }
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryPage {
pub repositories: Vec<String>,
pub truncated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VoteStyle {
BinaryApprove,
NumericVotes,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Terminology {
pub pull_requests: String,
pub work_items: String,
pub pipelines: String,
}
impl Default for Terminology {
fn default() -> Self {
Self {
pull_requests: "Pull Requests".into(),
work_items: "Work Items".into(),
pipelines: "Pipelines".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Capabilities {
pub supports_pull_requests: bool,
pub supports_work_items: bool,
pub supports_pipelines: bool,
pub vote_style: VoteStyle,
pub supports_merge: bool,
pub supports_inline_comments: bool,
pub supports_pipeline_trigger: bool,
pub supports_pipeline_discovery: bool,
pub supports_notifications: bool,
pub terminology: Terminology,
}
impl Default for Capabilities {
fn default() -> Self {
Self {
supports_pull_requests: false,
supports_work_items: false,
supports_pipelines: false,
vote_style: VoteStyle::BinaryApprove,
supports_merge: false,
supports_inline_comments: false,
supports_pipeline_trigger: false,
supports_pipeline_discovery: false,
supports_notifications: false,
terminology: Terminology::default(),
}
}
}
impl Capabilities {
pub fn supports(&self, section: Section) -> bool {
match section {
Section::PullRequests => self.supports_pull_requests,
Section::WorkItems => self.supports_work_items,
Section::Pipelines => self.supports_pipelines,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PullRequestFilter {
#[default]
All,
Mine,
ReviewRequested,
}
#[derive(Debug, Clone)]
pub struct PullRequestQuery {
pub filter: PullRequestFilter,
pub include_completed: bool,
pub limit: Option<u32>,
pub decorate: bool,
}
impl Default for PullRequestQuery {
fn default() -> Self {
Self { filter: PullRequestFilter::default(), include_completed: false, limit: None, decorate: true }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PrDecoration {
pub mergeable: MergeableState,
pub changed_files: i64,
pub additions: i64,
pub deletions: i64,
pub checks: CheckStatus,
pub check_summary: Option<CheckSummary>,
}
impl PrDecoration {
pub fn from_pull_request(pr: &PullRequest) -> Self {
Self {
mergeable: pr.mergeable,
changed_files: pr.changed_files,
additions: pr.additions,
deletions: pr.deletions,
checks: pr.checks,
check_summary: pr.check_summary.clone(),
}
}
pub fn apply_to(&self, pr: &mut PullRequest) {
pr.mergeable = self.mergeable;
pr.changed_files = self.changed_files;
pr.additions = self.additions;
pr.deletions = self.deletions;
pr.checks = self.checks;
pr.check_summary = self.check_summary.clone();
}
}
#[derive(Debug, Clone, Default)]
pub struct WorkItemQuery {
pub mine_only: bool,
pub include_completed: bool,
pub limit: Option<u32>,
}
#[derive(Debug, Clone, Default)]
pub struct PipelineRunQuery {
pub definition_id: Option<String>,
pub repository: Option<String>,
pub branch: Option<String>,
pub limit: Option<u32>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum MergeStrategy {
#[default]
Merge,
Squash,
Rebase,
}
#[derive(Debug, Clone, Default)]
pub struct MergeOptions {
pub strategy: MergeStrategy,
pub delete_source_ref: bool,
}
#[async_trait]
pub trait PullRequestSource: Send + Sync {
async fn list(&self, query: &PullRequestQuery) -> Result<Vec<PullRequest>>;
async fn current_user(&self) -> Result<Option<String>> {
Ok(None)
}
async fn get(&self, item: &ItemRef) -> Result<PullRequest>;
async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
Ok(Vec::new())
}
async fn changes(&self, item: &ItemRef) -> Result<Vec<FileChange>>;
async fn checks(&self, _item: &ItemRef) -> Result<Vec<CheckRun>> {
Ok(Vec::new())
}
async fn commits(&self, _item: &ItemRef) -> Result<Vec<Commit>> {
Ok(Vec::new())
}
fn list_omits_decoration(&self) -> bool {
false
}
async fn decorate(&self, item: &ItemRef) -> Result<PrDecoration> {
Ok(PrDecoration::from_pull_request(&self.get(item).await?))
}
async fn commit_changes(&self, _item: &ItemRef, _sha: &str) -> Result<Vec<FileChange>> {
Ok(Vec::new())
}
async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
async fn reply_to_thread(&self, item: &ItemRef, _thread_id: &str, body: &str) -> Result<()> {
self.add_comment(item, body).await
}
async fn vote(&self, item: &ItemRef, vote: ReviewVote) -> Result<()>;
async fn merge(&self, item: &ItemRef, options: &MergeOptions) -> Result<()>;
async fn revert(&self, _item: &ItemRef) -> Result<()> {
Err(Error::Provider("this provider has no revert API — revert it from the provider's web UI".into()))
}
async fn submit_review(&self, _item: &ItemRef, _event: ReviewVote, _comments: &[LineComment]) -> Result<()> {
Err(Error::Provider("this provider doesn't support line-comment reviews".into()))
}
}
#[async_trait]
pub trait WorkItemSource: Send + Sync {
async fn list(&self, query: &WorkItemQuery) -> Result<Vec<WorkItem>>;
async fn get(&self, item: &ItemRef) -> Result<WorkItem>;
async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
Ok(Vec::new())
}
async fn set_state(&self, item: &ItemRef, state: &str) -> Result<()>;
async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
async fn available_states(&self, _item: &ItemRef) -> Result<Vec<String>> {
Ok(Vec::new())
}
async fn assignable_users(&self, _item: &ItemRef) -> Result<Vec<User>> {
Ok(Vec::new())
}
async fn set_assignee(&self, _item: &ItemRef, _assignee_id: Option<&str>) -> Result<()> {
Err(Error::Provider("set_assignee not supported by this provider".into()))
}
async fn update_fields(&self, _item: &ItemRef, _title: Option<&str>, _description: Option<&str>) -> Result<()> {
Err(Error::Provider("update_fields not supported by this provider".into()))
}
}
#[async_trait]
pub trait PipelineSource: Send + Sync {
async fn discover(&self) -> Result<Vec<PipelineDefinition>>;
async fn list_runs(&self, query: &PipelineRunQuery) -> Result<Vec<PipelineRun>>;
async fn get_run(&self, run: &ItemRef) -> Result<PipelineRun>;
async fn logs(&self, run: &ItemRef, job_id: Option<&str>) -> Result<String>;
async fn trigger(&self, definition: &ItemRef, branch: Option<&str>) -> Result<()>;
async fn cancel_run(&self, _run: &ItemRef) -> Result<()> {
Err(Error::Provider("cancel_run not supported by this provider".into()))
}
fn supports_approvals(&self) -> bool {
false
}
fn can_respond_to_approvals(&self) -> bool {
self.supports_approvals()
}
async fn pending_approvals(&self, _run: &ItemRef) -> Result<Vec<PipelineApproval>> {
Ok(Vec::new())
}
async fn respond_approval(
&self,
_run: &ItemRef,
_approval_id: &str,
_decision: ApprovalDecision,
_comment: Option<&str>,
) -> Result<()> {
Err(Error::Provider("this provider doesn't support pipeline approvals".into()))
}
}
#[async_trait]
pub trait NotificationSource: Send + Sync {
async fn list(&self) -> Result<Vec<Notification>>;
async fn mark_read(&self, id: &str) -> Result<()>;
async fn mark_all_read(&self) -> Result<()> {
for n in self.list().await? {
let _ = self.mark_read(&n.id).await;
}
Ok(())
}
}
#[async_trait]
pub trait ProviderConnection: Send + Sync {
fn connection_id(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn display_name(&self) -> &str;
fn capabilities(&self) -> &Capabilities;
fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>>;
fn work_items(&self) -> Option<Arc<dyn WorkItemSource>>;
fn pipelines(&self) -> Option<Arc<dyn PipelineSource>>;
fn notifications(&self) -> Option<Arc<dyn NotificationSource>> {
None
}
async fn discover_repositories(&self) -> Result<RepositoryPage> {
Ok(RepositoryPage::default())
}
async fn check(&self) -> bool;
}
pub trait ProviderFactory: Send + Sync {
fn provider_type(&self) -> ProviderType;
fn describe_capabilities(&self) -> Capabilities;
fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>>;
}
pub struct ProviderRegistry {
factories: HashMap<ProviderType, Arc<dyn ProviderFactory>>,
}
impl ProviderRegistry {
pub fn new(factories: Vec<Arc<dyn ProviderFactory>>) -> Self {
let factories = factories.into_iter().map(|f| (f.provider_type(), f)).collect();
Self { factories }
}
pub fn available(&self) -> Vec<ProviderType> {
self.factories.keys().copied().collect()
}
pub fn supports(&self, provider: ProviderType) -> bool {
self.factories.contains_key(&provider)
}
pub fn describe(&self, provider: ProviderType) -> Option<Capabilities> {
self.factories.get(&provider).map(|f| f.describe_capabilities())
}
pub fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
let factory = self
.factories
.get(&connection.provider_type)
.ok_or_else(|| Error::Config(format!("no provider registered for '{}'", connection.provider_type.as_str())))?;
factory.create(connection, secret)
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
struct FakeConn {
caps: Capabilities,
}
#[async_trait]
impl ProviderConnection for FakeConn {
fn connection_id(&self) -> &str {
"fake"
}
fn provider_type(&self) -> ProviderType {
ProviderType::GitHub
}
fn display_name(&self) -> &str {
"Fake"
}
fn capabilities(&self) -> &Capabilities {
&self.caps
}
fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> {
None
}
fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> {
None
}
fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> {
None
}
async fn check(&self) -> bool {
true
}
}
struct FakeFactory {
provider: ProviderType,
caps: Capabilities,
}
impl ProviderFactory for FakeFactory {
fn provider_type(&self) -> ProviderType {
self.provider
}
fn describe_capabilities(&self) -> Capabilities {
self.caps.clone()
}
fn create(&self, _connection: &Connection, _secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
}
}
fn conn(provider: ProviderType) -> Connection {
Connection {
id: "c".into(),
provider_type: provider,
display_name: "c".into(),
base_url: None,
organization: None,
project: None,
repository: None,
username: None,
credential_ref: None,
repo_scope: None,
}
}
#[test]
fn capabilities_supports_reflects_flags() {
let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
assert!(caps.supports(Section::PullRequests));
assert!(!caps.supports(Section::WorkItems));
}
#[test]
fn registry_dispatches_and_rejects_unknown() {
let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
let registry = ProviderRegistry::new(vec![Arc::new(FakeFactory { provider: ProviderType::GitHub, caps })]);
assert!(registry.supports(ProviderType::GitHub));
assert!(!registry.supports(ProviderType::Linear));
let live = registry.create(&conn(ProviderType::GitHub), Some("pat".into())).unwrap();
assert_eq!(live.provider_type(), ProviderType::GitHub);
assert!(registry.create(&conn(ProviderType::Linear), None).is_err());
}
#[test]
fn scope_falls_back_only_when_absent_never_when_empty() {
let mut c = conn(ProviderType::GitHub);
c.repository = Some("acme/pay".into());
assert_eq!(c.resolve_repo_scope(|| c.repository.clone()), vec!["acme/pay".to_string()]);
c.repo_scope = Some(vec![]);
assert!(c.resolve_repo_scope(|| c.repository.clone()).is_empty());
c.repo_scope = Some(vec!["acme/pay".into(), "acme/ledger".into()]);
assert_eq!(c.resolve_repo_scope(|| c.repository.clone()).len(), 2);
}
#[test]
fn item_ref_carries_the_connection_relative_repo() {
let r = ItemRef::in_repo("acme/pay", "7");
assert_eq!(r.repo.as_deref(), Some("acme/pay"));
assert_eq!(ItemRef::new("7").repo, None);
assert_eq!(ItemRef::maybe(None, "7"), ItemRef::new("7"));
}
#[test]
fn pull_request_query_decorates_by_default() {
assert!(PullRequestQuery::default().decorate);
}
#[test]
fn new_id_is_prefixed() {
assert!(Connection::new_id(ProviderType::GitHub).starts_with("github-"));
}
}