Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions server/routes/cos.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ vi.mock('../services/cosTaskGenerator.js', async (importActual) => ({
vi.mock('../services/apps.js', () => ({
getAppById: vi.fn(),
getAppWorkTracker: vi.fn(),
// buildJiraTicketTask layers the app's claim-work metadata over the Code
// Review Defaults (#6210), so the route exercises getAppTaskTypeOverrides
// through it — default to no per-app override.
getAppTaskTypeOverrides: vi.fn(async () => ({})),
PORTOS_APP_ID: 'portos-default'
}));

Expand Down
29 changes: 15 additions & 14 deletions server/services/cosTaskGenerator.js
Original file line number Diff line number Diff line change
Expand Up @@ -503,25 +503,26 @@ export async function buildClaimWorkTask(app, {
}

/**
* Resolve the reviewer prompt pieces for the claim flow exactly as
* buildClaimWorkTask does (including local-LLM reviewers). Mirrors the
* scheduled claim-work resolution so the JIRA play button honors the user's
* reviewer choice.
* Resolve the reviewer prompt pieces for the claim flow as buildClaimWorkTask
* does on its default (no-explicit-option) path (including local-LLM
* reviewers). Mirrors the scheduled claim-work resolution so the JIRA play
* button honors the user's reviewer choice.
*
* Returns each piece separately because they travel differently: `csv` fills the
* template's `{reviewers}` placeholder, `effortBlock` is appended prose (the
* claim agent spawns each reviewer CLI itself, so no `--review-with` parser ever
* reads the CSV's `~effort=` suffix), and `taskMetadata` is PERSISTED so the
* prompt builder resolves the same list back off the task record (#4770).
*/
async function resolveClaimReviewerPrompt() {
const codeReviewDefaults = await getCodeReviewDefaults().catch(() => null);
// No task record exists yet for the play button, so the whole bundle resolves
// from the Code Review Defaults: the reviewer list, the `@user` tokens that
// gate the merge, the `~opt` set, the `~max=<n>` caps, and the model/effort
// pins (which resolve together — an agy model id can carry its effort as a
// suffix, so the bracket and the appended instruction can't disagree).
const config = resolveClaimReviewerConfig({}, codeReviewDefaults, codeReviewDefaults?.reviewers);
async function resolveClaimReviewerPrompt(app) {
const [{ metadata }, codeReviewDefaults] = await Promise.all([
resolveClaimWorkMetadata(app),
getCodeReviewDefaults().catch(() => null)
]);
// The app's configured claim-work metadata layers over the Code Review
// Defaults through the same claimReviewersFrom the scheduled path uses —
// resolving from the defaults alone would silently drop a pinned override.
const config = claimReviewersFrom(metadata, codeReviewDefaults);
const { reviewers: list, reviewerModels, reviewerEfforts, csv } = config;
return {
csv,
Expand Down Expand Up @@ -556,10 +557,10 @@ export async function buildJiraTicketTask(app, ticketKey) {
// null (unnormalizable) key can only come from a direct service caller.
const key = normalizeWorkItemRef(ticketKey);

// Independent reads (prompt body + Code Review Defaults) — fetch concurrently.
// Independent reads (prompt body + claim reviewers) — fetch concurrently.
const [template, { csv: reviewersCsv, taskMetadata: reviewerMetadata, effortBlock, localReviewerBlock }] = await Promise.all([
getTaskPrompt('claim-issue-jira'),
resolveClaimReviewerPrompt(),
resolveClaimReviewerPrompt(app),
]);
const prompt = template
.replace(/\{appName\}/g, app.name)
Expand Down
39 changes: 30 additions & 9 deletions server/services/cosTaskGenerator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,10 @@ describe('{reviewers} interpolation honors Code Review Defaults', () => {
// one site and silently miss another. It also applies the claim copilot
// guard, which is what keeps the retired Copilot fallback from reappearing.
expect(GEN_SRC).toContain('resolveClaimReviewerConfig(metadata, codeReviewDefaults, codeReviewDefaults?.reviewers)');
expect(GEN_SRC).toContain('resolveClaimReviewerConfig({}, codeReviewDefaults, codeReviewDefaults?.reviewers)');
// Every claim path layers the app's claim-work metadata over the defaults
// through claimReviewersFrom — no path may resolve the defaults alone with
// a bare `{}` (that silently drops a pinned override; #6210).
expect(GEN_SRC).not.toContain('resolveClaimReviewerConfig({}, codeReviewDefaults, codeReviewDefaults?.reviewers)');
expect(GEN_SRC).not.toMatch(/normalizeReviewers\(metadata\)(?!,)/);
});

Expand Down Expand Up @@ -522,8 +525,11 @@ describe('{reviewers} interpolation honors Code Review Defaults', () => {
// rejects, while the other paths emitted the split form.
expect(GEN_SRC).toContain('reviewerModels: reviewerModels ?? metadata?.reviewerModels');
expect(GEN_SRC).toContain('reviewerEfforts: reviewerEfforts ?? metadata?.reviewerEfforts');
// The play-button path reads the defaults directly (no task metadata to layer).
expect(GEN_SRC).toContain('resolveClaimReviewerConfig({}, codeReviewDefaults, codeReviewDefaults?.reviewers)');
// All three claim paths layer claim-work metadata over the defaults via
// claimReviewersFrom — including the JIRA play button (#6210). No path may
// resolve the defaults alone with a bare `{}`.
expect(GEN_SRC).toContain('claimReviewersFrom(metadata, codeReviewDefaults)');
expect(GEN_SRC).not.toContain('resolveClaimReviewerConfig({}, codeReviewDefaults, codeReviewDefaults?.reviewers)');
// No path may resolve one map without the other — or reach past the shared
// claim resolver, which wraps `resolveReviewerPins` for all three sites.
expect(GEN_SRC).not.toContain('resolveReviewerModels(');
Expand Down Expand Up @@ -763,19 +769,21 @@ describe('buildJiraTicketTask', () => {
const { ticketKey, prompt, taskMetadata } = await buildJiraTicketTask(app, 'proj-1234');
// Placeholders resolved from the app object.
expect(prompt).toContain('App Acme App at /repos/acme (id acme)');
// {reviewers} substituted (no literal placeholder left) with the Code Review
// Defaults reviewer + @username token.
// {reviewers} substituted (no literal placeholder left) with the claim-work
// override (['codex','claude'] in this file's taskSchedule mock) layered
// over the Code Review Defaults' `@alice` token — the JIRA play button
// honors the same override the /do:next claim does (#6210).
expect(prompt).not.toContain('{reviewers}');
expect(prompt).toContain('ollama');
expect(prompt).toContain('Local Reviewer Procedure');
expect(prompt).toContain('codex');
expect(prompt).toContain('claude');
expect(prompt).not.toContain('copilot');
expect(prompt).toContain('@alice');
// Target-ticket constraint pins the uppercased key.
expect(prompt).toContain('## Target Ticket Constraint');
expect(prompt).toContain('PROJ-1234');
// Ticket key normalized to upper-case.
expect(ticketKey).toBe('PROJ-1234');
// claim-issue-jira self-manages worktree + PR; claimFlow keeps that
// claim-issue-jira self-manages its worktree + PR; claimFlow keeps that
// lifecycle from falling into CoS's generic false/false handoff. The
// resolved reviewer bundle rides along so the prompt builder's reviewer pin
// names the same tokens this prompt does (#4770) — the play button's claim
Expand All @@ -784,7 +792,7 @@ describe('buildJiraTicketTask', () => {
useWorktree: false,
openPR: false,
claimFlow: true,
reviewers: ['ollama'],
reviewers: ['codex', 'claude'],
usernames: ['alice'],
optionalReviewers: [],
reviewerMaxRounds: {},
Expand All @@ -793,11 +801,24 @@ describe('buildJiraTicketTask', () => {
});
});

it('falls through to the Code Review Defaults when claim-work pins no list', async () => {
getTaskInterval.mockResolvedValueOnce({ prompt: null, taskMetadata: { issueAuthorFilter: 'owner' } });
const { prompt, taskMetadata } = await buildJiraTicketTask(app, 'proj-1234');
expect(prompt).toContain('ollama');
expect(prompt).toContain('Local Reviewer Procedure');
expect(prompt).toContain('@alice');
expect(taskMetadata.reviewers).toEqual(['ollama']);
});

it('is exported so the /tasks/jira-ticket route reuses the shared assembly', () => {
expect(GEN_SRC).toContain('export async function buildJiraTicketTask(');
// Routes the JIRA flow directly, not via buildClaimWorkTask.
expect(GEN_SRC).toMatch(/buildJiraTicketTask[\s\S]*getTaskPrompt\('claim-issue-jira'\)/);
expect(GEN_SRC).toMatch(/buildJiraTicketTask[\s\S]*appendTargetWorkItemBlock\('claim-issue-jira', key\)/);
// The play button layers the app's claim-work metadata, not the defaults
// alone (#6210) — the app travels into the reviewer resolution.
expect(GEN_SRC).toMatch(/buildJiraTicketTask[\s\S]*resolveClaimReviewerPrompt\(app\)/);
expect(GEN_SRC).toMatch(/async function resolveClaimReviewerPrompt\(app\)/);
});
});

Expand Down