pi-onedev-toolkit 0.1.1 → 0.2.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/CHANGELOG.md +33 -1
- package/README.md +22 -7
- package/package.json +9 -5
- package/skills/onedev-issue-to-pr/SKILL.md +9 -4
- package/skills/onedev-pr-review/SKILL.md +12 -6
- package/src/config-storage.ts +105 -0
- package/src/context.ts +204 -38
- package/src/extension.ts +43 -7
- package/src/mutation-approvals.ts +153 -0
- package/src/source-watch.ts +521 -0
- package/src/tod.ts +33 -3
- package/src/tools/build.ts +143 -21
- package/src/tools/common.ts +27 -12
- package/src/tools/index.ts +91 -63
- package/src/tools/issue.ts +50 -26
- package/src/tools/pull.ts +116 -67
- package/src/tools/watch.ts +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 — 2026-09-03
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- "Always allow" now saves one project-wide approval (`[server, project, "*"]`) instead of a single operation, so new operations no longer re-prompt after approving. Legacy per-operation entries are still honored.
|
|
8
|
+
- "Allow for this session" also covers every operation in the project for the session.
|
|
9
|
+
- Concurrent identical mutations share one approval dialog instead of opening several.
|
|
10
|
+
- Safer dialog default (session-wide allow) and explicit "ALL OneDev mutations" labels; Enter no longer silently picks "Allow once".
|
|
11
|
+
|
|
12
|
+
## 0.2.0 — 2026-08-26
|
|
13
|
+
|
|
14
|
+
### Security and correctness
|
|
15
|
+
|
|
16
|
+
- Remove attachment download until TOD can enforce same-origin credentials, redirect safety, bounded downloads, and non-destructive destinations.
|
|
17
|
+
- Treat killed TOD processes as aborts or timeouts instead of successful calls.
|
|
18
|
+
- Reject unknown tool parameters and standardize all multiword parameters on `snake_case`.
|
|
19
|
+
- Fix Cobra boolean arguments for issue confidentiality and PR auto-merge, including explicit `false`, and require a branch in branch build mode.
|
|
20
|
+
- Isolate local build runs with a temporary Git index and disclose/approve build-spec upgrades.
|
|
21
|
+
|
|
22
|
+
### Agent and user experience
|
|
23
|
+
|
|
24
|
+
- Split the always-active readiness/setup tool (`onedev_context`) from the domain activator (`onedev_tools`), and remove prompt metadata from deferred domain schemas.
|
|
25
|
+
- Add explicit readiness states, lazy authenticated health checks, coalesced context probes, and caller-isolated cancellation.
|
|
26
|
+
- Replace agent-supplied confirmation booleans with real Pi UI approvals scoped once, per session, or persistently by server/project/operation.
|
|
27
|
+
- Add code-comment reply/resolve/unresolve actions and unit-test report support with TOD capability detection.
|
|
28
|
+
- Add bounded, session-scoped one-shot watches for build completion and pull-request attention.
|
|
29
|
+
|
|
30
|
+
### Quality
|
|
31
|
+
|
|
32
|
+
- Add execution-level, lifecycle, approval-storage, local-index, context, and watch regression tests.
|
|
33
|
+
- Add Bun checks, Node 22.19/24 import smoke tests, and an opt-in read-only live OneDev smoke workflow.
|
|
34
|
+
|
|
3
35
|
## 0.1.1
|
|
4
36
|
|
|
5
37
|
- Add `/od-settings` to enable or disable the OneDev project status in the TUI footer.
|
|
@@ -8,7 +40,7 @@
|
|
|
8
40
|
## 0.1.0
|
|
9
41
|
|
|
10
42
|
- Initial release.
|
|
11
|
-
- `onedev_tools` domain activator with context, login, and attachment-download actions.
|
|
43
|
+
- `onedev_tools` domain activator with context, login, and attachment-download actions (download removed under Unreleased for credential safety).
|
|
12
44
|
- `onedev_issue`, `onedev_pull`, `onedev_build` tools wrapping the TOD CLI.
|
|
13
45
|
- `/od-context` and `/od-health` commands; session status line with the inferred project.
|
|
14
46
|
- `onedev-issue-to-pr` and `onedev-pr-review` skills.
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# pi-onedev-toolkit
|
|
2
2
|
|
|
3
|
-
Lightweight [OneDev](https://onedev.io/) integration for [Pi](https://pi.dev/), built on the official [TOD CLI](https://code.onedev.io/onedev/tod): repo-aware issue, pull request, and
|
|
3
|
+
Lightweight [OneDev](https://onedev.io/) integration for [Pi](https://pi.dev/), built on the official [TOD CLI](https://code.onedev.io/onedev/tod): repo-aware issue, pull request, build, and one-shot watch tools plus issue-to-PR and PR-review skills.
|
|
4
4
|
|
|
5
|
-
The agent never
|
|
5
|
+
The agent never sees tokens. The extension invokes `tod` with structured arguments, resolves the OneDev project from the repository's git remotes, and keeps unavailable domain schemas out of the default prompt. Model-facing multiword parameters use strict `snake_case`; unknown parameters are rejected instead of being silently ignored.
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
@@ -22,18 +22,26 @@ pi install npm:pi-onedev-toolkit
|
|
|
22
22
|
pi install git:github.com/alpertarhan/pi-onedev-toolkit
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
When the current directory is a OneDev repository, the status line shows the active project. `onedev_context` action `setup` explains missing TOD configuration, credentials, or project remotes; `/od-health` performs an authenticated re-check. Use `/od-settings` to enable or disable the footer for the current session branch.
|
|
26
26
|
|
|
27
27
|
## Tools
|
|
28
28
|
|
|
29
29
|
| Tool | Domains | Covers |
|
|
30
30
|
| --- | --- | --- |
|
|
31
|
-
| `
|
|
31
|
+
| `onedev_context` (always active) | — | Readiness, authenticated health, login, and setup guidance |
|
|
32
|
+
| `onedev_tools` (always active) | — | Activate only the issue, pull, build, and watch domains needed now |
|
|
32
33
|
| `onedev_issue` | issue | Query/get/comment issues, change fields/labels/state, log work, issue branches |
|
|
33
|
-
| `onedev_pull` | pull | PR metadata/comments/code comments/labels/builds/patch, create/edit, approve, request changes,
|
|
34
|
-
| `onedev_build` | build | Build queries, logs, code-problem reports, changes since
|
|
34
|
+
| `onedev_pull` | pull | PR metadata/comments/code comments/labels/builds/patch, create/edit, reply/resolve review comments, approve, request changes, merge/discard |
|
|
35
|
+
| `onedev_build` | build | Build queries, logs, code-problem and unit-test reports, changes since success, jobs, build-spec validation/upgrades |
|
|
36
|
+
| `onedev_watch` | watch | One-shot build-result and pull-attention notifications; list/stop active session watches |
|
|
35
37
|
|
|
36
|
-
Lazy by design: domain tools activate only through `onedev_tools`, keeping the default tool list light.
|
|
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
|
+
|
|
40
|
+
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`.
|
|
41
|
+
|
|
42
|
+
Watches are session-scoped and one-shot. They use one timer, bounded polling, expiration and backoff, and wake the agent with metadata only. The agent retrieves logs, comments, or build details with the corresponding read tool. At most ten watches may be active; session changes and shutdown clear them.
|
|
43
|
+
|
|
44
|
+
Attachment download is intentionally not exposed: current TOD releases may send the OneDev bearer token to an absolute external Markdown URL and do not provide a safe download-size/overwrite boundary.
|
|
37
45
|
|
|
38
46
|
## Commands
|
|
39
47
|
|
|
@@ -41,6 +49,12 @@ Lazy by design: domain tools activate only through `onedev_tools`, keeping the d
|
|
|
41
49
|
- `/od-health` — re-check tod configuration and server reachability
|
|
42
50
|
- `/od-settings` — enable or disable the OneDev footer status in the TUI
|
|
43
51
|
|
|
52
|
+
## Compatibility notes
|
|
53
|
+
|
|
54
|
+
- Update TOD regularly from its upstream install script. `get_unit_test_report` reports an actionable upgrade error when the installed TOD predates that command.
|
|
55
|
+
- Plain references require the current repository to have a OneDev remote from which TOD can infer the project. The toolkit reports `no_project` rather than pretending a qualified reference can bypass TOD's resolver.
|
|
56
|
+
- Tool calls use `snake_case`, for example `for_code_review`, `target_branch`, `report_name`, and `interval_seconds`.
|
|
57
|
+
|
|
44
58
|
## Skills
|
|
45
59
|
|
|
46
60
|
- `onedev-issue-to-pr` — gather issue context, branch, implement, present for review, then push and open the PR
|
|
@@ -51,6 +65,7 @@ Lazy by design: domain tools activate only through `onedev_tools`, keeping the d
|
|
|
51
65
|
```bash
|
|
52
66
|
bun install
|
|
53
67
|
bun run check # typecheck + vitest
|
|
68
|
+
bun run scripts/smoke.ts # optional read-only live smoke; see CONTRIBUTING.md
|
|
54
69
|
```
|
|
55
70
|
|
|
56
71
|
## License
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-onedev-toolkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Lightweight OneDev integration for Pi via
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Lightweight OneDev integration for Pi via TOD: repo-aware issue, pull request, build, and watch tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Alper Tarhan",
|
|
@@ -55,13 +55,17 @@
|
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@earendil-works/pi-ai": "0.84.1",
|
|
57
57
|
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
58
|
-
"@types/node": "^
|
|
58
|
+
"@types/node": "^22.19.0",
|
|
59
|
+
"@types/proper-lockfile": "4.1.4",
|
|
59
60
|
"typebox": "1.3.12",
|
|
60
61
|
"typescript": "^7.0.2",
|
|
61
62
|
"vitest": "^4.1.10"
|
|
62
63
|
},
|
|
63
64
|
"engines": {
|
|
64
|
-
"node": ">=
|
|
65
|
+
"node": ">=22.19.0"
|
|
65
66
|
},
|
|
66
|
-
"packageManager": "bun@1.3.14"
|
|
67
|
+
"packageManager": "bun@1.3.14",
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"proper-lockfile": "4.1.2"
|
|
70
|
+
}
|
|
67
71
|
}
|
|
@@ -7,7 +7,7 @@ description: Implement a OneDev issue end to end - gather issue context, branch,
|
|
|
7
7
|
|
|
8
8
|
## Prerequisites
|
|
9
9
|
|
|
10
|
-
- Call `onedev_tools` with domains `["issue", "pull"]` first; note your login name from the returned context.
|
|
10
|
+
- Call `onedev_tools` with domains `["issue", "pull"]` first; note your login name from the returned context. If activation reports a readiness problem, use `onedev_context` `setup` and stop until it is resolved.
|
|
11
11
|
- The current repository belongs to the issue's project (plain refs like `123` resolve against it).
|
|
12
12
|
|
|
13
13
|
## Workflow
|
|
@@ -24,10 +24,9 @@ description: Implement a OneDev issue end to end - gather issue context, branch,
|
|
|
24
24
|
- `onedev_issue` `get` (title, description, fields, submitter)
|
|
25
25
|
- `onedev_issue` `get_comments`
|
|
26
26
|
- Match your login name against submitter/comment authors to understand your role.
|
|
27
|
-
- Download embedded resources (screenshots, logs) with `onedev_tools` action
|
|
28
|
-
`download`, passing the URL exactly as it appears in the markdown.
|
|
29
27
|
- If a failed build is part of the issue, gather evidence with `onedev_build`
|
|
30
|
-
(`get`, `get_log
|
|
28
|
+
after activating the `build` domain (`get`, `get_log`, and a named
|
|
29
|
+
`get_unit_test_report` when useful) before planning changes. Follow `Dependency build … failed`
|
|
31
30
|
chains: same-commit dependency failures are the real target; different-commit
|
|
32
31
|
ones mean the failure is inherited.
|
|
33
32
|
|
|
@@ -48,8 +47,14 @@ description: Implement a OneDev issue end to end - gather issue context, branch,
|
|
|
48
47
|
is inferred from the branch; state it in the description if not.
|
|
49
48
|
- Post the drafted comment with `onedev_issue` `add_comment` if one was prepared.
|
|
50
49
|
|
|
50
|
+
7. **For long-running CI**, activate `watch` and use `onedev_watch` `watch_build`
|
|
51
|
+
after obtaining a build reference. The notification is one-shot and metadata-only;
|
|
52
|
+
inspect the build with `onedev_build` when it fires.
|
|
53
|
+
|
|
51
54
|
## Notes
|
|
52
55
|
|
|
53
56
|
- Write comment text in Markdown.
|
|
54
57
|
- Reference entities as `#123` (current project), `project#123`, or `PROJ-123`.
|
|
55
58
|
- If blocked mid-workflow, report the command and error, and stop.
|
|
59
|
+
- Toolkit mutations still open Pi's interactive approval UI. Do not try to bypass
|
|
60
|
+
it with a confirmation parameter.
|
|
@@ -7,7 +7,7 @@ description: Review a OneDev pull request - gather PR context, builds, and fixed
|
|
|
7
7
|
|
|
8
8
|
## Prerequisites
|
|
9
9
|
|
|
10
|
-
- Call `onedev_tools` with domains `["pull", "issue", "build"]`; note your login name.
|
|
10
|
+
- Call `onedev_tools` with domains `["pull", "issue", "build"]`; note your login name. If activation reports a readiness problem, use `onedev_context` `setup` and stop until it is resolved.
|
|
11
11
|
- The current repository belongs to the PR's source or target project.
|
|
12
12
|
|
|
13
13
|
## Gather context (never review from the patch alone)
|
|
@@ -27,7 +27,6 @@ description: Review a OneDev pull request - gather PR context, builds, and fixed
|
|
|
27
27
|
for what the PR should accomplish.
|
|
28
28
|
- `onedev_pull` `get_builds` — investigate every `FAILED` build with
|
|
29
29
|
`onedev_build` `get` / `get_log` before reviewing code.
|
|
30
|
-
- Download embedded resources via `onedev_tools` `download`.
|
|
31
30
|
- Match your login name against authors to understand your role
|
|
32
31
|
(pending reviewer, prior commenter, outsider).
|
|
33
32
|
|
|
@@ -39,7 +38,8 @@ description: Review a OneDev pull request - gather PR context, builds, and fixed
|
|
|
39
38
|
|
|
40
39
|
2. **Triage prior discussion:** resolve addressed code comments, reply where new
|
|
41
40
|
information is needed, leave unrelated unresolved ones alone. Never modify
|
|
42
|
-
another user's code comments.
|
|
41
|
+
another user's code comments. Use `reply_code_comment`, `resolve_code_comment`,
|
|
42
|
+
or `unresolve_code_comment` with the comment `id`.
|
|
43
43
|
|
|
44
44
|
3. **Draft findings:**
|
|
45
45
|
- Line-anchored findings → `onedev_pull` `add_code_comment` (`file`, `from_line`,
|
|
@@ -53,13 +53,19 @@ description: Review a OneDev pull request - gather PR context, builds, and fixed
|
|
|
53
53
|
|
|
54
54
|
5. **On explicit request only:**
|
|
55
55
|
- Post findings/comments/review outcome.
|
|
56
|
-
- Merge:
|
|
57
|
-
message from PR title/description + fixed issues,
|
|
58
|
-
`commit_message_requirement` for the target project/branch.
|
|
56
|
+
- Merge: the toolkit asks for real interactive approval. For squash merges,
|
|
57
|
+
compose the commit message from PR title/description + fixed issues,
|
|
58
|
+
satisfying `commit_message_requirement` for the target project/branch.
|
|
59
59
|
- Resolving merge conflicts: fetch the target branch, merge with the PR's
|
|
60
60
|
strategy without committing, resolve, verify clean `git status`, commit.
|
|
61
61
|
For rebase-based strategies, ask for direction before rewriting commits.
|
|
62
62
|
|
|
63
|
+
6. **When waiting on updates**, activate `watch` and use `onedev_watch`
|
|
64
|
+
`watch_pull_attention`. It wakes once for a head/state/review/comment/build
|
|
65
|
+
signal change and includes no comment bodies; re-read the PR after it fires.
|
|
66
|
+
|
|
63
67
|
## Notes
|
|
64
68
|
|
|
65
69
|
- Comment text is Markdown; entity refs are `#123`, `project#123`, or `PROJ-123`.
|
|
70
|
+
- Toolkit mutations still open Pi's interactive approval UI. Do not add or invent
|
|
71
|
+
a confirmation parameter.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, resolve } from "node:path";
|
|
4
|
+
import { lock } from "proper-lockfile";
|
|
5
|
+
|
|
6
|
+
interface StoredConfig {
|
|
7
|
+
allowedMutations?: string[];
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function readText(path: string): Promise<string | undefined> {
|
|
12
|
+
try {
|
|
13
|
+
return await readFile(path, "utf8");
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseConfig(text: string | undefined, path: string): StoredConfig {
|
|
21
|
+
if (text === undefined) return {};
|
|
22
|
+
let parsed: unknown;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(text);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`invalid JSON in OneDev toolkit config ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
31
|
+
throw new Error(`OneDev toolkit config is not a JSON object: ${path}`);
|
|
32
|
+
}
|
|
33
|
+
return parsed as StoredConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function allowedMutations(config: StoredConfig): string[] {
|
|
37
|
+
if (!Array.isArray(config.allowedMutations)) return [];
|
|
38
|
+
return config.allowedMutations.filter(
|
|
39
|
+
(value): value is string => typeof value === "string",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readAllowedMutationScopes(path: string): Promise<string[]> {
|
|
44
|
+
return allowedMutations(parseConfig(await readText(path), path));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function removeIfPresent(path: string): Promise<void> {
|
|
48
|
+
try {
|
|
49
|
+
await rm(path);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function saveAllowedMutationScope(
|
|
56
|
+
path: string,
|
|
57
|
+
scope: string,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
60
|
+
let compromised: Error | undefined;
|
|
61
|
+
const release = await lock(path, {
|
|
62
|
+
realpath: false,
|
|
63
|
+
stale: 10_000,
|
|
64
|
+
update: 5_000,
|
|
65
|
+
retries: {
|
|
66
|
+
retries: 200,
|
|
67
|
+
factor: 1.1,
|
|
68
|
+
minTimeout: 10,
|
|
69
|
+
maxTimeout: 100,
|
|
70
|
+
randomize: true,
|
|
71
|
+
},
|
|
72
|
+
onCompromised: (error) => {
|
|
73
|
+
compromised = error;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
const original = await readText(path);
|
|
78
|
+
const config = parseConfig(original, path);
|
|
79
|
+
const allowed = allowedMutations(config);
|
|
80
|
+
if (!allowed.includes(scope)) allowed.push(scope);
|
|
81
|
+
config.allowedMutations = allowed;
|
|
82
|
+
|
|
83
|
+
const temporary = resolve(
|
|
84
|
+
dirname(path),
|
|
85
|
+
`.${basename(path)}.${randomUUID()}.tmp`,
|
|
86
|
+
);
|
|
87
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, {
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
flag: "wx",
|
|
90
|
+
mode: 0o600,
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
if (compromised) throw compromised;
|
|
94
|
+
await rename(temporary, path);
|
|
95
|
+
} finally {
|
|
96
|
+
await removeIfPresent(temporary);
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
try {
|
|
100
|
+
await release();
|
|
101
|
+
} catch {
|
|
102
|
+
// A compromised lock is handled before rename; cleanup failure must not kill Pi.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -1,34 +1,79 @@
|
|
|
1
1
|
import type { CommandExecutor } from "./tod.js";
|
|
2
2
|
import { runTod } from "./tod.js";
|
|
3
3
|
|
|
4
|
+
export type OneDevContextStatus =
|
|
5
|
+
| "ready"
|
|
6
|
+
| "not_configured"
|
|
7
|
+
| "no_project"
|
|
8
|
+
| "auth_failed"
|
|
9
|
+
| "unavailable";
|
|
10
|
+
|
|
4
11
|
export interface OneDevSessionContext {
|
|
5
12
|
readonly cwd: string;
|
|
6
|
-
readonly status:
|
|
13
|
+
readonly status: OneDevContextStatus;
|
|
7
14
|
readonly serverUrl?: string;
|
|
8
|
-
/** Project path inferred from the repository's git remotes
|
|
15
|
+
/** Project path inferred from the repository's git remotes. */
|
|
9
16
|
readonly project?: string;
|
|
10
17
|
readonly loginName?: string;
|
|
11
|
-
/** Human-readable setup guidance when status is "unavailable". */
|
|
12
18
|
readonly problem?: string;
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
export interface ContextRefreshOptions {
|
|
22
|
+
verifyAuth?: boolean;
|
|
23
|
+
force?: boolean;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export const TOD_INSTALL_HINT =
|
|
16
28
|
"tod is not installed. Install it (curl -fsSL https://code.onedev.io/onedev/tod/~raw/main/install.sh | bash), then run `tod config set`.";
|
|
17
29
|
|
|
18
|
-
function
|
|
19
|
-
|
|
30
|
+
function failed(
|
|
31
|
+
cwd: string,
|
|
32
|
+
status: Exclude<OneDevContextStatus, "ready">,
|
|
33
|
+
problem: string,
|
|
34
|
+
serverUrl?: string,
|
|
35
|
+
project?: string,
|
|
36
|
+
): OneDevSessionContext {
|
|
37
|
+
return { cwd, status, problem, serverUrl, project };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function authenticateContext(
|
|
41
|
+
exec: CommandExecutor,
|
|
42
|
+
context: OneDevSessionContext,
|
|
43
|
+
signal?: AbortSignal,
|
|
44
|
+
): Promise<OneDevSessionContext> {
|
|
45
|
+
if (
|
|
46
|
+
context.status !== "ready" ||
|
|
47
|
+
!context.serverUrl ||
|
|
48
|
+
!context.project
|
|
49
|
+
) {
|
|
50
|
+
return context;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const login = await runTod(exec, ["get-login-name"], {
|
|
54
|
+
cwd: context.cwd,
|
|
55
|
+
timeoutMs: 15_000,
|
|
56
|
+
signal,
|
|
57
|
+
maxOutputBytes: 2_000,
|
|
58
|
+
});
|
|
59
|
+
return { ...context, loginName: login.text.trim() || undefined };
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
return failed(
|
|
62
|
+
context.cwd,
|
|
63
|
+
"auth_failed",
|
|
64
|
+
`OneDev rejected the server or access token (${cause instanceof Error ? cause.message : String(cause)}). Run \`tod config set\` to fix credentials.`,
|
|
65
|
+
context.serverUrl,
|
|
66
|
+
context.project,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
20
69
|
}
|
|
21
70
|
|
|
22
|
-
/**
|
|
23
|
-
* Probe the local OneDev setup once per session:
|
|
24
|
-
* 1. `tod config get server-url` — tod present and configured
|
|
25
|
-
* 2. `tod get-login-name` — server reachable and access token accepted
|
|
26
|
-
* 3. `tod project current` — repo-aware project context (optional)
|
|
27
|
-
*/
|
|
71
|
+
/** Probe local configuration/project first; authenticate only on explicit use. */
|
|
28
72
|
export async function probeOneDevContext(
|
|
29
73
|
exec: CommandExecutor,
|
|
30
74
|
cwd: string,
|
|
31
75
|
signal?: AbortSignal,
|
|
76
|
+
verifyAuth = false,
|
|
32
77
|
): Promise<OneDevSessionContext> {
|
|
33
78
|
let serverUrl: string;
|
|
34
79
|
try {
|
|
@@ -41,57 +86,178 @@ export async function probeOneDevContext(
|
|
|
41
86
|
serverUrl = configured.text.trim();
|
|
42
87
|
} catch (cause) {
|
|
43
88
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
44
|
-
return
|
|
89
|
+
return failed(
|
|
45
90
|
cwd,
|
|
91
|
+
"unavailable",
|
|
46
92
|
message.startsWith("tod is not usable") ? TOD_INSTALL_HINT : message,
|
|
47
93
|
);
|
|
48
94
|
}
|
|
49
95
|
if (!serverUrl) {
|
|
50
|
-
return
|
|
96
|
+
return failed(
|
|
51
97
|
cwd,
|
|
52
|
-
"
|
|
98
|
+
"not_configured",
|
|
99
|
+
"tod is installed but not configured. Run `tod config set` for server-url and access-token, or set ONEDEV_SERVER_URL and ONEDEV_ACCESS_TOKEN.",
|
|
53
100
|
);
|
|
54
101
|
}
|
|
55
102
|
|
|
56
|
-
let
|
|
103
|
+
let project: string;
|
|
57
104
|
try {
|
|
58
|
-
const
|
|
105
|
+
const current = await runTod(exec, ["project", "current"], {
|
|
59
106
|
cwd,
|
|
60
|
-
timeoutMs:
|
|
107
|
+
timeoutMs: 10_000,
|
|
61
108
|
signal,
|
|
62
109
|
maxOutputBytes: 2_000,
|
|
63
110
|
});
|
|
64
|
-
|
|
111
|
+
project = current.text.trim();
|
|
65
112
|
} catch (cause) {
|
|
66
|
-
return
|
|
113
|
+
return failed(
|
|
67
114
|
cwd,
|
|
68
|
-
|
|
115
|
+
"no_project",
|
|
116
|
+
`No OneDev project can be inferred from this repository's remotes (${cause instanceof Error ? cause.message : String(cause)}). Run Pi from a OneDev clone or add a OneDev remote.`,
|
|
117
|
+
serverUrl,
|
|
69
118
|
);
|
|
70
119
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
const current = await runTod(exec, ["project", "current"], {
|
|
120
|
+
if (!project) {
|
|
121
|
+
return failed(
|
|
75
122
|
cwd,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
123
|
+
"no_project",
|
|
124
|
+
"No OneDev project can be inferred from this repository's remotes. Run Pi from a OneDev clone or add a OneDev remote.",
|
|
125
|
+
serverUrl,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!verifyAuth) return { cwd, status: "ready", serverUrl, project };
|
|
130
|
+
return authenticateContext(
|
|
131
|
+
exec,
|
|
132
|
+
{ cwd, status: "ready", serverUrl, project },
|
|
133
|
+
signal,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function waitForCaller<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
138
|
+
if (!signal) return promise;
|
|
139
|
+
if (signal.aborted)
|
|
140
|
+
return Promise.reject(
|
|
141
|
+
signal.reason instanceof Error ? signal.reason : new Error("cancelled"),
|
|
142
|
+
);
|
|
143
|
+
return new Promise<T>((resolve, reject) => {
|
|
144
|
+
const abort = (): void =>
|
|
145
|
+
reject(
|
|
146
|
+
signal.reason instanceof Error ? signal.reason : new Error("cancelled"),
|
|
147
|
+
);
|
|
148
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
149
|
+
promise.then(
|
|
150
|
+
(value) => {
|
|
151
|
+
signal.removeEventListener("abort", abort);
|
|
152
|
+
resolve(value);
|
|
153
|
+
},
|
|
154
|
+
(error) => {
|
|
155
|
+
signal.removeEventListener("abort", abort);
|
|
156
|
+
reject(error);
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Coalesces probes while isolating individual caller cancellation. */
|
|
163
|
+
export class OneDevContextManager {
|
|
164
|
+
#cwd: string | undefined;
|
|
165
|
+
#current: OneDevSessionContext | undefined;
|
|
166
|
+
#pending:
|
|
167
|
+
| {
|
|
168
|
+
controller: AbortController;
|
|
169
|
+
promise: Promise<OneDevSessionContext>;
|
|
170
|
+
}
|
|
171
|
+
| undefined;
|
|
172
|
+
|
|
173
|
+
constructor(readonly exec: CommandExecutor) {}
|
|
174
|
+
|
|
175
|
+
current(): OneDevSessionContext {
|
|
176
|
+
if (!this.#current) throw new Error("OneDev extension is still initializing");
|
|
177
|
+
return this.#current;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
reset(cwd: string): void {
|
|
181
|
+
this.#pending?.controller.abort(new Error("OneDev session context reset"));
|
|
182
|
+
this.#pending = undefined;
|
|
183
|
+
this.#current = undefined;
|
|
184
|
+
this.#cwd = cwd;
|
|
83
185
|
}
|
|
84
186
|
|
|
85
|
-
|
|
187
|
+
clear(): void {
|
|
188
|
+
this.#pending?.controller.abort(new Error("OneDev session ended"));
|
|
189
|
+
this.#pending = undefined;
|
|
190
|
+
this.#current = undefined;
|
|
191
|
+
this.#cwd = undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async refresh(
|
|
195
|
+
options: ContextRefreshOptions = {},
|
|
196
|
+
): Promise<OneDevSessionContext> {
|
|
197
|
+
if (!this.#cwd) throw new Error("OneDev extension is still initializing");
|
|
198
|
+
const verifyAuth = options.verifyAuth === true;
|
|
199
|
+
if (
|
|
200
|
+
!options.force &&
|
|
201
|
+
this.#current &&
|
|
202
|
+
(!verifyAuth || this.#current.loginName)
|
|
203
|
+
) {
|
|
204
|
+
return this.#current;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (this.#pending) {
|
|
208
|
+
const context = await waitForCaller(this.#pending.promise, options.signal);
|
|
209
|
+
if (!verifyAuth || context.loginName || context.status !== "ready") {
|
|
210
|
+
return context;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
const cached = this.#current;
|
|
216
|
+
const canAuthenticateCached =
|
|
217
|
+
verifyAuth &&
|
|
218
|
+
!options.force &&
|
|
219
|
+
cached?.status === "ready" &&
|
|
220
|
+
!cached.loginName;
|
|
221
|
+
const promise = (canAuthenticateCached
|
|
222
|
+
? authenticateContext(this.exec, cached, controller.signal)
|
|
223
|
+
: probeOneDevContext(
|
|
224
|
+
this.exec,
|
|
225
|
+
this.#cwd,
|
|
226
|
+
controller.signal,
|
|
227
|
+
verifyAuth,
|
|
228
|
+
)).then((context) => {
|
|
229
|
+
if (!controller.signal.aborted) this.#current = context;
|
|
230
|
+
return context;
|
|
231
|
+
});
|
|
232
|
+
const pending = { controller, promise };
|
|
233
|
+
this.#pending = pending;
|
|
234
|
+
const clearPending = (): void => {
|
|
235
|
+
if (this.#pending === pending) this.#pending = undefined;
|
|
236
|
+
};
|
|
237
|
+
void promise.then(clearPending, clearPending);
|
|
238
|
+
return waitForCaller(promise, options.signal);
|
|
239
|
+
}
|
|
86
240
|
}
|
|
87
241
|
|
|
88
242
|
export function formatContext(context: OneDevSessionContext): string {
|
|
89
|
-
if (context.status
|
|
90
|
-
return `OneDev
|
|
243
|
+
if (context.status !== "ready") {
|
|
244
|
+
return `OneDev ${context.status.replace("_", " ")}: ${context.problem ?? "unknown problem"}`;
|
|
91
245
|
}
|
|
92
|
-
const parts = [`server ${context.serverUrl}`];
|
|
93
|
-
|
|
94
|
-
else parts.push("no OneDev project inferred from git remotes");
|
|
95
|
-
if (context.loginName) parts.push(`user ${context.loginName}`);
|
|
246
|
+
const parts = [`server ${context.serverUrl}`, `project ${context.project}`];
|
|
247
|
+
parts.push(context.loginName ? `user ${context.loginName}` : "auth not checked yet");
|
|
96
248
|
return `OneDev ready: ${parts.join(" · ")}`;
|
|
97
249
|
}
|
|
250
|
+
|
|
251
|
+
export function setupGuidance(context: OneDevSessionContext): string {
|
|
252
|
+
switch (context.status) {
|
|
253
|
+
case "ready":
|
|
254
|
+
return `${formatContext(context)}\nNo setup action is required.`;
|
|
255
|
+
case "unavailable":
|
|
256
|
+
case "not_configured":
|
|
257
|
+
case "no_project":
|
|
258
|
+
case "auth_failed":
|
|
259
|
+
return `${formatContext(context)}\n${context.problem ?? TOD_INSTALL_HINT}`;
|
|
260
|
+
default:
|
|
261
|
+
throw new Error(`unsupported OneDev context status: ${String(context.status)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|