Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/gittensory-ui/src/lib/command-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,13 @@ export const ACTION_COMMAND_ENTRIES = [
description:
"Explain a specific review finding; supply the finding reference in trailing text.",
},
{
id: "generate-tests",
title: "Generate E2E tests",
description:
"Generate an AI E2E test for this PR's changed behavior and post it as a reply comment (maintainer-only).",
},
] as const;

export const ACTION_COMMAND_LIST =
"@gittensory gate-override\n@gittensory review\n@gittensory pause\n@gittensory resume\n@gittensory resolve\n@gittensory configuration\n@gittensory explain";
"@gittensory gate-override\n@gittensory review\n@gittensory pause\n@gittensory resume\n@gittensory resolve\n@gittensory configuration\n@gittensory explain\n@gittensory generate-tests";
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export const DEFAULT_COMMAND_AUTHORIZATION_POLICY: RepositoryCommandAuthorizatio
resolve: ["maintainer", "collaborator"],
configuration: ["maintainer", "collaborator"],
explain: ["maintainer", "collaborator"],
// #4195 (part of the #4189 E2E-test-generation epic): deliberately NARROWER than every command above --
// "maintainer" ONLY, excluding "collaborator" and "confirmed_miner". This command can write real content
// (a generated test) attributed to the PR; a repo could grant a contributor/miner collaborator-level
// push access, and that tier must not be able to invoke test generation for their own scored PR (the
// exact loophole a click-to-generate button would otherwise open). The existing
// `maintainer_command_requires_maintainer` guard below already denies the PR's own author when they
// don't independently hold the `maintainer` role, so no bespoke pr_author check is needed here.
"generate-tests": ["maintainer"],
},
};

Expand Down
5 changes: 5 additions & 0 deletions src/github/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ export const GITTENSORY_ACTION_COMMAND_CATALOG = [
title: "Explain finding",
description: "Explain a specific review finding; supply the finding reference in trailing text.",
},
{
id: "generate-tests",
title: "Generate E2E tests",
description: "Generate an AI E2E test for this PR's changed behavior and post it as a reply comment (maintainer-only).",
},
] as const;

export type GittensoryActionCommandName = (typeof GITTENSORY_ACTION_COMMAND_CATALOG)[number]["id"];
Expand Down
110 changes: 110 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,8 @@ 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 { resolveE2eTestGenInstructions, runGittensoryE2eTestGeneration } from "../services/ai-e2e-test-gen";
import {
buildRepoCultureProfileContext,
isRepoCultureProfileEnabled,
Expand Down Expand Up @@ -5669,6 +5671,7 @@ async function processGitHubWebhook(

if (eventName === "issue_comment" && (await maybeProcessResolveCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, eventName, action: payload.action, installationId: payload.installation?.id, repositoryFullName: payload.repository?.full_name, payloadHash: "processed", status: "processed" }); return; }
if (eventName === "issue_comment" && (await maybeProcessExplainCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, eventName, action: payload.action, installationId: payload.installation?.id, repositoryFullName: payload.repository?.full_name, payloadHash: "processed", status: "processed" }); return; }
if (eventName === "issue_comment" && (await maybeProcessGenerateTestsCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, eventName, action: payload.action, installationId: payload.installation?.id, repositoryFullName: payload.repository?.full_name, payloadHash: "processed", status: "processed" }); return; }
if (eventName === "issue_comment" && (await maybeProcessReviewCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, eventName, action: payload.action, installationId: payload.installation?.id, repositoryFullName: payload.repository?.full_name, payloadHash: "processed", status: "processed" }); return; }
if (eventName === "issue_comment" && (await maybeProcessPauseCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, eventName, action: payload.action, installationId: payload.installation?.id, repositoryFullName: payload.repository?.full_name, payloadHash: "processed", status: "processed" }); return; }
if (eventName === "issue_comment" && (await maybeProcessResumeCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, eventName, action: payload.action, installationId: payload.installation?.id, repositoryFullName: payload.repository?.full_name, payloadHash: "processed", status: "processed" }); return; }
Expand Down Expand Up @@ -11406,6 +11409,113 @@ async function recordFindingExplainedSkip(env: Env, deliveryId: string, repoFull
await recordGithubProductUsage(env, "finding_explained_skipped", { actor, repoFullName, targetKey, outcome: "skipped", metadata: { reason } });
}

