This repository has no description
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}