Files
PaperClipAI/tests/runner-e2e/github-review-flow.ts
T
DottaandPaperclip 6d46851920 test(e2e): add attended GitHub review bot journey (#17457)
## Thinking Path

> - Paperclip manages agents and the work they perform.
> - Product E2E checks verify real user paths through those agents.
> - A recorded GitHub review-bot walkthrough had no repeatable
registered case.
> - App setup, automatic triggering, publication, and task completion
need separate evidence.
> - This pull request adds an explicit attended journey to the existing
catalog and report tools.
> - Reusable setup profiles and independent assertions preserve the same
outcome across compatible targets.

## Linked Issues or Issue Description

Refs: #17262. This case remains an explicit attended attached-target
flow. It does not replace the shared deployment-target or
paid-operations framework.

**What existing behavior does this improve?**

The Product E2E catalog can now select and retain evidence for GitHub
review-bot setup and its first automatic review.

**Current behavior**

Recorded walkthroughs cannot be rerun as a fixed registered case. A
published review alone can hide a failed run or unfinished task.

**Proposed behavior**

Create and install an owned App, save automatic new-PR settings, create
a defective PR, and require correlated review delivery, an inline
finding, a consistent check, a successful run, and a Done task. Retain
failures and confirm owned-resource cleanup.

**Reason and benefit**

The case makes a useful external integration reproducible without
treating setup, unit checks, or report rendering as proof of a completed
live journey.

## What Changed

- Add two explicit attended cells for an existing configured agent in
local or Daytona execution.
- Declare hosting, account, connection, and credential prerequisites
through private setup bindings.
- Add fixed browser controls, ownership checks, independent grading, and
cleanup.
- Package declared screenshots through the existing sanitizer, manifest,
validator, and dashboard.
- Reject operations-managed execution until budgeted routing is
qualified.
- Document hosted QA credential setup and evidence limits.

## Verification

- Focused catalog, oracle, report, connection, and ownership checks: 127
tests passed.
- Inherited-environment preflight regression failed against the previous
commit and passed after the fix. Actual-run environment verification
remains required.
- Harness TypeScript check and full workspace typecheck passed. Full
build passed. The local full-test attempt was interrupted after repeated
embedded PostgreSQL startup failures; its exit code of zero does not
establish a pass; Exact-head Linux CI passed the full test, typecheck,
build, Runner, serialized, browser, and canary gates on
`abbddeade848edefc8de75f11cc9008b2cd52764`.
- A real hosted attempt published a rated review and inline finding. It
failed when its native session timed out before the task reached Done.
Cleanup passed. Full live qualification remains open.
- Credential scan found no known credential values in the authored files
or retained report corpus.

## Risks

- The full case creates external test resources and uses the configured
provider. It requires an explicit selection and current task authority.
It is excluded from default and CI matrices.
- Time and run limits are not a hard dollar cap. Missing dollar cost
stays unknown. Fixture setup does not weaken budget policy.
- Fresh personal OAuth, unattended authentication, runtime skill-read
proof, and cross-host qualification are separate claims.
- The selected live fixture needs environment/lifecycle recovery before
it can adopt a longer provider deadline.

## Model Used

OpenAI GPT-6 family through Codex, with reasoning, code execution, and
browser tools. The exact deployment model ID and context-window size are
not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used, including the available version
and capability details
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the enhancement
template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket ID
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open findings
- [x] I will address all Greptile and reviewer comments before merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-24 12:16:26 -07:00

237 lines
20 KiB
TypeScript

import { expect, type Page } from "@playwright/test";
import type { GitHubTaskReview } from "../../packages/shared/src/types/chat-github.js";
import type { GitHubRepositoryPage } from "../../packages/shared/src/types/chat-channels.js";
import { ConnectionBlock, ConnectionFailure } from "./connection-config.js";
import { ConnectionApi } from "./connection-target.js";
import { awaitJourneyCondition, createGitHubJourneyPullRequest, enterGitHubJourneyCompany, observeGitHubHead, selectGitHubJourneyRepository } from "./github-review-browser.js";
import { githubCartFixture, hasOnlyOpenedAutomaticReview, matchesGitHubJourneyEnvironment, retainGitHubReview, verifyGitHubReview, verifyGitHubReviewRun, type GitHubReviewJourneyEvidence } from "./github-review-evidence.js";
import type { GitHubReviewJourneyConfig } from "./github-review-config.js";
import { captureOwnedGitHubApp, readOwnedGitHubReviewRuns, type GitHubJourneyResources } from "./github-review-ownership.js";
import { attendGitHubJourneyAction } from "./github-review-attendance.js";
export async function runGitHubReviewFlow(input: {
page: Page; github: Page; githubUsername: string; api: ConnectionApi; config: GitHubReviewJourneyConfig; resources: GitHubJourneyResources;
evidence: GitHubReviewJourneyEvidence; signal: AbortSignal;
checkpoint(id: keyof GitHubReviewJourneyEvidence["checkpoints"], page: Page): Promise<void>;
phase(id: string): Promise<void>;
persistResources(): Promise<void>;
}) {
const { page, github, api, config, resources, evidence } = input;
const capabilities = await api.get('/api/instance/settings/experimental');
if (capabilities.enableGitHubReviewBots !== true) throw new ConnectionBlock("blocked_target", "github_review_capability_unavailable");
const company = await api.get(`/api/companies/${config.target.companyId}`);
const agent = await api.get(`/api/agents/${config.agent.id}`);
if (company.id !== config.target.companyId || agent.companyId !== company.id || agent.name !== config.agent.name || !["idle", "running"].includes(agent.status)) throw new ConnectionBlock("blocked_target", "configured_review_agent_unavailable");
const environment = await api.get(`/api/environments/${config.target.environmentId}`);
// Inheritance and task trust policy can select the environment without an
// agent override. Validate the declared target now; the actual run binding
// remains authoritative after dispatch.
if (environment.status !== "active"
|| !matchesGitHubJourneyEnvironment(environment, config.target.environmentId, config.executionEnvironment)) throw new ConnectionBlock("blocked_target", "configured_review_environment_mismatch");
// The profile is explicit about the provider/model it claims to qualify.
if (JSON.stringify(agent.adapterConfig ?? {}).includes(config.agent.model) === false) throw new ConnectionBlock("blocked_target", "configured_agent_model_mismatch");
// Record effective accounting prerequisites without granting or changing them.
try {
const budgets = await api.get(`/api/companies/${company.id}/budgets/overview`);
evidence.budgetPolicies = budgets.policies.filter((p: any) => p.isActive && (p.scopeType === "company" || (p.scopeType === "agent" && p.scopeId === agent.id)))
.map((p: any) => ({ scopeType: p.scopeType, amount: p.amount, hardStopEnabled: p.hardStopEnabled, unpricedUsagePolicy: p.unpricedUsagePolicy }));
evidence.budgetPolicyRead = "verified";
} catch { evidence.budgetPolicyRead = "unavailable"; }
await enterGitHubJourneyCompany(page, api.origin, company);
await input.checkpoint("agent", page);
await input.phase("connector_navigation");
await page.getByRole("link", { name: "Connectors", exact: true }).click();
const botCard = page.getByRole("listitem").filter({ has: page.getByRole("heading", { name: "GitHub Code Review Bot", exact: true }) });
await botCard.getByRole("button", { name: /^(?:Connect|Add connection) GitHub Code Review Bot$/ }).click();
await page.getByRole("button", { name: /Select agent/ }).click();
await page.getByRole("button", { name: `Select ${config.agent.name}`, exact: true }).click();
const creation = page.waitForResponse(r => new URL(r.url()).pathname === `/api/companies/${company.id}/chat-endpoints` && r.request().method() === "POST");
await page.getByRole("button", { name: "Continue", exact: true }).click();
const response = await creation;
if (!response.ok()) throw new ConnectionFailure("endpoint_creation_failed");
const endpoint = await response.json();
if (endpoint.companyId !== company.id || endpoint.assignedAgentId !== agent.id || endpoint.provider !== "github") throw new ConnectionFailure("created_endpoint_binding_mismatch");
resources.endpointId = endpoint.id;
evidence.cleanup.endpoint = false;
await input.persistResources();
// Check the declared existing-user prerequisite before creating an external
// App. A missing fixture must not leave an unnecessary GitHub installation.
const existingAccounts = await api.get<Array<{ connectionId: string; status: string; enabled: boolean; login: string | null }>>(`/api/chat-endpoints/${endpoint.id}/github/personal-connections`);
const expectedAccounts = existingAccounts.filter(a => a.status === "active" && a.enabled && a.login?.toLowerCase() === input.githubUsername.toLowerCase());
if (expectedAccounts.length !== 1) throw new ConnectionBlock("blocked_target", "dedicated_github_user_connection_required");
await input.phase("github_app_registration");
if (config.github.ownerType === "organization") {
await page.getByLabel("GitHub account", { exact: true }).selectOption("organization");
await page.getByLabel("Organization", { exact: true }).fill(config.github.appOwner);
}
await page.getByLabel("App name", { exact: true }).fill(resources.appName);
await page.getByRole("button", { name: "Continue to GitHub", exact: true }).click();
// Manifest flow navigates the same tab. External App grants remain attended.
evidence.assisted = true;
await awaitJourneyCondition(async () => new URL(page.url()).hostname === "github.com", 30_000, "github_manifest_handoff_missing", input.signal);
const createApp = page.getByRole("button", { name: new RegExp(`^Create GitHub App(?: for ${config.github.appOwner})?$`) });
await createApp.waitFor({ timeout: config.browser.loginTimeoutMs });
await page.bringToFront();
await input.checkpoint("app_creation_consent", page);
console.log(`[github-review-journey] Authorize creation of only ${resources.appName} in the dedicated QA browser. Install it only on ${config.github.repository}.`);
await attendGitHubJourneyAction({ control: createApp, action: "create-app", appName: resources.appName, signal: input.signal, timeoutMs: config.browser.loginTimeoutMs });
await input.phase("github_repository_installation");
await awaitJourneyCondition(async () => await page.getByRole("radio", { name: /Only select repositories/i }).isVisible(), config.browser.loginTimeoutMs, "github_app_creation_consent_required", input.signal);
await awaitJourneyCondition(async () => captureOwnedGitHubApp(resources, await api.get(`/api/chat-endpoints/${endpoint.id}`), config), 30_000, "created_github_app_identity_unavailable", input.signal, "failed");
await input.checkpoint("app_created", page);
await selectGitHubJourneyRepository(page, config.github.repository);
await input.checkpoint("repository_scope", page);
await page.bringToFront();
console.log(`[github-review-journey] The installation selects only ${config.github.repository}. Confirm Install in the QA browser.`);
await attendGitHubJourneyAction({ control: page.getByRole("button", { name: "Install", exact: true }), action: "install-app", appName: resources.appName, signal: input.signal, timeoutMs: config.browser.loginTimeoutMs });
await awaitJourneyCondition(async () => new URL(page.url()).origin === api.origin, config.browser.loginTimeoutMs, "github_installation_consent_required", input.signal);
const savedAccount = page.getByLabel("Existing GitHub connection", { exact: true });
await input.phase("github_identity_binding");
// Returning to the tenant can precede signed-webhook verification and the
// asynchronously loaded identity choices. Wait for a usable wizard state.
await awaitJourneyCondition(async () => await savedAccount.isVisible()
|| await page.getByRole("button", { name: "Connect my GitHub account", exact: true }).isVisible()
|| await page.getByRole("button", { name: "Confirm my account", exact: true }).isVisible()
|| await page.getByRole("button", { name: "Done", exact: true }).isVisible(),
120_000, "github_identity_or_connection_state_missing", input.signal, "failed");
if (await savedAccount.isVisible()) {
// Select only a single already configured account; ambiguity never picks a stranger's login.
const accounts = await api.get<Array<{ connectionId: string; status: string; enabled: boolean; login: string | null }>>(`/api/chat-endpoints/${endpoint.id}/github/personal-connections`);
const active = accounts.filter(a => a.status === "active" && a.enabled && a.login?.toLowerCase() === input.githubUsername.toLowerCase());
if (active.length !== 1) throw new ConnectionBlock("blocked_target", "dedicated_github_user_connection_required");
await savedAccount.selectOption(active[0]!.connectionId);
await page.getByRole("button", { name: "Check my account", exact: true }).click();
await expect(page.getByText(`GitHub account: ${input.githubUsername}`, { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Confirm my account", exact: true }).click();
} else if (await page.getByRole("button", { name: "Confirm my account", exact: true }).isVisible()) {
await expect(page.getByText(`Connect GitHub account ${input.githubUsername} to your Paperclip account.`, { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Confirm my account", exact: true }).click();
} else if (await page.getByRole("button", { name: "Connect my GitHub account", exact: true }).isVisible()) {
throw new ConnectionBlock("blocked_target", "saved_github_user_connection_required");
}
await page.getByRole("button", { name: "Done", exact: true }).click();
await input.checkpoint("identity", page);
await input.phase("review_configuration");
const configuredEndpoint = await api.get(`/api/chat-endpoints/${endpoint.id}`);
const repositories = await api.get<GitHubRepositoryPage>(`/api/chat-endpoints/${endpoint.id}/github/repositories?limit=100&offset=0`);
if (configuredEndpoint.status !== "active" || configuredEndpoint.assignedAgentId !== config.agent.id
|| repositories.totalCount !== 1 || repositories.enabledCount !== 1
|| repositories.items.length !== 1 || !repositories.items[0]!.enabled
|| repositories.items[0]!.label !== config.github.repository) throw new ConnectionFailure("installation_repository_scope_mismatch");
if (!captureOwnedGitHubApp(resources, configuredEndpoint, config)) throw new ConnectionFailure("created_github_app_identity_unavailable");
const editor = page.getByRole("textbox", { name: "Agent instructions", exact: true });
await editor.fill("/");
await page.getByRole("button", { name: new RegExp(`/${config.agent.skill}.*SKILL`, "i") }).click();
await editor.press("End");
await page.keyboard.type("\nReview this pull request using the selected review skill. Post findings and a numeric rating out of 5. Review only; do not modify repository files.");
const automatic = page.getByRole("switch", { name: "Run automatically", exact: true });
if (await automatic.getAttribute("aria-checked") !== "true") await automatic.click();
for (const label of ["New pull request", "Updated commits", "Reopened", "Ready for review"]) await page.getByRole("checkbox", { name: label, exact: true }).setChecked(label === "New pull request");
await page.getByRole("checkbox", { name: "New issues", exact: true }).setChecked(false);
await page.getByLabel("Passing score", { exact: true }).fill("5");
const savedConfiguration = page.waitForResponse(r => new URL(r.url()).pathname === `/api/chat-endpoints/${endpoint.id}/github/configuration` && r.request().method() === "PUT");
await page.getByRole("button", { name: "Save changes", exact: true }).click();
if (!(await savedConfiguration).ok()) throw new ConnectionFailure("review_configuration_save_failed");
await expect(page.getByText("Changes saved.", { exact: true })).toBeVisible();
const record = await api.get(`/api/chat-endpoints/${endpoint.id}/github/configuration`);
const policy = record.configuration.defaults;
if (policy.invocation === "mentions_only") throw new ConnectionFailure("saved_review_invocation_mismatch");
if (!hasOnlyOpenedAutomaticReview(policy)) throw new ConnectionFailure("saved_review_events_mismatch");
if (policy.ratingThreshold !== 5) throw new ConnectionFailure("saved_review_threshold_mismatch");
if (!policy.instructions.includes(config.agent.skill)) throw new ConnectionFailure("saved_review_skill_mismatch");
await input.checkpoint("configuration_saved", page);
await page.reload();
await expect(editor).toContainText(config.agent.skill);
await input.checkpoint("configuration_reloaded", page);
await input.checkpoint("skill_binding", page);
// A branch can persist even if PR submission or navigation later fails.
evidence.cleanup.branch = false;
await input.persistResources();
resources.pullNumber = await createGitHubJourneyPullRequest({ page: github, branch: resources.branch, title: `Ponytail precision eval ${resources.branch}`, contents: githubCartFixture });
evidence.cleanup.pullRequest = false;
await input.persistResources();
await input.checkpoint("pr_created", github);
const headSha = await observeGitHubHead(github);
await page.getByRole("link", { name: "Reviews", exact: true }).click();
let review: GitHubTaskReview | undefined;
await awaitJourneyCondition(async () => {
const rows = await api.get<GitHubTaskReview[]>(`/api/chat-endpoints/${endpoint.id}/github/reviews`);
const matches = rows.filter(r => r.repository === config.github.repository && r.pullNumber === resources.pullNumber && r.headSha === headSha && r.event.event === "opened");
if (matches.length > 1) throw new ConnectionFailure("duplicate_automatic_review");
review = matches[0]; return Boolean(review);
}, config.deadlineMs, "automatic_review_missing", input.signal, "failed");
resources.issueId = review!.issueId;
await page.getByRole("link").filter({ hasText: `#${resources.pullNumber}` }).click();
await input.checkpoint("automatic_task", page);
await awaitJourneyCondition(async () => {
review = await api.get(`/api/chat-endpoints/${endpoint.id}/github/reviews/${review!.id}`);
const runs = await readOwnedGitHubReviewRuns(api, { companyId: company.id, agentId: agent.id, issueId: review!.issueId });
resources.runIds = runs.map(r => r.id);
if (resources.runIds.length > config.maximumRuns) throw new ConnectionFailure("review_run_limit_exceeded");
if (["error", "incomplete", "superseded", "manual_required"].includes(review!.state)) throw new ConnectionFailure("review_terminal_failure");
return review!.state === "completed";
}, config.deadlineMs, "review_completion_timeout", input.signal, "failed");
await input.phase("review_execution_completion");
evidence.review = retainGitHubReview(review!);
let issue: any; let completedRun: any;
// Submitting the assessment can precede publication and the agent's final
// task completion. A completed review record is not a settled heartbeat run.
await awaitJourneyCondition(async () => {
review = await api.get(`/api/chat-endpoints/${endpoint.id}/github/reviews/${review!.id}`);
evidence.review = retainGitHubReview(review!);
const runs = await readOwnedGitHubReviewRuns(api, { companyId: company.id, agentId: agent.id, issueId: review!.issueId });
resources.runIds = runs.map(r => r.id);
if (resources.runIds.length > config.maximumRuns) throw new ConnectionFailure("review_run_limit_exceeded");
issue = await api.get(`/api/issues/${review!.issueId}`);
completedRun = review!.runId ? await api.get(`/api/heartbeat-runs/${review!.runId}`) : undefined;
evidence.execution = { reviewId: review!.id, issueId: review!.issueId, runId: review!.runId,
runStatus: completedRun?.status, issueStatus: issue.status, runtimeMode: completedRun?.runtimeMode,
environmentId: completedRun?.contextSnapshot?.paperclipEnvironment?.id,
provider: completedRun?.runnerProfileJson?.nativeExecutionInput?.provider?.kind,
model: completedRun?.runnerProfileJson?.nativeExecutionInput?.provider?.model };
if (completedRun && (completedRun.companyId !== company.id || completedRun.agentId !== agent.id
|| completedRun.issueId !== review!.issueId)) throw new ConnectionFailure("review_run_owner_mismatch");
if (completedRun && ["failed", "timed_out", "cancelled", "interrupted"].includes(completedRun.status)) throw new ConnectionFailure("review_run_terminal_failure");
return completedRun?.status === "succeeded" && issue.status === "done";
}, config.deadlineMs, "review_execution_completion_timeout", input.signal, "failed");
if (!verifyGitHubReviewRun(completedRun, { runId: review!.runId, companyId: company.id, agentId: agent.id, issueId: review!.issueId,
runtimeMode: config.agent.runtimeMode, environmentId: config.target.environmentId, executionEnvironment: config.executionEnvironment,
environment: await api.get(`/api/environments/${config.target.environmentId}`), provider: config.agent.provider, model: config.agent.model })) throw new ConnectionFailure("review_run_binding_mismatch");
evidence.dependencies.find(d => d.alias === "review-agent-provider")!.status = "ready";
if (!verifyGitHubReview({ review: review!, issue, companyId: company.id, endpointId: endpoint.id, issueId: review!.issueId, repository: config.github.repository,
pullNumber: resources.pullNumber, headSha, agentId: agent.id })) throw new ConnectionFailure("review_oracle_failed");
evidence.score = review!.assessment!.score; evidence.conclusion = review!.conclusion!;
await input.checkpoint("review_completed", page);
await github.reload();
await expect(github.getByText(`${evidence.score}/5`, { exact: false }).first()).toBeVisible();
await expect(github.getByText(/precision|decimal|floating|cents/i).first()).toBeVisible();
await input.checkpoint("github_delivery", github);
const inline = github.locator(".review-thread-component")
.filter({ has: github.getByRole("link", { name: "cart-total.js", exact: true }) })
.filter({ has: github.locator(".review-comment .comment-body").filter({ hasText: /precision|decimal|floating|cents/i }) }).first();
await expect(inline).toBeVisible();
await expect(inline.locator(`.review-comment a[href="/apps/${resources.appSlug}"]`).first()).toBeVisible();
const fileUrl = new URL(await inline.getByRole("link", { name: "cart-total.js", exact: true }).getAttribute("href") ?? "", "https://github.com");
const commentUrl = new URL(await inline.locator('a[href^="#discussion_r"]').first().getAttribute("href") ?? "", github.url());
if (fileUrl.origin !== "https://github.com" || fileUrl.pathname !== `/${config.github.repository}/pull/${resources.pullNumber}/files/${headSha}`
|| commentUrl.origin !== "https://github.com" || commentUrl.pathname !== `/${config.github.repository}/pull/${resources.pullNumber}`
|| !/^#discussion_r\d+$/.test(commentUrl.hash)) throw new ConnectionFailure("inline_publication_binding_mismatch");
evidence.inlinePublication = { url: commentUrl.href, fileUrl: fileUrl.href, path: "cart-total.js", author: resources.appSlug!,
text: await inline.locator(".review-comment .comment-body").filter({ hasText: /precision|decimal|floating|cents/i }).first().innerText() };
await inline.scrollIntoViewIfNeeded();
await input.checkpoint("github_inline_delivery", github);
const checkPopup = page.context().waitForEvent("page");
await page.getByRole("link", { name: "Check", exact: true }).click();
const check = await checkPopup;
await expect(check.getByText(`${evidence.score}/5`, { exact: false }).first()).toBeVisible();
await expect(check.getByText(/failed/i).first()).toBeVisible();
await input.checkpoint("check_projection", check);
await page.getByRole("link", { name: "Paperclip task", exact: true }).click();
await expect(page.getByText("Done", { exact: true }).first()).toBeVisible();
await input.checkpoint("task_done", page);
await page.reload(); await github.reload();
await expect(page.getByText("Done", { exact: true }).first()).toBeVisible();
await expect(github.getByText(`${evidence.score}/5`, { exact: false }).first()).toBeVisible();
await input.checkpoint("persisted", page);
}