pi-onedev-toolkit 0.1.0 → 0.2.0

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.
@@ -1,7 +1,11 @@
1
+ import { copyFile, mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { isAbsolute, join, resolve } from "node:path";
1
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
5
  import { StringEnum } from "@earendil-works/pi-ai";
3
6
  import { Type, type Static } from "typebox";
4
7
  import {
8
+ confirmMutation,
5
9
  pushRepeated,
6
10
  QUERY_DESCRIPTION,
7
11
  REF_DESCRIPTION,
@@ -11,13 +15,20 @@ import {
11
15
  withFooter,
12
16
  type ToolDeps,
13
17
  } from "./common.js";
14
- import { BUILD_RUN_TIMEOUT_MS } from "../tod.js";
18
+ import {
19
+ BUILD_RUN_TIMEOUT_MS,
20
+ runTod,
21
+ TodError,
22
+ type TodOutput,
23
+ } from "../tod.js";
24
+ import type { MutationOperation } from "../mutation-approvals.js";
15
25
 
16
26
  const BUILD_ACTIONS = [
17
27
  "list",
18
28
  "get",
19
29
  "get_log",
20
30
  "get_code_problems",
31
+ "get_unit_test_report",
21
32
  "get_changes_since_success",
22
33
  "run",
23
34
  "check_spec",
@@ -26,13 +37,18 @@ const BUILD_ACTIONS = [
26
37
  ] as const;
27
38
  export type BuildAction = (typeof BUILD_ACTIONS)[number];
28
39
 
40
+ const BUILD_MUTATIONS: Partial<Record<BuildAction, MutationOperation>> = {
41
+ run: "build.run",
42
+ check_spec: "build.check_spec",
43
+ };
44
+
29
45
  export const SEVERITY_LEVELS = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
30
46
  export const RUN_MODES = ["local", "branch", "tag"] as const;
31
47
 
32
48
  export interface BuildParams {
33
49
  action?: BuildAction;
34
50
  ref?: string;
35
- reportName?: string;
51
+ report_name?: string;
36
52
  severity?: (typeof SEVERITY_LEVELS)[number];
37
53
  job?: string;
38
54
  mode?: (typeof RUN_MODES)[number];
@@ -67,23 +83,42 @@ export function buildBuildArgs(params: BuildParams): string[] {
67
83
  ...args,
68
84
  "get-code-problems",
69
85
  ref(),
70
- requireParam(params.reportName, action, "report_name"),
86
+ requireParam(params.report_name, action, "report_name"),
71
87
  requireParam(params.severity, action, "severity"),
72
88
  ];
89
+ case "get_unit_test_report":
90
+ return [
91
+ ...args,
92
+ "get-unit-test-report",
93
+ ref(),
94
+ requireParam(params.report_name, action, "report_name"),
95
+ ];
73
96
  case "get_changes_since_success":
74
97
  return [...args, "get-changes-since-success", ref()];
75
98
  case "run":
76
99
  args.push("run", requireParam(params.job, action, "job"));
100
+ if (params.branch !== undefined && params.tag !== undefined) {
101
+ throw new Error("run accepts either branch or tag, not both");
102
+ }
103
+ if (
104
+ params.mode === "local" &&
105
+ (params.branch !== undefined || params.tag !== undefined)
106
+ ) {
107
+ throw new Error("local run mode does not accept branch or tag");
108
+ }
109
+ if (params.mode === "branch" && params.tag !== undefined) {
110
+ throw new Error("branch run mode does not accept tag");
111
+ }
112
+ if (params.mode === "tag" && params.branch !== undefined) {
113
+ throw new Error("tag run mode does not accept branch");
114
+ }
77
115
  if (
78
116
  params.mode === "local" ||
79
117
  (params.mode === undefined && !params.branch && !params.tag)
80
118
  ) {
81
119
  args.push("--local");
82
120
  } else if (params.mode === "branch" || params.branch !== undefined) {
83
- args.push(
84
- "--branch",
85
- requireParam(params.branch ?? params.job, action, "branch"),
86
- );
121
+ args.push("--branch", requireParam(params.branch, action, "branch"));
87
122
  } else {
88
123
  args.push("--tag", requireParam(params.tag, action, "tag"));
89
124
  }
@@ -95,6 +130,72 @@ export function buildBuildArgs(params: BuildParams): string[] {
95
130
  return [...args, "get-spec-schema"];
96
131
  case "query_description":
97
132
  return [...args, "get-query-description"];
133
+ default:
134
+ throw new Error(`unsupported build action: ${String(action)}`);
135
+ }
136
+ }
137
+
138
+ async function ensureUnitTestReportSupport(
139
+ deps: ToolDeps,
140
+ signal?: AbortSignal,
141
+ ): Promise<void> {
142
+ const help = await runInContext(deps, ["build", "--help"], {
143
+ signal,
144
+ maxOutputBytes: 12_000,
145
+ });
146
+ if (!/^\s+get-unit-test-report\s/m.test(help.text)) {
147
+ throw new TodError(
148
+ "the installed tod does not support build get-unit-test-report; update tod from https://code.onedev.io/onedev/tod",
149
+ );
150
+ }
151
+ }
152
+
153
+ async function runLocalBuildWithIsolatedIndex(
154
+ deps: ToolDeps,
155
+ args: string[],
156
+ signal?: AbortSignal,
157
+ ): Promise<TodOutput> {
158
+ if (process.platform === "win32") {
159
+ throw new TodError(
160
+ "local build mode is disabled on Windows because TOD modifies the git index; use branch or tag mode",
161
+ );
162
+ }
163
+ const context = deps.context();
164
+ const indexResult = await deps.exec(
165
+ "git",
166
+ ["rev-parse", "--git-path", "index"],
167
+ { cwd: context.cwd, timeout: 10_000, signal },
168
+ );
169
+ if (indexResult.killed || indexResult.code !== 0) {
170
+ throw new TodError(
171
+ `could not isolate the git index for local build mode: ${(indexResult.stderr || indexResult.stdout).trim() || "git rev-parse failed"}`,
172
+ );
173
+ }
174
+ const indexPath = indexResult.stdout.trim();
175
+ const sourceIndex = isAbsolute(indexPath)
176
+ ? indexPath
177
+ : resolve(context.cwd, indexPath);
178
+ const directory = await mkdtemp(join(tmpdir(), "pi-onedev-index-"));
179
+ const isolatedIndex = join(directory, "index");
180
+ try {
181
+ await copyFile(sourceIndex, isolatedIndex);
182
+ return await runTod(deps.exec, args, {
183
+ cwd: context.cwd,
184
+ signal,
185
+ timeoutMs: BUILD_RUN_TIMEOUT_MS,
186
+ maxOutputBytes: 24_000,
187
+ environment: { GIT_INDEX_FILE: isolatedIndex },
188
+ });
189
+ } catch (error) {
190
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
191
+ throw new TodError(
192
+ "local build mode requires an existing git index and at least one commit",
193
+ { cause: error },
194
+ );
195
+ }
196
+ throw error;
197
+ } finally {
198
+ await rm(directory, { recursive: true, force: true });
98
199
  }
99
200
  }
100
201
 
@@ -102,10 +203,10 @@ export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void {
102
203
  const parameters = Type.Object({
103
204
  action: StringEnum(BUILD_ACTIONS, {
104
205
  description:
105
- "list, get, get_log, get_code_problems (needs report_name+severity), get_changes_since_success, run (job against local/branch/tag), check_spec (validate .onedev-buildspec.yml), get_spec_schema, query_description",
206
+ "list, get, get_log, get_code_problems, get_unit_test_report, get_changes_since_success, run, check_spec (may upgrade .onedev-buildspec.yml), get_spec_schema, query_description",
106
207
  }),
107
208
  ref: Type.Optional(Type.String({ description: REF_DESCRIPTION })),
108
- reportName: Type.Optional(
209
+ report_name: Type.Optional(
109
210
  Type.String({ description: "Report name quoted in the build log" }),
110
211
  ),
111
212
  severity: Type.Optional(StringEnum(SEVERITY_LEVELS)),
@@ -137,7 +238,7 @@ export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void {
137
238
  query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })),
138
239
  count: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
139
240
  offset: Type.Optional(Type.Integer({ minimum: 0 })),
140
- });
241
+ }, { additionalProperties: false });
141
242
  type Params = Static<typeof parameters>;
