diff --git a/apps/api/src/handlers/tasks/manageSourceControl.ts b/apps/api/src/handlers/tasks/manageSourceControl.ts index f62ffa6aa..7b807cc99 100644 --- a/apps/api/src/handlers/tasks/manageSourceControl.ts +++ b/apps/api/src/handlers/tasks/manageSourceControl.ts @@ -73,6 +73,7 @@ export async function manageSourceControl( ); case 'get_pull_request': case 'list_pull_request_comments': + case 'list_pull_requests': return c.json( await readSourceControlPullRequestForTaskRun({ taskRun, diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 8869cb4d2..f8a3e5116 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -462,7 +462,11 @@ describe('AutomationsSettings', () => { // Suggester, announcer, and Sentry triage scan any source control provider. expect(screen.getAllByText('All source control').length).toBe(3); // Dependabot/CI triage, auditors, manager stats, and conflict resolver. - expect(screen.getAllByText('GitHub only').length).toBe(6); + expect(screen.getAllByText('GitHub only').length).toBe(5); + // conflict_resolver now supports GitHub, GitLab, and Azure DevOps + expect(screen.getAllByText('GitHub · GitLab · Azure DevOps').length).toBe( + 1, + ); }); it('reflects the reviewer all-author setting in the review scope copy', async () => { diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index 9fb2341be..b76a19df9 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -593,7 +593,8 @@ roomoteMcpServer.registerTool( 'Provider-neutral pull request/merge request operations for the current task. ' + 'Use action "create_or_update_pull_request" after committing and pushing a branch; ' + 'when an open PR/MR already exists for sourceBranch, targetBranch may be omitted and defaults to its current base. ' + - 'Use action "get_pull_request" to read PR/MR details (state, branches, head/base SHAs) and ' + + 'Use action "get_pull_request" to read PR/MR details (state, branches, head/base SHAs), ' + + '"list_pull_requests" to list open PRs/MRs in a repository (summaries with branches, labels, and mergeability where the provider exposes it), and ' + '"list_pull_request_comments" to read review threads (with resolution state) and issue comments. ' + 'Use "reply_to_pull_request_comment" to answer a review thread, "create_pull_request_comment" for a top-level comment, ' + '"resolve_pull_request_thread" to resolve or reopen a thread, and "submit_pull_request_review" to approve, request changes, or leave a review comment. ' + @@ -605,6 +606,7 @@ roomoteMcpServer.registerTool( .enum([ 'create_or_update_pull_request', 'get_pull_request', + 'list_pull_requests', 'list_pull_request_comments', 'reply_to_pull_request_comment', 'create_pull_request_comment', @@ -613,7 +615,7 @@ roomoteMcpServer.registerTool( 'update_pull_request_comment', ]) .describe( - 'create_or_update_pull_request creates or refreshes the PR/MR for a branch; get_pull_request reads PR/MR details; list_pull_request_comments reads review threads and issue comments; reply_to_pull_request_comment answers a review thread; create_pull_request_comment posts a top-level comment; resolve_pull_request_thread resolves or reopens a thread; submit_pull_request_review approves, requests changes, or leaves a review comment; update_pull_request_comment edits an existing comment in place.', + 'create_or_update_pull_request creates or refreshes the PR/MR for a branch; get_pull_request reads PR/MR details; list_pull_requests lists open PRs/MRs in the repository; list_pull_request_comments reads review threads and issue comments; reply_to_pull_request_comment answers a review thread; create_pull_request_comment posts a top-level comment; resolve_pull_request_thread resolves or reopens a thread; submit_pull_request_review approves, requests changes, or leaves a review comment; update_pull_request_comment edits an existing comment in place.', ), repositoryFullName: z .string() @@ -626,7 +628,22 @@ roomoteMcpServer.registerTool( .positive() .optional() .describe( - 'Required for every action except create_or_update_pull_request: the PR/MR number (GitLab iid).', + 'Required for every action except create_or_update_pull_request and list_pull_requests: the PR/MR number (GitLab iid).', + ), + state: z + .literal('open') + .optional() + .describe( + 'Optional filter for list_pull_requests. Only "open" is supported; open pull requests are listed either way.', + ), + limit: z + .number() + .int() + .positive() + .max(200) + .optional() + .describe( + 'Optional cap for list_pull_requests on how many pull requests to return (default 100, max 200).', ), threadId: z .string() @@ -715,6 +732,8 @@ roomoteMcpServer.registerTool( action: params.action, repositoryFullName: params.repositoryFullName, prNumber: params.prNumber, + state: params.state, + limit: params.limit, threadId: params.threadId, commentId: params.commentId, resolved: params.resolved, diff --git a/apps/worker/src/mcp/roomote-mcp-server/source-control.ts b/apps/worker/src/mcp/roomote-mcp-server/source-control.ts index 23c580db2..2fe0cd9ef 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/source-control.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/source-control.ts @@ -12,6 +12,7 @@ type ManageSourceControlParams = { action: | 'create_or_update_pull_request' | 'get_pull_request' + | 'list_pull_requests' | 'list_pull_request_comments' | 'reply_to_pull_request_comment' | 'create_pull_request_comment' @@ -20,6 +21,8 @@ type ManageSourceControlParams = { | 'update_pull_request_comment'; repositoryFullName: string; prNumber?: number; + state?: 'open'; + limit?: number; threadId?: string; commentId?: string; resolved?: boolean; @@ -92,6 +95,18 @@ export async function handleManageSourceControl( }); } + if (params.action === 'list_pull_requests') { + return jsonResult( + await readSourceControl(config, taskId, { + action: params.action, + repositoryFullName: params.repositoryFullName, + state: params.state, + limit: params.limit, + sourceControlProvider: params.sourceControlProvider, + }), + ); + } + if ( typeof params.prNumber !== 'number' || !Number.isInteger(params.prNumber) || diff --git a/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts b/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts index a2d85266e..be7c00df2 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts @@ -274,9 +274,15 @@ export async function readSourceControl( config: RoomoteConfig, taskId: string, params: { - action: 'get_pull_request' | 'list_pull_request_comments'; + action: + | 'get_pull_request' + | 'list_pull_request_comments' + | 'list_pull_requests'; repositoryFullName: string; - prNumber: number; + // Required for the single-PR actions; unused by list_pull_requests. + prNumber?: number; + state?: 'open'; + limit?: number; sourceControlProvider?: SourceControlProvider; }, ): Promise { diff --git a/apps/worker/src/mcp/roomote-mcp-server/types.ts b/apps/worker/src/mcp/roomote-mcp-server/types.ts index adfda8727..c7e983fe9 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/types.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/types.ts @@ -84,7 +84,8 @@ export interface SourceControlPullRequestReadResponse { success: true; provider: SourceControlProvider; repositoryFullName: string; - number: number; + /** Present for single-PR reads; list_pull_requests returns pullRequests instead. */ + number?: number; warnings: string[]; [key: string]: unknown; } diff --git a/packages/cloud-agents/src/server/workflows/githubPrConflictResolve.ts b/packages/cloud-agents/src/server/workflows/githubPrConflictResolve.ts index 4341c09aa..c2ba2efee 100644 --- a/packages/cloud-agents/src/server/workflows/githubPrConflictResolve.ts +++ b/packages/cloud-agents/src/server/workflows/githubPrConflictResolve.ts @@ -1,6 +1,7 @@ import { type GithubPrConflictResolveTask, getSkillCommandDelimiter, + resolveSourceControlProviderFromPayload, } from '@roomote/types'; import type { ResolvedTaskCommitAuthor } from '../commit-author'; @@ -23,12 +24,16 @@ export function githubPrConflictResolve({ const { payload: { repo, prNumber, prTitle, prUrl, headRef, baseRef }, } = taskSpec; + // The payload kind stays GithubPrConflictResolve for every provider; the + // stamped payload provider decides which surface the task reports under. + const provider = resolveSourceControlProviderFromPayload(taskSpec.payload); const delimiter = getSkillCommandDelimiter(taskSpec.harness); const description = buildStructuredTaskRequest({ command: `${delimiter}resolve-github-pr-merge-conflicts`, activeAppendixPath: 'resolve-github-pr-merge-conflicts', taskContext: { repository: repo, + source_control_provider: provider, pull_request_number: prNumber, workflow: 'pr_conflict_resolve', task_link_follow: `[Follow](${taskRunUrl})`, @@ -45,7 +50,7 @@ export function githubPrConflictResolve({ return standardTask({ description, repo, - taskSurface: 'github', + taskSurface: provider, taskRunUrl, attribution, requestFormat: 'structured', diff --git a/packages/cloud-agents/src/server/workflows/skills/standard/resolve-github-pr-merge-conflicts/SKILL.md b/packages/cloud-agents/src/server/workflows/skills/standard/resolve-github-pr-merge-conflicts/SKILL.md index 189e67d03..a5076ab65 100644 --- a/packages/cloud-agents/src/server/workflows/skills/standard/resolve-github-pr-merge-conflicts/SKILL.md +++ b/packages/cloud-agents/src/server/workflows/skills/standard/resolve-github-pr-merge-conflicts/SKILL.md @@ -79,8 +79,9 @@ You are a PR merge-conflict resolver. Merge the base branch into the target PR b Fetch PR context and prepare the merge Read the PR title, body, and branch names before starting the merge flow. - Fetch PR info with `gh pr view --json title,body,headRefName,baseRefName`. - Check out the PR and merge the base branch into it with `gh pr checkout --force`, `git fetch origin `, and `GIT_EDITOR=true git merge --no-ff --no-edit origin/`. + On GitHub, fetch PR info with `gh pr view --json title,body,headRefName,baseRefName` and check out the PR with `gh pr checkout --force`. + On any other provider (`source_control_provider` in the task context is not `github`), do not use `gh`: the task context already carries the PR title, URL, and branch names, so run `git fetch origin ` and `git checkout -B origin/` instead. + Merge the base branch into the checked-out PR branch with `git fetch origin ` and `GIT_EDITOR=true git merge --no-ff --no-edit origin/`. If a merge is already in progress, inspect `git status` first and continue the existing merge when it is already resolving this PR; abort only when the in-progress merge is stale or unrelated to the requested PR. If a stale rebase from an older run is in progress, abort it with `git rebase --abort` before starting the merge flow. Identify conflicts with `git diff --name-only --diff-filter=U`. @@ -182,8 +183,9 @@ You are a PR merge-conflict resolver. Merge the base branch into the target PR b -`gh pr view --json title,body,headRefName,baseRefName` -`gh pr checkout --force` +`gh pr view --json title,body,headRefName,baseRefName` +`gh pr checkout --force` +`git fetch origin ` then `git checkout -B origin/` `git fetch origin ` `GIT_EDITOR=true git merge --no-ff --no-edit origin/` `git diff --name-only --diff-filter=U` @@ -277,8 +279,8 @@ You are a PR merge-conflict resolver. Merge the base branch into the target PR b When the run succeeds, the final response must stay machine-parseable for the conflict-resolution callback and PR success comment flow. -Start the response with `Resolved merge conflicts in:` followed by one `- \`path/to/file\`` item per resolved file. If no files required manual resolution, start with `Resolved merge conflicts.` instead. -If there are controversial decisions, include a `Decisions I'm not 100% sure:` section with one `- ` bullet per decision. -If there are warnings, include a `Warnings:` section with one `- ` bullet per warning. +Start the response with `Resolved merge conflicts in:` followed by one `- \`path/to/file\``item per resolved file. If no files required manual resolution, start with`Resolved merge conflicts.`instead. +If there are controversial decisions, include a`Decisions I'm not 100% sure:`section with one`- `bullet per decision. +If there are warnings, include a`Warnings:`section with one`- ` bullet per warning. Keep the response concise and do not add extra prose before or after these sections. diff --git a/packages/db/src/lib/__tests__/github-branch-activity.test.ts b/packages/db/src/lib/__tests__/github-branch-activity.test.ts index f7903789e..ad550bf17 100644 --- a/packages/db/src/lib/__tests__/github-branch-activity.test.ts +++ b/packages/db/src/lib/__tests__/github-branch-activity.test.ts @@ -306,6 +306,100 @@ describe('findActiveGitHubBranchWork', () => { }); }); + it('does not let a GitHub branch run suppress lookups for another provider', async () => { + const { user } = await createActor(); + const repoFullName = 'owner/repo-cross-provider'; + const prNumber = 352; + const branchName = 'feature/cross-provider'; + + // Legacy GitHub payload: no sourceControlProvider field, which defaults + // to 'github' at runtime. + const githubRun = await runFactory.create({ + actingUserId: user.id, + payloadKind: TaskPayloadKind.StandardTask, + status: RunStatus.Running, + taskPhase: 'running', + payload: { + repo: repoFullName, + branch: branchName, + description: 'GitHub work on the branch', + }, + }); + + // A GitLab scan for the same repository fullName + branch must not match + // the GitHub run. + const gitlabResult = await findActiveGitHubBranchWork({ + repoFullName, + prNumber, + branchName, + sourceControlProvider: 'gitlab', + }); + + expect(gitlabResult).toBeNull(); + + // The default (GitHub) lookup still matches the legacy payload. + const githubResult = await findActiveGitHubBranchWork({ + repoFullName, + prNumber, + branchName, + }); + + expect(githubResult).toMatchObject({ + runId: githubRun.id, + match: 'branch', + }); + }); + + it('matches provider-tagged branch runs only for the same provider', async () => { + const { user } = await createActor(); + const repoFullName = 'owner/repo-gitlab-branch'; + const prNumber = 353; + const branchName = 'feature/gitlab-branch'; + + const gitlabRun = await runFactory.create({ + actingUserId: user.id, + payloadKind: TaskPayloadKind.StandardTask, + status: RunStatus.Running, + taskPhase: 'running', + payload: { + repo: repoFullName, + branch: branchName, + sourceControlProvider: 'gitlab', + description: 'GitLab work on the branch', + }, + }); + + const gitlabResult = await findActiveGitHubBranchWork({ + repoFullName, + prNumber, + branchName, + sourceControlProvider: 'gitlab', + }); + + expect(gitlabResult).toMatchObject({ + runId: gitlabRun.id, + match: 'branch', + }); + + // The GitLab-tagged run must not suppress GitHub or ADO lookups. + const githubResult = await findActiveGitHubBranchWork({ + repoFullName, + prNumber, + branchName, + }); + + expect(githubResult).toBeNull(); + + const adoResult = await findActiveGitHubBranchWork({ + repoFullName, + prNumber, + branchName, + sourceControlProvider: 'ado', + }); + + expect(adoResult).toBeNull(); + }); + it('ignores runs that are not actively running anymore', async () => { const { user } = await createActor(); const repoFullName = 'owner/repo-inactive'; diff --git a/packages/db/src/lib/github-branch-activity.ts b/packages/db/src/lib/github-branch-activity.ts index a92771cad..0bef682a3 100644 --- a/packages/db/src/lib/github-branch-activity.ts +++ b/packages/db/src/lib/github-branch-activity.ts @@ -165,6 +165,7 @@ export async function findActiveGitHubBranchWork({ .where( and( ...baseConditions, + payloadProviderCondition(sourceControlProvider), sql`${taskRuns.payload}->>'repo' = ${repoFullName}`, sql`( ${taskRuns.payload}->>'branch' = ${branchName} @@ -178,6 +179,20 @@ export async function findActiveGitHubBranchWork({ return pickActiveWork(branchRows, 'branch'); } +/** + * Payload-side provider scoping for the branch fallback lookups. Payloads + * written before provider-neutral support omit `sourceControlProvider` and + * mean GitHub at runtime (see `normalizeSourceControlProvider`), so missing + * or empty values are treated as `'github'`. This keeps a same-named + * repo/branch on another provider from cross-matching GitHub work and vice + * versa. + */ +function payloadProviderCondition( + sourceControlProvider: SourceControlProvider, +) { + return sql`COALESCE(NULLIF(${taskRuns.payload}->>'sourceControlProvider', ''), 'github') = ${sourceControlProvider}`; +} + /** * Returns the newest active Roomote run that can safely continue follow-up * work on the same PR branch without introducing a second writer or second diff --git a/packages/sdk/src/server/automations/__tests__/conflict-scan.test.ts b/packages/sdk/src/server/automations/__tests__/conflict-scan.test.ts index 3faae0a71..1dc6e3787 100644 --- a/packages/sdk/src/server/automations/__tests__/conflict-scan.test.ts +++ b/packages/sdk/src/server/automations/__tests__/conflict-scan.test.ts @@ -12,6 +12,8 @@ const { mockFindInstallation, mockGetAutomationRuntime, mockRecordAutomationRunOutcome, + mockListOpenPullRequests, + mockGetPullRequestDetails, } = vi.hoisted(() => { type AnyMock = Mock<(...args: never[]) => unknown>; @@ -27,9 +29,16 @@ const { mockFindInstallation: vi.fn() as AnyMock, mockGetAutomationRuntime: vi.fn() as AnyMock, mockRecordAutomationRunOutcome: vi.fn() as AnyMock, + mockListOpenPullRequests: vi.fn() as AnyMock, + mockGetPullRequestDetails: vi.fn() as AnyMock, }; }); +vi.mock('../../lib/pull-requests/source-control-pull-request-reads', () => ({ + listOpenSourceControlPullRequestsForRepository: mockListOpenPullRequests, + getSourceControlPullRequestDetailsForRepository: mockGetPullRequestDetails, +})); + vi.mock('@roomote/cloud-agents/server', () => ({ enqueueTask: mockEnqueueTask, })); @@ -156,12 +165,22 @@ describe('conflictScanJob', () => { managerSlackChannelId: null, }); mockFindInstallation.mockResolvedValue({ id: 'install-row-1' }); + // Select order: eligible installations, provider-neutral (GitLab/ADO) + // repos, then the installation's GitHub repos. mockSelectWhere .mockResolvedValueOnce([{ orgId: 'org-1', installationId: 123 }]) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { fullName: 'Roomote/example-app', orgId: 'org-1' }, ]) .mockResolvedValue([]); + mockListOpenPullRequests.mockResolvedValue({ + success: true, + provider: 'gitlab', + repositoryFullName: 'acme/backend', + pullRequests: [], + warnings: [], + }); mockSelectLimit.mockResolvedValue([]); mockFindActiveGitHubBranchWork.mockResolvedValue(null); mockGetCommitCommittedAt.mockResolvedValue( @@ -211,6 +230,7 @@ describe('conflictScanJob', () => { await conflictScanJob(); + expect(mockRecordAutomationRunOutcome).toHaveBeenCalledTimes(1); expect(mockRecordAutomationRunOutcome).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -496,4 +516,302 @@ describe('conflictScanJob', () => { expect(mockGetCommitCommittedAt).not.toHaveBeenCalled(); expect(mockEnqueueTask).not.toHaveBeenCalled(); }); + + function queueProviderNeutralRepo(repo: Record) { + // Select order: eligible installations (none, so the GitHub section is + // skipped), then the provider-neutral repos. + mockSelectWhere.mockReset(); + mockSelectWhere + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([repo]) + .mockResolvedValue([]); + // Drop once-values earlier tests queued but never consumed so the + // active-resolution-run dedup guard sees no active run. + mockSelectLimit.mockReset(); + mockSelectLimit.mockResolvedValue([]); + } + + function makePullRequestSummary(overrides: Record = {}) { + return { + number: 42, + url: 'https://gitlab.com/acme/backend/-/merge_requests/42', + title: 'MR 42', + state: 'open', + draft: false, + sourceBranch: 'feature/work', + targetBranch: 'main', + author: { id: '77', login: 'gitlab-author' }, + updatedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + labels: ['auto-resolve-conflicts'], + headSha: 'abc1234', + baseSha: 'base5678', + mergeable: false, + mergeStateDescription: 'conflict', + isCrossRepository: false, + headRepositoryFullName: null, + ...overrides, + }; + } + + it('enqueues GitLab conflict resolutions from the list mergeable signal without the GitHub idle guard', async () => { + mockIsRepoSkipped.mockReturnValue(false); + queueProviderNeutralRepo({ + id: 'repo-1', + sourceControlProvider: 'gitlab', + host: null, + installationId: null, + externalRepoId: '101', + fullName: 'acme/backend', + htmlUrl: 'https://gitlab.com/acme/backend', + }); + mockListOpenPullRequests.mockResolvedValueOnce({ + success: true, + provider: 'gitlab', + repositoryFullName: 'acme/backend', + pullRequests: [makePullRequestSummary()], + warnings: [], + }); + mockEnqueueTask.mockResolvedValueOnce({ id: 9, taskId: 'task-9' }); + + const result = await conflictScanJob(); + + expect(mockListOpenPullRequests).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'gitlab', + repository: expect.objectContaining({ fullName: 'acme/backend' }), + }), + ); + // The list already carried a definitive mergeable signal. + expect(mockGetPullRequestDetails).not.toHaveBeenCalled(); + // The recent-commit idle guard is GitHub-only and skipped gracefully. + expect(mockGetCommitCommittedAt).not.toHaveBeenCalled(); + expect(mockHasRecentGitHubBranchCommit).not.toHaveBeenCalled(); + expect(mockFindActiveGitHubBranchWork).toHaveBeenCalledWith({ + repoFullName: 'acme/backend', + prNumber: 42, + branchName: 'feature/work', + sourceControlProvider: 'gitlab', + }); + expect(mockEnqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + type: 'github_pr_conflict_resolve', + payload: expect.objectContaining({ + repo: 'acme/backend', + prNumber: 42, + headRef: 'feature/work', + baseRef: 'main', + sourceControlProvider: 'gitlab', + }), + }), + initiator: { + kind: 'automation', + key: 'conflict_resolver', + actor: { externalId: '77', displayName: 'gitlab-author' }, + }, + workflow: 'pr_conflict_resolve', + surface: 'gitlab', + trigger: 'schedule', + prLinkage: expect.objectContaining({ + provider: 'gitlab', + repository: 'acme/backend', + prNumber: 42, + prSha: 'abc1234', + prBaseRef: 'main', + prBaseSha: 'base5678', + }), + }), + ); + expect(result.launchedTaskId).toBe('task-9'); + }); + + it('falls back to a single-PR detail read when an ADO list row has no mergeable signal', async () => { + mockIsRepoSkipped.mockReturnValue(false); + queueProviderNeutralRepo({ + id: 'repo-2', + sourceControlProvider: 'ado', + host: null, + installationId: null, + externalRepoId: 'repo-uuid', + fullName: 'acme/Platform/backend', + htmlUrl: 'https://dev.azure.com/acme/Platform/_git/backend', + }); + mockListOpenPullRequests.mockResolvedValueOnce({ + success: true, + provider: 'ado', + repositoryFullName: 'acme/Platform/backend', + pullRequests: [ + makePullRequestSummary({ + url: 'https://dev.azure.com/acme/Platform/_git/backend/pullrequest/42', + updatedAt: null, + mergeable: null, + mergeStateDescription: null, + }), + ], + warnings: [], + }); + mockGetPullRequestDetails.mockResolvedValueOnce({ + success: true, + provider: 'ado', + mergeable: false, + mergeStateDescription: 'conflicts', + }); + mockEnqueueTask.mockResolvedValueOnce({ id: 10, taskId: 'task-10' }); + + await conflictScanJob(); + + expect(mockGetPullRequestDetails).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'ado', + prNumber: 42, + repository: expect.objectContaining({ + fullName: 'acme/Platform/backend', + }), + }), + ); + expect(mockEnqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + surface: 'ado', + prLinkage: expect.objectContaining({ + provider: 'ado', + repository: 'acme/Platform/backend', + prNumber: 42, + }), + }), + ); + }); + + it('records a single failed outcome when the GitHub scan fails and the provider-neutral scan succeeds', async () => { + mockIsRepoSkipped.mockReturnValue(false); + + // Select order: eligible installations, then provider-neutral repos. The + // installation's GitHub repos are never fetched because the octokit + // lookup fails first. + mockSelectWhere.mockReset(); + mockSelectWhere + .mockResolvedValueOnce([{ orgId: 'org-1', installationId: 123 }]) + .mockResolvedValueOnce([ + { + id: 'repo-1', + sourceControlProvider: 'gitlab', + host: null, + installationId: null, + externalRepoId: '101', + fullName: 'acme/backend', + htmlUrl: 'https://gitlab.com/acme/backend', + }, + ]) + .mockResolvedValue([]); + mockSelectLimit.mockReset(); + mockSelectLimit.mockResolvedValue([]); + + mockGetInstallationOctokit.mockReset(); + mockGetInstallationOctokit.mockRejectedValueOnce( + new Error('GitHub API exploded'), + ); + + mockListOpenPullRequests.mockResolvedValueOnce({ + success: true, + provider: 'gitlab', + repositoryFullName: 'acme/backend', + pullRequests: [makePullRequestSummary()], + warnings: [], + }); + mockEnqueueTask.mockResolvedValueOnce({ id: 11, taskId: 'task-11' }); + + const result = await conflictScanJob(); + + // The provider-neutral scan still ran and launched work. + expect(result.launchedTaskId).toBe('task-11'); + expect(result.errors).toEqual(['GitHub API exploded']); + + // One combined outcome: failed, preserving the GitHub error instead of + // letting the successful provider-neutral pass clear it. + expect(mockRecordAutomationRunOutcome).toHaveBeenCalledTimes(1); + expect(mockRecordAutomationRunOutcome).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + key: 'conflict_resolver', + status: 'failed', + error: expect.stringContaining('GitHub API exploded'), + }), + ); + }); + + it('records a single succeeded outcome when only the provider-neutral scan finds candidates', async () => { + mockIsRepoSkipped.mockReturnValue(false); + + // Select order: eligible installations, provider-neutral repos, then the + // installation's GitHub repos (which yield no PRs). + mockSelectWhere.mockReset(); + mockSelectWhere + .mockResolvedValueOnce([{ orgId: 'org-1', installationId: 123 }]) + .mockResolvedValueOnce([ + { + id: 'repo-1', + sourceControlProvider: 'gitlab', + host: null, + installationId: null, + externalRepoId: '101', + fullName: 'acme/backend', + htmlUrl: 'https://gitlab.com/acme/backend', + }, + ]) + .mockResolvedValueOnce([ + { fullName: 'Roomote/example-app', orgId: 'org-1' }, + ]) + .mockResolvedValue([]); + mockSelectLimit.mockReset(); + mockSelectLimit.mockResolvedValue([]); + + mockListOpenPullRequests.mockResolvedValueOnce({ + success: true, + provider: 'gitlab', + repositoryFullName: 'acme/backend', + pullRequests: [makePullRequestSummary()], + warnings: [], + }); + mockEnqueueTask.mockResolvedValueOnce({ id: 12, taskId: 'task-12' }); + + const result = await conflictScanJob(); + + expect(result.launchedTaskId).toBe('task-12'); + expect(result.errors).toEqual([]); + + expect(mockRecordAutomationRunOutcome).toHaveBeenCalledTimes(1); + expect(mockRecordAutomationRunOutcome).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + key: 'conflict_resolver', + status: 'succeeded', + }), + ); + }); + + it('skips provider-neutral PRs without the opt-in label', async () => { + mockIsRepoSkipped.mockReturnValue(false); + queueProviderNeutralRepo({ + id: 'repo-1', + sourceControlProvider: 'gitlab', + host: null, + installationId: null, + externalRepoId: '101', + fullName: 'acme/backend', + htmlUrl: 'https://gitlab.com/acme/backend', + }); + mockListOpenPullRequests.mockResolvedValueOnce({ + success: true, + provider: 'gitlab', + repositoryFullName: 'acme/backend', + pullRequests: [makePullRequestSummary({ labels: ['other-label'] })], + warnings: [], + }); + + const result = await conflictScanJob(); + + expect(mockGetPullRequestDetails).not.toHaveBeenCalled(); + expect(mockEnqueueTask).not.toHaveBeenCalled(); + expect(result.skippedReason).toBe('No labeled conflict candidates found.'); + }); }); diff --git a/packages/sdk/src/server/automations/conflict-scan.ts b/packages/sdk/src/server/automations/conflict-scan.ts index 444ad02de..f7701ffd8 100644 --- a/packages/sdk/src/server/automations/conflict-scan.ts +++ b/packages/sdk/src/server/automations/conflict-scan.ts @@ -27,6 +27,7 @@ import { RunStatus, DEFAULT_CONFLICT_RESOLUTION_MAX_PR_AGE_DAYS, isConflictResolverMaxPrAgeDays, + type SourceControlProvider, } from '@roomote/types'; import { Env } from '@roomote/env'; @@ -35,6 +36,12 @@ import { DEFAULT_CONFLICT_SCAN_LOOKBACK_DAYS, } from '@roomote/types'; +import { + getSourceControlPullRequestDetailsForRepository, + listOpenSourceControlPullRequestsForRepository, +} from '../lib/pull-requests/source-control-pull-request-reads'; +import type { RepositoryRow } from '../lib/pull-requests/source-control-pull-request-shared'; + import { emptyJobResult, type AutomationJobResult, @@ -43,6 +50,20 @@ import { const LOG_PREFIX = '[conflictScan]'; +/** + * Providers the scan covers through the provider-neutral list/read primitive. + * Gitea and Bitbucket stay unsupported: Bitbucket exposes no mergeable + * signal at all and Gitea computes mergeability asynchronously, so neither + * yields a trustworthy conflict signal for an unattended automation. + */ +const PROVIDER_NEUTRAL_SCAN_PROVIDERS = [ + 'gitlab', + 'ado', +] as const satisfies readonly SourceControlProvider[]; + +/** Cap on open PRs fetched per non-GitHub repository in one scan pass. */ +const PROVIDER_NEUTRAL_SCAN_LIST_LIMIT = 200; + interface ActiveInstallation { installationId: number; } @@ -83,6 +104,32 @@ async function getActiveRepos( return repos; } +/** + * Get all active repositories on the providers the provider-neutral scan + * covers, shaped for the source-control read primitive. + */ +async function getActiveProviderNeutralRepos(): Promise { + return db + .select({ + id: repositories.id, + sourceControlProvider: repositories.sourceControlProvider, + host: repositories.host, + installationId: repositories.installationId, + externalRepoId: repositories.externalRepoId, + fullName: repositories.fullName, + htmlUrl: repositories.htmlUrl, + }) + .from(repositories) + .where( + and( + eq(repositories.isActive, true), + inArray(repositories.sourceControlProvider, [ + ...PROVIDER_NEUTRAL_SCAN_PROVIDERS, + ]), + ), + ); +} + /** * Check if there's already an active (pending/running) conflict resolution * run for the given PR. @@ -90,6 +137,7 @@ async function getActiveRepos( async function hasActiveResolutionRun( repoFullName: string, prNumber: number, + sourceControlProvider: SourceControlProvider, ): Promise { const activeStatuses = [RunStatus.Pending, RunStatus.Running]; @@ -101,7 +149,7 @@ async function hasActiveResolutionRun( .where( and( eq(tasks.workflow, 'pr_conflict_resolve'), - eq(taskPullRequests.sourceControlProvider, 'github'), + eq(taskPullRequests.sourceControlProvider, sourceControlProvider), eq(taskPullRequests.repository, repoFullName), eq(taskPullRequests.prNumber, prNumber), inArray(taskRuns.status, activeStatuses), @@ -131,53 +179,55 @@ export async function conflictScanJob( console.log(`${LOG_PREFIX} Starting scheduled conflict scan`); const result = emptyJobResult(); - const installations = await findEligibleInstallations(); - if (installations.length === 0) { - result.skippedReason = 'No active GitHub installation.'; - } + const runtime = await getAutomationRuntime('conflict_resolver'); + const frequency = runtime.enabled ? runtime.scheduleMode : 'off'; - let totalCandidates = 0; - let totalConflicting = 0; + if (!frequency || frequency === 'off') { + result.skippedReason = 'Automation is disabled.'; + return result; + } - for (const { installationId } of installations) { - const runtime = await getAutomationRuntime('conflict_resolver'); - const frequency = runtime.enabled ? runtime.scheduleMode : 'off'; + // Frequency gating: skip if last run was too recent for the configured + // frequency. + const intervalMs = CONFLICT_RESOLVER_INTERVAL_MS[frequency]; + + if ( + !opts.manualTrigger && + intervalMs && + runtime.lastRunAt && + Date.now() - runtime.lastRunAt.getTime() < intervalMs + ) { + console.log( + `${LOG_PREFIX} Skipping deployment: last run ${Math.round((Date.now() - runtime.lastRunAt.getTime()) / 60_000)}m ago, interval ${Math.round(intervalMs / 60_000)}m`, + ); + result.skippedReason = 'Not due yet.'; + return result; + } - if (!frequency || frequency === 'off') { - result.skippedReason = 'Automation is disabled.'; - continue; - } + const rawMaxPrAgeDays = runtime.settings.maxPrAgeDays; + const conflictResolverMaxPrAgeDays = + typeof rawMaxPrAgeDays === 'number' && + isConflictResolverMaxPrAgeDays(rawMaxPrAgeDays) + ? rawMaxPrAgeDays + : DEFAULT_CONFLICT_RESOLUTION_MAX_PR_AGE_DAYS; + const conflictResolverLabel = + typeof runtime.settings.label === 'string' && runtime.settings.label.trim() + ? runtime.settings.label + : DEFAULT_CONFLICT_RESOLVER_LABEL; - // Frequency gating: skip if last run was too recent for the configured - // frequency. - const intervalMs = CONFLICT_RESOLVER_INTERVAL_MS[frequency]; + const installations = await findEligibleInstallations(); + const providerNeutralRepos = await getActiveProviderNeutralRepos(); - if ( - !opts.manualTrigger && - intervalMs && - runtime.lastRunAt && - Date.now() - runtime.lastRunAt.getTime() < intervalMs - ) { - console.log( - `${LOG_PREFIX} Skipping deployment: last run ${Math.round((Date.now() - runtime.lastRunAt.getTime()) / 60_000)}m ago, interval ${Math.round(intervalMs / 60_000)}m`, - ); - result.skippedReason = 'Not due yet.'; - continue; - } + if (installations.length === 0 && providerNeutralRepos.length === 0) { + result.skippedReason = + 'No active GitHub installation or GitLab/Azure DevOps repositories.'; + } - const rawMaxPrAgeDays = runtime.settings.maxPrAgeDays; - const conflictResolverMaxPrAgeDays = - typeof rawMaxPrAgeDays === 'number' && - isConflictResolverMaxPrAgeDays(rawMaxPrAgeDays) - ? rawMaxPrAgeDays - : DEFAULT_CONFLICT_RESOLUTION_MAX_PR_AGE_DAYS; - const conflictResolverLabel = - typeof runtime.settings.label === 'string' && - runtime.settings.label.trim() - ? runtime.settings.label - : DEFAULT_CONFLICT_RESOLVER_LABEL; + let totalCandidates = 0; + let totalConflicting = 0; + for (const { installationId } of installations) { console.log(`${LOG_PREFIX} Scanning installation ${installationId}`); let candidateCount = 0; @@ -264,7 +314,9 @@ export async function conflictScanJob( ); // Check for existing active resolution run (dedup guard) - if (await hasActiveResolutionRun(repo.fullName, pr.number)) { + if ( + await hasActiveResolutionRun(repo.fullName, pr.number, 'github') + ) { console.log( `${LOG_PREFIX} Active resolution run exists for ${repo.fullName}#${pr.number} — skipping`, ); @@ -398,12 +450,6 @@ export async function conflictScanJob( result.skippedReason ??= 'No labeled conflict candidates found.'; } - await recordAutomationRunOutcome(db, { - key: 'conflict_resolver', - status: candidateCount > 0 ? 'succeeded' : 'skipped', - at: new Date(), - }); - console.log( `${LOG_PREFIX} Installation ${installationId}: ${candidateCount} candidates, ${conflictingCount} conflicting, ${launchedTaskCount} launched, ${notificationCount} notified`, ); @@ -411,13 +457,6 @@ export async function conflictScanJob( const message = error instanceof Error ? error.message : String(error); result.errors.push(message); - await recordAutomationRunOutcome(db, { - key: 'conflict_resolver', - status: 'failed', - at: new Date(), - error: message, - }); - console.error( `${LOG_PREFIX} Error scanning installation ${installationId}:`, message, @@ -425,6 +464,41 @@ export async function conflictScanJob( } } + if (providerNeutralRepos.length > 0) { + const providerCounts = await scanProviderNeutralRepos({ + repos: providerNeutralRepos, + conflictResolverLabel, + conflictResolverMaxPrAgeDays, + manualTrigger: Boolean(opts.manualTrigger), + result, + }); + + totalCandidates += providerCounts.candidateCount; + totalConflicting += providerCounts.conflictingCount; + } + + // Record one combined outcome covering both the GitHub and provider-neutral + // sections. Per-section recording let the later section overwrite the + // earlier one — including clearing a GitHub failure from lastError when the + // provider-neutral pass then succeeded or skipped. + if (installations.length > 0 || providerNeutralRepos.length > 0) { + const combinedStatus = + result.errors.length > 0 + ? 'failed' + : totalCandidates > 0 + ? 'succeeded' + : 'skipped'; + + await recordAutomationRunOutcome(db, { + key: 'conflict_resolver', + status: combinedStatus, + at: new Date(), + ...(combinedStatus === 'failed' + ? { error: result.errors.join('; ') } + : {}), + }); + } + console.log( `${LOG_PREFIX} Scan complete: ${totalCandidates} candidates, ${totalConflicting} conflicting`, ); @@ -432,6 +506,204 @@ export async function conflictScanJob( return result; } +/** + * Scan GitLab/Azure DevOps repositories through the provider-neutral pull + * request primitives. Conflict detection uses the list summaries' mergeable + * signal (GitLab `has_conflicts`) and falls back to a single-PR detail read + * when the list payload carries none (e.g. an ADO row missing mergeStatus). + */ +async function scanProviderNeutralRepos({ + repos, + conflictResolverLabel, + conflictResolverMaxPrAgeDays, + manualTrigger, + result, +}: { + repos: RepositoryRow[]; + conflictResolverLabel: string; + conflictResolverMaxPrAgeDays: number; + manualTrigger: boolean; + result: AutomationJobResult; +}): Promise<{ candidateCount: number; conflictingCount: number }> { + let candidateCount = 0; + let conflictingCount = 0; + let launchedTaskCount = 0; + + try { + for (const repo of repos) { + if (isRepoSkipped(repo.fullName)) { + console.log( + `${LOG_PREFIX} Skipping scheduled conflict scan for ${repo.fullName}`, + ); + continue; + } + + const provider = repo.sourceControlProvider; + + const oldestAllowedUpdatedAt = new Date(); + oldestAllowedUpdatedAt.setDate( + oldestAllowedUpdatedAt.getDate() - DEFAULT_CONFLICT_SCAN_LOOKBACK_DAYS, + ); + const oldestAllowedCreatedAt = new Date(); + oldestAllowedCreatedAt.setDate( + oldestAllowedCreatedAt.getDate() - conflictResolverMaxPrAgeDays, + ); + + const listResult = await listOpenSourceControlPullRequestsForRepository({ + repository: repo, + provider, + limit: PROVIDER_NEUTRAL_SCAN_LIST_LIMIT, + }); + + for (const pr of listResult.pullRequests) { + // Must have the opt-in label + if (!pr.labels.includes(conflictResolverLabel)) { + continue; + } + + // Must be within the lookback window. Unlike the GitHub path (sorted + // by updated desc, so it can break), ADO lists carry no updatedAt and + // no update ordering, so filter each PR instead of breaking. + if (pr.updatedAt && new Date(pr.updatedAt) < oldestAllowedUpdatedAt) { + continue; + } + + if (pr.createdAt && new Date(pr.createdAt) < oldestAllowedCreatedAt) { + continue; + } + + candidateCount++; + + // Check mergeability: trust the list signal when present, otherwise + // fall back to a single-PR read (mirrors the GitHub per-candidate + // detail fetch and is bounded by the same candidate set). + let mergeable = pr.mergeable; + + if (mergeable === null) { + const detail = await getSourceControlPullRequestDetailsForRepository({ + repository: repo, + provider, + prNumber: pr.number, + }); + mergeable = detail.mergeable; + } + + if (mergeable !== false) { + continue; + } + + conflictingCount++; + console.log( + `${LOG_PREFIX} PR ${repo.fullName}#${pr.number} has conflicts`, + ); + + // Check for existing active resolution run (dedup guard) + if (await hasActiveResolutionRun(repo.fullName, pr.number, provider)) { + console.log( + `${LOG_PREFIX} Active resolution run exists for ${repo.fullName}#${pr.number} — skipping`, + ); + continue; + } + + const activeBranchWork = await findActiveGitHubBranchWork({ + repoFullName: repo.fullName, + prNumber: pr.number, + branchName: pr.sourceBranch, + sourceControlProvider: provider, + }); + + if (activeBranchWork) { + console.log( + `${LOG_PREFIX} Skipping ${repo.fullName}#${pr.number} — active Roomote run ${activeBranchWork.runId} (${activeBranchWork.type}, match=${activeBranchWork.match}) is still working on the branch`, + ); + continue; + } + + // The recent-commit idle guard needs the head commit timestamp and + // getCommitCommittedAt only provides that for GitHub. An unknown + // timestamp is treated as NOT recent (matching + // hasRecentGitHubBranchCommit's `!latestCommitAt → false`), so the + // guard is skipped and the PR proceeds. + + try { + const launchResult = await enqueueTask({ + task: { + type: TaskPayloadKind.GithubPrConflictResolve, + payload: { + repo: repo.fullName, + prNumber: pr.number, + prTitle: pr.title, + prUrl: pr.url, + headRef: pr.sourceBranch, + baseRef: pr.targetBranch, + sourceControlProvider: provider, + }, + }, + initiator: { + kind: 'automation', + key: 'conflict_resolver', + ...(pr.author?.id + ? { + actor: { + externalId: pr.author.id, + displayName: pr.author.login ?? undefined, + }, + } + : {}), + }, + workflow: 'pr_conflict_resolve', + surface: provider, + trigger: manualTrigger ? 'manual' : 'schedule', + prLinkage: { + provider, + repository: repo.fullName, + prNumber: pr.number, + prUrl: pr.url, + prTitle: pr.title, + prSha: pr.headSha ?? undefined, + prBaseRef: pr.targetBranch, + prBaseSha: pr.baseSha ?? undefined, + }, + }); + + launchedTaskCount++; + result.launchedTaskId ??= launchResult.taskId; + console.log( + `${LOG_PREFIX} Launched conflict resolution task run ${launchResult.id} for ${repo.fullName}#${pr.number}`, + ); + // "Working on it" PR comments are GitHub-only today; other + // providers rely on the launched task itself for visibility. + } catch (error) { + console.error( + `${LOG_PREFIX} Failed to enqueue resolution for ${repo.fullName}#${pr.number}:`, + error instanceof Error ? error.message : error, + ); + } + } + } + + result.completed = true; + + if (candidateCount === 0) { + result.skippedReason ??= 'No labeled conflict candidates found.'; + } + + console.log( + `${LOG_PREFIX} Provider-neutral repos: ${candidateCount} candidates, ${conflictingCount} conflicting, ${launchedTaskCount} launched`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + result.errors.push(message); + + console.error( + `${LOG_PREFIX} Error scanning provider-neutral repositories:`, + message, + ); + } + + return { candidateCount, conflictingCount }; +} + /** * Post a notification comment on a PR indicating merge conflicts were detected. * Used as a fallback when conflict-resolution work cannot be enqueued. diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index eb53e69ce..4910b3c65 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -181,6 +181,8 @@ export { type SourceControlPullRequestReadInput, type SourceControlPullRequestDetailsResult, type SourceControlPullRequestCommentsResult, + type SourceControlPullRequestListResult, + type SourceControlPullRequestSummary, } from './lib/pull-requests/source-control-pull-request-reads'; export { writeSourceControlPullRequestForTaskRun, diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts index 503b51a14..7afaefec5 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts @@ -44,6 +44,13 @@ vi.mock('@roomote/gitlab', () => ({ `${baseUrl.replace(/\/+$/, '')}/api/v4`, })); +vi.mock('@roomote/bitbucket', () => ({ + resolveBitbucketToken: async () => 'bitbucket-token', + resolveBitbucketUsername: async () => 'bb-bot', + resolveBitbucketBaseUrl: async () => 'https://bitbucket.org', + buildBitbucketApiBaseUrl: () => 'https://api.bitbucket.org/2.0', +})); + vi.mock('@roomote/gitea', () => ({ resolveGiteaToken: (...args: unknown[]) => mockResolveGiteaToken(...args), resolveGiteaBaseUrl: (...args: unknown[]) => mockResolveGiteaBaseUrl(...args), @@ -81,7 +88,10 @@ vi.mock('@roomote/db/server', () => ({ eq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })), })); -import { readSourceControlPullRequestForTaskRun } from '../source-control-pull-request-reads'; +import { + readSourceControlPullRequestForTaskRun, + sourceControlPullRequestReadInputSchema, +} from '../source-control-pull-request-reads'; function makeTaskRun(payload: TaskRun['payload']): TaskRun { return { @@ -589,6 +599,534 @@ describe('readSourceControlPullRequestForTaskRun', () => { ]); }); + it('lists open GitHub pull requests as provider-neutral summaries', async () => { + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 'installation-1', + externalRepoId: null, + fullName: 'acme/backend', + htmlUrl: 'https://github.com/acme/backend', + }); + mockCreateGitHubToken.mockResolvedValue('github-token'); + const paginate = vi.fn().mockResolvedValue([ + { + number: 55, + html_url: 'https://github.com/acme/backend/pull/55', + title: '[Fix] Read surface', + state: 'open', + merged_at: null, + draft: false, + created_at: '2026-06-30T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + labels: [{ name: 'auto-resolve-conflicts' }, { name: undefined }], + head: { + ref: 'codex/read-surface', + sha: 'head-sha', + repo: { full_name: 'acme/backend' }, + }, + base: { + ref: 'develop', + sha: 'base-sha', + repo: { full_name: 'acme/backend' }, + }, + user: { id: 9, login: 'octocat' }, + }, + { + number: 54, + html_url: 'https://github.com/acme/backend/pull/54', + title: 'Second PR', + state: 'open', + merged_at: null, + draft: true, + created_at: '2026-06-29T00:00:00Z', + updated_at: '2026-06-30T00:00:00Z', + labels: [], + head: { ref: 'feature/two', sha: 'sha-2', repo: null }, + base: { ref: 'develop', sha: 'base-sha', repo: null }, + user: null, + }, + ]); + const pullsList = vi.fn(); + mockGetOctokit.mockReturnValue({ + paginate, + rest: { pulls: { list: pullsList } }, + }); + + const result = await readSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/backend', + sourceControlProvider: 'github', + }), + input: { + action: 'list_pull_requests', + repositoryFullName: 'acme/backend', + limit: 1, + sourceControlProvider: 'github', + }, + }); + + expect(paginate).toHaveBeenCalledWith(pullsList, { + owner: 'acme', + repo: 'backend', + state: 'open', + sort: 'updated', + direction: 'desc', + per_page: 100, + }); + + if (!('pullRequests' in result)) { + throw new Error('Expected a list result.'); + } + + expect(result.pullRequests).toEqual([ + { + number: 55, + url: 'https://github.com/acme/backend/pull/55', + title: '[Fix] Read surface', + state: 'open', + draft: false, + sourceBranch: 'codex/read-surface', + targetBranch: 'develop', + author: { id: '9', login: 'octocat' }, + updatedAt: '2026-07-01T00:00:00Z', + createdAt: '2026-06-30T00:00:00Z', + labels: ['auto-resolve-conflicts'], + headSha: 'head-sha', + baseSha: 'base-sha', + mergeable: null, + mergeStateDescription: null, + isCrossRepository: false, + headRepositoryFullName: 'acme/backend', + }, + ]); + expect(result.warnings).toEqual([ + 'Result truncated to the 1 most relevant open pull requests; more exist.', + 'GitHub does not include mergeability in pull request lists; use get_pull_request for a per-PR mergeable signal.', + ]); + }); + + it('lists open GitLab merge requests with labels and conflict signal', async () => { + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: null, + externalRepoId: '101', + fullName: 'acme/backend', + htmlUrl: 'https://gitlab.com/acme/backend', + }); + const fetchImpl = vi.fn().mockResolvedValueOnce( + jsonResponse([ + { + iid: 42, + title: 'Conflicting MR', + state: 'opened', + web_url: 'https://gitlab.com/acme/backend/-/merge_requests/42', + draft: false, + source_branch: 'feature/work', + target_branch: 'main', + has_conflicts: true, + merge_status: 'cannot_be_merged', + detailed_merge_status: 'conflict', + source_project_id: 101, + target_project_id: 101, + sha: 'head-sha', + labels: ['auto-resolve-conflicts'], + created_at: '2026-06-30T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + author: { id: 7, username: 'gitlab-user' }, + }, + ]), + ); + + const result = await readSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/backend', + sourceControlProvider: 'gitlab', + }), + input: { + action: 'list_pull_requests', + repositoryFullName: 'acme/backend', + sourceControlProvider: 'gitlab', + }, + fetchImpl, + }); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/projects/101/merge_requests?state=opened&order_by=updated_at&sort=desc&per_page=100&page=1', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ 'PRIVATE-TOKEN': 'gitlab-token' }), + }), + ); + + if (!('pullRequests' in result)) { + throw new Error('Expected a list result.'); + } + + expect(result.pullRequests).toEqual([ + { + number: 42, + url: 'https://gitlab.com/acme/backend/-/merge_requests/42', + title: 'Conflicting MR', + state: 'open', + draft: false, + sourceBranch: 'feature/work', + targetBranch: 'main', + author: { id: '7', login: 'gitlab-user' }, + updatedAt: '2026-07-01T00:00:00Z', + createdAt: '2026-06-30T00:00:00Z', + labels: ['auto-resolve-conflicts'], + headSha: 'head-sha', + baseSha: null, + mergeable: false, + mergeStateDescription: 'conflict', + isCrossRepository: false, + headRepositoryFullName: null, + }, + ]); + expect(result.warnings).toEqual([]); + }); + + it('truncates GitLab merge request lists to the requested limit', async () => { + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: null, + externalRepoId: '101', + fullName: 'acme/backend', + htmlUrl: 'https://gitlab.com/acme/backend', + }); + const makeItem = (iid: number) => ({ + iid, + title: `MR ${iid}`, + state: 'opened', + source_branch: `feature/${iid}`, + target_branch: 'main', + }); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([makeItem(3), makeItem(2), makeItem(1)]), + ); + + const result = await readSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/backend', + sourceControlProvider: 'gitlab', + }), + input: { + action: 'list_pull_requests', + repositoryFullName: 'acme/backend', + limit: 2, + sourceControlProvider: 'gitlab', + }, + fetchImpl, + }); + + if (!('pullRequests' in result)) { + throw new Error('Expected a list result.'); + } + + expect( + result.pullRequests.map((pullRequest) => pullRequest.number), + ).toEqual([3, 2]); + expect(result.warnings).toEqual([ + 'Result truncated to the 2 most relevant open pull requests; more exist.', + ]); + }); + + it('lists open Gitea pull requests with label names and mergeable passthrough', async () => { + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: null, + externalRepoId: null, + fullName: 'acme/backend', + htmlUrl: 'https://git.example.com/acme/backend', + }); + const fetchImpl = vi.fn().mockResolvedValueOnce( + jsonResponse([ + { + number: 8, + title: 'WIP: Gitea PR', + state: 'open', + merged: false, + mergeable: false, + html_url: 'https://git.example.com/acme/backend/pulls/8', + labels: [{ name: 'auto-resolve-conflicts' }], + created_at: '2026-06-30T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + user: { id: 4, login: 'gitea-user' }, + head: { + ref: 'feature/work', + sha: 'head-sha', + repo: { full_name: 'acme/backend' }, + }, + base: { + ref: 'main', + sha: 'base-sha', + repo: { full_name: 'acme/backend' }, + }, + }, + ]), + ); + + const result = await readSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/backend', + sourceControlProvider: 'gitea', + }), + input: { + action: 'list_pull_requests', + repositoryFullName: 'acme/backend', + sourceControlProvider: 'gitea', + }, + fetchImpl, + }); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://git.example.com/api/v1/repos/acme/backend/pulls?state=open&limit=50&page=1', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'token gitea-token', + }), + }), + ); + + if (!('pullRequests' in result)) { + throw new Error('Expected a list result.'); + } + + expect(result.pullRequests).toEqual([ + { + number: 8, + url: 'https://git.example.com/acme/backend/pulls/8', + title: 'WIP: Gitea PR', + state: 'open', + draft: true, + sourceBranch: 'feature/work', + targetBranch: 'main', + author: { id: '4', login: 'gitea-user' }, + updatedAt: '2026-07-01T00:00:00Z', + createdAt: '2026-06-30T00:00:00Z', + labels: ['auto-resolve-conflicts'], + headSha: 'head-sha', + baseSha: 'base-sha', + mergeable: false, + mergeStateDescription: null, + isCrossRepository: false, + headRepositoryFullName: 'acme/backend', + }, + ]); + }); + + it('lists open Bitbucket pull requests across cursor pages without a mergeable signal', async () => { + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: null, + externalRepoId: null, + fullName: 'acme/backend', + htmlUrl: 'https://bitbucket.org/acme/backend', + }); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + values: [ + { + id: 3, + title: 'First page PR', + state: 'OPEN', + draft: false, + links: { + html: { + href: 'https://bitbucket.org/acme/backend/pull-requests/3', + }, + }, + author: { uuid: '{u-1}', nickname: 'bb-user' }, + created_on: '2026-06-30T00:00:00Z', + updated_on: '2026-07-01T00:00:00Z', + source: { + branch: { name: 'feature/work' }, + commit: { hash: 'head-sha' }, + repository: { full_name: 'acme/backend' }, + }, + destination: { + branch: { name: 'main' }, + commit: { hash: 'base-sha' }, + repository: { full_name: 'acme/backend' }, + }, + }, + ], + next: 'https://api.bitbucket.org/2.0/repositories/acme/backend/pullrequests?page=2', + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + values: [ + { + id: 2, + title: 'Second page PR', + state: 'OPEN', + source: { branch: { name: 'feature/two' } }, + destination: { branch: { name: 'main' } }, + }, + ], + next: null, + }), + ); + + const result = await readSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/backend', + sourceControlProvider: 'bitbucket', + }), + input: { + action: 'list_pull_requests', + repositoryFullName: 'acme/backend', + sourceControlProvider: 'bitbucket', + }, + fetchImpl, + }); + + if (!('pullRequests' in result)) { + throw new Error('Expected a list result.'); + } + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect( + result.pullRequests.map((pullRequest) => pullRequest.number), + ).toEqual([3, 2]); + expect(result.pullRequests[0]).toMatchObject({ + author: { id: '{u-1}', login: 'bb-user' }, + labels: [], + mergeable: null, + mergeStateDescription: null, + headSha: 'head-sha', + baseSha: 'base-sha', + }); + expect(result.warnings).toEqual([ + 'Bitbucket does not expose a mergeable signal or labels; mergeable is null and labels are empty.', + ]); + }); + + it('lists open Azure DevOps pull requests with merge status mapping and a labels caveat', async () => { + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: null, + externalRepoId: 'repo-uuid', + fullName: 'acme/Platform/backend', + htmlUrl: 'https://dev.azure.com/acme/Platform/_git/backend', + }); + const fetchImpl = vi.fn().mockResolvedValueOnce( + jsonResponse({ + value: [ + { + pullRequestId: 7, + title: 'Conflicting PR', + status: 'active', + isDraft: false, + mergeStatus: 'conflicts', + creationDate: '2026-06-30T00:00:00Z', + sourceRefName: 'refs/heads/feature/work', + targetRefName: 'refs/heads/main', + lastMergeSourceCommit: { commitId: 'head-sha' }, + lastMergeTargetCommit: { commitId: 'base-sha' }, + createdBy: { + id: 'user-guid', + displayName: 'Author', + uniqueName: 'author@acme.com', + }, + }, + { + pullRequestId: 6, + title: 'Clean PR', + status: 'active', + mergeStatus: 'succeeded', + sourceRefName: 'refs/heads/feature/two', + targetRefName: 'refs/heads/main', + labels: [{ name: 'auto-resolve-conflicts' }], + }, + ], + }), + ); + + const result = await readSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/Platform/backend', + sourceControlProvider: 'ado', + }), + input: { + action: 'list_pull_requests', + repositoryFullName: 'acme/Platform/backend', + sourceControlProvider: 'ado', + }, + fetchImpl, + }); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://dev.azure.com/acme/Platform/_apis/git/repositories/repo-uuid/pullrequests?api-version=7.1&searchCriteria.status=active&%24top=100&%24skip=0', + expect.objectContaining({ method: 'GET' }), + ); + + if (!('pullRequests' in result)) { + throw new Error('Expected a list result.'); + } + + expect(result.pullRequests[0]).toMatchObject({ + number: 7, + url: 'https://dev.azure.com/acme/Platform/_git/backend/pullrequest/7', + state: 'open', + sourceBranch: 'feature/work', + targetBranch: 'main', + author: { id: 'user-guid', login: 'author@acme.com' }, + updatedAt: null, + createdAt: '2026-06-30T00:00:00Z', + labels: [], + headSha: 'head-sha', + baseSha: 'base-sha', + mergeable: false, + mergeStateDescription: 'conflicts', + }); + expect(result.pullRequests[1]).toMatchObject({ + number: 6, + mergeable: true, + mergeStateDescription: 'succeeded', + labels: ['auto-resolve-conflicts'], + }); + expect(result.warnings).toEqual([ + 'Azure DevOps did not include labels in the pull request list; labels may be reported as empty.', + ]); + }); + + it('requires prNumber for single-PR read actions', async () => { + const schemaResult = sourceControlPullRequestReadInputSchema.safeParse({ + action: 'get_pull_request', + repositoryFullName: 'acme/backend', + }); + expect(schemaResult.success).toBe(false); + + const listSchemaResult = sourceControlPullRequestReadInputSchema.safeParse({ + action: 'list_pull_requests', + repositoryFullName: 'acme/backend', + }); + expect(listSchemaResult.success).toBe(true); + + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: null, + externalRepoId: '101', + fullName: 'acme/backend', + htmlUrl: 'https://gitlab.com/acme/backend', + }); + + await expect( + readSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/backend', + sourceControlProvider: 'gitlab', + }), + input: { + action: 'get_pull_request', + repositoryFullName: 'acme/backend', + sourceControlProvider: 'gitlab', + }, + fetchImpl: vi.fn(), + }), + ).rejects.toThrow('prNumber is required for get_pull_request.'); + }); + it('rejects reads whose provider does not match the task payload', async () => { const fetchImpl = vi.fn(); diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts index 7a8bd1587..b44f1792d 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts @@ -47,12 +47,39 @@ import { const ADO_API_VERSION = '7.1'; -export const sourceControlPullRequestReadInputSchema = z.object({ - action: z.enum(['get_pull_request', 'list_pull_request_comments']), - repositoryFullName: z.string().trim().min(1), - prNumber: z.number().int().positive(), - sourceControlProvider: sourceControlProviderSchema.optional(), -}); +const DEFAULT_LIST_PULL_REQUESTS_LIMIT = 100; +const MAX_LIST_PULL_REQUESTS_LIMIT = 200; +const LIST_PULL_REQUESTS_MAX_PAGES = 40; + +export const sourceControlPullRequestReadInputSchema = z + .object({ + action: z.enum([ + 'get_pull_request', + 'list_pull_request_comments', + 'list_pull_requests', + ]), + repositoryFullName: z.string().trim().min(1), + // Required for the single-PR actions; unused by list_pull_requests. + prNumber: z.number().int().positive().optional(), + // list_pull_requests filters. Only open pull requests are supported. + state: z.literal('open').optional(), + limit: z + .number() + .int() + .positive() + .max(MAX_LIST_PULL_REQUESTS_LIMIT) + .optional(), + sourceControlProvider: sourceControlProviderSchema.optional(), + }) + .superRefine((input, ctx) => { + if (input.action !== 'list_pull_requests' && input.prNumber === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['prNumber'], + message: `prNumber is required for ${input.action}.`, + }); + } + }); export type SourceControlPullRequestReadInput = z.infer< typeof sourceControlPullRequestReadInputSchema @@ -110,9 +137,42 @@ export type SourceControlPullRequestCommentsResult = { warnings: string[]; }; +export type SourceControlPullRequestSummary = { + number: number; + url: string; + title: string; + state: 'open' | 'closed' | 'merged'; + draft: boolean; + sourceBranch: string; + targetBranch: string; + author: { id: string | null; login: string | null } | null; + /** Null when the provider's list payload has no last-updated timestamp (Azure DevOps). */ + updatedAt: string | null; + createdAt: string | null; + labels: string[]; + headSha: string | null; + baseSha: string | null; + /** Null when the provider's list payload carries no mergeability signal. */ + mergeable: boolean | null; + /** The provider's raw merge-state string when the list payload exposes one. */ + mergeStateDescription: string | null; + /** True when the PR head lives in a different repository/fork than the base; null when the provider cannot say. */ + isCrossRepository: boolean | null; + headRepositoryFullName: string | null; +}; + +export type SourceControlPullRequestListResult = { + success: true; + provider: SourceControlProvider; + repositoryFullName: string; + pullRequests: SourceControlPullRequestSummary[]; + warnings: string[]; +}; + type SourceControlPullRequestReadResult = | SourceControlPullRequestDetailsResult - | SourceControlPullRequestCommentsResult; + | SourceControlPullRequestCommentsResult + | SourceControlPullRequestListResult; /** * Mirrors SourceControlMutationError in source-control-pull-requests.ts so @@ -429,6 +489,85 @@ const gitHubReviewThreadsQueryResponseSchema = z.object({ .nullable(), }); +const gitLabMergeRequestListItemSchema = z + .object({ + iid: z.number().int(), + title: z.string(), + state: z.string(), + web_url: z.string().url().optional(), + draft: z.boolean().optional(), + work_in_progress: z.boolean().optional(), + source_branch: z.string(), + target_branch: z.string(), + has_conflicts: z.boolean().optional(), + merge_status: z.string().nullable().optional(), + detailed_merge_status: z.string().nullable().optional(), + source_project_id: z.number().nullable().optional(), + target_project_id: z.number().nullable().optional(), + sha: z.string().nullable().optional(), + labels: z.array(z.string()).optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), + author: z + .object({ id: z.number().optional(), username: z.string().optional() }) + .nullable() + .optional(), + }) + .passthrough(); +const gitLabMergeRequestListPageSchema = z.array( + gitLabMergeRequestListItemSchema, +); + +const giteaPullRequestListItemSchema = giteaPullRequestDetailsSchema.extend({ + labels: z + .array(z.object({ name: z.string().optional() }).passthrough()) + .optional(), + created_at: z.string().nullable().optional(), + updated_at: z.string().nullable().optional(), + user: z + .object({ id: z.number().optional(), login: z.string().optional() }) + .nullable() + .optional(), +}); +const giteaPullRequestListPageSchema = z.array(giteaPullRequestListItemSchema); + +const bitbucketPullRequestListItemSchema = + bitbucketPullRequestDetailsSchema.extend({ + created_on: z.string().optional(), + updated_on: z.string().optional(), + author: z + .object({ + uuid: z.string().optional(), + nickname: z.string().optional(), + display_name: z.string().optional(), + username: z.string().optional(), + }) + .nullable() + .optional(), + }); +const bitbucketPullRequestListPageSchema = z.object({ + values: z.array(bitbucketPullRequestListItemSchema), + next: z.string().url().optional().nullable(), +}); + +const adoPullRequestListItemSchema = adoPullRequestDetailsSchema.extend({ + creationDate: z.string().optional(), + labels: z + .array(z.object({ name: z.string().optional() }).passthrough()) + .optional(), + createdBy: z + .object({ + id: z.string().optional(), + displayName: z.string().optional(), + uniqueName: z.string().optional(), + }) + .nullable() + .optional(), +}); +const adoPullRequestListPageSchema = z.object({ + value: z.array(adoPullRequestListItemSchema), +}); + export async function readSourceControlPullRequestForTaskRun({ taskRun, input, @@ -458,66 +597,184 @@ export async function readSourceControlPullRequestForTaskRun({ repositoryFullName: input.repositoryFullName, }); + if (input.action === 'list_pull_requests') { + return listOpenSourceControlPullRequestsForRepository({ + repository, + provider, + limit: input.limit, + fetchImpl, + }); + } + + const { prNumber } = input; + + if (prNumber === undefined) { + throw new SourceControlReadError( + 400, + `prNumber is required for ${input.action}.`, + ); + } + + if (input.action === 'get_pull_request') { + return getSourceControlPullRequestDetailsForRepository({ + repository, + provider, + prNumber, + fetchImpl, + }); + } + switch (provider) { case 'github': - return input.action === 'get_pull_request' - ? getGitHubPullRequestDetails({ input, repository, provider }) - : listGitHubPullRequestComments({ input, repository, provider }); + return listGitHubPullRequestComments({ prNumber, repository, provider }); case 'gitlab': - return input.action === 'get_pull_request' - ? getGitLabMergeRequestDetails({ - input, - repository, - provider, - fetchImpl, - }) - : listGitLabMergeRequestComments({ - input, - repository, - provider, - fetchImpl, - }); + return listGitLabMergeRequestComments({ + prNumber, + repository, + provider, + fetchImpl, + }); case 'gitea': - return input.action === 'get_pull_request' - ? getGiteaPullRequestDetails({ input, repository, provider, fetchImpl }) - : listGiteaPullRequestComments({ - input, - repository, - provider, - fetchImpl, - }); + return listGiteaPullRequestComments({ + prNumber, + repository, + provider, + fetchImpl, + }); case 'bitbucket': - return input.action === 'get_pull_request' - ? getBitbucketPullRequestDetails({ - input, - repository, - provider, - fetchImpl, - }) - : listBitbucketPullRequestComments({ - input, - repository, - provider, - fetchImpl, - }); + return listBitbucketPullRequestComments({ + prNumber, + repository, + provider, + fetchImpl, + }); case 'ado': - return input.action === 'get_pull_request' - ? getAdoPullRequestDetails({ input, repository, provider, fetchImpl }) - : listAdoPullRequestComments({ - input, - repository, - provider, - fetchImpl, - }); + return listAdoPullRequestComments({ + prNumber, + repository, + provider, + fetchImpl, + }); + } +} + +/** + * Single-PR details for a resolved repository row, dispatched by provider. + * Exported for server-side automations (e.g. the conflict scan) that operate + * outside a task run; the task-run entry point above adds scope checks before + * delegating here. + */ +export async function getSourceControlPullRequestDetailsForRepository({ + repository, + provider, + prNumber, + fetchImpl = fetch, +}: { + repository: RepositoryRow; + provider: SourceControlProvider; + prNumber: number; + fetchImpl?: FetchImpl; +}): Promise { + switch (provider) { + case 'github': + return getGitHubPullRequestDetails({ prNumber, repository, provider }); + case 'gitlab': + return getGitLabMergeRequestDetails({ + prNumber, + repository, + provider, + fetchImpl, + }); + case 'gitea': + return getGiteaPullRequestDetails({ + prNumber, + repository, + provider, + fetchImpl, + }); + case 'bitbucket': + return getBitbucketPullRequestDetails({ + prNumber, + repository, + provider, + fetchImpl, + }); + case 'ado': + return getAdoPullRequestDetails({ + prNumber, + repository, + provider, + fetchImpl, + }); + } +} + +/** + * Open pull requests for a resolved repository row, dispatched by provider. + * Exported for server-side automations (e.g. the conflict scan) that operate + * outside a task run; the task-run entry point above adds scope checks before + * delegating here. + */ +export async function listOpenSourceControlPullRequestsForRepository({ + repository, + provider, + limit, + fetchImpl = fetch, +}: { + repository: RepositoryRow; + provider: SourceControlProvider; + limit?: number; + fetchImpl?: FetchImpl; +}): Promise { + const effectiveLimit = Math.min( + limit ?? DEFAULT_LIST_PULL_REQUESTS_LIMIT, + MAX_LIST_PULL_REQUESTS_LIMIT, + ); + + switch (provider) { + case 'github': + return listGitHubOpenPullRequests({ + repository, + provider, + limit: effectiveLimit, + }); + case 'gitlab': + return listGitLabOpenMergeRequests({ + repository, + provider, + limit: effectiveLimit, + fetchImpl, + }); + case 'gitea': + return listGiteaOpenPullRequests({ + repository, + provider, + limit: effectiveLimit, + fetchImpl, + }); + case 'bitbucket': + return listBitbucketOpenPullRequests({ + repository, + provider, + limit: effectiveLimit, + fetchImpl, + }); + case 'ado': + return listAdoOpenPullRequests({ + repository, + provider, + limit: effectiveLimit, + fetchImpl, + }); } } async function getGitHubPullRequestDetails({ - input, + prNumber, repository, provider, }: { - input: SourceControlPullRequestReadInput; + prNumber: number; repository: RepositoryRow; provider: 'github'; }): Promise { @@ -529,7 +786,7 @@ async function getGitHubPullRequestDetails({ const { data } = await octokit.rest.pulls.get({ owner, repo, - pull_number: input.prNumber, + pull_number: prNumber, }); return { @@ -563,11 +820,11 @@ async function getGitHubPullRequestDetails({ } async function listGitHubPullRequestComments({ - input, + prNumber, repository, provider, }: { - input: SourceControlPullRequestReadInput; + prNumber: number; repository: RepositoryRow; provider: 'github'; }): Promise { @@ -581,13 +838,13 @@ async function listGitHubPullRequestComments({ octokit.paginate(octokit.rest.pulls.listReviewComments, { owner, repo, - pull_number: input.prNumber, + pull_number: prNumber, per_page: 100, }), octokit.paginate(octokit.rest.issues.listComments, { owner, repo, - issue_number: input.prNumber, + issue_number: prNumber, per_page: 100, }), ]); @@ -608,7 +865,7 @@ async function listGitHubPullRequestComments({ octokit, owner, repo, - prNumber: input.prNumber, + prNumber: prNumber, }); } catch (error) { warnings.push( @@ -623,7 +880,7 @@ async function listGitHubPullRequestComments({ success: true, provider, repositoryFullName: repository.fullName, - number: input.prNumber, + number: prNumber, threads, issueComments, warnings, @@ -759,12 +1016,12 @@ function groupGitHubReviewCommentsIntoThreads( } async function getGitLabMergeRequestDetails({ - input, + prNumber, repository, provider, fetchImpl, }: { - input: SourceControlPullRequestReadInput; + prNumber: number; repository: RepositoryRow; provider: 'gitlab'; fetchImpl: FetchImpl; @@ -776,7 +1033,7 @@ async function getGitLabMergeRequestDetails({ fetchImpl, url: buildApiUrl( apiBaseUrl, - `/projects/${encodeURIComponent(projectId)}/merge_requests/${input.prNumber}`, + `/projects/${encodeURIComponent(projectId)}/merge_requests/${prNumber}`, {}, ), tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, @@ -829,12 +1086,12 @@ async function getGitLabMergeRequestDetails({ } async function listGitLabMergeRequestComments({ - input, + prNumber, repository, provider, fetchImpl, }: { - input: SourceControlPullRequestReadInput; + prNumber: number; repository: RepositoryRow; provider: 'gitlab'; fetchImpl: FetchImpl; @@ -846,7 +1103,7 @@ async function listGitLabMergeRequestComments({ fetchImpl, url: buildApiUrl( apiBaseUrl, - `/projects/${encodeURIComponent(projectId)}/merge_requests/${input.prNumber}/discussions`, + `/projects/${encodeURIComponent(projectId)}/merge_requests/${prNumber}/discussions`, { per_page: 100 }, ), tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, @@ -897,7 +1154,7 @@ async function listGitLabMergeRequestComments({ success: true, provider, repositoryFullName: repository.fullName, - number: input.prNumber, + number: prNumber, threads, issueComments, warnings: [], @@ -924,12 +1181,12 @@ async function resolveGitLabReadContext( } async function getGiteaPullRequestDetails({ - input, + prNumber, repository, provider, fetchImpl, }: { - input: SourceControlPullRequestReadInput; + prNumber: number; repository: RepositoryRow; provider: 'gitea'; fetchImpl: FetchImpl; @@ -941,14 +1198,14 @@ async function getGiteaPullRequestDetails({ fetchImpl, url: buildApiUrl( apiBaseUrl, - `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${input.prNumber}`, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}`, {}, ), tokenHeader: { name: 'Authorization', value: `token ${token}` }, schema: giteaPullRequestDetailsSchema, }); - const number = pullRequest.number ?? pullRequest.index ?? input.prNumber; + const number = pullRequest.number ?? pullRequest.index ?? prNumber; const title = pullRequest.title ?? ''; const host = new URL(baseUrl).host; @@ -990,12 +1247,12 @@ async function getGiteaPullRequestDetails({ } async function listGiteaPullRequestComments({ - input, + prNumber, repository, provider, fetchImpl, }: { - input: SourceControlPullRequestReadInput; + prNumber: number; repository: RepositoryRow; provider: 'gitea'; fetchImpl: FetchImpl; @@ -1011,7 +1268,7 @@ async function listGiteaPullRequestComments({ fetchImpl, url: buildApiUrl( apiBaseUrl, - `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${input.prNumber}/comments`, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${prNumber}/comments`, {}, ), tokenHeader, @@ -1024,7 +1281,7 @@ async function listGiteaPullRequestComments({ fetchImpl, url: buildApiUrl( apiBaseUrl, - `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${input.prNumber}/reviews`, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/reviews`, {}, ), tokenHeader, @@ -1038,7 +1295,7 @@ async function listGiteaPullRequestComments({ fetchImpl, url: buildApiUrl( apiBaseUrl, - `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${input.prNumber}/reviews/${review.id}/comments`, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/reviews/${review.id}/comments`, {}, ), tokenHeader, @@ -1068,7 +1325,7 @@ async function listGiteaPullRequestComments({ success: true, provider, repositoryFullName: repository.fullName, - number: input.prNumber, + number: prNumber, threads, issueComments, warnings, @@ -1119,12 +1376,12 @@ async function resolveGiteaReadContext( } async function getBitbucketPullRequestDetails({ - input, + prNumber, repository, provider, fetchImpl, }: { - input: SourceControlPullRequestReadInput; + prNumber: number; repository: RepositoryRow; provider: 'bitbucket'; fetchImpl: FetchImpl; @@ -1138,7 +1395,7 @@ async function getBitbucketPullRequestDetails({ apiBaseUrl, `/repositories/${encodeURIComponent(workspace)}/${encodeURIComponent( repo, - )}/pullrequests/${input.prNumber}`, + )}/pullrequests/${prNumber}`, {}, ), tokenHeader: { name: 'Authorization', value: authHeader }, @@ -1195,12 +1452,12 @@ async function getBitbucketPullRequestDetails({ } async function listBitbucketPullRequestComments({ - input, + prNumber, repository, provider, fetchImpl, }: { - input: SourceControlPullRequestReadInput; + prNumber: number; repository: RepositoryRow; provider: 'bitbucket'; fetchImpl: FetchImpl; @@ -1213,7 +1470,7 @@ async function listBitbucketPullRequestComments({ apiBaseUrl, `/repositories/${encodeURIComponent(workspace)}/${encodeURIComponent( repo, - )}/pullrequests/${input.prNumber}/comments`, + )}/pullrequests/${prNumber}/comments`, { pagelen: 50 }, ); @@ -1265,7 +1522,7 @@ async function listBitbucketPullRequestComments({ success: true, provider, repositoryFullName: repository.fullName, - number: input.prNumber, + number: prNumber, threads: Array.from(threadsById.values()), issueComments, warnings: @@ -1333,12 +1590,12 @@ async function resolveBitbucketReadContext( } async function getAdoPullRequestDetails({ - input, + prNumber, repository, provider, fetchImpl, }: { - input: SourceControlPullRequestReadInput; + prNumber: number; repository: RepositoryRow; provider: 'ado'; fetchImpl: FetchImpl; @@ -1350,7 +1607,7 @@ async function getAdoPullRequestDetails({ fetchImpl, url: buildApiUrl( organizationApiBaseUrl, - `${repositoryPullRequestsPath}/${input.prNumber}`, + `${repositoryPullRequestsPath}/${prNumber}`, { 'api-version': ADO_API_VERSION }, ), tokenHeader: { @@ -1405,12 +1662,12 @@ async function getAdoPullRequestDetails({ } async function listAdoPullRequestComments({ - input, + prNumber, repository, provider, fetchImpl, }: { - input: SourceControlPullRequestReadInput; + prNumber: number; repository: RepositoryRow; provider: 'ado'; fetchImpl: FetchImpl; @@ -1422,7 +1679,7 @@ async function listAdoPullRequestComments({ fetchImpl, url: buildApiUrl( organizationApiBaseUrl, - `${repositoryPullRequestsPath}/${input.prNumber}/threads`, + `${repositoryPullRequestsPath}/${prNumber}/threads`, { 'api-version': ADO_API_VERSION }, ), tokenHeader: { @@ -1473,7 +1730,7 @@ async function listAdoPullRequestComments({ success: true, provider, repositoryFullName: repository.fullName, - number: input.prNumber, + number: prNumber, threads, issueComments, warnings: [], @@ -1565,3 +1822,496 @@ async function requestJson({ function stripAdoBranchRef(ref: string): string { return ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref; } + +function mapProviderPullRequestState(state: { + merged: boolean; + closed: boolean; +}): SourceControlPullRequestSummary['state'] { + return state.merged ? 'merged' : state.closed ? 'closed' : 'open'; +} + +function truncationWarning(limit: number): string { + return `Result truncated to the ${limit} most relevant open pull requests; more exist.`; +} + +async function listGitHubOpenPullRequests({ + repository, + provider, + limit, +}: { + repository: RepositoryRow; + provider: 'github'; + limit: number; +}): Promise { + const { octokit, owner, repo } = await createGitHubReadClient( + repository, + provider, + ); + const warnings: string[] = []; + + const pulls = await octokit.paginate(octokit.rest.pulls.list, { + owner, + repo, + state: 'open', + sort: 'updated', + direction: 'desc', + per_page: 100, + }); + + if (pulls.length > limit) { + warnings.push(truncationWarning(limit)); + } + + warnings.push( + 'GitHub does not include mergeability in pull request lists; use get_pull_request for a per-PR mergeable signal.', + ); + + return { + success: true, + provider, + repositoryFullName: repository.fullName, + pullRequests: pulls.slice(0, limit).map((pull) => ({ + number: pull.number, + url: pull.html_url, + title: pull.title, + state: mapProviderPullRequestState({ + merged: Boolean(pull.merged_at), + closed: pull.state === 'closed', + }), + draft: Boolean(pull.draft), + sourceBranch: pull.head.ref, + targetBranch: pull.base.ref, + author: pull.user + ? { id: String(pull.user.id), login: pull.user.login ?? null } + : null, + updatedAt: pull.updated_at ?? null, + createdAt: pull.created_at ?? null, + labels: pull.labels + .map((label) => label.name) + .filter((name): name is string => Boolean(name)), + headSha: pull.head.sha ?? null, + baseSha: pull.base.sha ?? null, + mergeable: null, + mergeStateDescription: null, + isCrossRepository: + pull.head?.repo && pull.base?.repo + ? pull.head.repo.full_name !== pull.base.repo.full_name + : null, + headRepositoryFullName: pull.head?.repo?.full_name ?? null, + })), + warnings, + }; +} + +const GITLAB_MR_LIST_PAGE_SIZE = 100; + +async function listGitLabOpenMergeRequests({ + repository, + provider, + limit, + fetchImpl, +}: { + repository: RepositoryRow; + provider: 'gitlab'; + limit: number; + fetchImpl: FetchImpl; +}): Promise { + const { projectId, token, apiBaseUrl } = + await resolveGitLabReadContext(repository); + const host = new URL(apiBaseUrl).host; + const warnings: string[] = []; + const rows: z.infer[] = []; + + for (let page = 1; page <= LIST_PULL_REQUESTS_MAX_PAGES; page++) { + const pageRows = await requestJson({ + fetchImpl, + url: buildApiUrl( + apiBaseUrl, + `/projects/${encodeURIComponent(projectId)}/merge_requests`, + { + state: 'opened', + order_by: 'updated_at', + sort: 'desc', + per_page: GITLAB_MR_LIST_PAGE_SIZE, + page, + }, + ), + tokenHeader: { name: 'PRIVATE-TOKEN', value: token }, + schema: gitLabMergeRequestListPageSchema, + }); + + rows.push(...pageRows); + + if (rows.length > limit || pageRows.length < GITLAB_MR_LIST_PAGE_SIZE) { + break; + } + } + + if (rows.length > limit) { + warnings.push(truncationWarning(limit)); + } + + return { + success: true, + provider, + repositoryFullName: repository.fullName, + pullRequests: rows.slice(0, limit).map((mergeRequest) => ({ + number: mergeRequest.iid, + url: + mergeRequest.web_url ?? + buildPullRequestUrl({ + provider, + host, + repositoryFullName: repository.fullName, + number: mergeRequest.iid, + }), + title: mergeRequest.title, + state: mapProviderPullRequestState({ + merged: mergeRequest.state === 'merged', + closed: mergeRequest.state === 'closed', + }), + draft: isGitLabDraft(mergeRequest), + sourceBranch: mergeRequest.source_branch, + targetBranch: mergeRequest.target_branch, + author: mergeRequest.author + ? { + id: + mergeRequest.author.id != null + ? String(mergeRequest.author.id) + : null, + login: mergeRequest.author.username ?? null, + } + : null, + updatedAt: mergeRequest.updated_at ?? null, + createdAt: mergeRequest.created_at ?? null, + labels: mergeRequest.labels ?? [], + headSha: mergeRequest.sha ?? null, + // The GitLab MR list payload has no diff_refs, so the base sha is unknown. + baseSha: null, + mergeable: + typeof mergeRequest.has_conflicts === 'boolean' + ? !mergeRequest.has_conflicts + : null, + mergeStateDescription: + mergeRequest.detailed_merge_status ?? mergeRequest.merge_status ?? null, + isCrossRepository: + typeof mergeRequest.source_project_id === 'number' && + typeof mergeRequest.target_project_id === 'number' + ? mergeRequest.source_project_id !== mergeRequest.target_project_id + : null, + headRepositoryFullName: null, + })), + warnings, + }; +} + +const GITEA_PULLS_LIST_PAGE_SIZE = 50; + +async function listGiteaOpenPullRequests({ + repository, + provider, + limit, + fetchImpl, +}: { + repository: RepositoryRow; + provider: 'gitea'; + limit: number; + fetchImpl: FetchImpl; +}): Promise { + const { apiBaseUrl, baseUrl, owner, repo, token } = + await resolveGiteaReadContext(repository, provider); + const host = new URL(baseUrl).host; + const warnings: string[] = []; + const rows: z.infer[] = []; + + for (let page = 1; page <= LIST_PULL_REQUESTS_MAX_PAGES; page++) { + const pageRows = await requestJson({ + fetchImpl, + url: buildApiUrl( + apiBaseUrl, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, + { state: 'open', limit: GITEA_PULLS_LIST_PAGE_SIZE, page }, + ), + tokenHeader: { name: 'Authorization', value: `token ${token}` }, + schema: giteaPullRequestListPageSchema, + }); + + rows.push(...pageRows); + + if (rows.length > limit || pageRows.length < GITEA_PULLS_LIST_PAGE_SIZE) { + break; + } + } + + if (rows.length > limit) { + warnings.push(truncationWarning(limit)); + } + + return { + success: true, + provider, + repositoryFullName: repository.fullName, + pullRequests: rows.slice(0, limit).map((pullRequest) => { + const number = pullRequest.number ?? pullRequest.index ?? 0; + const title = pullRequest.title ?? ''; + + return { + number, + url: + pullRequest.html_url ?? + buildPullRequestUrl({ + provider, + host, + repositoryFullName: repository.fullName, + number, + }), + title, + state: mapProviderPullRequestState({ + merged: Boolean(pullRequest.merged), + closed: pullRequest.state === 'closed', + }), + draft: Boolean(pullRequest.draft) || isDraftTitle(title), + sourceBranch: pullRequest.head?.ref ?? '', + targetBranch: pullRequest.base?.ref ?? '', + author: pullRequest.user + ? { + id: + pullRequest.user.id != null + ? String(pullRequest.user.id) + : null, + login: pullRequest.user.login ?? null, + } + : null, + updatedAt: pullRequest.updated_at ?? null, + createdAt: pullRequest.created_at ?? null, + labels: (pullRequest.labels ?? []) + .map((label) => label.name) + .filter((name): name is string => Boolean(name)), + headSha: pullRequest.head?.sha ?? null, + baseSha: pullRequest.base?.sha ?? null, + mergeable: pullRequest.mergeable ?? null, + mergeStateDescription: null, + isCrossRepository: + pullRequest.head?.repo?.full_name && pullRequest.base?.repo?.full_name + ? pullRequest.head.repo.full_name !== + pullRequest.base.repo.full_name + : null, + headRepositoryFullName: pullRequest.head?.repo?.full_name ?? null, + }; + }), + warnings, + }; +} + +async function listBitbucketOpenPullRequests({ + repository, + provider, + limit, + fetchImpl, +}: { + repository: RepositoryRow; + provider: 'bitbucket'; + limit: number; + fetchImpl: FetchImpl; +}): Promise { + const { apiBaseUrl, authHeader, baseUrl, workspace, repo } = + await resolveBitbucketReadContext(repository, provider); + const host = new URL(baseUrl).host; + const warnings: string[] = []; + const rows: z.infer[] = []; + let nextUrl: string | null = buildApiUrl( + apiBaseUrl, + `/repositories/${encodeURIComponent(workspace)}/${encodeURIComponent( + repo, + )}/pullrequests`, + { state: 'OPEN', pagelen: 50 }, + ); + + for (let page = 1; nextUrl && page <= LIST_PULL_REQUESTS_MAX_PAGES; page++) { + const pageResult: z.infer = + await requestJson({ + fetchImpl, + url: nextUrl, + tokenHeader: { name: 'Authorization', value: authHeader }, + schema: bitbucketPullRequestListPageSchema, + }); + + rows.push(...pageResult.values); + nextUrl = pageResult.next ?? null; + + if (rows.length > limit) { + break; + } + } + + if (rows.length > limit) { + warnings.push(truncationWarning(limit)); + } + + warnings.push( + 'Bitbucket does not expose a mergeable signal or labels; mergeable is null and labels are empty.', + ); + + return { + success: true, + provider, + repositoryFullName: repository.fullName, + pullRequests: rows.slice(0, limit).map((pullRequest) => { + const title = pullRequest.title ?? ''; + const state = (pullRequest.state ?? '').toUpperCase(); + + return { + number: pullRequest.id, + url: + pullRequest.links?.html?.href ?? + buildPullRequestUrl({ + provider, + host, + repositoryFullName: repository.fullName, + number: pullRequest.id, + }), + title, + state: mapProviderPullRequestState({ + merged: state === 'MERGED', + closed: state === 'DECLINED' || state === 'SUPERSEDED', + }), + draft: Boolean(pullRequest.draft) || isDraftTitle(title), + sourceBranch: pullRequest.source?.branch?.name ?? '', + targetBranch: pullRequest.destination?.branch?.name ?? '', + author: pullRequest.author + ? { + id: pullRequest.author.uuid ?? null, + login: + pullRequest.author.nickname ?? + pullRequest.author.display_name ?? + pullRequest.author.username ?? + null, + } + : null, + updatedAt: pullRequest.updated_on ?? null, + createdAt: pullRequest.created_on ?? null, + labels: [], + headSha: pullRequest.source?.commit?.hash ?? null, + baseSha: pullRequest.destination?.commit?.hash ?? null, + mergeable: null, + mergeStateDescription: null, + isCrossRepository: + pullRequest.source?.repository?.full_name && + pullRequest.destination?.repository?.full_name + ? pullRequest.source.repository.full_name !== + pullRequest.destination.repository.full_name + : null, + headRepositoryFullName: + pullRequest.source?.repository?.full_name ?? null, + }; + }), + warnings, + }; +} + +const ADO_PULLS_LIST_PAGE_SIZE = 100; + +async function listAdoOpenPullRequests({ + repository, + provider, + limit, + fetchImpl, +}: { + repository: RepositoryRow; + provider: 'ado'; + limit: number; + fetchImpl: FetchImpl; +}): Promise { + const { baseUrl, organizationApiBaseUrl, repositoryPullRequestsPath, token } = + await resolveAdoReadContext(repository); + const host = new URL(baseUrl).host; + const warnings: string[] = []; + const rows: z.infer[] = []; + + for (let page = 0; page < LIST_PULL_REQUESTS_MAX_PAGES; page++) { + const pageResult = await requestJson({ + fetchImpl, + url: buildApiUrl(organizationApiBaseUrl, repositoryPullRequestsPath, { + 'api-version': ADO_API_VERSION, + 'searchCriteria.status': 'active', + $top: ADO_PULLS_LIST_PAGE_SIZE, + $skip: page * ADO_PULLS_LIST_PAGE_SIZE, + }), + tokenHeader: { + name: 'Authorization', + value: buildAdoBasicAuthHeader(token), + }, + schema: adoPullRequestListPageSchema, + }); + + rows.push(...pageResult.value); + + if ( + rows.length > limit || + pageResult.value.length < ADO_PULLS_LIST_PAGE_SIZE + ) { + break; + } + } + + if (rows.length > limit) { + warnings.push(truncationWarning(limit)); + } + + if (rows.some((pullRequest) => pullRequest.labels === undefined)) { + warnings.push( + 'Azure DevOps did not include labels in the pull request list; labels may be reported as empty.', + ); + } + + return { + success: true, + provider, + repositoryFullName: repository.fullName, + pullRequests: rows.slice(0, limit).map((pullRequest) => ({ + number: pullRequest.pullRequestId, + url: buildPullRequestUrl({ + provider, + host, + repositoryFullName: repository.fullName, + number: pullRequest.pullRequestId, + }), + title: pullRequest.title, + state: mapProviderPullRequestState({ + merged: pullRequest.status === 'completed', + closed: pullRequest.status === 'abandoned', + }), + draft: Boolean(pullRequest.isDraft), + sourceBranch: stripAdoBranchRef(pullRequest.sourceRefName ?? ''), + targetBranch: stripAdoBranchRef(pullRequest.targetRefName ?? ''), + author: pullRequest.createdBy + ? { + id: pullRequest.createdBy.id ?? null, + login: + pullRequest.createdBy.uniqueName ?? + pullRequest.createdBy.displayName ?? + null, + } + : null, + // Azure DevOps pull request lists carry no last-updated timestamp. + updatedAt: null, + createdAt: pullRequest.creationDate ?? null, + labels: (pullRequest.labels ?? []) + .map((label) => label.name) + .filter((name): name is string => Boolean(name)), + headSha: pullRequest.lastMergeSourceCommit?.commitId ?? null, + baseSha: pullRequest.lastMergeTargetCommit?.commitId ?? null, + mergeable: + pullRequest.mergeStatus === 'succeeded' + ? true + : pullRequest.mergeStatus === 'conflicts' + ? false + : null, + mergeStateDescription: pullRequest.mergeStatus ?? null, + // ADO exposes forkSource only on fork PRs, so its absence means same-repo. + isCrossRepository: pullRequest.forkSource ? true : false, + headRepositoryFullName: pullRequest.forkSource?.repository?.name ?? null, + })), + warnings, + }; +} diff --git a/packages/types/src/background-automation-registry.ts b/packages/types/src/background-automation-registry.ts index 9bdaad6d5..07d8cc25d 100644 --- a/packages/types/src/background-automation-registry.ts +++ b/packages/types/src/background-automation-registry.ts @@ -124,10 +124,12 @@ export const TRIGGERABLE_BACKGROUND_AUTOMATION_DESCRIPTORS = [ label: 'Resolve PR Conflicts', availability: 'stable', scheduleModes: CONFLICT_RESOLVER_SCHEDULE_MODES, - manualTriggerRequirements: ['github', 'repository'], + // Provider-neutral scan: any active repository qualifies, GitHub + // installation no longer required. + manualTriggerRequirements: ['repository'], usesManagerChannel: false, supportedCommunicationProviders: [], - supportedSourceControlProviders: ['github'], + supportedSourceControlProviders: ['github', 'gitlab', 'ado'], }, { automationKey: 'suggester',