Skip to content

g1t/crates/contracts/src/checks.rs

1,098 lines43,259 bytesCodeBlame
1//! Checks: what CI, integrations and g1t's own workflows say about a
2//! commit, shown next to it wherever it appears.
3//!
4//! A commit's checks come in two shapes, as they do on GitHub, so the same
5//! integrations work unchanged:
6//!
7//! - **Statuses**: a state (`pending`, `success`, `failure`, `error`) per
8//! context, such as `ci/build`. Kept in the work service's
9//! `commit_statuses` (see `work::CommitStatus`).
10//! - **Check runs**: one run of one check, with a lifecycle (`queued`,
11//! `in_progress`, `completed`), a conclusion, a Markdown report,
12//! annotations on lines of files, and buttons the reporter offers.
13//! Grouped per reporter and commit into a **check suite**. Those reported
14//! through the API are kept by the work service; each job of a g1t
15//! Actions workflow run is read as a check run too, its run as the suite,
16//! from the actions service's own records.
17//!
18//! Every check run reported through the API also stands as a status of its
19//! name (`check_run_id` set on it), so required checks and rulesets'
20//! required status checks are met by either shape alike.
21//!
22//! The work service serves the methods below at `POST /rpc/<method>`.
23//! Mirrors `packages/contracts/src/checks.ts`.
24
25use serde::{Deserialize, Serialize};
26
27use crate::actions::{Job, WorkflowRun};
28use crate::repos::RepoPath;
29use crate::work::CommitStatus;
30use crate::{User, Viewer};
31
32/// Where a check run is in its life.
33pub const STATUSES: [&str; 3] = ["queued", "in_progress", "completed"];
34/// How a completed check run came out.
35pub const CONCLUSIONS: [&str; 7] = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"];
36/// How serious an annotation is.
37pub const ANNOTATION_LEVELS: [&str; 3] = ["notice", "warning", "failure"];
38/// A status's states.
39pub const STATUS_STATES: [&str; 4] = ["pending", "success", "failure", "error"];
40
41/// The most annotations one request adds, and one check run keeps.
42pub const MAX_ANNOTATIONS_PER_REQUEST: usize = 50;
43pub const MAX_ANNOTATIONS: u32 = 1000;
44/// The most buttons a check run offers, and the longest label, description
45/// and identifier of one.
46pub const MAX_ACTIONS: usize = 3;
47pub const MAX_ACTION_LABEL: usize = 20;
48pub const MAX_ACTION_DESCRIPTION: usize = 40;
49pub const MAX_ACTION_IDENTIFIER: usize = 20;
50/// The longest check run name or status context, title, summary and text.
51pub const MAX_NAME_CHARS: usize = 100;
52pub const MAX_TITLE_CHARS: usize = 255;
53pub const MAX_OUTPUT_CHARS: usize = 65_535;
54/// The longest status description.
55pub const MAX_DESCRIPTION_CHARS: usize = 140;
56/// The most commits one `commit_checks` call reads.
57pub const MAX_COMMITS: usize = 100;
58
59/// Who reported a check run or suite: an integration, a token's name, or
60/// `actions` for g1t Actions.
61#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct CheckApp {
64 pub slug: String,
65 pub name: String,
66}
67
68impl CheckApp {
69 /// g1t Actions, whose jobs are check runs.
70 pub fn actions() -> CheckApp {
71 CheckApp { slug: "actions".to_owned(), name: "g1t Actions".to_owned() }
72 }
73
74 /// The reporter named `name`: its slug is the name in lowercase, with
75 /// a dash for anything but a letter or digit.
76 pub fn named(name: &str) -> CheckApp {
77 let name: String = name.trim().chars().take(MAX_NAME_CHARS).collect();
78 let mut slug = String::new();
79 for c in name.chars() {
80 if c.is_ascii_alphanumeric() {
81 slug.push(c.to_ascii_lowercase());
82 } else if !slug.ends_with('-') {
83 slug.push('-');
84 }
85 }
86 let slug = slug.trim_matches('-').to_owned();
87 CheckApp { slug: if slug.is_empty() { "api".to_owned() } else { slug }, name }
88 }
89
90 /// Who an API caller reports as: `app` when given; a job's G1T_TOKEN
91 /// as g1t Actions; otherwise the token's name, or the account's.
92 pub fn of(actor: &User, app: Option<&str>) -> CheckApp {
93 if let Some(app) = app.map(str::trim).filter(|app| !app.is_empty()) {
94 return CheckApp::named(app);
95 }
96 match actor.token.as_ref().and_then(|token| token.name.as_deref()).map(str::trim) {
97 Some(name) if name.starts_with("G1T_TOKEN") => CheckApp::actions(),
98 Some(name) if !name.is_empty() => CheckApp::named(name),
99 _ => CheckApp::named(&actor.username),
100 }
101 }
102}
103
104/// A line range of a file a check run has something to say about.
105#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct CheckAnnotation {
108 /// The file, relative to the repository's root, such as `src/main.rs`.
109 pub path: String,
110 pub start_line: u32,
111 pub end_line: u32,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub start_column: Option<u32>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub end_column: Option<u32>,
116 /// `notice`, `warning` or `failure`.
117 pub annotation_level: String,
118 pub message: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub title: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub raw_details: Option<String>,
123}
124
125/// A button on a check run's page. Pressing it sends the reporter a
126/// `check_run.requested_action` webhook with its `identifier`.
127#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct CheckAction {
130 pub label: String,
131 pub description: String,
132 pub identifier: String,
133}
134
135/// What a check run reports: Markdown `summary` and `text` under a `title`.
136#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct CheckOutput {
139 pub title: Option<String>,
140 pub summary: Option<String>,
141 pub text: Option<String>,
142 pub annotations_count: u32,
143}
144
145/// The suite a check run belongs to.
146#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct SuiteRef {
149 pub id: String,
150}
151
152/// For a g1t Actions job: the workflow run it is part of.
153#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct WorkflowRef {
156 pub run_id: String,
157 /// The workflow's name, such as `CI`.
158 pub name: String,
159 /// The event that started it, such as `push`.
160 pub event: String,
161}
162
163/// One run of one check on a commit.
164#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub struct CommitCheckRun {
167 /// `cr_…` for one reported through the API; a g1t Actions job's id
168 /// (`job_…`) for a job.
169 pub id: String,
170 pub name: String,
171 pub head_sha: String,
172 /// `queued`, `in_progress` or `completed`.
173 pub status: String,
174 /// Once completed: one of [`CONCLUSIONS`].
175 pub conclusion: Option<String>,
176 pub started_at: Option<String>,
177 pub completed_at: Option<String>,
178 /// The reporter's own page for it.
179 pub details_url: Option<String>,
180 /// The reporter's own id for it.
181 pub external_id: Option<String>,
182 /// Its page on g1t (a path on the site; absolute in API responses).
183 pub html_url: String,
184 pub output: CheckOutput,
185 #[serde(default)]
186 pub actions: Vec<CheckAction>,
187 pub check_suite: SuiteRef,
188 pub app: CheckApp,
189 /// For a g1t Actions job: its workflow run.
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub workflow: Option<WorkflowRef>,
192 #[serde(default)]
193 pub created_at: String,
194}
195
196impl CommitCheckRun {
197 /// What it is called where it is listed among a commit's checks: a
198 /// job as `CI / test (push)`, anything else by its name.
199 pub fn display_name(&self) -> String {
200 match &self.workflow {
201 Some(workflow) => format!("{} / {} ({})", workflow.name, self.name, workflow.event),
202 None => self.name.clone(),
203 }
204 }
205}
206
207/// A reporter's check runs on one commit.
208#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct CommitCheckSuite {
211 /// `cs_…`, or a g1t Actions workflow run's id (`run_…`).
212 pub id: String,
213 pub head_sha: String,
214 pub head_branch: Option<String>,
215 /// `queued`, `in_progress` or `completed`, from its latest check runs.
216 pub status: String,
217 pub conclusion: Option<String>,
218 pub app: CheckApp,
219 /// For a g1t Actions run: its workflow's name.
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub name: Option<String>,
222 pub latest_check_runs_count: u32,
223 pub created_at: String,
224 pub updated_at: String,
225}
226
227#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase")]
229pub struct CheckRunList {
230 pub total_count: u32,
231 pub check_runs: Vec<CommitCheckRun>,
232}
233
234#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct CheckSuiteList {
237 pub total_count: u32,
238 pub check_suites: Vec<CommitCheckSuite>,
239}
240
241/// A commit's statuses, one per context, and the state they add up to.
242#[derive(Clone, Debug, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase")]
244pub struct CombinedStatus {
245 /// `failure` if any status failed or errored, else `pending` if any is
246 /// pending or there are none, else `success`.
247 pub state: String,
248 pub sha: String,
249 pub total_count: u32,
250 pub statuses: Vec<CommitStatus>,
251}
252
253/// One check as a commit's list shows it: a check run or a status.
254#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct CheckItem {
257 /// `check_run` or `status`.
258 pub kind: String,
259 /// A check run's id.
260 pub id: Option<String>,
261 pub name: String,
262 /// `success`, `failure`, `pending`, `neutral`, `skipped` or `cancelled`.
263 pub state: String,
264 /// What it says: a check run's title, or a status's description.
265 pub description: Option<String>,
266 /// The reporter's page for it.
267 pub details_url: Option<String>,
268 /// Its page on g1t: a check run's, or a job's run.
269 pub url: Option<String>,
270 /// Who reported it.
271 pub app: Option<String>,
272 pub started_at: Option<String>,
273 pub completed_at: Option<String>,
274}
275
276/// Every check on one commit, and what they add up to.
277#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct CommitChecks {
280 /// `success`, `failure`, `pending`, or `none` when nothing reported.
281 pub state: String,
282 pub total: u32,
283 pub successful: u32,
284 pub failed: u32,
285 pub pending: u32,
286 /// Neutral, skipped and cancelled: neither passed nor failed.
287 pub skipped: u32,
288 /// Failing first, then pending, then the rest, each by name.
289 pub checks: Vec<CheckItem>,
290}
291
292// --- What states mean -----------------------------------------------------
293
294/// A check run's state as one word: `pending` until completed, then its
295/// conclusion's kind. Cancelled is counted with neutral and skipped, and
296/// timed_out and action_required with failure.
297pub fn run_state(status: &str, conclusion: Option<&str>) -> &'static str {
298 if status != "completed" {
299 return "pending";
300 }
301 match conclusion.unwrap_or("neutral") {
302 "success" => "success",
303 "neutral" => "neutral",
304 "skipped" => "skipped",
305 "cancelled" => "cancelled",
306 _ => "failure",
307 }
308}
309
310/// The status a check run stands as, for required checks: pending until
311/// completed; success when it passed, was neutral or skipped; failure
312/// otherwise, a cancelled one included.
313pub fn status_state_of(status: &str, conclusion: Option<&str>) -> &'static str {
314 if status != "completed" {
315 return "pending";
316 }
317 match conclusion.unwrap_or("neutral") {
318 "success" | "neutral" | "skipped" => "success",
319 _ => "failure",
320 }
321}
322
323/// A status's state as one word: `error` is a failure.
324pub fn status_item_state(state: &str) -> &'static str {
325 match state {
326 "success" => "success",
327 "pending" => "pending",
328 _ => "failure",
329 }
330}
331
332/// The state a commit's statuses add up to, as GitHub's combined status.
333pub fn combined_state(statuses: &[CommitStatus]) -> &'static str {
334 if statuses.iter().any(|status| matches!(status.state.as_str(), "failure" | "error")) {
335 "failure"
336 } else if statuses.is_empty() || statuses.iter().any(|status| status.state == "pending") {
337 "pending"
338 } else {
339 "success"
340 }
341}
342
343/// Where a suite stands from its latest check runs' `(status, conclusion)`:
344/// in progress while any is not completed (queued while none started),
345/// then the worst conclusion.
346pub fn suite_state(runs: &[(String, Option<String>)]) -> (&'static str, Option<&'static str>) {
347 if runs.is_empty() {
348 return ("queued", None);
349 }
350 if runs.iter().any(|(status, _)| status != "completed") {
351 let started = runs.iter().any(|(status, _)| status != "queued");
352 return (if started { "in_progress" } else { "queued" }, None);
353 }
354 let has = |wanted: &str| runs.iter().any(|(_, conclusion)| conclusion.as_deref() == Some(wanted));
355 let conclusion = if has("action_required") {
356 "action_required"
357 } else if has("failure") || has("timed_out") {
358 "failure"
359 } else if has("cancelled") {
360 "cancelled"
361 } else if has("success") {
362 "success"
363 } else if runs.iter().all(|(_, conclusion)| conclusion.as_deref() == Some("skipped")) {
364 "skipped"
365 } else {
366 "neutral"
367 };
368 ("completed", Some(conclusion))
369}
370
371// --- g1t Actions jobs as check runs --------------------------------------
372
373/// A job's annotation level as a check run's.
374fn annotation_level(level: &str) -> &'static str {
375 match level {
376 "error" => "failure",
377 "warning" => "warning",
378 _ => "notice",
379 }
380}
381
382/// A g1t Actions job as a check run of `repo` (`owner/name`), its workflow
383/// run as the suite.
384pub fn job_check_run(repo: &str, run: &WorkflowRun, job: &Job) -> CommitCheckRun {
385 let status = match job.status.as_str() {
386 "in_progress" => "in_progress",
387 "completed" => "completed",
388 _ => "queued",
389 };
390 let url = format!("/{repo}/actions/runs/{}?job={}", run.id, job.id);
391 CommitCheckRun {
392 id: job.id.clone(),
393 name: job.name.clone(),
394 head_sha: run.sha.clone(),
395 status: status.to_owned(),
396 conclusion: if status == "completed" { Some(job.conclusion.clone().unwrap_or_else(|| "neutral".to_owned())) } else { None },
397 started_at: job.started_at.clone(),
398 completed_at: if status == "completed" { job.finished_at.clone() } else { None },
399 details_url: Some(url.clone()),
400 external_id: None,
401 html_url: url,
402 output: CheckOutput {
403 title: job.reason.clone(),
404 summary: None,
405 text: None,
406 annotations_count: u32::try_from(job.annotations.len()).unwrap_or(u32::MAX),
407 },
408 actions: Vec::new(),
409 check_suite: SuiteRef { id: run.id.clone() },
410 app: CheckApp::actions(),
411 workflow: Some(WorkflowRef { run_id: run.id.clone(), name: run.name.clone(), event: run.event.clone() }),
412 created_at: run.created_at.clone(),
413 }
414}
415
416/// A job's annotations as a check run's.
417pub fn job_annotations(job: &Job) -> Vec<CheckAnnotation> {
418 job.annotations
419 .iter()
420 .map(|note| CheckAnnotation {
421 path: note.file.clone().unwrap_or_default(),
422 start_line: note.line.unwrap_or(0),
423 end_line: note.line.unwrap_or(0),
424 start_column: None,
425 end_column: None,
426 annotation_level: annotation_level(&note.level).to_owned(),
427 message: note.message.clone(),
428 title: note.title.clone(),
429 raw_details: None,
430 })
431 .collect()
432}
433
434/// A workflow run as a check suite.
435pub fn run_check_suite(run: &WorkflowRun, jobs: &[Job]) -> CommitCheckSuite {
436 let states: Vec<(String, Option<String>)> = jobs
437 .iter()
438 .map(|job| {
439 let status = match job.status.as_str() {
440 "in_progress" | "completed" => job.status.clone(),
441 _ => "queued".to_owned(),
442 };
443 (status, job.conclusion.clone())
444 })
445 .collect();
446 let (status, conclusion) = if jobs.is_empty() {
447 let completed = run.status == "completed";
448 (if completed { "completed" } else { "queued" }, run.conclusion.as_deref().filter(|_| completed).map(|c| match c {
449 "success" => "success",
450 "skipped" => "skipped",
451 "cancelled" => "cancelled",
452 _ => "failure",
453 }))
454 } else {
455 suite_state(&states)
456 };
457 CommitCheckSuite {
458 id: run.id.clone(),
459 head_sha: run.sha.clone(),
460 head_branch: run.git_ref.strip_prefix("refs/heads/").map(str::to_owned),
461 status: status.to_owned(),
462 conclusion: conclusion.map(str::to_owned),
463 app: CheckApp::actions(),
464 name: Some(run.name.clone()),
465 latest_check_runs_count: u32::try_from(jobs.len()).unwrap_or(u32::MAX),
466 created_at: run.created_at.clone(),
467 updated_at: run.finished_at.clone().or_else(|| run.started_at.clone()).unwrap_or_else(|| run.created_at.clone()),
468 }
469}
470
471/// Of a commit's workflow runs, newest first, the latest of each workflow
472/// for each event: an earlier attempt is superseded by the one after it.
473pub fn latest_runs<T>(runs: &[(WorkflowRun, T)]) -> Vec<&(WorkflowRun, T)> {
474 let mut seen: Vec<(&str, &str, &str)> = Vec::new();
475 let mut out = Vec::new();
476 for entry in runs {
477 let key = (entry.0.sha.as_str(), entry.0.path.as_str(), entry.0.event.as_str());
478 if !seen.contains(&key) {
479 seen.push(key);
480 out.push(entry);
481 }
482 }
483 out
484}
485
486// --- A commit's checks, added up -----------------------------------------
487
488/// Every check on one commit, as its list shows them: each check run, and
489/// each status that is not one of them standing in as a status (its
490/// `check_run_id`) nor a workflow's whose jobs are listed (its context
491/// `Workflow / event` among `job_contexts`).
492pub fn summarize(check_runs: &[CommitCheckRun], statuses: &[CommitStatus], job_contexts: &[String]) -> CommitChecks {
493 let mut checks: Vec<CheckItem> = Vec::new();
494 for run in check_runs {
495 checks.push(CheckItem {
496 kind: "check_run".to_owned(),
497 id: Some(run.id.clone()),
498 name: run.display_name(),
499 state: run_state(&run.status, run.conclusion.as_deref()).to_owned(),
500 description: run.output.title.clone().or_else(|| run.output.summary.as_deref().map(first_line)),
501 details_url: run.details_url.clone(),
502 url: Some(run.html_url.clone()),
503 app: Some(run.app.name.clone()),
504 started_at: run.started_at.clone(),
505 completed_at: run.completed_at.clone(),
506 });
507 }
508 for status in statuses {
509 if status.check_run_id.is_some() || job_contexts.contains(&status.context) {
510 continue;
511 }
512 checks.push(CheckItem {
513 kind: "status".to_owned(),
514 id: None,
515 name: status.context.clone(),
516 state: status_item_state(&status.state).to_owned(),
517 description: status.description.clone(),
518 details_url: status.target_url.clone(),
519 url: None,
520 app: status.source.clone(),
521 started_at: None,
522 completed_at: Some(status.updated_at.clone()),
523 });
524 }
525 let rank = |state: &str| match state {
526 "failure" => 0,
527 "pending" => 1,
528 "success" => 2,
529 _ => 3,
530 };
531 checks.sort_by(|a, b| rank(&a.state).cmp(&rank(&b.state)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
532 let count = |wanted: &[&str]| u32::try_from(checks.iter().filter(|check| wanted.contains(&check.state.as_str())).count()).unwrap_or(u32::MAX);
533 let failed = count(&["failure"]);
534 let pending = count(&["pending"]);
535 let state = if checks.is_empty() {
536 "none"
537 } else if failed > 0 {
538 "failure"
539 } else if pending > 0 {
540 "pending"
541 } else {
542 "success"
543 };
544 CommitChecks {
545 state: state.to_owned(),
546 total: u32::try_from(checks.len()).unwrap_or(u32::MAX),
547 successful: count(&["success"]),
548 failed,
549 pending,
550 skipped: count(&["neutral", "skipped", "cancelled"]),
551 checks,
552 }
553}
554
555fn first_line(text: &str) -> String {
556 text.lines().find(|line| !line.trim().is_empty()).unwrap_or("").trim().chars().take(MAX_TITLE_CHARS).collect()
557}
558
559// --- Validating what a reporter sends ------------------------------------
560
561/// Whether `text` is a full commit id: 40 hex digits (or 64, for SHA-256).
562pub fn is_full_sha(text: &str) -> bool {
563 matches!(text.len(), 40 | 64) && text.chars().all(|c| c.is_ascii_hexdigit())
564}
565
566/// What a reporter sends to create or change a check run. On a change,
567/// fields left out stay as they are.
568#[derive(Clone, Debug, Default, Serialize, Deserialize)]
569#[serde(rename_all = "camelCase")]
570pub struct CheckRunInput {
571 #[serde(default)]
572 pub name: Option<String>,
573 #[serde(default)]
574 pub head_sha: Option<String>,
575 #[serde(default)]
576 pub status: Option<String>,
577 #[serde(default)]
578 pub conclusion: Option<String>,
579 #[serde(default)]
580 pub started_at: Option<String>,
581 #[serde(default)]
582 pub completed_at: Option<String>,
583 #[serde(default)]
584 pub details_url: Option<String>,
585 #[serde(default)]
586 pub external_id: Option<String>,
587 #[serde(default)]
588 pub output: Option<OutputInput>,
589 /// Replaces its buttons when given.
590 #[serde(default)]
591 pub actions: Option<Vec<CheckAction>>,
592}
593
594#[derive(Clone, Debug, Default, Serialize, Deserialize)]
595#[serde(rename_all = "camelCase")]
596pub struct OutputInput {
597 #[serde(default)]
598 pub title: Option<String>,
599 #[serde(default)]
600 pub summary: Option<String>,
601 #[serde(default)]
602 pub text: Option<String>,
603 /// Added to those it has, at most [`MAX_ANNOTATIONS_PER_REQUEST`].
604 #[serde(default)]
605 pub annotations: Vec<CheckAnnotation>,
606}
607
608fn too_long(text: Option<&str>, max: usize) -> bool {
609 text.is_some_and(|text| text.chars().count() > max)
610}
611
612fn valid_url(url: Option<&str>) -> bool {
613 url.is_none_or(|url| url.is_empty() || url.starts_with("https://") || url.starts_with("http://"))
614}
615
616/// Why `input` cannot be used, if it cannot. `creating` asks for a name and
617/// a head commit.
618pub fn validate_run(input: &CheckRunInput, creating: bool) -> Result<(), String> {
619 if creating {
620 if input.name.as_deref().is_none_or(|name| name.trim().is_empty()) {
621 return Err("`name` is required.".to_owned());
622 }
623 if input.head_sha.as_deref().is_none_or(|sha| sha.trim().is_empty()) {
624 return Err("`head_sha` is required: the commit the check is about.".to_owned());
625 }
626 }
627 if input.name.as_deref().is_some_and(|name| name.trim().is_empty() || name.trim().chars().count() > MAX_NAME_CHARS) {
628 return Err(format!("`name` is 1 to {MAX_NAME_CHARS} characters."));
629 }
630 if let Some(status) = input.status.as_deref()
631 && !STATUSES.contains(&status)
632 {
633 return Err("`status` is queued, in_progress or completed.".to_owned());
634 }
635 if let Some(conclusion) = input.conclusion.as_deref()
636 && !CONCLUSIONS.contains(&conclusion)
637 {
638 return Err(format!("`conclusion` is one of {}.", CONCLUSIONS.join(", ")));
639 }
640 if input.status.as_deref() == Some("completed") && input.conclusion.is_none() && creating {
641 return Err("A completed check run needs a `conclusion`.".to_owned());
642 }
643 if input.conclusion.is_some() && input.status.as_deref().is_some_and(|status| status != "completed") {
644 return Err("A check run with a `conclusion` is completed: leave `status` out or set it to completed.".to_owned());
645 }
646 if !valid_url(input.details_url.as_deref()) {
647 return Err("`details_url` is an http or https address.".to_owned());
648 }
649 if too_long(input.external_id.as_deref(), MAX_TITLE_CHARS) {
650 return Err(format!("`external_id` is at most {MAX_TITLE_CHARS} characters."));
651 }
652 if let Some(output) = &input.output {
653 if too_long(output.title.as_deref(), MAX_TITLE_CHARS) {
654 return Err(format!("`output.title` is at most {MAX_TITLE_CHARS} characters."));
655 }
656 if too_long(output.summary.as_deref(), MAX_OUTPUT_CHARS) || too_long(output.text.as_deref(), MAX_OUTPUT_CHARS) {
657 return Err(format!("`output.summary` and `output.text` are at most {MAX_OUTPUT_CHARS} characters each."));
658 }
659 if output.annotations.len() > MAX_ANNOTATIONS_PER_REQUEST {
660 return Err(format!("At most {MAX_ANNOTATIONS_PER_REQUEST} annotations at a time: send more with further updates."));
661 }
662 for annotation in &output.annotations {
663 validate_annotation(annotation)?;
664 }
665 }
666 if let Some(actions) = &input.actions {
667 if actions.len() > MAX_ACTIONS {
668 return Err(format!("At most {MAX_ACTIONS} actions."));
669 }
670 for action in actions {
671 if action.label.trim().is_empty() || action.label.chars().count() > MAX_ACTION_LABEL {
672 return Err(format!("An action's `label` is 1 to {MAX_ACTION_LABEL} characters."));
673 }
674 if action.description.chars().count() > MAX_ACTION_DESCRIPTION {
675 return Err(format!("An action's `description` is at most {MAX_ACTION_DESCRIPTION} characters."));
676 }
677 if action.identifier.trim().is_empty() || action.identifier.chars().count() > MAX_ACTION_IDENTIFIER {
678 return Err(format!("An action's `identifier` is 1 to {MAX_ACTION_IDENTIFIER} characters."));
679 }
680 }
681 }
682 Ok(())
683}
684
685fn validate_annotation(annotation: &CheckAnnotation) -> Result<(), String> {
686 if annotation.path.trim().is_empty() {
687 return Err("Each annotation needs a `path`.".to_owned());
688 }
689 if annotation.start_line == 0 || annotation.end_line < annotation.start_line {
690 return Err("An annotation's `start_line` is from 1, and its `end_line` no less.".to_owned());
691 }
692 if !ANNOTATION_LEVELS.contains(&annotation.annotation_level.as_str()) {
693 return Err("An annotation's `annotation_level` is notice, warning or failure.".to_owned());
694 }
695 if annotation.message.trim().is_empty() || annotation.message.chars().count() > MAX_OUTPUT_CHARS {
696 return Err("Each annotation needs a `message`.".to_owned());
697 }
698 if annotation.start_column.is_some() && annotation.start_line != annotation.end_line {
699 return Err("Columns are for an annotation on one line: start_line and end_line the same.".to_owned());
700 }
701 Ok(())
702}
703
704/// Why a status cannot be set, if it cannot.
705pub fn validate_status(state: &str, context: &str, description: Option<&str>, target_url: Option<&str>) -> Result<(), String> {
706 if !STATUS_STATES.contains(&state) {
707 return Err("`state` is pending, success, failure or error.".to_owned());
708 }
709 if context.trim().is_empty() || context.chars().count() > MAX_NAME_CHARS {
710 return Err(format!("`context` is 1 to {MAX_NAME_CHARS} characters."));
711 }
712 if too_long(description, MAX_DESCRIPTION_CHARS) {
713 return Err(format!("`description` is at most {MAX_DESCRIPTION_CHARS} characters."));
714 }
715 if !valid_url(target_url) {
716 return Err("`target_url` is an http or https address.".to_owned());
717 }
718 Ok(())
719}
720
721// --- Methods ---------------------------------------------------------------
722
723/// `create_commit_status`: sets a status on a commit, as `actor` (the
724/// Write role). Publishes `status.created`. Returns `Outcome<CommitStatus>`.
725#[derive(Debug, Serialize, Deserialize)]
726#[serde(rename_all = "camelCase")]
727pub struct CreateStatusArgs {
728 pub actor: User,
729 pub repo: RepoPath,
730 pub sha: String,
731 pub state: String,
732 /// `default` when left out.
733 #[serde(default)]
734 pub context: Option<String>,
735 #[serde(default)]
736 pub description: Option<String>,
737 #[serde(default)]
738 pub target_url: Option<String>,
739}
740
741/// `commit_statuses` (a list of `CommitStatus`, newest first) and
742/// `combined_status` (`CombinedStatus`): a commit's statuses, by a branch,
743/// tag or commit.
744#[derive(Debug, Serialize, Deserialize)]
745#[serde(rename_all = "camelCase")]
746pub struct RefArgs {
747 pub viewer: Viewer,
748 pub repo: RepoPath,
749 #[serde(rename = "ref")]
750 pub git_ref: String,
751}
752
753/// `create_check_run`: as `actor` (the Write role), reporting as `app`
754/// (see [`CheckApp::of`]). Publishes `check_run.created`, and
755/// `check_run.completed` when it is created completed. Returns
756/// `Outcome<CommitCheckRun>`.
757#[derive(Debug, Serialize, Deserialize)]
758#[serde(rename_all = "camelCase")]
759pub struct CreateCheckRunArgs {
760 pub actor: User,
761 pub repo: RepoPath,
762 #[serde(default)]
763 pub app: Option<String>,
764 pub run: CheckRunInput,
765}
766
767/// `update_check_run`: changes a check run reported through the API.
768/// Returns `Outcome<CommitCheckRun>`.
769#[derive(Debug, Serialize, Deserialize)]
770#[serde(rename_all = "camelCase")]
771pub struct UpdateCheckRunArgs {
772 pub actor: User,
773 pub repo: RepoPath,
774 pub id: String,
775 pub run: CheckRunInput,
776}
777
778/// `get_check_run` (`Outcome<CommitCheckRun>`), `check_run_annotations`
779/// (`Outcome<Vec<CheckAnnotation>>`) and `get_check_suite`
780/// (`Outcome<CommitCheckSuite>`), by id.
781#[derive(Debug, Serialize, Deserialize)]
782#[serde(rename_all = "camelCase")]
783pub struct CheckIdArgs {
784 pub viewer: Viewer,
785 pub repo: RepoPath,
786 pub id: String,
787}
788
789/// `ref_check_runs`: a commit's check runs, by a branch, tag or commit,
790/// filtered. Returns `Outcome<CheckRunList>`.
791#[derive(Debug, Serialize, Deserialize)]
792#[serde(rename_all = "camelCase")]
793pub struct RefCheckRunsArgs {
794 pub viewer: Viewer,
795 pub repo: RepoPath,
796 #[serde(rename = "ref")]
797 pub git_ref: String,
798 #[serde(default)]
799 pub check_name: Option<String>,
800 #[serde(default)]
801 pub status: Option<String>,
802 /// An app's slug, such as `actions`.
803 #[serde(default)]
804 pub app: Option<String>,
805 /// `latest` (the default): the latest run of each name; `all`: every one.
806 #[serde(default)]
807 pub filter: Option<String>,
808}
809
810/// `ref_check_suites`: a commit's check suites. Returns
811/// `Outcome<CheckSuiteList>`.
812#[derive(Debug, Serialize, Deserialize)]
813#[serde(rename_all = "camelCase")]
814pub struct RefCheckSuitesArgs {
815 pub viewer: Viewer,
816 pub repo: RepoPath,
817 #[serde(rename = "ref")]
818 pub git_ref: String,
819 #[serde(default)]
820 pub app: Option<String>,
821 #[serde(default)]
822 pub check_name: Option<String>,
823}
824
825/// `rerequest_check_run` and `rerequest_check_suite`: asks the reporter to
826/// run it again (`check_run.rerequested`, `check_suite.rerequested`); a
827/// g1t Actions job or run is run again. Returns `Outcome<bool>`.
828#[derive(Debug, Serialize, Deserialize)]
829#[serde(rename_all = "camelCase")]
830pub struct RerequestArgs {
831 pub actor: User,
832 pub repo: RepoPath,
833 pub id: String,
834}
835
836/// `request_check_action`: someone pressed one of a check run's buttons.
837/// Publishes `check_run.requested_action`. Returns `Outcome<bool>`.
838#[derive(Debug, Serialize, Deserialize)]
839#[serde(rename_all = "camelCase")]
840pub struct RequestActionArgs {
841 pub actor: User,
842 pub repo: RepoPath,
843 pub id: String,
844 pub identifier: String,
845}
846
847/// `commit_checks`: every check on each of `shas` (at most
848/// [`MAX_COMMITS`]), for showing them next to commits. Commits nothing
849/// reported on are left out. Returns
850/// `Outcome<BTreeMap<String, CommitChecks>>`.
851#[derive(Debug, Serialize, Deserialize)]
852#[serde(rename_all = "camelCase")]
853pub struct CommitChecksArgs {
854 pub viewer: Viewer,
855 pub repo: RepoPath,
856 pub shas: Vec<String>,
857}
858
859/// The actions service's `check_runs`: workflow runs with their jobs, by
860/// commits, by a run's id, or by one of its jobs' ids, newest first. For
861/// the work service only; it decides who may see them. Returns
862/// `Vec<RunDetail>` (without notes).
863#[derive(Debug, Default, Serialize, Deserialize)]
864#[serde(rename_all = "camelCase")]
865pub struct ActionsChecksArgs {
866 pub repo_id: String,
867 #[serde(default)]
868 pub shas: Vec<String>,
869 #[serde(default)]
870 pub run_id: Option<String>,
871 #[serde(default)]
872 pub job_id: Option<String>,
873}
874
875/// What `check_run.*` events carry.
876#[derive(Clone, Debug, Serialize, Deserialize)]
877#[serde(rename_all = "camelCase")]
878pub struct CheckRunEvent {
879 pub repo_id: String,
880 pub check_run: CommitCheckRun,
881 /// For `check_run.requested_action`: the button's identifier.
882 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub requested_action: Option<String>,
884}
885
886/// What `check_suite.*` events carry.
887#[derive(Clone, Debug, Serialize, Deserialize)]
888#[serde(rename_all = "camelCase")]
889pub struct CheckSuiteEvent {
890 pub repo_id: String,
891 pub check_suite: CommitCheckSuite,
892}
893
894/// What `status.created` carries.
895#[derive(Clone, Debug, Serialize, Deserialize)]
896#[serde(rename_all = "camelCase")]
897pub struct StatusEvent {
898 pub repo_id: String,
899 pub sha: String,
900 pub context: String,
901 pub state: String,
902 pub description: Option<String>,
903 pub target_url: Option<String>,
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909 use crate::actions::Annotation;
910
911 fn status(context: &str, state: &str) -> CommitStatus {
912 CommitStatus {
913 context: context.into(),
914 state: state.into(),
915 description: None,
916 target_url: None,
917 updated_at: "2026-10-07T00:00:00Z".into(),
918 source: None,
919 check_run_id: None,
920 }
921 }
922
923 fn run(name: &str, status: &str, conclusion: Option<&str>) -> CommitCheckRun {
924 CommitCheckRun {
925 id: format!("cr_{name}"),
926 name: name.into(),
927 head_sha: "a".repeat(40),
928 status: status.into(),
929 conclusion: conclusion.map(Into::into),
930 html_url: format!("/acme/web/checks/cr_{name}"),
931 app: CheckApp::named("Codecov"),
932 ..CommitCheckRun::default()
933 }
934 }
935
936 #[test]
937 fn states_read_as_github_reads_them() {
938 assert_eq!(run_state("queued", None), "pending");
939 assert_eq!(run_state("completed", Some("timed_out")), "failure");
940 assert_eq!(run_state("completed", Some("cancelled")), "cancelled");
941 assert_eq!(status_state_of("in_progress", None), "pending");
942 assert_eq!(status_state_of("completed", Some("skipped")), "success");
943 assert_eq!(status_state_of("completed", Some("cancelled")), "failure");
944 assert_eq!(status_state_of("completed", Some("action_required")), "failure");
945 assert_eq!(combined_state(&[]), "pending");
946 assert_eq!(combined_state(&[status("a", "success"), status("b", "pending")]), "pending");
947 assert_eq!(combined_state(&[status("a", "error"), status("b", "pending")]), "failure");
948 assert_eq!(combined_state(&[status("a", "success")]), "success");
949 }
950
951 #[test]
952 fn a_suite_is_its_worst_run() {
953 let s = |status: &str, conclusion: Option<&str>| (status.to_owned(), conclusion.map(str::to_owned));
954 assert_eq!(suite_state(&[s("queued", None), s("queued", None)]), ("queued", None));
955 assert_eq!(suite_state(&[s("completed", Some("success")), s("in_progress", None)]), ("in_progress", None));
956 assert_eq!(suite_state(&[s("completed", Some("success")), s("completed", Some("timed_out"))]), ("completed", Some("failure")));
957 assert_eq!(suite_state(&[s("completed", Some("skipped")), s("completed", Some("skipped"))]), ("completed", Some("skipped")));
958 assert_eq!(suite_state(&[s("completed", Some("skipped")), s("completed", Some("success"))]), ("completed", Some("success")));
959 assert_eq!(suite_state(&[s("completed", Some("neutral"))]), ("completed", Some("neutral")));
960 }
961
962 #[test]
963 fn a_commits_checks_add_up_without_counting_one_twice() {
964 let runs = [run("lint", "completed", Some("failure")), run("coverage", "in_progress", None)];
965 let mut projected = status("lint", "failure");
966 projected.check_run_id = Some("cr_lint".into());
967 let statuses = [projected, status("CI / push", "success"), status("deploy", "success")];
968 let summary = summarize(&runs, &statuses, &["CI / push".to_owned()]);
969 assert_eq!(summary.total, 3, "{summary:?}");
970 assert_eq!(summary.state, "failure");
971 assert_eq!((summary.failed, summary.pending, summary.successful), (1, 1, 1));
972 let names: Vec<&str> = summary.checks.iter().map(|check| check.name.as_str()).collect();
973 assert_eq!(names, ["lint", "coverage", "deploy"]);
974 assert_eq!(summarize(&[], &[], &[]).state, "none");
975 let passing = summarize(&[run("a", "completed", Some("success")), run("b", "completed", Some("skipped"))], &[], &[]);
976 assert_eq!((passing.state.as_str(), passing.successful, passing.skipped), ("success", 1, 1));
977 }
978
979 #[test]
980 fn a_job_is_a_check_run_of_its_workflow_run() {
981 let workflow_run = WorkflowRun {
982 id: "run_1".into(),
983 workflow_id: "wf".into(),
984 path: ".g1t/workflows/ci.yml".into(),
985 name: "CI".into(),
986 title: "t".into(),
987 number: 3,
988 attempt: 1,
989 event: "push".into(),
990 git_ref: "refs/heads/main".into(),
991 sha: "b".repeat(40),
992 pull: None,
993 status: "in_progress".into(),
994 conclusion: None,
995 error: None,
996 actor: None,
997 created_at: "2026-10-07T00:00:00Z".into(),
998 started_at: None,
999 finished_at: None,
1000 };
1001 let job = Job {
1002 id: "job_1".into(),
1003 run_id: "run_1".into(),
1004 key: "test".into(),
1005 name: "Test".into(),
1006 needs: vec![],
1007 status: "completed".into(),
1008 conclusion: Some("success".into()),
1009 steps: vec![],
1010 annotations: vec![Annotation { level: "error".into(), message: "boom".into(), title: None, file: Some("src/a.rs".into()), line: Some(4) }],
1011 reason: None,
1012 started_at: Some("2026-10-07T00:00:01Z".into()),
1013 finished_at: Some("2026-10-07T00:00:26Z".into()),
1014 self_hosted: false,
1015 runner: None,
1016 };
1017 let check = job_check_run("acme/web", &workflow_run, &job);
1018 assert_eq!(check.display_name(), "CI / Test (push)");
1019 assert_eq!(check.check_suite.id, "run_1");
1020 assert_eq!(check.html_url, "/acme/web/actions/runs/run_1?job=job_1");
1021 assert_eq!(check.output.annotations_count, 1);
1022 assert_eq!(job_annotations(&job)[0].annotation_level, "failure");
1023 let waiting = Job { status: "waiting".into(), conclusion: None, ..job.clone() };
1024 let suite = run_check_suite(&workflow_run, &[job, waiting]);
1025 assert_eq!((suite.status.as_str(), suite.head_branch.as_deref()), ("in_progress", Some("main")));
1026 assert_eq!(suite.name.as_deref(), Some("CI"));
1027 }
1028
1029 #[test]
1030 fn only_the_latest_run_of_each_workflow_and_event_counts() {
1031 let base = WorkflowRun {
1032 id: "run_2".into(),
1033 workflow_id: "wf".into(),
1034 path: "ci.yml".into(),
1035 name: "CI".into(),
1036 title: String::new(),
1037 number: 2,
1038 attempt: 1,
1039 event: "push".into(),
1040 git_ref: String::new(),
1041 sha: "c".into(),
1042 pull: None,
1043 status: "completed".into(),
1044 conclusion: None,
1045 error: None,
1046 actor: None,
1047 created_at: String::new(),
1048 started_at: None,
1049 finished_at: None,
1050 };
1051 let older = WorkflowRun { id: "run_1".into(), ..base.clone() };
1052 let pull = WorkflowRun { id: "run_0".into(), event: "pull_request".into(), ..base.clone() };
1053 let runs = [(base, ()), (older, ()), (pull, ())];
1054 let ids: Vec<&str> = latest_runs(&runs).iter().map(|(run, _)| run.id.as_str()).collect();
1055 assert_eq!(ids, ["run_2", "run_0"]);
1056 }
1057
1058 #[test]
1059 fn reporters_are_named_from_the_token() {
1060 let mut user = User { id: "u".into(), username: "ada".into(), ..User::default() };
1061 assert_eq!(CheckApp::of(&user, None).slug, "ada");
1062 assert_eq!(CheckApp::of(&user, Some(" Vercel Preview ")), CheckApp { slug: "vercel-preview".into(), name: "Vercel Preview".into() });
1063 user.token = Some(Box::new(crate::scopes::TokenAccess { name: Some("G1T_TOKEN for acme/web run 4".into()), ..Default::default() }));
1064 assert_eq!(CheckApp::of(&user, None), CheckApp::actions());
1065 user.token = Some(Box::new(crate::scopes::TokenAccess { name: Some("Buildkite".into()), ..Default::default() }));
1066 assert_eq!(CheckApp::of(&user, None).slug, "buildkite");
1067 }
1068
1069 #[test]
1070 fn what_a_reporter_sends_is_checked() {
1071 let ok = CheckRunInput { name: Some("lint".into()), head_sha: Some("a".repeat(40)), ..Default::default() };
1072 assert!(validate_run(&ok, true).is_ok());
1073 assert!(validate_run(&CheckRunInput::default(), true).is_err());
1074 assert!(validate_run(&CheckRunInput::default(), false).is_ok());
1075 let completed = CheckRunInput { status: Some("completed".into()), ..ok.clone() };
1076 assert!(validate_run(&completed, true).unwrap_err().contains("conclusion"));
1077 let odd = CheckRunInput { conclusion: Some("passed".into()), ..ok.clone() };
1078 assert!(validate_run(&odd, true).is_err());
1079 let annotated = CheckRunInput {
1080 output: Some(OutputInput {
1081 title: Some("1 problem".into()),
1082 annotations: vec![CheckAnnotation { path: "a.rs".into(), start_line: 3, end_line: 2, annotation_level: "warning".into(), message: "x".into(), ..Default::default() }],
1083 ..Default::default()
1084 }),
1085 ..ok.clone()
1086 };
1087 assert!(validate_run(&annotated, true).unwrap_err().contains("end_line"));
1088 let buttons = CheckRunInput {
1089 actions: Some(vec![CheckAction { label: "Fix this".into(), description: "Let us fix it".into(), identifier: "fix".into() }; 4]),
1090 ..ok
1091 };
1092 assert!(validate_run(&buttons, true).is_err());
1093 assert!(validate_status("success", "ci/build", Some("Passed"), Some("https://ci.example.com/1")).is_ok());
1094 assert!(validate_status("passed", "ci", None, None).is_err());
1095 assert!(validate_status("success", "ci", None, Some("javascript:alert(1)")).is_err());
1096 assert!(is_full_sha(&"a".repeat(40)) && !is_full_sha("main"));
1097 }
1098}