pi-onedev-toolkit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/extension.ts CHANGED
@@ -1,18 +1,34 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import {
3
6
  formatContext,
4
- probeOneDevContext,
7
+ OneDevContextManager,
5
8
  type OneDevSessionContext,
6
9
  } from "./context.js";
10
+ import { MutationApprovalManager } from "./mutation-approvals.js";
11
+ import { OneDevSourceWatchManager } from "./source-watch.js";
7
12
  import {
8
13
  registerOnedevTools,
9
14
  type OnedevToolController,
10
15
  } from "./tools/index.js";
11
16
  import type { ToolDeps } from "./tools/common.js";
12
17
 
18
+ const SETTINGS_ENTRY = "onedev-settings";
19
+
20
+ interface OnedevSettings {
21
+ footerStatus: boolean;
22
+ }
23
+
13
24
  export default function onedevExtension(pi: ExtensionAPI): void {
14
25
  let context: OneDevSessionContext | undefined;
15
26
  let controller: OnedevToolController | undefined;
27
+ let footerStatus = true;
28
+ const contextManager = new OneDevContextManager((command, args, options) =>
29
+ pi.exec(command, args, options),
30
+ );
31
+ const approvals = new MutationApprovalManager();
16
32
 
17
33
  const deps: ToolDeps = {
18
34
  exec: (command, args, options) => pi.exec(command, args, options),
@@ -20,6 +36,11 @@ export default function onedevExtension(pi: ExtensionAPI): void {
20
36
  if (!context) throw new Error("OneDev extension is still initializing");
21
37
  return context;
22
38
  },
39
+ refreshContext: async (options) => {
40
+ context = await contextManager.refresh(options);
41
+ return context;
42
+ },
43
+ approvals,
23
44
  };
24
45
 
25
46
  const applyStatus = (ctx: {
@@ -27,28 +48,43 @@ export default function onedevExtension(pi: ExtensionAPI): void {
27
48
  ui: { setStatus: (key: string, value: string | undefined) => void };
28
49
  }): void => {
29
50
  if (!ctx.hasUI) return;
30
- if (context?.status === "ready" && context.project) {
51
+ if (footerStatus && context?.status === "ready" && context.project) {
31
52
  ctx.ui.setStatus("onedev", `onedev: ${context.project}`);
32
53
  } else {
33
54
  ctx.ui.setStatus("onedev", undefined);
34
55
  }
35
56
  };
36
57
 
58
+ const restoreSettings = (ctx: ExtensionContext): void => {
59
+ footerStatus = true;
60
+ for (const entry of ctx.sessionManager.getBranch()) {
61
+ if (entry.type !== "custom" || entry.customType !== SETTINGS_ENTRY) continue;
62
+ const saved = entry.data as Partial<OnedevSettings> | undefined;
63
+ if (typeof saved?.footerStatus === "boolean") {
64
+ footerStatus = saved.footerStatus;
65
+ }
66
+ }
67
+ };
68
+
37
69
  pi.registerCommand("od-context", {
38
70
  description: "Show the active OneDev server, project, and user",
39
- handler: async (_args, ctx) => {
71
+ handler: (_args, ctx) => {
40
72
  if (!context) throw new Error("OneDev extension is still initializing");
41
73
  ctx.ui.notify(
42
74
  formatContext(context),
43
75
  context.status === "ready" ? "info" : "warning",
44
76
  );
77
+ return Promise.resolve();
45
78
  },
46
79
  });
47
80
 
48
81
  pi.registerCommand("od-health", {
49
82
  description: "Re-check the tod CLI configuration and OneDev server",
50
83
  handler: async (_args, ctx) => {
51
- context = await probeOneDevContext(deps.exec, ctx.cwd);
84
+ context = await contextManager.refresh({
85
+ verifyAuth: true,
86
+ force: true,
87
+ });
52
88
  applyStatus(ctx);
53
89
  ctx.ui.notify(
54
90
  formatContext(context),
@@ -57,16 +93,70 @@ export default function onedevExtension(pi: ExtensionAPI): void {
57
93
  },
58
94
  });
59
95
 
60
- controller = registerOnedevTools(pi, deps);
96
+ pi.registerCommand("od-settings", {
97
+ description: "Configure OneDev TUI settings",
98
+ handler: async (_args, ctx) => {
99
+ if (ctx.mode !== "tui") {
100
+ ctx.ui.notify("/od-settings requires TUI mode", "error");
101
+ return;
102
+ }
103
+ const selected = await ctx.ui.select(
104
+ `OneDev footer status (currently ${footerStatus ? "enabled" : "disabled"})`,
105
+ ["Enabled", "Disabled"],
106
+ );
107
+ if (!selected) return;
108
+ const enabled = selected === "Enabled";
109
+ if (enabled !== footerStatus) {
110
+ pi.appendEntry<OnedevSettings>(SETTINGS_ENTRY, {
111
+ footerStatus: enabled,
112
+ });
113
+ footerStatus = enabled;
114
+ applyStatus(ctx);
115
+ }
116
+ ctx.ui.notify(
117
+ `OneDev footer status ${enabled ? "enabled" : "disabled"}`,
118
+ "info",
119
+ );
120
+ },
121
+ });
122
+
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);
61
138
 
62
139
  pi.on("session_start", async (_event, ctx) => {
140
+ watches.clear();
63
141
  controller?.reset();
64
- context = await probeOneDevContext(deps.exec, ctx.cwd);
142
+ approvals.resetSession();
143
+ restoreSettings(ctx);
144
+ contextManager.reset(ctx.cwd);
145
+ context = await contextManager.refresh();
146
+ applyStatus(ctx);
147
+ });
148
+
149
+ pi.on("session_tree", (_event, ctx) => {
150
+ watches.clear();
151
+ restoreSettings(ctx);
65
152
  applyStatus(ctx);
66
153
  });
67
154
 
68
- pi.on("session_shutdown", async (_event, ctx) => {
155
+ pi.on("session_shutdown", (_event, ctx) => {
69
156
  if (ctx.hasUI) ctx.ui.setStatus("onedev", undefined);
157
+ approvals.resetSession();
158
+ watches.clear();
159
+ contextManager.clear();
70
160
  context = undefined;
71
161
  });
72
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
+ }