@dfsramos/foreman 0.0.0-stage → 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 +23 -0
- package/LICENSE +21 -0
- package/README.md +385 -2
- package/dist/agent/adapters/agy.js +96 -0
- package/dist/agent/adapters/claude.js +152 -0
- package/dist/agent/adapters/codex.js +107 -0
- package/dist/agent/adapters/opencode.js +91 -0
- package/dist/agent/config.js +169 -0
- package/dist/agent/process.js +230 -0
- package/dist/agent/prompt.js +184 -0
- package/dist/agent/registry.js +57 -0
- package/dist/agent/types.js +12 -0
- package/dist/cli-args.js +70 -0
- package/dist/cli.js +184 -0
- package/dist/config.js +102 -0
- package/dist/daemon.js +109 -0
- package/dist/db.js +467 -0
- package/dist/git/taskstore.js +224 -0
- package/dist/git/worktree.js +248 -0
- package/dist/imports/connections.js +57 -0
- package/dist/imports/destination.js +2 -0
- package/dist/imports/linear.js +40 -0
- package/dist/imports/registry.js +4 -0
- package/dist/imports/types.js +1 -0
- package/dist/kaneo/client.js +47 -0
- package/dist/kaneo/issues.js +138 -0
- package/dist/kaneo/workspace.js +24 -0
- package/dist/linear/browse.js +55 -0
- package/dist/linear/client.js +48 -0
- package/dist/linear/issues.js +99 -0
- package/dist/linear/workspace.js +30 -0
- package/dist/paths.js +12 -0
- package/dist/pidfile.js +35 -0
- package/dist/projects.js +211 -0
- package/dist/review/decide.js +186 -0
- package/dist/review/loop.js +114 -0
- package/dist/runner.js +1281 -0
- package/dist/server/app.js +344 -0
- package/dist/server/imports.js +182 -0
- package/dist/server/tasks.js +94 -0
- package/dist/sources/branch.js +17 -0
- package/dist/sources/git-import.js +85 -0
- package/dist/sources/git.js +431 -0
- package/dist/sources/kaneo.js +156 -0
- package/dist/sources/lease.js +38 -0
- package/dist/sources/linear.js +186 -0
- package/dist/sources/options.js +26 -0
- package/dist/sources/registry.js +6 -0
- package/dist/sources/taskfile.js +210 -0
- package/dist/sources/types.js +1 -0
- package/package.json +63 -4
- package/public/agents.js +435 -0
- package/public/app.js +1200 -0
- package/public/imports.js +481 -0
- package/public/index.html +19 -0
- package/public/projects.js +481 -0
- package/public/style.css +1614 -0
- package/public/tasks.js +279 -0
- package/public/ui.js +165 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
export class GitError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'GitError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const COMMAND_TIMEOUT_MS = 120_000;
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
/** Runs a command with git's prompts turned off and a timeout; the task store shares it. */
|
|
14
|
+
export const defaultExec = (file, args, options) => execFileAsync(file, args, {
|
|
15
|
+
...options,
|
|
16
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
17
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
18
|
+
// Fail instead of waiting on a credential prompt nobody will answer.
|
|
19
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
20
|
+
});
|
|
21
|
+
/** One state for a set of checks: any failure or cancellation fails it; else anything pending; else any pass; else none. */
|
|
22
|
+
export function summariseChecks(checks) {
|
|
23
|
+
if (checks.some((check) => check.bucket === 'fail' || check.bucket === 'cancel'))
|
|
24
|
+
return 'failing';
|
|
25
|
+
if (checks.some((check) => check.bucket === 'pending'))
|
|
26
|
+
return 'pending';
|
|
27
|
+
return checks.some((check) => check.bucket === 'pass') ? 'passing' : 'none';
|
|
28
|
+
}
|
|
29
|
+
const CHECK_FIELDS = 'name,state,bucket,link,workflow';
|
|
30
|
+
const LOG_EXCERPT_LIMIT = 4000;
|
|
31
|
+
/** gh prefixes each line of a run's log with the job, the step and a timestamp. */
|
|
32
|
+
const LOG_PREFIX = /^[^\t]*\t[^\t]*\t\S+Z ?/;
|
|
33
|
+
/** The part of a failed run's log that shows why: the lines up to its last error, or its end when there is none. */
|
|
34
|
+
function excerptLog(raw) {
|
|
35
|
+
const lines = raw.split('\n').map((line) => line.replace(LOG_PREFIX, ''));
|
|
36
|
+
const lastError = lines.findLastIndex((line) => line.includes('##[error]'));
|
|
37
|
+
const upTo = lastError >= 0 ? lines.slice(0, lastError + 1) : lines;
|
|
38
|
+
const kept = [];
|
|
39
|
+
let size = 0;
|
|
40
|
+
for (const line of upTo.reverse()) {
|
|
41
|
+
if (size + line.length + 1 > LOG_EXCERPT_LIMIT)
|
|
42
|
+
break;
|
|
43
|
+
kept.unshift(line);
|
|
44
|
+
size += line.length + 1;
|
|
45
|
+
}
|
|
46
|
+
return kept.join('\n').trim();
|
|
47
|
+
}
|
|
48
|
+
function describeFailure(file, args, error) {
|
|
49
|
+
const stderr = error.stderr?.trim();
|
|
50
|
+
const detail = stderr || (error instanceof Error ? error.message : String(error));
|
|
51
|
+
return `${file} ${args.join(' ')} failed: ${detail}`;
|
|
52
|
+
}
|
|
53
|
+
export function createGitApi(exec = defaultExec) {
|
|
54
|
+
const run = async (file, args, options) => {
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await exec(file, args, options);
|
|
57
|
+
return stdout.trim();
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new GitError(describeFailure(file, args, error));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
async fetch(repoPath, signal) {
|
|
65
|
+
await run('git', ['-C', repoPath, 'fetch', 'origin', '--prune'], { signal });
|
|
66
|
+
},
|
|
67
|
+
async defaultBaseRef(repoPath, signal) {
|
|
68
|
+
try {
|
|
69
|
+
return await run('git', ['-C', repoPath, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { signal });
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw new GitError(`Could not determine the default branch of ${repoPath}; run "git remote set-head origin --auto" there. ` +
|
|
73
|
+
error.message);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
async createWorktree({ repoPath, worktreeRoot, identifier, branch, baseRef, signal }) {
|
|
77
|
+
const worktreePath = path.join(worktreeRoot, identifier);
|
|
78
|
+
if (fs.existsSync(worktreePath)) {
|
|
79
|
+
throw new GitError(`worktree already exists at ${worktreePath}; remove it to retry`);
|
|
80
|
+
}
|
|
81
|
+
const branchExists = await exec('git', ['-C', repoPath, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { signal }).then(() => true, () => false);
|
|
82
|
+
if (branchExists) {
|
|
83
|
+
throw new GitError(`branch ${branch} already exists; delete it to retry`);
|
|
84
|
+
}
|
|
85
|
+
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
86
|
+
// --no-track: otherwise the branch's upstream is the base branch and a bare "git push" targets it.
|
|
87
|
+
await run('git', ['-C', repoPath, 'worktree', 'add', '--no-track', '-b', branch, worktreePath, baseRef], { signal });
|
|
88
|
+
return worktreePath;
|
|
89
|
+
},
|
|
90
|
+
async removeWorktree(repoPath, worktreePath) {
|
|
91
|
+
// No --force: files the agent left uncommitted are kept rather than silently discarded.
|
|
92
|
+
await run('git', ['-C', repoPath, 'worktree', 'remove', worktreePath]);
|
|
93
|
+
},
|
|
94
|
+
async deleteBranchAt(repoPath, branch, ref) {
|
|
95
|
+
const commit = await run('git', ['-C', repoPath, 'rev-parse', '--verify', `${ref}^{commit}`]);
|
|
96
|
+
// update-ref with the old value deletes atomically, and refuses when the branch has moved on.
|
|
97
|
+
await run('git', ['-C', repoPath, 'update-ref', '-d', `refs/heads/${branch}`, commit]);
|
|
98
|
+
},
|
|
99
|
+
async checkoutWorktree({ repoPath, worktreeRoot, identifier, branch, signal }) {
|
|
100
|
+
const worktreePath = path.join(worktreeRoot, identifier);
|
|
101
|
+
if (fs.existsSync(worktreePath)) {
|
|
102
|
+
// A round that failed leaves its worktree behind. It is reused when that loses nothing: clean, on this
|
|
103
|
+
// branch, and not diverged from what was pushed (commits it has not pushed yet are kept). Otherwise it is left for a person to look at.
|
|
104
|
+
const refuse = (why) => new GitError(`worktree already exists at ${worktreePath} and ${why}; remove it to retry`);
|
|
105
|
+
const current = await run('git', ['-C', worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], { signal }).catch(() => null);
|
|
106
|
+
if (current !== branch)
|
|
107
|
+
throw refuse(current ? `is on ${current}` : 'is not a worktree');
|
|
108
|
+
if (await run('git', ['-C', worktreePath, 'status', '--porcelain'], { signal }))
|
|
109
|
+
throw refuse('holds modified or untracked files');
|
|
110
|
+
await run('git', ['-C', worktreePath, 'merge', '--ff-only', `origin/${branch}`], { signal }).catch(() => {
|
|
111
|
+
throw refuse('has diverged from what was pushed');
|
|
112
|
+
});
|
|
113
|
+
return worktreePath;
|
|
114
|
+
}
|
|
115
|
+
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
116
|
+
// -B: the local branch, if one is left from the first run, is reset to what was pushed.
|
|
117
|
+
await run('git', ['-C', repoPath, 'worktree', 'add', '-B', branch, worktreePath, `origin/${branch}`], { signal });
|
|
118
|
+
return worktreePath;
|
|
119
|
+
},
|
|
120
|
+
async pullRequest(repoPath, branch, signal) {
|
|
121
|
+
const json = async (args) => {
|
|
122
|
+
const stdout = await run('gh', args, { cwd: repoPath, signal });
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(stdout);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new GitError(`gh ${args.slice(0, 2).join(' ')} returned unexpected output: ${stdout.slice(0, 200)}`);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const found = await json([
|
|
131
|
+
'pr', 'list', '--head', branch, '--state', 'all', '--json', 'number,url,state,createdAt,author', '--limit', '20',
|
|
132
|
+
]);
|
|
133
|
+
const pr = found.find((candidate) => candidate.state === 'OPEN') ?? [...found].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
|
|
134
|
+
if (!pr)
|
|
135
|
+
return null;
|
|
136
|
+
const view = await json(['pr', 'view', String(pr.number), '--json', 'reviews,comments,mergeable,baseRefName,headRefOid']);
|
|
137
|
+
// Comments on lines of the diff are not part of "pr view".
|
|
138
|
+
const inline = await json([
|
|
139
|
+
'api', `repos/{owner}/{repo}/pulls/${pr.number}/comments`, '--paginate',
|
|
140
|
+
]);
|
|
141
|
+
const feedback = [
|
|
142
|
+
...view.reviews.map((review) => ({
|
|
143
|
+
id: review.id,
|
|
144
|
+
kind: 'review',
|
|
145
|
+
author: review.author?.login ?? 'unknown',
|
|
146
|
+
body: review.body,
|
|
147
|
+
state: review.state,
|
|
148
|
+
createdAt: review.submittedAt,
|
|
149
|
+
})),
|
|
150
|
+
...view.comments.map((comment) => ({
|
|
151
|
+
id: comment.id,
|
|
152
|
+
kind: 'comment',
|
|
153
|
+
author: comment.author?.login ?? 'unknown',
|
|
154
|
+
body: comment.body,
|
|
155
|
+
createdAt: comment.createdAt,
|
|
156
|
+
})),
|
|
157
|
+
...inline.map((comment) => ({
|
|
158
|
+
id: String(comment.id),
|
|
159
|
+
kind: 'inline',
|
|
160
|
+
author: comment.user?.login ?? 'unknown',
|
|
161
|
+
body: comment.body,
|
|
162
|
+
path: comment.path,
|
|
163
|
+
line: comment.line,
|
|
164
|
+
createdAt: comment.created_at,
|
|
165
|
+
})),
|
|
166
|
+
].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
167
|
+
return {
|
|
168
|
+
number: pr.number,
|
|
169
|
+
url: pr.url,
|
|
170
|
+
state: pr.state,
|
|
171
|
+
author: pr.author?.login ?? 'unknown',
|
|
172
|
+
mergeable: view.mergeable === 'MERGEABLE' || view.mergeable === 'CONFLICTING' ? view.mergeable : 'UNKNOWN',
|
|
173
|
+
baseBranch: view.baseRefName ?? '',
|
|
174
|
+
headSha: view.headRefOid ?? '',
|
|
175
|
+
feedback,
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
async requestReview(repoPath, prNumber, logins, signal) {
|
|
179
|
+
const result = { requested: [], failed: [] };
|
|
180
|
+
for (const login of logins) {
|
|
181
|
+
// gh asks Copilot for a review through a special name, not its bot login.
|
|
182
|
+
const reviewer = /^copilot-pull-request-reviewer(\[bot\])?$/i.test(login) ? '@copilot' : login.replace(/\[bot\]$/, '');
|
|
183
|
+
try {
|
|
184
|
+
await run('gh', ['pr', 'edit', String(prNumber), '--add-reviewer', reviewer], { cwd: repoPath, signal });
|
|
185
|
+
result.requested.push(login);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
result.failed.push({ login, reason: error instanceof Error ? error.message : String(error) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
},
|
|
193
|
+
async findOpenPr(worktreePath, branch, signal) {
|
|
194
|
+
const stdout = await run('gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'url', '--limit', '1'], { cwd: worktreePath, signal });
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(stdout);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
throw new GitError(`gh pr list returned unexpected output: ${stdout.slice(0, 200)}`);
|
|
201
|
+
}
|
|
202
|
+
const first = Array.isArray(parsed) ? parsed[0] : undefined;
|
|
203
|
+
return typeof first?.url === 'string' ? first.url : null;
|
|
204
|
+
},
|
|
205
|
+
async ciStatus(repoPath, prNumber, signal) {
|
|
206
|
+
// gh exits 1 both when nothing is required and when there are no checks at all; neither is a failure here.
|
|
207
|
+
const checks = async (required) => {
|
|
208
|
+
const args = ['pr', 'checks', String(prNumber), ...(required ? ['--required'] : []), '--json', CHECK_FIELDS];
|
|
209
|
+
try {
|
|
210
|
+
return await run('gh', args, { cwd: repoPath, signal });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error instanceof GitError && /no (required )?checks reported/.test(error.message))
|
|
214
|
+
return null;
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const stdout = (await checks(true)) ?? (await checks(false));
|
|
219
|
+
if (stdout === null)
|
|
220
|
+
return { state: 'none', checks: [] };
|
|
221
|
+
let parsed;
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(stdout);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
throw new GitError(`gh pr checks returned unexpected output: ${stdout.slice(0, 200)}`);
|
|
227
|
+
}
|
|
228
|
+
const list = parsed.map((check) => {
|
|
229
|
+
const run = /\/actions\/runs\/(\d+)/.exec(check.link);
|
|
230
|
+
return { name: check.name, bucket: check.bucket, state: check.state, link: check.link, runId: run ? Number(run[1]) : null };
|
|
231
|
+
});
|
|
232
|
+
return { state: summariseChecks(list), checks: list };
|
|
233
|
+
},
|
|
234
|
+
async failedLog(repoPath, runId, signal) {
|
|
235
|
+
try {
|
|
236
|
+
return excerptLog(await run('gh', ['run', 'view', String(runId), '--log-failed'], { cwd: repoPath, signal }));
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (signal?.aborted)
|
|
240
|
+
throw error;
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
async rerunFailed(repoPath, runId, signal) {
|
|
245
|
+
await run('gh', ['run', 'rerun', String(runId), '--failed'], { cwd: repoPath, signal });
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { IMPORT_TYPES } from './registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Settings from the console with the stored secrets filled in where the input left them empty.
|
|
4
|
+
* Unknown keys are dropped; use `validateConnection` to be told about them.
|
|
5
|
+
*/
|
|
6
|
+
export function resolveSettings(type, raw, existing) {
|
|
7
|
+
const given = typeof raw === 'object' && raw !== null ? raw : {};
|
|
8
|
+
const settings = {};
|
|
9
|
+
for (const field of type.fields) {
|
|
10
|
+
const value = typeof given[field.key] === 'string' ? given[field.key].trim() : '';
|
|
11
|
+
settings[field.key] = value === '' && field.type === 'secret' && existing?.type === type.id ? (existing.settings[field.key] ?? '') : value;
|
|
12
|
+
}
|
|
13
|
+
return settings;
|
|
14
|
+
}
|
|
15
|
+
/** Turns console input into a storable connection, or says what is wrong with it field by field. */
|
|
16
|
+
export function validateConnection(input, { existing, others, types = IMPORT_TYPES }) {
|
|
17
|
+
const errors = {};
|
|
18
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input))
|
|
19
|
+
return { errors: { _: 'Request body must be a JSON object' } };
|
|
20
|
+
const body = input;
|
|
21
|
+
const type = types.find((candidate) => candidate.id === body.type);
|
|
22
|
+
if (!type)
|
|
23
|
+
errors.type = 'is not a known import source';
|
|
24
|
+
const rawSettings = typeof body.settings === 'object' && body.settings !== null ? body.settings : {};
|
|
25
|
+
const settings = type ? resolveSettings(type, rawSettings, existing) : {};
|
|
26
|
+
for (const field of type?.fields ?? []) {
|
|
27
|
+
if (rawSettings[field.key] !== undefined && typeof rawSettings[field.key] !== 'string')
|
|
28
|
+
errors[`settings.${field.key}`] = 'must be text';
|
|
29
|
+
else if (settings[field.key] === '' && field.required)
|
|
30
|
+
errors[`settings.${field.key}`] = 'is required';
|
|
31
|
+
}
|
|
32
|
+
for (const key of Object.keys(rawSettings)) {
|
|
33
|
+
if (type && !type.fields.some((field) => field.key === key))
|
|
34
|
+
errors[`settings.${key}`] = 'is not a setting of this source';
|
|
35
|
+
}
|
|
36
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
37
|
+
if (name === '')
|
|
38
|
+
errors.name = 'is required';
|
|
39
|
+
else if (name.length > 80)
|
|
40
|
+
errors.name = 'must be at most 80 characters';
|
|
41
|
+
else if (others.some((other) => other.name.toLowerCase() === name.toLowerCase()))
|
|
42
|
+
errors.name = 'is already used by another connection';
|
|
43
|
+
if (Object.keys(errors).length > 0)
|
|
44
|
+
return { errors };
|
|
45
|
+
return { connection: { name, type: type.id, settings } };
|
|
46
|
+
}
|
|
47
|
+
/** A connection as the console may see it: every secret replaced by whether it is set. */
|
|
48
|
+
export function maskConnection(connection, types = IMPORT_TYPES) {
|
|
49
|
+
const type = types.find((candidate) => candidate.id === connection.type);
|
|
50
|
+
const settings = {};
|
|
51
|
+
// Settings without a known field are dropped rather than risk exposing a secret of a removed type.
|
|
52
|
+
for (const field of type?.fields ?? []) {
|
|
53
|
+
const value = connection.settings[field.key] ?? '';
|
|
54
|
+
settings[field.key] = field.type === 'secret' ? { set: value !== '' } : value;
|
|
55
|
+
}
|
|
56
|
+
return { ...connection, settings, summary: type?.summarise(connection.settings) ?? connection.type };
|
|
57
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { listIssues } from '../linear/browse.js';
|
|
2
|
+
import { createLinearClient } from '../linear/client.js';
|
|
3
|
+
import { fetchWorkspace } from '../linear/workspace.js';
|
|
4
|
+
/** Turns a workspace listing into options for the team, project and state filters. */
|
|
5
|
+
export function importOptions(teams) {
|
|
6
|
+
const states = [];
|
|
7
|
+
for (const team of teams) {
|
|
8
|
+
for (const name of team.states) {
|
|
9
|
+
const existing = states.find((option) => option.value === name);
|
|
10
|
+
if (existing)
|
|
11
|
+
existing.for.push(team.id);
|
|
12
|
+
else
|
|
13
|
+
states.push({ value: name, label: name, for: [team.id] });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
teamId: teams.map((team) => ({ value: team.id, label: `${team.name} (${team.key})` })),
|
|
18
|
+
projectId: teams.flatMap((team) => team.projects.map((project) => ({ value: project.id, label: project.name, for: [team.id] }))),
|
|
19
|
+
states,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export const linearImportType = {
|
|
23
|
+
id: 'linear',
|
|
24
|
+
name: 'Linear',
|
|
25
|
+
fields: [{ key: 'apiKey', label: 'API key', type: 'secret', required: true, help: 'A Linear personal API key. It is only used to read.' }],
|
|
26
|
+
list: (settings, filter) => listIssues(createLinearClient(settings.apiKey ?? ''), filter),
|
|
27
|
+
async lookup(settings) {
|
|
28
|
+
return importOptions(await fetchWorkspace(createLinearClient(settings.apiKey ?? '')));
|
|
29
|
+
},
|
|
30
|
+
async check(settings) {
|
|
31
|
+
try {
|
|
32
|
+
const teams = await fetchWorkspace(createLinearClient(settings.apiKey ?? ''));
|
|
33
|
+
return { ok: true, message: `Connected. ${teams.length === 1 ? '1 team is' : `${teams.length} teams are`} visible.` };
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
summarise: () => 'Linear · read-only',
|
|
40
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
2
|
+
export class KaneoError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
constructor(message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'KaneoError';
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function createKaneoClient(baseUrl, apiKey, fetchImpl = fetch) {
|
|
11
|
+
const root = `${baseUrl.trim().replace(/\/+$/, '')}/api`;
|
|
12
|
+
return {
|
|
13
|
+
async request(method, path, body) {
|
|
14
|
+
let response;
|
|
15
|
+
try {
|
|
16
|
+
response = await fetchImpl(`${root}${path}`, {
|
|
17
|
+
method,
|
|
18
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
19
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
20
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
25
|
+
throw new KaneoError(`Kaneo API unreachable: ${reason}`);
|
|
26
|
+
}
|
|
27
|
+
if (response.status === 429) {
|
|
28
|
+
// X-RateLimit-Reset is a Unix time in seconds.
|
|
29
|
+
const reset = Number(response.headers.get('X-RateLimit-Reset'));
|
|
30
|
+
const when = Number.isFinite(reset) && reset > 0 ? `; the limit resets at ${new Date(reset * 1000).toISOString()}` : '';
|
|
31
|
+
throw new KaneoError(`Kaneo API rate limit reached${when}`, 429);
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = await response.json();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
parsed = undefined;
|
|
39
|
+
}
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const message = parsed?.message;
|
|
42
|
+
throw new KaneoError(`Kaneo API returned HTTP ${response.status}${typeof message === 'string' ? `: ${message}` : ''}`, response.status);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { branchNameOf } from '../sources/branch.js';
|
|
2
|
+
import { KaneoError } from './client.js';
|
|
3
|
+
const PAGE_SIZE = 100;
|
|
4
|
+
/** Bounds a listing against a page count that never ends; far outside normal use. */
|
|
5
|
+
const MAX_PAGES = 100;
|
|
6
|
+
const LABEL_COLOUR = '#6366f1';
|
|
7
|
+
const PRIORITY = { urgent: 1, high: 2, medium: 3, low: 4 };
|
|
8
|
+
const KANEO_PRIORITIES = ['no-priority', 'urgent', 'high', 'medium', 'low'];
|
|
9
|
+
/** The line an imported task ends with; it is what lets a later import see that the task is already here. */
|
|
10
|
+
const FOOTER = /^Imported from (\S+) · (\S+)\s*$/m;
|
|
11
|
+
// "No priority" (0) sorts after "low" (4).
|
|
12
|
+
const priorityRank = (priority) => (priority === 0 ? 5 : priority);
|
|
13
|
+
const same = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
14
|
+
export function createIssueApi(client, where) {
|
|
15
|
+
const projectId = encodeURIComponent(where.projectId);
|
|
16
|
+
const baseUrl = where.baseUrl.trim().replace(/\/+$/, '');
|
|
17
|
+
let identity;
|
|
18
|
+
const columns = () => client.request('GET', `/column/${projectId}`);
|
|
19
|
+
/** The project's key (`FS`) and its workspace's slug, which make up a task's identifier and address. */
|
|
20
|
+
const projectIdentity = () => {
|
|
21
|
+
identity ??= (async () => {
|
|
22
|
+
const project = await client.request('GET', `/project/${projectId}`);
|
|
23
|
+
const workspace = await client.request('GET', `/workspace/${encodeURIComponent(project.workspaceId)}`);
|
|
24
|
+
return { key: project.slug, workspaceSlug: workspace.slug, workspaceId: project.workspaceId };
|
|
25
|
+
})();
|
|
26
|
+
// A failed lookup must not stay cached.
|
|
27
|
+
identity.catch(() => {
|
|
28
|
+
identity = undefined;
|
|
29
|
+
});
|
|
30
|
+
return identity;
|
|
31
|
+
};
|
|
32
|
+
/** Every task a listing returns, across pages. */
|
|
33
|
+
const listBoard = async (filter) => {
|
|
34
|
+
const found = [];
|
|
35
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
36
|
+
const query = new URLSearchParams({ ...filter, limit: String(PAGE_SIZE), page: String(page) });
|
|
37
|
+
const board = await client.request('GET', `/task/tasks/${projectId}?${query.toString()}`);
|
|
38
|
+
for (const column of board.data.columns)
|
|
39
|
+
found.push(...column.tasks);
|
|
40
|
+
if (page >= board.pagination.totalPages)
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
};
|
|
45
|
+
/** The id of the workspace label with this name, creating it when only per-task copies exist. */
|
|
46
|
+
const labelId = async (workspaceId, name) => {
|
|
47
|
+
const all = await client.request('GET', `/label/workspace/${encodeURIComponent(workspaceId)}`);
|
|
48
|
+
// Attaching a label copies it to the task, so the copies carry a task id and are not the label.
|
|
49
|
+
const existing = all.find((label) => !label.taskId && same(label.name, name));
|
|
50
|
+
if (existing)
|
|
51
|
+
return existing.id;
|
|
52
|
+
return (await client.request('POST', '/label', { name, color: LABEL_COLOUR, workspaceId })).id;
|
|
53
|
+
};
|
|
54
|
+
const findColumnSlugs = async (names) => {
|
|
55
|
+
const all = await columns();
|
|
56
|
+
const slugs = {};
|
|
57
|
+
const missing = [];
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const slug = all.find((column) => same(column.name, name))?.slug;
|
|
60
|
+
if (slug)
|
|
61
|
+
slugs[name] = slug;
|
|
62
|
+
else
|
|
63
|
+
missing.push(name);
|
|
64
|
+
}
|
|
65
|
+
if (missing.length > 0) {
|
|
66
|
+
const quoted = [...new Set(missing)].map((name) => `"${name}"`).join(', ');
|
|
67
|
+
throw new KaneoError(`Column not found: ${quoted}. Available columns: ${all.map((column) => column.name).join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
return slugs;
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
findColumnSlugs,
|
|
73
|
+
async fetchInState(columnName, label) {
|
|
74
|
+
const wanted = label?.trim() ?? '';
|
|
75
|
+
const slug = (await findColumnSlugs([columnName]))[columnName];
|
|
76
|
+
// Kaneo returns the whole board; with a status filter only the requested column holds tasks.
|
|
77
|
+
const found = await listBoard({ status: slug });
|
|
78
|
+
const matching = found.filter((item) => wanted === '' || item.labels.some((l) => same(l.name, wanted)));
|
|
79
|
+
if (matching.length === 0)
|
|
80
|
+
return [];
|
|
81
|
+
const { key, workspaceSlug } = await projectIdentity();
|
|
82
|
+
return matching
|
|
83
|
+
.map((item) => {
|
|
84
|
+
const identifier = `${key}-${item.number}`;
|
|
85
|
+
return {
|
|
86
|
+
id: item.id,
|
|
87
|
+
identifier,
|
|
88
|
+
title: item.title,
|
|
89
|
+
description: item.description?.trim() ? item.description : null,
|
|
90
|
+
url: `${baseUrl}/${workspaceSlug}/task/${identifier}`,
|
|
91
|
+
priority: PRIORITY[item.priority] ?? 0,
|
|
92
|
+
createdAt: item.createdAt,
|
|
93
|
+
branchName: branchNameOf(identifier, item.title),
|
|
94
|
+
};
|
|
95
|
+
})
|
|
96
|
+
.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.createdAt.localeCompare(b.createdAt));
|
|
97
|
+
},
|
|
98
|
+
async moveTask(taskId, statusSlug) {
|
|
99
|
+
await client.request('PUT', `/task/status/${encodeURIComponent(taskId)}`, { status: statusSlug });
|
|
100
|
+
},
|
|
101
|
+
async addComment(taskId, body) {
|
|
102
|
+
await client.request('POST', `/comment/${encodeURIComponent(taskId)}`, { content: body });
|
|
103
|
+
},
|
|
104
|
+
async createTask(input) {
|
|
105
|
+
// Resolved first so that a missing column is reported before anything is created.
|
|
106
|
+
const status = (await findColumnSlugs([input.columnName]))[input.columnName];
|
|
107
|
+
const { key, workspaceId } = await projectIdentity();
|
|
108
|
+
const created = await client.request('POST', `/task/${projectId}`, {
|
|
109
|
+
title: input.title,
|
|
110
|
+
description: input.description,
|
|
111
|
+
priority: KANEO_PRIORITIES[input.priority] ?? 'no-priority',
|
|
112
|
+
status,
|
|
113
|
+
});
|
|
114
|
+
const wanted = input.labelName?.trim();
|
|
115
|
+
if (wanted) {
|
|
116
|
+
await client.request('PUT', `/label/${encodeURIComponent(await labelId(workspaceId, wanted))}/task`, { taskId: created.id });
|
|
117
|
+
}
|
|
118
|
+
if (input.link) {
|
|
119
|
+
// The footer in the description is what de-duplication reads; the link is a convenience.
|
|
120
|
+
await client.request('POST', `/external-link/task/${encodeURIComponent(created.id)}`, input.link).catch(() => undefined);
|
|
121
|
+
}
|
|
122
|
+
return { id: created.id, identifier: `${key}-${created.number}` };
|
|
123
|
+
},
|
|
124
|
+
async importedFrom() {
|
|
125
|
+
const found = new Map();
|
|
126
|
+
const held = (await listBoard({})).flatMap((item) => {
|
|
127
|
+
const footer = item.description ? FOOTER.exec(item.description) : null;
|
|
128
|
+
return footer ? [{ item, url: footer[2] }] : [];
|
|
129
|
+
});
|
|
130
|
+
if (held.length === 0)
|
|
131
|
+
return found;
|
|
132
|
+
const { key } = await projectIdentity();
|
|
133
|
+
for (const { item, url } of held)
|
|
134
|
+
found.set(url, `${key}-${item.number}`);
|
|
135
|
+
return found;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The projects an API key can see, in every workspace it belongs to, each with its columns and labels. Read-only. */
|
|
2
|
+
export async function fetchWorkspaces(client) {
|
|
3
|
+
const workspaces = await client.request('GET', '/workspace');
|
|
4
|
+
const result = [];
|
|
5
|
+
for (const workspace of workspaces) {
|
|
6
|
+
const id = encodeURIComponent(workspace.id);
|
|
7
|
+
const projects = await client.request('GET', `/project?workspaceId=${id}`);
|
|
8
|
+
// Attaching a label copies it for the task, so a name can occur many times.
|
|
9
|
+
const labelRows = await client.request('GET', `/label/workspace/${id}`);
|
|
10
|
+
const labels = [...new Set(labelRows.map((label) => label.name))].sort((a, b) => a.localeCompare(b));
|
|
11
|
+
for (const project of projects) {
|
|
12
|
+
const columns = await client.request('GET', `/column/${encodeURIComponent(project.id)}`);
|
|
13
|
+
result.push({
|
|
14
|
+
id: project.id,
|
|
15
|
+
name: project.name,
|
|
16
|
+
identifier: project.slug,
|
|
17
|
+
workspace: workspace.name,
|
|
18
|
+
states: [...columns].sort((a, b) => a.position - b.position).map((column) => column.name),
|
|
19
|
+
labels,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return result.sort((a, b) => a.workspace.localeCompare(b.workspace) || a.name.localeCompare(b.name));
|
|
24
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Bounds a listing against a runaway cursor; 2000 matching issues is far outside normal use.
|
|
2
|
+
const MAX_PAGES = 20;
|
|
3
|
+
// Only conditions that were asked for are part of the query, so nothing is sent as "any".
|
|
4
|
+
const issuesQuery = (filter) => `
|
|
5
|
+
query BrowseIssues($teamId: ID!, $after: String${filter.projectId ? ', $projectId: ID!' : ''}${filter.states?.length ? ', $states: [String!]' : ''}) {
|
|
6
|
+
issues(
|
|
7
|
+
first: 100
|
|
8
|
+
after: $after
|
|
9
|
+
filter: {
|
|
10
|
+
team: { id: { eq: $teamId } }${filter.projectId ? '\n project: { id: { eq: $projectId } }' : ''}${filter.states?.length ? '\n state: { name: { in: $states } }' : ''}
|
|
11
|
+
}
|
|
12
|
+
) {
|
|
13
|
+
nodes {
|
|
14
|
+
id identifier title description url priority createdAt
|
|
15
|
+
state { name }
|
|
16
|
+
labels { nodes { name } }
|
|
17
|
+
assignee { name }
|
|
18
|
+
}
|
|
19
|
+
pageInfo { hasNextPage endCursor }
|
|
20
|
+
}
|
|
21
|
+
}`;
|
|
22
|
+
// "No priority" (0) sorts after "low" (4).
|
|
23
|
+
const priorityRank = (priority) => (priority === 0 ? 5 : priority);
|
|
24
|
+
/** Issues matching the filter, most urgent first. A read-only query; nothing in Linear changes. */
|
|
25
|
+
export async function listIssues(client, filter) {
|
|
26
|
+
const query = issuesQuery(filter);
|
|
27
|
+
const tasks = [];
|
|
28
|
+
let after = null;
|
|
29
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
30
|
+
const data = await client.request(query, {
|
|
31
|
+
teamId: filter.teamId,
|
|
32
|
+
...(filter.projectId ? { projectId: filter.projectId } : {}),
|
|
33
|
+
...(filter.states?.length ? { states: filter.states } : {}),
|
|
34
|
+
after,
|
|
35
|
+
});
|
|
36
|
+
for (const node of data.issues.nodes) {
|
|
37
|
+
tasks.push({
|
|
38
|
+
id: node.id,
|
|
39
|
+
identifier: node.identifier,
|
|
40
|
+
title: node.title,
|
|
41
|
+
description: node.description,
|
|
42
|
+
url: node.url,
|
|
43
|
+
priority: node.priority,
|
|
44
|
+
status: node.state.name,
|
|
45
|
+
labels: node.labels.nodes.map((label) => label.name),
|
|
46
|
+
assignee: node.assignee?.name ?? null,
|
|
47
|
+
createdAt: node.createdAt,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (!data.issues.pageInfo?.hasNextPage || !data.issues.pageInfo.endCursor)
|
|
51
|
+
break;
|
|
52
|
+
after = data.issues.pageInfo.endCursor;
|
|
53
|
+
}
|
|
54
|
+
return tasks.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.createdAt.localeCompare(b.createdAt));
|
|
55
|
+
}
|