pi-onedev-toolkit 0.2.2 → 0.3.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.
- package/CHANGELOG.md +14 -1
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/source-watch.ts +61 -19
- package/src/tools/build.ts +30 -9
- package/src/tools/common.ts +232 -1
- package/src/tools/index.ts +44 -2
- package/src/tools/issue.ts +18 -4
- package/src/tools/pull.ts +22 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.3.0 — 2026-09-05
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- `issue`, `pull`, and `build` `list` actions now return a one-line-per-item digest instead of raw JSON (raw list items embed full Markdown descriptions); pass `full: true` for the raw payload. Digests fetch internally up to 512 KB; larger or non-JSON payloads fall back to the normal bounded raw output.
|
|
8
|
+
- Query parameter descriptions now include common OneDev query examples (`open`, `submitted by "user"`, `Assignees is "user"`, `order by`), so most queries no longer need the 8–11 KB grammar dump from `query_description`.
|
|
9
|
+
- `onedev_context` gains `list_projects` to discover accessible projects without activating a domain tool; it works from any repository once TOD is configured.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- One-shot watch notifications can no longer be lost when the emission consumer throws.
|
|
14
|
+
- Watch polls record `lastError` (visible in `onedev_watch list`) instead of failing silently, and clear it on recovery.
|
|
15
|
+
- Pull-attention watches read state only from top-level fields, so a nested build status can no longer masquerade as the PR state and cause spurious or missed wakes.
|
|
16
|
+
- Watch snapshot calls are bounded to 10 seconds so one slow TOD call cannot stall other watches.
|
|
4
17
|
|
|
5
18
|
## 0.2.2 — 2026-09-04
|
|
6
19
|
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ When the current directory is a OneDev repository, the status line shows the act
|
|
|
28
28
|
|
|
29
29
|
| Tool | Domains | Covers |
|
|
30
30
|
| --- | --- | --- |
|
|
31
|
-
| `onedev_context` (always active) | — | Readiness, authenticated health, login,
|
|
31
|
+
| `onedev_context` (always active) | — | Readiness, authenticated health, login, setup guidance, and accessible project discovery |
|
|
32
32
|
| `onedev_tools` (always active) | — | Activate only the issue, pull, build, and watch domains needed now |
|
|
33
33
|
| `onedev_issue` | issue | Query/get/comment issues, change fields/labels/state, log work, issue branches |
|
|
34
34
|
| `onedev_pull` | pull | PR metadata/comments/code comments/labels/builds/patch, create/edit, reply/resolve review comments, approve, request changes, merge/discard |
|
|
@@ -37,7 +37,7 @@ When the current directory is a OneDev repository, the status line shows the act
|
|
|
37
37
|
|
|
38
38
|
Lazy by design: domain tools activate only through `onedev_tools`, keeping the default tool list and system prompt light. Activation performs the first authenticated check and returns the server, project, and login context in the same response.
|
|
39
39
|
|
|
40
|
-
Model-visible issue, PR, and build output defaults to 16 KB. PR patches, build logs, and build-spec schemas default to 32 KB. Use `max_bytes` to raise or lower a call's budget up to 128 KB; truncation keeps the payload head/tail and is reported explicitly. Internal watch polling remains separately bounded and is never copied into the model context.
|
|
40
|
+
Model-visible issue, PR, and build output defaults to 16 KB. PR patches, build logs, and build-spec schemas default to 32 KB. Use `max_bytes` to raise or lower a call's budget up to 128 KB; truncation keeps the payload head/tail and is reported explicitly. List actions instead return one-line-per-item digests by default (pass `full` for the raw payload), so bulk queries stay small without losing items; payloads beyond the 512 KB internal fetch fall back to the normal bounded raw output. Internal watch polling remains separately bounded and is never copied into the model context.
|
|
41
41
|
|
|
42
42
|
Remote and local mutations use Pi's real interactive approval UI. An approval can apply once, for the session, or persist for the exact server/project/operation in `~/.pi/agent/onedev-toolkit.json`; remove that file to revoke all saved approvals. Non-interactive sessions reject unapproved mutations. `build run` in local mode uses an isolated temporary Git index so TOD cannot leave the user's index staged; `check_spec` is treated as a mutation because TOD may upgrade `.onedev-buildspec.yml`.
|
|
43
43
|
|
package/package.json
CHANGED
package/src/source-watch.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface OneDevWatchInfo {
|
|
|
19
19
|
intervalSeconds: number;
|
|
20
20
|
expiresAt: string;
|
|
21
21
|
failures: number;
|
|
22
|
+
lastError?: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface AddOneDevWatchOptions {
|
|
@@ -62,6 +63,7 @@ interface WatchRecord {
|
|
|
62
63
|
expiresAt: number;
|
|
63
64
|
nextPollAt: number;
|
|
64
65
|
failures: number;
|
|
66
|
+
lastError?: string;
|
|
65
67
|
baseline: WatchSnapshot;
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -77,6 +79,8 @@ const MAX_INTERVAL_SECONDS = 300;
|
|
|
77
79
|
const MAX_TTL_MINUTES = 24 * 60;
|
|
78
80
|
const MAX_WATCHES = 10;
|
|
79
81
|
const MAX_BACKOFF_MS = 5 * 60_000;
|
|
82
|
+
|
|
83
|
+
const SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
80
84
|
const TERMINAL_BUILD_STATUSES = new Set([
|
|
81
85
|
"SUCCESSFUL",
|
|
82
86
|
"FAILED",
|
|
@@ -163,6 +167,33 @@ function firstBoolean(value: JsonValue, keys: readonly string[]): boolean | unde
|
|
|
163
167
|
return found;
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
function topLevelString(
|
|
171
|
+
value: JsonValue,
|
|
172
|
+
keys: readonly string[],
|
|
173
|
+
): string | undefined {
|
|
174
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
175
|
+
return undefined;
|
|
176
|
+
for (const key of keys) {
|
|
177
|
+
const child = value[key];
|
|
178
|
+
if (typeof child === "string" && child.trim() !== "") return child.trim();
|
|
179
|
+
if (typeof child === "number") return String(child);
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function topLevelBoolean(
|
|
185
|
+
value: JsonValue,
|
|
186
|
+
keys: readonly string[],
|
|
187
|
+
): boolean | undefined {
|
|
188
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
189
|
+
return undefined;
|
|
190
|
+
for (const key of keys) {
|
|
191
|
+
const child = value[key];
|
|
192
|
+
if (typeof child === "boolean") return child;
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
166
197
|
function unresolvedCodeComments(
|
|
167
198
|
value: JsonValue,
|
|
168
199
|
): Array<{ id: string | number; replies: number }> {
|
|
@@ -335,6 +366,7 @@ export class OneDevSourceWatchManager {
|
|
|
335
366
|
intervalSeconds: record.intervalMs / 1_000,
|
|
336
367
|
expiresAt: new Date(record.expiresAt).toISOString(),
|
|
337
368
|
failures: record.failures,
|
|
369
|
+
lastError: record.lastError,
|
|
338
370
|
};
|
|
339
371
|
}
|
|
340
372
|
|
|
@@ -353,6 +385,7 @@ export class OneDevSourceWatchManager {
|
|
|
353
385
|
const output = await runTod(this.deps.exec, ["build", "get", ref], {
|
|
354
386
|
cwd: context.cwd,
|
|
355
387
|
signal,
|
|
388
|
+
timeoutMs: SNAPSHOT_TIMEOUT_MS,
|
|
356
389
|
maxOutputBytes: 512_000,
|
|
357
390
|
});
|
|
358
391
|
const value = json(output.text, `build ${ref}`);
|
|
@@ -368,16 +401,19 @@ export class OneDevSourceWatchManager {
|
|
|
368
401
|
runTod(this.deps.exec, ["pr", "get", ref], {
|
|
369
402
|
cwd: context.cwd,
|
|
370
403
|
signal,
|
|
404
|
+
timeoutMs: SNAPSHOT_TIMEOUT_MS,
|
|
371
405
|
maxOutputBytes: 512_000,
|
|
372
406
|
}),
|
|
373
407
|
runTod(this.deps.exec, ["pr", "get-code-comments", ref], {
|
|
374
408
|
cwd: context.cwd,
|
|
375
409
|
signal,
|
|
410
|
+
timeoutMs: SNAPSHOT_TIMEOUT_MS,
|
|
376
411
|
maxOutputBytes: 512_000,
|
|
377
412
|
}),
|
|
378
413
|
runTod(this.deps.exec, ["pr", "get-builds", ref], {
|
|
379
414
|
cwd: context.cwd,
|
|
380
415
|
signal,
|
|
416
|
+
timeoutMs: SNAPSHOT_TIMEOUT_MS,
|
|
381
417
|
maxOutputBytes: 512_000,
|
|
382
418
|
}),
|
|
383
419
|
]);
|
|
@@ -397,17 +433,16 @@ export class OneDevSourceWatchManager {
|
|
|
397
433
|
"requestedChanges",
|
|
398
434
|
]);
|
|
399
435
|
const selected = {
|
|
400
|
-
state:
|
|
436
|
+
state: topLevelString(pullValue, ["status", "state"]) ?? null,
|
|
401
437
|
head:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
438
|
+
topLevelString(pullValue, [
|
|
439
|
+
"headCommitHash",
|
|
440
|
+
"sourceCommitHash",
|
|
441
|
+
"headCommit",
|
|
442
|
+
"sourceCommit",
|
|
407
443
|
]) ?? null,
|
|
408
|
-
mergeable:
|
|
409
|
-
conflicted:
|
|
410
|
-
firstBoolean(pullValue, ["hasConflicts", "conflicted"]) ?? null,
|
|
444
|
+
mergeable: topLevelBoolean(pullValue, ["mergeable"]) ?? null,
|
|
445
|
+
conflicted: topLevelBoolean(pullValue, ["hasConflicts", "conflicted"]) ?? null,
|
|
411
446
|
unresolved,
|
|
412
447
|
reviews,
|
|
413
448
|
buildSignal,
|
|
@@ -496,21 +531,28 @@ export class OneDevSourceWatchManager {
|
|
|
496
531
|
if (generation !== this.#generation || !this.#records.has(record.id)) break;
|
|
497
532
|
if (this.#changed(record, snapshot)) {
|
|
498
533
|
this.#records.delete(record.id);
|
|
499
|
-
|
|
534
|
+
try {
|
|
535
|
+
this.emit(this.#emission(record, snapshot));
|
|
536
|
+
} catch {
|
|
537
|
+
// a throwing consumer must not re-enter the poll-failure path
|
|
538
|
+
}
|
|
500
539
|
continue;
|
|
501
540
|
}
|
|
502
541
|
record.failures = 0;
|
|
542
|
+
record.lastError = undefined;
|
|
503
543
|
record.baseline = snapshot;
|
|
504
544
|
record.nextPollAt = this.now() + record.intervalMs;
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
)
|
|
513
|
-
|
|
545
|
+
} catch (error) {
|
|
546
|
+
record.failures += 1;
|
|
547
|
+
record.lastError =
|
|
548
|
+
error instanceof Error ? error.message : String(error);
|
|
549
|
+
record.nextPollAt =
|
|
550
|
+
this.now() +
|
|
551
|
+
Math.min(
|
|
552
|
+
record.intervalMs * 2 ** Math.min(record.failures, 6),
|
|
553
|
+
MAX_BACKOFF_MS,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
514
556
|
if (record.expiresAt <= now) this.#records.delete(record.id);
|
|
515
557
|
}
|
|
516
558
|
} finally {
|
package/src/tools/build.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Type, type Static } from "typebox";
|
|
|
7
7
|
import {
|
|
8
8
|
confirmMutation,
|
|
9
9
|
DEFAULT_LARGE_MODEL_OUTPUT_BYTES,
|
|
10
|
+
digestBuilds,
|
|
10
11
|
modelOutputBytes,
|
|
11
12
|
modelOutputLimit,
|
|
12
13
|
pushRepeated,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
REF_DESCRIPTION,
|
|
15
16
|
requireParam,
|
|
16
17
|
runInContext,
|
|
18
|
+
runListInContext,
|
|
17
19
|
toolResult,
|
|
18
20
|
withFooter,
|
|
19
21
|
type ToolDeps,
|
|
@@ -62,9 +64,24 @@ export interface BuildParams {
|
|
|
62
64
|
query?: string;
|
|
63
65
|
count?: number;
|
|
64
66
|
offset?: number;
|
|
67
|
+
full?: boolean;
|
|
65
68
|
max_bytes?: number;
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Local unless another mode or an explicit branch/tag is given. Names the
|
|
73
|
+
* non-obvious defaulting rule shared by the arg builder and the isolated
|
|
74
|
+
* index path; both call sites must resolve it identically.
|
|
75
|
+
*/
|
|
76
|
+
function isLocalRunMode(
|
|
77
|
+
params: Pick<BuildParams, "mode" | "branch" | "tag">,
|
|
78
|
+
): boolean {
|
|
79
|
+
return (
|
|
80
|
+
params.mode === "local" ||
|
|
81
|
+
(params.mode === undefined && !params.branch && !params.tag)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
export function buildBuildArgs(params: BuildParams): string[] {
|
|
69
86
|
const args: string[] = ["build"];
|
|
70
87
|
const action = requireParam(params.action, "build", "action");
|
|
@@ -116,10 +133,7 @@ export function buildBuildArgs(params: BuildParams): string[] {
|
|
|
116
133
|
if (params.mode === "tag" && params.branch !== undefined) {
|
|
117
134
|
throw new Error("tag run mode does not accept branch");
|
|
118
135
|
}
|
|
119
|
-
if (
|
|
120
|
-
params.mode === "local" ||
|
|
121
|
-
(params.mode === undefined && !params.branch && !params.tag)
|
|
122
|
-
) {
|
|
136
|
+
if (isLocalRunMode(params)) {
|
|
123
137
|
args.push("--local");
|
|
124
138
|
} else if (params.mode === "branch" || params.branch !== undefined) {
|
|
125
139
|
args.push("--branch", requireParam(params.branch, action, "branch"));
|
|
@@ -225,6 +239,9 @@ export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
225
239
|
query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })),
|
|
226
240
|
count: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
227
241
|
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
242
|
+
full: Type.Optional(
|
|
243
|
+
Type.Boolean({ description: "Raw JSON instead of digest" }),
|
|
244
|
+
),
|
|
228
245
|
max_bytes: modelOutputBytes(),
|
|
229
246
|
}, { additionalProperties: false });
|
|
230
247
|
type Params = Static<typeof parameters>;
|
|
@@ -253,12 +270,16 @@ export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
253
270
|
});
|
|
254
271
|
}
|
|
255
272
|
// ponytail: build run streams the full log; preserve bounded metadata and the failure tail
|
|
256
|
-
const isLocalRun =
|
|
257
|
-
isRun &&
|
|
258
|
-
(params.mode === "local" ||
|
|
259
|
-
(params.mode === undefined && !params.branch && !params.tag));
|
|
273
|
+
const isLocalRun = isRun && isLocalRunMode(params);
|
|
260
274
|
let output: TodOutput;
|
|
261
|
-
if (
|
|
275
|
+
if (params.action === "list") {
|
|
276
|
+
output = await runListInContext(deps, args, {
|
|
277
|
+
signal,
|
|
278
|
+
full: params.full,
|
|
279
|
+
maxOutputBytes: params.max_bytes,
|
|
280
|
+
digest: digestBuilds,
|
|
281
|
+
});
|
|
282
|
+
} else if (isLocalRun) {
|
|
262
283
|
output = await runLocalBuildWithIsolatedIndex(
|
|
263
284
|
deps,
|
|
264
285
|
args,
|
package/src/tools/common.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
import {
|
|
12
12
|
TodError,
|
|
13
13
|
runTod,
|
|
14
|
+
truncateMiddle,
|
|
14
15
|
type CommandExecutor,
|
|
15
16
|
type TodOutput,
|
|
16
17
|
} from "../tod.js";
|
|
@@ -34,7 +35,7 @@ export const REF_DESCRIPTION =
|
|
|
34
35
|
export const FIELDS_DESCRIPTION =
|
|
35
36
|
'"key=value" assignments; use valid_fields to discover values.';
|
|
36
37
|
export const QUERY_DESCRIPTION =
|
|
37
|
-
|
|
38
|
+
'OneDev query; e.g. \'open\', \'submitted by "user"\', \'Assignees is "user"\', \'order by "Submit Date" desc\'; full syntax: query_description.';
|
|
38
39
|
|
|
39
40
|
export function modelOutputBytes() {
|
|
40
41
|
return Type.Optional(
|
|
@@ -110,6 +111,236 @@ export function pushRepeated(
|
|
|
110
111
|
for (const value of values ?? []) args.push(flag, value);
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* One-line-per-item digests for list results. Raw OneDev list payloads embed
|
|
116
|
+
* full Markdown descriptions (~2 KB per item); digests keep bulk queries in a
|
|
117
|
+
* few KB so pagination stays lossless. Digests return undefined when the
|
|
118
|
+
* payload is not a JSON object array so callers fall back to the raw text.
|
|
119
|
+
*/
|
|
120
|
+
export const DIGEST_FETCH_BYTES = 512_000;
|
|
121
|
+
|
|
122
|
+
type DigestItem = Record<string, unknown>;
|
|
123
|
+
|
|
124
|
+
function parseDigestItems(text: string): DigestItem[] | undefined {
|
|
125
|
+
let parsed: unknown;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(text);
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
132
|
+
return parsed.every(
|
|
133
|
+
(item) => typeof item === "object" && item !== null && !Array.isArray(item),
|
|
134
|
+
)
|
|
135
|
+
? (parsed as DigestItem[])
|
|
136
|
+
: undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stringAt(
|
|
140
|
+
item: DigestItem,
|
|
141
|
+
keys: readonly string[],
|
|
142
|
+
): string | undefined {
|
|
143
|
+
for (const key of keys) {
|
|
144
|
+
const value = item[key];
|
|
145
|
+
if (typeof value === "number") return String(value);
|
|
146
|
+
if (typeof value === "string" && value.trim() !== "") return value.trim();
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function listAt(item: DigestItem, keys: readonly string[]): string[] {
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const value = item[key];
|
|
154
|
+
if (Array.isArray(value) && value.length > 0) return value.map(String);
|
|
155
|
+
}
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function clip(value: string, maxChars: number): string {
|
|
160
|
+
if (value.length <= maxChars) return value;
|
|
161
|
+
let end = maxChars - 1;
|
|
162
|
+
const previous = value.charCodeAt(end - 1);
|
|
163
|
+
if (previous >= 0xd800 && previous <= 0xdbff) end -= 1;
|
|
164
|
+
return `${value.slice(0, Math.max(end, 0))}…`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function day(value: string | undefined): string | undefined {
|
|
168
|
+
return value === undefined ? undefined : value.slice(0, 10);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function digestLine(
|
|
172
|
+
ref: string,
|
|
173
|
+
tags: ReadonlyArray<string | undefined>,
|
|
174
|
+
summary: string,
|
|
175
|
+
trailer: ReadonlyArray<string | undefined>,
|
|
176
|
+
): string {
|
|
177
|
+
const one = (value: string): string => value.replace(/\s+/g, " ");
|
|
178
|
+
const bracketed = tags
|
|
179
|
+
.filter((tag): tag is string => tag !== undefined)
|
|
180
|
+
.map(one);
|
|
181
|
+
const filled = trailer
|
|
182
|
+
.filter((part): part is string => part !== undefined)
|
|
183
|
+
.map(one);
|
|
184
|
+
const label = one(ref);
|
|
185
|
+
const head =
|
|
186
|
+
bracketed.length > 0 ? `${label} [${bracketed.join(" ")}]` : label;
|
|
187
|
+
const text = one(summary);
|
|
188
|
+
return `${head}${text !== "" ? ` ${text}` : ""}${filled.length > 0 ? ` · ${filled.join(" · ")}` : ""}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function digestIssues(text: string): string | undefined {
|
|
192
|
+
const items = parseDigestItems(text);
|
|
193
|
+
if (!items) return undefined;
|
|
194
|
+
if (items.length === 0) return "(no issues)";
|
|
195
|
+
return items
|
|
196
|
+
.map((item) => {
|
|
197
|
+
const assignees = listAt(item, ["Assignees"]);
|
|
198
|
+
return digestLine(
|
|
199
|
+
stringAt(item, ["reference", "number"]) ?? "#?",
|
|
200
|
+
[
|
|
201
|
+
stringAt(item, ["state", "status"]),
|
|
202
|
+
...listAt(item, ["Priority"]),
|
|
203
|
+
...listAt(item, ["Type"]),
|
|
204
|
+
],
|
|
205
|
+
clip(stringAt(item, ["title"]) ?? "(untitled)", 120),
|
|
206
|
+
[
|
|
207
|
+
stringAt(item, ["submitter"]),
|
|
208
|
+
day(stringAt(item, ["submitDate"])),
|
|
209
|
+
assignees.length > 0
|
|
210
|
+
? `assigned: ${assignees.join(", ")}`
|
|
211
|
+
: undefined,
|
|
212
|
+
],
|
|
213
|
+
);
|
|
214
|
+
})
|
|
215
|
+
.join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function digestPulls(text: string): string | undefined {
|
|
219
|
+
const items = parseDigestItems(text);
|
|
220
|
+
if (!items) return undefined;
|
|
221
|
+
if (items.length === 0) return "(no pull requests)";
|
|
222
|
+
return items
|
|
223
|
+
.map((item) => {
|
|
224
|
+
const flow = [
|
|
225
|
+
stringAt(item, ["sourceBranch"]),
|
|
226
|
+
stringAt(item, ["targetBranch"]),
|
|
227
|
+
]
|
|
228
|
+
.filter((branch): branch is string => branch !== undefined)
|
|
229
|
+
.join("→");
|
|
230
|
+
return digestLine(
|
|
231
|
+
stringAt(item, ["reference", "number"]) ?? "#?",
|
|
232
|
+
[stringAt(item, ["status", "state"])],
|
|
233
|
+
clip(stringAt(item, ["title"]) ?? "(untitled)", 120),
|
|
234
|
+
[
|
|
235
|
+
flow !== "" ? flow : undefined,
|
|
236
|
+
stringAt(item, ["submitter"]),
|
|
237
|
+
day(stringAt(item, ["submitDate"])),
|
|
238
|
+
],
|
|
239
|
+
);
|
|
240
|
+
})
|
|
241
|
+
.join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function digestBuilds(text: string): string | undefined {
|
|
245
|
+
const items = parseDigestItems(text);
|
|
246
|
+
if (!items) return undefined;
|
|
247
|
+
if (items.length === 0) return "(no builds)";
|
|
248
|
+
return items
|
|
249
|
+
.map((item) =>
|
|
250
|
+
digestLine(
|
|
251
|
+
stringAt(item, ["reference", "number"]) ?? "#?",
|
|
252
|
+
[stringAt(item, ["status"])],
|
|
253
|
+
clip(stringAt(item, ["jobName"]) ?? "(unknown job)", 80),
|
|
254
|
+
[
|
|
255
|
+
stringAt(item, ["refName"])?.replace("refs/heads/", ""),
|
|
256
|
+
stringAt(item, ["agent", "submitter"]),
|
|
257
|
+
day(stringAt(item, ["submitDate"])),
|
|
258
|
+
],
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
.join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function digestProjects(text: string): string | undefined {
|
|
265
|
+
const items = parseDigestItems(text);
|
|
266
|
+
if (!items) return undefined;
|
|
267
|
+
if (items.length === 0) return "(no projects)";
|
|
268
|
+
return items
|
|
269
|
+
.map((item) => {
|
|
270
|
+
const features = [
|
|
271
|
+
item.codeManagement === true ? "code" : undefined,
|
|
272
|
+
item.issueManagement === true ? "issues" : undefined,
|
|
273
|
+
].filter((feature): feature is string => feature !== undefined);
|
|
274
|
+
return digestLine(
|
|
275
|
+
stringAt(item, ["path"]) ?? "#?",
|
|
276
|
+
[],
|
|
277
|
+
clip(
|
|
278
|
+
stringAt(item, ["description"]) ?? stringAt(item, ["name"]) ?? "",
|
|
279
|
+
80,
|
|
280
|
+
),
|
|
281
|
+
[features.length > 0 ? `[${features.join("+")}]` : undefined],
|
|
282
|
+
);
|
|
283
|
+
})
|
|
284
|
+
.join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Fetch a whole bounded list payload, then reduce it to a digest unless the
|
|
289
|
+
* caller opted into raw output. The internal fetch budget exceeds the
|
|
290
|
+
* model-visible cap on purpose: the digest, not the payload, reaches the
|
|
291
|
+
* model.
|
|
292
|
+
*/
|
|
293
|
+
export async function runListInContext(
|
|
294
|
+
deps: ToolDeps,
|
|
295
|
+
args: readonly string[],
|
|
296
|
+
options: {
|
|
297
|
+
signal?: AbortSignal;
|
|
298
|
+
full?: boolean;
|
|
299
|
+
maxOutputBytes?: number;
|
|
300
|
+
digest: (text: string) => string | undefined;
|
|
301
|
+
},
|
|
302
|
+
): Promise<TodOutput> {
|
|
303
|
+
const context = deps.context();
|
|
304
|
+
if (context.status !== "ready" || !context.project) {
|
|
305
|
+
throw new TodError(
|
|
306
|
+
context.problem ??
|
|
307
|
+
"OneDev tools require a repository whose project can be inferred from a OneDev remote",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (options.full === true) {
|
|
311
|
+
return runTod(deps.exec, args, {
|
|
312
|
+
cwd: context.cwd,
|
|
313
|
+
signal: options.signal,
|
|
314
|
+
maxOutputBytes: modelOutputLimit(options.maxOutputBytes),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
const output = await runTod(deps.exec, args, {
|
|
318
|
+
cwd: context.cwd,
|
|
319
|
+
signal: options.signal,
|
|
320
|
+
maxOutputBytes: DIGEST_FETCH_BYTES,
|
|
321
|
+
});
|
|
322
|
+
const limit = modelOutputLimit(options.maxOutputBytes);
|
|
323
|
+
const digested = options.digest(output.text);
|
|
324
|
+
if (
|
|
325
|
+
digested !== undefined &&
|
|
326
|
+
Buffer.byteLength(digested, "utf8") <= limit
|
|
327
|
+
) {
|
|
328
|
+
return {
|
|
329
|
+
text: digested,
|
|
330
|
+
truncated: false,
|
|
331
|
+
originalBytes: output.originalBytes,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
// no digest, or one that outgrew the budget: the final model-visible
|
|
335
|
+
// text is still bounded by the normal output limit
|
|
336
|
+
const bounded = truncateMiddle(digested ?? output.text, limit);
|
|
337
|
+
return {
|
|
338
|
+
text: bounded.text,
|
|
339
|
+
truncated: bounded.truncated || output.truncated,
|
|
340
|
+
originalBytes: output.originalBytes,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
113
344
|
export function confirmMutation(
|
|
114
345
|
deps: ToolDeps,
|
|
115
346
|
ctx: ExtensionContext,
|
package/src/tools/index.ts
CHANGED
|
@@ -2,8 +2,15 @@ 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
4
|
import { formatContext, setupGuidance } from "../context.js";
|
|
5
|
+
import { runTod } from "../tod.js";
|
|
5
6
|
import type { OneDevSourceWatchManager } from "../source-watch.js";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
DIGEST_FETCH_BYTES,
|
|
9
|
+
digestProjects,
|
|
10
|
+
toolResult,
|
|
11
|
+
withFooter,
|
|
12
|
+
type ToolDeps,
|
|
13
|
+
} from "./common.js";
|
|
7
14
|
import { registerBuildTool } from "./build.js";
|
|
8
15
|
import { registerIssueTool } from "./issue.js";
|
|
9
16
|
import { registerPullTool } from "./pull.js";
|
|
@@ -46,7 +53,17 @@ function registerContextTool(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
46
53
|
description: "Inspect OneDev readiness, health, login, or setup.",
|
|
47
54
|
parameters: Type.Object(
|
|
48
55
|
{
|
|
49
|
-
action: StringEnum([
|
|
56
|
+
action: StringEnum([
|
|
57
|
+
"current",
|
|
58
|
+
"health",
|
|
59
|
+
"login_name",
|
|
60
|
+
"setup",
|
|
61
|
+
"list_projects",
|
|
62
|
+
]),
|
|
63
|
+
count: Type.Optional(
|
|
64
|
+
Type.Integer({ minimum: 1, maximum: 100, description: "Default: 25" }),
|
|
65
|
+
),
|
|
66
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
50
67
|
},
|
|
51
68
|
{ additionalProperties: false },
|
|
52
69
|
),
|
|
@@ -57,6 +74,31 @@ function registerContextTool(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
57
74
|
if (params.action === "setup") {
|
|
58
75
|
return toolResult(setupGuidance(deps.context()));
|
|
59
76
|
}
|
|
77
|
+
if (params.action === "list_projects") {
|
|
78
|
+
const context = deps.context();
|
|
79
|
+
if (
|
|
80
|
+
context.status === "not_configured" ||
|
|
81
|
+
context.status === "unavailable"
|
|
82
|
+
) {
|
|
83
|
+
return toolResult(setupGuidance(context));
|
|
84
|
+
}
|
|
85
|
+
const args = ["project", "list", "--count", String(params.count ?? 25)];
|
|
86
|
+
if (params.offset !== undefined)
|
|
87
|
+
args.push("--offset", String(params.offset));
|
|
88
|
+
const output = await runTod(deps.exec, args, {
|
|
89
|
+
cwd: context.cwd,
|
|
90
|
+
signal,
|
|
91
|
+
maxOutputBytes: DIGEST_FETCH_BYTES,
|
|
92
|
+
});
|
|
93
|
+
const digested = digestProjects(output.text);
|
|
94
|
+
return toolResult(
|
|
95
|
+
withFooter(
|
|
96
|
+
digested ?? output.text,
|
|
97
|
+
context,
|
|
98
|
+
digested === undefined && output.truncated,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
60
102
|
const context = await deps.refreshContext({
|
|
61
103
|
verifyAuth: true,
|
|
62
104
|
force: true,
|
package/src/tools/issue.ts
CHANGED
|
@@ -4,12 +4,14 @@ import { Type, type Static } from "typebox";
|
|
|
4
4
|
import {
|
|
5
5
|
FIELDS_DESCRIPTION,
|
|
6
6
|
confirmMutation,
|
|
7
|
+
digestIssues,
|
|
7
8
|
modelOutputBytes,
|
|
8
9
|
pushRepeated,
|
|
9
10
|
QUERY_DESCRIPTION,
|
|
10
11
|
REF_DESCRIPTION,
|
|
11
12
|
requireParam,
|
|
12
13
|
runInContext,
|
|
14
|
+
runListInContext,
|
|
13
15
|
toolResult,
|
|
14
16
|
withFooter,
|
|
15
17
|
type ToolDeps,
|
|
@@ -66,6 +68,7 @@ export interface IssueParams {
|
|
|
66
68
|
query?: string;
|
|
67
69
|
count?: number;
|
|
68
70
|
offset?: number;
|
|
71
|
+
full?: boolean;
|
|
69
72
|
max_bytes?: number;
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -200,6 +203,9 @@ export function registerIssueTool(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
200
203
|
}),
|
|
201
204
|
),
|
|
202
205
|
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
206
|
+
full: Type.Optional(
|
|
207
|
+
Type.Boolean({ description: "Raw JSON instead of digest" }),
|
|
208
|
+
),
|
|
203
209
|
max_bytes: modelOutputBytes(),
|
|
204
210
|
}, { additionalProperties: false });
|
|
205
211
|
type Params = Static<typeof parameters>;
|
|
@@ -223,10 +229,18 @@ export function registerIssueTool(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
223
229
|
signal,
|
|
224
230
|
});
|
|
225
231
|
}
|
|
226
|
-
const output =
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
232
|
+
const output =
|
|
233
|
+
params.action === "list"
|
|
234
|
+
? await runListInContext(deps, args, {
|
|
235
|
+
signal,
|
|
236
|
+
full: params.full,
|
|
237
|
+
maxOutputBytes: params.max_bytes,
|
|
238
|
+
digest: digestIssues,
|
|
239
|
+
})
|
|
240
|
+
: await runInContext(deps, args, {
|
|
241
|
+
signal,
|
|
242
|
+
maxOutputBytes: params.max_bytes,
|
|
243
|
+
});
|
|
230
244
|
return toolResult(
|
|
231
245
|
withFooter(output.text, deps.context(), output.truncated),
|
|
232
246
|
{
|
package/src/tools/pull.ts
CHANGED
|
@@ -4,12 +4,14 @@ import { Type, type Static } from "typebox";
|
|
|
4
4
|
import {
|
|
5
5
|
confirmMutation,
|
|
6
6
|
DEFAULT_LARGE_MODEL_OUTPUT_BYTES,
|
|
7
|
+
digestPulls,
|
|
7
8
|
modelOutputBytes,
|
|
8
9
|
pushRepeated,
|
|
9
10
|
QUERY_DESCRIPTION,
|
|
10
11
|
REF_DESCRIPTION,
|
|
11
12
|
requireParam,
|
|
12
13
|
runInContext,
|
|
14
|
+
runListInContext,
|
|
13
15
|
toolResult,
|
|
14
16
|
withFooter,
|
|
15
17
|
type ToolDeps,
|
|
@@ -88,6 +90,7 @@ export interface PullParams {
|
|
|
88
90
|
query?: string;
|
|
89
91
|
count?: number;
|
|
90
92
|
offset?: number;
|
|
93
|
+
full?: boolean;
|
|
91
94
|
max_bytes?: number;
|
|
92
95
|
}
|
|
93
96
|
|
|
@@ -274,6 +277,9 @@ export function registerPullTool(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
274
277
|
query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })),
|
|
275
278
|
count: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
276
279
|
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
280
|
+
full: Type.Optional(
|
|
281
|
+
Type.Boolean({ description: "Raw JSON instead of digest" }),
|
|
282
|
+
),
|
|
277
283
|
max_bytes: modelOutputBytes(),
|
|
278
284
|
}, { additionalProperties: false });
|
|
279
285
|
type Params = Static<typeof parameters>;
|
|
@@ -298,14 +304,22 @@ export function registerPullTool(pi: ExtensionAPI, deps: ToolDeps): void {
|
|
|
298
304
|
signal,
|
|
299
305
|
});
|
|
300
306
|
}
|
|
301
|
-
const output =
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
const output =
|
|
308
|
+
params.action === "list"
|
|
309
|
+
? await runListInContext(deps, args, {
|
|
310
|
+
signal,
|
|
311
|
+
full: params.full,
|
|
312
|
+
maxOutputBytes: params.max_bytes,
|
|
313
|
+
digest: digestPulls,
|
|
314
|
+
})
|
|
315
|
+
: await runInContext(deps, args, {
|
|
316
|
+
signal,
|
|
317
|
+
maxOutputBytes:
|
|
318
|
+
params.max_bytes ??
|
|
319
|
+
(params.action === "get_patch"
|
|
320
|
+
? DEFAULT_LARGE_MODEL_OUTPUT_BYTES
|
|
321
|
+
: undefined),
|
|
322
|
+
});
|
|
309
323
|
return toolResult(
|
|
310
324
|
withFooter(output.text, deps.context(), output.truncated),
|
|
311
325
|
{
|