@metasession.co/devaudit-cli 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/scripts/upload-evidence.sh +23 -3
- package/sdlc/files/_common/joining-an-existing-project.md +5 -4
- package/sdlc/files/_common/scripts/close-out-contract.test.sh +71 -0
- package/sdlc/files/_common/scripts/derive-release-version.test.sh +48 -0
- package/sdlc/files/_common/scripts/extract-release-metadata.test.sh +85 -0
- package/sdlc/files/_common/scripts/prepare-release-pr.sh +99 -0
- package/sdlc/files/_common/scripts/upload-evidence.sh +593 -0
- package/sdlc/files/_common/scripts/validate-commits.sh +32 -7
- package/sdlc/files/_common/scripts/validate-commits.test.sh +143 -0
- package/sdlc/files/_common/skills/sdlc-implementer/SKILL.md +39 -3
- package/sdlc/files/ci/ci.yml.template +2 -2
- package/sdlc/files/ci/compliance-evidence.yml.template +49 -4
- package/sdlc/files/ci/incident-export.yml.template +7 -3
- package/sdlc/files/ci/label-retention.yml.template +3 -1
- package/sdlc/files/ci/periodic-review.yml.template +5 -3
- package/sdlc/files/ci/post-deploy-prod.yml.template +6 -1
- package/sdlc/files/ci/quality-gates-provenance.yml.template +1 -1
- package/sdlc/files/stacks/node/hooks/pre-push +13 -13
- package/sdlc/package.json +1 -1
- package/sdlc/src/bin/devaudit-sdlc.js +490 -54
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
4
5
|
|
|
5
6
|
const args = process.argv.slice(2);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
7
|
+
const sentinelPath = path.join(process.cwd(), '.sdlc-implementer-invoked');
|
|
8
|
+
const watchStatePath = path.join(process.cwd(), '.sdlc-pr-watch.json');
|
|
9
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 30;
|
|
10
|
+
const DEFAULT_MAX_POLLS = 40;
|
|
11
|
+
const DEFAULT_FLAKY_PATTERNS = [
|
|
12
|
+
'playwright',
|
|
13
|
+
'e2e',
|
|
14
|
+
'regression',
|
|
15
|
+
'smoke',
|
|
16
|
+
'flaky',
|
|
17
|
+
'retry',
|
|
18
|
+
];
|
|
19
|
+
const RELEASE_APPROVAL_STATUSES = new Set([
|
|
20
|
+
'uat_approved',
|
|
21
|
+
'release_approved',
|
|
22
|
+
'prod_review',
|
|
23
|
+
'prod_approved',
|
|
24
|
+
'released',
|
|
25
|
+
]);
|
|
23
26
|
|
|
24
27
|
const phaseMap = {
|
|
25
28
|
'1': '1-plan-requirement.raw.md',
|
|
@@ -30,53 +33,115 @@ const phaseMap = {
|
|
|
30
33
|
'issue': 'implementing-an-sdlc-issue.raw.md'
|
|
31
34
|
};
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
function getOption(name) {
|
|
37
|
+
const inline = args.find(arg => arg.startsWith(`--${name}=`));
|
|
38
|
+
if (inline) return inline.split('=').slice(1).join('=');
|
|
39
|
+
const index = args.indexOf(`--${name}`);
|
|
40
|
+
if (index !== -1 && args[index + 1] && !args[index + 1].startsWith('--')) {
|
|
41
|
+
return args[index + 1];
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hasFlag(name) {
|
|
47
|
+
return args.includes(`--${name}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function printUsageAndExit() {
|
|
51
|
+
console.error("❌ ERROR: Missing required configuration property. Execution syntax: devaudit-sdlc --phase=<1-5|issue> | --watch-pr=<number>");
|
|
36
52
|
process.exit(1);
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
function sleep(ms) {
|
|
56
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
57
|
+
}
|
|
41
58
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
function normalizeState(value) {
|
|
60
|
+
return String(value || '').trim().toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPendingState(value) {
|
|
64
|
+
const state = normalizeState(value);
|
|
65
|
+
return ['queued', 'in_progress', 'pending', 'waiting', 'requested', 'startup_failure', 'expected'].includes(state);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isPassingState(value) {
|
|
69
|
+
const state = normalizeState(value);
|
|
70
|
+
return ['success', 'passed', 'pass', 'completed', 'neutral', 'skipped'].includes(state);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isFailingState(value) {
|
|
74
|
+
const state = normalizeState(value);
|
|
75
|
+
return ['failure', 'failed', 'error', 'timed_out', 'cancelled', 'action_required'].includes(state);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isReleaseApprovalState(value) {
|
|
79
|
+
return RELEASE_APPROVAL_STATUSES.has(normalizeState(value));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isReleaseGateCheck(check) {
|
|
83
|
+
const haystack = `${check.name || ''} ${check.workflow || ''}`.toLowerCase();
|
|
84
|
+
return haystack.includes('release approval') || haystack.includes('uat approval');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isFlakyCheck(check, patterns) {
|
|
88
|
+
const haystack = `${check.name || ''} ${check.workflow || ''}`.toLowerCase();
|
|
89
|
+
return patterns.some(pattern => haystack.includes(pattern));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractRunId(link) {
|
|
93
|
+
const match = String(link || '').match(/\/actions\/runs\/(\d+)/);
|
|
94
|
+
return match ? match[1] : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatCheckName(check) {
|
|
98
|
+
return check.workflow ? `${check.workflow} / ${check.name}` : String(check.name || 'unnamed-check');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadJsonFile(filePath, fallbackValue) {
|
|
102
|
+
if (!fs.existsSync(filePath)) return fallbackValue;
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
105
|
+
} catch (_error) {
|
|
106
|
+
return fallbackValue;
|
|
46
107
|
}
|
|
47
|
-
const content = fs.readFileSync(blueprintPath, 'utf8');
|
|
48
|
-
console.log(content);
|
|
49
|
-
process.exit(0);
|
|
50
108
|
}
|
|
51
109
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
110
|
+
function saveJsonFile(filePath, value) {
|
|
111
|
+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function detectInvocationContext() {
|
|
57
115
|
const invokedBySkill = !!(process.env.DEVAUDIT_SKILL_NAME || process.env.DEVAUDIT_SKILL_INVOKED);
|
|
58
116
|
const invokedByAgent = !!(process.env.DEVAUDIT_AGENT || process.env.AI_AGENT_ID);
|
|
59
|
-
|
|
117
|
+
return {
|
|
118
|
+
invokedBySkill,
|
|
119
|
+
invokedByAgent,
|
|
120
|
+
initializedBy: invokedBySkill ? 'skill' : invokedByAgent ? 'native-agent' : 'cli',
|
|
121
|
+
agentType: invokedBySkill
|
|
122
|
+
? (process.env.DEVAUDIT_SKILL_NAME || 'skill')
|
|
123
|
+
: (invokedByAgent ? (process.env.DEVAUDIT_AGENT || 'native-agent') : 'manual'),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
60
126
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
127
|
+
function resolveReqId() {
|
|
128
|
+
return getOption('req') || process.env.DEVAUDIT_REQ_ID || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function appendPhaseRecord(phase) {
|
|
132
|
+
const blueprintPath = path.join(__dirname, '..', 'blueprints', phaseMap[phase]);
|
|
133
|
+
const context = detectInvocationContext();
|
|
134
|
+
const reqId = resolveReqId();
|
|
69
135
|
|
|
70
136
|
const newRecord = {
|
|
71
137
|
activatedAt: new Date().toISOString(),
|
|
72
138
|
currentPhase: phase,
|
|
73
|
-
initializedBy: initializedBy,
|
|
139
|
+
initializedBy: context.initializedBy,
|
|
74
140
|
status: 'active',
|
|
75
141
|
reqId: reqId,
|
|
76
|
-
agentType:
|
|
142
|
+
agentType: context.agentType,
|
|
77
143
|
};
|
|
78
144
|
|
|
79
|
-
// Read existing sentinel and append, or create new array
|
|
80
145
|
let phaseHistory = [];
|
|
81
146
|
if (fs.existsSync(sentinelPath)) {
|
|
82
147
|
try {
|
|
@@ -85,31 +150,402 @@ try {
|
|
|
85
150
|
if (Array.isArray(parsed)) {
|
|
86
151
|
phaseHistory = parsed;
|
|
87
152
|
} else {
|
|
88
|
-
// Legacy single-object format — migrate it into the array
|
|
89
153
|
console.warn('⚠️ WARNING: Sentinel file contained a legacy single-object format. Migrating to array format.');
|
|
90
154
|
phaseHistory = [parsed];
|
|
91
155
|
}
|
|
92
|
-
} catch (
|
|
93
|
-
// Corrupt or plain-text sentinel — start fresh
|
|
156
|
+
} catch (_parseError) {
|
|
94
157
|
console.warn('⚠️ WARNING: Sentinel file was corrupt or in legacy plain-text format. Overwriting with fresh phase history.');
|
|
95
158
|
phaseHistory = [];
|
|
96
159
|
}
|
|
97
160
|
}
|
|
98
161
|
|
|
99
162
|
phaseHistory.push(newRecord);
|
|
100
|
-
|
|
163
|
+
saveJsonFile(sentinelPath, phaseHistory);
|
|
101
164
|
|
|
102
165
|
console.log(`\n✅ SDLC Gateway Initialized: Appended phase ${phase} record to sentinel at ${sentinelPath}`);
|
|
103
166
|
console.log(`🚀 Phase ${phase} orchestration active. Your local git commit gates are now open.`);
|
|
104
167
|
console.log(`📋 Phase history: ${phaseHistory.map(r => r.currentPhase).join(' → ')}`);
|
|
105
|
-
console.log(`👤 Initialized by: ${initializedBy}${reqId ? ` (REQ-${reqId})` : ''}`);
|
|
168
|
+
console.log(`👤 Initialized by: ${context.initializedBy}${reqId ? ` (REQ-${reqId})` : ''}`);
|
|
106
169
|
|
|
107
170
|
if (fs.existsSync(blueprintPath)) {
|
|
108
171
|
console.log(`\n--- PHASE EXECUTION MANIFEST ---`);
|
|
109
172
|
const content = fs.readFileSync(blueprintPath, 'utf8');
|
|
110
173
|
console.log(content);
|
|
111
174
|
}
|
|
112
|
-
} catch (error) {
|
|
113
|
-
console.error("❌ SYSTEM ERROR: Failed to instantiate workspace sentinel tracking state:", error.message);
|
|
114
|
-
process.exit(1);
|
|
115
175
|
}
|
|
176
|
+
|
|
177
|
+
function runBlueprintView(phase) {
|
|
178
|
+
const blueprintPath = path.join(__dirname, '..', 'blueprints', phaseMap[phase]);
|
|
179
|
+
if (!fs.existsSync(blueprintPath)) {
|
|
180
|
+
console.error("❌ ERROR: Target operational asset blueprint could not be resolved.");
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
console.log(fs.readFileSync(blueprintPath, 'utf8'));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function runGhJson(ghArgs) {
|
|
187
|
+
const ghCommand = process.env.DEVAUDIT_GH_BIN || 'gh';
|
|
188
|
+
const result = process.env.DEVAUDIT_GH_BIN
|
|
189
|
+
? spawnSync(process.execPath, [ghCommand, ...ghArgs], {
|
|
190
|
+
cwd: process.cwd(),
|
|
191
|
+
encoding: 'utf8',
|
|
192
|
+
env: process.env,
|
|
193
|
+
})
|
|
194
|
+
: spawnSync(ghCommand, ghArgs, {
|
|
195
|
+
cwd: process.cwd(),
|
|
196
|
+
encoding: 'utf8',
|
|
197
|
+
env: process.env,
|
|
198
|
+
});
|
|
199
|
+
if (result.error) {
|
|
200
|
+
throw new Error(`gh ${ghArgs.join(' ')} failed: ${result.error.message}`);
|
|
201
|
+
}
|
|
202
|
+
if (result.status !== 0) {
|
|
203
|
+
throw new Error((result.stderr || result.stdout || `gh ${ghArgs.join(' ')} exited ${result.status}`).trim());
|
|
204
|
+
}
|
|
205
|
+
const stdout = (result.stdout || '').trim();
|
|
206
|
+
return stdout ? JSON.parse(stdout) : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function runGh(ghArgs) {
|
|
210
|
+
const ghCommand = process.env.DEVAUDIT_GH_BIN || 'gh';
|
|
211
|
+
const result = process.env.DEVAUDIT_GH_BIN
|
|
212
|
+
? spawnSync(process.execPath, [ghCommand, ...ghArgs], {
|
|
213
|
+
cwd: process.cwd(),
|
|
214
|
+
encoding: 'utf8',
|
|
215
|
+
env: process.env,
|
|
216
|
+
})
|
|
217
|
+
: spawnSync(ghCommand, ghArgs, {
|
|
218
|
+
cwd: process.cwd(),
|
|
219
|
+
encoding: 'utf8',
|
|
220
|
+
env: process.env,
|
|
221
|
+
});
|
|
222
|
+
if (result.error) {
|
|
223
|
+
throw new Error(`gh ${ghArgs.join(' ')} failed: ${result.error.message}`);
|
|
224
|
+
}
|
|
225
|
+
if (result.status !== 0) {
|
|
226
|
+
throw new Error((result.stderr || result.stdout || `gh ${ghArgs.join(' ')} exited ${result.status}`).trim());
|
|
227
|
+
}
|
|
228
|
+
return result.stdout || '';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function resolveRepoFromGh() {
|
|
232
|
+
const repo = runGh(['repo', 'view', '--json', 'nameWithOwner']);
|
|
233
|
+
const parsed = JSON.parse(repo);
|
|
234
|
+
if (!parsed || !parsed.nameWithOwner) {
|
|
235
|
+
throw new Error('Unable to resolve current GitHub repository nameWithOwner.');
|
|
236
|
+
}
|
|
237
|
+
return parsed.nameWithOwner;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function resolvePortalStatus(baseUrl, projectSlug, releaseVersion, apiKeyEnvName) {
|
|
241
|
+
if (!baseUrl || !projectSlug || !releaseVersion) return null;
|
|
242
|
+
const apiKey = process.env[apiKeyEnvName || 'DEVAUDIT_API_KEY'];
|
|
243
|
+
if (!apiKey) return null;
|
|
244
|
+
|
|
245
|
+
const url = `${baseUrl.replace(/\/$/, '')}/api/ci/releases/resolve?projectSlug=${encodeURIComponent(projectSlug)}&versionPrefix=${encodeURIComponent(releaseVersion)}`;
|
|
246
|
+
const controller = new AbortController();
|
|
247
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
248
|
+
try {
|
|
249
|
+
const response = await fetch(url, {
|
|
250
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
251
|
+
signal: controller.signal,
|
|
252
|
+
});
|
|
253
|
+
if (!response.ok) return null;
|
|
254
|
+
const body = await response.json();
|
|
255
|
+
return normalizeState(body && body.latest && body.latest.status);
|
|
256
|
+
} catch (_error) {
|
|
257
|
+
return null;
|
|
258
|
+
} finally {
|
|
259
|
+
clearTimeout(timeout);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function loadWatchState() {
|
|
264
|
+
const parsed = loadJsonFile(watchStatePath, { watches: {} });
|
|
265
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
266
|
+
return { watches: {} };
|
|
267
|
+
}
|
|
268
|
+
if (!parsed.watches || typeof parsed.watches !== 'object' || Array.isArray(parsed.watches)) {
|
|
269
|
+
parsed.watches = {};
|
|
270
|
+
}
|
|
271
|
+
return parsed;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function getOrCreateWatch(state, key, seed) {
|
|
275
|
+
if (!state.watches[key]) {
|
|
276
|
+
state.watches[key] = {
|
|
277
|
+
prNumber: seed.prNumber,
|
|
278
|
+
repo: seed.repo,
|
|
279
|
+
reqId: seed.reqId || null,
|
|
280
|
+
releaseVersion: seed.releaseVersion || null,
|
|
281
|
+
projectSlug: seed.projectSlug || null,
|
|
282
|
+
createdAt: new Date().toISOString(),
|
|
283
|
+
updatedAt: new Date().toISOString(),
|
|
284
|
+
pollCount: 0,
|
|
285
|
+
reruns: {},
|
|
286
|
+
releaseGateReruns: 0,
|
|
287
|
+
lastClassification: null,
|
|
288
|
+
lastSummary: null,
|
|
289
|
+
lastPortalStatus: null,
|
|
290
|
+
history: [],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return state.watches[key];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function recordWatchHistory(watch, kind, summary, action) {
|
|
297
|
+
watch.updatedAt = new Date().toISOString();
|
|
298
|
+
watch.lastClassification = kind;
|
|
299
|
+
watch.lastSummary = summary;
|
|
300
|
+
watch.history = Array.isArray(watch.history) ? watch.history : [];
|
|
301
|
+
watch.history.push({
|
|
302
|
+
at: watch.updatedAt,
|
|
303
|
+
kind,
|
|
304
|
+
summary,
|
|
305
|
+
action: action || null,
|
|
306
|
+
});
|
|
307
|
+
if (watch.history.length > 25) {
|
|
308
|
+
watch.history = watch.history.slice(-25);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function classifyPrStatus(pr, checks, portalStatus, watch, options) {
|
|
313
|
+
const failingChecks = checks.filter(check => isFailingState(check.state || check.bucket));
|
|
314
|
+
const pendingChecks = checks.filter(check => isPendingState(check.state || check.bucket));
|
|
315
|
+
const releaseGate = checks.find(isReleaseGateCheck) || null;
|
|
316
|
+
|
|
317
|
+
if (normalizeState(pr.state) !== 'open') {
|
|
318
|
+
return {
|
|
319
|
+
kind: 'terminal',
|
|
320
|
+
exitCode: 0,
|
|
321
|
+
summary: `PR #${watch.prNumber} is ${normalizeState(pr.state) || 'not open'}. Watch complete.`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (pr.isDraft) {
|
|
326
|
+
return {
|
|
327
|
+
kind: 'waiting',
|
|
328
|
+
exitCode: 2,
|
|
329
|
+
summary: `PR #${watch.prNumber} is still a draft. Waiting for ready-for-review.`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (normalizeState(pr.reviewDecision) === 'changes_requested') {
|
|
334
|
+
return {
|
|
335
|
+
kind: 'blocked',
|
|
336
|
+
exitCode: 3,
|
|
337
|
+
summary: `PR #${watch.prNumber} has changes requested. Human action required before resume.`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (releaseGate && (isPendingState(releaseGate.state || releaseGate.bucket) || isFailingState(releaseGate.state || releaseGate.bucket))) {
|
|
342
|
+
if (portalStatus && isReleaseApprovalState(portalStatus)) {
|
|
343
|
+
const runId = extractRunId(releaseGate.link);
|
|
344
|
+
if (runId && watch.releaseGateReruns < 3) {
|
|
345
|
+
return {
|
|
346
|
+
kind: 'action',
|
|
347
|
+
exitCode: 4,
|
|
348
|
+
summary: `Release approval is already ${portalStatus} on the portal. Re-running ${formatCheckName(releaseGate)}.`,
|
|
349
|
+
action: { type: 'rerun-release-gate', runId, checkName: formatCheckName(releaseGate) },
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
kind: 'blocked',
|
|
354
|
+
exitCode: 3,
|
|
355
|
+
summary: `Release approval is ${portalStatus} on the portal, but ${formatCheckName(releaseGate)} is still not green and cannot be auto-rerun further.`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
kind: 'waiting',
|
|
360
|
+
exitCode: 2,
|
|
361
|
+
summary: `Release approval gate is not green yet. Portal status is ${portalStatus || 'unknown'}; waiting for review/approval on the portal.`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for (const check of failingChecks) {
|
|
366
|
+
if (!isReleaseGateCheck(check) && isFlakyCheck(check, options.flakyPatterns || DEFAULT_FLAKY_PATTERNS)) {
|
|
367
|
+
const runId = extractRunId(check.link);
|
|
368
|
+
const checkKey = formatCheckName(check);
|
|
369
|
+
const priorReruns = Number((watch.reruns || {})[checkKey] || 0);
|
|
370
|
+
if (runId && priorReruns < 2) {
|
|
371
|
+
return {
|
|
372
|
+
kind: 'action',
|
|
373
|
+
exitCode: 4,
|
|
374
|
+
summary: `Detected likely flaky failure in ${checkKey}. Re-running workflow.`,
|
|
375
|
+
action: { type: 'rerun-flaky', runId, checkName: checkKey },
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (failingChecks.length > 0) {
|
|
382
|
+
return {
|
|
383
|
+
kind: 'blocked',
|
|
384
|
+
exitCode: 3,
|
|
385
|
+
summary: `PR #${watch.prNumber} has failing checks that need code fixes: ${failingChecks.map(formatCheckName).join(', ')}.`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (pendingChecks.length > 0) {
|
|
390
|
+
return {
|
|
391
|
+
kind: 'waiting',
|
|
392
|
+
exitCode: 2,
|
|
393
|
+
summary: `PR #${watch.prNumber} still has pending checks: ${pendingChecks.map(formatCheckName).join(', ')}.`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (normalizeState(pr.reviewDecision) !== 'approved') {
|
|
398
|
+
return {
|
|
399
|
+
kind: 'blocked',
|
|
400
|
+
exitCode: 3,
|
|
401
|
+
summary: `All checks are green, but PR #${watch.prNumber} still needs human review/approval.`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
kind: 'ready',
|
|
407
|
+
exitCode: 0,
|
|
408
|
+
summary: `PR #${watch.prNumber} is approved and all observed checks are green. Ready for merge.`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function performWatchAction(repo, watch, decision) {
|
|
413
|
+
if (!decision.action) return;
|
|
414
|
+
runGh(['run', 'rerun', decision.action.runId, '--repo', repo]);
|
|
415
|
+
if (decision.action.type === 'rerun-release-gate') {
|
|
416
|
+
watch.releaseGateReruns = Number(watch.releaseGateReruns || 0) + 1;
|
|
417
|
+
} else {
|
|
418
|
+
watch.reruns = watch.reruns || {};
|
|
419
|
+
watch.reruns[decision.action.checkName] = Number(watch.reruns[decision.action.checkName] || 0) + 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function watchPullRequest() {
|
|
424
|
+
const prNumber = getOption('watch-pr');
|
|
425
|
+
if (!prNumber) printUsageAndExit();
|
|
426
|
+
|
|
427
|
+
const repo = getOption('repo') || resolveRepoFromGh();
|
|
428
|
+
const reqId = resolveReqId();
|
|
429
|
+
const releaseVersion = getOption('release') || (reqId ? `REQ-${reqId}` : null);
|
|
430
|
+
const baseUrl = getOption('base-url') || process.env.DEVAUDIT_BASE_URL || null;
|
|
431
|
+
const projectSlug = getOption('project-slug') || process.env.DEVAUDIT_PROJECT_SLUG || null;
|
|
432
|
+
const apiKeyEnvName = getOption('api-key-env') || 'DEVAUDIT_API_KEY';
|
|
433
|
+
const pollIntervalSeconds = Number.parseInt(getOption('poll-interval-seconds') || String(DEFAULT_POLL_INTERVAL_SECONDS), 10);
|
|
434
|
+
const maxPolls = Number.parseInt(getOption('max-polls') || String(DEFAULT_MAX_POLLS), 10);
|
|
435
|
+
const once = hasFlag('once');
|
|
436
|
+
const flakyPatterns = (getOption('flaky-patterns') || '')
|
|
437
|
+
.split(',')
|
|
438
|
+
.map(pattern => pattern.trim().toLowerCase())
|
|
439
|
+
.filter(Boolean);
|
|
440
|
+
|
|
441
|
+
const state = loadWatchState();
|
|
442
|
+
const watchKey = `${repo}#${prNumber}`;
|
|
443
|
+
const watch = getOrCreateWatch(state, watchKey, {
|
|
444
|
+
prNumber: Number(prNumber),
|
|
445
|
+
repo,
|
|
446
|
+
reqId,
|
|
447
|
+
releaseVersion,
|
|
448
|
+
projectSlug,
|
|
449
|
+
});
|
|
450
|
+
watch.repo = repo;
|
|
451
|
+
watch.reqId = reqId || watch.reqId || null;
|
|
452
|
+
watch.releaseVersion = releaseVersion || watch.releaseVersion || null;
|
|
453
|
+
watch.projectSlug = projectSlug || watch.projectSlug || null;
|
|
454
|
+
|
|
455
|
+
const options = {
|
|
456
|
+
flakyPatterns: flakyPatterns.length > 0 ? flakyPatterns : DEFAULT_FLAKY_PATTERNS,
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
let pollsRemaining = once ? 1 : maxPolls;
|
|
460
|
+
while (pollsRemaining > 0) {
|
|
461
|
+
watch.pollCount = Number(watch.pollCount || 0) + 1;
|
|
462
|
+
|
|
463
|
+
let pr;
|
|
464
|
+
let checks;
|
|
465
|
+
try {
|
|
466
|
+
pr = runGhJson(['pr', 'view', String(prNumber), '--repo', repo, '--json', 'state,isDraft,reviewDecision']);
|
|
467
|
+
checks = runGhJson(['pr', 'checks', String(prNumber), '--repo', repo, '--json', 'name,state,link,workflow,bucket']) || [];
|
|
468
|
+
if (!Array.isArray(checks)) checks = [];
|
|
469
|
+
} catch (error) {
|
|
470
|
+
recordWatchHistory(watch, 'error', `Failed to inspect PR state: ${error.message}`);
|
|
471
|
+
saveJsonFile(watchStatePath, state);
|
|
472
|
+
console.error(`❌ SYSTEM ERROR: ${error.message}`);
|
|
473
|
+
process.exit(1);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const portalStatus = await resolvePortalStatus(baseUrl, projectSlug || watch.projectSlug, releaseVersion || watch.releaseVersion, apiKeyEnvName);
|
|
477
|
+
watch.lastPortalStatus = portalStatus || watch.lastPortalStatus || null;
|
|
478
|
+
|
|
479
|
+
const decision = classifyPrStatus(pr || {}, checks, portalStatus, watch, options);
|
|
480
|
+
if (decision.kind === 'action') {
|
|
481
|
+
try {
|
|
482
|
+
performWatchAction(repo, watch, decision);
|
|
483
|
+
recordWatchHistory(watch, decision.kind, decision.summary, decision.action);
|
|
484
|
+
saveJsonFile(watchStatePath, state);
|
|
485
|
+
console.log(`🔁 ${decision.summary}`);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
recordWatchHistory(watch, 'error', `Auto-rerun failed: ${error.message}`, decision.action);
|
|
488
|
+
saveJsonFile(watchStatePath, state);
|
|
489
|
+
console.error(`❌ SYSTEM ERROR: ${error.message}`);
|
|
490
|
+
process.exit(1);
|
|
491
|
+
}
|
|
492
|
+
if (once) {
|
|
493
|
+
process.exit(decision.exitCode);
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
recordWatchHistory(watch, decision.kind, decision.summary);
|
|
497
|
+
saveJsonFile(watchStatePath, state);
|
|
498
|
+
const prefix = decision.kind === 'ready' ? '✅' : decision.kind === 'blocked' ? '⛔' : '⏳';
|
|
499
|
+
console.log(`${prefix} ${decision.summary}`);
|
|
500
|
+
if (decision.kind === 'ready' || decision.kind === 'blocked' || decision.kind === 'terminal') {
|
|
501
|
+
process.exit(decision.exitCode);
|
|
502
|
+
}
|
|
503
|
+
if (once) {
|
|
504
|
+
process.exit(decision.exitCode);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
pollsRemaining -= 1;
|
|
509
|
+
if (pollsRemaining <= 0) break;
|
|
510
|
+
await sleep(pollIntervalSeconds * 1000);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
recordWatchHistory(watch, 'waiting', `Polling limit reached after ${watch.pollCount} observations. Re-run the watcher to continue monitoring.`);
|
|
514
|
+
saveJsonFile(watchStatePath, state);
|
|
515
|
+
console.log(`⏳ Polling limit reached after ${watch.pollCount} observations. Re-run the watcher to continue monitoring.`);
|
|
516
|
+
process.exit(2);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function main() {
|
|
520
|
+
const phase = getOption('phase');
|
|
521
|
+
const viewOnly = hasFlag('view');
|
|
522
|
+
const watchPr = getOption('watch-pr');
|
|
523
|
+
|
|
524
|
+
if (!phase && !watchPr) {
|
|
525
|
+
printUsageAndExit();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (watchPr) {
|
|
529
|
+
await watchPullRequest();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!phaseMap[phase]) {
|
|
534
|
+
console.error(`❌ ERROR: Invalid phase argument [${phase}]. Supported options: 1, 2, 3, 4, 5, issue`);
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (viewOnly) {
|
|
539
|
+
runBlueprintView(phase);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
appendPhaseRecord(phase);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
console.error("❌ SYSTEM ERROR: Failed to instantiate workspace sentinel tracking state:", error.message);
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
main();
|