mandrel 2.44.0 → 2.46.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/.agents/scripts/lib/gh-exec.js +29 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +15 -1
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +4 -2
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +33 -12
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +17 -6
- package/.agents/scripts/providers/github/labels.js +45 -1
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
|
@@ -158,6 +158,35 @@ export class GhGraphqlError extends GhExecError {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Render a `gh` failure as one operator-legible line: the error message plus
|
|
163
|
+
* the reason `gh` itself printed.
|
|
164
|
+
*
|
|
165
|
+
* Every `GhExecError` carries the captured `stderr`, but the classified
|
|
166
|
+
* message keeps only the exit status — so a caller that logs `err.message`
|
|
167
|
+
* alone flattens every unclassified failure to `gh-exec: gh exited with code
|
|
168
|
+
* 1`. That is exactly what hid an HTTP 422 `description is too long` behind
|
|
169
|
+
* three identical "label ensure failed" warnings (Story #5201). `gh` prints
|
|
170
|
+
* the actionable sentence on the *last* stderr line — the leading lines are
|
|
171
|
+
* the `HTTP 422:` banner and the API URL — so that line is what is appended.
|
|
172
|
+
*
|
|
173
|
+
* Non-`GhExecError` inputs (a plain `Error` from a provider fake, a thrown
|
|
174
|
+
* string) degrade to their own message, so this is safe on any catch path.
|
|
175
|
+
*
|
|
176
|
+
* @param {unknown} err
|
|
177
|
+
* @returns {string} `"<message>: <gh stderr detail>"`, or just the message
|
|
178
|
+
* when no stderr was captured.
|
|
179
|
+
*/
|
|
180
|
+
export function describeGhFailure(err) {
|
|
181
|
+
const message = String(err?.message ?? err ?? 'unknown error');
|
|
182
|
+
const lines = String(err?.stderr ?? '')
|
|
183
|
+
.split('\n')
|
|
184
|
+
.map((line) => line.trim())
|
|
185
|
+
.filter(Boolean);
|
|
186
|
+
const detail = lines.at(-1);
|
|
187
|
+
return detail ? `${message}: ${detail}` : message;
|
|
188
|
+
}
|
|
189
|
+
|
|
161
190
|
/**
|
|
162
191
|
* Classify a non-zero `gh` invocation into the most specific typed error
|
|
163
192
|
* subclass available. Pure function — no side effects, no I/O.
|
|
@@ -49,6 +49,19 @@ export function listLocalBranches(cwd) {
|
|
|
49
49
|
* Pure: strip the `<remote>/` prefix off a remote-ref listing and drop the
|
|
50
50
|
* symbolic `HEAD` entry.
|
|
51
51
|
*
|
|
52
|
+
* `HEAD` arrives under two spellings. `%(refname:lstrip=3)` renders
|
|
53
|
+
* `refs/remotes/origin/HEAD` as the literal `HEAD`, but
|
|
54
|
+
* `%(refname:short)` — what both callers ask for — shortens it to the bare
|
|
55
|
+
* remote name `origin`, because git keeps the remote segment only when the
|
|
56
|
+
* result would otherwise be ambiguous. Missing that second spelling leaked
|
|
57
|
+
* a phantom `origin` candidate into the remote-only sweep, which the reap
|
|
58
|
+
* phase then reported as `already gone`.
|
|
59
|
+
*
|
|
60
|
+
* Both spellings are rejected **before** the prefix strip, because the
|
|
61
|
+
* strip is what makes them ambiguous: a real branch named `origin` lists
|
|
62
|
+
* as `origin/origin` and strips to `origin` too. Discriminating on the raw
|
|
63
|
+
* line keeps that branch reapable while still dropping the symbolic ref.
|
|
64
|
+
*
|
|
52
65
|
* Shared by {@link listRemoteBranches} and {@link listRemoteMergedBranches}
|
|
53
66
|
* so both enumerations speak the same short-name vocabulary the planner's
|
|
54
67
|
* remote-only walk keys on — a divergence there would make the ancestry
|
|
@@ -64,8 +77,9 @@ function shortRemoteNames(stdout, remoteName) {
|
|
|
64
77
|
.split('\n')
|
|
65
78
|
.map((l) => l.trim())
|
|
66
79
|
.filter(Boolean)
|
|
80
|
+
.filter((l) => l !== 'HEAD' && l !== remoteName)
|
|
67
81
|
.map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : l))
|
|
68
|
-
.filter(
|
|
82
|
+
.filter(Boolean);
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
/* node:coverage ignore next */
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import { linkStoriesToEpic } from '../../../providers/github/sub-issue-add.js';
|
|
19
|
+
import { describeGhFailure } from '../../gh-exec.js';
|
|
19
20
|
import { Logger } from '../../Logger.js';
|
|
20
21
|
import { LABEL_COLORS, TYPE_LABELS } from '../../label-constants.js';
|
|
21
22
|
import { composeEpicBody } from '../epic-container.js';
|
|
@@ -103,8 +104,9 @@ async function ensureEpicLabel({ provider }) {
|
|
|
103
104
|
return true;
|
|
104
105
|
} catch (err) {
|
|
105
106
|
Logger.warn(
|
|
106
|
-
`[plan-persist] "${TYPE_LABELS.EPIC}" label ensure failed
|
|
107
|
-
|
|
107
|
+
`[plan-persist] "${TYPE_LABELS.EPIC}" label ensure failed ` +
|
|
108
|
+
`(${describeGhFailure(err)}) — skipping the container Epic. ` +
|
|
109
|
+
'The Stories are unaffected and deliver by id.',
|
|
108
110
|
);
|
|
109
111
|
return false;
|
|
110
112
|
}
|
|
@@ -632,10 +632,20 @@ function logEffectiveRoute(route, isLiteRoute) {
|
|
|
632
632
|
* Log the operator-facing persist epilogue: adoption, the ready primary, the
|
|
633
633
|
* deliver command, and the cohort grouping label.
|
|
634
634
|
*
|
|
635
|
-
* @param {{
|
|
635
|
+
* @param {{
|
|
636
|
+
* created: object[],
|
|
637
|
+
* primary: object,
|
|
638
|
+
* planRunLabel: string,
|
|
639
|
+
* planRunLabelApplied: boolean,
|
|
640
|
+
* }} args
|
|
636
641
|
* @returns {void}
|
|
637
642
|
*/
|
|
638
|
-
function logPersistEpilogue({
|
|
643
|
+
function logPersistEpilogue({
|
|
644
|
+
created,
|
|
645
|
+
primary,
|
|
646
|
+
planRunLabel,
|
|
647
|
+
planRunLabelApplied,
|
|
648
|
+
}) {
|
|
639
649
|
const adopted = created.filter((story) => story.adopted);
|
|
640
650
|
if (adopted.length > 0) {
|
|
641
651
|
Logger.info(
|
|
@@ -652,10 +662,15 @@ function logPersistEpilogue({ created, primary, planRunLabel }) {
|
|
|
652
662
|
);
|
|
653
663
|
// Metadata only — a GitHub filter for the cohort this run authored, never
|
|
654
664
|
// a delivery-resolution input (/mandrel-deliver stays ids-only, Story #4540).
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
)
|
|
665
|
+
// Gated on the ensure result: the label ensure degrades non-fatally, and
|
|
666
|
+
// advertising a filter GitHub just refused to create is worse than saying
|
|
667
|
+
// nothing (Story #5201). The derived id survives in the result envelope.
|
|
668
|
+
if (planRunLabelApplied) {
|
|
669
|
+
Logger.info(
|
|
670
|
+
`[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
|
|
671
|
+
`label:${planRunLabel}`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
659
674
|
}
|
|
660
675
|
|
|
661
676
|
/**
|
|
@@ -800,11 +815,12 @@ export async function runPlanPersist({
|
|
|
800
815
|
const isLiteRoute = route?.route === 'lite';
|
|
801
816
|
logEffectiveRoute(route, isLiteRoute);
|
|
802
817
|
|
|
803
|
-
const { created, planRunLabel } =
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
818
|
+
const { created, planRunLabel, planRunLabelApplied } =
|
|
819
|
+
await createStoryIssues({
|
|
820
|
+
provider,
|
|
821
|
+
stories,
|
|
822
|
+
opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
|
|
823
|
+
});
|
|
808
824
|
|
|
809
825
|
const primary = created[0];
|
|
810
826
|
const waveTable = buildWaveTable(
|
|
@@ -883,7 +899,12 @@ export async function runPlanPersist({
|
|
|
883
899
|
supersede.sourceTicketOrigin = sourceTicketOrigin;
|
|
884
900
|
|
|
885
901
|
await cleanupPlanDirs({ config, planDir, skipCleanup });
|
|
886
|
-
logPersistEpilogue({
|
|
902
|
+
logPersistEpilogue({
|
|
903
|
+
created,
|
|
904
|
+
primary,
|
|
905
|
+
planRunLabel,
|
|
906
|
+
planRunLabelApplied,
|
|
907
|
+
});
|
|
887
908
|
|
|
888
909
|
return {
|
|
889
910
|
stories: created,
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
ownedProvenanceSource,
|
|
23
23
|
} from '../../findings/provenance-field.js';
|
|
24
24
|
import { carryProvenanceFooters } from '../../findings/route-finding.js';
|
|
25
|
+
import { describeGhFailure } from '../../gh-exec.js';
|
|
25
26
|
import { Logger } from '../../Logger.js';
|
|
26
27
|
import { AGENT_LABELS, TYPE_LABELS } from '../../label-constants.js';
|
|
27
28
|
import {
|
|
@@ -902,8 +903,8 @@ async function ensurePersistLabel({
|
|
|
902
903
|
return true;
|
|
903
904
|
} catch (err) {
|
|
904
905
|
Logger.warn(
|
|
905
|
-
`[plan-persist] ${role} label ensure failed (${err
|
|
906
|
-
'creating the Stories without it. Add the label by hand if you ' +
|
|
906
|
+
`[plan-persist] ${role} label ensure failed (${describeGhFailure(err)})` +
|
|
907
|
+
' — creating the Stories without it. Add the label by hand if you ' +
|
|
907
908
|
'want it.',
|
|
908
909
|
);
|
|
909
910
|
return false;
|
|
@@ -978,6 +979,7 @@ async function ensurePersistLabel({
|
|
|
978
979
|
* created: Array<{ slug: string, id: number, url?: string, title: string, adopted: boolean }>,
|
|
979
980
|
* dependencyEdges: { edgesAdded: number, edgesSkipped: number, edgesFailed: number, storiesProcessed: number }|null,
|
|
980
981
|
* planRunLabel: string,
|
|
982
|
+
* planRunLabelApplied: boolean,
|
|
981
983
|
* routeLabel: string|null,
|
|
982
984
|
* }>}
|
|
983
985
|
*/
|
|
@@ -1012,6 +1014,10 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
|
|
|
1012
1014
|
})),
|
|
1013
1015
|
dependencyEdges: null,
|
|
1014
1016
|
planRunLabel: cohortLabel,
|
|
1017
|
+
// A dry run writes nothing, so nothing was ensured and nothing carries
|
|
1018
|
+
// the label — the derived id is still reported, the application is not
|
|
1019
|
+
// claimed.
|
|
1020
|
+
planRunLabelApplied: false,
|
|
1015
1021
|
routeLabel,
|
|
1016
1022
|
};
|
|
1017
1023
|
}
|
|
@@ -1021,8 +1027,8 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
|
|
|
1021
1027
|
label: cohortLabel,
|
|
1022
1028
|
color: PLAN_RUN_LABEL_COLOR,
|
|
1023
1029
|
description:
|
|
1024
|
-
'Groups the Stories one /mandrel-plan persist run authored
|
|
1025
|
-
'only
|
|
1030
|
+
'Groups the Stories one /mandrel-plan persist run authored — ' +
|
|
1031
|
+
'metadata only, never a deliver input.',
|
|
1026
1032
|
role: 'cohort',
|
|
1027
1033
|
});
|
|
1028
1034
|
const applyRouteLabel =
|
|
@@ -1032,8 +1038,8 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
|
|
|
1032
1038
|
label: routeLabel,
|
|
1033
1039
|
color: LITE_ROUTE_LABEL_COLOR,
|
|
1034
1040
|
description:
|
|
1035
|
-
'Ceremony-lite hint
|
|
1036
|
-
'
|
|
1041
|
+
'Ceremony-lite hint only: /mandrel-deliver re-derives the route; ' +
|
|
1042
|
+
'every close gate still runs.',
|
|
1037
1043
|
role: 'route-marker',
|
|
1038
1044
|
}));
|
|
1039
1045
|
|
|
@@ -1111,7 +1117,12 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
|
|
|
1111
1117
|
return {
|
|
1112
1118
|
created,
|
|
1113
1119
|
dependencyEdges,
|
|
1120
|
+
// The derived id and whether it actually landed are separate facts. The id
|
|
1121
|
+
// is reported either way (a resumed persist re-derives it, and the
|
|
1122
|
+
// dry-run path reports it write-free); the flag is what an epilogue must
|
|
1123
|
+
// consult before advertising a `label:` filter that may match nothing.
|
|
1114
1124
|
planRunLabel: cohortLabel,
|
|
1125
|
+
planRunLabelApplied: applyCohortLabel,
|
|
1115
1126
|
routeLabel: applyRouteLabel ? routeLabel : null,
|
|
1116
1127
|
};
|
|
1117
1128
|
}
|
|
@@ -83,6 +83,46 @@ export function isLabelNotFoundError(err) {
|
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* GitHub's cap on a label description. A longer one is refused with an HTTP
|
|
88
|
+
* 422 that `gh label create` reports as a bare exit 1 — a deterministic
|
|
89
|
+
* create failure, never a truncation. Lives here rather than in the
|
|
90
|
+
* provider-agnostic label vocabulary because it is an API constraint of this
|
|
91
|
+
* provider (Story #5201, where two over-long `plan-persist` descriptions made
|
|
92
|
+
* the `plan-run::<id>` cohort label uncreatable on every run).
|
|
93
|
+
*/
|
|
94
|
+
// Module-private on purpose: nothing in production reads the number outside
|
|
95
|
+
// the guard below, and the suite pins GitHub's published cap as a literal
|
|
96
|
+
// rather than against our own constant.
|
|
97
|
+
const LABEL_DESCRIPTION_MAX_LENGTH = 100;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Refuse a label description GitHub will reject anyway, **before** `gh` is
|
|
101
|
+
* spawned.
|
|
102
|
+
*
|
|
103
|
+
* The API caps a description at {@link LABEL_DESCRIPTION_MAX_LENGTH}
|
|
104
|
+
* characters and answers a longer one with an HTTP 422 that `gh label create`
|
|
105
|
+
* surfaces as a bare exit 1 — legible only to a caller that digs the reason
|
|
106
|
+
* out of stderr. Failing at the call site instead names the label and its
|
|
107
|
+
* actual length, so the fix is obvious from the message alone.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately a throw rather than a silent truncation: a truncated
|
|
110
|
+
* description is a label whose text nobody chose, and every `ensureLabels`
|
|
111
|
+
* caller already handles a throw — the bootstrap paths surface it, and
|
|
112
|
+
* `plan-persist` degrades to creating its Stories without the cosmetic label.
|
|
113
|
+
*
|
|
114
|
+
* @param {{ name?: string, description?: string }} def
|
|
115
|
+
* @throws {Error} when the description exceeds the cap.
|
|
116
|
+
*/
|
|
117
|
+
function assertLabelDescriptionWithinCap(def) {
|
|
118
|
+
const description = def?.description ?? '';
|
|
119
|
+
if (description.length <= LABEL_DESCRIPTION_MAX_LENGTH) return;
|
|
120
|
+
throw new Error(
|
|
121
|
+
`label "${def?.name}" description is ${description.length} characters; ` +
|
|
122
|
+
`GitHub rejects anything over ${LABEL_DESCRIPTION_MAX_LENGTH}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
export class LabelGateway {
|
|
87
127
|
/**
|
|
88
128
|
* @param {{ gh: object, owner: string, repo: string }} deps
|
|
@@ -94,7 +134,10 @@ export class LabelGateway {
|
|
|
94
134
|
}
|
|
95
135
|
|
|
96
136
|
/**
|
|
97
|
-
* Idempotent label creation.
|
|
137
|
+
* Idempotent label creation. Each def's description is checked against
|
|
138
|
+
* GitHub's length cap first (`assertLabelDescriptionWithinCap`) so a
|
|
139
|
+
* guaranteed-422 create never reaches the network. Then, for each labelDef,
|
|
140
|
+
* attempt `gh label create
|
|
98
141
|
* <name> --color <hex> --description <text>`. The CLI prints
|
|
99
142
|
* "label already exists" (or the API surfaces a 422 "already_exists"
|
|
100
143
|
* error) when the name is taken; we swallow that and count it as
|
|
@@ -114,6 +157,7 @@ export class LabelGateway {
|
|
|
114
157
|
const created = [];
|
|
115
158
|
const skipped = [];
|
|
116
159
|
for (const def of labelDefs) {
|
|
160
|
+
assertLabelDescriptionWithinCap(def);
|
|
117
161
|
const color = (def.color ?? '').replace(/^#/, '');
|
|
118
162
|
try {
|
|
119
163
|
await withTransientRetry(() =>
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,20 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.46.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.45.0...mandrel-v2.46.0) (2026-09-07)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
* keep every shipped label description under GitHub's 100-char limit, and make a refused ensure legible ([#5201](https://github.com/dsj1984/mandrel/issues/5201)) ([#5202](https://github.com/dsj1984/mandrel/issues/5202)) ([f444ac6](https://github.com/dsj1984/mandrel/commit/f444ac62255b25fc44603f5603f128083ae327fa))
|
|
24
|
+
|
|
25
|
+
## [2.45.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.44.0...mandrel-v2.45.0) (2026-09-07)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
* **git-cleanup:** drop the symbolic HEAD ref under its short spelling ([#5197](https://github.com/dsj1984/mandrel/issues/5197)) ([98802f6](https://github.com/dsj1984/mandrel/commit/98802f6ee49b699560ab7362815e054751f3024b))
|
|
31
|
+
|
|
18
32
|
## [2.44.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.43.0...mandrel-v2.44.0) (2026-09-07)
|
|
19
33
|
|
|
20
34
|
|
package/package.json
CHANGED