Skip to content

Commit 9d8bb8d

Browse files
committed
feat(git): classify implementing jira tickets in text generation
- Add buildClassifyImplementingJiraTicketsPrompt to extract implementing ticket keys from user message - Wire JiraContextCollector into GitManager; resolve thread's Jira context before each text gen operation - Pass jiraTickets through all text generation layers (Claude, Codex, Cursor, OpenCode) - Commit/PR/branch prompts now include Jira section with ticket summary, status, and URL - Implement filterToAllowedKeys guard to prevent model hallucination of unrecognized keys - Add JiraTicketContext type and jiraContext.ts shared utilities for type safety
1 parent 5112a05 commit 9d8bb8d

52 files changed

Lines changed: 1664 additions & 208 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/server/src/git/Layers/ClaudeTextGeneration.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ import { TextGenerationError } from "@marcode/contracts";
1717
import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
1818
import {
1919
buildBranchNamePrompt,
20+
buildClassifyImplementingJiraTicketsPrompt,
2021
buildCommitMessagePrompt,
2122
buildPrContentPrompt,
2223
buildThreadTitlePrompt,
2324
} from "../Prompts.ts";
2425
import {
26+
filterToAllowedKeys,
2527
normalizeCliError,
2628
sanitizeCommitSubject,
2729
sanitizePrBody,
@@ -85,7 +87,8 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
8587
| "generateCommitMessage"
8688
| "generatePrContent"
8789
| "generateBranchName"
88-
| "generateThreadTitle";
90+
| "generateThreadTitle"
91+
| "classifyImplementingJiraTickets";
8992
cwd: string;
9093
prompt: string;
9194
outputSchemaJson: S;
@@ -232,6 +235,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
232235
stagedSummary: input.stagedSummary,
233236
stagedPatch: input.stagedPatch,
234237
includeBranch: input.includeBranch === true,
238+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
235239
});
236240

237241
if (input.modelSelection.provider !== "claudeAgent") {
@@ -267,6 +271,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
267271
commitSummary: input.commitSummary,
268272
diffSummary: input.diffSummary,
269273
diffPatch: input.diffPatch,
274+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
270275
});
271276

272277
if (input.modelSelection.provider !== "claudeAgent") {
@@ -296,6 +301,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
296301
const { prompt, outputSchema } = buildBranchNamePrompt({
297302
message: input.message,
298303
attachments: input.attachments,
304+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
299305
});
300306

301307
if (input.modelSelection.provider !== "claudeAgent") {
@@ -324,6 +330,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
324330
const { prompt, outputSchema } = buildThreadTitlePrompt({
325331
message: input.message,
326332
attachments: input.attachments,
333+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
327334
});
328335

329336
if (input.modelSelection.provider !== "claudeAgent") {
@@ -346,11 +353,42 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
346353
};
347354
});
348355

356+
const classifyImplementingJiraTickets: TextGenerationShape["classifyImplementingJiraTickets"] =
357+
Effect.fn("ClaudeTextGeneration.classifyImplementingJiraTickets")(function* (input) {
358+
if (input.modelSelection.provider !== "claudeAgent") {
359+
return yield* new TextGenerationError({
360+
operation: "classifyImplementingJiraTickets",
361+
detail: "Invalid model selection.",
362+
});
363+
}
364+
if (input.jiraTickets.length === 0) {
365+
return { implementingKeys: [] };
366+
}
367+
368+
const { prompt, outputSchema } = buildClassifyImplementingJiraTicketsPrompt({
369+
message: input.message,
370+
jiraTickets: input.jiraTickets,
371+
});
372+
373+
const generated = yield* runClaudeJson({
374+
operation: "classifyImplementingJiraTickets",
375+
cwd: input.cwd,
376+
prompt,
377+
outputSchemaJson: outputSchema,
378+
modelSelection: input.modelSelection,
379+
});
380+
381+
return {
382+
implementingKeys: filterToAllowedKeys(generated.implementingKeys, input.jiraTickets),
383+
};
384+
});
385+
349386
return {
350387
generateCommitMessage,
351388
generatePrContent,
352389
generateBranchName,
353390
generateThreadTitle,
391+
classifyImplementingJiraTickets,
354392
} satisfies TextGenerationShape;
355393
});
356394

