use knot_git::{EntryKind, RefUpdate, Repo}; use knot_messages::{CiLogsKey, PushMessages, UrlKey}; use knot_types::{ AccountDid, AppviewEndpoint, BranchName, ChangedFiles, CiLogsAddr, Handle, Listing, Oid, OriginUrl, OwnerDid, RefName, RefTransition, RepoDid, RepoPath, RepoRkey, }; use knot_workflow::{Compiled, RawWorkflow, Trigger, WorkflowName}; use url::Url; const WORKFLOW_DIR: &str = ".tangled/workflows"; pub struct Actor { pub committer: AccountDid, pub repo: RepoDid, } pub enum Ci { Skip, Compile { logs: Option, verbose: bool, }, } pub enum OwnerLabel { Handle(Handle), Did(OwnerDid), } impl OwnerLabel { pub fn as_str(&self) -> &str { match self { OwnerLabel::Handle(handle) => handle.as_str(), OwnerLabel::Did(did) => did.as_str(), } } } pub struct PullLink { pub appview: AppviewEndpoint, pub owner: OwnerLabel, pub rkey: RepoRkey, } struct SourceBranch(BranchName); struct TargetBranch(BranchName); struct PushContext<'a> { actor: &'a Actor, ci: &'a Ci, pull: Option<&'a PullLink>, messages: &'a PushMessages, } pub fn post_receive( repo: &Repo, actor: &Actor, applied: &[RefUpdate], ci: &Ci, pull: Option<&PullLink>, messages: &PushMessages, ) -> Vec { let context = PushContext { actor, ci, pull, messages, }; applied .iter() .flat_map(|update| publish_one(repo, update, &context)) .collect() } fn publish_one(repo: &Repo, update: &RefUpdate, context: &PushContext) -> Vec { let name = update.name(); let transition = update.transition(); let changed = match transition.new_oid() { Some(new) => changed_paths(repo, name, transition.old_oid(), new), None => ChangedFiles::none(), }; let pipeline = ci_messages(repo, name, transition, &changed, context); let pull_link = match (transition, context.pull) { (RefTransition::Create { .. }, Some(link)) => { pull_request_message(repo, link, name, context.messages, &context.actor.repo) .unwrap_or_default() } _ => Vec::new(), }; pull_link.into_iter().chain(pipeline).collect() } fn changed_paths(repo: &Repo, name: &RefName, old: Option, new: Oid) -> ChangedFiles { let range = knot_git::PatchRange { base: old, head: new, }; match repo.changed_paths(range) { Ok(changed) => { if changed.listing() == Listing::Truncated { tracing::warn!( ref_name = name.as_str(), path = %repo.path().display(), files = changed.paths().len(), "changed-file listing truncated at the record budget, leaving every paths constraint assumed matched" ); } changed } Err(error) => { tracing::warn!( ref_name = name.as_str(), path = %repo.path().display(), %error, "changed-file listing failed, leaving every paths constraint assumed matched" ); ChangedFiles::unknown() } } } fn pull_request_message( repo: &Repo, link: &PullLink, name: &RefName, messages: &PushMessages, repo_did: &RepoDid, ) -> Option> { let branch = branch_short(name)?; let default_ref = repo.default_branch()?; let default = branch_short(&default_ref)?; if branch == default { return None; } repo.find_ref(&default_ref).ok().flatten()?; let url = match repo.origin_url() { Some(remote) => fork_pull_url( &link.appview, &SourceBranch(branch), &TargetBranch(default), remote, repo_did, )?, None => branch_pull_url( &link.appview, &link.owner, &link.rkey, &SourceBranch(branch), &TargetBranch(default), )?, }; Some(messages.pull_request.lines(|UrlKey::Url| url.to_string())) } fn branch_pull_url( appview: &AppviewEndpoint, owner: &OwnerLabel, repo_rkey: &RepoRkey, source: &SourceBranch, target: &TargetBranch, ) -> Option { let mut url = Url::parse(appview.as_str()).ok()?; url.path_segments_mut().ok()?.pop_if_empty().extend([ owner.as_str(), repo_rkey.as_str(), "pulls", "new", ]); url.query_pairs_mut() .append_pair("source", "branch") .append_pair("sourceBranch", source.0.as_str()) .append_pair("targetBranch", target.0.as_str()); Some(url) } fn fork_pull_url( appview: &AppviewEndpoint, source: &SourceBranch, target: &TargetBranch, remote: OriginUrl, repo_did: &RepoDid, ) -> Option { let remote_url = Url::parse(remote.as_str()).ok()?; // TODO: We need to handle file schemes. For now though if the remote is a // file scheme a fork PR link won't be created. match remote_url.scheme() { "http" | "https" => (), _ => return None, } let paths: Vec<&str> = remote_url .path_segments() .map(|segments| segments.collect()) .unwrap_or_default(); let mut url = Url::parse(appview.as_str()).ok()?; url.path_segments_mut() .ok()? .pop_if_empty() .extend(paths) .extend(["pulls", "new"]); url.query_pairs_mut() .append_pair("source", "fork") .append_pair("sourceBranch", source.0.as_str()) .append_pair("targetBranch", target.0.as_str()) .append_pair("fork", repo_did.as_str()); Some(url) } fn branch_short(name: &RefName) -> Option { name.as_str() .strip_prefix("refs/heads/") .and_then(|short| BranchName::new(short).ok()) } fn ci_messages( repo: &Repo, name: &RefName, transition: RefTransition, changed: &ChangedFiles, context: &PushContext, ) -> Vec { let Ci::Compile { logs, verbose } = context.ci else { return Vec::new(); }; let Some(new) = transition.new_oid() else { return Vec::new(); }; let templates = context.messages; let raws = read_workflows(repo, new); let compiled = knot_workflow::compile( &raws, &Trigger::Push { ref_name: name.clone(), }, changed, ); let listed = compiled.any_listed_match(); let Compiled { workflows, diagnostics, } = compiled; let mut messages = diagnostics.errors; if *verbose { let clean = messages.is_empty() && diagnostics.warnings.is_empty(); messages.extend(diagnostics.warnings); match (workflows.is_empty(), clean) { (true, _) => messages.extend(templates.pipeline_none.text_lines()), (false, true) => messages.extend(templates.pipeline_clean.text_lines()), (false, false) => {} } } if let Some(addr) = logs.as_ref().filter(|_| listed) { messages.extend(templates.ci_logs.lines(|key| match key { CiLogsKey::Host => addr.host().to_string(), CiLogsKey::Port => addr.port().to_string(), CiLogsKey::Repo => context.actor.repo.to_string(), CiLogsKey::Sha => new.to_hex(), })); } messages } fn read_workflows(repo: &Repo, new: Oid) -> Vec { let commit = match repo.peel_to_commit(new) { Ok(commit) => commit, Err(error) => { tracing::warn!( commit = %new.to_hex(), path = %repo.path().display(), %error, "workflow read failed peeling commit" ); return Vec::new(); } }; let workflow_dir = RepoPath::new(WORKFLOW_DIR).expect("literal workflow dir is well-formed"); let entries = match repo.tree_entries_at(commit, Some(&workflow_dir)) { Ok(entries) => entries.unwrap_or_default(), Err(error) => { tracing::warn!( path = %repo.path().display(), commit = %new.to_hex(), %error, "workflow directory read failed" ); return Vec::new(); } }; entries .into_iter() .filter(|entry| matches!(entry.kind, EntryKind::Blob | EntryKind::BlobExecutable)) .filter_map(|entry| { let name = match WorkflowName::new(entry.name.as_str()) { Ok(name) => name, Err(error) => { tracing::warn!( workflow = %entry.name, path = %repo.path().display(), %error, "workflow name rejected" ); return None; } }; match repo.read_blob(entry.oid) { Ok(contents) => Some(RawWorkflow { name, contents }), Err(error) => { tracing::warn!( workflow = %entry.name, path = %repo.path().display(), %error, "workflow unreadable" ); None } } }) .collect() }