This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-postreceive / src / lib.rs
13 kB 447 lines
1use std::collections::BTreeMap; 2use std::time::{Duration, Instant}; 3 4use knot_events::{ 5 CommitCount, EmailCommitCount, GitRefUpdate, LanguageSize, RefUpdateMeta, Reservation, 6}; 7use knot_git::{EntryKind, Haves, RefUpdate, Repo, Wants}; 8use knot_messages::{CiLogsKey, PushMessages, UrlKey}; 9use knot_types::{ 10 AccountDid, AppviewEndpoint, BranchName, ChangedFiles, CiLogsAddr, Email, Handle, Listing, Oid, 11 OwnerDid, PushOptions, RefName, RefTransition, RepoDid, RepoPath, RepoRkey, 12}; 13use knot_workflow::{Compiled, RawWorkflow, Trigger, WorkflowName}; 14use url::Url; 15 16const WORKFLOW_DIR: &str = ".tangled/workflows"; 17 18pub struct Actor { 19 pub committer: AccountDid, 20 pub owner: Option<OwnerDid>, 21 pub repo: RepoDid, 22} 23 24pub enum Ci { 25 Skip, 26 Compile { 27 logs: Option<CiLogsAddr>, 28 verbose: bool, 29 }, 30} 31 32pub enum OwnerLabel { 33 Handle(Handle), 34 Did(OwnerDid), 35} 36 37impl OwnerLabel { 38 pub fn as_str(&self) -> &str { 39 match self { 40 OwnerLabel::Handle(handle) => handle.as_str(), 41 OwnerLabel::Did(did) => did.as_str(), 42 } 43 } 44} 45 46pub struct PullLink { 47 pub appview: AppviewEndpoint, 48 pub owner: OwnerLabel, 49 pub rkey: RepoRkey, 50} 51 52struct SourceBranch(BranchName); 53struct TargetBranch(BranchName); 54 55knot_types::scalar_newtype! { 56 pub struct LanguagesPushBudget(Duration); 57} 58 59struct PushContext<'a> { 60 actor: &'a Actor, 61 ci: &'a Ci, 62 push_options: &'a PushOptions, 63 pull: Option<&'a PullLink>, 64 languages_budget: LanguagesPushBudget, 65 messages: &'a PushMessages, 66} 67 68#[allow(clippy::too_many_arguments)] 69pub fn post_receive( 70 repo: &Repo, 71 actor: &Actor, 72 applied: Vec<(RefUpdate, Reservation)>, 73 ci: &Ci, 74 push_options: &PushOptions, 75 pull: Option<&PullLink>, 76 languages_budget: LanguagesPushBudget, 77 messages: &PushMessages, 78) -> Vec<String> { 79 let context = PushContext { 80 actor, 81 ci, 82 push_options, 83 pull, 84 languages_budget, 85 messages, 86 }; 87 applied 88 .into_iter() 89 .flat_map(|(update, reservation)| publish_one(repo, update, reservation, &context)) 90 .collect() 91} 92 93fn publish_one( 94 repo: &Repo, 95 update: RefUpdate, 96 reservation: Reservation, 97 context: &PushContext, 98) -> Vec<String> { 99 let name = update.name(); 100 let transition = update.transition(); 101 let changed = match transition.new_oid() { 102 Some(new) => changed_paths(repo, name, transition.old_oid(), new), 103 None => ChangedFiles::none(), 104 }; 105 let pipeline = ci_messages(repo, name, transition, &changed, context); 106 let event = GitRefUpdate::new( 107 context.actor.repo.clone(), 108 context.actor.owner.clone(), 109 context.actor.committer.clone(), 110 ) 111 .on_ref(name.clone(), transition, repo.object_format()) 112 .with_push_options(context.push_options) 113 .with_changed_files(changed); 114 let event = match transition.new_oid() { 115 Some(new) => event.with_meta(ref_update_meta( 116 repo, 117 name, 118 transition.old_oid(), 119 new, 120 context.languages_budget, 121 )), 122 None => event, 123 }; 124 reservation.fulfill(&event); 125 126 let pull_link = match (transition, context.pull) { 127 (RefTransition::Create { .. }, Some(link)) => { 128 pull_request_message(repo, link, name, context.messages).unwrap_or_default() 129 } 130 _ => Vec::new(), 131 }; 132 pull_link.into_iter().chain(pipeline).collect() 133} 134 135fn changed_paths(repo: &Repo, name: &RefName, old: Option<Oid>, new: Oid) -> ChangedFiles { 136 let range = knot_git::PatchRange { 137 base: old, 138 head: new, 139 }; 140 match repo.changed_paths(range) { 141 Ok(changed) => { 142 if changed.listing() == Listing::Truncated { 143 tracing::warn!( 144 ref_name = name.as_str(), 145 path = %repo.path().display(), 146 files = changed.paths().len(), 147 "changed-file listing truncated at the record budget, leaving every paths constraint assumed matched" 148 ); 149 } 150 changed 151 } 152 Err(error) => { 153 tracing::warn!( 154 ref_name = name.as_str(), 155 path = %repo.path().display(), 156 %error, 157 "changed-file listing failed, leaving every paths constraint assumed matched" 158 ); 159 ChangedFiles::unknown() 160 } 161 } 162} 163 164fn pull_request_message( 165 repo: &Repo, 166 link: &PullLink, 167 name: &RefName, 168 messages: &PushMessages, 169) -> Option<Vec<String>> { 170 let branch = branch_short(name)?; 171 let default_ref = repo.default_branch()?; 172 let default = branch_short(&default_ref)?; 173 if branch == default { 174 return None; 175 } 176 repo.find_ref(&default_ref).ok().flatten()?; 177 if repo.origin_url().is_some() { 178 return None; 179 } 180 let url = pull_url( 181 &link.appview, 182 &link.owner, 183 &link.rkey, 184 &SourceBranch(branch), 185 &TargetBranch(default), 186 )?; 187 Some(messages.pull_request.lines(|UrlKey::Url| url.to_string())) 188} 189 190fn pull_url( 191 appview: &AppviewEndpoint, 192 owner: &OwnerLabel, 193 repo: &RepoRkey, 194 source: &SourceBranch, 195 target: &TargetBranch, 196) -> Option<Url> { 197 let mut url = Url::parse(appview.as_str()).ok()?; 198 url.path_segments_mut().ok()?.pop_if_empty().extend([ 199 owner.as_str(), 200 repo.as_str(), 201 "pulls", 202 "new", 203 ]); 204 url.query_pairs_mut() 205 .append_pair("source", "branch") 206 .append_pair("sourceBranch", source.0.as_str()) 207 .append_pair("targetBranch", target.0.as_str()); 208 Some(url) 209} 210 211fn ref_update_meta( 212 repo: &Repo, 213 name: &RefName, 214 old: Option<Oid>, 215 new: Oid, 216 languages_budget: LanguagesPushBudget, 217) -> RefUpdateMeta { 218 let is_default_ref = is_default_branch(repo, name); 219 let by_email = commit_counts(repo, name, old, new); 220 let languages = match is_default_ref { 221 true => language_sizes(repo, new, languages_budget), 222 false => Vec::new(), 223 }; 224 RefUpdateMeta::new(is_default_ref, by_email, languages) 225} 226 227fn is_default_branch(repo: &Repo, name: &RefName) -> bool { 228 match (branch_short(name), repo.default_branch()) { 229 (Some(short), Some(default)) => branch_short(&default) == Some(short), 230 _ => false, 231 } 232} 233 234fn branch_short(name: &RefName) -> Option<BranchName> { 235 name.as_str() 236 .strip_prefix("refs/heads/") 237 .and_then(|short| BranchName::new(short).ok()) 238} 239 240fn commit_counts(repo: &Repo, name: &RefName, old: Option<Oid>, new: Oid) -> Vec<EmailCommitCount> { 241 let tip = match repo.peel_to_commit(new) { 242 Ok(tip) => tip, 243 Err(error) => { 244 tracing::warn!( 245 ref_name = name.as_str(), 246 path = %repo.path().display(), 247 %error, 248 "commit tally failed peeling new tip" 249 ); 250 return Vec::new(); 251 } 252 }; 253 let haves = match old { 254 Some(old) => match repo.peel_to_commit(old) { 255 Ok(base) => vec![base], 256 Err(error) => { 257 tracing::warn!( 258 ref_name = name.as_str(), 259 path = %repo.path().display(), 260 %error, 261 "commit tally failed reading prior tip" 262 ); 263 return Vec::new(); 264 } 265 }, 266 None => sibling_tips(repo, name), 267 }; 268 let walked = match repo.rev_walk(Wants::new(&[tip]), Haves::new(&haves)) { 269 Ok(oids) => oids, 270 Err(error) => { 271 tracing::warn!( 272 ref_name = name.as_str(), 273 path = %repo.path().display(), 274 %error, 275 "commit tally walk failed" 276 ); 277 return Vec::new(); 278 } 279 }; 280 let tallies = walked 281 .into_iter() 282 .filter_map(|oid| repo.find_commit(oid).ok()) 283 .fold( 284 BTreeMap::<Email, CommitCount>::new(), 285 |mut counts, commit| { 286 let slot = counts.entry(commit.author.email).or_default(); 287 *slot = slot.succ(); 288 counts 289 }, 290 ); 291 tallies 292 .into_iter() 293 .map(|(email, count)| EmailCommitCount::new(email, count)) 294 .collect() 295} 296 297fn sibling_tips(repo: &Repo, name: &RefName) -> Vec<Oid> { 298 match repo.references() { 299 Ok(records) => records 300 .into_iter() 301 .filter(|record| { 302 record.name.as_str() != name.as_str() 303 && record.name.as_str().starts_with("refs/heads/") 304 }) 305 .filter_map(|record| repo.peel_to_commit(record.target).ok()) 306 .collect(), 307 Err(error) => { 308 tracing::warn!( 309 ref_name = name.as_str(), 310 path = %repo.path().display(), 311 %error, 312 "sibling ref scan failed" 313 ); 314 Vec::new() 315 } 316 } 317} 318 319fn language_sizes( 320 repo: &Repo, 321 new: Oid, 322 languages_budget: LanguagesPushBudget, 323) -> Vec<LanguageSize> { 324 let deadline = Instant::now() + languages_budget.get(); 325 match knot_langs::analyze(repo, new, Some(deadline)) { 326 Ok(sizes) => sizes 327 .into_iter() 328 .filter(|(_, size)| size.get() > 0) 329 .map(|(name, size)| LanguageSize::new(name, size)) 330 .collect(), 331 Err(error) => { 332 tracing::warn!( 333 path = %repo.path().display(), 334 commit = %new.to_hex(), 335 %error, 336 "language breakdown failed" 337 ); 338 Vec::new() 339 } 340 } 341} 342 343fn ci_messages( 344 repo: &Repo, 345 name: &RefName, 346 transition: RefTransition, 347 changed: &ChangedFiles, 348 context: &PushContext, 349) -> Vec<String> { 350 let Ci::Compile { logs, verbose } = context.ci else { 351 return Vec::new(); 352 }; 353 let Some(new) = transition.new_oid() else { 354 return Vec::new(); 355 }; 356 let templates = context.messages; 357 let raws = read_workflows(repo, new); 358 let compiled = knot_workflow::compile( 359 &raws, 360 &Trigger::Push { 361 ref_name: name.clone(), 362 }, 363 changed, 364 ); 365 let listed = compiled.any_listed_match(); 366 let Compiled { 367 workflows, 368 diagnostics, 369 } = compiled; 370 let mut messages = diagnostics.errors; 371 if *verbose { 372 let clean = messages.is_empty() && diagnostics.warnings.is_empty(); 373 messages.extend(diagnostics.warnings); 374 match (workflows.is_empty(), clean) { 375 (true, _) => messages.extend(templates.pipeline_none.text_lines()), 376 (false, true) => messages.extend(templates.pipeline_clean.text_lines()), 377 (false, false) => {} 378 } 379 } 380 if let Some(addr) = logs.as_ref().filter(|_| listed) { 381 messages.extend(templates.ci_logs.lines(|key| match key { 382 CiLogsKey::Host => addr.host().to_string(), 383 CiLogsKey::Port => addr.port().to_string(), 384 CiLogsKey::Repo => context.actor.repo.to_string(), 385 CiLogsKey::Sha => new.to_hex(), 386 })); 387 } 388 messages 389} 390 391fn read_workflows(repo: &Repo, new: Oid) -> Vec<RawWorkflow> { 392 let commit = match repo.peel_to_commit(new) { 393 Ok(commit) => commit, 394 Err(error) => { 395 tracing::warn!( 396 commit = %new.to_hex(), 397 path = %repo.path().display(), 398 %error, 399 "workflow read failed peeling commit" 400 ); 401 return Vec::new(); 402 } 403 }; 404 let workflow_dir = RepoPath::new(WORKFLOW_DIR).expect("literal workflow dir is well-formed"); 405 let entries = match repo.tree_entries_at(commit, Some(&workflow_dir)) { 406 Ok(entries) => entries.unwrap_or_default(), 407 Err(error) => { 408 tracing::warn!( 409 path = %repo.path().display(), 410 commit = %new.to_hex(), 411 %error, 412 "workflow directory read failed" 413 ); 414 return Vec::new(); 415 } 416 }; 417 entries 418 .into_iter() 419 .filter(|entry| matches!(entry.kind, EntryKind::Blob | EntryKind::BlobExecutable)) 420 .filter_map(|entry| { 421 let name = match WorkflowName::new(entry.name.as_str()) { 422 Ok(name) => name, 423 Err(error) => { 424 tracing::warn!( 425 workflow = %entry.name, 426 path = %repo.path().display(), 427 %error, 428 "workflow name rejected" 429 ); 430 return None; 431 } 432 }; 433 match repo.read_blob(entry.oid) { 434 Ok(contents) => Some(RawWorkflow { name, contents }), 435 Err(error) => { 436 tracing::warn!( 437 workflow = %entry.name, 438 path = %repo.path().display(), 439 %error, 440 "workflow unreadable" 441 ); 442 None 443 } 444 } 445 }) 446 .collect() 447}