Skip to content
873 linesCodeBlameRaw
1//! `github.event`: the webhook-shaped payload GitHub gives a workflow,
2//! built from g1t's own records so `github.event.pull_request.number`,
3//! `github.event.issue.labels.*.name` and the like read as they do there.
4//!
5//! Ids are as GitHub's are: `id` a whole number, the same for an object
6//! wherever it appears ([`github_id`] of its g1t id, [`account_id`] of a
7//! person's or workspace's name), and `node_id` g1t's own id. What g1t's
8//! API addresses by its own id keeps that id as `id`, so a payload's id
9//! can be passed straight back to the API: a comment (`PATCH
10//! /repos/{owner}/{repo}/issues/comments/{comment_id}`), a release, a
11//! deployment and its statuses, and a workflow run.
12
13use g1t_actions::events::{account_id, github_id};
14use g1t_contracts::{PrincipalKind, User};
15use g1t_contracts::repos::{Commit, Repo, RepoPath};
16use g1t_contracts::work::{Comment, Issue, MilestoneRef, Pull, PullStatus, State};
17use serde_json::{Value, json};
18
19use crate::{API, SITE};
20
21/// The commit hash GitHub gives for no commit: a branch made or deleted.
22pub const ZERO: &str = "0000000000000000000000000000000000000000";
23
24/// A repository's owner, as a workspace (an `Organization`). A personal
25/// workspace's repositories are owned by a `User` instead: see
26/// [`owned_by_person`].
27fn owner(login: &str) -> Value {
28 json!({
29 "login": login,
30 "id": account_id(login),
31 "type": "Organization",
32 "html_url": format!("{SITE}/{login}"),
33 "url": format!("{API}/users/{login}"),
34 })
35}
36
37pub fn repository(repo: &Repo) -> Value {
38 let full_name = format!("{}/{}", repo.namespace, repo.name);
39 json!({
40 "id": github_id(&repo.id),
41 "node_id": repo.id,
42 "name": repo.name,
43 "full_name": full_name,
44 "private": repo.is_private,
45 "owner": owner(&repo.namespace),
46 "html_url": format!("{SITE}/{full_name}"),
47 "url": format!("{API}/repos/{full_name}"),
48 "clone_url": format!("{SITE}/{full_name}.git"),
49 "description": repo.description,
50 "homepage": repo.website,
51 "topics": repo.topics,
52 "default_branch": repo.default_branch,
53 "fork": false,
54 "archived": repo.archived(),
55 "created_at": repo.created_at,
56 "visibility": if repo.is_private { "private" } else { "public" },
57 })
58}
59
60/// A pull request's fork, from what the pull request knows of it.
61fn fork_repository(fork: &RepoPath, id: Option<&str>) -> Value {
62 let full_name = format!("{}/{}", fork.namespace, fork.name);
63 json!({
64 "id": id.map(github_id),
65 "node_id": id,
66 "name": fork.name,
67 "full_name": full_name,
68 "owner": owner(&fork.namespace),
69 "html_url": format!("{SITE}/{full_name}"),
70 "url": format!("{API}/repos/{full_name}"),
71 "clone_url": format!("{SITE}/{full_name}.git"),
72 "fork": true,
73 })
74}
75
76/// Marks the owner of `namespace`'s repositories as a `User` everywhere
77/// in a payload: for a personal workspace, whose name is its person's.
78pub fn owned_by_person(payload: &mut Value, namespace: &str) {
79 match payload {
80 Value::Object(map) => {
81 if map.get("login").and_then(Value::as_str).is_some_and(|login| login.eq_ignore_ascii_case(namespace))
82 && map.get("type").and_then(Value::as_str) == Some("Organization")
83 {
84 map.insert("type".into(), json!("User"));
85 }
86 map.values_mut().for_each(|value| owned_by_person(value, namespace));
87 }
88 Value::Array(items) => items.iter_mut().for_each(|value| owned_by_person(value, namespace)),
89 _ => {}
90 }
91}
92
93pub fn user(login: &str) -> Value {
94 json!({
95 "login": login,
96 "id": account_id(login),
97 "type": "User",
98 "html_url": format!("{SITE}/{login}"),
99 "url": format!("{API}/users/{login}"),
100 })
101}
102
103/// Someone as GitHub names them. g1t, the author of what it makes and
104/// files, is a `Bot`, as an app is there.
105fn person(user: &User) -> Value {
106 let mut named = self::user(&user.username);
107 named["node_id"] = json!(user.id);
108 if matches!(user.kind, PrincipalKind::Agent | PrincipalKind::System) {
109 named["type"] = json!("Bot");
110 }
111 named
112}
113
114fn people(logins: &[String]) -> Vec<Value> {
115 logins.iter().map(|login| user(login)).collect()
116}
117
118fn labels(repo: &Repo, names: &[String]) -> Value {
119 Value::Array(names.iter().map(|name| label(repo, name, None)).collect())
120}
121
122/// A label, by its name in `repo`.
123fn label(repo: &Repo, name: &str, color: Option<&Value>) -> Value {
124 let mut label = json!({ "id": github_id(&format!("{}:label:{name}", repo.id)), "name": name });
125 if let Some(color) = color {
126 label["color"] = color.clone();
127 }
128 label
129}
130
131/// A milestone as an issue or a pull request names it.
132fn milestone(repo: &Repo, milestone: Option<&MilestoneRef>) -> Value {
133 milestone.map_or(Value::Null, |milestone| milestone_of(repo, milestone.number, &json!(milestone.title)))
134}
135
136fn milestone_of(repo: &Repo, number: u32, title: &Value) -> Value {
137 json!({
138 "id": github_id(&format!("{}:milestone:{number}", repo.id)),
139 "number": number,
140 "title": title,
141 "html_url": format!("{SITE}/{}/{}/milestones/{number}", repo.namespace, repo.name),
142 })
143}
144
145pub fn commit(repo: &Repo, commit: &Commit) -> Value {
146 json!({
147 "id": commit.hash,
148 "tree_id": commit.tree_hash,
149 "message": commit.message,
150 "timestamp": commit.authored_at,
151 "url": format!("{SITE}/{}/{}/commit/{}", repo.namespace, repo.name, commit.hash),
152 "author": { "name": commit.author.name, "email": commit.author.email },
153 "committer": { "name": commit.author.name, "email": commit.author.email },
154 "distinct": true,
155 "added": [],
156 "removed": [],
157 "modified": [],
158 })
159}
160
161/// A push. `forced` is whether `before` is not in `after`'s history, when
162/// that is known; a push whose history is too long to read says `false`.
163pub fn push(repo: &Repo, git_ref: &str, before: Option<&str>, after: &str, commits: &[Commit], pusher: &str, forced: bool) -> Value {
164 let before = before.unwrap_or(ZERO);
165 let created = before == ZERO;
166 let deleted = after == ZERO;
167 let commits: Vec<Value> = commits.iter().map(|c| commit(repo, c)).collect();
168 let full_name = format!("{}/{}", repo.namespace, repo.name);
169 let compare = if created {
170 format!("{SITE}/{full_name}/commit/{after}")
171 } else {
172 format!("{SITE}/{full_name}/compare/{before}...{after}")
173 };
174 json!({
175 "ref": git_ref,
176 "before": before,
177 "after": after,
178 "created": created,
179 "deleted": deleted,
180 "forced": forced && !created && !deleted,
181 "base_ref": null,
182 "compare": compare,
183 // GitHub's head commit is the one pushed: the last.
184 "head_commit": commits.last().cloned().unwrap_or(Value::Null),
185 "commits": commits,
186 "pusher": { "name": pusher, "email": null },
187 "repository": repository(repo),
188 "sender": user(pusher),
189 })
190}
191
192pub fn issue(repo: &Repo, issue: &Issue) -> Value {
193 let full_name = format!("{}/{}", repo.namespace, repo.name);
194 json!({
195 "id": github_id(&issue.id),
196 "node_id": issue.id,
197 "number": issue.number,
198 "title": issue.title,
199 "body": issue.body,
200 "state": if issue.state == State::Open { "open" } else { "closed" },
201 "state_reason": issue.reason,
202 "locked": false,
203 "labels": labels(repo, &issue.labels),
204 "milestone": milestone(repo, issue.milestone.as_ref()),
205 "user": person(&issue.author),
206 // g1t's own field: for an issue its agent filed, who it worked for.
207 "requested_by": issue.requested_by.as_ref().map(person),
208 "assignee": issue.assignees.first().map(|a| user(a)),
209 "assignees": people(&issue.assignees),
210 "comments": issue.comment_count,
211 "created_at": issue.created_at,
212 "updated_at": issue.updated_at,
213 "closed_at": issue.closed_at,
214 "html_url": format!("{SITE}/{full_name}/issues/{}", issue.number),
215 "url": format!("{API}/repos/{full_name}/issues/{}", issue.number),
216 "repository_url": format!("{API}/repos/{full_name}"),
217 })
218}
219
220/// A pull request, with its own labels. `base.sha` is the pull request's
221/// merge base until the trigger sets the base branch's tip, as GitHub
222/// gives it (trigger.rs).
223pub fn pull(repo: &Repo, pull: &Pull) -> Value {
224 let full_name = format!("{}/{}", repo.namespace, repo.name);
225 let head_ref = head_ref(pull);
226 let base_ref = pull.base_branch(&repo.default_branch);
227 let (head_repo, head_owner) = match &pull.fork {
228 Some(fork) => (fork_repository(fork, pull.fork_repo_id.as_deref()), fork.namespace.as_str()),
229 None => (repository(repo), repo.namespace.as_str()),
230 };
231 let merged = pull.status == PullStatus::Merged;
232 let closed_at = match pull.status {
233 PullStatus::Merged => pull.merged_at.clone(),
234 PullStatus::Closed => Some(pull.updated_at.clone()),
235 _ => None,
236 };
237 json!({
238 "id": github_id(&pull.id),
239 "node_id": pull.id,
240 "number": pull.number,
241 "title": pull.title,
242 "body": pull.body,
243 "state": if pull.status.is_active() { "open" } else { "closed" },
244 "locked": false,
245 "draft": pull.status == PullStatus::Draft,
246 "merged": merged,
247 "merged_at": pull.merged_at,
248 "merged_by": pull.merged_by.as_deref().map(user),
249 // Only once merged, as on GitHub; the trigger sets the commit it
250 // landed as when the event says.
251 "merge_commit_sha": if merged { pull.head_commit.clone() } else { None },
252 "labels": labels(repo, &pull.labels),
253 "milestone": milestone(repo, pull.milestone.as_ref()),
254 "user": person(&pull.author),
255 // g1t's own field: for a change g1t made, who asked for it.
256 "requested_by": pull.requested_by.as_ref().map(person),
257 "assignee": pull.assignees.first().map(|a| user(a)),
258 "assignees": people(&pull.assignees),
259 "requested_reviewers": people(&pull.reviewers),
260 "head": {
261 "ref": head_ref,
262 "sha": pull.head_commit,
263 "label": format!("{head_owner}:{head_ref}"),
264 "user": owner(head_owner),
265 "repo": head_repo,
266 },
267 "base": {
268 "ref": base_ref,
269 "sha": pull.merge_base,
270 "label": format!("{}:{base_ref}", repo.namespace),
271 "user": owner(&repo.namespace),
272 "repo": repository(repo),
273 },
274 "changed_files": pull.files.len(),
275 "additions": pull.files.iter().map(|f| u64::from(f.additions)).sum::<u64>(),
276 "deletions": pull.files.iter().map(|f| u64::from(f.deletions)).sum::<u64>(),
277 "created_at": pull.created_at,
278 "updated_at": pull.updated_at,
279 "closed_at": closed_at,
280 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
281 "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
282 "issue_url": format!("{API}/repos/{full_name}/issues/{}", pull.number),
283 })
284}
285
286/// What a `labeled`, `unlabeled`, `milestoned`, `demilestoned`,
287/// `assigned` or base change was about, from the g1t event: `label`,
288/// `milestone`, `assignee` (the first of those newly assigned; g1t sends
289/// one event for them all, GitHub one each), and for a new base,
290/// `changes.base.ref.from` is not known, so only the new base is in
291/// `pull_request.base`.
292pub fn changed(repo: &Repo, payload: &mut Value, data: &Value) {
293 if let Some(first) = data["added"].as_array().and_then(|added| added.first()).and_then(Value::as_str) {
294 payload["assignee"] = user(first);
295 }
296 if let Some(found) = data.get("label").filter(|label| label.is_object()) {
297 payload["label"] = label(repo, found["name"].as_str().unwrap_or_default(), Some(&found["color"]));
298 }
299 if let Some(found) = data.get("milestone").filter(|milestone| milestone.is_object()) {
300 payload["milestone"] = match found["number"].as_u64() {
301 Some(number) => milestone_of(repo, number as u32, &found["title"]),
302 None => json!({ "number": found["number"], "title": found["title"] }),
303 };
304 }
305}
306
307/// The branch a pull request comes from, or a name for its fork.
308pub fn head_ref(pull: &Pull) -> String {
309 pull.branch.clone().unwrap_or_else(|| format!("pull/{}", pull.number))
310}
311
312/// A comment. Its `id` is g1t's (`cmt_…`), as the API takes it back to
313/// edit or delete it.
314pub fn comment(repo: &Repo, number: u32, comment: &Comment, on_pull: bool) -> Value {
315 let full_name = format!("{}/{}", repo.namespace, repo.name);
316 let page = if on_pull { "pull" } else { "issues" };
317 json!({
318 "id": comment.id,
319 "node_id": comment.id,
320 "body": comment.body,
321 "user": person(&comment.author),
322 "created_at": comment.created_at,
323 "updated_at": comment.edited_at.as_deref().unwrap_or(&comment.created_at),
324 "path": comment.path,
325 "line": comment.line,
326 "html_url": format!("{SITE}/{full_name}/{page}/{number}#{}", comment.id),
327 "url": format!("{API}/repos/{full_name}/issues/comments/{}", comment.id),
328 "issue_url": format!("{API}/repos/{full_name}/issues/{number}"),
329 })
330}
331
332/// A review, for `pull_request_review`: g1t keeps a review as a comment
333/// with a verdict, and its id is that comment's.
334pub fn review(repo: &Repo, pull: &Pull, review: Option<&Comment>) -> Value {
335 let full_name = format!("{}/{}", repo.namespace, repo.name);
336 let Some(review) = review else {
337 return json!({ "state": null, "body": null, "user": null });
338 };
339 json!({
340 "id": github_id(&review.id),
341 "node_id": review.id,
342 "state": review.verdict.map(review_state),
343 "body": review.body,
344 "user": person(&review.author),
345 "commit_id": pull.head_commit,
346 "submitted_at": review.created_at,
347 "html_url": format!("{SITE}/{full_name}/pull/{}#{}", pull.number, review.id),
348 "pull_request_url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
349 })
350}
351
352/// An issue as `issue_comment` gives it for a pull request: the pull
353/// request's number and title, with `pull_request` set. Its id is not the
354/// pull request's, as on GitHub, where the two are different objects.
355pub fn pull_as_issue(repo: &Repo, pull: &Pull) -> Value {
356 let full_name = format!("{}/{}", repo.namespace, repo.name);
357 json!({
358 "id": github_id(&format!("issue:{}", pull.id)),
359 "node_id": pull.id,
360 "number": pull.number,
361 "title": pull.title,
362 "body": pull.body,
363 "state": if pull.status.is_active() { "open" } else { "closed" },
364 "locked": false,
365 "draft": pull.status == PullStatus::Draft,
366 "labels": labels(repo, &pull.labels),
367 "milestone": milestone(repo, pull.milestone.as_ref()),
368 "user": person(&pull.author),
369 "requested_by": pull.requested_by.as_ref().map(person),
370 "assignee": pull.assignees.first().map(|a| user(a)),
371 "assignees": people(&pull.assignees),
372 "created_at": pull.created_at,
373 "updated_at": pull.updated_at,
374 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
375 "url": format!("{API}/repos/{full_name}/issues/{}", pull.number),
376 "repository_url": format!("{API}/repos/{full_name}"),
377 "pull_request": {
378 "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
379 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
380 "merged_at": pull.merged_at,
381 },
382 })
383}
384
385/// A comment as the event that deleted it kept it (`comment.deleted`'s
386/// `comment`), since it can no longer be read.
387pub fn deleted_comment(repo: &Repo, number: u32, kept: &Value, on_pull: bool) -> Value {
388 let full_name = format!("{}/{}", repo.namespace, repo.name);
389 let page = if on_pull { "pull" } else { "issues" };
390 let id = kept["id"].as_str().unwrap_or_default();
391 let login = kept["author"]["username"].as_str().unwrap_or_default();
392 json!({
393 "id": id,
394 "node_id": id,
395 "body": kept["body"],
396 "user": user(login),
397 "created_at": kept["createdAt"],
398 "updated_at": kept["editedAt"].as_str().map_or(kept["createdAt"].clone(), |at| json!(at)),
399 "path": kept["path"],
400 "line": kept["line"],
401 "html_url": format!("{SITE}/{full_name}/{page}/{number}#{id}"),
402 "url": format!("{API}/repos/{full_name}/issues/comments/{id}"),
403 "issue_url": format!("{API}/repos/{full_name}/issues/{number}"),
404 })
405}
406
407/// What `workflow_run` is told of the run it follows: the
408/// `workflow.completed` event's data, and the run's row when it is still
409/// there. Its `id` and `workflow_id` are g1t's (`run_…`, `wfl_…`), which
410/// `GITHUB_RUN_ID` and the API use too, so `actions/download-artifact`
411/// with `run-id: ${{ github.event.workflow_run.id }}` finds its artifacts.
412pub struct FollowedRun<'a> {
413 pub data: &'a Value,
414 pub workflow_id: Option<&'a str>,
415 pub attempt: Option<u64>,
416 pub created_at: Option<&'a str>,
417 pub started_at: Option<&'a str>,
418 pub updated_at: Option<&'a str>,
419 pub actor: Option<&'a str>,
420 pub triggering_actor: Option<&'a str>,
421 pub title: Option<&'a str>,
422 pub head_commit: Option<&'a Commit>,
423}
424
425pub fn workflow_run(repo: &Repo, run: &FollowedRun) -> Value {
426 let full_name = format!("{}/{}", repo.namespace, repo.name);
427 let data = run.data;
428 let id = data["runId"].as_str().unwrap_or_default();
429 let name = data["workflow"].as_str().unwrap_or_default();
430 let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned();
431 let url = format!("{API}/repos/{full_name}/actions/runs/{id}");
432 let actor = run.actor.map(user);
433 json!({
434 "id": id,
435 "node_id": id,
436 "name": name,
437 "display_title": run.title.unwrap_or(name),
438 "path": data["path"],
439 "event": data["event"],
440 "status": "completed",
441 "conclusion": data["conclusion"],
442 "workflow_id": run.workflow_id,
443 "head_sha": data["sha"],
444 "head_branch": head_branch,
445 "head_commit": run.head_commit.map(|c| json!({
446 "id": c.hash,
447 "tree_id": c.tree_hash,
448 "message": c.message,
449 "timestamp": c.authored_at,
450 "author": { "name": c.author.name, "email": c.author.email },
451 "committer": { "name": c.author.name, "email": c.author.email },
452 })),
453 "run_number": data["number"],
454 "run_attempt": run.attempt.unwrap_or(1),
455 "created_at": run.created_at,
456 "run_started_at": run.started_at.or(run.created_at),
457 "updated_at": run.updated_at.or(run.created_at),
458 "actor": actor,
459 "triggering_actor": run.triggering_actor.map(user).or_else(|| run.actor.map(user)),
460 "html_url": format!("{SITE}/{full_name}/actions/runs/{id}"),
461 "url": url,
462 "jobs_url": format!("{url}/jobs"),
463 "logs_url": format!("{url}/logs"),
464 "artifacts_url": format!("{url}/artifacts"),
465 "workflow_url": run.workflow_id.map(|w| format!("{API}/repos/{full_name}/actions/workflows/{w}")),
466 "pull_requests": data["pull"].as_u64().map(|n| vec![json!({
467 "number": n,
468 "url": format!("{API}/repos/{full_name}/pulls/{n}"),
469 })]).unwrap_or_default(),
470 "repository": repository(repo),
471 "head_repository": repository(repo),
472 })
473}
474
475/// A release, from what a `release.*` event carries
476/// (`g1t_contracts::about::Release`). Its `id` is g1t's, as
477/// `/repos/{owner}/{repo}/releases/{id}` takes it.
478pub fn release(repo: &Repo, release: &Value) -> Value {
479 let full_name = format!("{}/{}", repo.namespace, repo.name);
480 let tag = release["tagName"].as_str().unwrap_or_default();
481 let id = release["id"].as_str().unwrap_or_default();
482 json!({
483 "id": id,
484 "node_id": id,
485 "tag_name": tag,
486 "target_commitish": release["target"],
487 "name": release["name"].as_str().unwrap_or(tag),
488 "body": release["body"],
489 "draft": release["draft"].as_bool().unwrap_or(false),
490 "prerelease": release["prerelease"].as_bool().unwrap_or(false),
491 "created_at": release["createdAt"],
492 "published_at": release["publishedAt"],
493 "author": user(release["author"].as_str().unwrap_or(&repo.namespace)),
494 "assets": [],
495 "html_url": format!("{SITE}/{full_name}/releases/tag/{tag}"),
496 "url": format!("{API}/repos/{full_name}/releases/{id}"),
497 "zipball_url": format!("{SITE}/{full_name}/archive/{tag}.zip"),
498 })
499}
500
501/// A deployment, from what `deployment.created` and
502/// `deployment_status.created` carry (`RepoDeployment`, already in GitHub's
503/// spelling).
504pub fn deployment(repo: &Repo, deployment: &Value) -> Value {
505 let full_name = format!("{}/{}", repo.namespace, repo.name);
506 let id = deployment["id"].as_str().unwrap_or_default();
507 json!({
508 "id": id,
509 "node_id": id,
510 "sha": deployment["sha"],
511 "ref": deployment["ref"],
512 "task": deployment["task"].as_str().unwrap_or("deploy"),
513 "payload": deployment.get("payload").filter(|p| p.is_object()).cloned().unwrap_or_else(|| json!({})),
514 "environment": deployment["environment"],
515 "original_environment": deployment["environment"],
516 "description": deployment["description"],
517 "creator": user(deployment["creator"].as_str().unwrap_or("g1t")),
518 "transient_environment": deployment["transient_environment"].as_bool().unwrap_or(false),
519 "production_environment": deployment["production_environment"].as_bool().unwrap_or(false),
520 "created_at": deployment["created_at"],
521 "updated_at": deployment["updated_at"],
522 "url": format!("{API}/repos/{full_name}/deployments/{id}"),
523 "statuses_url": format!("{API}/repos/{full_name}/deployments/{id}/statuses"),
524 })
525}
526
527/// A deployment's status (`DeploymentStatus`), for `deployment_status`.
528pub fn deployment_status(repo: &Repo, status: &Value, deployment: &Value) -> Value {
529 let full_name = format!("{}/{}", repo.namespace, repo.name);
530 let deployment_id = deployment["id"].as_str().unwrap_or_default();
531 let id = status["id"].as_str().unwrap_or_default();
532 json!({
533 "id": id,
534 "node_id": id,
535 "state": status["state"],
536 "description": status["description"].as_str().unwrap_or_default(),
537 "environment": deployment["environment"],
538 "environment_url": status["environment_url"].as_str().unwrap_or_default(),
539 "log_url": status["log_url"].as_str().unwrap_or_default(),
540 "target_url": status["log_url"].as_str().unwrap_or_default(),
541 "creator": user(status["creator"].as_str().unwrap_or("g1t")),
542 "created_at": status["created_at"],
543 "updated_at": status["created_at"],
544 "deployment_url": format!("{API}/repos/{full_name}/deployments/{deployment_id}"),
545 "url": format!("{API}/repos/{full_name}/deployments/{deployment_id}/statuses/{id}"),
546 })
547}
548
549
550/// A review's `state` as GitHub's `pull_request_review` event spells it.
551pub(crate) fn review_state(verdict: g1t_contracts::work::Verdict) -> &'static str {
552 match verdict {
553 g1t_contracts::work::Verdict::Approve => "approved",
554 g1t_contracts::work::Verdict::RequestChanges => "changes_requested",
555 }
556}
557
558#[cfg(test)]
559mod review_tests {
560 use super::*;
561
562 #[test]
563 fn review_states_are_spelled_as_githubs() {
564 assert_eq!(review_state(g1t_contracts::work::Verdict::Approve), "approved");
565 assert_eq!(review_state(g1t_contracts::work::Verdict::RequestChanges), "changes_requested");
566 }
567}
568
569/// g1t's payloads against GitHub's: each fixture in `tests/fixtures` is a
570/// trimmed webhook payload in GitHub's documented shape, and every key it
571/// has must be in g1t's payload for the same event with the same JSON type
572/// (a whole number where GitHub's is one). `null` in a fixture takes any
573/// type. Where g1t differs on purpose, `KNOWN` says how and why.
574#[cfg(test)]
575mod github_shapes {
576 use super::*;
577 use g1t_contracts::work::Verdict;
578
579 /// Paths where g1t's payload differs from GitHub's on purpose, and
580 /// what g1t has there: a JSON type, or `absent`.
581 const KNOWN: &[(&str, &str, &str)] = &[
582 // The API addresses these by g1t's own id, so the payload gives
583 // that id, to be passed straight back.
584 ("issue_comment", "comment.id", "string"),
585 ("release", "release.id", "string"),
586 ("workflow_run", "workflow_run.id", "string"),
587 ("workflow_run", "workflow_run.workflow_id", "string"),
588 ("workflow_run", "workflow.id", "string"),
589 // g1t keeps labels by name on issues and pull requests; only a
590 // `labeled` event's own `label` has its color.
591 ("pull_request", "pull_request.labels[].color", "absent"),
592 ("issues", "issue.labels[].color", "absent"),
593 // A push names who pushed, not their address.
594 ("push", "pusher.email", "null"),
595 ];
596
597 fn kind(value: &Value) -> &'static str {
598 match value {
599 Value::Null => "null",
600 Value::Bool(_) => "boolean",
601 Value::Number(_) => "number",
602 Value::String(_) => "string",
603 Value::Array(_) => "array",
604 Value::Object(_) => "object",
605 }
606 }
607
608 fn compare(event: &str, github: &Value, ours: Option<&Value>, path: &str, problems: &mut Vec<String>) {
609 if let Some((_, _, known)) = KNOWN.iter().find(|(e, p, _)| *e == event && *p == path) {
610 let had = ours.map_or("absent", kind);
611 if had != *known {
612 problems.push(format!("{path}: g1t is known to have {known} here, but has {had}"));
613 }
614 return;
615 }
616 let Some(ours) = ours else {
617 problems.push(format!("{path}: missing"));
618 return;
619 };
620 if github.is_null() {
621 return;
622 }
623 if kind(github) != kind(ours) {
624 problems.push(format!("{path}: GitHub's is {}, g1t's {}", kind(github), kind(ours)));
625 return;
626 }
627 match github {
628 Value::Number(n) if n.is_u64() => {
629 if !ours.as_u64().is_some_and(|n| n < 1 << 53) {
630 problems.push(format!("{path}: GitHub's is a whole number, g1t's {ours}"));
631 }
632 }
633 Value::Object(map) => {
634 for (key, inner) in map {
635 let at = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
636 compare(event, inner, ours.get(key), &at, problems);
637 }
638 }
639 Value::Array(items) => {
640 if let (Some(first), Some(our_first)) = (items.first(), ours.as_array().and_then(|o| o.first())) {
641 compare(event, first, Some(our_first), &format!("{path}[]"), problems);
642 }
643 }
644 _ => {}
645 }
646 }
647
648 fn repo() -> Repo {
649 serde_json::from_value(json!({
650 "id": "rep_01kpw0aaaaaaaaaaaaaaaaaaaa", "namespace": "acme", "name": "web", "description": null, "isPrivate": false,
651 "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-05-15T15:19:25Z"
652 }))
653 .unwrap()
654 }
655
656 fn someone(id: &str, username: &str) -> User {
657 User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
658 }
659
660 fn a_pull() -> Pull {
661 serde_json::from_value(json!({
662 "id": "pr_01kq000000000000000000000a", "repoId": "rep_1", "number": 2, "issue": null,
663 "title": "Update the README with new information.", "body": "A simple change.",
664 "agent": "", "runtime": "external", "status": "open",
665 "fork": null, "forkRepoId": null, "branch": "changes",
666 "headCommit": "ec26c3e57ca3a959ca5aad62de7213c562f8c821", "mergeBase": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e",
667 "mergedBy": null, "mergedAt": null, "supersededBy": null, "checkStatus": null,
668 "files": [{ "path": "README.md", "additions": 1, "deletions": 1 }],
669 "assignees": ["bo"], "reviewers": ["cy"], "labels": ["bug"], "milestone": { "number": 1, "title": "v1.0" },
670 "author": someone("usr_1", "ada"), "requestedBy": null,
671 "createdAt": "2026-05-15T15:20:33Z", "updatedAt": "2026-05-15T15:20:33Z"
672 }))
673 .unwrap()
674 }
675
676 fn an_issue() -> Issue {
677 serde_json::from_value(json!({
678 "id": "iss_01kq000000000000000000000a", "repoId": "rep_1", "number": 1,
679 "title": "Spelling error in the README file", "body": "Two t's.", "labels": ["bug"], "state": "open",
680 "reason": null, "resolvedBy": null, "author": someone("usr_1", "ada"), "requestedBy": null,
681 "createdAt": "2026-05-15T15:20:18Z", "updatedAt": "2026-05-15T15:20:18Z", "closedAt": null,
682 "pullCount": 0, "commentCount": 0, "assignees": ["bo"], "milestone": { "number": 1, "title": "v1.0" }
683 }))
684 .unwrap()
685 }
686
687 fn a_comment(verdict: Option<Verdict>) -> Comment {
688 serde_json::from_value(json!({
689 "id": "cmt_01kq000000000000000000000a", "author": someone("usr_3", "cy"), "body": "Looks good.",
690 "path": null, "line": null, "verdict": verdict, "createdAt": "2026-05-15T15:20:38Z"
691 }))
692 .unwrap()
693 }
694
695 fn a_commit() -> Commit {
696 serde_json::from_value(json!({
697 "hash": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", "treeHash": "f9d2a07e9488b91af2641b26b9407fe22a451433",
698 "message": "Update README.md", "author": { "name": "Ada", "email": "ada@example.com" },
699 "parents": ["6113728f27ae82c7b1a177c8d03f9e96e0adf246"], "authoredAt": "2026-05-15T15:20:30Z"
700 }))
701 .unwrap()
702 }
703
704 /// g1t's payload for each event, put together as trigger.rs does.
705 fn payloads() -> Vec<(&'static str, Value)> {
706 let repo = repo();
707 let pull = a_pull();
708 let push = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), &a_commit().hash, &[a_commit()], "ada", false);
709 let pull_request = json!({
710 "action": "opened", "number": 2, "pull_request": self::pull(&repo, &pull),
711 "repository": repository(&repo), "sender": user("ada"),
712 });
713 let review = json!({
714 "action": "submitted", "review": self::review(&repo, &pull, Some(&a_comment(Some(Verdict::Approve)))),
715 "pull_request": self::pull(&repo, &pull), "repository": repository(&repo), "sender": user("cy"),
716 });
717 let mut issues = json!({ "action": "labeled", "issue": issue(&repo, &an_issue()), "repository": repository(&repo), "sender": user("ada") });
718 changed(&repo, &mut issues, &json!({ "label": { "name": "bug", "color": "d73a4a" } }));
719 let mut on_pull = pull.clone();
720 on_pull.labels.clear();
721 on_pull.assignees.clear();
722 on_pull.milestone = None;
723 let mut comment = a_comment(None);
724 comment.author = someone("usr_1", "ada");
725 let issue_comment = json!({
726 "action": "created", "issue": pull_as_issue(&repo, &on_pull), "comment": self::comment(&repo, 2, &comment, true),
727 "repository": repository(&repo), "sender": user("ada"),
728 });
729 let data = json!({
730 "runId": "run_01kq000000000000000000000a", "workflow": "Build", "path": ".g1t/workflows/build.yml", "number": 562,
731 "event": "push", "conclusion": "success", "ref": "refs/heads/main", "sha": "acb5820ced9479c074f688cc328bf03f341a511d", "pull": 2,
732 });
733 let head = Commit { hash: "acb5820ced9479c074f688cc328bf03f341a511d".into(), ..a_commit() };
734 let followed = FollowedRun {
735 data: &data,
736 workflow_id: Some("wfl_1"),
737 attempt: Some(1),
738 created_at: Some("2026-05-15T15:20:35Z"),
739 started_at: Some("2026-05-15T15:20:35Z"),
740 updated_at: Some("2026-05-15T15:21:10Z"),
741 actor: Some("ada"),
742 triggering_actor: None,
743 title: Some("Update README.md"),
744 head_commit: Some(&head),
745 };
746 // As trigger.rs gives it.
747 let workflow_run = json!({
748 "action": "completed",
749 "workflow_run": self::workflow_run(&repo, &followed),
750 "workflow": { "id": "wfl_1", "node_id": "wfl_1", "name": "Build", "path": data["path"], "state": "active", "html_url": "https://g1t.sh/acme/web/actions" },
751 "repository": repository(&repo),
752 "sender": user("ada"),
753 });
754 let release = json!({
755 "action": "published",
756 "release": self::release(&repo, &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "main", "name": "v1.2.0", "body": "Notes",
757 "draft": false, "prerelease": false, "author": "ada", "createdAt": "2026-05-15T15:19:53Z", "publishedAt": "2026-05-15T15:20:05Z" })),
758 "repository": repository(&repo),
759 "sender": user("ada"),
760 });
761 vec![
762 ("push", push),
763 ("pull_request", pull_request),
764 ("pull_request_review", review),
765 ("issues", issues),
766 ("issue_comment", issue_comment),
767 ("workflow_run", workflow_run),
768 ("release", release),
769 ]
770 }
771
772 fn fixture(event: &str) -> Value {
773 let text = match event {
774 "push" => include_str!("../tests/fixtures/push.json"),
775 "pull_request" => include_str!("../tests/fixtures/pull_request.json"),
776 "pull_request_review" => include_str!("../tests/fixtures/pull_request_review.json"),
777 "issues" => include_str!("../tests/fixtures/issues.json"),
778 "issue_comment" => include_str!("../tests/fixtures/issue_comment.json"),
779 "workflow_run" => include_str!("../tests/fixtures/workflow_run.json"),
780 "release" => include_str!("../tests/fixtures/release.json"),
781 other => panic!("no fixture for {other}"),
782 };
783 serde_json::from_str(text).unwrap()
784 }
785
786 #[test]
787 fn payloads_have_githubs_keys_and_types() {
788 let mut problems = Vec::new();
789 for (event, ours) in payloads() {
790 let mut found = Vec::new();
791 compare(event, &fixture(event), Some(&ours), "", &mut found);
792 problems.extend(found.into_iter().map(|problem| format!("{event}: {problem}")));
793 }
794 assert!(problems.is_empty(), "g1t's payloads differ from GitHub's:\n{}", problems.join("\n"));
795 }
796
797 #[test]
798 fn the_comparison_finds_what_differs() {
799 let mut found = Vec::new();
800 let github = json!({ "id": 1, "user": { "login": "a" }, "labels": [{ "name": "x" }], "body": null });
801 compare("x", &github, Some(&json!({ "id": "pr_1", "labels": [{}], "body": 3 })), "", &mut found);
802 assert_eq!(found, ["id: GitHub's is number, g1t's string", "user: missing", "labels[].name: missing"]);
803 found.clear();
804 compare("x", &json!({ "id": 1 }), Some(&json!({ "id": 1.5 })), "", &mut found);
805 assert_eq!(found, ["id: GitHub's is a whole number, g1t's 1.5"]);
806 }
807
808 #[test]
809 fn the_same_object_has_the_same_number_everywhere() {
810 let all = payloads();
811 let by = |event: &str| all.iter().find(|(e, _)| *e == event).map(|(_, v)| v.clone()).unwrap();
812 let (pr, issues, run) = (by("pull_request"), by("issues"), by("workflow_run"));
813 let repo_id = &pr["repository"]["id"];
814 assert_eq!(repo_id, &json!(github_id(&repo().id)), "github.repository_id is the same number");
815 assert_eq!(&pr["pull_request"]["base"]["repo"]["id"], repo_id);
816 assert_eq!(&run["workflow_run"]["repository"]["id"], repo_id);
817 // ada as sender, author and run actor; acme as owner and head user.
818 assert_eq!(pr["sender"]["id"], pr["pull_request"]["user"]["id"]);
819 assert_eq!(pr["sender"]["id"], run["workflow_run"]["actor"]["id"]);
820 assert_eq!(pr["sender"]["id"], json!(account_id("ada")), "github.actor_id is the same number");
821 assert_eq!(pr["repository"]["owner"]["id"], pr["pull_request"]["head"]["user"]["id"]);
822 assert_eq!(pr["repository"]["owner"]["id"], json!(account_id("acme")));
823 assert_eq!(pr["pull_request"]["labels"][0]["id"], issues["label"]["id"]);
824 // A pull request's own id and its issue's are different objects.
825 assert_ne!(pull_as_issue(&repo(), &a_pull())["id"], pr["pull_request"]["id"]);
826 assert_eq!(pr["pull_request"]["node_id"], a_pull().id);
827 }
828
829 #[test]
830 fn a_personal_workspaces_repositories_are_a_users() {
831 let mut event = json!({ "pull_request": pull(&repo(), &a_pull()), "repository": repository(&repo()), "sender": user("ada") });
832 assert_eq!(event["repository"]["owner"]["type"], "Organization");
833 owned_by_person(&mut event, "Acme");
834 assert_eq!(event["repository"]["owner"]["type"], "User");
835 assert_eq!(event["pull_request"]["base"]["repo"]["owner"]["type"], "User");
836 assert_eq!(event["pull_request"]["head"]["user"]["type"], "User");
837 // Nobody else changes, a bot least of all.
838 assert_eq!(event["sender"]["type"], "User");
839 let mut bot = json!({ "user": { "login": "acme", "type": "Bot" } });
840 owned_by_person(&mut bot, "acme");
841 assert_eq!(bot["user"]["type"], "Bot");
842 }
843
844 #[test]
845 fn a_push_says_whether_it_made_deleted_or_forced_its_ref() {
846 let repo = repo();
847 let after = "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c";
848 let made = push(&repo, "refs/heads/new", None, after, &[a_commit()], "ada", false);
849 assert_eq!((made["created"].clone(), made["deleted"].clone(), made["forced"].clone()), (json!(true), json!(false), json!(false)));
850 assert_eq!(made["before"], ZERO);
851 let forced = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), after, &[a_commit()], "ada", true);
852 assert_eq!(forced["forced"], true);
853 assert_eq!(forced["compare"], format!("https://g1t.sh/acme/web/compare/6113728f27ae82c7b1a177c8d03f9e96e0adf246...{after}"));
854 assert_eq!(forced["head_commit"]["id"], after);
855 let gone = push(&repo, "refs/heads/old", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), ZERO, &[], "ada", true);
856 assert_eq!((gone["deleted"].clone(), gone["forced"].clone()), (json!(true), json!(false)));
857 }
858
859 #[test]
860 fn a_pull_request_from_a_fork_names_the_fork() {
861 let mut pull = a_pull();
862 pull.fork = Some(RepoPath { namespace: "ana".into(), name: "web".into() });
863 pull.fork_repo_id = Some("rep_f".into());
864 pull.branch = None;
865 let event = self::pull(&repo(), &pull);
866 assert_eq!(event["head"]["repo"]["full_name"], "ana/web");
867 assert_eq!(event["head"]["repo"]["fork"], true);
868 assert_eq!(event["head"]["repo"]["id"], json!(github_id("rep_f")));
869 assert_eq!(event["head"]["label"], "ana:pull/2");
870 assert_eq!(event["head"]["user"]["login"], "ana");
871 assert_eq!(event["merge_commit_sha"], Value::Null, "only once merged");
872 }
873}