Forgejo: CI tools (commit status, run jobs, job logs, run logs) #74

Closed
opened 2026-10-26 13:57:32 +01:00 by omp-deck · 0 comments
Collaborator

Problem

Agents need tools to inspect CI results on Forgejo. They are useful right away in manual sessions ("why did CI fail on #42"), and the CI watchdogs (plan steps 17, 20, 23) build on them: the PR watchdog for other people's PRs, CI monitoring on the agent's own PRs, and the branch-failure watchdog. This is step 16 of KB project/forgejo-integration-plan.md. The design is in project/forgejo-integration.md (Tools → CI; Implementation Notes → Forgejo webhook compatibility).

Requested capabilities:

  • Read all status checks for a commit: the combined state, plus name, state and target URL for each check. This tells whether every check has reached a terminal state.
  • List the jobs of a run (GET /repos/{o}/{r}/actions/runs/{run_id}/jobs).
  • Read job logs (GET /repos/{o}/{r}/actions/jobs/{job_id}/logs).
  • Download the full run logs as a ZIP (GET /repos/{o}/{r}/actions/runs/{run_id}/logs).

Facts checked against git.sclu1034.dev:

  • The instance runs Forgejo 16.0.6+gitea-1.22.0 (GET /api/v1/version). Its swagger lists every endpoint above, plus GET /repos/{o}/{r}/commits/{ref}/status (CombinedStatus), GET /actions/runs/{run_id}, and GET /actions/runs?run_number=N.
  • The job-log endpoint accepts only attempt (1-based; omitted = latest) and an HTTP Range header. It has no step or format parameter, so the plan does not use them.
  • The ZIP holds the latest attempt of each job, one entry per job, named {job-name}-{job-id}-attempt-{N}.log.
  • ActionRunJob fields: id, name, attempt, status, run_id, needs, runs_on, handle, task_id, owner_id, repo_id. ActionRun includes id, index_in_repo, status, commit_sha, html_url, title, started, stopped.

