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