@workit-dev/core 0.0.1 → 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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
// `workit release start|finish|publish`: a git-flow release or hotfix without
|
|
2
|
+
// the git-flow binary, with `git flow release finish` semantics.
|
|
3
|
+
//
|
|
4
|
+
// - start cuts release/<version> (hotfix/<version> from production) on the
|
|
5
|
+
// line's integration branch through gitBranch, locally. It never pushes.
|
|
6
|
+
// - finish (local half, here): --no-ff merge of the release branch into
|
|
7
|
+
// production, an annotated tag <prefix><version> on that merge (message: the
|
|
8
|
+
// changelog section when there is one), a --no-ff merge of the tag into
|
|
9
|
+
// every merge-back branch (git-flow merges back through the tag), then
|
|
10
|
+
// `git branch -d` of the release branch. Every step is decided from the
|
|
11
|
+
// repository state plus the ledger rows of earlier runs, so an interrupted
|
|
12
|
+
// finish resumes without repeating a merge or a tag. The CLI verb then
|
|
13
|
+
// pushes production, the merge-back branches and the tag through the
|
|
14
|
+
// existing `--release` push, publishes the forge release and verifies it.
|
|
15
|
+
// - publish is the one path that pushes a release branch (verbs/release.ts).
|
|
16
|
+
import { readLedger, appendObserved, type LedgerActor } from "../ledger";
|
|
17
|
+
import { failure, success, type ForgeResult } from "../forge/types";
|
|
18
|
+
import { vcsConfig } from "../core/vcs-config";
|
|
19
|
+
import { trackBranchName } from "../core/release-tracks";
|
|
20
|
+
import { resolveChangelogPaths } from "../core/changelog-gate";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import {
|
|
25
|
+
resolveRuntimeWorkspacePolicy,
|
|
26
|
+
workspaceChangelog,
|
|
27
|
+
workspaceReleaseNotes,
|
|
28
|
+
type ReleaseNotesMode,
|
|
29
|
+
} from "../core/workspaces";
|
|
30
|
+
import { compareUrl, highlightsOf, previousTag, renderReleaseNotes } from "../core/release-notes";
|
|
31
|
+
import {
|
|
32
|
+
git,
|
|
33
|
+
gitBranch,
|
|
34
|
+
gitError,
|
|
35
|
+
inProgressFailure,
|
|
36
|
+
operationInProgress,
|
|
37
|
+
releaseLinesOf,
|
|
38
|
+
type ReleaseLine,
|
|
39
|
+
} from "./ops";
|
|
40
|
+
import {
|
|
41
|
+
currentBranch,
|
|
42
|
+
deriveForge,
|
|
43
|
+
fetchRefs,
|
|
44
|
+
gitCommonDir,
|
|
45
|
+
pushRemoteName,
|
|
46
|
+
pushUrl,
|
|
47
|
+
remoteRefTip,
|
|
48
|
+
resolveRef,
|
|
49
|
+
} from "./rev";
|
|
50
|
+
|
|
51
|
+
export type ReleaseKind = "release" | "hotfix";
|
|
52
|
+
|
|
53
|
+
const VERSION = /^[0-9A-Za-z][0-9A-Za-z._+-]{0,99}$/u;
|
|
54
|
+
const PLACEHOLDER = /\{[^}]*\}/gu;
|
|
55
|
+
|
|
56
|
+
const validVersion = (version: string): boolean =>
|
|
57
|
+
VERSION.test(version) && !version.includes("..") && !version.endsWith(".lock");
|
|
58
|
+
|
|
59
|
+
/** The branch template of a line and kind: the track's naming, else `<kind>/{version}`. */
|
|
60
|
+
const templateOf = (line: ReleaseLine, kind: ReleaseKind): string => {
|
|
61
|
+
const template = line.naming[kind];
|
|
62
|
+
if (!template) return `${kind}/{version}`;
|
|
63
|
+
return /\{[^}]*\}/u.test(template) ? template : `${template.replace(/\/?\*?$/u, "")}/{version}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const branchFor = (line: ReleaseLine, kind: ReleaseKind, version: string): string =>
|
|
67
|
+
trackBranchName({ naming: line.naming }, kind, version) ?? `${kind}/${version}`;
|
|
68
|
+
|
|
69
|
+
/** The version a release branch name carries under `template`, or null. */
|
|
70
|
+
const versionIn = (template: string, branch: string): string | null => {
|
|
71
|
+
const pattern = template
|
|
72
|
+
.split(PLACEHOLDER)
|
|
73
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))
|
|
74
|
+
.join("(.+)");
|
|
75
|
+
const match = new RegExp(`^${pattern}$`, "u").exec(branch);
|
|
76
|
+
return match?.[1] && validVersion(match[1]) ? match[1] : null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type ReleaseRow = Record<string, unknown> & { type: string };
|
|
80
|
+
|
|
81
|
+
/** This repository's release.* ledger rows, oldest first. */
|
|
82
|
+
const releaseRows = (cwd: string): ReleaseRow[] => {
|
|
83
|
+
const ledger = readLedger(cwd);
|
|
84
|
+
if (!ledger.ok) return [];
|
|
85
|
+
return ledger.value.rows.filter((row) => row.type.startsWith("release."));
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const record = (cwd: string, actor: LedgerActor, row: ReleaseRow): void => {
|
|
89
|
+
appendObserved(cwd, { ...row, actor });
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// start
|
|
94
|
+
|
|
95
|
+
export type ReleaseStartOutcome = {
|
|
96
|
+
version: string;
|
|
97
|
+
kind: ReleaseKind;
|
|
98
|
+
branch: string;
|
|
99
|
+
base: string;
|
|
100
|
+
baseSha: string;
|
|
101
|
+
track: string | null;
|
|
102
|
+
tag: string;
|
|
103
|
+
notes: string[];
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The line `--track`, else the checkout's resolved release track, names. */
|
|
107
|
+
function lineFor(cwd: string, track: string | null): ForgeResult<ReleaseLine> {
|
|
108
|
+
const lines = releaseLinesOf(cwd);
|
|
109
|
+
let name = track;
|
|
110
|
+
if (name === null && lines.length > 1) {
|
|
111
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
112
|
+
if (resolved.ok === false) return failure("blocked", String(resolved.error));
|
|
113
|
+
if (resolved.releaseTrack?.blocking)
|
|
114
|
+
return failure("blocked", String(resolved.releaseTrack.blocking), "pass --track <name>");
|
|
115
|
+
name = resolved.releaseTrack?.track?.name ?? null;
|
|
116
|
+
}
|
|
117
|
+
const line = lines.find((entry) => entry.track === name);
|
|
118
|
+
if (!line)
|
|
119
|
+
return failure(
|
|
120
|
+
"invalid_input",
|
|
121
|
+
`no release track ${JSON.stringify(name)} (configured: ${
|
|
122
|
+
lines
|
|
123
|
+
.map((entry) => entry.track)
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.join(", ") || "none"
|
|
126
|
+
})`,
|
|
127
|
+
);
|
|
128
|
+
return success(line);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function releaseStart(
|
|
132
|
+
cwd: string,
|
|
133
|
+
input: {
|
|
134
|
+
version: string;
|
|
135
|
+
hotfix?: boolean;
|
|
136
|
+
track?: string | null;
|
|
137
|
+
actor: LedgerActor;
|
|
138
|
+
},
|
|
139
|
+
): ForgeResult<ReleaseStartOutcome> {
|
|
140
|
+
const version = input.version.trim();
|
|
141
|
+
if (!validVersion(version))
|
|
142
|
+
return failure("invalid_input", `${JSON.stringify(version)} is not a version (e.g. 1.4.0)`);
|
|
143
|
+
const kind: ReleaseKind = input.hotfix ? "hotfix" : "release";
|
|
144
|
+
const lineResult = lineFor(cwd, input.track ?? null);
|
|
145
|
+
if (!lineResult.ok) return lineResult;
|
|
146
|
+
const line = lineResult.data;
|
|
147
|
+
// git-flow allows one release (or hotfix) in flight per line.
|
|
148
|
+
const template = templateOf(line, kind);
|
|
149
|
+
const existing = git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
|
150
|
+
.stdout.split("\n")
|
|
151
|
+
.filter((name) => name && versionIn(template, name) !== null);
|
|
152
|
+
if (existing.length)
|
|
153
|
+
return failure(
|
|
154
|
+
"blocked",
|
|
155
|
+
`release_exists: ${existing[0]} is still open; git-flow finishes one ${kind} before starting the next`,
|
|
156
|
+
`workit release finish # on ${existing[0]}`,
|
|
157
|
+
);
|
|
158
|
+
const tag = `${line.tagPrefix}${version}`;
|
|
159
|
+
if (resolveRef(cwd, `refs/tags/${tag}`))
|
|
160
|
+
return failure(
|
|
161
|
+
"blocked",
|
|
162
|
+
`tag_exists: ${tag} already exists`,
|
|
163
|
+
"start the next version instead",
|
|
164
|
+
);
|
|
165
|
+
const base = kind === "hotfix" ? line.production : line.integration;
|
|
166
|
+
// The release starts from the fetched remote base (gitBranch); local
|
|
167
|
+
// commits on it that the remote lacks would silently stay out of it.
|
|
168
|
+
const remote = pushRemoteName(cwd, base);
|
|
169
|
+
if (remote) {
|
|
170
|
+
fetchRefs(cwd, remote, [`+refs/heads/${base}:refs/remotes/${remote}/${base}`]);
|
|
171
|
+
const local = resolveRef(cwd, `refs/heads/${base}`);
|
|
172
|
+
const tracked = resolveRef(cwd, `refs/remotes/${remote}/${base}`);
|
|
173
|
+
if (
|
|
174
|
+
local &&
|
|
175
|
+
tracked &&
|
|
176
|
+
local !== tracked &&
|
|
177
|
+
!git(cwd, ["merge-base", "--is-ancestor", local, tracked]).ok
|
|
178
|
+
)
|
|
179
|
+
return failure(
|
|
180
|
+
"blocked",
|
|
181
|
+
`base_unpushed: local ${base} has commits ${remote}/${base} lacks; the ${kind} would start without them`,
|
|
182
|
+
`land them on ${remote}/${base} first, or: git switch ${base} && git reset --keep ${remote}/${base}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const branched = gitBranch(cwd, {
|
|
186
|
+
name: branchFor(line, kind, version),
|
|
187
|
+
base,
|
|
188
|
+
...(line.track ? { track: line.track } : {}),
|
|
189
|
+
});
|
|
190
|
+
if (!branched.ok) return branched;
|
|
191
|
+
record(cwd, input.actor, {
|
|
192
|
+
type: "release.started",
|
|
193
|
+
version,
|
|
194
|
+
kind,
|
|
195
|
+
branch: branched.data.branch,
|
|
196
|
+
head: branched.data.baseSha,
|
|
197
|
+
base,
|
|
198
|
+
track: line.track,
|
|
199
|
+
tag,
|
|
200
|
+
});
|
|
201
|
+
return success({
|
|
202
|
+
version,
|
|
203
|
+
kind,
|
|
204
|
+
branch: branched.data.branch,
|
|
205
|
+
base,
|
|
206
|
+
baseSha: branched.data.baseSha,
|
|
207
|
+
track: line.track,
|
|
208
|
+
tag,
|
|
209
|
+
notes: branched.data.notes,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// finish: the plan
|
|
215
|
+
|
|
216
|
+
export type FinishState = {
|
|
217
|
+
merged: boolean;
|
|
218
|
+
tagged: boolean;
|
|
219
|
+
/** Merge-back branch -> already holds the tag. */
|
|
220
|
+
mergedBack: Record<string, boolean>;
|
|
221
|
+
branchDeleted: boolean;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export type FinishPlan = {
|
|
225
|
+
version: string;
|
|
226
|
+
kind: ReleaseKind;
|
|
227
|
+
branch: string;
|
|
228
|
+
track: string | null;
|
|
229
|
+
tag: string;
|
|
230
|
+
production: string;
|
|
231
|
+
mergeBack: string[];
|
|
232
|
+
/** What the finish pushes: production and the merge-back branches, never a release branch. */
|
|
233
|
+
pushBranches: string[];
|
|
234
|
+
/** A hotfix's merge-back target when a release branch is open (git-flow), else null. */
|
|
235
|
+
openRelease: string | null;
|
|
236
|
+
/** git-flow merges locally; elsewhere finish opens a release PR. */
|
|
237
|
+
gitflow: boolean;
|
|
238
|
+
remote: string | null;
|
|
239
|
+
/** The release branch's tip (from the ledger once the branch is deleted). */
|
|
240
|
+
releaseTip: string;
|
|
241
|
+
/** The remote release branch's tip when it was published, else null. */
|
|
242
|
+
published: string | null;
|
|
243
|
+
/** The commit the tag marks: the production merge, once made. */
|
|
244
|
+
tagTarget: string | null;
|
|
245
|
+
/** A release.merged row exists (a merge resolved by hand has none). */
|
|
246
|
+
mergeRecorded: boolean;
|
|
247
|
+
state: FinishState;
|
|
248
|
+
/** The tag's prefix (`v` in v1.4.0). */
|
|
249
|
+
tagPrefix: string;
|
|
250
|
+
/** The changelog section of this version (the workspace's changelogPath), when there is one. */
|
|
251
|
+
changelog: string | null;
|
|
252
|
+
notes: string[];
|
|
253
|
+
/** Said before merging, never blocking (integration mode pr). */
|
|
254
|
+
warnings: string[];
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const KINDS: ReleaseKind[] = ["release", "hotfix"];
|
|
258
|
+
|
|
259
|
+
type Located = {
|
|
260
|
+
line: ReleaseLine;
|
|
261
|
+
kind: ReleaseKind;
|
|
262
|
+
version: string;
|
|
263
|
+
branch: string;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Which release to finish: `version` (on the given or any line), else the
|
|
268
|
+
* checked-out release branch, else the newest started release this ledger
|
|
269
|
+
* has not finished, else the only release branch.
|
|
270
|
+
*/
|
|
271
|
+
function locate(cwd: string, version: string | null, track: string | null): ForgeResult<Located> {
|
|
272
|
+
const lines = releaseLinesOf(cwd).filter((line) => track === null || line.track === track);
|
|
273
|
+
if (!lines.length) return failure("invalid_input", `no release track ${JSON.stringify(track)}`);
|
|
274
|
+
const rows = releaseRows(cwd);
|
|
275
|
+
const started = rows.filter((row) => row.type === "release.started");
|
|
276
|
+
const finished = new Set(
|
|
277
|
+
rows.filter((row) => row.type === "release.finished").map((row) => String(row.branch)),
|
|
278
|
+
);
|
|
279
|
+
/** The started row of `branch`, newest first: it names the release's line. */
|
|
280
|
+
const startedRow = (branch: string) =>
|
|
281
|
+
started.findLast((row) => row.branch === branch && !finished.has(branch));
|
|
282
|
+
const lineOf = (branch: string, candidates: ReleaseLine[]): ReleaseLine => {
|
|
283
|
+
const row = startedRow(branch);
|
|
284
|
+
const byRow = row ? candidates.find((line) => line.track === (row.track ?? null)) : undefined;
|
|
285
|
+
if (byRow) return byRow;
|
|
286
|
+
const resolved = vcsConfig("resolve", cwd, { branch });
|
|
287
|
+
const name = resolved.ok === false ? null : (resolved.releaseTrack?.track?.name ?? null);
|
|
288
|
+
return candidates.find((line) => line.track === name) ?? candidates[0];
|
|
289
|
+
};
|
|
290
|
+
const match = (branch: string): Located | null => {
|
|
291
|
+
const hits: Array<{
|
|
292
|
+
line: ReleaseLine;
|
|
293
|
+
kind: ReleaseKind;
|
|
294
|
+
version: string;
|
|
295
|
+
}> = [];
|
|
296
|
+
for (const line of lines)
|
|
297
|
+
for (const kind of KINDS) {
|
|
298
|
+
const found = versionIn(templateOf(line, kind), branch);
|
|
299
|
+
if (found) hits.push({ line, kind, version: found });
|
|
300
|
+
}
|
|
301
|
+
if (!hits.length) return null;
|
|
302
|
+
const line = lineOf(
|
|
303
|
+
branch,
|
|
304
|
+
hits.map((hit) => hit.line),
|
|
305
|
+
);
|
|
306
|
+
const hit = hits.find((entry) => entry.line === line) ?? hits[0];
|
|
307
|
+
return { ...hit, branch };
|
|
308
|
+
};
|
|
309
|
+
if (version !== null) {
|
|
310
|
+
if (!validVersion(version))
|
|
311
|
+
return failure("invalid_input", `${JSON.stringify(version)} is not a version`);
|
|
312
|
+
for (const line of lines)
|
|
313
|
+
for (const kind of KINDS) {
|
|
314
|
+
const branch = branchFor(line, kind, version);
|
|
315
|
+
if (resolveRef(cwd, `refs/heads/${branch}`) || startedRow(branch)) {
|
|
316
|
+
const located = match(branch);
|
|
317
|
+
if (located) return success(located);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return failure(
|
|
321
|
+
"not_found",
|
|
322
|
+
`no release or hotfix branch for ${version} here, and none this ledger started`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const current = currentBranch(cwd);
|
|
326
|
+
const here = current ? match(current) : null;
|
|
327
|
+
if (here) return success(here);
|
|
328
|
+
const open = started.findLast((row) => !finished.has(String(row.branch)));
|
|
329
|
+
if (open) {
|
|
330
|
+
const located = match(String(open.branch));
|
|
331
|
+
if (located) return success(located);
|
|
332
|
+
}
|
|
333
|
+
const branches = git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
|
334
|
+
.stdout.split("\n")
|
|
335
|
+
.map((name) => (name ? match(name) : null))
|
|
336
|
+
.filter((entry): entry is Located => entry !== null);
|
|
337
|
+
if (branches.length === 1) return success(branches[0]);
|
|
338
|
+
return failure(
|
|
339
|
+
branches.length ? "invalid_input" : "not_found",
|
|
340
|
+
branches.length
|
|
341
|
+
? `several releases are open (${branches.map((entry) => entry.branch).join(", ")}); name one`
|
|
342
|
+
: "no release or hotfix branch to finish",
|
|
343
|
+
branches.length
|
|
344
|
+
? `workit release finish ${branches[0].version}`
|
|
345
|
+
: "workit release start <version>",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const isAncestor = (cwd: string, ancestor: string, descendant: string): boolean =>
|
|
350
|
+
git(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]).ok;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The changelog section for `version` at `ref` (`file`: the first file the
|
|
354
|
+
* workspace's changelogPath resolves to at `ref`, default CHANGELOG.md): a `## [1.4.0]`, `## 1.4.0` or
|
|
355
|
+
* `# [1.4.0](…)` heading (a `v` before the version is fine), up to the next
|
|
356
|
+
* heading of the same or a higher level. Null without one.
|
|
357
|
+
*/
|
|
358
|
+
export function changelogSection(
|
|
359
|
+
cwd: string,
|
|
360
|
+
ref: string,
|
|
361
|
+
version: string,
|
|
362
|
+
file: string | undefined = resolveChangelogPaths(cwd, workspaceChangelog(cwd).paths, ref)[0],
|
|
363
|
+
): string | null {
|
|
364
|
+
if (!file) return null;
|
|
365
|
+
const shown = git(cwd, ["show", `${ref}:${file}`]);
|
|
366
|
+
if (!shown.ok) return null;
|
|
367
|
+
const lines = shown.stdout.split(/\r?\n/u);
|
|
368
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
369
|
+
const heading = new RegExp(`^(#{1,3})\\s+\\[?v?${escaped}\\]?(?:[\\s(]|$)`, "u");
|
|
370
|
+
const start = lines.findIndex((line) => heading.test(line));
|
|
371
|
+
if (start < 0) return null;
|
|
372
|
+
const level = heading.exec(lines[start])?.[1].length ?? 2;
|
|
373
|
+
let end = lines.length;
|
|
374
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
375
|
+
const hashes = /^(#+)\s/u.exec(lines[index])?.[1].length;
|
|
376
|
+
if (hashes !== undefined && hashes <= level) {
|
|
377
|
+
end = index;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const body = lines
|
|
382
|
+
.slice(start + 1, end)
|
|
383
|
+
.join("\n")
|
|
384
|
+
.trim();
|
|
385
|
+
return body || null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Preflight and state of a finish: nothing changes here except fetched remote-tracking refs. */
|
|
389
|
+
export function planReleaseFinish(
|
|
390
|
+
cwd: string,
|
|
391
|
+
input: { version?: string | null; track?: string | null },
|
|
392
|
+
): ForgeResult<FinishPlan> {
|
|
393
|
+
const progress = operationInProgress(cwd);
|
|
394
|
+
if (progress) return inProgressFailure(progress.operation, "finish the release");
|
|
395
|
+
const located = locate(cwd, input.version?.trim() || null, input.track ?? null);
|
|
396
|
+
if (!located.ok) return located;
|
|
397
|
+
const { line, kind, version, branch } = located.data;
|
|
398
|
+
const notes: string[] = [];
|
|
399
|
+
const dirty = git(cwd, ["status", "--porcelain", "--untracked-files=no"]).stdout.trim();
|
|
400
|
+
if (dirty)
|
|
401
|
+
return failure(
|
|
402
|
+
"blocked",
|
|
403
|
+
"dirty_worktree: tracked files have uncommitted changes; a release finish merges on a clean tree",
|
|
404
|
+
"workit git commit -m '<msg>' -- <paths> # on the release branch, or stash them",
|
|
405
|
+
);
|
|
406
|
+
const tag = `${line.tagPrefix}${version}`;
|
|
407
|
+
const rows = releaseRows(cwd).filter((row) => row.branch === branch || row.release === branch);
|
|
408
|
+
// git-flow: a hotfix finished while a release is open merges back into
|
|
409
|
+
// that release branch instead of develop (the release carries it there).
|
|
410
|
+
// Recorded once, so a resumed finish keeps the same target.
|
|
411
|
+
const recordedOpen = rows.findLast((row) => row.type === "release.hotfix_target");
|
|
412
|
+
const openRelease =
|
|
413
|
+
kind !== "hotfix"
|
|
414
|
+
? null
|
|
415
|
+
: typeof recordedOpen?.target === "string"
|
|
416
|
+
? recordedOpen.target
|
|
417
|
+
: (git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
|
418
|
+
.stdout.split("\n")
|
|
419
|
+
.find((name) => name && versionIn(templateOf(line, "release"), name) !== null) ?? null);
|
|
420
|
+
const mergeBack = openRelease
|
|
421
|
+
? [...new Set(line.mergeBack.map((name) => (name === line.integration ? openRelease : name)))]
|
|
422
|
+
: line.mergeBack;
|
|
423
|
+
if (openRelease)
|
|
424
|
+
notes.push(
|
|
425
|
+
`${openRelease} is open: the hotfix merges back into it instead of ${line.integration}`,
|
|
426
|
+
);
|
|
427
|
+
const remote = pushRemoteName(cwd, line.production);
|
|
428
|
+
if (remote) {
|
|
429
|
+
const wanted = [line.production, ...mergeBack, branch];
|
|
430
|
+
for (const name of wanted)
|
|
431
|
+
fetchRefs(cwd, remote, [`+refs/heads/${name}:refs/remotes/${remote}/${name}`]);
|
|
432
|
+
}
|
|
433
|
+
const branchTip = resolveRef(cwd, `refs/heads/${branch}`);
|
|
434
|
+
const remoteBranch = remote ? resolveRef(cwd, `refs/remotes/${remote}/${branch}`) : null;
|
|
435
|
+
const mergedRow = rows.findLast((row) => row.type === "release.merged");
|
|
436
|
+
const deletedRow = rows.findLast((row) => row.type === "release.branch_deleted");
|
|
437
|
+
// Once the branch is gone its tip is in the ledger: the recorded merge, or
|
|
438
|
+
// the deletion (a merge resolved by hand left no release.merged row).
|
|
439
|
+
const recordedTip = [mergedRow?.releaseTip, deletedRow?.head].find(
|
|
440
|
+
(value): value is string => typeof value === "string",
|
|
441
|
+
);
|
|
442
|
+
const releaseTip = branchTip ?? recordedTip ?? null;
|
|
443
|
+
if (!releaseTip)
|
|
444
|
+
return failure(
|
|
445
|
+
"not_found",
|
|
446
|
+
`${branch} is gone and this ledger did not record its merge; nothing to resume`,
|
|
447
|
+
);
|
|
448
|
+
// Production and every merge-back branch: local, not behind the remote,
|
|
449
|
+
// and ahead of it only by this release's own merges.
|
|
450
|
+
for (const name of [line.production, ...mergeBack]) {
|
|
451
|
+
const local = resolveRef(cwd, `refs/heads/${name}`);
|
|
452
|
+
const tracked = remote ? resolveRef(cwd, `refs/remotes/${remote}/${name}`) : null;
|
|
453
|
+
if (!local && tracked) {
|
|
454
|
+
const created = git(cwd, ["branch", "--no-track", name, tracked]);
|
|
455
|
+
if (!created.ok)
|
|
456
|
+
return failure("failed", `could not create local ${name}: ${gitError(created)}`);
|
|
457
|
+
notes.push(`created local ${name} from ${remote}/${name}`);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (!local)
|
|
461
|
+
return failure("not_found", `${name} exists neither locally nor on ${remote ?? "a remote"}`);
|
|
462
|
+
if (!tracked || name === openRelease) continue;
|
|
463
|
+
if (!isAncestor(cwd, tracked, local))
|
|
464
|
+
return failure(
|
|
465
|
+
"blocked",
|
|
466
|
+
`branch_behind: local ${name} is behind or diverged from ${remote}/${name}; git-flow finishes only onto up-to-date branches`,
|
|
467
|
+
`git switch ${name} && git merge --ff-only ${remote}/${name} # then: workit release finish ${version}`,
|
|
468
|
+
);
|
|
469
|
+
// Published production commits arrive through the merge-back; not new.
|
|
470
|
+
const productionTracked = remote
|
|
471
|
+
? resolveRef(cwd, `refs/remotes/${remote}/${line.production}`)
|
|
472
|
+
: null;
|
|
473
|
+
const foreign = git(cwd, [
|
|
474
|
+
"rev-list",
|
|
475
|
+
local,
|
|
476
|
+
`^${tracked}`,
|
|
477
|
+
`^${releaseTip}`,
|
|
478
|
+
...(productionTracked ? [`^${productionTracked}`] : []),
|
|
479
|
+
])
|
|
480
|
+
.stdout.split("\n")
|
|
481
|
+
.filter(Boolean)
|
|
482
|
+
.filter(
|
|
483
|
+
(sha) =>
|
|
484
|
+
!(
|
|
485
|
+
git(cwd, ["rev-parse", "-q", "--verify", `${sha}^2`]).ok &&
|
|
486
|
+
isAncestor(cwd, releaseTip, sha)
|
|
487
|
+
),
|
|
488
|
+
);
|
|
489
|
+
if (foreign.length)
|
|
490
|
+
return failure(
|
|
491
|
+
"blocked",
|
|
492
|
+
`branch_ahead: local ${name} has ${foreign.length} commit(s) ${remote}/${name} lacks that are not part of ${branch}; the finish push would publish them`,
|
|
493
|
+
`land them through review first, or: git switch ${name} && git reset --keep ${remote}/${name} # then: workit release finish ${version}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (branchTip && remoteBranch && !isAncestor(cwd, remoteBranch, branchTip))
|
|
497
|
+
return failure(
|
|
498
|
+
"blocked",
|
|
499
|
+
`release_behind: ${remote}/${branch} has commits local ${branch} lacks`,
|
|
500
|
+
`git switch ${branch} && git merge --ff-only ${remote}/${branch}`,
|
|
501
|
+
);
|
|
502
|
+
const production = resolveRef(cwd, `refs/heads/${line.production}`) as string;
|
|
503
|
+
const merged = isAncestor(cwd, releaseTip, production);
|
|
504
|
+
const localTag = resolveRef(cwd, `refs/tags/${tag}`);
|
|
505
|
+
if (localTag && !(isAncestor(cwd, localTag, production) && isAncestor(cwd, releaseTip, localTag)))
|
|
506
|
+
return failure(
|
|
507
|
+
"blocked",
|
|
508
|
+
`tag_exists: ${tag} (${localTag.slice(0, 12)}) is not this release's merge on ${line.production}`,
|
|
509
|
+
"a release tag never moves; finish under the next version",
|
|
510
|
+
);
|
|
511
|
+
if (remote) {
|
|
512
|
+
const remoteTag = remoteRefTip(cwd, remote, `refs/tags/${tag}`);
|
|
513
|
+
if (remoteTag.ok && remoteTag.sha && remoteTag.sha !== localTag)
|
|
514
|
+
return failure(
|
|
515
|
+
"blocked",
|
|
516
|
+
`tag_exists: ${remote} already has ${tag} at ${remoteTag.sha.slice(0, 12)}`,
|
|
517
|
+
"a release tag never moves; finish under the next version",
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const recordedMerge = typeof mergedRow?.merge === "string" ? mergedRow.merge : null;
|
|
521
|
+
const tagTarget = localTag ?? (merged ? (recordedMerge ?? production) : null);
|
|
522
|
+
const state: FinishState = {
|
|
523
|
+
merged,
|
|
524
|
+
tagged: localTag !== null,
|
|
525
|
+
mergedBack: Object.fromEntries(
|
|
526
|
+
mergeBack.map((name) => [
|
|
527
|
+
name,
|
|
528
|
+
localTag !== null &&
|
|
529
|
+
isAncestor(cwd, localTag, resolveRef(cwd, `refs/heads/${name}`) as string),
|
|
530
|
+
]),
|
|
531
|
+
),
|
|
532
|
+
branchDeleted: branchTip === null,
|
|
533
|
+
};
|
|
534
|
+
return success({
|
|
535
|
+
version,
|
|
536
|
+
kind,
|
|
537
|
+
branch,
|
|
538
|
+
track: line.track,
|
|
539
|
+
tag,
|
|
540
|
+
production: line.production,
|
|
541
|
+
mergeBack,
|
|
542
|
+
pushBranches: [line.production, ...mergeBack.filter((name) => name !== openRelease)],
|
|
543
|
+
openRelease,
|
|
544
|
+
gitflow: line.gitflow,
|
|
545
|
+
remote,
|
|
546
|
+
releaseTip,
|
|
547
|
+
published: remoteBranch,
|
|
548
|
+
tagTarget,
|
|
549
|
+
mergeRecorded: mergedRow !== undefined,
|
|
550
|
+
state,
|
|
551
|
+
tagPrefix: line.tagPrefix,
|
|
552
|
+
changelog: changelogSection(cwd, branchTip ?? production, version),
|
|
553
|
+
notes,
|
|
554
|
+
warnings: integrationPrWarning(cwd, line.production),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* A workspace that sets `integration: "pr"` lands work through reviewed PRs;
|
|
560
|
+
* its forge may also protect production against the finish push. Said, not
|
|
561
|
+
* enforced: git-flow finishes locally either way.
|
|
562
|
+
*/
|
|
563
|
+
function integrationPrWarning(cwd: string, production: string): string[] {
|
|
564
|
+
try {
|
|
565
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
566
|
+
cwd,
|
|
567
|
+
"branch",
|
|
568
|
+
process.env.WORKIT_PROFILE?.trim() || undefined,
|
|
569
|
+
).policy as { integration?: unknown } | undefined;
|
|
570
|
+
return selected?.integration === "pr"
|
|
571
|
+
? [
|
|
572
|
+
`the workspace integration mode is pr: the remote may require pull requests on ${production}, which would reject the finish push`,
|
|
573
|
+
]
|
|
574
|
+
: [];
|
|
575
|
+
} catch {
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// finish: the local merges
|
|
582
|
+
|
|
583
|
+
export type LocalFinish = {
|
|
584
|
+
/** Steps run now (the rest were already done). */
|
|
585
|
+
ran: string[];
|
|
586
|
+
tagTarget: string;
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const switchTo = (cwd: string, branch: string): ForgeResult<void> => {
|
|
590
|
+
if (currentBranch(cwd) === branch) return success(undefined);
|
|
591
|
+
const switched = git(cwd, ["switch", "-q", branch]);
|
|
592
|
+
return switched.ok
|
|
593
|
+
? success(undefined)
|
|
594
|
+
: failure("failed", `git switch ${branch} failed: ${gitError(switched)}`);
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
/** A --no-ff merge of `what` into the checked-out branch; a conflict stops with the resume command. */
|
|
598
|
+
const mergeNoFf = (
|
|
599
|
+
cwd: string,
|
|
600
|
+
what: string,
|
|
601
|
+
into: string,
|
|
602
|
+
version: string,
|
|
603
|
+
): ForgeResult<string> => {
|
|
604
|
+
const merged = git(cwd, ["merge", "--no-ff", "--no-edit", what], {
|
|
605
|
+
timeoutMs: 10 * 60_000,
|
|
606
|
+
});
|
|
607
|
+
if (!merged.ok) {
|
|
608
|
+
const conflicted = git(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]).ok;
|
|
609
|
+
return failure(
|
|
610
|
+
"failed",
|
|
611
|
+
conflicted
|
|
612
|
+
? `merge_conflict: merging ${what} into ${into} conflicts`
|
|
613
|
+
: `git merge ${what} into ${into} failed: ${gitError(merged)}`,
|
|
614
|
+
conflicted
|
|
615
|
+
? `resolve the conflicts, git add <files>, git merge --continue, then: workit release finish ${version}`
|
|
616
|
+
: undefined,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
return success(git(cwd, ["rev-parse", "HEAD"]).stdout.trim());
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* The local half of `git flow release finish`, each step skipped when the
|
|
624
|
+
* plan's state says it is done and recorded as a ledger row when run.
|
|
625
|
+
*/
|
|
626
|
+
export function finishLocally(
|
|
627
|
+
cwd: string,
|
|
628
|
+
plan: FinishPlan,
|
|
629
|
+
actor: LedgerActor,
|
|
630
|
+
): ForgeResult<LocalFinish> {
|
|
631
|
+
const ran: string[] = [];
|
|
632
|
+
const base = {
|
|
633
|
+
version: plan.version,
|
|
634
|
+
release: plan.branch,
|
|
635
|
+
tag: plan.tag,
|
|
636
|
+
track: plan.track,
|
|
637
|
+
};
|
|
638
|
+
let tagTarget = plan.tagTarget;
|
|
639
|
+
// Once per release: a resumed finish reads the target back from this row.
|
|
640
|
+
if (
|
|
641
|
+
plan.openRelease &&
|
|
642
|
+
!releaseRows(cwd).some(
|
|
643
|
+
(row) => row.type === "release.hotfix_target" && row.release === plan.branch,
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
record(cwd, actor, {
|
|
647
|
+
type: "release.hotfix_target",
|
|
648
|
+
...base,
|
|
649
|
+
branch: plan.branch,
|
|
650
|
+
target: plan.openRelease,
|
|
651
|
+
});
|
|
652
|
+
// A merge resolved by hand (`git merge --continue`) left no row: record
|
|
653
|
+
// what the repository shows, so a resume after the branch is gone has its tip.
|
|
654
|
+
if (plan.state.merged && !plan.mergeRecorded && tagTarget)
|
|
655
|
+
record(cwd, actor, {
|
|
656
|
+
type: "release.merged",
|
|
657
|
+
...base,
|
|
658
|
+
branch: plan.production,
|
|
659
|
+
head: tagTarget,
|
|
660
|
+
merge: tagTarget,
|
|
661
|
+
releaseTip: plan.releaseTip,
|
|
662
|
+
observed: true,
|
|
663
|
+
});
|
|
664
|
+
if (!plan.state.merged) {
|
|
665
|
+
const on = switchTo(cwd, plan.production);
|
|
666
|
+
if (!on.ok) return on;
|
|
667
|
+
const merged = mergeNoFf(cwd, plan.branch, plan.production, plan.version);
|
|
668
|
+
if (!merged.ok) return merged;
|
|
669
|
+
tagTarget = merged.data;
|
|
670
|
+
ran.push("merge");
|
|
671
|
+
record(cwd, actor, {
|
|
672
|
+
type: "release.merged",
|
|
673
|
+
...base,
|
|
674
|
+
branch: plan.production,
|
|
675
|
+
head: merged.data,
|
|
676
|
+
merge: merged.data,
|
|
677
|
+
releaseTip: plan.releaseTip,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
if (!tagTarget) return failure("failed", `cannot tell which ${plan.production} commit to tag`);
|
|
681
|
+
if (!plan.state.tagged) {
|
|
682
|
+
const message = plan.changelog ?? `Release ${plan.version}`;
|
|
683
|
+
// verbatim: a changelog's `### Fixed` is not a comment to strip.
|
|
684
|
+
const tagged = git(cwd, [
|
|
685
|
+
"tag",
|
|
686
|
+
"-a",
|
|
687
|
+
"--cleanup=verbatim",
|
|
688
|
+
plan.tag,
|
|
689
|
+
"-m",
|
|
690
|
+
message,
|
|
691
|
+
tagTarget,
|
|
692
|
+
]);
|
|
693
|
+
if (!tagged.ok) return failure("failed", `git tag ${plan.tag} failed: ${gitError(tagged)}`);
|
|
694
|
+
ran.push("tag");
|
|
695
|
+
record(cwd, actor, {
|
|
696
|
+
type: "release.tagged",
|
|
697
|
+
...base,
|
|
698
|
+
branch: plan.production,
|
|
699
|
+
head: tagTarget,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
for (const name of plan.mergeBack) {
|
|
703
|
+
if (plan.state.mergedBack[name]) continue;
|
|
704
|
+
const on = switchTo(cwd, name);
|
|
705
|
+
if (!on.ok) return on;
|
|
706
|
+
const merged = mergeNoFf(cwd, plan.tag, name, plan.version);
|
|
707
|
+
if (!merged.ok) return merged;
|
|
708
|
+
ran.push(`merge-back ${name}`);
|
|
709
|
+
record(cwd, actor, {
|
|
710
|
+
type: "release.merged_back",
|
|
711
|
+
...base,
|
|
712
|
+
branch: name,
|
|
713
|
+
head: merged.data,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
if (!plan.state.branchDeleted) {
|
|
717
|
+
if (currentBranch(cwd) === plan.branch) {
|
|
718
|
+
const on = switchTo(cwd, plan.mergeBack.at(-1) ?? plan.production);
|
|
719
|
+
if (!on.ok) return on;
|
|
720
|
+
}
|
|
721
|
+
// -d, not -D: git refuses when the release has commits nothing merged.
|
|
722
|
+
const deleted = git(cwd, ["branch", "-d", plan.branch]);
|
|
723
|
+
// A published branch is checked against its upstream: commits made
|
|
724
|
+
// after publishing would be lost with it.
|
|
725
|
+
if (!deleted.ok)
|
|
726
|
+
return failure(
|
|
727
|
+
"failed",
|
|
728
|
+
`git branch -d ${plan.branch} failed: ${gitError(deleted)}`,
|
|
729
|
+
plan.published
|
|
730
|
+
? `workit release publish ${plan.version} # its remote copy lacks commits; then: workit release finish ${plan.version}`
|
|
731
|
+
: undefined,
|
|
732
|
+
);
|
|
733
|
+
ran.push("delete-branch");
|
|
734
|
+
record(cwd, actor, {
|
|
735
|
+
type: "release.branch_deleted",
|
|
736
|
+
...base,
|
|
737
|
+
branch: plan.branch,
|
|
738
|
+
head: plan.releaseTip,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
return success({ ran, tagTarget });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Check out the release branch (the release PR path pushes it from there). */
|
|
745
|
+
export const switchToRelease = (cwd: string, plan: FinishPlan): ForgeResult<void> =>
|
|
746
|
+
switchTo(cwd, plan.branch);
|
|
747
|
+
|
|
748
|
+
/** A ledger row for a finish step the CLI ran (push, forge release, done). */
|
|
749
|
+
export function recordReleaseStep(
|
|
750
|
+
cwd: string,
|
|
751
|
+
actor: LedgerActor,
|
|
752
|
+
plan: FinishPlan,
|
|
753
|
+
row: ReleaseRow,
|
|
754
|
+
): void {
|
|
755
|
+
record(cwd, actor, {
|
|
756
|
+
version: plan.version,
|
|
757
|
+
release: plan.branch,
|
|
758
|
+
tag: plan.tag,
|
|
759
|
+
track: plan.track,
|
|
760
|
+
...row,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
// publish
|
|
766
|
+
|
|
767
|
+
/** The release branch `workit release publish` pushes: the named version's, else the checkout's. */
|
|
768
|
+
export function releaseToPublish(cwd: string, version: string | null): ForgeResult<Located> {
|
|
769
|
+
const located = locate(cwd, version, null);
|
|
770
|
+
if (!located.ok) return located;
|
|
771
|
+
if (!resolveRef(cwd, `refs/heads/${located.data.branch}`))
|
|
772
|
+
return failure("not_found", `${located.data.branch} is not a local branch`);
|
|
773
|
+
return located;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
// release notes: the draft, its approval, and what finish publishes
|
|
778
|
+
|
|
779
|
+
export type ReleaseNotesContext = {
|
|
780
|
+
version: string;
|
|
781
|
+
tag: string;
|
|
782
|
+
track: string | null;
|
|
783
|
+
mode: ReleaseNotesMode;
|
|
784
|
+
/** Where the mode comes from (`track`, `workspace` or `default`). */
|
|
785
|
+
modeFrom: "track" | "workspace" | "default";
|
|
786
|
+
changelog: string | null;
|
|
787
|
+
/** The compare link from the previous tag, or null. */
|
|
788
|
+
compare: string | null;
|
|
789
|
+
previousTag: string | null;
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
/** The forge's compare link from the tag before `tag` (same prefix), or null. */
|
|
793
|
+
function compareLink(
|
|
794
|
+
cwd: string,
|
|
795
|
+
production: string,
|
|
796
|
+
prefix: string,
|
|
797
|
+
version: string,
|
|
798
|
+
tag: string,
|
|
799
|
+
) {
|
|
800
|
+
const before = previousTag(
|
|
801
|
+
git(cwd, ["tag", "-l"]).stdout.split("\n").filter(Boolean),
|
|
802
|
+
prefix,
|
|
803
|
+
version,
|
|
804
|
+
);
|
|
805
|
+
const remote = pushRemoteName(cwd, production);
|
|
806
|
+
const url = remote ? pushUrl(cwd, remote) : null;
|
|
807
|
+
const forge = url ? deriveForge(url) : null;
|
|
808
|
+
return {
|
|
809
|
+
previousTag: before,
|
|
810
|
+
compare:
|
|
811
|
+
before && forge
|
|
812
|
+
? compareUrl(forge.kind, `https://${forge.apiHost}/${forge.repo}`, before, tag)
|
|
813
|
+
: null,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* What the notes of `version` are built from: its release branch when one is
|
|
819
|
+
* here (or the tag once finished, else HEAD), the line's tag prefix, and the
|
|
820
|
+
* workspace's (or release track's) releaseNotes mode.
|
|
821
|
+
*/
|
|
822
|
+
export function releaseNotesContext(
|
|
823
|
+
cwd: string,
|
|
824
|
+
input: { version: string; track?: string | null },
|
|
825
|
+
): ForgeResult<ReleaseNotesContext> {
|
|
826
|
+
const version = input.version.trim();
|
|
827
|
+
if (!validVersion(version))
|
|
828
|
+
return failure("invalid_input", `${JSON.stringify(version)} is not a version (e.g. 1.4.0)`);
|
|
829
|
+
const located = locate(cwd, version, input.track ?? null);
|
|
830
|
+
let line: ReleaseLine;
|
|
831
|
+
let ref = "HEAD";
|
|
832
|
+
if (located.ok) {
|
|
833
|
+
line = located.data.line;
|
|
834
|
+
if (resolveRef(cwd, `refs/heads/${located.data.branch}`)) ref = located.data.branch;
|
|
835
|
+
} else {
|
|
836
|
+
const lines = releaseLinesOf(cwd).filter(
|
|
837
|
+
(entry) => input.track == null || entry.track === input.track,
|
|
838
|
+
);
|
|
839
|
+
if (!lines.length)
|
|
840
|
+
return failure("invalid_input", `no release track ${JSON.stringify(input.track)}`);
|
|
841
|
+
line = lines[0];
|
|
842
|
+
}
|
|
843
|
+
const tag = `${line.tagPrefix}${version}`;
|
|
844
|
+
if (ref === "HEAD" && resolveRef(cwd, `refs/tags/${tag}`)) ref = tag;
|
|
845
|
+
const mode = workspaceReleaseNotes(cwd, line.track);
|
|
846
|
+
return success({
|
|
847
|
+
version,
|
|
848
|
+
tag,
|
|
849
|
+
track: line.track,
|
|
850
|
+
mode: mode.mode,
|
|
851
|
+
modeFrom: mode.from,
|
|
852
|
+
changelog: changelogSection(cwd, ref, version),
|
|
853
|
+
...compareLink(cwd, line.production, line.tagPrefix, version, tag),
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Where the draft of `version` lives: the git common dir, never committed. */
|
|
858
|
+
export const releaseNotesDraftPath = (cwd: string, version: string): string | null => {
|
|
859
|
+
const common = gitCommonDir(cwd);
|
|
860
|
+
return common ? path.join(common, "workit", "release-notes", `${version}.md`) : null;
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
const sha256 = (text: string): string => createHash("sha256").update(text).digest("hex");
|
|
864
|
+
|
|
865
|
+
export type ReleaseNotesDraft = { path: string; notes: string; sha256: string };
|
|
866
|
+
|
|
867
|
+
/** The draft of `version`, or null when none was written. */
|
|
868
|
+
export function readReleaseNotesDraft(cwd: string, version: string): ReleaseNotesDraft | null {
|
|
869
|
+
const file = releaseNotesDraftPath(cwd, version);
|
|
870
|
+
if (!file || !existsSync(file)) return null;
|
|
871
|
+
const notes = readFileSync(file, "utf8");
|
|
872
|
+
return { path: file, notes, sha256: sha256(notes) };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Write the draft of `version` from its changelog section. An existing
|
|
877
|
+
* draft's Highlights stay unless `reset`.
|
|
878
|
+
*/
|
|
879
|
+
export function writeReleaseNotesDraft(
|
|
880
|
+
cwd: string,
|
|
881
|
+
context: ReleaseNotesContext,
|
|
882
|
+
options: { reset?: boolean } = {},
|
|
883
|
+
): ForgeResult<ReleaseNotesDraft & { keptHighlights: boolean }> {
|
|
884
|
+
const file = releaseNotesDraftPath(cwd, context.version);
|
|
885
|
+
if (!file) return failure("invalid_input", "not a git repository: nowhere to keep the draft");
|
|
886
|
+
const existing = options.reset ? null : readReleaseNotesDraft(cwd, context.version);
|
|
887
|
+
const highlights = existing ? highlightsOf(existing.notes) : null;
|
|
888
|
+
const notes = renderReleaseNotes({
|
|
889
|
+
version: context.version,
|
|
890
|
+
changelog: context.changelog,
|
|
891
|
+
highlights,
|
|
892
|
+
compare: context.compare,
|
|
893
|
+
});
|
|
894
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
895
|
+
writeFileSync(file, notes);
|
|
896
|
+
return success({
|
|
897
|
+
path: file,
|
|
898
|
+
notes,
|
|
899
|
+
sha256: sha256(notes),
|
|
900
|
+
keptHighlights: highlights !== null,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** The newest `release.notes.approved` row of `version`, or null. */
|
|
905
|
+
export function latestReleaseNotesApproval(
|
|
906
|
+
cwd: string,
|
|
907
|
+
version: string,
|
|
908
|
+
): { sha256: string } | null {
|
|
909
|
+
const row = releaseRows(cwd).findLast(
|
|
910
|
+
(entry) => entry.type === "release.notes.approved" && entry.version === version,
|
|
911
|
+
);
|
|
912
|
+
return row && typeof row.sha256 === "string" ? { sha256: row.sha256 } : null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Record the user's approval of the draft as it is now. */
|
|
916
|
+
export function recordReleaseNotesApproval(
|
|
917
|
+
cwd: string,
|
|
918
|
+
actor: LedgerActor,
|
|
919
|
+
draft: ReleaseNotesDraft & { version: string; tag: string },
|
|
920
|
+
): void {
|
|
921
|
+
record(cwd, actor, {
|
|
922
|
+
type: "release.notes.approved",
|
|
923
|
+
version: draft.version,
|
|
924
|
+
tag: draft.tag,
|
|
925
|
+
sha256: draft.sha256,
|
|
926
|
+
path: draft.path,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
export type ReleaseNotesDecision =
|
|
931
|
+
| { publish: false; mode: "off"; reason: string }
|
|
932
|
+
| {
|
|
933
|
+
publish: true;
|
|
934
|
+
mode: ReleaseNotesMode;
|
|
935
|
+
body: string;
|
|
936
|
+
source: "draft" | "changelog";
|
|
937
|
+
}
|
|
938
|
+
| { publish: false; mode: "review"; blocked: string; unblock: string };
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* What finish publishes as the forge release body: nothing under `off`; the
|
|
942
|
+
* draft (else the notes built from the changelog) under `auto`; under
|
|
943
|
+
* `review` only the draft whose hash the newest approval names.
|
|
944
|
+
*/
|
|
945
|
+
export function releaseNotesDecision(
|
|
946
|
+
cwd: string,
|
|
947
|
+
plan: Pick<FinishPlan, "version" | "track" | "tag" | "tagPrefix" | "production" | "changelog">,
|
|
948
|
+
): ReleaseNotesDecision {
|
|
949
|
+
const setting = workspaceReleaseNotes(cwd, plan.track);
|
|
950
|
+
const where =
|
|
951
|
+
setting.from === "track" ? `release track ${plan.track}` : `workspace ${setting.workspace}`;
|
|
952
|
+
if (setting.mode === "off")
|
|
953
|
+
return {
|
|
954
|
+
publish: false,
|
|
955
|
+
mode: "off",
|
|
956
|
+
reason: `releaseNotes is off (${where}): no forge release`,
|
|
957
|
+
};
|
|
958
|
+
const draft = readReleaseNotesDraft(cwd, plan.version);
|
|
959
|
+
if (setting.mode === "review") {
|
|
960
|
+
const approved = latestReleaseNotesApproval(cwd, plan.version);
|
|
961
|
+
const draftCmd = `workit release notes draft ${plan.version}`;
|
|
962
|
+
const approveCmd = `workit release notes approve ${plan.version}`;
|
|
963
|
+
if (!draft)
|
|
964
|
+
return {
|
|
965
|
+
publish: false,
|
|
966
|
+
mode: "review",
|
|
967
|
+
blocked: `release_notes_unapproved: releaseNotes is review (${where}) and ${plan.version} has no draft`,
|
|
968
|
+
unblock: `${draftCmd} # write the Highlights, show the user, then ask the user to run, in a terminal outside the agent host: ${approveCmd}`,
|
|
969
|
+
};
|
|
970
|
+
if (!approved || approved.sha256 !== draft.sha256)
|
|
971
|
+
return {
|
|
972
|
+
publish: false,
|
|
973
|
+
mode: "review",
|
|
974
|
+
blocked: `release_notes_unapproved: releaseNotes is review (${where}) and the draft of ${plan.version} (${draft.path}) ${approved ? "changed after the user approved it" : "is not approved"}`,
|
|
975
|
+
unblock: `ask the user to run, in a terminal outside the agent host (not Claude Code's \`!\` prefix or an agent shell): ${approveCmd} # the draft: ${draftCmd}`,
|
|
976
|
+
};
|
|
977
|
+
return {
|
|
978
|
+
publish: true,
|
|
979
|
+
mode: "review",
|
|
980
|
+
body: draft.notes,
|
|
981
|
+
source: "draft",
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
if (draft) return { publish: true, mode: "auto", body: draft.notes, source: "draft" };
|
|
985
|
+
return {
|
|
986
|
+
publish: true,
|
|
987
|
+
mode: "auto",
|
|
988
|
+
source: "changelog",
|
|
989
|
+
body: renderReleaseNotes({
|
|
990
|
+
version: plan.version,
|
|
991
|
+
changelog: plan.changelog,
|
|
992
|
+
compare: compareLink(cwd, plan.production, plan.tagPrefix, plan.version, plan.tag).compare,
|
|
993
|
+
}),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* The notes decision for a release tag `workit git push --release` pushes
|
|
999
|
+
* (the release PR path: the tag is made after the PR merged). The changelog
|
|
1000
|
+
* section is read at the tag.
|
|
1001
|
+
*/
|
|
1002
|
+
export function releaseNotesForTag(
|
|
1003
|
+
cwd: string,
|
|
1004
|
+
tag: {
|
|
1005
|
+
name: string;
|
|
1006
|
+
production: string;
|
|
1007
|
+
version: string;
|
|
1008
|
+
track: string | null;
|
|
1009
|
+
tagPrefix: string;
|
|
1010
|
+
},
|
|
1011
|
+
): ReleaseNotesDecision {
|
|
1012
|
+
return releaseNotesDecision(cwd, {
|
|
1013
|
+
version: tag.version,
|
|
1014
|
+
track: tag.track,
|
|
1015
|
+
tag: tag.name,
|
|
1016
|
+
tagPrefix: tag.tagPrefix,
|
|
1017
|
+
production: tag.production,
|
|
1018
|
+
changelog: changelogSection(cwd, `refs/tags/${tag.name}`, tag.version),
|
|
1019
|
+
});
|
|
1020
|
+
}
|