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

Commit

A workflow's event payload (`github.event` and the file at `GITHUB_EVENT_PATH`) reads as GitHub's webhook payload does. Ids are whole numbers, the same for an object wherever it appears and safe in JavaScript: `repository.id`, `pull_request.id`, `issue.id`, `review.id`, `sender.id` and every user's and owner's `id`, with g1t's own id as `node_id`; `github.repository_id`, `github.repository_owner_id`, `github.actor_id` and their `GITHUB_*` variables are the same numbers. What g1t's API takes back by id (comments, releases, deployments, workflow runs) keeps g1t's id, so `github.event.comment.id` still edits the comment. A repository in a person's own workspace is owned by a `User`, not an `Organization`. A push says whether it was `forced`, `created` or `deleted`, and its `head_commit` is the commit pushed. A pull request's `base.sha` is the tip of the branch it merges into at the event, not the merge base, and `merge_commit_sha` is the commit it landed as once merged; pull requests and issues carry `node_id`, `assignee`, `locked`, dates and label and milestone ids, and a fork's head names the fork. `workflow_run` has the followed run's attempt, times, actor, head commit, workflow id and URLs. Tests compare each payload's keys and types with trimmed copies of GitHub's documented payloads, and the Actions guide says what differs. OIDC claims keep g1t's ids, so trust policies already written still match.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parentb198e74Browse files
12 files+1196−870/12 viewed
+41−1
989989 | --- | --- |
990990 | `sub` | `repo:acme/web:environment:production` for a job with an `environment:`; `repo:acme/web:pull_request` for a pull request's run; otherwise `repo:acme/web:ref:refs/heads/main` (or `refs/tags/v1.2.0`) |
991991 | `repository`, `repository_owner` | `acme/web`, `acme` |
992−| `repository_id`, `repository_owner_id` | g1t's ids for them, such as `rep_01kpw0…` |
992+| `repository_id`, `repository_owner_id` | g1t's ids for them, such as `rep_01kpw0…` (not the numbers `github.repository_id` and `github.repository_owner_id` give, so trust policies already written keep matching) |
993993 | `repository_visibility` | `public` or `private` |
994994 | `ref`, `ref_type`, `ref_protected`, `sha` | `refs/heads/main`, `branch`, `"true"`, the commit |
995995 | `head_ref`, `base_ref` | A pull request's branches |
14051405 on anyone else's. `github.event.issue.requested_by` does the same for an
14061406 issue g1t's agent filed. `sender` is whoever caused the event.
14071407
1408+## The event payload
1409+
1410+`github.event`, and the file at `GITHUB_EVENT_PATH`, hold the webhook
1411+payload GitHub would send for the event, with the same keys and types.
1412+
1413+- **Ids are numbers.** `repository.id`, `pull_request.id`, `issue.id`,
1414+ `review.id`, `sender.id`, every `user.id` and `owner.id` are whole
1415+ numbers, the same for an object wherever it appears and below 2^53, so
1416+ JavaScript reads them exactly. `node_id` is g1t's own id, such as
1417+ `rep_01kpw0…`. A person's or workspace's number comes from their name,
1418+ so it changes when they are renamed. `github.repository_id`,
1419+ `github.repository_owner_id`, `github.actor_id` and their
1420+ `GITHUB_REPOSITORY_ID`, `GITHUB_REPOSITORY_OWNER_ID` and
1421+ `GITHUB_ACTOR_ID` variables are the same numbers, as text.
1422+- **What the API takes by id keeps g1t's id.** `comment.id`,
1423+ `release.id`, `deployment.id`, `deployment_status.id`,
1424+ `workflow_run.id` and `workflow_run.workflow_id` are g1t's ids
1425+ (`cmt_…`, `rel_…`, `run_…`, `wfl_…`), so you can pass them straight
1426+ back, as in `PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}`
1427+ or `run-id: ${{ github.event.workflow_run.id }}`.
1428+- **`repository.owner.type`** is `User` for a repository in a person's
1429+ own workspace and `Organization` for any other workspace's.
1430+- **On `push`,** `created` is true for a new branch or tag, and `forced`
1431+ is true when the commit before the push is not in the new commit's
1432+ history. g1t reads the 20 latest commits to tell, so a forced push onto
1433+ a longer history says `false`. `head_commit` is the commit pushed and
1434+ `commits` lists at most 20, oldest first.
1435+- **On pull request events,** `pull_request.base.sha` is the tip of the
1436+ branch it merges into when the event happened, and
1437+ `pull_request.merge_commit_sha` is set only once it is merged, to the
1438+ commit it landed as.
1439+- **On `workflow_run`,** `workflow_run` has the followed run's `name`,
1440+ `path`, `event`, `status`, `conclusion`, `head_branch`, `head_sha`,
1441+ `head_commit`, `run_number`, `run_attempt`, `actor`, `created_at`,
1442+ `updated_at`, `html_url`, `repository` and `pull_requests` (each with
1443+ its `number` and `url`).
1444+
1445+Labels on `issue` and `pull_request` have their `id` and `name` but no
1446+`color`; a `labeled` event's own `label` has its color.
1447+
14081448 ## Checks
14091449
14101450 A pull request's checks are its workflows. Each workflow that runs on
+86−7
104104 self.repository.split('/').next().unwrap_or_default()
105105 }
106106
107− /// The owner's id, as the event's `repository.owner.id` gives it.
107+ /// The owner's id, as the event's `repository.owner.id` gives it, or
108+ /// else the number for the owner's name ([`account_id`]).
108109 fn owner_id(&self) -> String {
109− owner_id(&self.event).unwrap_or_default()
110+ owner_id(&self.event).unwrap_or_else(|| match self.owner() {
111+ "" => String::new(),
112+ owner => account_id(owner).to_string(),
113+ })
114+ }
115+
116+ /// `github.repository_id`: the repository's number ([`github_id`]).
117+ fn repository_number(&self) -> String {
118+ id_text(&self.repository_id)
119+ }
120+
121+ /// `github.actor_id`: the actor's number, as the event's `sender.id`
122+ /// gives it ([`account_id`]).
123+ fn actor_number(&self) -> String {
124+ match self.actor.as_str() {
125+ "" => id_text(&self.actor_id),
126+ actor => account_id(actor).to_string(),
127+ }
110128 }
111129
112130 /// The `github` context for a job. `token` is `github.token` (and
118136 "action_ref": "",
119137 "action_repository": "",
120138 "actor": self.actor,
121− "actor_id": self.actor_id,
139+ "actor_id": self.actor_number(),
122140 "api_url": self.api_url,
123141 "base_ref": self.base_ref.clone().unwrap_or_default(),
124142 "env": "",
134152 "ref_protected": self.ref_name() == self.default_branch,
135153 "ref_type": self.ref_type(),
136154 "repository": self.repository,
137− "repository_id": self.repository_id,
155+ "repository_id": self.repository_number(),
138156 "repository_owner": self.owner(),
139157 "repository_owner_id": self.owner_id(),
140158 "repositoryUrl": format!("{}/{}.git", self.server_url, self.repository),
164182 set("GITHUB_ACTIONS", "true".into());
165183 set("G1T", "true".into());
166184 set("GITHUB_ACTOR", self.actor.clone());
167− set("GITHUB_ACTOR_ID", self.actor_id.clone());
185+ set("GITHUB_ACTOR_ID", self.actor_number());
168186 set("GITHUB_API_URL", self.api_url.clone());
169187 set("GITHUB_BASE_REF", self.base_ref.clone().unwrap_or_default());
170188 set("GITHUB_EVENT_NAME", self.event_name.clone());
177195 set("GITHUB_REF_PROTECTED", (self.ref_name() == self.default_branch).to_string());
178196 set("GITHUB_REF_TYPE", self.ref_type().into());
179197 set("GITHUB_REPOSITORY", self.repository.clone());
180− set("GITHUB_REPOSITORY_ID", self.repository_id.clone());
198+ set("GITHUB_REPOSITORY_ID", self.repository_number());
181199 set("GITHUB_REPOSITORY_OWNER", self.owner().to_owned());
182200 set("GITHUB_REPOSITORY_OWNER_ID", self.owner_id());
183201 set("GITHUB_RETENTION_DAYS", "14".into());
202220 }
203221 }
204222
223+/// The whole number GitHub gives an object as its `id`, for a g1t id
224+/// (`rep_01kpw0…`, `pr_01…`): the same number every time, positive, and
225+/// below 2^53 so JavaScript reads it exactly. g1t's own id stays the
226+/// object's `node_id`.
227+pub fn github_id(id: &str) -> u64 {
228+ // FNV-1a, 64 bits, cut to 53.
229+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
230+ for byte in id.bytes() {
231+ hash ^= u64::from(byte);
232+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
233+ }
234+ (hash & ((1 << 53) - 1)).max(1)
235+}
236+
237+/// The number of a person or workspace, by their name: usernames and
238+/// workspaces share one namespace, so `sender.id`, `user.id`,
239+/// `repository.owner.id`, `github.actor_id` and
240+/// `github.repository_owner_id` agree wherever the same name appears, and
241+/// a personal workspace's repositories are owned by its person's number,
242+/// as on GitHub. Case does not matter.
243+pub fn account_id(login: &str) -> u64 {
244+ github_id(&format!("account:{}", login.to_ascii_lowercase()))
245+}
246+
247+/// An id as text of a number: one that is already a number as it is,
248+/// a g1t id as its [`github_id`], nothing as nothing.
249+fn id_text(id: &str) -> String {
250+ if id.is_empty() || id.bytes().all(|b| b.is_ascii_digit()) {
251+ id.to_owned()
252+ } else {
253+ github_id(id).to_string()
254+ }
255+}
256+
205257 /// The owner's id in a webhook-shaped payload, `repository.owner.id`, a
206258 /// number or a string.
207259 pub fn owner_id(event: &Value) -> Option<String> {
301353 assert_eq!(vars["GITHUB_JOB"], "build");
302354 assert_eq!(vars["RUNNER_OS"], "Linux");
303355 assert_eq!(vars["RUNNER_WORKSPACE"], "/home/runner/work");
304− assert_eq!(vars["GITHUB_REPOSITORY_OWNER_ID"], "");
356+ assert_eq!(vars["GITHUB_REPOSITORY_OWNER_ID"], account_id("acme").to_string());
357+ }
358+
359+ #[test]
360+ fn ids_are_whole_numbers_as_on_github() {
361+ let id = github_id("rep_01kpw0abcdefghjkmnpqrstvwx");
362+ assert_eq!(id, github_id("rep_01kpw0abcdefghjkmnpqrstvwx"), "the same every time");
363+ assert!(id > 0 && id < 1 << 53, "a safe integer in JavaScript");
364+ assert_ne!(id, github_id("rep_01kpw0abcdefghjkmnpqrstvwy"));
365+ assert_eq!(account_id("Ada"), account_id("ada"));
366+ assert_ne!(account_id("ada"), github_id("ada"));
367+ let info = RunInfo {
368+ repository: "acme/web".into(),
369+ repository_id: "rep_1".into(),
370+ actor: "ada".into(),
371+ actor_id: "usr_1".into(),
372+ ..RunInfo::default()
373+ };
374+ let github = info.context("build", "", None);
375+ assert_eq!(github["repository_id"], github_id("rep_1").to_string());
376+ assert_eq!(github["actor_id"], account_id("ada").to_string());
377+ assert_eq!(github["repository_owner_id"], account_id("acme").to_string());
378+ let vars = info.variables("build");
379+ assert_eq!(vars["GITHUB_REPOSITORY_ID"], github["repository_id"]);
380+ assert_eq!(vars["GITHUB_ACTOR_ID"], github["actor_id"]);
381+ assert!(vars["GITHUB_REPOSITORY_ID"].as_str().unwrap().bytes().all(|b| b.is_ascii_digit()));
382+ // A number already is one.
383+ assert_eq!(RunInfo { repository_id: "42".into(), ..RunInfo::default() }.context("", "", None)["repository_id"], "42");
305384 }
306385
307386 #[test]
+592−45
11 //! `github.event`: the webhook-shaped payload GitHub gives a workflow,
22 //! built from g1t's own records so `github.event.pull_request.number`,
33 //! `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.
412
13+use g1t_actions::events::{account_id, github_id};
514 use g1t_contracts::{PrincipalKind, User};
6−use g1t_contracts::repos::{Commit, Repo};
15+use g1t_contracts::repos::{Commit, Repo, RepoPath};
716 use g1t_contracts::work::{Comment, Issue, MilestoneRef, Pull, PullStatus, State};
817 use serde_json::{Value, json};
918
1019 use crate::{API, SITE};
1120
21+/// The commit hash GitHub gives for no commit: a branch made or deleted.
22+pub const ZERO: &str = "0000000000000000000000000000000000000000";
23+
24+/// A repository's owner, as a workspace (an `Organization`). A personal
25+/// workspace's repositories are owned by a `User` instead: see
26+/// [`owned_by_person`].
27+fn owner(login: &str) -> Value {
28+ json!({
29+ "login": login,
30+ "id": account_id(login),
31+ "type": "Organization",
32+ "html_url": format!("{SITE}/{login}"),
33+ "url": format!("{API}/users/{login}"),
34+ })
35+}
36+
1237 pub fn repository(repo: &Repo) -> Value {
1338 let full_name = format!("{}/{}", repo.namespace, repo.name);
1439 json!({
15− "id": repo.id,
40+ "id": github_id(&repo.id),
1641 "node_id": repo.id,
1742 "name": repo.name,
1843 "full_name": full_name,
1944 "private": repo.is_private,
20− "owner": { "login": repo.namespace, "type": "Organization" },
45+ "owner": owner(&repo.namespace),
2146 "html_url": format!("{SITE}/{full_name}"),
2247 "url": format!("{API}/repos/{full_name}"),
2348 "clone_url": format!("{SITE}/{full_name}.git"),
2449 "description": repo.description,
50+ "homepage": repo.website,
51+ "topics": repo.topics,
2552 "default_branch": repo.default_branch,
2653 "fork": false,
54+ "archived": repo.archived(),
55+ "created_at": repo.created_at,
2756 "visibility": if repo.is_private { "private" } else { "public" },
2857 })
2958 }
3059
60+/// A pull request's fork, from what the pull request knows of it.
61+fn fork_repository(fork: &RepoPath, id: Option<&str>) -> Value {
62+ let full_name = format!("{}/{}", fork.namespace, fork.name);
63+ json!({
64+ "id": id.map(github_id),
65+ "node_id": id,
66+ "name": fork.name,
67+ "full_name": full_name,
68+ "owner": owner(&fork.namespace),
69+ "html_url": format!("{SITE}/{full_name}"),
70+ "url": format!("{API}/repos/{full_name}"),
71+ "clone_url": format!("{SITE}/{full_name}.git"),
72+ "fork": true,
73+ })
74+}
75+
76+/// Marks the owner of `namespace`'s repositories as a `User` everywhere
77+/// in a payload: for a personal workspace, whose name is its person's.
78+pub fn owned_by_person(payload: &mut Value, namespace: &str) {
79+ match payload {
80+ Value::Object(map) => {
81+ if map.get("login").and_then(Value::as_str).is_some_and(|login| login.eq_ignore_ascii_case(namespace))
82+ && map.get("type").and_then(Value::as_str) == Some("Organization")
83+ {
84+ map.insert("type".into(), json!("User"));
85+ }
86+ map.values_mut().for_each(|value| owned_by_person(value, namespace));
87+ }
88+ Value::Array(items) => items.iter_mut().for_each(|value| owned_by_person(value, namespace)),
89+ _ => {}
90+ }
91+}
92+
3193 pub fn user(login: &str) -> Value {
32− json!({ "login": login, "type": "User", "html_url": format!("{SITE}/{login}") })
94+ json!({
95+ "login": login,
96+ "id": account_id(login),
97+ "type": "User",
98+ "html_url": format!("{SITE}/{login}"),
99+ "url": format!("{API}/users/{login}"),
100+ })
33101 }
34102
35103 /// Someone as GitHub names them. g1t, the author of what it makes and
36104 /// files, is a `Bot`, as an app is there.
37105 fn person(user: &User) -> Value {
38106 let mut named = self::user(&user.username);
107+ named["node_id"] = json!(user.id);
39108 if matches!(user.kind, PrincipalKind::Agent | PrincipalKind::System) {
40109 named["type"] = json!("Bot");
41110 }
42111 named
43112 }
44113
45−fn labels(names: &[String]) -> Value {
46− Value::Array(names.iter().map(|name| json!({ "name": name })).collect())
114+fn people(logins: &[String]) -> Vec<Value> {
115+ logins.iter().map(|login| user(login)).collect()
116+}
117+
118+fn labels(repo: &Repo, names: &[String]) -> Value {
119+ Value::Array(names.iter().map(|name| label(repo, name, None)).collect())
120+}
121+
122+/// A label, by its name in `repo`.
123+fn label(repo: &Repo, name: &str, color: Option<&Value>) -> Value {
124+ let mut label = json!({ "id": github_id(&format!("{}:label:{name}", repo.id)), "name": name });
125+ if let Some(color) = color {
126+ label["color"] = color.clone();
127+ }
128+ label
47129 }
48130
49131 /// A milestone as an issue or a pull request names it.
50−fn milestone(milestone: Option<&MilestoneRef>) -> Value {
51− milestone.map_or(Value::Null, |milestone| json!({ "number": milestone.number, "title": milestone.title }))
132+fn milestone(repo: &Repo, milestone: Option<&MilestoneRef>) -> Value {
133+ milestone.map_or(Value::Null, |milestone| milestone_of(repo, milestone.number, &json!(milestone.title)))
134+}
135+
136+fn milestone_of(repo: &Repo, number: u32, title: &Value) -> Value {
137+ json!({
138+ "id": github_id(&format!("{}:milestone:{number}", repo.id)),
139+ "number": number,
140+ "title": title,
141+ "html_url": format!("{SITE}/{}/{}/milestones/{number}", repo.namespace, repo.name),
142+ })
52143 }
53144
54145 pub fn commit(repo: &Repo, commit: &Commit) -> Value {
61152 "author": { "name": commit.author.name, "email": commit.author.email },
62153 "committer": { "name": commit.author.name, "email": commit.author.email },
63154 "distinct": true,
155+ "added": [],
156+ "removed": [],
157+ "modified": [],
64158 })
65159 }
66160
67−pub fn push(repo: &Repo, git_ref: &str, before: Option<&str>, after: &str, commits: &[Commit], pusher: &str) -> Value {
68− let zero = "0000000000000000000000000000000000000000";
161+/// A push. `forced` is whether `before` is not in `after`'s history, when
162+/// that is known; a push whose history is too long to read says `false`.
163+pub fn push(repo: &Repo, git_ref: &str, before: Option<&str>, after: &str, commits: &[Commit], pusher: &str, forced: bool) -> Value {
164+ let before = before.unwrap_or(ZERO);
165+ let created = before == ZERO;
166+ let deleted = after == ZERO;
69167 let commits: Vec<Value> = commits.iter().map(|c| commit(repo, c)).collect();
168+ let full_name = format!("{}/{}", repo.namespace, repo.name);
169+ let compare = if created {
170+ format!("{SITE}/{full_name}/commit/{after}")
171+ } else {
172+ format!("{SITE}/{full_name}/compare/{before}...{after}")
173+ };
70174 json!({
71175 "ref": git_ref,
72− "before": before.unwrap_or(zero),
176+ "before": before,
73177 "after": after,
74− "created": before.is_none(),
75− "deleted": false,
76− "forced": false,
178+ "created": created,
179+ "deleted": deleted,
180+ "forced": forced && !created && !deleted,
77181 "base_ref": null,
78− "compare": format!("{SITE}/{}/{}/commit/{after}", repo.namespace, repo.name),
79− "head_commit": commits.first().cloned().unwrap_or(Value::Null),
182+ "compare": compare,
183+ // GitHub's head commit is the one pushed: the last.
184+ "head_commit": commits.last().cloned().unwrap_or(Value::Null),
80185 "commits": commits,
81186 "pusher": { "name": pusher, "email": null },
82187 "repository": repository(repo),
87192 pub fn issue(repo: &Repo, issue: &Issue) -> Value {
88193 let full_name = format!("{}/{}", repo.namespace, repo.name);
89194 json!({
90− "id": issue.id,
195+ "id": github_id(&issue.id),
196+ "node_id": issue.id,
91197 "number": issue.number,
92198 "title": issue.title,
93199 "body": issue.body,
94200 "state": if issue.state == State::Open { "open" } else { "closed" },
95201 "state_reason": issue.reason,
96− "labels": labels(&issue.labels),
97− "milestone": milestone(issue.milestone.as_ref()),
202+ "locked": false,
203+ "labels": labels(repo, &issue.labels),
204+ "milestone": milestone(repo, issue.milestone.as_ref()),
98205 "user": person(&issue.author),
99206 // g1t's own field: for an issue its agent filed, who it worked for.
100207 "requested_by": issue.requested_by.as_ref().map(person),
101− "assignees": issue.assignees.iter().map(|a| user(a)).collect::<Vec<_>>(),
208+ "assignee": issue.assignees.first().map(|a| user(a)),
209+ "assignees": people(&issue.assignees),
102210 "comments": issue.comment_count,
103211 "created_at": issue.created_at,
104212 "updated_at": issue.updated_at,
105213 "closed_at": issue.closed_at,
106214 "html_url": format!("{SITE}/{full_name}/issues/{}", issue.number),
107215 "url": format!("{API}/repos/{full_name}/issues/{}", issue.number),
216+ "repository_url": format!("{API}/repos/{full_name}"),
108217 })
109218 }
110219
111−/// A pull request, with its own labels.
220+/// A pull request, with its own labels. `base.sha` is the pull request's
221+/// merge base until the trigger sets the base branch's tip, as GitHub
222+/// gives it (trigger.rs).
112223 pub fn pull(repo: &Repo, pull: &Pull) -> Value {
113224 let full_name = format!("{}/{}", repo.namespace, repo.name);
114225 let head_ref = head_ref(pull);
115226 let base_ref = pull.base_branch(&repo.default_branch);
116− let head_repo = match &pull.fork {
117− Some(fork) => json!({ "full_name": format!("{}/{}", fork.namespace, fork.name), "fork": true }),
118− None => json!({ "full_name": full_name, "fork": false }),
227+ let (head_repo, head_owner) = match &pull.fork {
228+ Some(fork) => (fork_repository(fork, pull.fork_repo_id.as_deref()), fork.namespace.as_str()),
229+ None => (repository(repo), repo.namespace.as_str()),
119230 };
231+ let merged = pull.status == PullStatus::Merged;
232+ let closed_at = match pull.status {
233+ PullStatus::Merged => pull.merged_at.clone(),
234+ PullStatus::Closed => Some(pull.updated_at.clone()),
235+ _ => None,
236+ };
120237 json!({
121− "id": pull.id,
238+ "id": github_id(&pull.id),
239+ "node_id": pull.id,
122240 "number": pull.number,
123241 "title": pull.title,
124242 "body": pull.body,
125243 "state": if pull.status.is_active() { "open" } else { "closed" },
244+ "locked": false,
126245 "draft": pull.status == PullStatus::Draft,
127− "merged": pull.status == PullStatus::Merged,
246+ "merged": merged,
128247 "merged_at": pull.merged_at,
129248 "merged_by": pull.merged_by.as_deref().map(user),
130− "merge_commit_sha": if pull.status == PullStatus::Merged { pull.head_commit.clone() } else { None },
131− "labels": labels(&pull.labels),
132− "milestone": milestone(pull.milestone.as_ref()),
249+ // Only once merged, as on GitHub; the trigger sets the commit it
250+ // landed as when the event says.
251+ "merge_commit_sha": if merged { pull.head_commit.clone() } else { None },
252+ "labels": labels(repo, &pull.labels),
253+ "milestone": milestone(repo, pull.milestone.as_ref()),
133254 "user": person(&pull.author),
134255 // g1t's own field: for a change g1t made, who asked for it.
135256 "requested_by": pull.requested_by.as_ref().map(person),
136− "assignees": pull.assignees.iter().map(|a| user(a)).collect::<Vec<_>>(),
137− "requested_reviewers": pull.reviewers.iter().map(|r| user(r)).collect::<Vec<_>>(),
257+ "assignee": pull.assignees.first().map(|a| user(a)),
258+ "assignees": people(&pull.assignees),
259+ "requested_reviewers": people(&pull.reviewers),
138260 "head": {
139261 "ref": head_ref,
140262 "sha": pull.head_commit,
141− "label": format!("{}:{head_ref}", repo.namespace),
263+ "label": format!("{head_owner}:{head_ref}"),
264+ "user": owner(head_owner),
142265 "repo": head_repo,
143266 },
144267 "base": {
145268 "ref": base_ref,
146269 "sha": pull.merge_base,
147270 "label": format!("{}:{base_ref}", repo.namespace),
271+ "user": owner(&repo.namespace),
148272 "repo": repository(repo),
149273 },
274+ "changed_files": pull.files.len(),
275+ "additions": pull.files.iter().map(|f| u64::from(f.additions)).sum::<u64>(),
276+ "deletions": pull.files.iter().map(|f| u64::from(f.deletions)).sum::<u64>(),
277+ "created_at": pull.created_at,
278+ "updated_at": pull.updated_at,
279+ "closed_at": closed_at,
150280 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
151281 "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
152− "issue_url": pull.issue.map(|n| format!("{API}/repos/{full_name}/issues/{n}")),
282+ "issue_url": format!("{API}/repos/{full_name}/issues/{}", pull.number),
153283 })
154284 }
155285
159289 /// one event for them all, GitHub one each), and for a new base,
160290 /// `changes.base.ref.from` is not known, so only the new base is in
161291 /// `pull_request.base`.
162−pub fn changed(payload: &mut Value, data: &Value) {
292+pub fn changed(repo: &Repo, payload: &mut Value, data: &Value) {
163293 if let Some(first) = data["added"].as_array().and_then(|added| added.first()).and_then(Value::as_str) {
164294 payload["assignee"] = user(first);
165295 }
166− if let Some(label) = data.get("label").filter(|label| label.is_object()) {
167− payload["label"] = json!({ "name": label["name"], "color": label["color"] });
296+ if let Some(found) = data.get("label").filter(|label| label.is_object()) {
297+ payload["label"] = label(repo, found["name"].as_str().unwrap_or_default(), Some(&found["color"]));
168298 }
169− if let Some(milestone) = data.get("milestone").filter(|milestone| milestone.is_object()) {
170− payload["milestone"] = json!({ "number": milestone["number"], "title": milestone["title"] });
299+ if let Some(found) = data.get("milestone").filter(|milestone| milestone.is_object()) {
300+ payload["milestone"] = match found["number"].as_u64() {
301+ Some(number) => milestone_of(repo, number as u32, &found["title"]),
302+ None => json!({ "number": found["number"], "title": found["title"] }),
303+ };
171304 }
172305 }
173306
176309 pull.branch.clone().unwrap_or_else(|| format!("pull/{}", pull.number))
177310 }
178311
312+/// A comment. Its `id` is g1t's (`cmt_…`), as the API takes it back to
313+/// edit or delete it.
179314 pub fn comment(repo: &Repo, number: u32, comment: &Comment, on_pull: bool) -> Value {
180315 let full_name = format!("{}/{}", repo.namespace, repo.name);
181316 let page = if on_pull { "pull" } else { "issues" };
182317 json!({
183318 "id": comment.id,
319+ "node_id": comment.id,
184320 "body": comment.body,
185321 "user": person(&comment.author),
186322 "created_at": comment.created_at,
187− "updated_at": comment.created_at,
323+ "updated_at": comment.edited_at.as_deref().unwrap_or(&comment.created_at),
188324 "path": comment.path,
189325 "line": comment.line,
190326 "html_url": format!("{SITE}/{full_name}/{page}/{number}#{}", comment.id),
327+ "url": format!("{API}/repos/{full_name}/issues/comments/{}", comment.id),
328+ "issue_url": format!("{API}/repos/{full_name}/issues/{number}"),
329+ })
330+}
331+
332+/// A review, for `pull_request_review`: g1t keeps a review as a comment
333+/// with a verdict, and its id is that comment's.
334+pub fn review(repo: &Repo, pull: &Pull, review: Option<&Comment>) -> Value {
335+ let full_name = format!("{}/{}", repo.namespace, repo.name);
336+ let Some(review) = review else {
337+ return json!({ "state": null, "body": null, "user": null });
338+ };
339+ json!({
340+ "id": github_id(&review.id),
341+ "node_id": review.id,
342+ "state": review.verdict.map(review_state),
343+ "body": review.body,
344+ "user": person(&review.author),
345+ "commit_id": pull.head_commit,
346+ "submitted_at": review.created_at,
347+ "html_url": format!("{SITE}/{full_name}/pull/{}#{}", pull.number, review.id),
348+ "pull_request_url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
191349 })
192350 }
193351
194352 /// An issue as `issue_comment` gives it for a pull request: the pull
195−/// request's number and title, with `pull_request` set.
353+/// request's number and title, with `pull_request` set. Its id is not the
354+/// pull request's, as on GitHub, where the two are different objects.
196355 pub fn pull_as_issue(repo: &Repo, pull: &Pull) -> Value {
197356 let full_name = format!("{}/{}", repo.namespace, repo.name);
198357 json!({
199− "id": pull.id,
358+ "id": github_id(&format!("issue:{}", pull.id)),
359+ "node_id": pull.id,
200360 "number": pull.number,
201361 "title": pull.title,
202362 "body": pull.body,
203363 "state": if pull.status.is_active() { "open" } else { "closed" },
204− "labels": labels(&pull.labels),
205− "milestone": milestone(pull.milestone.as_ref()),
364+ "locked": false,
365+ "draft": pull.status == PullStatus::Draft,
366+ "labels": labels(repo, &pull.labels),
367+ "milestone": milestone(repo, pull.milestone.as_ref()),
206368 "user": person(&pull.author),
207369 "requested_by": pull.requested_by.as_ref().map(person),
370+ "assignee": pull.assignees.first().map(|a| user(a)),
371+ "assignees": people(&pull.assignees),
372+ "created_at": pull.created_at,
373+ "updated_at": pull.updated_at,
208374 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
375+ "url": format!("{API}/repos/{full_name}/issues/{}", pull.number),
376+ "repository_url": format!("{API}/repos/{full_name}"),
209377 "pull_request": {
210378 "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
211379 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
223391 let login = kept["author"]["username"].as_str().unwrap_or_default();
224392 json!({
225393 "id": id,
394+ "node_id": id,
226395 "body": kept["body"],
227396 "user": user(login),
228397 "created_at": kept["createdAt"],
229− "updated_at": kept["createdAt"],
398+ "updated_at": kept["editedAt"].as_str().map_or(kept["createdAt"].clone(), |at| json!(at)),
230399 "path": kept["path"],
231400 "line": kept["line"],
232401 "html_url": format!("{SITE}/{full_name}/{page}/{number}#{id}"),
402+ "url": format!("{API}/repos/{full_name}/issues/comments/{id}"),
403+ "issue_url": format!("{API}/repos/{full_name}/issues/{number}"),
233404 })
234405 }
235406
407+/// What `workflow_run` is told of the run it follows: the
408+/// `workflow.completed` event's data, and the run's row when it is still
409+/// there. Its `id` and `workflow_id` are g1t's (`run_…`, `wfl_…`), which
410+/// `GITHUB_RUN_ID` and the API use too, so `actions/download-artifact`
411+/// with `run-id: ${{ github.event.workflow_run.id }}` finds its artifacts.
412+pub struct FollowedRun<'a> {
413+ pub data: &'a Value,
414+ pub workflow_id: Option<&'a str>,
415+ pub attempt: Option<u64>,
416+ pub created_at: Option<&'a str>,
417+ pub started_at: Option<&'a str>,
418+ pub updated_at: Option<&'a str>,
419+ pub actor: Option<&'a str>,
420+ pub triggering_actor: Option<&'a str>,
421+ pub title: Option<&'a str>,
422+ pub head_commit: Option<&'a Commit>,
423+}
424+
425+pub fn workflow_run(repo: &Repo, run: &FollowedRun) -> Value {
426+ let full_name = format!("{}/{}", repo.namespace, repo.name);
427+ let data = run.data;
428+ let id = data["runId"].as_str().unwrap_or_default();
429+ let name = data["workflow"].as_str().unwrap_or_default();
430+ let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned();
431+ let url = format!("{API}/repos/{full_name}/actions/runs/{id}");
432+ let actor = run.actor.map(user);
433+ json!({
434+ "id": id,
435+ "node_id": id,
436+ "name": name,
437+ "display_title": run.title.unwrap_or(name),
438+ "path": data["path"],
439+ "event": data["event"],
440+ "status": "completed",
441+ "conclusion": data["conclusion"],
442+ "workflow_id": run.workflow_id,
443+ "head_sha": data["sha"],
444+ "head_branch": head_branch,
445+ "head_commit": run.head_commit.map(|c| json!({
446+ "id": c.hash,
447+ "tree_id": c.tree_hash,
448+ "message": c.message,
449+ "timestamp": c.authored_at,
450+ "author": { "name": c.author.name, "email": c.author.email },
451+ "committer": { "name": c.author.name, "email": c.author.email },
452+ })),
453+ "run_number": data["number"],
454+ "run_attempt": run.attempt.unwrap_or(1),
455+ "created_at": run.created_at,
456+ "run_started_at": run.started_at.or(run.created_at),
457+ "updated_at": run.updated_at.or(run.created_at),
458+ "actor": actor,
459+ "triggering_actor": run.triggering_actor.map(user).or_else(|| run.actor.map(user)),
460+ "html_url": format!("{SITE}/{full_name}/actions/runs/{id}"),
461+ "url": url,
462+ "jobs_url": format!("{url}/jobs"),
463+ "logs_url": format!("{url}/logs"),
464+ "artifacts_url": format!("{url}/artifacts"),
465+ "workflow_url": run.workflow_id.map(|w| format!("{API}/repos/{full_name}/actions/workflows/{w}")),
466+ "pull_requests": data["pull"].as_u64().map(|n| vec![json!({
467+ "number": n,
468+ "url": format!("{API}/repos/{full_name}/pulls/{n}"),
469+ })]).unwrap_or_default(),
470+ "repository": repository(repo),
471+ "head_repository": repository(repo),
472+ })
473+}
474+
236475 /// A release, from what a `release.*` event carries
237−/// (`g1t_contracts::about::Release`).
476+/// (`g1t_contracts::about::Release`). Its `id` is g1t's, as
477+/// `/repos/{owner}/{repo}/releases/{id}` takes it.
238478 pub fn release(repo: &Repo, release: &Value) -> Value {
239479 let full_name = format!("{}/{}", repo.namespace, repo.name);
240480 let tag = release["tagName"].as_str().unwrap_or_default();
254494 "assets": [],
255495 "html_url": format!("{SITE}/{full_name}/releases/tag/{tag}"),
256496 "url": format!("{API}/repos/{full_name}/releases/{id}"),
497+ "zipball_url": format!("{SITE}/{full_name}/archive/{tag}.zip"),
257498 })
258499 }
259500
324565 assert_eq!(review_state(g1t_contracts::work::Verdict::RequestChanges), "changes_requested");
325566 }
326567 }
568+
569+/// g1t's payloads against GitHub's: each fixture in `tests/fixtures` is a
570+/// trimmed webhook payload in GitHub's documented shape, and every key it
571+/// has must be in g1t's payload for the same event with the same JSON type
572+/// (a whole number where GitHub's is one). `null` in a fixture takes any
573+/// type. Where g1t differs on purpose, `KNOWN` says how and why.
574+#[cfg(test)]
575+mod github_shapes {
576+ use super::*;
577+ use g1t_contracts::work::Verdict;
578+
579+ /// Paths where g1t's payload differs from GitHub's on purpose, and
580+ /// what g1t has there: a JSON type, or `absent`.
581+ const KNOWN: &[(&str, &str, &str)] = &[
582+ // The API addresses these by g1t's own id, so the payload gives
583+ // that id, to be passed straight back.
584+ ("issue_comment", "comment.id", "string"),
585+ ("release", "release.id", "string"),
586+ ("workflow_run", "workflow_run.id", "string"),
587+ ("workflow_run", "workflow_run.workflow_id", "string"),
588+ ("workflow_run", "workflow.id", "string"),
589+ // g1t keeps labels by name on issues and pull requests; only a
590+ // `labeled` event's own `label` has its color.
591+ ("pull_request", "pull_request.labels[].color", "absent"),
592+ ("issues", "issue.labels[].color", "absent"),
593+ // A push names who pushed, not their address.
594+ ("push", "pusher.email", "null"),
595+ ];
596+
597+ fn kind(value: &Value) -> &'static str {
598+ match value {
599+ Value::Null => "null",
600+ Value::Bool(_) => "boolean",
601+ Value::Number(_) => "number",
602+ Value::String(_) => "string",
603+ Value::Array(_) => "array",
604+ Value::Object(_) => "object",
605+ }
606+ }
607+
608+ fn compare(event: &str, github: &Value, ours: Option<&Value>, path: &str, problems: &mut Vec<String>) {
609+ if let Some((_, _, known)) = KNOWN.iter().find(|(e, p, _)| *e == event && *p == path) {
610+ let had = ours.map_or("absent", kind);
611+ if had != *known {
612+ problems.push(format!("{path}: g1t is known to have {known} here, but has {had}"));
613+ }
614+ return;
615+ }
616+ let Some(ours) = ours else {
617+ problems.push(format!("{path}: missing"));
618+ return;
619+ };
620+ if github.is_null() {
621+ return;
622+ }
623+ if kind(github) != kind(ours) {
624+ problems.push(format!("{path}: GitHub's is {}, g1t's {}", kind(github), kind(ours)));
625+ return;
626+ }
627+ match github {
628+ Value::Number(n) if n.is_u64() => {
629+ if !ours.as_u64().is_some_and(|n| n < 1 << 53) {
630+ problems.push(format!("{path}: GitHub's is a whole number, g1t's {ours}"));
631+ }
632+ }
633+ Value::Object(map) => {
634+ for (key, inner) in map {
635+ let at = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
636+ compare(event, inner, ours.get(key), &at, problems);
637+ }
638+ }
639+ Value::Array(items) => {
640+ if let (Some(first), Some(our_first)) = (items.first(), ours.as_array().and_then(|o| o.first())) {
641+ compare(event, first, Some(our_first), &format!("{path}[]"), problems);
642+ }
643+ }
644+ _ => {}
645+ }
646+ }
647+
648+ fn repo() -> Repo {
649+ serde_json::from_value(json!({
650+ "id": "rep_01kpw0aaaaaaaaaaaaaaaaaaaa", "namespace": "acme", "name": "web", "description": null, "isPrivate": false,
651+ "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-05-15T15:19:25Z"
652+ }))
653+ .unwrap()
654+ }
655+
656+ fn someone(id: &str, username: &str) -> User {
657+ User { id: id.into(), username: username.into(), kind: PrincipalKind::User, ..User::default() }
658+ }
659+
660+ fn a_pull() -> Pull {
661+ serde_json::from_value(json!({
662+ "id": "pr_01kq000000000000000000000a", "repoId": "rep_1", "number": 2, "issue": null,
663+ "title": "Update the README with new information.", "body": "A simple change.",
664+ "agent": "", "runtime": "external", "status": "open",
665+ "fork": null, "forkRepoId": null, "branch": "changes",
666+ "headCommit": "ec26c3e57ca3a959ca5aad62de7213c562f8c821", "mergeBase": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e",
667+ "mergedBy": null, "mergedAt": null, "supersededBy": null, "checkStatus": null,
668+ "files": [{ "path": "README.md", "additions": 1, "deletions": 1 }],
669+ "assignees": ["bo"], "reviewers": ["cy"], "labels": ["bug"], "milestone": { "number": 1, "title": "v1.0" },
670+ "author": someone("usr_1", "ada"), "requestedBy": null,
671+ "createdAt": "2026-05-15T15:20:33Z", "updatedAt": "2026-05-15T15:20:33Z"
672+ }))
673+ .unwrap()
674+ }
675+
676+ fn an_issue() -> Issue {
677+ serde_json::from_value(json!({
678+ "id": "iss_01kq000000000000000000000a", "repoId": "rep_1", "number": 1,
679+ "title": "Spelling error in the README file", "body": "Two t's.", "labels": ["bug"], "state": "open",
680+ "reason": null, "resolvedBy": null, "author": someone("usr_1", "ada"), "requestedBy": null,
681+ "createdAt": "2026-05-15T15:20:18Z", "updatedAt": "2026-05-15T15:20:18Z", "closedAt": null,
682+ "pullCount": 0, "commentCount": 0, "assignees": ["bo"], "milestone": { "number": 1, "title": "v1.0" }
683+ }))
684+ .unwrap()
685+ }
686+
687+ fn a_comment(verdict: Option<Verdict>) -> Comment {
688+ serde_json::from_value(json!({
689+ "id": "cmt_01kq000000000000000000000a", "author": someone("usr_3", "cy"), "body": "Looks good.",
690+ "path": null, "line": null, "verdict": verdict, "createdAt": "2026-05-15T15:20:38Z"
691+ }))
692+ .unwrap()
693+ }
694+
695+ fn a_commit() -> Commit {
696+ serde_json::from_value(json!({
697+ "hash": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", "treeHash": "f9d2a07e9488b91af2641b26b9407fe22a451433",
698+ "message": "Update README.md", "author": { "name": "Ada", "email": "ada@example.com" },
699+ "parents": ["6113728f27ae82c7b1a177c8d03f9e96e0adf246"], "authoredAt": "2026-05-15T15:20:30Z"
700+ }))
701+ .unwrap()
702+ }
703+
704+ /// g1t's payload for each event, put together as trigger.rs does.
705+ fn payloads() -> Vec<(&'static str, Value)> {
706+ let repo = repo();
707+ let pull = a_pull();
708+ let push = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), &a_commit().hash, &[a_commit()], "ada", false);
709+ let pull_request = json!({
710+ "action": "opened", "number": 2, "pull_request": self::pull(&repo, &pull),
711+ "repository": repository(&repo), "sender": user("ada"),
712+ });
713+ let review = json!({
714+ "action": "submitted", "review": self::review(&repo, &pull, Some(&a_comment(Some(Verdict::Approve)))),
715+ "pull_request": self::pull(&repo, &pull), "repository": repository(&repo), "sender": user("cy"),
716+ });
717+ let mut issues = json!({ "action": "labeled", "issue": issue(&repo, &an_issue()), "repository": repository(&repo), "sender": user("ada") });
718+ changed(&repo, &mut issues, &json!({ "label": { "name": "bug", "color": "d73a4a" } }));
719+ let mut on_pull = pull.clone();
720+ on_pull.labels.clear();
721+ on_pull.assignees.clear();
722+ on_pull.milestone = None;
723+ let mut comment = a_comment(None);
724+ comment.author = someone("usr_1", "ada");
725+ let issue_comment = json!({
726+ "action": "created", "issue": pull_as_issue(&repo, &on_pull), "comment": self::comment(&repo, 2, &comment, true),
727+ "repository": repository(&repo), "sender": user("ada"),
728+ });
729+ let data = json!({
730+ "runId": "run_01kq000000000000000000000a", "workflow": "Build", "path": ".g1t/workflows/build.yml", "number": 562,
731+ "event": "push", "conclusion": "success", "ref": "refs/heads/main", "sha": "acb5820ced9479c074f688cc328bf03f341a511d", "pull": 2,
732+ });
733+ let head = Commit { hash: "acb5820ced9479c074f688cc328bf03f341a511d".into(), ..a_commit() };
734+ let followed = FollowedRun {
735+ data: &data,
736+ workflow_id: Some("wfl_1"),
737+ attempt: Some(1),
738+ created_at: Some("2026-05-15T15:20:35Z"),
739+ started_at: Some("2026-05-15T15:20:35Z"),
740+ updated_at: Some("2026-05-15T15:21:10Z"),
741+ actor: Some("ada"),
742+ triggering_actor: None,
743+ title: Some("Update README.md"),
744+ head_commit: Some(&head),
745+ };
746+ // As trigger.rs gives it.
747+ let workflow_run = json!({
748+ "action": "completed",
749+ "workflow_run": self::workflow_run(&repo, &followed),
750+ "workflow": { "id": "wfl_1", "node_id": "wfl_1", "name": "Build", "path": data["path"], "state": "active", "html_url": "https://g1t.sh/acme/web/actions" },
751+ "repository": repository(&repo),
752+ "sender": user("ada"),
753+ });
754+ let release = json!({
755+ "action": "published",
756+ "release": self::release(&repo, &json!({ "id": "rel_1", "tagName": "v1.2.0", "target": "main", "name": "v1.2.0", "body": "Notes",
757+ "draft": false, "prerelease": false, "author": "ada", "createdAt": "2026-05-15T15:19:53Z", "publishedAt": "2026-05-15T15:20:05Z" })),
758+ "repository": repository(&repo),
759+ "sender": user("ada"),
760+ });
761+ vec![
762+ ("push", push),
763+ ("pull_request", pull_request),
764+ ("pull_request_review", review),
765+ ("issues", issues),
766+ ("issue_comment", issue_comment),
767+ ("workflow_run", workflow_run),
768+ ("release", release),
769+ ]
770+ }
771+
772+ fn fixture(event: &str) -> Value {
773+ let text = match event {
774+ "push" => include_str!("../tests/fixtures/push.json"),
775+ "pull_request" => include_str!("../tests/fixtures/pull_request.json"),
776+ "pull_request_review" => include_str!("../tests/fixtures/pull_request_review.json"),
777+ "issues" => include_str!("../tests/fixtures/issues.json"),
778+ "issue_comment" => include_str!("../tests/fixtures/issue_comment.json"),
779+ "workflow_run" => include_str!("../tests/fixtures/workflow_run.json"),
780+ "release" => include_str!("../tests/fixtures/release.json"),
781+ other => panic!("no fixture for {other}"),
782+ };
783+ serde_json::from_str(text).unwrap()
784+ }
785+
786+ #[test]
787+ fn payloads_have_githubs_keys_and_types() {
788+ let mut problems = Vec::new();
789+ for (event, ours) in payloads() {
790+ let mut found = Vec::new();
791+ compare(event, &fixture(event), Some(&ours), "", &mut found);
792+ problems.extend(found.into_iter().map(|problem| format!("{event}: {problem}")));
793+ }
794+ assert!(problems.is_empty(), "g1t's payloads differ from GitHub's:\n{}", problems.join("\n"));
795+ }
796+
797+ #[test]
798+ fn the_comparison_finds_what_differs() {
799+ let mut found = Vec::new();
800+ let github = json!({ "id": 1, "user": { "login": "a" }, "labels": [{ "name": "x" }], "body": null });
801+ compare("x", &github, Some(&json!({ "id": "pr_1", "labels": [{}], "body": 3 })), "", &mut found);
802+ assert_eq!(found, ["id: GitHub's is number, g1t's string", "user: missing", "labels[].name: missing"]);
803+ found.clear();
804+ compare("x", &json!({ "id": 1 }), Some(&json!({ "id": 1.5 })), "", &mut found);
805+ assert_eq!(found, ["id: GitHub's is a whole number, g1t's 1.5"]);
806+ }
807+
808+ #[test]
809+ fn the_same_object_has_the_same_number_everywhere() {
810+ let all = payloads();
811+ let by = |event: &str| all.iter().find(|(e, _)| *e == event).map(|(_, v)| v.clone()).unwrap();
812+ let (pr, issues, run) = (by("pull_request"), by("issues"), by("workflow_run"));
813+ let repo_id = &pr["repository"]["id"];
814+ assert_eq!(repo_id, &json!(github_id(&repo().id)), "github.repository_id is the same number");
815+ assert_eq!(&pr["pull_request"]["base"]["repo"]["id"], repo_id);
816+ assert_eq!(&run["workflow_run"]["repository"]["id"], repo_id);
817+ // ada as sender, author and run actor; acme as owner and head user.
818+ assert_eq!(pr["sender"]["id"], pr["pull_request"]["user"]["id"]);
819+ assert_eq!(pr["sender"]["id"], run["workflow_run"]["actor"]["id"]);
820+ assert_eq!(pr["sender"]["id"], json!(account_id("ada")), "github.actor_id is the same number");
821+ assert_eq!(pr["repository"]["owner"]["id"], pr["pull_request"]["head"]["user"]["id"]);
822+ assert_eq!(pr["repository"]["owner"]["id"], json!(account_id("acme")));
823+ assert_eq!(pr["pull_request"]["labels"][0]["id"], issues["label"]["id"]);
824+ // A pull request's own id and its issue's are different objects.
825+ assert_ne!(pull_as_issue(&repo(), &a_pull())["id"], pr["pull_request"]["id"]);
826+ assert_eq!(pr["pull_request"]["node_id"], a_pull().id);
827+ }
828+
829+ #[test]
830+ fn a_personal_workspaces_repositories_are_a_users() {
831+ let mut event = json!({ "pull_request": pull(&repo(), &a_pull()), "repository": repository(&repo()), "sender": user("ada") });
832+ assert_eq!(event["repository"]["owner"]["type"], "Organization");
833+ owned_by_person(&mut event, "Acme");
834+ assert_eq!(event["repository"]["owner"]["type"], "User");
835+ assert_eq!(event["pull_request"]["base"]["repo"]["owner"]["type"], "User");
836+ assert_eq!(event["pull_request"]["head"]["user"]["type"], "User");
837+ // Nobody else changes, a bot least of all.
838+ assert_eq!(event["sender"]["type"], "User");
839+ let mut bot = json!({ "user": { "login": "acme", "type": "Bot" } });
840+ owned_by_person(&mut bot, "acme");
841+ assert_eq!(bot["user"]["type"], "Bot");
842+ }
843+
844+ #[test]
845+ fn a_push_says_whether_it_made_deleted_or_forced_its_ref() {
846+ let repo = repo();
847+ let after = "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c";
848+ let made = push(&repo, "refs/heads/new", None, after, &[a_commit()], "ada", false);
849+ assert_eq!((made["created"].clone(), made["deleted"].clone(), made["forced"].clone()), (json!(true), json!(false), json!(false)));
850+ assert_eq!(made["before"], ZERO);
851+ let forced = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), after, &[a_commit()], "ada", true);
852+ assert_eq!(forced["forced"], true);
853+ assert_eq!(forced["compare"], format!("https://g1t.sh/acme/web/compare/6113728f27ae82c7b1a177c8d03f9e96e0adf246...{after}"));
854+ assert_eq!(forced["head_commit"]["id"], after);
855+ let gone = push(&repo, "refs/heads/old", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), ZERO, &[], "ada", true);
856+ assert_eq!((gone["deleted"].clone(), gone["forced"].clone()), (json!(true), json!(false)));
857+ }
858+
859+ #[test]
860+ fn a_pull_request_from_a_fork_names_the_fork() {
861+ let mut pull = a_pull();
862+ pull.fork = Some(RepoPath { namespace: "ana".into(), name: "web".into() });
863+ pull.fork_repo_id = Some("rep_f".into());
864+ pull.branch = None;
865+ let event = self::pull(&repo(), &pull);
866+ assert_eq!(event["head"]["repo"]["full_name"], "ana/web");
867+ assert_eq!(event["head"]["repo"]["fork"], true);
868+ assert_eq!(event["head"]["repo"]["id"], json!(github_id("rep_f")));
869+ assert_eq!(event["head"]["label"], "ana:pull/2");
870+ assert_eq!(event["head"]["user"]["login"], "ana");
871+ assert_eq!(event["merge_commit_sha"], Value::Null, "only once merged");
872+ }
873+}
+35−0
568568 contexts
569569 }
570570
571+ /// Whether `namespace` is a person's own workspace: usernames and
572+ /// workspaces share one namespace, so it is when someone has that
573+ /// username. Kept in the isolate for a few minutes, so a burst of
574+ /// runs asks identity once.
575+ async fn personal_namespace(&self, namespace: &str) -> Result<bool> {
576+ const KEEP_MS: u64 = 5 * 60 * 1000;
577+ thread_local! {
578+ static PERSONAL: std::cell::RefCell<std::collections::HashMap<String, (bool, u64)>> = Default::default();
579+ }
580+ let now = now_ms();
581+ let key = namespace.to_ascii_lowercase();
582+ if let Some(personal) = PERSONAL.with(|kept| kept.borrow().get(&key).filter(|(_, at)| now.saturating_sub(*at) < KEEP_MS).map(|(personal, _)| *personal)) {
583+ return Ok(personal);
584+ }
585+ let found: g1t_contracts::Viewer = g1t_kit::call(
586+ &self.identity,
587+ "user_by_username",
588+ &g1t_contracts::identity::UsernameArgs { username: key.clone() },
589+ )
590+ .await?;
591+ let personal = found.is_some_and(|user| user.username.eq_ignore_ascii_case(namespace));
592+ PERSONAL.with(|kept| {
593+ let mut kept = kept.borrow_mut();
594+ if kept.len() > 1000 {
595+ kept.clear();
596+ }
597+ kept.insert(key, (personal, now));
598+ });
599+ Ok(personal)
600+ }
601+
571602 /// Makes a run and its jobs, and starts what can start. `None` when the
572603 /// event already started this workflow.
573604 pub async fn create_run(&self, new: NewRun) -> Result<Option<String>> {
586617 .map_or(1, |count| count.n);
587618 let id = new_id("run", now_ms());
588619 let mut info = new.info.clone();
620+ // A personal workspace's repositories are a `User`'s, as on GitHub.
621+ if self.personal_namespace(&new.repo.namespace).await? {
622+ crate::payload::owned_by_person(&mut info.event, &new.repo.namespace);
623+ }
589624 info.workflow = new.workflow.display_name(&new.path);
590625 info.workflow_path = new.path.clone();
591626 info.run_id = id.clone();
+80−34
2020 use crate::sync::{Read, WorkflowRow};
2121 use crate::{API, Actions, SITE, check, fail, payload};
2222
23+/// How many commits a push's log reads: its `commits`, and how far back
24+/// `before` is looked for to tell a forced push.
25+const PUSH_LOG: u32 = 20;
26+
2327 /// What an event is about, worked out once for every workflow it starts.
2428 struct Subject {
2529 /// Where the workflow files are read, and at which commit.
124128 }
125129
126130 async fn commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<Vec<Commit>> {
131+ Ok(self.pushed_commits(repo, actor, after, before).await?.0)
132+ }
133+
134+ /// The commits a push added, oldest first, and whether it was forced:
135+ /// `before` is not in `after`'s history. Known only when that history
136+ /// is read whole (it is shorter than the [`PUSH_LOG`] commits read);
137+ /// a longer one says not forced.
138+ async fn pushed_commits(&self, repo: &Repo, actor: &User, after: &str, before: Option<&str>) -> Result<(Vec<Commit>, bool)> {
127139 let log: Outcome<Vec<Commit>> = g1t_kit::call(
128140 &self.repos,
129141 "log",
134146 },
135147 viewer: Some(actor.clone()),
136148 git_ref: Some(after.to_owned()),
137− limit: 20,
149+ limit: PUSH_LOG,
138150 },
139151 )
140152 .await?;
141153 let mut commits: Vec<Commit> = log.into_result().unwrap_or_default();
142− if let Some(before) = before
143− && let Some(at) = commits.iter().position(|commit| commit.hash == before)
144− {
145− commits.truncate(at);
154+ let whole = commits.len() < PUSH_LOG as usize;
155+ let mut forced = false;
156+ if let Some(before) = before.filter(|before| *before != payload::ZERO) {
157+ match commits.iter().position(|commit| commit.hash == before) {
158+ Some(at) => commits.truncate(at),
159+ None => forced = whole && !commits.is_empty(),
160+ }
146161 }
147162 // GitHub lists them oldest first, with the head commit last.
148163 commits.reverse();
149− Ok(commits)
164+ Ok((commits, forced))
150165 }
151166
152167 /// The message of the commit `sha` in `path`, when it can be read.
153168 async fn head_message(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<String>> {
169+ Ok(self.commit_at(path, actor, sha).await?.map(|commit| commit.message))
170+ }
171+
172+ /// The commit `sha` in `path`, when it can be read.
173+ async fn commit_at(&self, path: &RepoPath, actor: &User, sha: &str) -> Result<Option<Commit>> {
154174 let log: Outcome<Vec<Commit>> = g1t_kit::call(
155175 &self.repos,
156176 "log",
157177 &LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: Some(sha.to_owned()), limit: 1 },
158178 )
159179 .await?;
160− Ok(log.into_result().ok().and_then(|commits| commits.into_iter().next()).map(|commit| commit.message))
180+ Ok(log.into_result().ok().and_then(|commits| commits.into_iter().next()))
161181 }
162182
163183 async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<String>> {
177197 }
178198
179199 async fn default_head(&self, repo: &Repo) -> Result<Option<String>> {
200+ self.branch_head(repo, &repo.default_branch).await
201+ }
202+
203+ /// The commit `branch` points to now.
204+ async fn branch_head(&self, repo: &Repo, branch: &str) -> Result<Option<String>> {
180205 g1t_kit::call(
181206 &self.repos,
182207 "head",
183208 &g1t_contracts::repos::HeadArgs {
184209 repo_id: repo.id.clone(),
185− branch: repo.default_branch.clone(),
210+ branch: branch.to_owned(),
186211 },
187212 )
188213 .await
245270 return Ok(None);
246271 }
247272 let before = data["before"].as_str();
248− let commits = self.commits(repo, ws, after, before).await?;
273+ let (commits, forced) = self.pushed_commits(repo, ws, after, before).await?;
249274 let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default();
250− let mut payload = payload::push(repo, git_ref, before, after, &commits, sender);
251− if let Some(head) = commits.last() {
252− payload["head_commit"] = payload::commit(repo, head);
253− }
275+ let payload = payload::push(repo, git_ref, before, after, &commits, sender, forced);
254276 Some(Subject {
255277 source: path.clone(),
256278 source_ref: Some(after.to_owned()),
337359 "repository": payload::repository(repo),
338360 "sender": payload::user(sender),
339361 });
340− payload::changed(&mut payload, data);
362+ payload::changed(repo, &mut payload, data);
363+ // `base.sha` is the base branch's tip now, as GitHub gives
364+ // it, not where the two last agreed.
365+ if let Some(tip) = self.branch_head(repo, &base_ref).await? {
366+ payload["pull_request"]["base"]["sha"] = json!(tip);
367+ }
368+ // A merge's commit, once it has landed.
369+ if pull.status == g1t_contracts::work::PullStatus::Merged
370+ && let Some(commit) = data["commit"].as_str()
371+ {
372+ payload["pull_request"]["merge_commit_sha"] = json!(commit);
373+ }
341374 if event_name == "pull_request_review" {
342375 let review = detail.comments.iter().rev().find(|c| c.verdict.is_some() && !c.advisory);
343− payload["review"] = json!({
344− "state": review.and_then(|r| r.verdict).map(payload::review_state),
345− "body": review.map(|r| r.body.clone()),
346− "user": review.map(|r| payload::user(&r.author.username)),
347− });
376+ payload["review"] = payload::review(repo, pull, review);
348377 }
349378 let trusted = self.insider(pull.owner(), repo, ws).await?;
350379 // A pull request from outside may wait for approval before
410439 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
411440 let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned();
412441 let name = data["workflow"].as_str().unwrap_or_default();
442+ // The followed run's row, for what the event does not carry.
443+ let row = match data["runId"].as_str() {
444+ Some(id) => self.db.prepare("SELECT * FROM runs WHERE id = ?").bind(&[id.into()])?.first::<crate::plan::RunRow>(None).await?,
445+ None => None,
446+ };
447+ let head_commit = match data["sha"].as_str() {
448+ Some(head) => self.commit_at(&path, ws, head).await?.filter(|commit| commit.hash == head),
449+ None => None,
450+ };
451+ let followed = payload::FollowedRun {
452+ data,
453+ workflow_id: row.as_ref().map(|r| r.workflow_id.as_str()),
454+ attempt: row.as_ref().map(|r| r.attempt),
455+ created_at: row.as_ref().map(|r| r.created_at.as_str()),
456+ started_at: row.as_ref().and_then(|r| r.started_at.as_deref()),
457+ updated_at: row.as_ref().and_then(|r| r.finished_at.as_deref()),
458+ actor: row.as_ref().and_then(|r| r.actor.as_deref()),
459+ triggering_actor: row.as_ref().and_then(|r| r.triggering_actor.as_deref()),
460+ title: row.as_ref().map(|r| r.title.as_str()),
461+ head_commit: head_commit.as_ref(),
462+ };
413463 let payload = json!({
414464 "action": "completed",
415− "workflow_run": {
416− "id": data["runId"],
465+ "workflow_run": payload::workflow_run(repo, &followed),
466+ "workflow": {
467+ "id": followed.workflow_id,
468+ "node_id": followed.workflow_id,
417469 "name": name,
418470 "path": data["path"],
419− "event": data["event"],
420− "status": "completed",
421− "conclusion": data["conclusion"],
422− "head_sha": data["sha"],
423− "head_branch": head_branch,
424− "run_number": data["number"],
425− "html_url": format!("{SITE}/{}/{}/actions/runs/{}", repo.namespace, repo.name, data["runId"].as_str().unwrap_or_default()),
426− "pull_requests": data["pull"].as_u64().map(|n| vec![json!({ "number": n })]).unwrap_or_default(),
471+ "state": "active",
472+ "html_url": format!("{SITE}/{}/{}/actions", repo.namespace, repo.name),
427473 },
428− "workflow": { "name": name, "path": data["path"] },
429474 "repository": payload::repository(repo),
430475 "sender": payload::user(sender),
431476 });
455500 "repository": payload::repository(repo),
456501 "sender": payload::user(sender),
457502 });
458− payload::changed(&mut payload, data);
503+ payload::changed(repo, &mut payload, data);
459504 if event_name == "issue_comment" {
460505 let comment_id = data["commentId"].as_str();
461506 if action == Some("deleted") {
14101455 pull.milestone = Some(g1t_contracts::work::MilestoneRef { number: 2, title: "1.1".into() });
14111456 let event = payload::pull(&repo(), &pull);
14121457 assert_eq!(event["base"]["ref"], "release/1.x");
1413− assert_eq!(event["labels"], serde_json::json!([{ "name": "bug" }]));
1458+ assert_eq!(event["labels"][0]["name"], "bug");
1459+ assert!(event["labels"][0]["id"].is_u64());
14141460 assert_eq!(event["milestone"]["title"], "1.1");
14151461 let mut labeled = serde_json::json!({ "action": "labeled" });
1416− payload::changed(&mut labeled, &serde_json::json!({ "label": { "name": "bug", "color": "d73a4a" } }));
1462+ payload::changed(&repo(), &mut labeled, &serde_json::json!({ "label": { "name": "bug", "color": "d73a4a" } }));
14171463 assert_eq!(labeled["label"]["color"], "d73a4a");
14181464 let mut assigned = serde_json::json!({ "action": "assigned" });
1419− payload::changed(&mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] }));
1465+ payload::changed(&repo(), &mut assigned, &serde_json::json!({ "assignees": ["ana", "bo"], "added": ["bo"] }));
14201466 assert_eq!(assigned["assignee"]["login"], "bo");
14211467 assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] })));
14221468 assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] })));
+41−0
1+{
2+ "action": "created",
3+ "issue": {
4+ "id": 444500041,
5+ "node_id": "MDU6SXNzdWU0NDQ1MDAwNDE=",
6+ "number": 2,
7+ "title": "Update the README with new information.",
8+ "body": "This is a pretty simple change that we need to pull into main.",
9+ "state": "open",
10+ "locked": false,
11+ "draft": false,
12+ "labels": [],
13+ "milestone": null,
14+ "user": { "login": "ada", "id": 21031068, "type": "User", "html_url": "https://github.com/ada", "url": "https://api.github.com/users/ada" },
15+ "assignee": null,
16+ "assignees": [],
17+ "created_at": "2026-05-15T15:20:18Z",
18+ "updated_at": "2026-05-15T15:20:21Z",
19+ "html_url": "https://github.com/acme/web/pull/2",
20+ "url": "https://api.github.com/repos/acme/web/issues/2",
21+ "repository_url": "https://api.github.com/repos/acme/web",
22+ "pull_request": {
23+ "url": "https://api.github.com/repos/acme/web/pulls/2",
24+ "html_url": "https://github.com/acme/web/pull/2",
25+ "merged_at": null
26+ }
27+ },
28+ "comment": {
29+ "id": 492700400,
30+ "node_id": "MDEyOklzc3VlQ29tbWVudDQ5MjcwMDQwMA==",
31+ "body": "You are totally right! I'll get this fixed right away.",
32+ "user": { "login": "ada", "id": 21031068, "type": "User", "html_url": "https://github.com/ada", "url": "https://api.github.com/users/ada" },
33+ "created_at": "2026-05-15T15:20:21Z",
34+ "updated_at": "2026-05-15T15:20:21Z",
35+ "html_url": "https://github.com/acme/web/pull/2#issuecomment-492700400",
36+ "url": "https://api.github.com/repos/acme/web/issues/comments/492700400",
37+ "issue_url": "https://api.github.com/repos/acme/web/issues/2"
38+ },
39+ "repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } },
40+ "sender": { "login": "ada", "id": 21031068, "type": "User" }
41+}
+28−0
1+{
2+ "action": "labeled",
3+ "issue": {
4+ "id": 444500041,
5+ "node_id": "MDU6SXNzdWU0NDQ1MDAwNDE=",
6+ "number": 1,
7+ "title": "Spelling error in the README file",
8+ "body": "It looks like you accidentally spelled 'commit' with two 't's.",
9+ "state": "open",
10+ "state_reason": null,
11+ "locked": false,
12+ "labels": [{ "id": 1362934389, "name": "bug", "color": "d73a4a" }],
13+ "milestone": { "id": 4317517, "number": 1, "title": "v1.0", "html_url": "https://github.com/acme/web/milestone/1" },
14+ "user": { "login": "ada", "id": 21031068, "node_id": "MDQ6VXNlcjIxMDMxMDY4", "type": "User", "html_url": "https://github.com/ada", "url": "https://api.github.com/users/ada" },
15+ "assignee": { "login": "bo", "id": 21031069, "type": "User", "html_url": "https://github.com/bo", "url": "https://api.github.com/users/bo" },
16+ "assignees": [{ "login": "bo", "id": 21031069, "type": "User", "html_url": "https://github.com/bo", "url": "https://api.github.com/users/bo" }],
17+ "comments": 0,
18+ "created_at": "2026-05-15T15:20:18Z",
19+ "updated_at": "2026-05-15T15:20:18Z",
20+ "closed_at": null,
21+ "html_url": "https://github.com/acme/web/issues/1",
22+ "url": "https://api.github.com/repos/acme/web/issues/1",
23+ "repository_url": "https://api.github.com/repos/acme/web"
24+ },
25+ "label": { "id": 1362934389, "name": "bug", "color": "d73a4a" },
26+ "repository": { "id": 186853002, "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", "full_name": "acme/web", "private": false, "owner": { "login": "acme", "id": 21031067, "type": "Organization" } },
27+ "sender": { "login": "ada", "id": 21031068, "type": "User" }
28+}
+124−0
1+{
2+ "action": "opened",
3+ "number": 2,
4+ "pull_request": {
5+ "id": 279147437,
6+ "node_id": "MDExOlB1bGxSZXF1ZXN0Mjc5MTQ3NDM3",
7+ "number": 2,
8+ "title": "Update the README with new information.",
9+ "body": "This is a pretty simple change that we need to pull into main.",
10+ "state": "open",
11+ "locked": false,
12+ "draft": false,
13+ "merged": false,
14+ "merged_at": null,
15+ "merged_by": null,
16+ "merge_commit_sha": null,
17+ "labels": [
18+ { "id": 1362934389, "name": "bug", "color": "d73a4a" }
19+ ],
20+ "milestone": {
21+ "id": 4317517,
22+ "number": 1,
23+ "title": "v1.0",
24+ "html_url": "https://github.com/acme/web/milestone/1"
25+ },
26+ "user": {
27+ "login": "ada",
28+ "id": 21031068,
29+ "node_id": "MDQ6VXNlcjIxMDMxMDY4",
30+ "type": "User",
31+ "html_url": "https://github.com/ada",
32+ "url": "https://api.github.com/users/ada"
33+ },
34+ "assignee": {
35+ "login": "bo",
36+ "id": 21031069,
37+ "type": "User",
38+ "html_url": "https://github.com/bo",
39+ "url": "https://api.github.com/users/bo"
40+ },
41+ "assignees": [
42+ {
43+ "login": "bo",
44+ "id": 21031069,
45+ "type": "User",
46+ "html_url": "https://github.com/bo",
47+ "url": "https://api.github.com/users/bo"
48+ }
49+ ],
50+ "requested_reviewers": [
51+ {
52+ "login": "cy",
53+ "id": 21031070,
54+ "type": "User",
55+ "html_url": "https://github.com/cy",
56+ "url": "https://api.github.com/users/cy"
57+ }
58+ ],
59+ "head": {
60+ "label": "acme:changes",
61+ "ref": "changes",
62+ "sha": "ec26c3e57ca3a959ca5aad62de7213c562f8c821",
63+ "user": { "login": "acme", "id": 21031067, "type": "Organization", "html_url": "https://github.com/acme", "url": "https://api.github.com/users/acme" },
64+ "repo": {
65+ "id": 186853002,
66+ "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=",
67+ "name": "web",
68+ "full_name": "acme/web",
69+ "owner": { "login": "acme", "id": 21031067, "type": "Organization", "html_url": "https://github.com/acme", "url": "https://api.github.com/users/acme" },
70+ "html_url": "https://github.com/acme/web",
71+ "url": "https://api.github.com/repos/acme/web",
72+ "clone_url": "https://github.com/acme/web.git",
73+ "fork": false
74+ }
75+ },
76+ "base": {
77+ "label": "acme:main",
78+ "ref": "main",
79+ "sha": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e",
80+ "user": { "login": "acme", "id": 21031067, "type": "Organization", "html_url": "https://github.com/acme", "url": "https://api.github.com/users/acme" },
81+ "repo": {
82+ "id": 186853002,
83+ "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=",
84+ "name": "web",
85+ "full_name": "acme/web",
86+ "private": false,
87+ "owner": { "login": "acme", "id": 21031067, "type": "Organization", "html_url": "https://github.com/acme", "url": "https://api.github.com/users/acme" },
88+ "html_url": "https://github.com/acme/web",
89+ "url": "https://api.github.com/repos/acme/web",
90+ "clone_url": "https://github.com/acme/web.git",
91+ "default_branch": "main",
92+ "fork": false,
93+ "visibility": "public"
94+ }
95+ },
96+ "changed_files": 1,
97+ "additions": 1,
98+ "deletions": 1,
99+ "created_at": "2026-05-15T15:20:33Z",
100+ "updated_at": "2026-05-15T15:20:33Z",
101+ "closed_at": null,
102+ "html_url": "https://github.com/acme/web/pull/2",
103+ "url": "https://api.github.com/repos/acme/web/pulls/2",
104+ "issue_url": "https://api.github.com/repos/acme/web/issues/2"
105+ },
106+ "repository": {
107+ "id": 186853002,
108+ "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=",
109+ "name": "web",
110+ "full_name": "acme/web",
111+ "private": false,
112+ "owner": { "login": "acme", "id": 21031067, "type": "Organization", "html_url": "https://github.com/acme", "url": "https://api.github.com/users/acme" },
113+ "html_url": "https://github.com/acme/web",
114+ "url": "https://api.github.com/repos/acme/web",
115+ "clone_url": "https://github.com/acme/web.git",
116+ "description": null,
117+ "default_branch": "main",
118+ "fork": false,
119+ "archived": false,
120+ "created_at": "2026-05-15T15:19:25Z",
121+ "visibility": "public"
122+ },
123+ "sender": { "login": "ada", "id": 21031068, "type": "User", "html_url": "https://github.com/ada", "url": "https://api.github.com/users/ada" }
124+}
+25−0
1+{
2+ "action": "submitted",
3+ "review": {
4+ "id": 237895671,
5+ "node_id": "MDE3OlB1bGxSZXF1ZXN0UmV2aWV3MjM3ODk1Njcx",
6+ "user": { "login": "cy", "id": 21031070, "node_id": "MDQ6VXNlcjIxMDMxMDcw", "type": "User", "html_url": "https://github.com/cy", "url": "https://api.github.com/users/cy" },
7+ "body": "Looks good.",
8+ "commit_id": "ec26c3e57ca3a959ca5aad62de7213c562f8c821",
9+ "submitted_at": "2026-05-15T15:20:38Z",
10+ "state": "approved",
11+ "html_url": "https://github.com/acme/web/pull/2#pullrequestreview-237895671",
12+ "pull_request_url": "https://api.github.com/repos/acme/web/pulls/2"
13+ },
14+ "pull_request": {
15+ "id": 279147437,
16+ "node_id": "MDExOlB1bGxSZXF1ZXN0Mjc5MTQ3NDM3",
17+ "number": 2,
18+ "state": "open",
19+ "title": "Update the README with new information.",
20+ "head": { "ref": "changes", "sha": "ec26c3e57ca3a959ca5aad62de7213c562f8c821" },
21+ "base": { "ref": "main", "sha": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e" }
22+ },
23+ "repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } },
24+ "sender": { "login": "cy", "id": 21031070, "type": "User" }
25+}
+70−0
1+{
2+ "ref": "refs/heads/main",
3+ "before": "6113728f27ae82c7b1a177c8d03f9e96e0adf246",
4+ "after": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
5+ "created": false,
6+ "deleted": false,
7+ "forced": false,
8+ "base_ref": null,
9+ "compare": "https://github.com/acme/web/compare/6113728f27ae...0d1a26e67d8f",
10+ "commits": [
11+ {
12+ "id": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
13+ "tree_id": "f9d2a07e9488b91af2641b26b9407fe22a451433",
14+ "distinct": true,
15+ "message": "Update README.md",
16+ "timestamp": "2026-05-15T15:20:30-05:00",
17+ "url": "https://github.com/acme/web/commit/0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
18+ "author": { "name": "Ada", "email": "ada@example.com" },
19+ "committer": { "name": "Ada", "email": "ada@example.com" },
20+ "added": [],
21+ "removed": [],
22+ "modified": ["README.md"]
23+ }
24+ ],
25+ "head_commit": {
26+ "id": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
27+ "tree_id": "f9d2a07e9488b91af2641b26b9407fe22a451433",
28+ "distinct": true,
29+ "message": "Update README.md",
30+ "timestamp": "2026-05-15T15:20:30-05:00",
31+ "url": "https://github.com/acme/web/commit/0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
32+ "author": { "name": "Ada", "email": "ada@example.com" },
33+ "committer": { "name": "Ada", "email": "ada@example.com" },
34+ "added": [],
35+ "removed": [],
36+ "modified": ["README.md"]
37+ },
38+ "repository": {
39+ "id": 186853002,
40+ "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=",
41+ "name": "web",
42+ "full_name": "acme/web",
43+ "private": false,
44+ "owner": {
45+ "login": "acme",
46+ "id": 21031067,
47+ "type": "Organization",
48+ "html_url": "https://github.com/acme",
49+ "url": "https://api.github.com/users/acme"
50+ },
51+ "html_url": "https://github.com/acme/web",
52+ "url": "https://api.github.com/repos/acme/web",
53+ "clone_url": "https://github.com/acme/web.git",
54+ "description": null,
55+ "homepage": null,
56+ "topics": [],
57+ "default_branch": "main",
58+ "fork": false,
59+ "archived": false,
60+ "visibility": "public"
61+ },
62+ "pusher": { "name": "ada", "email": "ada@example.com" },
63+ "sender": {
64+ "login": "ada",
65+ "id": 21031068,
66+ "type": "User",
67+ "html_url": "https://github.com/ada",
68+ "url": "https://api.github.com/users/ada"
69+ }
70+}
+22−0
1+{
2+ "action": "published",
3+ "release": {
4+ "id": 17372790,
5+ "node_id": "MDc6UmVsZWFzZTE3MzcyNzkw",
6+ "tag_name": "v1.2.0",
7+ "target_commitish": "main",
8+ "name": "v1.2.0",
9+ "body": "Notes",
10+ "draft": false,
11+ "prerelease": false,
12+ "created_at": "2026-05-15T15:19:53Z",
13+ "published_at": "2026-05-15T15:20:05Z",
14+ "author": { "login": "ada", "id": 21031068, "type": "User", "html_url": "https://github.com/ada", "url": "https://api.github.com/users/ada" },
15+ "assets": [],
16+ "html_url": "https://github.com/acme/web/releases/tag/v1.2.0",
17+ "url": "https://api.github.com/repos/acme/web/releases/17372790",
18+ "zipball_url": "https://api.github.com/repos/acme/web/zipball/v1.2.0"
19+ },
20+ "repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } },
21+ "sender": { "login": "ada", "id": 21031068, "type": "User" }
22+}
+52−0
1+{
2+ "action": "completed",
3+ "workflow_run": {
4+ "id": 30433642,
5+ "node_id": "MDEyOldvcmtmbG93IFJ1bjI2OTI4OQ==",
6+ "name": "Build",
7+ "display_title": "Update README.md",
8+ "path": ".github/workflows/build.yml",
9+ "event": "push",
10+ "status": "completed",
11+ "conclusion": "success",
12+ "workflow_id": 159038,
13+ "head_sha": "acb5820ced9479c074f688cc328bf03f341a511d",
14+ "head_branch": "main",
15+ "head_commit": {
16+ "id": "acb5820ced9479c074f688cc328bf03f341a511d",
17+ "tree_id": "d23f6eedb1e1b9610bbc754ddb5197bfe7271223",
18+ "message": "Update README.md",
19+ "timestamp": "2026-05-15T15:20:30Z",
20+ "author": { "name": "Ada", "email": "ada@example.com" },
21+ "committer": { "name": "Ada", "email": "ada@example.com" }
22+ },
23+ "run_number": 562,
24+ "run_attempt": 1,
25+ "created_at": "2026-05-15T15:20:35Z",
26+ "run_started_at": "2026-05-15T15:20:35Z",
27+ "updated_at": "2026-05-15T15:21:10Z",
28+ "actor": { "login": "ada", "id": 21031068, "type": "User", "html_url": "https://github.com/ada", "url": "https://api.github.com/users/ada" },
29+ "triggering_actor": { "login": "ada", "id": 21031068, "type": "User", "html_url": "https://github.com/ada", "url": "https://api.github.com/users/ada" },
30+ "html_url": "https://github.com/acme/web/actions/runs/30433642",
31+ "url": "https://api.github.com/repos/acme/web/actions/runs/30433642",
32+ "jobs_url": "https://api.github.com/repos/acme/web/actions/runs/30433642/jobs",
33+ "logs_url": "https://api.github.com/repos/acme/web/actions/runs/30433642/logs",
34+ "artifacts_url": "https://api.github.com/repos/acme/web/actions/runs/30433642/artifacts",
35+ "workflow_url": "https://api.github.com/repos/acme/web/actions/workflows/159038",
36+ "pull_requests": [
37+ { "number": 2, "url": "https://api.github.com/repos/acme/web/pulls/2" }
38+ ],
39+ "repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } },
40+ "head_repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } }
41+ },
42+ "workflow": {
43+ "id": 159038,
44+ "node_id": "MDg6V29ya2Zsb3cxNTkwMzg=",
45+ "name": "Build",
46+ "path": ".github/workflows/build.yml",
47+ "state": "active",
48+ "html_url": "https://github.com/acme/web/blob/main/.github/workflows/build.yml"
49+ },
50+ "repository": { "id": 186853002, "full_name": "acme/web", "owner": { "login": "acme", "id": 21031067, "type": "Organization" } },
51+ "sender": { "login": "ada", "id": 21031068, "type": "User" }
52+}