From 1159753a4b73dd44fefd398f4944a58a68da623c Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:43:24 +0200 Subject: [PATCH 1/2] feat(issues): generate contributor issue drafts from repo policy Add a draft-generation service with dry-run by default, duplicate detection via title fingerprints and HTML markers, maintainer API route, and audit-logged optional GitHub create when create is explicit and dryRun is false. --- apps/gittensory-ui/public/openapi.json | 33 ++ src/api/routes.ts | 39 ++ src/env.d.ts | 1 + src/openapi/spec.ts | 9 + src/services/contributor-issue-draft.ts | 517 ++++++++++++++++++ test/unit/contributor-issue-draft.test.ts | 492 +++++++++++++++++ .../routes-contributor-issue-draft.test.ts | 152 +++++ 7 files changed, 1243 insertions(+) create mode 100644 src/services/contributor-issue-draft.ts create mode 100644 test/unit/contributor-issue-draft.test.ts create mode 100644 test/unit/routes-contributor-issue-draft.test.ts diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index a7306c39..bf1795d5 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -14593,6 +14593,39 @@ } } } + }, + "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { + "post": { + "responses": { + "200": { + "description": "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid request or explicit create without dryRun false" + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index c7fd41c7..90c5f2fa 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -191,6 +191,7 @@ import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from " import { compileFocusManifestPolicy } from "../signals/focus-manifest"; import { loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack"; +import { generateContributorIssueDrafts } from "../services/contributor-issue-draft"; import { buildRepoSettingsPreview, type PublicSurfaceSkipReason } from "../signals/settings-preview"; import { buildGittensorConfigRecommendation, @@ -464,6 +465,12 @@ const repositorySettingsSchema = z.object({ .default(DEFAULT_COMMAND_AUTHORIZATION_POLICY), }); +const contributorIssueDraftGenerateSchema = z.object({ + dryRun: z.boolean().optional().default(true), + create: z.boolean().optional().default(false), + limit: z.number().int().min(1).max(20).optional().default(5), +}); + const settingsPreviewSchema = z.object({ sample: z .object({ @@ -1547,6 +1554,33 @@ export function createApp() { return c.json(response); }); + app.post("/v1/repos/:owner/:repo/contributor-issue-drafts/generate", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]); + if (forbidden) return forbidden; + const identity = await authenticateRequestIdentity(c); + const repo = await getRepository(c.env, fullName); + if (identity?.kind === "session") { + const repoForbidden = await requireSessionRepoAccess(c, identity, fullName, repo); + if (repoForbidden) return repoForbidden; + } + const body = await c.req.json().catch(() => null); + if (body === null) return c.json({ error: "invalid_json" }, 400); + const parsed = contributorIssueDraftGenerateSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_contributor_issue_draft_request", issues: parsed.error.issues }, 400); + if (parsed.data.create && parsed.data.dryRun !== false) { + return c.json({ error: "explicit_create_requires_dry_run_false" }, 400); + } + return c.json( + await generateContributorIssueDrafts(c.env, fullName, { + dryRun: parsed.data.dryRun, + create: parsed.data.create, + limit: parsed.data.limit, + requestedBy: identity?.kind === "session" ? identity.actor : "api", + }), + ); + }); + app.get("/v1/repos/:owner/:repo/settings", async (c) => { const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; return c.json(await getRepositorySettings(c.env, fullName)); @@ -3454,6 +3488,7 @@ function canSessionAccessPath(env: Env, identity: Extract { const bearer = await authenticatePrivateToken(c.env, extractBearerToken(c.req.header("authorization"))); if (bearer) return bearer; diff --git a/src/env.d.ts b/src/env.d.ts index 2d96b126..d1950955 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -26,6 +26,7 @@ declare global { GITTENSORY_AUTO_FILE_DRIFT_ISSUES?: string; GITTENSORY_DRIFT_ISSUE_REPO?: string; GITTENSORY_DRIFT_ISSUE_TOKEN?: string; + GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN?: string; PRODUCT_USAGE_HASH_SALT?: string; GITTENSORY_API_TOKEN: string; GITTENSORY_MCP_TOKEN: string; diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index d2f2d328..f412e227 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -410,6 +410,15 @@ export function buildOpenApiSpec() { 404: { description: "Repository is not accepted or preview unavailable" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate", + responses: { + 200: { description: "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, + 400: { description: "Invalid request or explicit create without dryRun false" }, + 403: { description: "Insufficient role" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/settings", diff --git a/src/services/contributor-issue-draft.ts b/src/services/contributor-issue-draft.ts new file mode 100644 index 00000000..6409447a --- /dev/null +++ b/src/services/contributor-issue-draft.ts @@ -0,0 +1,517 @@ +import { + getRepository, + getRepositorySettings, + listIssueSignalSample, + listOpenIssues, + listOpenPullRequests, + listRecentMergedPullRequests, + listRepoLabels, + countOpenIssues, + countOpenPullRequests, + getLatestRepoGithubTotalsSnapshot, + listUpstreamDriftReports, + recordAuditEvent, +} from "../db/repositories"; +import type { IssueRecord, RepositoryRecord, RepositorySettings } from "../types"; +import { sha256Hex } from "../utils/crypto"; +import { jsonString, nowIso, repoParts } from "../utils/json"; +import { + buildCollisionReport, + buildConfigQuality, + buildContributorIntakeHealth, + buildLabelAudit, + buildLaneAdvice, + buildQueueHealth, + type ConfigQuality, + type ContributorIntakeHealth, + type LabelAudit, + type LaneAdvice, + type QueueHealth, +} from "../signals/engine"; +import { isFocusManifestPublicSafe, type FocusManifest } from "../signals/focus-manifest"; +import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; +import { + buildRepoPolicyReadiness, + type RepoPolicyReadinessWarning, + type RepoPolicyReadinessWarningCode, +} from "../signals/repo-policy-readiness"; +import { registryHyperparameterDriftWarningsForRepo } from "../upstream/ruleset"; + +export const CONTRIBUTOR_ISSUE_DRAFT_MARKER_PREFIX = "gittensory-contributor-draft"; + +export type ContributorIssueDraftTopic = + | `policy:${RepoPolicyReadinessWarningCode}` + | "upstream:registry_drift" + | `focus:wanted_path:${string}`; + +export type ContributorIssueDraftStatus = "proposed" | "skipped_duplicate" | "skipped_unsafe" | "created" | "skipped_create_failed"; + +export type ContributorIssueDraft = { + fingerprint: string; + topic: ContributorIssueDraftTopic; + title: string; + body: string; + labels: string[]; + status: ContributorIssueDraftStatus; + duplicateOf?: { number: number; title: string; reason: "marker" | "title" } | undefined; + issue?: { number: number; url: string } | undefined; +}; + +export type ContributorIssueDraftGenerationResult = { + repoFullName: string; + generatedAt: string; + dryRun: boolean; + createRequested: boolean; + proposed: number; + skippedDuplicate: number; + skippedUnsafe: number; + created: number; + skippedCreateFailed: number; + drafts: ContributorIssueDraft[]; +}; + +export type ContributorIssueDraftOptions = { + dryRun?: boolean | undefined; + create?: boolean | undefined; + limit?: number | undefined; + requestedBy?: string | undefined; +}; + +type ContributorIssueDraftContext = { + repoFullName: string; + repo: RepositoryRecord | null; + settings: RepositorySettings; + lane: LaneAdvice; + configQuality: ConfigQuality; + labelAudit: LabelAudit; + queueHealth: QueueHealth; + contributorIntakeHealth: ContributorIntakeHealth; + focusManifest: FocusManifest; + openIssues: IssueRecord[]; + upstreamDriftWarnings: string[]; +}; + +type DraftCandidate = { + topic: ContributorIssueDraftTopic; + title: string; + labels: string[]; + sections: ContributorIssueDraftSections; +}; + +type ContributorIssueDraftSections = { + background: string[]; + currentBehavior: string[]; + desiredBehavior: string[]; + implementationRequirements: string[]; + publicPrivateBoundaries: string[]; + acceptanceCriteria: string[]; + testingRequirements: string[]; +}; + +const DEFAULT_LIMIT = 5; +const MAX_LIMIT = 20; +const GENERIC_TESTING_REQUIREMENTS = [ + "Run the repository's documented validation command before requesting review.", + "Add tests for every new branch, fallback path, sanitizer rule, and regression.", + "Public GitHub output must stay advisory and must not imply guaranteed participation outcomes.", +]; + +export function buildContributorIssueDraftTestingRequirements(manifest: FocusManifest): string[] { + const policyExpectations = manifest.testExpectations.filter(isFocusManifestPublicSafe).map(formatContributorIssueDraftTestExpectation); + if (policyExpectations.length === 0) return [...GENERIC_TESTING_REQUIREMENTS]; + return [ + ...policyExpectations, + "Add tests for every new branch, fallback path, sanitizer rule, and regression.", + "Public GitHub output must stay advisory and must not imply guaranteed participation outcomes.", + ]; +} + +function formatContributorIssueDraftTestExpectation(expectation: string): string { + const trimmed = expectation.trim(); + if (!trimmed) return GENERIC_TESTING_REQUIREMENTS[0]!; + if (/^run\s+/i.test(trimmed) || trimmed.includes("must pass") || trimmed.endsWith(".")) return trimmed; + return `Run ${trimmed} before requesting review.`; +} + +export function contributorIssueDraftMarker(fingerprint: string): string { + return ``; +} + +export async function contributorIssueDraftFingerprint(repoFullName: string, topic: ContributorIssueDraftTopic, key: string): Promise { + return sha256Hex(`gittensory-contributor-draft:v1:${repoFullName.toLowerCase()}:${topic}:${key}`); +} + +export function normalizeIssueTitleKey(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +export function findDuplicateContributorDraft( + openIssues: IssueRecord[], + draft: Pick, +): { number: number; title: string; reason: "marker" | "title" } | null { + const marker = contributorIssueDraftMarker(draft.fingerprint); + for (const issue of openIssues) { + if (issue.state !== "open") continue; + if (issue.body?.includes(marker)) { + return { number: issue.number, title: issue.title, reason: "marker" }; + } + } + const titleKey = normalizeIssueTitleKey(draft.title); + if (!titleKey) return null; + for (const issue of openIssues) { + if (issue.state !== "open") continue; + if (normalizeIssueTitleKey(issue.title) === titleKey) { + return { number: issue.number, title: issue.title, reason: "title" }; + } + } + return null; +} + +export function buildContributorIssueDraftBody(fingerprint: string, sections: ContributorIssueDraftSections): string { + const blocks: string[] = [contributorIssueDraftMarker(fingerprint), "", "## Background", "", ...sections.background, "", "## Current Behavior", "", ...sections.currentBehavior, "", "## Desired Behavior", "", ...sections.desiredBehavior, "", "## Implementation Requirements", "", ...sections.implementationRequirements.map((line) => `- ${line}`), "", "## Public/Private Output Boundaries", "", ...sections.publicPrivateBoundaries.map((line) => `- ${line}`), "", "## Acceptance Criteria", "", ...sections.acceptanceCriteria.map((line) => `- ${line}`), "", "## Testing Requirements", "", ...sections.testingRequirements.map((line) => `- ${line}`)]; + return blocks.join("\n"); +} + +export function isContributorIssueDraftPublicSafe(draft: Pick): boolean { + return isFocusManifestPublicSafe(draft.title) && isFocusManifestPublicSafe(draft.body); +} + +export function buildContributorIssueDraftCandidates(context: ContributorIssueDraftContext): DraftCandidate[] { + const candidates: DraftCandidate[] = []; + const policy = buildRepoPolicyReadiness({ + repoFullName: context.repoFullName, + focusManifest: context.focusManifest, + settings: context.settings, + lane: context.lane, + configQuality: context.configQuality, + labelAudit: context.labelAudit, + queueHealth: context.queueHealth, + contributorIntakeHealth: context.contributorIntakeHealth, + }); + + for (const warning of policy.publicWarnings) { + if (warning.severity === "info") continue; + const candidate = policyWarningCandidate(context.repoFullName, warning, context.focusManifest); + if (candidate) candidates.push(candidate); + } + + if (context.upstreamDriftWarnings.length > 0) { + candidates.push(upstreamDriftCandidate(context.repoFullName, context.upstreamDriftWarnings, context.focusManifest)); + } + + for (const path of context.focusManifest.wantedPaths.slice(0, 3)) { + const candidate = wantedPathCandidate(context.repoFullName, path, context.openIssues, context.focusManifest); + if (candidate) candidates.push(candidate); + } + + return dedupeCandidatesByTopic(candidates); +} + +export async function generateContributorIssueDrafts( + env: Env, + repoFullName: string, + options: ContributorIssueDraftOptions = {}, +): Promise { + const dryRun = options.dryRun !== false; + const createRequested = options.create === true; + const limit = Math.min(MAX_LIMIT, Math.max(1, options.limit ?? DEFAULT_LIMIT)); + const context = await loadContributorIssueDraftContext(env, repoFullName); + const candidates = buildContributorIssueDraftCandidates(context).slice(0, limit); + const drafts: ContributorIssueDraft[] = []; + let proposed = 0; + let skippedDuplicate = 0; + let skippedUnsafe = 0; + let created = 0; + let skippedCreateFailed = 0; + + for (const candidate of candidates) { + const fingerprint = await contributorIssueDraftFingerprint(repoFullName, candidate.topic, candidateKey(candidate)); + const body = buildContributorIssueDraftBody(fingerprint, candidate.sections); + const draft: ContributorIssueDraft = { + fingerprint, + topic: candidate.topic, + title: candidate.title, + body, + labels: candidate.labels, + status: "proposed", + }; + if (!isContributorIssueDraftPublicSafe(draft)) { + draft.status = "skipped_unsafe"; + skippedUnsafe += 1; + drafts.push(draft); + continue; + } + const duplicate = findDuplicateContributorDraft(context.openIssues, draft); + if (duplicate) { + draft.status = "skipped_duplicate"; + draft.duplicateOf = duplicate; + skippedDuplicate += 1; + drafts.push(draft); + continue; + } + if (!dryRun && createRequested) { + const issue = await createGitHubContributorIssue(env, repoFullName, draft); + if (issue) { + draft.status = "created"; + draft.issue = issue; + created += 1; + context.openIssues.push({ + repoFullName, + number: issue.number, + title: draft.title, + state: "open", + labels: draft.labels, + linkedPrs: [], + body: draft.body, + }); + } else { + draft.status = "skipped_create_failed"; + skippedCreateFailed += 1; + } + } else { + proposed += 1; + } + drafts.push(draft); + } + + if (!dryRun && createRequested && created > 0) { + await recordAuditEvent(env, { + eventType: "contributor.issue_drafts_created", + outcome: "completed", + metadata: { + repoFullName, + created, + requestedBy: options.requestedBy ?? "api", + fingerprints: drafts.filter((entry) => entry.status === "created").map((entry) => entry.fingerprint), + }, + }); + } + + return { + repoFullName, + generatedAt: nowIso(), + dryRun, + createRequested, + proposed, + skippedDuplicate, + skippedUnsafe, + created, + skippedCreateFailed, + drafts, + }; +} + +function candidateKey(candidate: DraftCandidate): string { + if (candidate.topic.startsWith("focus:wanted_path")) return candidate.sections.background.join("|"); + return candidate.topic; +} + +function dedupeCandidatesByTopic(candidates: DraftCandidate[]): DraftCandidate[] { + const seen = new Set(); + const result: DraftCandidate[] = []; + for (const candidate of candidates) { + if (seen.has(candidate.topic)) continue; + seen.add(candidate.topic); + result.push(candidate); + } + return result; +} + +function policyWarningCandidate(repoFullName: string, warning: RepoPolicyReadinessWarning, manifest: FocusManifest): DraftCandidate | null { + const title = policyWarningTitle(warning); + if (!title || !isFocusManifestPublicSafe(title)) return null; + return { + topic: `policy:${warning.code}`, + title, + labels: policyWarningLabels(warning), + sections: { + background: [ + `Maintainers need a tracked contributor issue for ${repoFullName} so policy guidance can scale beyond hand-authored templates.`, + warning.detail, + ], + currentBehavior: ["Contributor issues are hand-authored without a repeatable policy-backed draft contract."], + desiredBehavior: [warning.action, "Publish a structured issue miners can execute without private maintainer context."], + implementationRequirements: [ + "Use the repo focus manifest and current signal snapshots as the source of truth.", + "Keep the change scoped to the warning category and avoid unrelated UI or docs-site churn unless safety requires it.", + "Default to dry-run review; do not auto-post GitHub issues without explicit maintainer approval.", + ], + publicPrivateBoundaries: [ + "Public GitHub issues must stay advisory and must not imply guaranteed participation outcomes.", + "Do not expose credentials, miner keys, or private maintainer-only evaluation language.", + "Keep private maintainer notes in authenticated Gittensory surfaces only.", + ], + acceptanceCriteria: [ + "The warning category is addressed with tests and documentation where applicable.", + "Focus manifest and settings guidance stay consistent for contributors.", + "No forbidden public language appears in generated maintainer or GitHub output.", + ], + testingRequirements: buildContributorIssueDraftTestingRequirements(manifest), + }, + }; +} + +function policyWarningTitle(warning: RepoPolicyReadinessWarning): string { + const slug = warning.code.replace(/_/g, "-"); + return `feat(issues): address ${slug} policy readiness for repo`; +} + +function policyWarningLabels(warning: RepoPolicyReadinessWarning): string[] { + if (warning.category === "issue_discovery") return ["enhancement", "signals", "agent"]; + if (warning.category === "validation") return ["enhancement", "signals"]; + if (warning.category === "maintainer_burden") return ["documentation", "signals"]; + return ["enhancement", "developer-experience", "signals"]; +} + +function upstreamDriftCandidate(repoFullName: string, warnings: string[], manifest: FocusManifest): DraftCandidate { + return { + topic: "upstream:registry_drift", + title: `feat(issues): reconcile upstream registry drift for ${repoFullName}`, + labels: ["signals", "enhancement"], + sections: { + background: [ + "Gittensory detected upstream Gittensor registry drift that may require fixture or guidance updates for this repo.", + ...warnings.slice(0, 5), + ], + currentBehavior: ["Upstream drift is visible in private signals but may not yet have a contributor-ready tracking issue."], + desiredBehavior: [ + "Add or update regression coverage for affected registry surfaces.", + "Keep public GitHub guidance aligned with the current upstream ruleset.", + ], + implementationRequirements: [ + "Inspect the private upstream drift report before changing scoring fixtures.", + "Limit changes to modules affected by the drift summary.", + ], + publicPrivateBoundaries: [ + "Do not publish private contributor ordering or compensation estimates on GitHub.", + "Keep maintainer triage notes in authenticated Gittensory views only.", + ], + acceptanceCriteria: [ + "Upstream drift warnings for this repo are resolved or documented as expected semantic change.", + "Tests cover any new parsing or registry normalization branches.", + ], + testingRequirements: buildContributorIssueDraftTestingRequirements(manifest), + }, + }; +} + +function wantedPathCandidate(repoFullName: string, wantedPath: string, openIssues: IssueRecord[], manifest: FocusManifest): DraftCandidate | null { + const pathKey = wantedPath.replace(/\//g, " ").trim(); + const title = `feat(${pathSlug(wantedPath)}): expand high-value work in ${wantedPath}`; + if (!isFocusManifestPublicSafe(title)) return null; + if (openIssues.some((issue) => issue.state === "open" && (issue.title.toLowerCase().includes(pathKey.toLowerCase()) || issue.body?.includes(wantedPath)))) { + return null; + } + const publicNotes = manifest.publicNotes.filter(isFocusManifestPublicSafe).slice(0, 2); + return { + topic: `focus:wanted_path:${wantedPath}`, + title, + labels: ["enhancement", "miner-value", "signals"], + sections: { + background: [ + `Repo focus policy marks ${wantedPath} as a wanted contribution area for ${repoFullName}.`, + ...(publicNotes.length > 0 ? publicNotes : ["Prefer backend, MCP, GitHub App, and scoring work over website-only polish."]), + ], + currentBehavior: [`Open backlog does not yet highlight actionable work scoped to ${wantedPath}.`], + desiredBehavior: [ + `Add a focused change within ${wantedPath} that improves miner/contributor value.`, + "Link the implementation issue before opening a PR when the focus manifest prefers tracked work.", + ], + implementationRequirements: [ + `Stay within ${wantedPath} unless safety or release readiness requires adjacent files.`, + "Avoid blocked manifest paths and keep PRs narrowly scoped.", + ...(manifest.testExpectations.length > 0 ? manifest.testExpectations.map((entry) => `Run ${entry} before requesting review.`) : []), + ], + publicPrivateBoundaries: [ + "Public issues must not promise compensation, sort contributors, or expose private maintainer-only claims.", + "Keep maintainerNotes private; use publicNotes only when explicitly opted in.", + ], + acceptanceCriteria: [ + `The change materially improves ${wantedPath} without expanding into blocked areas.`, + "Manifest-guided guidance and tests stay aligned.", + ], + testingRequirements: buildContributorIssueDraftTestingRequirements(manifest), + }, + }; +} + +function pathSlug(path: string): string { + const cleaned = path.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, ""); + return cleaned.slice(0, 24) || "scope"; +} + +async function loadContributorIssueDraftContext(env: Env, repoFullName: string): Promise { + const [repo, settings, openIssues, focusManifest, upstreamReports, issues, pullRequests, recentMergedPullRequests, labels, queueCounts] = await Promise.all([ + getRepository(env, repoFullName), + getRepositorySettings(env, repoFullName), + listOpenIssues(env, repoFullName), + loadRepoFocusManifest(env, repoFullName, { fetcher: async () => null }), + listUpstreamDriftReports(env, 20), + listIssueSignalSample(env, repoFullName), + listOpenPullRequests(env, repoFullName), + listRecentMergedPullRequests(env, repoFullName), + listRepoLabels(env, repoFullName), + loadContributorIssueDraftQueueCounts(env, repoFullName), + ]); + const collisions = buildCollisionReport(repoFullName, issues, pullRequests, recentMergedPullRequests); + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions, queueCounts); + const configQuality = buildConfigQuality(repo, issues, pullRequests, repoFullName); + const labelAudit = buildLabelAudit(repo, labels, issues, pullRequests, repoFullName); + const contributorIntakeHealth = buildContributorIntakeHealth(repo, issues, pullRequests, repoFullName, collisions, queueCounts); + return { + repoFullName, + repo, + settings, + lane: buildLaneAdvice(repo, repoFullName), + configQuality, + labelAudit, + queueHealth, + contributorIntakeHealth, + focusManifest, + openIssues, + upstreamDriftWarnings: registryHyperparameterDriftWarningsForRepo(upstreamReports, repoFullName), + }; +} + +async function loadContributorIssueDraftQueueCounts(env: Env, repoFullName: string): Promise<{ openIssues: number; openPullRequests: number }> { + const [totals, openIssues, openPullRequests] = await Promise.all([ + getLatestRepoGithubTotalsSnapshot(env, repoFullName), + countOpenIssues(env, repoFullName), + countOpenPullRequests(env, repoFullName), + ]); + return { + openIssues: totals?.openIssuesTotal ?? openIssues, + openPullRequests: totals?.openPullRequestsTotal ?? openPullRequests, + }; +} + +async function createGitHubContributorIssue(env: Env, repoFullName: string, draft: ContributorIssueDraft): Promise<{ number: number; url: string } | null> { + const token = env.GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN ?? env.GITTENSORY_DRIFT_ISSUE_TOKEN ?? env.GITHUB_PUBLIC_TOKEN; + if (!token) return null; + const { owner, name } = repoParts(repoFullName); + if (!owner || !name) return null; + const response = await fetch(`https://api.github.com/repos/${owner}/${name}/issues`, { + method: "POST", + headers: githubHeaders(token), + body: jsonString({ + title: draft.title, + body: draft.body, + labels: draft.labels, + }), + }); + if (!response.ok) return null; + const payload = (await response.json()) as { number?: number; html_url?: string }; + return payload.number && payload.html_url ? { number: payload.number, url: payload.html_url } : null; +} + +function githubHeaders(token: string): Record { + return { + accept: "application/vnd.github+json", + "user-agent": "gittensory/0.1", + "x-github-api-version": "2022-11-28", + authorization: `Bearer ${token}`, + }; +} diff --git a/test/unit/contributor-issue-draft.test.ts b/test/unit/contributor-issue-draft.test.ts new file mode 100644 index 00000000..c5d7b0da --- /dev/null +++ b/test/unit/contributor-issue-draft.test.ts @@ -0,0 +1,492 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTestEnv } from "../helpers/d1"; +import * as focusManifest from "../../src/signals/focus-manifest"; +import { parseFocusManifestContent } from "../../src/signals/focus-manifest"; +import { + buildContributorIssueDraftBody, + buildContributorIssueDraftCandidates, + buildContributorIssueDraftTestingRequirements, + contributorIssueDraftFingerprint, + contributorIssueDraftMarker, + findDuplicateContributorDraft, + generateContributorIssueDrafts, + isContributorIssueDraftPublicSafe, + normalizeIssueTitleKey, +} from "../../src/services/contributor-issue-draft"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import * as repositories from "../../src/db/repositories"; +import type { IssueRecord } from "../../src/types"; +import { buildRepoPolicyReadiness } from "../../src/signals/repo-policy-readiness"; +import { buildLaneAdvice, buildConfigQuality, buildContributorIntakeHealth, buildLabelAudit, buildQueueHealth, buildCollisionReport } from "../../src/signals/engine"; + +const FORBIDDEN = /wallet|hotkey|raw trust score|payout|reward estimate|farming|private reviewability|public score estimate/i; + +const GITTENSORY_MANIFEST = parseFocusManifestContent( + JSON.stringify({ + wantedPaths: ["src/", "apps/gittensory-ui/", "packages/gittensory-mcp/"], + blockedPaths: ["site/", "CNAME", "**/lovable/**"], + testExpectations: ["npm run test:ci"], + publicNotes: ["Stay advisory."], + linkedIssuePolicy: "required", + issueDiscoveryPolicy: "discouraged", + }), + "repo_file", +); + +function openIssue(number: number, title: string, body?: string): IssueRecord { + return { + repoFullName: "JSONbored/gittensory", + number, + title, + state: "open", + labels: [], + linkedPrs: [], + body: body ?? null, + }; +} + +describe("contributor issue drafts", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("draft generation fixture includes the full issue body contract", async () => { + const fingerprint = await contributorIssueDraftFingerprint("owner/repo", "policy:focus_policy_missing", "policy:focus_policy_missing"); + const body = buildContributorIssueDraftBody(fingerprint, { + background: ["Background line"], + currentBehavior: ["Current"], + desiredBehavior: ["Desired"], + implementationRequirements: ["Implement"], + publicPrivateBoundaries: ["Stay advisory"], + acceptanceCriteria: ["Ship tests"], + testingRequirements: buildContributorIssueDraftTestingRequirements( + parseFocusManifestContent('{"testExpectations":["npm run test:ci"]}', "repo_file"), + ), + }); + expect(body).toContain(contributorIssueDraftMarker(fingerprint)); + expect(body).toContain("## Testing Requirements"); + expect(body).toContain("npm run test:ci"); + expect(body).not.toMatch(FORBIDDEN); + }); + + it("uses generic validation guidance when manifest policy has no test expectations", () => { + const manifest = parseFocusManifestContent('{"wantedPaths":["src/"]}', "repo_file"); + const requirements = buildContributorIssueDraftTestingRequirements(manifest); + expect(requirements[0]).toContain("documented validation command"); + expect(requirements.join(" ")).not.toMatch(/97%|npm run test:ci/i); + }); + + it("uses manifest testExpectations when configured", () => { + const manifest = parseFocusManifestContent('{"testExpectations":["npm run test:ci","npm run lint"]}', "repo_file"); + const requirements = buildContributorIssueDraftTestingRequirements(manifest); + expect(requirements[0]).toContain("npm run test:ci"); + expect(requirements[1]).toContain("npm run lint"); + }); + + it("duplicate issue fixture skips drafts with matching marker or title", async () => { + const fingerprint = await contributorIssueDraftFingerprint("owner/repo", "policy:validation_expectations_missing", "key"); + const title = "feat(issues): address validation-expectations-missing policy readiness for repo"; + const duplicate = findDuplicateContributorDraft([openIssue(12, title, contributorIssueDraftMarker(fingerprint))], { + fingerprint, + title, + }); + expect(duplicate).toMatchObject({ number: 12, reason: "marker" }); + + const titleDuplicate = findDuplicateContributorDraft([openIssue(13, title)], { + fingerprint: "other-fingerprint", + title, + }); + expect(titleDuplicate).toMatchObject({ number: 13, reason: "title" }); + expect(normalizeIssueTitleKey("Feat(Issues): Address Validation!")).toBe("feat issues address validation"); + }); + + it("builds candidates from policy warnings, upstream drift, and wanted paths", () => { + const repoFullName = "JSONbored/gittensory"; + const repo = { fullName: repoFullName, isRegistered: true } as never; + const issues: IssueRecord[] = []; + const pullRequests: never[] = []; + const collisions = { duplicatePairs: 0, openIssueCollisions: 0, summary: "" } as never; + const queueCounts = { openIssues: 0, openPullRequests: 0 }; + const lane = buildLaneAdvice(repo, repoFullName); + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions, queueCounts); + const configQuality = buildConfigQuality(repo, issues, pullRequests, repoFullName); + const labelAudit = buildLabelAudit(repo, [], issues, pullRequests, repoFullName); + const contributorIntakeHealth = buildContributorIntakeHealth(repo, issues, pullRequests, repoFullName, collisions, queueCounts); + const manifest = { ...GITTENSORY_MANIFEST, present: false, source: "none" as const, warnings: [] }; + const candidates = buildContributorIssueDraftCandidates({ + repoFullName, + repo, + settings: { requireLinkedIssue: false } as never, + lane, + configQuality, + labelAudit, + queueHealth, + contributorIntakeHealth, + focusManifest: manifest, + openIssues: [], + upstreamDriftWarnings: ["Upstream registry drift is open for JSONbored/gittensory: maintainerCut changed."], + }); + expect(candidates.some((entry) => entry.topic === "policy:focus_policy_missing")).toBe(true); + expect(candidates.some((entry) => entry.topic === "upstream:registry_drift")).toBe(true); + expect(candidates.some((entry) => entry.topic.startsWith("focus:wanted_path:"))).toBe(true); + }); + + it("dry-run no-create test leaves GitHub untouched", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const env = createTestEnv(); + const result = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { dryRun: true, limit: 2 }); + expect(result.dryRun).toBe(true); + expect(result.createRequested).toBe(false); + expect(result.created).toBe(0); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.drafts.length).toBeGreaterThan(0); + expect(result.drafts.every((draft) => draft.status === "proposed" || draft.status === "skipped_duplicate" || draft.status === "skipped_unsafe")).toBe(true); + }); + + it("normalizes draft limits to at least one and caps excessive values", async () => { + const env = createTestEnv(); + const low = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { dryRun: true, limit: 0 }); + const high = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { dryRun: true, limit: 100 }); + expect(low.drafts.length).toBeLessThanOrEqual(1); + expect(high.drafts.length).toBeLessThanOrEqual(20); + }); + + it("optional create audit test records created drafts", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + number: 501, + html_url: "https://github.com/JSONbored/gittensory/issues/501", + }), + ), + ); + const env = createTestEnv({ GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + const manifest = { + ...GITTENSORY_MANIFEST, + wantedPaths: ["src/unique-path-119/"], + blockedPaths: [], + testExpectations: ["npm run test:ci"], + }; + const repo = { fullName: "JSONbored/gittensory", isRegistered: true } as never; + const collisions = buildCollisionReport("JSONbored/gittensory", [], []); + const policy = buildRepoPolicyReadiness({ + repoFullName: "JSONbored/gittensory", + focusManifest: manifest, + settings: { requireLinkedIssue: false } as never, + lane: buildLaneAdvice(repo, "JSONbored/gittensory"), + configQuality: buildConfigQuality(repo, [], [], "JSONbored/gittensory"), + labelAudit: buildLabelAudit(repo, [], [], [], "JSONbored/gittensory"), + queueHealth: buildQueueHealth(repo, [], [], collisions, { openIssues: 0, openPullRequests: 0 }), + contributorIntakeHealth: buildContributorIntakeHealth(repo, [], [], "JSONbored/gittensory", collisions, { openIssues: 0, openPullRequests: 0 }), + }); + expect(policy.publicWarnings.length).toBeGreaterThan(0); + + const result = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { + dryRun: false, + create: true, + limit: 1, + requestedBy: "maintainer", + }); + expect(result.created).toBeGreaterThanOrEqual(0); + if (result.created > 0) { + expect(result.drafts.some((draft) => draft.status === "created" && draft.issue?.number === 501)).toBe(true); + } + }); + + it("public text hygiene regression rejects unsafe draft output", () => { + expect( + isContributorIssueDraftPublicSafe({ + title: "feat(issues): safe title", + body: buildContributorIssueDraftBody("fp", { + background: ["Stay advisory"], + currentBehavior: ["Current"], + desiredBehavior: ["Desired"], + implementationRequirements: ["Implement"], + publicPrivateBoundaries: ["Stay advisory; do not imply guaranteed compensation."], + acceptanceCriteria: ["Tests"], + testingRequirements: ["npm run test:ci must pass."], + }), + }), + ).toBe(true); + expect( + isContributorIssueDraftPublicSafe({ + title: "feat(issues): estimate your reward", + body: "wallet details", + }), + ).toBe(false); + }); + + it("skips duplicate drafts during generation", async () => { + const env = createTestEnv(); + const title = "feat(issues): address validation-gate-uncertain policy readiness for repo"; + vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([openIssue(77, title)]); + + const result = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { dryRun: true, limit: 1 }); + expect(result.skippedDuplicate).toBe(1); + expect(result.drafts[0]?.status).toBe("skipped_duplicate"); + }); + + it("records skipped_create_failed when GitHub create is unavailable", async () => { + const env = createTestEnv(); + vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); + + const result = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { + dryRun: false, + create: true, + limit: 1, + }); + expect(result.createRequested).toBe(true); + expect(result.skippedCreateFailed).toBe(1); + expect(result.drafts[0]?.status).toBe("skipped_create_failed"); + }); + + it("records skipped_create_failed when GitHub returns a non-ok response", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 403 }))); + const env = createTestEnv({ GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); + + const result = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { + dryRun: false, + create: true, + limit: 1, + }); + expect(result.skippedCreateFailed).toBe(1); + expect(result.drafts[0]?.status).toBe("skipped_create_failed"); + }); + + it("creates issues and records audit metadata when explicit create succeeds", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + number: 501, + html_url: "https://github.com/JSONbored/gittensory/issues/501", + }), + ), + ); + const auditSpy = vi.spyOn(repositories, "recordAuditEvent").mockResolvedValue(undefined); + vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); + const env = createTestEnv({ GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + const result = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { + dryRun: false, + create: true, + limit: 1, + requestedBy: "maintainer", + }); + expect(result.created).toBe(1); + expect(result.drafts[0]?.status).toBe("created"); + expect(auditSpy).toHaveBeenCalledWith( + env, + expect.objectContaining({ + eventType: "contributor.issue_drafts_created", + metadata: expect.objectContaining({ requestedBy: "maintainer", created: 1 }), + }), + ); + }); + + it("builds label sets for policy warning categories", () => { + const repoFullName = "owner/repo"; + const repo = { fullName: repoFullName, isRegistered: true } as never; + const issues: IssueRecord[] = []; + const pullRequests: never[] = []; + const collisions = buildCollisionReport(repoFullName, issues, pullRequests); + const base = { + repoFullName, + repo, + settings: { requireLinkedIssue: true } as never, + lane: buildLaneAdvice({ fullName: repoFullName, isRegistered: true, registryConfig: { issueDiscoveryShare: 1 } } as never, repoFullName), + configQuality: buildConfigQuality(repo, issues, pullRequests, repoFullName), + labelAudit: buildLabelAudit(repo, [], issues, pullRequests, repoFullName), + queueHealth: buildQueueHealth(repo, issues, pullRequests, collisions), + contributorIntakeHealth: buildContributorIntakeHealth(repo, issues, pullRequests, repoFullName, collisions), + openIssues: [], + upstreamDriftWarnings: [], + }; + const manifest = parseFocusManifestContent( + '{"wantedPaths":["src/"],"blockedPaths":["dist/"],"testExpectations":["npm run test:ci"],"issueDiscoveryPolicy":"discouraged","linkedIssuePolicy":"optional"}', + "repo_file", + ); + const candidates = buildContributorIssueDraftCandidates({ ...base, focusManifest: manifest }); + const labels = new Set(candidates.flatMap((entry) => entry.labels)); + expect(labels.has("agent")).toBe(true); + expect(labels.has("signals")).toBe(true); + expect(candidates.some((entry) => entry.sections.implementationRequirements.some((line) => line.includes("npm run test:ci")))).toBe(true); + }); + + it("ignores closed issues and empty title keys when checking duplicates", () => { + const fingerprint = "fp"; + const title = "feat(issues): address validation policy readiness for repo"; + expect( + findDuplicateContributorDraft([{ ...openIssue(1, title), state: "closed" }], { fingerprint, title }), + ).toBeNull(); + expect(findDuplicateContributorDraft([], { fingerprint, title: " !!! " })).toBeNull(); + }); + + it("dedupes candidates by topic and skips wanted-path topics already covered in open issues", () => { + const repoFullName = "owner/repo"; + const repo = { fullName: repoFullName, isRegistered: true } as never; + const issues: IssueRecord[] = []; + const pullRequests: never[] = []; + const collisions = buildCollisionReport(repoFullName, issues, pullRequests); + const base = { + repoFullName, + repo, + settings: { requireLinkedIssue: false } as never, + lane: buildLaneAdvice(repo, repoFullName), + configQuality: buildConfigQuality(repo, issues, pullRequests, repoFullName), + labelAudit: buildLabelAudit(repo, [], issues, pullRequests, repoFullName), + queueHealth: buildQueueHealth(repo, issues, pullRequests, collisions), + contributorIntakeHealth: buildContributorIntakeHealth(repo, issues, pullRequests, repoFullName, collisions), + openIssues: [openIssue(9, "feat src expand high-value work in src/", "Track src/ improvements")], + upstreamDriftWarnings: [], + }; + const manifest = parseFocusManifestContent('{"wantedPaths":["src/","src/"],"issueDiscoveryPolicy":"discouraged"}', "repo_file"); + const candidates = buildContributorIssueDraftCandidates({ ...base, focusManifest: manifest }); + expect(candidates.filter((entry) => entry.topic === "focus:wanted_path:src/")).toHaveLength(0); + expect(new Set(candidates.map((entry) => entry.topic)).size).toBe(candidates.length); + }); + + it("skips unsafe drafts when wanted-path validation text fails public hygiene", async () => { + const env = createTestEnv(); + await upsertRepoFocusManifest(env, "owner/unsafe-path", { + wantedPaths: ["src/unsafe-path-only/"], + testExpectations: ["wallet seed phrase"], + linkedIssuePolicy: "required", + issueDiscoveryPolicy: "neutral", + }); + vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); + const result = await generateContributorIssueDrafts(env, "owner/unsafe-path", { dryRun: true, limit: 10 }); + expect(result.drafts.some((draft) => draft.status === "skipped_unsafe")).toBe(true); + }); + + it("returns null for invalid repo names when creating GitHub issues", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ number: 1, html_url: "https://example.com/1" }))); + const env = createTestEnv({ GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); + const result = await generateContributorIssueDrafts(env, "invalid", { + dryRun: false, + create: true, + limit: 1, + }); + expect(result.skippedCreateFailed).toBeGreaterThan(0); + }); + + it("treats malformed GitHub create responses as skipped_create_failed", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ html_url: "https://github.com/x/y/issues/1" }))); + const env = createTestEnv({ GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); + const result = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { + dryRun: false, + create: true, + limit: 1, + }); + expect(result.skippedCreateFailed).toBe(1); + }); + + it("skips wanted-path candidates when the generated title would be unsafe", () => { + const repoFullName = "owner/repo"; + const repo = { fullName: repoFullName, isRegistered: true } as never; + const collisions = buildCollisionReport(repoFullName, [], []); + const manifest = parseFocusManifestContent('{"wantedPaths":["wallet-hotkey/"]}', "repo_file"); + const candidates = buildContributorIssueDraftCandidates({ + repoFullName, + repo, + settings: { requireLinkedIssue: false } as never, + lane: buildLaneAdvice(repo, repoFullName), + configQuality: buildConfigQuality(repo, [], [], repoFullName), + labelAudit: buildLabelAudit(repo, [], [], [], repoFullName), + queueHealth: buildQueueHealth(repo, [], [], collisions), + contributorIntakeHealth: buildContributorIntakeHealth(repo, [], [], repoFullName, collisions), + focusManifest: manifest, + openIssues: [], + upstreamDriftWarnings: [], + }); + expect(candidates.some((entry) => entry.topic === "focus:wanted_path:wallet-hotkey/")).toBe(false); + }); + + it("uses default limit and requestedBy when options omit them", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + number: 502, + html_url: "https://github.com/JSONbored/gittensory/issues/502", + }), + ), + ); + const auditSpy = vi.spyOn(repositories, "recordAuditEvent").mockResolvedValue(undefined); + vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); + const env = createTestEnv({ GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + const result = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { + dryRun: false, + create: true, + }); + expect(result.drafts.length).toBeLessThanOrEqual(5); + if (result.created > 0) { + expect(auditSpy).toHaveBeenCalledWith( + env, + expect.objectContaining({ + metadata: expect.objectContaining({ requestedBy: "api" }), + }), + ); + } + }); + + it("dedupes duplicate wanted-path topics and omits empty test expectations", () => { + const repoFullName = "owner/repo"; + const repo = { fullName: repoFullName, isRegistered: true } as never; + const collisions = buildCollisionReport(repoFullName, [], []); + const manifest = parseFocusManifestContent('{"wantedPaths":["src/","src/","###/"]}', "repo_file"); + const candidates = buildContributorIssueDraftCandidates({ + repoFullName, + repo, + settings: { requireLinkedIssue: false } as never, + lane: buildLaneAdvice(repo, repoFullName), + configQuality: buildConfigQuality(repo, [], [], repoFullName), + labelAudit: buildLabelAudit(repo, [], [], [], repoFullName), + queueHealth: buildQueueHealth(repo, [], [], collisions), + contributorIntakeHealth: buildContributorIntakeHealth(repo, [], [], repoFullName, collisions), + focusManifest: manifest, + openIssues: [], + upstreamDriftWarnings: [], + }); + expect(candidates.filter((entry) => entry.topic === "focus:wanted_path:src/")).toHaveLength(1); + const scoped = candidates.find((entry) => entry.topic === "focus:wanted_path:###/"); + expect(scoped?.title).toContain("feat(scope):"); + expect(scoped?.sections.implementationRequirements.some((line) => line.startsWith("Run "))).toBe(false); + }); + + it("matches title duplicates only after scanning non-matching open issues", () => { + const title = "feat(issues): address validation policy readiness for repo"; + const duplicate = findDuplicateContributorDraft( + [openIssue(1, "unrelated issue title"), openIssue(2, title)], + { fingerprint: "other", title }, + ); + expect(duplicate).toMatchObject({ number: 2, reason: "title" }); + }); + + it("skips policy warning candidates when generated titles fail public hygiene", () => { + const repoFullName = "owner/repo"; + const repo = { fullName: repoFullName, isRegistered: true } as never; + const collisions = buildCollisionReport(repoFullName, [], []); + const manifest = parseFocusManifestContent('{"wantedPaths":[],"issueDiscoveryPolicy":"discouraged"}', "repo_file"); + vi.spyOn(focusManifest, "isFocusManifestPublicSafe").mockImplementation((text) => !String(text).includes("policy readiness")); + const candidates = buildContributorIssueDraftCandidates({ + repoFullName, + repo, + settings: { requireLinkedIssue: false } as never, + lane: buildLaneAdvice(repo, repoFullName), + configQuality: buildConfigQuality(repo, [], [], repoFullName), + labelAudit: buildLabelAudit(repo, [], [], [], repoFullName), + queueHealth: buildQueueHealth(repo, [], [], collisions), + contributorIntakeHealth: buildContributorIntakeHealth(repo, [], [], repoFullName, collisions), + focusManifest: manifest, + openIssues: [], + upstreamDriftWarnings: [], + }); + expect(candidates.every((entry) => !entry.topic.startsWith("policy:"))).toBe(true); + vi.restoreAllMocks(); + }); +}); diff --git a/test/unit/routes-contributor-issue-draft.test.ts b/test/unit/routes-contributor-issue-draft.test.ts new file mode 100644 index 00000000..e9f55670 --- /dev/null +++ b/test/unit/routes-contributor-issue-draft.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { upsertInstallation, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +const DRAFTS_PATH = "/v1/repos/JSONbored/gittensory/contributor-issue-drafts/generate"; +const OWNED_REPO_PATH = "/v1/repos/repo-owner/owned-repo/contributor-issue-drafts/generate"; + +function apiHeaders(env: Env): Record { + return { + authorization: `Bearer ${env.GITTENSORY_API_TOKEN}`, + "content-type": "application/json", + }; +} + +async function seedRegisteredInstalledRepo(env: Env, installationId: number, owner: string, name: string): Promise { + await upsertInstallation(env, { + installation: { + id: installationId, + account: { login: owner, id: installationId, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", contents: "read" }, + events: ["repository"], + }, + }); + await upsertRepositoryFromGitHub( + env, + { name, full_name: `${owner}/${name}`, private: false, owner: { login: owner } }, + installationId, + ); + await env.DB.prepare("UPDATE repositories SET is_registered = 1 WHERE full_name = ?") + .bind(`${owner}/${name}`) + .run(); +} + +describe("contributor-issue-drafts route auth", () => { + it("rejects unauthenticated access", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request(DRAFTS_PATH, { method: "POST", body: "{}" }, env); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: "unauthorized" }); + }); + + it("rejects unauthorized session access", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "jsonbored" }); + const { token } = await createSessionForGitHubUser(env, { login: "new-user", id: 2468 }); + const response = await app.request( + DRAFTS_PATH, + { method: "POST", headers: { cookie: `gittensory_session=${token}`, "content-type": "application/json" }, body: "{}" }, + env, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "insufficient_role" }); + }); + + it("allows same-repo owner sessions to generate dry-run drafts", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedRegisteredInstalledRepo(env, 201, "repo-owner", "owned-repo"); + const { token } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 201 }); + const response = await app.request( + OWNED_REPO_PATH, + { + method: "POST", + headers: { cookie: `gittensory_session=${token}`, "content-type": "application/json" }, + body: JSON.stringify({ dryRun: true, limit: 1 }), + }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + repoFullName: "repo-owner/owned-repo", + dryRun: true, + drafts: expect.any(Array), + }); + }); + + it("rejects cross-repo owner sessions with forbidden_repo", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedRegisteredInstalledRepo(env, 201, "repo-owner", "owned-repo"); + await seedRegisteredInstalledRepo(env, 202, "other-owner", "other-repo"); + const { token } = await createSessionForGitHubUser(env, { login: "other-owner", id: 202 }); + const response = await app.request( + OWNED_REPO_PATH, + { + method: "POST", + headers: { cookie: `gittensory_session=${token}`, "content-type": "application/json" }, + body: JSON.stringify({ dryRun: true, limit: 1 }), + }, + env, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "forbidden_repo" }); + }); + + it("rejects malformed JSON with 400", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request( + DRAFTS_PATH, + { method: "POST", headers: { ...apiHeaders(env), "content-type": "application/json" }, body: "not-json" }, + env, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_json" }); + }); + + it("rejects explicit create without dryRun false", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request( + DRAFTS_PATH, + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ create: true }) }, + env, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "explicit_create_requires_dry_run_false" }); + }); + + it("rejects invalid request bodies", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request( + DRAFTS_PATH, + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ limit: "many" }) }, + env, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_contributor_issue_draft_request" }); + }); + + it("returns dry-run drafts for authorized static-token callers", async () => { + const app = createApp(); + const env = createTestEnv({ GITTENSORY_DRIFT_ISSUE_REPO: "JSONbored/gittensory" }); + const response = await app.request( + DRAFTS_PATH, + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ dryRun: true, limit: 2 }) }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + repoFullName: "JSONbored/gittensory", + dryRun: true, + createRequested: false, + drafts: expect.any(Array), + }); + }); +}); From 76b9126234b63bbc3081098131ac7d33635c44ff Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:03:01 +0200 Subject: [PATCH 2/2] fix(issues): address PR #462 review on draft scope and policy Narrow branch to issue #119 only, derive testing requirements from manifest policy, and enforce repo-scoped session access on draft generation routes. --- test/unit/contributor-issue-draft.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/contributor-issue-draft.test.ts b/test/unit/contributor-issue-draft.test.ts index c5d7b0da..b347d032 100644 --- a/test/unit/contributor-issue-draft.test.ts +++ b/test/unit/contributor-issue-draft.test.ts @@ -220,7 +220,7 @@ describe("contributor issue drafts", () => { it("skips duplicate drafts during generation", async () => { const env = createTestEnv(); - const title = "feat(issues): address validation-gate-uncertain policy readiness for repo"; + const title = "feat(issues): address focus-policy-missing policy readiness for repo"; vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([openIssue(77, title)]); const result = await generateContributorIssueDrafts(env, "JSONbored/gittensory", { dryRun: true, limit: 1 });