Skip to content
1,153 linesCodeBlameRaw
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/// The latest run of each workflow on `sha` (`runs` newest first, as the
487/// actions service lists them), each of its jobs as the status its check
488/// run stands as: named as the check run is (the job's name, such as
489/// `test` or `build (ubuntu-latest)`), so a required check named for a job
490/// is met by it, as on GitHub. A job still waiting or going is pending; one
491/// that passed, was skipped or was neutral passed; any other ending failed.
492pub fn job_statuses(runs: &[(WorkflowRun, Vec<Job>)], sha: &str) -> Vec<CommitStatus> {
493 latest_runs(runs)
494 .into_iter()
495 .filter(|(run, _)| run.sha == sha)
496 .flat_map(|(run, jobs)| {
497 jobs.iter().map(move |job| {
498 let state = match (job.status.as_str(), job.conclusion.as_deref()) {
499 ("completed", Some("success" | "skipped" | "neutral")) => "success",
500 ("completed", _) => "failure",
501 _ => "pending",
502 };
503 CommitStatus {
504 context: job.name.clone(),
505 state: state.to_owned(),
506 description: job.reason.clone(),
507 target_url: None,
508 updated_at: job.finished_at.clone().or_else(|| job.started_at.clone()).unwrap_or_else(|| run.created_at.clone()),
509 source: Some("actions".to_owned()),
510 check_run_id: Some(job.id.clone()),
511 }
512 })
513 })
514 .collect()
515}
516
517// --- A commit's checks, added up -----------------------------------------
518
519/// Every check on one commit, as its list shows them: each check run, and
520/// each status that is not one of them standing in as a status (its
521/// `check_run_id`) nor a workflow's whose jobs are listed (its context
522/// `Workflow / event` among `job_contexts`).
523pub fn summarize(check_runs: &[CommitCheckRun], statuses: &[CommitStatus], job_contexts: &[String]) -> CommitChecks {
524 let mut checks: Vec<CheckItem> = Vec::new();
525 for run in check_runs {
526 checks.push(CheckItem {
527 kind: "check_run".to_owned(),
528 id: Some(run.id.clone()),
529 name: run.display_name(),
530 state: run_state(&run.status, run.conclusion.as_deref()).to_owned(),
531 description: run.output.title.clone().or_else(|| run.output.summary.as_deref().map(first_line)),
532 details_url: run.details_url.clone(),
533 url: Some(run.html_url.clone()),
534 app: Some(run.app.name.clone()),
535 started_at: run.started_at.clone(),
536 completed_at: run.completed_at.clone(),
537 });
538 }
539 for status in statuses {
540 if status.check_run_id.is_some() || job_contexts.contains(&status.context) {
541 continue;
542 }
543 checks.push(CheckItem {
544 kind: "status".to_owned(),
545 id: None,
546 name: status.context.clone(),
547 state: status_item_state(&status.state).to_owned(),
548 description: status.description.clone(),
549 details_url: status.target_url.clone(),
550 url: None,
551 app: status.source.clone(),
552 started_at: None,
553 completed_at: Some(status.updated_at.clone()),
554 });
555 }
556 let rank = |state: &str| match state {
557 "failure" => 0,
558 "pending" => 1,
559 "success" => 2,
560 _ => 3,
561 };
562 checks.sort_by(|a, b| rank(&a.state).cmp(&rank(&b.state)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
563 let count = |wanted: &[&str]| u32::try_from(checks.iter().filter(|check| wanted.contains(&check.state.as_str())).count()).unwrap_or(u32::MAX);
564 let failed = count(&["failure"]);
565 let pending = count(&["pending"]);
566 let state = if checks.is_empty() {
567 "none"
568 } else if failed > 0 {
569 "failure"
570 } else if pending > 0 {
571 "pending"
572 } else {
573 "success"
574 };
575 CommitChecks {
576 state: state.to_owned(),
577 total: u32::try_from(checks.len()).unwrap_or(u32::MAX),
578 successful: count(&["success"]),
579 failed,
580 pending,
581 skipped: count(&["neutral", "skipped", "cancelled"]),
582 checks,
583 }
584}
585
586fn first_line(text: &str) -> String {
587 text.lines().find(|line| !line.trim().is_empty()).unwrap_or("").trim().chars().take(MAX_TITLE_CHARS).collect()
588}
589
590// --- Validating what a reporter sends ------------------------------------
591
592/// Whether `text` is a full commit id: 40 hex digits (or 64, for SHA-256).
593pub fn is_full_sha(text: &str) -> bool {
594 matches!(text.len(), 40 | 64) && text.chars().all(|c| c.is_ascii_hexdigit())
595}
596
597/// What a reporter sends to create or change a check run. On a change,
598/// fields left out stay as they are.
599#[derive(Clone, Debug, Default, Serialize, Deserialize)]
600#[serde(rename_all = "camelCase")]
601pub struct CheckRunInput {
602 #[serde(default)]
603 pub name: Option<String>,
604 #[serde(default)]
605 pub head_sha: Option<String>,
606 #[serde(default)]
607 pub status: Option<String>,
608 #[serde(default)]
609 pub conclusion: Option<String>,
610 #[serde(default)]
611 pub started_at: Option<String>,
612 #[serde(default)]
613 pub completed_at: Option<String>,
614 #[serde(default)]
615 pub details_url: Option<String>,
616 #[serde(default)]
617 pub external_id: Option<String>,
618 #[serde(default)]
619 pub output: Option<OutputInput>,
620 /// Replaces its buttons when given.
621 #[serde(default)]
622 pub actions: Option<Vec<CheckAction>>,
623}
624
625#[derive(Clone, Debug, Default, Serialize, Deserialize)]
626#[serde(rename_all = "camelCase")]
627pub struct OutputInput {
628 #[serde(default)]
629 pub title: Option<String>,
630 #[serde(default)]
631 pub summary: Option<String>,
632 #[serde(default)]
633 pub text: Option<String>,
634 /// Added to those it has, at most [`MAX_ANNOTATIONS_PER_REQUEST`].
635 #[serde(default)]
636 pub annotations: Vec<CheckAnnotation>,
637}
638
639fn too_long(text: Option<&str>, max: usize) -> bool {
640 text.is_some_and(|text| text.chars().count() > max)
641}
642
643fn valid_url(url: Option<&str>) -> bool {
644 url.is_none_or(|url| url.is_empty() || url.starts_with("https://") || url.starts_with("http://"))
645}
646
647/// Why `input` cannot be used, if it cannot. `creating` asks for a name and
648/// a head commit.
649pub fn validate_run(input: &CheckRunInput, creating: bool) -> Result<(), String> {
650 if creating {
651 if input.name.as_deref().is_none_or(|name| name.trim().is_empty()) {
652 return Err("`name` is required.".to_owned());
653 }
654 if input.head_sha.as_deref().is_none_or(|sha| sha.trim().is_empty()) {
655 return Err("`head_sha` is required: the commit the check is about.".to_owned());
656 }
657 }
658 if input.name.as_deref().is_some_and(|name| name.trim().is_empty() || name.trim().chars().count() > MAX_NAME_CHARS) {
659 return Err(format!("`name` is 1 to {MAX_NAME_CHARS} characters."));
660 }
661 if let Some(status) = input.status.as_deref()
662 && !STATUSES.contains(&status)
663 {
664 return Err("`status` is queued, in_progress or completed.".to_owned());
665 }
666 if let Some(conclusion) = input.conclusion.as_deref()
667 && !CONCLUSIONS.contains(&conclusion)
668 {
669 return Err(format!("`conclusion` is one of {}.", CONCLUSIONS.join(", ")));
670 }
671 if input.status.as_deref() == Some("completed") && input.conclusion.is_none() && creating {
672 return Err("A completed check run needs a `conclusion`.".to_owned());
673 }
674 if input.conclusion.is_some() && input.status.as_deref().is_some_and(|status| status != "completed") {
675 return Err("A check run with a `conclusion` is completed: leave `status` out or set it to completed.".to_owned());
676 }
677 if !valid_url(input.details_url.as_deref()) {
678 return Err("`details_url` is an http or https address.".to_owned());
679 }
680 if too_long(input.external_id.as_deref(), MAX_TITLE_CHARS) {
681 return Err(format!("`external_id` is at most {MAX_TITLE_CHARS} characters."));
682 }
683 if let Some(output) = &input.output {
684 if too_long(output.title.as_deref(), MAX_TITLE_CHARS) {
685 return Err(format!("`output.title` is at most {MAX_TITLE_CHARS} characters."));
686 }
687 if too_long(output.summary.as_deref(), MAX_OUTPUT_CHARS) || too_long(output.text.as_deref(), MAX_OUTPUT_CHARS) {
688 return Err(format!("`output.summary` and `output.text` are at most {MAX_OUTPUT_CHARS} characters each."));
689 }
690 if output.annotations.len() > MAX_ANNOTATIONS_PER_REQUEST {
691 return Err(format!("At most {MAX_ANNOTATIONS_PER_REQUEST} annotations at a time: send more with further updates."));
692 }
693 for annotation in &output.annotations {
694 validate_annotation(annotation)?;
695 }
696 }
697 if let Some(actions) = &input.actions {
698 if actions.len() > MAX_ACTIONS {
699 return Err(format!("At most {MAX_ACTIONS} actions."));
700 }
701 for action in actions {
702 if action.label.trim().is_empty() || action.label.chars().count() > MAX_ACTION_LABEL {
703 return Err(format!("An action's `label` is 1 to {MAX_ACTION_LABEL} characters."));
704 }
705 if action.description.chars().count() > MAX_ACTION_DESCRIPTION {
706 return Err(format!("An action's `description` is at most {MAX_ACTION_DESCRIPTION} characters."));
707 }
708 if action.identifier.trim().is_empty() || action.identifier.chars().count() > MAX_ACTION_IDENTIFIER {
709 return Err(format!("An action's `identifier` is 1 to {MAX_ACTION_IDENTIFIER} characters."));
710 }
711 }
712 }
713 Ok(())
714}
715
716fn validate_annotation(annotation: &CheckAnnotation) -> Result<(), String> {
717 if annotation.path.trim().is_empty() {
718 return Err("Each annotation needs a `path`.".to_owned());
719 }
720 if annotation.start_line == 0 || annotation.end_line < annotation.start_line {
721 return Err("An annotation's `start_line` is from 1, and its `end_line` no less.".to_owned());
722 }
723 if !ANNOTATION_LEVELS.contains(&annotation.annotation_level.as_str()) {
724 return Err("An annotation's `annotation_level` is notice, warning or failure.".to_owned());
725 }
726 if annotation.message.trim().is_empty() || annotation.message.chars().count() > MAX_OUTPUT_CHARS {
727 return Err("Each annotation needs a `message`.".to_owned());
728 }
729 if annotation.start_column.is_some() && annotation.start_line != annotation.end_line {
730 return Err("Columns are for an annotation on one line: start_line and end_line the same.".to_owned());
731 }
732 Ok(())
733}
734
735/// Why a status cannot be set, if it cannot.
736pub fn validate_status(state: &str, context: &str, description: Option<&str>, target_url: Option<&str>) -> Result<(), String> {
737 if !STATUS_STATES.contains(&state) {
738 return Err("`state` is pending, success, failure or error.".to_owned());
739 }
740 if context.trim().is_empty() || context.chars().count() > MAX_NAME_CHARS {
741 return Err(format!("`context` is 1 to {MAX_NAME_CHARS} characters."));
742 }
743 if too_long(description, MAX_DESCRIPTION_CHARS) {
744 return Err(format!("`description` is at most {MAX_DESCRIPTION_CHARS} characters."));
745 }
746 if !valid_url(target_url) {
747 return Err("`target_url` is an http or https address.".to_owned());
748 }
749 Ok(())
750}
751
752// --- Methods ---------------------------------------------------------------
753
754/// `create_commit_status`: sets a status on a commit, as `actor` (the
755/// Write role). Publishes `status.created`. Returns `Outcome<CommitStatus>`.
756#[derive(Debug, Serialize, Deserialize)]
757#[serde(rename_all = "camelCase")]
758pub struct CreateStatusArgs {
759 pub actor: User,
760 pub repo: RepoPath,
761 pub sha: String,
762 pub state: String,
763 /// `default` when left out.
764 #[serde(default)]
765 pub context: Option<String>,
766 #[serde(default)]
767 pub description: Option<String>,
768 #[serde(default)]
769 pub target_url: Option<String>,
770}
771
772/// `commit_statuses` (a list of `CommitStatus`, newest first) and
773/// `combined_status` (`CombinedStatus`): a commit's statuses, by a branch,
774/// tag or commit.
775#[derive(Debug, Serialize, Deserialize)]
776#[serde(rename_all = "camelCase")]
777pub struct RefArgs {
778 pub viewer: Viewer,
779 pub repo: RepoPath,
780 #[serde(rename = "ref")]
781 pub git_ref: String,
782}
783
784/// `create_check_run`: as `actor` (the Write role), reporting as `app`
785/// (see [`CheckApp::of`]). Publishes `check_run.created`, and
786/// `check_run.completed` when it is created completed. Returns
787/// `Outcome<CommitCheckRun>`.
788#[derive(Debug, Serialize, Deserialize)]
789#[serde(rename_all = "camelCase")]
790pub struct CreateCheckRunArgs {
791 pub actor: User,
792 pub repo: RepoPath,
793 #[serde(default)]
794 pub app: Option<String>,
795 pub run: CheckRunInput,
796}
797
798/// `update_check_run`: changes a check run reported through the API.
799/// Returns `Outcome<CommitCheckRun>`.
800#[derive(Debug, Serialize, Deserialize)]
801#[serde(rename_all = "camelCase")]
802pub struct UpdateCheckRunArgs {
803 pub actor: User,
804 pub repo: RepoPath,
805 pub id: String,
806 pub run: CheckRunInput,
807}
808
809/// `get_check_run` (`Outcome<CommitCheckRun>`), `check_run_annotations`
810/// (`Outcome<Vec<CheckAnnotation>>`) and `get_check_suite`
811/// (`Outcome<CommitCheckSuite>`), by id.
812#[derive(Debug, Serialize, Deserialize)]
813#[serde(rename_all = "camelCase")]
814pub struct CheckIdArgs {
815 pub viewer: Viewer,
816 pub repo: RepoPath,
817 pub id: String,
818}
819
820/// `ref_check_runs`: a commit's check runs, by a branch, tag or commit,
821/// filtered. Returns `Outcome<CheckRunList>`.
822#[derive(Debug, Serialize, Deserialize)]
823#[serde(rename_all = "camelCase")]
824pub struct RefCheckRunsArgs {
825 pub viewer: Viewer,
826 pub repo: RepoPath,
827 #[serde(rename = "ref")]
828 pub git_ref: String,
829 #[serde(default)]
830 pub check_name: Option<String>,
831 #[serde(default)]
832 pub status: Option<String>,
833 /// An app's slug, such as `actions`.
834 #[serde(default)]
835 pub app: Option<String>,
836 /// `latest` (the default): the latest run of each name; `all`: every one.
837 #[serde(default)]
838 pub filter: Option<String>,
839}
840
841/// `ref_check_suites`: a commit's check suites. Returns
842/// `Outcome<CheckSuiteList>`.
843#[derive(Debug, Serialize, Deserialize)]
844#[serde(rename_all = "camelCase")]
845pub struct RefCheckSuitesArgs {
846 pub viewer: Viewer,
847 pub repo: RepoPath,
848 #[serde(rename = "ref")]
849 pub git_ref: String,
850 #[serde(default)]
851 pub app: Option<String>,
852 #[serde(default)]
853 pub check_name: Option<String>,
854}
855
856/// `rerequest_check_run` and `rerequest_check_suite`: asks the reporter to
857/// run it again (`check_run.rerequested`, `check_suite.rerequested`); a
858/// g1t Actions job or run is run again. Returns `Outcome<bool>`.
859#[derive(Debug, Serialize, Deserialize)]
860#[serde(rename_all = "camelCase")]
861pub struct RerequestArgs {
862 pub actor: User,
863 pub repo: RepoPath,
864 pub id: String,
865}
866
867/// `request_check_action`: someone pressed one of a check run's buttons.
868/// Publishes `check_run.requested_action`. Returns `Outcome<bool>`.
869#[derive(Debug, Serialize, Deserialize)]
870#[serde(rename_all = "camelCase")]
871pub struct RequestActionArgs {
872 pub actor: User,
873 pub repo: RepoPath,
874 pub id: String,
875 pub identifier: String,
876}
877
878/// `commit_checks`: every check on each of `shas` (at most
879/// [`MAX_COMMITS`]), for showing them next to commits. Commits nothing
880/// reported on are left out. Returns
881/// `Outcome<BTreeMap<String, CommitChecks>>`.
882#[derive(Debug, Serialize, Deserialize)]
883#[serde(rename_all = "camelCase")]
884pub struct CommitChecksArgs {
885 pub viewer: Viewer,
886 pub repo: RepoPath,
887 pub shas: Vec<String>,
888}
889
890/// The actions service's `check_runs`: workflow runs with their jobs, by
891/// commits, by a run's id, or by one of its jobs' ids, newest first. For
892/// the work service only; it decides who may see them. Returns
893/// `Vec<RunDetail>` (without notes).
894#[derive(Debug, Default, Serialize, Deserialize)]
895#[serde(rename_all = "camelCase")]
896pub struct ActionsChecksArgs {
897 pub repo_id: String,
898 #[serde(default)]
899 pub shas: Vec<String>,
900 #[serde(default)]
901 pub run_id: Option<String>,
902 #[serde(default)]
903 pub job_id: Option<String>,
904}
905
906/// What `check_run.*` events carry.
907#[derive(Clone, Debug, Serialize, Deserialize)]
908#[serde(rename_all = "camelCase")]
909pub struct CheckRunEvent {
910 pub repo_id: String,
911 pub check_run: CommitCheckRun,
912 /// For `check_run.requested_action`: the button's identifier.
913 #[serde(default, skip_serializing_if = "Option::is_none")]
914 pub requested_action: Option<String>,
915}
916
917/// What `check_suite.*` events carry.
918#[derive(Clone, Debug, Serialize, Deserialize)]
919#[serde(rename_all = "camelCase")]
920pub struct CheckSuiteEvent {
921 pub repo_id: String,
922 pub check_suite: CommitCheckSuite,
923}
924
925/// What `status.created` carries.
926#[derive(Clone, Debug, Serialize, Deserialize)]
927#[serde(rename_all = "camelCase")]
928pub struct StatusEvent {
929 pub repo_id: String,
930 pub sha: String,
931 pub context: String,
932 pub state: String,
933 pub description: Option<String>,
934 pub target_url: Option<String>,
935}
936
937#[cfg(test)]
938mod tests {
939 use super::*;
940 use crate::actions::Annotation;
941
942 fn status(context: &str, state: &str) -> CommitStatus {
943 CommitStatus {
944 context: context.into(),
945 state: state.into(),
946 description: None,
947 target_url: None,
948 updated_at: "2026-10-07T00:00:00Z".into(),
949 source: None,
950 check_run_id: None,
951 }
952 }
953
954 fn run(name: &str, status: &str, conclusion: Option<&str>) -> CommitCheckRun {
955 CommitCheckRun {
956 id: format!("cr_{name}"),
957 name: name.into(),
958 head_sha: "a".repeat(40),
959 status: status.into(),
960 conclusion: conclusion.map(Into::into),
961 html_url: format!("/acme/web/checks/cr_{name}"),
962 app: CheckApp::named("Codecov"),
963 ..CommitCheckRun::default()
964 }
965 }
966
967 #[test]
968 fn states_read_as_github_reads_them() {
969 assert_eq!(run_state("queued", None), "pending");
970 assert_eq!(run_state("completed", Some("timed_out")), "failure");
971 assert_eq!(run_state("completed", Some("cancelled")), "cancelled");
972 assert_eq!(status_state_of("in_progress", None), "pending");
973 assert_eq!(status_state_of("completed", Some("skipped")), "success");
974 assert_eq!(status_state_of("completed", Some("cancelled")), "failure");
975 assert_eq!(status_state_of("completed", Some("action_required")), "failure");
976 assert_eq!(combined_state(&[]), "pending");
977 assert_eq!(combined_state(&[status("a", "success"), status("b", "pending")]), "pending");
978 assert_eq!(combined_state(&[status("a", "error"), status("b", "pending")]), "failure");
979 assert_eq!(combined_state(&[status("a", "success")]), "success");
980 }
981
982 #[test]
983 fn a_suite_is_its_worst_run() {
984 let s = |status: &str, conclusion: Option<&str>| (status.to_owned(), conclusion.map(str::to_owned));
985 assert_eq!(suite_state(&[s("queued", None), s("queued", None)]), ("queued", None));
986 assert_eq!(suite_state(&[s("completed", Some("success")), s("in_progress", None)]), ("in_progress", None));
987 assert_eq!(suite_state(&[s("completed", Some("success")), s("completed", Some("timed_out"))]), ("completed", Some("failure")));
988 assert_eq!(suite_state(&[s("completed", Some("skipped")), s("completed", Some("skipped"))]), ("completed", Some("skipped")));
989 assert_eq!(suite_state(&[s("completed", Some("skipped")), s("completed", Some("success"))]), ("completed", Some("success")));
990 assert_eq!(suite_state(&[s("completed", Some("neutral"))]), ("completed", Some("neutral")));
991 }
992
993 #[test]
994 fn a_commits_checks_add_up_without_counting_one_twice() {
995 let runs = [run("lint", "completed", Some("failure")), run("coverage", "in_progress", None)];
996 let mut projected = status("lint", "failure");
997 projected.check_run_id = Some("cr_lint".into());
998 let statuses = [projected, status("CI / push", "success"), status("deploy", "success")];
999 let summary = summarize(&runs, &statuses, &["CI / push".to_owned()]);
1000 assert_eq!(summary.total, 3, "{summary:?}");
1001 assert_eq!(summary.state, "failure");
1002 assert_eq!((summary.failed, summary.pending, summary.successful), (1, 1, 1));
1003 let names: Vec<&str> = summary.checks.iter().map(|check| check.name.as_str()).collect();
1004 assert_eq!(names, ["lint", "coverage", "deploy"]);
1005 assert_eq!(summarize(&[], &[], &[]).state, "none");
1006 let passing = summarize(&[run("a", "completed", Some("success")), run("b", "completed", Some("skipped"))], &[], &[]);
1007 assert_eq!((passing.state.as_str(), passing.successful, passing.skipped), ("success", 1, 1));
1008 }
1009
1010 #[test]
1011 fn a_job_is_a_check_run_of_its_workflow_run() {
1012 let workflow_run = WorkflowRun {
1013 id: "run_1".into(),
1014 workflow_id: "wf".into(),
1015 path: ".g1t/workflows/ci.yml".into(),
1016 name: "CI".into(),
1017 title: "t".into(),
1018 number: 3,
1019 attempt: 1,
1020 event: "push".into(),
1021 git_ref: "refs/heads/main".into(),
1022 sha: "b".repeat(40),
1023 pull: None,
1024 status: "in_progress".into(),
1025 conclusion: None,
1026 error: None,
1027 actor: None,
1028 created_at: "2026-10-07T00:00:00Z".into(),
1029 started_at: None,
1030 finished_at: None,
1031 };
1032 let job = Job {
1033 id: "job_1".into(),
1034 run_id: "run_1".into(),
1035 key: "test".into(),
1036 name: "Test".into(),
1037 needs: vec![],
1038 status: "completed".into(),
1039 conclusion: Some("success".into()),
1040 steps: vec![],
1041 annotations: vec![Annotation { level: "error".into(), message: "boom".into(), title: None, file: Some("src/a.rs".into()), line: Some(4) }],
1042 reason: None,
1043 started_at: Some("2026-10-07T00:00:01Z".into()),
1044 finished_at: Some("2026-10-07T00:00:26Z".into()),
1045 environment: None,
1046 self_hosted: false,
1047 runner: None,
1048 cancelling: false,
1049 environment_url: None,
1050 uses: None,
1051 };
1052 let check = job_check_run("acme/web", &workflow_run, &job);
1053 assert_eq!(check.display_name(), "CI / Test (push)");
1054 assert_eq!(check.check_suite.id, "run_1");
1055 assert_eq!(check.html_url, "/acme/web/actions/runs/run_1?job=job_1");
1056 assert_eq!(check.output.annotations_count, 1);
1057 assert_eq!(job_annotations(&job)[0].annotation_level, "failure");
1058 let waiting = Job { status: "waiting".into(), conclusion: None, ..job.clone() };
1059 let suite = run_check_suite(&workflow_run, &[job.clone(), waiting.clone()]);
1060 assert_eq!((suite.status.as_str(), suite.head_branch.as_deref()), ("in_progress", Some("main")));
1061 assert_eq!(suite.name.as_deref(), Some("CI"));
1062
1063 // Each job stands as a status named as GitHub names its check run,
1064 // from the latest attempt of each workflow only.
1065 let sha = "b".repeat(40);
1066 let skipped = Job { id: "job_3".into(), name: "Lint".into(), conclusion: Some("skipped".into()), ..job.clone() };
1067 let cancelled = Job { id: "job_4".into(), name: "Build (ubuntu-latest)".into(), conclusion: Some("cancelled".into()), ..job.clone() };
1068 let older = WorkflowRun { id: "run_0".into(), attempt: 0, ..workflow_run.clone() };
1069 let other = WorkflowRun { sha: "c".repeat(40), ..workflow_run.clone() };
1070 let statuses = job_statuses(
1071 &[
1072 (workflow_run.clone(), vec![job.clone(), waiting, skipped, cancelled]),
1073 (older, vec![Job { conclusion: Some("failure".into()), ..job.clone() }]),
1074 (other, vec![job.clone()]),
1075 ],
1076 &sha,
1077 );
1078 let states: Vec<(&str, &str)> = statuses.iter().map(|s| (s.context.as_str(), s.state.as_str())).collect();
1079 assert_eq!(states, [("Test", "success"), ("Test", "pending"), ("Lint", "success"), ("Build (ubuntu-latest)", "failure")]);
1080 assert_eq!(statuses[0].check_run_id.as_deref(), Some("job_1"));
1081 assert_eq!(statuses[0].source.as_deref(), Some("actions"));
1082 }
1083
1084 #[test]
1085 fn only_the_latest_run_of_each_workflow_and_event_counts() {
1086 let base = WorkflowRun {
1087 id: "run_2".into(),
1088 workflow_id: "wf".into(),
1089 path: "ci.yml".into(),
1090 name: "CI".into(),
1091 title: String::new(),
1092 number: 2,
1093 attempt: 1,
1094 event: "push".into(),
1095 git_ref: String::new(),
1096 sha: "c".into(),
1097 pull: None,
1098 status: "completed".into(),
1099 conclusion: None,
1100 error: None,
1101 actor: None,
1102 created_at: String::new(),
1103 started_at: None,
1104 finished_at: None,
1105 };
1106 let older = WorkflowRun { id: "run_1".into(), ..base.clone() };
1107 let pull = WorkflowRun { id: "run_0".into(), event: "pull_request".into(), ..base.clone() };
1108 let runs = [(base, ()), (older, ()), (pull, ())];
1109 let ids: Vec<&str> = latest_runs(&runs).iter().map(|(run, _)| run.id.as_str()).collect();
1110 assert_eq!(ids, ["run_2", "run_0"]);
1111 }
1112
1113 #[test]
1114 fn reporters_are_named_from_the_token() {
1115 let mut user = User { id: "u".into(), username: "ada".into(), ..User::default() };
1116 assert_eq!(CheckApp::of(&user, None).slug, "ada");
1117 assert_eq!(CheckApp::of(&user, Some(" Vercel Preview ")), CheckApp { slug: "vercel-preview".into(), name: "Vercel Preview".into() });
1118 user.token = Some(Box::new(crate::scopes::TokenAccess { name: Some("G1T_TOKEN for acme/web run 4".into()), ..Default::default() }));
1119 assert_eq!(CheckApp::of(&user, None), CheckApp::actions());
1120 user.token = Some(Box::new(crate::scopes::TokenAccess { name: Some("Buildkite".into()), ..Default::default() }));
1121 assert_eq!(CheckApp::of(&user, None).slug, "buildkite");
1122 }
1123
1124 #[test]
1125 fn what_a_reporter_sends_is_checked() {
1126 let ok = CheckRunInput { name: Some("lint".into()), head_sha: Some("a".repeat(40)), ..Default::default() };
1127 assert!(validate_run(&ok, true).is_ok());
1128 assert!(validate_run(&CheckRunInput::default(), true).is_err());
1129 assert!(validate_run(&CheckRunInput::default(), false).is_ok());
1130 let completed = CheckRunInput { status: Some("completed".into()), ..ok.clone() };
1131 assert!(validate_run(&completed, true).unwrap_err().contains("conclusion"));
1132 let odd = CheckRunInput { conclusion: Some("passed".into()), ..ok.clone() };
1133 assert!(validate_run(&odd, true).is_err());
1134 let annotated = CheckRunInput {
1135 output: Some(OutputInput {
1136 title: Some("1 problem".into()),
1137 annotations: vec![CheckAnnotation { path: "a.rs".into(), start_line: 3, end_line: 2, annotation_level: "warning".into(), message: "x".into(), ..Default::default() }],
1138 ..Default::default()
1139 }),
1140 ..ok.clone()
1141 };
1142 assert!(validate_run(&annotated, true).unwrap_err().contains("end_line"));
1143 let buttons = CheckRunInput {
1144 actions: Some(vec![CheckAction { label: "Fix this".into(), description: "Let us fix it".into(), identifier: "fix".into() }; 4]),
1145 ..ok
1146 };
1147 assert!(validate_run(&buttons, true).is_err());
1148 assert!(validate_status("success", "ci/build", Some("Passed"), Some("https://ci.example.com/1")).is_ok());
1149 assert!(validate_status("passed", "ci", None, None).is_err());
1150 assert!(validate_status("success", "ci", None, Some("javascript:alert(1)")).is_err());
1151 assert!(is_full_sha(&"a".repeat(40)) && !is_full_sha("main"));
1152 }
1153}