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.

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