pi-onedev-toolkit 0.1.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.
@@ -0,0 +1,170 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Type, type Static } from "typebox";
4
+ import {
5
+ pushRepeated,
6
+ QUERY_DESCRIPTION,
7
+ REF_DESCRIPTION,
8
+ requireParam,
9
+ runInContext,
10
+ toolResult,
11
+ withFooter,
12
+ type ToolDeps,
13
+ } from "./common.js";
14
+ import { BUILD_RUN_TIMEOUT_MS } from "../tod.js";
15
+
16
+ const BUILD_ACTIONS = [
17
+ "list",
18
+ "get",
19
+ "get_log",
20
+ "get_code_problems",
21
+ "get_changes_since_success",
22
+ "run",
23
+ "check_spec",
24
+ "get_spec_schema",
25
+ "query_description",
26
+ ] as const;
27
+ export type BuildAction = (typeof BUILD_ACTIONS)[number];
28
+
29
+ export const SEVERITY_LEVELS = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
30
+ export const RUN_MODES = ["local", "branch", "tag"] as const;
31
+
32
+ export interface BuildParams {
33
+ action?: BuildAction;
34
+ ref?: string;
35
+ reportName?: string;
36
+ severity?: (typeof SEVERITY_LEVELS)[number];
37
+ job?: string;
38
+ mode?: (typeof RUN_MODES)[number];
39
+ branch?: string;
40
+ tag?: string;
41
+ params?: string[];
42
+ project?: string;
43
+ query?: string;
44
+ count?: number;
45
+ offset?: number;
46
+ }
47
+
48
+ export function buildBuildArgs(params: BuildParams): string[] {
49
+ const args: string[] = ["build"];
50
+ const action = requireParam(params.action, "build", "action");
51
+ const ref = (): string => requireParam(params.ref, action, "ref");
52
+ switch (action) {
53
+ case "list":
54
+ args.push("list");
55
+ if (params.project !== undefined) args.push("--project", params.project);
56
+ if (params.query !== undefined) args.push("--query", params.query);
57
+ if (params.offset !== undefined)
58
+ args.push("--offset", String(params.offset));
59
+ args.push("--count", String(params.count ?? 25));
60
+ return args;
61
+ case "get":
62
+ return [...args, "get", ref()];
63
+ case "get_log":
64
+ return [...args, "get-log", ref()];
65
+ case "get_code_problems":
66
+ return [
67
+ ...args,
68
+ "get-code-problems",
69
+ ref(),
70
+ requireParam(params.reportName, action, "report_name"),
71
+ requireParam(params.severity, action, "severity"),
72
+ ];
73
+ case "get_changes_since_success":
74
+ return [...args, "get-changes-since-success", ref()];
75
+ case "run":
76
+ args.push("run", requireParam(params.job, action, "job"));
77
+ if (
78
+ params.mode === "local" ||
79
+ (params.mode === undefined && !params.branch && !params.tag)
80
+ ) {
81
+ args.push("--local");
82
+ } else if (params.mode === "branch" || params.branch !== undefined) {
83
+ args.push(
84
+ "--branch",
85
+ requireParam(params.branch ?? params.job, action, "branch"),
86
+ );
87
+ } else {
88
+ args.push("--tag", requireParam(params.tag, action, "tag"));
89
+ }
90
+ pushRepeated(args, "-p", params.params);
91
+ return args;
92
+ case "check_spec":
93
+ return [...args, "check-spec"];
94
+ case "get_spec_schema":
95
+ return [...args, "get-spec-schema"];
96
+ case "query_description":
97
+ return [...args, "get-query-description"];
98
+ }
99
+ }
100
+
101
+ export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void {
102
+ const parameters = Type.Object({
103
+ action: StringEnum(BUILD_ACTIONS, {
104
+ 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",
106
+ }),
107
+ ref: Type.Optional(Type.String({ description: REF_DESCRIPTION })),
108
+ reportName: Type.Optional(
109
+ Type.String({ description: "Report name quoted in the build log" }),
110
+ ),
111
+ severity: Type.Optional(StringEnum(SEVERITY_LEVELS)),
112
+ job: Type.Optional(
113
+ Type.String({ description: "Job name defined in .onedev-buildspec.yml" }),
114
+ ),
115
+ mode: Type.Optional(
116
+ StringEnum(RUN_MODES, {
117
+ description:
118
+ "run: local (uncommitted changes), branch, or tag; default local",
119
+ }),
120
+ ),
121
+ branch: Type.Optional(
122
+ Type.String({ description: "run with mode=branch: branch to run against" }),
123
+ ),
124
+ tag: Type.Optional(
125
+ Type.String({ description: "run with mode=tag: tag to run against" }),
126
+ ),
127
+ params: Type.Optional(
128
+ Type.Array(Type.String(), {
129
+ description: 'Job parameters as "key=value", repeatable (-p)',
130
+ }),
131
+ ),
132
+ project: Type.Optional(
133
+ Type.String({
134
+ description: "Project path for list; defaults to the current project",
135
+ }),
136
+ ),
137
+ query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })),
138
+ count: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
139
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
140
+ });
141
+ type Params = Static<typeof parameters>;
142
+
143
+ pi.registerTool({
144
+ name: "onedev_build",
145
+ label: "OneDev Builds",
146
+ description:
147
+ "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
+ parameters,
153
+ async execute(_toolCallId, params: Params, signal) {
154
+ 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
+ });
161
+ return toolResult(
162
+ withFooter(output.text, deps.context(), output.truncated),
163
+ {
164
+ action: params.action,
165
+ truncated: output.truncated,
166
+ },
167
+ );
168
+ },
169
+ });
170
+ }
@@ -0,0 +1,102 @@
1
+ import type { OneDevSessionContext } from "../context.js";
2
+ import {
3
+ TodError,
4
+ runTod,
5
+ type CommandExecutor,
6
+ type TodOutput,
7
+ } from "../tod.js";
8
+
9
+ export interface ToolDeps {
10
+ exec: CommandExecutor;
11
+ /** Session OneDev context; throws a friendly error before initialization. */
12
+ context: () => OneDevSessionContext;
13
+ }
14
+
15
+ export const REF_DESCRIPTION =
16
+ "OneDev reference: plain number (123), #number, project#number (myproject#123), or issue key (PROJ-123). Plain numbers resolve against the current repository's project.";
17
+ export const FIELDS_DESCRIPTION =
18
+ 'Issue field assignments as "key=value" strings, repeatable. Discover valid fields and values with the valid_fields action.';
19
+ export const QUERY_DESCRIPTION =
20
+ "OneDev query, e.g. '\"State\" is \"Open\"' or 'assignee is me'. Syntax reference: query_description action.";
21
+
22
+ export function toolResult(
23
+ text: string,
24
+ data?: unknown,
25
+ ): {
26
+ content: Array<{ type: "text"; text: string }>;
27
+ details: { data?: unknown };
28
+ } {
29
+ return {
30
+ content: [{ type: "text", text }],
31
+ details: data === undefined ? {} : { data },
32
+ };
33
+ }
34
+
35
+ /** Run tod with session cwd and pass the abort signal through. */
36
+ export async function runInContext(
37
+ deps: ToolDeps,
38
+ args: readonly string[],
39
+ options: {
40
+ timeoutMs?: number;
41
+ signal?: AbortSignal;
42
+ maxOutputBytes?: number;
43
+ } = {},
44
+ ): Promise<TodOutput> {
45
+ const context = deps.context();
46
+ return runTod(deps.exec, args, {
47
+ cwd: context.cwd,
48
+ signal: options.signal,
49
+ timeoutMs: options.timeoutMs,
50
+ maxOutputBytes: options.maxOutputBytes,
51
+ });
52
+ }
53
+
54
+ export function requireParam<T>(
55
+ value: T | undefined,
56
+ action: string,
57
+ name: string,
58
+ ): T {
59
+ if (value === undefined || value === null) {
60
+ throw new TodError(`action '${action}' requires parameter '${name}'`);
61
+ }
62
+ return value;
63
+ }
64
+
65
+ /** Append `--flag value` pairs for every entry of a repeatable string array. */
66
+ export function pushRepeated(
67
+ args: string[],
68
+ flag: string,
69
+ values: readonly string[] | undefined,
70
+ ): void {
71
+ for (const value of values ?? []) args.push(flag, value);
72
+ }
73
+
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
+ }
83
+ }
84
+
85
+ export function contextFooter(context: OneDevSessionContext): string {
86
+ if (context.status === "ready" && context.project) {
87
+ return `Context: project ${context.project} · server ${context.serverUrl}`;
88
+ }
89
+ return `Context: server ${context.serverUrl ?? "?"} · no project inferred from git remotes; qualify refs or pass project explicitly`;
90
+ }
91
+
92
+ export function withFooter(
93
+ body: string,
94
+ context: OneDevSessionContext,
95
+ truncated: boolean,
96
+ ): string {
97
+ const parts = [body || "(no output)"];
98
+ if (truncated)
99
+ parts.push("(output truncated; narrow the query or fetch a single entity)");
100
+ parts.push(contextFooter(context));
101
+ return parts.filter(Boolean).join("\n\n");
102
+ }
@@ -0,0 +1,126 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Type } from "typebox";
4
+ import { formatContext } from "../context.js";
5
+ import { runInContext, toolResult, type ToolDeps } from "./common.js";
6
+ import { registerBuildTool } from "./build.js";
7
+ import { registerIssueTool } from "./issue.js";
8
+ import { registerPullTool } from "./pull.js";
9
+
10
+ const ONEDev_TOOL_DOMAINS = ["issue", "pull", "build"] as const;
11
+ type OneDevToolDomain = (typeof ONEDev_TOOL_DOMAINS)[number];
12
+
13
+ const LAZY_TOOL_NAMES = [
14
+ "onedev_issue",
15
+ "onedev_pull",
16
+ "onedev_build",
17
+ ] as const;
18
+ const LAZY_TOOLS = new Set<string>(LAZY_TOOL_NAMES);
19
+ const DOMAIN_TOOL_NAMES = {
20
+ issue: ["onedev_issue"],
21
+ pull: ["onedev_pull"],
22
+ build: ["onedev_build"],
23
+ } as const satisfies Record<OneDevToolDomain, readonly string[]>;
24
+
25
+ export interface OnedevToolController {
26
+ /** Deactivate lazy OneDev tools unless the user explicitly enabled them. */
27
+ reset(): void;
28
+ }
29
+
30
+ function supportsDynamicTools(pi: ExtensionAPI): boolean {
31
+ return (
32
+ typeof pi.getActiveTools === "function" &&
33
+ typeof pi.setActiveTools === "function"
34
+ );
35
+ }
36
+
37
+ export function registerOnedevTools(
38
+ pi: ExtensionAPI,
39
+ deps: ToolDeps,
40
+ ): OnedevToolController {
41
+ registerIssueTool(pi, deps);
42
+ registerPullTool(pi, deps);
43
+ registerBuildTool(pi, deps);
44
+
45
+ pi.registerTool({
46
+ name: "onedev_tools",
47
+ label: "OneDev Tools",
48
+ 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.",
50
+ promptGuidelines: [
51
+ "Use onedev_tools before an unavailable OneDev operation.",
52
+ ],
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), {
60
+ minItems: 1,
61
+ maxItems: 3,
62
+ uniqueItems: true,
63
+ description: "issue, pull, build — activate all you need in one call",
64
+ }),
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
+ }),
78
+ 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]);
94
+ return toolResult(
95
+ added.length > 0
96
+ ? `Enabled OneDev tools: ${added.join(", ")}`
97
+ : `Requested OneDev tools already active: ${selected.join(", ")}`,
98
+ { requested, added },
99
+ );
100
+ }
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");
110
+ }
111
+ await runInContext(deps, ["download", params.url, params.outputFile], {
112
+ signal,
113
+ });
114
+ return toolResult(`Downloaded ${params.url} to ${params.outputFile}`);
115
+ },
116
+ });
117
+
118
+ return {
119
+ reset() {
120
+ 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)));
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,225 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Type, type Static } from "typebox";
4
+ import {
5
+ confirmGate,
6
+ FIELDS_DESCRIPTION,
7
+ pushRepeated,
8
+ QUERY_DESCRIPTION,
9
+ REF_DESCRIPTION,
10
+ requireParam,
11
+ runInContext,
12
+ toolResult,
13
+ withFooter,
14
+ type ToolDeps,
15
+ } from "./common.js";
16
+
17
+ const ISSUE_ACTIONS = [
18
+ "list",
19
+ "get",
20
+ "get_comments",
21
+ "create",
22
+ "edit",
23
+ "change_state",
24
+ "add_comment",
25
+ "link",
26
+ "log_work",
27
+ "create_branch",
28
+ "checkout",
29
+ "current_reference",
30
+ "valid_fields",
31
+ "valid_links",
32
+ "query_description",
33
+ ] as const;
34
+ export type IssueAction = (typeof ISSUE_ACTIONS)[number];
35
+
36
+ export interface IssueParams {
37
+ action?: IssueAction;
38
+ ref?: string;
39
+ title?: string;
40
+ description?: string;
41
+ state?: string;
42
+ comment?: string;
43
+ content?: string;
44
+ linkName?: string;
45
+ targetRef?: string;
46
+ hours?: number;
47
+ fields?: string[];
48
+ iterations?: string[];
49
+ ownEstimatedTime?: number;
50
+ confidential?: boolean;
51
+ forWrite?: boolean;
52
+ project?: string;
53
+ query?: string;
54
+ count?: number;
55
+ offset?: number;
56
+ }
57
+
58
+ export function buildIssueArgs(params: IssueParams): string[] {
59
+ const args: string[] = ["issue"];
60
+ const action = requireParam(params.action, "issue", "action");
61
+ const ref = (): string => requireParam(params.ref, action, "ref");
62
+ switch (action) {
63
+ case "list":
64
+ args.push("list");
65
+ if (params.project !== undefined) args.push("--project", params.project);
66
+ if (params.query !== undefined) args.push("--query", params.query);
67
+ if (params.offset !== undefined)
68
+ args.push("--offset", String(params.offset));
69
+ args.push("--count", String(params.count ?? 25));
70
+ return args;
71
+ case "get":
72
+ return [...args, "get", ref()];
73
+ case "get_comments":
74
+ return [...args, "get-comments", ref()];
75
+ case "create":
76
+ args.push("create", requireParam(params.title, action, "title"));
77
+ if (params.description !== undefined)
78
+ args.push("--description", params.description);
79
+ if (params.project !== undefined) args.push("--project", params.project);
80
+ pushRepeated(args, "--field", params.fields);
81
+ pushRepeated(args, "--iteration", params.iterations);
82
+ if (params.ownEstimatedTime !== undefined)
83
+ args.push("--own-estimated-time", String(params.ownEstimatedTime));
84
+ if (params.confidential === true) args.push("--confidential");
85
+ return args;
86
+ case "edit":
87
+ args.push("edit", ref());
88
+ if (params.title !== undefined) args.push("--title", params.title);
89
+ if (params.description !== undefined)
90
+ args.push("--description", params.description);
91
+ pushRepeated(args, "--field", params.fields);
92
+ pushRepeated(args, "--iteration", params.iterations);
93
+ if (params.ownEstimatedTime !== undefined)
94
+ args.push("--own-estimated-time", String(params.ownEstimatedTime));
95
+ if (params.confidential !== undefined)
96
+ args.push("--confidential", String(params.confidential));
97
+ return args;
98
+ case "change_state":
99
+ args.push(
100
+ "change-state",
101
+ ref(),
102
+ requireParam(params.state, action, "state"),
103
+ );
104
+ if (params.comment !== undefined) args.push("--comment", params.comment);
105
+ pushRepeated(args, "--field", params.fields);
106
+ return args;
107
+ case "add_comment":
108
+ return [
109
+ ...args,
110
+ "add-comment",
111
+ ref(),
112
+ requireParam(params.content, action, "content"),
113
+ ];
114
+ case "link":
115
+ return [
116
+ ...args,
117
+ "link",
118
+ ref(),
119
+ requireParam(params.linkName, action, "link_name"),
120
+ requireParam(params.targetRef, action, "target_ref"),
121
+ ];
122
+ case "log_work":
123
+ args.push(
124
+ "log-work",
125
+ ref(),
126
+ String(requireParam(params.hours, action, "hours")),
127
+ );
128
+ if (params.comment !== undefined) args.push("--comment", params.comment);
129
+ return args;
130
+ case "create_branch":
131
+ return [...args, "create-branch", ref()];
132
+ case "checkout":
133
+ args.push("checkout", ref());
134
+ if (params.forWrite === true) args.push("--for-write");
135
+ return args;
136
+ case "current_reference":
137
+ return [...args, "current-reference"];
138
+ case "valid_fields":
139
+ return [...args, "get-valid-fields"];
140
+ case "valid_links":
141
+ return [...args, "get-valid-links"];
142
+ case "query_description":
143
+ return [...args, "get-query-description"];
144
+ }
145
+ }
146
+
147
+ export function registerIssueTool(pi: ExtensionAPI, deps: ToolDeps): void {
148
+ const parameters = Type.Object({
149
+ action: StringEnum(ISSUE_ACTIONS, {
150
+ description:
151
+ "list (query issues), get, get_comments, create, edit, change_state, add_comment, link, log_work, create_branch, checkout (branch locally), current_reference (issue for current branch), valid_fields, valid_links, query_description",
152
+ }),
153
+ ref: Type.Optional(Type.String({ description: REF_DESCRIPTION })),
154
+ title: Type.Optional(Type.String()),
155
+ description: Type.Optional(Type.String({ description: "Markdown body" })),
156
+ state: Type.Optional(
157
+ Type.String({
158
+ description:
159
+ "Target state, e.g. Open or Released; discover via valid_fields",
160
+ }),
161
+ ),
162
+ comment: Type.Optional(
163
+ Type.String({ description: "Markdown note attached to the action" }),
164
+ ),
165
+ content: Type.Optional(Type.String({ description: "Markdown comment text" })),
166
+ linkName: Type.Optional(
167
+ Type.String({ description: "Issue link name; discover via valid_links" }),
168
+ ),
169
+ targetRef: Type.Optional(
170
+ Type.String({ description: "Target issue reference for the link action" }),
171
+ ),
172
+ hours: Type.Optional(
173
+ Type.Integer({ minimum: 1, description: "Hours to log for log_work" }),
174
+ ),
175
+ fields: Type.Optional(
176
+ Type.Array(Type.String(), { description: FIELDS_DESCRIPTION }),
177
+ ),
178
+ iterations: Type.Optional(Type.Array(Type.String())),
179
+ ownEstimatedTime: Type.Optional(
180
+ Type.Integer({ minimum: 0, description: "Estimated hours" }),
181
+ ),
182
+ confidential: Type.Optional(Type.Boolean()),
183
+ forWrite: Type.Optional(
184
+ Type.Boolean({ description: "checkout: set up the branch for pushing" }),
185
+ ),
186
+ project: Type.Optional(
187
+ Type.String({
188
+ description:
189
+ "Project path for list/create; defaults to the current project",
190
+ }),
191
+ ),
192
+ query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })),
193
+ count: Type.Optional(
194
+ Type.Integer({
195
+ minimum: 1,
196
+ maximum: 100,
197
+ description: "Page size for list (default 25, max 100)",
198
+ }),
199
+ ),
200
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
201
+ });
202
+ type Params = Static<typeof parameters>;
203
+
204
+ pi.registerTool({
205
+ name: "onedev_issue",
206
+ label: "OneDev Issues",
207
+ description:
208
+ "Read and edit OneDev issues: query, inspect, comment, change fields/labels/state, log work, manage issue branches. Runs in the current repository's project; plain numbers resolve against it.",
209
+ promptGuidelines: [
210
+ "Use onedev_issue valid_fields before setting fields or state you have not seen this session.",
211
+ "Post comments and field changes only after presenting the intent to the user.",
212
+ ],
213
+ parameters,
214
+ async execute(_toolCallId, params: Params, signal) {
215
+ const output = await runInContext(deps, buildIssueArgs(params), { signal });
216
+ return toolResult(
217
+ withFooter(output.text, deps.context(), output.truncated),
218
+ {
219
+ action: params.action,
220
+ truncated: output.truncated,
221
+ },
222
+ );
223
+ },
224
+ });
225
+ }