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
12 files+1196−870/12 viewed
| 989 | 989 | | --- | --- | | |
| 990 | 990 | | `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`) | | |
| 991 | 991 | | `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) | | |
| 993 | 993 | | `repository_visibility` | `public` or `private` | | |
| 994 | 994 | | `ref`, `ref_type`, `ref_protected`, `sha` | `refs/heads/main`, `branch`, `"true"`, the commit | | |
| 995 | 995 | | `head_ref`, `base_ref` | A pull request's branches | | |
| ⋯ | |||
| 1405 | 1405 | on anyone else's. `github.event.issue.requested_by` does the same for an | |
| 1406 | 1406 | issue g1t's agent filed. `sender` is whoever caused the event. | |
| 1407 | 1407 | ||
| 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 | + | ||
| 1408 | 1448 | ## Checks | |
| 1409 | 1449 | ||
| 1410 | 1450 | A pull request's checks are its workflows. Each workflow that runs on | |
| 104 | 104 | self.repository.split('/').next().unwrap_or_default() | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 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`]). | |
| 108 | 109 | 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 | + | } | |
| 110 | 128 | } | |
| 111 | 129 | ||
| 112 | 130 | /// The `github` context for a job. `token` is `github.token` (and | |
| ⋯ | |||
| 118 | 136 | "action_ref": "", | |
| 119 | 137 | "action_repository": "", | |
| 120 | 138 | "actor": self.actor, | |
| 121 | − | "actor_id": self.actor_id, | |
| 139 | + | "actor_id": self.actor_number(), | |
| 122 | 140 | "api_url": self.api_url, | |
| 123 | 141 | "base_ref": self.base_ref.clone().unwrap_or_default(), | |
| 124 | 142 | "env": "", | |
| ⋯ | |||
| 134 | 152 | "ref_protected": self.ref_name() == self.default_branch, | |
| 135 | 153 | "ref_type": self.ref_type(), | |
| 136 | 154 | "repository": self.repository, | |
| 137 | − | "repository_id": self.repository_id, | |
| 155 | + | "repository_id": self.repository_number(), | |
| 138 | 156 | "repository_owner": self.owner(), | |
| 139 | 157 | "repository_owner_id": self.owner_id(), | |
| 140 | 158 | "repositoryUrl": format!("{}/{}.git", self.server_url, self.repository), | |
| ⋯ | |||
| 164 | 182 | set("GITHUB_ACTIONS", "true".into()); | |
| 165 | 183 | set("G1T", "true".into()); | |
| 166 | 184 | set("GITHUB_ACTOR", self.actor.clone()); | |
| 167 | − | set("GITHUB_ACTOR_ID", self.actor_id.clone()); | |
| 185 | + | set("GITHUB_ACTOR_ID", self.actor_number()); | |
| 168 | 186 | set("GITHUB_API_URL", self.api_url.clone()); | |
| 169 | 187 | set("GITHUB_BASE_REF", self.base_ref.clone().unwrap_or_default()); | |
| 170 | 188 | set("GITHUB_EVENT_NAME", self.event_name.clone()); | |
| ⋯ | |||
| 177 | 195 | set("GITHUB_REF_PROTECTED", (self.ref_name() == self.default_branch).to_string()); | |
| 178 | 196 | set("GITHUB_REF_TYPE", self.ref_type().into()); | |
| 179 | 197 | set("GITHUB_REPOSITORY", self.repository.clone()); | |
| 180 | − | set("GITHUB_REPOSITORY_ID", self.repository_id.clone()); | |
| 198 | + | set("GITHUB_REPOSITORY_ID", self.repository_number()); | |
| 181 | 199 | set("GITHUB_REPOSITORY_OWNER", self.owner().to_owned()); | |
| 182 | 200 | set("GITHUB_REPOSITORY_OWNER_ID", self.owner_id()); | |
| 183 | 201 | set("GITHUB_RETENTION_DAYS", "14".into()); | |
| ⋯ | |||
| 202 | 220 | } | |
| 203 | 221 | } | |
| 204 | 222 | ||
| 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 | + | ||
| 205 | 257 | /// The owner's id in a webhook-shaped payload, `repository.owner.id`, a | |
| 206 | 258 | /// number or a string. | |
| 207 | 259 | pub fn owner_id(event: &Value) -> Option<String> { | |
| ⋯ | |||
| 301 | 353 | assert_eq!(vars["GITHUB_JOB"], "build"); | |
| 302 | 354 | assert_eq!(vars["RUNNER_OS"], "Linux"); | |
| 303 | 355 | 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"); | |
| 305 | 384 | } | |
| 306 | 385 | ||
| 307 | 386 | #[test] | |
| 1 | 1 | //! `github.event`: the webhook-shaped payload GitHub gives a workflow, | |
| 2 | 2 | //! built from g1t's own records so `github.event.pull_request.number`, | |
| 3 | 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. | |
| 4 | 12 | ||
| 13 | + | use g1t_actions::events::{account_id, github_id}; | |
| 5 | 14 | use g1t_contracts::{PrincipalKind, User}; | |
| 6 | − | use g1t_contracts::repos::{Commit, Repo}; | |
| 15 | + | use g1t_contracts::repos::{Commit, Repo, RepoPath}; | |
| 7 | 16 | use g1t_contracts::work::{Comment, Issue, MilestoneRef, Pull, PullStatus, State}; | |
| 8 | 17 | use serde_json::{Value, json}; | |
| 9 | 18 | ||
| 10 | 19 | use crate::{API, SITE}; | |
| 11 | 20 | ||
| 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 | + | ||
| 12 | 37 | pub fn repository(repo: &Repo) -> Value { | |
| 13 | 38 | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 14 | 39 | json!({ | |
| 15 | − | "id": repo.id, | |
| 40 | + | "id": github_id(&repo.id), | |
| 16 | 41 | "node_id": repo.id, | |
| 17 | 42 | "name": repo.name, | |
| 18 | 43 | "full_name": full_name, | |
| 19 | 44 | "private": repo.is_private, | |
| 20 | − | "owner": { "login": repo.namespace, "type": "Organization" }, | |
| 45 | + | "owner": owner(&repo.namespace), | |
| 21 | 46 | "html_url": format!("{SITE}/{full_name}"), | |
| 22 | 47 | "url": format!("{API}/repos/{full_name}"), | |
| 23 | 48 | "clone_url": format!("{SITE}/{full_name}.git"), | |
| 24 | 49 | "description": repo.description, | |
| 50 | + | "homepage": repo.website, | |
| 51 | + | "topics": repo.topics, | |
| 25 | 52 | "default_branch": repo.default_branch, | |
| 26 | 53 | "fork": false, | |
| 54 | + | "archived": repo.archived(), | |
| 55 | + | "created_at": repo.created_at, | |
| 27 | 56 | "visibility": if repo.is_private { "private" } else { "public" }, | |
| 28 | 57 | }) | |
| 29 | 58 | } | |
| 30 | 59 | ||
| 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 | + | ||
| 31 | 93 | 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 | + | }) | |
| 33 | 101 | } | |
| 34 | 102 | ||
| 35 | 103 | /// Someone as GitHub names them. g1t, the author of what it makes and | |
| 36 | 104 | /// files, is a `Bot`, as an app is there. | |
| 37 | 105 | fn person(user: &User) -> Value { | |
| 38 | 106 | let mut named = self::user(&user.username); | |
| 107 | + | named["node_id"] = json!(user.id); | |
| 39 | 108 | if matches!(user.kind, PrincipalKind::Agent | PrincipalKind::System) { | |
| 40 | 109 | named["type"] = json!("Bot"); | |
| 41 | 110 | } | |
| 42 | 111 | named | |
| 43 | 112 | } | |
| 44 | 113 | ||
| 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 | |
| 47 | 129 | } | |
| 48 | 130 | ||
| 49 | 131 | /// 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 | + | }) | |
| 52 | 143 | } | |
| 53 | 144 | ||
| 54 | 145 | pub fn commit(repo: &Repo, commit: &Commit) -> Value { | |
| ⋯ | |||
| 61 | 152 | "author": { "name": commit.author.name, "email": commit.author.email }, | |
| 62 | 153 | "committer": { "name": commit.author.name, "email": commit.author.email }, | |
| 63 | 154 | "distinct": true, | |
| 155 | + | "added": [], | |
| 156 | + | "removed": [], | |
| 157 | + | "modified": [], | |
| 64 | 158 | }) | |
| 65 | 159 | } | |
| 66 | 160 | ||
| 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; | |
| 69 | 167 | let commits: Vec<Value> = commits.iter().map(|c| commit(repo, c)).collect(); | |
| 168 | + | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 169 | + | let compare = if created { | |
| 170 | + | format!("{SITE}/{full_name}/commit/{after}") | |
| 171 | + | } else { | |
| 172 | + | format!("{SITE}/{full_name}/compare/{before}...{after}") | |
| 173 | + | }; | |
| 70 | 174 | json!({ | |
| 71 | 175 | "ref": git_ref, | |
| 72 | − | "before": before.unwrap_or(zero), | |
| 176 | + | "before": before, | |
| 73 | 177 | "after": after, | |
| 74 | − | "created": before.is_none(), | |
| 75 | − | "deleted": false, | |
| 76 | − | "forced": false, | |
| 178 | + | "created": created, | |
| 179 | + | "deleted": deleted, | |
| 180 | + | "forced": forced && !created && !deleted, | |
| 77 | 181 | "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), | |
| 80 | 185 | "commits": commits, | |
| 81 | 186 | "pusher": { "name": pusher, "email": null }, | |
| 82 | 187 | "repository": repository(repo), | |
| ⋯ | |||
| 87 | 192 | pub fn issue(repo: &Repo, issue: &Issue) -> Value { | |
| 88 | 193 | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 89 | 194 | json!({ | |
| 90 | − | "id": issue.id, | |
| 195 | + | "id": github_id(&issue.id), | |
| 196 | + | "node_id": issue.id, | |
| 91 | 197 | "number": issue.number, | |
| 92 | 198 | "title": issue.title, | |
| 93 | 199 | "body": issue.body, | |
| 94 | 200 | "state": if issue.state == State::Open { "open" } else { "closed" }, | |
| 95 | 201 | "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()), | |
| 98 | 205 | "user": person(&issue.author), | |
| 99 | 206 | // g1t's own field: for an issue its agent filed, who it worked for. | |
| 100 | 207 | "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), | |
| 102 | 210 | "comments": issue.comment_count, | |
| 103 | 211 | "created_at": issue.created_at, | |
| 104 | 212 | "updated_at": issue.updated_at, | |
| 105 | 213 | "closed_at": issue.closed_at, | |
| 106 | 214 | "html_url": format!("{SITE}/{full_name}/issues/{}", issue.number), | |
| 107 | 215 | "url": format!("{API}/repos/{full_name}/issues/{}", issue.number), | |
| 216 | + | "repository_url": format!("{API}/repos/{full_name}"), | |
| 108 | 217 | }) | |
| 109 | 218 | } | |
| 110 | 219 | ||
| 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). | |
| 112 | 223 | pub fn pull(repo: &Repo, pull: &Pull) -> Value { | |
| 113 | 224 | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 114 | 225 | let head_ref = head_ref(pull); | |
| 115 | 226 | 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()), | |
| 119 | 230 | }; | |
| 231 | + | let merged = pull.status == PullStatus::Merged; | |
| 232 | + | let closed_at = match pull.status { | |
| 233 | + | PullStatus::Merged => pull.merged_at.clone(), | |
| 234 | + | PullStatus::Closed => Some(pull.updated_at.clone()), | |
| 235 | + | _ => None, | |
| 236 | + | }; | |
| 120 | 237 | json!({ | |
| 121 | − | "id": pull.id, | |
| 238 | + | "id": github_id(&pull.id), | |
| 239 | + | "node_id": pull.id, | |
| 122 | 240 | "number": pull.number, | |
| 123 | 241 | "title": pull.title, | |
| 124 | 242 | "body": pull.body, | |
| 125 | 243 | "state": if pull.status.is_active() { "open" } else { "closed" }, | |
| 244 | + | "locked": false, | |
| 126 | 245 | "draft": pull.status == PullStatus::Draft, | |
| 127 | − | "merged": pull.status == PullStatus::Merged, | |
| 246 | + | "merged": merged, | |
| 128 | 247 | "merged_at": pull.merged_at, | |
| 129 | 248 | "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()), | |
| 133 | 254 | "user": person(&pull.author), | |
| 134 | 255 | // g1t's own field: for a change g1t made, who asked for it. | |
| 135 | 256 | "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), | |
| 138 | 260 | "head": { | |
| 139 | 261 | "ref": head_ref, | |
| 140 | 262 | "sha": pull.head_commit, | |
| 141 | − | "label": format!("{}:{head_ref}", repo.namespace), | |
| 263 | + | "label": format!("{head_owner}:{head_ref}"), | |
| 264 | + | "user": owner(head_owner), | |
| 142 | 265 | "repo": head_repo, | |
| 143 | 266 | }, | |
| 144 | 267 | "base": { | |
| 145 | 268 | "ref": base_ref, | |
| 146 | 269 | "sha": pull.merge_base, | |
| 147 | 270 | "label": format!("{}:{base_ref}", repo.namespace), | |
| 271 | + | "user": owner(&repo.namespace), | |
| 148 | 272 | "repo": repository(repo), | |
| 149 | 273 | }, | |
| 274 | + | "changed_files": pull.files.len(), | |
| 275 | + | "additions": pull.files.iter().map(|f| u64::from(f.additions)).sum::<u64>(), | |
| 276 | + | "deletions": pull.files.iter().map(|f| u64::from(f.deletions)).sum::<u64>(), | |
| 277 | + | "created_at": pull.created_at, | |
| 278 | + | "updated_at": pull.updated_at, | |
| 279 | + | "closed_at": closed_at, | |
| 150 | 280 | "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number), | |
| 151 | 281 | "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), | |
| 153 | 283 | }) | |
| 154 | 284 | } | |
| 155 | 285 | ||
| ⋯ | |||
| 159 | 289 | /// one event for them all, GitHub one each), and for a new base, | |
| 160 | 290 | /// `changes.base.ref.from` is not known, so only the new base is in | |
| 161 | 291 | /// `pull_request.base`. | |
| 162 | − | pub fn changed(payload: &mut Value, data: &Value) { | |
| 292 | + | pub fn changed(repo: &Repo, payload: &mut Value, data: &Value) { | |
| 163 | 293 | if let Some(first) = data["added"].as_array().and_then(|added| added.first()).and_then(Value::as_str) { | |
| 164 | 294 | payload["assignee"] = user(first); | |
| 165 | 295 | } | |
| 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"])); | |
| 168 | 298 | } | |
| 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 | + | }; | |
| 171 | 304 | } | |
| 172 | 305 | } | |
| 173 | 306 | ||
| ⋯ | |||
| 176 | 309 | pull.branch.clone().unwrap_or_else(|| format!("pull/{}", pull.number)) | |
| 177 | 310 | } | |
| 178 | 311 | ||
| 312 | + | /// A comment. Its `id` is g1t's (`cmt_…`), as the API takes it back to | |
| 313 | + | /// edit or delete it. | |
| 179 | 314 | pub fn comment(repo: &Repo, number: u32, comment: &Comment, on_pull: bool) -> Value { | |
| 180 | 315 | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 181 | 316 | let page = if on_pull { "pull" } else { "issues" }; | |
| 182 | 317 | json!({ | |
| 183 | 318 | "id": comment.id, | |
| 319 | + | "node_id": comment.id, | |
| 184 | 320 | "body": comment.body, | |
| 185 | 321 | "user": person(&comment.author), | |
| 186 | 322 | "created_at": comment.created_at, | |
| 187 | − | "updated_at": comment.created_at, | |
| 323 | + | "updated_at": comment.edited_at.as_deref().unwrap_or(&comment.created_at), | |
| 188 | 324 | "path": comment.path, | |
| 189 | 325 | "line": comment.line, | |
| 190 | 326 | "html_url": format!("{SITE}/{full_name}/{page}/{number}#{}", comment.id), | |
| 327 | + | "url": format!("{API}/repos/{full_name}/issues/comments/{}", comment.id), | |
| 328 | + | "issue_url": format!("{API}/repos/{full_name}/issues/{number}"), | |
| 329 | + | }) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// A review, for `pull_request_review`: g1t keeps a review as a comment | |
| 333 | + | /// with a verdict, and its id is that comment's. | |
| 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), | |
| 191 | 349 | }) | |
| 192 | 350 | } | |
| 193 | 351 | ||
| 194 | 352 | /// 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. | |
| 196 | 355 | pub fn pull_as_issue(repo: &Repo, pull: &Pull) -> Value { | |
| 197 | 356 | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 198 | 357 | json!({ | |
| 199 | − | "id": pull.id, | |
| 358 | + | "id": github_id(&format!("issue:{}", pull.id)), | |
| 359 | + | "node_id": pull.id, | |
| 200 | 360 | "number": pull.number, | |
| 201 | 361 | "title": pull.title, | |
| 202 | 362 | "body": pull.body, | |
| 203 | 363 | "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()), | |
| 206 | 368 | "user": person(&pull.author), | |
| 207 | 369 | "requested_by": pull.requested_by.as_ref().map(person), | |
| 370 | + | "assignee": pull.assignees.first().map(|a| user(a)), | |
| 371 | + | "assignees": people(&pull.assignees), | |
| 372 | + | "created_at": pull.created_at, | |
| 373 | + | "updated_at": pull.updated_at, | |
| 208 | 374 | "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number), | |
| 375 | + | "url": format!("{API}/repos/{full_name}/issues/{}", pull.number), | |
| 376 | + | "repository_url": format!("{API}/repos/{full_name}"), | |
| 209 | 377 | "pull_request": { | |
| 210 | 378 | "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number), | |
| 211 | 379 | "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number), | |
| ⋯ | |||
| 223 | 391 | let login = kept["author"]["username"].as_str().unwrap_or_default(); | |
| 224 | 392 | json!({ | |
| 225 | 393 | "id": id, | |
| 394 | + | "node_id": id, | |
| 226 | 395 | "body": kept["body"], | |
| 227 | 396 | "user": user(login), | |
| 228 | 397 | "created_at": kept["createdAt"], | |
| 229 | − | "updated_at": kept["createdAt"], | |
| 398 | + | "updated_at": kept["editedAt"].as_str().map_or(kept["createdAt"].clone(), |at| json!(at)), | |
| 230 | 399 | "path": kept["path"], | |
| 231 | 400 | "line": kept["line"], | |
| 232 | 401 | "html_url": format!("{SITE}/{full_name}/{page}/{number}#{id}"), | |
| 402 | + | "url": format!("{API}/repos/{full_name}/issues/comments/{id}"), | |
| 403 | + | "issue_url": format!("{API}/repos/{full_name}/issues/{number}"), | |
| 233 | 404 | }) | |
| 234 | 405 | } | |
| 235 | 406 | ||
| 407 | + | /// What `workflow_run` is told of the run it follows: the | |
| 408 | + | /// `workflow.completed` event's data, and the run's row when it is still | |
| 409 | + | /// there. Its `id` and `workflow_id` are g1t's (`run_…`, `wfl_…`), which | |
| 410 | + | /// `GITHUB_RUN_ID` and the API use too, so `actions/download-artifact` | |
| 411 | + | /// with `run-id: ${{ github.event.workflow_run.id }}` finds its artifacts. | |
| 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 | + | ||
| 236 | 475 | /// 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. | |
| 238 | 478 | pub fn release(repo: &Repo, release: &Value) -> Value { | |
| 239 | 479 | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 240 | 480 | let tag = release["tagName"].as_str().unwrap_or_default(); | |
| ⋯ | |||
| 254 | 494 | "assets": [], | |
| 255 | 495 | "html_url": format!("{SITE}/{full_name}/releases/tag/{tag}"), | |
| 256 | 496 | "url": format!("{API}/repos/{full_name}/releases/{id}"), | |
| 497 | + | "zipball_url": format!("{SITE}/{full_name}/archive/{tag}.zip"), | |
| 257 | 498 | }) | |
| 258 | 499 | } | |
| 259 | 500 | ||
| ⋯ | |||
| 324 | 565 | assert_eq!(review_state(g1t_contracts::work::Verdict::RequestChanges), "changes_requested"); | |
| 325 | 566 | } | |
| 326 | 567 | } | |
| 568 | + | ||
| 569 | + | /// g1t's payloads against GitHub's: each fixture in `tests/fixtures` is a | |
| 570 | + | /// trimmed webhook payload in GitHub's documented shape, and every key it | |
| 571 | + | /// has must be in g1t's payload for the same event with the same JSON type | |
| 572 | + | /// (a whole number where GitHub's is one). `null` in a fixture takes any | |
| 573 | + | /// type. Where g1t differs on purpose, `KNOWN` says how and why. | |
| 574 | + | #[cfg(test)] | |
| 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 | + | } | |
| 568 | 568 | contexts | |
| 569 | 569 | } | |
| 570 | 570 | ||
| 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 | + | ||
| 571 | 602 | /// Makes a run and its jobs, and starts what can start. `None` when the | |
| 572 | 603 | /// event already started this workflow. | |
| 573 | 604 | pub async fn create_run(&self, new: NewRun) -> Result<Option<String>> { | |
| ⋯ | |||
| 586 | 617 | .map_or(1, |count| count.n); | |
| 587 | 618 | let id = new_id("run", now_ms()); | |
| 588 | 619 | 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 | + | } | |
| 589 | 624 | info.workflow = new.workflow.display_name(&new.path); | |
| 590 | 625 | info.workflow_path = new.path.clone(); | |
| 591 | 626 | info.run_id = id.clone(); | |
| 20 | 20 | use crate::sync::{Read, WorkflowRow}; | |
| 21 | 21 | use crate::{API, Actions, SITE, check, fail, payload}; | |
| 22 | 22 | ||
| 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 | + | ||
| 23 | 27 | /// What an event is about, worked out once for every workflow it starts. | |
| 24 | 28 | struct Subject { | |
| 25 | 29 | /// Where the workflow files are read, and at which commit. | |
| ⋯ | |||
| 124 | 128 | } | |
| 125 | 129 | ||
| 126 | 130 | 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)> { | |
| 127 | 139 | let log: Outcome<Vec<Commit>> = g1t_kit::call( | |
| 128 | 140 | &self.repos, | |
| 129 | 141 | "log", | |
| ⋯ | |||
| 134 | 146 | }, | |
| 135 | 147 | viewer: Some(actor.clone()), | |
| 136 | 148 | git_ref: Some(after.to_owned()), | |
| 137 | − | limit: 20, | |
| 149 | + | limit: PUSH_LOG, | |
| 138 | 150 | }, | |
| 139 | 151 | ) | |
| 140 | 152 | .await?; | |
| 141 | 153 | 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 | + | } | |
| 146 | 161 | } | |
| 147 | 162 | // GitHub lists them oldest first, with the head commit last. | |
| 148 | 163 | commits.reverse(); | |
| 149 | − | Ok(commits) | |
| 164 | + | Ok((commits, forced)) | |
| 150 | 165 | } | |
| 151 | 166 | ||
| 152 | 167 | /// The message of the commit `sha` in `path`, when it can be read. | |
| 153 | 168 | 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>> { | |
| 154 | 174 | let log: Outcome<Vec<Commit>> = g1t_kit::call( | |
| 155 | 175 | &self.repos, | |
| 156 | 176 | "log", | |
| 157 | 177 | &LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: Some(sha.to_owned()), limit: 1 }, | |
| 158 | 178 | ) | |
| 159 | 179 | .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())) | |
| 161 | 181 | } | |
| 162 | 182 | ||
| 163 | 183 | async fn changed_paths(&self, repo: &Repo, actor: &User, base: Option<String>, head: String) -> Result<Vec<String>> { | |
| ⋯ | |||
| 177 | 197 | } | |
| 178 | 198 | ||
| 179 | 199 | 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>> { | |
| 180 | 205 | g1t_kit::call( | |
| 181 | 206 | &self.repos, | |
| 182 | 207 | "head", | |
| 183 | 208 | &g1t_contracts::repos::HeadArgs { | |
| 184 | 209 | repo_id: repo.id.clone(), | |
| 185 | − | branch: repo.default_branch.clone(), | |
| 210 | + | branch: branch.to_owned(), | |
| 186 | 211 | }, | |
| 187 | 212 | ) | |
| 188 | 213 | .await | |
| ⋯ | |||
| 245 | 270 | return Ok(None); | |
| 246 | 271 | } | |
| 247 | 272 | 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?; | |
| 249 | 274 | 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); | |
| 254 | 276 | Some(Subject { | |
| 255 | 277 | source: path.clone(), | |
| 256 | 278 | source_ref: Some(after.to_owned()), | |
| ⋯ | |||
| 337 | 359 | "repository": payload::repository(repo), | |
| 338 | 360 | "sender": payload::user(sender), | |
| 339 | 361 | }); | |
| 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 | + | } | |
| 341 | 374 | if event_name == "pull_request_review" { | |
| 342 | 375 | 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); | |
| 348 | 377 | } | |
| 349 | 378 | let trusted = self.insider(pull.owner(), repo, ws).await?; | |
| 350 | 379 | // A pull request from outside may wait for approval before | |
| ⋯ | |||
| 410 | 439 | let Some(sha) = self.default_head(repo).await? else { return Ok(None) }; | |
| 411 | 440 | let head_branch = data["ref"].as_str().unwrap_or_default().trim_start_matches("refs/heads/").to_owned(); | |
| 412 | 441 | 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 | + | }; | |
| 413 | 463 | let payload = json!({ | |
| 414 | 464 | "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, | |
| 417 | 469 | "name": name, | |
| 418 | 470 | "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), | |
| 427 | 473 | }, | |
| 428 | − | "workflow": { "name": name, "path": data["path"] }, | |
| 429 | 474 | "repository": payload::repository(repo), | |
| 430 | 475 | "sender": payload::user(sender), | |
| 431 | 476 | }); | |
| ⋯ | |||
| 455 | 500 | "repository": payload::repository(repo), | |
| 456 | 501 | "sender": payload::user(sender), | |
| 457 | 502 | }); | |
| 458 | − | payload::changed(&mut payload, data); | |
| 503 | + | payload::changed(repo, &mut payload, data); | |
| 459 | 504 | if event_name == "issue_comment" { | |
| 460 | 505 | let comment_id = data["commentId"].as_str(); | |
| 461 | 506 | if action == Some("deleted") { | |
| ⋯ | |||
| 1410 | 1455 | pull.milestone = Some(g1t_contracts::work::MilestoneRef { number: 2, title: "1.1".into() }); | |
| 1411 | 1456 | let event = payload::pull(&repo(), &pull); | |
| 1412 | 1457 | 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()); | |
| 1414 | 1460 | assert_eq!(event["milestone"]["title"], "1.1"); | |
| 1415 | 1461 | 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" } })); | |
| 1417 | 1463 | assert_eq!(labeled["label"]["color"], "d73a4a"); | |
| 1418 | 1464 | 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"] })); | |
| 1420 | 1466 | assert_eq!(assigned["assignee"]["login"], "bo"); | |
| 1421 | 1467 | assert!(nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": [] }))); | |
| 1422 | 1468 | assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] }))); | |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |