@magnusekdahl/parallix 1.0.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 +140 -0
- package/LICENSE +661 -0
- package/README.md +196 -0
- package/config/agents.json +25 -0
- package/config/agents.local.json.template +8 -0
- package/config/state-map.json +4 -0
- package/config/state-map.json.template +31 -0
- package/config/workflow.config.schema.json +98 -0
- package/data/.gitkeep +0 -0
- package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
- package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
- package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
- package/docs/adr/0041-integration-pipeline-gates.md +165 -0
- package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
- package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
- package/docs/adr/0044-workflow-distribution-model.md +277 -0
- package/docs/adr/0045-parallax-branch-model.md +182 -0
- package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
- package/docs/adr/index.md +20 -0
- package/docs/agents.md +212 -0
- package/docs/authority-reference.md +298 -0
- package/docs/forgejo-setup.md +31 -0
- package/docs/migration/extraction.md +61 -0
- package/docs/migration/task-classification.md +36 -0
- package/docs/operator-setup.md +76 -0
- package/docs/readme-rewrite-benchmark.md +188 -0
- package/docs/use-cases.md +105 -0
- package/examples/README.md +62 -0
- package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
- package/examples/run-verify-env-smoke.sh +40 -0
- package/index.js +250 -0
- package/lib/README.md +13 -0
- package/lib/agents/agents.js +867 -0
- package/lib/agents/claude-telemetry.js +233 -0
- package/lib/agents/claude.js +139 -0
- package/lib/agents/codex-telemetry.js +202 -0
- package/lib/agents/codex.js +219 -0
- package/lib/agents/limit-hit.js +252 -0
- package/lib/agents/mistral-telemetry.js +44 -0
- package/lib/agents/mistral.js +68 -0
- package/lib/agents/opencode-export.js +110 -0
- package/lib/agents/opencode-telemetry.js +356 -0
- package/lib/agents/opencode.js +218 -0
- package/lib/agents/stage-telemetry.js +37 -0
- package/lib/commands/active.js +625 -0
- package/lib/commands/checkpoint.js +76 -0
- package/lib/commands/config.js +39 -0
- package/lib/commands/coverage-gate.js +358 -0
- package/lib/commands/diff.js +119 -0
- package/lib/commands/draft.js +854 -0
- package/lib/commands/handoff.js +501 -0
- package/lib/commands/integrate.js +1528 -0
- package/lib/commands/mission-start.js +246 -0
- package/lib/commands/rebase.js +597 -0
- package/lib/commands/repair-handoff.js +227 -0
- package/lib/commands/resolve-conflict.js +109 -0
- package/lib/commands/review.js +13 -0
- package/lib/commands/setup-review.js +13 -0
- package/lib/commands/setup.js +3 -0
- package/lib/commands/stats-backfill.js +395 -0
- package/lib/commands/stats.js +1601 -0
- package/lib/commands/status.js +183 -0
- package/lib/commands/verify.js +1 -0
- package/lib/core/fmt.js +202 -0
- package/lib/core/git.js +73 -0
- package/lib/core/gitignore.js +110 -0
- package/lib/core/mission-utils.js +1017 -0
- package/lib/core/persistent-data-migration.js +201 -0
- package/lib/core/product-config.js +508 -0
- package/lib/core/runtime-matrix.js +82 -0
- package/lib/core/spawn-tee.js +173 -0
- package/lib/core/state-map.js +89 -0
- package/lib/core/storage.js +165 -0
- package/lib/core/verification.js +149 -0
- package/lib/index.js +77 -0
- package/lib/review/rebase.js +163 -0
- package/lib/review/review-adapter.js +135 -0
- package/lib/review/review-artifacts.js +619 -0
- package/lib/review/review-commands.js +1375 -0
- package/lib/review/review-events.js +1007 -0
- package/lib/review/review-loop.js +1004 -0
- package/lib/review/review-polling.js +141 -0
- package/lib/review/review-prompts.js +212 -0
- package/lib/review/review-state.js +280 -0
- package/lib/review/review.js +96 -0
- package/lib/tools/backlog.js +680 -0
- package/lib/tools/forgejo.js +1585 -0
- package/lib/tools/gatekeeper.js +106 -0
- package/lib/tools/sessions.js +74 -0
- package/lib/tools/setup-review.js +1053 -0
- package/package.json +56 -0
- package/prompts/act-on-review-verbose.md +20 -0
- package/prompts/act-on-review.md +22 -0
- package/prompts/draft.md +20 -0
- package/prompts/execute.md +24 -0
- package/prompts/portfolio.md +30 -0
- package/prompts/review-verbose.md +20 -0
- package/prompts/review.md +17 -0
- package/px.js +236 -0
- package/templates/AGENTS-snippet.md +14 -0
- package/templates/AGENTS.md.template +34 -0
- package/templates/CLAUDE.md.template +27 -0
- package/templates/CODEX.md.template +38 -0
- package/templates/MISTRAL.md.template +24 -0
- package/templates/claude-commands/act-on-review.md +3 -0
- package/templates/claude-commands/area-review.md +3 -0
- package/templates/claude-commands/draft.md +6 -0
- package/templates/claude-commands/execute.md +6 -0
- package/templates/claude-commands/integrate.md +4 -0
- package/templates/claude-commands/portfolio.md +5 -0
- package/templates/claude-commands/review.md +4 -0
- package/templates/codex/config.toml +6 -0
- package/templates/mission-scaffold.md +39 -0
- package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
- package/templates/vibe/skills/area-review/SKILL.md +16 -0
- package/templates/vibe/skills/draft/SKILL.md +16 -0
- package/templates/vibe/skills/execute/SKILL.md +16 -0
- package/templates/vibe/skills/integrate/SKILL.md +16 -0
- package/templates/vibe/skills/portfolio/SKILL.md +21 -0
- package/templates/vibe/skills/review/SKILL.md +16 -0
- package/tools/setup-forgejo-docker.sh +84 -0
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const fmt = require('../core/fmt');
|
|
6
|
+
const { eligibleAgentsForStep } = require('../agents/agents');
|
|
7
|
+
const { resolveReviewAdapter } = require('../core/product-config');
|
|
8
|
+
const {
|
|
9
|
+
resolveForgejoHome,
|
|
10
|
+
resolveForgejoSettings,
|
|
11
|
+
reviewRemoteUrl,
|
|
12
|
+
} = require('./forgejo');
|
|
13
|
+
const { loadWorkflowConfig } = require('../core/product-config');
|
|
14
|
+
const { ensureWorkflowGitignore } = require('../core/gitignore');
|
|
15
|
+
|
|
16
|
+
let tokenNameCounter = 0;
|
|
17
|
+
const REVIEW_TOKEN_SCOPES = ['write:user', 'write:repository', 'write:issue', 'write:organization'];
|
|
18
|
+
const PASSWORD_PROMPT_OPTIONS = { hidden: false };
|
|
19
|
+
|
|
20
|
+
function parseRepoSlug(repo) {
|
|
21
|
+
if (typeof repo !== 'string') return null;
|
|
22
|
+
const match = repo.trim().match(/^([^/\s]+)\/([^/\s]+)$/);
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
return { owner: match[1], repo: match[2] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function unique(values) {
|
|
28
|
+
return [...new Set(values.filter(Boolean))];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeBaseUrl(baseUrl) {
|
|
32
|
+
return typeof baseUrl === 'string' ? baseUrl.replace(/\/+$/, '') : baseUrl;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildTokenName(repoName, user, suffix = `${Date.now()}-${++tokenNameCounter}`) {
|
|
36
|
+
return `workflow-${repoName}-${user}-${suffix}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function passwordPrompt(user, options = {}) {
|
|
40
|
+
const { allowBlank = false } = options;
|
|
41
|
+
return allowBlank
|
|
42
|
+
? `Password for ${user} (input visible, leave blank to skip token creation): `
|
|
43
|
+
: `Password for ${user} (input visible): `;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseYesNo(value, defaultValue = true) {
|
|
47
|
+
if (typeof value !== 'string' || value.trim() === '') return defaultValue;
|
|
48
|
+
const normalized = value.trim().toLowerCase();
|
|
49
|
+
if (['y', 'yes'].includes(normalized)) return true;
|
|
50
|
+
if (['n', 'no'].includes(normalized)) return false;
|
|
51
|
+
return defaultValue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function defaultProductName(rootDir = process.cwd()) {
|
|
55
|
+
return path.basename(rootDir) || 'Workflow Project';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defaultRepoSlug(rootDir = process.cwd(), ownerLogin = 'human') {
|
|
59
|
+
return `${ownerLogin}/${path.basename(rootDir) || 'project'}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function detectPrimaryBranchDefault(rootDir = process.cwd(), options = {}) {
|
|
63
|
+
const { spawnSyncFn = spawnSync, configuredPrimaryBranch = null } = options;
|
|
64
|
+
const listResult = spawnSyncFn(
|
|
65
|
+
'git',
|
|
66
|
+
['-C', rootDir, 'branch', '--list', '--format=%(refname:short)', 'main', 'master'],
|
|
67
|
+
{ encoding: 'utf8' }
|
|
68
|
+
);
|
|
69
|
+
const branches = listResult.status === 0
|
|
70
|
+
? (listResult.stdout || '').split('\n').map(value => value.trim()).filter(Boolean)
|
|
71
|
+
: [];
|
|
72
|
+
|
|
73
|
+
if (configuredPrimaryBranch && branches.includes(configuredPrimaryBranch)) {
|
|
74
|
+
return configuredPrimaryBranch;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const currentResult = spawnSyncFn('git', ['-C', rootDir, 'branch', '--show-current'], {
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
});
|
|
80
|
+
const currentBranch = currentResult.status === 0 ? (currentResult.stdout || '').trim() : '';
|
|
81
|
+
if (currentBranch === 'main' || currentBranch === 'master') {
|
|
82
|
+
return currentBranch;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (branches.includes('main')) return 'main';
|
|
86
|
+
if (branches.includes('master')) return 'master';
|
|
87
|
+
if (configuredPrimaryBranch) return configuredPrimaryBranch;
|
|
88
|
+
return 'main';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function standardLayoutDescription() {
|
|
92
|
+
return [
|
|
93
|
+
'standard = Backlog.md task storage in `backlog/`',
|
|
94
|
+
'missions in `docs/missions/`',
|
|
95
|
+
'mission/* branches with auto-detected `main`/`master` primary',
|
|
96
|
+
'worktrees in `../<repo>-<slug>`',
|
|
97
|
+
'verification via `npm test` with default area `docs`',
|
|
98
|
+
].join('; ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildReviewAdapterConfig(answers) {
|
|
102
|
+
const provider = answers.reviewProvider || 'forgejo';
|
|
103
|
+
if (provider !== 'forgejo') {
|
|
104
|
+
return { provider };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
provider: 'forgejo',
|
|
108
|
+
baseUrl: normalizeBaseUrl(answers.baseUrl),
|
|
109
|
+
remote: answers.reviewRemote,
|
|
110
|
+
repo: answers.reviewRepo,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildWorkflowConfig(answers) {
|
|
115
|
+
const missions = {
|
|
116
|
+
baseDir: answers.missionsBaseDir,
|
|
117
|
+
branchPrefix: answers.branchPrefix,
|
|
118
|
+
worktreePattern: answers.worktreePattern,
|
|
119
|
+
};
|
|
120
|
+
if (
|
|
121
|
+
typeof answers.primaryBranch === 'string' &&
|
|
122
|
+
answers.primaryBranch.trim() &&
|
|
123
|
+
!['main', 'master'].includes(answers.primaryBranch.trim())
|
|
124
|
+
) {
|
|
125
|
+
missions.primaryBranch = answers.primaryBranch.trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
product: {
|
|
130
|
+
name: answers.productName,
|
|
131
|
+
targetUser: 'Engineering teams using git, task tracking, and code review',
|
|
132
|
+
},
|
|
133
|
+
adapters: {
|
|
134
|
+
tasks: {
|
|
135
|
+
provider: answers.tasksProvider,
|
|
136
|
+
storage: answers.tasksStorage,
|
|
137
|
+
},
|
|
138
|
+
missions,
|
|
139
|
+
verification: {
|
|
140
|
+
command: answers.verificationCommand,
|
|
141
|
+
defaultArea: answers.verificationDefaultArea,
|
|
142
|
+
},
|
|
143
|
+
review: buildReviewAdapterConfig(answers),
|
|
144
|
+
agents: {},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeWorkflowConfig(rootDir, config) {
|
|
150
|
+
const configPath = path.join(rootDir, 'workflow.config.json');
|
|
151
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
152
|
+
return configPath;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function suggestedForgejoUsers() {
|
|
156
|
+
return unique([
|
|
157
|
+
...eligibleAgentsForStep('active'),
|
|
158
|
+
...eligibleAgentsForStep('review'),
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function tokenFilePath(user, forgejoHome = resolveForgejoHome()) {
|
|
163
|
+
return path.join(forgejoHome, 'tokens', user);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function ensureTokenDir(forgejoHome = resolveForgejoHome()) {
|
|
167
|
+
const dir = path.join(forgejoHome, 'tokens');
|
|
168
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
169
|
+
return dir;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolveBootstrapForgejoHome(rootDir = process.cwd(), explicitForgejoHome) {
|
|
173
|
+
if (explicitForgejoHome) return explicitForgejoHome;
|
|
174
|
+
return path.join(rootDir, '.forgejo-local');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readConfiguredReviewRemote(rootDir = process.cwd(), remoteName = 'review') {
|
|
178
|
+
const result = spawnSync('git', ['-C', rootDir, 'remote', 'get-url', remoteName], {
|
|
179
|
+
encoding: 'utf8',
|
|
180
|
+
});
|
|
181
|
+
if (result.status !== 0) return null;
|
|
182
|
+
return (result.stdout || '').trim() || null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function evaluateReviewSetup(rootDir = process.cwd(), options = {}) {
|
|
186
|
+
const {
|
|
187
|
+
users = suggestedForgejoUsers(),
|
|
188
|
+
remoteName,
|
|
189
|
+
remoteUrlFn = reviewRemoteUrl,
|
|
190
|
+
getRemoteUrlFn = readConfiguredReviewRemote,
|
|
191
|
+
tokenPathFn = tokenFilePath,
|
|
192
|
+
requestFn = apiRequest,
|
|
193
|
+
} = options;
|
|
194
|
+
const reviewAdapter = resolveReviewAdapter(rootDir);
|
|
195
|
+
if (reviewAdapter.provider !== 'forgejo') {
|
|
196
|
+
return {
|
|
197
|
+
required: false,
|
|
198
|
+
ok: true,
|
|
199
|
+
issues: [],
|
|
200
|
+
steps: [],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const review = resolveForgejoSettings(rootDir);
|
|
204
|
+
if (review.repo === '' || !review.url) {
|
|
205
|
+
return {
|
|
206
|
+
required: false,
|
|
207
|
+
ok: true,
|
|
208
|
+
issues: [],
|
|
209
|
+
steps: [],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const expectedRemote = remoteUrlFn(rootDir);
|
|
214
|
+
const configuredRemoteName = remoteName || reviewAdapter.remote || 'review';
|
|
215
|
+
const issues = [];
|
|
216
|
+
const steps = [];
|
|
217
|
+
const missingUsers = users.filter(user => !fs.existsSync(tokenPathFn(user)));
|
|
218
|
+
|
|
219
|
+
if (missingUsers.length > 0) {
|
|
220
|
+
issues.push(`missing Forgejo tokens for: ${missingUsers.join(', ')}`);
|
|
221
|
+
steps.push('Run `px setup` and enter Forgejo passwords for the agent users you plan to run.');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const currentRemote = getRemoteUrlFn(rootDir, configuredRemoteName);
|
|
225
|
+
if (!currentRemote) {
|
|
226
|
+
issues.push(`git remote "${configuredRemoteName}" is missing`);
|
|
227
|
+
steps.push('Run `px setup` to create the review remote.');
|
|
228
|
+
} else if (expectedRemote && currentRemote !== expectedRemote) {
|
|
229
|
+
issues.push(`git remote "${configuredRemoteName}" points at ${currentRemote} instead of ${expectedRemote}`);
|
|
230
|
+
steps.push('Run `px setup` to update the review remote URL.');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (missingUsers.length === 0) {
|
|
234
|
+
const normalizedBaseUrl = normalizeBaseUrl(review.url);
|
|
235
|
+
for (const user of users) {
|
|
236
|
+
const tokenPath = tokenPathFn(user);
|
|
237
|
+
const token = fs.readFileSync(tokenPath, 'utf8').trim();
|
|
238
|
+
if (!token) {
|
|
239
|
+
issues.push(`Forgejo token for ${user} is empty`);
|
|
240
|
+
steps.push('Run `px setup-review` to rotate the empty token file.');
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const probe = requestFn('GET', `${normalizedBaseUrl}/api/v1/repos/${review.repo}`, { token });
|
|
244
|
+
if (isAuthFailure(probe)) {
|
|
245
|
+
issues.push(`Forgejo token for ${user} is invalid or expired (HTTP ${probe.statusCode})`);
|
|
246
|
+
steps.push('Run `px setup-review` and re-enter the Forgejo passwords to rotate local PATs.');
|
|
247
|
+
} else if (!probe.ok && probe.statusCode === 404) {
|
|
248
|
+
issues.push(`Forgejo review repo ${review.repo} is missing or inaccessible for ${user}`);
|
|
249
|
+
steps.push('Run `px setup` to recreate the review repo and refresh token/remote wiring.');
|
|
250
|
+
} else if (!probe.ok && probe.statusCode == null) {
|
|
251
|
+
issues.push(`Forgejo at ${normalizedBaseUrl} is unreachable while validating ${user}`);
|
|
252
|
+
steps.push('Start Forgejo and rerun `px verify-env` or `px setup-review`.');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (issues.length === 0) {
|
|
258
|
+
return { required: true, ok: true, issues: [], steps: [] };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { required: true, ok: false, issues, steps };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function apiRequest(method, requestUrl, options = {}) {
|
|
265
|
+
const args = [
|
|
266
|
+
'-s',
|
|
267
|
+
'-X', method,
|
|
268
|
+
'-H', 'Content-Type: application/json',
|
|
269
|
+
'-w', '\n%{http_code}',
|
|
270
|
+
];
|
|
271
|
+
if (options.basicAuth) {
|
|
272
|
+
args.push('-u', `${options.basicAuth.user}:${options.basicAuth.password}`);
|
|
273
|
+
}
|
|
274
|
+
if (options.token) {
|
|
275
|
+
args.push('-H', `Authorization: token ${options.token}`);
|
|
276
|
+
}
|
|
277
|
+
if (options.body) {
|
|
278
|
+
args.push('--data-binary', '@-');
|
|
279
|
+
}
|
|
280
|
+
args.push(requestUrl);
|
|
281
|
+
|
|
282
|
+
const result = spawnSync('curl', args, {
|
|
283
|
+
encoding: 'utf8',
|
|
284
|
+
input: options.body ? JSON.stringify(options.body) : undefined,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (result.status !== 0 || !result.stdout) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
statusCode: null,
|
|
291
|
+
error: (result.stderr || result.stdout || 'curl failed').trim(),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const output = result.stdout.trim();
|
|
296
|
+
const lastLineIndex = output.lastIndexOf('\n');
|
|
297
|
+
const statusCode = Number(lastLineIndex === -1 ? output : output.slice(lastLineIndex + 1));
|
|
298
|
+
const payload = lastLineIndex === -1 ? '' : output.slice(0, lastLineIndex).trim();
|
|
299
|
+
|
|
300
|
+
let data = null;
|
|
301
|
+
if (payload) {
|
|
302
|
+
try {
|
|
303
|
+
data = JSON.parse(payload);
|
|
304
|
+
} catch (_) {
|
|
305
|
+
data = payload;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
ok: statusCode >= 200 && statusCode < 300,
|
|
311
|
+
statusCode,
|
|
312
|
+
data,
|
|
313
|
+
error: null,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function createToken(baseUrl, user, password, tokenName, requestFn = apiRequest, scopes = REVIEW_TOKEN_SCOPES) {
|
|
318
|
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
319
|
+
const result = requestFn('POST', `${normalizedBaseUrl}/api/v1/users/${encodeURIComponent(user)}/tokens`, {
|
|
320
|
+
basicAuth: { user, password },
|
|
321
|
+
body: { name: tokenName, scopes },
|
|
322
|
+
});
|
|
323
|
+
if (!result.ok) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
error: `token creation failed for ${user} (HTTP ${result.statusCode || 'n/a'})`,
|
|
327
|
+
statusCode: result.statusCode,
|
|
328
|
+
response: result.data,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const token = result.data && result.data.sha1;
|
|
332
|
+
if (!token) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
error: `token creation for ${user} did not return a token`,
|
|
336
|
+
statusCode: result.statusCode,
|
|
337
|
+
response: result.data,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return { ok: true, token };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function ensureRepo(baseUrl, repoSlug, ownerLogin, ownerToken, requestFn = apiRequest) {
|
|
344
|
+
const repoInfo = parseRepoSlug(repoSlug);
|
|
345
|
+
if (!repoInfo) {
|
|
346
|
+
return { ok: false, error: `invalid review repo slug: ${repoSlug}` };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
350
|
+
const existing = requestFn('GET', `${normalizedBaseUrl}/api/v1/repos/${repoInfo.owner}/${repoInfo.repo}`, {
|
|
351
|
+
token: ownerToken,
|
|
352
|
+
});
|
|
353
|
+
if (existing.ok) {
|
|
354
|
+
return { ok: true, created: false };
|
|
355
|
+
}
|
|
356
|
+
if (existing.statusCode && existing.statusCode !== 404) {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
error: `failed to check review repo ${repoSlug} (HTTP ${existing.statusCode})`,
|
|
360
|
+
response: existing.data,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const payload = { name: repoInfo.repo, private: true, auto_init: false, default_branch: 'main' };
|
|
365
|
+
const createUrl = repoInfo.owner === ownerLogin
|
|
366
|
+
? `${normalizedBaseUrl}/api/v1/user/repos`
|
|
367
|
+
: `${normalizedBaseUrl}/api/v1/orgs/${encodeURIComponent(repoInfo.owner)}/repos`;
|
|
368
|
+
const created = requestFn('POST', createUrl, {
|
|
369
|
+
token: ownerToken,
|
|
370
|
+
body: payload,
|
|
371
|
+
});
|
|
372
|
+
if (!created.ok) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
error: `failed to create review repo ${repoSlug} (HTTP ${created.statusCode || 'n/a'})`,
|
|
376
|
+
response: created.data,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
return { ok: true, created: true };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function ensureRepoCollaborator(baseUrl, repoSlug, ownerToken, collaborator, permission = 'write', requestFn = apiRequest) {
|
|
383
|
+
const repoInfo = parseRepoSlug(repoSlug);
|
|
384
|
+
if (!repoInfo) {
|
|
385
|
+
return { ok: false, error: `invalid review repo slug: ${repoSlug}` };
|
|
386
|
+
}
|
|
387
|
+
if (!collaborator || collaborator === repoInfo.owner) {
|
|
388
|
+
return { ok: true, skipped: true };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
392
|
+
const result = requestFn('PUT', `${normalizedBaseUrl}/api/v1/repos/${repoInfo.owner}/${repoInfo.repo}/collaborators/${encodeURIComponent(collaborator)}`, {
|
|
393
|
+
token: ownerToken,
|
|
394
|
+
body: { permission },
|
|
395
|
+
});
|
|
396
|
+
if (!result.ok) {
|
|
397
|
+
return {
|
|
398
|
+
ok: false,
|
|
399
|
+
error: `failed to add review collaborator ${collaborator} to ${repoSlug} (HTTP ${result.statusCode || 'n/a'})`,
|
|
400
|
+
response: result.data,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
return { ok: true, created: true };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function ensureRepoCollaborators(baseUrl, repoSlug, ownerToken, collaborators = [], permission = 'write', requestFn = apiRequest) {
|
|
407
|
+
const uniqueCollaborators = unique(collaborators.map(user => (typeof user === 'string' ? user.trim() : '')).filter(Boolean));
|
|
408
|
+
const created = [];
|
|
409
|
+
for (const collaborator of uniqueCollaborators) {
|
|
410
|
+
const result = ensureRepoCollaborator(baseUrl, repoSlug, ownerToken, collaborator, permission, requestFn);
|
|
411
|
+
if (!result.ok) {
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
if (result.created) {
|
|
415
|
+
created.push(collaborator);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return { ok: true, created };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function ensureReviewRemote(rootDir, remoteName, remoteUrl) {
|
|
422
|
+
const current = readConfiguredReviewRemote(rootDir, remoteName);
|
|
423
|
+
const args = current
|
|
424
|
+
? ['-C', rootDir, 'remote', 'set-url', remoteName, remoteUrl]
|
|
425
|
+
: ['-C', rootDir, 'remote', 'add', remoteName, remoteUrl];
|
|
426
|
+
const result = spawnSync('git', args, { encoding: 'utf8' });
|
|
427
|
+
if (result.status !== 0) {
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
error: (result.stderr || result.stdout || `git remote ${current ? 'set-url' : 'add'} failed`).trim(),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return { ok: true, created: !current, updated: Boolean(current && current !== remoteUrl) };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function promptLine(prompt, options = {}) {
|
|
437
|
+
const { hidden = false, input = process.stdin, output = process.stdout } = options;
|
|
438
|
+
return await new Promise((resolve) => {
|
|
439
|
+
const rl = readline.createInterface({ input, output, terminal: true });
|
|
440
|
+
if (!hidden) {
|
|
441
|
+
rl.question(prompt, answer => {
|
|
442
|
+
rl.close();
|
|
443
|
+
resolve(answer.trim());
|
|
444
|
+
});
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const originalWrite = output.write.bind(output);
|
|
449
|
+
const maskChunk = (chunk) => {
|
|
450
|
+
const value = typeof chunk === 'string' ? chunk : String(chunk);
|
|
451
|
+
let masked = '';
|
|
452
|
+
for (const character of value) {
|
|
453
|
+
if (character === '\r' || character === '\n') {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (character === '\b' || character === '\x7f') {
|
|
457
|
+
masked += '\b \b';
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (character < ' ' || character === '\u001b') {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
masked += '*';
|
|
464
|
+
}
|
|
465
|
+
return masked;
|
|
466
|
+
};
|
|
467
|
+
output.write = (chunk, encoding, cb) => {
|
|
468
|
+
if (rl.stdoutMuted) {
|
|
469
|
+
const masked = maskChunk(chunk);
|
|
470
|
+
if (masked) {
|
|
471
|
+
return originalWrite(masked, encoding, cb);
|
|
472
|
+
}
|
|
473
|
+
if (typeof cb === 'function') cb();
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
return originalWrite(chunk, encoding, cb);
|
|
477
|
+
};
|
|
478
|
+
rl.stdoutMuted = true;
|
|
479
|
+
originalWrite(prompt);
|
|
480
|
+
rl.question('', answer => {
|
|
481
|
+
output.write = originalWrite;
|
|
482
|
+
rl.close();
|
|
483
|
+
output.write('\n');
|
|
484
|
+
resolve(answer.trim());
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isAuthFailure(result) {
|
|
490
|
+
return Boolean(result && (result.statusCode === 401 || result.statusCode === 403));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function createTokenWithRetries(setup, user, initialPassword, options = {}) {
|
|
494
|
+
const {
|
|
495
|
+
allowBlank = false,
|
|
496
|
+
log = fmt.log.info,
|
|
497
|
+
promptFn,
|
|
498
|
+
maxAttempts = 3,
|
|
499
|
+
requestFn = apiRequest,
|
|
500
|
+
scopes = REVIEW_TOKEN_SCOPES,
|
|
501
|
+
} = options;
|
|
502
|
+
|
|
503
|
+
const repoInfo = parseRepoSlug(setup.repo);
|
|
504
|
+
if (!repoInfo) {
|
|
505
|
+
return { ok: false, error: `invalid review repo slug: ${setup.repo}` };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let password = typeof initialPassword === 'string' ? initialPassword : '';
|
|
509
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
510
|
+
if (!password) {
|
|
511
|
+
if (typeof promptFn !== 'function') {
|
|
512
|
+
return allowBlank
|
|
513
|
+
? { ok: true, skipped: true }
|
|
514
|
+
: { ok: false, error: `Password is required for ${user}.` };
|
|
515
|
+
}
|
|
516
|
+
password = await promptFn(
|
|
517
|
+
passwordPrompt(user, { allowBlank }),
|
|
518
|
+
PASSWORD_PROMPT_OPTIONS
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!password) {
|
|
523
|
+
if (allowBlank) {
|
|
524
|
+
return { ok: true, skipped: true };
|
|
525
|
+
}
|
|
526
|
+
if (attempt === maxAttempts) {
|
|
527
|
+
return { ok: false, error: `Password is required for ${user}.` };
|
|
528
|
+
}
|
|
529
|
+
log(fmt.status('WARN', `Password is required for ${user}. Attempt ${attempt} of ${maxAttempts}.`));
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const tokenResult = createToken(
|
|
534
|
+
setup.baseUrl,
|
|
535
|
+
user,
|
|
536
|
+
password,
|
|
537
|
+
buildTokenName(repoInfo.repo, user),
|
|
538
|
+
requestFn,
|
|
539
|
+
scopes
|
|
540
|
+
);
|
|
541
|
+
if (tokenResult.ok) return tokenResult;
|
|
542
|
+
if (!isAuthFailure(tokenResult) || attempt === maxAttempts || typeof promptFn !== 'function') {
|
|
543
|
+
return tokenResult;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
log(fmt.status('WARN', `Forgejo authentication failed for ${user}. Attempt ${attempt} of ${maxAttempts}; please try again.`));
|
|
547
|
+
password = '';
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return { ok: false, error: `token creation failed for ${user}` };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function collectSetupAnswers(rootDir = process.cwd(), options = {}) {
|
|
554
|
+
const {
|
|
555
|
+
promptFn = promptLine,
|
|
556
|
+
log = fmt.log.info,
|
|
557
|
+
users = suggestedForgejoUsers(),
|
|
558
|
+
} = options;
|
|
559
|
+
const review = resolveForgejoSettings(rootDir);
|
|
560
|
+
const repoInfo = parseRepoSlug(review.repo || '');
|
|
561
|
+
const defaultOwnerLogin = repoInfo ? repoInfo.owner : 'human';
|
|
562
|
+
const defaultUsers = users.join(',');
|
|
563
|
+
|
|
564
|
+
log(fmt.status('INFO', `Forgejo setup target: ${review.url || 'unset'} / ${review.repo || 'unset'}`));
|
|
565
|
+
log(fmt.status('INFO', 'Forgejo review bootstrap will ask for one repo-creating login password and then optional passwords for each listed agent user.'));
|
|
566
|
+
log(fmt.status('INFO', 'Leave an agent password blank to skip writing a token for that user on this machine.'));
|
|
567
|
+
|
|
568
|
+
const ownerLogin = (await promptFn(`Forgejo login with repo-create access [${defaultOwnerLogin}]: `)) || defaultOwnerLogin;
|
|
569
|
+
const ownerPassword = await promptFn(passwordPrompt(ownerLogin), PASSWORD_PROMPT_OPTIONS);
|
|
570
|
+
const userList = (await promptFn(`Agent Forgejo users [${defaultUsers}]: `)) || defaultUsers;
|
|
571
|
+
const agentUsers = unique(userList.split(',').map(value => value.trim()).filter(Boolean));
|
|
572
|
+
const agentPasswords = [];
|
|
573
|
+
|
|
574
|
+
for (const user of agentUsers) {
|
|
575
|
+
const password = await promptFn(passwordPrompt(user, { allowBlank: true }), PASSWORD_PROMPT_OPTIONS);
|
|
576
|
+
if (password) {
|
|
577
|
+
agentPasswords.push({ user, password });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
baseUrl: review.url,
|
|
583
|
+
repo: review.repo,
|
|
584
|
+
ownerLogin,
|
|
585
|
+
ownerPassword,
|
|
586
|
+
agentPasswords,
|
|
587
|
+
agentUsers,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function collectWizardAnswers(rootDir = process.cwd(), options = {}) {
|
|
592
|
+
const {
|
|
593
|
+
promptFn = promptLine,
|
|
594
|
+
log = fmt.log.info,
|
|
595
|
+
users = suggestedForgejoUsers(),
|
|
596
|
+
loadWorkflowConfigFn = loadWorkflowConfig,
|
|
597
|
+
detectPrimaryBranchDefaultFn = detectPrimaryBranchDefault,
|
|
598
|
+
} = options;
|
|
599
|
+
|
|
600
|
+
const existing = loadWorkflowConfigFn(rootDir);
|
|
601
|
+
const existingConfig = existing.found ? existing.config : null;
|
|
602
|
+
const existingAdapters = existingConfig && existingConfig.adapters ? existingConfig.adapters : {};
|
|
603
|
+
const existingReview = existingAdapters.review || {};
|
|
604
|
+
const existingTasks = existingAdapters.tasks || {};
|
|
605
|
+
const existingMissions = existingAdapters.missions || {};
|
|
606
|
+
const existingVerification = existingAdapters.verification || {};
|
|
607
|
+
const defaultPrimaryBranch = detectPrimaryBranchDefaultFn(rootDir, {
|
|
608
|
+
configuredPrimaryBranch: existingMissions.primaryBranch || null,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const productName = (await promptFn(`Product name [${(existingConfig && existingConfig.product && existingConfig.product.name) || defaultProductName(rootDir)}]: `))
|
|
612
|
+
|| (existingConfig && existingConfig.product && existingConfig.product.name)
|
|
613
|
+
|| defaultProductName(rootDir);
|
|
614
|
+
log(fmt.status('INFO', `Standard workflow layout: ${standardLayoutDescription()}`));
|
|
615
|
+
const useDefaults = parseYesNo(
|
|
616
|
+
await promptFn('Use the standard Backlog.md-style workflow layout and defaults? [Y/n]: '),
|
|
617
|
+
true
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
const ownerLoginDefault = parseRepoSlug(existingReview.repo || '')?.owner || 'human';
|
|
621
|
+
const ownerLogin = (await promptFn(`Forgejo login with repo-create access [${ownerLoginDefault}]: `)) || ownerLoginDefault;
|
|
622
|
+
const repoDefault = existingReview.repo || defaultRepoSlug(rootDir, ownerLogin);
|
|
623
|
+
const reviewRepo = (await promptFn(`Forgejo review repo [${repoDefault}]: `)) || repoDefault;
|
|
624
|
+
const baseUrl = normalizeBaseUrl((await promptFn(`Forgejo base URL [${existingReview.baseUrl || 'http://localhost:3300'}]: `)) || existingReview.baseUrl || 'http://localhost:3300');
|
|
625
|
+
const reviewRemote = (await promptFn(`Git review remote name [${existingReview.remote || 'review'}]: `)) || existingReview.remote || 'review';
|
|
626
|
+
|
|
627
|
+
if (!useDefaults) {
|
|
628
|
+
log(fmt.status('INFO',
|
|
629
|
+
'Verification command runs the primary gate. Put the {{area}} token where the detected ' +
|
|
630
|
+
'changed-area name (docs, workflow, server, ...) should be substituted. Typical patterns: ' +
|
|
631
|
+
'"./scripts/verify-local.sh {{area}}" (shell script), "npm run verify -- {{area}}" (npm ' +
|
|
632
|
+
'script), or "make verify-{{area}}" (make target). Omit {{area}} to always run one command; ' +
|
|
633
|
+
'leave it as `npm test` for the no-op-friendly default.'));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const defaults = useDefaults ? {
|
|
637
|
+
tasksProvider: 'backlog-md',
|
|
638
|
+
tasksStorage: 'backlog',
|
|
639
|
+
missionsBaseDir: 'docs/missions',
|
|
640
|
+
branchPrefix: 'mission/',
|
|
641
|
+
primaryBranch: defaultPrimaryBranch,
|
|
642
|
+
worktreePattern: '../<repo>-<slug>',
|
|
643
|
+
verificationCommand: 'npm test',
|
|
644
|
+
verificationDefaultArea: 'docs',
|
|
645
|
+
} : {
|
|
646
|
+
tasksProvider: (await promptFn(`Task provider [${existingTasks.provider || 'backlog-md'}]: `)) || existingTasks.provider || 'backlog-md',
|
|
647
|
+
tasksStorage: (await promptFn(`Task storage path [${existingTasks.storage || 'backlog'}]: `)) || existingTasks.storage || 'backlog',
|
|
648
|
+
missionsBaseDir: (await promptFn(`Mission base dir [${existingMissions.baseDir || 'docs/missions'}]: `)) || existingMissions.baseDir || 'docs/missions',
|
|
649
|
+
branchPrefix: (await promptFn(`Mission branch prefix [${existingMissions.branchPrefix || 'mission/'}]: `)) || existingMissions.branchPrefix || 'mission/',
|
|
650
|
+
primaryBranch: (await promptFn(`Primary branch [${defaultPrimaryBranch}]: `)) || defaultPrimaryBranch,
|
|
651
|
+
worktreePattern: (await promptFn(`Worktree pattern [${existingMissions.worktreePattern || '../<repo>-<slug>'}]: `)) || existingMissions.worktreePattern || '../<repo>-<slug>',
|
|
652
|
+
verificationCommand: (await promptFn(`Verification command [${existingVerification.command || 'npm test'}]: `)) || existingVerification.command || 'npm test',
|
|
653
|
+
verificationDefaultArea: (await promptFn(`Default verification area [${existingVerification.defaultArea || 'docs'}]: `)) || existingVerification.defaultArea || 'docs',
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const bootstrapReview = parseYesNo(
|
|
657
|
+
await promptFn('Create/update the Forgejo repo, token files, and review remote now? This will ask for the repo-owner password and optional agent passwords. [Y/n]: '),
|
|
658
|
+
true
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
log(fmt.status('INFO', `Setup target: ${baseUrl} / ${reviewRepo}`));
|
|
662
|
+
|
|
663
|
+
let ownerPassword = '';
|
|
664
|
+
const agentPasswords = [];
|
|
665
|
+
const defaultUsers = users.join(',');
|
|
666
|
+
let agentUsers = users;
|
|
667
|
+
if (bootstrapReview) {
|
|
668
|
+
log(fmt.status('INFO', 'Forgejo bootstrap is enabled. Hidden password prompts are next: first for the repo-creating login, then once per listed agent user.'));
|
|
669
|
+
ownerPassword = await promptFn(passwordPrompt(ownerLogin), PASSWORD_PROMPT_OPTIONS);
|
|
670
|
+
const userList = (await promptFn(`Agent Forgejo users [${defaultUsers}]: `)) || defaultUsers;
|
|
671
|
+
agentUsers = unique(userList.split(',').map(value => value.trim()).filter(Boolean));
|
|
672
|
+
for (const user of agentUsers) {
|
|
673
|
+
const password = await promptFn(passwordPrompt(user, { allowBlank: true }), PASSWORD_PROMPT_OPTIONS);
|
|
674
|
+
if (password) {
|
|
675
|
+
agentPasswords.push({ user, password });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
productName,
|
|
682
|
+
ownerLogin,
|
|
683
|
+
baseUrl,
|
|
684
|
+
reviewRepo,
|
|
685
|
+
reviewRemote,
|
|
686
|
+
bootstrapReview,
|
|
687
|
+
ownerPassword,
|
|
688
|
+
agentPasswords,
|
|
689
|
+
agentUsers,
|
|
690
|
+
...defaults,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function writeToken(user, token, forgejoHome = resolveForgejoHome()) {
|
|
695
|
+
ensureTokenDir(forgejoHome);
|
|
696
|
+
const file = tokenFilePath(user, forgejoHome);
|
|
697
|
+
fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
|
|
698
|
+
return file;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Create Forgejo tokens for agent users using an existing owner token.
|
|
703
|
+
* This is the non-interactive bootstrap path: the owner token (admin-level)
|
|
704
|
+
* is used to authenticate POST requests to the Forgejo user-token API.
|
|
705
|
+
*
|
|
706
|
+
* @param {string} baseUrl - Forgejo base URL
|
|
707
|
+
* @param {string} repoSlug - Repository slug for token naming
|
|
708
|
+
* @param {string} ownerToken - Owner's Forgejo PAT
|
|
709
|
+
* @param {Array} agentPasswords - Array of {user, password} to create tokens for
|
|
710
|
+
* @param {object} repoInfo - Parsed repo slug {owner, repo}
|
|
711
|
+
* @param {object} options - Injected dependencies
|
|
712
|
+
* @returns {{ ok: boolean, createdTokens: Array, warnings?: Array }}
|
|
713
|
+
*/
|
|
714
|
+
function tokenCreateViaOwnerToken(baseUrl, repoSlug, ownerToken, agentPasswords, repoInfo, options = {}) {
|
|
715
|
+
const { requestFn = apiRequest, writeTokenFn, buildTokenNameFn = buildTokenName, log = fmt.log.info, forgejoHome: injectedForgejoHome } = options;
|
|
716
|
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
717
|
+
const forgejoHome = typeof injectedForgejoHome === 'string' ? injectedForgejoHome : resolveForgejoHome();
|
|
718
|
+
|
|
719
|
+
const createdTokens = [];
|
|
720
|
+
const warnings = [];
|
|
721
|
+
|
|
722
|
+
for (const agent of agentPasswords) {
|
|
723
|
+
const tokenName = buildTokenNameFn(repoInfo.repo, agent.user);
|
|
724
|
+
const result = requestFn('POST', `${normalizedBaseUrl}/api/v1/users/${encodeURIComponent(agent.user)}/tokens`, {
|
|
725
|
+
token: ownerToken,
|
|
726
|
+
body: { name: tokenName, scopes: REVIEW_TOKEN_SCOPES },
|
|
727
|
+
});
|
|
728
|
+
if (!result.ok) {
|
|
729
|
+
warnings.push({
|
|
730
|
+
user: agent.user,
|
|
731
|
+
error: `token creation via owner token failed (HTTP ${result.statusCode || 'n/a'})`,
|
|
732
|
+
response: result.data,
|
|
733
|
+
});
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
const token = result.data && result.data.sha1;
|
|
737
|
+
if (!token) {
|
|
738
|
+
warnings.push({
|
|
739
|
+
user: agent.user,
|
|
740
|
+
error: 'token creation via owner token did not return a token',
|
|
741
|
+
response: result.data,
|
|
742
|
+
});
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const filePath = writeTokenFn(agent.user, token, forgejoHome);
|
|
746
|
+
createdTokens.push({ user: agent.user, path: filePath });
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (createdTokens.length === 0) {
|
|
750
|
+
const firstWarning = warnings[0];
|
|
751
|
+
const detail = firstWarning
|
|
752
|
+
? `${firstWarning.user}: ${firstWarning.error}`
|
|
753
|
+
: 'no requested agent token was created';
|
|
754
|
+
return { ok: false, createdTokens, warnings, error: detail };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return { ok: true, createdTokens, warnings };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function bootstrapReviewSurface(rootDir = process.cwd(), setup, options = {}) {
|
|
761
|
+
const {
|
|
762
|
+
log = fmt.log.info,
|
|
763
|
+
promptFn,
|
|
764
|
+
requestFn = apiRequest,
|
|
765
|
+
maxPasswordAttempts = 3,
|
|
766
|
+
interactive = true,
|
|
767
|
+
} = options;
|
|
768
|
+
const forgejoHome = resolveBootstrapForgejoHome(rootDir, options.forgejoHome);
|
|
769
|
+
const repoInfo = parseRepoSlug(setup.repo);
|
|
770
|
+
if (!setup.baseUrl || !repoInfo) {
|
|
771
|
+
return { ok: false, error: 'workflow.config.json must define adapters.review.baseUrl and adapters.review.repo before setup can run.' };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const baseUrl = normalizeBaseUrl(setup.baseUrl);
|
|
775
|
+
const collaboratorUsers = unique([
|
|
776
|
+
...(Array.isArray(setup.agentUsers) ? setup.agentUsers : []),
|
|
777
|
+
...(Array.isArray(setup.agentPasswords) ? setup.agentPasswords.map(agent => agent.user) : []),
|
|
778
|
+
].filter(Boolean));
|
|
779
|
+
|
|
780
|
+
if (interactive) {
|
|
781
|
+
// Original interactive path: prompt for owner password, create tokens for all users
|
|
782
|
+
const ownerTokenResult = await createTokenWithRetries({
|
|
783
|
+
...setup,
|
|
784
|
+
baseUrl,
|
|
785
|
+
}, setup.ownerLogin, setup.ownerPassword, {
|
|
786
|
+
allowBlank: false,
|
|
787
|
+
log,
|
|
788
|
+
promptFn,
|
|
789
|
+
maxAttempts: maxPasswordAttempts,
|
|
790
|
+
requestFn,
|
|
791
|
+
});
|
|
792
|
+
if (!ownerTokenResult.ok) return ownerTokenResult;
|
|
793
|
+
|
|
794
|
+
const ownerTokenPath = writeToken(setup.ownerLogin, ownerTokenResult.token, forgejoHome);
|
|
795
|
+
const repoResult = ensureRepo(baseUrl, setup.repo, setup.ownerLogin, ownerTokenResult.token, requestFn);
|
|
796
|
+
if (!repoResult.ok) return repoResult;
|
|
797
|
+
|
|
798
|
+
const collaboratorResult = ensureRepoCollaborators(baseUrl, setup.repo, ownerTokenResult.token, collaboratorUsers, 'write', requestFn);
|
|
799
|
+
if (!collaboratorResult.ok) return collaboratorResult;
|
|
800
|
+
if (collaboratorResult.created.length > 0) {
|
|
801
|
+
log(fmt.status('PASS', `Granted write access on ${setup.repo} to ${collaboratorResult.created.join(', ')}`));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const createdTokens = [{ user: setup.ownerLogin, path: ownerTokenPath }];
|
|
805
|
+
const warnings = [];
|
|
806
|
+
for (const agent of setup.agentPasswords) {
|
|
807
|
+
const tokenResult = await createTokenWithRetries({
|
|
808
|
+
...setup,
|
|
809
|
+
baseUrl,
|
|
810
|
+
}, agent.user, agent.password, {
|
|
811
|
+
allowBlank: true,
|
|
812
|
+
log,
|
|
813
|
+
promptFn,
|
|
814
|
+
maxAttempts: maxPasswordAttempts,
|
|
815
|
+
requestFn,
|
|
816
|
+
});
|
|
817
|
+
if (!tokenResult.ok) {
|
|
818
|
+
warnings.push({
|
|
819
|
+
user: agent.user,
|
|
820
|
+
error: tokenResult.error,
|
|
821
|
+
response: tokenResult.response,
|
|
822
|
+
});
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (tokenResult.skipped) continue;
|
|
826
|
+
createdTokens.push({ user: agent.user, path: writeToken(agent.user, tokenResult.token, forgejoHome) });
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const remoteUrl = reviewRemoteUrl(rootDir);
|
|
830
|
+
const remoteName = resolveReviewAdapter(rootDir).remote || 'review';
|
|
831
|
+
const remoteResult = remoteUrl ? ensureReviewRemote(rootDir, remoteName, remoteUrl) : { ok: true, created: false, updated: false };
|
|
832
|
+
if (!remoteResult.ok) return remoteResult;
|
|
833
|
+
|
|
834
|
+
log(fmt.status('PASS', `Forgejo repo ${setup.repo} ${repoResult.created ? 'created' : 'already exists'}.`));
|
|
835
|
+
for (const tokenInfo of createdTokens) {
|
|
836
|
+
log(fmt.status('PASS', `Wrote Forgejo token for ${tokenInfo.user} to ${tokenInfo.path}`));
|
|
837
|
+
}
|
|
838
|
+
for (const warning of warnings) {
|
|
839
|
+
log(fmt.status('WARN', `Skipping Forgejo token for ${warning.user}: ${warning.error}`));
|
|
840
|
+
if (warning.response) {
|
|
841
|
+
log(fmt.status('INFO', `Forgejo response for ${warning.user}: ${JSON.stringify(warning.response)}`));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
log(fmt.status('PASS', `Review remote "${remoteName}" now points at ${remoteUrl}`));
|
|
845
|
+
|
|
846
|
+
return { ok: true, warnings };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Non-interactive path: use an existing owner token (e.g. human) to create
|
|
850
|
+
// tokens for agent users via the Forgejo user-token creation API.
|
|
851
|
+
// This is the bootstrap path called from handoff when an agent user's token
|
|
852
|
+
// is missing but another user has a valid token file.
|
|
853
|
+
const ownerTokenPath = path.join(forgejoHome, 'tokens', setup.ownerLogin);
|
|
854
|
+
let ownerToken = null;
|
|
855
|
+
if (fs.existsSync(ownerTokenPath)) {
|
|
856
|
+
ownerToken = fs.readFileSync(ownerTokenPath, 'utf8').trim();
|
|
857
|
+
}
|
|
858
|
+
if (!ownerToken) {
|
|
859
|
+
return { ok: false, error: `No owner token found for ${setup.ownerLogin} at ${ownerTokenPath}. A token file for an existing user (typically human) is required to bootstrap new agent tokens.` };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const repoResult = ensureRepo(baseUrl, setup.repo, setup.ownerLogin, ownerToken, requestFn);
|
|
863
|
+
if (!repoResult.ok) return repoResult;
|
|
864
|
+
|
|
865
|
+
const collaboratorResult = ensureRepoCollaborators(baseUrl, setup.repo, ownerToken, collaboratorUsers, 'write', requestFn);
|
|
866
|
+
if (!collaboratorResult.ok) return collaboratorResult;
|
|
867
|
+
if (collaboratorResult.created.length > 0) {
|
|
868
|
+
log(fmt.status('PASS', `Granted write access on ${setup.repo} to ${collaboratorResult.created.join(', ')}`));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const tokensResult = tokenCreateViaOwnerToken(baseUrl, setup.repo, ownerToken, setup.agentPasswords, repoInfo, { requestFn, writeTokenFn: writeToken, forgejoHome, log });
|
|
872
|
+
if (!tokensResult.ok) return tokensResult;
|
|
873
|
+
|
|
874
|
+
const { createdTokens, warnings } = tokensResult;
|
|
875
|
+
for (const tokenInfo of createdTokens) {
|
|
876
|
+
log(fmt.status('PASS', `Wrote Forgejo token for ${tokenInfo.user} to ${tokenInfo.path}`));
|
|
877
|
+
}
|
|
878
|
+
for (const warning of warnings) {
|
|
879
|
+
log(fmt.status('WARN', `Skipping Forgejo token for ${warning.user}: ${warning.error}`));
|
|
880
|
+
if (warning.response) {
|
|
881
|
+
log(fmt.status('INFO', `Forgejo response for ${warning.user}: ${JSON.stringify(warning.response)}`));
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const remoteUrl = reviewRemoteUrl(rootDir);
|
|
886
|
+
const remoteName = resolveReviewAdapter(rootDir).remote || 'review';
|
|
887
|
+
const remoteResult = remoteUrl ? ensureReviewRemote(rootDir, remoteName, remoteUrl) : { ok: true, created: false, updated: false };
|
|
888
|
+
if (!remoteResult.ok) return remoteResult;
|
|
889
|
+
|
|
890
|
+
log(fmt.status('PASS', `Forgejo repo ${setup.repo} ${repoResult.created ? 'created' : 'already exists'}.`));
|
|
891
|
+
log(fmt.status('PASS', `Review remote "${remoteName}" now points at ${remoteUrl}`));
|
|
892
|
+
|
|
893
|
+
return { ok: true, warnings, createdTokens };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function setupReview(_args, options = {}) {
|
|
897
|
+
const rootDir = options.rootDir || process.cwd();
|
|
898
|
+
const log = options.log || fmt.log.plain;
|
|
899
|
+
const error = options.error || fmt.log.plainError;
|
|
900
|
+
const exit = options.exit || process.exit;
|
|
901
|
+
const promptFn = options.promptFn || promptLine;
|
|
902
|
+
const setup = await collectSetupAnswers(rootDir, { promptFn, log });
|
|
903
|
+
const result = await bootstrapReviewSurface(rootDir, setup, {
|
|
904
|
+
...options,
|
|
905
|
+
forgejoHome: resolveBootstrapForgejoHome(rootDir, options.forgejoHome),
|
|
906
|
+
});
|
|
907
|
+
if (!result.ok) {
|
|
908
|
+
error(fmt.status('FAIL', result.error));
|
|
909
|
+
if (result.response) {
|
|
910
|
+
error(fmt.status('INFO', `Forgejo response: ${JSON.stringify(result.response)}`));
|
|
911
|
+
}
|
|
912
|
+
exit(1);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function runVerifyEnv(rootDir, options = {}) {
|
|
918
|
+
const verifyFn = options.verifyFn || ((cwd) => spawnSync('node', ['workflow', 'verify-env'], {
|
|
919
|
+
cwd,
|
|
920
|
+
stdio: 'inherit',
|
|
921
|
+
encoding: 'utf8',
|
|
922
|
+
}));
|
|
923
|
+
return verifyFn(rootDir);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function buildNonInteractiveAnswers(rootDir, options = {}) {
|
|
927
|
+
const log = options.log || fmt.log.plain;
|
|
928
|
+
const env = process.env;
|
|
929
|
+
const productName = env.WORKFLOW_SETUP_PRODUCT_NAME || defaultProductName(rootDir);
|
|
930
|
+
const reviewProvider = env.WORKFLOW_SETUP_REVIEW_PROVIDER || 'none';
|
|
931
|
+
const primaryBranch = detectPrimaryBranchDefault(rootDir);
|
|
932
|
+
|
|
933
|
+
const answers = {
|
|
934
|
+
productName,
|
|
935
|
+
reviewProvider,
|
|
936
|
+
tasksProvider: 'backlog-md',
|
|
937
|
+
tasksStorage: 'backlog',
|
|
938
|
+
missionsBaseDir: 'docs/missions',
|
|
939
|
+
branchPrefix: 'mission/',
|
|
940
|
+
primaryBranch,
|
|
941
|
+
worktreePattern: '../<repo>-<slug>',
|
|
942
|
+
verificationCommand: 'npm test',
|
|
943
|
+
verificationDefaultArea: 'docs',
|
|
944
|
+
bootstrapReview: false,
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
if (reviewProvider === 'forgejo') {
|
|
948
|
+
const ownerLogin = env.WORKFLOW_SETUP_OWNER_LOGIN || 'human';
|
|
949
|
+
const baseUrl = normalizeBaseUrl(env.WORKFLOW_SETUP_FORGEJO_URL || 'http://localhost:3300');
|
|
950
|
+
const reviewRepo = env.WORKFLOW_SETUP_FORGEJO_REPO || defaultRepoSlug(rootDir, ownerLogin);
|
|
951
|
+
const reviewRemote = env.WORKFLOW_SETUP_REVIEW_REMOTE || 'review';
|
|
952
|
+
const ownerPassword = env.WORKFLOW_SETUP_OWNER_PASSWORD || '';
|
|
953
|
+
const agentUserList = env.WORKFLOW_SETUP_AGENT_USERS
|
|
954
|
+
? env.WORKFLOW_SETUP_AGENT_USERS.split(',').map(u => u.trim()).filter(Boolean)
|
|
955
|
+
: suggestedForgejoUsers();
|
|
956
|
+
const agentPassword = env.WORKFLOW_SETUP_AGENT_PASSWORD || '';
|
|
957
|
+
const agentPasswords = agentPassword
|
|
958
|
+
? agentUserList.map(user => ({ user, password: agentPassword }))
|
|
959
|
+
: [];
|
|
960
|
+
Object.assign(answers, {
|
|
961
|
+
ownerLogin, baseUrl, reviewRepo, reviewRemote,
|
|
962
|
+
bootstrapReview: Boolean(ownerPassword),
|
|
963
|
+
ownerPassword, agentPasswords, agentUsers: agentUserList,
|
|
964
|
+
});
|
|
965
|
+
log(fmt.status('INFO', `Non-interactive setup: product=${productName}, repo=${reviewRepo}, url=${baseUrl}`));
|
|
966
|
+
} else {
|
|
967
|
+
log(fmt.status('INFO', `Non-interactive setup: product=${productName}, review=none (no Forgejo)`));
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return answers;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function setupWizard(_args, options = {}) {
|
|
974
|
+
const rootDir = options.rootDir || process.cwd();
|
|
975
|
+
const log = options.log || fmt.log.plain;
|
|
976
|
+
const error = options.error || fmt.log.plainError;
|
|
977
|
+
const exit = options.exit || process.exit;
|
|
978
|
+
const promptFn = options.promptFn || promptLine;
|
|
979
|
+
const forgejoHome = resolveBootstrapForgejoHome(rootDir, options.forgejoHome);
|
|
980
|
+
|
|
981
|
+
const nonInteractive = (_args && _args.includes('--non-interactive')) || process.env.WORKFLOW_SETUP_NON_INTERACTIVE === '1';
|
|
982
|
+
const answers = nonInteractive
|
|
983
|
+
? buildNonInteractiveAnswers(rootDir, { log })
|
|
984
|
+
: await collectWizardAnswers(rootDir, { ...options, promptFn, log });
|
|
985
|
+
const configPath = writeWorkflowConfig(rootDir, buildWorkflowConfig(answers));
|
|
986
|
+
log(fmt.status('PASS', `Wrote ${configPath}`));
|
|
987
|
+
|
|
988
|
+
const gitignoreResult = ensureWorkflowGitignore(rootDir, { logFn: log });
|
|
989
|
+
if (gitignoreResult.created) {
|
|
990
|
+
log(fmt.status('PASS', `Created .gitignore with ${gitignoreResult.appended} workflow entries in ${fmt.path(rootDir)}`));
|
|
991
|
+
} else if (gitignoreResult.appended > 0) {
|
|
992
|
+
log(fmt.status('PASS', `Appended ${gitignoreResult.appended} workflow entries to .gitignore in ${fmt.path(rootDir)}`));
|
|
993
|
+
} else if (gitignoreResult.skipped) {
|
|
994
|
+
log(fmt.status('INFO', `.gitignore in ${fmt.path(rootDir)}: ${gitignoreResult.reason === 'symlink' ? 'symbolic link (skipped)' : 'not a git repo (skipped)'}`));
|
|
995
|
+
} else {
|
|
996
|
+
log(fmt.status('PASS', `.gitignore in ${fmt.path(rootDir)} already contains all workflow entries`));
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (answers.bootstrapReview) {
|
|
1000
|
+
const result = await bootstrapReviewSurface(rootDir, {
|
|
1001
|
+
baseUrl: answers.baseUrl,
|
|
1002
|
+
repo: answers.reviewRepo,
|
|
1003
|
+
ownerLogin: answers.ownerLogin,
|
|
1004
|
+
ownerPassword: answers.ownerPassword,
|
|
1005
|
+
agentPasswords: answers.agentPasswords,
|
|
1006
|
+
agentUsers: answers.agentUsers,
|
|
1007
|
+
}, {
|
|
1008
|
+
...options,
|
|
1009
|
+
forgejoHome,
|
|
1010
|
+
});
|
|
1011
|
+
if (!result.ok) {
|
|
1012
|
+
error(fmt.status('FAIL', result.error));
|
|
1013
|
+
if (result.response) {
|
|
1014
|
+
error(fmt.status('INFO', `Forgejo response: ${JSON.stringify(result.response)}`));
|
|
1015
|
+
}
|
|
1016
|
+
exit(1);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
} else {
|
|
1020
|
+
log(fmt.status('INFO', 'Skipped Forgejo bootstrap. You can run `px setup-review` later if needed.'));
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const verifyResult = await runVerifyEnv(rootDir, options);
|
|
1024
|
+
if (verifyResult && typeof verifyResult.status === 'number' && verifyResult.status !== 0) {
|
|
1025
|
+
exit(verifyResult.status);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
module.exports = setupReview;
|
|
1030
|
+
module.exports.setupReview = setupReview;
|
|
1031
|
+
module.exports.setupWizard = setupWizard;
|
|
1032
|
+
module.exports.apiRequest = apiRequest;
|
|
1033
|
+
module.exports.buildWorkflowConfig = buildWorkflowConfig;
|
|
1034
|
+
module.exports.collectWizardAnswers = collectWizardAnswers;
|
|
1035
|
+
module.exports.defaultProductName = defaultProductName;
|
|
1036
|
+
module.exports.defaultRepoSlug = defaultRepoSlug;
|
|
1037
|
+
module.exports.parseYesNo = parseYesNo;
|
|
1038
|
+
module.exports.runVerifyEnv = runVerifyEnv;
|
|
1039
|
+
module.exports.bootstrapReviewSurface = bootstrapReviewSurface;
|
|
1040
|
+
module.exports.collectSetupAnswers = collectSetupAnswers;
|
|
1041
|
+
module.exports.createToken = createToken;
|
|
1042
|
+
module.exports.ensureRepo = ensureRepo;
|
|
1043
|
+
module.exports.ensureReviewRemote = ensureReviewRemote;
|
|
1044
|
+
module.exports.evaluateReviewSetup = evaluateReviewSetup;
|
|
1045
|
+
module.exports.parseRepoSlug = parseRepoSlug;
|
|
1046
|
+
module.exports.promptLine = promptLine;
|
|
1047
|
+
module.exports.readConfiguredReviewRemote = readConfiguredReviewRemote;
|
|
1048
|
+
module.exports.suggestedForgejoUsers = suggestedForgejoUsers;
|
|
1049
|
+
module.exports.tokenFilePath = tokenFilePath;
|
|
1050
|
+
module.exports.writeToken = writeToken;
|
|
1051
|
+
module.exports.writeWorkflowConfig = writeWorkflowConfig;
|
|
1052
|
+
module.exports.resolveBootstrapForgejoHome = resolveBootstrapForgejoHome;
|
|
1053
|
+
module.exports.buildNonInteractiveAnswers = buildNonInteractiveAnswers;
|