apps/server/src/git/Layers/CodexTextGeneration.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import {
2222
} from "../Services/TextGeneration.ts";
2323
import {
2424
buildBranchNamePrompt,
25+
buildClassifyImplementingJiraTicketsPrompt,
2526
buildCommitMessagePrompt,
2627
buildPrContentPrompt,
2728
buildThreadTitlePrompt,
2829
} from "../Prompts.ts";
2930
import {
31+
filterToAllowedKeys,
3032
normalizeCliError,
3133
sanitizeCommitSubject,
3234
sanitizePrBody,
@@ -138,7 +140,8 @@ const makeCodexTextGeneration = Effect.gen(function* () {
138140
| "generateCommitMessage"
139141
| "generatePrContent"
140142
| "generateBranchName"
141-
| "generateThreadTitle";
143+
| "generateThreadTitle"
144+
| "classifyImplementingJiraTickets";
142145
cwd: string;
143146
prompt: string;
144147
outputSchemaJson: S;
@@ -286,6 +289,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
286289
stagedSummary: input.stagedSummary,
287290
stagedPatch: input.stagedPatch,
288291
includeBranch: input.includeBranch === true,
292+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
289293
});
290294

291295
if (input.modelSelection.provider !== "codex") {
@@ -321,6 +325,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
321325
commitSummary: input.commitSummary,
322326
diffSummary: input.diffSummary,
323327
diffPatch: input.diffPatch,
328+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
324329
});
325330

326331
if (input.modelSelection.provider !== "codex") {
@@ -354,6 +359,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
354359
const { prompt, outputSchema } = buildBranchNamePrompt({
355360
message: input.message,
356361
attachments: input.attachments,
362+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
357363
});
358364

359365
if (input.modelSelection.provider !== "codex") {
@@ -387,6 +393,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
387393
const { prompt, outputSchema } = buildThreadTitlePrompt({
388394
message: input.message,
389395
attachments: input.attachments,
396+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
390397
});
391398

392399
if (input.modelSelection.provider !== "codex") {
@@ -410,11 +417,42 @@ const makeCodexTextGeneration = Effect.gen(function* () {
410417
} satisfies ThreadTitleGenerationResult;
411418
});
412419

420+
const classifyImplementingJiraTickets: TextGenerationShape["classifyImplementingJiraTickets"] =
421+
Effect.fn("CodexTextGeneration.classifyImplementingJiraTickets")(function* (input) {
422+
if (input.modelSelection.provider !== "codex") {
423+
return yield* new TextGenerationError({
424+
operation: "classifyImplementingJiraTickets",
425+
detail: "Invalid model selection.",
426+
});
427+
}
428+
if (input.jiraTickets.length === 0) {
429+
return { implementingKeys: [] };
430+
}
431+
432+
const { prompt, outputSchema } = buildClassifyImplementingJiraTicketsPrompt({
433+
message: input.message,
434+
jiraTickets: input.jiraTickets,
435+
});
436+
437+
const generated = yield* runCodexJson({
438+
operation: "classifyImplementingJiraTickets",
439+
cwd: input.cwd,
440+
prompt,
441+
outputSchemaJson: outputSchema,
442+
modelSelection: input.modelSelection,
443+
});
444+
445+
return {
446+
implementingKeys: filterToAllowedKeys(generated.implementingKeys, input.jiraTickets),
447+
};
448+
});
449+
413450
return {
414451
generateCommitMessage,
415452
generatePrContent,
416453
generateBranchName,
417454
generateThreadTitle,
455+
classifyImplementingJiraTickets,
418456
} satisfies TextGenerationShape;
419457
});
420458

