@bytesbrains/pi-ci-gate 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,123 @@
1
+ # CI Gate — Agent Usage Guide
2
+
3
+ > You are an AI agent. Use ci-gate tools to observe CI workflows, diagnose failures, and re-trigger runs.
4
+
5
+ ## Golden Rules
6
+
7
+ > **⚠️ Always check CI after submitting a PR.** Use `ci_list_runs()` to find the run for your branch before asking for reviews.
8
+
9
+ > **⚠️ Read logs before re-running.** Blindly re-running a failing workflow wastes compute. Use `ci_get_logs()` to diagnose first.
10
+
11
+ > **⚠️ Never skip confirm=true.** The `ci_rerun` and `ci_cancel` tools require explicit confirmation — this is intentional.
12
+
13
+ ## Workflow
14
+
15
+ ```
16
+ ci_list_runs(status="failure", branch="feat/my-branch")
17
+
18
+
19
+ ci_get_run(run_index="42") ← see all jobs, find the failing one
20
+
21
+
22
+ ci_get_logs(run_index="42") ← read failure logs for all jobs
23
+ │ (or: run_index="42", job_index="512" for one job)
24
+
25
+ [understand the error, fix the code]
26
+
27
+
28
+ contrib_propose(message="fix: resolve CI failure in build step")
29
+
30
+
31
+ contrib_submit(...) ← push the fix (triggers new CI run)
32
+
33
+
34
+ ci_list_runs(status="failure", branch="feat/my-branch")
35
+ ← verify the fix passes
36
+ ```
37
+
38
+ ## Common Patterns
39
+
40
+ ### Find a failing CI run for your PR
41
+
42
+ ```
43
+ ci_list_runs(status="failure", branch="feat/my-feature", limit=5)
44
+ ```
45
+
46
+ ### Inspect a specific run
47
+
48
+ ```
49
+ ci_get_run(run_index="42")
50
+ ```
51
+
52
+ ### See which job failed
53
+
54
+ ```
55
+ ci_list_jobs(run_index="42")
56
+ ```
57
+
58
+ ### Read the failure logs (specific job)
59
+
60
+ ```
61
+ ci_get_logs(run_index="42", job_index="512")
62
+ ```
63
+ > 💡 Job IDs are numeric and come from `ci_list_jobs` or `ci_get_run` output — not an arbitrary index.
64
+
65
+ ### Read all logs for a run (all jobs)
66
+
67
+ ```
68
+ ci_get_logs(run_index="42")
69
+ ```
70
+
71
+ ### Re-run after fixing
72
+
73
+ ```
74
+ ci_rerun(run_index="42", confirm=true)
75
+ ```
76
+
77
+ ### Cancel a stuck run
78
+
79
+ ```
80
+ ci_cancel(run_index="42", confirm=true)
81
+ ```
82
+
83
+ ### List available workflows
84
+
85
+ ```
86
+ ci_list_workflows()
87
+ ```
88
+
89
+ ### Filter runs by workflow and event
90
+
91
+ ```
92
+ ci_list_runs(workflow="ci.yml", event="push", branch="main", limit=10)
93
+ ```
94
+ > 💡 The `workflow` filter matches against the workflow file path (e.g., `.gitea/workflows/ci.yml`). A substring like `ci.yml` is enough.
95
+
96
+ ## Log Truncation
97
+
98
+ Logs are truncated to `maxLogLines` (default: 200) per job. You'll see the first ~40% (setup) and last ~60% (failures/results). A marker shows how many lines were omitted. This keeps context manageable while surfacing the key information.
99
+
100
+ ## Status Icons
101
+
102
+ | Icon | Meaning |
103
+ |---|---|
104
+ | 🔄 | Running |
105
+ | ⏳ | Waiting / Blocked |
106
+ | ✅ | Success |
107
+ | ❌ | Failure |
108
+ | 🚫 | Cancelled |
109
+ | ⏭️ | Skipped |
110
+ | ⚪ | Unknown / Other |
111
+
112
+ ## When Things Go Wrong
113
+
114
+ | Problem | Solution |
115
+ |---|---|
116
+ | `ci_rerun` blocked without confirm | Pass `confirm=true` — this is a safety gate |
117
+ | `ci_rerun` blocked by feature flag | `.circ.yml` has `allowRerun: false` — ask a human |
118
+ | Logs are truncated | Increase `maxLogLines` in `.circ.yml`, or fetch a specific job with `job_index` |
119
+ | No workflows found | The repo may not have Gitea Actions configured |
120
+ | 404 on run ID | The run may have been deleted or the ID is wrong — use `ci_list_runs()` to find it |
121
+ | Rate limited | Wait for the cooldown (60s) before retrying a destructive action |
122
+ | `ci_get_logs` returns "no logs available" for a job | The job may not have produced logs (e.g., skipped or still queued). Check its status first with `ci_list_jobs` |
123
+ | Need to rerun only failed jobs | Use `ci_rerun(run_index="42", confirm=true, failed_only=true)` |
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nandal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # CI Gate for Pi
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pi-ci-gate)](https://www.npmjs.com/package/pi-ci-gate)
4
+ [![license](https://img.shields.io/npm/l/pi-ci-gate)](./LICENSE)
5
+
6
+ > CI observability gate for AI agents — view workflow runs, job statuses, and logs from Gitea Actions with safety controls. **Agents self-diagnose CI failures instead of asking humans.**
7
+
8
+ ## Philosophy
9
+
10
+ `pi-contrib-gate` handles the *contribution* side (branch → commit → PR).
11
+ `pi-review-gate` handles the *review* side (check → approve → merge).
12
+ `pi-project-gate` handles the *project* side (issue → plan → release).
13
+ `pi-ci-gate` handles the *CI* side (observe → diagnose → re-trigger).
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pi install npm:pi-ci-gate
19
+ ```
20
+
21
+ ## Tools
22
+
23
+ | Tool | Safety | What it does |
24
+ |---|---|---|
25
+ | `ci_list_workflows()` | ✅ Read-only | List registered workflows in the repo |
26
+ | `ci_list_runs(workflow?, status?, branch?, limit?)` | ✅ Read-only | List workflow runs with filters |
27
+ | `ci_get_run(run_index)` | ✅ Read-only | Get full run details (status, timing, trigger) |
28
+ | `ci_list_jobs(run_index)` | ✅ Read-only | List jobs for a run with statuses |
29
+ | `ci_get_logs(run_index, job_index?)` | ✅ Read-only | Get job logs (truncated to safe limits) |
30
+ | `ci_rerun(run_index, confirm)` | ⚠️ Destructive | Re-run a failed/cancelled workflow |
31
+ | `ci_cancel(run_index, confirm)` | ⚠️ Destructive | Cancel a running workflow |
32
+
33
+ ## Safety Harness
34
+
35
+ The destructive tools (`ci_rerun`, `ci_cancel`) have a triple safety gate:
36
+
37
+ ```
38
+ 🛡️ Gate 1: Feature flag
39
+ └─ .circ.yml: allowRerun: false / allowCancel: false → tool blocked entirely
40
+
41
+ 🛡️ Gate 2: Explicit confirmation
42
+ └─ Must pass confirm=true — prevents accidental triggers
43
+
44
+ 🛡️ Gate 3: Rate limiting
45
+ └─ 60s cooldown between destructive actions on the same run
46
+ ```
47
+
48
+ Log output is also truncated: shows the head (setup) and tail (failures), keeping agents from drowning in logs while still surfacing what matters.
49
+
50
+ ## Configuration
51
+
52
+ Create `.circ.yml`:
53
+
54
+ ```yaml
55
+ # Max log lines returned per job (head + tail split)
56
+ maxLogLines: 200
57
+
58
+ # Enable/disable destructive tools
59
+ allowRerun: true
60
+ allowCancel: true
61
+
62
+ # Default limit for listing runs
63
+ defaultLimit: 20
64
+ ```
65
+
66
+ ## Workflow
67
+
68
+ ```
69
+ ci_list_runs(status="failure") ← find failed runs
70
+
71
+
72
+ ci_get_run(42) ← inspect a specific run
73
+
74
+
75
+ ci_list_jobs(42) ← see which jobs failed
76
+
77
+
78
+ ci_get_logs(42, job_index=1) ← read the failure logs
79
+
80
+
81
+ [fix the code]
82
+
83
+
84
+ ci_rerun(42, confirm=true) ← re-trigger the workflow
85
+
86
+
87
+ ci_get_run(42) ← verify it passed ✅
88
+ ```
89
+
90
+ ## Integration
91
+
92
+ Install all four gates for full agent governance:
93
+
94
+ ```bash
95
+ pi install npm:pi-contrib-gate
96
+ pi install npm:pi-review-gate
97
+ pi install npm:pi-project-gate
98
+ pi install npm:pi-ci-gate
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT © [nandal](https://github.com/nandal)
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@bytesbrains/pi-ci-gate",
3
+ "version": "1.1.1",
4
+ "description": "CI observability gate for AI agents — view workflow runs, job statuses, and logs from Gitea Actions with safety controls.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "ci",
9
+ "workflow",
10
+ "gitea-actions",
11
+ "observability"
12
+ ],
13
+ "author": "nandal <nandal@users.noreply.github.com>",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/nandal/pi-ext",
17
+ "directory": "ci-gate"
18
+ },
19
+ "homepage": "https://github.com/nandal/pi-ext/tree/main/ci-gate",
20
+ "bugs": {
21
+ "url": "https://github.com/nandal/pi-ext/issues"
22
+ },
23
+ "license": "MIT",
24
+ "main": "./src/index.ts",
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "src/",
30
+ "README.md",
31
+ "AGENTS.md",
32
+ "LICENSE"
33
+ ],
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "typebox": "*"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./src/index.ts"
41
+ ]
42
+ },
43
+ "scripts": {
44
+ "test": "vitest run",
45
+ "test:watch": "vitest"
46
+ },
47
+ "devDependencies": {
48
+ "vitest": "^2.1.9"
49
+ }
50
+ }
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { loadConfig, DEFAULT_CONFIG } from "../config";
3
+ import { truncateLogs, checkRateLimit } from "../helpers";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import * as os from "node:os";
7
+
8
+ // ═══════════════════════════════════════
9
+ // Config
10
+ // ═══════════════════════════════════════
11
+ describe("CiConfig", () => {
12
+ it("returns defaults when no config file", () => {
13
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ci-test-"));
14
+ const config = loadConfig(tmp);
15
+ expect(config.maxLogLines).toBe(200);
16
+ expect(config.allowRerun).toBe(true);
17
+ expect(config.allowCancel).toBe(true);
18
+ expect(config.defaultLimit).toBe(20);
19
+ fs.rmSync(tmp, { recursive: true, force: true });
20
+ });
21
+
22
+ it("parses .circ.yml", () => {
23
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ci-test-"));
24
+ fs.writeFileSync(
25
+ path.join(tmp, ".circ.yml"),
26
+ ["maxLogLines: 100", "allowRerun: false", "allowCancel: false", "defaultLimit: 10"].join("\n"),
27
+ );
28
+ const config = loadConfig(tmp);
29
+ expect(config.maxLogLines).toBe(100);
30
+ expect(config.allowRerun).toBe(false);
31
+ expect(config.allowCancel).toBe(false);
32
+ expect(config.defaultLimit).toBe(10);
33
+ fs.rmSync(tmp, { recursive: true, force: true });
34
+ });
35
+
36
+ it("parses quoted string values", () => {
37
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ci-test-"));
38
+ fs.writeFileSync(path.join(tmp, ".circ.yml"), 'maxLogLines: "256"');
39
+ const config = loadConfig(tmp);
40
+ expect(config.maxLogLines).toBe(256);
41
+ fs.rmSync(tmp, { recursive: true, force: true });
42
+ });
43
+
44
+ it("handles partial config with defaults for missing keys", () => {
45
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ci-test-"));
46
+ fs.writeFileSync(path.join(tmp, ".circ.yml"), "maxLogLines: 50");
47
+ const config = loadConfig(tmp);
48
+ expect(config.maxLogLines).toBe(50);
49
+ expect(config.allowRerun).toBe(DEFAULT_CONFIG.allowRerun);
50
+ expect(config.allowCancel).toBe(DEFAULT_CONFIG.allowCancel);
51
+ expect(config.defaultLimit).toBe(DEFAULT_CONFIG.defaultLimit);
52
+ fs.rmSync(tmp, { recursive: true, force: true });
53
+ });
54
+
55
+ it("handles invalid YAML gracefully", () => {
56
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ci-test-"));
57
+ fs.writeFileSync(path.join(tmp, ".circ.yml"), "::: invalid :::");
58
+ const config = loadConfig(tmp);
59
+ expect(config).toEqual(DEFAULT_CONFIG);
60
+ fs.rmSync(tmp, { recursive: true, force: true });
61
+ });
62
+ });
63
+
64
+ // ═══════════════════════════════════════
65
+ // Log Truncation
66
+ // ═══════════════════════════════════════
67
+ describe("truncateLogs", () => {
68
+ it("returns full text when under limit", () => {
69
+ const text = "line 1\nline 2\nline 3";
70
+ const result = truncateLogs(text, 10);
71
+ expect(result.truncated).toBe(false);
72
+ expect(result.text).toBe(text);
73
+ expect(result.totalLines).toBe(3);
74
+ });
75
+
76
+ it("returns full text when exactly at limit", () => {
77
+ const text = "a\nb\nc\nd\ne";
78
+ const result = truncateLogs(text, 5);
79
+ expect(result.truncated).toBe(false);
80
+ expect(result.totalLines).toBe(5);
81
+ });
82
+
83
+ it("truncates when over limit, keeping head and tail", () => {
84
+ const lines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`);
85
+ const text = lines.join("\n");
86
+ const result = truncateLogs(text, 20);
87
+ expect(result.truncated).toBe(true);
88
+ expect(result.totalLines).toBe(100);
89
+ // Should contain a truncation marker
90
+ expect(result.text).toContain("lines truncated");
91
+ // Head portion should have first lines
92
+ expect(result.text).toContain("line 1");
93
+ // Tail portion should have last lines
94
+ expect(result.text).toContain("line 100");
95
+ // Should not exceed the limit significantly
96
+ const outputLines = result.text.split("\n");
97
+ expect(outputLines.length).toBeLessThanOrEqual(24); // 20 + marker lines
98
+ });
99
+
100
+ it("handles empty string", () => {
101
+ const result = truncateLogs("", 100);
102
+ expect(result.truncated).toBe(false);
103
+ expect(result.totalLines).toBe(1); // empty string split gives [""]
104
+ expect(result.text).toBe("");
105
+ });
106
+ });
107
+
108
+ // ═══════════════════════════════════════
109
+ // Rate Limiter
110
+ // ═══════════════════════════════════════
111
+ describe("checkRateLimit", () => {
112
+ it("allows first call", () => {
113
+ const result = checkRateLimit("test-key-1", 10);
114
+ expect(result.allowed).toBe(true);
115
+ expect(result.retryAfter).toBe(0);
116
+ });
117
+
118
+ it("blocks second call within cooldown", () => {
119
+ const key = "test-key-2";
120
+ checkRateLimit(key, 10); // first call
121
+ const result = checkRateLimit(key, 10); // second call immediately
122
+ expect(result.allowed).toBe(false);
123
+ expect(result.retryAfter).toBeGreaterThan(0);
124
+ });
125
+
126
+ it("different keys don't interfere", () => {
127
+ checkRateLimit("key-a", 10);
128
+ const result = checkRateLimit("key-b", 10);
129
+ expect(result.allowed).toBe(true);
130
+ });
131
+
132
+ it("retryAfter is a positive integer", () => {
133
+ const key = "test-key-3";
134
+ checkRateLimit(key, 60);
135
+ const result = checkRateLimit(key, 60);
136
+ expect(result.allowed).toBe(false);
137
+ expect(Number.isInteger(result.retryAfter)).toBe(true);
138
+ expect(result.retryAfter).toBeGreaterThan(0);
139
+ });
140
+ });
141
+
142
+ // ═══════════════════════════════════════
143
+ // Tool definitions
144
+ // ═══════════════════════════════════════
145
+ import {
146
+ listWorkflowsTool,
147
+ listRunsTool,
148
+ getRunTool,
149
+ listJobsTool,
150
+ getLogsTool,
151
+ rerunTool,
152
+ cancelTool,
153
+ } from "../tools/ci";
154
+
155
+ describe("ci tool definitions", () => {
156
+ it("listWorkflowsTool has proper metadata", () => {
157
+ expect(listWorkflowsTool.name).toBe("ci_list_workflows");
158
+ expect(listWorkflowsTool.label).toBe("List Workflows");
159
+ expect(listWorkflowsTool.description).toContain("registered CI workflows");
160
+ });
161
+
162
+ it("listRunsTool has filter parameters", () => {
163
+ expect(listRunsTool.name).toBe("ci_list_runs");
164
+ const props = (listRunsTool.parameters as any)?.properties;
165
+ expect(props["workflow"]).toBeDefined();
166
+ expect(props["status"]).toBeDefined();
167
+ expect(props["branch"]).toBeDefined();
168
+ expect(props["event"]).toBeDefined();
169
+ expect(props["limit"]).toBeDefined();
170
+ });
171
+
172
+ it("getRunTool requires run_index", () => {
173
+ expect(getRunTool.name).toBe("ci_get_run");
174
+ const props = (getRunTool.parameters as any)?.properties;
175
+ expect(props["run_index"]).toBeDefined();
176
+ });
177
+
178
+ it("listJobsTool requires run_index", () => {
179
+ expect(listJobsTool.name).toBe("ci_list_jobs");
180
+ const props = (listJobsTool.parameters as any)?.properties;
181
+ expect(props["run_index"]).toBeDefined();
182
+ });
183
+
184
+ it("getLogsTool has run_index and optional job_index", () => {
185
+ expect(getLogsTool.name).toBe("ci_get_logs");
186
+ const props = (getLogsTool.parameters as any)?.properties;
187
+ expect(props["run_index"]).toBeDefined();
188
+ expect(props["job_index"]).toBeDefined();
189
+ });
190
+
191
+ it("rerunTool requires confirm=true and accepts optional failed_only", () => {
192
+ expect(rerunTool.name).toBe("ci_rerun");
193
+ const props = (rerunTool.parameters as any)?.properties;
194
+ expect(props["run_index"]).toBeDefined();
195
+ expect(props["confirm"]).toBeDefined();
196
+ expect(props["failed_only"]).toBeDefined();
197
+ // confirm is required (not optional)
198
+ const required = (rerunTool.parameters as any)?.required || [];
199
+ expect(required).toContain("confirm");
200
+ });
201
+
202
+ it("cancelTool requires confirm=true", () => {
203
+ expect(cancelTool.name).toBe("ci_cancel");
204
+ const props = (cancelTool.parameters as any)?.properties;
205
+ expect(props["run_index"]).toBeDefined();
206
+ expect(props["confirm"]).toBeDefined();
207
+ const required = (cancelTool.parameters as any)?.required || [];
208
+ expect(required).toContain("confirm");
209
+ });
210
+ });
package/src/config.ts ADDED
@@ -0,0 +1,56 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export interface CiConfig {
5
+ /** Max log lines returned per job (prevents context overflow) */
6
+ maxLogLines: number;
7
+ /** Enable/disable the rerun tool */
8
+ allowRerun: boolean;
9
+ /** Enable/disable the cancel tool */
10
+ allowCancel: boolean;
11
+ /** Default limit for listing runs */
12
+ defaultLimit: number;
13
+ }
14
+
15
+ export const DEFAULT_CONFIG: CiConfig = {
16
+ maxLogLines: 200,
17
+ allowRerun: true,
18
+ allowCancel: true,
19
+ defaultLimit: 20,
20
+ };
21
+
22
+ export function loadConfig(cwd: string): CiConfig {
23
+ const configPath = path.join(cwd, ".circ.yml");
24
+ if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
25
+ try {
26
+ const content = fs.readFileSync(configPath, "utf-8");
27
+ const result: Record<string, unknown> = {};
28
+ for (const line of content.split("\n")) {
29
+ const m = line.match(/^\s*([\w][\w.]*):\s*(.+)$/);
30
+ if (m) {
31
+ let val = m[2].trim();
32
+ if (
33
+ (val.startsWith('"') && val.endsWith('"')) ||
34
+ (val.startsWith("'") && val.endsWith("'"))
35
+ )
36
+ val = val.slice(1, -1);
37
+ result[m[1]] = val;
38
+ }
39
+ }
40
+ return {
41
+ maxLogLines: parseInt(result["maxLogLines"] as string) || DEFAULT_CONFIG.maxLogLines,
42
+ allowRerun:
43
+ result["allowRerun"] !== undefined
44
+ ? result["allowRerun"] === "true"
45
+ : DEFAULT_CONFIG.allowRerun,
46
+ allowCancel:
47
+ result["allowCancel"] !== undefined
48
+ ? result["allowCancel"] === "true"
49
+ : DEFAULT_CONFIG.allowCancel,
50
+ defaultLimit:
51
+ parseInt(result["defaultLimit"] as string) || DEFAULT_CONFIG.defaultLimit,
52
+ };
53
+ } catch {
54
+ return { ...DEFAULT_CONFIG };
55
+ }
56
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,99 @@
1
+ import * as cp from "node:child_process";
2
+
3
+ export function exec(
4
+ cmd: string,
5
+ cwd?: string,
6
+ ): { ok: boolean; stdout: string; stderr: string } {
7
+ try {
8
+ const r = cp.execSync(cmd, { cwd, encoding: "utf-8", timeout: 30000 });
9
+ return { ok: true, stdout: r.trim(), stderr: "" };
10
+ } catch (e: any) {
11
+ return {
12
+ ok: false,
13
+ stdout: e.stdout?.trim() || "",
14
+ stderr: e.stderr?.trim() || e.message,
15
+ };
16
+ }
17
+ }
18
+
19
+ export function resolveGitea(cwd: string): { repo: string; token: string } {
20
+ const remote = exec(
21
+ "git remote get-url gitea 2>/dev/null || git remote get-url origin",
22
+ cwd,
23
+ );
24
+ const url = remote.stdout || "";
25
+ const match = url.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
26
+ const repo = match ? `${match[1]}/${match[2]}` : "factory/wrok.in";
27
+ const credMatch = url.match(/:\/\/([^:]+):([^@]+)@/);
28
+ return { repo, token: credMatch ? credMatch[2] : (process.env.GITEA_TOKEN || "") };
29
+ }
30
+
31
+ export async function giteaApi(
32
+ path: string,
33
+ method: string,
34
+ body: Record<string, unknown> | null,
35
+ opts: { repo: string; token?: string },
36
+ _cwd: string,
37
+ ): Promise<{ ok: boolean; data: unknown; error?: string; statusCode?: number }> {
38
+ const base = `http://127.0.0.1:3001/api/v1/repos/${opts.repo}`;
39
+ const url = `${base}${path}`;
40
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
41
+ if (opts.token) headers["Authorization"] = `token ${opts.token}`;
42
+
43
+ try {
44
+ const res = await fetch(url, {
45
+ method,
46
+ headers,
47
+ body: body ? JSON.stringify(body) : undefined,
48
+ });
49
+ const text = await res.text();
50
+ const statusCode = res.status;
51
+ if (!res.ok) {
52
+ return { ok: false, data: null, statusCode, error: text || `HTTP ${statusCode}` };
53
+ }
54
+ try {
55
+ return { ok: true, data: JSON.parse(text), statusCode };
56
+ } catch {
57
+ return { ok: true, data: text, statusCode };
58
+ }
59
+ } catch (e: any) {
60
+ return { ok: false, data: null, error: e.message || "Network error" };
61
+ }
62
+ }
63
+
64
+ /** Truncate logs to maxLines, keeping head + tail so agents see setup and failures. */
65
+ export function truncateLogs(
66
+ raw: string,
67
+ maxLines: number,
68
+ ): { text: string; truncated: boolean; totalLines: number } {
69
+ const allLines = raw.split("\n");
70
+ const totalLines = allLines.length;
71
+ if (totalLines <= maxLines) {
72
+ return { text: raw, truncated: false, totalLines };
73
+ }
74
+ const head = Math.floor(maxLines * 0.4);
75
+ const tail = maxLines - head;
76
+ const headText = allLines.slice(0, head).join("\n");
77
+ const tailText = allLines.slice(-tail).join("\n");
78
+ return {
79
+ text: `${headText}\n\n... [${totalLines - maxLines} lines truncated] ...\n\n${tailText}`,
80
+ truncated: true,
81
+ totalLines,
82
+ };
83
+ }
84
+
85
+ /** In-memory rate limiter for destructive actions. */
86
+ const rateLimitMap = new Map<string, number>();
87
+ export function checkRateLimit(
88
+ key: string,
89
+ cooldownSeconds: number,
90
+ ): { allowed: boolean; retryAfter: number } {
91
+ const last = rateLimitMap.get(key) || 0;
92
+ const now = Date.now();
93
+ const elapsed = (now - last) / 1000;
94
+ if (elapsed < cooldownSeconds) {
95
+ return { allowed: false, retryAfter: Math.ceil(cooldownSeconds - elapsed) };
96
+ }
97
+ rateLimitMap.set(key, now);
98
+ return { allowed: true, retryAfter: 0 };
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * pi-ci-gate — CI Observability Gate
3
+ *
4
+ * Tools: ci_list_workflows, ci_list_runs, ci_get_run, ci_list_jobs,
5
+ * ci_get_logs, ci_rerun, ci_cancel
6
+ * Config: .circ.yml
7
+ */
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ listWorkflowsTool,
11
+ listRunsTool,
12
+ getRunTool,
13
+ listJobsTool,
14
+ getLogsTool,
15
+ rerunTool,
16
+ cancelTool,
17
+ } from "./tools/ci";
18
+
19
+ export default function (pi: ExtensionAPI) {
20
+ pi.registerTool(listWorkflowsTool);
21
+ pi.registerTool(listRunsTool);
22
+ pi.registerTool(getRunTool);
23
+ pi.registerTool(listJobsTool);
24
+ pi.registerTool(getLogsTool);
25
+ pi.registerTool(rerunTool);
26
+ pi.registerTool(cancelTool);
27
+ }
@@ -0,0 +1,515 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig } from "../config";
4
+ import { resolveGitea, giteaApi, truncateLogs, checkRateLimit } from "../helpers";
5
+
6
+ // ─── Types ──────────────────────────────────────────────────────────────────────
7
+
8
+ interface GiteaRun {
9
+ id: number;
10
+ run_number: number;
11
+ display_title: string;
12
+ path: string; // workflow file path, e.g. ".gitea/workflows/ci.yml"
13
+ event: string;
14
+ head_branch: string;
15
+ head_sha: string;
16
+ status: string; // pending, queued, in_progress
17
+ conclusion: string | null; // failure, success, skipped, cancelled
18
+ started_at: string;
19
+ completed_at: string | null;
20
+ html_url: string;
21
+ url: string;
22
+ run_attempt: number;
23
+ }
24
+
25
+ interface GiteaJob {
26
+ id: number;
27
+ run_id: number;
28
+ name: string;
29
+ status: string;
30
+ conclusion: string | null;
31
+ started_at: string;
32
+ completed_at: string | null;
33
+ head_branch: string;
34
+ head_sha: string;
35
+ }
36
+
37
+ // ─── Helpers ────────────────────────────────────────────────────────────────────
38
+
39
+ function opts(ctx: ExtensionContext) {
40
+ return resolveGitea(ctx.cwd);
41
+ }
42
+
43
+ function guard(
44
+ enabled: boolean,
45
+ name: string,
46
+ ): null | { content: { type: "text"; text: string }[]; isError: true; details: {} } {
47
+ if (!enabled) {
48
+ return {
49
+ content: [{ type: "text", text: `⛔ "${name}" is disabled via .circ.yml (allowRerun/allowCancel: false).` }],
50
+ isError: true,
51
+ details: {},
52
+ };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function confirm(
58
+ confirmed: boolean,
59
+ action: string,
60
+ ): null | { content: { type: "text"; text: string }[]; isError: true; details: {} } {
61
+ if (!confirmed) {
62
+ return {
63
+ content: [
64
+ {
65
+ type: "text",
66
+ text: `⚠️ Destructive action: ${action}\nPass confirm=true to proceed.`,
67
+ },
68
+ ],
69
+ isError: true,
70
+ details: {},
71
+ };
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function rateLimit(
77
+ key: string,
78
+ seconds: number,
79
+ ): null | { content: { type: "text"; text: string }[]; isError: true; details: {} } {
80
+ const rl = checkRateLimit(key, seconds);
81
+ if (!rl.allowed) {
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: `⏳ Rate limited. Try again in ${rl.retryAfter}s.`,
87
+ },
88
+ ],
89
+ isError: true,
90
+ details: { retryAfter: rl.retryAfter },
91
+ };
92
+ }
93
+ return null;
94
+ }
95
+
96
+ // ─── List Workflows ─────────────────────────────────────────────────────────────
97
+
98
+ export const listWorkflowsTool = {
99
+ name: "ci_list_workflows" as const,
100
+ label: "List Workflows",
101
+ description:
102
+ "List registered CI workflows in the repository. Returns workflow ID, name, path, and state.",
103
+ parameters: Type.Object({}),
104
+ async execute(_id: string, _p: any, _s: any, _u: any, ctx: ExtensionContext) {
105
+ const r = await giteaApi("/actions/workflows", "GET", null, opts(ctx), ctx.cwd);
106
+ if (!r.ok || !r.data) {
107
+ return {
108
+ content: [{ type: "text", text: `❌ Failed to list workflows: ${r.error || "unknown"}` }],
109
+ isError: true,
110
+ details: {},
111
+ };
112
+ }
113
+ const workflows = (r.data as any)?.workflows ?? [];
114
+ if (workflows.length === 0) {
115
+ return { content: [{ type: "text", text: "No workflows registered." }], details: { count: 0 } };
116
+ }
117
+ const lines = [`🔧 Workflows (${workflows.length})`, ""];
118
+ for (const w of workflows) {
119
+ const badge = w.state === "active" ? "🟢" : w.state === "disabled" ? "🔴" : "⚪";
120
+ lines.push(` ${badge} #${w.id} ${w.name}`);
121
+ lines.push(` path: ${w.path}`);
122
+ }
123
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { count: workflows.length } };
124
+ },
125
+ };
126
+
127
+ // ─── List Runs ──────────────────────────────────────────────────────────────────
128
+
129
+ export const listRunsTool = {
130
+ name: "ci_list_runs" as const,
131
+ label: "List Workflow Runs",
132
+ description:
133
+ "List workflow runs with optional filters: workflow path, status, branch, event. Returns run ID, status, and timing.",
134
+ parameters: Type.Object({
135
+ workflow: Type.Optional(Type.String({ description: "Workflow path or ID to filter by (e.g., '.gitea/workflows/ci.yml' or 'ci.yml')" })),
136
+ status: Type.Optional(
137
+ Type.String({ description: "Filter by status: pending, queued, in_progress, failure, success, skipped" }),
138
+ ),
139
+ branch: Type.Optional(Type.String({ description: "Filter by branch name" })),
140
+ event: Type.Optional(Type.String({ description: "Filter by trigger event: push, pull_request, schedule" })),
141
+ limit: Type.Optional(Type.Number({ description: "Max runs to return (default: 20, max: 50)" })),
142
+ }),
143
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
144
+ const config = loadConfig(ctx.cwd);
145
+ const limit = Math.min(params.limit || config.defaultLimit, 50);
146
+
147
+ // Build query string with server-side filters (Gitea supports: event, branch, status)
148
+ const qs = [`limit=${limit}`, "page=1"];
149
+ if (params.event) qs.push(`event=${encodeURIComponent(params.event)}`);
150
+ if (params.branch) qs.push(`branch=${encodeURIComponent(params.branch)}`);
151
+ if (params.status) qs.push(`status=${encodeURIComponent(params.status)}`);
152
+
153
+ const r = await giteaApi(`/actions/runs?${qs.join("&")}`, "GET", null, opts(ctx), ctx.cwd);
154
+ if (!r.ok) {
155
+ return {
156
+ content: [{ type: "text", text: `❌ Failed to list runs: ${r.error || "unknown"}` }],
157
+ isError: true,
158
+ details: {},
159
+ };
160
+ }
161
+ let runs: GiteaRun[] = (r.data as any)?.workflow_runs ?? [];
162
+
163
+ // Client-side workflow filter (Gitea doesn't support server-side workflow filter on /runs)
164
+ if (params.workflow) {
165
+ const wf = params.workflow.toLowerCase();
166
+ runs = runs.filter((run: GiteaRun) => {
167
+ const path = (run.path || "").toLowerCase();
168
+ return path.includes(wf) || String(run.id) === wf;
169
+ });
170
+ }
171
+
172
+ if (runs.length === 0) {
173
+ return { content: [{ type: "text", text: "No workflow runs found." }], details: { count: 0 } };
174
+ }
175
+
176
+ const lines = [`🏃 Workflow Runs (${runs.length})`, ""];
177
+ for (const run of runs) {
178
+ const icon = statusIcon(run.status, run.conclusion);
179
+ const duration = run.started_at && run.completed_at
180
+ ? formatDuration(run.started_at, run.completed_at)
181
+ : run.started_at
182
+ ? "running..."
183
+ : "";
184
+ lines.push(` ${icon} id=${run.id} run#${run.run_number} ${run.display_title || "(untitled)"}`);
185
+ lines.push(` status: ${run.conclusion || run.status} | ${duration}`);
186
+ lines.push(` branch: ${run.head_branch} | event: ${run.event} | workflow: ${run.path}`);
187
+ lines.push(` commit: ${(run.head_sha || "?").slice(0, 8)}`);
188
+ }
189
+ return {
190
+ content: [{ type: "text", text: lines.join("\n") }],
191
+ details: { count: runs.length, totalCount: (r.data as any)?.total_count },
192
+ };
193
+ },
194
+ };
195
+
196
+ // ─── Get Run ────────────────────────────────────────────────────────────────────
197
+
198
+ export const getRunTool = {
199
+ name: "ci_get_run" as const,
200
+ label: "Get Run Details",
201
+ description:
202
+ "Get a specific workflow run by ID. Also fetches its jobs so you can see all job statuses at a glance.",
203
+ parameters: Type.Object({
204
+ run_index: Type.String({ description: "Workflow run ID (numeric, e.g. '42'). Use ci_list_runs to find IDs." }),
205
+ }),
206
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
207
+ const runId = parseInt(params.run_index, 10);
208
+ if (isNaN(runId)) {
209
+ return {
210
+ content: [{ type: "text", text: `❌ Invalid run_index: "${params.run_index}" — must be a numeric run ID.` }],
211
+ isError: true,
212
+ details: {},
213
+ };
214
+ }
215
+
216
+ // Fetch the run directly
217
+ const runR = await giteaApi(`/actions/runs/${runId}`, "GET", null, opts(ctx), ctx.cwd);
218
+ if (!runR.ok) {
219
+ return {
220
+ content: [{ type: "text", text: `❌ Run #${runId} not found: ${runR.error || "unknown"}` }],
221
+ isError: true,
222
+ details: {},
223
+ };
224
+ }
225
+ const run: GiteaRun = (runR.data as any) || {};
226
+
227
+ // Also fetch jobs for this run
228
+ const jobs: GiteaJob[] = [];
229
+ const jobsR = await giteaApi(`/actions/runs/${runId}/jobs?limit=100&page=1`, "GET", null, opts(ctx), ctx.cwd);
230
+ if (jobsR.ok) {
231
+ jobs.push(...((jobsR.data as any)?.jobs ?? []));
232
+ }
233
+
234
+ const created = (run.started_at || "?").slice(0, 19).replace("T", " ");
235
+ const sha = (run.head_sha || "?").slice(0, 8);
236
+
237
+ const lines = [
238
+ `🏃 Run #${run.run_number} (id=${run.id})`,
239
+ ` Title: ${run.display_title || "(untitled)"}`,
240
+ ` Branch: ${run.head_branch} | Event: ${run.event}`,
241
+ ` Commit: ${sha} | Workflow: ${run.path}`,
242
+ ` Started: ${created}`,
243
+ ` URL: ${run.html_url || "—"}`,
244
+ ` Status: ${run.conclusion || run.status} | Attempt: ${run.run_attempt || 1}`,
245
+ "",
246
+ ];
247
+
248
+ if (jobs.length > 0) {
249
+ lines.push(` Jobs (${jobs.length}):`);
250
+ for (const j of jobs) {
251
+ const icon = statusIcon(j.status, j.conclusion);
252
+ lines.push(` ${icon} id=${j.id} ${j.name} → ${j.conclusion || j.status}`);
253
+ }
254
+ } else {
255
+ lines.push(` Jobs: (could not fetch — Gitea may not support /runs/{id}/jobs on this version)`);
256
+ }
257
+
258
+ return {
259
+ content: [{ type: "text", text: lines.join("\n") }],
260
+ details: { runId, runNumber: run.run_number, jobCount: jobs.length, workflow: run.path },
261
+ };
262
+ },
263
+ };
264
+
265
+ // ─── List Jobs ──────────────────────────────────────────────────────────────────
266
+
267
+ export const listJobsTool = {
268
+ name: "ci_list_jobs" as const,
269
+ label: "List Run Jobs",
270
+ description:
271
+ "List jobs for a specific workflow run by run ID. Returns job ID, name, status, and duration.",
272
+ parameters: Type.Object({
273
+ run_index: Type.String({ description: "Workflow run ID (numeric, e.g. '42'). Use ci_list_runs to find IDs." }),
274
+ }),
275
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
276
+ const runId = parseInt(params.run_index, 10);
277
+ if (isNaN(runId)) {
278
+ return {
279
+ content: [{ type: "text", text: `❌ Invalid run_index: "${params.run_index}" — must be a numeric run ID.` }],
280
+ isError: true,
281
+ details: {},
282
+ };
283
+ }
284
+
285
+ const r = await giteaApi(`/actions/runs/${runId}/jobs?limit=100&page=1`, "GET", null, opts(ctx), ctx.cwd);
286
+ if (!r.ok) {
287
+ return {
288
+ content: [{ type: "text", text: `❌ Failed to fetch jobs for run #${runId}: ${r.error || "unknown"}` }],
289
+ isError: true,
290
+ details: {},
291
+ };
292
+ }
293
+ const jobs: GiteaJob[] = (r.data as any)?.jobs ?? [];
294
+
295
+ if (jobs.length === 0) {
296
+ return { content: [{ type: "text", text: `No jobs found for run #${runId}.` }], details: { count: 0 } };
297
+ }
298
+
299
+ const lines = [`📋 Jobs for Run #${runId} (${jobs.length})`, ""];
300
+ for (const j of jobs) {
301
+ const icon = statusIcon(j.status, j.conclusion);
302
+ const duration = j.started_at && j.completed_at
303
+ ? formatDuration(j.started_at, j.completed_at)
304
+ : j.started_at
305
+ ? "running..."
306
+ : "";
307
+ lines.push(` ${icon} id=${j.id} ${j.name}`);
308
+ lines.push(` status: ${j.conclusion || j.status} | ${duration}`);
309
+ if (j.head_branch) {
310
+ lines.push(` branch: ${j.head_branch} | commit: ${(j.head_sha || "?").slice(0, 8)}`);
311
+ }
312
+ }
313
+ return {
314
+ content: [{ type: "text", text: lines.join("\n") }],
315
+ details: { runId, count: jobs.length },
316
+ };
317
+ },
318
+ };
319
+
320
+ // ─── Get Logs ───────────────────────────────────────────────────────────────────
321
+
322
+ export const getLogsTool = {
323
+ name: "ci_get_logs" as const,
324
+ label: "Get Job Logs",
325
+ description:
326
+ "Get logs for a job by job ID, or all jobs in a run by run ID. Logs are truncated to the configured max lines, showing the head and tail.",
327
+ parameters: Type.Object({
328
+ run_index: Type.String({ description: "Workflow run ID (numeric). If job_index is omitted, fetches logs for all jobs in this run." }),
329
+ job_index: Type.Optional(Type.String({ description: "Job ID (numeric, e.g. '512'). If provided, fetches just that job's logs directly." })),
330
+ }),
331
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
332
+ const config = loadConfig(ctx.cwd);
333
+
334
+ // Path A: direct job ID — fetch that specific job's logs
335
+ if (params.job_index) {
336
+ const jobId = params.job_index;
337
+ const r = await giteaApi(`/actions/jobs/${jobId}/logs`, "GET", null, opts(ctx), ctx.cwd);
338
+ if (!r.ok) {
339
+ return {
340
+ content: [{ type: "text", text: `❌ Failed to get logs for job #${jobId}: ${r.error || "no logs available"}` }],
341
+ isError: true,
342
+ details: {},
343
+ };
344
+ }
345
+ const raw = typeof r.data === "string" ? r.data : JSON.stringify(r.data);
346
+ const { text, truncated, totalLines } = truncateLogs(raw, config.maxLogLines);
347
+ const header = truncated
348
+ ? `📜 Job #${jobId} logs (${totalLines} lines, showing ${config.maxLogLines} — truncated)\n\n`
349
+ : `📜 Job #${jobId} logs (${totalLines} lines)\n\n`;
350
+ return {
351
+ content: [{ type: "text", text: header + text }],
352
+ details: { jobId, totalLines, truncated },
353
+ };
354
+ }
355
+
356
+ // Path B: run ID — discover jobs via /runs/{id}/jobs, then fetch each job's logs
357
+ const runId = parseInt(params.run_index, 10);
358
+ if (isNaN(runId)) {
359
+ return {
360
+ content: [{ type: "text", text: `❌ Invalid run_index: "${params.run_index}" — must be a numeric run ID.` }],
361
+ isError: true,
362
+ details: {},
363
+ };
364
+ }
365
+
366
+ const jobsR = await giteaApi(`/actions/runs/${runId}/jobs?limit=100&page=1`, "GET", null, opts(ctx), ctx.cwd);
367
+ if (!jobsR.ok) {
368
+ return {
369
+ content: [{ type: "text", text: `❌ Failed to fetch jobs for run #${runId}: ${jobsR.error || "unknown"}` }],
370
+ isError: true,
371
+ details: {},
372
+ };
373
+ }
374
+ const jobs: GiteaJob[] = (jobsR.data as any)?.jobs ?? [];
375
+ if (jobs.length === 0) {
376
+ return { content: [{ type: "text", text: `No jobs found for run #${runId}.` }], details: {} };
377
+ }
378
+
379
+ // Fetch logs for each job using its real job ID
380
+ const parts: string[] = [`📜 Logs for Run #${runId} (${jobs.length} jobs)`, ""];
381
+ for (const j of jobs) {
382
+ const logR = await giteaApi(`/actions/jobs/${j.id}/logs`, "GET", null, opts(ctx), ctx.cwd);
383
+ const icon = statusIcon(j.status, j.conclusion);
384
+ if (logR.ok && typeof logR.data === "string") {
385
+ const { text, truncated, totalLines } = truncateLogs(logR.data, config.maxLogLines);
386
+ parts.push(`─── ${icon} Job id=${j.id} (${j.name}) ───`);
387
+ if (truncated) parts.push(` (${totalLines} lines total, showing ${config.maxLogLines} — head + tail)`);
388
+ parts.push(text, "");
389
+ } else {
390
+ parts.push(`─── ${icon} Job id=${j.id} (${j.name}) — no logs available`);
391
+ parts.push(` (status: ${j.conclusion || j.status}, error: ${logR.error || "none"})`);
392
+ parts.push("");
393
+ }
394
+ }
395
+ return {
396
+ content: [{ type: "text", text: parts.join("\n") }],
397
+ details: { runId, jobCount: jobs.length },
398
+ };
399
+ },
400
+ };
401
+
402
+ // ─── Rerun ──────────────────────────────────────────────────────────────────────
403
+
404
+ export const rerunTool = {
405
+ name: "ci_rerun" as const,
406
+ label: "Re-run Workflow",
407
+ description:
408
+ "Re-run a workflow run (all jobs or just failed jobs). Uses Gitea's native rerun endpoint.",
409
+ parameters: Type.Object({
410
+ run_index: Type.String({ description: "Workflow run ID (numeric). Use ci_list_runs to find IDs." }),
411
+ confirm: Type.Boolean({ description: "Must be true to confirm" }),
412
+ failed_only: Type.Optional(Type.Boolean({ description: "If true, rerun only failed jobs (default: false, reruns all jobs)" })),
413
+ }),
414
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
415
+ const config = loadConfig(ctx.cwd);
416
+
417
+ // Safety gate 1: feature flag
418
+ const flag = guard(config.allowRerun, "ci_rerun");
419
+ if (flag) return flag;
420
+
421
+ // Safety gate 2: explicit confirmation
422
+ const action = params.failed_only ? "rerun failed jobs" : "rerun entire run";
423
+ const conf = confirm(params.confirm === true, `${action} (run #${params.run_index})`);
424
+ if (conf) return conf;
425
+
426
+ // Safety gate 3: rate limit
427
+ const rl = rateLimit(`rerun:${params.run_index}`, 60);
428
+ if (rl) return rl;
429
+
430
+ const runId = parseInt(params.run_index, 10);
431
+ if (isNaN(runId)) {
432
+ return {
433
+ content: [{ type: "text", text: `❌ Invalid run_index: "${params.run_index}" — must be a numeric run ID.` }],
434
+ isError: true,
435
+ details: {},
436
+ };
437
+ }
438
+
439
+ // Use Gitea's native rerun endpoint
440
+ const endpoint = params.failed_only
441
+ ? `/actions/runs/${runId}/rerun-failed-jobs`
442
+ : `/actions/runs/${runId}/rerun`;
443
+
444
+ const dispatchR = await giteaApi(endpoint, "POST", null, opts(ctx), ctx.cwd);
445
+ if (!dispatchR.ok) {
446
+ return {
447
+ content: [{ type: "text", text: `❌ Failed to rerun run #${runId}: ${dispatchR.error || "unknown"}` }],
448
+ isError: true,
449
+ details: {},
450
+ };
451
+ }
452
+ return {
453
+ content: [{ type: "text", text: `🔄 Re-running run #${runId}${params.failed_only ? " (failed jobs only)" : ""}. Check ci_list_runs for the new run.` }],
454
+ details: { runId, failedOnly: !!params.failed_only },
455
+ };
456
+ },
457
+ };
458
+
459
+ // ─── Cancel (NOT SUPPORTED by Gitea) ───────────────────────────────────────────
460
+
461
+ export const cancelTool = {
462
+ name: "ci_cancel" as const,
463
+ label: "Cancel Workflow Run",
464
+ description:
465
+ "Cancel a running workflow run. ⚠️ Gitea does not expose a cancel endpoint; this will return an error.",
466
+ parameters: Type.Object({
467
+ run_index: Type.String({ description: "Workflow run ID to cancel" }),
468
+ confirm: Type.Boolean({ description: "Must be true to confirm cancellation" }),
469
+ }),
470
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
471
+ const config = loadConfig(ctx.cwd);
472
+
473
+ const flag = guard(config.allowCancel, "ci_cancel");
474
+ if (flag) return flag;
475
+
476
+ const conf = confirm(params.confirm === true, `cancel workflow run #${params.run_index}`);
477
+ if (conf) return conf;
478
+
479
+ const rl = rateLimit(`cancel:${params.run_index}`, 60);
480
+ if (rl) return rl;
481
+
482
+ return {
483
+ content: [{
484
+ type: "text",
485
+ text: `❌ Cancel not available: Gitea's Actions API does not expose a cancel endpoint for workflow runs.`,
486
+ }],
487
+ isError: true,
488
+ details: {},
489
+ };
490
+ },
491
+ };
492
+
493
+ // ─── Formatting helpers ─────────────────────────────────────────────────────────
494
+
495
+ function statusIcon(status: string, conclusion: string | null): string {
496
+ const s = conclusion || status;
497
+ if (s === "in_progress" || s === "running") return "🔄";
498
+ if (s === "pending" || s === "queued" || s === "waiting" || s === "blocked") return "⏳";
499
+ if (s === "cancelled") return "🚫";
500
+ if (s === "success") return "✅";
501
+ if (s === "failure") return "❌";
502
+ if (s === "skipped") return "⏭️";
503
+ return "⚪";
504
+ }
505
+
506
+ function formatDuration(start: string, end: string): string {
507
+ const ms = new Date(end).getTime() - new Date(start).getTime();
508
+ if (ms < 0) return "";
509
+ const s = Math.floor(ms / 1000);
510
+ if (s < 60) return `${s}s`;
511
+ const m = Math.floor(s / 60);
512
+ if (m < 60) return `${m}m ${s % 60}s`;
513
+ const h = Math.floor(m / 60);
514
+ return `${h}h ${m % 60}m`;
515
+ }