@openboard/start 1.0.20 → 1.0.22
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/README.md +2 -4
- package/bin/openboard.js +23 -0
- package/package.json +1 -1
- package/packages/client/README.md +30 -0
- package/packages/client/dist/assets/index-B5k_YB6Y.css +0 -0
- package/packages/client/dist/assets/{index-B5qL8ybM.js → index-D6WEx7Lr.js} +17 -17
- package/packages/client/dist/index.html +1 -1
- package/packages/server/dist/agents/agent-queue.js +0 -0
- package/packages/server/dist/agents/agent-runner.js +0 -0
- package/packages/server/dist/agents/agent.interface.js +0 -0
- package/packages/server/dist/agents/codereview.agent.js +8 -22
- package/packages/server/dist/agents/dummy.agent.js +0 -0
- package/packages/server/dist/agents/opencode.agent.js +31 -66
- package/packages/server/dist/agents/opencode.events.js +12 -54
- package/packages/server/dist/db/database.js +0 -0
- package/packages/server/dist/gh-worker.js +0 -0
- package/packages/server/dist/index.js +0 -0
- package/packages/server/dist/repositories/board.repository.js +20 -4
- package/packages/server/dist/repositories/column-config.repository.js +0 -0
- package/packages/server/dist/repositories/column.repository.js +0 -0
- package/packages/server/dist/repositories/comment.repository.js +0 -0
- package/packages/server/dist/repositories/ticket.repository.js +0 -0
- package/packages/server/dist/routes/boards.router.js +4 -4
- package/packages/server/dist/routes/column-config.router.js +0 -0
- package/packages/server/dist/routes/columns.router.js +0 -0
- package/packages/server/dist/routes/tickets.router.js +0 -0
- package/packages/server/dist/sse.js +0 -0
- package/packages/server/dist/types.js +0 -0
- package/packages/server/dist/utils/opencode.js +11 -0
- package/packages/server/dist/utils/os.js +73 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<title>OpenBoard</title>
|
|
7
7
|
<meta name="description" content="A local project management board — create boards, columns, and tickets." />
|
|
8
8
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-D6WEx7Lr.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-B5k_YB6Y.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -1,27 +1,10 @@
|
|
|
1
|
-
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
2
1
|
import { ticketRepository } from '../repositories/ticket.repository.js';
|
|
2
|
+
import { boardRepository } from '../repositories/board.repository.js';
|
|
3
3
|
import { commentRepository } from '../repositories/comment.repository.js';
|
|
4
4
|
import { setupOpencodeEventListener } from './opencode.events.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
const execAsync = promisify(exec);
|
|
8
|
-
const execFileAsync = promisify(execFile);
|
|
9
|
-
// Helper function to execute commands robustly on Windows
|
|
10
|
-
async function runCmd(cmd, args, cwd) {
|
|
11
|
-
console.log(`[codereview-agent] Running: ${cmd} ${args.join(' ')} in cwd: ${cwd}`);
|
|
12
|
-
try {
|
|
13
|
-
return await execFileAsync(cmd, args, { cwd });
|
|
14
|
-
}
|
|
15
|
-
catch (e) {
|
|
16
|
-
if (e.code === 'ENOENT') {
|
|
17
|
-
console.log(`[codereview-agent] ENOENT finding binary. Trying fallback exec...`);
|
|
18
|
-
return await execAsync(`${cmd} ${args.join(' ')}`, { cwd });
|
|
19
|
-
}
|
|
20
|
-
throw e;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
5
|
+
import { createBoardScopedClient } from '../utils/opencode.js';
|
|
6
|
+
import { runCmd, normalizePathForOS } from '../utils/os.js';
|
|
23
7
|
const opencodePort = process.env.OPENCODE_PORT || 4096;
|
|
24
|
-
const opencodeClient = createOpencodeClient({ baseUrl: `http://127.0.0.1:${opencodePort}` });
|
|
25
8
|
const activeSessions = {};
|
|
26
9
|
export class CodeReviewAgent {
|
|
27
10
|
async run(ticket, config) {
|
|
@@ -46,6 +29,9 @@ export class CodeReviewAgent {
|
|
|
46
29
|
});
|
|
47
30
|
return;
|
|
48
31
|
}
|
|
32
|
+
// Create a board-scoped Opencode client for this ticket
|
|
33
|
+
const board = boardRepository.findById(ticket.board_id);
|
|
34
|
+
const opencodeClient = createBoardScopedClient(board?.path);
|
|
49
35
|
ticketRepository.updateAgentSession(ticket.id, {
|
|
50
36
|
column_id: ticket.column_id,
|
|
51
37
|
agent_type: 'code_review',
|
|
@@ -62,7 +48,7 @@ export class CodeReviewAgent {
|
|
|
62
48
|
delete activeSessions[ticket.id];
|
|
63
49
|
}
|
|
64
50
|
// Resolve workspace path — code review runs in the main workspace (no new worktree needed).
|
|
65
|
-
let workspacePath = process.cwd();
|
|
51
|
+
let workspacePath = normalizePathForOS(board?.path || process.cwd());
|
|
66
52
|
try {
|
|
67
53
|
const session = await opencodeClient.session.create({
|
|
68
54
|
body: { title: `Code Review for Ticket: ${ticket.title}` },
|
|
@@ -91,7 +77,7 @@ export class CodeReviewAgent {
|
|
|
91
77
|
// Fetch GH token so the LLM environment can execute `gh` commands
|
|
92
78
|
let ghTokenEnv = '';
|
|
93
79
|
try {
|
|
94
|
-
const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], workspacePath);
|
|
80
|
+
const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], workspacePath, 'codereview-agent');
|
|
95
81
|
if (ghToken.trim()) {
|
|
96
82
|
ghTokenEnv = `export GH_TOKEN=${ghToken.trim()}; `;
|
|
97
83
|
}
|
|
File without changes
|
|
@@ -1,54 +1,12 @@
|
|
|
1
|
-
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
2
1
|
import { ticketRepository } from '../repositories/ticket.repository.js';
|
|
2
|
+
import { boardRepository } from '../repositories/board.repository.js';
|
|
3
3
|
import { commentRepository } from '../repositories/comment.repository.js';
|
|
4
4
|
import { setupOpencodeEventListener } from './opencode.events.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import os from 'os';
|
|
5
|
+
import { createBoardScopedClient } from '../utils/opencode.js';
|
|
6
|
+
import { runCmd, normalizePathForOS } from '../utils/os.js';
|
|
8
7
|
import path from 'path';
|
|
9
8
|
import fs from 'fs';
|
|
10
|
-
const execAsync = promisify(exec);
|
|
11
|
-
const execFileAsync = promisify(execFile);
|
|
12
|
-
// Cache the gh token so we only fetch it once per server process
|
|
13
|
-
let cachedGhToken = null;
|
|
14
|
-
async function getGhToken(cwd) {
|
|
15
|
-
if (cachedGhToken !== null)
|
|
16
|
-
return cachedGhToken;
|
|
17
|
-
try {
|
|
18
|
-
const { stdout } = await execFileAsync('gh', ['auth', 'token'], { cwd });
|
|
19
|
-
cachedGhToken = stdout.trim() || null;
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
cachedGhToken = null;
|
|
23
|
-
}
|
|
24
|
-
return cachedGhToken;
|
|
25
|
-
}
|
|
26
|
-
// Helper function to execute commands robustly on Windows.
|
|
27
|
-
// Automatically injects GH_TOKEN for any `gh` subcommand.
|
|
28
|
-
async function runCmd(cmd, args, cwd) {
|
|
29
|
-
console.log(`[opencode-agent] Running: ${cmd} ${args.join(' ')} in cwd: ${cwd}`);
|
|
30
|
-
let extraEnv = {};
|
|
31
|
-
if (cmd === 'gh') {
|
|
32
|
-
const token = await getGhToken(cwd);
|
|
33
|
-
if (token)
|
|
34
|
-
extraEnv['GH_TOKEN'] = token;
|
|
35
|
-
}
|
|
36
|
-
const env = Object.keys(extraEnv).length > 0 ? { ...process.env, ...extraEnv } : undefined;
|
|
37
|
-
try {
|
|
38
|
-
return await execFileAsync(cmd, args, { cwd, ...(env && { env }) });
|
|
39
|
-
}
|
|
40
|
-
catch (e) {
|
|
41
|
-
if (e.code === 'ENOENT') {
|
|
42
|
-
console.log(`[opencode-agent] ENOENT finding binary. Trying fallback exec...`);
|
|
43
|
-
const envPrefix = extraEnv['GH_TOKEN'] ? (process.platform === 'win32' ? `set GH_TOKEN=${extraEnv['GH_TOKEN']}&& ` : `GH_TOKEN=${extraEnv['GH_TOKEN']} `) : '';
|
|
44
|
-
return await execAsync(`${envPrefix}${cmd} ${args.join(' ')}`, { cwd });
|
|
45
|
-
}
|
|
46
|
-
throw e;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
// Central OpenCode client connecting to the user's running OpenCode server
|
|
50
9
|
const opencodePort = process.env.OPENCODE_PORT || 4096;
|
|
51
|
-
const opencodeClient = createOpencodeClient({ baseUrl: `http://127.0.0.1:${opencodePort}` });
|
|
52
10
|
// Track active session IDs by ticket ID to prevent/cancel overlaps
|
|
53
11
|
const activeSessions = {};
|
|
54
12
|
export class OpencodeAgent {
|
|
@@ -61,20 +19,12 @@ export class OpencodeAgent {
|
|
|
61
19
|
status: 'processing',
|
|
62
20
|
port: Number(opencodePort)
|
|
63
21
|
});
|
|
22
|
+
// Create a board-scoped Opencode client for this ticket
|
|
23
|
+
const board = boardRepository.findById(ticket.board_id);
|
|
24
|
+
const opencodeClient = createBoardScopedClient(board?.path);
|
|
64
25
|
// Prevent duplicate runs blocking new requests: replace the old session.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
// Ignore typescript error if abort typing differs slightly in SDK version
|
|
69
|
-
await opencodeClient.session.abort({ path: { id: activeSessions[ticket.id] } });
|
|
70
|
-
}
|
|
71
|
-
catch (e) {
|
|
72
|
-
console.error(`[opencode-agent] Error aborting previous session:`, e);
|
|
73
|
-
}
|
|
74
|
-
delete activeSessions[ticket.id];
|
|
75
|
-
}
|
|
76
|
-
// 1. Find Worktree (Use the directory where openboard was started)
|
|
77
|
-
let originalWorkspacePath = process.cwd();
|
|
26
|
+
// 1. Find Worktree (Use the board's designated path)
|
|
27
|
+
let originalWorkspacePath = normalizePathForOS(board?.path || process.cwd());
|
|
78
28
|
// 2. Set up Worktree
|
|
79
29
|
// If this ticket already has a PR, we reuse the existing branch and worktree.
|
|
80
30
|
// Worktree paths are derived from branch name so they are stable across re-runs.
|
|
@@ -87,7 +37,7 @@ export class OpencodeAgent {
|
|
|
87
37
|
// Ticket was already worked on — reuse the existing branch & worktree
|
|
88
38
|
console.log(`[opencode-agent] Found existing PR ${existingPrUrl}. Fetching branch name.`);
|
|
89
39
|
try {
|
|
90
|
-
const { stdout: prDataStr } = await runCmd('gh', ['pr', 'view', existingPrUrl, '--json', 'headRefName'], originalWorkspacePath);
|
|
40
|
+
const { stdout: prDataStr } = await runCmd('gh', ['pr', 'view', existingPrUrl, '--json', 'headRefName'], originalWorkspacePath, 'opencode-agent');
|
|
91
41
|
const prData = JSON.parse(prDataStr);
|
|
92
42
|
if (!prData.headRefName)
|
|
93
43
|
throw new Error('Could not parse headRefName from PR');
|
|
@@ -97,13 +47,13 @@ export class OpencodeAgent {
|
|
|
97
47
|
console.warn(`[opencode-agent] Could not read PR branch name, will create a new branch.`, ghErr.message);
|
|
98
48
|
branchName = `ticket-${ticket.id}-${Date.now()}`;
|
|
99
49
|
}
|
|
100
|
-
worktreePath = path.join(os.tmpdir(), 'openboard-worktrees', branchName);
|
|
101
50
|
}
|
|
102
51
|
else {
|
|
103
52
|
// Fresh ticket — create a new branch and worktree
|
|
104
53
|
branchName = `ticket-${ticket.id}-${Date.now()}`;
|
|
105
|
-
worktreePath = path.join(os.tmpdir(), 'openboard-worktrees', branchName);
|
|
106
54
|
}
|
|
55
|
+
// Use a local folder within the board path for worktrees
|
|
56
|
+
worktreePath = normalizePathForOS(path.join(originalWorkspacePath, '.openboard-worktrees', branchName));
|
|
107
57
|
try {
|
|
108
58
|
if (fs.existsSync(worktreePath)) {
|
|
109
59
|
// Worktree directory already on disk — just reuse it, no git command needed
|
|
@@ -112,12 +62,27 @@ export class OpencodeAgent {
|
|
|
112
62
|
else if (existingPrUrl) {
|
|
113
63
|
// Branch exists but worktree was cleaned up — check out the branch into the path
|
|
114
64
|
console.log(`[opencode-agent] Checking out existing branch ${branchName} into new worktree at ${worktreePath}`);
|
|
115
|
-
await runCmd('git', ['worktree', 'add', worktreePath, branchName], originalWorkspacePath);
|
|
65
|
+
await runCmd('git', ['worktree', 'add', worktreePath, branchName], originalWorkspacePath, 'opencode-agent');
|
|
116
66
|
}
|
|
117
67
|
else {
|
|
118
|
-
// Completely new —
|
|
119
|
-
|
|
120
|
-
|
|
68
|
+
// Completely new — check if the repo is empty (no commits yet)
|
|
69
|
+
let isRepoEmpty = false;
|
|
70
|
+
try {
|
|
71
|
+
await runCmd('git', ['rev-parse', 'HEAD'], originalWorkspacePath, 'opencode-agent');
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
// if rev-parse HEAD fails, it usually means the repo has no commits
|
|
75
|
+
isRepoEmpty = true;
|
|
76
|
+
console.log(`[opencode-agent] Repository appears to be empty. Using --orphan for worktree.`);
|
|
77
|
+
}
|
|
78
|
+
if (isRepoEmpty) {
|
|
79
|
+
console.log(`[opencode-agent] Creating new orphan worktree at ${worktreePath} on branch ${branchName}`);
|
|
80
|
+
await runCmd('git', ['worktree', 'add', '--orphan', '-b', branchName, worktreePath], originalWorkspacePath, 'opencode-agent');
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log(`[opencode-agent] Creating new worktree at ${worktreePath} on branch ${branchName}`);
|
|
84
|
+
await runCmd('git', ['worktree', 'add', '-b', branchName, worktreePath], originalWorkspacePath, 'opencode-agent');
|
|
85
|
+
}
|
|
121
86
|
}
|
|
122
87
|
}
|
|
123
88
|
catch (e) {
|
|
@@ -171,7 +136,7 @@ export class OpencodeAgent {
|
|
|
171
136
|
// Fetch GH token so the LLM environment can execute `gh` commands
|
|
172
137
|
let ghTokenEnv = '';
|
|
173
138
|
try {
|
|
174
|
-
const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], originalWorkspacePath);
|
|
139
|
+
const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], originalWorkspacePath, 'opencode-agent');
|
|
175
140
|
if (ghToken.trim()) {
|
|
176
141
|
ghTokenEnv = `export GH_TOKEN=${ghToken.trim()}; `;
|
|
177
142
|
}
|
|
@@ -1,53 +1,11 @@
|
|
|
1
1
|
import { ticketRepository } from '../repositories/ticket.repository.js';
|
|
2
2
|
import { commentRepository } from '../repositories/comment.repository.js';
|
|
3
3
|
import { agentQueue } from './agent-queue.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
// Cache the gh token so we only fetch it once per server process
|
|
10
|
-
let cachedGhToken = null;
|
|
11
|
-
async function getGhToken(cwd) {
|
|
12
|
-
if (cachedGhToken !== null)
|
|
13
|
-
return cachedGhToken;
|
|
14
|
-
try {
|
|
15
|
-
const { stdout } = await execFileAsync('gh', ['auth', 'token'], { cwd, shell: true });
|
|
16
|
-
cachedGhToken = stdout.trim() || null;
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
cachedGhToken = null;
|
|
20
|
-
}
|
|
21
|
-
return cachedGhToken;
|
|
22
|
-
}
|
|
23
|
-
// Helper function to execute commands robustly on Windows.
|
|
24
|
-
// Automatically injects GH_TOKEN for any `gh` subcommand.
|
|
25
|
-
async function runCmd(cmd, args, cwd) {
|
|
26
|
-
console.log(`[opencode-events] Running: ${cmd} ${args.join(' ')} in cwd: ${cwd}`);
|
|
27
|
-
// Safety check for CWD to avoid obscure ENOENT shell errors
|
|
28
|
-
if (!fs.existsSync(cwd)) {
|
|
29
|
-
throw new Error(`Directory does not exist: ${cwd}`);
|
|
30
|
-
}
|
|
31
|
-
let extraEnv = {};
|
|
32
|
-
if (cmd === 'gh') {
|
|
33
|
-
const token = await getGhToken(cwd);
|
|
34
|
-
if (token)
|
|
35
|
-
extraEnv['GH_TOKEN'] = token;
|
|
36
|
-
}
|
|
37
|
-
const env = Object.keys(extraEnv).length > 0 ? { ...process.env, ...extraEnv } : undefined;
|
|
38
|
-
try {
|
|
39
|
-
return await execFileAsync(cmd, args, { cwd, shell: true, ...(env && { env }) });
|
|
40
|
-
}
|
|
41
|
-
catch (e) {
|
|
42
|
-
if (e.code === 'ENOENT') {
|
|
43
|
-
console.log(`[opencode-events] ENOENT with shell: true. Trying fallback exec...`);
|
|
44
|
-
const envPrefix = extraEnv['GH_TOKEN'] ? `GH_TOKEN=${extraEnv['GH_TOKEN']} ` : '';
|
|
45
|
-
return await execAsync(`${envPrefix}${cmd} ${args.join(' ')}`, { cwd });
|
|
46
|
-
}
|
|
47
|
-
throw e;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
export async function setupOpencodeEventListener(events, opencodeClient, sessionID, ticket, agentUrl, config, activeSessions, worktreePath, originalWorkspacePath, branchName, agentType = 'opencode') {
|
|
4
|
+
import { runCmd, getGhToken } from '../utils/os.js';
|
|
5
|
+
import { createBoardScopedClient } from '../utils/opencode.js';
|
|
6
|
+
export async function setupOpencodeEventListener(events, _unused, // kept for signature compatibility if called elsewhere
|
|
7
|
+
sessionID, ticket, agentUrl, config, activeSessions, worktreePath, originalWorkspacePath, branchName, agentType = 'opencode') {
|
|
8
|
+
const opencodeClient = createBoardScopedClient(originalWorkspacePath);
|
|
51
9
|
const processedMessages = new Set();
|
|
52
10
|
const activeParts = new Map();
|
|
53
11
|
let rawSessionCost = 0;
|
|
@@ -200,7 +158,7 @@ export async function setupOpencodeEventListener(events, opencodeClient, session
|
|
|
200
158
|
const prUrlSession = [...(latestTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
|
|
201
159
|
const prUrl = prUrlSession?.pr_url;
|
|
202
160
|
if (prUrl) {
|
|
203
|
-
const { stdout: prStatus } = await runCmd('gh', ['pr', 'view', prUrl, '--json', 'comments'], worktreePath);
|
|
161
|
+
const { stdout: prStatus } = await runCmd('gh', ['pr', 'view', prUrl, '--json', 'comments'], worktreePath, 'opencode-events');
|
|
204
162
|
const comments = JSON.parse(prStatus).comments;
|
|
205
163
|
// Find the latest comment that contains a decision
|
|
206
164
|
let reviewDecision = 'NONE';
|
|
@@ -275,11 +233,11 @@ export async function setupOpencodeEventListener(events, opencodeClient, session
|
|
|
275
233
|
// Commit, push, and create PR before moving ticket
|
|
276
234
|
try {
|
|
277
235
|
console.log(`[opencode-agent] Checking for changes in worktree ${worktreePath}`);
|
|
278
|
-
const { stdout: statusOut } = await runCmd('git', ['status', '--porcelain'], worktreePath);
|
|
236
|
+
const { stdout: statusOut } = await runCmd('git', ['status', '--porcelain'], worktreePath, 'opencode-events');
|
|
279
237
|
if (statusOut.trim()) {
|
|
280
238
|
console.log(`[opencode-agent] Changes found for ticket ${ticket.id}. Committing and pushing.`);
|
|
281
|
-
await runCmd('git', ['add', '.'], worktreePath);
|
|
282
|
-
await runCmd('git', ['commit', '-m', `"${ticket.title.replace(/"/g, '\\"')}"`], worktreePath);
|
|
239
|
+
await runCmd('git', ['add', '.'], worktreePath, 'opencode-events');
|
|
240
|
+
await runCmd('git', ['commit', '-m', `"${ticket.title.replace(/"/g, '\\"')}"`], worktreePath, 'opencode-events');
|
|
283
241
|
// Inject GH_TOKEN into remote URL for git push authentication
|
|
284
242
|
const token = await getGhToken(worktreePath);
|
|
285
243
|
if (token) {
|
|
@@ -288,14 +246,14 @@ export async function setupOpencodeEventListener(events, opencodeClient, session
|
|
|
288
246
|
const remoteUrl = remoteUrlOut.trim();
|
|
289
247
|
if (remoteUrl.startsWith('https://github.com/')) {
|
|
290
248
|
const authedUrl = remoteUrl.replace('https://github.com/', `https://x-access-token:${token}@github.com/`);
|
|
291
|
-
await runCmd('git', ['remote', 'set-url', 'origin', authedUrl], worktreePath);
|
|
249
|
+
await runCmd('git', ['remote', 'set-url', 'origin', authedUrl], worktreePath, 'opencode-events');
|
|
292
250
|
}
|
|
293
251
|
}
|
|
294
252
|
catch (urlErr) {
|
|
295
253
|
console.warn(`[opencode-agent] Could not set authenticated remote URL:`, urlErr);
|
|
296
254
|
}
|
|
297
255
|
}
|
|
298
|
-
await runCmd('git', ['push', '-u', 'origin', branchName], worktreePath);
|
|
256
|
+
await runCmd('git', ['push', '-u', 'origin', branchName], worktreePath, 'opencode-events');
|
|
299
257
|
// Check if a PR already exists for this ticket (fetch fresh from DB)
|
|
300
258
|
const freshTicket = ticketRepository.findById(ticket.id) || ticket;
|
|
301
259
|
const existingPrUrlSession = [...(freshTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
|
|
@@ -304,7 +262,7 @@ export async function setupOpencodeEventListener(events, opencodeClient, session
|
|
|
304
262
|
updateSessionComment(`🚀 **Pull Request Updated**\n\nThe agent has updated the existing PR:\n${prUrl}${rawSessionCost > 0 ? `\n\n**Total Cost:** $${rawSessionCost.toFixed(4)}` : ''}`, 'pr');
|
|
305
263
|
}
|
|
306
264
|
else {
|
|
307
|
-
const { stdout: prOut } = await runCmd('gh', ['pr', 'create', '--title', `"${ticket.title.replace(/"/g, '\\"')}"`, '--body', `"Automated PR from OpenCode Agent for ticket #${ticket.id}"`], worktreePath);
|
|
265
|
+
const { stdout: prOut } = await runCmd('gh', ['pr', 'create', '--title', `"${ticket.title.replace(/"/g, '\\"')}"`, '--body', `"Automated PR from OpenCode Agent for ticket #${ticket.id}"`], worktreePath, 'opencode-events');
|
|
308
266
|
prUrl = prOut.trim();
|
|
309
267
|
updateSessionComment(`🚀 **Pull Request Created**\n\nThe agent has proposed the following changes in a PR. Check it out:\n${prUrl}${rawSessionCost > 0 ? `\n\n**Total Cost:** $${rawSessionCost.toFixed(4)}` : ''}`, 'pr');
|
|
310
268
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -23,11 +23,22 @@ export const boardRepository = {
|
|
|
23
23
|
workspaces
|
|
24
24
|
};
|
|
25
25
|
},
|
|
26
|
-
|
|
26
|
+
findByPath(path) {
|
|
27
|
+
const db = getDb();
|
|
28
|
+
const board = db.prepare('SELECT * FROM boards WHERE path = ?').get(path);
|
|
29
|
+
if (!board)
|
|
30
|
+
return undefined;
|
|
31
|
+
const workspaces = db.prepare('SELECT * FROM board_workspaces WHERE board_id = ?').all(board.id);
|
|
32
|
+
return {
|
|
33
|
+
...board,
|
|
34
|
+
workspaces
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
create(name, path, workspaces = []) {
|
|
27
38
|
const boardId = randomUUID();
|
|
28
39
|
const db = getDb();
|
|
29
40
|
db.transaction(() => {
|
|
30
|
-
db.prepare('INSERT INTO boards (id, name) VALUES (?, ?)').run(boardId, name);
|
|
41
|
+
db.prepare('INSERT INTO boards (id, name, path) VALUES (?, ?, ?)').run(boardId, name, path || null);
|
|
31
42
|
for (const ws of workspaces) {
|
|
32
43
|
db.prepare('INSERT INTO board_workspaces (id, board_id, type, path) VALUES (?, ?, ?, ?)')
|
|
33
44
|
.run(randomUUID(), boardId, ws.type, ws.path);
|
|
@@ -35,10 +46,15 @@ export const boardRepository = {
|
|
|
35
46
|
})();
|
|
36
47
|
return this.findById(boardId);
|
|
37
48
|
},
|
|
38
|
-
update(id, name, workspaces) {
|
|
49
|
+
update(id, name, path, workspaces) {
|
|
39
50
|
const db = getDb();
|
|
40
51
|
db.transaction(() => {
|
|
41
|
-
|
|
52
|
+
if (path !== undefined) {
|
|
53
|
+
db.prepare('UPDATE boards SET name = ?, path = ? WHERE id = ?').run(name, path, id);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
db.prepare('UPDATE boards SET name = ? WHERE id = ?').run(name, id);
|
|
57
|
+
}
|
|
42
58
|
if (workspaces) {
|
|
43
59
|
// Simple sync: delete all and re-create
|
|
44
60
|
db.prepare('DELETE FROM board_workspaces WHERE board_id = ?').run(id);
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -6,18 +6,18 @@ router.get('/', (_req, res) => {
|
|
|
6
6
|
res.json(boardRepository.findAll());
|
|
7
7
|
});
|
|
8
8
|
router.post('/', (req, res) => {
|
|
9
|
-
const { name, workspaces } = req.body;
|
|
9
|
+
const { name, path, workspaces } = req.body;
|
|
10
10
|
if (!name?.trim()) {
|
|
11
11
|
res.status(400).json({ error: 'Name is required' });
|
|
12
12
|
return;
|
|
13
13
|
}
|
|
14
|
-
const board = boardRepository.create(name.trim(), workspaces);
|
|
14
|
+
const board = boardRepository.create(name.trim(), path, workspaces);
|
|
15
15
|
sseManager.emitGlobal('board:created', board);
|
|
16
16
|
res.status(201).json(board);
|
|
17
17
|
});
|
|
18
18
|
router.patch('/:id', (req, res) => {
|
|
19
|
-
const { name, workspaces } = req.body;
|
|
20
|
-
const board = boardRepository.update(req.params.id, name?.trim(), workspaces);
|
|
19
|
+
const { name, path, workspaces } = req.body;
|
|
20
|
+
const board = boardRepository.update(req.params.id, name?.trim(), path, workspaces);
|
|
21
21
|
if (!board) {
|
|
22
22
|
res.status(404).json({ error: 'Board not found' });
|
|
23
23
|
return;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
2
|
+
const opencodePort = process.env.OPENCODE_PORT || 4096;
|
|
3
|
+
/**
|
|
4
|
+
* Creates an Opencode client instance scoped to a specific board directory.
|
|
5
|
+
*/
|
|
6
|
+
export function createBoardScopedClient(boardPath) {
|
|
7
|
+
return createOpencodeClient({
|
|
8
|
+
baseUrl: `http://127.0.0.1:${opencodePort}`,
|
|
9
|
+
directory: boardPath
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile, exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
/**
|
|
7
|
+
* Normalizes paths for mixed environments (e.g. WSL-style paths in a Windows process).
|
|
8
|
+
*/
|
|
9
|
+
export function normalizePathForOS(p) {
|
|
10
|
+
if (!p)
|
|
11
|
+
return p;
|
|
12
|
+
let normalized = p;
|
|
13
|
+
if (process.platform === 'win32') {
|
|
14
|
+
// Convert all forward slashes to backslashes first for consistency on Windows
|
|
15
|
+
normalized = normalized.replace(/\//g, '\\');
|
|
16
|
+
// Match \mnt\c\... or /mnt/c/... (case insensitive)
|
|
17
|
+
const mntMatch = normalized.match(/^[\\\/]mnt[\\\/]([a-z])([\\\/]|$)/i);
|
|
18
|
+
if (mntMatch) {
|
|
19
|
+
const drive = mntMatch[1].toUpperCase();
|
|
20
|
+
// Remove the /mnt/c/ prefix
|
|
21
|
+
const rest = normalized.substring(mntMatch[0].length);
|
|
22
|
+
normalized = `${drive}:\\${rest}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
normalized = normalized.replace(/\\/g, '/');
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
// Cache the gh token so we only fetch it once per server process
|
|
31
|
+
let cachedGhToken = null;
|
|
32
|
+
export async function getGhToken(cwd) {
|
|
33
|
+
if (cachedGhToken !== null)
|
|
34
|
+
return cachedGhToken;
|
|
35
|
+
try {
|
|
36
|
+
const { stdout } = await execFileAsync('gh', ['auth', 'token'], { cwd: normalizePathForOS(cwd), shell: true });
|
|
37
|
+
cachedGhToken = stdout.trim() || null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
cachedGhToken = null;
|
|
41
|
+
}
|
|
42
|
+
return cachedGhToken;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Helper function to execute commands robustly across OS.
|
|
46
|
+
* Automatically injects GH_TOKEN for any `gh` subcommand and handles shell execution on Windows.
|
|
47
|
+
*/
|
|
48
|
+
export async function runCmd(cmd, args, cwd, prefix = '') {
|
|
49
|
+
const normalizedCwd = normalizePathForOS(cwd);
|
|
50
|
+
console.log(`[${prefix || 'os-util'}] Running: ${cmd} ${args.join(' ')} in cwd: ${normalizedCwd}`);
|
|
51
|
+
// Safety check for CWD
|
|
52
|
+
if (!fs.existsSync(normalizedCwd)) {
|
|
53
|
+
throw new Error(`Directory does not exist: ${normalizedCwd}`);
|
|
54
|
+
}
|
|
55
|
+
let extraEnv = {};
|
|
56
|
+
if (cmd === 'gh') {
|
|
57
|
+
const token = await getGhToken(normalizedCwd);
|
|
58
|
+
if (token)
|
|
59
|
+
extraEnv['GH_TOKEN'] = token;
|
|
60
|
+
}
|
|
61
|
+
const env = { ...process.env, ...extraEnv };
|
|
62
|
+
try {
|
|
63
|
+
// use shell: true to help find binaries in PATH on Windows
|
|
64
|
+
return await execFileAsync(cmd, args, { cwd: normalizedCwd, env, shell: true });
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
if (e.code === 'ENOENT') {
|
|
68
|
+
const envPrefix = extraEnv['GH_TOKEN'] ? (process.platform === 'win32' ? `set GH_TOKEN=${extraEnv['GH_TOKEN']}&& ` : `GH_TOKEN=${extraEnv['GH_TOKEN']} `) : '';
|
|
69
|
+
return await execAsync(`${envPrefix}${cmd} ${args.join(' ')}`, { cwd: normalizedCwd });
|
|
70
|
+
}
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
}
|