apps/server/src/git/Layers/CursorTextGeneration.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
} from "../Services/TextGeneration.ts";
1313
import {
1414
buildBranchNamePrompt,
15+
buildClassifyImplementingJiraTicketsPrompt,
1516
buildCommitMessagePrompt,
1617
buildPrContentPrompt,
1718
buildThreadTitlePrompt,
1819
} from "../Prompts.ts";
1920
import {
2021
extractJsonObject,
22+
filterToAllowedKeys,
2123
sanitizeCommitSubject,
2224
sanitizePrTitle,
2325
sanitizeThreadTitle,
@@ -35,7 +37,8 @@ function mapCursorAcpError(
3537
| "generateCommitMessage"
3638
| "generatePrContent"
3739
| "generateBranchName"
38-
| "generateThreadTitle",
40+
| "generateThreadTitle"
41+
| "classifyImplementingJiraTickets",
3942
detail: string,
4043
cause: unknown,
4144
): TextGenerationError {
@@ -70,7 +73,8 @@ const makeCursorTextGeneration = Effect.gen(function* () {
7073
| "generateCommitMessage"
7174
| "generatePrContent"
7275
| "generateBranchName"
73-
| "generateThreadTitle";
76+
| "generateThreadTitle"
77+
| "classifyImplementingJiraTickets";
7478
cwd: string;
7579
prompt: string;
7680
outputSchemaJson: S;
@@ -184,6 +188,7 @@ const makeCursorTextGeneration = Effect.gen(function* () {
184188
stagedSummary: input.stagedSummary,
185189
stagedPatch: input.stagedPatch,
186190
includeBranch: input.includeBranch === true,
191+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
187192
});
188193

189194
if (input.modelSelection.provider !== "cursor") {
@@ -219,6 +224,7 @@ const makeCursorTextGeneration = Effect.gen(function* () {
219224
commitSummary: input.commitSummary,
220225
diffSummary: input.diffSummary,
221226
diffPatch: input.diffPatch,
227+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
222228
});
223229

224230
if (input.modelSelection.provider !== "cursor") {
@@ -248,6 +254,7 @@ const makeCursorTextGeneration = Effect.gen(function* () {
248254
const { prompt, outputSchema } = buildBranchNamePrompt({
249255
message: input.message,
250256
attachments: input.attachments,
257+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
251258
});
252259

253260
if (input.modelSelection.provider !== "cursor") {
@@ -276,6 +283,7 @@ const makeCursorTextGeneration = Effect.gen(function* () {
276283
const { prompt, outputSchema } = buildThreadTitlePrompt({
277284
message: input.message,
278285
attachments: input.attachments,
286+
...(input.jiraTickets ? { jiraTickets: input.jiraTickets } : {}),
279287
});
280288

281289
if (input.modelSelection.provider !== "cursor") {
@@ -298,11 +306,42 @@ const makeCursorTextGeneration = Effect.gen(function* () {
298306
} satisfies ThreadTitleGenerationResult;
299307
});
300308

309+
const classifyImplementingJiraTickets: TextGenerationShape["classifyImplementingJiraTickets"] =
310+
Effect.fn("CursorTextGeneration.classifyImplementingJiraTickets")(function* (input) {
311+
if (input.modelSelection.provider !== "cursor") {
312+
return yield* new TextGenerationError({
313+
operation: "classifyImplementingJiraTickets",
314+
detail: "Invalid model selection.",
315+
});
316+
}
317+
if (input.jiraTickets.length === 0) {
318+
return { implementingKeys: [] };
319+
}
320+
321+
const { prompt, outputSchema } = buildClassifyImplementingJiraTicketsPrompt({
322+
message: input.message,
323+
jiraTickets: input.jiraTickets,
324+
});
325+
326+
const generated = yield* runCursorJson({
327+
operation: "classifyImplementingJiraTickets",
328+
cwd: input.cwd,
329+
prompt,
330+
outputSchemaJson: outputSchema,
331+
modelSelection: input.modelSelection,
332+
});
333+
334+
return {
335+
implementingKeys: filterToAllowedKeys(generated.implementingKeys, input.jiraTickets),
336+
};
337+
});
338+
301339
return {
302340
generateCommitMessage,
303341
generatePrContent,
304342
generateBranchName,
305343
generateThreadTitle,
344+
classifyImplementingJiraTickets,
306345
} satisfies TextGenerationShape;
307346
});
308347

apps/server/src/git/Layers/GitManager.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type ProjectSetupScriptRunnerInput,
3232
type ProjectSetupScriptRunnerShape,
3333
} from "../../project/Services/ProjectSetupScriptRunner.ts";
34+
import { JiraContextCollector } from "../../jira/Services/JiraContextCollector.ts";
3435

3536
interface FakeGhScenario {
3637
prListSequence?: string[];
@@ -84,6 +85,12 @@ interface FakeGitTextGeneration {
8485
message: string;
8586
modelSelection: ModelSelection;
8687
}) => Effect.Effect<{ title: string }, TextGenerationError>;
88+
classifyImplementingJiraTickets: (input: {
89+
cwd: string;
90+
message: string;
91+
jiraTickets: ReadonlyArray<unknown>;
92+
modelSelection: ModelSelection;
93+
}) => Effect.Effect<{ implementingKeys: ReadonlyArray<string> }, TextGenerationError>;
8794
}
8895

8996
type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
@@ -226,6 +233,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
226233
Effect.succeed({
227234
title: "Implement stacked git actions",
228235
}),
236+
classifyImplementingJiraTickets: () =>
237+
Effect.succeed({
238+
implementingKeys: [],
239+
}),
229240
...overrides,
230241
};
231242

@@ -274,6 +285,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
274285
}),
275286
),
276287
),
288+
classifyImplementingJiraTickets: (input) =>
289+
implementation.classifyImplementingJiraTickets(input).pipe(
290+
Effect.mapError(
291+
(cause) =>
292+
new TextGenerationError({
293+
operation: "classifyImplementingJiraTickets",
294+
detail: "fake text generation failed",
295+
...(cause !== undefined ? { cause } : {}),
296+
}),
297+
),
298+
),
277299
};
278300
}
279301

@@ -671,6 +693,9 @@ function makeManager(input?: {
671693
runForThread: () => Effect.succeed({ status: "no-script" as const }),
672694
},
673695
),
696+
Layer.succeed(JiraContextCollector, {
697+
forThread: () => Effect.succeed([]),
698+
}),
674699
gitCoreLayer,
675700
serverSettingsLayer,
676701
).pipe(Layer.provideMerge(NodeServices.layer));

0 commit comments

Comments
 (0)