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
15 kB 498 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 OriginUrl, 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, &context.actor.repo) 129 .unwrap_or_default() 130 } 131 _ => Vec::new(), 132 }; 133 pull_link.into_iter().chain(pipeline).collect() 134} 135 136fn changed_paths(repo: &Repo, name: &RefName, old: Option<Oid>, new: Oid) -> ChangedFiles { 137 let range = knot_git::PatchRange { 138 base: old, 139 head: new, 140 }; 141 match repo.changed_paths(range) { 142 Ok(changed) => { 143 if changed.listing() == Listing::Truncated { 144 tracing::warn!( 145 ref_name = name.as_str(), 146 path = %repo.path().display(), 147 files = changed.paths().len(), 148 "changed-file listing truncated at the record budget, leaving every paths constraint assumed matched" 149 ); 150 } 151 changed 152 } 153 Err(error) => { 154 tracing::warn!( 155 ref_name = name.as_str(), 156 path = %repo.path().display(), 157 %error, 158 "changed-file listing failed, leaving every paths constraint assumed matched" 159 ); 160 ChangedFiles::unknown() 161 } 162 } 163} 164 165fn pull_request_message( 166 repo: &Repo, 167 link: &PullLink, 168 name: &RefName, 169 messages: &PushMessages, 170 repo_did: &RepoDid, 171) -> Option<Vec<String>> { 172 let branch = branch_short(name)?; 173 let default_ref = repo.default_branch()?; 174 let default = branch_short(&default_ref)?; 175 if branch == default { 176 return None; 177 } 178 repo.find_ref(&default_ref).ok().flatten()?; 179 180 let url = match repo.origin_url() { 181 Some(remote) => fork_pull_url( 182 &link.appview, 183 &SourceBranch(branch), 184 &TargetBranch(default), 185 remote, 186 repo_did, 187 )?, 188 None => branch_pull_url( 189 &link.appview, 190 &link.owner, 191 &link.rkey, 192 &SourceBranch(branch), 193 &TargetBranch(default), 194 )?, 195 }; 196 197 Some(messages.pull_request.lines(|UrlKey::Url| url.to_string())) 198} 199 200fn branch_pull_url( 201 appview: &AppviewEndpoint, 202 owner: &OwnerLabel, 203 repo_rkey: &RepoRkey, 204 source: &SourceBranch, 205 target: &TargetBranch, 206) -> Option<Url> { 207 let mut url = Url::parse(appview.as_str()).ok()?; 208 209 url.path_segments_mut().ok()?.pop_if_empty().extend([ 210 owner.as_str(), 211 repo_rkey.as_str(), 212 "pulls", 213 "new", 214 ]); 215 216 url.query_pairs_mut() 217 .append_pair("source", "branch") 218 .append_pair("sourceBranch", source.0.as_str()) 219 .append_pair("targetBranch", target.0.as_str()); 220 221 Some(url) 222} 223 224fn fork_pull_url( 225 appview: &AppviewEndpoint, 226 source: &SourceBranch, 227 target: &TargetBranch, 228 remote: OriginUrl, 229 repo_did: &RepoDid, 230) -> Option<Url> { 231 let remote_url = Url::parse(remote.as_str()).ok()?; 232 233 // TODO: We need to handle file schemes. For now though if the remote is a 234 // file scheme a fork PR link won't be created. 235 match remote_url.scheme() { 236 "http" | "https" => (), 237 _ => return None, 238 } 239 240 let paths: Vec<&str> = remote_url 241 .path_segments() 242 .map(|segments| segments.collect()) 243 .unwrap_or_default(); 244 245 let mut url = Url::parse(appview.as_str()).ok()?; 246 247 url.path_segments_mut() 248 .ok()? 249 .pop_if_empty() 250 .extend(paths) 251 .extend(["pulls", "new"]); 252 253 url.query_pairs_mut() 254 .append_pair("source", "fork") 255 .append_pair("sourceBranch", source.0.as_str()) 256 .append_pair("targetBranch", target.0.as_str()) 257 .append_pair("fork", repo_did.as_str()); 258 259 Some(url) 260} 261 262fn ref_update_meta( 263 repo: &Repo, 264 name: &RefName, 265 old: Option<Oid>, 266 new: Oid, 267 languages_budget: LanguagesPushBudget, 268) -> RefUpdateMeta { 269 let is_default_ref = is_default_branch(repo, name); 270 let by_email = commit_counts(repo, name, old, new); 271 let languages = match is_default_ref { 272 true => language_sizes(repo, new, languages_budget), 273 false => Vec::new(), 274 }; 275 RefUpdateMeta::new(is_default_ref, by_email, languages) 276} 277 278fn is_default_branch(repo: &Repo, name: &RefName) -> bool { 279 match (branch_short(name), repo.default_branch()) { 280 (Some(short), Some(default)) => branch_short(&default) == Some(short), 281 _ => false, 282 } 283} 284 285fn branch_short(name: &RefName) -> Option<BranchName> { 286 name.as_str() 287 .strip_prefix("refs/heads/") 288 .and_then(|short| BranchName::new(short).ok()) 289} 290 291fn commit_counts(repo: &Repo, name: &RefName, old: Option<Oid>, new: Oid) -> Vec<EmailCommitCount> { 292 let tip = match repo.peel_to_commit(new) { 293 Ok(tip) => tip, 294 Err(error) => { 295 tracing::warn!( 296 ref_name = name.as_str(), 297 path = %repo.path().display(), 298 %error, 299 "commit tally failed peeling new tip" 300 ); 301 return Vec::new(); 302 } 303 }; 304 let haves = match old { 305 Some(old) => match repo.peel_to_commit(old) { 306 Ok(base) => vec![base], 307 Err(error) => { 308 tracing::warn!( 309 ref_name = name.as_str(), 310 path = %repo.path().display(), 311 %error, 312 "commit tally failed reading prior tip" 313 ); 314 return Vec::new(); 315 } 316 }, 317 None => sibling_tips(repo, name), 318 }; 319 let walked = match repo.rev_walk(Wants::new(&[tip]), Haves::new(&haves)) { 320 Ok(oids) => oids, 321 Err(error) => { 322 tracing::warn!( 323 ref_name = name.as_str(), 324 path = %repo.path().display(), 325 %error, 326 "commit tally walk failed" 327 ); 328 return Vec::new(); 329 } 330 }; 331 let tallies = walked 332 .into_iter() 333 .filter_map(|oid| repo.find_commit(oid).ok()) 334 .fold( 335 BTreeMap::<Email, CommitCount>::new(), 336 |mut counts, commit| { 337 let slot = counts.entry(commit.author.email).or_default(); 338 *slot = slot.succ(); 339 counts 340 }, 341 ); 342 tallies 343 .into_iter() 344 .map(|(email, count)| EmailCommitCount::new(email, count)) 345 .collect() 346} 347 348fn sibling_tips(repo: &Repo, name: &RefName) -> Vec<Oid> { 349 match repo.references() { 350 Ok(records) => records 351 .into_iter() 352 .filter(|record| { 353 record.name.as_str() != name.as_str() 354 && record.name.as_str().starts_with("refs/heads/") 355 }) 356 .filter_map(|record| repo.peel_to_commit(record.target).ok()) 357 .collect(), 358 Err(error) => { 359 tracing::warn!( 360 ref_name = name.as_str(), 361 path = %repo.path().display(), 362 %error, 363 "sibling ref scan failed" 364 ); 365 Vec::new() 366 } 367 } 368} 369 370fn language_sizes( 371 repo: &Repo, 372 new: Oid, 373 languages_budget: LanguagesPushBudget, 374) -> Vec<LanguageSize> { 375 let deadline = Instant::now() + languages_budget.get(); 376 match knot_langs::analyze(repo, new, Some(deadline)) { 377 Ok(sizes) => sizes 378 .into_iter() 379 .filter(|(_, size)| size.get() > 0) 380 .map(|(name, size)| LanguageSize::new(name, size)) 381 .collect(), 382 Err(error) => { 383 tracing::warn!( 384 path = %repo.path().display(), 385 commit = %new.to_hex(), 386 %error, 387 "language breakdown failed" 388 ); 389 Vec::new() 390 } 391 } 392} 393 394fn ci_messages( 395 repo: &Repo, 396 name: &RefName, 397 transition: RefTransition, 398 changed: &ChangedFiles, 399 context: &PushContext, 400) -> Vec<String> { 401 let Ci::Compile { logs, verbose } = context.ci else { 402 return Vec::new(); 403 }; 404 let Some(new) = transition.new_oid() else { 405 return Vec::new(); 406 }; 407 let templates = context.messages; 408 let raws = read_workflows(repo, new); 409 let compiled = knot_workflow::compile( 410 &raws, 411 &Trigger::Push { 412 ref_name: name.clone(), 413 }, 414 changed, 415 ); 416 let listed = compiled.any_listed_match(); 417 let Compiled { 418 workflows, 419 diagnostics, 420 } = compiled; 421 let mut messages = diagnostics.errors; 422 if *verbose { 423 let clean = messages.is_empty() && diagnostics.warnings.is_empty(); 424 messages.extend(diagnostics.warnings); 425 match (workflows.is_empty(), clean) { 426 (true, _) => messages.extend(templates.pipeline_none.text_lines()), 427 (false, true) => messages.extend(templates.pipeline_clean.text_lines()), 428 (false, false) => {} 429 } 430 } 431 if let Some(addr) = logs.as_ref().filter(|_| listed) { 432 messages.extend(templates.ci_logs.lines(|key| match key { 433 CiLogsKey::Host => addr.host().to_string(), 434 CiLogsKey::Port => addr.port().to_string(), 435 CiLogsKey::Repo => context.actor.repo.to_string(), 436 CiLogsKey::Sha => new.to_hex(), 437 })); 438 } 439 messages 440} 441 442fn read_workflows(repo: &Repo, new: Oid) -> Vec<RawWorkflow> { 443 let commit = match repo.peel_to_commit(new) { 444 Ok(commit) => commit, 445 Err(error) => { 446 tracing::warn!( 447 commit = %new.to_hex(), 448 path = %repo.path().display(), 449 %error, 450 "workflow read failed peeling commit" 451 ); 452 return Vec::new(); 453 } 454 }; 455 let workflow_dir = RepoPath::new(WORKFLOW_DIR).expect("literal workflow dir is well-formed"); 456 let entries = match repo.tree_entries_at(commit, Some(&workflow_dir)) { 457 Ok(entries) => entries.unwrap_or_default(), 458 Err(error) => { 459 tracing::warn!( 460 path = %repo.path().display(), 461 commit = %new.to_hex(), 462 %error, 463 "workflow directory read failed" 464 ); 465 return Vec::new(); 466 } 467 }; 468 entries 469 .into_iter() 470 .filter(|entry| matches!(entry.kind, EntryKind::Blob | EntryKind::BlobExecutable)) 471 .filter_map(|entry| { 472 let name = match WorkflowName::new(entry.name.as_str()) { 473 Ok(name) => name, 474 Err(error) => { 475 tracing::warn!( 476 workflow = %entry.name, 477 path = %repo.path().display(), 478 %error, 479 "workflow name rejected" 480 ); 481 return None; 482 } 483 }; 484 match repo.read_blob(entry.oid) { 485 Ok(contents) => Some(RawWorkflow { name, contents }), 486 Err(error) => { 487 tracing::warn!( 488 workflow = %entry.name, 489 path = %repo.path().display(), 490 %error, 491 "workflow unreadable" 492 ); 493 None 494 } 495 } 496 }) 497 .collect() 498}