supipowers 1.2.6 → 1.5.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/README.md +118 -56
- package/bin/install.ts +48 -128
- package/package.json +11 -3
- package/skills/code-review/SKILL.md +137 -40
- package/skills/context-mode/SKILL.md +67 -56
- package/skills/creating-supi-agents/SKILL.md +204 -0
- package/skills/debugging/SKILL.md +86 -40
- package/skills/fix-pr/SKILL.md +96 -65
- package/skills/planning/SKILL.md +103 -46
- package/skills/qa-strategy/SKILL.md +68 -46
- package/skills/receiving-code-review/SKILL.md +60 -53
- package/skills/release/SKILL.md +111 -39
- package/skills/tdd/SKILL.md +118 -67
- package/skills/verification/SKILL.md +71 -37
- package/src/bootstrap.ts +27 -5
- package/src/commands/agents.ts +249 -0
- package/src/commands/ai-review.ts +1113 -0
- package/src/commands/config.ts +224 -95
- package/src/commands/doctor.ts +19 -13
- package/src/commands/fix-pr.ts +8 -11
- package/src/commands/generate.ts +200 -0
- package/src/commands/model-picker.ts +5 -15
- package/src/commands/model.ts +4 -5
- package/src/commands/optimize-context.ts +202 -0
- package/src/commands/plan.ts +148 -92
- package/src/commands/qa.ts +14 -23
- package/src/commands/release.ts +504 -275
- package/src/commands/review.ts +643 -86
- package/src/commands/status.ts +44 -17
- package/src/commands/supi.ts +69 -41
- package/src/commands/update.ts +57 -2
- package/src/config/defaults.ts +6 -39
- package/src/config/loader.ts +388 -40
- package/src/config/model-resolver.ts +26 -22
- package/src/config/schema.ts +113 -48
- package/src/context/analyzer.ts +61 -2
- package/src/context/optimizer.ts +199 -0
- package/src/context-mode/compressor.ts +14 -11
- package/src/context-mode/detector.ts +16 -54
- package/src/context-mode/event-extractor.ts +45 -16
- package/src/context-mode/event-store.ts +225 -16
- package/src/context-mode/hooks.ts +195 -22
- package/src/context-mode/knowledge/chunker.ts +235 -0
- package/src/context-mode/knowledge/store.ts +187 -0
- package/src/context-mode/routing.ts +12 -23
- package/src/context-mode/sandbox/executor.ts +183 -0
- package/src/context-mode/sandbox/runners.ts +40 -0
- package/src/context-mode/snapshot-builder.ts +243 -7
- package/src/context-mode/tools.ts +440 -0
- package/src/context-mode/web/fetcher.ts +117 -0
- package/src/context-mode/web/html-to-md.ts +293 -0
- package/src/debug/logger.ts +107 -0
- package/src/deps/registry.ts +0 -20
- package/src/docs/drift.ts +454 -0
- package/src/fix-pr/fetch-comments.ts +66 -0
- package/src/git/commit-msg.ts +2 -1
- package/src/git/commit.ts +123 -141
- package/src/git/conventions.ts +2 -2
- package/src/git/status.ts +4 -1
- package/src/lsp/bridge.ts +138 -12
- package/src/planning/approval-flow.ts +125 -19
- package/src/planning/plan-writer-prompt.ts +4 -11
- package/src/planning/planning-ask-tool.ts +81 -0
- package/src/planning/prompt-builder.ts +9 -169
- package/src/planning/system-prompt.ts +290 -0
- package/src/platform/omp.ts +50 -4
- package/src/platform/progress.ts +182 -0
- package/src/platform/test-utils.ts +4 -1
- package/src/platform/tui-colors.ts +30 -0
- package/src/platform/types.ts +1 -0
- package/src/qa/detect-app-type.ts +102 -0
- package/src/qa/discover-routes.ts +353 -0
- package/src/quality/ai-session.ts +96 -0
- package/src/quality/ai-setup.ts +86 -0
- package/src/quality/gates/ai-review.ts +129 -0
- package/src/quality/gates/build.ts +8 -0
- package/src/quality/gates/command.ts +150 -0
- package/src/quality/gates/format.ts +28 -0
- package/src/quality/gates/lint.ts +22 -0
- package/src/quality/gates/lsp-diagnostics.ts +84 -0
- package/src/quality/gates/test-suite.ts +8 -0
- package/src/quality/gates/typecheck.ts +22 -0
- package/src/quality/registry.ts +25 -0
- package/src/quality/review-gates.ts +33 -0
- package/src/quality/runner.ts +268 -0
- package/src/quality/schemas.ts +48 -0
- package/src/quality/setup.ts +227 -0
- package/src/release/changelog.ts +7 -3
- package/src/release/channels/custom.ts +43 -0
- package/src/release/channels/gitea.ts +35 -0
- package/src/release/channels/github.ts +35 -0
- package/src/release/channels/gitlab.ts +35 -0
- package/src/release/channels/registry.ts +52 -0
- package/src/release/channels/types.ts +27 -0
- package/src/release/detector.ts +10 -63
- package/src/release/executor.ts +61 -51
- package/src/release/prompt.ts +38 -38
- package/src/release/version.ts +129 -10
- package/src/review/agent-loader.ts +331 -0
- package/src/review/consolidator.ts +180 -0
- package/src/review/default-agents/correctness.md +72 -0
- package/src/review/default-agents/maintainability.md +64 -0
- package/src/review/default-agents/security.md +67 -0
- package/src/review/fixer.ts +219 -0
- package/src/review/multi-agent-runner.ts +135 -0
- package/src/review/output.ts +147 -0
- package/src/review/prompts/agent-review-wrapper.md +36 -0
- package/src/review/prompts/fix-findings.md +32 -0
- package/src/review/prompts/fix-output-schema.md +18 -0
- package/src/review/prompts/invalid-output-retry.md +22 -0
- package/src/review/prompts/output-instructions.md +14 -0
- package/src/review/prompts/review-output-schema.md +38 -0
- package/src/review/prompts/single-review.md +53 -0
- package/src/review/prompts/validation-review.md +30 -0
- package/src/review/runner.ts +128 -0
- package/src/review/scope.ts +353 -0
- package/src/review/template.ts +15 -0
- package/src/review/types.ts +296 -0
- package/src/review/validator.ts +160 -0
- package/src/storage/plans.ts +5 -3
- package/src/storage/reports.ts +50 -7
- package/src/storage/review-sessions.ts +117 -0
- package/src/text.ts +19 -0
- package/src/types.ts +336 -26
- package/src/utils/paths.ts +39 -0
- package/src/visual/companion.ts +5 -3
- package/src/visual/start-server.ts +101 -0
- package/src/visual/stop-server.ts +39 -0
- package/bin/ctx-mode-wrapper.mjs +0 -66
- package/src/config/profiles.ts +0 -64
- package/src/context-mode/installer.ts +0 -38
- package/src/quality/ai-review-gate.ts +0 -43
- package/src/quality/gate-runner.ts +0 -67
- package/src/quality/lsp-gate.ts +0 -24
- package/src/quality/test-gate.ts +0 -39
- package/src/visual/scripts/start-server.sh +0 -98
- package/src/visual/scripts/stop-server.sh +0 -21
package/src/commands/release.ts
CHANGED
|
@@ -2,16 +2,34 @@ import type { Platform } from "../platform/types.js";
|
|
|
2
2
|
import { modelRegistry } from "../config/model-registry-instance.js";
|
|
3
3
|
import { resolveModelForAction, createModelBridge, applyModelOverride } from "../config/model-resolver.js";
|
|
4
4
|
import { loadModelConfig } from "../config/model-config.js";
|
|
5
|
-
import type { ReleaseChannel, BumpType } from "../types.js";
|
|
5
|
+
import type { ReleaseChannel, BumpType, ReviewReport, ResolvedModel } from "../types.js";
|
|
6
6
|
import { loadConfig, updateConfig } from "../config/loader.js";
|
|
7
7
|
import { detectChannels } from "../release/detector.js";
|
|
8
|
+
import type { ChannelStatus } from "../release/channels/types.js";
|
|
8
9
|
import { parseConventionalCommits, buildChangelogMarkdown, summarizeChanges } from "../release/changelog.js";
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
getCurrentVersion,
|
|
12
|
+
suggestBump,
|
|
13
|
+
bumpVersion,
|
|
14
|
+
isVersionReleased,
|
|
15
|
+
isTagOnRemote,
|
|
16
|
+
findResumableLocalRelease,
|
|
17
|
+
formatTag,
|
|
18
|
+
} from "../release/version.js";
|
|
10
19
|
import { executeRelease, type ReleaseProgressFn } from "../release/executor.js";
|
|
11
20
|
import { buildPolishPrompt } from "../release/prompt.js";
|
|
12
21
|
import { notifyInfo, notifySuccess, notifyError } from "../notifications/renderer.js";
|
|
13
22
|
import { analyzeAndCommit } from "../git/commit.js";
|
|
14
23
|
import { getWorkingTreeStatus } from "../git/status.js";
|
|
24
|
+
import { runStructuredAgentSession } from "../quality/ai-session.js";
|
|
25
|
+
import {
|
|
26
|
+
checkDocDrift,
|
|
27
|
+
buildFixPrompt,
|
|
28
|
+
} from "../docs/drift.js";
|
|
29
|
+
import { runQualityGates } from "../quality/runner.js";
|
|
30
|
+
import { REVIEW_GATE_REGISTRY } from "../quality/review-gates.js";
|
|
31
|
+
import { GATE_DISPLAY_NAMES } from "../quality/registry.js";
|
|
32
|
+
import { createWorkflowProgress } from "../platform/progress.js";
|
|
15
33
|
|
|
16
34
|
modelRegistry.register({
|
|
17
35
|
id: "release",
|
|
@@ -20,10 +38,6 @@ modelRegistry.register({
|
|
|
20
38
|
harnessRoleHint: "slow",
|
|
21
39
|
});
|
|
22
40
|
|
|
23
|
-
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
24
|
-
const STATUS_KEY = "supi-release";
|
|
25
|
-
const WIDGET_KEY = "supi-release";
|
|
26
|
-
|
|
27
41
|
const BUMP_OPTIONS = [
|
|
28
42
|
"patch — bug fixes only",
|
|
29
43
|
"minor — new features, backwards compatible",
|
|
@@ -37,8 +51,7 @@ const BUMP_OPTIONS = [
|
|
|
37
51
|
* This is the resume path: the version in package.json hasn't been tagged yet
|
|
38
52
|
* AND all channels are already configured — the user staged the release
|
|
39
53
|
* deliberately when they bumped the version, so no new decisions are needed.
|
|
40
|
-
* Dry-run is excluded because it is exploratory by intent
|
|
41
|
-
* wants to preview and confirm before anything happens.
|
|
54
|
+
* Dry-run is excluded because it is exploratory by intent.
|
|
42
55
|
*/
|
|
43
56
|
export function isInProgressRelease(opts: {
|
|
44
57
|
skipBump: boolean;
|
|
@@ -48,131 +61,189 @@ export function isInProgressRelease(opts: {
|
|
|
48
61
|
return opts.skipBump && opts.channelsWerePreConfigured && !opts.isDryRun;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
|
-
|
|
64
|
+
export function findInvalidReleaseChannels(
|
|
65
|
+
channels: ReleaseChannel[],
|
|
66
|
+
detected: ChannelStatus[],
|
|
67
|
+
): string[] {
|
|
68
|
+
const detectedById = new Map(detected.map((status) => [status.channel, status]));
|
|
69
|
+
|
|
70
|
+
return channels.flatMap((channel) => {
|
|
71
|
+
const status = detectedById.get(channel);
|
|
72
|
+
if (!status) {
|
|
73
|
+
return [`${channel}: unknown channel`];
|
|
74
|
+
}
|
|
75
|
+
if (!status.available) {
|
|
76
|
+
return [`${channel}: unavailable (${status.detail})`];
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
});
|
|
80
|
+
}
|
|
52
81
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
export function buildSelectableReleaseChannelOptions(detected: ChannelStatus[]): string[] {
|
|
83
|
+
return detected
|
|
84
|
+
.filter((channel) => channel.available)
|
|
85
|
+
.map((channel) => `${channel.channel} — ${channel.detail}`);
|
|
57
86
|
}
|
|
58
87
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
];
|
|
69
|
-
|
|
70
|
-
let frame = 0;
|
|
71
|
-
let statusDetail = "";
|
|
72
|
-
let timer: ReturnType<typeof setInterval> | null = null;
|
|
73
|
-
|
|
74
|
-
function icon(step: ReleaseStep): string {
|
|
75
|
-
switch (step.status) {
|
|
76
|
-
case "done": return "✓";
|
|
77
|
-
case "active": return SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
|
|
78
|
-
case "error": return "✗";
|
|
79
|
-
case "skipped": return "–";
|
|
80
|
-
default: return "○";
|
|
81
|
-
}
|
|
88
|
+
interface GitHubAuthAccount {
|
|
89
|
+
host: string;
|
|
90
|
+
user: string;
|
|
91
|
+
active: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isGitHubPermissionDeniedError(detail: string | undefined): boolean {
|
|
95
|
+
if (!detail) {
|
|
96
|
+
return false;
|
|
82
97
|
}
|
|
83
98
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
const normalized = detail.toLowerCase();
|
|
100
|
+
return normalized.includes("github.com")
|
|
101
|
+
&& normalized.includes("403")
|
|
102
|
+
&& (normalized.includes("permission to") || normalized.includes("denied to"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseGithubAuthStatusAccounts(output: string, host = "github.com"): GitHubAuthAccount[] {
|
|
106
|
+
const accounts: GitHubAuthAccount[] = [];
|
|
107
|
+
let currentAccount: GitHubAuthAccount | null = null;
|
|
108
|
+
|
|
109
|
+
for (const rawLine of output.split(/\r?\n/)) {
|
|
110
|
+
const line = rawLine.trim();
|
|
111
|
+
if (!line) {
|
|
112
|
+
continue;
|
|
90
113
|
}
|
|
91
|
-
lines.push("└─────────────────────────────────────┘");
|
|
92
|
-
return lines;
|
|
93
|
-
}
|
|
94
114
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
115
|
+
const accountMatch = line.match(/^✓ Logged in to (\S+) account ([^(]+?) \(/);
|
|
116
|
+
if (accountMatch) {
|
|
117
|
+
currentAccount = {
|
|
118
|
+
host: accountMatch[1] ?? "",
|
|
119
|
+
user: (accountMatch[2] ?? "").trim(),
|
|
120
|
+
active: false,
|
|
121
|
+
};
|
|
122
|
+
if (currentAccount.host === host) {
|
|
123
|
+
accounts.push(currentAccount);
|
|
124
|
+
} else {
|
|
125
|
+
currentAccount = null;
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
100
128
|
}
|
|
101
|
-
}
|
|
102
129
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
timer = setInterval(refresh, 80);
|
|
130
|
+
if (currentAccount && line.startsWith("- Active account:")) {
|
|
131
|
+
currentAccount.active = line.endsWith("true");
|
|
106
132
|
}
|
|
107
133
|
}
|
|
108
134
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
135
|
+
return accounts;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function maybeSwitchGithubAccountForReleaseFailure(
|
|
139
|
+
platform: Pick<Platform, "exec">,
|
|
140
|
+
ctx: { cwd: string; ui: { select: (title: string, options: string[], opts?: { helpText?: string }) => Promise<string | null>; notify?: (message: string, type?: string) => void } },
|
|
141
|
+
detail: string | undefined,
|
|
142
|
+
): Promise<string | null> {
|
|
143
|
+
if (!isGitHubPermissionDeniedError(detail)) {
|
|
144
|
+
return null;
|
|
114
145
|
}
|
|
115
146
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
step.status = "active";
|
|
121
|
-
step.detail = detail;
|
|
122
|
-
}
|
|
123
|
-
statusDetail = detail ?? step?.label ?? "";
|
|
124
|
-
startTimer();
|
|
125
|
-
refresh();
|
|
126
|
-
},
|
|
147
|
+
const statusResult = await platform.exec("gh", ["auth", "status", "--hostname", "github.com"], { cwd: ctx.cwd });
|
|
148
|
+
if (statusResult.code !== 0) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
127
151
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (detail !== undefined) step.detail = detail;
|
|
133
|
-
}
|
|
134
|
-
refresh();
|
|
135
|
-
},
|
|
152
|
+
const accounts = parseGithubAuthStatusAccounts(`${statusResult.stdout}\n${statusResult.stderr}`);
|
|
153
|
+
if (accounts.length < 2) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
136
156
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
},
|
|
157
|
+
const deniedUser = detail?.match(/denied to\s+([A-Za-z0-9-]+)/i)?.[1] ?? null;
|
|
158
|
+
const options = accounts.map((account) => {
|
|
159
|
+
const suffix: string[] = [];
|
|
160
|
+
if (account.active) suffix.push("current");
|
|
161
|
+
if (deniedUser && account.user === deniedUser) suffix.push("denied here");
|
|
162
|
+
return suffix.length > 0 ? `${account.user} — ${suffix.join(", ")}` : account.user;
|
|
163
|
+
});
|
|
145
164
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
const choice = await ctx.ui.select(
|
|
166
|
+
"GitHub account mismatch detected",
|
|
167
|
+
options,
|
|
168
|
+
{
|
|
169
|
+
helpText: deniedUser
|
|
170
|
+
? `GitHub denied the current release with account ${deniedUser}. Choose another authenticated GitHub account to retry the release.`
|
|
171
|
+
: "GitHub denied the current release. Choose another authenticated GitHub account to retry.",
|
|
153
172
|
},
|
|
173
|
+
);
|
|
174
|
+
if (!choice) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const selectedUser = choice.split(" — ")[0] ?? choice;
|
|
179
|
+
const switchResult = await platform.exec(
|
|
180
|
+
"gh",
|
|
181
|
+
["auth", "switch", "--hostname", "github.com", "--user", selectedUser],
|
|
182
|
+
{ cwd: ctx.cwd },
|
|
183
|
+
);
|
|
184
|
+
if (switchResult.code !== 0) {
|
|
185
|
+
ctx.ui.notify?.(`GitHub account switch failed: ${switchResult.stderr || switchResult.stdout || "unknown error"}`, "error");
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return selectedUser;
|
|
190
|
+
}
|
|
154
191
|
|
|
192
|
+
|
|
193
|
+
export const RELEASE_STEPS = [
|
|
194
|
+
{ key: "checks", label: "Quality checks" },
|
|
195
|
+
{ key: "doc-drift", label: "Check documentation drift" },
|
|
196
|
+
{ key: "working-tree", label: "Check working tree" },
|
|
197
|
+
{ key: "channels", label: "Detect channels" },
|
|
198
|
+
{ key: "commits", label: "Analyze commits" },
|
|
199
|
+
{ key: "version", label: "Select version" },
|
|
200
|
+
{ key: "changelog", label: "Build changelog" },
|
|
201
|
+
{ key: "polish", label: "Polish release notes" },
|
|
202
|
+
{ key: "execute", label: "Execute release" },
|
|
203
|
+
{ key: "publish", label: "Publish channels" },
|
|
204
|
+
] as const;
|
|
205
|
+
|
|
206
|
+
type ReleaseStepKey = (typeof RELEASE_STEPS)[number]["key"];
|
|
207
|
+
|
|
208
|
+
function createReleaseProgress(ctx: any) {
|
|
209
|
+
const progress = createWorkflowProgress(ctx.ui, {
|
|
210
|
+
title: "supi:release",
|
|
211
|
+
statusKey: "supi-release",
|
|
212
|
+
statusLabel: "Releasing...",
|
|
213
|
+
widgetKey: "supi-release",
|
|
214
|
+
clearStatusKeys: ["supi-model"],
|
|
215
|
+
steps: [...RELEASE_STEPS],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
activate(key: ReleaseStepKey, detail?: string) {
|
|
220
|
+
progress.activate(key, detail);
|
|
221
|
+
},
|
|
222
|
+
complete(key: ReleaseStepKey, detail?: string) {
|
|
223
|
+
progress.complete(key, detail);
|
|
224
|
+
},
|
|
225
|
+
fail(key: ReleaseStepKey, detail?: string) {
|
|
226
|
+
progress.fail(key, detail);
|
|
227
|
+
},
|
|
228
|
+
skip(key: ReleaseStepKey, detail?: string) {
|
|
229
|
+
progress.skip(key, detail);
|
|
230
|
+
},
|
|
231
|
+
detail(text: string) {
|
|
232
|
+
progress.detail(text);
|
|
233
|
+
},
|
|
155
234
|
/** Build an onProgress callback for executeRelease. */
|
|
156
235
|
executorProgress(): ReleaseProgressFn {
|
|
157
236
|
return (step, status, detail) => {
|
|
158
|
-
// Map executor steps to sub-detail on "Execute release" (index 5)
|
|
159
|
-
// or "Publish channels" (index 6)
|
|
160
237
|
const isPublish = step.startsWith("publish-");
|
|
161
|
-
const
|
|
162
|
-
if (status === "active") this.activate(
|
|
238
|
+
const key: ReleaseStepKey = isPublish ? "publish" : "execute";
|
|
239
|
+
if (status === "active") this.activate(key, detail);
|
|
163
240
|
else if (status === "done") {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (s) { s.detail = detail; }
|
|
167
|
-
refresh();
|
|
168
|
-
} else if (status === "error") this.fail(idx, detail);
|
|
241
|
+
progress.detail(detail ?? "");
|
|
242
|
+
} else if (status === "error") this.fail(key, detail);
|
|
169
243
|
};
|
|
170
244
|
},
|
|
171
|
-
|
|
172
245
|
dispose() {
|
|
173
|
-
|
|
174
|
-
ctx.ui.setStatus?.(STATUS_KEY, undefined);
|
|
175
|
-
ctx.ui.setWidget?.(WIDGET_KEY, undefined);
|
|
246
|
+
progress.dispose();
|
|
176
247
|
},
|
|
177
248
|
};
|
|
178
249
|
}
|
|
@@ -199,7 +270,7 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
|
|
|
199
270
|
const modelCfg = loadModelConfig(platform.paths, ctx.cwd);
|
|
200
271
|
const bridge = createModelBridge(platform);
|
|
201
272
|
const resolved = resolveModelForAction("release", modelRegistry, modelCfg, bridge);
|
|
202
|
-
await applyModelOverride(platform, ctx, "release", resolved);
|
|
273
|
+
const modelCleanup = await applyModelOverride(platform, ctx, "release", resolved);
|
|
203
274
|
|
|
204
275
|
if (!ctx.hasUI) {
|
|
205
276
|
ctx.ui.notify("Release requires interactive mode", "warning");
|
|
@@ -210,16 +281,90 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
|
|
|
210
281
|
|
|
211
282
|
void (async () => {
|
|
212
283
|
try {
|
|
213
|
-
const
|
|
284
|
+
const skipPolish = args?.includes("--raw") ?? false;
|
|
214
285
|
const isDryRun = args?.includes("--dry-run") ?? false;
|
|
215
286
|
const config = loadConfig(platform.paths, ctx.cwd);
|
|
216
|
-
|
|
217
|
-
// 0. Check for uncommitted changes
|
|
218
|
-
progress.activate(0, "Checking working tree");
|
|
287
|
+
const tagFormat = config.release.tagFormat;
|
|
219
288
|
let didStash = false;
|
|
289
|
+
|
|
290
|
+
// ── 1. Quality checks (headless) ────────────────────────────────────
|
|
291
|
+
progress.activate("checks", "Running quality gates");
|
|
292
|
+
const checksReport = await runHeadlessChecks(platform, ctx, config, resolved);
|
|
293
|
+
if (checksReport) {
|
|
294
|
+
const { summary, overallStatus } = checksReport;
|
|
295
|
+
const detail = `${summary.passed} passed, ${summary.failed} failed, ${summary.blocked} blocked`;
|
|
296
|
+
if (overallStatus === "passed") {
|
|
297
|
+
progress.complete("checks", detail);
|
|
298
|
+
} else {
|
|
299
|
+
progress.fail("checks", detail);
|
|
300
|
+
const failedNames = checksReport.gates
|
|
301
|
+
.filter((g) => g.status === "failed" || g.status === "blocked")
|
|
302
|
+
.map((g) => GATE_DISPLAY_NAMES[g.gate] ?? g.gate);
|
|
303
|
+
const continueChoice = await ctx.ui.select(
|
|
304
|
+
`Quality checks ${overallStatus}: ${failedNames.join(", ")}`,
|
|
305
|
+
[
|
|
306
|
+
"Continue — release despite failures",
|
|
307
|
+
"Abort — fix issues first",
|
|
308
|
+
],
|
|
309
|
+
{ helpText: detail },
|
|
310
|
+
);
|
|
311
|
+
if (!continueChoice || continueChoice.startsWith("Abort")) {
|
|
312
|
+
progress.dispose();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
progress.skip("checks", "No gates configured");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── 2. Doc-drift pre-check ──────────────────────────────────────────
|
|
321
|
+
progress.activate("doc-drift", "Checking documentation drift");
|
|
322
|
+
const driftResult = await checkDocDrift(platform, ctx.cwd);
|
|
323
|
+
if (driftResult?.drifted) {
|
|
324
|
+
progress.complete("doc-drift", "Drift detected");
|
|
325
|
+
const driftAction = await ctx.ui.select(
|
|
326
|
+
"Documentation drift detected before release",
|
|
327
|
+
[
|
|
328
|
+
"Update docs — fix documentation before continuing",
|
|
329
|
+
"Continue — release without updating docs",
|
|
330
|
+
],
|
|
331
|
+
{ helpText: driftResult.summary },
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
if (driftAction?.startsWith("Update docs")) {
|
|
335
|
+
progress.activate("doc-drift", "Fixing documentation");
|
|
336
|
+
notifyInfo(ctx, "Updating documentation", driftResult.summary);
|
|
337
|
+
const fixPrompt = buildFixPrompt(driftResult.findings);
|
|
338
|
+
const { loadState: loadDriftState, saveState: saveDriftState, getHeadCommit } = await import("../docs/drift.js");
|
|
339
|
+
const driftHead = await getHeadCommit(platform, ctx.cwd);
|
|
340
|
+
const driftState = loadDriftState(platform.paths, ctx.cwd);
|
|
341
|
+
saveDriftState(platform.paths, ctx.cwd, { ...driftState, lastCommit: driftHead, lastRunAt: new Date().toISOString() });
|
|
342
|
+
|
|
343
|
+
const fixResult = await runStructuredAgentSession(
|
|
344
|
+
platform.createAgentSession.bind(platform),
|
|
345
|
+
{ cwd: ctx.cwd, prompt: fixPrompt },
|
|
346
|
+
);
|
|
347
|
+
if (fixResult.status === "ok") {
|
|
348
|
+
progress.complete("doc-drift", "Fixed");
|
|
349
|
+
notifySuccess(ctx, "Documentation updated");
|
|
350
|
+
} else {
|
|
351
|
+
progress.fail("doc-drift", fixResult.error ?? "Agent session error");
|
|
352
|
+
notifyError(ctx, "Doc update failed", fixResult.error ?? "Agent session error");
|
|
353
|
+
progress.dispose();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
progress.skip("doc-drift", "Skipped by user");
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
progress.complete("doc-drift", "No drift");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── 3. Check for uncommitted changes after preflight side effects ─────
|
|
364
|
+
progress.activate("working-tree", "Checking working tree");
|
|
220
365
|
const treeStatus = await getWorkingTreeStatus(platform.exec.bind(platform), ctx.cwd);
|
|
221
366
|
if (treeStatus.dirty) {
|
|
222
|
-
progress.complete(
|
|
367
|
+
progress.complete("working-tree", `${treeStatus.files.length} files changed`);
|
|
223
368
|
const filePreview = treeStatus.files.slice(0, 8).join(", ");
|
|
224
369
|
const extra = treeStatus.files.length > 8 ? ` (+${treeStatus.files.length - 8} more)` : "";
|
|
225
370
|
|
|
@@ -227,8 +372,8 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
|
|
|
227
372
|
`Uncommitted changes detected (${treeStatus.files.length} files)`,
|
|
228
373
|
[
|
|
229
374
|
"commit — commit changes with AI-generated message",
|
|
230
|
-
"stash
|
|
231
|
-
"abort
|
|
375
|
+
"stash — stash changes and continue",
|
|
376
|
+
"abort — cancel release",
|
|
232
377
|
],
|
|
233
378
|
{ helpText: `Files: ${filePreview}${extra}` },
|
|
234
379
|
);
|
|
@@ -239,72 +384,85 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
|
|
|
239
384
|
}
|
|
240
385
|
|
|
241
386
|
if (action.startsWith("commit")) {
|
|
242
|
-
// commit.ts has its own progress widget; dispose ours temporarily
|
|
243
387
|
progress.dispose();
|
|
244
388
|
const commitResult = await analyzeAndCommit(platform, ctx);
|
|
245
389
|
if (!commitResult) {
|
|
246
390
|
notifyError(ctx, "Commit failed or cancelled", "Aborting release.");
|
|
247
391
|
return;
|
|
248
392
|
}
|
|
249
|
-
// Verify tree is now clean
|
|
250
393
|
const afterStatus = await getWorkingTreeStatus(platform.exec.bind(platform), ctx.cwd);
|
|
251
394
|
if (afterStatus.dirty) {
|
|
252
395
|
notifyError(ctx, "Still uncommitted changes", "Not all changes were committed. Aborting release.");
|
|
253
396
|
return;
|
|
254
397
|
}
|
|
255
|
-
|
|
256
|
-
// (reactivate shows state from where we left off)
|
|
257
|
-
progress.complete(0, "Committed");
|
|
398
|
+
progress.complete("working-tree", "Committed");
|
|
258
399
|
} else if (action.startsWith("stash")) {
|
|
259
|
-
progress.activate(
|
|
400
|
+
progress.activate("working-tree", "Stashing changes");
|
|
260
401
|
const stashResult = await platform.exec("git", ["stash", "push", "-m", "supi:release auto-stash"], { cwd: ctx.cwd });
|
|
261
402
|
if (stashResult.code !== 0) {
|
|
262
|
-
progress.fail(
|
|
403
|
+
progress.fail("working-tree", stashResult.stderr || "stash failed");
|
|
263
404
|
progress.dispose();
|
|
264
405
|
notifyError(ctx, "git stash failed", stashResult.stderr || "Non-zero exit");
|
|
265
406
|
return;
|
|
266
407
|
}
|
|
267
408
|
didStash = true;
|
|
268
|
-
progress.complete(
|
|
409
|
+
progress.complete("working-tree", "Stashed");
|
|
269
410
|
}
|
|
270
411
|
} else {
|
|
271
|
-
progress.complete(
|
|
412
|
+
progress.complete("working-tree", "Clean");
|
|
272
413
|
}
|
|
273
414
|
|
|
274
|
-
//
|
|
275
|
-
progress.activate(
|
|
415
|
+
// ── 4. Ensure channels are configured (or detect + ask) ─────────────
|
|
416
|
+
progress.activate("channels", "Detecting channels");
|
|
417
|
+
const customChannels = config.release.customChannels ?? {};
|
|
418
|
+
const detectedChannels = await detectChannels(platform.exec.bind(platform), ctx.cwd, customChannels);
|
|
276
419
|
let channels = config.release.channels;
|
|
277
420
|
// Track whether channels were already set in config before any interactive
|
|
278
421
|
// setup. This distinguishes "user already decided" from "just configured now",
|
|
279
422
|
// which determines whether we can auto-continue without a confirmation prompt.
|
|
280
423
|
const channelsWerePreConfigured = config.release.channels.length > 0;
|
|
281
424
|
|
|
425
|
+
if (channelsWerePreConfigured) {
|
|
426
|
+
const invalidChannels = findInvalidReleaseChannels(channels, detectedChannels);
|
|
427
|
+
if (invalidChannels.length > 0) {
|
|
428
|
+
const detail = invalidChannels.join("; ");
|
|
429
|
+
progress.fail("channels", detail);
|
|
430
|
+
notifyError(ctx, "Release channels invalid", detail);
|
|
431
|
+
progress.dispose();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
282
436
|
if (channels.length === 0) {
|
|
283
|
-
progress.complete(
|
|
284
|
-
channels = await setupChannels(platform, ctx);
|
|
437
|
+
progress.complete("channels", "Awaiting selection");
|
|
438
|
+
channels = await setupChannels(platform, ctx, detectedChannels);
|
|
285
439
|
if (channels.length === 0) {
|
|
286
440
|
progress.dispose();
|
|
287
441
|
return;
|
|
288
442
|
}
|
|
289
443
|
}
|
|
290
|
-
progress.complete(
|
|
444
|
+
progress.complete("channels", channels.join(", "));
|
|
291
445
|
|
|
292
|
-
//
|
|
293
|
-
progress.activate(
|
|
446
|
+
// ── 5. Get last tag + current version + check if bump is needed ─────
|
|
447
|
+
progress.activate("commits", "Parsing git history");
|
|
294
448
|
const lastTag = await getLastTag(platform, ctx.cwd);
|
|
295
449
|
const currentVersion = getCurrentVersion(ctx.cwd);
|
|
450
|
+
const resumableLocalRelease = await findResumableLocalRelease(
|
|
451
|
+
platform.exec.bind(platform),
|
|
452
|
+
ctx.cwd,
|
|
453
|
+
currentVersion,
|
|
454
|
+
tagFormat,
|
|
455
|
+
);
|
|
296
456
|
const localTagExists = await isVersionReleased(
|
|
297
457
|
platform.exec.bind(platform),
|
|
298
458
|
ctx.cwd,
|
|
299
459
|
currentVersion,
|
|
460
|
+
tagFormat,
|
|
300
461
|
);
|
|
301
|
-
// Only check remote when local tag exists — avoids a network call when
|
|
302
|
-
// the version was never tagged at all.
|
|
303
462
|
const remoteTagExists = localTagExists
|
|
304
|
-
? await isTagOnRemote(platform.exec.bind(platform), ctx.cwd, currentVersion)
|
|
463
|
+
? await isTagOnRemote(platform.exec.bind(platform), ctx.cwd, currentVersion, tagFormat)
|
|
305
464
|
: false;
|
|
306
465
|
|
|
307
|
-
// 3. Parse commits since last tag
|
|
308
466
|
const sinceArg = lastTag ? `${lastTag}..HEAD` : "HEAD~50..HEAD";
|
|
309
467
|
let gitLogOutput: string;
|
|
310
468
|
try {
|
|
@@ -321,103 +479,122 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
|
|
|
321
479
|
const commits = parseConventionalCommits(gitLogOutput);
|
|
322
480
|
const summary = summarizeChanges(commits);
|
|
323
481
|
const commitCount = commits.features.length + commits.fixes.length + commits.breaking.length + commits.improvements.length + commits.maintenance.length + commits.other.length;
|
|
324
|
-
progress.complete(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
// a) No local tag → version is unreleased, skip bump
|
|
329
|
-
// b) Local tag, not on remote → incomplete release, skip bump + skip tag
|
|
330
|
-
// c) Local + remote tag → fully released, ask for bump
|
|
331
|
-
let nextVersion: string;
|
|
332
|
-
let skipBump: boolean;
|
|
482
|
+
progress.complete("commits", `${commitCount} commits since ${lastTag ?? "start"}`);
|
|
483
|
+
|
|
484
|
+
let nextVersion: string | null = null;
|
|
485
|
+
let skipBump: boolean | null = null;
|
|
333
486
|
let skipTag = false;
|
|
334
487
|
|
|
335
|
-
if (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
progress.activate(3, "Awaiting version selection");
|
|
351
|
-
const suggested = suggestBump(commits);
|
|
352
|
-
const bumpChoice = await ctx.ui.select(
|
|
353
|
-
`Version bump (${summary})`,
|
|
354
|
-
BUMP_OPTIONS,
|
|
355
|
-
{ helpText: `Current: ${currentVersion} (released) | Suggested: ${suggested}` },
|
|
488
|
+
if (resumableLocalRelease) {
|
|
489
|
+
progress.activate("version", "Found local release tag");
|
|
490
|
+
const resumeChoice = await ctx.ui.select(
|
|
491
|
+
`Continue failed release ${resumableLocalRelease.tag}?`,
|
|
492
|
+
[
|
|
493
|
+
`Continue — resume ${resumableLocalRelease.tag}`,
|
|
494
|
+
"Ignore — choose version normally",
|
|
495
|
+
],
|
|
496
|
+
{
|
|
497
|
+
helpText: [
|
|
498
|
+
`A newer local tag (${resumableLocalRelease.tag}) exists on the current HEAD but is not on origin.`,
|
|
499
|
+
`package.json still reports ${currentVersion}.`,
|
|
500
|
+
"No commits or worktree changes were detected after that local tag.",
|
|
501
|
+
].join(" "),
|
|
502
|
+
},
|
|
356
503
|
);
|
|
357
|
-
if (!
|
|
504
|
+
if (!resumeChoice) {
|
|
358
505
|
progress.dispose();
|
|
359
506
|
return;
|
|
360
507
|
}
|
|
361
508
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
509
|
+
if (resumeChoice.startsWith("Continue")) {
|
|
510
|
+
nextVersion = resumableLocalRelease.version;
|
|
511
|
+
skipBump = true;
|
|
512
|
+
skipTag = true;
|
|
513
|
+
progress.skip("version", `${resumableLocalRelease.tag} (local tag not deployed)`);
|
|
514
|
+
notifyInfo(
|
|
515
|
+
ctx,
|
|
516
|
+
`Resuming failed release ${resumableLocalRelease.tag}`,
|
|
517
|
+
"Local tag exists only on this machine — release will continue from that tagged commit.",
|
|
518
|
+
);
|
|
519
|
+
}
|
|
366
520
|
}
|
|
367
521
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
522
|
+
if (nextVersion === null || skipBump === null) {
|
|
523
|
+
if (!localTagExists && currentVersion !== "0.0.0") {
|
|
524
|
+
nextVersion = currentVersion;
|
|
525
|
+
skipBump = true;
|
|
526
|
+
progress.skip("version", `${formatTag(currentVersion, tagFormat)} (already set, not yet released)`);
|
|
527
|
+
notifyInfo(ctx, `Using ${formatTag(currentVersion, tagFormat)}`, "Version not yet released — skipping bump");
|
|
528
|
+
} else if (localTagExists && !remoteTagExists) {
|
|
529
|
+
nextVersion = currentVersion;
|
|
530
|
+
skipBump = true;
|
|
531
|
+
skipTag = true;
|
|
532
|
+
progress.skip("version", `${formatTag(currentVersion, tagFormat)} (tag exists locally, not pushed)`);
|
|
533
|
+
notifyInfo(ctx, `Resuming ${formatTag(currentVersion, tagFormat)}`, "Tag exists locally but not on remote — will push");
|
|
534
|
+
} else {
|
|
535
|
+
progress.activate("version", "Awaiting version selection");
|
|
536
|
+
const suggested = suggestBump(commits);
|
|
537
|
+
const bumpChoice = await ctx.ui.select(
|
|
538
|
+
`Version bump (${summary})`,
|
|
539
|
+
BUMP_OPTIONS,
|
|
540
|
+
{ helpText: `Current: ${currentVersion} (released) | Suggested: ${suggested}` },
|
|
541
|
+
);
|
|
542
|
+
if (!bumpChoice) {
|
|
543
|
+
progress.dispose();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
389
546
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
547
|
+
const bump = bumpChoice.split(" — ")[0] as BumpType;
|
|
548
|
+
nextVersion = bumpVersion(currentVersion, bump);
|
|
549
|
+
skipBump = false;
|
|
550
|
+
progress.complete("version", `${currentVersion} → ${nextVersion} (${bump})`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// ── 8. Build changelog ──────────────────────────────────────────────
|
|
554
|
+
progress.activate("changelog", "Generating changelog");
|
|
555
|
+
const rawChangelog = buildChangelogMarkdown(commits, nextVersion, tagFormat);
|
|
556
|
+
progress.complete("changelog", `${commitCount} entries`);
|
|
557
|
+
|
|
558
|
+
// ── 9. Polish release notes (default, skip with --raw) ──────────────
|
|
559
|
+
let changelog: string;
|
|
560
|
+
if (skipPolish) {
|
|
561
|
+
progress.skip("polish", "Skipped (--raw)");
|
|
562
|
+
changelog = rawChangelog;
|
|
563
|
+
} else {
|
|
564
|
+
progress.activate("polish", "Polishing release notes");
|
|
565
|
+
const polishPrompt = buildPolishPrompt({ changelog: rawChangelog, version: nextVersion, tagFormat });
|
|
566
|
+
const polishResult = await runStructuredAgentSession(
|
|
567
|
+
platform.createAgentSession.bind(platform),
|
|
568
|
+
{ cwd: ctx.cwd, prompt: polishPrompt },
|
|
397
569
|
);
|
|
398
|
-
|
|
570
|
+
if (polishResult.status === "ok") {
|
|
571
|
+
changelog = polishResult.finalText;
|
|
572
|
+
progress.complete("polish", "Polished");
|
|
573
|
+
} else {
|
|
574
|
+
// Polish failed — fall back to raw changelog, don't block the release
|
|
575
|
+
changelog = rawChangelog;
|
|
576
|
+
progress.fail("polish", polishResult.error ?? "Agent error — using raw changelog");
|
|
577
|
+
notifyInfo(ctx, "Polish failed — using raw changelog", polishResult.error ?? "");
|
|
578
|
+
}
|
|
399
579
|
}
|
|
400
580
|
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// channels were pre-configured there's nothing left to decide. Asking
|
|
404
|
-
// again is friction without benefit. Dry-run always asks because the
|
|
405
|
-
// user explicitly opted into preview mode.
|
|
581
|
+
// ── 10. Confirm via UI ──────────────────────────────────────────────
|
|
582
|
+
// Skipped when resuming a staged unreleased release.
|
|
406
583
|
const isResume = isInProgressRelease({ skipBump, channelsWerePreConfigured, isDryRun });
|
|
407
584
|
|
|
408
585
|
if (isResume) {
|
|
409
586
|
notifyInfo(
|
|
410
587
|
ctx,
|
|
411
|
-
`Resuming release
|
|
588
|
+
`Resuming release ${formatTag(nextVersion, tagFormat)}`,
|
|
412
589
|
"Version staged and channels configured — proceeding without confirmation",
|
|
413
590
|
);
|
|
414
591
|
} else {
|
|
415
|
-
const confirmLabel = isDryRun ? `[DRY RUN] Ship
|
|
592
|
+
const confirmLabel = isDryRun ? `[DRY RUN] Ship ${formatTag(nextVersion, tagFormat)}?` : `Ship ${formatTag(nextVersion, tagFormat)}?`;
|
|
416
593
|
// When skipBump=true, currentVersion === nextVersion — avoid the
|
|
417
594
|
// misleading "0.5.0 → 0.5.0" display.
|
|
418
595
|
const versionLine = skipBump
|
|
419
|
-
?
|
|
420
|
-
: `${currentVersion}
|
|
596
|
+
? `${formatTag(nextVersion, tagFormat)} (staged, not yet released)`
|
|
597
|
+
: `${currentVersion} \u2192 ${nextVersion}`;
|
|
421
598
|
const confirmDetail = [
|
|
422
599
|
versionLine,
|
|
423
600
|
`Channels: ${channels.join(", ")}`,
|
|
@@ -428,7 +605,7 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
|
|
|
428
605
|
|
|
429
606
|
const confirmed = ctx.ui.confirm
|
|
430
607
|
? await ctx.ui.confirm(confirmLabel, confirmDetail)
|
|
431
|
-
: (await ctx.ui.select(confirmLabel, ["Yes
|
|
608
|
+
: (await ctx.ui.select(confirmLabel, ["Yes — publish", "No — abort"])) === "Yes — publish";
|
|
432
609
|
|
|
433
610
|
if (!confirmed) {
|
|
434
611
|
progress.dispose();
|
|
@@ -437,10 +614,10 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
|
|
|
437
614
|
}
|
|
438
615
|
}
|
|
439
616
|
|
|
440
|
-
//
|
|
441
|
-
progress.activate(
|
|
617
|
+
// ── 11. Execute release ─────────────────────────────────────────────
|
|
618
|
+
progress.activate("execute", "Starting release execution");
|
|
442
619
|
try {
|
|
443
|
-
|
|
620
|
+
let result = await executeRelease({
|
|
444
621
|
exec: platform.exec.bind(platform),
|
|
445
622
|
cwd: ctx.cwd,
|
|
446
623
|
version: nextVersion,
|
|
@@ -449,38 +626,56 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
|
|
|
449
626
|
dryRun: isDryRun,
|
|
450
627
|
skipBump,
|
|
451
628
|
skipTag,
|
|
629
|
+
tagFormat,
|
|
630
|
+
customChannels,
|
|
452
631
|
onProgress: progress.executorProgress(),
|
|
453
632
|
});
|
|
454
633
|
|
|
455
|
-
|
|
634
|
+
if (!isDryRun && result.error && !result.pushed) {
|
|
635
|
+
const switchedTo = await maybeSwitchGithubAccountForReleaseFailure(platform, ctx, result.error);
|
|
636
|
+
if (switchedTo) {
|
|
637
|
+
progress.activate("execute", `Retrying with GitHub account ${switchedTo}`);
|
|
638
|
+
result = await executeRelease({
|
|
639
|
+
exec: platform.exec.bind(platform),
|
|
640
|
+
cwd: ctx.cwd,
|
|
641
|
+
version: nextVersion,
|
|
642
|
+
changelog,
|
|
643
|
+
channels,
|
|
644
|
+
dryRun: false,
|
|
645
|
+
skipBump: true,
|
|
646
|
+
skipTag: true,
|
|
647
|
+
tagFormat,
|
|
648
|
+
customChannels,
|
|
649
|
+
onProgress: progress.executorProgress(),
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
456
654
|
if (result.tagCreated && result.pushed) {
|
|
457
|
-
progress.complete(
|
|
655
|
+
progress.complete("execute", "Tag + push complete");
|
|
458
656
|
} else if (result.error) {
|
|
459
|
-
progress.fail(
|
|
657
|
+
progress.fail("execute", result.error);
|
|
460
658
|
} else {
|
|
461
|
-
progress.fail(
|
|
659
|
+
progress.fail("execute", `Tag: ${result.tagCreated ? "✓" : "✗"} | Push: ${result.pushed ? "✓" : "✗"}`);
|
|
462
660
|
}
|
|
463
661
|
|
|
464
|
-
// Mark channel publishing
|
|
465
662
|
if (result.channels.length > 0) {
|
|
466
663
|
const allOk = result.channels.every((c) => c.success);
|
|
467
664
|
if (allOk) {
|
|
468
|
-
progress.complete(
|
|
665
|
+
progress.complete("publish", result.channels.map((c) => c.channel).join(", "));
|
|
469
666
|
} else {
|
|
470
667
|
const failedChannels = result.channels.filter((c) => !c.success);
|
|
471
|
-
progress.fail(
|
|
668
|
+
progress.fail("publish", failedChannels.map((c) => `${c.channel}: ${c.error ?? "failed"}`).join("; "));
|
|
472
669
|
}
|
|
473
670
|
} else if (result.error) {
|
|
474
|
-
progress.skip(
|
|
671
|
+
progress.skip("publish", "Skipped (release failed)");
|
|
475
672
|
} else {
|
|
476
|
-
progress.complete(
|
|
673
|
+
progress.complete("publish", "No channels");
|
|
477
674
|
}
|
|
478
675
|
|
|
479
|
-
// Give the user a moment to see the final widget state
|
|
480
676
|
await new Promise((r) => setTimeout(r, 1500));
|
|
481
677
|
progress.dispose();
|
|
482
678
|
|
|
483
|
-
// Final notification
|
|
484
679
|
const channelSummary = result.channels
|
|
485
680
|
.map((c) => `${c.channel}: ${c.success ? "✓" : `✗ ${c.error ?? "failed"}`}`)
|
|
486
681
|
.join(", ");
|
|
@@ -489,18 +684,17 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
|
|
|
489
684
|
const prefix = isDryRun ? "[DRY RUN] " : "";
|
|
490
685
|
notifySuccess(
|
|
491
686
|
ctx,
|
|
492
|
-
`${prefix}Released
|
|
687
|
+
`${prefix}Released ${formatTag(nextVersion, tagFormat)}`,
|
|
493
688
|
`Tag: ${result.tagCreated ? "✓" : "✗"} | Push: ${result.pushed ? "✓" : "✗"} | ${channelSummary}`,
|
|
494
689
|
);
|
|
495
690
|
} else {
|
|
496
|
-
// Include the error detail so the user knows WHY it failed
|
|
497
691
|
const detail = result.error
|
|
498
692
|
? result.error
|
|
499
693
|
: `Tag: ${result.tagCreated ? "✓" : "✗"} | Push: ${result.pushed ? "✓" : "✗"}`;
|
|
500
|
-
notifyError(ctx, `Release
|
|
694
|
+
notifyError(ctx, `Release ${formatTag(nextVersion, tagFormat)} failed`, detail);
|
|
501
695
|
}
|
|
502
696
|
} catch (err) {
|
|
503
|
-
progress.fail(
|
|
697
|
+
progress.fail("execute", err instanceof Error ? err.message : "Unknown error");
|
|
504
698
|
await new Promise((r) => setTimeout(r, 1500));
|
|
505
699
|
progress.dispose();
|
|
506
700
|
notifyError(
|
|
@@ -519,10 +713,41 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
|
|
|
519
713
|
} catch (err) {
|
|
520
714
|
progress.dispose();
|
|
521
715
|
notifyError(ctx, "Release error", err instanceof Error ? err.message : String(err));
|
|
716
|
+
} finally {
|
|
717
|
+
await modelCleanup();
|
|
522
718
|
}
|
|
523
719
|
})();
|
|
524
720
|
}
|
|
525
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Run quality gates headlessly — no UI widget of its own, results reported
|
|
724
|
+
* back to the release progress widget.
|
|
725
|
+
*
|
|
726
|
+
* Returns null when no gates are configured (caller should skip the step).
|
|
727
|
+
*/
|
|
728
|
+
async function runHeadlessChecks(
|
|
729
|
+
platform: Platform,
|
|
730
|
+
ctx: any,
|
|
731
|
+
config: ReturnType<typeof loadConfig>,
|
|
732
|
+
resolved: ResolvedModel,
|
|
733
|
+
): Promise<ReviewReport | null> {
|
|
734
|
+
const enabledGates = Object.entries(config.quality.gates)
|
|
735
|
+
.filter(([, gate]) => gate?.enabled === true);
|
|
736
|
+
|
|
737
|
+
if (enabledGates.length === 0) {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return runQualityGates({
|
|
742
|
+
platform,
|
|
743
|
+
cwd: ctx.cwd,
|
|
744
|
+
gates: config.quality.gates,
|
|
745
|
+
filters: {},
|
|
746
|
+
reviewModel: resolved,
|
|
747
|
+
gateRegistry: REVIEW_GATE_REGISTRY,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
526
751
|
async function getLastTag(platform: Platform, cwd: string): Promise<string | null> {
|
|
527
752
|
try {
|
|
528
753
|
const result = await platform.exec("git", ["describe", "--tags", "--abbrev=0"], { cwd });
|
|
@@ -532,60 +757,64 @@ async function getLastTag(platform: Platform, cwd: string): Promise<string | nul
|
|
|
532
757
|
}
|
|
533
758
|
}
|
|
534
759
|
|
|
535
|
-
async function setupChannels(
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
760
|
+
async function setupChannels(
|
|
761
|
+
platform: Platform,
|
|
762
|
+
ctx: any,
|
|
763
|
+
detected: ChannelStatus[],
|
|
764
|
+
): Promise<ReleaseChannel[]> {
|
|
765
|
+
const availableChannels = detected.filter((channel) => channel.available);
|
|
766
|
+
const unavailableChannels = detected.filter((channel) => !channel.available);
|
|
767
|
+
const unavailableSummary = unavailableChannels
|
|
768
|
+
.map((channel) => `${channel.channel}: ${channel.detail}`)
|
|
769
|
+
.join("; ");
|
|
770
|
+
|
|
771
|
+
if (availableChannels.length === 0) {
|
|
772
|
+
const detail = unavailableSummary || "No release channels are currently available.";
|
|
773
|
+
ctx.ui.notify(`No release channels are currently available. ${detail}`, "warning");
|
|
774
|
+
return [];
|
|
543
775
|
}
|
|
544
|
-
for (const d of detected) {
|
|
545
|
-
options.push(`${d.channel} — ${d.available ? d.detail : `unavailable (${d.detail})`}`);
|
|
546
|
-
}
|
|
547
|
-
options.push("skip — configure later");
|
|
548
|
-
|
|
549
|
-
const choice = await ctx.ui.select(
|
|
550
|
-
"Release Setup — Select publish channels",
|
|
551
|
-
options,
|
|
552
|
-
{ helpText: "Which channels should releases be published to?" },
|
|
553
|
-
);
|
|
554
776
|
|
|
555
|
-
|
|
777
|
+
const channelOptions = buildSelectableReleaseChannelOptions(detected);
|
|
778
|
+
const helpText = unavailableSummary
|
|
779
|
+
? `Select channels one at a time. Pick Done when finished. Unavailable: ${unavailableSummary}`
|
|
780
|
+
: "Select channels one at a time. Pick Done when finished.";
|
|
781
|
+
|
|
782
|
+
// Loop-based multi-select: user picks channels one at a time, "Done" to finish
|
|
783
|
+
const selected: ReleaseChannel[] = [];
|
|
784
|
+
|
|
785
|
+
while (true) {
|
|
786
|
+
const remaining = channelOptions.filter(
|
|
787
|
+
(option) => !selected.includes(option.split(" — ")[0]),
|
|
788
|
+
);
|
|
789
|
+
const options = [
|
|
790
|
+
...remaining,
|
|
791
|
+
...(selected.length > 0 ? [`Done — selected: ${selected.join(", ")}`] : []),
|
|
792
|
+
"skip — configure later",
|
|
793
|
+
];
|
|
794
|
+
|
|
795
|
+
const choice = await ctx.ui.select(
|
|
796
|
+
selected.length === 0
|
|
797
|
+
? "Release Setup — Select publish channels"
|
|
798
|
+
: `Selected: ${selected.join(", ")} — add more or Done`,
|
|
799
|
+
options,
|
|
800
|
+
{ helpText },
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
if (!choice || choice.startsWith("skip")) {
|
|
804
|
+
if (selected.length > 0) break; // keep what was selected
|
|
805
|
+
return [];
|
|
806
|
+
}
|
|
807
|
+
if (choice.startsWith("Done")) break;
|
|
556
808
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
channels = [choice.split(" — ")[0] as ReleaseChannel];
|
|
809
|
+
const channelId = choice.split(" — ")[0];
|
|
810
|
+
if (!selected.includes(channelId)) {
|
|
811
|
+
selected.push(channelId);
|
|
812
|
+
}
|
|
562
813
|
}
|
|
563
814
|
|
|
564
815
|
// Persist to config
|
|
565
|
-
updateConfig(platform.paths, ctx.cwd, { release: { channels } });
|
|
566
|
-
ctx.ui.notify(`Release channels set to: ${
|
|
567
|
-
|
|
568
|
-
return channels;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
function buildReleaseCommands(
|
|
572
|
-
version: string,
|
|
573
|
-
channels: ReleaseChannel[],
|
|
574
|
-
): string[] {
|
|
575
|
-
const commands: string[] = [
|
|
576
|
-
`git add -A`,
|
|
577
|
-
`git commit -m "chore(release): v${version}"`,
|
|
578
|
-
`git tag -a v${version} -m "Release v${version}"`,
|
|
579
|
-
`git push origin HEAD --follow-tags`,
|
|
580
|
-
];
|
|
581
|
-
|
|
582
|
-
for (const channel of channels) {
|
|
583
|
-
if (channel === "github") {
|
|
584
|
-
commands.push(`gh release create v${version} --title "v${version}" --notes "...changelog..."`);
|
|
585
|
-
} else if (channel === "npm") {
|
|
586
|
-
commands.push(`npm publish`);
|
|
587
|
-
}
|
|
588
|
-
}
|
|
816
|
+
updateConfig(platform.paths, ctx.cwd, { release: { channels: selected } });
|
|
817
|
+
ctx.ui.notify(`Release channels set to: ${selected.join(", ")}`, "info");
|
|
589
818
|
|
|
590
|
-
return
|
|
819
|
+
return selected;
|
|
591
820
|
}
|