mandrel 1.57.0 → 1.58.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.
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* normalize-pr-title.js — guarantee the standalone-Story PR title is a
|
|
3
|
+
* valid Conventional Commit subject so the squash-merge subject on `main`
|
|
4
|
+
* parses for release-please.
|
|
5
|
+
*
|
|
6
|
+
* Story #3969 (framework gap). The repo squash-merges, and GitHub uses the
|
|
7
|
+
* PR title as the squash-commit subject. `buildPullRequest` previously
|
|
8
|
+
* emitted the raw human issue title (`<storyTitle> (#<id>)`), which is a
|
|
9
|
+
* plain description ("Rename the published npm package…") that
|
|
10
|
+
* release-please's Conventional-Commit parser rejects:
|
|
11
|
+
*
|
|
12
|
+
* ❯ commit could not be parsed: … Rename the published npm package …
|
|
13
|
+
* ❯ error: unexpected token ' ' at 1:7, valid tokens [(, !, :]
|
|
14
|
+
* ❯ commits: 0 → no release cut
|
|
15
|
+
*
|
|
16
|
+
* The `commit-msg` commitlint Husky hook only validates *local* commits and
|
|
17
|
+
* never runs on a GitHub-UI squash-merge title, so nothing mechanized the
|
|
18
|
+
* documented "author the PR title in conventional form" contract. This
|
|
19
|
+
* module mechanizes it.
|
|
20
|
+
*
|
|
21
|
+
* Contract (pure where possible — the only side effect is an injectable
|
|
22
|
+
* `git log` read used to derive the type):
|
|
23
|
+
*
|
|
24
|
+
* - If `storyTitle` is **already** a parseable Conventional Commit
|
|
25
|
+
* subject, it is preserved verbatim and suffixed with `(#<storyId>)`.
|
|
26
|
+
* No re-prefixing, no double type.
|
|
27
|
+
* - Otherwise the title is **synthesized** into conventional form:
|
|
28
|
+
* `<type>: <descriptive text> (#<storyId>)`. The `type` is derived
|
|
29
|
+
* from the branch's own (already-conventional) commit subjects when
|
|
30
|
+
* available, falling back to a safe configured default (`chore`).
|
|
31
|
+
*
|
|
32
|
+
* Mirrors the already-normalized Epic-finalize default in
|
|
33
|
+
* `lib/orchestration/finalize/open-or-locate-pr.js` (`feat: Epic #<id>`),
|
|
34
|
+
* bringing the standalone path to the same guarantee.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { gitSpawn as defaultGitSpawn } from '../../../git-utils.js';
|
|
38
|
+
import { Logger as DefaultLogger } from '../../../Logger.js';
|
|
39
|
+
|
|
40
|
+
/** Safe default Conventional-Commit type when none can be derived. */
|
|
41
|
+
export const DEFAULT_CONVENTIONAL_TYPE = 'chore';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The Conventional-Commit types Mandrel accepts. Mirrors
|
|
45
|
+
* `commitlint.config.js` → `type-enum` and `release-please-config.json` →
|
|
46
|
+
* `changelog-sections`. Kept in sync by hand (single hard-cutover, no
|
|
47
|
+
* shim) — adding a type means touching all three.
|
|
48
|
+
*/
|
|
49
|
+
export const CONVENTIONAL_TYPES = Object.freeze([
|
|
50
|
+
'feat',
|
|
51
|
+
'fix',
|
|
52
|
+
'perf',
|
|
53
|
+
'refactor',
|
|
54
|
+
'revert',
|
|
55
|
+
'docs',
|
|
56
|
+
'style',
|
|
57
|
+
'chore',
|
|
58
|
+
'test',
|
|
59
|
+
'build',
|
|
60
|
+
'ci',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
// Precedence used when a branch carries a mix of conventional types: pick
|
|
64
|
+
// the most release-significant one so the squash subject communicates the
|
|
65
|
+
// branch's headline impact (and release-please bumps appropriately).
|
|
66
|
+
const TYPE_PRECEDENCE = Object.freeze([
|
|
67
|
+
'feat',
|
|
68
|
+
'fix',
|
|
69
|
+
'perf',
|
|
70
|
+
'refactor',
|
|
71
|
+
'revert',
|
|
72
|
+
'docs',
|
|
73
|
+
'style',
|
|
74
|
+
'test',
|
|
75
|
+
'build',
|
|
76
|
+
'ci',
|
|
77
|
+
'chore',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const TYPE_GROUP = CONVENTIONAL_TYPES.join('|');
|
|
81
|
+
// Anchored Conventional-Commit header matcher:
|
|
82
|
+
// <type>(<optional scope>)<optional !>: <non-empty description>
|
|
83
|
+
// Mirrors the shape `@commitlint/config-conventional` enforces (a known
|
|
84
|
+
// type, an optional parenthesised scope, an optional breaking `!`, a
|
|
85
|
+
// colon-space separator, and a non-empty subject). Used for the pure
|
|
86
|
+
// "is this already conventional?" check and to pull the type off a branch
|
|
87
|
+
// commit subject without spawning commitlint per call.
|
|
88
|
+
const CONVENTIONAL_HEADER_RE = new RegExp(
|
|
89
|
+
`^(?:${TYPE_GROUP})(?:\\([^()\\r\\n]+\\))?!?: \\S.*$`,
|
|
90
|
+
);
|
|
91
|
+
const LEADING_TYPE_RE = new RegExp(
|
|
92
|
+
`^(${TYPE_GROUP})(?:\\([^()\\r\\n]+\\))?!?:`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* True iff `subject` is a parseable Conventional Commit subject under the
|
|
97
|
+
* repo's type vocabulary. Pure.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} subject
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function isConventionalSubject(subject) {
|
|
103
|
+
if (typeof subject !== 'string') return false;
|
|
104
|
+
return CONVENTIONAL_HEADER_RE.test(subject.trim());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract the Conventional-Commit `type` from a single commit subject, or
|
|
109
|
+
* `null` when the subject is not conventional. Pure.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} subject
|
|
112
|
+
* @returns {string|null}
|
|
113
|
+
*/
|
|
114
|
+
export function parseConventionalType(subject) {
|
|
115
|
+
if (typeof subject !== 'string') return null;
|
|
116
|
+
const match = subject.trim().match(LEADING_TYPE_RE);
|
|
117
|
+
return match ? match[1] : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pick the most release-significant type from a list of conventional
|
|
122
|
+
* types, honouring `TYPE_PRECEDENCE`. Returns `null` for an empty list.
|
|
123
|
+
* Pure.
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} types
|
|
126
|
+
* @returns {string|null}
|
|
127
|
+
*/
|
|
128
|
+
export function pickDominantType(types) {
|
|
129
|
+
const present = new Set(types.filter(Boolean));
|
|
130
|
+
for (const candidate of TYPE_PRECEDENCE) {
|
|
131
|
+
if (present.has(candidate)) return candidate;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Read the branch's own commit subjects (commits unique to the Story
|
|
138
|
+
* branch relative to the base branch) and derive the dominant
|
|
139
|
+
* Conventional-Commit type. Returns `DEFAULT_CONVENTIONAL_TYPE` when no
|
|
140
|
+
* conventional subject is found or the git read fails.
|
|
141
|
+
*
|
|
142
|
+
* @param {{
|
|
143
|
+
* storyBranch: string,
|
|
144
|
+
* baseBranch: string,
|
|
145
|
+
* cwd?: string,
|
|
146
|
+
* gitSpawn?: typeof defaultGitSpawn,
|
|
147
|
+
* logger?: { warn?: Function },
|
|
148
|
+
* }} args
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
export function deriveTypeFromBranchCommits({
|
|
152
|
+
storyBranch,
|
|
153
|
+
baseBranch,
|
|
154
|
+
cwd = process.cwd(),
|
|
155
|
+
gitSpawn = defaultGitSpawn,
|
|
156
|
+
logger = DefaultLogger,
|
|
157
|
+
}) {
|
|
158
|
+
try {
|
|
159
|
+
const range = `${baseBranch}..${storyBranch}`;
|
|
160
|
+
const result = gitSpawn(cwd, 'log', '--no-merges', '--format=%s', range);
|
|
161
|
+
if (!result || result.status !== 0) {
|
|
162
|
+
logger?.warn?.(
|
|
163
|
+
`[normalize-pr-title] git log ${range} failed (status=${result?.status ?? 'n/a'}); ` +
|
|
164
|
+
`defaulting type to "${DEFAULT_CONVENTIONAL_TYPE}".`,
|
|
165
|
+
);
|
|
166
|
+
return DEFAULT_CONVENTIONAL_TYPE;
|
|
167
|
+
}
|
|
168
|
+
const types = String(result.stdout ?? '')
|
|
169
|
+
.split('\n')
|
|
170
|
+
.map((line) => parseConventionalType(line))
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
return pickDominantType(types) ?? DEFAULT_CONVENTIONAL_TYPE;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
logger?.warn?.(
|
|
175
|
+
`[normalize-pr-title] could not derive type from branch commits ` +
|
|
176
|
+
`(defaulting to "${DEFAULT_CONVENTIONAL_TYPE}"): ${err?.message ?? err}`,
|
|
177
|
+
);
|
|
178
|
+
return DEFAULT_CONVENTIONAL_TYPE;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Produce a PR title that parses as a Conventional Commit.
|
|
184
|
+
*
|
|
185
|
+
* - Already-conventional `storyTitle` → preserved verbatim + `(#<id>)`.
|
|
186
|
+
* - Otherwise → `<derivedType>: <storyTitle> (#<id>)`.
|
|
187
|
+
* - Empty / missing `storyTitle` → `<derivedType>: Story #<id>`.
|
|
188
|
+
*
|
|
189
|
+
* The type derivation (`deriveTypeFromBranchCommits`) is the only side
|
|
190
|
+
* effect, and is skipped entirely when the title is already conventional.
|
|
191
|
+
*
|
|
192
|
+
* @param {{
|
|
193
|
+
* storyTitle: string,
|
|
194
|
+
* storyId: number|string,
|
|
195
|
+
* storyBranch?: string,
|
|
196
|
+
* baseBranch?: string,
|
|
197
|
+
* cwd?: string,
|
|
198
|
+
* gitSpawn?: typeof defaultGitSpawn,
|
|
199
|
+
* logger?: { warn?: Function },
|
|
200
|
+
* }} args
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
export function normalizePrTitle({
|
|
204
|
+
storyTitle,
|
|
205
|
+
storyId,
|
|
206
|
+
storyBranch,
|
|
207
|
+
baseBranch,
|
|
208
|
+
cwd = process.cwd(),
|
|
209
|
+
gitSpawn = defaultGitSpawn,
|
|
210
|
+
logger = DefaultLogger,
|
|
211
|
+
}) {
|
|
212
|
+
const idSuffix = `(#${storyId})`;
|
|
213
|
+
const trimmed = typeof storyTitle === 'string' ? storyTitle.trim() : '';
|
|
214
|
+
|
|
215
|
+
// Already conventional → preserve verbatim, append the id reference.
|
|
216
|
+
if (isConventionalSubject(trimmed)) {
|
|
217
|
+
return `${trimmed} ${idSuffix}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Not conventional → derive a type and synthesize.
|
|
221
|
+
const type =
|
|
222
|
+
storyBranch && baseBranch
|
|
223
|
+
? deriveTypeFromBranchCommits({
|
|
224
|
+
storyBranch,
|
|
225
|
+
baseBranch,
|
|
226
|
+
cwd,
|
|
227
|
+
gitSpawn,
|
|
228
|
+
logger,
|
|
229
|
+
})
|
|
230
|
+
: DEFAULT_CONVENTIONAL_TYPE;
|
|
231
|
+
|
|
232
|
+
// Lowercase the leading character of a synthesized description so the
|
|
233
|
+
// subject satisfies commitlint's `subject-case` rule (matching the
|
|
234
|
+
// `shapeMergeSubject` behaviour). An already-conventional title is left
|
|
235
|
+
// untouched (it was preserved verbatim above). The empty-title fallback
|
|
236
|
+
// uses a lowercased `story #<id>` for the same reason.
|
|
237
|
+
const rawDescription = trimmed.length > 0 ? trimmed : `Story #${storyId}`;
|
|
238
|
+
const description =
|
|
239
|
+
rawDescription.charAt(0).toLowerCase() + rawDescription.slice(1);
|
|
240
|
+
return `${type}: ${description} ${idSuffix}`;
|
|
241
|
+
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { gh as defaultGh } from '../../../gh-exec.js';
|
|
21
21
|
import { Logger } from '../../../Logger.js';
|
|
22
|
+
import { normalizePrTitle } from './normalize-pr-title.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Probe for an existing open PR with `head = storyBranch`; create one if
|
|
@@ -76,9 +77,21 @@ export async function ensurePullRequestWith({
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
progress('PR', `Opening PR for ${storyBranch} → ${baseBranch}...`);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// The repo squash-merges and GitHub uses the PR title as the squash
|
|
81
|
+
// subject on `main`. A raw human issue title is not a Conventional
|
|
82
|
+
// Commit, so release-please silently counts it as 0 releasable commits
|
|
83
|
+
// (Story #3969). Normalize the title to conventional form: preserve an
|
|
84
|
+
// already-conventional `storyTitle` verbatim, otherwise synthesize a
|
|
85
|
+
// type derived from the branch's own commit subjects (default `chore`).
|
|
86
|
+
// `gh-exec` spawns `gh` against the current process cwd (the worktree),
|
|
87
|
+
// so the branch-commit read uses the same cwd.
|
|
88
|
+
const title = normalizePrTitle({
|
|
89
|
+
storyTitle,
|
|
90
|
+
storyId,
|
|
91
|
+
storyBranch,
|
|
92
|
+
baseBranch,
|
|
93
|
+
cwd: _cwd ?? process.cwd(),
|
|
94
|
+
});
|
|
82
95
|
const body = [
|
|
83
96
|
`Closes #${storyId}`,
|
|
84
97
|
'',
|
package/README.md
CHANGED
|
@@ -145,6 +145,14 @@ npx mandrel sync # re-materialize ./.agents/
|
|
|
145
145
|
npx mandrel doctor # verify the install
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
### Migrating from `@mandrelai/agents`
|
|
149
|
+
|
|
150
|
+
The framework package was renamed from the scoped `@mandrelai/agents` to the
|
|
151
|
+
unscoped `mandrel`. Already on the old name? `mandrel update` does **not**
|
|
152
|
+
auto-migrate (it resolves the package by name), so make the one-time manual
|
|
153
|
+
hop documented in
|
|
154
|
+
[`docs/migrate-mandrelai-to-mandrel.md`](docs/migrate-mandrelai-to-mandrel.md).
|
|
155
|
+
|
|
148
156
|
## Contributors
|
|
149
157
|
|
|
150
158
|
Only `.agents/` is distributed to consumers — it ships inside the
|
package/package.json
CHANGED