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
28 changes: 28 additions & 0 deletions src/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,34 @@ export async function addIssueLabel(
}
}


export interface UpdateIssueFields {
title?: string;
body?: string | null;
}

/**
* Update issue title and/or body via GitHub API.
*/
export async function updateIssueTitleAndBody(
repoFullName: string,
issueNumber: number,
fields: UpdateIssueFields,
): Promise<void> {
const [owner, repo] = repoFullName.split("/");
const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/${issueNumber}`;

const response = await fetch(url, {
method: "PATCH",
headers: await getHeadersAsync(),
body: JSON.stringify(fields),
});

if (!response.ok) {
const text = await response.text();
throw new Error(`GitHub API error updating issue #${issueNumber}: ${response.status} ${text}`);
}
}
export async function removeIssueLabel(
repoFullName: string,
issueNumber: number,
Expand Down
19 changes: 17 additions & 2 deletions src/lib/groomer/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ Return ONLY valid JSON with this exact schema:
"githubComment": "optional comment to post on the issue (omit if nothing to say)",
"needsInfoReason": "optional reason if info is needed",
"blockedReason": "optional reason if blocked",
"nextGroomingAction": "optional: promote_to_ready|escalate|mark_not_ready|mark_needs_info|mark_blocked"
"nextGroomingAction": "optional: promote_to_ready|escalate|mark_not_ready|mark_needs_info|mark_blocked",
"proposedTitle": "optional: rewritten title if current one is bad",
"proposedBody": "optional: enriched body if current one is sparse"
}

