diff --git a/src/services/github/client.test.ts b/src/services/github/client.test.ts index 07b0c6a..c0138d5 100644 --- a/src/services/github/client.test.ts +++ b/src/services/github/client.test.ts @@ -409,3 +409,377 @@ describe('GitHubClient — listReviewThreads truncation', () => { expect(warn).not.toHaveBeenCalled(); }); }); + +describe('GitHubClient — enableAutoMerge', () => { + afterEach(() => { vi.unstubAllGlobals(); }); + + it('fetches the PR node_id then POSTs the enablePullRequestAutoMerge mutation', async () => { + const urls: string[] = []; + const bodies: unknown[] = []; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + urls.push(url); + if (url.endsWith('/graphql')) { + bodies.push(JSON.parse(init.body as string)); + return new Response( + JSON.stringify({ data: { enablePullRequestAutoMerge: { pullRequest: { number: 5, autoMergeRequest: { mergeMethod: 'SQUASH', enabledAt: '2024-01-01T00:00:00Z', enabledBy: { login: 'alice' } } } } } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + // REST call to get PR node_id + return new Response( + JSON.stringify({ number: 5, node_id: 'PR_node_123' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + }); + + const client = new GitHubClient(new HttpClient(makeConfig())); + const result = await client.enableAutoMerge({ owner: 'o', repo: 'r', pullNumber: 5, mergeMethod: 'SQUASH' }); + + expect(urls[0]).toContain('/repos/o/r/pulls/5'); + expect(urls[1]).toBe('https://api.github.com/graphql'); + const gqlBody = bodies[0] as { query: string; variables: Record }; + expect(gqlBody.query).toContain('enablePullRequestAutoMerge'); + expect(gqlBody.variables.pullRequestId).toBe('PR_node_123'); + expect(gqlBody.variables.mergeMethod).toBe('SQUASH'); + expect(result.autoMergeRequest?.mergeMethod).toBe('SQUASH'); + }); + + it('passes expectedHeadOid when --match-head-commit is provided', async () => { + let capturedVars: Record = {}; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + if (url.endsWith('/graphql')) { + capturedVars = JSON.parse(init.body as string).variables; + return new Response( + JSON.stringify({ data: { enablePullRequestAutoMerge: { pullRequest: { number: 7, autoMergeRequest: null } } } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ number: 7, node_id: 'PR_node_456' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const client = new GitHubClient(new HttpClient(makeConfig())); + await client.enableAutoMerge({ owner: 'o', repo: 'r', pullNumber: 7, expectedHeadOid: 'abc123sha' }); + + expect(capturedVars.expectedHeadOid).toBe('abc123sha'); + }); +}); + +describe('GitHubClient — disableAutoMerge', () => { + afterEach(() => { vi.unstubAllGlobals(); }); + + it('fetches the PR node_id then POSTs the disablePullRequestAutoMerge mutation', async () => { + const urls: string[] = []; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + urls.push(url); + if (url.endsWith('/graphql')) { + const body = JSON.parse(init.body as string); + expect(body.query).toContain('disablePullRequestAutoMerge'); + expect(body.variables.pullRequestId).toBe('PR_node_789'); + return new Response( + JSON.stringify({ data: { disablePullRequestAutoMerge: { pullRequest: { number: 3, autoMergeRequest: null } } } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ number: 3, node_id: 'PR_node_789' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const client = new GitHubClient(new HttpClient(makeConfig())); + const result = await client.disableAutoMerge('o', 'r', 3); + + expect(urls).toHaveLength(2); + expect(result.autoMergeRequest).toBeNull(); + }); +}); + +describe('GitHubClient — enqueuePR', () => { + afterEach(() => { vi.unstubAllGlobals(); }); + + it('POSTs the addPullRequestToMergeQueue mutation and returns the queue URL', async () => { + let capturedBody: { query: string; variables: Record } = { query: '', variables: {} }; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + if (url.endsWith('/graphql')) { + capturedBody = JSON.parse(init.body as string); + return new Response( + JSON.stringify({ data: { addPullRequestToMergeQueue: { mergeQueue: { url: 'https://github.com/o/r/queue' } } } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ number: 10, node_id: 'PR_node_enqueue' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const client = new GitHubClient(new HttpClient(makeConfig())); + const result = await client.enqueuePR({ owner: 'o', repo: 'r', pullNumber: 10, mergeMethod: 'MERGE' }); + + expect(capturedBody.query).toContain('addPullRequestToMergeQueue'); + expect(capturedBody.variables.pullRequestId).toBe('PR_node_enqueue'); + expect(capturedBody.variables.mergeMethod).toBe('MERGE'); + expect(result.mergeQueueUrl).toBe('https://github.com/o/r/queue'); + }); +}); + +describe('GitHubClient — getPRStatus', () => { + afterEach(() => { vi.unstubAllGlobals(); }); + + it('queries GraphQL and aggregates review, check, and auto-merge state', async () => { + const prData = { + number: 42, + title: 'My PR', + state: 'OPEN', + isDraft: false, + merged: false, + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + reviewDecision: 'APPROVED', + autoMergeRequest: { mergeMethod: 'SQUASH', enabledAt: '2024-01-01T00:00:00Z', enabledBy: { login: 'bot' } }, + commits: { + nodes: [{ + commit: { + statusCheckRollup: { + contexts: { + nodes: [ + { __typename: 'CheckRun', name: 'ci', status: 'completed', conclusion: 'success' }, + { __typename: 'CheckRun', name: 'lint', status: 'completed', conclusion: 'failure' }, + { __typename: 'CheckRun', name: 'build', status: 'in_progress', conclusion: null }, + { __typename: 'StatusContext', context: 'security', state: 'success' } + ] + } + } + } + }] + } + }; + + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + expect(url).toBe('https://api.github.com/graphql'); + const body = JSON.parse(init.body as string); + expect(body.variables).toEqual({ owner: 'o', repo: 'r', number: 42 }); + return new Response( + JSON.stringify({ data: { repository: { pullRequest: prData } } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + }); + + const client = new GitHubClient(new HttpClient(makeConfig())); + const status = await client.getPRStatus('o', 'r', 42); + + expect(status.number).toBe(42); + expect(status.reviewDecision).toBe('APPROVED'); + expect(status.autoMergeRequest?.enabledBy).toBe('bot'); + expect(status.checks.total).toBe(4); + expect(status.checks.passed).toBe(2); + expect(status.checks.failed).toBe(1); + expect(status.checks.pending).toBe(1); + expect(status.checks.other).toBe(0); + }); + + it('buckets skipped/cancelled/neutral checks into `other` so the counts sum to total', async () => { + const prData = { + number: 7, + title: 'Mixed', + state: 'OPEN', + isDraft: false, + merged: false, + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + reviewDecision: null, + autoMergeRequest: null, + commits: { + nodes: [{ + commit: { + statusCheckRollup: { + contexts: { + nodes: [ + { __typename: 'CheckRun', name: 'ci', status: 'completed', conclusion: 'success' }, + { __typename: 'CheckRun', name: 'skipped-job', status: 'completed', conclusion: 'skipped' }, + { __typename: 'CheckRun', name: 'cancelled-job', status: 'completed', conclusion: 'cancelled' }, + { __typename: 'CheckRun', name: 'neutral-job', status: 'completed', conclusion: 'neutral' } + ] + } + } + } + }] + } + }; + + vi.stubGlobal('fetch', async () => + new Response(JSON.stringify({ data: { repository: { pullRequest: prData } } }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + ); + + const client = new GitHubClient(new HttpClient(makeConfig())); + const status = await client.getPRStatus('o', 'r', 7); + + expect(status.checks.total).toBe(4); + expect(status.checks.passed).toBe(1); + expect(status.checks.failed).toBe(0); + expect(status.checks.pending).toBe(0); + expect(status.checks.other).toBe(3); + expect(status.checks.passed + status.checks.failed + status.checks.pending + status.checks.other) + .toBe(status.checks.total); + }); + + it('handles a PR with no checks', async () => { + const prData = { + number: 1, + title: 'Minimal', + state: 'OPEN', + isDraft: true, + merged: false, + mergeable: 'UNKNOWN', + mergeStateStatus: 'DRAFT', + reviewDecision: null, + autoMergeRequest: null, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] } + }; + + vi.stubGlobal('fetch', async () => + new Response(JSON.stringify({ data: { repository: { pullRequest: prData } } }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + ); + + const client = new GitHubClient(new HttpClient(makeConfig())); + const status = await client.getPRStatus('o', 'r', 1); + + expect(status.checks.total).toBe(0); + expect(status.autoMergeRequest).toBeNull(); + }); +}); + +describe('GitHubClient — convertToDraft / markReadyForReview', () => { + afterEach(() => { vi.unstubAllGlobals(); }); + + it('convertToDraft POSTs the convertPullRequestToDraft mutation', async () => { + let capturedQuery = ''; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + if (url.endsWith('/graphql')) { + capturedQuery = JSON.parse(init.body as string).query; + return new Response( + JSON.stringify({ data: { convertPullRequestToDraft: { pullRequest: { number: 8, isDraft: true } } } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ number: 8, node_id: 'PR_node_draft' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const result = await new GitHubClient(new HttpClient(makeConfig())).convertToDraft('o', 'r', 8); + expect(capturedQuery).toContain('convertPullRequestToDraft'); + expect(result.isDraft).toBe(true); + }); + + it('markReadyForReview POSTs the markPullRequestReadyForReview mutation', async () => { + let capturedQuery = ''; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + if (url.endsWith('/graphql')) { + capturedQuery = JSON.parse(init.body as string).query; + return new Response( + JSON.stringify({ data: { markPullRequestReadyForReview: { pullRequest: { number: 9, isDraft: false } } } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ number: 9, node_id: 'PR_node_ready' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const result = await new GitHubClient(new HttpClient(makeConfig())).markReadyForReview('o', 'r', 9); + expect(capturedQuery).toContain('markPullRequestReadyForReview'); + expect(result.isDraft).toBe(false); + }); +}); + +describe('GitHubClient — addLabels / removeLabel', () => { + afterEach(() => { vi.unstubAllGlobals(); }); + + it('addLabels POSTs to the issues labels endpoint', async () => { + let capturedUrl = ''; + let capturedBody: unknown; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + capturedUrl = url; + capturedBody = JSON.parse(init.body as string); + return new Response(JSON.stringify({ labels: [{ name: 'bug' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + await new GitHubClient(new HttpClient(makeConfig())).addLabels('o', 'r', 5, ['bug', 'enhancement']); + expect(capturedUrl).toContain('/repos/o/r/issues/5/labels'); + expect(capturedBody).toEqual({ labels: ['bug', 'enhancement'] }); + }); + + it('removeLabel sends DELETE to the label endpoint', async () => { + let capturedUrl = ''; + let capturedMethod = ''; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + capturedUrl = url; + capturedMethod = init.method ?? 'GET'; + return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + await new GitHubClient(new HttpClient(makeConfig())).removeLabel('o', 'r', 5, 'bug'); + expect(capturedUrl).toContain('/repos/o/r/issues/5/labels/bug'); + expect(capturedMethod).toBe('DELETE'); + }); + + it('removeLabel URL-encodes the label name via the URL constructor', async () => { + let capturedUrl = ''; + vi.stubGlobal('fetch', async (url: string) => { + capturedUrl = url; + return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + await new GitHubClient(new HttpClient(makeConfig())).removeLabel('o', 'r', 5, 'help wanted'); + expect(capturedUrl).toContain('/labels/help%20wanted'); + }); +}); + +describe('GitHubClient — addReviewers / removeReviewers', () => { + afterEach(() => { vi.unstubAllGlobals(); }); + + it('addReviewers POSTs to requested_reviewers with user and team slugs', async () => { + let capturedUrl = ''; + let capturedBody: unknown; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + capturedUrl = url; + capturedBody = JSON.parse(init.body as string); + return new Response(JSON.stringify({ url: 'https://api.github.com/repos/o/r/pulls/3' }), { status: 201, headers: { 'Content-Type': 'application/json' } }); + }); + + await new GitHubClient(new HttpClient(makeConfig())).addReviewers({ owner: 'o', repo: 'r', pullNumber: 3, reviewers: ['alice'], teamReviewers: ['backend-team'] }); + expect(capturedUrl).toContain('/repos/o/r/pulls/3/requested_reviewers'); + expect(capturedBody).toEqual({ reviewers: ['alice'], team_reviewers: ['backend-team'] }); + }); + + it('removeReviewers sends DELETE to requested_reviewers', async () => { + let capturedMethod = ''; + vi.stubGlobal('fetch', async (_url: string, init: RequestInit) => { + capturedMethod = init.method ?? 'GET'; + return new Response('', { status: 200 }); + }); + + await new GitHubClient(new HttpClient(makeConfig())).removeReviewers({ owner: 'o', repo: 'r', pullNumber: 3, reviewers: ['bob'] }); + expect(capturedMethod).toBe('DELETE'); + }); +}); + +describe('GitHubClient — addAssignees / removeAssignees', () => { + afterEach(() => { vi.unstubAllGlobals(); }); + + it('addAssignees POSTs to the issues assignees endpoint', async () => { + let capturedUrl = ''; + let capturedBody: unknown; + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + capturedUrl = url; + capturedBody = JSON.parse(init.body as string); + return new Response(JSON.stringify({ number: 7, assignees: [{ login: 'alice' }] }), { status: 201, headers: { 'Content-Type': 'application/json' } }); + }); + + await new GitHubClient(new HttpClient(makeConfig())).addAssignees('o', 'r', 7, ['alice']); + expect(capturedUrl).toContain('/repos/o/r/issues/7/assignees'); + expect(capturedBody).toEqual({ assignees: ['alice'] }); + }); + + it('removeAssignees sends DELETE to the assignees endpoint', async () => { + let capturedMethod = ''; + vi.stubGlobal('fetch', async (_url: string, init: RequestInit) => { + capturedMethod = init.method ?? 'GET'; + return new Response(JSON.stringify({ number: 7 }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + await new GitHubClient(new HttpClient(makeConfig())).removeAssignees('o', 'r', 7, ['bob']); + expect(capturedMethod).toBe('DELETE'); + }); +}); diff --git a/src/services/github/client.ts b/src/services/github/client.ts index fc95189..8bc2968 100644 --- a/src/services/github/client.ts +++ b/src/services/github/client.ts @@ -8,7 +8,12 @@ import type { GitHubCheckRun, GitHubIssue, GitHubRepo, - GitHubReviewThread + GitHubReviewThread, + GitHubAutoMergeResult, + GitHubMergeQueueResult, + GitHubPRStatusResult, + GitHubLabel, + GitHubUser } from '../../types/github.js'; export interface ListPRsOpts { @@ -85,6 +90,39 @@ export interface CreateRepoOpts { autoInit?: boolean; } +export interface EnableAutoMergeOpts { + owner: string; + repo: string; + pullNumber: number; + mergeMethod?: 'MERGE' | 'SQUASH' | 'REBASE'; + /** Safety: mutation is rejected if the PR's head SHA doesn't match */ + expectedHeadOid?: string; +} + +export interface EnqueuePROpts { + owner: string; + repo: string; + pullNumber: number; + mergeMethod?: 'MERGE' | 'SQUASH' | 'REBASE'; + /** Safety: mutation is rejected if the PR's head SHA doesn't match */ + expectedHeadOid?: string; +} + +export interface ReviewersOpts { + owner: string; + repo: string; + pullNumber: number; + reviewers?: string[]; + teamReviewers?: string[]; +} + +export interface AddAssigneesOpts { + owner: string; + repo: string; + issueNumber: number; + assignees: string[]; +} + export class GitHubClient { constructor(private http: HttpClient) {} @@ -384,4 +422,305 @@ export class GitHubClient { }>(mutation, { threadId }); return data.resolveReviewThread.thread; } + + // ── Auto-merge ───────────────────────────────────────────────────── + + /** Returns the GraphQL node ID of a PR, needed for mutations. */ + private async getPRNodeId(owner: string, repo: string, pullNumber: number): Promise { + const pr = await this.getPR(owner, repo, pullNumber); + return pr.node_id; + } + + async enableAutoMerge(opts: EnableAutoMergeOpts): Promise { + const nodeId = await this.getPRNodeId(opts.owner, opts.repo, opts.pullNumber); + const mutation = ` + mutation($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod, $expectedHeadOid: GitObjectID) { + enablePullRequestAutoMerge(input: { + pullRequestId: $pullRequestId + mergeMethod: $mergeMethod + expectedHeadOid: $expectedHeadOid + }) { + pullRequest { + number + autoMergeRequest { + mergeMethod + enabledAt + enabledBy { login } + } + } + } + } + `; + const variables: Record = { pullRequestId: nodeId }; + if (opts.mergeMethod) variables.mergeMethod = opts.mergeMethod; + if (opts.expectedHeadOid) variables.expectedHeadOid = opts.expectedHeadOid; + + const data = await this.http.githubGraphQL<{ + enablePullRequestAutoMerge: { pullRequest: GitHubAutoMergeResult }; + }>(mutation, variables); + return data.enablePullRequestAutoMerge.pullRequest; + } + + async disableAutoMerge(owner: string, repo: string, pullNumber: number): Promise { + const nodeId = await this.getPRNodeId(owner, repo, pullNumber); + const mutation = ` + mutation($pullRequestId: ID!) { + disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { + pullRequest { + number + autoMergeRequest { + mergeMethod + enabledAt + enabledBy { login } + } + } + } + } + `; + const data = await this.http.githubGraphQL<{ + disablePullRequestAutoMerge: { pullRequest: GitHubAutoMergeResult }; + }>(mutation, { pullRequestId: nodeId }); + return data.disablePullRequestAutoMerge.pullRequest; + } + + async enqueuePR(opts: EnqueuePROpts): Promise { + const nodeId = await this.getPRNodeId(opts.owner, opts.repo, opts.pullNumber); + const mutation = ` + mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID, $mergeMethod: PullRequestMergeMethod) { + addPullRequestToMergeQueue(input: { + pullRequestId: $pullRequestId + expectedHeadOid: $expectedHeadOid + mergeMethod: $mergeMethod + }) { + mergeQueue { + url + } + } + } + `; + const variables: Record = { pullRequestId: nodeId }; + if (opts.expectedHeadOid) variables.expectedHeadOid = opts.expectedHeadOid; + if (opts.mergeMethod) variables.mergeMethod = opts.mergeMethod; + + const data = await this.http.githubGraphQL<{ + addPullRequestToMergeQueue: { mergeQueue: { url: string } }; + }>(mutation, variables); + return { mergeQueueUrl: data.addPullRequestToMergeQueue.mergeQueue.url }; + } + + // ── PR status aggregation ────────────────────────────────────────── + + async getPRStatus(owner: string, repo: string, pullNumber: number): Promise { + const query = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + number + title + state + isDraft + merged + mergeable + mergeStateStatus + reviewDecision + autoMergeRequest { + mergeMethod + enabledAt + enabledBy { login } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + contexts(first: 100) { + nodes { + ... on CheckRun { + __typename + name + status + conclusion + } + ... on StatusContext { + __typename + context + state + } + } + } + } + } + } + } + } + } + } + `; + type GraphQLCheckNode = + | { __typename: 'CheckRun'; name: string; status: string; conclusion: string | null } + | { __typename: 'StatusContext'; context: string; state: string }; + type GraphQLPR = { + number: number; + title: string; + state: 'OPEN' | 'CLOSED' | 'MERGED'; + isDraft: boolean; + merged: boolean; + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeStateStatus: string; + reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null; + autoMergeRequest: { + mergeMethod: string; + enabledAt: string; + enabledBy: { login: string }; + } | null; + commits: { + nodes: Array<{ + commit: { + statusCheckRollup: { + contexts: { nodes: GraphQLCheckNode[] }; + } | null; + }; + }>; + }; + }; + + const data = await this.http.githubGraphQL<{ + repository: { pullRequest: GraphQLPR }; + }>(query, { owner, repo, number: pullNumber }); + + const pr = data.repository.pullRequest; + const checkNodes = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []; + + const details = checkNodes.map(n => { + if (n.__typename === 'CheckRun') { + return { name: n.name, status: n.status, conclusion: n.conclusion }; + } + return { name: n.context, status: n.state, conclusion: null }; + }); + + const passed = details.filter(d => d.conclusion === 'success' || d.status === 'success').length; + const failed = details.filter(d => + d.conclusion === 'failure' || d.conclusion === 'timed_out' || + d.conclusion === 'action_required' || d.status === 'failure' || d.status === 'error' + ).length; + const pending = details.filter(d => + d.status === 'queued' || d.status === 'in_progress' || d.status === 'pending' + ).length; + + // Conclusions like skipped/cancelled/neutral are neither passing, failing, + // nor pending — bucket them separately so the four counts sum to `total`. + const other = details.length - passed - failed - pending; + + return { + number: pr.number, + title: pr.title, + state: pr.state, + isDraft: pr.isDraft, + merged: pr.merged, + mergeable: pr.mergeable, + mergeStateStatus: pr.mergeStateStatus, + reviewDecision: pr.reviewDecision, + autoMergeRequest: pr.autoMergeRequest + ? { + mergeMethod: pr.autoMergeRequest.mergeMethod, + enabledAt: pr.autoMergeRequest.enabledAt, + enabledBy: pr.autoMergeRequest.enabledBy.login + } + : null, + checks: { total: details.length, passed, failed, pending, other, details } + }; + } + + // ── Draft / ready-for-review ─────────────────────────────────────── + + async convertToDraft(owner: string, repo: string, pullNumber: number): Promise<{ number: number; isDraft: boolean }> { + const nodeId = await this.getPRNodeId(owner, repo, pullNumber); + const mutation = ` + mutation($pullRequestId: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { + pullRequest { number isDraft } + } + } + `; + const data = await this.http.githubGraphQL<{ + convertPullRequestToDraft: { pullRequest: { number: number; isDraft: boolean } }; + }>(mutation, { pullRequestId: nodeId }); + return data.convertPullRequestToDraft.pullRequest; + } + + async markReadyForReview(owner: string, repo: string, pullNumber: number): Promise<{ number: number; isDraft: boolean }> { + const nodeId = await this.getPRNodeId(owner, repo, pullNumber); + const mutation = ` + mutation($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { + pullRequest { number isDraft } + } + } + `; + const data = await this.http.githubGraphQL<{ + markPullRequestReadyForReview: { pullRequest: { number: number; isDraft: boolean } }; + }>(mutation, { pullRequestId: nodeId }); + return data.markPullRequestReadyForReview.pullRequest; + } + + // ── Reopen ───────────────────────────────────────────────────────── + + async reopenPR(owner: string, repo: string, pullNumber: number): Promise { + return this.updatePR({ owner, repo, pullNumber, state: 'open' }); + } + + // ── Labels ───────────────────────────────────────────────────────── + + async addLabels(owner: string, repo: string, issueNumber: number, labels: string[]): Promise<{ labels: GitHubLabel[] }> { + return this.http.github<{ labels: GitHubLabel[] }>( + `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, + { method: 'POST', body: { labels } } + ); + } + + async removeLabel(owner: string, repo: string, issueNumber: number, label: string): Promise { + // Pass the label name unencoded — buildUrl() uses new URL() which handles + // percent-encoding of spaces and special characters in the path segment. + await this.http.github( + `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${label}`, + { method: 'DELETE' } + ); + } + + // ── Reviewers ────────────────────────────────────────────────────── + + async addReviewers(opts: ReviewersOpts): Promise<{ url: string; users: GitHubUser[]; teams: unknown[] }> { + const body: Record = {}; + if (opts.reviewers?.length) body.reviewers = opts.reviewers; + if (opts.teamReviewers?.length) body.team_reviewers = opts.teamReviewers; + return this.http.github<{ url: string; users: GitHubUser[]; teams: unknown[] }>( + `/repos/${opts.owner}/${opts.repo}/pulls/${opts.pullNumber}/requested_reviewers`, + { method: 'POST', body } + ); + } + + async removeReviewers(opts: ReviewersOpts): Promise { + const body: Record = {}; + if (opts.reviewers?.length) body.reviewers = opts.reviewers; + if (opts.teamReviewers?.length) body.team_reviewers = opts.teamReviewers; + await this.http.github( + `/repos/${opts.owner}/${opts.repo}/pulls/${opts.pullNumber}/requested_reviewers`, + { method: 'DELETE', body } + ); + } + + // ── Assignees ───────────────────────────────────────────────────── + + async addAssignees(owner: string, repo: string, issueNumber: number, assignees: string[]): Promise { + return this.http.github( + `/repos/${owner}/${repo}/issues/${issueNumber}/assignees`, + { method: 'POST', body: { assignees } } + ); + } + + async removeAssignees(owner: string, repo: string, issueNumber: number, assignees: string[]): Promise { + return this.http.github( + `/repos/${owner}/${repo}/issues/${issueNumber}/assignees`, + { method: 'DELETE', body: { assignees } } + ); + } } diff --git a/src/services/github/commands.ts b/src/services/github/commands.ts index a294dcd..acfd515 100644 --- a/src/services/github/commands.ts +++ b/src/services/github/commands.ts @@ -188,7 +188,7 @@ export function registerGitHubCommands(program: Command): void { }); gh.command('merge-pr') - .description('Merge a pull request') + .description('Merge a pull request (immediate merge — use enable-auto-merge for auto-merge)') .requiredOption('--number ', 'Pull request number') .addOption(new Option('--method ', 'Merge method').choices(['merge', 'squash', 'rebase']).default('merge')) .option('--commit-title ', 'Commit title for merge/squash') @@ -209,6 +209,58 @@ export function registerGitHubCommands(program: Command): void { } catch (err) { fail(err, 'github', 'merge-pr', start); } }); + gh.command('enable-auto-merge') + .description('Enable auto-merge on a pull request (merges automatically when all requirements are met)') + .requiredOption('--number <n>', 'Pull request number') + .addOption(new Option('--method <method>', 'Merge method').choices(['merge', 'squash', 'rebase'])) + .option('--match-head-commit <sha>', 'Only enable if the PR head matches this SHA (prevents race conditions)') + .action(async (opts: { number: string; method?: string; matchHeadCommit?: string }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + const data = await client.enableAutoMerge({ + owner, + repo, + pullNumber: parseInt(opts.number, 10), + mergeMethod: opts.method?.toUpperCase() as 'MERGE' | 'SQUASH' | 'REBASE' | undefined, + expectedHeadOid: opts.matchHeadCommit + }); + success(data, 'github', 'enable-auto-merge', start); + } catch (err) { fail(err, 'github', 'enable-auto-merge', start); } + }); + + gh.command('disable-auto-merge') + .description('Disable auto-merge on a pull request') + .requiredOption('--number <n>', 'Pull request number') + .action(async (opts: { number: string }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + const data = await client.disableAutoMerge(owner, repo, parseInt(opts.number, 10)); + success(data, 'github', 'disable-auto-merge', start); + } catch (err) { fail(err, 'github', 'disable-auto-merge', start); } + }); + + gh.command('enqueue-pr') + .description('Add a pull request to the repository merge queue') + .requiredOption('--number <n>', 'Pull request number') + .addOption(new Option('--method <method>', 'Merge method').choices(['merge', 'squash', 'rebase'])) + .option('--match-head-commit <sha>', 'Only enqueue if the PR head matches this SHA (prevents race conditions)') + .action(async (opts: { number: string; method?: string; matchHeadCommit?: string }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + const data = await client.enqueuePR({ + owner, + repo, + pullNumber: parseInt(opts.number, 10), + mergeMethod: opts.method?.toUpperCase() as 'MERGE' | 'SQUASH' | 'REBASE' | undefined, + expectedHeadOid: opts.matchHeadCommit + }); + success(data, 'github', 'enqueue-pr', start); + } catch (err) { fail(err, 'github', 'enqueue-pr', start); } + }); + gh.command('close-pr') .description('Close a pull request') .requiredOption('--number <n>', 'Pull request number') @@ -463,4 +515,159 @@ export function registerGitHubCommands(program: Command): void { success(data, 'github', 'list-checks', start); } catch (err) { fail(err, 'github', 'list-checks', start); } }); + + gh.command('pr-status') + .description('Aggregated PR status: review decision, checks, mergeability, draft state, and auto-merge') + .requiredOption('--number <n>', 'Pull request number') + .action(async (opts: { number: string }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + const data = await client.getPRStatus(owner, repo, parseInt(opts.number, 10)); + success(data, 'github', 'pr-status', start); + } catch (err) { fail(err, 'github', 'pr-status', start); } + }); + + gh.command('convert-to-draft') + .description('Convert an open pull request to a draft') + .requiredOption('--number <n>', 'Pull request number') + .action(async (opts: { number: string }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + const data = await client.convertToDraft(owner, repo, parseInt(opts.number, 10)); + success(data, 'github', 'convert-to-draft', start); + } catch (err) { fail(err, 'github', 'convert-to-draft', start); } + }); + + gh.command('ready-for-review') + .description('Mark a draft pull request as ready for review') + .requiredOption('--number <n>', 'Pull request number') + .action(async (opts: { number: string }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + const data = await client.markReadyForReview(owner, repo, parseInt(opts.number, 10)); + success(data, 'github', 'ready-for-review', start); + } catch (err) { fail(err, 'github', 'ready-for-review', start); } + }); + + gh.command('reopen-pr') + .description('Reopen a closed pull request') + .requiredOption('--number <n>', 'Pull request number') + .action(async (opts: { number: string }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + const data = await client.reopenPR(owner, repo, parseInt(opts.number, 10)); + success(data, 'github', 'reopen-pr', start); + } catch (err) { fail(err, 'github', 'reopen-pr', start); } + }); + + // ── Label management ─────────────────────────────────────────────── + + gh.command('add-labels') + .description('Add labels to a pull request or issue') + .requiredOption('--number <n>', 'PR or issue number') + .option('--label <label>', 'Label to add (repeatable)', (v: string, acc: string[]) => { acc.push(v); return acc; }, [] as string[]) + .action(async (opts: { number: string; label: string[] }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + if (!opts.label.length) throw new PncliError('At least one --label is required', 1); + const data = await client.addLabels(owner, repo, parseInt(opts.number, 10), opts.label); + success(data, 'github', 'add-labels', start); + } catch (err) { fail(err, 'github', 'add-labels', start); } + }); + + gh.command('remove-label') + .description('Remove a label from a pull request or issue') + .requiredOption('--number <n>', 'PR or issue number') + .requiredOption('--label <label>', 'Label name to remove') + .action(async (opts: { number: string; label: string }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + await client.removeLabel(owner, repo, parseInt(opts.number, 10), opts.label); + success({ removed: true, label: opts.label }, 'github', 'remove-label', start); + } catch (err) { fail(err, 'github', 'remove-label', start); } + }); + + // ── Reviewer management ──────────────────────────────────────────── + + gh.command('add-reviewers') + .description('Request reviewers on a pull request') + .requiredOption('--number <n>', 'Pull request number') + .option('--reviewer <login>', 'User reviewer login (repeatable)', (v: string, acc: string[]) => { acc.push(v); return acc; }, [] as string[]) + .option('--team-reviewer <slug>', 'Team slug to request (repeatable)', (v: string, acc: string[]) => { acc.push(v); return acc; }, [] as string[]) + .action(async (opts: { number: string; reviewer: string[]; teamReviewer: string[] }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + if (!opts.reviewer.length && !opts.teamReviewer.length) { + throw new PncliError('At least one --reviewer or --team-reviewer is required', 1); + } + const data = await client.addReviewers({ + owner, + repo, + pullNumber: parseInt(opts.number, 10), + reviewers: opts.reviewer.length ? opts.reviewer : undefined, + teamReviewers: opts.teamReviewer.length ? opts.teamReviewer : undefined + }); + success(data, 'github', 'add-reviewers', start); + } catch (err) { fail(err, 'github', 'add-reviewers', start); } + }); + + gh.command('remove-reviewers') + .description('Remove requested reviewers from a pull request') + .requiredOption('--number <n>', 'Pull request number') + .option('--reviewer <login>', 'User reviewer login to remove (repeatable)', (v: string, acc: string[]) => { acc.push(v); return acc; }, [] as string[]) + .option('--team-reviewer <slug>', 'Team slug to remove (repeatable)', (v: string, acc: string[]) => { acc.push(v); return acc; }, [] as string[]) + .action(async (opts: { number: string; reviewer: string[]; teamReviewer: string[] }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + if (!opts.reviewer.length && !opts.teamReviewer.length) { + throw new PncliError('At least one --reviewer or --team-reviewer is required', 1); + } + await client.removeReviewers({ + owner, + repo, + pullNumber: parseInt(opts.number, 10), + reviewers: opts.reviewer.length ? opts.reviewer : undefined, + teamReviewers: opts.teamReviewer.length ? opts.teamReviewer : undefined + }); + success({ removed: true }, 'github', 'remove-reviewers', start); + } catch (err) { fail(err, 'github', 'remove-reviewers', start); } + }); + + // ── Assignee management ──────────────────────────────────────────── + + gh.command('add-assignees') + .description('Add assignees to a pull request or issue') + .requiredOption('--number <n>', 'PR or issue number') + .option('--assignee <login>', 'Assignee login (repeatable)', (v: string, acc: string[]) => { acc.push(v); return acc; }, [] as string[]) + .action(async (opts: { number: string; assignee: string[] }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + if (!opts.assignee.length) throw new PncliError('At least one --assignee is required', 1); + const data = await client.addAssignees(owner, repo, parseInt(opts.number, 10), opts.assignee); + success(data, 'github', 'add-assignees', start); + } catch (err) { fail(err, 'github', 'add-assignees', start); } + }); + + gh.command('remove-assignees') + .description('Remove assignees from a pull request or issue') + .requiredOption('--number <n>', 'PR or issue number') + .option('--assignee <login>', 'Assignee login to remove (repeatable)', (v: string, acc: string[]) => { acc.push(v); return acc; }, [] as string[]) + .action(async (opts: { number: string; assignee: string[] }) => { + const start = Date.now(); + try { + const { client, owner, repo } = getClient(gh); + if (!opts.assignee.length) throw new PncliError('At least one --assignee is required', 1); + const data = await client.removeAssignees(owner, repo, parseInt(opts.number, 10), opts.assignee); + success(data, 'github', 'remove-assignees', start); + } catch (err) { fail(err, 'github', 'remove-assignees', start); } + }); } diff --git a/src/types/github.ts b/src/types/github.ts index 6ee94f4..2ad1678 100644 --- a/src/types/github.ts +++ b/src/types/github.ts @@ -28,6 +28,8 @@ export interface GitHubRef { export interface GitHubPR { number: number; + /** GraphQL node ID — used for mutations (enablePullRequestAutoMerge, etc.) */ + node_id: string; title: string; body?: string; state: 'open' | 'closed'; @@ -49,6 +51,54 @@ export interface GitHubPR { additions?: number; deletions?: number; changed_files?: number; + /** Present when auto-merge is enabled */ + auto_merge?: { + merge_method: 'merge' | 'squash' | 'rebase'; + commit_title?: string; + commit_message?: string; + } | null; +} + +export interface GitHubAutoMergeResult { + number: number; + autoMergeRequest: { + mergeMethod: 'MERGE' | 'SQUASH' | 'REBASE'; + enabledAt: string; + enabledBy: { login: string }; + } | null; +} + +export interface GitHubMergeQueueResult { + mergeQueueUrl: string; +} + +export interface GitHubPRStatusResult { + number: number; + title: string; + state: 'OPEN' | 'CLOSED' | 'MERGED'; + isDraft: boolean; + merged: boolean; + mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeStateStatus: string; + reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null; + autoMergeRequest: { + mergeMethod: string; + enabledAt: string; + enabledBy: string; + } | null; + checks: { + total: number; + passed: number; + failed: number; + pending: number; + /** Checks that are neither passing, failing, nor pending (skipped, cancelled, neutral). */ + other: number; + details: Array<{ + name: string; + status: string; + conclusion: string | null; + }>; + }; } export interface GitHubComment {