Decisions from grilling:

  • Job logs are snapshotted whole into session storage (local://). The tool also returns an inline tail of the last 50 lines, capped at 5 KB. The agent reads or greps the snapshot for more.
  • The run-logs tool really downloads the ZIP and unpacks it into local:// using a new dependency, fflate.
  • forgejo_read_commit_status resolves each Actions check's run number to the API run id itself and prints that run id on the check's line.
  • A snapshot is reused when the job or run is terminal. Otherwise it is refetched and overwritten.
  • All three trigger-session policies get the four tools: conversation, pull_request and implementer.
  • Each tool gets a dedicated web card fed by typed protocol details.

Plan

Tool surface (exact)

All four tools are read-only (mutating: false), so they are not in FORGEJO_MUTATING_TOOLS. Like every Forgejo tool, their descriptions start with Forgejo: and use REPO_SCOPE.

name label params (zod)
forgejo_read_commit_status Forgejo Read Commit Status ref: z.string().min(1) (commit SHA, branch or tag)
forgejo_list_run_jobs Forgejo List Run Jobs run_id: z.number().int().positive()
forgejo_read_job_log Forgejo Read Job Log run_id, job_id (both positive ints), attempt?: z.number().int().positive()
forgejo_get_run_logs Forgejo Get Run Logs run_id: z.number().int().positive()

Every run_id/job_id description says it is the API id shown by forgejo_read_commit_status / forgejo_list_run_jobs, not the run number in the web URL.

Terminal status set: const TERMINAL = new Set(["success", "failure", "cancelled", "skipped"]). Every other Actions run/job status (waiting, running, blocked, unknown, …) counts as non-terminal. Commit-status terminal states are success, failure, error, warning. pending is non-terminal.

Steps

  1. Dependency. Add fflate (latest 0.8.x) to apps/server/package.json dependencies with the repo's package manager (bun add fflate in apps/server). Nothing in the repo unzips today, so there is no existing helper to reuse.

  2. Binary download in the client. In apps/server/src/forgejo/client.ts, add a bytes method to ForgejoClient, next to text():

    /** Binary GET (ZIP archives) read through a byte cap. */
    async bytes(path: string, opts: TextOpts): Promise<{ ok: true; data: Uint8Array; truncated: boolean } | { ok: false; status: number; message: string }>
    
    • It uses this.send("GET", path, opts, "application/zip, application/octet-stream, */*") and returns httpError on a non-2xx response.
    • It reads res.body chunks into a list until maxBytes is reached, then cancels the stream. truncated is true when data existed beyond the cap. It concatenates the chunks into one Uint8Array. This differs from readBodyCapped, which does UTF-8 work; a ZIP has no UTF-8 boundaries.
    • Read errors map to { ok: false, status: 0, message: "failed reading response: …" } (redacted), the same way safeRead does.
  3. New module apps/server/src/forgejo/tools/ci.ts. Its header doc comment lists the four tools. The module exports CI_TOOLS = [readCommitStatus, listRunJobs, readJobLog, getRunLogs] and the pure helpers named below, which tests import. API response types go in apps/server/src/forgejo/tools/api-types.ts, following its existing style: ApiCombinedStatus { state; sha; total_count; statuses: ApiCommitStatus[] }, ApiCommitStatus { context; status; description; target_url; updated_at }, ApiActionRun { id; index_in_repo; status; commit_sha; html_url; title }, ApiActionRunList { workflow_runs: ApiActionRun[]; total_count }, and ApiActionRunJob { id; name; attempt; status; needs; runs_on }.

    • Check the list wrapper first: read the ActionRunList definition in https://git.sclu1034.dev/swagger.v1.json. If the array key is not workflow_runs, use the key the swagger gives.

    3a. parseActionsTarget(url: string | null): { runNumber: number; jobIndex: number | null } | null. Match /\/actions\/runs\/(\d+)(?:\/jobs\/(\d+))?\/?(?:[?#].*)?$/ on the URL. Return null for a null URL or no match; that is a non-Actions status.

    3b. forgejo_read_commit_status

    • GET repoApiPath(o, r, "commits", encodeURIComponent(ref), "status") with { limit: 50 } for state, sha and the first statuses. When total_count > statuses.length, take the check list from fetchAllPages<ApiCommitStatus>(client, repoApiPath(o, r, "commits", encodeURIComponent(ref), "statuses"), { pageSize: 50, maxPages: 10, signal }) instead.
    • Forgejo returns 404 for an unknown ref. Map it to ref "<ref>" not found.
    • Collect the distinct runNumbers from parseActionsTarget(target_url). Resolve each to a run with mapLimit(numbers, 4, n => client.json<ApiActionRunList>("GET", repoApiPath(o, r, "actions", "runs"), { query: { run_number: n, limit: 1 }, signal })). Take workflow_runs[0]. A miss or an API failure on one number leaves that check's run id null; the tool does not fail.
    • Output text:
      • Line 1: <sha7> combined: <state> · <n> checks · <all terminal|k pending>.
      • Then one line per status, in API order: - <state> <context> — <description> [run <run_id> (#<runNumber>)[ job index <jobIndex>]] <target_url>.
      • Footer: next: forgejo_list_run_jobs run_id=<id> for job ids, then forgejo_read_job_log.
      • Zero statuses: <sha7>: no status checks.
      • Wrap the text in withCap(text, "pass a commit SHA to narrow").
    • Details: ForgejoCommitStatusResult (step 5).

    3c. forgejo_list_run_jobs

    • GET actions/runs/<run_id> for the run header, then GET actions/runs/<run_id>/jobs (an array; ActionRunJobList). A 404 on either maps to run <run_id> not found.
    • Text:
      • Line 1: run <id> (#<index_in_repo>) <status> · <title> · <sha7> · <html_url>.
      • Then one line per job: - job <id> <status> <name> · attempt <attempt>[ · needs: a, b].
      • Footer: logs: forgejo_read_job_log run_id=<id> job_id=<job>; all jobs: forgejo_get_run_logs run_id=<id>.
      • Zero jobs: no jobs.
      • Wrap with withCap(..., "the run has too many jobs to list; use forgejo_get_run_logs").

    3d. forgejo_read_job_log

    • Get the storage root with localRoot(call); on failure return errorResult.
    • GET actions/runs/<run_id>/jobs and find job_id in it. If it is missing, return job <job_id> is not in run <run_id>.
    • attempt = p.attempt ?? job.attempt. If p.attempt > job.attempt, return job <id> has <job.attempt> attempt(s).
    • rel = \ci/job-${job_id}-attempt-${attempt}.log`. The log is final when TERMINAL.has(job.status)orattempt < job.attempt` (an earlier attempt is always final).
    • If the log is final and existsSync(join(root, rel)), reuse the file: read it, and set reused: true.
    • Otherwise fetch with client.text(repoApiPath(o, r, "actions", "jobs", String(job_id), "logs"), { query: { attempt }, maxBytes: SNAPSHOT_READ_BYTES, signal }) and write the snapshot with writeFileAtomic(root, rel, text). Always overwrite when the log is not final.
    • Inline tail, from the pure helper logTail(text: string, maxLines = 50, maxBytes = 5 * 1024): string:
      • Take the last 50 lines.
      • If those exceed 5 KB, drop whole lines from the front. If only one line remains and it is still over 5 KB, encode it with Buffer.from(line), take subarray(-5120), skip leading UTF-8 continuation bytes ((b & 0xc0) === 0x80), and decode the rest.
    • Text:
      • job <id> <name> · attempt <a> · <status>[ (still running — log incomplete, call again later)] · <lines> lines · <formatSize(bytes)>
      • snapshot: local://<rel>, plus (reused) when the snapshot was reused.
      • When the log was truncated: [truncated: the log exceeds the 64 MB read limit; the snapshot holds the first <size>].
      • last <k> lines: followed by a fenced block containing the tail.
      • An empty log gives log is empty instead of the tail.
    • Count lines exactly as readFileAtRef does (apps/server/src/forgejo/tools/prs.ts ~L273–275). Move that count into an exported countLines(text) in snapshot.ts and use it from both tools.

    3e. forgejo_get_run_logs

    • Get the storage root with localRoot(call). GET actions/runs/<run_id>; a 404 maps to run <run_id> not found. The run is final when TERMINAL.has(run.status).
    • The snapshot directory name is ci/run-<run_id>. If the run is final and the directory exists, reuse it: list its files and skip the download. If the run is not final, rm(join(root, "ci", \run-${run_id}`), { recursive: true, force: true })` first, then download.
    • Download with client.bytes(repoApiPath(o, r, "actions", "runs", String(run_id), "logs"), { maxBytes: SNAPSHOT_READ_BYTES, signal }). If the result is truncated, return the error run logs ZIP exceeds the 64 MB download limit; use forgejo_read_job_log per job.
    • Unpack with unzipSync(data, { filter: f => … }) from fflate. The filter keeps only names ending .log and adds up f.originalSize. Once the total would exceed MAX_UNZIPPED_BYTES = 256 * 1024 * 1024, it returns false for the rest and sets a skipped counter. If unzipSync throws, return the error run logs ZIP could not be read: <msg>.
    • Entry file name, from the pure helper zipEntryName(name: string): string: take the basename after the last /, then replace every character outside [A-Za-z0-9._-] with _. If two entries collide, append -2, -3, … before .log.
    • Decode each entry with new TextDecoder().decode(bytes). Write all entries with writeSnapshotDir(root, \ci/run-${run_id}`, files). If the result is "exists"` (a concurrent call won the race), treat the snapshot as reused.
      • Check first: writeSnapshotDir calls mkdir(root) and then join(root, name). A name containing / needs join(root, "ci") to exist, so pass join(root, "ci") as the root and run-<id> as the name instead.
    • Text:
      • run <id> (#<index_in_repo>) <status>[ (still running — logs incomplete)] · <n> job logs · <total size>
      • snapshot: local://ci/run-<id>/, plus (reused) when reused.
      • One line per file: - local://ci/run-<id>/<file> · <lines> lines · <size>.
      • When entries were skipped: [<skipped> entries skipped: unpacked size limit 256 MB].
      • Footer: read or grep the files; search all with grep path=local://ci/run-<id>/.
      • Wrap with withCap.
  4. Registry. In apps/server/src/forgejo/tools/index.ts:

    • Import CI_TOOLS and set FORGEJO_TOOL_DEFS = [...ISSUE_TOOLS, ...PR_TOOLS, ...CI_TOOLS].
    • Change the header comment "18 tools" to "22 tools".
    • In apps/server/src/forgejo/tools/shared.ts, the header comment already says "and later CI tools". Change it to "and CI tools".
  5. Protocol types. Append to packages/protocol/src/forgejo-tools.ts:

    /** `forgejo_read_commit_status`. */
    export interface ForgejoCommitStatusResult {
    	ref: string;
    	/** Full SHA. */
    	sha: string;
    	/** Combined state: pending | success | error | failure | warning. */
    	state: string;
    	allTerminal: boolean;
    	checks: Array<{ context: string; state: string; description: string; targetUrl: ForgejoUrl; runId: number | null; runNumber: number | null; jobIndex: number | null }>;
    }
    /** `forgejo_list_run_jobs`. */
    export interface ForgejoRunJobsResult {
    	runId: number; runNumber: number; status: string; title: string; sha: string; url: ForgejoUrl;
    	jobs: Array<{ id: number; name: string; status: string; attempt: number; needs: string[] }>;
    }
    /** `forgejo_read_job_log`. `snapshot` e.g. `local://ci/job-12-attempt-1.log`. */
    export interface ForgejoJobLogResult {
    	runId: number; jobId: number; name: string; attempt: number; status: string; final: boolean;
    	lines: number; bytes: number; truncated: boolean; reused: boolean; snapshot: string; tail: string;
    }
    /** `forgejo_get_run_logs`. `snapshot` e.g. `local://ci/run-7` (no trailing slash). */
    export interface ForgejoRunLogsResult {
    	runId: number; runNumber: number; status: string; final: boolean; reused: boolean; snapshot: string;
    	skipped: number; files: Array<{ name: string; path: string; lines: number; bytes: number }>;
    }
    

    Export these the same way the existing types are exported. Check packages/protocol/src/index.ts for a re-export of forgejo-tools.ts (unverified — confirm first).

  6. Web cards. In apps/web/src/components/tools/forgejo/, add CommitStatusView.tsx, RunJobsView.tsx, JobLogView.tsx and RunLogsView.tsx.

    • Model each on ReadFileView.tsx: use ForgejoShell with rows (ArgRows for the args), hasDetails, and a children body. Each card gets an isX(details) guard written like isReadFileAtRef in ReadFileSummary.tsx.
    • CommitStatus card: a state badge and one row per check (state, context, ForgejoLink to the target URL, run id).
    • RunJobs card: the run header with a ForgejoLink to url, and one row per job.
    • JobLog card: a status line, SnapshotLine path={snapshot}, and the tail in a collapsed <details><pre>.
    • RunLogs card: a status line, SnapshotLine, and the file list.
    • In ForgejoTool.tsx, add the four cases to the main switch, next to the forgejo_read_file_at_ref case. Add header summaries in summarizeForgejoArgs:
      • forgejo_read_commit_status → ref (7 chars when the ref is 40 hex characters)
      • forgejo_list_run_jobs / forgejo_get_run_logs → run <run_id>
      • forgejo_read_job_log → run <run_id> · job <job_id>[ · attempt <n>]
    • Update the top doc comment's list of tools that have dedicated cards.
    • Add preview fixtures to apps/web/src/preview-forgejo.ts FORGEJO_FIXTURES, following the fj-read entries: fj-ci-status (mixed pending/failure), fj-ci-jobs, fj-ci-joblog, fj-ci-joblog-running, fj-ci-runlogs, and fj-ci-error (isError).
  7. Session policies. In apps/server/src/bridge/session-policy.ts:

    • Append "forgejo_read_commit_status", "forgejo_list_run_jobs", "forgejo_read_job_log", "forgejo_get_run_logs" to both CONVERSATION_TOOL_NAMES (after forgejo_search_prs) and IMPLEMENTER_TOOL_NAMES (after forgejo_search_prs). The pull_request policy inherits them through CONVERSATION_TOOL_NAMES.
    • Their decide already returns "default" for listed tools, and read tools never prompt, so no other change is needed.
    • Add the constant CI_BULLET = "- CI results: \forgejo_read_commit_status` shows a commit's checks with run ids, `forgejo_list_run_jobs` the jobs of a run, `forgejo_read_job_log` / `forgejo_get_run_logs` snapshot logs to local:// for `read`/`grep`.". Insert it right after WEB_ACCESS_BULLETin the conversation and pull-requestsystemContextarrays, and as a line in the implementer policy'ssystemContext` immediately before its untrusted-data line. If the implementer has no such line, put it at the end.
  8. Docs. In docs/configuration.md around L216, change "All 18 tools" to "All 22 tools". Add one bullet describing the four CI tools and their local://ci/… snapshots. In L248 (the trigger-session tool set), mention the CI read tools.

Critical files & anchors

  • apps/server/src/forgejo/client.ts: ForgejoClient.text/send/safeRead (~L223–311). The new bytes() goes beside them.
  • apps/server/src/forgejo/tools/prs.ts: readFileAtRef (~L238–291). This is the template for snapshot tools (localRoot, writeFileAtomic, details plus text).
  • apps/server/src/forgejo/tools/snapshot.ts: writeSnapshotDir (~L53), writeFileAtomic, SNAPSHOT_READ_BYTES, formatSize. countLines is added here.
  • apps/server/src/bridge/session-policy.ts: CONVERSATION_TOOL_NAMES (~L115), IMPLEMENTER_TOOL_NAMES (~L142), and the systemContext arrays.
  • apps/web/src/components/tools/ForgejoTool.tsx: main switch (~L64) and summarizeForgejoArgs (~L149).

Verification

Run from /workspace/omp-deck (apps/server for server tests).

  • Tests to update:
    • apps/server/src/forgejo/tools/index.test.ts: add the four names to READ_TOOLS. Without that, the read/mutating split assertion at ~L74 and the registered-tools assertion at ~L128 fail.
    • apps/server/src/bridge/permission-filter.test.ts: add the four names to READ (~L35), which asserts that read tools don't prompt.
    • apps/server/src/bridge/session-policy.test.ts: assert that policyToolNames of a conversation, a pull_request and an implementer trigger each contain forgejo_read_job_log, and that decidePolicy(TRIGGER, "forgejo_read_job_log", {...}, "s") is "default".
  • New unit tests in apps/server/src/forgejo/tools/ci.test.ts, pure helpers only:
    • parseActionsTarget:
      • https://h/o/r/actions/runs/12/jobs/0 → {runNumber:12, jobIndex:0}
      • …/actions/runs/12 → {12, null}
      • https://ci.example/build/5 → null
      • null → null
    • logTail:
      • 120 short lines → the last 50
      • 50 lines of 200 bytes each → fewer than 50 lines, total ≤ 5120 bytes, ending with the last line
      • one 10 KB line → ≤ 5120 bytes, valid UTF-8 (include a multibyte character near the cut)
    • zipEntryName: "build/test (ubuntu)-17-attempt-1.log" → "test__ubuntu_-17-attempt-1.log".
    • Unzip round-trip: build a ZIP with zipSync({"a/x-1-attempt-1.log": strToU8("hi\n"), "readme.txt": …}). The extraction helper, factored as an exported extractRunLogs(data: Uint8Array): { files: {rel, content}[]; skipped: number }, returns exactly x-1-attempt-1.log with content hi\n.
  • Commands: bun test apps/server/src/forgejo apps/server/src/bridge, then the repo typecheck (bun run typecheck at the root — confirm the script name in the root package.json).
  • Live end-to-end (needs the deck configured for git.sclu1034.dev and a session in this repo): in a new manual deck session, ask the agent to call forgejo_read_commit_status ref=main.
    • Expect a combined-state line, and checks whose target_url contains /actions/runs/ showing a numeric run <id>.
    • Then forgejo_list_run_jobs with that id lists jobs. forgejo_read_job_log writes local://ci/job-<id>-attempt-<n>.log, and read opens it. A second call on a finished job says (reused).
    • forgejo_get_run_logs creates local://ci/run-<id>/*.log.
    • Open the deck UI and confirm each call renders its dedicated card.

Assumptions & contingencies

  • Target URL format: Forgejo Actions commit statuses point to <repo html>/actions/runs/<run_number>/jobs/<job_index>, where <run_number> equals ActionRun.index_in_repo. The repo is private, so this is unverified. If the live check shows a different format (for example the API run id itself), adapt parseActionsTarget and skip the run_number lookup when the number already is the id. Confirm by comparing a real status's target_url with GET /actions/runs/{id}'s index_in_repo.
  • run_number filter: if GET /actions/runs?run_number=N ignores the filter on 16.0.6 (it returns runs whose index_in_repo ≠ N), fall back to paging /actions/runs (at most 5 pages × 50) and matching index_in_repo locally.
  • Job-log endpoint while a job is running: if it returns 404 or an error, the tool reports the API error unchanged together with the job status. No retry logic.
## Problem Agents need tools to inspect CI results on Forgejo. They are useful right away in manual sessions ("why did CI fail on #42"), and the CI watchdogs (plan steps 17, 20, 23) build on them: the PR watchdog for other people's PRs, CI monitoring on the agent's own PRs, and the branch-failure watchdog. This is step 16 of KB `project/forgejo-integration-plan.md`. The design is in `project/forgejo-integration.md` (Tools → CI; Implementation Notes → Forgejo webhook compatibility). Requested capabilities: - Read all status checks for a commit: the combined state, plus name, state and target URL for each check. This tells whether every check has reached a terminal state. - List the jobs of a run (`GET /repos/{o}/{r}/actions/runs/{run_id}/jobs`). - Read job logs (`GET /repos/{o}/{r}/actions/jobs/{job_id}/logs`). - Download the full run logs as a ZIP (`GET /repos/{o}/{r}/actions/runs/{run_id}/logs`). Facts checked against git.sclu1034.dev: - The instance runs Forgejo `16.0.6+gitea-1.22.0` (`GET /api/v1/version`). Its swagger lists every endpoint above, plus `GET /repos/{o}/{r}/commits/{ref}/status` (CombinedStatus), `GET /actions/runs/{run_id}`, and `GET /actions/runs?run_number=N`. - The job-log endpoint accepts only `attempt` (1-based; omitted = latest) and an HTTP Range header. It has **no** `step` or `format` parameter, so the plan does not use them. - The ZIP holds the latest attempt of each job, one entry per job, named `{job-name}-{job-id}-attempt-{N}.log`. - `ActionRunJob` fields: `id, name, attempt, status, run_id, needs, runs_on, handle, task_id, owner_id, repo_id`. `ActionRun` includes `id, index_in_repo, status, commit_sha, html_url, title, started, stopped`. Decisions from grilling: - Job logs are snapshotted whole into session storage (`local://`). The tool also returns an inline tail of the last 50 lines, capped at 5 KB. The agent reads or greps the snapshot for more. - The run-logs tool really downloads the ZIP and unpacks it into `local://` using a new dependency, `fflate`. - `forgejo_read_commit_status` resolves each Actions check's run number to the API run id itself and prints that run id on the check's line. - A snapshot is reused when the job or run is terminal. Otherwise it is refetched and overwritten. - All three trigger-session policies get the four tools: conversation, pull_request and implementer. - Each tool gets a dedicated web card fed by typed protocol `details`. ## Plan ### Tool surface (exact) All four tools are read-only (`mutating: false`), so they are not in `FORGEJO_MUTATING_TOOLS`. Like every Forgejo tool, their descriptions start with `Forgejo:` and use `REPO_SCOPE`. | name | label | params (zod) | |---|---|---| | `forgejo_read_commit_status` | `Forgejo Read Commit Status` | `ref: z.string().min(1)` (commit SHA, branch or tag) | | `forgejo_list_run_jobs` | `Forgejo List Run Jobs` | `run_id: z.number().int().positive()` | | `forgejo_read_job_log` | `Forgejo Read Job Log` | `run_id`, `job_id` (both positive ints), `attempt?: z.number().int().positive()` | | `forgejo_get_run_logs` | `Forgejo Get Run Logs` | `run_id: z.number().int().positive()` | Every `run_id`/`job_id` description says it is the API id shown by `forgejo_read_commit_status` / `forgejo_list_run_jobs`, not the run number in the web URL. Terminal status set: `const TERMINAL = new Set(["success", "failure", "cancelled", "skipped"])`. Every other Actions run/job status (`waiting`, `running`, `blocked`, `unknown`, …) counts as non-terminal. Commit-status terminal states are `success`, `failure`, `error`, `warning`. `pending` is non-terminal. ### Steps 1. **Dependency.** Add `fflate` (latest 0.8.x) to `apps/server/package.json` `dependencies` with the repo's package manager (`bun add fflate` in `apps/server`). Nothing in the repo unzips today, so there is no existing helper to reuse. 2. **Binary download in the client.** In `apps/server/src/forgejo/client.ts`, add a `bytes` method to `ForgejoClient`, next to `text()`: ```ts /** Binary GET (ZIP archives) read through a byte cap. */ async bytes(path: string, opts: TextOpts): Promise<{ ok: true; data: Uint8Array; truncated: boolean } | { ok: false; status: number; message: string }> ``` - It uses `this.send("GET", path, opts, "application/zip, application/octet-stream, */*")` and returns `httpError` on a non-2xx response. - It reads `res.body` chunks into a list until `maxBytes` is reached, then cancels the stream. `truncated` is true when data existed beyond the cap. It concatenates the chunks into one `Uint8Array`. This differs from `readBodyCapped`, which does UTF-8 work; a ZIP has no UTF-8 boundaries. - Read errors map to `{ ok: false, status: 0, message: "failed reading response: …" }` (redacted), the same way `safeRead` does. 3. **New module `apps/server/src/forgejo/tools/ci.ts`.** Its header doc comment lists the four tools. The module exports `CI_TOOLS = [readCommitStatus, listRunJobs, readJobLog, getRunLogs]` and the pure helpers named below, which tests import. API response types go in `apps/server/src/forgejo/tools/api-types.ts`, following its existing style: `ApiCombinedStatus { state; sha; total_count; statuses: ApiCommitStatus[] }`, `ApiCommitStatus { context; status; description; target_url; updated_at }`, `ApiActionRun { id; index_in_repo; status; commit_sha; html_url; title }`, `ApiActionRunList { workflow_runs: ApiActionRun[]; total_count }`, and `ApiActionRunJob { id; name; attempt; status; needs; runs_on }`. - **Check the list wrapper first:** read the `ActionRunList` definition in `https://git.sclu1034.dev/swagger.v1.json`. If the array key is not `workflow_runs`, use the key the swagger gives. 3a. **`parseActionsTarget(url: string | null): { runNumber: number; jobIndex: number | null } | null`.** Match `/\/actions\/runs\/(\d+)(?:\/jobs\/(\d+))?\/?(?:[?#].*)?$/` on the URL. Return null for a null URL or no match; that is a non-Actions status. 3b. **`forgejo_read_commit_status`** - GET `repoApiPath(o, r, "commits", encodeURIComponent(ref), "status")` with `{ limit: 50 }` for `state`, `sha` and the first statuses. When `total_count` > `statuses.length`, take the check list from `fetchAllPages<ApiCommitStatus>(client, repoApiPath(o, r, "commits", encodeURIComponent(ref), "statuses"), { pageSize: 50, maxPages: 10, signal })` instead. - Forgejo returns 404 for an unknown ref. Map it to `ref "<ref>" not found`. - Collect the distinct `runNumber`s from `parseActionsTarget(target_url)`. Resolve each to a run with `mapLimit(numbers, 4, n => client.json<ApiActionRunList>("GET", repoApiPath(o, r, "actions", "runs"), { query: { run_number: n, limit: 1 }, signal }))`. Take `workflow_runs[0]`. A miss or an API failure on one number leaves that check's run id `null`; the tool does not fail. - Output text: - Line 1: `<sha7> combined: <state> · <n> checks · <all terminal|k pending>`. - Then one line per status, in API order: `- <state> <context> — <description> [run <run_id> (#<runNumber>)[ job index <jobIndex>]] <target_url>`. - Footer: `next: forgejo_list_run_jobs run_id=<id> for job ids, then forgejo_read_job_log`. - Zero statuses: `<sha7>: no status checks`. - Wrap the text in `withCap(text, "pass a commit SHA to narrow")`. - Details: `ForgejoCommitStatusResult` (step 5). 3c. **`forgejo_list_run_jobs`** - GET `actions/runs/<run_id>` for the run header, then GET `actions/runs/<run_id>/jobs` (an array; `ActionRunJobList`). A 404 on either maps to `run <run_id> not found`. - Text: - Line 1: `run <id> (#<index_in_repo>) <status> · <title> · <sha7> · <html_url>`. - Then one line per job: `- job <id> <status> <name> · attempt <attempt>[ · needs: a, b]`. - Footer: `logs: forgejo_read_job_log run_id=<id> job_id=<job>; all jobs: forgejo_get_run_logs run_id=<id>`. - Zero jobs: `no jobs`. - Wrap with `withCap(..., "the run has too many jobs to list; use forgejo_get_run_logs")`. 3d. **`forgejo_read_job_log`** - Get the storage root with `localRoot(call)`; on failure return `errorResult`. - GET `actions/runs/<run_id>/jobs` and find `job_id` in it. If it is missing, return `job <job_id> is not in run <run_id>`. - `attempt = p.attempt ?? job.attempt`. If `p.attempt > job.attempt`, return `job <id> has <job.attempt> attempt(s)`. - `rel = \`ci/job-${job_id}-attempt-${attempt}.log\``. The log is final when `TERMINAL.has(job.status)` or `attempt < job.attempt` (an earlier attempt is always final). - If the log is final and `existsSync(join(root, rel))`, reuse the file: read it, and set `reused: true`. - Otherwise fetch with `client.text(repoApiPath(o, r, "actions", "jobs", String(job_id), "logs"), { query: { attempt }, maxBytes: SNAPSHOT_READ_BYTES, signal })` and write the snapshot with `writeFileAtomic(root, rel, text)`. Always overwrite when the log is not final. - Inline tail, from the pure helper `logTail(text: string, maxLines = 50, maxBytes = 5 * 1024): string`: - Take the last 50 lines. - If those exceed 5 KB, drop whole lines from the front. If only one line remains and it is still over 5 KB, encode it with `Buffer.from(line)`, take `subarray(-5120)`, skip leading UTF-8 continuation bytes (`(b & 0xc0) === 0x80`), and decode the rest. - Text: - `job <id> <name> · attempt <a> · <status>[ (still running — log incomplete, call again later)] · <lines> lines · <formatSize(bytes)>` - `snapshot: local://<rel>`, plus ` (reused)` when the snapshot was reused. - When the log was truncated: `[truncated: the log exceeds the 64 MB read limit; the snapshot holds the first <size>]`. - `last <k> lines:` followed by a fenced block containing the tail. - An empty log gives `log is empty` instead of the tail. - Count lines exactly as `readFileAtRef` does (`apps/server/src/forgejo/tools/prs.ts` ~L273–275). Move that count into an exported `countLines(text)` in `snapshot.ts` and use it from both tools. 3e. **`forgejo_get_run_logs`** - Get the storage root with `localRoot(call)`. GET `actions/runs/<run_id>`; a 404 maps to `run <run_id> not found`. The run is final when `TERMINAL.has(run.status)`. - The snapshot directory name is `ci/run-<run_id>`. If the run is final and the directory exists, reuse it: list its files and skip the download. If the run is not final, `rm(join(root, "ci", \`run-${run_id}\`), { recursive: true, force: true })` first, then download. - Download with `client.bytes(repoApiPath(o, r, "actions", "runs", String(run_id), "logs"), { maxBytes: SNAPSHOT_READ_BYTES, signal })`. If the result is truncated, return the error `run logs ZIP exceeds the 64 MB download limit; use forgejo_read_job_log per job`. - Unpack with `unzipSync(data, { filter: f => … })` from `fflate`. The filter keeps only names ending `.log` and adds up `f.originalSize`. Once the total would exceed `MAX_UNZIPPED_BYTES = 256 * 1024 * 1024`, it returns false for the rest and sets a `skipped` counter. If `unzipSync` throws, return the error `run logs ZIP could not be read: <msg>`. - Entry file name, from the pure helper `zipEntryName(name: string): string`: take the basename after the last `/`, then replace every character outside `[A-Za-z0-9._-]` with `_`. If two entries collide, append `-2`, `-3`, … before `.log`. - Decode each entry with `new TextDecoder().decode(bytes)`. Write all entries with `writeSnapshotDir(root, \`ci/run-${run_id}\`, files)`. If the result is `"exists"` (a concurrent call won the race), treat the snapshot as reused. - **Check first:** `writeSnapshotDir` calls `mkdir(root)` and then `join(root, name)`. A name containing `/` needs `join(root, "ci")` to exist, so pass `join(root, "ci")` as the root and `run-<id>` as the name instead. - Text: - `run <id> (#<index_in_repo>) <status>[ (still running — logs incomplete)] · <n> job logs · <total size>` - `snapshot: local://ci/run-<id>/`, plus ` (reused)` when reused. - One line per file: `- local://ci/run-<id>/<file> · <lines> lines · <size>`. - When entries were skipped: `[<skipped> entries skipped: unpacked size limit 256 MB]`. - Footer: `read or grep the files; search all with grep path=local://ci/run-<id>/`. - Wrap with `withCap`. 4. **Registry.** In `apps/server/src/forgejo/tools/index.ts`: - Import `CI_TOOLS` and set `FORGEJO_TOOL_DEFS = [...ISSUE_TOOLS, ...PR_TOOLS, ...CI_TOOLS]`. - Change the header comment "18 tools" to "22 tools". - In `apps/server/src/forgejo/tools/shared.ts`, the header comment already says "and later CI tools". Change it to "and CI tools". 5. **Protocol types.** Append to `packages/protocol/src/forgejo-tools.ts`: ```ts /** `forgejo_read_commit_status`. */ export interface ForgejoCommitStatusResult { ref: string; /** Full SHA. */ sha: string; /** Combined state: pending | success | error | failure | warning. */ state: string; allTerminal: boolean; checks: Array<{ context: string; state: string; description: string; targetUrl: ForgejoUrl; runId: number | null; runNumber: number | null; jobIndex: number | null }>; } /** `forgejo_list_run_jobs`. */ export interface ForgejoRunJobsResult { runId: number; runNumber: number; status: string; title: string; sha: string; url: ForgejoUrl; jobs: Array<{ id: number; name: string; status: string; attempt: number; needs: string[] }>; } /** `forgejo_read_job_log`. `snapshot` e.g. `local://ci/job-12-attempt-1.log`. */ export interface ForgejoJobLogResult { runId: number; jobId: number; name: string; attempt: number; status: string; final: boolean; lines: number; bytes: number; truncated: boolean; reused: boolean; snapshot: string; tail: string; } /** `forgejo_get_run_logs`. `snapshot` e.g. `local://ci/run-7` (no trailing slash). */ export interface ForgejoRunLogsResult { runId: number; runNumber: number; status: string; final: boolean; reused: boolean; snapshot: string; skipped: number; files: Array<{ name: string; path: string; lines: number; bytes: number }>; } ``` Export these the same way the existing types are exported. Check `packages/protocol/src/index.ts` for a re-export of `forgejo-tools.ts` (unverified — confirm first). 6. **Web cards.** In `apps/web/src/components/tools/forgejo/`, add `CommitStatusView.tsx`, `RunJobsView.tsx`, `JobLogView.tsx` and `RunLogsView.tsx`. - Model each on `ReadFileView.tsx`: use `ForgejoShell` with `rows` (`ArgRow`s for the args), `hasDetails`, and a children body. Each card gets an `isX(details)` guard written like `isReadFileAtRef` in `ReadFileSummary.tsx`. - CommitStatus card: a state badge and one row per check (state, context, `ForgejoLink` to the target URL, run id). - RunJobs card: the run header with a `ForgejoLink` to `url`, and one row per job. - JobLog card: a status line, `SnapshotLine path={snapshot}`, and the tail in a collapsed `<details><pre>`. - RunLogs card: a status line, `SnapshotLine`, and the file list. - In `ForgejoTool.tsx`, add the four `case`s to the main switch, next to the `forgejo_read_file_at_ref` case. Add header summaries in `summarizeForgejoArgs`: - `forgejo_read_commit_status` → `ref` (7 chars when the ref is 40 hex characters) - `forgejo_list_run_jobs` / `forgejo_get_run_logs` → `run <run_id>` - `forgejo_read_job_log` → `run <run_id> · job <job_id>[ · attempt <n>]` - Update the top doc comment's list of tools that have dedicated cards. - Add preview fixtures to `apps/web/src/preview-forgejo.ts` `FORGEJO_FIXTURES`, following the `fj-read` entries: `fj-ci-status` (mixed pending/failure), `fj-ci-jobs`, `fj-ci-joblog`, `fj-ci-joblog-running`, `fj-ci-runlogs`, and `fj-ci-error` (isError). 7. **Session policies.** In `apps/server/src/bridge/session-policy.ts`: - Append `"forgejo_read_commit_status", "forgejo_list_run_jobs", "forgejo_read_job_log", "forgejo_get_run_logs"` to both `CONVERSATION_TOOL_NAMES` (after `forgejo_search_prs`) and `IMPLEMENTER_TOOL_NAMES` (after `forgejo_search_prs`). The pull_request policy inherits them through `CONVERSATION_TOOL_NAMES`. - Their `decide` already returns `"default"` for listed tools, and read tools never prompt, so no other change is needed. - Add the constant `CI_BULLET = "- CI results: \`forgejo_read_commit_status\` shows a commit's checks with run ids, \`forgejo_list_run_jobs\` the jobs of a run, \`forgejo_read_job_log\` / \`forgejo_get_run_logs\` snapshot logs to local:// for \`read\`/\`grep\`."`. Insert it right after `WEB_ACCESS_BULLET` in the conversation and pull-request `systemContext` arrays, and as a line in the implementer policy's `systemContext` immediately before its untrusted-data line. If the implementer has no such line, put it at the end. 8. **Docs.** In `docs/configuration.md` around L216, change "All 18 tools" to "All 22 tools". Add one bullet describing the four CI tools and their `local://ci/…` snapshots. In L248 (the trigger-session tool set), mention the CI read tools. ### Critical files & anchors - `apps/server/src/forgejo/client.ts`: `ForgejoClient.text`/`send`/`safeRead` (~L223–311). The new `bytes()` goes beside them. - `apps/server/src/forgejo/tools/prs.ts`: `readFileAtRef` (~L238–291). This is the template for snapshot tools (`localRoot`, `writeFileAtomic`, details plus text). - `apps/server/src/forgejo/tools/snapshot.ts`: `writeSnapshotDir` (~L53), `writeFileAtomic`, `SNAPSHOT_READ_BYTES`, `formatSize`. `countLines` is added here. - `apps/server/src/bridge/session-policy.ts`: `CONVERSATION_TOOL_NAMES` (~L115), `IMPLEMENTER_TOOL_NAMES` (~L142), and the systemContext arrays. - `apps/web/src/components/tools/ForgejoTool.tsx`: main switch (~L64) and `summarizeForgejoArgs` (~L149). ### Verification Run from `/workspace/omp-deck` (`apps/server` for server tests). - **Tests to update:** - `apps/server/src/forgejo/tools/index.test.ts`: add the four names to `READ_TOOLS`. Without that, the read/mutating split assertion at ~L74 and the registered-tools assertion at ~L128 fail. - `apps/server/src/bridge/permission-filter.test.ts`: add the four names to `READ` (~L35), which asserts that read tools don't prompt. - `apps/server/src/bridge/session-policy.test.ts`: assert that `policyToolNames` of a conversation, a pull_request and an implementer trigger each contain `forgejo_read_job_log`, and that `decidePolicy(TRIGGER, "forgejo_read_job_log", {...}, "s")` is `"default"`. - **New unit tests in `apps/server/src/forgejo/tools/ci.test.ts`, pure helpers only:** - `parseActionsTarget`: - `https://h/o/r/actions/runs/12/jobs/0` → `{runNumber:12, jobIndex:0}` - `…/actions/runs/12` → `{12, null}` - `https://ci.example/build/5` → null - null → null - `logTail`: - 120 short lines → the last 50 - 50 lines of 200 bytes each → fewer than 50 lines, total ≤ 5120 bytes, ending with the last line - one 10 KB line → ≤ 5120 bytes, valid UTF-8 (include a multibyte character near the cut) - `zipEntryName`: `"build/test (ubuntu)-17-attempt-1.log"` → `"test__ubuntu_-17-attempt-1.log"`. - Unzip round-trip: build a ZIP with `zipSync({"a/x-1-attempt-1.log": strToU8("hi\n"), "readme.txt": …})`. The extraction helper, factored as an exported `extractRunLogs(data: Uint8Array): { files: {rel, content}[]; skipped: number }`, returns exactly `x-1-attempt-1.log` with content `hi\n`. - **Commands:** `bun test apps/server/src/forgejo apps/server/src/bridge`, then the repo typecheck (`bun run typecheck` at the root — confirm the script name in the root `package.json`). - **Live end-to-end** (needs the deck configured for git.sclu1034.dev and a session in this repo): in a new manual deck session, ask the agent to call `forgejo_read_commit_status ref=main`. - Expect a combined-state line, and checks whose `target_url` contains `/actions/runs/` showing a numeric `run <id>`. - Then `forgejo_list_run_jobs` with that id lists jobs. `forgejo_read_job_log` writes `local://ci/job-<id>-attempt-<n>.log`, and `read` opens it. A second call on a finished job says `(reused)`. - `forgejo_get_run_logs` creates `local://ci/run-<id>/*.log`. - Open the deck UI and confirm each call renders its dedicated card. ### Assumptions & contingencies - **Target URL format:** Forgejo Actions commit statuses point to `<repo html>/actions/runs/<run_number>/jobs/<job_index>`, where `<run_number>` equals `ActionRun.index_in_repo`. The repo is private, so this is unverified. If the live check shows a different format (for example the API run id itself), adapt `parseActionsTarget` and skip the `run_number` lookup when the number already is the id. Confirm by comparing a real status's `target_url` with `GET /actions/runs/{id}`'s `index_in_repo`. - **`run_number` filter:** if `GET /actions/runs?run_number=N` ignores the filter on 16.0.6 (it returns runs whose `index_in_repo` ≠ N), fall back to paging `/actions/runs` (at most 5 pages × 50) and matching `index_in_repo` locally. - **Job-log endpoint while a job is running:** if it returns 404 or an error, the tool reports the API error unchanged together with the job status. No retry logic.
lucas closed this issue 2026-10-26 14:21:00 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#74
No description provided.