@spexcode/spec-forge 0.6.5
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/dist/cache.d.ts +29 -0
- package/dist/cache.js +59 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +100 -0
- package/dist/drivers/github.d.ts +2 -0
- package/dist/drivers/github.js +116 -0
- package/dist/drivers/gitlab.d.ts +7 -0
- package/dist/drivers/gitlab.js +144 -0
- package/dist/drivers.d.ts +10 -0
- package/dist/drivers.js +83 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/links.d.ts +15 -0
- package/dist/links.js +65 -0
- package/dist/needs-eval.d.ts +10 -0
- package/dist/needs-eval.js +20 -0
- package/dist/port.d.ts +53 -0
- package/dist/port.js +1 -0
- package/dist/resident.d.ts +7 -0
- package/dist/resident.js +43 -0
- package/package.json +32 -0
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ForgeDriver, ForgeIssue, ForgePR } from './port.js';
|
|
2
|
+
import { type NodeLinks } from './links.js';
|
|
3
|
+
export type ForgeDelta = {
|
|
4
|
+
kind: 'issue';
|
|
5
|
+
issue: ForgeIssue;
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'pr';
|
|
8
|
+
pr: ForgePR;
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'remove';
|
|
11
|
+
target: 'issue' | 'pr';
|
|
12
|
+
number: number;
|
|
13
|
+
};
|
|
14
|
+
export declare class ForgeCache {
|
|
15
|
+
private issues;
|
|
16
|
+
private prs;
|
|
17
|
+
private revision;
|
|
18
|
+
apply(delta: ForgeDelta): void;
|
|
19
|
+
reconcile(driver: ForgeDriver): Promise<void>;
|
|
20
|
+
applyIssues(issues: ForgeIssue[]): void;
|
|
21
|
+
setPRs(prs: ForgePR[]): void;
|
|
22
|
+
view(nodeIds: string[]): NodeLinks[];
|
|
23
|
+
state(): {
|
|
24
|
+
issues: ForgeIssue[];
|
|
25
|
+
prs: ForgePR[];
|
|
26
|
+
};
|
|
27
|
+
stateRevision(): number;
|
|
28
|
+
private set;
|
|
29
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { resolveLinks } from './links.js';
|
|
2
|
+
function sameMap(a, b) {
|
|
3
|
+
if (a.size !== b.size)
|
|
4
|
+
return false;
|
|
5
|
+
for (const [number, value] of a)
|
|
6
|
+
if (!b.has(number) || JSON.stringify(value) !== JSON.stringify(b.get(number)))
|
|
7
|
+
return false;
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
export class ForgeCache {
|
|
11
|
+
issues = new Map();
|
|
12
|
+
prs = new Map();
|
|
13
|
+
revision = 0;
|
|
14
|
+
apply(delta) {
|
|
15
|
+
if (delta.kind === 'issue')
|
|
16
|
+
this.set(this.issues, delta.issue);
|
|
17
|
+
else if (delta.kind === 'pr')
|
|
18
|
+
this.set(this.prs, delta.pr);
|
|
19
|
+
else {
|
|
20
|
+
const target = delta.target === 'issue' ? this.issues : this.prs;
|
|
21
|
+
if (target.delete(delta.number))
|
|
22
|
+
this.revision++;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async reconcile(driver) {
|
|
26
|
+
const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()]);
|
|
27
|
+
const nextIssues = new Map(issues.map((i) => [i.number, i]));
|
|
28
|
+
const nextPRs = new Map(prs.map((p) => [p.number, p]));
|
|
29
|
+
if (!sameMap(this.issues, nextIssues) || !sameMap(this.prs, nextPRs))
|
|
30
|
+
this.revision++;
|
|
31
|
+
this.issues = nextIssues;
|
|
32
|
+
this.prs = nextPRs;
|
|
33
|
+
}
|
|
34
|
+
applyIssues(issues) {
|
|
35
|
+
for (const i of issues)
|
|
36
|
+
this.set(this.issues, i);
|
|
37
|
+
}
|
|
38
|
+
setPRs(prs) {
|
|
39
|
+
const next = new Map(prs.map((p) => [p.number, p]));
|
|
40
|
+
if (!sameMap(this.prs, next))
|
|
41
|
+
this.revision++;
|
|
42
|
+
this.prs = next;
|
|
43
|
+
}
|
|
44
|
+
view(nodeIds) {
|
|
45
|
+
return resolveLinks([...this.issues.values()], [...this.prs.values()], nodeIds);
|
|
46
|
+
}
|
|
47
|
+
state() {
|
|
48
|
+
return { issues: [...this.issues.values()], prs: [...this.prs.values()] };
|
|
49
|
+
}
|
|
50
|
+
stateRevision() {
|
|
51
|
+
return this.revision;
|
|
52
|
+
}
|
|
53
|
+
set(target, value) {
|
|
54
|
+
if (JSON.stringify(target.get(value.number)) !== JSON.stringify(value)) {
|
|
55
|
+
target.set(value.number, value);
|
|
56
|
+
this.revision++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runIssueLinks(args: string[]): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { loadSpecs } from '@spexcode/spec-core';
|
|
2
|
+
import { FORGE_DRIVERS, forgeDriverFor, resolveForgeHost } from './drivers.js';
|
|
3
|
+
import { resolveLinks } from './links.js';
|
|
4
|
+
import { resolveEvalPending } from './needs-eval.js';
|
|
5
|
+
function flag(args, name) {
|
|
6
|
+
const i = args.indexOf(`--${name}`);
|
|
7
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
8
|
+
}
|
|
9
|
+
const has = (args, name) => args.includes(`--${name}`);
|
|
10
|
+
async function readForge(args) {
|
|
11
|
+
const host = flag(args, 'store') ?? resolveForgeHost();
|
|
12
|
+
const driver = forgeDriverFor(host);
|
|
13
|
+
if (!driver) {
|
|
14
|
+
console.error(`spex issue links: unknown --store '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')})`);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const nodeIds = (await loadSpecs()).map((s) => s.id);
|
|
18
|
+
const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()]);
|
|
19
|
+
return { driver, nodeIds, issues, prs };
|
|
20
|
+
}
|
|
21
|
+
function render(links) {
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const n of links) {
|
|
24
|
+
out.push(`\n${n.node}`);
|
|
25
|
+
if (n.issues.length) {
|
|
26
|
+
out.push(' issues:');
|
|
27
|
+
for (const i of n.issues)
|
|
28
|
+
out.push(` #${i.number} ${i.state} ${i.title} (via ${i.via}) ${i.url}`);
|
|
29
|
+
}
|
|
30
|
+
if (n.prs.length) {
|
|
31
|
+
out.push(' prs:');
|
|
32
|
+
for (const p of n.prs)
|
|
33
|
+
out.push(` #${p.number} ${p.state} ${p.title} ${p.headRefName} ${p.url}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out.join('\n');
|
|
37
|
+
}
|
|
38
|
+
async function links(args) {
|
|
39
|
+
const forge = await readForge(args);
|
|
40
|
+
if (!forge)
|
|
41
|
+
return 2;
|
|
42
|
+
const { driver, nodeIds, issues, prs } = forge;
|
|
43
|
+
let resolved = resolveLinks(issues, prs, nodeIds);
|
|
44
|
+
const only = flag(args, 'node');
|
|
45
|
+
if (only) {
|
|
46
|
+
if (!nodeIds.includes(only)) {
|
|
47
|
+
console.error(`spex issue links: no such node '${only}'`);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
resolved = resolved.filter((n) => n.node === only);
|
|
51
|
+
}
|
|
52
|
+
if (has(args, 'json')) {
|
|
53
|
+
console.log(JSON.stringify(resolved, null, 2));
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
const nIssues = resolved.reduce((a, n) => a + n.issues.length, 0);
|
|
57
|
+
const nPRs = resolved.reduce((a, n) => a + n.prs.length, 0);
|
|
58
|
+
console.log(`spec-forge · ${driver.host} · ${resolved.length} linked node(s) · ${nIssues} issue(s), ${nPRs} pr(s)` +
|
|
59
|
+
` · traced ${issues.length} issue(s), ${prs.length} pr(s)`);
|
|
60
|
+
if (resolved.length)
|
|
61
|
+
console.log(render(resolved));
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
function renderPending(pending) {
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const n of pending) {
|
|
67
|
+
out.push(`\n${n.node}`);
|
|
68
|
+
for (const i of n.pending)
|
|
69
|
+
out.push(` #${i.number} ${i.state} ${i.title} (via ${i.via}) ${i.url}`);
|
|
70
|
+
}
|
|
71
|
+
return out.join('\n');
|
|
72
|
+
}
|
|
73
|
+
async function evalPending(args) {
|
|
74
|
+
const forge = await readForge(args);
|
|
75
|
+
if (!forge)
|
|
76
|
+
return 2;
|
|
77
|
+
const { driver, nodeIds, issues, prs } = forge;
|
|
78
|
+
let resolved = resolveEvalPending(issues, prs, nodeIds);
|
|
79
|
+
const only = flag(args, 'node');
|
|
80
|
+
if (only) {
|
|
81
|
+
if (!nodeIds.includes(only)) {
|
|
82
|
+
console.error(`spex issue links: no such node '${only}'`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
resolved = resolved.filter((n) => n.node === only);
|
|
86
|
+
}
|
|
87
|
+
if (has(args, 'json')) {
|
|
88
|
+
console.log(JSON.stringify(resolved, null, 2));
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
const nPending = resolved.reduce((a, n) => a + n.pending.length, 0);
|
|
92
|
+
console.log(`spec-forge · ${driver.host} · ${resolved.length} node(s) with eval pending · ${nPending} issue(s)` +
|
|
93
|
+
` · traced ${issues.length} issue(s), ${prs.length} pr(s)`);
|
|
94
|
+
if (resolved.length)
|
|
95
|
+
console.log(renderPending(resolved));
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
export async function runIssueLinks(args) {
|
|
99
|
+
return has(args, 'pending') ? evalPending(args) : links(args);
|
|
100
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
// maxBuffer is raised above the 1MB default: a busy repo's issue/PR JSON can exceed it
|
|
5
|
+
async function gh(args) {
|
|
6
|
+
const { stdout } = await run('gh', args, { maxBuffer: 16 * 1024 * 1024 });
|
|
7
|
+
return JSON.parse(stdout);
|
|
8
|
+
}
|
|
9
|
+
export const githubDriver = {
|
|
10
|
+
host: 'github',
|
|
11
|
+
// fetch open and closed in separate `--limit 200` windows and merge, so a flood of closed issues can't crowd the open set out of one shared `--state all` limit
|
|
12
|
+
async listIssues() {
|
|
13
|
+
const list = (state) => gh(['issue', 'list', '--state', state, '--limit', '200', '--json', 'number,title,body,url,state,labels,author,createdAt,comments']);
|
|
14
|
+
const [open, closed] = await Promise.all([list('open'), list('closed')]);
|
|
15
|
+
return [...open, ...closed].map((r) => ({
|
|
16
|
+
number: r.number,
|
|
17
|
+
title: r.title,
|
|
18
|
+
body: r.body ?? '',
|
|
19
|
+
url: r.url,
|
|
20
|
+
state: (r.state || '').toLowerCase(),
|
|
21
|
+
labels: forgeLabels(r.labels),
|
|
22
|
+
author: r.author?.login ?? '',
|
|
23
|
+
createdAt: r.createdAt ?? '',
|
|
24
|
+
comments: (r.comments ?? []).map((c) => ({ author: c.author?.login ?? '', createdAt: c.createdAt ?? '', body: c.body ?? '' })),
|
|
25
|
+
}));
|
|
26
|
+
},
|
|
27
|
+
async listIssuesSince(sinceISO) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (let page = 1; page <= 20; page++) {
|
|
30
|
+
const rows = await gh(['api', `repos/{owner}/{repo}/issues?state=all&since=${encodeURIComponent(sinceISO)}&per_page=100&page=${page}`]);
|
|
31
|
+
out.push(...rows);
|
|
32
|
+
if (rows.length < 100)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
return Promise.all(out.filter((r) => !r.pull_request).map(async (r) => ({
|
|
36
|
+
number: r.number,
|
|
37
|
+
title: r.title,
|
|
38
|
+
body: r.body ?? '',
|
|
39
|
+
url: r.html_url,
|
|
40
|
+
state: (r.state || '').toLowerCase(),
|
|
41
|
+
labels: forgeLabels(r.labels),
|
|
42
|
+
author: r.user?.login ?? '',
|
|
43
|
+
createdAt: r.created_at ?? '',
|
|
44
|
+
comments: r.comments > 0 ? await listComments(r.number) : [],
|
|
45
|
+
})));
|
|
46
|
+
},
|
|
47
|
+
async listPRs() {
|
|
48
|
+
const base = ['pr', 'list', '--state', 'open', '--limit', '200', '--json'];
|
|
49
|
+
const fields = 'number,title,url,state,headRefName';
|
|
50
|
+
let rows;
|
|
51
|
+
try {
|
|
52
|
+
rows = await gh([...base, `${fields},closingIssuesReferences`]);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (!isUnknownFieldError(err))
|
|
56
|
+
throw err;
|
|
57
|
+
warnNoTransitiveOnce();
|
|
58
|
+
rows = await gh([...base, fields]);
|
|
59
|
+
}
|
|
60
|
+
return rows.map((r) => ({
|
|
61
|
+
number: r.number,
|
|
62
|
+
title: r.title,
|
|
63
|
+
url: r.url,
|
|
64
|
+
state: r.state,
|
|
65
|
+
headRefName: r.headRefName,
|
|
66
|
+
closesIssues: (r.closingIssuesReferences ?? []).map((c) => c.number),
|
|
67
|
+
}));
|
|
68
|
+
},
|
|
69
|
+
async createIssue({ title, body }) {
|
|
70
|
+
const { stdout } = await run('gh', ['issue', 'create', '--title', title, '--body', body], { maxBuffer: 1024 * 1024 });
|
|
71
|
+
const url = stdout.trim().split('\n').pop() ?? '';
|
|
72
|
+
const number = parseInt(url.split('/').pop() ?? '', 10);
|
|
73
|
+
if (!url.startsWith('http') || !Number.isFinite(number))
|
|
74
|
+
throw new Error(`gh issue create returned an unexpected result: ${stdout.trim()}`);
|
|
75
|
+
return { number, url };
|
|
76
|
+
},
|
|
77
|
+
async createComment({ number, body }) {
|
|
78
|
+
const { stdout } = await run('gh', ['issue', 'comment', String(number), '--body', body], { maxBuffer: 1024 * 1024 });
|
|
79
|
+
const url = stdout.trim().split('\n').pop() ?? '';
|
|
80
|
+
if (!url.startsWith('http'))
|
|
81
|
+
throw new Error(`gh issue comment returned an unexpected result: ${stdout.trim()}`);
|
|
82
|
+
return { url };
|
|
83
|
+
},
|
|
84
|
+
// the lifecycle write verb (see port.ts). `gh issue close` does not reliably print a permalink, so read
|
|
85
|
+
// the issue URL back through gh's JSON surface after the close succeeds.
|
|
86
|
+
async closeIssue({ number }) {
|
|
87
|
+
await run('gh', ['issue', 'close', String(number)], { maxBuffer: 1024 * 1024 });
|
|
88
|
+
const r = await gh(['issue', 'view', String(number), '--json', 'url']);
|
|
89
|
+
if (!r.url?.startsWith('http'))
|
|
90
|
+
throw new Error(`gh issue view returned an unexpected url after close: ${JSON.stringify(r)}`);
|
|
91
|
+
return { url: r.url };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
function forgeLabels(labels) {
|
|
95
|
+
return (labels ?? []).flatMap((label) => {
|
|
96
|
+
const name = typeof label === 'string' ? label : label.name ?? '';
|
|
97
|
+
return name ? [{ name, ...(typeof label === 'string' || !label.color ? {} : { color: label.color }) }] : [];
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async function listComments(number) {
|
|
101
|
+
const rows = await gh(['api', `repos/{owner}/{repo}/issues/${number}/comments?per_page=100`]);
|
|
102
|
+
return rows.map((c) => ({ author: c.user?.login ?? '', createdAt: c.created_at ?? '', body: c.body ?? '' }));
|
|
103
|
+
}
|
|
104
|
+
function isUnknownFieldError(err) {
|
|
105
|
+
const e = err;
|
|
106
|
+
const text = `${e?.stderr ?? ''}\n${e?.message ?? ''}`;
|
|
107
|
+
return /unknown json field/i.test(text);
|
|
108
|
+
}
|
|
109
|
+
let warnedNoTransitive = false;
|
|
110
|
+
function warnNoTransitiveOnce() {
|
|
111
|
+
if (warnedNoTransitive)
|
|
112
|
+
return;
|
|
113
|
+
warnedNoTransitive = true;
|
|
114
|
+
console.warn('spec-forge: this `gh` is too old for `closingIssuesReferences` — transitive issue↔PR links are ' +
|
|
115
|
+
'disabled (branch + `Spec:` marker links still work). Upgrade `gh` to restore them.');
|
|
116
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
let ctx = null;
|
|
3
|
+
function gitlabCtx() {
|
|
4
|
+
if (ctx)
|
|
5
|
+
return ctx;
|
|
6
|
+
const remote = execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' }).trim();
|
|
7
|
+
const parsed = parseRemote(remote);
|
|
8
|
+
if (!parsed)
|
|
9
|
+
throw new Error(`gitlab: cannot parse origin remote '${remote}' as a GitLab URL`);
|
|
10
|
+
const token = process.env.GITLAB_TOKEN || credentialToken(parsed.host);
|
|
11
|
+
if (!token) {
|
|
12
|
+
throw new Error(`gitlab: no token for ${parsed.host} — set GITLAB_TOKEN, or store a PAT in git's credential store:\n` +
|
|
13
|
+
` printf "protocol=https\\nhost=${parsed.host}\\nusername=<user>\\npassword=<token>\\n\\n" | git credential approve`);
|
|
14
|
+
}
|
|
15
|
+
ctx = { ...parsed, token };
|
|
16
|
+
return ctx;
|
|
17
|
+
}
|
|
18
|
+
export function parseRemote(remote) {
|
|
19
|
+
let m = remote.match(/^(https?:\/\/[^/]+)\/(.+?)(?:\.git)?\/?$/);
|
|
20
|
+
if (m)
|
|
21
|
+
return { base: m[1], host: m[1].replace(/^https?:\/\//, ''), project: m[2] };
|
|
22
|
+
m = remote.match(/^(?:ssh:\/\/)?[\w.-]+@([\w.-]+)(?::\d+)?[:/](.+?)(?:\.git)?\/?$/);
|
|
23
|
+
if (m)
|
|
24
|
+
return { base: `https://${m[1]}`, host: m[1], project: m[2] };
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
function credentialToken(host) {
|
|
28
|
+
try {
|
|
29
|
+
const out = execFileSync('git', ['credential', 'fill'], {
|
|
30
|
+
input: `protocol=https\nhost=${host}\n\n`,
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'true' },
|
|
33
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
34
|
+
});
|
|
35
|
+
return out.match(/^password=(.+)$/m)?.[1] ?? '';
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function api(path, init) {
|
|
42
|
+
const { base, project, token } = gitlabCtx();
|
|
43
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
44
|
+
const url = `${base}/api/v4/projects/${encodeURIComponent(project)}/${path}${init?.method ? '' : `${sep}per_page=100`}`;
|
|
45
|
+
const res = await fetch(url, {
|
|
46
|
+
...init,
|
|
47
|
+
headers: { 'PRIVATE-TOKEN': token, 'content-type': 'application/json', ...init?.headers },
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok)
|
|
50
|
+
throw new Error(`gitlab: ${init?.method ?? 'GET'} ${path} → ${res.status} ${(await res.text()).slice(0, 300)}`);
|
|
51
|
+
return (await res.json());
|
|
52
|
+
}
|
|
53
|
+
// GitLab's API has no bounded list query; stop at a short page and cap the walk.
|
|
54
|
+
async function paged(path) {
|
|
55
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
56
|
+
const out = [];
|
|
57
|
+
for (let page = 1; page <= 20; page++) {
|
|
58
|
+
const rows = await api(`${path}${sep}page=${page}`);
|
|
59
|
+
out.push(...rows);
|
|
60
|
+
if (rows.length < 100)
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
async function toIssue(r) {
|
|
66
|
+
return {
|
|
67
|
+
number: r.iid,
|
|
68
|
+
title: r.title,
|
|
69
|
+
body: r.description ?? '',
|
|
70
|
+
url: r.web_url,
|
|
71
|
+
state: normalizeState(r.state),
|
|
72
|
+
labels: forgeLabels(r.labels),
|
|
73
|
+
author: r.author?.username ?? '',
|
|
74
|
+
createdAt: r.created_at ?? '',
|
|
75
|
+
comments: r.user_notes_count > 0 ? await listNotes(r.iid) : [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function forgeLabels(labels) {
|
|
79
|
+
return (labels ?? []).flatMap((label) => {
|
|
80
|
+
const name = typeof label === 'string' ? label : label.name ?? '';
|
|
81
|
+
if (!name)
|
|
82
|
+
return [];
|
|
83
|
+
if (typeof label === 'string')
|
|
84
|
+
return [{ name }];
|
|
85
|
+
return [{ name, ...(label.color ? { color: label.color } : {}), ...(label.text_color ? { textColor: label.text_color } : {}) }];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function normalizeState(state) {
|
|
89
|
+
const s = (state || '').toLowerCase();
|
|
90
|
+
return s === 'opened' ? 'open' : s;
|
|
91
|
+
}
|
|
92
|
+
async function listNotes(iid) {
|
|
93
|
+
const rows = await paged(`issues/${iid}/notes`);
|
|
94
|
+
return rows
|
|
95
|
+
.filter((n) => !n.system)
|
|
96
|
+
.map((n) => ({ author: n.author?.username ?? '', createdAt: n.created_at ?? '', body: n.body ?? '' }));
|
|
97
|
+
}
|
|
98
|
+
export const gitlabDriver = {
|
|
99
|
+
host: 'gitlab',
|
|
100
|
+
async listIssues() {
|
|
101
|
+
const rows = await paged('issues?state=all&with_labels_details=true');
|
|
102
|
+
return Promise.all(rows.map(toIssue));
|
|
103
|
+
},
|
|
104
|
+
async listIssuesSince(sinceISO) {
|
|
105
|
+
const rows = await paged(`issues?state=all&with_labels_details=true&updated_after=${encodeURIComponent(sinceISO)}`);
|
|
106
|
+
return Promise.all(rows.map(toIssue));
|
|
107
|
+
},
|
|
108
|
+
async listPRs() {
|
|
109
|
+
const rows = await paged('merge_requests?state=opened');
|
|
110
|
+
return Promise.all(rows.map(async (r) => ({
|
|
111
|
+
number: r.iid,
|
|
112
|
+
title: r.title,
|
|
113
|
+
url: r.web_url,
|
|
114
|
+
state: normalizeState(r.state),
|
|
115
|
+
headRefName: r.source_branch,
|
|
116
|
+
closesIssues: (await paged(`merge_requests/${r.iid}/closes_issues`)).map((i) => i.iid),
|
|
117
|
+
})));
|
|
118
|
+
},
|
|
119
|
+
async createIssue({ title, body }) {
|
|
120
|
+
const r = await api('issues', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
body: JSON.stringify({ title, description: body }),
|
|
123
|
+
});
|
|
124
|
+
if (!r.web_url?.startsWith('http') || !Number.isFinite(r.iid))
|
|
125
|
+
throw new Error(`gitlab: issue create returned an unexpected result: ${JSON.stringify(r)}`);
|
|
126
|
+
return { number: r.iid, url: r.web_url };
|
|
127
|
+
},
|
|
128
|
+
async createComment({ number, body }) {
|
|
129
|
+
const { base, project } = gitlabCtx();
|
|
130
|
+
const r = await api(`issues/${number}/notes`, { method: 'POST', body: JSON.stringify({ body }) });
|
|
131
|
+
if (!Number.isFinite(r.id))
|
|
132
|
+
throw new Error(`gitlab: note create returned an unexpected result: ${JSON.stringify(r)}`);
|
|
133
|
+
return { url: `${base}/${project}/-/issues/${number}#note_${r.id}` };
|
|
134
|
+
},
|
|
135
|
+
async closeIssue({ number }) {
|
|
136
|
+
const r = await api(`issues/${number}`, {
|
|
137
|
+
method: 'PUT',
|
|
138
|
+
body: JSON.stringify({ state_event: 'close' }),
|
|
139
|
+
});
|
|
140
|
+
if (!r.web_url?.startsWith('http'))
|
|
141
|
+
throw new Error(`gitlab: issue close returned an unexpected result: ${JSON.stringify(r)}`);
|
|
142
|
+
return { url: r.web_url };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ForgeDriver } from './port.js';
|
|
2
|
+
export declare const FORGE_DRIVERS: ForgeDriver[];
|
|
3
|
+
export declare const DEFAULT_FORGE_HOST = "github";
|
|
4
|
+
export declare function forgeDriverFor(host: string): ForgeDriver | undefined;
|
|
5
|
+
export declare function forgeIssueStores(): {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
kind: 'forge';
|
|
9
|
+
}[];
|
|
10
|
+
export declare function resolveForgeHost(): string;
|
package/dist/drivers.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { githubDriver } from './drivers/github.js';
|
|
5
|
+
import { gitlabDriver } from './drivers/gitlab.js';
|
|
6
|
+
export const FORGE_DRIVERS = [githubDriver, gitlabDriver];
|
|
7
|
+
export const DEFAULT_FORGE_HOST = 'github';
|
|
8
|
+
export function forgeDriverFor(host) {
|
|
9
|
+
return FORGE_DRIVERS.find((d) => d.host === host);
|
|
10
|
+
}
|
|
11
|
+
export function forgeIssueStores() {
|
|
12
|
+
const driver = forgeDriverFor(resolveForgeHost());
|
|
13
|
+
return driver ? [{ id: driver.host, label: driver.host, kind: 'forge' }] : [];
|
|
14
|
+
}
|
|
15
|
+
function gitEnv() {
|
|
16
|
+
const env = { ...process.env };
|
|
17
|
+
delete env.GIT_DIR;
|
|
18
|
+
delete env.GIT_WORK_TREE;
|
|
19
|
+
delete env.GIT_INDEX_FILE;
|
|
20
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
21
|
+
return env;
|
|
22
|
+
}
|
|
23
|
+
function gitOut(args) {
|
|
24
|
+
try {
|
|
25
|
+
return execFileSync('git', args, { env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function configuredHost() {
|
|
32
|
+
const root = gitOut(['rev-parse', '--show-toplevel']);
|
|
33
|
+
if (!root)
|
|
34
|
+
return null;
|
|
35
|
+
for (const name of ['spexcode.local.json', 'spexcode.json']) {
|
|
36
|
+
const p = join(root, name);
|
|
37
|
+
if (!existsSync(p))
|
|
38
|
+
continue;
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
throw new Error(`${p} is not valid JSON: ${e.message}`);
|
|
45
|
+
}
|
|
46
|
+
const host = parsed?.forge?.host;
|
|
47
|
+
if (typeof host === 'string' && host.trim())
|
|
48
|
+
return host.trim();
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function remoteHostname(url) {
|
|
53
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
54
|
+
try {
|
|
55
|
+
return new URL(url).hostname || null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):./.exec(url);
|
|
62
|
+
return scp ? scp[1] : null;
|
|
63
|
+
}
|
|
64
|
+
function hostFor(hostname) {
|
|
65
|
+
const h = hostname.toLowerCase();
|
|
66
|
+
if (h.includes('github'))
|
|
67
|
+
return 'github';
|
|
68
|
+
if (h.includes('bitbucket'))
|
|
69
|
+
return 'bitbucket';
|
|
70
|
+
return 'gitlab';
|
|
71
|
+
}
|
|
72
|
+
let cached = null;
|
|
73
|
+
const RESOLVE_TTL_MS = 30_000;
|
|
74
|
+
export function resolveForgeHost() {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
if (cached && now - cached.at < RESOLVE_TTL_MS)
|
|
77
|
+
return cached.host;
|
|
78
|
+
const url = gitOut(['remote', 'get-url', 'origin']);
|
|
79
|
+
const hostname = url ? remoteHostname(url) : null;
|
|
80
|
+
const host = configuredHost() ?? (hostname ? hostFor(hostname) : DEFAULT_FORGE_HOST);
|
|
81
|
+
cached = { host, at: now };
|
|
82
|
+
return host;
|
|
83
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/links.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ForgeIssue, ForgePR } from './port.js';
|
|
2
|
+
export type LinkedIssue = ForgeIssue & {
|
|
3
|
+
via: 'marker' | 'pr';
|
|
4
|
+
};
|
|
5
|
+
export type LinkedPR = ForgePR & {
|
|
6
|
+
via: 'branch';
|
|
7
|
+
};
|
|
8
|
+
export type NodeLinks = {
|
|
9
|
+
node: string;
|
|
10
|
+
issues: LinkedIssue[];
|
|
11
|
+
prs: LinkedPR[];
|
|
12
|
+
};
|
|
13
|
+
export declare function parseSpecMarkers(body: string): string[];
|
|
14
|
+
export declare function branchToNode(branch: string, nodeIds: string[]): string | null;
|
|
15
|
+
export declare function resolveLinks(issues: ForgeIssue[], prs: ForgePR[], nodeIds: string[]): NodeLinks[];
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function parseSpecMarkers(body) {
|
|
2
|
+
const ids = [];
|
|
3
|
+
for (const m of (body || '').matchAll(/^\s*spec:\s*(.+)$/gim)) {
|
|
4
|
+
for (const part of m[1].split(',')) {
|
|
5
|
+
const id = part.trim();
|
|
6
|
+
if (id)
|
|
7
|
+
ids.push(id);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return ids;
|
|
11
|
+
}
|
|
12
|
+
export function branchToNode(branch, nodeIds) {
|
|
13
|
+
if (!branch?.startsWith('node/'))
|
|
14
|
+
return null;
|
|
15
|
+
const rest = branch.slice('node/'.length);
|
|
16
|
+
let best = null;
|
|
17
|
+
for (const id of nodeIds) {
|
|
18
|
+
if (rest === id || rest.startsWith(id + '-')) {
|
|
19
|
+
if (!best || id.length > best.length)
|
|
20
|
+
best = id;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return best;
|
|
24
|
+
}
|
|
25
|
+
export function resolveLinks(issues, prs, nodeIds) {
|
|
26
|
+
const known = new Set(nodeIds);
|
|
27
|
+
const byNode = new Map();
|
|
28
|
+
const slot = (node) => {
|
|
29
|
+
let s = byNode.get(node);
|
|
30
|
+
if (!s) {
|
|
31
|
+
s = { issues: new Map(), prs: new Map() };
|
|
32
|
+
byNode.set(node, s);
|
|
33
|
+
}
|
|
34
|
+
return s;
|
|
35
|
+
};
|
|
36
|
+
const prNode = new Map();
|
|
37
|
+
for (const pr of prs) {
|
|
38
|
+
const node = branchToNode(pr.headRefName, nodeIds);
|
|
39
|
+
if (!node)
|
|
40
|
+
continue;
|
|
41
|
+
prNode.set(pr.number, node);
|
|
42
|
+
slot(node).prs.set(pr.number, { ...pr, via: 'branch' });
|
|
43
|
+
}
|
|
44
|
+
const issueByNumber = new Map(issues.map((i) => [i.number, i]));
|
|
45
|
+
for (const issue of issues) {
|
|
46
|
+
for (const id of parseSpecMarkers(issue.body)) {
|
|
47
|
+
if (known.has(id))
|
|
48
|
+
slot(id).issues.set(issue.number, { ...issue, via: 'marker' });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const pr of prs) {
|
|
52
|
+
const node = prNode.get(pr.number);
|
|
53
|
+
if (!node)
|
|
54
|
+
continue;
|
|
55
|
+
for (const num of pr.closesIssues) {
|
|
56
|
+
const issue = issueByNumber.get(num);
|
|
57
|
+
if (issue && !slot(node).issues.has(num)) {
|
|
58
|
+
slot(node).issues.set(num, { ...issue, via: 'pr' });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...byNode.entries()]
|
|
63
|
+
.map(([node, s]) => ({ node, issues: [...s.issues.values()], prs: [...s.prs.values()] }))
|
|
64
|
+
.sort((a, b) => a.node.localeCompare(b.node));
|
|
65
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ForgeIssue, ForgePR } from './port.js';
|
|
2
|
+
import { type LinkedIssue } from './links.js';
|
|
3
|
+
export declare const NEEDS_EVAL = "needs-eval";
|
|
4
|
+
export declare function isNeedsEval(issue: ForgeIssue): boolean;
|
|
5
|
+
export type EvalPending = LinkedIssue;
|
|
6
|
+
export type NodeEvalPending = {
|
|
7
|
+
node: string;
|
|
8
|
+
pending: EvalPending[];
|
|
9
|
+
};
|
|
10
|
+
export declare function resolveEvalPending(issues: ForgeIssue[], prs: ForgePR[], nodeIds: string[]): NodeEvalPending[];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resolveLinks } from './links.js';
|
|
2
|
+
export const NEEDS_EVAL = 'needs-eval';
|
|
3
|
+
const BODY_MARKER = new RegExp(`^\\s*${NEEDS_EVAL}\\s*:?\\s*$`, 'im');
|
|
4
|
+
export function isNeedsEval(issue) {
|
|
5
|
+
if (issue.labels.some((l) => l.name.trim().toLowerCase() === NEEDS_EVAL))
|
|
6
|
+
return true;
|
|
7
|
+
return BODY_MARKER.test(issue.body || '');
|
|
8
|
+
}
|
|
9
|
+
export function resolveEvalPending(issues, prs, nodeIds) {
|
|
10
|
+
const flagged = new Set(issues.filter(isNeedsEval).map((i) => i.number));
|
|
11
|
+
if (!flagged.size)
|
|
12
|
+
return [];
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const { node, issues: linked } of resolveLinks(issues, prs, nodeIds)) {
|
|
15
|
+
const pending = linked.filter((i) => i.state === 'open' && flagged.has(i.number));
|
|
16
|
+
if (pending.length)
|
|
17
|
+
out.push({ node, pending });
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
package/dist/port.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type ForgeComment = {
|
|
2
|
+
author: string;
|
|
3
|
+
createdAt: string;
|
|
4
|
+
body: string;
|
|
5
|
+
};
|
|
6
|
+
export type ForgeLabel = {
|
|
7
|
+
name: string;
|
|
8
|
+
color?: string;
|
|
9
|
+
textColor?: string;
|
|
10
|
+
};
|
|
11
|
+
export type ForgeIssue = {
|
|
12
|
+
number: number;
|
|
13
|
+
title: string;
|
|
14
|
+
body: string;
|
|
15
|
+
url: string;
|
|
16
|
+
state: string;
|
|
17
|
+
labels: ForgeLabel[];
|
|
18
|
+
author: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
comments: ForgeComment[];
|
|
21
|
+
};
|
|
22
|
+
export type ForgePR = {
|
|
23
|
+
number: number;
|
|
24
|
+
title: string;
|
|
25
|
+
url: string;
|
|
26
|
+
state: string;
|
|
27
|
+
headRefName: string;
|
|
28
|
+
closesIssues: number[];
|
|
29
|
+
};
|
|
30
|
+
export interface ForgeDriver {
|
|
31
|
+
readonly host: string;
|
|
32
|
+
listIssues(): Promise<ForgeIssue[]>;
|
|
33
|
+
listPRs(): Promise<ForgePR[]>;
|
|
34
|
+
createIssue(input: {
|
|
35
|
+
title: string;
|
|
36
|
+
body: string;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
number: number;
|
|
39
|
+
url: string;
|
|
40
|
+
}>;
|
|
41
|
+
createComment(input: {
|
|
42
|
+
number: number;
|
|
43
|
+
body: string;
|
|
44
|
+
}): Promise<{
|
|
45
|
+
url: string;
|
|
46
|
+
}>;
|
|
47
|
+
closeIssue(input: {
|
|
48
|
+
number: number;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
url: string;
|
|
51
|
+
}>;
|
|
52
|
+
listIssuesSince?(sinceISO: string): Promise<ForgeIssue[]>;
|
|
53
|
+
}
|
package/dist/port.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ForgeIssue, ForgePR } from './port.js';
|
|
2
|
+
export declare function residentForgeState(): {
|
|
3
|
+
issues: ForgeIssue[];
|
|
4
|
+
prs: ForgePR[];
|
|
5
|
+
};
|
|
6
|
+
export declare function residentForgeRevision(): number;
|
|
7
|
+
export declare function refreshForgeNow(): Promise<void>;
|
package/dist/resident.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ForgeCache } from './cache.js';
|
|
2
|
+
import { forgeDriverFor, resolveForgeHost } from './drivers.js';
|
|
3
|
+
const cache = new ForgeCache();
|
|
4
|
+
let inFlight = null;
|
|
5
|
+
let lastAttempt = 0;
|
|
6
|
+
const TTL_MS = 20_000;
|
|
7
|
+
let lastIssueSync = null;
|
|
8
|
+
let lastFull = 0;
|
|
9
|
+
const FULL_MS = 30 * 60_000;
|
|
10
|
+
function refreshIfStale(now) {
|
|
11
|
+
if (inFlight || (lastAttempt && now - lastAttempt < TTL_MS))
|
|
12
|
+
return;
|
|
13
|
+
const driver = forgeDriverFor(resolveForgeHost());
|
|
14
|
+
if (!driver)
|
|
15
|
+
return;
|
|
16
|
+
lastAttempt = now;
|
|
17
|
+
const startISO = new Date(now).toISOString(); // stamped at fetch START so an update during the fetch lands in the next window
|
|
18
|
+
const incremental = lastIssueSync && driver.listIssuesSince && now - lastFull < FULL_MS;
|
|
19
|
+
inFlight = (incremental
|
|
20
|
+
? Promise.all([
|
|
21
|
+
driver.listIssuesSince(lastIssueSync).then((delta) => cache.applyIssues(delta)),
|
|
22
|
+
driver.listPRs().then((prs) => cache.setPRs(prs)),
|
|
23
|
+
]).then(() => { lastIssueSync = startISO; })
|
|
24
|
+
: cache.reconcile(driver).then(() => { lastFull = now; lastIssueSync = startISO; }))
|
|
25
|
+
.catch(() => { })
|
|
26
|
+
.finally(() => { inFlight = null; });
|
|
27
|
+
}
|
|
28
|
+
export function residentForgeState() {
|
|
29
|
+
refreshIfStale(Date.now());
|
|
30
|
+
return cache.state();
|
|
31
|
+
}
|
|
32
|
+
export function residentForgeRevision() {
|
|
33
|
+
return cache.stateRevision();
|
|
34
|
+
}
|
|
35
|
+
export async function refreshForgeNow() {
|
|
36
|
+
if (inFlight)
|
|
37
|
+
await inFlight;
|
|
38
|
+
lastAttempt = 0;
|
|
39
|
+
lastFull = 0;
|
|
40
|
+
refreshIfStale(Date.now());
|
|
41
|
+
if (inFlight)
|
|
42
|
+
await inFlight;
|
|
43
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spexcode/spec-forge",
|
|
3
|
+
"version": "0.6.5",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./dist/index.js",
|
|
7
|
+
"./port": "./dist/port.js",
|
|
8
|
+
"./links": "./dist/links.js",
|
|
9
|
+
"./drivers": "./dist/drivers.js",
|
|
10
|
+
"./resident": "./dist/resident.js",
|
|
11
|
+
"./cli": "./dist/cli.js",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist"],
|
|
15
|
+
"description": "Host-agnostic forge link tracer — reads a forge's open issues/PRs and resolves each to the spec node it serves (issue-body `Spec: <id>` marker + `node/<id>` PR branch). Read-only; a node's status stays git-derived.",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "node ../scripts/build-dist.mjs",
|
|
21
|
+
"prepublishOnly": "node ../scripts/release-publish.mjs --from-package-publish",
|
|
22
|
+
"test": "tsx --test src/*.test.ts"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@spexcode/spec-core": "0.6.5"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^20.16.0",
|
|
29
|
+
"tsx": "^4.19.2",
|
|
30
|
+
"typescript": "^5.6.3"
|
|
31
|
+
}
|
|
32
|
+
}
|