142
243
 
143
244
  pi.registerTool({
@@ -145,19 +246,40 @@ export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void {
145
246
  label: "OneDev Builds",
146
247
  description:
147
248
  "Inspect OneDev CI/CD: query builds, read logs and code-problem reports, diff changes since the last success, validate the build spec, and run jobs (including against uncommitted local changes).",
148
- promptGuidelines: [
149
- "Use onedev_build get_log before guessing at build failures.",
150
- "run with mode=local pushes uncommitted changes to a temporary server ref; prefer it over committing just to test.",
151
- ],
152
249
  parameters,
153
- async execute(_toolCallId, params: Params, signal) {
250
+ async execute(_toolCallId, params: Params, signal, _onUpdate, ctx) {
154
251
  const isRun = params.action === "run";
155
- // ponytail: build run streams the full log; keep the tail, the failure sits at the end
156
- const output = await runInContext(deps, buildBuildArgs(params), {
157
- signal,
158
- timeoutMs: isRun ? BUILD_RUN_TIMEOUT_MS : undefined,
159
- maxOutputBytes: isRun ? 24_000 : undefined,
160
- });
252
+ const args = buildBuildArgs(params);
253
+ if (params.action === "get_unit_test_report") {
254
+ await ensureUnitTestReportSupport(deps, signal);
255
+ }
256
+ const operation = BUILD_MUTATIONS[params.action];
257
+ if (operation) {
258
+ await confirmMutation(deps, ctx, {
259
+ operation,
260
+ title: `Approve OneDev build action: ${params.action}`,
261
+ details:
262
+ params.action === "check_spec"
263
+ ? "TOD may upgrade .onedev-buildspec.yml in the working tree."
264
+ : `Job: ${params.job ?? "?"} · mode: ${params.mode ?? "local"}`,
265
+ signal,
266
+ });
267
+ }
268
+ // ponytail: build run streams the full log; preserve bounded metadata and the failure tail
269
+ const isLocalRun =
270
+ isRun &&
271
+ (params.mode === "local" ||
272
+ (params.mode === undefined && !params.branch && !params.tag));
273
+ let output: TodOutput;
274
+ if (isLocalRun) {
275
+ output = await runLocalBuildWithIsolatedIndex(deps, args, signal);
276
+ } else {
277
+ output = await runInContext(deps, args, {
278
+ signal,
279
+ timeoutMs: isRun ? BUILD_RUN_TIMEOUT_MS : undefined,
280
+ maxOutputBytes: isRun ? 24_000 : undefined,
281
+ });
282
+ }
161
283
  return toolResult(
162
284
  withFooter(output.text, deps.context(), output.truncated),
163
285
  {
@@ -1,4 +1,12 @@
1
- import type { OneDevSessionContext } from "../context.js";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ ContextRefreshOptions,
4
+ OneDevSessionContext,
5
+ } from "../context.js";
6
+ import type {
7
+ MutationApprovalManager,
8
+ MutationApprovalRequest,
9
+ } from "../mutation-approvals.js";
2
10
  import {
3
11
  TodError,
4
12
  runTod,
@@ -10,6 +18,10 @@ export interface ToolDeps {
10
18
  exec: CommandExecutor;
11
19
  /** Session OneDev context; throws a friendly error before initialization. */
12
20
  context: () => OneDevSessionContext;
21
+ refreshContext: (
22
+ options?: ContextRefreshOptions,
23
+ ) => Promise<OneDevSessionContext>;
24
+ approvals: MutationApprovalManager;
13
25
  }
14
26
 
15
27
  export const REF_DESCRIPTION =
@@ -43,6 +55,12 @@ export async function runInContext(
43
55
  } = {},
44
56
  ): Promise<TodOutput> {
45
57
  const context = deps.context();
58
+ if (context.status !== "ready" || !context.project) {
59
+ throw new TodError(
60
+ context.problem ??
61
+ "OneDev tools require a repository whose project can be inferred from a OneDev remote",
62
+ );
63
+ }
46
64
  return runTod(deps.exec, args, {
47
65
  cwd: context.cwd,
48
66
  signal: options.signal,
@@ -71,22 +89,19 @@ export function pushRepeated(
71
89
  for (const value of values ?? []) args.push(flag, value);
72
90
  }
73
91
 
74
- export function confirmGate(
75
- action: string,
76
- confirmed: boolean | undefined,
77
- ): void {
78
- if (confirmed !== true) {
79
- throw new TodError(
80
- `action '${action}' changes remote state; re-run with confirm: true once the user has approved it`,
81
- );
82
- }
92
+ export function confirmMutation(
93
+ deps: ToolDeps,
94
+ ctx: ExtensionContext,
95
+ request: MutationApprovalRequest,
96
+ ): Promise<void> {
97
+ return deps.approvals.confirm(deps.context(), ctx, request);
83
98
  }
84
99
 
85
- export function contextFooter(context: OneDevSessionContext): string {
100
+ function contextFooter(context: OneDevSessionContext): string {
86
101
  if (context.status === "ready" && context.project) {
87
102
  return `Context: project ${context.project} · server ${context.serverUrl}`;
88
103
  }
89
- return `Context: server ${context.serverUrl ?? "?"} · no project inferred from git remotes; qualify refs or pass project explicitly`;
104
+ return `Context unavailable: ${context.problem ?? "no OneDev project inferred from git remotes"}`;
90
105
  }
91
106
 
92
107
  export function withFooter(
@@ -1,29 +1,34 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "typebox";
4
- import { formatContext } from "../context.js";
5
- import { runInContext, toolResult, type ToolDeps } from "./common.js";
4
+ import { formatContext, setupGuidance } from "../context.js";
5
+ import type { OneDevSourceWatchManager } from "../source-watch.js";
6
+ import { toolResult, type ToolDeps } from "./common.js";
6
7
  import { registerBuildTool } from "./build.js";
7
8
  import { registerIssueTool } from "./issue.js";
8
9
  import { registerPullTool } from "./pull.js";
10
+ import { registerWatchTool } from "./watch.js";
9
11
 
10
- const ONEDev_TOOL_DOMAINS = ["issue", "pull", "build"] as const;
11
- type OneDevToolDomain = (typeof ONEDev_TOOL_DOMAINS)[number];
12
+ const ONEDEV_TOOL_DOMAINS = ["issue", "pull", "build", "watch"] as const;
13
+ type OneDevToolDomain = (typeof ONEDEV_TOOL_DOMAINS)[number];
12
14
 
15
+ const ALWAYS_ACTIVE_TOOLS = ["onedev_context", "onedev_tools"] as const;
13
16
  const LAZY_TOOL_NAMES = [
14
17
  "onedev_issue",
15
18
  "onedev_pull",
16
19
  "onedev_build",
20
+ "onedev_watch",
17
21
  ] as const;
18
22
  const LAZY_TOOLS = new Set<string>(LAZY_TOOL_NAMES);
19
23
  const DOMAIN_TOOL_NAMES = {
20
24
  issue: ["onedev_issue"],
21
25
  pull: ["onedev_pull"],
22
26
  build: ["onedev_build"],
27
+ watch: ["onedev_watch"],
23
28
  } as const satisfies Record<OneDevToolDomain, readonly string[]>;
24
29
 
25
30
  export interface OnedevToolController {
26
- /** Deactivate lazy OneDev tools unless the user explicitly enabled them. */
31
+ /** Restore only the small context/loader surface at session boundaries. */
27
32
  reset(): void;
28
33
  }
29
34
 
@@ -34,93 +39,116 @@ function supportsDynamicTools(pi: ExtensionAPI): boolean {
34
39
  );
35
40
  }
36
41
 
42
+ function registerContextTool(pi: ExtensionAPI, deps: ToolDeps): void {
43
+ pi.registerTool({
44
+ name: "onedev_context",
45
+ label: "OneDev Context",
46
+ description:
47
+ "Inspect OneDev readiness without loading domain tools. Use health for an authenticated re-check and setup for actionable configuration guidance.",
48
+ parameters: Type.Object(
49
+ {
50
+ action: StringEnum(["current", "health", "login_name", "setup"]),
51
+ },
52
+ { additionalProperties: false },
53
+ ),
54
+ async execute(_toolCallId, params, signal) {
55
+ if (params.action === "current") {
56
+ return toolResult(formatContext(deps.context()));
57
+ }
58
+ if (params.action === "setup") {
59
+ return toolResult(setupGuidance(deps.context()));
60
+ }
61
+ const context = await deps.refreshContext({
62
+ verifyAuth: true,
63
+ force: true,
64
+ signal,
65
+ });
66
+ if (params.action === "login_name" && context.status === "ready") {
67
+ return toolResult(context.loginName ?? "(no login name)");
68
+ }
69
+ return toolResult(formatContext(context));
70
+ },
71
+ });
72
+ }
73
+
37
74
  export function registerOnedevTools(
38
75
  pi: ExtensionAPI,
39
76
  deps: ToolDeps,
77
+ watches: OneDevSourceWatchManager,
40
78
  ): OnedevToolController {
79
+ registerContextTool(pi, deps);
41
80
  registerIssueTool(pi, deps);
42
81
  registerPullTool(pi, deps);
43
82
  registerBuildTool(pi, deps);
83
+ registerWatchTool(pi, watches);
44
84
 
45
85
  pi.registerTool({
46
86
  name: "onedev_tools",
47
87
  label: "OneDev Tools",
48
88
  description:
49
- "Activate OneDev tool domains for the current task, or inspect the session OneDev context (server, project inferred from git remotes, login). Misc actions: download linked issue/PR resources.",
89
+ "Activate only the OneDev domains needed for the current task. Returns authenticated server, project, and login context with the activation result.",
50
90
  promptGuidelines: [
51
- "Use onedev_tools before an unavailable OneDev operation.",
91
+ "Use onedev_tools before an unavailable OneDev domain operation.",
52
92
  ],
53
- parameters: Type.Object({
54
- action: StringEnum(["activate", "context", "login_name", "download"], {
55
- description:
56
- "activate (default; pass domains), context (server/project/user), login_name, download (fetch an issue/PR-attached resource)",
57
- }),
58
- domains: Type.Optional(
59
- Type.Array(StringEnum(ONEDev_TOOL_DOMAINS), {
93
+ parameters: Type.Object(
94
+ {
95
+ action: StringEnum(["activate"]),
96
+ domains: Type.Array(StringEnum(ONEDEV_TOOL_DOMAINS), {
60
97
  minItems: 1,
61
- maxItems: 3,
98
+ maxItems: 4,
62
99
  uniqueItems: true,
63
- description: "issue, pull, build — activate all you need in one call",
100
+ description: "Activate issue, pull, build, and/or watch in one call",
64
101
  }),
65
- ),
66
- url: Type.Optional(
67
- Type.String({
68
- description:
69
- "download: resource URL exactly as it appears in the markdown",
70
- }),
71
- ),
72
- outputFile: Type.Optional(
73
- Type.String({
74
- description: "download: local path to write the resource to",
75
- }),
76
- ),
77
- }),
102
+ },
103
+ { additionalProperties: false },
104
+ ),
78
105
  async execute(_toolCallId, params, signal) {
79
- const action = params.action ?? "activate";
80
- if (action === "activate") {
81
- const requested = params.domains ?? [...ONEDev_TOOL_DOMAINS];
82
- if (!supportsDynamicTools(pi)) {
83
- return toolResult(
84
- "Dynamic OneDev tool activation is unavailable in this Pi version; registered tools remain unchanged.",
85
- { requested },
86
- );
87
- }
88
- const selected = [
89
- ...new Set(requested.flatMap((domain) => DOMAIN_TOOL_NAMES[domain])),
90
- ];
91
- const active = pi.getActiveTools();
92
- const added = selected.filter((name) => !active.includes(name));
93
- if (added.length > 0) pi.setActiveTools([...active, ...added]);
106
+ const context = await deps.refreshContext({ verifyAuth: true, signal });
107
+ if (context.status !== "ready") {
94
108
  return toolResult(
95
- added.length > 0
96
- ? `Enabled OneDev tools: ${added.join(", ")}`
97
- : `Requested OneDev tools already active: ${selected.join(", ")}`,
98
- { requested, added },
109
+ `${formatContext(context)}\nNo domain tools were activated. Use onedev_context setup for the next step.`,
110
+ { requested: params.domains, added: [] },
99
111
  );
100
112
  }
101
- if (action === "context") {
102
- return toolResult(formatContext(deps.context()));
103
- }
104
- if (action === "login_name") {
105
- const output = await runInContext(deps, ["get-login-name"], { signal });
106
- return toolResult(output.text || "(no login name)");
107
- }
108
- if (!params.url || !params.outputFile) {
109
- throw new Error("download requires url and output_file");
113
+ if (!supportsDynamicTools(pi)) {
114
+ return toolResult(
115
+ `Dynamic OneDev tool activation is unavailable in this Pi version.\n${formatContext(context)}`,
116
+ { requested: params.domains, added: [] },
117
+ );
110
118
  }
111
- await runInContext(deps, ["download", params.url, params.outputFile], {
112
- signal,
119
+ const selected = [
120
+ ...new Set(
121
+ params.domains.flatMap((domain) => DOMAIN_TOOL_NAMES[domain]),
122
+ ),
123
+ ];
124
+ const active = pi.getActiveTools();
125
+ const added = selected.filter((name) => !active.includes(name));
126
+ if (added.length > 0) pi.setActiveTools([...active, ...added]);
127
+ const activation =
128
+ added.length > 0
129
+ ? `Enabled OneDev tools: ${added.join(", ")}`
130
+ : `Requested OneDev tools already active: ${selected.join(", ")}`;
131
+ return toolResult(`${activation}\n${formatContext(context)}`, {
132
+ requested: params.domains,
133
+ added,
113
134
  });
114
- return toolResult(`Downloaded ${params.url} to ${params.outputFile}`);
115
135
  },
116
136
  });
117
137
 
118
138
  return {
119
139
  reset() {
120
140
  if (!supportsDynamicTools(pi)) return;
121
- const active = pi.getActiveTools();
122
- if (!active.includes("onedev_tools")) return;
123
- pi.setActiveTools(active.filter((name) => !LAZY_TOOLS.has(name)));
141
+ const current = pi.getActiveTools();
142
+ const next = current.filter((name) => !LAZY_TOOLS.has(name));
143
+ for (const name of ALWAYS_ACTIVE_TOOLS) {
144
+ if (!next.includes(name)) next.push(name);
145
+ }
146
+ if (
147
+ next.length !== current.length ||
148
+ next.some((name, index) => name !== current[index])
149
+ ) {
150
+ pi.setActiveTools(next);
151
+ }
124
152
  },
125
153
  };
126
154
  }