From 1c68a877b107f48ac46f8f3ef01632e1a538bb21 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 14 Jun 2026 09:19:53 -0700 Subject: [PATCH] feat(slop): issue-side slop triage (#533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends anti-slop from PRs to issues — a deterministic, advisory-only maintainer triage signal for clearly low-effort issues. Issues have no gate, so these NEVER block; they just flag issues for the maintainer. - src/signals/slop.ts: buildIssueSlopAssessment with two high-precision, conservative signals — empty_issue_body (empty/whitespace body) and unfilled_issue_template (a body that reduces to nothing after stripping template scaffolding — an opened-but-unfilled template). Mutually exclusive; any real prose -> clean, so a terse-but-real issue stays clean. - processors: the issue webhook path computes issue slop when the repo opted in (slopGateMode != off) and appends the findings to the (maintainer-facing) issue advisory. - MCP: gittensory_check_issue_slop — pure local-metadata (title + body, no repo/auth scope), mirrors gittensory_check_slop_risk; returns slopRisk/band/findings + the issue rubric. Tests: the detector (empty/unfilled/clean/mutual-exclusion + direct guards), the MCP tool, and the webhook wiring (opted-in repo gets the finding, default-off repo does not). 97% coverage held. Closes #533. --- src/mcp/server.ts | 30 +++++++++- src/queue/processors.ts | 8 ++- src/signals/slop.ts | 84 +++++++++++++++++++++++++++ test/unit/mcp-check-slop-risk.test.ts | 24 ++++++++ test/unit/queue.test.ts | 28 +++++++++ test/unit/slop.test.ts | 49 ++++++++++++++++ 6 files changed, 221 insertions(+), 2 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 52213989ea..3949b745c2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -83,7 +83,7 @@ import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-mo import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildPredictedGateVerdict } from "../rules/predicted-gate"; -import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "../signals/slop"; +import { buildIssueSlopAssessment, buildSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN, SLOP_RUBRIC_MARKDOWN } from "../signals/slop"; import { buildRepoDataQuality } from "../signals/data-quality"; import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; @@ -403,6 +403,15 @@ const checkSlopRiskOutputSchema = { rubric: z.string().optional(), }; +// Issue-side slop triage (#533): pure local-metadata, like checkSlopRisk — the agent supplies the issue +// title + body, nothing to scope. Advisory-only; issues never block. +const checkIssueSlopShape = { + title: z.string().max(500).optional(), + body: z.string().max(40000).optional(), +}; + +const checkIssueSlopOutputSchema = checkSlopRiskOutputSchema; + const predictGateOutputSchema = { predicted: z.boolean().optional(), basis: z.string().optional(), @@ -652,6 +661,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.checkSlopRisk(input)), ); + server.registerTool( + "gittensory_check_issue_slop", + { + description: + "Assess the deterministic slop risk of an issue from its title + body alone (no repo data) — flags clearly low-effort issues (empty body, an unfilled template) for triage. Returns slopRisk (0-100), band, findings, and the rubric. Advisory-only: issues never block.", + inputSchema: checkIssueSlopShape, + outputSchema: checkIssueSlopOutputSchema, + }, + async (input) => this.toolResult(await this.checkIssueSlop(input)), + ); + server.registerTool( "gittensory_pr_outcome", { @@ -1268,6 +1288,14 @@ export class GittensoryMcp { }; } + private async checkIssueSlop(input: z.infer>): Promise { + const assessment = buildIssueSlopAssessment(input); + return { + summary: `Issue slop risk: ${assessment.slopRisk}/100 (${assessment.band}).`, + data: { ...assessment, rubric: ISSUE_SLOP_RUBRIC_MARKDOWN } as unknown as Record, + }; + } + private async predictGate(input: z.infer>): Promise { this.requireContributorAccess(input.login); const repoFullName = `${input.owner}/${input.repo}`; diff --git a/src/queue/processors.ts b/src/queue/processors.ts index d037f2ed17..c30a8c8fd0 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -132,7 +132,7 @@ import { PR_PANEL_RETRIGGER_MARKER, unionScopedOverlapClusters, } from "../signals/engine"; -import { buildSlopAssessment, type SlopBand } from "../signals/slop"; +import { buildIssueSlopAssessment, buildSlopAssessment, type SlopBand } from "../signals/slop"; import { runGittensoryAiSlopAdvisory } from "../services/ai-slop"; import { decidePublicSurface } from "../signals/settings-preview"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; @@ -763,6 +763,12 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str const issue = await upsertIssueFromGitHub(env, payload.repository.full_name, payload.issue); const repo = await getRepository(env, payload.repository.full_name); const advisory = buildIssueAdvisory(repo, issue); + // Issue-side slop triage (#533): opt-in via slopGateMode, advisory-only (issues have no gate, and + // the issue advisory is maintainer-facing — never a public comment). Flags clearly low-effort issues. + const issueSettings = await resolveRepositorySettings(env, payload.repository.full_name); + if (issueSettings.slopGateMode !== "off") { + advisory.findings.push(...buildIssueSlopAssessment({ title: issue.title, body: issue.body }).findings); + } await persistAdvisory(env, advisory); } diff --git a/src/signals/slop.ts b/src/signals/slop.ts index b5a2bc82a6..803ed34750 100644 --- a/src/signals/slop.ts +++ b/src/signals/slop.ts @@ -178,6 +178,90 @@ function buildTrivialChurnFinding(changedLineCount: number, nonCodeLineCount: nu }; } +// ─── Issue-side slop triage (#533) ────────────────────────────────────────────────────────────────── +// Advisory-only maintainer triage signal for low-effort issues — there is no issue gate, so these never +// block. High-precision signals only (an empty issue body is sometimes legitimate, so the bar is set at +// "clearly low-effort": empty body, or a template opened and submitted without being filled in). + +export type IssueSlopAssessmentInput = { + title?: string | null | undefined; + body?: string | null | undefined; +}; + +export const ISSUE_SLOP_WEIGHTS = { + unfilledTemplate: 50, + emptyBody: 40, +} as const; + +export const ISSUE_SLOP_RUBRIC_MARKDOWN = [ + "# Gittensory issue slop triage rubric", + "", + "- `clean`: 0", + "- `low`: 1-24", + "- `elevated`: 25-59", + "- `high`: 60-100", + "", + "Advisory-only (issues never block). Current deterministic signals:", + "- empty issue body", + "- issue template opened but left unfilled", +].join("\n"); + +export function buildIssueSlopAssessment(input: IssueSlopAssessmentInput): SlopAssessment { + const findings: SignalFinding[] = []; + const emptyBodyFinding = buildEmptyIssueBodyFinding(input); + // An empty body and an unfilled template are mutually exclusive (the latter needs a non-empty body), so + // only probe for the template when there IS a body to inspect. + const unfilledTemplateFinding = emptyBodyFinding ? null : buildUnfilledIssueTemplateFinding(input); + if (unfilledTemplateFinding) findings.push(unfilledTemplateFinding); + if (emptyBodyFinding) findings.push(emptyBodyFinding); + + const slopRisk = clamp( + (emptyBodyFinding ? ISSUE_SLOP_WEIGHTS.emptyBody : 0) + (unfilledTemplateFinding ? ISSUE_SLOP_WEIGHTS.unfilledTemplate : 0), + 0, + 100, + ); + return { slopRisk, band: slopBandFor(slopRisk), findings }; +} + +export function buildEmptyIssueBodyFinding(input: IssueSlopAssessmentInput): SignalFinding | null { + if ((input.body ?? "").trim().length > 0) return null; + // Static, public-safe text (no interpolation) — no sanitizer guard needed, unlike the PR findings. + const detail = "This issue was opened with an empty body."; + return { + code: "empty_issue_body", + title: "Issue has no description", + severity: "warning", + detail, + action: "Add a clear description: what is wrong, where, and why it matters.", + publicText: detail, + }; +} + +// Fires when a non-empty body reduces to NOTHING substantive after stripping template scaffolding (HTML +// comments, markdown headings, empty bullets/checkboxes, residual punctuation) — i.e. the submitter opened +// the issue template and submitted it without filling anything in. Any real prose survives the strip → no fire. +export function buildUnfilledIssueTemplateFinding(input: IssueSlopAssessmentInput): SignalFinding | null { + const body = (input.body ?? "").trim(); + if (body.length === 0) return null; + const substantive = body + .replace(//g, "") // HTML comment placeholders + .replace(/^#{1,6}\s.*$/gm, "") // markdown heading lines + .replace(/^\s*[-*]\s*(\[[ xX]\])?\s*$/gm, "") // empty bullets / checkboxes + .replace(/[\s>#*_`+-]/g, "") // residual markdown punctuation + whitespace + .trim(); + if (substantive.length > 0) return null; + // Static, public-safe text (no interpolation) — no sanitizer guard needed. + const detail = "The issue body contains only an unfilled template (headings or comment placeholders, no details)."; + return { + code: "unfilled_issue_template", + title: "Issue template left unfilled", + severity: "warning", + detail, + action: "Fill in the template sections with the actual problem details.", + publicText: detail, + }; +} + function nonNegative(value: number | undefined): number { return Number.isFinite(value) && (value ?? 0) > 0 ? Math.trunc(value as number) : 0; } diff --git a/test/unit/mcp-check-slop-risk.test.ts b/test/unit/mcp-check-slop-risk.test.ts index 0ab566a356..4dcb97c89d 100644 --- a/test/unit/mcp-check-slop-risk.test.ts +++ b/test/unit/mcp-check-slop-risk.test.ts @@ -44,3 +44,27 @@ describe("MCP gittensory_check_slop_risk", () => { expect(data.band).toBe("clean"); }); }); + +describe("MCP gittensory_check_issue_slop (#533)", () => { + it("flags a low-effort issue (empty body) from title+body alone and returns the issue rubric", async () => { + const client = await connect(); + const result = await client.callTool({ name: "gittensory_check_issue_slop", arguments: { title: "broken", body: " " } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { slopRisk: number; band: string; findings: Array<{ code: string }>; rubric: string }; + expect(data.slopRisk).toBeGreaterThan(0); + expect(data.findings.map((f) => f.code)).toEqual(["empty_issue_body"]); + expect(data.rubric).toContain("issue slop triage rubric"); + expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward|payout|trust score/i); + }); + + it("returns a clean assessment for a genuine issue", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "gittensory_check_issue_slop", + arguments: { title: "500 on save", body: "Clicking Save on /settings returns a 500; expected a redirect. Repro: open /settings, submit." }, + }); + const data = result.structuredContent as { slopRisk: number; band: string }; + expect(data.slopRisk).toBe(0); + expect(data.band).toBe("clean"); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 3193f0ed59..bab8f8ad04 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -3803,6 +3803,34 @@ describe("queue processors", () => { expect(evaluateJob).toBeDefined(); expect(evaluateJob!.event.recipientLogin).toBe("contributor"); }); + + it("appends issue-side slop findings to the issue advisory only when slop is opted in (#533)", async () => { + const env = createTestEnv(); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); // no .gittensory.yml → empty manifest + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositoryFromGitHub(env, { name: "other", full_name: "JSONbored/other", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", slopGateMode: "advisory" }); + // JSONbored/other keeps the default slopGateMode "off". + + const emptyBodyIssue = (repoFull: string, name: string, number: number) => ({ + type: "github-webhook" as const, + deliveryId: `issue-slop-${number}`, + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name, full_name: repoFull, private: false, owner: { login: "JSONbored" } }, + issue: { number, title: "Something is broken", state: "open", user: { login: "reporter" }, body: " " }, + }, + }); + await processJob(env, emptyBodyIssue("JSONbored/gittensory", "gittensory", 501)); + await processJob(env, emptyBodyIssue("JSONbored/other", "other", 502)); + + const slopOn = await env.DB.prepare("select findings_json from advisories where target_type = 'issue' and repo_full_name = ?").bind("JSONbored/gittensory").first<{ findings_json: string }>(); + const slopOff = await env.DB.prepare("select findings_json from advisories where target_type = 'issue' and repo_full_name = ?").bind("JSONbored/other").first<{ findings_json: string }>(); + expect(slopOn?.findings_json).toContain("empty_issue_body"); // opted in → triage finding present + expect(slopOff?.findings_json ?? "").not.toContain("empty_issue_body"); // default off → no slop finding + }); }); function completeSegment(repoFullName: string, segment: "labels" | "open_issues" | "open_pull_requests") { diff --git a/test/unit/slop.test.ts b/test/unit/slop.test.ts index 40bcf9d2a0..af04807430 100644 --- a/test/unit/slop.test.ts +++ b/test/unit/slop.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; import { + buildEmptyIssueBodyFinding, + buildIssueSlopAssessment, buildMissingTestEvidenceFinding, buildSlopAssessment, buildTrivialWhitespaceChurnFinding, + buildUnfilledIssueTemplateFinding, + ISSUE_SLOP_WEIGHTS, SLOP_RUBRIC_MARKDOWN, SLOP_WEIGHTS, } from "../../src/signals/slop"; @@ -183,3 +187,48 @@ describe("buildTrivialWhitespaceChurnFinding", () => { expect(JSON.stringify(finding)).not.toMatch(FORBIDDEN_PUBLIC_TERMS); }); }); + +describe("buildIssueSlopAssessment (#533 issue-side triage)", () => { + it("flags an empty/whitespace body", () => { + const result = buildIssueSlopAssessment({ title: "It is broken", body: " \n " }); + expect(result.findings.map((f) => f.code)).toEqual(["empty_issue_body"]); + expect(result.slopRisk).toBe(ISSUE_SLOP_WEIGHTS.emptyBody); + expect(result.band).toBe("elevated"); + expect(JSON.stringify(result)).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + }); + + it("treats an omitted body as empty", () => { + expect(buildIssueSlopAssessment({ title: "No body at all" }).findings.map((f) => f.code)).toEqual(["empty_issue_body"]); + }); + + it("flags a body that is only an unfilled template (headings + comment placeholders)", () => { + const body = "### Description\n\n\n### Steps to reproduce\n\n- [ ]\n"; + const result = buildIssueSlopAssessment({ title: "Bug", body }); + expect(result.findings.map((f) => f.code)).toEqual(["unfilled_issue_template"]); + expect(result.slopRisk).toBe(ISSUE_SLOP_WEIGHTS.unfilledTemplate); + expect(result.band).toBe("elevated"); + }); + + it("does NOT flag a genuine issue, even a terse one (conservative, advisory-only)", () => { + expect(buildIssueSlopAssessment({ title: "Typo", body: "The README says 'recieve' on line 12; should be 'receive'." })).toEqual({ + slopRisk: 0, + band: "clean", + findings: [], + }); + // A filled template (prose under the headings) is clean. + expect(buildIssueSlopAssessment({ title: "Bug", body: "### Description\nClicking save throws a 500.\n### Steps\nOpen /save and submit." }).findings).toEqual([]); + }); + + it("empty body and unfilled template are mutually exclusive (never both)", () => { + // An empty body fires only empty_issue_body; a comment-only body fires only unfilled_issue_template. + expect(buildIssueSlopAssessment({ body: "" }).findings.map((f) => f.code)).toEqual(["empty_issue_body"]); + expect(buildIssueSlopAssessment({ body: "" }).findings.map((f) => f.code)).toEqual(["unfilled_issue_template"]); + }); + + it("finding builders are correct when called directly (the standalone guards)", () => { + // The unfilled-template builder guards an empty body for direct callers (assessment handles it upstream). + expect(buildUnfilledIssueTemplateFinding({ body: "" })).toBeNull(); + expect(buildUnfilledIssueTemplateFinding({ body: "Real prose explaining the bug." })).toBeNull(); + expect(buildEmptyIssueBodyFinding({ body: "has content" })).toBeNull(); + }); +});