diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index e73eac857d..37308b3da1 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -139,6 +139,12 @@ const checkBeforeStartShape = { plannedPaths: z.array(z.string()).optional(), }; +const lintPrTextShape = { + commitMessages: z.array(z.string()).max(50).optional(), + prBody: z.string().optional(), + linkedIssue: z.number().int().positive().optional(), +}; + const preflightShape = { repoFullName: z.string().min(3), contributorLogin: z.string().min(1).optional(), @@ -314,6 +320,16 @@ server.registerTool( }, ); +server.registerTool( + "gittensory_lint_pr_text", + { + description: + "Lint a commit message + PR body against the gittensor traceability/no-issue-rationale and Conventional Commit rubric before submitting. Returns a deterministic verdict (strong/adequate/weak) plus specific public-safe fixes. No source upload.", + inputSchema: lintPrTextShape, + }, + async (input) => toolResult("Gittensory PR-text lint.", await apiPost("/v1/lint/pr-text", input)), +); + server.registerTool( "gittensory_preflight_local_diff", { diff --git a/src/api/routes.ts b/src/api/routes.ts index 5c157ce4a7..d2247b2e94 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -181,6 +181,7 @@ import { buildLaneAdvice, buildLinkedIssueValidation, buildLocalDiffPreflightResult, + buildPrTextLint, buildMaintainerCutReadiness, buildMaintainerLaneReport, buildPullRequestMaintainerPacket, @@ -352,6 +353,12 @@ const checkBeforeStartSchema = z.object({ plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), }); +const lintPrTextSchema = z.object({ + commitMessages: z.array(z.string().max(PREFLIGHT_LIMITS.bodyChars)).max(50).optional(), + prBody: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), + linkedIssue: z.number().int().positive().optional(), +}); + const skippedPrAuditQuerySchema = z .object({ limit: z.coerce.number().int().optional(), @@ -2062,6 +2069,13 @@ export function createApp() { }); }); + app.post("/v1/lint/pr-text", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = lintPrTextSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_lint_pr_text_request", issues: parsed.error.issues }, 400); + return c.json(buildPrTextLint(parsed.data)); + }); + app.post("/v1/preflight/pr", async (c) => { const body = await c.req.json().catch(() => null); const parsed = preflightSchema.safeParse(body); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 88b3db0dc2..831a42da91 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -75,6 +75,7 @@ import { buildLocalDiffPreflightResult, buildPreflightResult, buildPreStartCheck, + buildPrTextLint, buildQueueHealth, buildRegistryChangeReport, buildRoleContext, @@ -142,6 +143,12 @@ const checkBeforeStartShape = { plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), }; +const lintPrTextShape = { + commitMessages: z.array(z.string().max(PREFLIGHT_LIMITS.bodyChars)).max(50).optional(), + prBody: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), + linkedIssue: z.number().int().positive().optional(), +}; + const preflightShape = { repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), @@ -508,6 +515,15 @@ const checkBeforeStartOutputSchema = { report: z.unknown().optional(), }; +const lintPrTextOutputSchema = { + verdict: z.string().optional(), + score: z.number().optional(), + components: z.unknown().optional(), + fixes: z.unknown().optional(), + summary: z.string().optional(), + generatedAt: z.string().optional(), +}; + export async function handleMcpRequest(c: AppContext): Promise { if (c.req.method === "OPTIONS") return new Response(null, { status: 204 }); const identity = await authenticateMcpRequest(c); @@ -786,6 +802,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.checkBeforeStart(input)), ); + server.registerTool( + "gittensory_lint_pr_text", + { + description: + "Lint a commit message + PR body against the gittensor traceability/no-issue-rationale and Conventional Commit rubric, before submitting. Returns a deterministic quality verdict (strong/adequate/weak) and specific public-safe fixes. Metadata only; no source upload, no GitHub writes.", + inputSchema: lintPrTextShape, + outputSchema: lintPrTextOutputSchema, + }, + async (input) => this.toolResult(this.lintPrText(input)), + ); + server.registerTool( "gittensory_preflight_local_diff", { @@ -1201,6 +1228,14 @@ export class GittensoryMcp { }; } + private lintPrText(input: { commitMessages?: string[] | undefined; prBody?: string | undefined; linkedIssue?: number | undefined }): ToolPayload { + const report = buildPrTextLint(input); + return { + summary: `Gittensory PR-text lint verdict: ${report.verdict}.`, + data: report as unknown as Record, + }; + } + private async canAccessRepo(fullName: string): Promise { if (this.identity.kind !== "session") return true; const [scope, repo] = await Promise.all([this.loadSessionAccessScope(), getRepository(this.env, fullName)]); diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 26d94e6498..98bc819dcd 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -4293,7 +4293,156 @@ function queuePressureOpenPullRequestScore(openPullRequests: number): number { return 3; } -function hasClearNoIssueRationale(pr: PullRequestRecord): boolean { +export type PrTextLintInput = { + commitMessages?: string[] | undefined; + prBody?: string | undefined; + linkedIssue?: number | undefined; +}; + +export type PrTextLintComponent = { + key: "traceability" | "commit_message" | "pr_body"; + label: string; + status: "ok" | "weak"; + evidence: string; + fix?: string | undefined; +}; + +export type PrTextLintReport = { + generatedAt: string; + verdict: "strong" | "adequate" | "weak"; + /** + * 0-100 PR-text quality score from the deterministic rubric (sum of per-component weights; weak + * components score 25% of their weight). Advisory sub-signal only — `verdict` is authoritative. + * Because traceability is a hard gate for the verdict but only one weighted component of the score, + * the two can rank-disagree (e.g. a strong commit + body with no linked issue scores ~78 yet the + * verdict is "weak"). Rank by `verdict`, not `score`. Not a Gittensor reward/trust score. + */ + score: number; + components: PrTextLintComponent[]; + fixes: string[]; + summary: string; +}; + +const GENERIC_COMMIT_PATTERN = /^(?:wip|fix(?:es|ed|ing)?|updat(?:e|es|ed|ing)|change[sd]?|edit[sd]?|patch|minor|tweak[sd]?|misc|cleanup|chore|stuff|temp|tmp|test|final|done|commit|asdf+|\.+)\b[\s.!]*$/i; +// Conventional Commit subject: one of CONTRIBUTING's allowed types, optional `(scope)`, optional `!`, +// then `: ` and a non-empty summary (e.g. `feat(api): add cursor pagination`). Single source of truth +// with CONTRIBUTING.md "Commit And PR Titles". +const CONVENTIONAL_COMMIT_PATTERN = /^(?:feat|fix|test|docs|refactor|build|ci|chore|revert)(?:\([^()\r\n]+\))?!?:\s+\S/i; +const PR_TEXT_LINT_WEIGHTS = { traceability: 30, commit_message: 35, pr_body: 35 } as const; + +function stripPrBodyScaffolding(body: string): string { + return body + .replace(//g, " ") + .replace(/^#{1,6}\s.*$/gm, " ") + .replace(/^\s*[-*]\s*\[[ xX]\]/gm, " ") + .replace(/[#>*_`[\]()]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Deterministic commit-message + PR-body rubric linter. Catches generic/empty AI-slop text before + * submit and returns a quality verdict plus specific, public-safe fixes. Reuses the gittensor + * traceability/no-issue-rationale rubric ({@link hasClearNoIssueRationale}, {@link tokenize}, + * {@link STOPWORDS}) shared with the public readiness score. All output is routed through + * {@link sanitizePublicComment}; no private scoring is exposed. + */ +export function buildPrTextLint(input: PrTextLintInput): PrTextLintReport { + const commitMessages = (input.commitMessages ?? []).map((message) => message.trim()).filter((message) => message.length > 0); + const prBody = (input.prBody ?? "").trim(); + const linkedIssue = typeof input.linkedIssue === "number" && input.linkedIssue > 0 ? input.linkedIssue : undefined; + + const hasRationale = hasClearNoIssueRationale({ title: "", body: prBody }); + const traceabilityOk = linkedIssue !== undefined || hasRationale; + const traceability: PrTextLintComponent = traceabilityOk + ? { + key: "traceability", + label: "Traceability", + status: "ok", + evidence: linkedIssue !== undefined ? `Linked issue #${linkedIssue}.` : "PR body includes a no-issue rationale.", + } + : { + key: "traceability", + label: "Traceability", + status: "weak", + evidence: "No linked issue and no no-issue rationale in the PR body.", + fix: 'Link the issue this PR resolves (e.g. "Fixes #123"), or explain in the body why no issue applies.', + }; + + const primaryCommit = commitMessages[0] ?? ""; + const commitTokens = tokenize(commitMessages.join(" ")); + const commitGeneric = primaryCommit.length > 0 && GENERIC_COMMIT_PATTERN.test(primaryCommit); + // The `^`-anchored pattern matches against the subject line at the start of the message. + const commitConventional = CONVENTIONAL_COMMIT_PATTERN.test(primaryCommit); + const commitOk = commitConventional && primaryCommit.length >= 15 && commitTokens.length >= 2 && !commitGeneric; + const commitMessage: PrTextLintComponent = commitOk + ? { key: "commit_message", label: "Commit message", status: "ok", evidence: "Commit message is specific and follows Conventional Commit format." } + : { + key: "commit_message", + label: "Commit message", + status: "weak", + evidence: + commitMessages.length === 0 + ? "No commit message was provided." + : commitGeneric + ? "Commit message is generic (e.g. update/fix/wip)." + : !commitConventional + ? "Commit message does not follow Conventional Commit format (type(scope): summary)." + : "Commit message is too short or lacks specific detail.", + fix: "Use a Conventional Commit subject (type(scope): summary, e.g. feat(api): add cursor pagination) that names what changed and why; avoid generic words like update, fix, or wip on their own.", + }; + + const strippedBody = stripPrBodyScaffolding(prBody); + const bodyTokens = tokenize(strippedBody); + const bodyLooksTemplated = prBody.length > 0 && /\[[ xX]\]|\n\n## Checklist\n- [ ] Tests\n- [ ] Docs"; + const report = buildPrTextLint({ commitMessages: [GOOD_COMMIT], prBody: templated, linkedIssue: 160 }); + expect(component(report, "pr_body").status).toBe("weak"); + expect(component(report, "pr_body").evidence).toMatch(/unfilled template/i); + assertPublicSafe(report); + }); + + it("flags a thin PR body", () => { + const report = buildPrTextLint({ commitMessages: [GOOD_COMMIT], prBody: "fixes stuff", linkedIssue: 160 }); + expect(component(report, "pr_body").evidence).toMatch(/thin/i); + assertPublicSafe(report); + }); + + it("does not flag a substantive non-Latin PR body as thin", () => { + const cyrillic = + "Этот запрос добавляет курсорную пагинацию к конечной точке меток репозитория, чтобы возвращались все страницы результатов для больших репозиториев."; + const cjk = "この変更は、リポジトリのラベルエンドポイントにカーソルベースのページネーションを追加し、最初のページ以降のラベルも返されるようにします。"; + for (const prBody of [cyrillic, cjk]) { + const report = buildPrTextLint({ commitMessages: [GOOD_COMMIT], prBody, linkedIssue: 160 }); + expect(component(report, "pr_body").status).toBe("ok"); + assertPublicSafe(report); + } + }); + + it("returns weak with all fixes when every dimension is low-effort", () => { + const report = buildPrTextLint({ commitMessages: ["update"], prBody: "" }); + expect(report.verdict).toBe("weak"); + expect(report.fixes).toHaveLength(3); + expect(report.score).toBeLessThan(50); + expect(report.summary).toMatch(/low-effort/i); + assertPublicSafe(report); + }); + + it("handles entirely empty input deterministically", () => { + const report = buildPrTextLint({}); + expect(report.verdict).toBe("weak"); + expect(report.components).toHaveLength(3); + assertPublicSafe(report); + }); +});