Skip to content

g1t/apps/api/src/checks.rs

413 lines23,862 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge checks: statuses and check runs on every commit1//! Checks over REST and MCP: statuses on commits, and check runs and
2//! check suites, in GitHub's shapes so that existing integrations and
3//! actions report to g1t unchanged.
4//!
5//! The work service keeps them and decides who may read and report them
6//! (`g1t_contracts::checks`). A g1t Actions job is a check run here too,
7//! and its workflow run the suite.
8
9use g1t_contracts::checks::*;
10use g1t_contracts::{FailureCode, Outcome, Viewer};
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use serde_json::{Map, Value, json};
14use worker::Result;
15
16use crate::operations::Services;
17
18/// One operation on checks.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum ChecksOp {
21 CreateCommitStatus,
22 ListCommitStatuses,
23 GetCombinedStatus,
24 CreateCheckRun,
25 UpdateCheckRun,
26 GetCheckRun,
27 ListCheckRunAnnotations,
28 RerequestCheckRun,
29 ListCheckRunsForRef,
30 ListCheckSuitesForRef,
31 GetCheckSuite,
32 RerequestCheckSuite,
33}
34
35impl ChecksOp {
36 /// Every one: `Op::ALL` lists each as `Op::Checks(…)`, which a test
37 /// checks against this.
38 #[cfg(test)]
39 pub const ALL: [ChecksOp; 12] = [
40 ChecksOp::CreateCommitStatus,
41 ChecksOp::ListCommitStatuses,
42 ChecksOp::GetCombinedStatus,
43 ChecksOp::CreateCheckRun,
44 ChecksOp::UpdateCheckRun,
45 ChecksOp::GetCheckRun,
46 ChecksOp::ListCheckRunAnnotations,
47 ChecksOp::RerequestCheckRun,
48 ChecksOp::ListCheckRunsForRef,
49 ChecksOp::ListCheckSuitesForRef,
50 ChecksOp::GetCheckSuite,
51 ChecksOp::RerequestCheckSuite,
52 ];
53
54 pub fn name(self) -> &'static str {
55 match self {
56 ChecksOp::CreateCommitStatus => "create_commit_status",
57 ChecksOp::ListCommitStatuses => "list_commit_statuses",
58 ChecksOp::GetCombinedStatus => "get_combined_status",
59 ChecksOp::CreateCheckRun => "create_check_run",
60 ChecksOp::UpdateCheckRun => "update_check_run",
61 ChecksOp::GetCheckRun => "get_check_run",
62 ChecksOp::ListCheckRunAnnotations => "list_check_run_annotations",
63 ChecksOp::RerequestCheckRun => "rerequest_check_run",
64 ChecksOp::ListCheckRunsForRef => "list_check_runs_for_ref",
65 ChecksOp::ListCheckSuitesForRef => "list_check_suites_for_ref",
66 ChecksOp::GetCheckSuite => "get_check_suite",
67 ChecksOp::RerequestCheckSuite => "rerequest_check_suite",
68 }
69 }
70
71 /// For the API reference.
72 pub fn title(self) -> &'static str {
73 match self {
74 ChecksOp::CreateCommitStatus => "Create a commit status",
75 ChecksOp::ListCommitStatuses => "List a commit's statuses",
76 ChecksOp::GetCombinedStatus => "Get a commit's combined status",
77 ChecksOp::CreateCheckRun => "Create a check run",
78 ChecksOp::UpdateCheckRun => "Update a check run",
79 ChecksOp::GetCheckRun => "Get a check run",
80 ChecksOp::ListCheckRunAnnotations => "List a check run's annotations",
81 ChecksOp::RerequestCheckRun => "Rerequest a check run",
82 ChecksOp::ListCheckRunsForRef => "List a commit's check runs",
83 ChecksOp::ListCheckSuitesForRef => "List a commit's check suites",
84 ChecksOp::GetCheckSuite => "Get a check suite",
85 ChecksOp::RerequestCheckSuite => "Rerequest a check suite",
86 }
87 }
88
89 pub fn description(self) -> &'static str {
90 match self {
91 ChecksOp::CreateCommitStatus => "Set a status on a commit: state (pending, success, failure or error), context (what reports it, such as ci/build; default by default), description (at most 140 characters) and target_url (where to see more). A context has one status per commit: setting it again replaces it. A status is a check: a required check or a ruleset's required status check of its context is met by it. Needs the Write role; publishes status.created.",
92 ChecksOp::ListCommitStatuses => "List the statuses on a commit, named by its SHA, a branch or a tag: one per context, newest first. Check runs are listed by list_check_runs_for_ref instead.",
93 ChecksOp::GetCombinedStatus => "A commit's statuses, one per context, and the state they add up to: failure if any failed or errored, pending if any is pending or there are none, success otherwise. Named by its SHA, a branch or a tag.",
94 ChecksOp::CreateCheckRun => "Report a check run on a commit: name and head_sha are required; status (queued, in_progress or completed; queued by default), conclusion (success, failure, neutral, cancelled, skipped, timed_out or action_required; it makes the run completed), started_at and completed_at (RFC 3339; filled in when left out), details_url (your page for it), external_id (your id for it), output (title, summary and text in Markdown, and up to 50 annotations: path, start_line, end_line, start_column, end_column, annotation_level notice, warning or failure, message, title, raw_details) and actions (up to 3 buttons: label, description, identifier). app names who reports it, by default your token's name. Runs are grouped per reporter and commit into a check suite. A check run is a check: a required check of its name is met by it, a cancelled one failing. Needs the Write role; publishes check_run.created, and check_run.completed when it is created completed.",
95 ChecksOp::UpdateCheckRun => "Change a check run reported through the API, by id (cr_…). Fields left out stay as they are; output annotations are added to the ones it has (at most 1000 in all); actions, when given, replace its buttons. Giving a conclusion completes it. Publishes check_run.completed when it completes. A g1t Actions job's check run is its workflow's and cannot be changed.",
96 ChecksOp::GetCheckRun => "Get a check run by id: cr_… for one reported through the API, or a g1t Actions job's id (job_…), whose workflow run is its suite and whose workflow says its name, run and event in workflow.",
97 ChecksOp::ListCheckRunAnnotations => "List a check run's annotations in the order they were reported: path, start_line, end_line, start_column, end_column, annotation_level (notice, warning or failure), message, title and raw_details.",
98 ChecksOp::RerequestCheckRun => "Ask for a check run to run again. For one reported through the API, its reporter is sent check_run.rerequested; for a g1t Actions job, its workflow run runs again (which also needs workflows:write). Needs the Write role.",
99 ChecksOp::ListCheckRunsForRef => "List a commit's check runs, named by its SHA, a branch or a tag: those reported through the API and each job of its g1t Actions workflow runs. filter latest (the default) gives each name's latest run and each workflow's latest run per event; all gives every one. Narrow with check_name, status and app (a reporter's slug; actions for g1t Actions).",
100 ChecksOp::ListCheckSuitesForRef => "List a commit's check suites, named by its SHA, a branch or a tag: one per reporter that reported check runs on it through the API, and one per g1t Actions workflow run, with its status and conclusion worked out from its latest check runs. Narrow with app and check_name.",
101 ChecksOp::GetCheckSuite => "Get a check suite by id: cs_… for a reporter's, or a g1t Actions workflow run's id (run_…).",
102 ChecksOp::RerequestCheckSuite => "Ask for a check suite to run again: its reporter is sent check_suite.rerequested, or a g1t Actions workflow run runs again (which also needs workflows:write). Needs the Write role.",
103 }
104 }
105
106 /// Whether it only reads, which anyone who can see the repository may.
107 pub fn reads(self) -> bool {
108 !matches!(
109 self,
110 ChecksOp::CreateCommitStatus
111 | ChecksOp::CreateCheckRun
112 | ChecksOp::UpdateCheckRun
113 | ChecksOp::RerequestCheckRun
114 | ChecksOp::RerequestCheckSuite
115 )
116 }
117
118 pub fn input(self) -> Value {
119 let repo = || json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
120 let git_ref = || json!({ "type": "string", "description": "The commit: its SHA, a branch or a tag." });
121 let run_id = || json!({ "type": "string", "description": "The check run's id: cr_…, or a g1t Actions job's job_…" });
122 let suite_id = || json!({ "type": "string", "description": "The check suite's id: cs_…, or a g1t Actions run's run_…" });
123 let run_fields = |mut properties: Value, creating: bool| {
124 properties["name"] = json!({ "type": "string", "description": "The check's name, at most 100 characters, such as lint or coverage." });
125 if creating {
126 properties["head_sha"] = json!({ "type": "string", "description": "The commit's full SHA (or a branch or tag, read as the commit it points to now)." });
127 properties["app"] = json!({ "type": "string", "description": "Who reports it, shown with it and grouping its check suite: by default your token's name." });
128 }
129 properties["status"] = json!({ "type": "string", "enum": STATUSES, "description": "Where it is: queued, in_progress or completed." });
130 properties["conclusion"] = json!({ "type": "string", "enum": CONCLUSIONS, "description": "How it came out; giving one completes it." });
131 properties["started_at"] = json!({ "type": "string", "description": "When it started, RFC 3339." });
132 properties["completed_at"] = json!({ "type": "string", "description": "When it completed, RFC 3339." });
133 properties["details_url"] = json!({ "type": "string", "description": "Your page for it, http or https." });
134 properties["external_id"] = json!({ "type": "string", "description": "Your id for it." });
135 properties["output"] = json!({
136 "type": "object",
137 "description": "Its report: a title, a Markdown summary and text, and annotations on lines of files (at most 50 a request).",
138 "properties": {
139 "title": { "type": "string" },
140 "summary": { "type": "string" },
141 "text": { "type": "string" },
142 "annotations": {
143 "type": "array",
144 "items": {
145 "type": "object",
146 "properties": {
147 "path": { "type": "string" },
148 "start_line": { "type": "integer" },
149 "end_line": { "type": "integer" },
150 "start_column": { "type": "integer" },
151 "end_column": { "type": "integer" },
152 "annotation_level": { "type": "string", "enum": ANNOTATION_LEVELS },
153 "message": { "type": "string" },
154 "title": { "type": "string" },
155 "raw_details": { "type": "string" },
156 },
157 "required": ["path", "start_line", "end_line", "annotation_level", "message"],
158 },
159 },
160 },
161 });
162 properties["actions"] = json!({
163 "type": "array",
164 "description": "Up to 3 buttons on its page. Pressing one sends you check_run.requested_action with its identifier.",
165 "items": {
166 "type": "object",
167 "properties": {
168 "label": { "type": "string", "description": "At most 20 characters." },
169 "description": { "type": "string", "description": "At most 40 characters." },
170 "identifier": { "type": "string", "description": "At most 20 characters." },
171 },
172 "required": ["label", "description", "identifier"],
173 },
174 });
175 properties
176 };
177 let (properties, required): (Value, &[&str]) = match self {
178 ChecksOp::CreateCommitStatus => (
179 json!({
180 "repo": repo(),
181 "sha": { "type": "string", "description": "The commit's full SHA." },
182 "state": { "type": "string", "enum": STATUS_STATES, "description": "pending, success, failure or error." },
183 "context": { "type": "string", "description": "What reports it, such as ci/build; default when left out." },
184 "description": { "type": "string", "description": "A short word on it, at most 140 characters." },
185 "target_url": { "type": "string", "description": "Where to see more, http or https." },
186 }),
187 &["repo", "sha", "state"],
188 ),
189 ChecksOp::ListCommitStatuses | ChecksOp::GetCombinedStatus => (json!({ "repo": repo(), "ref": git_ref() }), &["repo", "ref"]),
190 ChecksOp::CreateCheckRun => (run_fields(json!({ "repo": repo() }), true), &["repo", "name", "head_sha"]),
191 ChecksOp::UpdateCheckRun => (run_fields(json!({ "repo": repo(), "id": run_id() }), false), &["repo", "id"]),
192 ChecksOp::GetCheckRun | ChecksOp::ListCheckRunAnnotations | ChecksOp::RerequestCheckRun => {
193 (json!({ "repo": repo(), "id": run_id() }), &["repo", "id"])
194 }
195 ChecksOp::ListCheckRunsForRef => (
196 json!({
197 "repo": repo(),
198 "ref": git_ref(),
199 "check_name": { "type": "string", "description": "Only runs of this name." },
200 "status": { "type": "string", "enum": STATUSES, "description": "Only runs in this status." },
201 "app": { "type": "string", "description": "Only this reporter's runs, by slug: actions for g1t Actions." },
202 "filter": { "type": "string", "enum": ["latest", "all"], "description": "latest (the default) or all." },
203 }),
204 &["repo", "ref"],
205 ),
206 ChecksOp::ListCheckSuitesForRef => (
207 json!({
208 "repo": repo(),
209 "ref": git_ref(),
210 "app": { "type": "string", "description": "Only this reporter's suites, by slug." },
211 "check_name": { "type": "string", "description": "Only suites with a run of this name." },
212 }),
213 &["repo", "ref"],
214 ),
215 ChecksOp::GetCheckSuite | ChecksOp::RerequestCheckSuite => (json!({ "repo": repo(), "id": suite_id() }), &["repo", "id"]),
216 };
217 json!({ "type": "object", "properties": properties, "required": required })
218 }
219}
220
221fn text(input: &Value, key: &str) -> Option<String> {
222 match &input[key] {
223 Value::String(text) => Some(text.trim().to_owned()).filter(|text| !text.is_empty()),
224 _ => None,
225 }
226}
227
228/// A key in `camelCase`, as the contracts read it: `start_line` is `startLine`.
229fn camel_key(key: &str) -> String {
230 let mut out = String::with_capacity(key.len());
231 let mut upper = false;
232 for c in key.chars() {
233 if c == '_' {
234 upper = true;
235 } else if upper {
236 out.extend(c.to_uppercase());
237 upper = false;
238 } else {
239 out.push(c);
240 }
241 }
242 out
243}
244
245fn camel(value: &Value) -> Value {
246 match value {
247 Value::Object(fields) => Value::Object(fields.iter().map(|(key, value)| (camel_key(key), camel(value))).collect::<Map<_, _>>()),
248 Value::Array(items) => Value::Array(items.iter().map(camel).collect()),
249 other => other.clone(),
250 }
251}
252
253/// A check run's fields from a request body.
254pub(crate) fn run_input(input: &Value) -> std::result::Result<CheckRunInput, String> {
255 const FIELDS: [&str; 10] =
256 ["name", "head_sha", "status", "conclusion", "started_at", "completed_at", "details_url", "external_id", "output", "actions"];
257 let mut fields = Map::new();
258 for key in FIELDS {
259 if let Some(value) = input.get(key).filter(|value| !value.is_null()) {
260 fields.insert(camel_key(key), camel(value));
261 }
262 }
263 serde_json::from_value(Value::Object(fields)).map_err(|error| format!("The check run could not be read: {error}"))
264}
265
266/// Pages on the site, which the work service names by path, as full
267/// addresses.
268fn absolute(value: Value, site: &str) -> Value {
269 match value {
270 Value::Object(fields) => Value::Object(
271 fields
272 .into_iter()
273 .map(|(key, value)| {
274 let value = match value {
275 Value::String(path) if matches!(key.as_str(), "htmlUrl" | "detailsUrl" | "targetUrl") && path.starts_with('/') => {
276 Value::String(format!("{site}{path}"))
277 }
278 other => absolute(other, site),
279 };
280 (key, value)
281 })
282 .collect(),
283 ),
284 Value::Array(items) => Value::Array(items.into_iter().map(|item| absolute(item, site)).collect()),
285 other => other,
286 }
287}
288
289async fn call<A: Serialize, T: DeserializeOwned + Serialize>(services: &Services, method: &str, args: &A) -> Result<Outcome<Value>> {
290 let found: Outcome<T> = g1t_kit::call(&services.work, method, args).await?;
291 Ok(match found {
292 Outcome::Ok(value) => Outcome::Ok(absolute(serde_json::to_value(value)?, &services.addresses.site)),
293 Outcome::Fail(failure) => Outcome::Fail(failure),
294 })
295}
296
297pub async fn run(op: ChecksOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
298 let Some(repo) = crate::operations::repo_path(input) else {
299 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
300 };
301 let actor = || viewer.clone().unwrap_or_default();
302 let id = || text(input, "id").unwrap_or_default();
303 let git_ref = || text(input, "ref").unwrap_or_default();
304 match op {
305 ChecksOp::CreateCommitStatus => {
306 let args = CreateStatusArgs {
307 actor: actor(),
308 repo,
309 sha: text(input, "sha").unwrap_or_default(),
310 state: text(input, "state").unwrap_or_default(),
311 context: text(input, "context"),
312 description: text(input, "description"),
313 target_url: text(input, "target_url"),
314 };
315 call::<_, g1t_contracts::work::CommitStatus>(services, "create_commit_status", &args).await
316 }
317 ChecksOp::ListCommitStatuses => {
318 call::<_, Vec<g1t_contracts::work::CommitStatus>>(services, "commit_statuses", &RefArgs { viewer: viewer.clone(), repo, git_ref: git_ref() }).await
319 }
320 ChecksOp::GetCombinedStatus => {
321 call::<_, CombinedStatus>(services, "combined_status", &RefArgs { viewer: viewer.clone(), repo, git_ref: git_ref() }).await
322 }
323 ChecksOp::CreateCheckRun | ChecksOp::UpdateCheckRun => {
324 let run = match run_input(input) {
325 Ok(run) => run,
326 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
327 };
328 if op == ChecksOp::CreateCheckRun {
329 let args = CreateCheckRunArgs { actor: actor(), repo, app: text(input, "app"), run };
330 call::<_, CommitCheckRun>(services, "create_check_run", &args).await
331 } else {
332 call::<_, CommitCheckRun>(services, "update_check_run", &UpdateCheckRunArgs { actor: actor(), repo, id: id(), run }).await
333 }
334 }
335 ChecksOp::GetCheckRun => call::<_, CommitCheckRun>(services, "get_check_run", &CheckIdArgs { viewer: viewer.clone(), repo, id: id() }).await,
336 ChecksOp::ListCheckRunAnnotations => {
337 call::<_, Vec<CheckAnnotation>>(services, "check_run_annotations", &CheckIdArgs { viewer: viewer.clone(), repo, id: id() }).await
338 }
339 ChecksOp::GetCheckSuite => call::<_, CommitCheckSuite>(services, "get_check_suite", &CheckIdArgs { viewer: viewer.clone(), repo, id: id() }).await,
340 ChecksOp::RerequestCheckRun | ChecksOp::RerequestCheckSuite => {
341 let method = if op == ChecksOp::RerequestCheckRun { "rerequest_check_run" } else { "rerequest_check_suite" };
342 let done: Outcome<bool> = g1t_kit::call(&services.work, method, &RerequestArgs { actor: actor(), repo, id: id() }).await?;
343 Ok(match done {
344 Outcome::Ok(_) => Outcome::Ok(json!({ "rerequested": true })),
345 Outcome::Fail(failure) => Outcome::Fail(failure),
346 })
347 }
348 ChecksOp::ListCheckRunsForRef => {
349 let args = RefCheckRunsArgs {
350 viewer: viewer.clone(),
351 repo,
352 git_ref: git_ref(),
353 check_name: text(input, "check_name"),
354 status: text(input, "status"),
355 app: text(input, "app"),
356 filter: text(input, "filter"),
357 };
358 call::<_, CheckRunList>(services, "ref_check_runs", &args).await
359 }
360 ChecksOp::ListCheckSuitesForRef => {
361 let args = RefCheckSuitesArgs { viewer: viewer.clone(), repo, git_ref: git_ref(), app: text(input, "app"), check_name: text(input, "check_name") };
362 call::<_, CheckSuiteList>(services, "ref_check_suites", &args).await
363 }
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn a_body_in_snake_case_is_a_check_run() {
373 let body = json!({
374 "repo": "acme/web",
375 "name": "lint",
376 "head_sha": "a".repeat(40),
377 "status": "completed",
378 "conclusion": "failure",
379 "output": {
380 "title": "2 problems",
381 "summary": "**2** problems",
382 "annotations": [{ "path": "src/a.rs", "start_line": 3, "end_line": 3, "annotation_level": "warning", "message": "unused" }]
383 },
384 "actions": [{ "label": "Fix", "description": "Fix it", "identifier": "fix" }]
385 });
386 let run = run_input(&body).unwrap();
387 assert_eq!(run.head_sha.as_deref(), Some("a".repeat(40).as_str()));
388 let output = run.output.unwrap();
389 assert_eq!(output.annotations[0].start_line, 3);
390 assert_eq!(output.annotations[0].annotation_level, "warning");
391 assert_eq!(run.actions.unwrap()[0].identifier, "fix");
392 assert!(run_input(&json!({ "output": { "annotations": "no" } })).is_err());
393 }
394
395 #[test]
396 fn pages_on_the_site_become_full_addresses() {
397 let value = json!({ "checkRuns": [{ "htmlUrl": "/acme/web/checks/cr_1", "detailsUrl": "https://ci.example.com/1", "name": "/x" }] });
398 let out = absolute(value, "https://g1t.sh");
399 assert_eq!(out["checkRuns"][0]["htmlUrl"], "https://g1t.sh/acme/web/checks/cr_1");
400 assert_eq!(out["checkRuns"][0]["detailsUrl"], "https://ci.example.com/1");
401 assert_eq!(out["checkRuns"][0]["name"], "/x");
402 }
403
404 #[test]
405 fn each_operation_is_described_with_a_schema() {
406 for op in ChecksOp::ALL {
407 assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Checks(op)), "{}", op.name());
408 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
409 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
410 assert_eq!(op.reads(), g1t_contracts::scopes::scope_for(op.name()).unwrap().level() == g1t_contracts::scopes::Level::Read, "{}", op.name());
411 }
412 }
413}

This file's history is long; its oldest lines are credited to the oldest commit read.