opencode-feature-factory 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +877 -0
- package/assets/agent/backend-builder.md +73 -0
- package/assets/agent/codebase-researcher.md +56 -0
- package/assets/agent/design-interpreter.md +37 -0
- package/assets/agent/frontend-builder.md +74 -0
- package/assets/agent/implementation-validator.md +73 -0
- package/assets/agent/security-reviewer.md +60 -0
- package/assets/agent/spec-writer.md +94 -0
- package/assets/agent/story-reader.md +38 -0
- package/assets/agent/story-writer.md +39 -0
- package/assets/agent/test-verifier.md +73 -0
- package/assets/agent/work-decomposer.md +102 -0
- package/assets/agent/work-reviewer.md +77 -0
- package/assets/command/feature.md +68 -0
- package/assets/skills/feature/SCHEMA.md +990 -0
- package/assets/skills/feature/SKILL.md +620 -0
- package/dist/tui.js +3641 -0
- package/package.json +65 -0
- package/src/cleanup-sweep-command.js +75 -0
- package/src/cleanup-sweep-eligibility.js +581 -0
- package/src/cleanup-sweep-output.js +139 -0
- package/src/cleanup-sweep-report.js +548 -0
- package/src/cleanup-sweep.js +546 -0
- package/src/cli-output.js +251 -0
- package/src/cli.js +1152 -0
- package/src/config.js +231 -0
- package/src/cost-attribution.js +327 -0
- package/src/cost-report.js +185 -0
- package/src/detached-log-supervisor.js +178 -0
- package/src/doctor.js +598 -0
- package/src/env-snapshot.js +211 -0
- package/src/factory-diagnostics.js +429 -0
- package/src/factory-paths.js +40 -0
- package/src/factory.js +4769 -0
- package/src/feature-command-payload.js +378 -0
- package/src/git.js +110 -0
- package/src/github.js +252 -0
- package/src/hardening/atomic-write.js +954 -0
- package/src/hardening/line-output.js +139 -0
- package/src/hardening/output-policy.js +365 -0
- package/src/hardening/process-verification.js +542 -0
- package/src/hardening/sensitive-data.js +341 -0
- package/src/hardening/terminal-encoding.js +224 -0
- package/src/plugin.js +246 -0
- package/src/post-pr-ci.js +754 -0
- package/src/process-evidence.js +1139 -0
- package/src/refs.js +144 -0
- package/src/run-state.js +2411 -0
- package/src/steering-conflicts.js +77 -0
- package/src/telemetry.js +419 -0
- package/src/tui-data.js +499 -0
- package/src/tui-rendering.js +148 -0
- package/src/utils.js +35 -0
- package/src/validate.js +1655 -0
- package/src/worktrees.js +56 -0
package/src/github.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { spawnSync as defaultSpawnSync } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_GITHUB_TIMEOUT_MS = 10_000;
|
|
5
|
+
export const MAX_GITHUB_TIMEOUT_MS = 30_000;
|
|
6
|
+
export const DEFAULT_GITHUB_MAX_BUFFER = 1024 * 1024;
|
|
7
|
+
export const MAX_GITHUB_MAX_BUFFER = 8 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export function github(cwd, args, options = {}) {
|
|
10
|
+
const resolvedCwd = resolve(requireNonEmptyString(cwd, "cwd"));
|
|
11
|
+
const commandArgs = normalizeArgs(args);
|
|
12
|
+
const timeout = boundedPositiveInteger(options.timeout, DEFAULT_GITHUB_TIMEOUT_MS, MAX_GITHUB_TIMEOUT_MS);
|
|
13
|
+
const maxBuffer = boundedPositiveInteger(options.maxBuffer, DEFAULT_GITHUB_MAX_BUFFER, MAX_GITHUB_MAX_BUFFER);
|
|
14
|
+
const spawn = typeof options.spawnSync === "function" ? options.spawnSync : defaultSpawnSync;
|
|
15
|
+
const command = {
|
|
16
|
+
file: "gh",
|
|
17
|
+
cwd: resolvedCwd,
|
|
18
|
+
args: commandArgs,
|
|
19
|
+
shell: false,
|
|
20
|
+
timeout,
|
|
21
|
+
maxBuffer,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const proc = spawn("gh", commandArgs, {
|
|
26
|
+
cwd: resolvedCwd,
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
env: {
|
|
29
|
+
...process.env,
|
|
30
|
+
GH_PROMPT_DISABLED: "1",
|
|
31
|
+
GH_PAGER: "cat",
|
|
32
|
+
PAGER: "cat",
|
|
33
|
+
},
|
|
34
|
+
shell: false,
|
|
35
|
+
timeout,
|
|
36
|
+
maxBuffer,
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
}) || {};
|
|
39
|
+
const status = Number.isInteger(proc.status) ? proc.status : null;
|
|
40
|
+
const error = proc.error ? normalizeError(proc.error) : null;
|
|
41
|
+
return {
|
|
42
|
+
ok: error === null && status === 0,
|
|
43
|
+
status,
|
|
44
|
+
stdout: normalizeOutput(proc.stdout),
|
|
45
|
+
stderr: joinErrorOutput(proc.stderr, error),
|
|
46
|
+
command,
|
|
47
|
+
error,
|
|
48
|
+
signal: typeof proc.signal === "string" ? proc.signal : null,
|
|
49
|
+
};
|
|
50
|
+
} catch (caught) {
|
|
51
|
+
const error = normalizeError(caught);
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
status: null,
|
|
55
|
+
stdout: "",
|
|
56
|
+
stderr: error,
|
|
57
|
+
command,
|
|
58
|
+
error,
|
|
59
|
+
signal: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function pullRequestLookupArgs(repository, number) {
|
|
65
|
+
const normalizedRepository = normalizeRepository(repository);
|
|
66
|
+
const normalizedNumber = normalizePullRequestNumber(number);
|
|
67
|
+
return [
|
|
68
|
+
"api",
|
|
69
|
+
"--method",
|
|
70
|
+
"GET",
|
|
71
|
+
`repos/${normalizedRepository}/pulls/${normalizedNumber}`,
|
|
72
|
+
"--header",
|
|
73
|
+
"Accept:application/vnd.github+json",
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function normalizePullRequestResponse(value) {
|
|
78
|
+
const response = requireRecord(value, "GitHub pull-request response");
|
|
79
|
+
const number = normalizePullRequestNumber(response.number);
|
|
80
|
+
const state = normalizePullRequestState(response.state, response.merged);
|
|
81
|
+
const url = normalizePullRequestUrl(response.html_url);
|
|
82
|
+
const urlTuple = pullRequestUrlTuple(url);
|
|
83
|
+
const base = requireRecord(response.base, "GitHub pull-request response base");
|
|
84
|
+
const baseRepo = requireRecord(base.repo, "GitHub pull-request response base repository");
|
|
85
|
+
const repository = normalizeRepository(baseRepo.full_name);
|
|
86
|
+
const baseRef = requireNonEmptyString(base.ref, "GitHub pull-request response base.ref");
|
|
87
|
+
const baseSha = requireNonEmptyString(base.sha, "GitHub pull-request response base.sha");
|
|
88
|
+
|
|
89
|
+
if (urlTuple.number !== number || urlTuple.repository !== repository) {
|
|
90
|
+
throw new Error("GitHub pull-request response tuple is contradictory");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Object.freeze({
|
|
94
|
+
url,
|
|
95
|
+
number,
|
|
96
|
+
state,
|
|
97
|
+
repository,
|
|
98
|
+
base_ref: baseRef,
|
|
99
|
+
base_sha: baseSha,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function normalizeRecordedPullRequest(run) {
|
|
104
|
+
const record = requireRecord(run, "run");
|
|
105
|
+
const terminalResult = requireRecord(record.terminal_result, "terminal_result");
|
|
106
|
+
const topLevelUrl = normalizePullRequestUrl(record.pr_url);
|
|
107
|
+
const terminalUrl = normalizePullRequestUrl(terminalResult.pr_url);
|
|
108
|
+
const repository = normalizeRepository(terminalResult.repository);
|
|
109
|
+
const number = normalizePullRequestNumber(terminalResult.pr_number);
|
|
110
|
+
const topLevelTuple = pullRequestUrlTuple(topLevelUrl);
|
|
111
|
+
const terminalTuple = pullRequestUrlTuple(terminalUrl);
|
|
112
|
+
|
|
113
|
+
if (topLevelUrl !== terminalUrl
|
|
114
|
+
|| terminalTuple.repository !== repository
|
|
115
|
+
|| terminalTuple.number !== number
|
|
116
|
+
|| topLevelTuple.repository !== repository
|
|
117
|
+
|| topLevelTuple.number !== number) {
|
|
118
|
+
throw new Error("Recorded pull-request metadata is inconsistent");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return Object.freeze({ url: topLevelUrl, repository, number });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function lookupPullRequest(cwd, run, options = {}) {
|
|
125
|
+
let recorded;
|
|
126
|
+
try {
|
|
127
|
+
recorded = normalizeRecordedPullRequest(run);
|
|
128
|
+
} catch {
|
|
129
|
+
return { ok: false, reason: "metadata-mismatch", pullRequest: null, command: null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const runner = typeof options.githubRunner === "function" ? options.githubRunner : github;
|
|
133
|
+
let result;
|
|
134
|
+
try {
|
|
135
|
+
result = runner(cwd, pullRequestLookupArgs(recorded.repository, recorded.number), {
|
|
136
|
+
timeout: options.timeout,
|
|
137
|
+
maxBuffer: options.maxBuffer,
|
|
138
|
+
});
|
|
139
|
+
} catch {
|
|
140
|
+
return { ok: false, reason: "lookup-uncertain", pullRequest: null, command: null };
|
|
141
|
+
}
|
|
142
|
+
if (!result || result.ok !== true || result.status !== 0 || typeof result.stdout !== "string") {
|
|
143
|
+
return { ok: false, reason: "lookup-uncertain", pullRequest: null, command: result?.command ?? null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const normalized = normalizePullRequestResponse(JSON.parse(result.stdout));
|
|
148
|
+
if (normalized.url !== recorded.url
|
|
149
|
+
|| normalized.repository !== recorded.repository
|
|
150
|
+
|| normalized.number !== recorded.number) {
|
|
151
|
+
return { ok: false, reason: "lookup-uncertain", pullRequest: null, command: result.command ?? null };
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, reason: null, pullRequest: normalized, command: result.command ?? null };
|
|
154
|
+
} catch {
|
|
155
|
+
return { ok: false, reason: "lookup-uncertain", pullRequest: null, command: result.command ?? null };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizePullRequestState(state, merged) {
|
|
160
|
+
if (typeof state !== "string" || typeof merged !== "boolean") {
|
|
161
|
+
throw new Error("GitHub pull-request response state is invalid");
|
|
162
|
+
}
|
|
163
|
+
if (state === "closed") return merged ? "MERGED" : "CLOSED";
|
|
164
|
+
if (state === "open" && merged === false) return "OPEN";
|
|
165
|
+
throw new Error("GitHub pull-request response state is contradictory");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizePullRequestUrl(value) {
|
|
169
|
+
const input = requireNonEmptyString(value, "GitHub pull-request URL");
|
|
170
|
+
let parsed;
|
|
171
|
+
try {
|
|
172
|
+
parsed = new URL(input);
|
|
173
|
+
} catch {
|
|
174
|
+
throw new Error("GitHub pull-request URL is invalid");
|
|
175
|
+
}
|
|
176
|
+
if (parsed.protocol !== "https:"
|
|
177
|
+
|| parsed.hostname !== "github.com"
|
|
178
|
+
|| parsed.port !== ""
|
|
179
|
+
|| parsed.username !== ""
|
|
180
|
+
|| parsed.password !== ""
|
|
181
|
+
|| parsed.search !== ""
|
|
182
|
+
|| parsed.hash !== "") {
|
|
183
|
+
throw new Error("GitHub pull-request URL is invalid");
|
|
184
|
+
}
|
|
185
|
+
const match = /^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9][0-9]*)$/u.exec(parsed.pathname);
|
|
186
|
+
if (!match) throw new Error("GitHub pull-request URL is invalid");
|
|
187
|
+
const repository = normalizeRepository(`${match[1]}/${match[2]}`);
|
|
188
|
+
const number = normalizePullRequestNumber(Number(match[3]));
|
|
189
|
+
return `https://github.com/${repository}/pull/${number}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function pullRequestUrlTuple(url) {
|
|
193
|
+
const segments = new URL(url).pathname.split("/").filter(Boolean);
|
|
194
|
+
return { repository: `${segments[0]}/${segments[1]}`, number: Number(segments[3]) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeRepository(value) {
|
|
198
|
+
const input = requireNonEmptyString(value, "GitHub repository");
|
|
199
|
+
const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/u.exec(input);
|
|
200
|
+
if (!match) throw new Error("GitHub repository must have shape owner/repo");
|
|
201
|
+
return `${match[1]}/${match[2]}`.toLowerCase();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function normalizePullRequestNumber(value) {
|
|
205
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new Error("GitHub pull-request number must be a positive integer");
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function requireRecord(value, label) {
|
|
210
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function requireNonEmptyString(value, label) {
|
|
215
|
+
if (typeof value !== "string" || value.trim() === "" || value !== value.trim() || value.includes("\0")) {
|
|
216
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
217
|
+
}
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizeArgs(args) {
|
|
222
|
+
if (!Array.isArray(args)) throw new Error("GitHub args must be an array of strings");
|
|
223
|
+
return args.map((arg, index) => {
|
|
224
|
+
if (typeof arg !== "string" || arg === "") throw new Error(`GitHub args[${index}] must be a non-empty string`);
|
|
225
|
+
if (arg.includes("\0")) throw new Error(`GitHub args[${index}] must not contain NUL bytes`);
|
|
226
|
+
return arg;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function boundedPositiveInteger(value, fallback, maximum) {
|
|
231
|
+
if (!Number.isInteger(value) || value <= 0) return fallback;
|
|
232
|
+
return Math.min(value, maximum);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function normalizeOutput(value) {
|
|
236
|
+
if (typeof value === "string") return value;
|
|
237
|
+
if (value == null) return "";
|
|
238
|
+
if (Buffer.isBuffer(value)) return value.toString("utf8");
|
|
239
|
+
return String(value);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeError(error) {
|
|
243
|
+
if (error instanceof Error && error.message) return error.message;
|
|
244
|
+
if (typeof error === "string") return error;
|
|
245
|
+
return String(error ?? "");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function joinErrorOutput(output, error) {
|
|
249
|
+
const normalizedOutput = normalizeOutput(output);
|
|
250
|
+
if (!error || error === normalizedOutput) return normalizedOutput;
|
|
251
|
+
return normalizedOutput === "" ? error : `${normalizedOutput}\n${error}`;
|
|
252
|
+
}
|