/**
* `@gittensory generate-tests` (#4195, part of the #4189 epic): on-demand, MAINTAINER-ONLY AI-generated E2E
* test coverage for this PR's changed behavior, posted as its own reply comment — mirroring
* `maybeProcessExplainCommand`'s classify → authorize → act → audit shape exactly, but posting fresh
* generated content rather than explaining already-published findings.
*
* Deliberately does NOT splice into the automated review's sticky unified comment (unlike fix-handoff):
* this is an explicit, cost-bearing, maintainer-triggered action, not something derived for free from data
* the regular review pass already computed — see `explain`/`configuration` for the same "own dedicated
* reply comment" precedent for on-demand actions.
*/
async function maybeProcessGenerateTestsCommand(env: Env, deliveryId: string, payload: GitHubWebhookPayload): Promise<boolean> {
const command = parseGittensoryMentionCommand(payload.comment?.body);
if (!command || command.name !== "generate-tests") return false;
const { classifyPrCommandRequest } = await import("../github/pr-command-request");
const req = classifyPrCommandRequest(payload, getInstallationId(payload));
if (!req.ok) {
await recordGenerateTestsSkip(env, deliveryId, req.repoFullName, req.targetKey, req.actor, req.reason);
return true;
}
const targetKey = `${req.repoFullName}#${req.pr.number}`;
const [pr, settings] = await Promise.all([getPullRequest(env, req.repoFullName, req.pr.number), resolveRepositorySettings(env, req.repoFullName)]);
if (!pr) {
await recordGenerateTestsSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "cached_pr_missing");
return true;
}
const { authorization } = await authorizePrActionActor({ env, deliveryId, installationId: req.installationId, repoFullName: req.repoFullName, issue: payload.issue!, actor: req.actor, commandName: "generate-tests" as GittensoryMentionCommandName, settings, pr });
if (!authorization.authorized) {
await recordAuditEvent(env, { eventType: "github_app.e2e_tests_generation_denied", actor: req.actor, targetKey, outcome: "denied", detail: authorization.reason, metadata: { deliveryId, repoFullName: req.repoFullName, allowedRoles: commandAuthorizationAllowedRoles(settings.commandAuthorization, "generate-tests") } });
await recordGithubProductUsage(env, "e2e_tests_generation_denied", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "denied", metadata: { reason: authorization.reason, actorKind: authorization.actorKind } });
return true;
}
const manifest = await loadRepoFocusManifest(env, req.repoFullName).catch(() => null);
if (!resolveConvergedFeature(env, manifest, "e2eTests", req.repoFullName)) {
await postGenerateTestsNotEnabledComment(env, req.installationId, req.repoFullName, req.pr.number);
await recordGenerateTestsSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "feature_disabled");
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
// established convention for this exact 3-line block — see e.g. the vision-capture caller above).
const storedKey = settings.aiReviewByok ? await getDecryptedRepositoryAiKey(env, req.repoFullName) : null;
const providerKey =
storedKey && (!settings.aiReviewProvider || settings.aiReviewProvider === storedKey.provider)
? { provider: storedKey.provider, key: storedKey.key, model: settings.aiReviewModel ?? storedKey.model }
: null;
const result = await runGittensoryE2eTestGeneration(env, {
repoFullName: req.repoFullName,
prNumber: req.pr.number,
title: pr.title,
body: pr.body,
files: files.map((file) => ({ path: file.path, patch: typeof file.payload?.patch === "string" ? file.payload.patch : undefined })),
instructions: resolveE2eTestGenInstructions(manifest?.review, changedPaths),
actor: req.actor,
providerKey,
});
const testSource = result.status === "ok" ? result.testSource : null;
const body = buildE2eTestGenCommentBody({ actor: req.actor, testSource });
try {
await createIssueComment(env, req.installationId, req.repoFullName, req.pr.number, sanitizePublicComment(body));
} catch (error) {
// sanitizePublicComment THROWS on a forbidden term rather than stripping it -- generated test source is
// far less predictable than this codebase's other curated comment content, so failing closed to a safe
// withheld-content note (never the raw error, never the raw generated text) is the right degrade here.
await createIssueComment(
env,
req.installationId,
req.repoFullName,
req.pr.number,
sanitizePublicComment(buildE2eTestGenCommentBody({ actor: req.actor, testSource: null })),
);
console.log(JSON.stringify({ event: "e2e_test_gen_comment_withheld", repoFullName: req.repoFullName, pr: req.pr.number, error: errorMessage(error) }));
}
await recordAuditEvent(env, {
eventType: "github_app.e2e_tests_generation",
actor: req.actor,
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) },
});
await recordGithubProductUsage(env, "e2e_tests_generation", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "completed", metadata: { status: result.status, generated: Boolean(testSource) } });
return true;
}

