Numeric ids for runs, jobs and workflows, so gh run and gh workflow work #325
12 files changed+180−52base 04dacaa → head b86dc2f
Files (12)
- apps/diffinery/docs/actions/overview.md+1−1
- apps/diffinery/docs/agents/github-cli.md+5−3
- apps/diffinery/docs/collaboration/checks-and-statuses.md+3−3
- apps/diffinery/docs/reference/rest-api.md+2−2
- apps/diffinery/src/http/api.ts+10−6
- apps/diffinery/src/http/gh.ts+53−14
- apps/diffinery/src/http/misc.ts+5−4
- apps/diffinery/src/http/statuses.ts+3−3
- apps/diffinery/src/repo/actions.ts+14−7
- apps/diffinery/src/repo/gitdata.ts+47−8
- apps/diffinery/src/repo/runtime.ts+5−1
- apps/diffinery/test/unit/rerun.test.ts+32−0
modifiedapps/diffinery/docs/actions/overview.md+1−1View file
| @@ -310,7 +310,7 @@ | ||
| 310 | 310 | | `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` | Re-runs the run's failed jobs and the jobs that depend on them (`201`, `{"run_id": ...}`). See below. | |
| 311 | 311 | | `POST /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}/rerun` | Re-runs one job and the jobs that depend on it (`201`, `{"run_id": ...}`). See below. | |
| 312 | 312 | |
| 313 | -Run objects use GitHub's field names (`id`, `node_id`, `run_number`, `run_attempt`, `name`, `display_title`, `path`, `workflow_id`, `event`, `head_branch`, `head_sha`, `sha`, `ref`, `status`, `conclusion`, `actor` and `triggering_actor` as user objects, `created_at`, `updated_at`, `run_started_at`, `url`, `html_url`, `jobs_url`, `logs_url`, `artifacts_url`, `cancel_url`, `rerun_url`, `workflow_url`, `previous_attempt_url` and `pull_requests`) plus `pull_request_number`. Job objects carry GitHub's fields (`run_id`, `run_attempt`, `head_sha`, `head_branch`, `html_url`, `started_at`, `completed_at`, `workflow_name`, `steps` with `number`) next to Diffinery's own camelCase fields, which the CLI reads. `head_sha` is the commit checks are reported on (the pull request head for pull request runs) and `sha` is the commit the run checked out. Run and job IDs are 32-character hexadecimal strings. Wherever a path takes `{run_id}`, the run number works too and means its latest attempt; this is what `github.run_id` and `GITHUB_RUN_ID` hold inside a job. A job's `check_run_url` points at `GET /repos/{owner}/{repo}/check-runs/{job_id}`, which returns the job's check run. While a job is running, one logs request returns at most 5,000 lines; page with `after`. The runs, jobs and artifacts lists send a `Link` header with `next`, `prev`, `first` and `last` URLs, so Octokit's `paginate()` works. Errors have a `message` and a `documentation_url`. Every endpoint is listed in the [API reference](/docs/api). | |
| 313 | +Run objects use GitHub's field names (`id`, `node_id`, `run_number`, `run_attempt`, `name`, `display_title`, `path`, `workflow_id`, `event`, `head_branch`, `head_sha`, `sha`, `ref`, `status`, `conclusion`, `actor` and `triggering_actor` as user objects, `created_at`, `updated_at`, `run_started_at`, `url`, `html_url`, `jobs_url`, `logs_url`, `artifacts_url`, `cancel_url`, `rerun_url`, `workflow_url`, `previous_attempt_url` and `pull_requests`) plus `pull_request_number`. Job objects carry GitHub's fields (`run_id`, `run_attempt`, `head_sha`, `head_branch`, `html_url`, `started_at`, `completed_at`, `workflow_name`, `steps` with `number`) next to Diffinery's own camelCase fields, which the CLI reads. `head_sha` is the commit checks are reported on (the pull request head for pull request runs) and `sha` is the commit the run checked out. Run, job, workflow and check suite IDs are numbers, as on GitHub, so `gh run` and `gh workflow` work: a run's or job's `id` has 16 digits, and a run's `check_suite_id` is its `id`. Paths also take the 32-character hexadecimal ids the web UI's addresses use, and a workflow's file name or path in place of `{workflow_id}`. Wherever a path takes `{run_id}`, the run number works too and means its latest attempt; this is what `github.run_id` and `GITHUB_RUN_ID` hold inside a job. A job's `check_run_url` points at `GET /repos/{owner}/{repo}/check-runs/{job_id}`, which returns the job's check run. `GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs` is a zip laid out like GitHub's: a text file per job and a folder per job with a file per step, which `gh run view --log` reads. While a job is running, one logs request returns at most 5,000 lines; page with `after`. The runs, jobs and artifacts lists send a `Link` header with `next`, `prev`, `first` and `last` URLs, so Octokit's `paginate()` works. Errors have a `message` and a `documentation_url`. Every endpoint is listed in the [API reference](/docs/api). | |
| 314 | 314 | |
| 315 | 315 | ### From MCP |
| 316 | 316 |
modifiedapps/diffinery/docs/agents/github-cli.md+5−3View file
| @@ -44,13 +44,15 @@ | ||
| 44 | 44 | gh issue create --title "The parser drops comments" --label bug |
| 45 | 45 | gh issue list --label bug |
| 46 | 46 | gh issue close 42 --reason "not planned" |
| 47 | +gh run list --workflow ci.yml | |
| 48 | +gh run view --log-failed | |
| 47 | 49 | ``` |
| 48 | 50 | |
| 49 | 51 | | Works | Commands | |
| 50 | 52 | | --- | --- | |
| 51 | -| Reading | `gh pr view` (with `--json`), `gh pr list`, `gh pr status`, `gh pr checks`, `gh pr diff`, `gh issue list` (with `--label`, `--assignee`, `--author`, `--mention`, `--search`, `--state`), `gh issue view` (with `--comments` and `--json`), `gh issue status`, `gh repo view`, `gh label list`, `gh release list`, `gh api` (REST and `gh api graphql`) | | |
| 52 | -| Changing | `gh pr create` (with `--draft`, `--reviewer`, `--assignee`, `--label`), `gh pr edit`, `gh pr ready`, `gh pr comment`, `gh pr close`, `gh pr reopen`, `gh pr merge` (`--merge`, `--squash`, `--rebase`, `--auto`, `--disable-auto`), `gh issue create` (with `--label`, `--assignee`, `--milestone`), `gh issue edit`, `gh issue comment`, `gh issue close` (with `--reason`), `gh issue reopen`, `gh label create` | | |
| 53 | -| Not yet | Team reviewers, projects, `gh issue delete`, `lock`, `pin` and `transfer`. Use `gh api` with the REST paths for anything else. | | |
| 53 | +| Reading | `gh pr view` (with `--json`), `gh pr list`, `gh pr status`, `gh pr checks`, `gh pr diff`, `gh issue list` (with `--label`, `--assignee`, `--author`, `--mention`, `--search`, `--state`), `gh issue view` (with `--comments` and `--json`), `gh issue status`, `gh run list`, `gh run view` (with `--job`, `--log`, `--log-failed` and `--json`), `gh run watch`, `gh workflow list`, `gh workflow view`, `gh repo view`, `gh label list`, `gh release list`, `gh api` (REST and `gh api graphql`) | | |
| 54 | +| Changing | `gh pr create` (with `--draft`, `--reviewer`, `--assignee`, `--label`), `gh pr edit`, `gh pr ready`, `gh pr comment`, `gh pr close`, `gh pr reopen`, `gh pr merge` (`--merge`, `--squash`, `--rebase`, `--auto`, `--disable-auto`), `gh issue create` (with `--label`, `--assignee`, `--milestone`), `gh issue edit`, `gh issue comment`, `gh issue close` (with `--reason`), `gh issue reopen`, `gh run rerun` (with `--failed` and `--job`), `gh run cancel`, `gh workflow run` (with `--ref` and `-f` inputs), `gh label create` | | |
| 55 | +| Not yet | Team reviewers, projects, `gh issue delete`, `lock`, `pin` and `transfer`, `gh run delete`, `gh workflow enable` and `disable`. Use `gh api` with the REST paths for anything else. | | |
| 54 | 56 | |
| 55 | 57 | gh changes things with your token's permissions and Diffinery's rules: branch protection, required checks and reviews, and the [agent guardrails](/docs/agents/overview) apply as they do on the web and over REST. `gh pr merge --auto` turns on Diffinery's auto-merge, which merges once the required checks pass. When nothing blocks the merge, gh merges at once. |
| 56 | 58 |
modifiedapps/diffinery/docs/collaboration/checks-and-statuses.md+3−3View file
| @@ -249,7 +249,7 @@ | ||
| 249 | 249 | "annotations_url": "https://diffinery.com/api/v3/repos/acme/website/check-runs/31/annotations" |
| 250 | 250 | }, |
| 251 | 251 | "name": "ci/external", |
| 252 | - "check_suite": { "id": "app-ci-bot-9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d" }, | |
| 252 | + "check_suite": { "id": 6145803720560937 }, | |
| 253 | 253 | "app": { "id": "ci-bot", "slug": "ci-bot", "node_id": "QXBwOmNpLWJvdA", "name": "ci-bot", "owner": null }, |
| 254 | 254 | "pull_requests": [], |
| 255 | 255 | "run_id": null, |
| @@ -257,7 +257,7 @@ | ||
| 257 | 257 | } |
| 258 | 258 | ``` |
| 259 | 259 | |
| 260 | -- `app` is the login of the account that reported the check run, or `diffinery-actions` for Actions. `run_id` and `job_id` are set only for Actions checks. | |
| 260 | +- `app` is the login of the account that reported the check run, or `diffinery-actions` for Actions. `run_id` and `job_id` are set only for Actions checks: the `id`s of the workflow run and job behind it. | |
| 261 | 261 | - A check run is keyed by commit, name and reporter. Posting again with the same `head_sha` and `name` from the same account updates that check run (same `id`) instead of adding a second one. |
| 262 | 262 | - Accounts whose login starts with `diffinery-actions` can't report check runs (`this account can't report check runs`). |
| 263 | 263 | |
| @@ -276,7 +276,7 @@ | ||
| 276 | 276 | | `GET /repos/{owner}/{repo}/check-suites/{check_suite_id}` | One check suite | |
| 277 | 277 | | `GET /repos/{owner}/{repo}/check-suites/{check_suite_id}/check-runs` | The check runs of a suite | |
| 278 | 278 | |
| 279 | -Check suites are derived, not created: each Actions workflow run is one suite (its id is the run id), and each external reporter has one suite per commit (id `app-<login>-<sha>`). A suite is `completed` when all its check runs are, with conclusion `failure` if any of them failed, timed out, was cancelled or needs action, and `success` otherwise. You can't create or re-request suites, and commit statuses are not part of any suite. | |
| 279 | +Check suites are derived, not created: each Actions workflow run is one suite (its id is the run's `id`), and each external reporter has one suite per commit, with a number of its own. A suite is `completed` when all its check runs are, with conclusion `failure` if any of them failed, timed out, was cancelled or needs action, and `success` otherwise. You can't create or re-request suites, and commit statuses are not part of any suite. | |
| 280 | 280 | |
| 281 | 281 | ## Required checks |
| 282 | 282 |
modifiedapps/diffinery/docs/reference/rest-api.md+2−2View file
| @@ -85,9 +85,9 @@ | ||
| 85 | 85 | | Users, organizations, agents, repositories | A positive integer derived from the account's or repository's own ID in Diffinery. It is stable, but nothing looks an account or repository up by it: use the login or `owner/repo` in paths. | |
| 86 | 86 | | Issues and pull requests | The `id` equals the `number` (issues and pull requests share one number sequence per repository). | |
| 87 | 87 | | Issue comments, review comments, reviews, check runs, releases, release assets, commit statuses, deployments, deployment statuses, reactions, issue events | Integers, as on GitHub. Use them in paths such as `/issues/comments/{id}` and `/check-runs/{id}`. | |
| 88 | -| Workflow runs and jobs | 32-character hexadecimal strings. Use them as they are in `/actions/runs/{id}` and `/actions/jobs/{id}`. Getting a run, its jobs, logs and attempts, re-running it and listing its artifacts also accept the run's number (`run_number`, which is `github.run_id` inside a workflow): it picks the latest attempt, and for artifacts it covers every attempt. | | |
| 88 | +| Workflow runs, jobs and check suites | 16-digit integers, so clients that expect GitHub's numbers (such as `gh run`) work. Use them in `/actions/runs/{id}`, `/actions/jobs/{id}` and `/check-suites/{id}`; a run's check suite has the run's `id`. These paths also take the 32-character hexadecimal ids in the web UI's addresses (`html_url`) and in webhook payloads. Getting a run, its jobs, logs and attempts, re-running it and listing its artifacts also accept the run's number (`run_number`, which is `github.run_id` inside a workflow): it picks the latest attempt, and for artifacts it covers every attempt. | | |
| 89 | 89 | | Artifacts and cache entries | Integers, as on GitHub: `/actions/artifacts/{id}`, `/actions/caches/{id}`. An artifact's `workflow_run.id` is the run number. | |
| 90 | -| Workflows | The workflow's path, such as `.github/workflows/ci.yml`. `/actions/workflows/{id}` also accepts the file name, `ci.yml`. | | |
| 90 | +| Workflows | An integer derived from the workflow's path. `/actions/workflows/{id}` also accepts the path, such as `.github/workflows/ci.yml`, or the file name, `ci.yml`. | | |
| 91 | 91 | | Webhooks and tokens | Strings. | |
| 92 | 92 | |
| 93 | 93 | `node_id` is an opaque string, stable for an object. The [GraphQL API](/docs/reference/graphql-api) accepts the `node_id` of a repository or pull request in `node(id:)` and in its mutations. |
modifiedapps/diffinery/src/http/api.ts+10−6View file
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | import { compat } from "./compat.js"; |
| 27 | 27 | import { appsApi } from "./apps.js"; |
| 28 | 28 | import { isAppCaller } from "../repo/token-policy.js"; |
| 29 | -import { baseOf, encPath, ghAutoMerge, ghCheckRun, ghComment, ghCommit, ghGitCommit, ghJob, ghRef, ghRun, ghFile, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, normalizeHomepage, numericId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js"; | |
| 29 | +import { baseOf, encPath, ghAutoMerge, ghCheckRun, ghComment, ghCommit, ghGitCommit, ghJob, ghRef, ghRun, ghFile, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, normalizeHomepage, numericId, pageOf, paginate, repoApi, repoHtml, setLinks, uuidNumber, workflowNumber } from "./gh.js"; | |
| 30 | 30 | import { decodePath, issueCtx, issuesOut, pullDetail, pullsOut, repoOut } from "./ghctx.js"; |
| 31 | 31 | |
| 32 | 32 | /** |
| @@ -1080,13 +1080,17 @@ | ||
| 1080 | 1080 | api.get("/repos/:owner/:repo/actions/workflows", async (c) => { |
| 1081 | 1081 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 1082 | 1082 | const wfs = await stub.workflows(); |
| 1083 | - return c.json({ total_count: wfs.length, workflows: wfs.map((w) => ({ id: w.path, name: w.name, path: w.path, state: w.errors.length ? "invalid" : "active", errors: w.errors, triggers: w.triggers })) }); | |
| 1083 | + return c.json({ total_count: wfs.length, workflows: wfs.map((w) => ({ id: workflowNumber(w.path), name: w.name, path: w.path, state: w.errors.length ? "invalid" : "active", errors: w.errors, triggers: w.triggers })) }); | |
| 1084 | 1084 | }); |
| 1085 | +// `run_id` is Diffinery's; `workflow_run_id` and `run_url` are what GitHub answers with `return_run_details`. | |
| 1085 | 1086 | api.post("/repos/:owner/:repo/actions/workflows/:workflow/dispatches", async (c) => { |
| 1086 | 1087 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 1087 | 1088 | const b = await body<{ ref: string; inputs?: Record<string, unknown> }>(c); |
| 1088 | - const run = await stub.dispatchWorkflow(decodeURIComponent(c.req.param("workflow")), b.ref, b.inputs ?? {}, caller); | |
| 1089 | - return c.json({ run_id: run.id, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201); | |
| 1089 | + let workflow = decodeURIComponent(c.req.param("workflow")); | |
| 1090 | + if (/^\d+$/.test(workflow)) workflow = (await stub.workflows()).find((w) => workflowNumber(w.path) === Number(workflow))?.path ?? workflow; | |
| 1091 | + const run = await stub.dispatchWorkflow(workflow, b.ref, b.inputs ?? {}, caller); | |
| 1092 | + const id = uuidNumber(run.id); | |
| 1093 | + return c.json({ run_id: id, workflow_run_id: id, run_url: `${repoApi(baseOf(c), P(c).owner, P(c).repo)}/actions/runs/${id}`, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201); | |
| 1090 | 1094 | }); |
| 1091 | 1095 | // Self-hosted runners poll this to pick up `runs-on: [self-hosted, ...]` jobs (see `diffinery runner`). |
| 1092 | 1096 | // Only a registered runner claims, with its own token and the labels it registered with. A person's |
| @@ -1163,13 +1167,13 @@ | ||
| 1163 | 1167 | api.post(`/repos/:owner/:repo/actions/runs/:id/${path}`, async (c) => { |
| 1164 | 1168 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 1165 | 1169 | const r = await stub.rerun(c.req.param("id"), caller, scope); |
| 1166 | - return c.json({ run_id: r.id }, 201); | |
| 1170 | + return c.json({ run_id: uuidNumber(r.id) }, 201); | |
| 1167 | 1171 | }); |
| 1168 | 1172 | } |
| 1169 | 1173 | api.post("/repos/:owner/:repo/actions/jobs/:id/rerun", async (c) => { |
| 1170 | 1174 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 1171 | 1175 | const r = await stub.rerunJob(c.req.param("id"), caller); |
| 1172 | - return c.json({ run_id: r.id }, 201); | |
| 1176 | + return c.json({ run_id: uuidNumber(r.id) }, 201); | |
| 1173 | 1177 | }); |
| 1174 | 1178 | api.get("/repos/:owner/:repo/actions/jobs/:id", async (c) => { |
| 1175 | 1179 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
modifiedapps/diffinery/src/http/gh.ts+53−14View file
| @@ -46,6 +46,33 @@ | ||
| 46 | 46 | return ((a >>> 0) % 0x200000) * 0x100000000 + (b >>> 0) || 1; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | +/** | |
| 50 | + * GitHub's numeric id for a workflow run or job, whose Diffinery ids are UUIDs written as 32 hex | |
| 51 | + * digits. It keeps 52 of the UUID's random bits (the version digit skipped) above 10^15, so it | |
| 52 | + * always has 16 digits: shorter numbers stay run numbers (`github.run_id`, `diffinery run view 42`), | |
| 53 | + * and `uuidPattern` turns it back into a lookup. Anything else gets `numericId`'s hash. | |
| 54 | + */ | |
| 55 | +export function uuidNumber(id: string): number { | |
| 56 | + if (!/^[0-9a-f]{32}$/.test(id)) return numericId(id); | |
| 57 | + return UUID_NUMBERS + parseInt(id.slice(0, 12) + id[13], 16); | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** The SQLite GLOB that matches the ids `uuidNumber` maps to `raw`, or null when `raw` isn't such a number. */ | |
| 61 | +export function uuidPattern(raw: string): string | null { | |
| 62 | + if (!/^\d{16}$/.test(raw)) return null; | |
| 63 | + const n = Number(raw) - UUID_NUMBERS; | |
| 64 | + if (n < 0 || n >= 2 ** 52) return null; | |
| 65 | + const h = n.toString(16).padStart(13, "0"); | |
| 66 | + return `${h.slice(0, 12)}?${h[12]}*`; | |
| 67 | +} | |
| 68 | + | |
| 69 | +const UUID_NUMBERS = 1e15; | |
| 70 | + | |
| 71 | +/** A workflow's numeric id (GitHub's `workflow_id`): a hash of its path, which the API also takes. */ | |
| 72 | +export function workflowNumber(path: string): number { | |
| 73 | + return numericId(`workflow:${path}`); | |
| 74 | +} | |
| 75 | + | |
| 49 | 76 | export type Who = { id: string; login: string; kind?: string | null }; |
| 50 | 77 | |
| 51 | 78 | export const userType = (kind?: string | null): "User" | "Organization" | "Bot" => (kind === "org" ? "Organization" : kind === "agent" || kind === "system" ? "Bot" : "User"); |
| @@ -598,12 +625,12 @@ | ||
| 598 | 625 | completed_at: isoSeconds(r.completedAt), |
| 599 | 626 | output: { title: out.title ?? null, summary: out.summary ?? null, text: out.text ?? null, annotations_count: out.annotations?.length ?? 0, annotations_url: `${api}/check-runs/${id}/annotations` }, |
| 600 | 627 | name: r.name, |
| 601 | - check_suite: { id: r.runId ?? `app-${r.app}-${r.sha}` }, | |
| 628 | + check_suite: { id: r.runId ? uuidNumber(r.runId) : numericId(`app-${r.app}-${r.sha}`) }, | |
| 602 | 629 | app: { id: r.app, slug: r.app, node_id: nodeId("App", r.app), name: r.app, owner: null }, |
| 603 | 630 | pull_requests: [], |
| 604 | 631 | // Diffinery: the workflow run and job behind an Actions check. |
| 605 | - run_id: r.runId, | |
| 606 | - job_id: r.jobId, | |
| 632 | + run_id: r.runId && uuidNumber(r.runId), | |
| 633 | + job_id: r.jobId && uuidNumber(r.jobId), | |
| 607 | 634 | }; |
| 608 | 635 | } |
| 609 | 636 | |
| @@ -629,20 +656,24 @@ | ||
| 629 | 656 | prNumber: number | null; |
| 630 | 657 | }; |
| 631 | 658 | |
| 632 | -/** A workflow run: GitHub's fields plus Diffinery's `sha`/`ref` (what the run checked out) and `pull_request_number`. */ | |
| 659 | +/** | |
| 660 | + * A workflow run: GitHub's fields plus Diffinery's `sha`/`ref` (what the run checked out) and `pull_request_number`. | |
| 661 | + * Its `id`, `workflow_id` and `check_suite_id` are numbers, as clients like gh expect; the API takes them back too. | |
| 662 | + */ | |
| 633 | 663 | export function ghRun(base: string, owner: string, repo: string, r: RunLike) { |
| 634 | 664 | const api = repoApi(base, owner, repo); |
| 635 | - const url = `${api}/actions/runs/${r.id}`; | |
| 665 | + const id = uuidNumber(r.id); | |
| 666 | + const url = `${api}/actions/runs/${id}`; | |
| 636 | 667 | return { |
| 637 | - id: r.id, | |
| 668 | + id, | |
| 638 | 669 | node_id: nodeId("WorkflowRun", r.id), |
| 639 | 670 | run_number: r.number, |
| 640 | 671 | run_attempt: r.attempt, |
| 641 | 672 | name: r.workflowName, |
| 642 | 673 | display_title: r.title, |
| 643 | 674 | path: r.workflowPath, |
| 644 | - workflow_id: r.workflowPath, | |
| 645 | - check_suite_id: r.id, | |
| 675 | + workflow_id: workflowNumber(r.workflowPath), | |
| 676 | + check_suite_id: id, | |
| 646 | 677 | event: r.event, |
| 647 | 678 | head_branch: r.headBranch, |
| 648 | 679 | head_sha: r.headSha, |
| @@ -663,7 +694,7 @@ | ||
| 663 | 694 | artifacts_url: `${url}/artifacts`, |
| 664 | 695 | cancel_url: `${url}/cancel`, |
| 665 | 696 | rerun_url: `${url}/rerun`, |
| 666 | - workflow_url: `${api}/actions/workflows/${encodeURIComponent(r.workflowPath)}`, | |
| 697 | + workflow_url: `${api}/actions/workflows/${workflowNumber(r.workflowPath)}`, | |
| 667 | 698 | previous_attempt_url: r.attempt > 1 ? `${url}/attempts/${r.attempt - 1}` : null, |
| 668 | 699 | pull_requests: r.prNumber ? [{ number: r.prNumber, url: `${api}/pulls/${r.prNumber}` }] : [], |
| 669 | 700 | pull_request_number: r.prNumber, |
| @@ -673,6 +704,7 @@ | ||
| 673 | 704 | export type JobLike = { |
| 674 | 705 | id: string; |
| 675 | 706 | runId: string; |
| 707 | + carriedFrom?: string | null; | |
| 676 | 708 | name: string; |
| 677 | 709 | status: string; |
| 678 | 710 | conclusion: string | null; |
| @@ -683,25 +715,32 @@ | ||
| 683 | 715 | steps: Array<{ index: number; name: string; status: string; conclusion?: string | null; startedAt?: string; completedAt?: string }>; |
| 684 | 716 | }; |
| 685 | 717 | |
| 686 | -/** A job: GitHub's snake_case fields next to Diffinery's own (camelCase) ones, which the CLI and web UI read. */ | |
| 718 | +/** | |
| 719 | + * A job: GitHub's snake_case fields next to Diffinery's own (camelCase) ones, which the CLI and web UI read. | |
| 720 | + * `id`, `run_id` and `carriedFrom` are numbers like GitHub's ids (`runId` stays Diffinery's own id). | |
| 721 | + */ | |
| 687 | 722 | export function ghJob<J extends JobLike>(base: string, owner: string, repo: string, run: RunLike, j: J) { |
| 688 | 723 | const api = repoApi(base, owner, repo); |
| 724 | + const id = uuidNumber(j.id); | |
| 725 | + const runId = uuidNumber(j.runId); | |
| 689 | 726 | return { |
| 690 | 727 | ...j, |
| 728 | + id, | |
| 729 | + carriedFrom: j.carriedFrom ? uuidNumber(j.carriedFrom) : null, | |
| 691 | 730 | // A job held by its environment's reviewers or wait timer is "waiting", as on GitHub. |
| 692 | 731 | status: j.status === "approval" ? "waiting" : j.status, |
| 693 | - run_id: j.runId, | |
| 694 | - run_url: `${api}/actions/runs/${j.runId}`, | |
| 732 | + run_id: runId, | |
| 733 | + run_url: `${api}/actions/runs/${runId}`, | |
| 695 | 734 | run_attempt: run.attempt, |
| 696 | 735 | node_id: nodeId("Job", j.id), |
| 697 | 736 | head_sha: run.headSha, |
| 698 | 737 | head_branch: run.headBranch, |
| 699 | - url: `${api}/actions/jobs/${j.id}`, | |
| 738 | + url: `${api}/actions/jobs/${id}`, | |
| 700 | 739 | html_url: `${repoHtml(base, owner, repo)}/actions/runs/${j.runId}/job/${j.id}`, |
| 701 | 740 | started_at: isoSeconds(j.startedAt), |
| 702 | 741 | completed_at: isoSeconds(j.completedAt), |
| 703 | 742 | workflow_name: run.workflowName, |
| 704 | - check_run_url: `${api}/check-runs/${j.id}`, | |
| 743 | + check_run_url: `${api}/check-runs/${id}`, | |
| 705 | 744 | labels: [] as string[], |
| 706 | 745 | runner_name: j.runnerName ?? null, |
| 707 | 746 | // Hosted runner size (large, medium, small, tiny); `usage` (from ...j) is what the job used of it. |
modifiedapps/diffinery/src/http/misc.ts+5−4View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | import type { Env } from "../env.js"; |
| 3 | 3 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 4 | 4 | import { HttpError } from "../lib/util.js"; |
| 5 | -import { baseOf, ghRun, isoSeconds, nodeId, normalizeTopics, repoApi, repoHtml } from "./gh.js"; | |
| 5 | +import { baseOf, ghRun, isoSeconds, nodeId, normalizeTopics, repoApi, repoHtml, workflowNumber } from "./gh.js"; | |
| 6 | 6 | import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js"; |
| 7 | 7 | import { auditRepo, refuseAgent, WHILE_IMPORTING } from "./common.js"; |
| 8 | 8 | |
| @@ -65,7 +65,8 @@ | ||
| 65 | 65 | async function findWorkflow(c: AppContext) { |
| 66 | 66 | const { stub } = await load(c); |
| 67 | 67 | const id = (c.req.param("workflow") ?? ""); |
| 68 | - const wf = (await stub.workflows()).find((w) => w.path === id || w.path.endsWith(`/${id}`)); | |
| 68 | + const wfs = await stub.workflows(); | |
| 69 | + const wf = wfs.find((w) => w.path === id || w.path.endsWith(`/${id}`)) ?? (/^\d+$/.test(id) ? wfs.find((w) => workflowNumber(w.path) === Number(id)) : undefined); | |
| 69 | 70 | if (!wf) throw new HttpError(404, "Not Found"); |
| 70 | 71 | return { stub, wf }; |
| 71 | 72 | } |
| @@ -76,14 +77,14 @@ | ||
| 76 | 77 | const { owner, repo } = repoParams(c); |
| 77 | 78 | const now = isoSeconds(new Date().toISOString()); |
| 78 | 79 | return c.json({ |
| 79 | - id: wf.path, | |
| 80 | + id: workflowNumber(wf.path), | |
| 80 | 81 | node_id: nodeId("Workflow", `${owner}/${repo}/${wf.path}`), |
| 81 | 82 | name: wf.name, |
| 82 | 83 | path: wf.path, |
| 83 | 84 | state: wf.errors.length ? "invalid" : "active", |
| 84 | 85 | created_at: now, |
| 85 | 86 | updated_at: now, |
| 86 | - url: `${repoApi(b, owner, repo)}/actions/workflows/${encodeURIComponent(wf.path)}`, | |
| 87 | + url: `${repoApi(b, owner, repo)}/actions/workflows/${workflowNumber(wf.path)}`, | |
| 87 | 88 | html_url: `${repoHtml(b, owner, repo)}/blob/HEAD/${wf.path}`, |
| 88 | 89 | badge_url: `${repoHtml(b, owner, repo)}/workflows/${encodeURIComponent(wf.name)}/badge.svg`, |
| 89 | 90 | errors: wf.errors, |
modifiedapps/diffinery/src/http/statuses.ts+3−3View file
| @@ -103,17 +103,17 @@ | ||
| 103 | 103 | const b = baseOf(c); |
| 104 | 104 | const { owner, repo } = repoParams(c); |
| 105 | 105 | return { |
| 106 | - id: s.id, | |
| 106 | + id: s.number, | |
| 107 | 107 | node_id: nodeId("CheckSuite", s.id), |
| 108 | 108 | head_branch: null, |
| 109 | 109 | head_sha: s.headSha, |
| 110 | 110 | status: s.status, |
| 111 | 111 | conclusion: s.conclusion, |
| 112 | - url: `${repoApi(b, owner, repo)}/check-suites/${s.id}`, | |
| 112 | + url: `${repoApi(b, owner, repo)}/check-suites/${s.number}`, | |
| 113 | 113 | app: { id: s.app, slug: s.app, name: s.app }, |
| 114 | 114 | pull_requests: [], |
| 115 | 115 | latest_check_runs_count: s.runs.length, |
| 116 | - check_runs_url: `${repoApi(b, owner, repo)}/check-suites/${s.id}/check-runs`, | |
| 116 | + check_runs_url: `${repoApi(b, owner, repo)}/check-suites/${s.number}/check-runs`, | |
| 117 | 117 | }; |
| 118 | 118 | } |
| 119 | 119 |
modifiedapps/diffinery/src/repo/actions.ts+14−7View file
| @@ -20,7 +20,7 @@ | ||
| 20 | 20 | import { getFirst, headFirst, legacyArtifactKeys, legacyCacheKeys, logKeys } from "../lib/storage-keys.js"; |
| 21 | 21 | import { permissionsForJob, type JobPermissions } from "../lib/job-permissions.js"; |
| 22 | 22 | import { signRuntimeToken } from "../lib/runtime-auth.js"; |
| 23 | -import { ghRepo, numericId } from "../http/gh.js"; | |
| 23 | +import { ghRepo, numericId, uuidPattern } from "../http/gh.js"; | |
| 24 | 24 | import { HttpError, newId, noreplyEmail, now, randomToken, sha256 } from "../lib/util.js"; |
| 25 | 25 | import { localOnly } from "../auth/dev.js"; |
| 26 | 26 | import { egressFor, type Egress } from "../lib/egress.js"; |
| @@ -875,17 +875,24 @@ | ||
| 875 | 875 | return why; |
| 876 | 876 | } |
| 877 | 877 | |
| 878 | - /** A run by id, or by its number (github.run_id; the latest attempt). */ | |
| 878 | + /** A run by id, its REST API number (http/gh.ts uuidNumber), or its run number (github.run_id; the latest attempt). */ | |
| 879 | 879 | private runRow(id: string): RunRow { |
| 880 | - const r = /^\d+$/.test(id) | |
| 881 | - ? this.sql.exec<RunRow>("SELECT * FROM runs WHERE run_number = ? ORDER BY attempt DESC, created_at DESC LIMIT 1", Number(id)).toArray()[0] | |
| 882 | - : this.sql.exec<RunRow>("SELECT * FROM runs WHERE id = ?", id).toArray()[0]; | |
| 880 | + const pattern = uuidPattern(id); | |
| 881 | + const r = pattern | |
| 882 | + ? this.sql.exec<RunRow>("SELECT * FROM runs WHERE id GLOB ? ORDER BY created_at DESC LIMIT 1", pattern).toArray()[0] | |
| 883 | + : /^\d+$/.test(id) | |
| 884 | + ? this.sql.exec<RunRow>("SELECT * FROM runs WHERE run_number = ? ORDER BY attempt DESC, created_at DESC LIMIT 1", Number(id)).toArray()[0] | |
| 885 | + : this.sql.exec<RunRow>("SELECT * FROM runs WHERE id = ?", id).toArray()[0]; | |
| 883 | 886 | if (!r) throw new HttpError(404, "run not found"); |
| 884 | 887 | return r; |
| 885 | 888 | } |
| 886 | 889 | |
| 890 | + /** A job by id or its REST API number (http/gh.ts uuidNumber). */ | |
| 887 | 891 | private jobRow(id: string): JobRow { |
| 888 | - const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", id).toArray()[0]; | |
| 892 | + const pattern = uuidPattern(id); | |
| 893 | + const j = pattern | |
| 894 | + ? this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id GLOB ? ORDER BY rowid DESC LIMIT 1", pattern).toArray()[0] | |
| 895 | + : this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", id).toArray()[0]; | |
| 889 | 896 | if (!j) throw new HttpError(404, "job not found"); |
| 890 | 897 | return j; |
| 891 | 898 | } |
| @@ -940,7 +947,7 @@ | ||
| 940 | 947 | async logs(jobId: string, afterSeq = -1): Promise<{ lines: Array<LogLine & { seq: number }>; complete: boolean }> { |
| 941 | 948 | const j = this.jobRow(jobId); |
| 942 | 949 | // A job a partial re-run kept from an earlier attempt shows the log of the job that ran. |
| 943 | - if (j.carried_from) jobId = j.carried_from; | |
| 950 | + jobId = j.carried_from ?? j.id; | |
| 944 | 951 | const rows = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT 5000", jobId, afterSeq).toArray(); |
| 945 | 952 | if (rows.length === 0 && j.status === "completed") { |
| 946 | 953 | const archived = await getFirst(this.env.BUCKET, logKeys(this.repo.meta.repoId, jobId)); |
modifiedapps/diffinery/src/repo/gitdata.ts+47−8View file
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | utf8, |
| 22 | 22 | } from "@diffinery/git-core"; |
| 23 | 23 | import type { Env } from "../env.js"; |
| 24 | +import { numericId, uuidNumber, uuidPattern } from "../http/gh.js"; | |
| 24 | 25 | import { scanForSecrets, type SecretHit } from "../git/secret-scan.js"; |
| 25 | 26 | import { ZipWriter, tarEnd, tarEntry, tarStart, zip, type ArchiveEntry } from "../lib/archive.js"; |
| 26 | 27 | import { R2Sink } from "../lib/r2-sink.js"; |
| @@ -512,17 +513,28 @@ | ||
| 512 | 513 | |
| 513 | 514 | // ---------------------------------------------------------------- Actions extras |
| 514 | 515 | |
| 515 | - /** A run's logs as a zip with one text file per job (GET /actions/runs/:id/logs). */ | |
| 516 | + /** | |
| 517 | + * A run's logs as a zip (GET /actions/runs/:id/logs), laid out like GitHub's: one text file per | |
| 518 | + * job, and a folder per job with a file per step (`build/1_Run tests.txt`), which `gh run view | |
| 519 | + * --log` shows each step's lines from. | |
| 520 | + */ | |
| 516 | 521 | async runLogsZip(runId: string): Promise<Uint8Array> { |
| 517 | 522 | const run = this.repo.actions.getRun(runId); |
| 518 | 523 | const entries: ArchiveEntry[] = []; |
| 524 | + const text = (lines: Array<{ t: string; text: string }>) => new TextEncoder().encode(lines.map((l) => `${l.t} ${l.text}`).join("\n") + (lines.length ? "\n" : "")); | |
| 519 | 525 | let total = 0; |
| 520 | 526 | for (const [i, j] of run.jobs.entries()) { |
| 521 | 527 | const { lines } = await this.repo.actions.logs(j.id); |
| 522 | - const text = new TextEncoder().encode(lines.map((l) => `${l.t} ${l.text}`).join("\n") + (lines.length ? "\n" : "")); | |
| 523 | - total += text.length; | |
| 528 | + const all = text(lines); | |
| 529 | + total += all.length * 2; | |
| 524 | 530 | if (total > 24 * 1024 * 1024) break; |
| 525 | - entries.push({ path: `${i + 1}_${j.name.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 100)}.txt`, mode: "100644", content: text }); | |
| 531 | + entries.push({ path: `${i + 1}_${j.name.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 100)}.txt`, mode: "100644", content: all }); | |
| 532 | + // gh finds a step's file by the job's name without "/" and ":", cut to 90 characters. | |
| 533 | + const dir = j.name.replace(/[\\/:*?"<>|]+/g, "").slice(0, 90); | |
| 534 | + for (const s of j.steps) { | |
| 535 | + const mine = lines.filter((l) => l.step === s.index); | |
| 536 | + entries.push({ path: `${dir}/${s.index + 1}_${s.name.replace(/[\\/:*?"<>|]+/g, "").slice(0, 100)}.txt`, mode: "100644", content: text(mine) }); | |
| 537 | + } | |
| 526 | 538 | } |
| 527 | 539 | return zip(entries, Math.floor(Date.parse(run.updatedAt) / 1000) || Math.floor(Date.now() / 1000)); |
| 528 | 540 | } |
| @@ -537,8 +549,13 @@ | ||
| 537 | 549 | |
| 538 | 550 | // ---------------------------------------------------------------- check runs and suites |
| 539 | 551 | |
| 540 | - /** A check run by GitHub's numeric id, Diffinery's own, or the Actions job that reports it (a job's `check_run_url`). */ | |
| 552 | + /** | |
| 553 | + * A check run by GitHub's numeric id, Diffinery's own, or the Actions job that reports it: a job's | |
| 554 | + * `check_run_url`, or its REST id (http/gh.ts uuidNumber), which `gh run view` asks for annotations by. | |
| 555 | + */ | |
| 541 | 556 | check(raw: string): Check { |
| 557 | + const job = uuidPattern(raw); | |
| 558 | + if (job) raw = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE id GLOB ? ORDER BY rowid DESC LIMIT 1", job).toArray()[0]?.id ?? raw; | |
| 542 | 559 | const row = /^\d{1,15}$/.test(raw) |
| 543 | 560 | ? this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE seq = ?", Number(raw)).toArray()[0] |
| 544 | 561 | : (this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE id = ?", raw).toArray()[0] ?? |
| @@ -600,8 +617,11 @@ | ||
| 600 | 617 | return this.check(c.id); |
| 601 | 618 | } |
| 602 | 619 | |
| 603 | - /** Check suites, synthesised: one per workflow run, or per app for external check runs. */ | |
| 604 | - checkSuites(sha: string): Array<{ id: string; app: string; headSha: string; status: string; conclusion: string | null; runs: Check[] }> { | |
| 620 | + /** | |
| 621 | + * Check suites, synthesised: one per workflow run, or per app for external check runs. `number` is | |
| 622 | + * the REST API's id: the run's (http/gh.ts uuidNumber), or a hash of the app suite's id. | |
| 623 | + */ | |
| 624 | + checkSuites(sha: string): Array<{ id: string; number: number; app: string; headSha: string; status: string; conclusion: string | null; runs: Check[] }> { | |
| 605 | 625 | const groups = new Map<string, Check[]>(); |
| 606 | 626 | for (const c of this.repo.collab.checks(sha)) { |
| 607 | 627 | const id = c.runId ?? `app-${c.app}-${sha}`; |
| @@ -610,11 +630,25 @@ | ||
| 610 | 630 | return [...groups].map(([id, runs]) => { |
| 611 | 631 | const done = runs.every((r) => r.status === "completed"); |
| 612 | 632 | const failed = runs.some((r) => ["failure", "timed_out", "cancelled", "action_required"].includes(r.conclusion ?? "")); |
| 613 | - return { id, app: runs[0]!.app, headSha: sha, status: done ? "completed" : runs.some((r) => r.status === "in_progress") ? "in_progress" : "queued", conclusion: done ? (failed ? "failure" : "success") : null, runs }; | |
| 633 | + return { id, number: suiteNumber(id), app: runs[0]!.app, headSha: sha, status: done ? "completed" : runs.some((r) => r.status === "in_progress") ? "in_progress" : "queued", conclusion: done ? (failed ? "failure" : "success") : null, runs }; | |
| 614 | 634 | }); |
| 615 | 635 | } |
| 616 | 636 | |
| 637 | + /** A check suite by its id or its REST API number (`number` in checkSuites). */ | |
| 617 | 638 | checkSuite(id: string): ReturnType<GitData["checkSuites"]>[number] { |
| 639 | + if (/^\d{1,16}$/.test(id)) { | |
| 640 | + const run = uuidPattern(id); | |
| 641 | + const n = Number(id); | |
| 642 | + id = | |
| 643 | + (run && this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE id GLOB ? ORDER BY created_at DESC LIMIT 1", run).toArray()[0]?.id) || | |
| 644 | + // App suites have no table to look their number up in; external check runs are few. | |
| 645 | + this.sql | |
| 646 | + .exec<{ app: string; sha: string }>("SELECT DISTINCT app, sha FROM checks WHERE run_id IS NULL") | |
| 647 | + .toArray() | |
| 648 | + .map((r) => `app-${r.app}-${r.sha}`) | |
| 649 | + .find((k) => suiteNumber(k) === n) || | |
| 650 | + id; | |
| 651 | + } | |
| 618 | 652 | const row = id.startsWith("app-") |
| 619 | 653 | ? this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE ? = 'app-' || app || '-' || sha LIMIT 1", id).toArray()[0] |
| 620 | 654 | : this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE run_id = ? LIMIT 1", id).toArray()[0]; |
| @@ -623,3 +657,8 @@ | ||
| 623 | 657 | return suite; |
| 624 | 658 | } |
| 625 | 659 | } |
| 660 | + | |
| 661 | +/** A check suite's REST API id: its workflow run's, or a hash of an app suite's `app-<app>-<sha>`. */ | |
| 662 | +function suiteNumber(id: string): number { | |
| 663 | + return id.startsWith("app-") ? numericId(id) : uuidNumber(id); | |
| 664 | +} |
modifiedapps/diffinery/src/repo/runtime.ts+5−1View file
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { Env } from "../env.js"; |
| 2 | +import { uuidPattern } from "../http/gh.js"; | |
| 2 | 3 | import { dropStaged, finalKey, runComposeStep, stagingPrefix, stepKeys, type BlobBucket, type ComposeStep } from "../lib/blob-store.js"; |
| 3 | 4 | import { READ_ALL, roleForPermissions, type JobPermissions } from "../lib/job-permissions.js"; |
| 4 | 5 | import { legacyArtifactKeys } from "../lib/storage-keys.js"; |
| @@ -474,9 +475,12 @@ | ||
| 474 | 475 | * Finished, unexpired artifacts of a run (latest first), optionally by name or id; |
| 475 | 476 | * with `includeExpired` (the REST API), expired ones too; with `legacy: false`, |
| 476 | 477 | * not those uploaded by older runners. |
| 477 | - * A numeric run id (github.run_id, the run number) covers all of the run's attempts. | |
| 478 | + * A numeric run id (github.run_id, the run number) covers all of the run's attempts; the REST | |
| 479 | + * API's 16-digit run id (http/gh.ts uuidNumber) is one attempt. | |
| 478 | 480 | */ |
| 479 | 481 | runArtifacts(runId: string, filter: { name?: string; id?: number; includeExpired?: boolean; legacy?: false } = {}): ArtifactEntry[] { |
| 482 | + const pattern = uuidPattern(runId); | |
| 483 | + if (pattern) runId = this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE id GLOB ? ORDER BY created_at DESC LIMIT 1", pattern).toArray()[0]?.id ?? "-"; | |
| 480 | 484 | const numeric = /^\d+$/.test(runId); |
| 481 | 485 | // An attempt also has the artifacts of the jobs a partial re-run kept from an earlier attempt, as on GitHub. |
| 482 | 486 | const where = [ |
modifiedapps/diffinery/test/unit/rerun.test.ts+32−0View file
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | import { Actions, canonicalJson } from "../../src/repo/actions.js"; |
| 5 | 5 | import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js"; |
| 6 | 6 | import { logKeys } from "../../src/lib/storage-keys.js"; |
| 7 | +import { uuidNumber, uuidPattern, workflowNumber } from "../../src/http/gh.js"; | |
| 7 | 8 | import { Runtime } from "../../src/repo/runtime.js"; |
| 8 | 9 | import { testD1 } from "./stubs/d1.js"; |
| 9 | 10 | import type { RepoDO } from "../../src/repo/RepoDO.js"; |
| @@ -267,6 +268,8 @@ | ||
| 267 | 268 | const logs = await f.actions.logs(kept.id); |
| 268 | 269 | expect(logs).toMatchObject({ complete: true, lines: [{ text: "linted" }] }); |
| 269 | 270 | expect(f.actions.getJob(kept.id).carriedFrom).toBe(lint.id); |
| 271 | + // gh asks by the REST API's numeric ids (http/gh.ts uuidNumber). | |
| 272 | + expect(await f.actions.logs(String(uuidNumber(kept.id)))).toEqual(logs); | |
| 270 | 273 | }); |
| 271 | 274 | |
| 272 | 275 | it("lists the kept jobs' artifacts with the new attempt's", async () => { |
| @@ -286,9 +289,38 @@ | ||
| 286 | 289 | // The failed leg ran again, so its first attempt's artifact isn't the new attempt's. |
| 287 | 290 | expect(rt.runArtifacts(second.id).map((a) => a.name).sort()).toEqual(["lint-report", "passed-b"]); |
| 288 | 291 | expect(rt.runArtifacts(first).map((a) => a.name).sort()).toEqual(["failed-b", "lint-report"]); |
| 292 | + expect(rt.runArtifacts(String(uuidNumber(second.id))).map((a) => a.name).sort()).toEqual(["lint-report", "passed-b"]); | |
| 289 | 293 | }); |
| 290 | 294 | }); |
| 291 | 295 | |
| 296 | +describe("the REST API's numeric run and job ids", () => { | |
| 297 | + it("are 16 digits that find the run or job again, while shorter numbers stay run numbers", async () => { | |
| 298 | + const f = fixture({ ".github/workflows/w.yml": WORKFLOW }); | |
| 299 | + const first = await f.firstRun(failB); | |
| 300 | + const second = await f.actions.rerun(first, ADA, "failed"); | |
| 301 | + const n = uuidNumber(first); | |
| 302 | + expect(String(n)).toMatch(/^\d{16}$/); | |
| 303 | + expect(uuidPattern(String(n))).not.toBeNull(); | |
| 304 | + expect(f.actions.getRun(String(n)).id).toBe(first); | |
| 305 | + expect(f.actions.getRun(String(uuidNumber(second.id))).id).toBe(second.id); | |
| 306 | + // A run number is the latest attempt, as before. | |
| 307 | + expect(f.actions.getRun(String(second.number)).id).toBe(second.id); | |
| 308 | + expect(f.actions.jobs(String(n)).map((j) => j.id)).toEqual(f.jobs(first).map((j) => j.id)); | |
| 309 | + const job = f.jobs(first)[0]!; | |
| 310 | + expect(f.actions.getJob(String(uuidNumber(job.id))).id).toBe(job.id); | |
| 311 | + expect(() => f.actions.getJob(String(uuidNumber(job.id) + 1))).toThrow(/job not found/); | |
| 312 | + expect(() => f.actions.getRun("9999999999999999")).toThrow(/run not found/); | |
| 313 | + expect(uuidPattern("123")).toBeNull(); | |
| 314 | + expect(uuidPattern("abc")).toBeNull(); | |
| 315 | + }); | |
| 316 | + | |
| 317 | + it("give workflows a stable number of their own", () => { | |
| 318 | + expect(workflowNumber(".github/workflows/ci.yml")).toBe(workflowNumber(".github/workflows/ci.yml")); | |
| 319 | + expect(workflowNumber(".github/workflows/ci.yml")).not.toBe(workflowNumber(".github/workflows/cd.yml")); | |
| 320 | + expect(Number.isSafeInteger(workflowNumber(".github/workflows/ci.yml"))).toBe(true); | |
| 321 | + }); | |
| 322 | +}); | |
| 323 | + | |
| 292 | 324 | describe("re-running failed jobs of a reusable workflow call", () => { |
| 293 | 325 | it("treats the call's jobs like any other and re-runs what needs the call", async () => { |
| 294 | 326 | const f = fixture({ |