Rules:
Expand All @@ -40,7 +42,20 @@ Rules:
- Valid type labels: ${typeLabels}
- Never remove agent/* labels
- Lane must be one of the configured lane ids
- Be concise in summary and reason fields`;
- Be concise in summary and reason fields

Title rewriting rules:
- Only propose a new title when the current title is bad: length < 10 chars, matches generic patterns (single word like "P0", "TODO", "bug", "fix"), or is clearly just a priority/label token
- If the title is already descriptive (>= 10 chars and looks like a real sentence/phrase), omit proposedTitle
- The new title should be 10-200 chars, imperative verb form, specific and actionable
- Base the rewritten title on body content, labels, and comments

Body enrichment rules:
- Only propose an enriched body when the current body is missing, empty, or < 100 characters (excluding markdown/HTML comments)
- If the body already has substantial content, omit proposedBody
- The enriched body should add structure: brief context, what's known, suggested approach based on labels/body/comments
- Do NOT clobber existing body content — if there's any meaningful body, append rather than replace; if empty/missing, create from scratch
- Keep enriched body under 10000 characters`;
}

export async function callGroomerLLM(options: CallLlmOptions): Promise<GroomerOutput> {
Expand Down
186 changes: 186 additions & 0 deletions src/lib/groomer/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const { mocks } = vi.hoisted(() => ({
getHostedGroomerConfig: vi.fn(),
updateIssueLabels: vi.fn(),
addIssueComment: vi.fn(),
updateIssueTitleAndBody: vi.fn(),
findActiveLeasesForIssue: vi.fn(),
upsertLease: vi.fn(),
releaseLease: vi.fn(),
Expand Down Expand Up @@ -64,6 +65,7 @@ vi.mock("./config", () => ({
vi.mock("@/lib/github", () => ({
updateIssueLabels: mocks.updateIssueLabels,
addIssueComment: mocks.addIssueComment,
updateIssueTitleAndBody: mocks.updateIssueTitleAndBody,
addIssueLabel: mocks.addIssueLabel,
removeIssueLabel: mocks.removeIssueLabel,
}));
Expand Down Expand Up @@ -131,6 +133,7 @@ describe("runHostedGroomer", () => {
mocks.getHostedGroomerConfig.mockReturnValue(mockConfig);
mocks.callGroomerLLM.mockResolvedValue(mockOutput);
mocks.updateIssueLabels.mockResolvedValue(undefined);
mocks.updateIssueTitleAndBody.mockResolvedValue(undefined);
mocks.addIssueComment.mockResolvedValue({ url: null });
mocks.findActiveLeasesForIssue.mockResolvedValue([]);
mocks.upsertLease.mockResolvedValue({ created: true, lease: { id: "lease-1" } });
Expand Down Expand Up @@ -469,4 +472,187 @@ describe("runHostedGroomer", () => {
}),
);
});

// ─── Title rewriting tests ───

it("does not rewrite a good title", async () => {
mocks.validateGroomerOutput.mockReturnValue({
valid: true,
parsed: { ...mockOutput, proposedTitle: "Fix the login bug" },
});

const result = await runHostedGroomer();

// "Fix login bug" (13 chars) is a good title — should not be rewritten
expect(result!.mutationPlan?.titleRewritten).toBe(false);
expect(mocks.updateIssueTitleAndBody).not.toHaveBeenCalled();
});

it("rewrites a bad short title", async () => {
const badCandidate = { ...mockCandidate, title: "P0" };
mocks.selectGroomingCandidate.mockResolvedValue(badCandidate);
mocks.validateGroomerOutput.mockReturnValue({
valid: true,
parsed: {
...mockOutput,
proposedTitle: "Fix SSO/OIDC callback state verification mismatch causing 400 errors",
},
});

const result = await runHostedGroomer();

expect(result!.mutationPlan?.titleRewritten).toBe(true);
expect(result!.mutationPlan?.originalTitle).toBe("P0");
expect(mocks.updateIssueTitleAndBody).toHaveBeenCalledWith(
"org/repo",
42,
expect.objectContaining({ title: "Fix SSO/OIDC callback state verification mismatch causing 400 errors" }),
);
expect(result!.appliedMutations?.titleUpdated).toBe(true);
});

it("rewrites a single-word generic title like TODO", async () => {
const badCandidate = { ...mockCandidate, title: "TODO" };
mocks.selectGroomingCandidate.mockResolvedValue(badCandidate);
mocks.validateGroomerOutput.mockReturnValue({
valid: true,
parsed: { ...mockOutput, proposedTitle: "Implement user authentication flow" },
});

const result = await runHostedGroomer();

expect(result!.mutationPlan?.titleRewritten).toBe(true);
expect(mocks.updateIssueTitleAndBody).toHaveBeenCalled();
});

it("rewrites an empty title", async () => {
const badCandidate = { ...mockCandidate, title: "" };
mocks.selectGroomingCandidate.mockResolvedValue(badCandidate);
mocks.validateGroomerOutput.mockReturnValue({
valid: true,
parsed: { ...mockOutput, proposedTitle: "Add missing error handling for database connections" },
});

const result = await runHostedGroomer();

expect(result!.mutationPlan?.titleRewritten).toBe(true);
expect(mocks.updateIssueTitleAndBody).toHaveBeenCalled();
});

// ─── Body enrichment tests ───

it("does not enrich a substantial body", async () => {
const goodCandidate = {
...mockCandidate,
body: "This is a detailed issue description that explains the problem clearly with enough context and detail for developers to understand what needs to be done.",
};
mocks.selectGroomingCandidate.mockResolvedValue(goodCandidate);
mocks.validateGroomerOutput.mockReturnValue({
valid: true,
parsed: { ...mockOutput, proposedBody: "Enriched body content." },
});

const result = await runHostedGroomer();

expect(result!.mutationPlan?.bodyEnriched).toBe(false);
expect(mocks.updateIssueTitleAndBody).not.toHaveBeenCalled();
});

it("enriches a sparse body", async () => {
const sparseCandidate = { ...mockCandidate, body: "Broken." };
mocks.selectGroomingCandidate.mockResolvedValue(sparseCandidate);
const enrichedBody = `## Context
This issue relates to the login flow.

## What's known
- Login fails after password reset

## Suggested approach
Investigate session handling in auth module.`;
mocks.validateGroomerOutput.mockReturnValue({
valid: true,
parsed: { ...mockOutput, proposedBody: enrichedBody },
});

const result = await runHostedGroomer();

expect(result!.mutationPlan?.bodyEnriched).toBe(true);
expect(mocks.updateIssueTitleAndBody).toHaveBeenCalledWith(
"org/repo",
42,
expect.objectContaining({ body: enrichedBody }),
);
expect(result!.appliedMutations?.bodyUpdated).toBe(true);
});

it("enriches a null body", async () => {
const noBodyCandidate = { ...mockCandidate, body: null };
mocks.selectGroomingCandidate.mockResolvedValue(noBodyCandidate);
const enrichedBody = "## Description\nMore detail needed.\n\n## Labels\npriority/p0";
mocks.validateGroomerOutput.mockReturnValue({
valid: true,
parsed: { ...mockOutput, proposedBody: enrichedBody },
});

const result = await runHostedGroomer();

expect(result!.mutationPlan?.bodyEnriched).toBe(true);
expect(mocks.updateIssueTitleAndBody).toHaveBeenCalled();
});

it("applies both title rewrite and body enrichment together", async () => {
const badCandidate = { ...mockCandidate, title: "P0", body: "Fix." };
mocks.selectGroomingCandidate.mockResolvedValue(badCandidate);
const enrichedBody = "## Context\nSSO login is broken.\n\n## What's known\nState verification fails on callback.";
mocks.validateGroomerOutput.mockReturnValue({
valid: true,
parsed: {
...mockOutput,
proposedTitle: "Fix SSO callback state mismatch",
proposedBody: enrichedBody,
},
});

const result = await runHostedGroomer();

expect(result!.mutationPlan?.titleRewritten).toBe(true);
expect(result!.mutationPlan?.bodyEnriched).toBe(true);
expect(mocks.updateIssueTitleAndBody).toHaveBeenCalledWith(
"org/repo",
42,
expect.objectContaining({
title: "Fix SSO callback state mismatch",
body: enrichedBody,
}),
);
expect(result!.appliedMutations?.titleUpdated).toBe(true);
expect(result!.appliedMutations?.bodyUpdated).toBe(true);
});

it("dry-run includes title/body plan but does not call GitHub API", async () => {
const badCandidate = { ...mockCandidate, title: "P0" };
mocks.selectGroomingCandidate.mockResolvedValue(badCandidate);
mocks.getHostedGroomerConfig.mockReturnValue({ ...mockConfig, dryRun: true });
mocks.validateGroomerOutput.mockReturnValue({
valid: true,
parsed: { ...mockOutput, proposedTitle: "Fix the thing" },
});

const result = await runHostedGroomer();

expect(result!.dryRun).toBe(true);
expect(result!.mutationPlan?.titleRewritten).toBe(true);
expect(mocks.updateIssueTitleAndBody).not.toHaveBeenCalled();
});

it("skips title/body update when LLM does not propose changes", async () => {
mocks.validateGroomerOutput.mockReturnValue({
valid: true,
parsed: mockOutput, // no proposedTitle or proposedBody
});

await runHostedGroomer();

expect(mocks.updateIssueTitleAndBody).not.toHaveBeenCalled();
});
});
Loading