async function postGenerateTestsNotEnabledComment(env: Env, installationId: number, repoFullName: string, prNumber: number): Promise<void> {
const body = sanitizePublicComment(
[
AGENT_COMMAND_COMMENT_MARKER,
"",
"> [!NOTE]",
"> **E2E test generation is not enabled for this repository**",
"> Ask a maintainer to enable `features.e2eTests` in `.gittensory.yml` (the operator's global flag must also be on).",
"",
"---",
gittensoryFooter(),
].join("\n"),
);
await createIssueComment(env, installationId, repoFullName, prNumber, body);
}

async function recordGenerateTestsSkip(env: Env, deliveryId: string, repoFullName: string | null, targetKey: string | null, actor: string | null, reason: string): Promise<void> {
await recordAuditEvent(env, { eventType: "github_app.e2e_tests_generation_skipped", actor, targetKey, outcome: "completed", detail: reason, metadata: { deliveryId, repoFullName, reason } });
await recordGithubProductUsage(env, "e2e_tests_generation_skipped", { actor, repoFullName, targetKey, outcome: "skipped", metadata: { reason } });
}

async function appendPublishedAiReviewFindingsForResolve(
env: Env,
repoFullName: string,
Expand Down
55 changes: 55 additions & 0 deletions src/review/e2e-test-gen-render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Public-safe rendering for AI-generated E2E test coverage (#4193, part of the #4189 epic).
//
// Unlike fix-handoff (which splices a block into the automated review's sticky unified comment), this
// renders its OWN dedicated reply comment for the `@gittensory generate-tests` command (#4195) — a
// maintainer-triggered, on-demand action, not something that runs on every automated review pass. This
// mirrors how `explain`/`configuration` already post their own on-demand response comments rather than
// editing the main review comment (see `maybeProcessExplainCommand` in `src/queue/processors.ts`).
//
// This layer never re-derives safety: it trusts that #4191's `parseE2eTestGenResponse` already validated
// the test source is plausible Playwright before this ever sees it, and that #4195's caller already
// resolved authorization — this file only turns already-decided content into a public-safe comment body.
import { AGENT_COMMAND_COMMENT_MARKER } from "../github/comments";
import { gittensoryFooter } from "../github/footer";

export type E2eTestGenCommentInput = {
actor: string;
/** The generated test source, or null when generation ran but produced nothing usable. */
testSource: string | null;
framework?: string | 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.
*/
export function buildE2eTestGenCommentBody(input: E2eTestGenCommentInput): string {
const framework = input.framework?.trim() || "Playwright";
if (!input.testSource) {
return [
AGENT_COMMAND_COMMENT_MARKER,
"",
"> [!NOTE]",
`> **E2E test generation for @${input.actor} did not produce a usable result**`,
`> The model's output didn't parse as valid ${framework} source — try again, or add the test by hand.`,
"",
"---",
gittensoryFooter(),
].join("\n");
}
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.",
"",
"```typescript",
input.testSource,
"```",
"",
"---",
gittensoryFooter(),
].join("\n");
}
8 changes: 8 additions & 0 deletions src/settings/command-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export const DEFAULT_COMMAND_AUTHORIZATION_POLICY: RepositoryCommandAuthorizatio
resolve: ["maintainer", "collaborator"],
configuration: ["maintainer", "collaborator"],
explain: ["maintainer", "collaborator"],
// #4195 (part of the #4189 E2E-test-generation epic): deliberately NARROWER than every command above --
// "maintainer" ONLY, excluding "collaborator" and "confirmed_miner". This command can write real content
// (a generated test) attributed to the PR; a repo could grant a contributor/miner collaborator-level
// push access, and that tier must not be able to invoke test generation for their own scored PR (the
// exact loophole a click-to-generate button would otherwise open). The existing
// `maintainer_command_requires_maintainer` guard below already denies the PR's own author when they
// don't independently hold the `maintainer` role, so no bespoke pr_author check is needed here.
"generate-tests": ["maintainer"],
},
};

