diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 6ffabf2590..155867777e 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -493,6 +493,16 @@ review: # quiet | normal | detailed. Default: null/normal (byte-identical). # comment_verbosity: normal + # How a `@gittensory generate-tests` result is delivered, once features.e2eTests is on (#4197, part of + # the #4189 E2E-test-generation epic). `comment` (default, null/absent) posts the generated test as a + # reply comment only. `commit` pushes it as a real commit onto the PR's own head branch instead -- a + # bigger blast radius, so it stays opt-in even with e2eTests already enabled. `commit` mode is ALSO + # blocked at runtime (regardless of this setting) for a PR whose author is a confirmed Gittensor miner, + # to protect the externally-computed score from ever including a maintainer-authored line the miner + # didn't write themselves. + # comment | commit. Default: null/comment (byte-identical -- no write access to any PR branch). + # e2e_test_delivery: comment + # Inline-comment layer toggles (#1956 / #1958). Bool | null. Default: null/false — byte-identical. # Requires operator flag GITTENSORY_REVIEW_INLINE_COMMENTS + cutover allowlist + review.inline_comments: true. # inline_comments: false diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 63ef086d0e..da732e84eb 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -506,6 +506,16 @@ review: # quiet | normal | detailed. Default: null/normal (byte-identical). # comment_verbosity: normal + # How a `@gittensory generate-tests` result is delivered, once features.e2eTests is on (#4197, part of + # the #4189 E2E-test-generation epic). `comment` (default, null/absent) posts the generated test as a + # reply comment only. `commit` pushes it as a real commit onto the PR's own head branch instead -- a + # bigger blast radius, so it stays opt-in even with e2eTests already enabled. `commit` mode is ALSO + # blocked at runtime (regardless of this setting) for a PR whose author is a confirmed Gittensor miner, + # to protect the externally-computed score from ever including a maintainer-authored line the miner + # didn't write themselves. + # comment | commit. Default: null/comment (byte-identical -- no write access to any PR branch). + # e2e_test_delivery: comment + # Inline-comment layer toggles (#1956 / #1958). Bool | null. Default: null/false — byte-identical. # Requires operator flag GITTENSORY_REVIEW_INLINE_COMMENTS + cutover allowlist + review.inline_comments: true. # inline_comments: false diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index ea1b8b6699..8d94069faa 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -526,6 +526,15 @@ export type FocusManifestReviewConfig = { * (default, absent) ⇒ byte-identical to today. Net-new vs the changed-files-summary (#1957) and effort-score * (#1955) knobs. (#2047) */ commentVerbosity: CommentVerbosity | null; + /** `review.e2e_test_delivery` (#4197, part of the #4189 epic): how a `@gittensory generate-tests` result is + * delivered once `features.e2eTests` is on. `"comment"` (default, null/absent) posts the generated test as + * a reply comment only — no write access to the PR branch. `"commit"` pushes it as a real commit onto the + * PR's own head branch (git/trees -> git/commits -> a ref UPDATE, mirroring `repo-doc-pr.ts`'s write + * chokepoint) — a materially bigger blast radius, so it stays opt-in per repo even with e2eTests already + * on. `"commit"` mode is additionally blocked at runtime (regardless of this config) for a PR whose author + * is a confirmed Gittensor miner, to protect the external, upstream-computed score from ever including a + * maintainer-authored line the miner didn't write themselves — see `src/github/e2e-test-commit.ts`. */ + e2eTestDelivery: E2eTestDeliveryMode | null; /** `review.path_instructions`: per-path natural-language guidance handed to the AI reviewer when the PR's * changed files match the glob. Empty (default) ⇒ byte-identical reviewer prompt. Also consumed by * AI-generated E2E test coverage (`resolveE2eTestGenInstructions` in `ai-e2e-test-gen.ts`, #4200) when @@ -591,6 +600,10 @@ export type LinkedIssueSatisfactionMode = (typeof LINKED_ISSUE_SATISFACTION_MODE export const COMMENT_VERBOSITY_LEVELS = ["quiet", "normal", "detailed"] as const; export type CommentVerbosity = (typeof COMMENT_VERBOSITY_LEVELS)[number]; +/** `review.e2e_test_delivery` modes (#4197). `comment` = today's behavior (same as unset). */ +export const E2E_TEST_DELIVERY_MODES = ["comment", "commit"] as const; +export type E2eTestDeliveryMode = (typeof E2E_TEST_DELIVERY_MODES)[number]; + /** One `review.labeling_rules[]` entry: a non-reserved `label` plus the deterministic `when` criteria that must ALL * match for it to fire. A rule always has at least one criterion (enforced at parse). */ export type LabelingRule = { @@ -936,7 +949,7 @@ const EMPTY_MANIFEST: FocusManifest = { publicNotes: [], gate: { ...EMPTY_GATE_CONFIG }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, + review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, features: { ...EMPTY_FEATURES_CONFIG }, contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, @@ -966,7 +979,7 @@ function emptyManifest(source: FocusManifestSource, warnings: string[] = []): Fo warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, + review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, features: { ...EMPTY_FEATURES_CONFIG }, contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, @@ -1990,7 +2003,7 @@ function parsePublicSafeText(value: JsonValue | undefined, field: string, warnin * throws; invalid/unsafe values are dropped with warnings. */ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestReviewConfig { - const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }; + const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }; if (value === undefined || value === null) return empty; if (typeof value !== "object" || Array.isArray(value)) { warnings.push(`Manifest field "review" must be a mapping; ignoring it.`); @@ -2050,6 +2063,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo ); const maxFindings = parseMaxFindingsConfig(r.max_findings, warnings); const commentVerbosity = normalizeOptionalEnum(r.comment_verbosity, "review.comment_verbosity", COMMENT_VERBOSITY_LEVELS, warnings); + const e2eTestDelivery = normalizeOptionalEnum(r.e2e_test_delivery, "review.e2e_test_delivery", E2E_TEST_DELIVERY_MODES, warnings); const pathInstructions = parseReviewPathInstructions(r.path_instructions, warnings); const instructions = parsePublicSafeText(r.instructions, "review.instructions", warnings); const excludePaths = parseReviewExcludePaths(r.exclude_paths, warnings); @@ -2082,6 +2096,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo minFindingSeverity !== null || maxFindingsPresent(maxFindings) || commentVerbosity !== null || + e2eTestDelivery !== null || pathInstructions.length > 0 || instructions !== null || excludePaths.length > 0 || @@ -2120,6 +2135,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo minFindingSeverity, maxFindings, commentVerbosity, + e2eTestDelivery, pathInstructions, instructions, excludePaths, @@ -2216,6 +2232,7 @@ function computeReviewConfigPresent(review: Omit 0 || review.instructions !== null || review.excludePaths.length > 0 || @@ -2260,6 +2277,7 @@ export function overlayReviewConfig( minFindingSeverity: pickOverlayNullable(override.minFindingSeverity, base.minFindingSeverity), maxFindings: overlayMaxFindingsConfig(base.maxFindings, override.maxFindings), commentVerbosity: pickOverlayNullable(override.commentVerbosity, base.commentVerbosity), + e2eTestDelivery: pickOverlayNullable(override.e2eTestDelivery, base.e2eTestDelivery), pathInstructions: override.pathInstructions.length > 0 ? [...override.pathInstructions] : [...base.pathInstructions], instructions: pickOverlayNullable(override.instructions, base.instructions), excludePaths: pickOverlayStringList(override.excludePaths, base.excludePaths), @@ -2765,6 +2783,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue out.max_findings = maxFindings; } if (review.commentVerbosity !== null) out.comment_verbosity = review.commentVerbosity; + if (review.e2eTestDelivery !== null) out.e2e_test_delivery = review.e2eTestDelivery; if (review.instructions !== null) out.instructions = review.instructions; if (review.pathInstructions.length > 0) out.path_instructions = review.pathInstructions.map((entry) => ({ path: entry.path, instructions: entry.instructions })); if (review.excludePaths.length > 0) out.exclude_paths = [...review.excludePaths]; diff --git a/src/github/e2e-test-commit.ts b/src/github/e2e-test-commit.ts new file mode 100644 index 0000000000..49321a2244 --- /dev/null +++ b/src/github/e2e-test-commit.ts @@ -0,0 +1,102 @@ +// E2E test-generation commit delivery (#4197, part of the #4189 epic). Pushes an AI-generated test file as a +// real commit onto an EXISTING PR's own head branch — reusing the SAME installation-token write chokepoint +// (`makeInstallationOctokit`) and git/trees -> git/commits pattern as `repo-doc-pr.ts`, but updating an +// existing ref (`PATCH git/refs/{ref}`) instead of creating a new branch/PR. +// +// Deliberately narrower in scope than repo-doc-pr.ts: this writes to SOMEONE ELSE'S branch (the PR author's), +// not a branch gittensory itself owns, so it carries a materially bigger blast radius — see #4195's +// maintainer-only authorization tier and the miner-scoring safeguard below, both required before this is ever +// invoked for real. +// +// SCORING-INTEGRITY SAFEGUARD (#4201): gittensory does not compute the authoritative Gittensor score itself — +// it is computed by external validators reading the merged PR directly from GitHub. A commit this module +// pushes onto a CONFIRMED MINER's PR branch would be indistinguishable, to that external validator, from a +// line the miner wrote themselves, inflating their apparent contribution. `isMinerAuthoredBranch` must be +// checked by the CALLER before invoking `commitE2eTestToPrBranch` for a confirmed-miner PR — this module does +// not re-check it itself (the caller already resolved miner status while authorizing the command, so +// re-deriving it here would be a redundant, easy-to-drift second source of truth). +import { githubErrorStatus, withInstallationTokenRetry } from "./app"; +import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "./client"; +import { errorMessage, repoParts } from "../utils/json"; +import type { AgentActionMode } from "../settings/agent-execution"; + +export type E2eTestCommitResult = + | { status: "committed"; commitSha: string; htmlUrl: string } + | { status: "declined"; reason: string } + | { status: "error"; reason: string }; + +/** Default path for a generated test file — clearly labeled and namespaced by PR number so repeat + * invocations on the same PR overwrite the same file rather than accumulating duplicates. A maintainer who + * wants a different location can move the file after it lands; this module has no per-repo convention to + * read (that is a possible future enhancement, not required for this first delivery mode). */ +export function defaultE2eTestFilePath(prNumber: number): string { + return `e2e/gittensory-pr-${prNumber}.spec.ts`; +} + +/** + * Push a generated test file as a new commit onto an EXISTING PR's head branch. Fail-safe on every expected + * failure mode (never a `"live"` mode, no write access / fork without "Allow edits by maintainers", the + * branch moved since this pass started) — those all return `{ status: "declined", reason }`, not a thrown + * error. A genuinely unexpected failure (network, auth) returns `{ status: "error", reason }` instead, so a + * caller can tell "this could never have worked" apart from "something broke and should be retried/reported". + */ +export async function commitE2eTestToPrBranch( + env: Env, + args: { + installationId: number; + repoFullName: string; + prNumber: number; + headRef: string; + headSha: string; + testSource: string; + actor: string; + mode: AgentActionMode; + testFilePath?: string | undefined; + }, +): Promise { + if (args.mode !== "live") return { status: "declined", reason: `commit not pushed: action mode is "${args.mode}"` }; + const { owner, name: repo } = repoParts(args.repoFullName); + const path = args.testFilePath?.trim() || defaultE2eTestFilePath(args.prNumber); + const message = `test: add AI-generated E2E test\n\nGenerated-by: gittensory (invoked by @${args.actor})`; + try { + return await withInstallationTokenRetry(env, args.installationId, async (token) => { + const octokit = makeInstallationOctokit(env, token, args.mode, githubRateLimitAdmissionKeyForInstallation(args.installationId)); + + const headCommit = await octokit.request("GET /repos/{owner}/{repo}/git/commits/{commit_sha}", { owner, repo, commit_sha: args.headSha }); + const baseTreeSha = (headCommit.data as { tree: { sha: string } }).tree.sha; + + const tree = await octokit.request("POST /repos/{owner}/{repo}/git/trees", { + owner, + repo, + base_tree: baseTreeSha, + tree: [{ path, mode: "100644", type: "blob", content: args.testSource }], + }); + const treeSha = (tree.data as { sha: string }).sha; + + const commit = await octokit.request("POST /repos/{owner}/{repo}/git/commits", { + owner, + repo, + message, + tree: treeSha, + parents: [args.headSha], + }); + const commitSha = (commit.data as { sha: string }).sha; + + // A ref UPDATE (not create) against the PR's own existing head branch — the one structural difference + // from repo-doc-pr.ts's new-branch flow. `force: false` (the default) so a genuinely concurrent push to + // the same branch surfaces as a 422/409 (handled below) rather than silently discarding it. + await octokit.request("PATCH /repos/{owner}/{repo}/git/refs/{ref}", { owner, repo, ref: `heads/${args.headRef}`, sha: commitSha }); + + return { status: "committed", commitSha, htmlUrl: `https://github.com/${args.repoFullName}/commit/${commitSha}` }; + }); + } catch (error) { + const status = githubErrorStatus(error); + if (status === 403 || status === 404) { + return { status: "declined", reason: 'no write access to the PR branch (a fork PR needs "Allow edits by maintainers" enabled, or the installation lacks contents:write)' }; + } + if (status === 422 || status === 409) { + return { status: "declined", reason: "the PR branch moved since this pass started (ref update rejected) — try the command again" }; + } + return { status: "error", reason: errorMessage(error, "unknown error committing the generated test") }; + } +} diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 06a7c19a4b..ecd5c38509 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -488,8 +488,9 @@ import { computeImpactMap, type ImpactMapEntry } from "../review/impact-map"; import { formatImpactMapPromptSection, shouldComputeImpactMap } from "../review/impact-map-wire"; import { shouldEmitFixHandoff } from "../review/fix-handoff"; import { buildFixHandoffBlocks } from "../review/fix-handoff-render"; -import { buildE2eTestGenCommentBody } from "../review/e2e-test-gen-render"; +import { buildE2eTestGenCommentBody, type E2eTestGenCommitOutcome } from "../review/e2e-test-gen-render"; import { resolveE2eTestGenInstructions, runGittensoryE2eTestGeneration } from "../services/ai-e2e-test-gen"; +import { commitE2eTestToPrBranch } from "../github/e2e-test-commit"; import { buildRepoCultureProfileContext, isRepoCultureProfileEnabled, @@ -11447,6 +11448,14 @@ async function maybeProcessGenerateTestsCommand(env: Env, deliveryId: string, pa await recordGenerateTestsSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "feature_disabled"); return true; } + // Same dry-run/paused gate every other action command respects (mirrors maybeProcessResolveCommand's own + // resolveAgentActionMode check) — an agent-paused or dry-run repo gets no generated content posted at all. + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + if (mode !== "live") { + const skipReason = mode === "dry_run" ? "dry_run" : "agent_paused"; + await recordGenerateTestsSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, skipReason); + return true; + } const files = await listPullRequestFiles(env, req.repoFullName, req.pr.number); const changedPaths = files.map((file) => file.path); // BYOK resolution mirrors runAiReviewForAdvisory's own (re-resolved per-caller is this codebase's @@ -11467,7 +11476,41 @@ async function maybeProcessGenerateTestsCommand(env: Env, deliveryId: string, pa providerKey, }); const testSource = result.status === "ok" ? result.testSource : null; - const body = buildE2eTestGenCommentBody({ actor: req.actor, testSource }); + + // Delivery escalation (#4197): "comment" (default) never attempts a write; "commit" pushes the generated + // test onto the PR's own head branch, UNLESS the PR author is a confirmed Gittensor miner (#4201's + // scoring-integrity safeguard) — that check runs regardless of this repo's own delivery config, since the + // external, upstream-computed score must never be able to include a maintainer-authored line a miner didn't + // write themselves. + const deliveryMode = resolveReviewPromptOverrides(manifest).e2eTestDelivery ?? "comment"; + let commitOutcome: E2eTestGenCommitOutcome | undefined; + if (testSource && deliveryMode === "commit") { + const minerDetection = pr.authorLogin + ? await getCachedOfficialMinerDetection(env, pr.authorLogin, { targetKey, deliveryId }) + : ({ status: "not_found" } as const); + if (minerDetection.status === "confirmed") { + commitOutcome = { status: "blocked" }; + } else if (pr.headSha && pr.headRef) { + const attempt = await commitE2eTestToPrBranch(env, { + installationId: req.installationId, + repoFullName: req.repoFullName, + prNumber: req.pr.number, + headRef: pr.headRef, + headSha: pr.headSha, + testSource, + actor: req.actor, + mode, + }); + // The render layer only distinguishes committed/declined/blocked -- an "error" (unexpected failure, + // vs. an expected can-never-work case) is still surfaced to the maintainer as "declined", with its + // real reason, so the generated test is never silently dropped just because the write failed oddly. + commitOutcome = attempt.status === "error" ? { status: "declined", reason: attempt.reason } : attempt; + } else { + commitOutcome = { status: "declined", reason: "the PR's head branch/commit is not cached" }; + } + } + + const body = buildE2eTestGenCommentBody({ actor: req.actor, testSource, commit: commitOutcome }); try { await createIssueComment(env, req.installationId, req.repoFullName, req.pr.number, sanitizePublicComment(body)); } catch (error) { @@ -11489,9 +11532,9 @@ async function maybeProcessGenerateTestsCommand(env: Env, deliveryId: string, pa targetKey, outcome: "completed", detail: testSource ? "Generated an E2E test." : `No usable test generated (${result.status}).`, - metadata: { deliveryId, repoFullName: req.repoFullName, status: result.status, byok: Boolean(providerKey) }, + metadata: { deliveryId, repoFullName: req.repoFullName, status: result.status, byok: Boolean(providerKey), deliveryMode, ...(commitOutcome ? { commitStatus: commitOutcome.status } : {}) }, }); - await recordGithubProductUsage(env, "e2e_tests_generation", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "completed", metadata: { status: result.status, generated: Boolean(testSource) } }); + await recordGithubProductUsage(env, "e2e_tests_generation", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "completed", metadata: { status: result.status, generated: Boolean(testSource), deliveryMode, ...(commitOutcome ? { commitStatus: commitOutcome.status } : {}) } }); return true; } diff --git a/src/review/e2e-test-gen-render.ts b/src/review/e2e-test-gen-render.ts index d6f51326de..a2757aacce 100644 --- a/src/review/e2e-test-gen-render.ts +++ b/src/review/e2e-test-gen-render.ts @@ -12,17 +12,32 @@ import { AGENT_COMMAND_COMMENT_MARKER } from "../github/comments"; import { gittensoryFooter } from "../github/footer"; +/** Outcome of an attempted `commit`-mode delivery (#4197), or its absence entirely (comment-only mode, or + * generation itself produced nothing usable — see `buildE2eTestGenCommentBody`'s own null-testSource + * branch). `blocked` is distinct from `declined`: it means commit delivery was never attempted because the + * PR author is a confirmed Gittensor miner (#4201's scoring-integrity safeguard), not a GitHub-side failure. */ +export type E2eTestGenCommitOutcome = + | { status: "committed"; commitSha: string; htmlUrl: string } + | { status: "declined"; reason: string } + | { status: "blocked" }; + export type E2eTestGenCommentInput = { actor: string; /** The generated test source, or null when generation ran but produced nothing usable. */ testSource: string | null; framework?: string | undefined; + /** Present only when `commit` delivery mode was configured AND generation produced a usable test. Absent + * for comment-only delivery — the generated test always renders as a suggestion in that case. */ + commit?: E2eTestGenCommitOutcome | undefined; }; /** * Build the PR-comment body for a `@gittensory generate-tests` result. A null `testSource` renders a * clear "nothing usable" note rather than silently posting no comment at all — the maintainer who invoked - * the command should always get a response, even a negative one. + * the command should always get a response, even a negative one. When `commit` delivery succeeded, the + * comment links to the pushed commit instead of repeating its content (the commit IS the deliverable); when + * it was declined or blocked, the comment explains why AND still renders the generated test as a suggestion, + * so a maintainer never loses the generated content just because the heavier delivery mode didn't apply. */ export function buildE2eTestGenCommentBody(input: E2eTestGenCommentInput): string { const framework = input.framework?.trim() || "Playwright"; @@ -38,12 +53,34 @@ export function buildE2eTestGenCommentBody(input: E2eTestGenCommentInput): strin gittensoryFooter(), ].join("\n"); } + if (input.commit?.status === "committed") { + return [ + AGENT_COMMAND_COMMENT_MARKER, + "", + "> [!NOTE]", + `> **AI-generated ${framework} test for @${input.actor} — pushed as a commit**`, + `> [View the commit](${input.commit.htmlUrl}) (\`${input.commit.commitSha.slice(0, 7)}\`). This is a suggestion, not a guarantee — review it like any other test before merging.`, + "", + "---", + gittensoryFooter(), + ].join("\n"); + } + const declineNote = + input.commit?.status === "declined" + ? [`> Commit delivery was requested but declined: ${input.commit.reason}. Posting it as a suggestion instead.`, ""] + : input.commit?.status === "blocked" + ? [ + "> Commit delivery was requested, but this PR's author is a confirmed Gittensor miner — a maintainer-authored commit is never pushed onto a scored contribution's branch, to keep the externally-computed score honest. Posting it as a suggestion instead.", + "", + ] + : []; return [ AGENT_COMMAND_COMMENT_MARKER, "", "> [!NOTE]", `> **AI-generated ${framework} test for @${input.actor}**`, "> This is a suggestion, not a guarantee — review it like any other test before merging.", + ...declineNote, "", "```typescript", input.testSource, diff --git a/src/review/guardrail-config.ts b/src/review/guardrail-config.ts index 718d535a8d..8a07af33d1 100644 --- a/src/review/guardrail-config.ts +++ b/src/review/guardrail-config.ts @@ -30,6 +30,9 @@ export const ENGINE_DECISION_GUARDRAIL_GLOBS = [ "src/github/pr-actions.ts", "src/github/app.ts", "src/github/backfill.ts", + // #4197: writes a real commit onto a CONTRIBUTOR's own PR branch (not a branch gittensory owns) — the same + // guardrail tier as pr-actions.ts/app.ts for the same reason, a new GitHub-write surface. + "src/github/e2e-test-commit.ts", "src/scoring/**", "src/auth/**", "src/review/safety.ts", diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 2fb1c90fa6..55d3147522 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -6,6 +6,7 @@ export { COMMENT_VERBOSITY_LEVELS, CONVERGED_FEATURE_KEYS, + E2E_TEST_DELIVERY_MODES, EMPTY_AUTO_REVIEW_CONFIG, EMPTY_MAX_FINDINGS_CONFIG, EMPTY_SELF_HOST_AI_MODEL_CONFIG, @@ -34,6 +35,7 @@ export { type AutoReviewConfig, type CommentVerbosity, type ConvergedFeatureKey, + type E2eTestDeliveryMode, type FocusManifest, type FocusManifestContentLaneConfig, type FocusManifestFeaturesConfig, @@ -85,6 +87,7 @@ import { matchesManifestPath, type AutoReviewConfig, type CommentVerbosity, + type E2eTestDeliveryMode, type FocusManifest, type FocusManifestFinding, type FocusManifestGateConfig, @@ -247,7 +250,7 @@ export function composeManifestReviewInstructions(instructions: string | null, t * failure). A null manifest yields the byte-identical defaults. Centralized so the AI-review caller threads them * in one place with the null-manifest branch covered here (unit-tested) rather than inline in the processor. * (#review-profile / #review-tone / #review-security-focus / #review-path-instructions / #review-exclude-paths / #2043 / #selfhost-ai-model-override / #1956) */ -export function resolveReviewPromptOverrides(manifest: FocusManifest | null): { profile: ReviewProfile | null; tone: string | null; securityFocus: boolean; inlineComments: boolean; suggestions: boolean; changedFilesSummary: boolean; effortScore: boolean; impactMap: boolean; cultureProfile: boolean; findingCategories: boolean; inlineCommentsPerCategory: number | null; minFindingSeverity: ReviewFindingSeverity | null; maxFindings: MaxFindingsConfig; commentVerbosity: CommentVerbosity | null; pathInstructions: ReviewPathInstruction[]; instructions: string | null; excludePaths: string[]; pathFilters: string[]; selfHostAiModel: SelfHostAiModelConfig } { +export function resolveReviewPromptOverrides(manifest: FocusManifest | null): { profile: ReviewProfile | null; tone: string | null; securityFocus: boolean; inlineComments: boolean; suggestions: boolean; changedFilesSummary: boolean; effortScore: boolean; impactMap: boolean; cultureProfile: boolean; findingCategories: boolean; inlineCommentsPerCategory: number | null; minFindingSeverity: ReviewFindingSeverity | null; maxFindings: MaxFindingsConfig; commentVerbosity: CommentVerbosity | null; e2eTestDelivery: E2eTestDeliveryMode | null; pathInstructions: ReviewPathInstruction[]; instructions: string | null; excludePaths: string[]; pathFilters: string[]; selfHostAiModel: SelfHostAiModelConfig } { // inlineComments resolves to a strict boolean — true ONLY when the manifest explicitly set review.inline_comments: // true; null/false/absent ⇒ false. `shouldRequestInlineFindings` (#4099) only ever checks `=== true`, so null // and false are functionally identical to it — collapsing here (matching every sibling field below) is simpler @@ -270,7 +273,7 @@ export function resolveReviewPromptOverrides(manifest: FocusManifest | null): { // cultureProfile resolves the same way (#2995) — true ONLY when the manifest explicitly set // review.culture_profile: true. The caller ANDs this per-repo opt-in with the GITTENSORY_REVIEW_CULTURE_PROFILE // global kill-switch (mirrors how RAG/reputation/grounding compose a global flag with a per-repo override). - return { profile: manifest?.review.profile ?? null, tone: manifest?.review.tone ?? null, securityFocus: manifest?.review.securityFocus === true, inlineComments: manifest?.review.inlineComments === true, suggestions: manifest?.review.suggestions === true, changedFilesSummary: manifest?.review.changedFilesSummary === true, effortScore: manifest?.review.effortScore === true, impactMap: manifest?.review.impactMap === true, cultureProfile: manifest?.review.cultureProfile === true, findingCategories: manifest?.review.findingCategories === true, inlineCommentsPerCategory: manifest?.review.inlineCommentsPerCategory ?? null, minFindingSeverity: manifest?.review.minFindingSeverity ?? null, maxFindings: manifest?.review.maxFindings ?? { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: manifest?.review.commentVerbosity ?? null, pathInstructions: manifest?.review.pathInstructions ?? [], instructions: manifest?.review.instructions ?? null, excludePaths: manifest?.review.excludePaths ?? [], pathFilters: manifest?.review.pathFilters ?? [], selfHostAiModel: resolveReviewSelfHostAiModel(manifest) }; + return { profile: manifest?.review.profile ?? null, tone: manifest?.review.tone ?? null, securityFocus: manifest?.review.securityFocus === true, inlineComments: manifest?.review.inlineComments === true, suggestions: manifest?.review.suggestions === true, changedFilesSummary: manifest?.review.changedFilesSummary === true, effortScore: manifest?.review.effortScore === true, impactMap: manifest?.review.impactMap === true, cultureProfile: manifest?.review.cultureProfile === true, findingCategories: manifest?.review.findingCategories === true, inlineCommentsPerCategory: manifest?.review.inlineCommentsPerCategory ?? null, minFindingSeverity: manifest?.review.minFindingSeverity ?? null, maxFindings: manifest?.review.maxFindings ?? { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: manifest?.review.commentVerbosity ?? null, e2eTestDelivery: manifest?.review.e2eTestDelivery ?? null, pathInstructions: manifest?.review.pathInstructions ?? [], instructions: manifest?.review.instructions ?? null, excludePaths: manifest?.review.excludePaths ?? [], pathFilters: manifest?.review.pathFilters ?? [], selfHostAiModel: resolveReviewSelfHostAiModel(manifest) }; } /** Resolve `review.memory` (#2179, config slice of #1964) from a possibly-null manifest (null = load failure ⇒ diff --git a/test/unit/e2e-test-commit.test.ts b/test/unit/e2e-test-commit.test.ts new file mode 100644 index 0000000000..3fa0fd2280 --- /dev/null +++ b/test/unit/e2e-test-commit.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { generateKeyPairSync } from "node:crypto"; +import { commitE2eTestToPrBranch, defaultE2eTestFilePath } from "../../src/github/e2e-test-commit"; +import { createTestEnv } from "../helpers/d1"; + +function generateRsaPrivateKeyPem(): string { + return generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs1", format: "pem" }, publicKeyEncoding: { type: "pkcs1", format: "pem" } }).privateKey; +} + +function envWithKey() { + return createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); +} + +const REPO = "owner/widgets"; +const TOKEN_URL = /\/access_tokens$/; +const TEST_SOURCE = "import { test } from '@playwright/test';\ntest('x', () => {});"; + +const baseArgs = { + installationId: 555, + repoFullName: REPO, + prNumber: 42, + headRef: "feature/my-branch", + headSha: "head-commit-sha", + testSource: TEST_SOURCE, + actor: "maintainer", + mode: "live" as const, +}; + +describe("defaultE2eTestFilePath", () => { + it("namespaces the generated file by PR number", () => { + expect(defaultE2eTestFilePath(42)).toBe("e2e/gittensory-pr-42.spec.ts"); + }); +}); + +describe("commitE2eTestToPrBranch (#4197)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("declines without any GitHub call when mode is not live", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const result = await commitE2eTestToPrBranch(envWithKey(), { ...baseArgs, mode: "dry_run" }); + expect(result).toEqual({ status: "declined", reason: 'commit not pushed: action mode is "dry_run"' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("pushes a commit onto the PR's existing head branch via a ref UPDATE (not create)", async () => { + const env = envWithKey(); + const calls: Array<{ method: string; url: string; body: Record }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + const method = init?.method ?? "GET"; + calls.push({ method, url, body: init?.body ? JSON.parse(String(init.body)) : {} }); + if (url.endsWith("/git/commits/head-commit-sha") && method === "GET") return Response.json({ tree: { sha: "base-tree-sha" } }); + if (url.endsWith("/git/trees") && method === "POST") return Response.json({ sha: "new-tree-sha" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "new-commit-sha" }); + // Match by method only, not the exact ref path segment -- whether octokit percent-encodes the "/" in + // "heads/feature/my-branch" is an internal templating detail this test shouldn't need to pin down. + if (method === "PATCH") return Response.json({ ref: "refs/heads/feature/my-branch" }); + return new Response("unexpected", { status: 500 }); + }); + + const result = await commitE2eTestToPrBranch(env, baseArgs); + + expect(result).toEqual({ status: "committed", commitSha: "new-commit-sha", htmlUrl: `https://github.com/${REPO}/commit/new-commit-sha` }); + + const treeCall = calls.find((c) => c.url.endsWith("/git/trees")); + expect(treeCall?.body).toMatchObject({ base_tree: "base-tree-sha", tree: [{ path: "e2e/gittensory-pr-42.spec.ts", mode: "100644", type: "blob", content: TEST_SOURCE }] }); + + const commitCall = calls.find((c) => c.url.endsWith("/git/commits") && c.method === "POST"); + expect(commitCall?.body).toMatchObject({ tree: "new-tree-sha", parents: ["head-commit-sha"] }); + expect(commitCall?.body.message as string).toContain("Generated-by: gittensory (invoked by @maintainer)"); + + // octokit percent-encodes the whole "heads/feature/my-branch" ref value into one path segment. + const refCall = calls.find((c) => c.method === "PATCH"); + expect(decodeURIComponent(refCall?.url ?? "")).toContain("/git/refs/heads/feature/my-branch"); + expect(refCall?.body).toMatchObject({ sha: "new-commit-sha" }); + }); + + it("uses a custom file path when provided instead of the default", async () => { + const env = envWithKey(); + let treeBody: Record = {}; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + const method = init?.method ?? "GET"; + if (url.endsWith("/git/commits/head-commit-sha") && method === "GET") return Response.json({ tree: { sha: "base-tree-sha" } }); + if (url.endsWith("/git/trees") && method === "POST") { + treeBody = init?.body ? JSON.parse(String(init.body)) : {}; + return Response.json({ sha: "new-tree-sha" }); + } + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "new-commit-sha" }); + if (method === "PATCH") return Response.json({}); + return new Response("unexpected", { status: 500 }); + }); + + await commitE2eTestToPrBranch(env, { ...baseArgs, testFilePath: "test/e2e/custom.spec.ts" }); + + expect((treeBody.tree as Array<{ path: string }>)[0]?.path).toBe("test/e2e/custom.spec.ts"); + }); + + it("declines with a clear reason on a 403/404 (no write access -- fork without maintainer edits)", async () => { + const env = envWithKey(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + if (url.endsWith("/git/commits/head-commit-sha")) return new Response("forbidden", { status: 403 }); + return new Response("unexpected", { status: 500 }); + }); + + const result = await commitE2eTestToPrBranch(env, baseArgs); + expect(result).toMatchObject({ status: "declined" }); + if (result.status !== "declined") throw new Error("unreachable"); + expect(result.reason).toContain("no write access"); + }); + + it("declines with a clear reason on a 422/409 (the branch moved since this pass started)", async () => { + const env = envWithKey(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + const method = init?.method ?? "GET"; + if (url.endsWith("/git/commits/head-commit-sha") && method === "GET") return Response.json({ tree: { sha: "base-tree-sha" } }); + if (url.endsWith("/git/trees") && method === "POST") return Response.json({ sha: "new-tree-sha" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "new-commit-sha" }); + if (method === "PATCH") return new Response("conflict", { status: 422 }); + return new Response("unexpected", { status: 500 }); + }); + + const result = await commitE2eTestToPrBranch(env, baseArgs); + expect(result).toMatchObject({ status: "declined" }); + if (result.status !== "declined") throw new Error("unreachable"); + expect(result.reason).toContain("branch moved"); + }); + + it("returns status: error (not declined, not thrown) on a genuinely unexpected failure", async () => { + const env = envWithKey(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + if (url.endsWith("/git/commits/head-commit-sha")) return new Response("server exploded", { status: 500 }); + return new Response("unexpected", { status: 500 }); + }); + + const result = await commitE2eTestToPrBranch(env, baseArgs); + expect(result.status).toBe("error"); + }); + + it("reports a genuinely unexpected failure (never throws) even when the underlying rejection is not itself an Error", async () => { + const env = envWithKey(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + throw "boom"; // deliberately a non-Error throw -- octokit wraps this into its own Error before it + // ever reaches our catch block, so this asserts the fail-safe status/shape, not a literal message. + }); + + const result = await commitE2eTestToPrBranch(env, baseArgs); + expect(result.status).toBe("error"); + if (result.status !== "error") throw new Error("unreachable"); + expect(typeof result.reason).toBe("string"); + expect(result.reason.length).toBeGreaterThan(0); + }); +}); diff --git a/test/unit/e2e-test-gen-render.test.ts b/test/unit/e2e-test-gen-render.test.ts index 2fc6864789..50fa6530ee 100644 --- a/test/unit/e2e-test-gen-render.test.ts +++ b/test/unit/e2e-test-gen-render.test.ts @@ -26,4 +26,42 @@ describe("buildE2eTestGenCommentBody", () => { const body = buildE2eTestGenCommentBody({ actor: "maintainer", testSource: null, framework: "Cypress" }); expect(body).toContain("didn't parse as valid Cypress source"); }); + + it("links to the commit instead of repeating the code when commit delivery succeeded", () => { + const body = buildE2eTestGenCommentBody({ + actor: "maintainer", + testSource: "test('x', () => {});", + commit: { status: "committed", commitSha: "abcdef1234567890", htmlUrl: "https://github.com/o/r/commit/abcdef1234567890" }, + }); + expect(body).toContain("pushed as a commit"); + expect(body).toContain("[View the commit](https://github.com/o/r/commit/abcdef1234567890)"); + expect(body).toContain("`abcdef1`"); // short sha + expect(body).not.toContain("```typescript"); // the commit IS the deliverable, not repeated inline + }); + + it("still renders the suggestion, with a reason, when commit delivery was declined", () => { + const body = buildE2eTestGenCommentBody({ + actor: "maintainer", + testSource: "test('x', () => {});", + commit: { status: "declined", reason: "no write access to the PR branch" }, + }); + expect(body).toContain("Commit delivery was requested but declined: no write access to the PR branch"); + expect(body).toContain("```typescript\ntest('x', () => {});\n```"); // falls back to the suggestion + }); + + it("still renders the suggestion, with the scoring-integrity reason, when commit delivery was blocked for a confirmed miner", () => { + const body = buildE2eTestGenCommentBody({ + actor: "maintainer", + testSource: "test('x', () => {});", + commit: { status: "blocked" }, + }); + expect(body).toContain("confirmed Gittensor miner"); + expect(body).toContain("```typescript\ntest('x', () => {});\n```"); + }); + + it("renders exactly like comment-only mode when commit is omitted entirely", () => { + const withoutCommit = buildE2eTestGenCommentBody({ actor: "maintainer", testSource: "test('x', () => {});" }); + const withUndefinedCommit = buildE2eTestGenCommentBody({ actor: "maintainer", testSource: "test('x', () => {});", commit: undefined }); + expect(withUndefinedCommit).toBe(withoutCommit); + }); }); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index f744174e5c..ac972d98ae 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -379,6 +379,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { minFindingSeverity: "min_finding_severity:", maxFindings: "max_findings:", commentVerbosity: "comment_verbosity:", + e2eTestDelivery: "e2e_test_delivery:", pathInstructions: "path_instructions:", instructions: "instructions:", excludePaths: "exclude_paths:", @@ -805,7 +806,7 @@ describe("compileFocusManifestPolicy", () => { publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, + review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, e2eTestDelivery: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null, grounding: null, e2eTests: null }, contentLane: { present: false, entryFileGlob: null, providerFileGlob: null, artifactGlob: null, collectionField: null, maxAppendedEntries: null, duplicateKeyFields: [], validatorId: null }, repoDocGeneration: { present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false, refreshIntervalDays: 7 }, @@ -3109,13 +3110,13 @@ describe("resolveReviewPathInstructions (#review-path-instructions)", () => { it("resolveReviewPromptOverrides: non-null manifest passes the config through; null manifest → defaults", () => { const manifest = parseFocusManifest({ review: { profile: "chill", security_focus: true, inline_comments: true, suggestions: true, changed_files_summary: true, effort_score: true, impact_map: true, culture_profile: true, finding_categories: true, comment_verbosity: "detailed", path_instructions: [{ path: "src/**", instructions: "be strict" }], instructions: "Follow our async-error conventions.", exclude_paths: ["**/*.lock"], path_filters: ["src/**", "!src/generated/**"] } }); - expect(resolveReviewPromptOverrides(manifest)).toEqual({ profile: "chill", tone: null, securityFocus: true, inlineComments: true, suggestions: true, changedFilesSummary: true, effortScore: true, impactMap: true, cultureProfile: true, findingCategories: true, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: "detailed", pathInstructions: [{ path: "src/**", instructions: "be strict" }], instructions: "Follow our async-error conventions.", excludePaths: ["**/*.lock"], pathFilters: ["src/**", "!src/generated/**"], selfHostAiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } }); + expect(resolveReviewPromptOverrides(manifest)).toEqual({ profile: "chill", tone: null, securityFocus: true, inlineComments: true, suggestions: true, changedFilesSummary: true, effortScore: true, impactMap: true, cultureProfile: true, findingCategories: true, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: "detailed", e2eTestDelivery: null, pathInstructions: [{ path: "src/**", instructions: "be strict" }], instructions: "Follow our async-error conventions.", excludePaths: ["**/*.lock"], pathFilters: ["src/**", "!src/generated/**"], selfHostAiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } }); // A null manifest (load failure) yields the byte-identical defaults; inline comments + suggestions + // changed-files summary + effort score + impact map + culture profile + finding categories + security focus // all default OFF (strict false) — inlineComments collapses the same way as every sibling flag on this // object (#4099: shouldRequestInlineFindings only ever checks `=== true`, so null/false/absent are // functionally identical to it; no tri-state needed here). - expect(resolveReviewPromptOverrides(null)).toEqual({ profile: null, tone: null, securityFocus: false, inlineComments: false, suggestions: false, changedFilesSummary: false, effortScore: false, impactMap: false, cultureProfile: false, findingCategories: false, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], selfHostAiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } }); + expect(resolveReviewPromptOverrides(null)).toEqual({ profile: null, tone: null, securityFocus: false, inlineComments: false, suggestions: false, changedFilesSummary: false, effortScore: false, impactMap: false, cultureProfile: false, findingCategories: false, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, e2eTestDelivery: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], selfHostAiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } }); // An explicit false / absent toggle both resolve to the strict-boolean false. expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { inline_comments: false } })).inlineComments).toBe(false); expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { profile: "chill" } })).inlineComments).toBe(false); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 25ec17daac..ef96c203d3 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -24763,18 +24763,30 @@ describe("queue processors", () => { // harness above (classify -> authorize -> act -> audit), but with the authorization tier deliberately // narrowed to ["maintainer"] only -- no collaborator, no confirmed_miner -- and a real (mocked) model call. describe("@gittensory generate-tests (#4195)", () => { - async function seedGenerateTestsPr(env: Env, repoFullName: string, prNumber: number, headSha: string, authorLogin = "contributor") { + async function seedGenerateTestsPr( + env: Env, + repoFullName: string, + prNumber: number, + headSha: string, + authorLogin = "contributor", + opts: { headRef?: string; e2eTestDelivery?: "comment" | "commit" } = {}, + ) { const slash = repoFullName.indexOf("/"); const owner = repoFullName.slice(0, slash); const name = repoFullName.slice(slash + 1); await upsertRepositoryFromGitHub(env, { name, full_name: repoFullName, private: false, owner: { login: owner } }, 123); await upsertRepositorySettings(env, { repoFullName, commentMode: "off", publicSurface: "off", autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", requireLinkedIssue: false, linkedIssueGateMode: "advisory", aiReviewMode: "advisory" }); - await upsertPullRequestFromGitHub(env, repoFullName, { number: prNumber, title: "Add retry to checkout", state: "open", user: { login: authorLogin }, author_association: "CONTRIBUTOR", head: { sha: headSha }, labels: [], body: "Retries the payment call once on a 5xx." }); + await upsertPullRequestFromGitHub(env, repoFullName, { number: prNumber, title: "Add retry to checkout", state: "open", user: { login: authorLogin }, author_association: "CONTRIBUTOR", head: { sha: headSha, ref: opts.headRef ?? "feature/checkout-retry" }, labels: [], body: "Retries the payment call once on a 5xx." }); await upsertPullRequestFile(env, { repoFullName, pullNumber: prNumber, path: "src/checkout.ts", status: "modified", additions: 3, deletions: 0, changes: 3, payload: { patch: "+function retryPayment() {\n+ return true;\n+}" } }); // A renamed-with-no-patch file (GitHub omits `patch` for pure renames) -- exercises the // payload?.patch-is-not-a-string branch in the files.map() that builds E2eTestGenChangedFile[]. await upsertPullRequestFile(env, { repoFullName, pullNumber: prNumber, path: "src/renamed.ts", status: "renamed", additions: 0, deletions: 0, changes: 0, payload: {} }); - await upsertRepoFocusManifest(env, repoFullName, { features: { e2eTests: true } }); + // features.e2eTests + review.e2e_test_delivery MUST land in the SAME upsertRepoFocusManifest call -- + // a second separate call REPLACES rather than merges with a prior one (see repo-doc-pr.test.ts). + await upsertRepoFocusManifest(env, repoFullName, { + features: { e2eTests: true }, + ...(opts.e2eTestDelivery ? { review: { e2e_test_delivery: opts.e2eTestDelivery } } : {}), + }); } const generateTestsWebhook = (repoFullName: string, prNumber: number, actor: string, opts: { association?: string; bot?: boolean; commenterIsAuthor?: boolean } = {}) => ({ type: "github-webhook" as const, @@ -25088,6 +25100,254 @@ describe("queue processors", () => { const rows = await env.DB.prepare("select count(*) as n from audit_events where event_type like 'github_app.e2e_tests_generation%'").first<{ n: number }>(); expect(rows?.n).toBe(0); }); + + // #4197 (commit delivery) + #4201 (scoring-integrity safeguard), both part of the #4189 epic. + describe("commit delivery mode (#4197, #4201)", () => { + it("pushes the generated test as a commit onto the PR's own head branch for a non-miner author, and records commitStatus: committed", async () => { + const repoFullName = "JSONbored/gen-tests-4197-commit-ok"; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => ({ response: "```typescript\n" + VALID_TEST_SOURCE + "\n```" }) } as unknown as Ai, + GITTENSORY_REVIEW_E2E_TESTS: "true", + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + }); + await seedGenerateTestsPr(env, repoFullName, 4207, "commit-ok-head-sha", "contributor", { headRef: "feature/checkout-retry", e2eTestDelivery: "commit" }); + let postedBody = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + if (url.endsWith("/git/commits/commit-ok-head-sha") && method === "GET") return Response.json({ tree: { sha: "base-tree-sha" } }); + if (url.endsWith("/git/trees") && method === "POST") return Response.json({ sha: "new-tree-sha" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "committed-sha-123" }); + if (method === "PATCH") return Response.json({}); + if (url.includes("/issues/4207/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4207/comments") && method === "POST") { postedBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); return Response.json({ id: 42070 }); } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, generateTestsWebhook(repoFullName, 4207, "maintainer", { association: "MEMBER" })); + + expect(postedBody).toContain("pushed as a commit"); + expect(postedBody).toContain(`https://github.com/${repoFullName}/commit/committed-sha-123`); + expect(postedBody).not.toContain("```typescript"); + const audited = await env.DB.prepare("select metadata_json from audit_events where event_type = ?").bind("github_app.e2e_tests_generation").first<{ metadata_json: string }>(); + expect(JSON.parse(audited?.metadata_json ?? "{}")).toMatchObject({ deliveryMode: "commit", commitStatus: "committed" }); + }); + + it("blocks commit delivery for a confirmed Gittensor miner PR author, but still posts the generated test as a suggestion (#4201)", async () => { + const repoFullName = "JSONbored/gen-tests-4201-miner-blocked"; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => ({ response: "```typescript\n" + VALID_TEST_SOURCE + "\n```" }) } as unknown as Ai, + GITTENSORY_REVIEW_E2E_TESTS: "true", + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + }); + await seedGenerateTestsPr(env, repoFullName, 4208, "miner-blocked-head-sha", "confirmed-miner", { headRef: "feature/checkout-retry", e2eTestDelivery: "commit" }); + await upsertOfficialMinerDetection(env, "confirmed-miner", { status: "confirmed", snapshot: queueMinerSnapshot("confirmed-miner") }, 60_000); + let posted = 0; + let postedBody = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + // No git/trees or git/commits stubs at all -- a blocked commit must never even attempt a GitHub write. + if (url.includes("/issues/4208/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4208/comments") && method === "POST") { posted += 1; postedBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); return Response.json({ id: 42080 }); } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, generateTestsWebhook(repoFullName, 4208, "maintainer", { association: "MEMBER" })); + + expect(posted).toBe(1); + expect(postedBody).toContain("confirmed Gittensor miner"); + expect(postedBody).toContain("```typescript\n" + VALID_TEST_SOURCE + "\n```"); + const audited = await env.DB.prepare("select metadata_json from audit_events where event_type = ?").bind("github_app.e2e_tests_generation").first<{ metadata_json: string }>(); + expect(JSON.parse(audited?.metadata_json ?? "{}")).toMatchObject({ deliveryMode: "commit", commitStatus: "blocked" }); + }); + + it("falls back to a declined-with-reason suggestion when commit delivery has no write access to a fork PR branch", async () => { + const repoFullName = "JSONbored/gen-tests-4197-commit-declined"; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => ({ response: "```typescript\n" + VALID_TEST_SOURCE + "\n```" }) } as unknown as Ai, + GITTENSORY_REVIEW_E2E_TESTS: "true", + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + }); + await seedGenerateTestsPr(env, repoFullName, 4209, "declined-head-sha", "contributor", { headRef: "feature/checkout-retry", e2eTestDelivery: "commit" }); + let postedBody = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + if (url.endsWith("/git/commits/declined-head-sha") && method === "GET") return new Response("forbidden", { status: 403 }); + if (url.includes("/issues/4209/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4209/comments") && method === "POST") { postedBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); return Response.json({ id: 42090 }); } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, generateTestsWebhook(repoFullName, 4209, "maintainer", { association: "MEMBER" })); + + expect(postedBody).toContain("Commit delivery was requested but declined: no write access"); + expect(postedBody).toContain("```typescript\n" + VALID_TEST_SOURCE + "\n```"); + const audited = await env.DB.prepare("select metadata_json from audit_events where event_type = ?").bind("github_app.e2e_tests_generation").first<{ metadata_json: string }>(); + expect(JSON.parse(audited?.metadata_json ?? "{}")).toMatchObject({ deliveryMode: "commit", commitStatus: "declined" }); + }); + + it("declines commit delivery with a clear reason when the PR's head branch/commit is not cached at all", async () => { + const repoFullName = "JSONbored/gen-tests-4197-commit-no-head"; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => ({ response: "```typescript\n" + VALID_TEST_SOURCE + "\n```" }) } as unknown as Ai, + GITTENSORY_REVIEW_E2E_TESTS: "true", + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + }); + const slash = repoFullName.indexOf("/"); + await upsertRepositoryFromGitHub(env, { name: repoFullName.slice(slash + 1), full_name: repoFullName, private: false, owner: { login: repoFullName.slice(0, slash) } }, 123); + await upsertRepositorySettings(env, { repoFullName, commentMode: "off", publicSurface: "off", autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", requireLinkedIssue: false, linkedIssueGateMode: "advisory", aiReviewMode: "advisory" }); + // No head sha/ref cached at all on this PR record. + await upsertPullRequestFromGitHub(env, repoFullName, { number: 4210, title: "Add retry to checkout", state: "open", user: { login: "contributor" }, author_association: "CONTRIBUTOR", labels: [], body: "x" }); + await upsertPullRequestFile(env, { repoFullName, pullNumber: 4210, path: "src/checkout.ts", status: "modified", additions: 3, deletions: 0, changes: 3, payload: { patch: "+function retryPayment() {\n+ return true;\n+}" } }); + await upsertRepoFocusManifest(env, repoFullName, { features: { e2eTests: true }, review: { e2e_test_delivery: "commit" } }); + let postedBody = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + if (url.includes("/issues/4210/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4210/comments") && method === "POST") { postedBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); return Response.json({ id: 42100 }); } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, generateTestsWebhook(repoFullName, 4210, "maintainer", { association: "MEMBER" })); + + expect(postedBody).toContain("Commit delivery was requested but declined: the PR's head branch/commit is not cached"); + const audited = await env.DB.prepare("select metadata_json from audit_events where event_type = ?").bind("github_app.e2e_tests_generation").first<{ metadata_json: string }>(); + expect(JSON.parse(audited?.metadata_json ?? "{}")).toMatchObject({ deliveryMode: "commit", commitStatus: "declined" }); + }); + + it("maps a genuinely unexpected git-write failure to a declined outcome (not a thrown error) in the posted comment", async () => { + const repoFullName = "JSONbored/gen-tests-4197-commit-error-mapped"; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => ({ response: "```typescript\n" + VALID_TEST_SOURCE + "\n```" }) } as unknown as Ai, + GITTENSORY_REVIEW_E2E_TESTS: "true", + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + }); + await seedGenerateTestsPr(env, repoFullName, 4213, "error-mapped-head-sha", "contributor", { headRef: "feature/checkout-retry", e2eTestDelivery: "commit" }); + let postedBody = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + // Neither a 403/404 (no write access) nor a 422/409 (branch moved) -- a genuinely unexpected 500, + // which commitE2eTestToPrBranch maps to status: "error" rather than "declined". + if (url.endsWith("/git/commits/error-mapped-head-sha") && method === "GET") return new Response("server exploded", { status: 500 }); + if (url.includes("/issues/4213/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4213/comments") && method === "POST") { postedBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); return Response.json({ id: 42130 }); } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, generateTestsWebhook(repoFullName, 4213, "maintainer", { association: "MEMBER" })); + + expect(postedBody).toContain("Commit delivery was requested but declined:"); + expect(postedBody).toContain("```typescript\n" + VALID_TEST_SOURCE + "\n```"); + const audited = await env.DB.prepare("select metadata_json from audit_events where event_type = ?").bind("github_app.e2e_tests_generation").first<{ metadata_json: string }>(); + expect(JSON.parse(audited?.metadata_json ?? "{}")).toMatchObject({ deliveryMode: "commit", commitStatus: "declined" }); + }); + + it("still resolves the miner-safeguard check (to not-found) when the cached PR record has no author login at all", async () => { + const repoFullName = "JSONbored/gen-tests-4201-no-author"; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => ({ response: "```typescript\n" + VALID_TEST_SOURCE + "\n```" }) } as unknown as Ai, + GITTENSORY_REVIEW_E2E_TESTS: "true", + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + }); + const slash = repoFullName.indexOf("/"); + await upsertRepositoryFromGitHub(env, { name: repoFullName.slice(slash + 1), full_name: repoFullName, private: false, owner: { login: repoFullName.slice(0, slash) } }, 123); + await upsertRepositorySettings(env, { repoFullName, commentMode: "off", publicSurface: "off", autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", requireLinkedIssue: false, linkedIssueGateMode: "advisory", aiReviewMode: "advisory" }); + // Deliberately no `user` field at all -- the cached PR's authorLogin resolves to null, exercising the + // ternary's not-found arm (`pr.authorLogin ? ... : { status: "not_found" }`) instead of ever calling + // getCachedOfficialMinerDetection. + await upsertPullRequestFromGitHub(env, repoFullName, { number: 4214, title: "Add retry to checkout", state: "open", author_association: "CONTRIBUTOR", head: { sha: "no-author-head-sha", ref: "feature/checkout-retry" }, labels: [], body: "x" }); + await upsertPullRequestFile(env, { repoFullName, pullNumber: 4214, path: "src/checkout.ts", status: "modified", additions: 3, deletions: 0, changes: 3, payload: { patch: "+function retryPayment() {\n+ return true;\n+}" } }); + await upsertRepoFocusManifest(env, repoFullName, { features: { e2eTests: true }, review: { e2e_test_delivery: "commit" } }); + let postedBody = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + if (url.endsWith("/git/commits/no-author-head-sha") && method === "GET") return Response.json({ tree: { sha: "base-tree-sha" } }); + if (url.endsWith("/git/trees") && method === "POST") return Response.json({ sha: "new-tree-sha" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "no-author-commit-sha" }); + if (method === "PATCH") return Response.json({}); + if (url.includes("/issues/4214/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4214/comments") && method === "POST") { postedBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); return Response.json({ id: 42140 }); } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, generateTestsWebhook(repoFullName, 4214, "maintainer", { association: "MEMBER" })); + + expect(postedBody).toContain("pushed as a commit"); + const audited = await env.DB.prepare("select metadata_json from audit_events where event_type = ?").bind("github_app.e2e_tests_generation").first<{ metadata_json: string }>(); + expect(JSON.parse(audited?.metadata_json ?? "{}")).toMatchObject({ deliveryMode: "commit", commitStatus: "committed" }); + }); + + it("respects agentDryRun — never attempts commit delivery, and records dry_run (not agent_paused)", async () => { + const repoFullName = "JSONbored/gen-tests-4197-commit-dryrun"; + const run = vi.fn(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), AI: { run } as unknown as Ai, GITTENSORY_REVIEW_E2E_TESTS: "true", AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true" }); + await seedGenerateTestsPr(env, repoFullName, 4211, "dryrun-head-sha", "contributor", { headRef: "feature/checkout-retry", e2eTestDelivery: "commit" }); + await upsertRepositorySettings(env, { repoFullName, commentMode: "off", publicSurface: "off", autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", requireLinkedIssue: false, linkedIssueGateMode: "advisory", aiReviewMode: "advisory", agentDryRun: true }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, generateTestsWebhook(repoFullName, 4211, "maintainer", { association: "MEMBER" })); + + expect(run).not.toHaveBeenCalled(); + const skipped = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.e2e_tests_generation_skipped").first<{ detail: string }>(); + expect(skipped?.detail).toBe("dry_run"); + const generated = await env.DB.prepare("select id from audit_events where event_type = ?").bind("github_app.e2e_tests_generation").first<{ id: string }>(); + expect(generated ?? null).toBeNull(); + }); + + it("respects agentPaused — never attempts generation or commit delivery, and records agent_paused", async () => { + const repoFullName = "JSONbored/gen-tests-4197-commit-paused"; + const run = vi.fn(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), AI: { run } as unknown as Ai, GITTENSORY_REVIEW_E2E_TESTS: "true", AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true" }); + await seedGenerateTestsPr(env, repoFullName, 4212, "paused-head-sha", "contributor", { headRef: "feature/checkout-retry", e2eTestDelivery: "commit" }); + await upsertRepositorySettings(env, { repoFullName, commentMode: "off", publicSurface: "off", autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", requireLinkedIssue: false, linkedIssueGateMode: "advisory", aiReviewMode: "advisory", agentPaused: true }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/maintainer/permission")) return Response.json({ permission: "admin" }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, generateTestsWebhook(repoFullName, 4212, "maintainer", { association: "MEMBER" })); + + expect(run).not.toHaveBeenCalled(); + const skipped = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.e2e_tests_generation_skipped").first<{ detail: string }>(); + expect(skipped?.detail).toBe("agent_paused"); + }); + }); }); it("ops-alerts job no-ops when GITTENSORY_REVIEW_OPS is OFF (does no anomaly scan)", async () => { diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 787fff5c79..2107170e1a 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1138,7 +1138,7 @@ describe("signal coverage edge cases", () => { collisions: buildCollisionReport(directRepo.fullName, [], [currentPr]), preflight: buildPreflightResult({ repoFullName: directRepo.fullName, title: "Fix isolated issue", body: "Fixes #99", linkedIssues: [99] }, directRepo, [], [currentPr]), settings: gateSettings, - review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false }, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { skipDrafts: null, ignoreAuthors: [], ignoreTitleKeywords: [], skipLabels: [], skipDocsOnly: null, maxAddedLines: 0, maxFiles: 0, baseBranches: [], autoPauseAfterReviewedCommits: null }, labelingRules: [], aiModel: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, visual: { preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null, actionsFallback: false }, linkedIssueSatisfaction: null, sharedConfigSource: null }, + review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false }, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, e2eTestDelivery: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { skipDrafts: null, ignoreAuthors: [], ignoreTitleKeywords: [], skipLabels: [], skipDocsOnly: null, maxAddedLines: 0, maxFiles: 0, baseBranches: [], autoPauseAfterReviewedCommits: null }, labelingRules: [], aiModel: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, visual: { preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null, actionsFallback: false }, linkedIssueSatisfaction: null, sharedConfigSource: null }, aiReview: { notes: "The change is focused.\n\n**Nits (2)**\n- Add a test for the edge case.\n- Keep the validator helper scoped." }, }); expect(customizedComment).toContain("Reviewed by the Acme maintainer bot."); // custom footer lead