Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331use 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<CiLogsAddr>, 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<String> { 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<String> { 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<Oid>, 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<Vec<String>> { 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<Url> { 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<Url> { 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<BranchName> { 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<String> { 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<RawWorkflow> { 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()}