pi-onedev-toolkit 0.1.1 → 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.
package/src/extension.ts CHANGED
@@ -4,9 +4,11 @@ import type {
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
6
  formatContext,
7
- probeOneDevContext,
7
+ OneDevContextManager,
8
8
  type OneDevSessionContext,
9
9
  } from "./context.js";
10
+ import { MutationApprovalManager } from "./mutation-approvals.js";
11
+ import { OneDevSourceWatchManager } from "./source-watch.js";
10
12
  import {
11
13
  registerOnedevTools,
12
14
  type OnedevToolController,
@@ -23,6 +25,10 @@ export default function onedevExtension(pi: ExtensionAPI): void {
23
25
  let context: OneDevSessionContext | undefined;
24
26
  let controller: OnedevToolController | undefined;
25
27
  let footerStatus = true;
28
+ const contextManager = new OneDevContextManager((command, args, options) =>
29
+ pi.exec(command, args, options),
30
+ );
31
+ const approvals = new MutationApprovalManager();
26
32
 
27
33
  const deps: ToolDeps = {
28
34
  exec: (command, args, options) => pi.exec(command, args, options),
@@ -30,6 +36,11 @@ export default function onedevExtension(pi: ExtensionAPI): void {
30
36
  if (!context) throw new Error("OneDev extension is still initializing");
31
37
  return context;
32
38
  },
39
+ refreshContext: async (options) => {
40
+ context = await contextManager.refresh(options);
41
+ return context;
42
+ },
43
+ approvals,
33
44
  };
34
45
 
35
46
  const applyStatus = (ctx: {
@@ -57,19 +68,23 @@ export default function onedevExtension(pi: ExtensionAPI): void {
57
68
 
58
69
  pi.registerCommand("od-context", {
59
70
  description: "Show the active OneDev server, project, and user",
60
- handler: async (_args, ctx) => {
71
+ handler: (_args, ctx) => {
61
72
  if (!context) throw new Error("OneDev extension is still initializing");
62
73
  ctx.ui.notify(
63
74
  formatContext(context),
64
75
  context.status === "ready" ? "info" : "warning",
65
76
  );
77
+ return Promise.resolve();
66
78
  },
67
79
  });
68
80
 
69
81
  pi.registerCommand("od-health", {
70
82
  description: "Re-check the tod CLI configuration and OneDev server",
71
83
  handler: async (_args, ctx) => {
72
- context = await probeOneDevContext(deps.exec, ctx.cwd);
84
+ context = await contextManager.refresh({
85
+ verifyAuth: true,
86
+ force: true,
87
+ });
73
88
  applyStatus(ctx);
74
89
  ctx.ui.notify(
75
90
  formatContext(context),
@@ -105,22 +120,43 @@ export default function onedevExtension(pi: ExtensionAPI): void {
105
120
  },
106
121
  });
107
122
 
108
- controller = registerOnedevTools(pi, deps);
123
+ const watches = new OneDevSourceWatchManager(
124
+ { exec: deps.exec, context: deps.context },
125
+ (emission) => {
126
+ pi.sendMessage(
127
+ {
128
+ customType: "onedev-watch",
129
+ content: emission.message,
130
+ display: true,
131
+ details: emission.details,
132
+ },
133
+ { triggerTurn: true, deliverAs: "steer" },
134
+ );
135
+ },
136
+ );
137
+ controller = registerOnedevTools(pi, deps, watches);
109
138
 
110
139
  pi.on("session_start", async (_event, ctx) => {
140
+ watches.clear();
111
141
  controller?.reset();
142
+ approvals.resetSession();
112
143
  restoreSettings(ctx);
113
- context = await probeOneDevContext(deps.exec, ctx.cwd);
144
+ contextManager.reset(ctx.cwd);
145
+ context = await contextManager.refresh();
114
146
  applyStatus(ctx);
115
147
  });
116
148
 
117
- pi.on("session_tree", async (_event, ctx) => {
149
+ pi.on("session_tree", (_event, ctx) => {
150
+ watches.clear();
118
151
  restoreSettings(ctx);
119
152
  applyStatus(ctx);
120
153
  });
121
154
 
122
- pi.on("session_shutdown", async (_event, ctx) => {
155
+ pi.on("session_shutdown", (_event, ctx) => {
123
156
  if (ctx.hasUI) ctx.ui.setStatus("onedev", undefined);
157
+ approvals.resetSession();
158
+ watches.clear();
159
+ contextManager.clear();
124
160
  context = undefined;
125
161
  });
126
162
  }
@@ -0,0 +1,130 @@
1
+ import { join } from "node:path";
2
+ import {
3
+ getAgentDir,
4
+ type ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { OneDevSessionContext } from "./context.js";
7
+ import {
8
+ readAllowedMutationScopes,
9
+ saveAllowedMutationScope,
10
+ } from "./config-storage.js";
11
+
12
+ const ALLOW_ONCE = "Allow once";
13
+ const ALLOW_SESSION = "Allow for this session";
14
+ const ALLOW_SAVED = "Always allow for this server and project";
15
+ const CANCEL = "Cancel";
16
+
17
+ export const MUTATION_OPERATIONS = [
18
+ "issue.create",
19
+ "issue.edit",
20
+ "issue.change_state",
21
+ "issue.add_comment",
22
+ "issue.link",
23
+ "issue.log_work",
24
+ "issue.create_branch",
25
+ "issue.checkout",
26
+ "pull.create",
27
+ "pull.edit",
28
+ "pull.add_comment",
29
+ "pull.add_code_comment",
30
+ "pull.reply_code_comment",
31
+ "pull.resolve_code_comment",
32
+ "pull.unresolve_code_comment",
33
+ "pull.approve",
34
+ "pull.request_changes",
35
+ "pull.merge",
36
+ "pull.discard",
37
+ "pull.checkout",
38
+ "build.run",
39
+ "build.check_spec",
40
+ ] as const;
41
+
42
+ export type MutationOperation = (typeof MUTATION_OPERATIONS)[number];
43
+
44
+ export interface MutationApprovalRequest {
45
+ operation: MutationOperation;
46
+ title: string;
47
+ details: string;
48
+ signal?: AbortSignal;
49
+ }
50
+
51
+ function scopeKey(
52
+ context: OneDevSessionContext,
53
+ operation: MutationOperation,
54
+ ): string {
55
+ return JSON.stringify([context.serverUrl, context.project, operation]);
56
+ }
57
+
58
+ export class MutationApprovalManager {
59
+ readonly #session = new Set<string>();
60
+
61
+ constructor(
62
+ readonly configPath = join(getAgentDir(), "onedev-toolkit.json"),
63
+ ) {}
64
+
65
+ resetSession(): void {
66
+ this.#session.clear();
67
+ }
68
+
69
+ async confirm(
70
+ context: OneDevSessionContext,
71
+ ctx: ExtensionContext,
72
+ request: MutationApprovalRequest,
73
+ ): Promise<void> {
74
+ if (request.signal?.aborted) {
75
+ throw request.signal.reason instanceof Error
76
+ ? request.signal.reason
77
+ : new Error("OneDev mutation cancelled");
78
+ }
79
+ if (context.status !== "ready" || !context.serverUrl || !context.project) {
80
+ throw new Error("OneDev mutation requires a ready server and project context");
81
+ }
82
+
83
+ const scope = scopeKey(context, request.operation);
84
+ if (this.#session.has(scope)) return;
85
+ try {
86
+ const savedScopes = await readAllowedMutationScopes(this.configPath);
87
+ if (savedScopes.includes(scope)) return;
88
+ } catch (error) {
89
+ if (ctx.hasUI) {
90
+ ctx.ui.notify(
91
+ `Could not read saved OneDev approvals: ${error instanceof Error ? error.message : String(error)}`,
92
+ "warning",
93
+ );
94
+ }
95
+ }
96
+
97
+ if (!ctx.hasUI) {
98
+ throw new Error(
99
+ `${request.operation} requires interactive confirmation; run it in TUI mode or save the scoped approval there first`,
100
+ );
101
+ }
102
+ const prompt = [
103
+ request.title,
104
+ request.details,
105
+ `Server: ${context.serverUrl}`,
106
+ `Project: ${context.project}`,
107
+ ].join("\n");
108
+ const choice = await ctx.ui.select(
109
+ prompt,
110
+ [ALLOW_ONCE, ALLOW_SESSION, ALLOW_SAVED, CANCEL],
111
+ { signal: request.signal },
112
+ );
113
+ if (!choice || choice === CANCEL) {
114
+ throw new Error(`${request.operation} cancelled by user`);
115
+ }
116
+ if (choice === ALLOW_ONCE) return;
117
+
118
+ this.#session.add(scope);
119
+ if (choice === ALLOW_SAVED) {
120
+ try {
121
+ await saveAllowedMutationScope(this.configPath, scope);
122
+ } catch (error) {
123
+ ctx.ui.notify(
124
+ `Approval applies to this session only; it could not be saved: ${error instanceof Error ? error.message : String(error)}`,
125
+ "warning",
126
+ );
127
+ }
128
+ }
129
+ }
130
+ }