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