Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,236 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.pull_request.labels.*.name` and the like read as they do
4//! there. g1t's `work_item` and `work_item_comment` events give
5//! `github.event.work_item`, shaped as GitHub's `issue` is, with the
6//! workspace and the repositories it is linked to.
7//!
8//! Ids are as GitHub's are: `id` a whole number, the same for an object
9//! wherever it appears ([`github_id`] of its g1t id, [`account_id`] of a
10//! person's or workspace's name), and `node_id` g1t's own id. What g1t's
11//! API addresses by its own id keeps that id as `id`, so a payload's id
12//! can be passed straight back to the API: a comment (`PATCH
13//! /workspaces/{workspace}/work/comments/{comment_id}`), a release, a
14//! deployment and its statuses, and a workflow run.
15
16use g1t_actions::events::{account_id, github_id};
17use g1t_contracts::{PrincipalKind, User};
18use g1t_contracts::repos::{Commit, Repo, RepoPath};
19use g1t_contracts::repos::FileStatus;
20use g1t_contracts::work::{Comment, DEFAULT_LABELS, Label, MilestoneRef, Pull, PullStatus, State, WorkItem, label_color_for};
21use serde_json::{Value, json};
22
23use crate::{API, SITE};
24
25/// The commit hash GitHub gives for no commit: a branch made or deleted.
26pub const ZERO: &str = "0000000000000000000000000000000000000000";
27
28/// A repository's owner, as a workspace (an `Organization`). A personal
29/// workspace's repositories are owned by a `User` instead: see
30/// [`owned_by_person`].
31fn owner(login: &str) -> Value {
32 json!({
33 "login": login,
34 "id": account_id(login),
35 "type": "Organization",
36 "html_url": format!("{SITE}/{login}"),
37 "url": format!("{API}/users/{login}"),
38 })
39}
40
41pub fn repository(repo: &Repo) -> Value {
42 let full_name = format!("{}/{}", repo.namespace, repo.name);
43 let mut repository = json!({
44 "id": github_id(&repo.id),
45 "node_id": repo.id,
46 "name": repo.name,
47 "full_name": full_name,
48 "private": repo.is_private,
49 "owner": owner(&repo.namespace),
50 "html_url": format!("{SITE}/{full_name}"),
51 "url": format!("{API}/repos/{full_name}"),
52 "clone_url": format!("{SITE}/{full_name}.git"),
53 // GitHub's field: a repository has no description of its own (its
54 // project does), so it is always null.
55 "description": Value::Null,
56 "homepage": repo.website,
57 "topics": repo.topics,
58 "default_branch": repo.default_branch,
59 // A repository someone forked into their workspace; a pull
60 // request's own working copy is never a run's repository.
61 "fork": repo.parent_id.is_some(),
62 "archived": repo.archived(),
63 "created_at": repo.created_at,
64 "visibility": if repo.is_private { "private" } else { "public" },
65 });
66 // Where a fork came from, when its reader may see it.
67 if let Some(parent) = &repo.parent {
68 let full_name = format!("{}/{}", parent.namespace, parent.name);
69 repository["parent"] = json!({
70 "id": repo.parent_id.as_deref().map(github_id),
71 "node_id": repo.parent_id,
72 "name": parent.name,
73 "full_name": full_name,
74 "owner": owner(&parent.namespace),
75 "html_url": format!("{SITE}/{full_name}"),
76 "url": format!("{API}/repos/{full_name}"),
77 "clone_url": format!("{SITE}/{full_name}.git"),
78 });
79 }
80 repository
81}
82
83/// A pull request's fork, from what the pull request knows of it.
84fn fork_repository(fork: &RepoPath, id: Option<&str>) -> Value {
85 let full_name = format!("{}/{}", fork.namespace, fork.name);
86 json!({
87 "id": id.map(github_id),
88 "node_id": id,
89 "name": fork.name,
90 "full_name": full_name,
91 "owner": owner(&fork.namespace),
92 "html_url": format!("{SITE}/{full_name}"),
93 "url": format!("{API}/repos/{full_name}"),
94 "clone_url": format!("{SITE}/{full_name}.git"),
95 "fork": true,
96 })
97}
98
99/// Marks the owner of `namespace`'s repositories as a `User` everywhere
100/// in a payload: for a personal workspace, whose name is its person's.
101pub fn owned_by_person(payload: &mut Value, namespace: &str) {
102 match payload {
103 Value::Object(map) => {
104 if map.get("login").and_then(Value::as_str).is_some_and(|login| login.eq_ignore_ascii_case(namespace))
105 && map.get("type").and_then(Value::as_str) == Some("Organization")
106 {
107 map.insert("type".into(), json!("User"));
108 }
109 map.values_mut().for_each(|value| owned_by_person(value, namespace));
110 }
111 Value::Array(items) => items.iter_mut().for_each(|value| owned_by_person(value, namespace)),
112 _ => {}
113 }
114}
115
116pub fn user(login: &str) -> Value {
117 json!({
118 "login": login,
119 "id": account_id(login),
120 "type": "User",
121 "html_url": format!("{SITE}/{login}"),
122 "url": format!("{API}/users/{login}"),
123 })
124}
125
126/// Someone as GitHub names them. g1t, the author of what it makes and
127/// files, is a `Bot`, as an app is there.
128fn person(user: &User) -> Value {
129 let mut named = self::user(&user.username);
130 named["node_id"] = json!(user.id);
131 if matches!(user.kind, PrincipalKind::Agent | PrincipalKind::System) {
132 named["type"] = json!("Bot");
133 }
134 named
135}
136
137fn people(logins: &[String]) -> Vec<Value> {
138 logins.iter().map(|login| user(login)).collect()
139}
140
141fn labels(repo: &Repo, names: &[String]) -> Value {
142 Value::Array(names.iter().map(|name| label(repo, name, None)).collect())
143}
144
145/// A label, by its name in `repo`'s workspace, which keeps the labels of
146/// all its repositories. Its `color` is the one g1t gives a label of that
147/// name until [`color_labels`] sets the workspace's own; `default` is
148/// whether it is one of the labels a workspace starts with.
149fn label(repo: &Repo, name: &str, color: Option<&Value>) -> Value {
150 json!({
151 "id": github_id(&format!("{}:label:{name}", repo.owner_id)),
152 "node_id": format!("{}:label:{name}", repo.owner_id),
153 "name": name,
154 "color": color.filter(|c| c.is_string()).cloned().unwrap_or_else(|| json!(label_color_for(name))),
155 "description": DEFAULT_LABELS.iter().find(|(known, _, _)| *known == name).map(|(_, _, about)| *about),
156 "default": DEFAULT_LABELS.iter().any(|(known, _, _)| *known == name),
157 })
158}
159
160/// Gives every label in a payload (`labels[]` and `label`) the color and
161/// description the workspace has for it (work's `list_labels`).
162pub fn color_labels(payload: &mut Value, known: &[Label]) {
163 fn set(label: &mut Value, known: &[Label]) {
164 let Some(found) = label["name"].as_str().and_then(|name| known.iter().find(|k| k.name == name)) else { return };
165 label["color"] = json!(found.color);
166 label["description"] = if found.description.is_empty() { Value::Null } else { json!(found.description) };
167 }
168 if let Value::Object(map) = payload {
169 for (key, value) in map.iter_mut() {
170 match (key.as_str(), value) {
171 ("labels", Value::Array(labels)) => labels.iter_mut().for_each(|label| set(label, known)),
172 ("label", label @ Value::Object(_)) => set(label, known),
173 (_, value) => color_labels(value, known),
174 }
175 }
176 }
177}
178
179/// Whether a payload has any label, so its colors are worth reading.
180pub fn has_labels(payload: &Value) -> bool {
181 match payload {
182 Value::Object(map) => map.iter().any(|(key, value)| match (key.as_str(), value) {
183 ("labels", Value::Array(labels)) => !labels.is_empty(),
184 ("label", Value::Object(_)) => true,
185 (_, value) => has_labels(value),
186 }),
187 _ => false,
188 }
189}
190
191/// A milestone as a work item or a pull request names it: one of the
192/// workspace's.
193fn milestone(repo: &Repo, milestone: Option<&MilestoneRef>) -> Value {
194 milestone.map_or(Value::Null, |milestone| milestone_of(repo, milestone.number, &json!(milestone.title)))
195}
196
197fn milestone_of(repo: &Repo, number: u32, title: &Value) -> Value {
198 json!({
199 "id": github_id(&format!("{}:milestone:{number}", repo.owner_id)),
200 "number": number,
201 "title": title,
202 "html_url": format!("{SITE}/{}/-/work/milestones/{number}", repo.namespace),
203 })
204}
205
206pub fn commit(repo: &Repo, commit: &Commit) -> Value {
207 json!({
208 "id": commit.hash,
209 "tree_id": commit.tree_hash,
210 "message": commit.message,
211 "timestamp": commit.authored_at,
212 "url": format!("{SITE}/{}/{}/commit/{}", repo.namespace, repo.name, commit.hash),
213 "author": { "name": commit.author.name, "email": commit.author.email },
214 "committer": { "name": commit.author.name, "email": commit.author.email },
215 "distinct": true,
216 "added": [],
217 "removed": [],
218 "modified": [],
219 })
220}
221
222/// Sets a one-commit push's `added`, `removed` and `modified` (on its
223/// commit and `head_commit`) from what the push changed, compared as its
224/// `paths` filters are. A push of several commits keeps them empty: the
225/// comparison is of the whole push, not of each commit.
226pub fn push_changes(payload: &mut Value, changes: &[(String, FileStatus)]) {
227 if payload["commits"].as_array().map(Vec::len) != Some(1) {
228 return;
229 }
230 let of = |status: FileStatus| -> Value {
231 changes.iter().filter(|(_, s)| *s == status).map(|(path, _)| json!(path)).collect::<Vec<_>>().into()
232 };
233 for commit in ["commits", "head_commit"] {
234 let commit = if commit == "commits" { &mut payload["commits"][0] } else { &mut payload["head_commit"] };
235 commit["added"] = of(FileStatus::Added);
236 commit["removed"] = of(FileStatus::Deleted);
237 commit["modified"] = of(FileStatus::Modified);
238 }
239}
240
241/// A push. `forced` is whether `before` is not in `after`'s history, when
242/// that is known; a push whose history is too long to read says `false`.
243/// `pusher_email` is the pusher's noreply address, when the push was a
244/// person's.
245#[allow(clippy::too_many_arguments)]
246pub fn push(
247 repo: &Repo,
248 git_ref: &str,
249 before: Option<&str>,
250 after: &str,
251 commits: &[Commit],
252 pusher: &str,
253 pusher_email: Option<&str>,
254 forced: bool,
255) -> Value {
256 let before = before.unwrap_or(ZERO);
257 let created = before == ZERO;
258 let deleted = after == ZERO;
259 let commits: Vec<Value> = commits.iter().map(|c| commit(repo, c)).collect();
260 let full_name = format!("{}/{}", repo.namespace, repo.name);
261 let compare = if created {
262 format!("{SITE}/{full_name}/commit/{after}")
263 } else {
264 format!("{SITE}/{full_name}/compare/{before}...{after}")
265 };
266 json!({
267 "ref": git_ref,
268 "before": before,
269 "after": after,
270 "created": created,
271 "deleted": deleted,
272 "forced": forced && !created && !deleted,
273 "base_ref": null,
274 "compare": compare,
275 // GitHub's head commit is the one pushed: the last.
276 "head_commit": commits.last().cloned().unwrap_or(Value::Null),
277 "commits": commits,
278 "pusher": { "name": pusher, "email": pusher_email },
279 "repository": repository(repo),
280 "sender": user(pusher),
281 })
282}
283
284/// Where a work item is on g1t.sh, and in the API.
285fn work_item_urls(workspace: &str, number: u32) -> (String, String) {
286 (format!("{SITE}/{workspace}/-/work/{number}"), format!("{API}/workspaces/{workspace}/work/{number}"))
287}
288
289/// A work item, as `github.event.work_item`: GitHub's `issue`, with g1t's
290/// own fields beside it: its `workspace`, the `repos` it is linked to (each
291/// with its role and whether the work landed there), what resolved it,
292/// and what it is blocked by. `repo` is the run's repository, which is one
293/// of them.
294pub fn work_item(repo: &Repo, item: &WorkItem) -> Value {
295 let (html_url, url) = work_item_urls(&item.workspace, item.number);
296 let repos: Vec<Value> = item
297 .repos
298 .iter()
299 .map(|linked| {
300 json!({
301 "id": github_id(&linked.repo_id),
302 "node_id": linked.repo_id,
303 "full_name": linked.path,
304 "role": linked.role.as_str(),
305 "legacy_number": linked.legacy_number,
306 "landed": linked.landed,
307 })
308 })
309 .collect();
310 let resolutions: Vec<Value> = item.resolutions.iter().map(resolution).collect();
311 json!({
312 "id": github_id(&item.id),
313 "node_id": item.id,
314 "number": item.number,
315 "title": item.title,
316 "body": item.body,
317 "state": if item.state == State::Open { "open" } else { "closed" },
318 "state_reason": item.reason,
319 "locked": false,
320 "labels": labels(repo, &item.labels),
321 "milestone": milestone(repo, item.milestone.as_ref()),
322 "user": person(&item.author),
323 // g1t's own field: for one its agent filed, who it worked for.
324 "requested_by": item.requested_by.as_ref().map(person),
325 "assignee": item.assignees.first().map(|a| user(a)),
326 "assignees": people(&item.assignees),
327 "comments": item.comment_count,
328 "created_at": item.created_at,
329 "updated_at": item.updated_at,
330 "closed_at": item.closed_at,
331 "html_url": html_url,
332 "url": url,
333 "workspace": item.workspace,
334 "workspace_url": format!("{API}/workspaces/{}", item.workspace),
335 "repos": repos,
336 "resolutions": resolutions,
337 "blocked_by": item.blocked_by,
338 "pull_count": item.pull_count,
339 })
340}
341
342/// What resolved a work item in one repository, as the payload names it.
343fn resolution(resolution: &g1t_contracts::work::Resolution) -> Value {
344 json!({
345 "repository": resolution.repo,
346 "pull": resolution.pull,
347 "commit": resolution.commit,
348 "pushed_by": resolution.pushed_by.as_deref().map(user),
349 })
350}
351
352/// What a `work_item` event says besides the item: on `linked` and
353/// `unlinked`, the repository and its role (`link`); on `landed` and on a
354/// close a landing caused, what landed (`resolution`) and the targets it
355/// has still to land in (`missing`).
356pub fn work_item_changed(payload: &mut Value, data: &Value) {
357 if let Some(path) = data["repo"].as_str() {
358 payload["link"] = json!({ "repository": path, "role": data["role"] });
359 }
360 if let Ok(landed) = serde_json::from_value::<g1t_contracts::work::Resolution>(data["resolution"].clone()) {
361 payload["resolution"] = resolution(&landed);
362 }
363 if let Some(missing) = data.get("missing").filter(|missing| missing.is_array()) {
364 payload["missing"] = missing.clone();
365 }
366}
367
368/// A pull request, with its own labels. `base.sha` is the pull request's
369/// merge base until the trigger sets the base branch's tip, as GitHub
370/// gives it (trigger.rs).
371pub fn pull(repo: &Repo, pull: &Pull) -> Value {
372 let full_name = format!("{}/{}", repo.namespace, repo.name);
373 let head_ref = head_ref(pull);
374 let base_ref = pull.base_branch(&repo.default_branch);
375 // A working copy, or the fork its branch is in, or the repository.
376 let elsewhere = match (&pull.fork, &pull.head_repo) {
377 (Some(fork), _) => Some((fork, pull.fork_repo_id.as_deref())),
378 (None, Some(fork)) => Some((fork, pull.head_repo_id.as_deref())),
379 (None, None) => None,
380 };
381 let (head_repo, head_owner) = match elsewhere {
382 Some((fork, id)) => (fork_repository(fork, id), fork.namespace.as_str()),
383 None => (repository(repo), repo.namespace.as_str()),
384 };
385 let merged = pull.status == PullStatus::Merged;
386 let closed_at = match pull.status {
387 PullStatus::Merged => pull.merged_at.clone(),
388 PullStatus::Closed => Some(pull.updated_at.clone()),
389 _ => None,
390 };
391 json!({
392 "id": github_id(&pull.id),
393 "node_id": pull.id,
394 "number": pull.number,
395 "title": pull.title,
396 "body": pull.body,
397 "state": if pull.status.is_active() { "open" } else { "closed" },
398 "locked": false,
399 "draft": pull.status == PullStatus::Draft,
400 "merged": merged,
401 "merged_at": pull.merged_at,
402 "merged_by": pull.merged_by.as_deref().map(user),
403 // Only once merged, as on GitHub; the trigger sets the commit it
404 // landed as when the event says.
405 "merge_commit_sha": if merged { pull.head_commit.clone() } else { None },
406 "labels": labels(repo, &pull.labels),
407 "milestone": milestone(repo, pull.milestone.as_ref()),
408 "user": person(&pull.author),
409 // g1t's own field: for a change g1t made, who asked for it.
410 "requested_by": pull.requested_by.as_ref().map(person),
411 "assignee": pull.assignees.first().map(|a| user(a)),
412 "assignees": people(&pull.assignees),
413 "requested_reviewers": people(&pull.reviewers),
414 "head": {
415 "ref": head_ref,
416 "sha": pull.head_commit,
417 "label": format!("{head_owner}:{head_ref}"),
418 "user": owner(head_owner),
419 "repo": head_repo,
420 },
421 "base": {
422 "ref": base_ref,
423 "sha": pull.merge_base,
424 "label": format!("{}:{base_ref}", repo.namespace),
425 "user": owner(&repo.namespace),
426 "repo": repository(repo),
427 },
428 "changed_files": pull.files.len(),
429 "additions": pull.files.iter().map(|f| u64::from(f.additions)).sum::<u64>(),
430 "deletions": pull.files.iter().map(|f| u64::from(f.deletions)).sum::<u64>(),
431 "created_at": pull.created_at,
432 "updated_at": pull.updated_at,
433 "closed_at": closed_at,
434 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
435 "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
436 // g1t's own: the work item it is for, by its number in the
437 // repository's workspace, and those it closes and is part of.
438 "item": pull.item,
439 "item_url": pull.item.map(|number| work_item_urls(&repo.namespace, number).1),
440 "closes": pull.closes,
441 "parts": pull.parts,
442 })
443}
444
445/// What a `labeled`, `unlabeled`, `milestoned`, `demilestoned`,
446/// `assigned` or base change was about, from the g1t event: `label`,
447/// `milestone`, `assignee` (the first of those newly assigned; g1t sends
448/// one event for them all, GitHub one each), and for a new base,
449/// `changes.base.ref.from` is not known, so only the new base is in
450/// `pull_request.base`.
451pub fn changed(repo: &Repo, payload: &mut Value, data: &Value) {
452 if let Some(first) = data["added"].as_array().and_then(|added| added.first()).and_then(Value::as_str) {
453 payload["assignee"] = user(first);
454 }
455 if let Some(found) = data.get("label").filter(|label| label.is_object()) {
456 payload["label"] = label(repo, found["name"].as_str().unwrap_or_default(), Some(&found["color"]));
457 }
458 if let Some(found) = data.get("milestone").filter(|milestone| milestone.is_object()) {
459 payload["milestone"] = match found["number"].as_u64() {
460 Some(number) => milestone_of(repo, number as u32, &found["title"]),
461 None => json!({ "number": found["number"], "title": found["title"] }),
462 };
463 }
464}
465
466/// The branch a pull request comes from, or a name for its fork.
467pub fn head_ref(pull: &Pull) -> String {
468 pull.branch.clone().unwrap_or_else(|| format!("pull/{}", pull.number))
469}
470
471/// What a comment is on: a work item of a workspace, or a pull request of
472/// the run's repository.
473#[derive(Clone, Copy, Debug, PartialEq, Eq)]
474pub enum Thread<'a> {
475 Item { workspace: &'a str },
476 Pull,
477}
478
479impl Thread<'_> {
480 /// Where the thread numbered `number` is on g1t.sh, and in the API.
481 fn urls(self, repo: &Repo, number: u32) -> (String, String) {
482 match self {
483 Thread::Item { workspace } => work_item_urls(workspace, number),
484 Thread::Pull => {
485 let full_name = format!("{}/{}", repo.namespace, repo.name);
486 (format!("{SITE}/{full_name}/pull/{number}"), format!("{API}/repos/{full_name}/pulls/{number}"))
487 }
488 }
489 }
490
491 /// Where the API edits and deletes a comment on it, by its id.
492 fn comment_url(self, repo: &Repo, id: &str) -> String {
493 let workspace = match self {
494 Thread::Item { workspace } => workspace,
495 Thread::Pull => &repo.namespace,
496 };
497 format!("{API}/workspaces/{workspace}/work/comments/{id}")
498 }
499}
500
501/// A comment. Its `id` is g1t's (`cmt_…`), as the API takes it back to
502/// edit or delete it.
503pub fn comment(repo: &Repo, number: u32, comment: &Comment, on: Thread) -> Value {
504 let (html_url, item_url) = on.urls(repo, number);
505 json!({
506 "id": comment.id,
507 "node_id": comment.id,
508 "body": comment.body,
509 "user": person(&comment.author),
510 "created_at": comment.created_at,
511 "updated_at": comment.edited_at.as_deref().unwrap_or(&comment.created_at),
512 "path": comment.path,
513 "line": comment.line,
514 "html_url": format!("{html_url}#{}", comment.id),
515 "url": on.comment_url(repo, &comment.id),
516 "item_url": item_url,
517 })
518}
519
520/// A review, for `pull_request_review`: g1t keeps a review as a comment
521/// with a verdict, and its id is that comment's.
522pub fn review(repo: &Repo, pull: &Pull, review: Option<&Comment>) -> Value {
523 let full_name = format!("{}/{}", repo.namespace, repo.name);
524 let Some(review) = review else {
525 return json!({ "state": null, "body": null, "user": null });
526 };
527 json!({
528 "id": github_id(&review.id),
529 "node_id": review.id,
530 "state": review.verdict.map(review_state),
531 "body": review.body,
532 "user": person(&review.author),
533 "commit_id": pull.head_commit,
534 "submitted_at": review.created_at,
535 "html_url": format!("{SITE}/{full_name}/pull/{}#{}", pull.number, review.id),
536 "pull_request_url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
537 })
538}
539
540/// A pull request as `work_item_comment` gives it in `work_item`, as
541/// GitHub's `issue_comment` gives one in `issue`: the pull request's
542/// number and title, with `pull_request` set. Its id is not the pull
543/// request's, as on GitHub, where the two are different objects.
544pub fn pull_as_item(repo: &Repo, pull: &Pull) -> Value {
545 let full_name = format!("{}/{}", repo.namespace, repo.name);
546 json!({
547 "id": github_id(&format!("item:{}", pull.id)),
548 "node_id": pull.id,
549 "number": pull.number,
550 "title": pull.title,
551 "body": pull.body,
552 "state": if pull.status.is_active() { "open" } else { "closed" },
553 "locked": false,
554 "draft": pull.status == PullStatus::Draft,
555 "labels": labels(repo, &pull.labels),
556 "milestone": milestone(repo, pull.milestone.as_ref()),
557 "user": person(&pull.author),
558 "requested_by": pull.requested_by.as_ref().map(person),
559 "assignee": pull.assignees.first().map(|a| user(a)),
560 "assignees": people(&pull.assignees),
561 "created_at": pull.created_at,
562 "updated_at": pull.updated_at,
563 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
564 "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
565 "repository_url": format!("{API}/repos/{full_name}"),
566 "workspace": repo.namespace,
567 "pull_request": {
568 "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
569 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
570 "merged_at": pull.merged_at,
571 },
572 })
573}
574
575/// A comment as the event that deleted it kept it (`comment.deleted`'s
576/// `comment`), since it can no longer be read.
577pub fn deleted_comment(repo: &Repo, number: u32, kept: &Value, on: Thread) -> Value {
578 let (html_url, item_url) = on.urls(repo, number);
579 let id = kept["id"].as_str().unwrap_or_default();
580 let login = kept["author"]["username"].as_str().unwrap_or_default();
581 json!({
582 "id": id,
583 "node_id": id,
584 "body": kept["body"],
585 "user": user(login),
586 "created_at": kept["createdAt"],
587 "updated_at": kept["editedAt"].as_str().map_or(kept["createdAt"].clone(), |at| json!(at)),
588 "path": kept["path"],
589 "line": kept["line"],
590 "html_url": format!("{html_url}#{id}"),
591 "url": on.comment_url(repo, id),
592 "item_url": item_url,
593 })
594}
595
596/// What `workflow_run` is told of the run it follows: the
597/// `workflow.completed` event's data, and the run's row when it is still
598/// there. Its `id` and `workflow_id` are g1t's (`run_…`, `wfl_…`), which
599/// `GITHUB_RUN_ID` and the API use too, so `actions/download-artifact`
600/// with `run-id: ${{ github.event.workflow_run.id }}` finds its artifacts.
601pub struct FollowedRun<'a> {
602 pub data: &'a Value,
603 pub workflow_id: Option<&'a str>,
604 pub attempt: Option<u64>,
605 pub created_at: Option<&'a str>,
606 pub started_at: Option<&'a str>,
607 pub updated_at: Option<&'a str>,
608 pub actor: Option<&'a str>,
609 pub triggering_actor: Option<&'a str>,
610 pub title: Option<&'a str>,
611 pub head_commit: Option<&'a Commit>,
612 /// The pull request the run was for (`data.pull`), when it can be
613 /// read, and its base branch's latest commit.
614 pub pull: Option<&'a Pull>,
615 pub base_sha: Option<&'a str>,
616}
617
618/// The run's pull request as `workflow_run.pull_requests[]` gives it. One
619/// from a fork is left out, as on GitHub, where only pull requests whose
620/// head is in the repository are listed.
621fn run_pulls(repo: &Repo, run: &FollowedRun) -> Vec<Value> {
622 let full_name = format!("{}/{}", repo.namespace, repo.name);
623 let Some(number) = run.data["pull"].as_u64() else { return Vec::new() };
624 let url = format!("{API}/repos/{full_name}/pulls/{number}");
625 let Some(pull) = run.pull else {
626 return vec![json!({ "number": number, "url": url })];
627 };
628 if pull.fork.is_some() || pull.head_repo.is_some() {
629 return Vec::new();
630 }
631 let short = json!({ "id": github_id(&repo.id), "url": format!("{API}/repos/{full_name}"), "name": repo.name });
632 vec![json!({
633 "id": github_id(&pull.id),
634 "number": number,
635 "url": url,
636 "head": { "ref": head_ref(pull), "sha": pull.head_commit, "repo": short },
637 "base": {
638 "ref": pull.base_branch(&repo.default_branch),
639 "sha": run.base_sha.map(str::to_owned).or_else(|| pull.merge_base.clone()),
640 "repo": short,
641 },
642 })]
643}
644
645pub fn workflow_run(repo: &Repo, run: &FollowedRun) -> Value {
646 let full_name = format!("{}/{}", repo.namespace, repo.name);
647 let data = run.data;
648 let id = data["runId"].as_str().unwrap_or_default();
649 let name = data["workflow"].as_str().unwrap_or_default();
650 let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned();
651 let url = format!("{API}/repos/{full_name}/actions/runs/{id}");
652 let actor = run.actor.map(user);
653 json!({
654 "id": id,
655 "node_id": id,
656 "name": name,
657 "display_title": run.title.unwrap_or(name),
658 "path": data["path"],
659 "event": data["event"],
660 "status": "completed",
661 "conclusion": data["conclusion"],
662 "workflow_id": run.workflow_id,
663 "head_sha": data["sha"],
664 "head_branch": head_branch,
665 "head_commit": run.head_commit.map(|c| json!({
666 "id": c.hash,
667 "tree_id": c.tree_hash,
668 "message": c.message,
669 "timestamp": c.authored_at,
670 "author": { "name": c.author.name, "email": c.author.email },
671 "committer": { "name": c.author.name, "email": c.author.email },
672 })),
673 "run_number": data["number"],
674 "run_attempt": run.attempt.unwrap_or(1),
675 "created_at": run.created_at,
676 "run_started_at": run.started_at.or(run.created_at),
677 "updated_at": run.updated_at.or(run.created_at),
678 "actor": actor,
679 "triggering_actor": run.triggering_actor.map(user).or_else(|| run.actor.map(user)),
680 "html_url": format!("{SITE}/{full_name}/actions/runs/{id}"),
681 "url": url,
682 "jobs_url": format!("{url}/jobs"),
683 "logs_url": format!("{url}/logs"),
684 "artifacts_url": format!("{url}/artifacts"),
685 "workflow_url": run.workflow_id.map(|w| format!("{API}/repos/{full_name}/actions/workflows/{w}")),
686 "pull_requests": run_pulls(repo, run),
687 "repository": repository(repo),
688 "head_repository": repository(repo),
689 })
690}
691
692/// A release, from what a `release.*` event carries
693/// (`g1t_contracts::about::Release`). Its `id` is g1t's, as
694/// `/repos/{owner}/{repo}/releases/{id}` takes it.
695pub fn release(repo: &Repo, release: &Value) -> Value {
696 let full_name = format!("{}/{}", repo.namespace, repo.name);
697 let tag = release["tagName"].as_str().unwrap_or_default();
698 let id = release["id"].as_str().unwrap_or_default();
699 json!({
700 "id": id,
701 "node_id": id,
702 "tag_name": tag,
703 "target_commitish": release["target"],
704 "name": release["name"].as_str().unwrap_or(tag),
705 "body": release["body"],
706 "draft": release["draft"].as_bool().unwrap_or(false),
707 "prerelease": release["prerelease"].as_bool().unwrap_or(false),
708 "created_at": release["createdAt"],
709 "published_at": release["publishedAt"],
710 "author": user(release["author"].as_str().unwrap_or(&repo.namespace)),
711 "assets": [],
712 "html_url": format!("{SITE}/{full_name}/releases/tag/{tag}"),
713 "url": format!("{API}/repos/{full_name}/releases/{id}"),
714 "zipball_url": format!("{SITE}/{full_name}/archive/{tag}.zip"),
715 })
716}
717
718/// A deployment, from what `deployment.created` and
719/// `deployment_status.created` carry (`RepoDeployment`, already in GitHub's
720/// spelling).
721pub fn deployment(repo: &Repo, deployment: &Value) -> Value {
722 let full_name = format!("{}/{}", repo.namespace, repo.name);
723 let id = deployment["id"].as_str().unwrap_or_default();
724 json!({
725 "id": id,
726 "node_id": id,
727 "sha": deployment["sha"],
728 "ref": deployment["ref"],
729 "task": deployment["task"].as_str().unwrap_or("deploy"),
730 "payload": deployment.get("payload").filter(|p| p.is_object()).cloned().unwrap_or_else(|| json!({})),
731 "environment": deployment["environment"],
732 "original_environment": deployment["environment"],
733 "description": deployment["description"],
734 "creator": user(deployment["creator"].as_str().unwrap_or("g1t")),
735 "transient_environment": deployment["transient_environment"].as_bool().unwrap_or(false),
736 "production_environment": deployment["production_environment"].as_bool().unwrap_or(false),
737 "created_at": deployment["created_at"],
738 "updated_at": deployment["updated_at"],
739 "url": format!("{API}/repos/{full_name}/deployments/{id}"),
740 "statuses_url": format!("{API}/repos/{full_name}/deployments/{id}/statuses"),
741 })
742}
743
744/// A deployment's status (`DeploymentStatus`), for `deployment_status`.
745pub fn deployment_status(repo: &Repo, status: &Value, deployment: &Value) -> Value {
746 let full_name = format!("{}/{}", repo.namespace, repo.name);
747 let deployment_id = deployment["id"].as_str().unwrap_or_default();
748 let id = status["id"].as_str().unwrap_or_default();
749 json!({
750 "id": id,
751 "node_id": id,
752 "state": status["state"],
753 "description": status["description"].as_str().unwrap_or_default(),
754 "environment": deployment["environment"],
755 "environment_url": status["environment_url"].as_str().unwrap_or_default(),
756 "log_url": status["log_url"].as_str().unwrap_or_default(),
757 "target_url": status["log_url"].as_str().unwrap_or_default(),
758 "creator": user(status["creator"].as_str().unwrap_or("g1t")),
759 "created_at": status["created_at"],
760 "updated_at": status["created_at"],
761 "deployment_url": format!("{API}/repos/{full_name}/deployments/{deployment_id}"),
762 "url": format!("{API}/repos/{full_name}/deployments/{deployment_id}/statuses/{id}"),
763 })
764}
765
766
767/// A review's `state` as GitHub's `pull_request_review` event spells it.
768pub(crate) fn review_state(verdict: g1t_contracts::work::Verdict) -> &'static str {
769 match verdict {
770 g1t_contracts::work::Verdict::Approve => "approved",
771 g1t_contracts::work::Verdict::RequestChanges => "changes_requested",
772 }
773}
774
775#[cfg(test)]
776mod review_tests {
777 use super::*;
778
779 #[test]
780 fn review_states_are_spelled_as_githubs() {
781 assert_eq!(review_state(g1t_contracts::work::Verdict::Approve), "approved");
782 assert_eq!(review_state(g1t_contracts::work::Verdict::RequestChanges), "changes_requested");
783 }
784}
785
786/// g1t's payloads against GitHub's: each fixture in `tests/fixtures` is a
787/// trimmed webhook payload in GitHub's documented shape, and every key it
788/// has must be in g1t's payload for the same event with the same JSON type
789/// (a whole number where GitHub's is one). `null` in a fixture takes any
790/// type. Where g1t differs on purpose, `KNOWN` says how and why.
791#[cfg(test)]
792mod github_shapes {
793 use super::*;
794 use g1t_contracts::work::Verdict;
795
796 /// Paths where g1t's payload differs from GitHub's on purpose, and
797 /// what g1t has there: a JSON type, or `absent`.
798 const KNOWN: &[(&str, &str, &str)] = &[
799 // The API addresses these by g1t's own id, so the payload gives
800 // that id, to be passed straight back.
801 ("work_item_comment", "comment.id", "string"),
802 // A work item belongs to a workspace, not to one repository: it
803 // has `workspace_url` and `repos`, and its comments `item_url`.
804 // A pull request has `item_url` for its work item.
805 ("work_item", "work_item.repository_url", "absent"),
806 ("work_item_comment", "comment.issue_url", "absent"),
807 ("pull_request", "pull_request.issue_url", "absent"),
808 ("release", "release.id", "string"),
809 ("workflow_run", "workflow_run.id", "string"),
810 ("workflow_run", "workflow_run.workflow_id", "string"),
811 ("workflow_run", "workflow.id", "string"),
812 ];
813
814 fn kind(value: &Value) -> &'static str {
815 match value {
816 Value::Null => "null",
817 Value::Bool(_) => "boolean",
818 Value::Number(_) => "number",
819 Value::String(_) => "string",
820 Value::Array(_) => "array",
821 Value::Object(_) => "object",
822 }
823 }
824
825 fn compare(event: &str, github: &Value, ours: Option<&Value>, path: &str, problems: &mut Vec<String>) {
826 if let Some((_, _, known)) = KNOWN.iter().find(|(e, p, _)| *e == event && *p == path) {
827 let had = ours.map_or("absent", kind);
828 if had != *known {
829 problems.push(format!("{path}: g1t is known to have {known} here, but has {had}"));
830 }
831 return;
832 }
833 let Some(ours) = ours else {
834 problems.push(format!("{path}: missing"));
835 return;
836 };
837 if github.is_null() {
838 return;
839 }
840 if kind(github) != kind(ours) {
841 problems.push(format!("{path}: GitHub's is {}, g1t's {}", kind(github), kind(ours)));
842 return;
843 }
844 match github {
845 Value::Number(n) if n.is_u64() => {
846 if !ours.as_u64().is_some_and(|n| n < 1 << 53) {
847 problems.push(format!("{path}: GitHub's is a whole number, g1t's {ours}"));
848 }
849 }
850 Value::Object(map) => {
851 for (key, inner) in map {
852 let at = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
853 compare(event, inner, ours.get(key), &at, problems);
854 }
855 }
856 Value::Array(items) => {
857 if let (Some(first), Some(our_first)) = (items.first(), ours.as_array().and_then(|o| o.first())) {
858 compare(event, first, Some(our_first), &format!("{path}[]"), problems);
859 }
860 }
861 _ => {}
862 }
863 }
864
865 fn repo() -> Repo {
866 serde_json::from_value(json!({
867 "id": "rep_01kpw0aaaaaaaaaaaaaaaaaaaa", "namespace": "acme", "name": "web", "isPrivate": false,
868 "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-05-15T15:19:25Z"
869 }))
870 .unwrap()
871 }
872
873 fn someone(id: &str, username: &str) -> User {
874 User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
875 }
876
877 fn a_pull() -> Pull {
878 serde_json::from_value(json!({
879 "id": "pr_01kq000000000000000000000a", "repoId": "rep_1", "number": 2, "item": null,
880 "title": "Update the README with new information.", "body": "A simple change.",
881 "agent": "", "runtime": "external", "status": "open",
882 "fork": null, "forkRepoId": null, "branch": "changes",
883 "headCommit": "ec26c3e57ca3a959ca5aad62de7213c562f8c821", "mergeBase": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e",
884 "mergedBy": null, "mergedAt": null, "supersededBy": null, "checkStatus": null,
885 "files": [{ "path": "README.md", "additions": 1, "deletions": 1 }],
886 "assignees": ["bo"], "reviewers": ["cy"], "labels": ["bug"], "milestone": { "number": 1, "title": "v1.0" },
887 "author": someone("usr_1", "ada"), "requestedBy": null,
888 "createdAt": "2026-05-15T15:20:33Z", "updatedAt": "2026-05-15T15:20:33Z"
889 }))
890 .unwrap()
891 }
892
893 fn an_item() -> WorkItem {
894 serde_json::from_value(json!({
895 "id": "wki_01kq000000000000000000000a", "workspace": "acme", "number": 1,
896 "title": "Spelling error in the README file", "body": "Two t's.", "labels": ["bug"], "state": "open",
897 "reason": null, "author": someone("usr_1", "ada"), "requestedBy": null,
898 "createdAt": "2026-05-15T15:20:18Z", "updatedAt": "2026-05-15T15:20:18Z", "closedAt": null,
899 "pullCount": 0, "commentCount": 0, "assignees": ["bo"], "milestone": { "number": 1, "title": "v1.0" },
900 "repos": [
901 { "repoId": "rep_01kpw0aaaaaaaaaaaaaaaaaaaa", "path": "acme/web", "role": "origin", "legacyNumber": 7 },
902 { "repoId": "rep_2", "path": "acme/api", "role": "target", "landed": true }
903 ],
904 "resolutions": [{ "repo": "acme/api", "pull": 3, "pushedBy": "bo" }]
905 }))
906 .unwrap()
907 }
908
909 fn a_comment(verdict: Option<Verdict>) -> Comment {
910 serde_json::from_value(json!({
911 "id": "cmt_01kq000000000000000000000a", "author": someone("usr_3", "cy"), "body": "Looks good.",
912 "path": null, "line": null, "verdict": verdict, "createdAt": "2026-05-15T15:20:38Z"
913 }))
914 .unwrap()
915 }
916
917 fn a_commit() -> Commit {
918 serde_json::from_value(json!({
919 "hash": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", "treeHash": "f9d2a07e9488b91af2641b26b9407fe22a451433",
920 "message": "Update README.md", "author": { "name": "Ada", "email": "ada@example.com" },
921 "parents": ["6113728f27ae82c7b1a177c8d03f9e96e0adf246"], "authoredAt": "2026-05-15T15:20:30Z"
922 }))
923 .unwrap()
924 }
925
926 /// g1t's payload for each event, put together as trigger.rs does.
927 fn payloads() -> Vec<(&'static str, Value)> {
928 let repo = repo();
929 let pull = a_pull();
930 let mut push = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), &a_commit().hash, &[a_commit()], "ada", Some("0d0efg+ada@users.noreply.g1t.sh"), false);
931 push_changes(&mut push, &[("README.md".into(), FileStatus::Modified)]);
932 let known = [Label { name: "bug".into(), color: "d73a4a".into(), description: "Something isn't working".into(), items: 1, pulls: 1 }];
933 let mut pull_request = json!({
934 "action": "opened", "number": 2, "pull_request": self::pull(&repo, &pull),
935 "repository": repository(&repo), "sender": user("ada"),
936 });
937 color_labels(&mut pull_request, &known);
938 let review = json!({
939 "action": "submitted", "review": self::review(&repo, &pull, Some(&a_comment(Some(Verdict::Approve)))),
940 "pull_request": self::pull(&repo, &pull), "repository": repository(&repo), "sender": user("cy"),
941 });
942 let mut items = json!({ "action": "labeled", "work_item": work_item(&repo, &an_item()), "repository": repository(&repo), "sender": user("ada") });
943 changed(&repo, &mut items, &json!({ "label": { "name": "bug", "color": "d73a4a" } }));
944 color_labels(&mut items, &known);
945 let mut on_pull = pull.clone();
946 on_pull.labels.clear();
947 on_pull.assignees.clear();
948 on_pull.milestone = None;
949 let mut comment = a_comment(None);
950 comment.author = someone("usr_1", "ada");
951 let item_comment = json!({
952 "action": "created", "work_item": pull_as_item(&repo, &on_pull), "comment": self::comment(&repo, 2, &comment, Thread::Pull),
953 "repository": repository(&repo), "sender": user("ada"),
954 });
955 let data = json!({
956 "runId": "run_01kq000000000000000000000a", "workflow": "Build", "path": ".g1t/workflows/build.yml", "number": 562,
957 "event": "push", "conclusion": "success", "ref": "refs/heads/main", "sha": "acb5820ced9479c074f688cc328bf03f341a511d", "pull": 2,
958 });
959 let head = Commit { hash: "acb5820ced9479c074f688cc328bf03f341a511d".into(), ..a_commit() };
960 let followed = FollowedRun {
961 data: &data,
962 workflow_id: Some("wfl_1"),
963 attempt: Some(1),
964 created_at: Some("2026-05-15T15:20:35Z"),
965 started_at: Some("2026-05-15T15:20:35Z"),
966 updated_at: Some("2026-05-15T15:21:10Z"),
967 actor: Some("ada"),
968 triggering_actor: None,
969 title: Some("Update README.md"),
970 head_commit: Some(&head),
971 pull: Some(&pull),
972 base_sha: Some("f95f852bd8fca8fcc58a9a2d6c842781e32a215e"),
973 };
974 // As trigger.rs gives it.
975 let workflow_run = json!({
976 "action": "completed",
977 "workflow_run": self::workflow_run(&repo, &followed),
978 "workflow": { "id": "wfl_1", "node_id": "wfl_1", "name": "Build", "path": data["path"], "state": "active", "html_url": "https://g1t.sh/acme/web/actions" },
979 "repository": repository(&repo),
980 "sender": user("ada"),
981 });
982 let release = json!({
983 "action": "published",
984 "release": self::release(&repo, &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "main", "name": "v1.2.0", "body": "Notes",
985 "draft": false, "prerelease": false, "author": "ada", "createdAt": "2026-05-15T15:19:53Z", "publishedAt": "2026-05-15T15:20:05Z" })),
986 "repository": repository(&repo),
987 "sender": user("ada"),
988 });
989 vec![
990 ("push", push),
991 ("pull_request", pull_request),
992 ("pull_request_review", review),
993 ("work_item", items),
994 ("work_item_comment", item_comment),
995 ("workflow_run", workflow_run),
996 ("release", release),
997 ]
998 }
999
1000 /// GitHub's payload for an event, as g1t names it: `work_item` is
1001 /// shaped as GitHub's `issues`, with `work_item` in place of `issue`,
1002 /// and `work_item_comment` as its `issue_comment`.
1003 fn fixture(event: &str) -> Value {
1004 let text = match event {
1005 "push" => include_str!("../tests/fixtures/push.json"),
1006 "pull_request" => include_str!("../tests/fixtures/pull_request.json"),
1007 "pull_request_review" => include_str!("../tests/fixtures/pull_request_review.json"),
1008 "work_item" => include_str!("../tests/fixtures/issues.json"),
1009 "work_item_comment" => include_str!("../tests/fixtures/issue_comment.json"),
1010 "workflow_run" => include_str!("../tests/fixtures/workflow_run.json"),
1011 "release" => include_str!("../tests/fixtures/release.json"),
1012 other => panic!("no fixture for {other}"),
1013 };
1014 let mut payload: Value = serde_json::from_str(text).unwrap();
1015 if let Some(issue) = payload.as_object_mut().and_then(|map| map.remove("issue")) {
1016 payload["work_item"] = issue;
1017 }
1018 payload
1019 }
1020
1021 #[test]
1022 fn payloads_have_githubs_keys_and_types() {
1023 let mut problems = Vec::new();
1024 for (event, ours) in payloads() {
1025 let mut found = Vec::new();
1026 compare(event, &fixture(event), Some(&ours), "", &mut found);
1027 problems.extend(found.into_iter().map(|problem| format!("{event}: {problem}")));
1028 }
1029 assert!(problems.is_empty(), "g1t's payloads differ from GitHub's:\n{}", problems.join("\n"));
1030 }
1031
1032 #[test]
1033 fn the_comparison_finds_what_differs() {
1034 let mut found = Vec::new();
1035 let github = json!({ "id": 1, "user": { "login": "a" }, "labels": [{ "name": "x" }], "body": null });
1036 compare("x", &github, Some(&json!({ "id": "pr_1", "labels": [{}], "body": 3 })), "", &mut found);
1037 assert_eq!(found, ["id: GitHub's is number, g1t's string", "user: missing", "labels[].name: missing"]);
1038 found.clear();
1039 compare("x", &json!({ "id": 1 }), Some(&json!({ "id": 1.5 })), "", &mut found);
1040 assert_eq!(found, ["id: GitHub's is a whole number, g1t's 1.5"]);
1041 }
1042
1043 #[test]
1044 fn the_same_object_has_the_same_number_everywhere() {
1045 let all = payloads();
1046 let by = |event: &str| all.iter().find(|(e, _)| *e == event).map(|(_, v)| v.clone()).unwrap();
1047 let (pr, items, run) = (by("pull_request"), by("work_item"), by("workflow_run"));
1048 let repo_id = &pr["repository"]["id"];
1049 assert_eq!(repo_id, &json!(github_id(&repo().id)), "github.repository_id is the same number");
1050 assert_eq!(&pr["pull_request"]["base"]["repo"]["id"], repo_id);
1051 assert_eq!(&run["workflow_run"]["repository"]["id"], repo_id);
1052 // ada as sender, author and run actor; acme as owner and head user.
1053 assert_eq!(pr["sender"]["id"], pr["pull_request"]["user"]["id"]);
1054 assert_eq!(pr["sender"]["id"], run["workflow_run"]["actor"]["id"]);
1055 assert_eq!(pr["sender"]["id"], json!(account_id("ada")), "github.actor_id is the same number");
1056 assert_eq!(pr["repository"]["owner"]["id"], pr["pull_request"]["head"]["user"]["id"]);
1057 assert_eq!(pr["repository"]["owner"]["id"], json!(account_id("acme")));
1058 assert_eq!(pr["pull_request"]["labels"][0]["id"], items["label"]["id"]);
1059 // A pull request's own id and its id as a thread are different objects.
1060 assert_ne!(pull_as_item(&repo(), &a_pull())["id"], pr["pull_request"]["id"]);
1061 assert_eq!(pr["pull_request"]["node_id"], a_pull().id);
1062 }
1063
1064 #[test]
1065 fn a_personal_workspaces_repositories_are_a_users() {
1066 let mut event = json!({ "pull_request": pull(&repo(), &a_pull()), "repository": repository(&repo()), "sender": user("ada") });
1067 assert_eq!(event["repository"]["owner"]["type"], "Organization");
1068 owned_by_person(&mut event, "Acme");
1069 assert_eq!(event["repository"]["owner"]["type"], "User");
1070 assert_eq!(event["pull_request"]["base"]["repo"]["owner"]["type"], "User");
1071 assert_eq!(event["pull_request"]["head"]["user"]["type"], "User");
1072 // Nobody else changes, a bot least of all.
1073 assert_eq!(event["sender"]["type"], "User");
1074 let mut bot = json!({ "user": { "login": "acme", "type": "Bot" } });
1075 owned_by_person(&mut bot, "acme");
1076 assert_eq!(bot["user"]["type"], "Bot");
1077 }
1078
1079 #[test]
1080 fn a_push_says_whether_it_made_deleted_or_forced_its_ref() {
1081 let repo = repo();
1082 let after = "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c";
1083 let made = push(&repo, "refs/heads/new", None, after, &[a_commit()], "ada", None, false);
1084 assert_eq!((made["created"].clone(), made["deleted"].clone(), made["forced"].clone()), (json!(true), json!(false), json!(false)));
1085 assert_eq!(made["before"], ZERO);
1086 let forced = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), after, &[a_commit()], "ada", None, true);
1087 assert_eq!(forced["forced"], true);
1088 assert_eq!(forced["compare"], format!("https://g1t.sh/acme/web/compare/6113728f27ae82c7b1a177c8d03f9e96e0adf246...{after}"));
1089 assert_eq!(forced["head_commit"]["id"], after);
1090 let gone = push(&repo, "refs/heads/old", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), ZERO, &[], "ada", None, true);
1091 assert_eq!((gone["deleted"].clone(), gone["forced"].clone()), (json!(true), json!(false)));
1092 }
1093
1094 #[test]
1095 fn a_one_commit_push_lists_what_it_changed() {
1096 let repo = repo();
1097 let changes = [
1098 ("new.txt".to_owned(), FileStatus::Added),
1099 ("README.md".to_owned(), FileStatus::Modified),
1100 ("old.txt".to_owned(), FileStatus::Deleted),
1101 ];
1102 let mut one = push(&repo, "refs/heads/main", None, &a_commit().hash, &[a_commit()], "ada", None, false);
1103 push_changes(&mut one, &changes);
1104 for commit in [&one["commits"][0], &one["head_commit"]] {
1105 assert_eq!(commit["added"], json!(["new.txt"]));
1106 assert_eq!(commit["modified"], json!(["README.md"]));
1107 assert_eq!(commit["removed"], json!(["old.txt"]));
1108 }
1109 // Several commits: the comparison is not any one commit's.
1110 let mut two = push(&repo, "refs/heads/main", None, &a_commit().hash, &[a_commit(), a_commit()], "ada", None, false);
1111 push_changes(&mut two, &changes);
1112 assert_eq!(two["head_commit"]["modified"], json!([]));
1113 }
1114
1115 #[test]
1116 fn a_work_item_names_its_workspace_and_repositories() {
1117 let repo = repo();
1118 let item = work_item(&repo, &an_item());
1119 assert_eq!(item["html_url"], "https://g1t.sh/acme/-/work/1");
1120 assert_eq!(item["url"], format!("{API}/workspaces/acme/work/1"));
1121 assert_eq!(item["workspace"], "acme");
1122 assert_eq!(item["number"], 1);
1123 assert_eq!(item["node_id"], "wki_01kq000000000000000000000a");
1124 assert_eq!(item["repos"][0]["full_name"], "acme/web");
1125 assert_eq!(item["repos"][0]["role"], "origin");
1126 assert_eq!(item["repos"][0]["legacy_number"], 7);
1127 assert_eq!(item["repos"][0]["id"], json!(github_id(&repo.id)), "the same number as the repository's");
1128 assert_eq!(item["repos"][1]["role"], "target");
1129 assert_eq!(item["repos"][1]["landed"], true);
1130 assert_eq!(item["resolutions"][0]["repository"], "acme/api");
1131 assert_eq!(item["resolutions"][0]["pull"], 3);
1132 assert_eq!(item["resolutions"][0]["pushed_by"]["login"], "bo");
1133 assert_eq!(item["milestone"]["html_url"], "https://g1t.sh/acme/-/work/milestones/1");
1134 // A label is the workspace's: the same object on a pull request.
1135 assert_eq!(item["labels"][0]["id"], pull(&repo, &a_pull())["labels"][0]["id"]);
1136 // What a link, a landing and a close say.
1137 let mut linked = json!({ "action": "linked" });
1138 work_item_changed(&mut linked, &json!({ "repo": "acme/api", "role": "target" }));
1139 assert_eq!(linked["link"], json!({ "repository": "acme/api", "role": "target" }));
1140 let mut landed = json!({ "action": "landed" });
1141 work_item_changed(&mut landed, &json!({ "resolution": { "repo": "acme/api", "commit": "abc" }, "missing": ["acme/web"] }));
1142 assert_eq!(landed["resolution"]["commit"], "abc");
1143 assert_eq!(landed["missing"], json!(["acme/web"]));
1144 assert!(landed.get("link").is_none());
1145 // A comment on it is addressed in its workspace.
1146 let on_item = comment(&repo, 1, &a_comment(None), Thread::Item { workspace: "acme" });
1147 assert_eq!(on_item["html_url"], "https://g1t.sh/acme/-/work/1#cmt_01kq000000000000000000000a");
1148 assert_eq!(on_item["item_url"], format!("{API}/workspaces/acme/work/1"));
1149 }
1150
1151 #[test]
1152 fn labels_have_the_workspaces_colors() {
1153 let repo = repo();
1154 let mut event = json!({ "work_item": work_item(&repo, &an_item()), "label": label(&repo, "bug", None) });
1155 // Before the repository's are known, g1t's default for the name.
1156 assert_eq!(event["work_item"]["labels"][0]["color"], "d73a4a");
1157 assert_eq!(event["work_item"]["labels"][0]["default"], true);
1158 assert!(has_labels(&event));
1159 let known = [Label { name: "bug".into(), color: "123456".into(), description: String::new(), items: 1, pulls: 0 }];
1160 color_labels(&mut event, &known);
1161 assert_eq!(event["work_item"]["labels"][0]["color"], "123456");
1162 assert_eq!(event["work_item"]["labels"][0]["description"], Value::Null);
1163 assert_eq!(event["label"]["color"], "123456");
1164 assert!(!has_labels(&json!({ "work_item": { "labels": [] } })));
1165 }
1166
1167 #[test]
1168 fn a_run_names_its_pull_request_unless_it_is_from_a_fork() {
1169 let repo = repo();
1170 let data = json!({ "runId": "run_1", "pull": 2 });
1171 let pull = a_pull();
1172 let run = |pull: Option<&Pull>| {
1173 workflow_run(&repo, &FollowedRun {
1174 data: &data, workflow_id: None, attempt: None, created_at: None, started_at: None, updated_at: None,
1175 actor: None, triggering_actor: None, title: None, head_commit: None, pull, base_sha: Some("b4se"),
1176 })
1177 };
1178 let named = run(Some(&pull));
1179 let first = &named["pull_requests"][0];
1180 assert_eq!(first["number"], 2);
1181 assert_eq!(first["id"], json!(github_id(&pull.id)));
1182 assert_eq!(first["head"]["ref"], "changes");
1183 assert_eq!(first["head"]["sha"], "ec26c3e57ca3a959ca5aad62de7213c562f8c821");
1184 assert_eq!(first["base"]["ref"], "main");
1185 assert_eq!(first["base"]["sha"], "b4se");
1186 assert_eq!(first["base"]["repo"]["id"], json!(github_id(&repo.id)));
1187 let mut forked = a_pull();
1188 forked.fork = Some(RepoPath { namespace: "ana".into(), name: "web".into() });
1189 assert_eq!(run(Some(&forked))["pull_requests"], json!([]));
1190 assert_eq!(run(None)["pull_requests"][0]["number"], 2, "unread, still numbered");
1191 }
1192
1193 #[test]
1194 fn a_fork_says_it_is_one_and_names_its_parent() {
1195 assert_eq!(repository(&repo())["fork"], false);
1196 assert!(repository(&repo()).get("parent").is_none());
1197 let mut fork = repo();
1198 fork.parent_id = Some("rep_p".into());
1199 assert_eq!(repository(&fork)["fork"], true);
1200 assert!(repository(&fork).get("parent").is_none(), "only where the parent is known");
1201 fork.parent = Some(RepoPath { namespace: "acme".into(), name: "web".into() });
1202 let event = repository(&fork);
1203 assert_eq!(event["parent"]["full_name"], "acme/web");
1204 assert_eq!(event["parent"]["id"], json!(github_id("rep_p")));
1205 assert_eq!(event["parent"]["owner"]["login"], "acme");
1206 }
1207
1208 #[test]
1209 fn a_pull_request_from_a_fork_names_the_fork() {
1210 let mut pull = a_pull();
1211 pull.fork = Some(RepoPath { namespace: "ana".into(), name: "web".into() });
1212 pull.fork_repo_id = Some("rep_f".into());
1213 pull.branch = None;
1214 let event = self::pull(&repo(), &pull);
1215 assert_eq!(event["head"]["repo"]["full_name"], "ana/web");
1216 assert_eq!(event["head"]["repo"]["fork"], true);
1217 assert_eq!(event["head"]["repo"]["id"], json!(github_id("rep_f")));
1218 assert_eq!(event["head"]["label"], "ana:pull/2");
1219 assert_eq!(event["head"]["user"]["login"], "ana");
1220 assert_eq!(event["merge_commit_sha"], Value::Null, "only once merged");
1221 }
1222
1223 #[test]
1224 fn a_pull_request_from_a_forks_branch_names_the_fork_and_branch() {
1225 let mut pull = a_pull();
1226 pull.head_repo = Some(RepoPath { namespace: "ana".into(), name: "web".into() });
1227 pull.head_repo_id = Some("rep_f".into());
1228 let event = self::pull(&repo(), &pull);
1229 assert_eq!(event["head"]["repo"]["full_name"], "ana/web");
1230 assert_eq!(event["head"]["repo"]["fork"], true);
1231 assert_eq!(event["head"]["repo"]["id"], json!(github_id("rep_f")));
1232 assert_eq!(event["head"]["ref"], "changes");
1233 assert_eq!(event["head"]["label"], "ana:changes");
1234 assert_eq!(event["base"]["repo"]["id"], json!(github_id(&repo().id)));
1235 }
1236}