@brainervirus/workit-core 7.0.0 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-implement/SKILL.md +3 -2
- package/skills/workit-ship/SKILL.md +31 -13
- package/src/autonomy.ts +132 -25
- package/src/core/branch.ts +63 -6
- package/src/core/config.ts +9 -3
- package/src/core/methods.ts +4 -4
- package/src/core/pr-create.ts +8 -3
- package/src/core/release-tracks.ts +779 -0
- package/src/core/vcs-config.ts +65 -16
- package/src/core/workspaces.ts +87 -18
- package/src/forge/pr-ops.ts +31 -0
- package/src/forge/report.ts +59 -0
- package/src/git/ops.ts +61 -13
package/src/core/vcs-config.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { configDir,
|
|
5
|
-
import { resolveRuntimeWorkspaceVcs } from "./workspaces";
|
|
6
|
-
import {
|
|
4
|
+
import { configDir, vcsConfigPath } from "./config";
|
|
5
|
+
import { resolveRuntimeWorkspaceVcs, workspaceDefaultTarget } from "./workspaces";
|
|
6
|
+
import { resolveTrackFor, type RuntimeReleaseTrack, type TrackResolution } from "./release-tracks";
|
|
7
7
|
// VCS resolution and CLI-backed identity/style reads.
|
|
8
8
|
|
|
9
|
-
export const vcsConfigPath = (): string =>
|
|
10
|
-
process.env.WORKFLOW_VCS_CONFIG ?? path.join(configDir(), "vcs.json");
|
|
11
|
-
|
|
12
9
|
const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
|
|
13
10
|
|
|
14
11
|
const vcsCwd = (cwd?: string): string =>
|
|
@@ -146,8 +143,45 @@ export const readVcsConfig = (): VcsConfigResult => {
|
|
|
146
143
|
return { status: "malformed", path: file, config: {}, error: `${file} is not a JSON object` };
|
|
147
144
|
};
|
|
148
145
|
|
|
146
|
+
/** The release track a vcsConfig call resolved (null: the workspace has no tracks). */
|
|
147
|
+
export type ResolvedReleaseTrack = {
|
|
148
|
+
name: string | null;
|
|
149
|
+
source: string;
|
|
150
|
+
detail: string;
|
|
151
|
+
warnings: string[];
|
|
152
|
+
track: RuntimeReleaseTrack | null;
|
|
153
|
+
/** Every configured track, for displays and the branch policy. */
|
|
154
|
+
tracks: RuntimeReleaseTrack[];
|
|
155
|
+
/** The line is undetermined (or WORKFLOW_RELEASE_TRACK is wrong): mutating verbs refuse. */
|
|
156
|
+
blocking: string | null;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export type VcsConfigOptions = {
|
|
160
|
+
/** An explicit release track (`--track`); WORKFLOW_RELEASE_TRACK is read below it. */
|
|
161
|
+
track?: string | null;
|
|
162
|
+
/** Resolve the track for this branch instead of the checked-out one. */
|
|
163
|
+
branch?: string | null;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const trackSummary = (resolution: TrackResolution): ResolvedReleaseTrack | null =>
|
|
167
|
+
resolution.status === "resolved"
|
|
168
|
+
? {
|
|
169
|
+
name: resolution.track?.name ?? null,
|
|
170
|
+
source: resolution.source,
|
|
171
|
+
detail: resolution.detail,
|
|
172
|
+
warnings: resolution.warnings,
|
|
173
|
+
track: resolution.track,
|
|
174
|
+
tracks: resolution.tracks,
|
|
175
|
+
blocking: resolution.blocking,
|
|
176
|
+
}
|
|
177
|
+
: null;
|
|
178
|
+
|
|
149
179
|
/** Port of scripts/vcs/config.sh — mode: load | summary | resolve. */
|
|
150
|
-
export function vcsConfig(
|
|
180
|
+
export function vcsConfig(
|
|
181
|
+
mode: "load" | "summary" | "resolve",
|
|
182
|
+
cwd?: string,
|
|
183
|
+
options: VcsConfigOptions = {},
|
|
184
|
+
): Record<string, any> {
|
|
151
185
|
const ws = resolveRuntimeWorkspaceVcs(vcsCwd(cwd));
|
|
152
186
|
const wsVcs = (ws?.vcs ?? {}) as Record<string, any>;
|
|
153
187
|
const wsYt = (ws?.youtrack ?? {}) as Record<string, any>;
|
|
@@ -183,14 +217,9 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
183
217
|
// main). Explicit workspace vcs.defaultTargetBranch stays authoritative; a
|
|
184
218
|
// workspace without a branchPolicy still falls back to the global vcs.json
|
|
185
219
|
// default, and unmatched repos keep it too.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
const defaultTarget = String(
|
|
190
|
-
wsVcs.defaultTargetBranch ??
|
|
191
|
-
(hasWorkspacePolicy ? policyDefault : (cfg.defaultTargetBranch ?? policyDefault)) ??
|
|
192
|
-
"develop",
|
|
193
|
-
);
|
|
220
|
+
// CA-05/CA-02 (workspaces.ts workspaceDefaultTarget): shared with doctor
|
|
221
|
+
// and grant show so their display never disagrees with the commands.
|
|
222
|
+
const workspaceDefault = workspaceDefaultTarget(root, cfg.defaultTargetBranch);
|
|
194
223
|
const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
|
|
195
224
|
const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
|
|
196
225
|
// github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
|
|
@@ -210,11 +239,30 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
210
239
|
// instead of silently resolving defaults; a missing file is still a
|
|
211
240
|
// legitimate unconfigured state that falls back to defaults.
|
|
212
241
|
if (cfgStatus === "malformed") return { ok: false, error: cfgError, configPath: cfgPath };
|
|
242
|
+
// Release tracks (core/release-tracks.ts): the one place a branch's line is
|
|
243
|
+
// decided, in resolve mode only (load serves identity, push and tracker
|
|
244
|
+
// paths that never need it). With tracks, the PR target and the base for
|
|
245
|
+
// new branches come from the resolved track; without, the workspace
|
|
246
|
+
// default stays as is.
|
|
247
|
+
const trackResolution = resolveTrackFor(root, {
|
|
248
|
+
track: options.track,
|
|
249
|
+
...(options.branch !== undefined ? { branch: options.branch } : {}),
|
|
250
|
+
defaultBranch: workspaceDefault,
|
|
251
|
+
});
|
|
252
|
+
if (trackResolution.status === "invalid")
|
|
253
|
+
return { ok: false, error: trackResolution.error, configPath: workspacesPath() };
|
|
254
|
+
const releaseTrack = trackSummary(trackResolution);
|
|
255
|
+
const defaultTarget = releaseTrack?.track?.pullRequestTarget ?? workspaceDefault;
|
|
256
|
+
const baseBranch = releaseTrack?.track?.baseBranch ?? defaultTarget;
|
|
213
257
|
return {
|
|
214
258
|
ok: true,
|
|
215
259
|
workspace_name: ws?.name ?? null,
|
|
216
260
|
provider,
|
|
217
261
|
defaultTargetBranch: defaultTarget,
|
|
262
|
+
/** The target before release tracks (vcs.defaultTargetBranch or the policy default). */
|
|
263
|
+
workspaceDefaultTargetBranch: workspaceDefault,
|
|
264
|
+
baseBranch,
|
|
265
|
+
releaseTrack,
|
|
218
266
|
link_issues: linkIssues,
|
|
219
267
|
youtrack_base_url: youtrackBaseUrl,
|
|
220
268
|
issues_provider: issuesProvider,
|
|
@@ -241,7 +289,8 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
241
289
|
ok: true,
|
|
242
290
|
configPath: path.resolve(cfgPath),
|
|
243
291
|
provider,
|
|
244
|
-
|
|
292
|
+
// Load mode does not resolve release tracks: the workspace default.
|
|
293
|
+
defaultTargetBranch: workspaceDefault,
|
|
245
294
|
pr: cfg.pr ?? {},
|
|
246
295
|
workspace_name: ws?.name ?? null,
|
|
247
296
|
link_issues: linkIssues,
|
package/src/core/workspaces.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
PRESETS,
|
|
8
8
|
configDir,
|
|
9
9
|
isConfigObject,
|
|
10
|
+
readConfig,
|
|
10
11
|
resolveBranchPolicy as resolveConfiguredBranchPolicy,
|
|
11
12
|
resolveCommitPolicy as resolveConfiguredCommitPolicy,
|
|
12
13
|
type BranchPreset,
|
|
@@ -68,31 +69,43 @@ const versionSourceSchema = z.discriminatedUnion("kind", [
|
|
|
68
69
|
z.object({ kind: z.literal("git-tag") }).strict(),
|
|
69
70
|
z.object({ kind: z.literal("manual") }).strict(),
|
|
70
71
|
]);
|
|
72
|
+
// Only the production and integration branches are required: runtime
|
|
73
|
+
// (core/release-tracks.ts) defaults the rest. Unknown keys pass through (D17:
|
|
74
|
+
// a newer Workit's field must not invalidate the file for an older one); a
|
|
75
|
+
// track lists the ones an older reader must not ignore in `critical`.
|
|
71
76
|
const releaseTrackSchema = z
|
|
72
77
|
.object({
|
|
73
|
-
strategy: branchPreset,
|
|
78
|
+
strategy: branchPreset.optional(),
|
|
74
79
|
productionBranch: nonBlank,
|
|
75
80
|
integrationBranch: nonBlank,
|
|
76
|
-
naming: z
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
naming: z
|
|
82
|
+
.object({
|
|
83
|
+
feature: nonBlank.optional(),
|
|
84
|
+
release: nonBlank.optional(),
|
|
85
|
+
hotfix: nonBlank.optional(),
|
|
86
|
+
})
|
|
87
|
+
.passthrough()
|
|
88
|
+
.optional(),
|
|
89
|
+
baseBranch: nonBlank.optional(),
|
|
90
|
+
mergeBackBranches: z.array(nonBlank).optional(),
|
|
91
|
+
pullRequestTarget: nonBlank.optional(),
|
|
92
|
+
tagNamespace: z.string().optional(),
|
|
93
|
+
versionSource: versionSourceSchema.optional(),
|
|
94
|
+
requiredChecks: z.array(nonBlank).optional(),
|
|
95
|
+
critical: z.array(nonBlank).optional(),
|
|
83
96
|
})
|
|
84
|
-
.
|
|
97
|
+
.passthrough()
|
|
85
98
|
.superRefine((track, context) => {
|
|
86
99
|
for (const [field, values] of [
|
|
87
|
-
["mergeBackBranches", track.mergeBackBranches],
|
|
88
|
-
["requiredChecks", track.requiredChecks],
|
|
100
|
+
["mergeBackBranches", track.mergeBackBranches ?? []],
|
|
101
|
+
["requiredChecks", track.requiredChecks ?? []],
|
|
89
102
|
] as const) {
|
|
90
103
|
if (new Set(values).size !== values.length) {
|
|
91
104
|
context.addIssue({ code: "custom", path: [field], message: "entries must be unique" });
|
|
92
105
|
}
|
|
93
106
|
}
|
|
94
107
|
if (
|
|
95
|
-
track.versionSource
|
|
108
|
+
track.versionSource?.kind === "package-json" &&
|
|
96
109
|
(path.posix.isAbsolute(track.versionSource.path.replaceAll("\\", "/")) ||
|
|
97
110
|
/^[A-Za-z]:\//u.test(track.versionSource.path.replaceAll("\\", "/")) ||
|
|
98
111
|
track.versionSource.path.replaceAll("\\", "/").split("/").includes(".."))
|
|
@@ -119,8 +132,13 @@ const workspaceConfigSchema = z
|
|
|
119
132
|
.optional(),
|
|
120
133
|
/** Legacy (≤4.x) standing approvals; read once as autonomy grants (autonomy.ts). */
|
|
121
134
|
autoApprove: z.union([z.boolean(), z.array(nonBlank)]).optional(),
|
|
122
|
-
/**
|
|
123
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Where an unnamed request stops (skills read it): commit, pr, green or
|
|
137
|
+
* merged; same rules as grants. Read leniently (autonomy.ts endpointOf):
|
|
138
|
+
* a value this version does not know is reported and treated as commit,
|
|
139
|
+
* so a newer Workit's endpoint never invalidates the whole file.
|
|
140
|
+
*/
|
|
141
|
+
defaultEndpoint: z.unknown().optional(),
|
|
124
142
|
/** How a normal-risk behavior change is verified (S17); same rules as grants. */
|
|
125
143
|
verification: z.enum(["self", "independent"]).optional(),
|
|
126
144
|
/** Autonomy grants (D15): only ever set here, by the user. */
|
|
@@ -557,10 +575,10 @@ const runtimeWorkspaceIndexSchema = z
|
|
|
557
575
|
})
|
|
558
576
|
.passthrough();
|
|
559
577
|
|
|
560
|
-
|
|
578
|
+
/** The workspaces.json entry matching cwd, checked only for name/glob (D17). */
|
|
579
|
+
const matchRuntimeWorkspace = (
|
|
561
580
|
cwd: string,
|
|
562
|
-
|
|
563
|
-
): RuntimeWorkspaceCandidate | null => {
|
|
581
|
+
): { file: string; selected: Record<string, unknown> & { name: string } } | null => {
|
|
564
582
|
const file = path.join(configDir(), "workspaces.json");
|
|
565
583
|
let raw: string;
|
|
566
584
|
try {
|
|
@@ -601,7 +619,30 @@ const resolveRuntimeWorkspaceCandidate = (
|
|
|
601
619
|
});
|
|
602
620
|
const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
|
|
603
621
|
const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
|
|
604
|
-
|
|
622
|
+
return selected ? { file, selected } : null;
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* The raw `releaseTracks` of the workspace matching cwd, unvalidated: the
|
|
627
|
+
* runtime reads them leniently (core/release-tracks.ts) so one malformed track
|
|
628
|
+
* is reported instead of disabling every command.
|
|
629
|
+
*/
|
|
630
|
+
export const resolveRuntimeReleaseTracks = (
|
|
631
|
+
cwd: string,
|
|
632
|
+
): { workspace: string; releaseTracks: unknown } | null => {
|
|
633
|
+
const matched = matchRuntimeWorkspace(cwd);
|
|
634
|
+
return matched
|
|
635
|
+
? { workspace: matched.selected.name, releaseTracks: matched.selected.releaseTracks }
|
|
636
|
+
: null;
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const resolveRuntimeWorkspaceCandidate = (
|
|
640
|
+
cwd: string,
|
|
641
|
+
kind: RuntimeWorkspacePolicy,
|
|
642
|
+
): RuntimeWorkspaceCandidate | null => {
|
|
643
|
+
const matched = matchRuntimeWorkspace(cwd);
|
|
644
|
+
if (!matched) return null;
|
|
645
|
+
const { file, selected } = matched;
|
|
605
646
|
const policy = runtimeWorkspacePolicySchema(kind).safeParse(selected);
|
|
606
647
|
if (!policy.success) {
|
|
607
648
|
const detail = policy.error.issues
|
|
@@ -730,3 +771,31 @@ export const selectReleaseTrack = (
|
|
|
730
771
|
}
|
|
731
772
|
return { status: "selected", name: requestedTrack, track: tracks[requestedTrack] };
|
|
732
773
|
};
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* The workspace default target before release tracks: the one computation
|
|
777
|
+
* vcsConfig, `workit doctor` and `workit grant show` share (CA-05/CA-02).
|
|
778
|
+
* Explicit workspace vcs.defaultTargetBranch wins; a workspace with its own
|
|
779
|
+
* branchPolicy uses its preset's default (gitflow -> develop, github-flow ->
|
|
780
|
+
* main, trunk-based -> master); otherwise the global vcs.json
|
|
781
|
+
* `defaultTargetBranch` (`globalDefault`), else the effective preset's.
|
|
782
|
+
*/
|
|
783
|
+
export function workspaceDefaultTarget(cwd: string, globalDefault: unknown): string {
|
|
784
|
+
const ws = resolveRuntimeWorkspaceVcs(cwd);
|
|
785
|
+
const wp = (ws?.branchPolicy ?? {}) as Record<string, unknown>;
|
|
786
|
+
const hasWorkspacePolicy = typeof wp.preset === "string" && Object.hasOwn(PRESETS, wp.preset);
|
|
787
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
788
|
+
cwd,
|
|
789
|
+
"branch",
|
|
790
|
+
process.env.WORKFLOW_PROFILE?.trim() || undefined,
|
|
791
|
+
);
|
|
792
|
+
const policyDefault = resolveConfiguredBranchPolicy(
|
|
793
|
+
readConfig(),
|
|
794
|
+
selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
|
|
795
|
+
).defaultTargetBranch;
|
|
796
|
+
return String(
|
|
797
|
+
(ws?.vcs as { defaultTargetBranch?: unknown } | undefined)?.defaultTargetBranch ??
|
|
798
|
+
(hasWorkspacePolicy ? policyDefault : (globalDefault ?? policyDefault)) ??
|
|
799
|
+
"develop",
|
|
800
|
+
);
|
|
801
|
+
}
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { requireGrant, type AutonomySource } from "../autonomy";
|
|
19
19
|
import { isProtectedTarget } from "../core/branch";
|
|
20
20
|
import { vcsConfig } from "../core/vcs-config";
|
|
21
|
+
import { workspaceReleaseTracks } from "../core/release-tracks";
|
|
21
22
|
import { deleteRemoteBranch } from "../git/ops";
|
|
22
23
|
import { spawnSync } from "node:child_process";
|
|
23
24
|
import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
|
|
@@ -260,6 +261,33 @@ export type MergeOutcome = {
|
|
|
260
261
|
grant: { source: AutonomySource };
|
|
261
262
|
deletedBranch: boolean | { error: string };
|
|
262
263
|
recorded: { id: string } | { error: string };
|
|
264
|
+
/**
|
|
265
|
+
* Release tracks: when the PR landed on a track's production branch, the
|
|
266
|
+
* branches it must flow back into (mergeBackBranches); empty otherwise.
|
|
267
|
+
* Workit reports them; it does not open the merge-back PRs itself.
|
|
268
|
+
*/
|
|
269
|
+
mergeBack: string[];
|
|
270
|
+
mergeBackTrack: string | null;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The merge-back a merge into `base` owes: the track whose production branch
|
|
275
|
+
* `base` is, by ownership alone (no --track, no WORKFLOW_RELEASE_TRACK, no
|
|
276
|
+
* history). Best effort: a config problem never stops the pr.merged record.
|
|
277
|
+
*/
|
|
278
|
+
const mergeBackFor = (cwd: string, base: string): { track: string | null; branches: string[] } => {
|
|
279
|
+
try {
|
|
280
|
+
const owners = workspaceReleaseTracks(cwd).tracks.filter(
|
|
281
|
+
(track) => track.productionBranch === base,
|
|
282
|
+
);
|
|
283
|
+
if (owners.length !== 1) return { track: null, branches: [] };
|
|
284
|
+
return {
|
|
285
|
+
track: owners[0].name,
|
|
286
|
+
branches: owners[0].mergeBackBranches.filter((branch) => branch !== base),
|
|
287
|
+
};
|
|
288
|
+
} catch {
|
|
289
|
+
return { track: null, branches: [] };
|
|
290
|
+
}
|
|
263
291
|
};
|
|
264
292
|
|
|
265
293
|
/** Why `pr merge` refused, for the envelope `data` (agents branch on `reason`). */
|
|
@@ -431,6 +459,7 @@ export async function mergePullRequest(
|
|
|
431
459
|
deletedBranch =
|
|
432
460
|
deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
|
|
433
461
|
}
|
|
462
|
+
const mergeBack = mergeBackFor(cwd, doc.base);
|
|
434
463
|
const row = appendObserved(cwd, {
|
|
435
464
|
type: "pr.merged",
|
|
436
465
|
actor: input.actor,
|
|
@@ -457,6 +486,8 @@ export async function mergePullRequest(
|
|
|
457
486
|
grant: { source: grant.source },
|
|
458
487
|
deletedBranch,
|
|
459
488
|
recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
|
|
489
|
+
mergeBack: mergeBack.branches,
|
|
490
|
+
mergeBackTrack: mergeBack.track,
|
|
460
491
|
},
|
|
461
492
|
};
|
|
462
493
|
}
|
package/src/forge/report.ts
CHANGED
|
@@ -34,6 +34,23 @@ export type NextAction =
|
|
|
34
34
|
| "NOT_MERGEABLE"
|
|
35
35
|
| "READY";
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The next babysitting step for a `green`/`merged` endpoint, coarser than
|
|
39
|
+
* `next`: `wait` is CI running (`workit ci wait`); `wait-forge` is CI done
|
|
40
|
+
* but the forge still deciding (merge queue, mergeability computing);
|
|
41
|
+
* `ready` means nothing is left for the agent (a human approval may still be
|
|
42
|
+
* pending); null for a closed, unmerged PR.
|
|
43
|
+
*/
|
|
44
|
+
export type BabysitAction =
|
|
45
|
+
| "wait"
|
|
46
|
+
| "wait-forge"
|
|
47
|
+
| "mark-ready"
|
|
48
|
+
| "fix-ci"
|
|
49
|
+
| "address-threads"
|
|
50
|
+
| "update-branch"
|
|
51
|
+
| "ready"
|
|
52
|
+
| "merged";
|
|
53
|
+
|
|
37
54
|
export type FailingCheck = {
|
|
38
55
|
name: string;
|
|
39
56
|
url: string | null;
|
|
@@ -90,6 +107,7 @@ export type PrStatusDoc = {
|
|
|
90
107
|
identity: { login: string | null; credential: string; note?: string } | null;
|
|
91
108
|
truncated: boolean;
|
|
92
109
|
next: NextAction;
|
|
110
|
+
babysit: BabysitAction | null;
|
|
93
111
|
};
|
|
94
112
|
|
|
95
113
|
/** Failing checks whose log tail is fetched; the rest list without a tail. */
|
|
@@ -192,6 +210,42 @@ export function nextAction(input: NextInput): NextAction {
|
|
|
192
210
|
return blockersOf(input)[0]?.next ?? "READY";
|
|
193
211
|
}
|
|
194
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Map `next` (plus the first blocker's reason) to one babysitting step. The
|
|
215
|
+
* branch is updated only when the forge requires it (conflicts, a required
|
|
216
|
+
* rebase): merely behind its base is still `ready`, so a busy base never
|
|
217
|
+
* starts a rebase/CI loop or drops approvals. A draft reads `mark-ready` once
|
|
218
|
+
* nothing else is open, even when a review is also pending.
|
|
219
|
+
*/
|
|
220
|
+
export function babysitAction(
|
|
221
|
+
next: NextAction,
|
|
222
|
+
reason: string | null,
|
|
223
|
+
draft: boolean,
|
|
224
|
+
): BabysitAction | null {
|
|
225
|
+
switch (next) {
|
|
226
|
+
case "MERGED":
|
|
227
|
+
return "merged";
|
|
228
|
+
case "CLOSED":
|
|
229
|
+
return null;
|
|
230
|
+
case "RESOLVE_CONFLICTS":
|
|
231
|
+
case "REBASE":
|
|
232
|
+
return "update-branch";
|
|
233
|
+
case "RESOLVE_THREADS":
|
|
234
|
+
case "ADDRESS_REVIEW":
|
|
235
|
+
return "address-threads";
|
|
236
|
+
case "FIX_CI":
|
|
237
|
+
return "fix-ci";
|
|
238
|
+
case "WAITING_CI":
|
|
239
|
+
return "wait";
|
|
240
|
+
case "IN_MERGE_QUEUE":
|
|
241
|
+
return "wait-forge";
|
|
242
|
+
default:
|
|
243
|
+
if (draft) return "mark-ready";
|
|
244
|
+
if (next === "NOT_MERGEABLE" && reason === "mergeability_unknown") return "wait-forge";
|
|
245
|
+
return "ready";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
195
249
|
/**
|
|
196
250
|
* Count head against the base tip the API reported, locally. A pure read:
|
|
197
251
|
* missing commits are fetched by id (no destination ref, no FETCH_HEAD), so
|
|
@@ -363,6 +417,11 @@ export function buildStatusDoc(
|
|
|
363
417
|
identity: options.identity ?? null,
|
|
364
418
|
truncated: status.truncated,
|
|
365
419
|
next: blockers[0]?.next ?? "READY",
|
|
420
|
+
babysit: babysitAction(
|
|
421
|
+
blockers[0]?.next ?? "READY",
|
|
422
|
+
blockers[0]?.reason ?? null,
|
|
423
|
+
status.draft || blockers.some((blocker) => blocker.next === "MARK_READY"),
|
|
424
|
+
),
|
|
366
425
|
});
|
|
367
426
|
}
|
|
368
427
|
|
package/src/git/ops.ts
CHANGED
|
@@ -27,7 +27,8 @@ import {
|
|
|
27
27
|
} from "../core/branch";
|
|
28
28
|
import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
|
|
29
29
|
import { pushTargetIsStable } from "../core/pr-create";
|
|
30
|
-
import {
|
|
30
|
+
import { recordBranchBase, trackBranchName } from "../core/release-tracks";
|
|
31
|
+
import { vcsConfig, type ResolvedReleaseTrack } from "../core/vcs-config";
|
|
31
32
|
import { redactText } from "../forge/redact";
|
|
32
33
|
import { failure, success, type ForgeResult } from "../forge/types";
|
|
33
34
|
import { appendObserved, readLedger, type LedgerActor } from "../ledger";
|
|
@@ -119,15 +120,51 @@ export type BranchOutcome = {
|
|
|
119
120
|
carried: boolean;
|
|
120
121
|
/** Fallbacks taken (fetch failed, base used from the local copy). */
|
|
121
122
|
notes: string[];
|
|
123
|
+
/** The release track the base and name came from; null without tracks. */
|
|
124
|
+
releaseTrack: ResolvedReleaseTrack | null;
|
|
122
125
|
};
|
|
123
126
|
|
|
124
|
-
export
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
export type BranchInput = {
|
|
128
|
+
/** An explicit branch name; else `kind` + `slug` through the track's naming. */
|
|
129
|
+
name?: string;
|
|
130
|
+
kind?: "feature" | "bugfix" | "hotfix";
|
|
131
|
+
slug?: string;
|
|
132
|
+
base?: string | null;
|
|
133
|
+
carry?: boolean;
|
|
134
|
+
/** An explicit release track (`--track`). */
|
|
135
|
+
track?: string | null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOutcome> {
|
|
129
139
|
if (!headSha(cwd) && currentBranch(cwd) === null)
|
|
130
140
|
return failure("invalid_input", "not inside a git repository with a commit");
|
|
141
|
+
// One resolution: the default target, the base for new branches and the
|
|
142
|
+
// release track (core/release-tracks.ts) all come from vcsConfig. With
|
|
143
|
+
// --base the track is the base's line (it decides the --kind naming);
|
|
144
|
+
// otherwise the checkout's.
|
|
145
|
+
const explicitBase = input.base?.trim() || null;
|
|
146
|
+
const resolved = vcsConfig("resolve", cwd, {
|
|
147
|
+
track: input.track ?? null,
|
|
148
|
+
...(explicitBase ? { branch: explicitBase } : {}),
|
|
149
|
+
});
|
|
150
|
+
const releaseTrack: ResolvedReleaseTrack | null =
|
|
151
|
+
resolved.ok === false ? null : (resolved.releaseTrack ?? null);
|
|
152
|
+
// An undetermined line blocks whenever the track would decide something:
|
|
153
|
+
// the base (no --base) or the --kind/--slug name.
|
|
154
|
+
if (releaseTrack?.blocking && (!explicitBase || input.name === undefined))
|
|
155
|
+
return failure(
|
|
156
|
+
"blocked",
|
|
157
|
+
String(releaseTrack.blocking),
|
|
158
|
+
"workit git branch … --track <name> # or --base <branch>",
|
|
159
|
+
);
|
|
160
|
+
const name = (
|
|
161
|
+
input.name ??
|
|
162
|
+
(input.kind && input.slug
|
|
163
|
+
? (trackBranchName(releaseTrack?.track ?? null, input.kind, input.slug) ??
|
|
164
|
+
`${input.kind}/${input.slug}`)
|
|
165
|
+
: "")
|
|
166
|
+
).trim();
|
|
167
|
+
if (!name) return failure("invalid_input", "missing branch name");
|
|
131
168
|
if (!validRefName(cwd, name))
|
|
132
169
|
return failure("invalid_input", `invalid branch name ${JSON.stringify(name)}`);
|
|
133
170
|
const policy = validateBranchNameFor(cwd, name);
|
|
@@ -135,28 +172,36 @@ export function gitBranch(
|
|
|
135
172
|
if (resolveRef(cwd, `refs/heads/${name}`))
|
|
136
173
|
return failure("failed", `branch_exists: ${name} already exists`, `git switch ${name}`);
|
|
137
174
|
|
|
138
|
-
let base =
|
|
175
|
+
let base = explicitBase;
|
|
139
176
|
let defaultTarget: string | null = null;
|
|
140
|
-
|
|
141
|
-
|
|
177
|
+
let defaultBase: string | null = null;
|
|
178
|
+
// An explicit --track that does not resolve is never ignored, even with --base.
|
|
179
|
+
if (resolved.ok === false && (!base || input.track))
|
|
142
180
|
return failure(
|
|
143
181
|
"blocked",
|
|
144
182
|
String(resolved.error),
|
|
145
183
|
"fix ~/.config/workit/vcs.json or pass --base <branch>",
|
|
146
184
|
);
|
|
147
|
-
if (resolved.ok !== false)
|
|
148
|
-
|
|
185
|
+
if (resolved.ok !== false) {
|
|
186
|
+
defaultTarget = String(resolved.defaultTargetBranch ?? "") || null;
|
|
187
|
+
defaultBase = String(resolved.baseBranch ?? "") || defaultTarget;
|
|
188
|
+
}
|
|
189
|
+
base ??= defaultBase;
|
|
149
190
|
if (!base)
|
|
150
191
|
return failure("invalid_input", "no default target branch is configured; pass --base <branch>");
|
|
151
192
|
if (base.startsWith("-")) return failure("invalid_input", "--base must not start with -");
|
|
152
193
|
|
|
153
|
-
const notes: string[] = [];
|
|
194
|
+
const notes: string[] = [...(releaseTrack?.warnings ?? [])];
|
|
154
195
|
const remotes = remoteNames(cwd);
|
|
155
196
|
const remote = remotes.includes("origin") ? "origin" : pushRemoteName(cwd);
|
|
156
197
|
const localTip = resolveRef(cwd, `refs/heads/${base}`);
|
|
157
198
|
// A local, unprotected branch that is not the default target is a stack
|
|
158
199
|
// parent: its local tip is the truth (it may not be pushed yet).
|
|
159
|
-
const stackParent =
|
|
200
|
+
const stackParent =
|
|
201
|
+
localTip !== null &&
|
|
202
|
+
base !== defaultTarget &&
|
|
203
|
+
base !== defaultBase &&
|
|
204
|
+
!isProtectedTarget(cwd, base);
|
|
160
205
|
let baseRef: string | null = null;
|
|
161
206
|
if (stackParent) baseRef = `refs/heads/${base}`;
|
|
162
207
|
else if (remote && validRefName(cwd, base)) {
|
|
@@ -194,6 +239,8 @@ export function gitBranch(
|
|
|
194
239
|
const previous = currentBranch(cwd);
|
|
195
240
|
const switched = git(cwd, ["switch", "--no-track", "-c", name, baseSha]);
|
|
196
241
|
if (!switched.ok) return failure("failed", `git switch -c ${name} failed: ${gitError(switched)}`);
|
|
242
|
+
// The cheapest track signal later (release-tracks.ts): where this branch began.
|
|
243
|
+
recordBranchBase(cwd, name, base);
|
|
197
244
|
return success({
|
|
198
245
|
branch: name,
|
|
199
246
|
previous,
|
|
@@ -202,6 +249,7 @@ export function gitBranch(
|
|
|
202
249
|
baseSha,
|
|
203
250
|
carried: dirt !== "clean",
|
|
204
251
|
notes,
|
|
252
|
+
releaseTrack,
|
|
205
253
|
});
|
|
206
254
|
}
|
|
207
255
|
|