@rsaglobaltech/specgate 0.21.0 → 0.22.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/CHANGELOG.md +93 -0
- package/dist/packages/core/src/domain/BriefCards.js +320 -0
- package/dist/packages/core/src/domain/BriefCards.js.map +1 -0
- package/dist/packages/core/src/domain/DraftChecklist.js +5 -1
- package/dist/packages/core/src/domain/DraftChecklist.js.map +1 -1
- package/dist/packages/core/src/domain/QuotedText.js +5 -0
- package/dist/packages/core/src/domain/QuotedText.js.map +1 -1
- package/dist/packages/core/src/domain/TicketText.js +337 -0
- package/dist/packages/core/src/domain/TicketText.js.map +1 -0
- package/dist/packages/core/src/infrastructure/ChangeWorkspace.js +7 -3
- package/dist/packages/core/src/infrastructure/ChangeWorkspace.js.map +1 -1
- package/dist/packages/core/src/infrastructure/IdsAcrossBranches.js +48 -0
- package/dist/packages/core/src/infrastructure/IdsAcrossBranches.js.map +1 -0
- package/dist/scripts/agents/commands.js +3 -1
- package/dist/scripts/agents/commands.js.map +1 -1
- package/dist/scripts/agents/contract-file.js +44 -0
- package/dist/scripts/agents/contract-file.js.map +1 -1
- package/dist/scripts/alm/cli.js +14 -3
- package/dist/scripts/alm/cli.js.map +1 -1
- package/dist/scripts/alm/port.js +1 -1
- package/dist/scripts/alm/port.js.map +1 -1
- package/dist/scripts/alm/providers/http-client.js +46 -3
- package/dist/scripts/alm/providers/http-client.js.map +1 -1
- package/dist/scripts/alm/providers/jira.js +108 -32
- package/dist/scripts/alm/providers/jira.js.map +1 -1
- package/dist/scripts/cli/commands/alm/PrCommand.js +198 -0
- package/dist/scripts/cli/commands/alm/PrCommand.js.map +1 -0
- package/dist/scripts/cli/commands/change/ChangeCommand.js +32 -6
- package/dist/scripts/cli/commands/change/ChangeCommand.js.map +1 -1
- package/dist/scripts/cli/commands/change/TicketCommand.js +657 -0
- package/dist/scripts/cli/commands/change/TicketCommand.js.map +1 -0
- package/dist/scripts/cli/commands/harness/RunCommand.js +4 -2
- package/dist/scripts/cli/commands/harness/RunCommand.js.map +1 -1
- package/dist/scripts/cli/commands/project/AdoptCommand.js +58 -0
- package/dist/scripts/cli/commands/project/AdoptCommand.js.map +1 -1
- package/dist/scripts/cli/commands/project/UpdateCommand.js +4 -0
- package/dist/scripts/cli/commands/project/UpdateCommand.js.map +1 -1
- package/dist/scripts/cli/commands/quality/CheckCommand.js +12 -0
- package/dist/scripts/cli/commands/quality/CheckCommand.js.map +1 -1
- package/dist/scripts/cli/commands/spec/DraftCommand.js +240 -27
- package/dist/scripts/cli/commands/spec/DraftCommand.js.map +1 -1
- package/dist/scripts/cli/commands/spec/ExploreCommand.js +13 -13
- package/dist/scripts/cli/commands/spec/ExploreCommand.js.map +1 -1
- package/dist/scripts/cli/commands/spec/StatusCommand.js +18 -6
- package/dist/scripts/cli/commands/spec/StatusCommand.js.map +1 -1
- package/dist/scripts/docs/journey-storyboard.js +33 -27
- package/dist/scripts/docs/journey-storyboard.js.map +1 -1
- package/dist/scripts/docs/nav.js +5 -0
- package/dist/scripts/docs/nav.js.map +1 -1
- package/dist/scripts/docs/record-journey.js +93 -30
- package/dist/scripts/docs/record-journey.js.map +1 -1
- package/dist/scripts/draft/brief-check.js +34 -0
- package/dist/scripts/draft/brief-check.js.map +1 -0
- package/dist/scripts/draft/brief-setup.js +182 -17
- package/dist/scripts/draft/brief-setup.js.map +1 -1
- package/dist/scripts/draft/drafting-guide.js +43 -1
- package/dist/scripts/draft/drafting-guide.js.map +1 -1
- package/dist/scripts/draft/ticket-guide.js +129 -0
- package/dist/scripts/draft/ticket-guide.js.map +1 -0
- package/dist/scripts/lib/ticket-keys.js +140 -0
- package/dist/scripts/lib/ticket-keys.js.map +1 -0
- package/dist/scripts/propose.js +8 -2
- package/dist/scripts/propose.js.map +1 -1
- package/dist/scripts/vcs/github-pr.js +88 -0
- package/dist/scripts/vcs/github-pr.js.map +1 -0
- package/package.json +1 -1
- package/templates/base/AGENTS.md.tpl +6 -5
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `specgate propose --from-ticket <KEY | issue.json>` — a Jira ticket as the
|
|
4
|
+
* source text of a change (ADR-0039).
|
|
5
|
+
*
|
|
6
|
+
* Reading the ticket is the ALM provider's job and stays read-only; writing
|
|
7
|
+
* the change is this command's, as `alm pull` writes its own outside
|
|
8
|
+
* `scripts/alm/` (ADR-0021 §1).
|
|
9
|
+
*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
22
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
23
|
+
}) : function(o, v) {
|
|
24
|
+
o["default"] = v;
|
|
25
|
+
});
|
|
26
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
27
|
+
var ownKeys = function(o) {
|
|
28
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
29
|
+
var ar = [];
|
|
30
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
31
|
+
return ar;
|
|
32
|
+
};
|
|
33
|
+
return ownKeys(o);
|
|
34
|
+
};
|
|
35
|
+
return function (mod) {
|
|
36
|
+
if (mod && mod.__esModule) return mod;
|
|
37
|
+
var result = {};
|
|
38
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
39
|
+
__setModuleDefault(result, mod);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.TicketCommand = void 0;
|
|
45
|
+
exports.parseTicketArgs = parseTicketArgs;
|
|
46
|
+
exports.changeIdFor = changeIdFor;
|
|
47
|
+
const node_child_process_1 = require("node:child_process");
|
|
48
|
+
const crypto = __importStar(require("node:crypto"));
|
|
49
|
+
const fs = __importStar(require("node:fs"));
|
|
50
|
+
const path = __importStar(require("node:path"));
|
|
51
|
+
const command_1 = require("../../../lib/command");
|
|
52
|
+
const ui = __importStar(require("../../../lib/ui"));
|
|
53
|
+
const agent_1 = require("../../../lib/agent");
|
|
54
|
+
const diagnostics_1 = require("../../../lib/diagnostics");
|
|
55
|
+
const project_root_1 = require("../../../lib/project-root");
|
|
56
|
+
const core_1 = require("../../../alm/core");
|
|
57
|
+
const clients_1 = require("../../../alm/clients");
|
|
58
|
+
const TicketText_1 = require("../../../../packages/core/src/domain/TicketText");
|
|
59
|
+
const DraftChecklist_1 = require("../../../../packages/core/src/domain/DraftChecklist");
|
|
60
|
+
const ChangeTemplates_1 = require("../../../../packages/core/src/domain/ChangeTemplates");
|
|
61
|
+
const DiskLanguageRepository_1 = require("../../../../packages/core/src/infrastructure/DiskLanguageRepository");
|
|
62
|
+
const ChangeWorkspace_1 = require("../../../../packages/core/src/infrastructure/ChangeWorkspace");
|
|
63
|
+
const DraftCommand_1 = require("../spec/DraftCommand");
|
|
64
|
+
const ticket_guide_1 = require("../../../draft/ticket-guide");
|
|
65
|
+
const confined_agent_1 = require("../../../draft/confined-agent");
|
|
66
|
+
const ticket_keys_1 = require("../../../lib/ticket-keys");
|
|
67
|
+
const CHANGES = "docs/specs/changes";
|
|
68
|
+
/** The placeholder `templateDelta` writes: a delta nobody has written yet. */
|
|
69
|
+
const PLACEHOLDER = "feature=features/<area>/<name>.feature";
|
|
70
|
+
const NULL_SHAPE = { ticket: null };
|
|
71
|
+
const JIRA_KEY = /^[A-Z][A-Z0-9_]+-\d+$/;
|
|
72
|
+
function usage() {
|
|
73
|
+
process.stdout.write([
|
|
74
|
+
"",
|
|
75
|
+
" specgate propose --from-ticket <KEY | issue.json> [options]",
|
|
76
|
+
"",
|
|
77
|
+
" A Jira ticket as the source text of a change (docs/from-a-ticket.md).",
|
|
78
|
+
"",
|
|
79
|
+
" --check Its Definition of Ready (T1–T9); writes nothing",
|
|
80
|
+
" --capability <name> The capability, when its epic is not under epics:",
|
|
81
|
+
" --agent <cmd> Have your agent draft it, confined to the change",
|
|
82
|
+
" --no-branch Do not create feature/<KEY>-<slug>",
|
|
83
|
+
" --no-push Create it, but do not push it to origin",
|
|
84
|
+
" --project-dir <path> --json",
|
|
85
|
+
"",
|
|
86
|
+
].join("\n") + "\n");
|
|
87
|
+
}
|
|
88
|
+
function parseTicketArgs(argv) {
|
|
89
|
+
const opts = {
|
|
90
|
+
ticket: "",
|
|
91
|
+
check: false,
|
|
92
|
+
json: false,
|
|
93
|
+
projectDir: ".",
|
|
94
|
+
capability: "",
|
|
95
|
+
branch: true,
|
|
96
|
+
push: true,
|
|
97
|
+
agent: "",
|
|
98
|
+
};
|
|
99
|
+
for (let i = 0; i < argv.length; i++) {
|
|
100
|
+
const a = argv[i];
|
|
101
|
+
if (a === "--from-ticket")
|
|
102
|
+
opts.ticket = argv[++i] ?? "";
|
|
103
|
+
else if (a === "--check")
|
|
104
|
+
opts.check = true;
|
|
105
|
+
else if (a === "--json")
|
|
106
|
+
opts.json = true;
|
|
107
|
+
else if (a === "--project-dir")
|
|
108
|
+
opts.projectDir = argv[++i] ?? ".";
|
|
109
|
+
else if (a === "--capability")
|
|
110
|
+
opts.capability = argv[++i] ?? "";
|
|
111
|
+
else if (a === "--no-branch")
|
|
112
|
+
opts.branch = false;
|
|
113
|
+
else if (a === "--no-push")
|
|
114
|
+
opts.push = false;
|
|
115
|
+
else if (a === "--agent")
|
|
116
|
+
opts.agent = argv[++i] ?? "";
|
|
117
|
+
}
|
|
118
|
+
return opts;
|
|
119
|
+
}
|
|
120
|
+
/** `alm.config.yaml`, when there is one: a file needs none, a key does. */
|
|
121
|
+
function almConfig(projectDir) {
|
|
122
|
+
return fs.existsSync(path.join(projectDir, "alm.config.yaml"))
|
|
123
|
+
? (0, core_1.readAlmConfig)(projectDir)
|
|
124
|
+
: undefined;
|
|
125
|
+
}
|
|
126
|
+
const looksLikeFile = (ref) => /\.json$/i.test(ref) || fs.existsSync(ref);
|
|
127
|
+
/** The ticket: an exported issue on disk, or the issue read from the board. */
|
|
128
|
+
async function loadTicket(ref, cfg, projectDir) {
|
|
129
|
+
const opts = {
|
|
130
|
+
acceptanceField: cfg?.acceptance_field ? String(cfg.acceptance_field) : undefined,
|
|
131
|
+
baseUrl: cfg?.base_url,
|
|
132
|
+
};
|
|
133
|
+
if (looksLikeFile(ref)) {
|
|
134
|
+
const file = path.resolve(ref);
|
|
135
|
+
return (0, TicketText_1.readTicket)(JSON.parse(fs.readFileSync(file, "utf8")), opts);
|
|
136
|
+
}
|
|
137
|
+
if (!cfg) {
|
|
138
|
+
throw new Error(`alm.config.yaml not found in ${projectDir}: reading ${ref} from the board needs it.\n` +
|
|
139
|
+
"Fix: add it (`specgate alm --help`), or export the issue as JSON and pass the file.");
|
|
140
|
+
}
|
|
141
|
+
const client = (0, clients_1.makeClient)(cfg, undefined, projectDir);
|
|
142
|
+
if (typeof client.readIssue !== "function") {
|
|
143
|
+
throw new Error(`Provider '${cfg.provider}' cannot read one issue.\n` +
|
|
144
|
+
"Fix: export the issue as JSON and pass the file to --from-ticket.");
|
|
145
|
+
}
|
|
146
|
+
return (0, TicketText_1.readTicket)(await client.readIssue(ref), opts);
|
|
147
|
+
}
|
|
148
|
+
function git(dir, args) {
|
|
149
|
+
return (0, node_child_process_1.spawnSync)("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
150
|
+
}
|
|
151
|
+
const isBug = (t) => /^(bug|defect|fehler|incidencia|error)$/i.test(t.type.trim());
|
|
152
|
+
/**
|
|
153
|
+
* `feature/KAN-13-register-vehicle-entry`, or `bugfix/…` for a bug: the key is
|
|
154
|
+
* what makes the Git host show the branch in the ticket. Only from a clean
|
|
155
|
+
* tree — carrying someone's uncommitted work onto a ticket's branch would mix
|
|
156
|
+
* it into that ticket.
|
|
157
|
+
*/
|
|
158
|
+
function ticketBranch(dir, t) {
|
|
159
|
+
if (git(dir, ["rev-parse", "--is-inside-work-tree"]).status !== 0) {
|
|
160
|
+
return { name: null, note: null };
|
|
161
|
+
}
|
|
162
|
+
const name = `${isBug(t) ? "bugfix" : "feature"}/${t.key}${changeIdFor(t).slice(t.key.length)}`;
|
|
163
|
+
if (git(dir, ["status", "--porcelain"]).stdout.trim()) {
|
|
164
|
+
return {
|
|
165
|
+
name: null,
|
|
166
|
+
note: `Branch not created: there are uncommitted changes. Commit them, then: git checkout -b ${name}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// From the base, not from wherever HEAD is: another ticket's branch would
|
|
170
|
+
// bring its commits into this ticket's pull request.
|
|
171
|
+
let base;
|
|
172
|
+
try {
|
|
173
|
+
base = (0, ticket_keys_1.baseFor)(dir);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return {
|
|
177
|
+
name: null,
|
|
178
|
+
note: `Branch ${name} not created: the base branch is not a safe git ref (check --base or SPECGATE_BASE).`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const made = git(dir, ["checkout", "-q", "-b", name, ...(base ? [base] : [])]);
|
|
182
|
+
if (made.status !== 0) {
|
|
183
|
+
return { name: null, note: `Branch ${name} not created: ${made.stderr.trim()}` };
|
|
184
|
+
}
|
|
185
|
+
return { name, note: null };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The branch on `origin` as soon as work starts: a Git host linked to Jira
|
|
189
|
+
* shows it in the ticket, and a Jira automation ("branch created → In
|
|
190
|
+
* Progress") moves the ticket — Specgate itself never writes to Jira. A failed
|
|
191
|
+
* push is said and leaves the change as it is.
|
|
192
|
+
*/
|
|
193
|
+
function pushBranch(dir, name) {
|
|
194
|
+
if (git(dir, ["remote", "get-url", "origin"]).status !== 0)
|
|
195
|
+
return null;
|
|
196
|
+
const pushed = git(dir, ["push", "-q", "-u", "origin", name]);
|
|
197
|
+
if (pushed.status === 0)
|
|
198
|
+
return { ok: true, note: `Branch ${name} pushed to origin` };
|
|
199
|
+
const why = pushed.stderr.trim().split("\n")[0] ?? "";
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
note: `Branch not pushed (${why}). Push it yourself: git push -u origin ${name}`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function readIf(file) {
|
|
206
|
+
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined;
|
|
207
|
+
}
|
|
208
|
+
/** `kan-13-register-vehicle-entry`: the key, then the summary's first words. */
|
|
209
|
+
function changeIdFor(t) {
|
|
210
|
+
const slug = t.summary
|
|
211
|
+
.normalize("NFKD")
|
|
212
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
213
|
+
.toLowerCase()
|
|
214
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
215
|
+
.trim()
|
|
216
|
+
.split(/\s+/)
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.slice(0, 6)
|
|
219
|
+
.join("-");
|
|
220
|
+
return [t.key.toLowerCase(), slug].filter(Boolean).join("-");
|
|
221
|
+
}
|
|
222
|
+
/** The capability the ticket's epic maps to, under `epics:` in `alm.config.yaml`. */
|
|
223
|
+
function capabilityOf(t, cfg) {
|
|
224
|
+
const epics = cfg?.epics;
|
|
225
|
+
if (!t.parent || !epics || typeof epics !== "object")
|
|
226
|
+
return "";
|
|
227
|
+
const mapped = epics[t.parent];
|
|
228
|
+
return typeof mapped === "string" ? mapped.trim() : "";
|
|
229
|
+
}
|
|
230
|
+
const sha256 = (text) => crypto.createHash("sha256").update(text, "utf8").digest("hex");
|
|
231
|
+
/** `ticket.md`: what was read, unedited, and when. */
|
|
232
|
+
function ticketCopy(t) {
|
|
233
|
+
const meta = [
|
|
234
|
+
`key=${t.key}`,
|
|
235
|
+
`type=${t.type.replace(/\s+/g, "_") || "issue"}`,
|
|
236
|
+
...(t.parent ? [`parent=${t.parent}`] : []),
|
|
237
|
+
...(t.url ? [`url=${t.url}`] : []),
|
|
238
|
+
`read=${new Date().toISOString()}`,
|
|
239
|
+
].join(" ");
|
|
240
|
+
return [
|
|
241
|
+
`# ${t.key} — ${t.summary}`,
|
|
242
|
+
"",
|
|
243
|
+
`<!-- specgate:ticket ${meta} -->`,
|
|
244
|
+
"",
|
|
245
|
+
...(t.labels.length ? [`Labels: ${t.labels.join(", ")}`, ""] : []),
|
|
246
|
+
t.description,
|
|
247
|
+
"",
|
|
248
|
+
...(t.criteria.length && !/acceptance|akzeptanz|aceptaci/i.test(t.description)
|
|
249
|
+
? ["Acceptance Criteria", "", ...t.criteria.map((c) => `- ${c}`), ""]
|
|
250
|
+
: []),
|
|
251
|
+
].join("\n");
|
|
252
|
+
}
|
|
253
|
+
function proposalFor(t, changeId, capability) {
|
|
254
|
+
return [
|
|
255
|
+
`# Proposal: ${changeId}`,
|
|
256
|
+
"",
|
|
257
|
+
`ticket: ${t.key}`,
|
|
258
|
+
...(t.url ? [`link: ${t.url}`] : []),
|
|
259
|
+
...(t.source ? [`source: ${t.source}`] : []),
|
|
260
|
+
"",
|
|
261
|
+
"## Intent",
|
|
262
|
+
"",
|
|
263
|
+
...(t.intent ? t.intent.split("\n").map((l) => `> ${l}`) : [`> ${t.summary}`]),
|
|
264
|
+
"",
|
|
265
|
+
"## Scope",
|
|
266
|
+
"",
|
|
267
|
+
`In scope: the acceptance criteria of ${t.key}, in the \`${capability}\` capability.`,
|
|
268
|
+
"",
|
|
269
|
+
`Out of scope: anything ${t.key} does not state.`,
|
|
270
|
+
"",
|
|
271
|
+
].join("\n");
|
|
272
|
+
}
|
|
273
|
+
function questionsFor(t) {
|
|
274
|
+
if (t.questions.length === 0)
|
|
275
|
+
return undefined;
|
|
276
|
+
const cell = (s) => s.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
|
|
277
|
+
return [
|
|
278
|
+
"# Questions",
|
|
279
|
+
"",
|
|
280
|
+
`From ${t.key}'s open questions. The answer is the analyst's assumption until`,
|
|
281
|
+
"someone confirms it.",
|
|
282
|
+
"",
|
|
283
|
+
"| # | Question | Blocks | Answer |",
|
|
284
|
+
"|---|---|---|---|",
|
|
285
|
+
...t.questions.map((q, i) => `| Q${i + 1} | ${cell(q.text)} | | ${q.assumption ? `Assumption: ${cell(q.assumption)}` : ""} |`),
|
|
286
|
+
"",
|
|
287
|
+
].join("\n");
|
|
288
|
+
}
|
|
289
|
+
/** Write the change: the copy, the proposal, the questions, the guide and the delta. */
|
|
290
|
+
function openChange(projectDir, t, changeId, capability) {
|
|
291
|
+
const final = path.join(projectDir, CHANGES, changeId);
|
|
292
|
+
// Written aside and moved in one rename: a change folder is either whole
|
|
293
|
+
// or absent, so an interrupted run never leaves one that looks opened.
|
|
294
|
+
const dir = path.join(projectDir, CHANGES, `.${changeId}.opening`);
|
|
295
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
296
|
+
try {
|
|
297
|
+
const reqRange = (0, ChangeWorkspace_1.reserveReqRange)(projectDir, Math.min(10, Math.max(3, t.criteria.length)));
|
|
298
|
+
const scnRange = (0, DraftCommand_1.reserveScenarioRange)(projectDir, reqRange);
|
|
299
|
+
const copyPath = `${CHANGES}/${changeId}/ticket.md`;
|
|
300
|
+
const copy = ticketCopy(t);
|
|
301
|
+
const created = [];
|
|
302
|
+
const write = (rel, contents) => {
|
|
303
|
+
const full = path.join(dir, rel);
|
|
304
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
305
|
+
fs.writeFileSync(full, contents, "utf8");
|
|
306
|
+
created.push(`${CHANGES}/${changeId}/${rel}`);
|
|
307
|
+
};
|
|
308
|
+
const values = [...new Set(t.criteria.flatMap((c) => (0, DraftChecklist_1.statedValues)(c)))];
|
|
309
|
+
write("ticket.md", copy);
|
|
310
|
+
write("proposal.md", proposalFor(t, changeId, capability));
|
|
311
|
+
const questions = questionsFor(t);
|
|
312
|
+
if (questions)
|
|
313
|
+
write("questions.md", questions);
|
|
314
|
+
write("DRAFTING.md", (0, ticket_guide_1.ticketGuide)({
|
|
315
|
+
ticket: t,
|
|
316
|
+
capability,
|
|
317
|
+
changeId,
|
|
318
|
+
copy: copyPath,
|
|
319
|
+
reqRange,
|
|
320
|
+
firstScenario: scnRange[0],
|
|
321
|
+
lastScenario: scnRange[1],
|
|
322
|
+
values,
|
|
323
|
+
}));
|
|
324
|
+
write(path.join("specs", capability, "spec.md"), (0, ChangeTemplates_1.templateDelta)(capability, reqRange[0], (0, DiskLanguageRepository_1.phrases)(projectDir)));
|
|
325
|
+
const config = {
|
|
326
|
+
...ChangeWorkspace_1.DEFAULT_CONFIG,
|
|
327
|
+
created: new Date().toISOString().slice(0, 10),
|
|
328
|
+
req_range: reqRange,
|
|
329
|
+
};
|
|
330
|
+
write("change.yaml", `${(0, ChangeWorkspace_1.renderConfig)(config).trimEnd()}\nscn_range: [${scnRange[0]}, ${scnRange[1]}]\nbrief: ${copyPath}\nticket: ${t.key}\nticket_sha256: ${sha256(copy)}\n`);
|
|
331
|
+
fs.renameSync(dir, final);
|
|
332
|
+
return { reqRange, created };
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
336
|
+
throw e;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** The copy without the line that records when it was read. */
|
|
340
|
+
const comparable = (copy) => copy
|
|
341
|
+
.split("\n")
|
|
342
|
+
.filter((l) => !l.startsWith("<!-- specgate:ticket "))
|
|
343
|
+
.map((l) => l.trimEnd());
|
|
344
|
+
/**
|
|
345
|
+
* `ticket_changed`: the ticket on the board is not the one the change was
|
|
346
|
+
* drafted from. The copy stays the record of what was agreed; the person
|
|
347
|
+
* decides whether the change follows.
|
|
348
|
+
*/
|
|
349
|
+
function changedSince(projectDirOption, t) {
|
|
350
|
+
let projectDir;
|
|
351
|
+
try {
|
|
352
|
+
projectDir = (0, project_root_1.resolveProjectDir)(projectDirOption);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
const copy = readIf(path.join(projectDir, CHANGES, changeIdFor(t), "ticket.md"));
|
|
358
|
+
if (copy === undefined)
|
|
359
|
+
return [];
|
|
360
|
+
const before = comparable(copy);
|
|
361
|
+
const now = comparable(ticketCopy(t));
|
|
362
|
+
const gone = before.filter((l) => l.trim() && !now.includes(l));
|
|
363
|
+
const added = now.filter((l) => l.trim() && !before.includes(l));
|
|
364
|
+
if (gone.length === 0 && added.length === 0)
|
|
365
|
+
return [];
|
|
366
|
+
return [
|
|
367
|
+
(0, diagnostics_1.warning)("ticket_changed", `${t.key} changed since ${changeIdFor(t)} was opened:\n` +
|
|
368
|
+
[...gone.map((l) => ` - ${l}`), ...added.map((l) => ` + ${l}`)].join("\n"), {
|
|
369
|
+
target: t.key,
|
|
370
|
+
fix: "Decide with the analyst whether the change follows; if it does, update the delta and ticket.md together, in one commit naming the ticket.",
|
|
371
|
+
}),
|
|
372
|
+
];
|
|
373
|
+
}
|
|
374
|
+
/** `ticket_copy_edited` when `ticket.md` no longer matches the hash taken when it was read. */
|
|
375
|
+
function copyIntegrity(dir) {
|
|
376
|
+
const yaml = readIf(path.join(dir, "change.yaml")) ?? "";
|
|
377
|
+
const recorded = /^ticket_sha256:\s*([0-9a-f]{64})\s*$/m.exec(yaml)?.[1];
|
|
378
|
+
const copy = readIf(path.join(dir, "ticket.md"));
|
|
379
|
+
if (!recorded)
|
|
380
|
+
return [];
|
|
381
|
+
if (copy !== undefined && sha256(copy) === recorded)
|
|
382
|
+
return [];
|
|
383
|
+
return [
|
|
384
|
+
(0, diagnostics_1.error)("ticket_copy_edited", "ticket.md is not the ticket as it was read: the draft cannot be checked against it.", {
|
|
385
|
+
file: "ticket.md",
|
|
386
|
+
fix: "Restore it (git checkout -- ticket.md). If the ticket changed in Jira, run --check to see the difference.",
|
|
387
|
+
}),
|
|
388
|
+
];
|
|
389
|
+
}
|
|
390
|
+
function listDeltas(dir) {
|
|
391
|
+
const specs = path.join(dir, "specs");
|
|
392
|
+
if (!fs.existsSync(specs))
|
|
393
|
+
return [];
|
|
394
|
+
return fs
|
|
395
|
+
.readdirSync(specs)
|
|
396
|
+
.map((cap) => path.join(specs, cap, "spec.md"))
|
|
397
|
+
.filter((f) => fs.existsSync(f))
|
|
398
|
+
.map((f) => fs.readFileSync(f, "utf8"));
|
|
399
|
+
}
|
|
400
|
+
function ticketJson(t) {
|
|
401
|
+
return {
|
|
402
|
+
key: t.key,
|
|
403
|
+
summary: t.summary,
|
|
404
|
+
type: t.type,
|
|
405
|
+
parent: t.parent,
|
|
406
|
+
labels: t.labels,
|
|
407
|
+
url: t.url,
|
|
408
|
+
intent: t.intent,
|
|
409
|
+
source: t.source,
|
|
410
|
+
criteria: t.criteria,
|
|
411
|
+
questions: t.questions,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
class TicketCommand extends command_1.BaseCommand {
|
|
415
|
+
async execute() {
|
|
416
|
+
if (this.args.includes("--help") || this.args.includes("-h"))
|
|
417
|
+
return usage();
|
|
418
|
+
const opts = parseTicketArgs(this.args);
|
|
419
|
+
const io = (0, agent_1.agentIo)(opts.json);
|
|
420
|
+
if (!opts.ticket) {
|
|
421
|
+
io.usage(NULL_SHAPE, [
|
|
422
|
+
(0, diagnostics_1.error)("ticket_missing", "--from-ticket needs a ticket key or an exported issue.", {
|
|
423
|
+
fix: "specgate propose --from-ticket KAN-13 --check, or --from-ticket KAN-13.json",
|
|
424
|
+
}),
|
|
425
|
+
]);
|
|
426
|
+
}
|
|
427
|
+
let projectDir;
|
|
428
|
+
let ticket;
|
|
429
|
+
try {
|
|
430
|
+
projectDir = (0, project_root_1.resolveProjectDir)(opts.projectDir);
|
|
431
|
+
ticket = await loadTicket(opts.ticket, almConfig(projectDir), projectDir);
|
|
432
|
+
}
|
|
433
|
+
catch (e) {
|
|
434
|
+
return io.fail(NULL_SHAPE, [(0, diagnostics_1.error)("ticket_unreadable", e.message)]);
|
|
435
|
+
}
|
|
436
|
+
// The key names the change, the branch and its folder: one that is not a
|
|
437
|
+
// Jira key — an exported file can say anything — would put them anywhere.
|
|
438
|
+
if (!JIRA_KEY.test(ticket.key)) {
|
|
439
|
+
return io.fail(NULL_SHAPE, [
|
|
440
|
+
(0, diagnostics_1.error)("ticket_bad_key", `${JSON.stringify(ticket.key)} is not a Jira issue key.`, {
|
|
441
|
+
fix: 'An issue key is a project key and a number: "KAN-13". Export the issue from Jira again.',
|
|
442
|
+
}),
|
|
443
|
+
]);
|
|
444
|
+
}
|
|
445
|
+
if (opts.check)
|
|
446
|
+
return this.check(opts, ticket);
|
|
447
|
+
return this.open(opts, projectDir, ticket);
|
|
448
|
+
}
|
|
449
|
+
/** Open the ticket's change, or report on the one already open. */
|
|
450
|
+
open(opts, projectDir, ticket) {
|
|
451
|
+
const io = (0, agent_1.agentIo)(opts.json);
|
|
452
|
+
const changeId = changeIdFor(ticket);
|
|
453
|
+
const dir = path.join(projectDir, CHANGES, changeId);
|
|
454
|
+
if (fs.existsSync(dir)) {
|
|
455
|
+
if (opts.agent)
|
|
456
|
+
return this.withAgent(opts, projectDir, ticket, changeId);
|
|
457
|
+
return this.report(opts, projectDir, ticket, changeId);
|
|
458
|
+
}
|
|
459
|
+
const findings = (0, TicketText_1.checkTicket)(ticket);
|
|
460
|
+
if (!(0, TicketText_1.ticketReady)(findings)) {
|
|
461
|
+
io.fail(NULL_SHAPE, [
|
|
462
|
+
(0, diagnostics_1.error)("ticket_not_ready", `${ticket.key} is not ready to draft.`, {
|
|
463
|
+
fix: `Fix what \`specgate propose --from-ticket ${opts.ticket} --check\` reports, in Jira.`,
|
|
464
|
+
}),
|
|
465
|
+
...findings.filter((d) => d.severity === "error"),
|
|
466
|
+
]);
|
|
467
|
+
}
|
|
468
|
+
if (!fs.existsSync(path.join(projectDir, "spec.md"))) {
|
|
469
|
+
io.fail(NULL_SHAPE, [
|
|
470
|
+
(0, diagnostics_1.error)("ticket_no_project", "This folder is not a Specgate project yet.", {
|
|
471
|
+
fix: "Run `specgate init` (or `specgate adopt`) first.",
|
|
472
|
+
}),
|
|
473
|
+
]);
|
|
474
|
+
}
|
|
475
|
+
const capability = opts.capability || capabilityOf(ticket, almConfig(projectDir));
|
|
476
|
+
if (!capability) {
|
|
477
|
+
io.fail(NULL_SHAPE, [
|
|
478
|
+
(0, diagnostics_1.error)("ticket_no_capability", `No capability for ${ticket.key}: ${ticket.parent ? `its epic ${ticket.parent} is not mapped` : "it has no epic"}.`, {
|
|
479
|
+
fix: `Pass --capability <name>, or map the epic in alm.config.yaml:\n` +
|
|
480
|
+
`epics:\n ${ticket.parent ?? "KAN-5"}: <capability>`,
|
|
481
|
+
}),
|
|
482
|
+
]);
|
|
483
|
+
}
|
|
484
|
+
const branch = opts.branch ? ticketBranch(projectDir, ticket) : { name: null, note: null };
|
|
485
|
+
const opened = openChange(projectDir, ticket, changeId, capability);
|
|
486
|
+
const pushed = branch.name && opts.push ? pushBranch(projectDir, branch.name) : null;
|
|
487
|
+
if (opts.agent)
|
|
488
|
+
return this.withAgent(opts, projectDir, ticket, changeId, opened.created);
|
|
489
|
+
io.emit({
|
|
490
|
+
ticket: ticketJson(ticket),
|
|
491
|
+
change: changeId,
|
|
492
|
+
capability,
|
|
493
|
+
reserved: opened.reqRange,
|
|
494
|
+
created: opened.created,
|
|
495
|
+
branch: branch.name,
|
|
496
|
+
pushed: pushed?.ok ?? false,
|
|
497
|
+
next: `specgate propose --from-ticket ${opts.ticket}`,
|
|
498
|
+
status: findings,
|
|
499
|
+
}, () => {
|
|
500
|
+
const out = process.stdout;
|
|
501
|
+
out.write(ui.header("propose --from-ticket", ticket.key));
|
|
502
|
+
out.write(ui.ok(`Change ${ui.bold(changeId)} opened for ${ticket.key} ` +
|
|
503
|
+
ui.muted(`· ${capability} · REQ ids ${opened.reqRange[0]}…${opened.reqRange[1]} reserved`)) + "\n");
|
|
504
|
+
for (const f of opened.created)
|
|
505
|
+
out.write(ui.fileLine("added", f, ""));
|
|
506
|
+
if (branch.name)
|
|
507
|
+
out.write("\n" + ui.ok(`On branch ${ui.bold(branch.name)}`));
|
|
508
|
+
if (branch.note)
|
|
509
|
+
out.write("\n" + ui.info(branch.note));
|
|
510
|
+
if (pushed)
|
|
511
|
+
out.write(pushed.ok ? ui.ok(pushed.note) : ui.warn(pushed.note));
|
|
512
|
+
out.write("\n" +
|
|
513
|
+
ui.section("Have your agent write it") +
|
|
514
|
+
" Paste this into Claude Code, Cursor, Codex…\n\n");
|
|
515
|
+
out.write(ui.quote(`Draft ${ticket.key}: follow ${CHANGES}/${changeId}/DRAFTING.md`));
|
|
516
|
+
out.write(ui.next(`specgate propose --from-ticket ${opts.ticket} --agent "claude -p"`, `or let Specgate call it — then run specgate propose --from-ticket ${opts.ticket} until it says Ready to review`));
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/** Hand the guide to the agent; it may write the change, never the ticket's copy. */
|
|
520
|
+
withAgent(opts, projectDir, ticket, changeId, created = []) {
|
|
521
|
+
const io = (0, agent_1.agentIo)(opts.json);
|
|
522
|
+
const guide = readIf(path.join(projectDir, CHANGES, changeId, "DRAFTING.md")) ?? "";
|
|
523
|
+
const agent = (0, confined_agent_1.withWritePermission)(opts.agent);
|
|
524
|
+
const copy = `${CHANGES}/${changeId}/ticket.md`;
|
|
525
|
+
if (!opts.json) {
|
|
526
|
+
process.stdout.write(ui.header("propose --from-ticket", ticket.key) +
|
|
527
|
+
(created.length ? ui.ok(`Change ${changeId} opened.`) : "") +
|
|
528
|
+
ui.logLine(`${agent.command} is drafting ${ticket.key}`));
|
|
529
|
+
}
|
|
530
|
+
const run = (0, confined_agent_1.runConfinedAgent)({
|
|
531
|
+
projectDir,
|
|
532
|
+
changeId,
|
|
533
|
+
allowed: (rel) => rel.startsWith(`${CHANGES}/${changeId}/`) && rel !== copy,
|
|
534
|
+
command: agent.command,
|
|
535
|
+
// A second run fixes what the checklist found, as on the brief path:
|
|
536
|
+
// without the findings, the agent read a finished draft and changed nothing.
|
|
537
|
+
prompt: (0, ticket_guide_1.ticketPrompt)(ticket, changeId, guide) + (0, DraftCommand_1.openFindings)(projectDir, changeId, copy),
|
|
538
|
+
showOutput: !opts.json,
|
|
539
|
+
});
|
|
540
|
+
if (run.problem === "no_git") {
|
|
541
|
+
io.fail(NULL_SHAPE, [
|
|
542
|
+
(0, diagnostics_1.error)("draft_agent_needs_git", "--agent needs a git repository.", {
|
|
543
|
+
fix: "Run `git init`, or paste the prompt into your agent yourself.",
|
|
544
|
+
}),
|
|
545
|
+
]);
|
|
546
|
+
}
|
|
547
|
+
if (run.reverted.length > 0 && !opts.json) {
|
|
548
|
+
process.stdout.write("\n" + ui.warn(`Undone — the agent wrote outside the draft: ${run.reverted.join(", ")}`));
|
|
549
|
+
}
|
|
550
|
+
if (!run.ok) {
|
|
551
|
+
io.fail(NULL_SHAPE, [
|
|
552
|
+
(0, diagnostics_1.error)("draft_agent_failed", `The agent ${run.detail}.`, {
|
|
553
|
+
fix: `Check the command runs on its own, or paste this into your agent: Draft ${ticket.key}: follow ${CHANGES}/${changeId}/DRAFTING.md`,
|
|
554
|
+
}),
|
|
555
|
+
]);
|
|
556
|
+
}
|
|
557
|
+
if (!opts.json)
|
|
558
|
+
process.stdout.write(ui.rule());
|
|
559
|
+
this.report(opts, projectDir, ticket, changeId, true);
|
|
560
|
+
}
|
|
561
|
+
/** The checklist on the ticket's change, the copy's integrity first. */
|
|
562
|
+
report(opts, projectDir, ticket, changeId, afterAgent = false) {
|
|
563
|
+
const io = (0, agent_1.agentIo)(opts.json);
|
|
564
|
+
const dir = path.join(projectDir, CHANGES, changeId);
|
|
565
|
+
const integrity = copyIntegrity(dir);
|
|
566
|
+
const deltas = listDeltas(dir);
|
|
567
|
+
const notWritten = deltas.length > 0 && deltas.every((d) => d.includes(PLACEHOLDER));
|
|
568
|
+
const result = notWritten ? undefined : (0, DraftCommand_1.draftReport)(projectDir, changeId);
|
|
569
|
+
if (result && "failure" in result) {
|
|
570
|
+
io.fail(NULL_SHAPE, [result.failure]);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const report = result?.report;
|
|
574
|
+
const status = [...integrity, ...(report?.status ?? [])];
|
|
575
|
+
const errors = status.filter((d) => d.severity === "error").length;
|
|
576
|
+
const ready = !notWritten && errors === 0;
|
|
577
|
+
const next = ready ? "specgate apply" : `specgate propose --from-ticket ${opts.ticket}`;
|
|
578
|
+
io.emit({
|
|
579
|
+
ticket: ticketJson(ticket),
|
|
580
|
+
change: changeId,
|
|
581
|
+
state: notWritten ? "not written" : ready ? "ready" : "drafting",
|
|
582
|
+
requirements: report?.requirements ?? 0,
|
|
583
|
+
scenarios: report?.scenarios ?? 0,
|
|
584
|
+
next,
|
|
585
|
+
status,
|
|
586
|
+
}, () => {
|
|
587
|
+
const out = process.stdout;
|
|
588
|
+
out.write(afterAgent ? "\n" : ui.header("propose --from-ticket", ticket.key));
|
|
589
|
+
if (report) {
|
|
590
|
+
out.write(` ${ui.bold(String(report.requirements))} requirement(s) · ${ui.bold(String(report.scenarios))} scenario(s)\n\n`);
|
|
591
|
+
}
|
|
592
|
+
writeFindings(status);
|
|
593
|
+
if (notWritten) {
|
|
594
|
+
out.write((status.length ? "\n" : "") +
|
|
595
|
+
" The draft is still the placeholder. Have your agent write it:\n\n" +
|
|
596
|
+
ui.quote(`Draft ${ticket.key}: follow ${CHANGES}/${changeId}/DRAFTING.md`) +
|
|
597
|
+
ui.next(`specgate propose --from-ticket ${opts.ticket} --agent "claude -p"`, "or let Specgate call it"));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
out.write("\n" +
|
|
601
|
+
(ready
|
|
602
|
+
? ui.ok(ui.bold("Ready to review"))
|
|
603
|
+
: ui.fail(`${ui.bold(`${errors} finding(s) to fix`)} ${ui.muted("— each says how; DRAFTING.md has the formats")}`)));
|
|
604
|
+
out.write(ui.next(next, ready
|
|
605
|
+
? `once a person has reviewed ${CHANGES}/${changeId}/ — its requirements, one at a time`
|
|
606
|
+
: "again, until it says Ready to review"));
|
|
607
|
+
});
|
|
608
|
+
process.exitCode = ready ? 0 : 1;
|
|
609
|
+
}
|
|
610
|
+
/** `--check`: the ticket's parts, and what keeps it from being drafted. */
|
|
611
|
+
check(opts, ticket) {
|
|
612
|
+
const io = (0, agent_1.agentIo)(opts.json);
|
|
613
|
+
const findings = [...(0, TicketText_1.checkTicket)(ticket), ...changedSince(opts.projectDir, ticket)];
|
|
614
|
+
const errors = findings.filter((d) => d.severity === "error").length;
|
|
615
|
+
const warnings = findings.filter((d) => d.severity === "warning").length;
|
|
616
|
+
const ready = (0, TicketText_1.ticketReady)(findings);
|
|
617
|
+
const epic = findings.some((d) => d.code === "ticket_epic");
|
|
618
|
+
const next = ready
|
|
619
|
+
? `specgate propose --from-ticket ${opts.ticket}`
|
|
620
|
+
: epic
|
|
621
|
+
? "specgate propose --from-ticket <story key> --check"
|
|
622
|
+
: `specgate propose --from-ticket ${opts.ticket} --check`;
|
|
623
|
+
io.emit({ ticket: ticketJson(ticket), ready, errors, warnings, next, status: findings }, () => {
|
|
624
|
+
const out = process.stdout;
|
|
625
|
+
out.write(ui.header("propose --from-ticket --check", ticket.key));
|
|
626
|
+
out.write(ui.ok(`${ui.bold(ticket.key)} ${ticket.summary} ` +
|
|
627
|
+
ui.muted(`· ${ticket.type || "issue"} · ${ticket.criteria.length} criteria · ` +
|
|
628
|
+
`${ticket.questions.length} open question(s)`)) + (findings.length ? "\n" : ""));
|
|
629
|
+
writeFindings(findings);
|
|
630
|
+
out.write("\n" +
|
|
631
|
+
(ready
|
|
632
|
+
? ui.ok(ui.bold("Ready to draft") +
|
|
633
|
+
(warnings ? ui.muted(` — ${warnings} warning(s) for you to judge`) : ""))
|
|
634
|
+
: ui.fail(epic
|
|
635
|
+
? ui.bold("An epic is not drafted — its stories are")
|
|
636
|
+
: `${ui.bold(`${errors} error(s) to fix in Jira before drafting`)} ${ui.muted("— each says how")}`)));
|
|
637
|
+
out.write(ui.next(next, ready ? "open the change" : epic ? "one of its stories" : "once the ticket is fixed"));
|
|
638
|
+
});
|
|
639
|
+
process.exitCode = ready ? 0 : 1;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
exports.TicketCommand = TicketCommand;
|
|
643
|
+
function writeFindings(findings) {
|
|
644
|
+
const out = process.stdout;
|
|
645
|
+
for (const d of findings) {
|
|
646
|
+
const where = d.target ? `${ui.bold(d.target)} ` : "";
|
|
647
|
+
const line = `${ui.muted(d.code)} ${where}${d.message}`;
|
|
648
|
+
out.write(d.severity === "error"
|
|
649
|
+
? ui.fail(line)
|
|
650
|
+
: d.severity === "warning"
|
|
651
|
+
? ui.warn(line)
|
|
652
|
+
: ui.info(line));
|
|
653
|
+
if (d.fix)
|
|
654
|
+
out.write(ui.fix(d.fix));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
//# sourceMappingURL=TicketCommand.js.map
|