Expand Down
29 changes: 29 additions & 0 deletions test/unit/e2e-test-gen-render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildE2eTestGenCommentBody } from "../../src/review/e2e-test-gen-render";
import { PR_PANEL_COMMENT_MARKER } from "../../src/github/comments";

describe("buildE2eTestGenCommentBody", () => {
it("renders the generated test source in a fenced code block, defaulting the framework to Playwright", () => {
const body = buildE2eTestGenCommentBody({ actor: "maintainer", testSource: "test('x', () => {});" });
expect(body).toContain(PR_PANEL_COMMENT_MARKER);
expect(body).toContain("AI-generated Playwright test for @maintainer");
expect(body).toContain("```typescript\ntest('x', () => {});\n```");
});

it("uses a custom framework name when provided", () => {
const body = buildE2eTestGenCommentBody({ actor: "maintainer", testSource: "it('x', () => {});", framework: "Cypress" });
expect(body).toContain("AI-generated Cypress test for @maintainer");
});

it("renders a not-usable note (no code fence) when testSource is null", () => {
const body = buildE2eTestGenCommentBody({ actor: "maintainer", testSource: null });
expect(body).toContain(PR_PANEL_COMMENT_MARKER);
expect(body).toContain("did not produce a usable result");
expect(body).not.toContain("```");
});

it("names the configured framework in the not-usable note too", () => {
const body = buildE2eTestGenCommentBody({ actor: "maintainer", testSource: null, framework: "Cypress" });
expect(body).toContain("didn't parse as valid Cypress source");
});
});
4 changes: 2 additions & 2 deletions test/unit/gen-command-reference-script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,12 @@ describe("gen-command-reference script (#3046)", () => {
expect(actionCommands).toHaveLength(7);
});

it("extracts the real 10 public + 9 maintainer-only + 7 action commands from the real repo source", () => {
it("extracts the real 10 public + 9 maintainer-only + 8 action commands from the real repo source", () => {
const { publicCommands, maintainerCommands, actionCommands } = collectCommandCatalogs({ rootDir: process.cwd() });

expect(publicCommands).toHaveLength(10);
expect(maintainerCommands).toHaveLength(9);
expect(actionCommands).toHaveLength(7);
expect(actionCommands).toHaveLength(8);
expect(publicCommands.map((c: CommandCatalogEntry) => c.id)).toEqual([
"help",
"ask",
Expand Down
Loading
Loading