diff --git a/README.md b/README.md index 7c0f5f3..806f8b7 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,12 @@ The plugin exposes GitHub workflow tools to Paperclip agents, including: - review-thread reads, replies, resolve and unresolve actions, and `request_pull_request_reviewers` reviewer requests - organization-level GitHub Project search/listing and pull-request-to-project association +`create_pull_request` is the single agent-facing delivery call for both branch publication and PR creation. The caller supplies the Paperclip issue id, a plain local branch name, the exact 40-character local branch-tip SHA, the base branch, and the PR metadata. The trusted plugin worker verifies checkout ownership, resolves the issue's execution worktree and GitHub secret, verifies the checked-out branch, worktree HEAD, local branch tip, and base ancestry, publishes only that exact commit with a non-forcing refspec, reads the remote branch SHA back, and only then creates and links the PR. The GitHub credential is never returned to or injected into the calling agent. + +Branch publication uses an isolated temporary bare repository backed by the trusted workspace object database. It does not execute repository hooks, accept arbitrary refspecs, or honor agent-supplied remote URLs. Publication rejects owner-qualified heads, base-branch targets, non-fast-forward updates, cross-project issues, mismatched local branch tips, and remote SHA mismatches. + +The call is ordered and retry-safe rather than a cross-system transaction: a published branch may remain if GitHub PR creation or Paperclip link persistence fails. On retry, the tool re-verifies and republishes the exact SHA, then recovers an already-open PR only when repository, head, base, and head SHA all match before repairing the link and metric. It never deletes a branch or closes a PR as automatic compensation. + When an agent sends GitHub body content through the plugin, including issue bodies, pull request descriptions, comments, and review-thread replies, the plugin adds a GitHub-flavored Markdown footer with a horizontal rule and compact heading that discloses AI authorship. If the tool caller supplies `llmModel`, the footer also includes the model name, for example `###### ✨ This comment was AI-generated using gpt-5.4`. ### KPI attribution API route diff --git a/SPEC.md b/SPEC.md index 852f639..50dad63 100644 --- a/SPEC.md +++ b/SPEC.md @@ -64,7 +64,12 @@ The plugin MUST persist repository mappings, company-scoped advanced issue defau - The plugin MUST expose agent tools for the GitHub issue and pull request workflow around synced work, including repository-item search, issue reads and updates, `assign_to_current_user` for assigning issues to the GitHub user authenticated by the saved token, issue comment reads and writes, pull request creation and updates, pull request asset upload, pull request file and CI inspection, review-thread reads and replies, review-thread resolution changes, `request_pull_request_reviewers` reviewer requests, organization-level GitHub Project search/listing, associating pull requests with organization-level GitHub Projects, and `link_github_item` for linking a Paperclip issue to a GitHub issue or pull request in mapped or third-party repositories. - Agent tools that send non-empty freeform GitHub body content, such as issue bodies, pull request descriptions, comments, or review-thread replies, MUST append a GitHub-flavored Markdown footer that uses a horizontal rule plus a compact heading to disclose that a Paperclip AI agent created the message. When the caller provides an `llmModel`, that footer MUST also include which LLM was used. - The plugin MUST expose an agent-authenticated native plugin JSON API route for recording Paperclip-attributed pull request metric events, and `create_pull_request` MUST automatically record a pull-request-created metric event when it succeeds. -- `create_pull_request` SHOULD accept a Paperclip issue id and, when present, MUST persist a pull-request link entity for the created PR so scheduled/manual sync can reconcile that issue from PR status even when the PR does not close a synced GitHub issue. +- `create_pull_request` MUST be the single agent-facing operation for branch publication and pull-request creation. It MUST require a Paperclip issue id, plain local head branch name, exact full local branch-tip commit SHA, base branch, and PR title. +- `create_pull_request` MUST assert issue checkout ownership and resolve the issue's trusted execution workspace plus company-scoped GitHub secret inside the plugin worker. It MUST NOT use the project primary workspace, require or return an agent-visible GitHub token, or expose a separate agent-facing branch-push tool. +- Before creating the PR, `create_pull_request` MUST verify that the issue, execution workspace, and mapped repository belong to the calling run company and project; the issue is checked out by the calling agent/run; the requested branch is checked out in that execution worktree; both worktree `HEAD` and the local branch tip equal the supplied commit SHA; the head differs from the base; and the commit descends from the fetched remote base. It MUST publish only the exact commit to the exact head branch without force, verify the remote SHA, and only then call GitHub's PR API. +- Branch publication MUST use a sanitized Git execution context that does not execute repository hooks or honor agent-controlled credential helpers, remotes, proxies, or arbitrary refspecs. Errors and logs MUST redact the resolved token. +- After successful PR creation, `create_pull_request` MUST persist a pull-request link entity for the supplied Paperclip issue so scheduled/manual sync can reconcile that issue from PR status even when the PR does not close a synced GitHub issue. +- `create_pull_request` MUST be retry-safe across partial outcomes. If GitHub reports that a PR already exists after exact-SHA publication, the worker MAY reuse an open PR only when repository, head branch, base branch, and head SHA all match, then MUST reconcile the durable link and metric. It MUST NOT delete branches or close PRs as automatic compensation. - That API route MUST accept `pull_request_created`, MUST use the host-provided authenticated agent company as the metric company, MUST reject any payload `companyId` that disagrees with that company, and MUST deduplicate repeated events using a stable pull-request identity or an explicit event key. - When that API route receives a Paperclip issue id, it MUST verify the live pull request and persist the same pull-request link entity that `create_pull_request` writes, so PRs created through `gh` or another non-plugin client still feed future issue status sync. - That API route MUST rely on the Paperclip host's `api.routes.register` authentication and company access checks, so missing, invalid, expired, non-agent, or cross-company `PAPERCLIP_API_KEY` requests are rejected before worker dispatch. diff --git a/package.json b/package.json index 8cae086..6dab9e5 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "dev": "node ./scripts/build.mjs --watch", "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", "prepack": "pnpm build", - "test": "node --test tests/build-script.spec.mjs && tsx --test tests/plugin.spec.ts", + "test": "node --test tests/build-script.spec.mjs && tsx --test tests/git-branch-publisher.spec.ts tests/plugin.spec.ts", "test:e2e": "pnpm build && pnpm exec playwright install chromium && node ./scripts/e2e/run-paperclip-smoke.mjs", "typecheck": "tsc --noEmit", "verify:manual": "pnpm build && node ./scripts/e2e/manual-paperclip-verify.mjs" diff --git a/src/git-branch-publisher.ts b/src/git-branch-publisher.ts new file mode 100644 index 0000000..e158660 --- /dev/null +++ b/src/git-branch-publisher.ts @@ -0,0 +1,371 @@ +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; +import type { Writable } from 'node:stream'; + +const FULL_GIT_SHA_PATTERN = /^[0-9a-f]{40}$/i; +const GITHUB_HOST = 'github.com'; +const MAX_GIT_OUTPUT_BYTES = 1024 * 1024; +const GIT_COMMAND_TIMEOUT_MS = 5 * 60_000; +const GIT_CREDENTIAL_HELPER = '!f() { if [ "$1" = get ]; then IFS= read -r password <&3; printf "%s\\n" username=x-access-token "password=$password"; fi; }; f'; + +export interface GitCommandResult { + stdout: string; + stderr: string; +} + +export interface GitCommandOptions { + env: NodeJS.ProcessEnv; + credential?: string; +} + +export type GitCommandRunner = ( + args: string[], + options: GitCommandOptions +) => Promise; + +export interface PublishLocalBranchInput { + workspacePath: string; + repositoryUrl: string; + branchName: string; + expectedCommitSha: string; + baseBranch: string; + githubToken: string; +} + +export interface PublishedBranch { + branchName: string; + commitSha: string; + remoteRef: string; +} + +export interface PublishLocalBranchDependencies { + runGit?: GitCommandRunner; +} + +const defaultRunGit: GitCommandRunner = async (args, options) => { + return await new Promise((resolvePromise, rejectPromise) => { + const child = spawn('git', args, { + env: options.env, + stdio: ['ignore', 'pipe', 'pipe', options.credential ? 'pipe' : 'ignore'] + }); + let stdout = ''; + let stderr = ''; + let settled = false; + let timeout: NodeJS.Timeout | undefined; + const rejectOnce = (error: Error): void => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + child.kill('SIGKILL'); + rejectPromise(error); + }; + const appendOutput = (target: 'stdout' | 'stderr', chunk: Buffer): void => { + if (settled) return; + if (target === 'stdout') stdout += chunk.toString('utf8'); + else stderr += chunk.toString('utf8'); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > MAX_GIT_OUTPUT_BYTES) { + rejectOnce(new Error('git output exceeded the 1 MiB safety limit')); + } + }; + + const stdoutPipe = child.stdout; + const stderrPipe = child.stderr; + if (!stdoutPipe || !stderrPipe) { + rejectOnce(new Error('git output pipes were not available')); + return; + } + stdoutPipe.on('data', (chunk: Buffer) => appendOutput('stdout', chunk)); + stderrPipe.on('data', (chunk: Buffer) => appendOutput('stderr', chunk)); + child.on('error', (error) => rejectOnce(error)); + child.on('close', (code, signal) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + if (code === 0) { + resolvePromise({ stdout, stderr }); + return; + } + const detail = stderr.trim() || stdout.trim() || `exit code ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`; + rejectPromise(new Error(detail)); + }); + + timeout = setTimeout(() => { + rejectOnce(new Error(`git command timed out after ${GIT_COMMAND_TIMEOUT_MS}ms`)); + }, GIT_COMMAND_TIMEOUT_MS); + timeout.unref(); + + if (options.credential) { + const credentialPipe = child.stdio[3] as Writable | null; + if (!credentialPipe || typeof credentialPipe.end !== 'function') { + rejectOnce(new Error('git credential pipe was not available')); + return; + } + credentialPipe.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EPIPE' || error.code === 'ECONNRESET') return; + rejectOnce(error); + }); + credentialPipe.end(`${options.credential}\n`); + } + }); +}; + +function normalizeRequiredString(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new Error(`${label} is required.`); + } + return normalized; +} + +function normalizePlainBranchName(value: string, label: string): string { + const branch = normalizeRequiredString(value, label); + if (branch.includes(':') || branch.startsWith('-')) { + throw new Error(`${label} must be a plain local branch name, not an owner-qualified branch or refspec.`); + } + return branch; +} + +function normalizeExpectedCommitSha(value: string): string { + const sha = normalizeRequiredString(value, 'headCommitSha').toLowerCase(); + if (!FULL_GIT_SHA_PATTERN.test(sha)) { + throw new Error('headCommitSha must be a full 40-character commit SHA.'); + } + return sha; +} + +function normalizeGitHubRepositoryRemote(value: string): string { + const normalized = normalizeRequiredString(value, 'repositoryUrl'); + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + throw new Error('repositoryUrl must be an HTTPS GitHub repository URL.'); + } + + if (parsed.protocol !== 'https:' || parsed.hostname.toLowerCase() !== GITHUB_HOST || parsed.username || parsed.password) { + throw new Error('repositoryUrl must be a credential-free HTTPS GitHub repository URL.'); + } + + const pathParts = parsed.pathname + .replace(/^\/+|\/+$/g, '') + .replace(/\.git$/i, '') + .split('/') + .filter(Boolean); + if (pathParts.length !== 2) { + throw new Error('repositoryUrl must identify exactly one GitHub owner/repository pair.'); + } + + const [owner, repository] = pathParts; + if (!owner || !repository) { + throw new Error('repositoryUrl must identify exactly one GitHub owner/repository pair.'); + } + return `https://${GITHUB_HOST}/${owner}/${repository}.git`; +} + +function createBaseGitEnvironment(): NodeJS.ProcessEnv { + return { + PATH: process.env.PATH ?? '', + LANG: process.env.LANG ?? 'C.UTF-8', + LC_ALL: process.env.LC_ALL ?? 'C.UTF-8', + GIT_TERMINAL_PROMPT: '0', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null' + }; +} + +function createAuthenticatedGitEnvironment(params: { + temporaryHome: string; + alternateObjectDirectory: string; +}): NodeJS.ProcessEnv { + return { + ...createBaseGitEnvironment(), + HOME: params.temporaryHome, + XDG_CONFIG_HOME: params.temporaryHome, + GIT_ALTERNATE_OBJECT_DIRECTORIES: params.alternateObjectDirectory, + GIT_CONFIG_COUNT: '3', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '', + GIT_CONFIG_KEY_1: 'credential.https://github.com.helper', + GIT_CONFIG_VALUE_1: GIT_CREDENTIAL_HELPER, + GIT_CONFIG_KEY_2: 'core.hooksPath', + GIT_CONFIG_VALUE_2: '/dev/null' + }; +} + +function sanitizeGitError(error: unknown, token: string): string { + const raw = error instanceof Error ? error.message : String(error); + return token ? raw.split(token).join('[REDACTED]') : raw; +} + +async function runGitStep( + runGit: GitCommandRunner, + args: string[], + env: NodeJS.ProcessEnv, + failureMessage: string, + credential = '' +): Promise { + try { + return await runGit(args, { + env, + ...(credential ? { credential } : {}) + }); + } catch (error) { + const details = sanitizeGitError(error, credential).trim(); + throw new Error(details ? `${failureMessage}: ${details}` : failureMessage); + } +} + +export async function publishLocalBranchForPullRequest( + input: PublishLocalBranchInput, + dependencies: PublishLocalBranchDependencies = {} +): Promise { + const runGit = dependencies.runGit ?? defaultRunGit; + const workspacePath = normalizeRequiredString(input.workspacePath, 'workspacePath'); + if (!isAbsolute(workspacePath)) { + throw new Error('workspacePath must be absolute.'); + } + const branchName = normalizePlainBranchName(input.branchName, 'head'); + const baseBranch = normalizePlainBranchName(input.baseBranch, 'base'); + if (branchName === baseBranch) { + throw new Error('The pull request head branch must differ from the base branch.'); + } + const expectedCommitSha = normalizeExpectedCommitSha(input.expectedCommitSha); + const githubToken = normalizeRequiredString(input.githubToken, 'GitHub token'); + const remoteUrl = normalizeGitHubRepositoryRemote(input.repositoryUrl); + const remoteRef = `refs/heads/${branchName}`; + const baseEnvironment = createBaseGitEnvironment(); + + await runGitStep( + runGit, + ['-C', workspacePath, 'check-ref-format', '--branch', branchName], + baseEnvironment, + 'The requested head branch name is invalid' + ); + await runGitStep( + runGit, + ['-C', workspacePath, 'check-ref-format', '--branch', baseBranch], + baseEnvironment, + 'The requested base branch name is invalid' + ); + + const expectedHeadRef = `refs/heads/${branchName}`; + const checkedOutBranchResult = await runGitStep( + runGit, + ['-C', workspacePath, 'symbolic-ref', '--quiet', 'HEAD'], + baseEnvironment, + 'Could not resolve the execution worktree branch' + ); + const checkedOutBranchRef = checkedOutBranchResult.stdout.trim(); + if (checkedOutBranchRef !== expectedHeadRef) { + throw new Error(`The requested head branch ${branchName} is not checked out in this execution worktree. Found ${checkedOutBranchRef || 'detached HEAD'}.`); + } + + const checkedOutCommitResult = await runGitStep( + runGit, + ['-C', workspacePath, 'rev-parse', '--verify', 'HEAD^{commit}'], + baseEnvironment, + 'Could not resolve the execution worktree HEAD commit' + ); + const checkedOutCommitSha = checkedOutCommitResult.stdout.trim().toLowerCase(); + if (checkedOutCommitSha !== expectedCommitSha) { + throw new Error(`headCommitSha does not match the execution worktree HEAD. Expected ${expectedCommitSha}, found ${checkedOutCommitSha || 'no commit'}.`); + } + + const commonDirResult = await runGitStep( + runGit, + ['-C', workspacePath, 'rev-parse', '--git-common-dir'], + baseEnvironment, + 'Could not resolve the workspace Git repository' + ); + const commonGitDirRaw = commonDirResult.stdout.trim(); + if (!commonGitDirRaw) { + throw new Error('Could not resolve the workspace Git repository: git returned an empty common directory.'); + } + const commonGitDir = isAbsolute(commonGitDirRaw) + ? commonGitDirRaw + : resolve(workspacePath, commonGitDirRaw); + + const localTipResult = await runGitStep( + runGit, + ['-C', workspacePath, 'rev-parse', '--verify', `${remoteRef}^{commit}`], + baseEnvironment, + `The local branch ${branchName} does not resolve to a commit` + ); + const localTip = localTipResult.stdout.trim().toLowerCase(); + if (localTip !== expectedCommitSha) { + throw new Error(`headCommitSha does not match the local branch tip for ${branchName}. Expected ${expectedCommitSha}, found ${localTip || 'no commit'}.`); + } + + const temporaryRoot = await mkdtemp(join(tmpdir(), 'paperclip-github-publish-')); + const temporaryGitDir = join(temporaryRoot, 'repository.git'); + const emptyTemplateDir = join(temporaryRoot, 'empty-template'); + await mkdir(emptyTemplateDir, { mode: 0o700 }); + const authenticatedEnvironment = createAuthenticatedGitEnvironment({ + temporaryHome: temporaryRoot, + alternateObjectDirectory: join(commonGitDir, 'objects') + }); + + try { + await runGitStep( + runGit, + ['init', '--bare', `--template=${emptyTemplateDir}`, temporaryGitDir], + authenticatedEnvironment, + 'Could not initialize the temporary Git publisher' + ); + await runGitStep( + runGit, + [ + '--git-dir', temporaryGitDir, + 'fetch', '--no-tags', remoteUrl, + `refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}` + ], + authenticatedEnvironment, + `Could not fetch the pull request base branch ${baseBranch}`, + githubToken + ); + await runGitStep( + runGit, + ['--git-dir', temporaryGitDir, 'rev-parse', '--verify', `refs/remotes/origin/${baseBranch}^{commit}`], + authenticatedEnvironment, + `The pull request base branch ${baseBranch} was not found on GitHub` + ); + await runGitStep( + runGit, + ['--git-dir', temporaryGitDir, 'merge-base', '--is-ancestor', `refs/remotes/origin/${baseBranch}`, expectedCommitSha], + authenticatedEnvironment, + `The local branch ${branchName} is not based on the requested base branch ${baseBranch}` + ); + await runGitStep( + runGit, + [ + '--git-dir', temporaryGitDir, + 'push', '--porcelain', '--no-verify', remoteUrl, + `${expectedCommitSha}:${remoteRef}` + ], + authenticatedEnvironment, + `Could not publish ${branchName}; only a new branch or fast-forward update is allowed`, + githubToken + ); + const remoteResult = await runGitStep( + runGit, + ['--git-dir', temporaryGitDir, 'ls-remote', '--heads', remoteUrl, remoteRef], + authenticatedEnvironment, + `Could not verify the published branch ${branchName}`, + githubToken + ); + const remoteCommitSha = remoteResult.stdout.trim().split(/\s+/)[0]?.toLowerCase() ?? ''; + if (remoteCommitSha !== expectedCommitSha) { + throw new Error(`Remote branch verification failed for ${branchName}. Expected ${expectedCommitSha}, found ${remoteCommitSha || 'no remote ref'}.`); + } + + return { + branchName, + commitSha: expectedCommitSha, + remoteRef + }; + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} diff --git a/src/github-agent-tools.ts b/src/github-agent-tools.ts index a0a9407..e815c82 100644 --- a/src/github-agent-tools.ts +++ b/src/github-agent-tools.ts @@ -263,20 +263,25 @@ export const GITHUB_AGENT_TOOLS: PluginToolDeclaration[] = [ { name: 'create_pull_request', displayName: 'Create Pull Request', - description: 'Open a GitHub pull request once the implementation branch is pushed. When a non-empty body is provided, the plugin appends an AI-authorship footer and includes llmModel when supplied.', + description: 'Publish the local branch at the exact requested commit, verify the remote branch, then create and link a GitHub pull request in one operation. The GitHub credential remains inside the trusted plugin worker. When a non-empty body is provided, the plugin appends an AI-authorship footer and includes llmModel when supplied.', parametersSchema: { type: 'object', additionalProperties: false, - required: ['head', 'base', 'title'], + required: ['paperclipIssueId', 'head', 'headCommitSha', 'base', 'title'], properties: { repository: repositoryProperty, paperclipIssueId: { type: 'string', - description: 'Optional Paperclip issue id to link with the created pull request so GitHub Sync can monitor PR status for that issue.' + description: 'Paperclip issue id used to resolve the trusted project workspace, mapped GitHub repository, and durable pull-request link.' }, head: { type: 'string', - description: 'Head branch name or owner:branch.' + description: 'Plain local branch name to publish. Owner-qualified branches and arbitrary refspecs are rejected.' + }, + headCommitSha: { + type: 'string', + pattern: '^[0-9a-fA-F]{40}$', + description: 'Full 40-character commit SHA that must equal the exact local branch tip and the verified remote branch tip.' }, base: { type: 'string', diff --git a/src/manifest.ts b/src/manifest.ts index 0acd4bb..a7586f6 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -35,7 +35,9 @@ export const manifest: PaperclipPluginManifestV1 = { 'plugin.state.write', 'instance.settings.register', 'projects.read', + 'execution.workspaces.read', 'issues.read', + 'issues.checkout', 'issues.create', 'issues.update', 'issues.wakeup', diff --git a/src/worker.ts b/src/worker.ts index 2f444a8..fa00fcf 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -19,6 +19,11 @@ import { } from '@paperclipai/plugin-sdk'; import { getGitHubAgentToolDeclaration } from './github-agent-tools.ts'; +import { + publishLocalBranchForPullRequest, + type PublishLocalBranchInput, + type PublishedBranch +} from './git-branch-publisher.ts'; import { parseRepositoryReference, type ParsedRepositoryReference } from './github-repo.ts'; import { COMPANY_METRIC_API_ROUTE_KEY, @@ -30,6 +35,12 @@ const GITHUB_SYNC_BASE_ORIGIN_KIND = `plugin:${GITHUB_SYNC_PLUGIN_ID}` as const; const GITHUB_ISSUE_ORIGIN_KIND = `plugin:${GITHUB_SYNC_PLUGIN_ID}:github-issue` as const; const GITHUB_PULL_REQUEST_ORIGIN_KIND = `plugin:${GITHUB_SYNC_PLUGIN_ID}:github-pull-request` as const; +type CreatePullRequestBranchPublisher = ( + input: PublishLocalBranchInput +) => Promise; + +let createPullRequestBranchPublisher: CreatePullRequestBranchPublisher = publishLocalBranchForPullRequest; + const SETTINGS_SCOPE = { scopeKind: 'instance' as const, stateKey: 'paperclip-github-plugin-settings' @@ -21046,20 +21057,73 @@ function registerGitHubAgentTools(ctx: PluginSetupContext): void { const input = getToolInputRecord(params); const paperclipIssueId = normalizeOptionalToolString(input.paperclipIssueId); const explicitRepository = normalizeOptionalToolString(input.repository); - const issueLinkScope = paperclipIssueId && !explicitRepository - ? await resolveIssueGitHubLinkMapping(ctx, { - companyId: runCtx.companyId, - issueId: paperclipIssueId - }) - : null; - const repository = issueLinkScope?.repository ?? await resolveGitHubToolRepository(ctx, runCtx, input); const head = normalizeOptionalToolString(input.head); + const headCommitSha = normalizeOptionalToolString(input.headCommitSha); const base = normalizeOptionalToolString(input.base); const title = normalizeOptionalToolString(input.title); - if (!head || !base || !title) { - throw new Error('head, base, and title are required.'); + if (!paperclipIssueId || !head || !headCommitSha || !base || !title) { + throw new Error('paperclipIssueId, head, headCommitSha, base, and title are required.'); } + const issueLinkScope = await resolveIssueGitHubLinkMapping(ctx, { + companyId: runCtx.companyId, + issueId: paperclipIssueId, + ...(explicitRepository ? { repositoryUrl: explicitRepository } : {}) + }); + if (issueLinkScope.projectId !== runCtx.projectId) { + throw new Error('The selected Paperclip issue does not belong to the current tool run project.'); + } + if ( + issueLinkScope.issue.assigneeAgentId + && issueLinkScope.issue.assigneeAgentId !== runCtx.agentId + ) { + throw new Error('The selected Paperclip issue is assigned to another agent.'); + } + await ctx.issues.assertCheckoutOwner({ + issueId: paperclipIssueId, + companyId: runCtx.companyId, + actorAgentId: runCtx.agentId, + actorRunId: runCtx.runId + }); + + const executionWorkspaceId = normalizeOptionalToolString(issueLinkScope.issue.executionWorkspaceId); + if (!executionWorkspaceId) { + throw new Error('The selected Paperclip issue does not have an execution workspace for branch publication.'); + } + const workspace = await ctx.executionWorkspaces.get(executionWorkspaceId, runCtx.companyId); + if ( + !workspace + || workspace.companyId !== runCtx.companyId + || workspace.projectId !== runCtx.projectId + ) { + throw new Error('The selected Paperclip issue execution workspace does not belong to the current tool run project.'); + } + const workspacePath = normalizeOptionalToolString(workspace.cwd) + ?? normalizeOptionalToolString(workspace.path); + if (!workspacePath) { + throw new Error('The selected Paperclip issue execution workspace is not locally realized.'); + } + const workspaceRepository = workspace.repoUrl + ? parseRepositoryReference(workspace.repoUrl) + : null; + const repository = issueLinkScope.repository; + if (!workspaceRepository || !areRepositoriesEqual(workspaceRepository, repository)) { + throw new Error('The execution workspace repository does not match the GitHub repository mapped to this Paperclip issue.'); + } + + const githubToken = (await resolveGithubToken(ctx, { companyId: runCtx.companyId })).trim(); + if (!githubToken) { + throw new Error(MISSING_GITHUB_TOKEN_SYNC_MESSAGE); + } + const publishedBranch = await createPullRequestBranchPublisher({ + workspacePath: workspacePath!, + repositoryUrl: repository.url, + branchName: head, + expectedCommitSha: headCommitSha, + baseBranch: base, + githubToken + }); + const body = typeof input.body === 'string' ? appendOptionalAiAuthorshipFooter( input.body, @@ -21067,19 +21131,60 @@ function registerGitHubAgentTools(ctx: PluginSetupContext): void { normalizeOptionalToolString(input.llmModel) ) : undefined; - const octokit = await createAgentToolOctokit(runCtx, 'create_pull_request', repository); - const response = await octokit.rest.pulls.create({ - owner: repository.owner, - repo: repository.repo, - head, - base, - title, - ...(body !== undefined ? { body } : {}), - ...(typeof input.draft === 'boolean' ? { draft: input.draft } : {}), - headers: { - 'X-GitHub-Api-Version': GITHUB_API_VERSION - } + const octokit = createGitHubOctokit(ctx, githubToken, { + companyId: runCtx.companyId, + operation: 'github-api', + repositoryUrl: repository.url, + toolName: 'create_pull_request' }); + let pullRequestData: { + number: number; + title: string; + body: string | null; + html_url: string; + state: string; + draft?: boolean; + head: { ref: string; sha: string }; + base: { ref: string }; + }; + try { + pullRequestData = (await octokit.rest.pulls.create({ + owner: repository.owner, + repo: repository.repo, + head: publishedBranch.branchName, + base, + title, + ...(body !== undefined ? { body } : {}), + ...(typeof input.draft === 'boolean' ? { draft: input.draft } : {}), + headers: { + 'X-GitHub-Api-Version': GITHUB_API_VERSION + } + })).data; + } catch (error) { + if (getErrorStatus(error) !== 422) { + throw error; + } + const existingResponse = await octokit.rest.pulls.list({ + owner: repository.owner, + repo: repository.repo, + state: 'open', + head: `${repository.owner}:${publishedBranch.branchName}`, + base, + per_page: 100, + headers: { + 'X-GitHub-Api-Version': GITHUB_API_VERSION + } + }); + const existingPullRequest = existingResponse.data.find((candidate) => + candidate.head.ref === publishedBranch.branchName + && candidate.base.ref === base + && candidate.head.sha.toLowerCase() === publishedBranch.commitSha.toLowerCase() + ); + if (!existingPullRequest) { + throw error; + } + pullRequestData = existingPullRequest; + } if (paperclipIssueId) { const linkScope = issueLinkScope ?? await resolveIssueGitHubLinkMapping(ctx, { @@ -21088,19 +21193,19 @@ function registerGitHubAgentTools(ctx: PluginSetupContext): void { repositoryUrl: repository.url }); const pullRequestUrl = - normalizeGitHubPullRequestHtmlUrl(response.data.html_url) - ?? `${repository.url}/pull/${response.data.number}`; + normalizeGitHubPullRequestHtmlUrl(pullRequestData.html_url) + ?? `${repository.url}/pull/${pullRequestData.number}`; await upsertGitHubPullRequestLinkRecord(ctx, { companyId: runCtx.companyId, projectId: linkScope.mapping.paperclipProjectId ?? linkScope.projectId, issueId: paperclipIssueId, repositoryUrl: repository.url, - pullRequestNumber: response.data.number, + pullRequestNumber: pullRequestData.number, pullRequestUrl, - pullRequestTitle: response.data.title || title, + pullRequestTitle: pullRequestData.title || title, pullRequestState: getGitHubPullRequestStateForLink({ - state: response.data.state, + state: pullRequestData.state, merged: false }) }); @@ -21117,8 +21222,8 @@ function registerGitHubAgentTools(ctx: PluginSetupContext): void { companyId: runCtx.companyId, metric: 'paperclipPullRequestsCreatedCount', repositoryUrl: repository.url, - pullRequestNumber: response.data.number, - pullRequestUrl: response.data.html_url ?? undefined + pullRequestNumber: pullRequestData.number, + pullRequestUrl: pullRequestData.html_url ?? undefined }, { throwOnPersistFailure: false @@ -21126,18 +21231,23 @@ function registerGitHubAgentTools(ctx: PluginSetupContext): void { ); return buildToolSuccessResult( - `Created pull request #${response.data.number} in ${formatRepositoryLabel(repository)}.`, + `Created pull request #${pullRequestData.number} in ${formatRepositoryLabel(repository)}.`, { repository: repository.url, + publishedBranch: { + name: publishedBranch.branchName, + commitSha: publishedBranch.commitSha, + remoteRef: publishedBranch.remoteRef + }, pullRequest: { - number: response.data.number, - title: response.data.title, - body: response.data.body ?? '', - url: response.data.html_url, - state: response.data.state, - isDraft: response.data.draft, - headRefName: response.data.head.ref, - baseRefName: response.data.base.ref + number: pullRequestData.number, + title: pullRequestData.title, + body: pullRequestData.body ?? '', + url: pullRequestData.html_url, + state: pullRequestData.state, + isDraft: pullRequestData.draft, + headRefName: pullRequestData.head.ref, + baseRefName: pullRequestData.base.ref } } ); @@ -21747,7 +21857,12 @@ export const __testing = { resolvePaperclipApiAuthTokens, resolveGithubToken, resolvePaperclipPullRequestIssueStatus, - resolveSyncTransitionAssignee + resolveSyncTransitionAssignee, + setCreatePullRequestBranchPublisher( + publisher?: CreatePullRequestBranchPublisher + ): void { + createPullRequestBranchPublisher = publisher ?? publishLocalBranchForPullRequest; + } }; const plugin = definePlugin({ diff --git a/tests/git-branch-publisher.spec.ts b/tests/git-branch-publisher.spec.ts new file mode 100644 index 0000000..7a5b57d --- /dev/null +++ b/tests/git-branch-publisher.spec.ts @@ -0,0 +1,237 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { + publishLocalBranchForPullRequest, + type GitCommandRunner +} from '../src/git-branch-publisher.ts'; + +const COMMIT_SHA = 'c13293efd19eca09004826317182be4e0f502eed'; +const BASE_SHA = '1111111111111111111111111111111111111111'; + +interface TestGitCall { + args: string[]; + env: NodeJS.ProcessEnv; + credential?: string; +} + +function createSuccessfulRunner(calls: TestGitCall[]): GitCommandRunner { + return async (args, options) => { + calls.push({ + args: [...args], + env: { ...options.env }, + ...(options.credential ? { credential: options.credential } : {}) + }); + const command = args.join(' '); + + if (command.includes('rev-parse --git-common-dir')) { + return { stdout: '/srv/example/.git\n', stderr: '' }; + } + if (command.includes('check-ref-format --branch')) { + return { stdout: 'feature/atomic-pr\n', stderr: '' }; + } + if (command.includes('symbolic-ref --quiet HEAD')) { + return { stdout: 'refs/heads/feature/atomic-pr\n', stderr: '' }; + } + if (command.includes('rev-parse --verify HEAD^{commit}')) { + return { stdout: `${COMMIT_SHA}\n`, stderr: '' }; + } + if (command.includes('rev-parse --verify refs/heads/feature/atomic-pr^{commit}')) { + return { stdout: `${COMMIT_SHA}\n`, stderr: '' }; + } + if (command.includes('init --bare')) { + return { stdout: '', stderr: '' }; + } + if (command.includes('fetch --no-tags')) { + return { stdout: '', stderr: '' }; + } + if (command.includes('rev-parse --verify refs/remotes/origin/main^{commit}')) { + return { stdout: `${BASE_SHA}\n`, stderr: '' }; + } + if (command.includes('merge-base --is-ancestor')) { + return { stdout: '', stderr: '' }; + } + if (command.includes('push --porcelain --no-verify')) { + return { stdout: 'To https://github.com/paperclipai/example-repo.git\n', stderr: '' }; + } + if (command.includes('ls-remote --heads')) { + return { stdout: `${COMMIT_SHA}\trefs/heads/feature/atomic-pr\n`, stderr: '' }; + } + + throw new Error(`Unexpected git command: ${command}`); + }; +} + +test('publishes the exact local branch tip through a sanitized temporary git directory', async () => { + const calls: TestGitCall[] = []; + const result = await publishLocalBranchForPullRequest({ + workspacePath: '/srv/example', + repositoryUrl: 'https://github.com/paperclipai/example-repo', + branchName: 'feature/atomic-pr', + expectedCommitSha: COMMIT_SHA, + baseBranch: 'main', + githubToken: 'github-secret-value' + }, { + runGit: createSuccessfulRunner(calls) + }); + + assert.deepEqual(result, { + branchName: 'feature/atomic-pr', + commitSha: COMMIT_SHA, + remoteRef: 'refs/heads/feature/atomic-pr' + }); + + const renderedCommands = calls.map((call) => call.args.join(' ')); + assert.ok(renderedCommands.some((command) => command.includes(`push --porcelain --no-verify https://github.com/paperclipai/example-repo.git ${COMMIT_SHA}:refs/heads/feature/atomic-pr`))); + assert.ok(renderedCommands.some((command) => command.includes('ls-remote --heads https://github.com/paperclipai/example-repo.git refs/heads/feature/atomic-pr'))); + assert.ok(renderedCommands.every((command) => !command.includes('github-secret-value'))); + + const authenticatedCalls = calls.filter((call) => call.args.some((arg) => arg.includes('github.com/paperclipai/example-repo'))); + assert.ok(authenticatedCalls.length >= 3); + for (const call of authenticatedCalls) { + assert.equal(call.credential, 'github-secret-value'); + assert.equal(call.env.PAPERCLIP_GITHUB_TOKEN, undefined); + assert.match(call.env.GIT_CONFIG_VALUE_1 ?? '', /<&3/); + assert.doesNotMatch(call.env.GIT_CONFIG_VALUE_1 ?? '', /github-secret-value/); + assert.equal(call.env.GIT_TERMINAL_PROMPT, '0'); + } +}); + +test('rejects a requested commit that is not the exact local branch tip before authenticated git runs', async () => { + const calls: TestGitCall[] = []; + const runGit = createSuccessfulRunner(calls); + + await assert.rejects( + publishLocalBranchForPullRequest({ + workspacePath: '/srv/example', + repositoryUrl: 'https://github.com/paperclipai/example-repo', + branchName: 'feature/atomic-pr', + expectedCommitSha: '2222222222222222222222222222222222222222', + baseBranch: 'main', + githubToken: 'github-secret-value' + }, { runGit }), + /does not match (?:the local branch tip|the execution worktree HEAD)/i + ); + + assert.ok(calls.every((call) => !call.args.some((arg) => arg === 'push'))); + assert.ok(calls.every((call) => call.credential === undefined)); +}); + +test('rejects a branch that is not checked out in the execution worktree before authenticated git runs', async () => { + const calls: TestGitCall[] = []; + const baseRunner = createSuccessfulRunner(calls); + const runGit: GitCommandRunner = async (args, options) => { + if (args.includes('symbolic-ref')) { + return { stdout: 'refs/heads/feature/another-worktree\n', stderr: '' }; + } + return baseRunner(args, options); + }; + + await assert.rejects( + publishLocalBranchForPullRequest({ + workspacePath: '/srv/example', + repositoryUrl: 'https://github.com/paperclipai/example-repo', + branchName: 'feature/atomic-pr', + expectedCommitSha: COMMIT_SHA, + baseBranch: 'main', + githubToken: 'github-secret-value' + }, { runGit }), + /is not checked out in this execution worktree/i + ); + + assert.ok(calls.every((call) => call.credential === undefined)); +}); + +test('rejects base-branch publication and invalid branch or commit inputs', async () => { + const runGit: GitCommandRunner = async () => { + throw new Error('git must not run for invalid input'); + }; + + await assert.rejects( + publishLocalBranchForPullRequest({ + workspacePath: '/srv/example', + repositoryUrl: 'https://github.com/paperclipai/example-repo', + branchName: 'main', + expectedCommitSha: COMMIT_SHA, + baseBranch: 'main', + githubToken: 'github-secret-value' + }, { runGit }), + /must differ from the base branch/i + ); + + await assert.rejects( + publishLocalBranchForPullRequest({ + workspacePath: '/srv/example', + repositoryUrl: 'https://github.com/paperclipai/example-repo', + branchName: 'owner:feature', + expectedCommitSha: COMMIT_SHA, + baseBranch: 'main', + githubToken: 'github-secret-value' + }, { runGit }), + /plain local branch name/i + ); + + await assert.rejects( + publishLocalBranchForPullRequest({ + workspacePath: '/srv/example', + repositoryUrl: 'https://github.com/paperclipai/example-repo', + branchName: 'feature/atomic-pr', + expectedCommitSha: 'short-sha', + baseBranch: 'main', + githubToken: 'github-secret-value' + }, { runGit }), + /full 40-character commit SHA/i + ); +}); + +test('redacts the token when authenticated git publication fails', async () => { + const calls: TestGitCall[] = []; + const baseRunner = createSuccessfulRunner(calls); + const runGit: GitCommandRunner = async (args, options) => { + if (args.includes('push')) { + throw new Error('remote rejected github-secret-value as a non-fast-forward update'); + } + return baseRunner(args, options); + }; + + await assert.rejects( + publishLocalBranchForPullRequest({ + workspacePath: '/srv/example', + repositoryUrl: 'https://github.com/paperclipai/example-repo', + branchName: 'feature/atomic-pr', + expectedCommitSha: COMMIT_SHA, + baseBranch: 'main', + githubToken: 'github-secret-value' + }, { runGit }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /only a new branch or fast-forward update is allowed/i); + assert.match(error.message, /\[REDACTED\]/); + assert.doesNotMatch(error.message, /github-secret-value/); + return true; + } + ); +}); + +test('fails when the remote branch readback does not match the requested commit', async () => { + const calls: TestGitCall[] = []; + const baseRunner = createSuccessfulRunner(calls); + const runGit: GitCommandRunner = async (args, options) => { + if (args.includes('ls-remote')) { + return { stdout: `3333333333333333333333333333333333333333\trefs/heads/feature/atomic-pr\n`, stderr: '' }; + } + return baseRunner(args, options); + }; + + await assert.rejects( + publishLocalBranchForPullRequest({ + workspacePath: '/srv/example', + repositoryUrl: 'https://github.com/paperclipai/example-repo', + branchName: 'feature/atomic-pr', + expectedCommitSha: COMMIT_SHA, + baseBranch: 'main', + githubToken: 'github-secret-value' + }, { runGit }), + /remote branch verification failed/i + ); +}); diff --git a/tests/plugin.spec.ts b/tests/plugin.spec.ts index e5aa948..9e3a39a 100644 --- a/tests/plugin.spec.ts +++ b/tests/plugin.spec.ts @@ -8,6 +8,7 @@ import test from 'node:test'; import type { Agent, Project } from '@paperclipai/plugin-sdk'; import { createTestHarness } from '@paperclipai/plugin-sdk/testing'; +import type { PublishLocalBranchInput, PublishedBranch } from '../src/git-branch-publisher.ts'; import manifest from '../src/manifest.ts'; import { COMPANY_METRIC_API_ROUTE_KEY, @@ -31,8 +32,13 @@ import { const TEST_GITHUB_TOKEN = 'ghp_test_token'; const TEST_GITHUB_SECRET_ID = '00000000-0000-4000-8000-000000000001'; +const TEST_HEAD_COMMIT_SHA = 'c13293efd19eca09004826317182be4e0f502eed'; let plugin!: typeof import('../src/worker.ts').default; +let publishedBranchRequests: PublishLocalBranchInput[] = []; +let configureCreatePullRequestBranchPublisher!: ( + publisher?: (input: PublishLocalBranchInput) => Promise +) => void; let workerImportSerial = 0; let uiImportSerial = 0; @@ -53,7 +59,18 @@ async function importFreshUiModule() { } test.beforeEach(async () => { - plugin = await importFreshWorker(); + const workerModule = await importFreshWorkerModule(); + publishedBranchRequests = []; + configureCreatePullRequestBranchPublisher = workerModule.__testing.setCreatePullRequestBranchPublisher; + workerModule.__testing.setCreatePullRequestBranchPublisher(async (input: PublishLocalBranchInput) => { + publishedBranchRequests.push(input); + return { + branchName: input.branchName, + commitSha: input.expectedCommitSha, + remoteRef: `refs/heads/${input.branchName}` + }; + }); + plugin = workerModule.default; }); test('sync keeps completed healthy PR waits unassigned instead of restarting internal review', async () => { @@ -656,6 +673,30 @@ async function createGitHubAgentToolHarness() { githubToken: TEST_GITHUB_TOKEN } }); + const project = createProjectFixture({ + id: 'project-1', + companyId: 'company-1', + name: 'Engineering', + repoUrl: 'https://github.com/paperclipai/example-repo' + }); + harness.seed({ + projects: [project], + executionWorkspaces: [ + { + id: 'execution-workspace-1', + companyId: 'company-1', + projectId: 'project-1', + projectWorkspaceId: 'workspace-project-1', + path: '/tmp/paperclip-github-plugin-example-repo/.paperclip/worktrees/issue-worktree', + cwd: '/tmp/paperclip-github-plugin-example-repo/.paperclip/worktrees/issue-worktree', + repoUrl: 'https://github.com/paperclipai/example-repo', + baseRef: 'main', + branchName: null, + providerType: 'local', + providerMetadata: null + } + ] + }); await plugin.definition.setup(harness.ctx); await harness.performAction('settings.saveRegistration', { companyId: 'company-1', @@ -1555,6 +1596,23 @@ test('manifest declares GitHub agent tools and only the external metrics API rou ); }); +test('create_pull_request requires one-call branch publication inputs', () => { + assert.ok(manifest.capabilities.includes('execution.workspaces.read')); + assert.ok(manifest.capabilities.includes('issues.checkout')); + const declaration = manifest.tools?.find((tool) => tool.name === 'create_pull_request'); + assert.ok(declaration); + assert.match(declaration.description, /publish(?:es)? the local branch/i); + assert.deepEqual( + declaration.parametersSchema.required, + ['paperclipIssueId', 'head', 'headCommitSha', 'base', 'title'] + ); + const properties = declaration.parametersSchema.properties as Record; + assert.match( + String(properties.headCommitSha?.description ?? ''), + /exact local branch tip/i + ); +}); + test('upload_pull_request_asset publishes an image asset with embeddable markdown', async () => { const harness = await createGitHubAgentToolHarness(); const originalFetch = globalThis.fetch; @@ -1905,6 +1963,15 @@ test('add_issue_comment appends a generic AI footer when llmModel is omitted', a test('create_pull_request appends the required AI footer to the pull request body', async () => { const harness = await createGitHubAgentToolHarness(); + const issue = await harness.ctx.issues.create({ + companyId: 'company-1', + projectId: 'project-1', + title: 'Fix the importer', + description: 'Publish and open the pull request in one call.', + status: 'in_progress', + assigneeAgentId: 'agent-1', + executionWorkspaceId: 'execution-workspace-1' + }); const originalFetch = globalThis.fetch; let createdBody = ''; @@ -1934,17 +2001,39 @@ test('create_pull_request appends the required AI footer to the pull request bod try { const result = await harness.executeTool('create_pull_request', { + paperclipIssueId: issue.id, head: 'feature/fix-importer', + headCommitSha: TEST_HEAD_COMMIT_SHA, base: 'main', title: 'Fix the importer', body: 'This closes the sync gap.', llmModel: 'gpt-5.4' }, { + agentId: 'agent-1', + runId: 'run-1', companyId: 'company-1', projectId: 'project-1' }); assert.ok(!result.error); + assert.equal(publishedBranchRequests.length, 1); + assert.deepEqual( + { + workspacePath: publishedBranchRequests[0]?.workspacePath, + repositoryUrl: publishedBranchRequests[0]?.repositoryUrl, + branchName: publishedBranchRequests[0]?.branchName, + expectedCommitSha: publishedBranchRequests[0]?.expectedCommitSha, + baseBranch: publishedBranchRequests[0]?.baseBranch + }, + { + workspacePath: '/tmp/paperclip-github-plugin-example-repo/.paperclip/worktrees/issue-worktree', + repositoryUrl: 'https://github.com/paperclipai/example-repo', + branchName: 'feature/fix-importer', + expectedCommitSha: TEST_HEAD_COMMIT_SHA, + baseBranch: 'main' + } + ); + assert.equal(publishedBranchRequests[0]?.githubToken, TEST_GITHUB_TOKEN); assert.match(createdBody, /^This closes the sync gap\./); assert.match(createdBody, /---\n###### ✨ This pull request description was AI-generated using gpt-5\.4/); assert.match((result.data as { pullRequest: { body: string } }).pullRequest.body, /gpt-5\.4/); @@ -1953,8 +2042,130 @@ test('create_pull_request appends the required AI footer to the pull request bod } }); +test('create_pull_request does not call the GitHub PR API when branch publication fails', async () => { + const harness = await createGitHubAgentToolHarness(); + const issue = await harness.ctx.issues.create({ + companyId: 'company-1', + projectId: 'project-1', + title: 'Publication failure', + description: 'The PR API must not run after a failed branch publication.', + status: 'in_progress', + assigneeAgentId: 'agent-1', + executionWorkspaceId: 'execution-workspace-1' + }); + configureCreatePullRequestBranchPublisher(async () => { + throw new Error('remote branch verification failed'); + }); + const originalFetch = globalThis.fetch; + let githubApiCalled = false; + globalThis.fetch = async () => { + githubApiCalled = true; + throw new Error('GitHub PR API should not be called.'); + }; + + try { + const result = await harness.executeTool('create_pull_request', { + paperclipIssueId: issue.id, + head: 'feature/publication-failure', + headCommitSha: TEST_HEAD_COMMIT_SHA, + base: 'main', + title: 'Do not create this PR' + }, { + agentId: 'agent-1', + runId: 'run-1', + companyId: 'company-1', + projectId: 'project-1' + }); + + assert.match(result.error ?? '', /remote branch verification failed/i); + assert.equal(githubApiCalled, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('create_pull_request recovers an existing exact-SHA pull request after a retryable create conflict', async () => { + const harness = await createGitHubAgentToolHarness(); + const issue = await harness.ctx.issues.create({ + companyId: 'company-1', + projectId: 'project-1', + title: 'Retry-safe pull request', + description: 'Recover the already-created PR after response or link loss.', + status: 'in_progress', + assigneeAgentId: 'agent-1', + executionWorkspaceId: 'execution-workspace-1' + }); + const originalFetch = globalThis.fetch; + const seenRequests: string[] = []; + globalThis.fetch = async (input, init) => { + const url = new URL(getRequestUrl(input)); + const method = init?.method ?? 'GET'; + seenRequests.push(`${method} ${url.pathname}`); + if (url.pathname === '/repos/paperclipai/example-repo/pulls' && method === 'POST') { + return jsonResponse({ message: 'A pull request already exists for this branch.' }, 422); + } + if (url.pathname === '/repos/paperclipai/example-repo/pulls' && method === 'GET') { + return jsonResponse([{ + number: 24, + title: 'Retry-safe pull request', + body: '', + html_url: 'https://github.com/paperclipai/example-repo/pull/24', + state: 'open', + draft: false, + head: { + ref: 'feature/retry-safe', + sha: TEST_HEAD_COMMIT_SHA + }, + base: { + ref: 'main' + } + }]); + } + throw new Error(`Unexpected GitHub request: ${method} ${url.pathname}`); + }; + + try { + const result = await harness.executeTool('create_pull_request', { + paperclipIssueId: issue.id, + head: 'feature/retry-safe', + headCommitSha: TEST_HEAD_COMMIT_SHA, + base: 'main', + title: 'Retry-safe pull request' + }, { + agentId: 'agent-1', + runId: 'run-1', + companyId: 'company-1', + projectId: 'project-1' + }); + + assert.ok(!result.error); + assert.deepEqual(seenRequests, [ + 'POST /repos/paperclipai/example-repo/pulls', + 'GET /repos/paperclipai/example-repo/pulls' + ]); + assert.equal((result.data as { pullRequest: { number: number } }).pullRequest.number, 24); + const links = await harness.ctx.entities.list({ + entityType: 'paperclip-github-plugin.pull-request-link', + scopeKind: 'issue', + scopeId: issue.id + }); + assert.equal(links.length, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('create_pull_request auto-records Paperclip PR metrics and API route attribution dedupes duplicate PR events', async () => { const harness = await createGitHubAgentToolHarness(); + const issue = await harness.ctx.issues.create({ + companyId: 'company-1', + projectId: 'project-1', + title: 'Metric-tracked pull request', + description: 'Track one atomic pull request creation.', + status: 'in_progress', + assigneeAgentId: 'agent-1', + executionWorkspaceId: 'execution-workspace-1' + }); const originalFetch = globalThis.fetch; globalThis.fetch = async (input, init) => { @@ -1982,10 +2193,14 @@ test('create_pull_request auto-records Paperclip PR metrics and API route attrib try { const createResult = await harness.executeTool('create_pull_request', { + paperclipIssueId: issue.id, head: 'feature/fix-importer', + headCommitSha: TEST_HEAD_COMMIT_SHA, base: 'main', title: 'Fix the importer' }, { + agentId: 'agent-1', + runId: 'run-1', companyId: 'company-1', projectId: 'project-1' }); @@ -2037,7 +2252,10 @@ test('create_pull_request links the created pull request to the current Papercli companyId: 'company-1', projectId: 'project-1', title: 'Native Paperclip issue', - description: 'This issue did not come from GitHub.' + description: 'This issue did not come from GitHub.', + status: 'in_progress', + assigneeAgentId: 'agent-1', + executionWorkspaceId: 'execution-workspace-1' }); globalThis.fetch = async (input, init) => { @@ -2067,9 +2285,12 @@ test('create_pull_request links the created pull request to the current Papercli const createResult = await harness.executeTool('create_pull_request', { paperclipIssueId: issue.id, head: 'feature/native-paperclip-issue', + headCommitSha: TEST_HEAD_COMMIT_SHA, base: 'main', title: 'Fix native issue sync' }, { + agentId: 'agent-1', + runId: 'run-1', companyId: 'company-1', projectId: 'project-1' });