specrails-desktop 2.24.1 → 2.24.2
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/README.md +5 -4
- package/docs/guide/de/integrations/3-jira-integration.md +3 -2
- package/docs/guide/de/pipeline/1-rails-and-jobs.md +5 -5
- package/docs/guide/de/pipeline/3-batch-implement-and-multi-feature.md +6 -4
- package/docs/guide/en/integrations/3-jira-integration.md +4 -3
- package/docs/guide/en/pipeline/1-rails-and-jobs.md +5 -5
- package/docs/guide/en/pipeline/3-batch-implement-and-multi-feature.md +6 -4
- package/docs/guide/es/integrations/3-jira-integration.md +3 -2
- package/docs/guide/es/pipeline/1-rails-and-jobs.md +5 -5
- package/docs/guide/es/pipeline/3-batch-implement-and-multi-feature.md +6 -4
- package/docs/guide/fr/integrations/3-jira-integration.md +3 -2
- package/docs/guide/fr/pipeline/1-rails-and-jobs.md +5 -5
- package/docs/guide/fr/pipeline/3-batch-implement-and-multi-feature.md +6 -4
- package/docs/guide/it/integrations/3-jira-integration.md +3 -2
- package/docs/guide/it/pipeline/1-rails-and-jobs.md +5 -5
- package/docs/guide/it/pipeline/3-batch-implement-and-multi-feature.md +6 -4
- package/docs/guide/ja/integrations/3-jira-integration.md +3 -2
- package/docs/guide/ja/pipeline/1-rails-and-jobs.md +5 -5
- package/docs/guide/ja/pipeline/3-batch-implement-and-multi-feature.md +6 -4
- package/docs/guide/pt/integrations/3-jira-integration.md +3 -2
- package/docs/guide/pt/pipeline/1-rails-and-jobs.md +5 -5
- package/docs/guide/pt/pipeline/3-batch-implement-and-multi-feature.md +6 -4
- package/docs/guide/zh/integrations/3-jira-integration.md +3 -2
- package/docs/guide/zh/pipeline/1-rails-and-jobs.md +5 -5
- package/docs/guide/zh/pipeline/3-batch-implement-and-multi-feature.md +6 -4
- package/package.json +1 -1
- package/server/dist/active-pr-continuation.js +171 -0
- package/server/dist/agent-operator-prompt.js +9 -0
- package/server/dist/docs-router.js +75 -10
- package/server/dist/jira/jira-adf.js +8 -0
- package/server/dist/jira/jira-sync-manager.js +50 -11
- package/server/dist/mcp/guide.js +9 -0
- package/server/dist/mcp/tools/rails.js +2 -1
- package/server/dist/rail-isolated-launch.js +82 -7
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveActivePrContinuationTargets = resolveActivePrContinuationTargets;
|
|
4
|
+
const jira_db_1 = require("./jira/jira-db");
|
|
5
|
+
const rail_pr_store_1 = require("./rail-pr-store");
|
|
6
|
+
const integration_branch_1 = require("./integration-branch");
|
|
7
|
+
function parsePrNumber(prUrl) {
|
|
8
|
+
if (!prUrl)
|
|
9
|
+
return null;
|
|
10
|
+
const m = /\/pull\/(\d+)/.exec(prUrl);
|
|
11
|
+
return m ? parseInt(m[1], 10) : null;
|
|
12
|
+
}
|
|
13
|
+
function normalizedText(v) {
|
|
14
|
+
return typeof v === 'string' ? v.toLowerCase() : '';
|
|
15
|
+
}
|
|
16
|
+
function words(v) {
|
|
17
|
+
return normalizedText(v)
|
|
18
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
19
|
+
.split(/\s+/)
|
|
20
|
+
.filter((w) => w.length >= 4);
|
|
21
|
+
}
|
|
22
|
+
function ticketRefs(db, ticketId, spec) {
|
|
23
|
+
const refs = new Set([`#${ticketId}`, `[${ticketId}]`, `ticket ${ticketId}`, `spec ${ticketId}`]);
|
|
24
|
+
let jiraKey = spec?.jira_key ?? null;
|
|
25
|
+
try {
|
|
26
|
+
const link = (0, jira_db_1.getLinkByLocalId)(db, ticketId);
|
|
27
|
+
if (link && !link.tombstoned && link.jiraKey)
|
|
28
|
+
jiraKey = link.jiraKey;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* tolerated */
|
|
32
|
+
}
|
|
33
|
+
if (jiraKey?.trim())
|
|
34
|
+
refs.add(jiraKey.trim());
|
|
35
|
+
return [...refs];
|
|
36
|
+
}
|
|
37
|
+
function hasJiraRef(db, ticketId, spec) {
|
|
38
|
+
if (spec?.jira_key?.trim())
|
|
39
|
+
return true;
|
|
40
|
+
try {
|
|
41
|
+
const link = (0, jira_db_1.getLinkByLocalId)(db, ticketId);
|
|
42
|
+
return Boolean(link && !link.tombstoned && link.jiraKey);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function prMatchKind(db, ticketId, spec, pr) {
|
|
49
|
+
const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`.toLowerCase();
|
|
50
|
+
for (const ref of ticketRefs(db, ticketId, spec)) {
|
|
51
|
+
const needle = ref.toLowerCase();
|
|
52
|
+
if (needle.startsWith('#') || needle.startsWith('[')) {
|
|
53
|
+
if (haystack.includes(needle))
|
|
54
|
+
return 'explicit-ref';
|
|
55
|
+
}
|
|
56
|
+
else if (new RegExp(`(^|[^a-z0-9])${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i').test(haystack)) {
|
|
57
|
+
return 'explicit-ref';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const titleWords = words(spec?.title);
|
|
61
|
+
if (titleWords.length < 3)
|
|
62
|
+
return null;
|
|
63
|
+
const prWords = new Set(words(`${pr.title ?? ''} ${pr.body ?? ''}`));
|
|
64
|
+
const hits = titleWords.filter((w) => prWords.has(w)).length;
|
|
65
|
+
return hits >= Math.max(3, Math.ceil(titleWords.length * 0.7)) ? 'title-similarity' : null;
|
|
66
|
+
}
|
|
67
|
+
function canProbeGithubForContinuation(db, ticketId, spec) {
|
|
68
|
+
if (spec?.status === 'on_review')
|
|
69
|
+
return true;
|
|
70
|
+
// Jira review can materialize as in_progress when the project has not mapped
|
|
71
|
+
// its exact Review status to Specrails on_review. In that fallback, require a
|
|
72
|
+
// Jira-linked ticket and later only accept explicit PR references.
|
|
73
|
+
return spec?.status === 'in_progress' && hasJiraRef(db, ticketId, spec);
|
|
74
|
+
}
|
|
75
|
+
function matchAllowedForTicket(spec, kind) {
|
|
76
|
+
if (spec?.status === 'on_review')
|
|
77
|
+
return true;
|
|
78
|
+
return kind === 'explicit-ref';
|
|
79
|
+
}
|
|
80
|
+
async function localBranchExists(git, repoDir, branch) {
|
|
81
|
+
const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoDir);
|
|
82
|
+
return r.code === 0;
|
|
83
|
+
}
|
|
84
|
+
async function remoteBranchExists(git, repoDir, branch) {
|
|
85
|
+
const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], repoDir);
|
|
86
|
+
return r.code === 0;
|
|
87
|
+
}
|
|
88
|
+
async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBranch, prUrl, prNumber, isDraft, source) {
|
|
89
|
+
const head = branch?.trim();
|
|
90
|
+
const base = baseBranch?.trim();
|
|
91
|
+
if (!head || !base || head === base)
|
|
92
|
+
return null;
|
|
93
|
+
if (!(0, integration_branch_1.isValidBranchName)(head) || !(0, integration_branch_1.isValidBranchName)(base))
|
|
94
|
+
return null;
|
|
95
|
+
if (await localBranchExists(git, repoDir, head)) {
|
|
96
|
+
return { ticketId, branch: head, baseBranch: base, prUrl, prNumber, isDraft, source };
|
|
97
|
+
}
|
|
98
|
+
if (fetchOk && await remoteBranchExists(git, repoDir, head)) {
|
|
99
|
+
return { ticketId, branch: head, baseBranch: base, baseRef: `origin/${head}`, prUrl, prNumber, isDraft, source };
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function internalDeliveryTargets(db, ticketIds) {
|
|
104
|
+
const wanted = new Set(ticketIds);
|
|
105
|
+
const out = new Map();
|
|
106
|
+
for (const row of (0, rail_pr_store_1.listActivePrDeliveries)(db)) {
|
|
107
|
+
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
108
|
+
if (!snap.prUrl || !snap.branch)
|
|
109
|
+
continue;
|
|
110
|
+
for (const ticketId of snap.ticketIds) {
|
|
111
|
+
if (!wanted.has(ticketId) || out.has(ticketId))
|
|
112
|
+
continue;
|
|
113
|
+
out.set(ticketId, {
|
|
114
|
+
ticketId,
|
|
115
|
+
branch: snap.branch,
|
|
116
|
+
baseBranch: snap.baseBranch,
|
|
117
|
+
prUrl: snap.prUrl,
|
|
118
|
+
prNumber: snap.prNumber,
|
|
119
|
+
isDraft: snap.decision === 'pr_draft',
|
|
120
|
+
source: 'rail-pr-delivery',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
async function listOpenPrs(exec, repoDir) {
|
|
127
|
+
const r = await exec.run('gh', ['pr', 'list', '--state', 'open', '--json', 'number,title,body,headRefName,baseRefName,url,isDraft'], repoDir);
|
|
128
|
+
if (r.code !== 0)
|
|
129
|
+
return [];
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(r.stdout);
|
|
132
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Detects tickets whose next implementation pass should continue an already
|
|
140
|
+
* open PR branch instead of creating fresh work from the integration branch.
|
|
141
|
+
* It is intentionally fail-open: no gh, no auth, ambiguous branch state, or no
|
|
142
|
+
* confident PR match all return no target, preserving the normal new-work flow.
|
|
143
|
+
*/
|
|
144
|
+
async function resolveActivePrContinuationTargets(input) {
|
|
145
|
+
const out = new Map();
|
|
146
|
+
const internal = internalDeliveryTargets(input.db, input.ticketIds);
|
|
147
|
+
for (const [ticketId, target] of internal) {
|
|
148
|
+
const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, target.branch, target.baseBranch, target.prUrl, target.prNumber, target.isDraft, 'rail-pr-delivery');
|
|
149
|
+
if (materialized)
|
|
150
|
+
out.set(ticketId, materialized);
|
|
151
|
+
}
|
|
152
|
+
const remaining = input.ticketIds.filter((id) => !out.has(id));
|
|
153
|
+
const candidateIds = remaining.filter((id) => canProbeGithubForContinuation(input.db, id, input.getTicketSpec(id)));
|
|
154
|
+
if (candidateIds.length === 0)
|
|
155
|
+
return out;
|
|
156
|
+
const openPrs = await listOpenPrs(input.exec, input.repoDir);
|
|
157
|
+
for (const ticketId of candidateIds) {
|
|
158
|
+
const spec = input.getTicketSpec(ticketId);
|
|
159
|
+
const matches = openPrs.filter((pr) => {
|
|
160
|
+
const kind = prMatchKind(input.db, ticketId, spec, pr);
|
|
161
|
+
return kind ? matchAllowedForTicket(spec, kind) : false;
|
|
162
|
+
});
|
|
163
|
+
if (matches.length !== 1)
|
|
164
|
+
continue;
|
|
165
|
+
const pr = matches[0];
|
|
166
|
+
const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, pr.headRefName, pr.baseRefName || input.integrationBranch, pr.url ?? null, typeof pr.number === 'number' ? pr.number : parsePrNumber(pr.url), typeof pr.isDraft === 'boolean' ? pr.isDraft : null, 'github-open-pr');
|
|
167
|
+
if (materialized)
|
|
168
|
+
out.set(ticketId, materialized);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
@@ -324,6 +324,13 @@ summary later.
|
|
|
324
324
|
- Configure then launch: \`specrails_rails(set_tickets, railIndex, ticketIds)\` →
|
|
325
325
|
\`specrails_rails(launch, railIndex, mode, …)\`. Setting a profile and then
|
|
326
326
|
switching the rail's engine to codex/gemini silently drops the profile.
|
|
327
|
+
- When relaunching an \`on_review\` spec that already has an OPEN GitHub PR,
|
|
328
|
+
\`launch\` automatically tries to continue that PR's head branch (matched by
|
|
329
|
+
Jira key / spec id / title) instead of starting from the integration branch.
|
|
330
|
+
Jira-linked \`in_progress\` specs can also continue an open PR when the match
|
|
331
|
+
is explicit, covering Jira projects whose Review status has not been mapped to
|
|
332
|
+
Specrails \`on_review\`. You do NOT need to know or pass the branch name. If
|
|
333
|
+
there is no confident open PR match, the normal new-work flow is preserved.
|
|
327
334
|
- Launch proposal shape: tickets (ids + titles), rail number, mode, engine and
|
|
328
335
|
model/profile, plus "runs for minutes and costs money". Wait for yes.
|
|
329
336
|
- **Parallel launches are safe and normal.** Every rail launch runs its work in
|
|
@@ -362,6 +369,8 @@ summary later.
|
|
|
362
369
|
folder is not a git repo; \`no-commits\` = git repo with no initial commit;
|
|
363
370
|
\`error\` = isolation failed, relay the detail), and — for no-git/no-commits —
|
|
364
371
|
that \`git init\` + one commit (no GitHub remote needed) unlocks the PR flow.
|
|
372
|
+
In no-git/no-commits projects, there is also no active-PR continuation because
|
|
373
|
+
there is no branch graph to continue.
|
|
365
374
|
When isolation IS available (no such field), the card WILL appear on settle.
|
|
366
375
|
- **After an ISOLATED rail settles, the DECISION belongs to the user, in the
|
|
367
376
|
app's UI** (the PR card in this chat and the rail header show the same
|
|
@@ -95,13 +95,78 @@ function langDir(guideRoot, lang) {
|
|
|
95
95
|
}
|
|
96
96
|
// ─── Category configuration ─────────────────────────────────────────────────
|
|
97
97
|
const CATEGORY_LABELS = {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
98
|
+
en: {
|
|
99
|
+
'getting-started': 'Getting started',
|
|
100
|
+
specs: 'Specs',
|
|
101
|
+
pipeline: 'Pipeline',
|
|
102
|
+
agents: 'Agents',
|
|
103
|
+
insights: 'Insights',
|
|
104
|
+
integrations: 'Integrations',
|
|
105
|
+
settings: 'Settings',
|
|
106
|
+
},
|
|
107
|
+
es: {
|
|
108
|
+
'getting-started': 'Primeros pasos',
|
|
109
|
+
specs: 'Specs',
|
|
110
|
+
pipeline: 'Pipeline',
|
|
111
|
+
agents: 'Agentes',
|
|
112
|
+
insights: 'Insights',
|
|
113
|
+
integrations: 'Integraciones',
|
|
114
|
+
settings: 'Ajustes',
|
|
115
|
+
},
|
|
116
|
+
fr: {
|
|
117
|
+
'getting-started': 'Bien démarrer',
|
|
118
|
+
specs: 'Specs',
|
|
119
|
+
pipeline: 'Pipeline',
|
|
120
|
+
agents: 'Agents',
|
|
121
|
+
insights: 'Insights',
|
|
122
|
+
integrations: 'Intégrations',
|
|
123
|
+
settings: 'Réglages',
|
|
124
|
+
},
|
|
125
|
+
de: {
|
|
126
|
+
'getting-started': 'Erste Schritte',
|
|
127
|
+
specs: 'Specs',
|
|
128
|
+
pipeline: 'Pipeline',
|
|
129
|
+
agents: 'Agenten',
|
|
130
|
+
insights: 'Insights',
|
|
131
|
+
integrations: 'Integrationen',
|
|
132
|
+
settings: 'Einstellungen',
|
|
133
|
+
},
|
|
134
|
+
pt: {
|
|
135
|
+
'getting-started': 'Primeiros passos',
|
|
136
|
+
specs: 'Specs',
|
|
137
|
+
pipeline: 'Pipeline',
|
|
138
|
+
agents: 'Agentes',
|
|
139
|
+
insights: 'Insights',
|
|
140
|
+
integrations: 'Integrações',
|
|
141
|
+
settings: 'Definições',
|
|
142
|
+
},
|
|
143
|
+
it: {
|
|
144
|
+
'getting-started': 'Primi passi',
|
|
145
|
+
specs: 'Spec',
|
|
146
|
+
pipeline: 'Pipeline',
|
|
147
|
+
agents: 'Agenti',
|
|
148
|
+
insights: 'Insights',
|
|
149
|
+
integrations: 'Integrazioni',
|
|
150
|
+
settings: 'Impostazioni',
|
|
151
|
+
},
|
|
152
|
+
zh: {
|
|
153
|
+
'getting-started': '入门',
|
|
154
|
+
specs: '规格',
|
|
155
|
+
pipeline: '流水线',
|
|
156
|
+
agents: '代理',
|
|
157
|
+
insights: '洞察',
|
|
158
|
+
integrations: '集成',
|
|
159
|
+
settings: '设置',
|
|
160
|
+
},
|
|
161
|
+
ja: {
|
|
162
|
+
'getting-started': 'はじめに',
|
|
163
|
+
specs: 'スペック',
|
|
164
|
+
pipeline: 'パイプライン',
|
|
165
|
+
agents: 'エージェント',
|
|
166
|
+
insights: 'インサイト',
|
|
167
|
+
integrations: '連携',
|
|
168
|
+
settings: '設定',
|
|
169
|
+
},
|
|
105
170
|
};
|
|
106
171
|
/**
|
|
107
172
|
* Preferred display order. Categories not in this list come after, alphabetically.
|
|
@@ -119,8 +184,8 @@ const CATEGORY_ORDER = [
|
|
|
119
184
|
function slugToTitle(slug) {
|
|
120
185
|
return slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
121
186
|
}
|
|
122
|
-
function categoryLabel(slug) {
|
|
123
|
-
return CATEGORY_LABELS[slug] ?? slugToTitle(slug);
|
|
187
|
+
function categoryLabel(slug, lang) {
|
|
188
|
+
return CATEGORY_LABELS[lang]?.[slug] ?? CATEGORY_LABELS[DEFAULT_LANG]?.[slug] ?? slugToTitle(slug);
|
|
124
189
|
}
|
|
125
190
|
function extractTitle(content, slug) {
|
|
126
191
|
const match = content.match(/^#\s+(.+)$/m);
|
|
@@ -190,7 +255,7 @@ function buildCategories(guideRoot, lang) {
|
|
|
190
255
|
return { title, slug: meta.slug };
|
|
191
256
|
});
|
|
192
257
|
if (docs.length > 0) {
|
|
193
|
-
categories.push({ name: categoryLabel(cat), slug: cat, docs });
|
|
258
|
+
categories.push({ name: categoryLabel(cat, lang), slug: cat, docs });
|
|
194
259
|
}
|
|
195
260
|
}
|
|
196
261
|
return categories.sort((a, b) => categoryRank(a.slug) - categoryRank(b.slug) || a.slug.localeCompare(b.slug));
|
|
@@ -12,6 +12,7 @@ exports.bodyForDeployment = bodyForDeployment;
|
|
|
12
12
|
exports.commentMarker = commentMarker;
|
|
13
13
|
exports.discardCommentMarker = discardCommentMarker;
|
|
14
14
|
exports.prMergedCommentMarker = prMergedCommentMarker;
|
|
15
|
+
exports.railReviewCommentMarker = railReviewCommentMarker;
|
|
15
16
|
exports.bodyContainsMarker = bodyContainsMarker;
|
|
16
17
|
exports.commentHasMarker = commentHasMarker;
|
|
17
18
|
exports.adfToText = adfToText;
|
|
@@ -62,6 +63,13 @@ function discardCommentMarker(ticketId, nonce) {
|
|
|
62
63
|
function prMergedCommentMarker(refId, ticketId) {
|
|
63
64
|
return `[specrails:pr-merged=${refId}:ticket=${ticketId}]`;
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Idempotency marker for the "ready for review" comment posted when an isolated
|
|
68
|
+
* rail parks a Jira-linked ticket on_review awaiting the PR decision.
|
|
69
|
+
*/
|
|
70
|
+
function railReviewCommentMarker(refId, ticketId) {
|
|
71
|
+
return `[specrails:rail-review=${refId}:ticket=${ticketId}]`;
|
|
72
|
+
}
|
|
65
73
|
/** True when an ADF doc or wiki string already contains the given marker. */
|
|
66
74
|
function bodyContainsMarker(body, marker) {
|
|
67
75
|
if (typeof body === 'string')
|
|
@@ -13,6 +13,7 @@ exports.JiraSyncManager = void 0;
|
|
|
13
13
|
exports.backoffMs = backoffMs;
|
|
14
14
|
exports.formatJqlDate = formatJqlDate;
|
|
15
15
|
exports.buildCompletionComment = buildCompletionComment;
|
|
16
|
+
exports.buildRailReviewComment = buildRailReviewComment;
|
|
16
17
|
exports.buildPrMergedComment = buildPrMergedComment;
|
|
17
18
|
const node_crypto_1 = require("node:crypto");
|
|
18
19
|
const ticket_store_1 = require("../ticket-store");
|
|
@@ -557,6 +558,16 @@ class JiraSyncManager {
|
|
|
557
558
|
const link = (0, jira_db_1.getLinkByLocalId)(this.db, localId);
|
|
558
559
|
if (!link || link.tombstoned)
|
|
559
560
|
continue;
|
|
561
|
+
ops.push({
|
|
562
|
+
jiraIssueId: link.jiraIssueId,
|
|
563
|
+
opType: 'comment',
|
|
564
|
+
idempotencyKey: `${refId}:${localId}:comment:on-review`,
|
|
565
|
+
payload: {
|
|
566
|
+
jiraIssueId: link.jiraIssueId,
|
|
567
|
+
text: buildRailReviewComment(link.jiraKey),
|
|
568
|
+
marker: (0, jira_adf_1.railReviewCommentMarker)(refId, localId),
|
|
569
|
+
},
|
|
570
|
+
});
|
|
560
571
|
ops.push({
|
|
561
572
|
jiraIssueId: link.jiraIssueId,
|
|
562
573
|
opType: 'transition',
|
|
@@ -1144,31 +1155,59 @@ function formatJqlDate(ms) {
|
|
|
1144
1155
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
1145
1156
|
}
|
|
1146
1157
|
function buildCompletionComment(args, jiraKey, needsReview) {
|
|
1158
|
+
const issue = jiraKey?.trim() ? jiraKey.trim() : 'the linked Jira issue';
|
|
1147
1159
|
if (needsReview) {
|
|
1148
|
-
return
|
|
1160
|
+
return [
|
|
1161
|
+
`Specrails rail finished for ${issue}.`,
|
|
1162
|
+
'Result: needs human review.',
|
|
1163
|
+
'The implementation reached its Ship phase, but the rail terminated abnormally before Specrails could fully close the run.',
|
|
1164
|
+
`Run: ${args.jobId}`,
|
|
1165
|
+
'Jira status: left unchanged for review.',
|
|
1166
|
+
].join('\n');
|
|
1149
1167
|
}
|
|
1150
1168
|
const parts = [];
|
|
1151
1169
|
if (args.status === 'completed') {
|
|
1152
|
-
parts.push(
|
|
1170
|
+
parts.push(`Specrails rail finished for ${issue}.`);
|
|
1171
|
+
parts.push('Result: completed successfully.');
|
|
1172
|
+
parts.push('Jira status: moving to Done.');
|
|
1153
1173
|
}
|
|
1154
1174
|
else if (args.status === 'canceled') {
|
|
1155
|
-
parts.push(
|
|
1175
|
+
parts.push(`Specrails rail finished for ${issue}.`);
|
|
1176
|
+
parts.push('Result: cancelled before completion.');
|
|
1177
|
+
parts.push('Jira status: returning to the backlog.');
|
|
1156
1178
|
}
|
|
1157
1179
|
else {
|
|
1158
|
-
parts.push(
|
|
1180
|
+
parts.push(`Specrails rail finished for ${issue}.`);
|
|
1181
|
+
parts.push('Result: failed before completion.');
|
|
1182
|
+
parts.push('Jira status: returning to the backlog.');
|
|
1159
1183
|
}
|
|
1160
|
-
|
|
1161
|
-
if (args.costUsd != null)
|
|
1162
|
-
meta.push(`cost $${args.costUsd.toFixed(2)}`);
|
|
1184
|
+
parts.push(`Run: ${args.jobId}`);
|
|
1163
1185
|
if (args.durationMs != null)
|
|
1164
|
-
|
|
1165
|
-
|
|
1186
|
+
parts.push(`Duration: ${formatDuration(args.durationMs)}`);
|
|
1187
|
+
if (args.costUsd != null)
|
|
1188
|
+
parts.push(`Cost: $${args.costUsd.toFixed(2)}`);
|
|
1166
1189
|
return parts.join('\n');
|
|
1167
1190
|
}
|
|
1191
|
+
/** Comment posted when an isolated rail has produced reviewable work. */
|
|
1192
|
+
function buildRailReviewComment(jiraKey) {
|
|
1193
|
+
const issue = jiraKey?.trim() ? jiraKey.trim() : 'the linked Jira issue';
|
|
1194
|
+
return [
|
|
1195
|
+
`Specrails rail finished for ${issue}.`,
|
|
1196
|
+
'Result: implementation ready for PR review.',
|
|
1197
|
+
'Jira status: moving to review while the PR decision remains open in Specrails.',
|
|
1198
|
+
'Next step: review the Specrails PR card, then publish/merge or discard the delivery.',
|
|
1199
|
+
].join('\n');
|
|
1200
|
+
}
|
|
1168
1201
|
/** Comment posted on a linked issue when its delivery PR is merged. */
|
|
1169
1202
|
function buildPrMergedComment(prUrl) {
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1203
|
+
const parts = [
|
|
1204
|
+
'Specrails delivery accepted.',
|
|
1205
|
+
'Result: PR merged into the integration branch.',
|
|
1206
|
+
'Jira status: moving to Done.',
|
|
1207
|
+
];
|
|
1208
|
+
if (prUrl)
|
|
1209
|
+
parts.push(`PR: ${prUrl}`);
|
|
1210
|
+
return parts.join('\n');
|
|
1172
1211
|
}
|
|
1173
1212
|
function formatDuration(ms) {
|
|
1174
1213
|
const s = Math.round(ms / 1000);
|
package/server/dist/mcp/guide.js
CHANGED
|
@@ -108,6 +108,15 @@ heading inside the description); \`labels\`; \`priority\`. Spec content is Engli
|
|
|
108
108
|
(ai-spawn) launches EVERY rail that has tickets and no active run / pending
|
|
109
109
|
PR decision in one call, each with its stored mode/engine/profile, returning
|
|
110
110
|
per-rail outcomes (launched / skipped with reason / failed).
|
|
111
|
+
- Relaunching an \`on_review\` ticket with a matching OPEN GitHub PR continues
|
|
112
|
+
that PR's head branch automatically. Jira-linked \`in_progress\` tickets can
|
|
113
|
+
also continue an open PR when the match is explicit, covering Jira projects
|
|
114
|
+
whose Review status has not been mapped to Specrails \`on_review\`. New
|
|
115
|
+
tickets, or tickets without a confident open-PR match, keep the normal
|
|
116
|
+
branch-from-integration flow.
|
|
117
|
+
- Projects without Git cannot use isolated worktrees or PR continuation. A
|
|
118
|
+
launch degrades to shared-cwd execution and returns \`isolationUnavailable\`;
|
|
119
|
+
explain that it writes directly to files and no PR card/branch will appear.
|
|
111
120
|
- Configure: \`set_tickets\` (replaces the assigned set), \`set_profile\` (null =
|
|
112
121
|
legacy), \`set_engine\` (null = project primary), \`set_name\`.
|
|
113
122
|
- Launch modes:
|
|
@@ -29,6 +29,7 @@ function railsTools() {
|
|
|
29
29
|
'launch (ai-spawn — spawns claude/codex/gemini CLI job(s) that WRITE CODE, RUN TESTS, COMMIT, and INCUR TOKEN COST; returns 202 with jobId/jobIds/loopRunIds), ' +
|
|
30
30
|
'launch_all (ai-spawn — launches EVERY rail that has tickets and no active run/pending PR decision, in parallel, using each rail\'s stored mode/engine/profile; returns per-rail outcomes with skip reasons), ' +
|
|
31
31
|
'stop (destructive — kills all active jobs and loop runs for the rail). ' +
|
|
32
|
+
'For on_review tickets with an already-open GitHub PR, launch automatically tries to continue that PR head branch; Jira-linked in_progress tickets can do the same when the PR match is explicit; fresh tickets still start from the project integration branch. ' +
|
|
32
33
|
'When launched from the in-app agent chat without an explicit aiEngine, the engine defaults to your conversation\'s provider (pass aiEngine to override; launch_all always uses each rail\'s stored engine). ' +
|
|
33
34
|
'NAMING: railIndex is the 0-BASED internal identity; the dashboard shows rails 1-based ("Rail N" = railIndex N-1). When talking to the user, ALWAYS say "Rail <railIndex + 1>" (or the rail\'s custom name) — results include railLabel with the correct user-facing label.',
|
|
34
35
|
hintTier: 'read',
|
|
@@ -234,7 +235,7 @@ function railsTools() {
|
|
|
234
235
|
return {
|
|
235
236
|
...r,
|
|
236
237
|
railLabel,
|
|
237
|
-
hint: `Launch accepted (202) on ${railLabel} (isolated worktree, PR flow active) — tell the user it runs on "${railLabel}" (UI labels are 1-based)
|
|
238
|
+
hint: `Launch accepted (202) on ${railLabel} (isolated worktree, PR flow active) — tell the user it runs on "${railLabel}" (UI labels are 1-based). If the assigned spec is on_review with a matching open PR, or is Jira-linked in_progress with an explicit PR match, Specrails continues that PR branch automatically; otherwise it starts a fresh branch from the integration branch. The PR-decision card will appear here and on the rail header when it settles. Use specrails_watch with the returned loopRunIds to await completion only if asked. Rails run for minutes; pass untilMs up to 600000 and re-watch on timeout.`,
|
|
238
239
|
};
|
|
239
240
|
}
|
|
240
241
|
case 'launch_all': {
|
|
@@ -80,6 +80,8 @@ const feature_flags_1 = require("./feature-flags");
|
|
|
80
80
|
const file_provenance_1 = require("./file-provenance");
|
|
81
81
|
const file_story_1 = require("./file-story");
|
|
82
82
|
const providers_1 = require("./providers");
|
|
83
|
+
const pr_publisher_1 = require("./pr-publisher");
|
|
84
|
+
const active_pr_continuation_1 = require("./active-pr-continuation");
|
|
83
85
|
/**
|
|
84
86
|
* Ticket data for the conventional branch name (pr-naming): title/labels from
|
|
85
87
|
* the ticket-spec reader, the Jira key resolved with the authoritative
|
|
@@ -106,6 +108,18 @@ function unitNamingInput(ctx, ticketId) {
|
|
|
106
108
|
}
|
|
107
109
|
return { ticketId, title: spec?.title ?? null, labels: spec?.labels ?? null, jiraKey };
|
|
108
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Commit messages are consumed by GitHub↔Jira development panels. Branch names
|
|
113
|
+
* already carry the Jira key for new work; commits must too, especially when
|
|
114
|
+
* continuing an existing PR whose branch name may predate the current ticket
|
|
115
|
+
* key. Keep the local id as a fallback/cross-reference for Specrails.
|
|
116
|
+
*/
|
|
117
|
+
function worktreeCommitMessage(ctx, ticketId, runId, partial = false) {
|
|
118
|
+
const input = unitNamingInput(ctx, ticketId);
|
|
119
|
+
const ref = (0, pr_naming_1.ticketRef)(input);
|
|
120
|
+
const subjectRef = ref === String(ticketId) ? `ticket-${ticketId}` : `${ref} ticket-${ticketId}`;
|
|
121
|
+
return `specrails: ${subjectRef}${partial ? ' partial' : ''} (run ${runId})`;
|
|
122
|
+
}
|
|
109
123
|
/**
|
|
110
124
|
* Launch the rail's tickets in isolated worktrees + schedule the merge-back.
|
|
111
125
|
* Returns the loop run ids. Throws if worktree allocation fails (the caller then
|
|
@@ -115,6 +129,7 @@ function unitNamingInput(ctx, ticketId) {
|
|
|
115
129
|
async function launchIsolatedRail(input, io = {}) {
|
|
116
130
|
const { ctx, railIndex, ticketIds, loopId, loopName, loopGraph, provider, model, effort } = input;
|
|
117
131
|
const git = io.git ?? worktree_manager_1.defaultGitRunner;
|
|
132
|
+
const exec = io.exec ?? pr_publisher_1.defaultExec;
|
|
118
133
|
const create = io.create ?? worktree_manager_1.createWorktree;
|
|
119
134
|
const remove = io.remove ?? worktree_manager_1.removeWorktree;
|
|
120
135
|
const overlay = io.overlay ?? worktree_overlay_1.applyWorktreeOverlay;
|
|
@@ -209,6 +224,7 @@ async function launchIsolatedRail(input, io = {}) {
|
|
|
209
224
|
// Only allocation is locked — the fan-out (the actual AI runs) below
|
|
210
225
|
// stays fully parallel.
|
|
211
226
|
let integration;
|
|
227
|
+
let launchContinuation = null;
|
|
212
228
|
const allocated = [];
|
|
213
229
|
await (0, repo_lock_1.withRepoLock)(baseRepo, async () => {
|
|
214
230
|
// Bring the repo's remote-tracking refs up to date BEFORE resolving the
|
|
@@ -243,13 +259,44 @@ async function launchIsolatedRail(input, io = {}) {
|
|
|
243
259
|
}
|
|
244
260
|
catch { /* non-fatal, mirrors notifyOverlayDegraded's broadcast guard below */ }
|
|
245
261
|
}
|
|
262
|
+
// Active-PR continuation: if a ticket is already parked on_review with a
|
|
263
|
+
// matching OPEN GitHub PR (or a Jira-linked in_progress ticket explicitly
|
|
264
|
+
// matches an open PR, covering unmapped Jira "Review" statuses), run the next
|
|
265
|
+
// pass directly on that PR's head branch. Fresh tickets stay on the normal
|
|
266
|
+
// integration-base path. Keep this deliberately single-target for now: one
|
|
267
|
+
// rail delivery row can represent one PR URL, so multi-PR batches continue to
|
|
268
|
+
// use the existing new-work flow instead of silently mixing PRs.
|
|
269
|
+
const continuationTargets = await (0, active_pr_continuation_1.resolveActivePrContinuationTargets)({
|
|
270
|
+
db: ctx.db,
|
|
271
|
+
git,
|
|
272
|
+
exec,
|
|
273
|
+
repoDir: baseRepo,
|
|
274
|
+
ticketIds: units.map((u) => u.ticketId),
|
|
275
|
+
integrationBranch: integration.branch,
|
|
276
|
+
fetchOk: fetchResult.ok,
|
|
277
|
+
getTicketSpec: (ticketId) => {
|
|
278
|
+
try {
|
|
279
|
+
return ctx.getTicketSpec(ticketId);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
const uniqueContinuationKeys = new Set(units
|
|
287
|
+
.map((u) => continuationTargets.get(u.ticketId))
|
|
288
|
+
.filter((t) => !!t)
|
|
289
|
+
.map((t) => `${t.prUrl ?? ''}\n${t.branch}`));
|
|
290
|
+
if (uniqueContinuationKeys.size === 1 && units.every((u) => continuationTargets.has(u.ticketId))) {
|
|
291
|
+
launchContinuation = continuationTargets.get(units[0].ticketId) ?? null;
|
|
292
|
+
}
|
|
246
293
|
if (prMode) {
|
|
247
294
|
prDeliveryId = (0, rail_pr_store_1.createPrDelivery)(ctx.db, {
|
|
248
295
|
railIndex,
|
|
249
296
|
loopId,
|
|
250
297
|
railKey: `${railIndex}-${loopId}`,
|
|
251
298
|
ticketIds: [...ticketIds],
|
|
252
|
-
baseBranch: integration.branch,
|
|
299
|
+
baseBranch: launchContinuation?.baseBranch ?? integration.branch,
|
|
253
300
|
loopName,
|
|
254
301
|
originSurface: input.originSurface ?? 'dashboard',
|
|
255
302
|
originConversationId: input.originConversationId ?? null,
|
|
@@ -266,6 +313,11 @@ async function launchIsolatedRail(input, io = {}) {
|
|
|
266
313
|
// launches see each other's just-created branches.
|
|
267
314
|
const takenBranches = await (0, worktree_manager_1.listLocalBranches)(git, baseRepo);
|
|
268
315
|
const unitBranchName = (ticketId) => {
|
|
316
|
+
const continuation = launchContinuation && continuationTargets.get(ticketId);
|
|
317
|
+
if (continuation) {
|
|
318
|
+
takenBranches.add(continuation.branch);
|
|
319
|
+
return continuation.branch;
|
|
320
|
+
}
|
|
269
321
|
const preferred = (0, pr_naming_1.ticketBranchName)(unitNamingInput(ctx, ticketId));
|
|
270
322
|
const branch = (0, pr_naming_1.resolveCollisionFreeName)(preferred, {
|
|
271
323
|
taken: (name) => takenBranches.has(name) && !(0, rail_worktrees_store_1.railWorktreeBranchExistsForTicket)(ctx.db, ticketId, name),
|
|
@@ -276,7 +328,15 @@ async function launchIsolatedRail(input, io = {}) {
|
|
|
276
328
|
};
|
|
277
329
|
try {
|
|
278
330
|
for (const unit of units) {
|
|
279
|
-
const
|
|
331
|
+
const continuationTarget = launchContinuation ? continuationTargets.get(unit.ticketId) ?? null : null;
|
|
332
|
+
const handle = await create(git, {
|
|
333
|
+
repoDir: baseRepo,
|
|
334
|
+
worktreesRoot,
|
|
335
|
+
slug,
|
|
336
|
+
ticketId: unit.ticketId,
|
|
337
|
+
baseRef: continuationTarget?.baseRef ?? worktreeBaseRef.baseRef,
|
|
338
|
+
branch: unitBranchName(unit.ticketId),
|
|
339
|
+
});
|
|
280
340
|
// Per-run overlay: merge-link the framework surface the checkout didn't
|
|
281
341
|
// bring into the worktree (idempotent; resume-safe via its manifest).
|
|
282
342
|
let overlayExcludes = [];
|
|
@@ -315,7 +375,7 @@ async function launchIsolatedRail(input, io = {}) {
|
|
|
315
375
|
id: ledgerId, railIndex, ticketId: unit.ticketId, runId,
|
|
316
376
|
branch: handle.branch, worktreePath: handle.worktreePath,
|
|
317
377
|
});
|
|
318
|
-
allocated.push({ ticketId: unit.ticketId, ticketIds: unit.ticketIds, runId, ledgerId, handle, overlayExcludes, provenanceSnapshot });
|
|
378
|
+
allocated.push({ ticketId: unit.ticketId, ticketIds: unit.ticketIds, runId, ledgerId, handle, overlayExcludes, provenanceSnapshot, continuationTarget });
|
|
319
379
|
}
|
|
320
380
|
}
|
|
321
381
|
catch (err) {
|
|
@@ -379,7 +439,7 @@ async function launchIsolatedRail(input, io = {}) {
|
|
|
379
439
|
// Commit the run's work to its branch so the merge-back can integrate it
|
|
380
440
|
// (loops like autoloop-tdd don't commit) and a re-launch can resume it.
|
|
381
441
|
// Overlay scaffolding is excluded — it must never reach the branch/PR.
|
|
382
|
-
await (0, worktree_manager_1.commitWorktree)(git, a.handle.worktreePath,
|
|
442
|
+
await (0, worktree_manager_1.commitWorktree)(git, a.handle.worktreePath, worktreeCommitMessage(ctx, a.ticketId, a.runId), a.overlayExcludes);
|
|
383
443
|
(0, rail_worktrees_store_1.updateRailWorktreeState)(ctx.db, a.ledgerId, succeeded ? 'built' : 'failed');
|
|
384
444
|
return { run: a, succeeded };
|
|
385
445
|
})
|
|
@@ -388,7 +448,7 @@ async function launchIsolatedRail(input, io = {}) {
|
|
|
388
448
|
ctx.onLoopRunFinished(a.runId, 'failed', runFinishedOpts);
|
|
389
449
|
recordRunProvenance(a);
|
|
390
450
|
// Commit partial work too → durable in git → resumable on re-launch.
|
|
391
|
-
await (0, worktree_manager_1.commitWorktree)(git, a.handle.worktreePath,
|
|
451
|
+
await (0, worktree_manager_1.commitWorktree)(git, a.handle.worktreePath, worktreeCommitMessage(ctx, a.ticketId, a.runId, true), a.overlayExcludes);
|
|
392
452
|
(0, rail_worktrees_store_1.updateRailWorktreeState)(ctx.db, a.ledgerId, 'failed');
|
|
393
453
|
return { run: a, succeeded: false };
|
|
394
454
|
});
|
|
@@ -427,8 +487,23 @@ async function launchIsolatedRail(input, io = {}) {
|
|
|
427
487
|
// → auto-close (per-run settle already reverted the tickets).
|
|
428
488
|
const worktreeIds = allocated.map((a) => a.ledgerId);
|
|
429
489
|
const anySucceeded = results.some((r) => r.succeeded);
|
|
430
|
-
const
|
|
431
|
-
|
|
490
|
+
const settledContinuation = anySucceeded ? launchContinuation : null;
|
|
491
|
+
const next = anySucceeded
|
|
492
|
+
? (settledContinuation
|
|
493
|
+
? (settledContinuation.isDraft === false ? 'pr_ready' : 'pr_draft')
|
|
494
|
+
: 'on_review')
|
|
495
|
+
: 'discarded';
|
|
496
|
+
const patch = settledContinuation
|
|
497
|
+
? {
|
|
498
|
+
branches,
|
|
499
|
+
worktreeIds,
|
|
500
|
+
branch: settledContinuation.branch,
|
|
501
|
+
prUrl: settledContinuation.prUrl,
|
|
502
|
+
prNumber: settledContinuation.prNumber,
|
|
503
|
+
prState: 'pr-created',
|
|
504
|
+
}
|
|
505
|
+
: { branches, worktreeIds };
|
|
506
|
+
if (prDeliveryId && (0, rail_pr_store_1.transitionDecision)(ctx.db, prDeliveryId, 'building', next, patch)) {
|
|
432
507
|
broadcastPrState();
|
|
433
508
|
// Completion driver: refresh the origin conversation's card in place —
|
|
434
509
|
// on_review asks the question, discarded informs the outcome.
|