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
30 changes: 29 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -1268,6 +1288,14 @@ export class GittensoryMcp {
};
}

private async checkIssueSlop(input: z.infer<z.ZodObject<typeof checkIssueSlopShape>>): Promise<ToolPayload> {
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<string, unknown>,
};
}

private async predictGate(input: z.infer<z.ZodObject<typeof predictGateShape>>): Promise<ToolPayload> {
this.requireContributorAccess(input.login);
const repoFullName = `${input.owner}/${input.repo}`;
Expand Down
8 changes: 7 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}

Expand Down
84 changes: 84 additions & 0 deletions src/signals/slop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<!--[\s\S]*?-->/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;
}
Expand Down
24 changes: 24 additions & 0 deletions test/unit/mcp-check-slop-risk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
28 changes: 28 additions & 0 deletions test/unit/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
49 changes: 49 additions & 0 deletions test/unit/slop.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<!-- describe the bug here -->\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: "<!-- nothing here -->" }).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();
});
});