Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Numeric ids for workflow runs, jobs, workflows and check suites, so gh run and gh workflow work

The REST API now answers with numbers where GitHub has them: a run's and
a job's id (16 digits, 52 bits of their UUID), workflow_id and
check_suite_id. Paths take those numbers back, as well as the hex ids,
run numbers and workflow paths they took before. The run logs zip gets
GitHub's per-step files, which gh run view --log reads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ALxdbMU5hpZXjGpL2jhC4
test-bot authored signed1 parent 04dacaacommit b86dc2f71697a72b1a1556d1747f4ca581b285a8Browse files
12 files changed+180−52
Files (12)
modifiedapps/diffinery/docs/actions/overview.md+1−1View file
@@ -310,7 +310,7 @@
310310 | `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. |
311311 | `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. |
312312
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).
314314
315315 ### From MCP
316316
modifiedapps/diffinery/docs/agents/github-cli.md+5−3View file
@@ -44,13 +44,15 @@
4444 gh issue create --title "The parser drops comments" --label bug
4545 gh issue list --label bug
4646 gh issue close 42 --reason "not planned"
47+gh run list --workflow ci.yml
48+gh run view --log-failed
4749 ```
4850
4951 | Works | Commands |
5052 | --- | --- |
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. |
5456
5557 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.
5658
modifiedapps/diffinery/docs/collaboration/checks-and-statuses.md+3−3View file
@@ -249,7 +249,7 @@
249249 "annotations_url": "https://diffinery.com/api/v3/repos/acme/website/check-runs/31/annotations"
250250 },
251251 "name": "ci/external",
252- "check_suite": { "id": "app-ci-bot-9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d" },
252+ "check_suite": { "id": 6145803720560937 },
253253 "app": { "id": "ci-bot", "slug": "ci-bot", "node_id": "QXBwOmNpLWJvdA", "name": "ci-bot", "owner": null },
254254 "pull_requests": [],
255255 "run_id": null,
@@ -257,7 +257,7 @@
257257 }
258258 ```
259259
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.
261261 - 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.
262262 - Accounts whose login starts with `diffinery-actions` can't report check runs (`this account can't report check runs`).
263263
@@ -276,7 +276,7 @@
276276 | `GET /repos/{owner}/{repo}/check-suites/{check_suite_id}` | One check suite |
277277 | `GET /repos/{owner}/{repo}/check-suites/{check_suite_id}/check-runs` | The check runs of a suite |
278278
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.
280280
281281 ## Required checks
282282
modifiedapps/diffinery/docs/reference/rest-api.md+2−2View file
@@ -85,9 +85,9 @@
8585 | 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. |
8686 | Issues and pull requests | The `id` equals the `number` (issues and pull requests share one number sequence per repository). |
8787 | 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. |
8989 | 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`. |
9191 | Webhooks and tokens | Strings. |
9292
9393 `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 @@
2626 import { compat } from "./compat.js";
2727 import { appsApi } from "./apps.js";
2828 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";
3030 import { decodePath, issueCtx, issuesOut, pullDetail, pullsOut, repoOut } from "./ghctx.js";
3131
3232 /**
@@ -1080,13 +1080,17 @@
10801080 api.get("/repos/:owner/:repo/actions/workflows", async (c) => {
10811081 const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
10821082 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 })) });
10841084 });
1085+// `run_id` is Diffinery's; `workflow_run_id` and `run_url` are what GitHub answers with `return_run_details`.
10851086 api.post("/repos/:owner/:repo/actions/workflows/:workflow/dispatches", async (c) => {
10861087 const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
10871088 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);
10901094 });
10911095 // Self-hosted runners poll this to pick up `runs-on: [self-hosted, ...]` jobs (see `diffinery runner`).
10921096 // Only a registered runner claims, with its own token and the labels it registered with. A person's
@@ -1163,13 +1167,13 @@
11631167 api.post(`/repos/:owner/:repo/actions/runs/:id/${path}`, async (c) => {
11641168 const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
11651169 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);
11671171 });
11681172 }
11691173 api.post("/repos/:owner/:repo/actions/jobs/:id/rerun", async (c) => {
11701174 const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
11711175 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);
11731177 });
11741178 api.get("/repos/:owner/:repo/actions/jobs/:id", async (c) => {
11751179 const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
modifiedapps/diffinery/src/http/gh.ts+53−14View file
@@ -46,6 +46,33 @@
4646 return ((a >>> 0) % 0x200000) * 0x100000000 + (b >>> 0) || 1;
4747 }
4848
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+
4976 export type Who = { id: string; login: string; kind?: string | null };
5077
5178 export const userType = (kind?: string | null): "User" | "Organization" | "Bot" => (kind === "org" ? "Organization" : kind === "agent" || kind === "system" ? "Bot" : "User");
@@ -598,12 +625,12 @@
598625 completed_at: isoSeconds(r.completedAt),
599626 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` },
600627 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}`) },
602629 app: { id: r.app, slug: r.app, node_id: nodeId("App", r.app), name: r.app, owner: null },
603630 pull_requests: [],
604631 // 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),
607634 };
608635 }
609636
@@ -629,20 +656,24 @@
629656 prNumber: number | null;
630657 };
631658
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+ */
633663 export function ghRun(base: string, owner: string, repo: string, r: RunLike) {
634664 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}`;
636667 return {
637- id: r.id,
668+ id,
638669 node_id: nodeId("WorkflowRun", r.id),
639670 run_number: r.number,
640671 run_attempt: r.attempt,
641672 name: r.workflowName,
642673 display_title: r.title,
643674 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,
646677 event: r.event,
647678 head_branch: r.headBranch,
648679 head_sha: r.headSha,
@@ -663,7 +694,7 @@
663694 artifacts_url: `${url}/artifacts`,
664695 cancel_url: `${url}/cancel`,
665696 rerun_url: `${url}/rerun`,
666- workflow_url: `${api}/actions/workflows/${encodeURIComponent(r.workflowPath)}`,
697+ workflow_url: `${api}/actions/workflows/${workflowNumber(r.workflowPath)}`,
667698 previous_attempt_url: r.attempt > 1 ? `${url}/attempts/${r.attempt - 1}` : null,
668699 pull_requests: r.prNumber ? [{ number: r.prNumber, url: `${api}/pulls/${r.prNumber}` }] : [],
669700 pull_request_number: r.prNumber,
@@ -673,6 +704,7 @@
673704 export type JobLike = {
674705 id: string;
675706 runId: string;
707+ carriedFrom?: string | null;
676708 name: string;
677709 status: string;
678710 conclusion: string | null;
@@ -683,25 +715,32 @@
683715 steps: Array<{ index: number; name: string; status: string; conclusion?: string | null; startedAt?: string; completedAt?: string }>;
684716 };
685717
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+ */
687722 export function ghJob<J extends JobLike>(base: string, owner: string, repo: string, run: RunLike, j: J) {
688723 const api = repoApi(base, owner, repo);
724+ const id = uuidNumber(j.id);
725+ const runId = uuidNumber(j.runId);
689726 return {
690727 ...j,
728+ id,
729+ carriedFrom: j.carriedFrom ? uuidNumber(j.carriedFrom) : null,
691730 // A job held by its environment's reviewers or wait timer is "waiting", as on GitHub.
692731 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}`,
695734 run_attempt: run.attempt,
696735 node_id: nodeId("Job", j.id),
697736 head_sha: run.headSha,
698737 head_branch: run.headBranch,
699- url: `${api}/actions/jobs/${j.id}`,
738+ url: `${api}/actions/jobs/${id}`,
700739 html_url: `${repoHtml(base, owner, repo)}/actions/runs/${j.runId}/job/${j.id}`,
701740 started_at: isoSeconds(j.startedAt),
702741 completed_at: isoSeconds(j.completedAt),
703742 workflow_name: run.workflowName,
704- check_run_url: `${api}/check-runs/${j.id}`,
743+ check_run_url: `${api}/check-runs/${id}`,
705744 labels: [] as string[],
706745 runner_name: j.runnerName ?? null,
707746 // 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 @@
22 import type { Env } from "../env.js";
33 import type { AppContext, AppVars } from "../auth/principal.js";
44 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";
66 import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js";
77 import { auditRepo, refuseAgent, WHILE_IMPORTING } from "./common.js";
88
@@ -65,7 +65,8 @@
6565 async function findWorkflow(c: AppContext) {
6666 const { stub } = await load(c);
6767 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);
6970 if (!wf) throw new HttpError(404, "Not Found");
7071 return { stub, wf };
7172 }
@@ -76,14 +77,14 @@
7677 const { owner, repo } = repoParams(c);
7778 const now = isoSeconds(new Date().toISOString());
7879 return c.json({
79- id: wf.path,
80+ id: workflowNumber(wf.path),
8081 node_id: nodeId("Workflow", `${owner}/${repo}/${wf.path}`),
8182 name: wf.name,
8283 path: wf.path,
8384 state: wf.errors.length ? "invalid" : "active",
8485 created_at: now,
8586 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)}`,
8788 html_url: `${repoHtml(b, owner, repo)}/blob/HEAD/${wf.path}`,
8889 badge_url: `${repoHtml(b, owner, repo)}/workflows/${encodeURIComponent(wf.name)}/badge.svg`,
8990 errors: wf.errors,
modifiedapps/diffinery/src/http/statuses.ts+3−3View file
@@ -103,17 +103,17 @@
103103 const b = baseOf(c);
104104 const { owner, repo } = repoParams(c);
105105 return {
106- id: s.id,
106+ id: s.number,
107107 node_id: nodeId("CheckSuite", s.id),
108108 head_branch: null,
109109 head_sha: s.headSha,
110110 status: s.status,
111111 conclusion: s.conclusion,
112- url: `${repoApi(b, owner, repo)}/check-suites/${s.id}`,
112+ url: `${repoApi(b, owner, repo)}/check-suites/${s.number}`,
113113 app: { id: s.app, slug: s.app, name: s.app },
114114 pull_requests: [],
115115 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`,
117117 };
118118 }
119119
modifiedapps/diffinery/src/repo/actions.ts+14−7View file
@@ -20,7 +20,7 @@
2020 import { getFirst, headFirst, legacyArtifactKeys, legacyCacheKeys, logKeys } from "../lib/storage-keys.js";
2121 import { permissionsForJob, type JobPermissions } from "../lib/job-permissions.js";
2222 import { signRuntimeToken } from "../lib/runtime-auth.js";
23-import { ghRepo, numericId } from "../http/gh.js";
23+import { ghRepo, numericId, uuidPattern } from "../http/gh.js";
2424 import { HttpError, newId, noreplyEmail, now, randomToken, sha256 } from "../lib/util.js";
2525 import { localOnly } from "../auth/dev.js";
2626 import { egressFor, type Egress } from "../lib/egress.js";
@@ -875,17 +875,24 @@
875875 return why;
876876 }
877877
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). */
879879 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];
883886 if (!r) throw new HttpError(404, "run not found");
884887 return r;
885888 }
886889
890+ /** A job by id or its REST API number (http/gh.ts uuidNumber). */
887891 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];
889896 if (!j) throw new HttpError(404, "job not found");
890897 return j;
891898 }
@@ -940,7 +947,7 @@
940947 async logs(jobId: string, afterSeq = -1): Promise<{ lines: Array<LogLine & { seq: number }>; complete: boolean }> {
941948 const j = this.jobRow(jobId);
942949 // 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;
944951 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();
945952 if (rows.length === 0 && j.status === "completed") {
946953 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 @@
2121 utf8,
2222 } from "@diffinery/git-core";
2323 import type { Env } from "../env.js";
24+import { numericId, uuidNumber, uuidPattern } from "../http/gh.js";
2425 import { scanForSecrets, type SecretHit } from "../git/secret-scan.js";
2526 import { ZipWriter, tarEnd, tarEntry, tarStart, zip, type ArchiveEntry } from "../lib/archive.js";
2627 import { R2Sink } from "../lib/r2-sink.js";
@@ -512,17 +513,28 @@
512513
513514 // ---------------------------------------------------------------- Actions extras
514515
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+ */
516521 async runLogsZip(runId: string): Promise<Uint8Array> {
517522 const run = this.repo.actions.getRun(runId);
518523 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" : ""));
519525 let total = 0;
520526 for (const [i, j] of run.jobs.entries()) {
521527 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;
524530 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+ }
526538 }
527539 return zip(entries, Math.floor(Date.parse(run.updatedAt) / 1000) || Math.floor(Date.now() / 1000));
528540 }
@@ -537,8 +549,13 @@
537549
538550 // ---------------------------------------------------------------- check runs and suites
539551
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+ */
541556 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;
542559 const row = /^\d{1,15}$/.test(raw)
543560 ? this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE seq = ?", Number(raw)).toArray()[0]
544561 : (this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE id = ?", raw).toArray()[0] ??
@@ -600,8 +617,11 @@
600617 return this.check(c.id);
601618 }
602619
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[] }> {
605625 const groups = new Map<string, Check[]>();
606626 for (const c of this.repo.collab.checks(sha)) {
607627 const id = c.runId ?? `app-${c.app}-${sha}`;
@@ -610,11 +630,25 @@
610630 return [...groups].map(([id, runs]) => {
611631 const done = runs.every((r) => r.status === "completed");
612632 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 };
614634 });
615635 }
616636
637+ /** A check suite by its id or its REST API number (`number` in checkSuites). */
617638 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+ }
618652 const row = id.startsWith("app-")
619653 ? this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE ? = 'app-' || app || '-' || sha LIMIT 1", id).toArray()[0]
620654 : this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE run_id = ? LIMIT 1", id).toArray()[0];
@@ -623,3 +657,8 @@
623657 return suite;
624658 }
625659 }
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 @@
11 import type { Env } from "../env.js";
2+import { uuidPattern } from "../http/gh.js";
23 import { dropStaged, finalKey, runComposeStep, stagingPrefix, stepKeys, type BlobBucket, type ComposeStep } from "../lib/blob-store.js";
34 import { READ_ALL, roleForPermissions, type JobPermissions } from "../lib/job-permissions.js";
45 import { legacyArtifactKeys } from "../lib/storage-keys.js";
@@ -474,9 +475,12 @@
474475 * Finished, unexpired artifacts of a run (latest first), optionally by name or id;
475476 * with `includeExpired` (the REST API), expired ones too; with `legacy: false`,
476477 * 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.
478480 */
479481 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 ?? "-";
480484 const numeric = /^\d+$/.test(runId);
481485 // An attempt also has the artifacts of the jobs a partial re-run kept from an earlier attempt, as on GitHub.
482486 const where = [
modifiedapps/diffinery/test/unit/rerun.test.ts+32−0View file
@@ -4,6 +4,7 @@
44 import { Actions, canonicalJson } from "../../src/repo/actions.js";
55 import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js";
66 import { logKeys } from "../../src/lib/storage-keys.js";
7+import { uuidNumber, uuidPattern, workflowNumber } from "../../src/http/gh.js";
78 import { Runtime } from "../../src/repo/runtime.js";
89 import { testD1 } from "./stubs/d1.js";
910 import type { RepoDO } from "../../src/repo/RepoDO.js";
@@ -267,6 +268,8 @@
267268 const logs = await f.actions.logs(kept.id);
268269 expect(logs).toMatchObject({ complete: true, lines: [{ text: "linted" }] });
269270 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);
270273 });
271274
272275 it("lists the kept jobs' artifacts with the new attempt's", async () => {
@@ -286,9 +289,38 @@
286289 // The failed leg ran again, so its first attempt's artifact isn't the new attempt's.
287290 expect(rt.runArtifacts(second.id).map((a) => a.name).sort()).toEqual(["lint-report", "passed-b"]);
288291 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"]);
289293 });
290294 });
291295
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+
292324 describe("re-running failed jobs of a reusable workflow call", () => {
293325 it("treats the call's jobs like any other and re-runs what needs the call", async () => {
294326 const f = fixture({