diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index 669fa7503d7..36ebd9459cc 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -46,7 +46,7 @@ vi.mock('./rate-limit', () => ({ noteRateLimitSpend: noteRateLimitSpendMock })) -import { getPRChecks, _resetOwnerRepoCache } from './client' +import { getPRChecks, rerunPRChecks, _resetOwnerRepoCache } from './client' describe('getPRChecks', () => { beforeEach(() => { @@ -91,7 +91,36 @@ describe('getPRChecks', () => { name: 'build', status: 'completed', conclusion: 'success', - url: 'https://github.com/acme/widgets/actions/runs/1' + url: 'https://github.com/acme/widgets/actions/runs/1', + workflowRunId: 1 + } + ]) + }) + + it('falls back to gh pr checks when the head SHA has no check runs', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ check_runs: [] }) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { name: 'verify', state: 'PENDING', link: 'https://example.com/verify' } + ]) + }) + + const checks = await getPRChecks('/repo-root', 42, 'head-oid') + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'], + { cwd: '/repo-root' } + ) + expect(checks).toEqual([ + { + name: 'verify', + status: 'queued', + conclusion: 'pending', + url: 'https://example.com/verify', + workflowRunId: undefined } ]) }) @@ -116,8 +145,33 @@ describe('getPRChecks', () => { name: 'lint', status: 'completed', conclusion: 'success', - url: 'https://example.com/lint' + url: 'https://example.com/lint', + workflowRunId: undefined } ]) }) + + it('reruns GitHub Actions checks for a PR', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + name: 'lint', + state: 'FAIL', + link: 'https://github.com/acme/widgets/actions/runs/77/job/88' + } + ]) + }) + .mockResolvedValueOnce({ stdout: '' }) + + const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true }) + + expect(result).toEqual({ ok: true, count: 1 }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['api', '-X', 'POST', 'repos/acme/widgets/actions/runs/77/rerun-failed-jobs'], + { cwd: '/repo-root', env: { ...process.env, GH_PROMPT_DISABLED: '1' } } + ) + }) }) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index f68e466db41..01a787393ad 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -155,6 +155,10 @@ describe('listWorkItems', () => { ], { cwd: '/repo-root' } ) + const prListFields = ghExecFileAsyncMock.mock.calls[1][0].join(',') + expect(prListFields).not.toContain('statusCheckRollup') + expect(prListFields).not.toContain('reviewRequests') + expect(prListFields).not.toContain('mergeStateStatus') expect(items).toEqual([ { id: 'issue:12', diff --git a/src/main/github/client.ts b/src/main/github/client.ts index b02aa6fdf80..4490933b174 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -12,7 +12,9 @@ import type { GitHubPRReviewCommentInput, PRComment, GitHubViewer, - GitHubWorkItem + GitHubWorkItem, + GitHubPullRequestStateUpdate, + GitHubRerunPRChecksResult } from '../../shared/types' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' import { @@ -226,6 +228,15 @@ export async function getAuthenticatedViewer(): Promise { // single-repo and cross-repo items are uniform downstream. type MainWorkItem = Omit +const WORK_ITEM_PR_LIST_JSON_FIELDS = + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner' + +// Why: these fields are intentionally excluded from `gh pr list` because +// statusCheckRollup/review/merge metadata fan out into expensive GraphQL work +// across every row. Fetch them only for single-PR detail surfaces. +const WORK_ITEM_PR_DETAIL_JSON_FIELDS = + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,maintainerCanModify' + function mapIssueWorkItem(item: Record): MainWorkItem { return { id: `issue:${String(item.number)}`, @@ -277,6 +288,142 @@ function extractHeadOwnerLogin(item: Record): string | null { return null } +function userFromUnknown( + value: unknown +): { login: string; name: string | null; avatarUrl: string } | null { + if (typeof value === 'string') { + const login = value.trim() + return login ? { login, name: null, avatarUrl: '' } : null + } + if (typeof value !== 'object' || value === null) { + return null + } + const raw = value as Record + const login = typeof raw.login === 'string' ? raw.login.trim() : '' + if (!login) { + return null + } + return { + login, + name: typeof raw.name === 'string' ? raw.name : null, + avatarUrl: typeof raw.avatarUrl === 'string' ? raw.avatarUrl : '' + } +} + +function usersFromUnknown( + value: unknown +): { login: string; name: string | null; avatarUrl: string }[] { + if (!Array.isArray(value)) { + return [] + } + const users: { login: string; name: string | null; avatarUrl: string }[] = [] + for (const entry of value) { + const direct = userFromUnknown(entry) + if (direct) { + users.push(direct) + continue + } + if (typeof entry === 'object' && entry !== null) { + const raw = entry as Record + const nested = userFromUnknown(raw.requestedReviewer ?? raw.user ?? raw.author) + if (nested) { + users.push(nested) + } + } + } + return users +} + +function latestReviewsFromUnknown(value: unknown): NonNullable { + if (!Array.isArray(value)) { + return [] + } + const reviews: NonNullable = [] + for (const entry of value) { + if (typeof entry !== 'object' || entry === null) { + continue + } + const raw = entry as Record + const author = userFromUnknown(raw.author) + if (!author) { + continue + } + reviews.push({ + login: author.login, + state: typeof raw.state === 'string' ? raw.state : null, + avatarUrl: author.avatarUrl + }) + } + return reviews +} + +function numberFromUnknown(value: unknown): number | undefined { + const number = typeof value === 'number' ? value : Number(value) + return Number.isFinite(number) ? number : undefined +} + +function normalizePRMergeable(value: unknown): PRMergeableState | undefined { + const raw = typeof value === 'string' ? value.toUpperCase() : '' + if (raw === 'MERGEABLE' || raw === 'CONFLICTING' || raw === 'UNKNOWN') { + return raw + } + if (typeof value === 'boolean') { + return value ? 'MERGEABLE' : 'CONFLICTING' + } + return undefined +} + +function checkRollupEntries(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value + } + if (typeof value !== 'object' || value === null) { + return [] + } + const raw = value as Record + const nodes = (raw.contexts as { nodes?: unknown } | undefined)?.nodes + return Array.isArray(nodes) ? nodes : [] +} + +function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSummary'] { + const entries = checkRollupEntries(value) + if (entries.length === 0) { + return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0 } + } + let passed = 0 + let failed = 0 + let pending = 0 + for (const entry of entries) { + if (typeof entry !== 'object' || entry === null) { + pending += 1 + continue + } + const raw = entry as Record + const conclusion = String(raw.conclusion ?? raw.state ?? '').toUpperCase() + const status = String(raw.status ?? '').toUpperCase() + if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(conclusion)) { + passed += 1 + } else if ( + ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes( + conclusion + ) + ) { + failed += 1 + } else if (status === 'COMPLETED' && conclusion) { + failed += 1 + } else { + pending += 1 + } + } + return { + state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success', + total: entries.length, + passed, + failed, + pending + } +} + function mapPullRequestWorkItem( item: Record, baseOwnerLogin: string | null = null @@ -291,13 +438,22 @@ function mapPullRequestWorkItem( // of falsely claiming "not a fork". const isCrossRepository = headOwnerLogin !== null && baseOwnerLogin !== null ? headOwnerLogin !== baseOwnerLogin : null + const state = String(item.state ?? '').toLowerCase() + const additions = numberFromUnknown(item.additions) + const deletions = numberFromUnknown(item.deletions) + const changedFiles = numberFromUnknown( + item.changedFiles ?? + item.changed_files ?? + (item.files as { totalCount?: unknown } | undefined)?.totalCount + ) + const mergeable = normalizePRMergeable(item.mergeable) return { id: `pr:${String(item.number)}`, type: 'pr', number: Number(item.number), title: String(item.title ?? ''), state: - item.state === 'closed' + state === 'closed' ? item.merged_at || item.mergedAt ? 'merged' : 'closed' @@ -329,6 +485,31 @@ function mapPullRequestWorkItem( typeof item.base === 'object' && item.base !== null && 'ref' in item.base ? String((item.base as { ref?: unknown }).ref ?? '') : String(item.baseRefName ?? ''), + ...(additions !== undefined ? { additions } : {}), + ...(deletions !== undefined ? { deletions } : {}), + ...(changedFiles !== undefined ? { changedFiles } : {}), + ...('reviewDecision' in item + ? { reviewDecision: typeof item.reviewDecision === 'string' ? item.reviewDecision : null } + : {}), + ...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined + ? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) } + : {}), + ...(item.latestReviews !== undefined + ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) } + : {}), + ...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}), + ...(item.statusCheckRollup !== undefined + ? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) } + : {}), + ...(mergeable ? { mergeable } : {}), + ...('mergeStateStatus' in item + ? { + mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null + } + : {}), + ...(typeof item.maintainerCanModify === 'boolean' + ? { maintainerCanModify: item.maintainerCanModify } + : {}), ...(isCrossRepository !== null ? { isCrossRepository } : {}) } } @@ -375,13 +556,7 @@ async function fetchPullRequestWorkItem( } const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'view', - String(number), - '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner' - ], + ['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS], ghOptions ) return mapPullRequestWorkItem(JSON.parse(stdout) as Record) @@ -398,7 +573,7 @@ function buildWorkItemListArgs(args: { const fields = kind === 'issue' ? 'number,title,state,url,labels,updatedAt,author' - : 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner' + : WORK_ITEM_PR_LIST_JSON_FIELDS const command = kind === 'issue' ? ['issue', 'list'] : ['pr', 'list'] const out = [...command, '--limit', String(limit), '--json', fields] @@ -520,7 +695,7 @@ async function listRecentWorkItems( '--state', 'open', '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner' + WORK_ITEM_PR_LIST_JSON_FIELDS ], ghOptions ) @@ -604,7 +779,7 @@ async function listRecentWorkItems( '--state', 'open', '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner' + WORK_ITEM_PR_LIST_JSON_FIELDS ], ghOptions ) @@ -1513,6 +1688,21 @@ export async function getPRChecks( ): Promise { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const fallbackToPRChecks = async (): Promise => { + const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link'] + if (ownerRepo) { + fallbackArgs.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions) + const data = JSON.parse(stdout) as { name: string; state: string; link: string }[] + return data.map((d) => ({ + name: d.name, + status: mapCheckStatus(d.state), + conclusion: mapCheckConclusion(d.state), + url: d.link || null, + workflowRunId: parseActionsRunId(d.link) + })) + } await acquire() try { if (ownerRepo && headSha) { @@ -1530,6 +1720,7 @@ export async function getPRChecks( ) const data = JSON.parse(stdout) as { check_runs: { + id?: number name: string status: string conclusion: string | null @@ -1537,11 +1728,16 @@ export async function getPRChecks( details_url: string | null }[] } + if (data.check_runs.length === 0) { + return fallbackToPRChecks() + } return data.check_runs.map((d) => ({ name: d.name, status: mapCheckRunRESTStatus(d.status), conclusion: mapCheckRunRESTConclusion(d.status, d.conclusion), - url: d.details_url || d.html_url || null + url: d.details_url || d.html_url || null, + ...(typeof d.id === 'number' ? { checkRunId: d.id } : {}), + workflowRunId: parseActionsRunId(d.details_url || d.html_url || null) })) } catch (err) { // Why: a PR can outlive the cached head SHA after force-pushes or remote @@ -1550,19 +1746,8 @@ export async function getPRChecks( console.warn('getPRChecks via head SHA failed, falling back to gh pr checks:', err) } } - // Fallback: no branch provided or non-GitHub remote - const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link'] - if (ownerRepo) { - fallbackArgs.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) - } - const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions) - const data = JSON.parse(stdout) as { name: string; state: string; link: string }[] - return data.map((d) => ({ - name: d.name, - status: mapCheckStatus(d.state), - conclusion: mapCheckConclusion(d.state), - url: d.link || null - })) + // Fallback: no branch provided, empty check-runs, or non-GitHub remote. + return fallbackToPRChecks() } catch (err) { console.warn('getPRChecks failed:', err) return [] @@ -1571,6 +1756,98 @@ export async function getPRChecks( } } +function parseActionsRunId(url: string | null | undefined): number | undefined { + if (!url) { + return undefined + } + const match = /\/actions\/runs\/(\d+)(?:\/|$)/.exec(url) + if (!match) { + return undefined + } + const id = Number(match[1]) + return Number.isSafeInteger(id) ? id : undefined +} + +export async function rerunPRChecks( + repoPath: string, + prNumber: number, + options: { headSha?: string; failedOnly?: boolean } = {}, + connectionId?: string | null +): Promise { + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ownerRepo = await getOwnerRepo(repoPath, connectionId) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + + const checks = await getPRChecks( + repoPath, + prNumber, + options.headSha, + { noCache: true }, + connectionId + ) + const candidates = options.failedOnly + ? checks.filter((check) => + ['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '') + ) + : checks + const workflowRunIds = new Set( + candidates + .map((check) => check.workflowRunId ?? parseActionsRunId(check.url)) + .filter((id): id is number => typeof id === 'number') + ) + const checkRunIds = new Set( + candidates + .filter((check) => !check.workflowRunId && !parseActionsRunId(check.url)) + .map((check) => check.checkRunId) + .filter((id): id is number => typeof id === 'number') + ) + + if (workflowRunIds.size === 0 && checkRunIds.size === 0) { + return { + ok: false, + error: options.failedOnly + ? 'No failed GitHub Actions checks to rerun.' + : 'No rerunnable checks found.' + } + } + + let count = 0 + await acquire() + try { + for (const runId of workflowRunIds) { + const endpoint = options.failedOnly + ? `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun-failed-jobs` + : `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun` + await ghExecFileAsync(['api', '-X', 'POST', endpoint], { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + count += 1 + } + for (const checkRunId of checkRunIds) { + await ghExecFileAsync( + [ + 'api', + '-X', + 'POST', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${checkRunId}/rerequest` + ], + { ...ghOptions, env: { ...process.env, GH_PROMPT_DISABLED: '1' } } + ) + count += 1 + } + return { ok: true, count } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: classifyGhError(message).message } + } finally { + release() + } +} + // Why: review thread resolution status and thread IDs are only available via // GraphQL. The REST pulls/{n}/comments endpoint does not expose them, so we // use GraphQL for review threads and REST for issue-level comments. @@ -2095,6 +2372,74 @@ export async function mergePR( } } +export async function updatePRState( + repoPath: string, + prNumber: number, + updates: GitHubPullRequestStateUpdate, + connectionId?: string | null +): Promise<{ ok: true } | { ok: false; error: string }> { + const context = githubRepoContext(repoPath, connectionId) + const ghOptions = ghRepoExecOptions(context) + const ownerRepo = await getOwnerRepo(repoPath, connectionId) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + + await acquire() + try { + await ghExecFileAsync( + [ + 'api', + '-X', + 'PATCH', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`, + '--raw-field', + `state=${updates.state}` + ], + ghOptions + ) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: classifyGhError(message).message } + } finally { + release() + } +} + +export async function requestPRReviewers( + repoPath: string, + prNumber: number, + reviewers: string[], + connectionId?: string | null +): Promise<{ ok: true } | { ok: false; error: string }> { + const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) + if (logins.length === 0) { + return { ok: false, error: 'Enter at least one reviewer login' } + } + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ownerRepo = await getOwnerRepo(repoPath, connectionId) + await acquire() + try { + const args = ['pr', 'edit', String(prNumber), '--add-reviewer', logins.join(',')] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: message } + } finally { + release() + } +} + /** * Update a PR's title. */ diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index cd4e0944fc9..2c9e21dc2aa 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -323,6 +323,10 @@ export async function updatePullRequestBySlug( patchArgs.push('--raw-field', `body=${args.updates.body}`) fieldCount++ } + if (args.updates.state !== undefined) { + patchArgs.push('--raw-field', `state=${args.updates.state}`) + fieldCount++ + } if (fieldCount === 0) { // No fields to update — nothing to do. return { ok: true } diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index e36c8f767c8..23fba6f78a4 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -4,7 +4,7 @@ reviewable as one surface. Splitting by feature area would risk drifting validation/gate conventions across handler files. */ import { ipcMain, webContents } from 'electron' import { resolve } from 'path' -import type { Repo, GitHubIssueUpdate } from '../../shared/types' +import type { Repo, GitHubIssueUpdate, GitHubPullRequestStateUpdate } from '../../shared/types' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import { @@ -30,6 +30,9 @@ import { addPRReviewCommentReply, updatePRTitle, mergePR, + updatePRState, + rerunPRChecks, + requestPRReviewers, checkOrcaStarred, starOrca } from '../github/client' @@ -506,6 +509,79 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) + ipcMain.handle( + 'gh:updatePRState', + async ( + event, + args: { repoPath: string; prNumber: number; updates: GitHubPullRequestStateUpdate } + ) => { + const repo = assertRegisteredRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid pull request number' } + } + const result = await updatePRState( + repo.path, + args.prNumber, + args.updates, + repoConnectionId(repo) + ) + if (result.ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle( + 'gh:rerunPRChecks', + async ( + _event, + args: { repoPath: string; prNumber: number; headSha?: string; failedOnly?: boolean } + ) => { + const repo = assertRegisteredRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid pull request number' } + } + return rerunPRChecks( + repo.path, + args.prNumber, + { headSha: args.headSha, failedOnly: args.failedOnly }, + repoConnectionId(repo) + ) + } + ) + + ipcMain.handle( + 'gh:requestPRReviewers', + async (event, args: { repoPath: string; prNumber: number; reviewers: string[] }) => { + const repo = assertRegisteredRepo(args, store) + const result = await requestPRReviewers( + repo.path, + args.prNumber, + args.reviewers, + repoConnectionId(repo) + ) + if (result.ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) + ipcMain.handle( 'gh:updateIssue', async ( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 408e6dbdcfc..7dead60960c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -95,6 +95,7 @@ import { listWorkItems, countWorkItems, getPRChecks, + rerunPRChecks, getPRComments, getIssue, resolveReviewThread, @@ -102,6 +103,8 @@ import { getWorkItemByOwnerRepo, updatePRTitle, mergePR, + updatePRState, + requestPRReviewers, createIssue, updateIssue, addIssueComment, @@ -114,6 +117,7 @@ import { getWorkItemDetails, getPRFileContents } from '../github/work-item-detai import { getRateLimit } from '../github/rate-limit' import type { GitHubIssueUpdate, + GitHubPullRequestStateUpdate, GitHubPRFile, GitHubPRReviewCommentInput } from '../../shared/types' @@ -4702,6 +4706,16 @@ export class OrcaRuntimeService { return getPRChecks(repo.path, prNumber, headSha, options) } + async rerunRepoPRChecks( + repoSelector: string, + prNumber: number, + options?: { headSha?: string; failedOnly?: boolean } + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_checks_rerun') + return rerunPRChecks(repo.path, prNumber, options) + } + async getRepoPRComments( repoSelector: string, prNumber: number, @@ -4771,6 +4785,26 @@ export class OrcaRuntimeService { return mergePR(repo.path, prNumber, method) } + async updateRepoPRState( + repoSelector: string, + prNumber: number, + updates: GitHubPullRequestStateUpdate + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_state') + return updatePRState(repo.path, prNumber, updates) + } + + async requestRepoPRReviewers( + repoSelector: string, + prNumber: number, + reviewers: string[] + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_reviewers') + return requestPRReviewers(repo.path, prNumber, reviewers) + } + async createRepoIssue( repoSelector: string, title: string, diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index c72537b2c6f..50ff2038c69 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -281,6 +281,25 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) + it('updates PR state on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepoPRState: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.updatePRState', { + repo: 'repo-1', + prNumber: 7, + updates: { state: 'closed' } + }) + ) + + expect(runtime.updateRepoPRState).toHaveBeenCalledWith('repo-1', 7, { state: 'closed' }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + it('creates issues on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 0aee0943201..60e6203e124 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -62,6 +62,11 @@ const PullRequestChecks = PullRequest.extend({ headSha: OptionalString }) +const RerunPullRequestChecks = PullRequest.extend({ + headSha: OptionalString, + failedOnly: z.boolean().optional() +}) + const PullRequestFileContents = RepoSelector.extend({ prNumber: z.number().int().positive(), path: requiredString('Missing file path'), @@ -92,6 +97,18 @@ const MergePr = RepoSelector.extend({ method: z.enum(['merge', 'squash', 'rebase']).optional() }) +const UpdatePrState = RepoSelector.extend({ + prNumber: z.number().int().positive(), + updates: z.object({ + state: z.enum(['open', 'closed']) + }) +}) + +const RequestPrReviewers = RepoSelector.extend({ + prNumber: z.number().int().positive(), + reviewers: z.array(z.string()).min(1) +}) + const CreateIssue = RepoSelector.extend({ title: requiredString('Missing title'), body: z.string() @@ -188,6 +205,7 @@ const SlugPullRequestUpdate = z.object({ repo: requiredString('Missing repo'), number: z.number().int().positive(), updates: z.object({ + state: z.enum(['open', 'closed']).optional(), title: OptionalString, body: OptionalString }) @@ -294,6 +312,15 @@ export const GITHUB_METHODS: RpcMethod[] = [ noCache: params.noCache }) }), + defineMethod({ + name: 'github.rerunPRChecks', + params: RerunPullRequestChecks, + handler: async (params, { runtime }) => + runtime.rerunRepoPRChecks(params.repo, params.prNumber, { + headSha: params.headSha, + failedOnly: params.failedOnly + }) + }), defineMethod({ name: 'github.prComments', params: PullRequest, @@ -341,6 +368,18 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.mergeRepoPR(params.repo, params.prNumber, params.method) }), + defineMethod({ + name: 'github.updatePRState', + params: UpdatePrState, + handler: async (params, { runtime }) => + runtime.updateRepoPRState(params.repo, params.prNumber, params.updates) + }), + defineMethod({ + name: 'github.requestPRReviewers', + params: RequestPrReviewers, + handler: async (params, { runtime }) => + runtime.requestRepoPRReviewers(params.repo, params.prNumber, params.reviewers) + }), defineMethod({ name: 'github.createIssue', params: CreateIssue, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 667dfc0dbd1..cb542b32de2 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -799,6 +799,13 @@ export type PreloadApi = { headSha?: string noCache?: boolean }) => Promise + rerunPRChecks: (args: { + repoPath: string + repoId?: string + prNumber: number + headSha?: string + failedOnly?: boolean + }) => Promise<{ ok: true; count: number } | { ok: false; error: string }> prComments: (args: { repoPath: string repoId?: string @@ -831,6 +838,18 @@ export type PreloadApi = { prNumber: number method?: 'merge' | 'squash' | 'rebase' }) => Promise<{ ok: true } | { ok: false; error: string }> + updatePRState: (args: { + repoPath: string + repoId?: string + prNumber: number + updates: { state: 'open' | 'closed' } + }) => Promise<{ ok: true } | { ok: false; error: string }> + requestPRReviewers: (args: { + repoPath: string + repoId?: string + prNumber: number + reviewers: string[] + }) => Promise<{ ok: true } | { ok: false; error: string }> updateIssue: (args: { repoPath: string repoId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 0bad7d8aa8d..af201cd9c57 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -817,6 +817,15 @@ const api = { noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prChecks', args), + rerunPRChecks: (args: { + repoPath: string + repoId?: string + prNumber: number + headSha?: string + failedOnly?: boolean + }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:rerunPRChecks', args), + prComments: (args: { repoPath: string repoId?: string @@ -855,6 +864,22 @@ const api = { }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args), + updatePRState: (args: { + repoPath: string + repoId?: string + prNumber: number + updates: { state: 'open' | 'closed' } + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:updatePRState', args), + + requestPRReviewers: (args: { + repoPath: string + repoId?: string + prNumber: number + reviewers: string[] + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:requestPRReviewers', args), + updateIssue: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index b0492fd750b..0659d0d1a90 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -350,6 +350,18 @@ height: 0; } +.project-view-tab-strip { + /* Why: project views load after the table shell; keeping the row stable + prevents the list below from jumping when the tabs arrive. */ + -ms-overflow-style: none; + scrollbar-width: none; +} + +.project-view-tab-strip::-webkit-scrollbar { + width: 0; + height: 0; +} + /* Tab activity affordance (amber wash) is rendered as a React DOM child in SortableTab.tsx rather than via ::after here, so the drop-indicator ::before/::after pseudo-elements stay free for drag-and-drop feedback. See diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 9f1b3f6d172..92f035a7cc8 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -25,11 +25,13 @@ import { FileText, Folder, FolderOpen, + GitMerge, GitPullRequest, LayoutList, LoaderCircle, MessageSquare, MessageSquarePlus, + RefreshCw, Send, UndoDot, X @@ -48,6 +50,12 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { detectLanguage } from '@/lib/language-detect' import { cn } from '@/lib/utils' @@ -85,6 +93,7 @@ import type { GitHubWorkItemDetails, GitHubAssignableUser, GitHubReaction, + PRCheckDetail, PRComment } from '../../../shared/types' import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' @@ -305,6 +314,31 @@ function getStateTone(item: GitHubWorkItem): string { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } +function getPRMergeTooltip(item: GitHubWorkItem): string { + if (item.mergeable === undefined && item.mergeStateStatus === undefined) { + return 'Merge status has not loaded yet' + } + if (item.state === 'merged') { + return 'This pull request is already merged' + } + if (item.state === 'closed') { + return 'This pull request is closed' + } + if (item.mergeable === 'CONFLICTING') { + return 'GitHub reports merge conflicts' + } + if (item.mergeStateStatus === 'BEHIND') { + return 'Update the branch before merging' + } + if (item.mergeStateStatus === 'BLOCKED') { + return 'GitHub reports this pull request is blocked' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'GitHub says this PR can merge' + } + return 'GitHub has not reported a final merge status' +} + function WorkItemStateBadge({ item, className @@ -696,6 +730,19 @@ function patchCachedPRFileViewedState( return previousState } +function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void { + const prev = workItemDetailsCache.get(cacheKey) + if (!prev?.details) { + return + } + touchWorkItemDetailsCache(cacheKey, { + ...prev, + details: { ...prev.details, checks }, + fetchedAt: Date.now(), + error: undefined + }) +} + // Why: install once at module load — every dialog instance shares the cache, // so a single subscription is enough. The preload bridge re-emits the // main-process broadcast for every window, so each renderer invalidates its @@ -1431,11 +1478,17 @@ function ConversationTab({ loading, checks, participants: detailsParticipants, + localState, + onStateChange, + projectOrigin, onUse, + onMutated, + onChecksUpdated, onCommentAdded }: { item: GitHubWorkItem repoPath: string | null + repoId: string | null body: string comments: PRComment[] files: GitHubPRFile[] @@ -1444,7 +1497,12 @@ function ConversationTab({ loading: boolean checks: GitHubWorkItemDetails['checks'] participants: GitHubAssignableUser[] + localState: GitHubWorkItem['state'] + onStateChange: (state: GitHubWorkItem['state']) => void + projectOrigin: GitHubItemDialogProjectOrigin | undefined onUse: (item: GitHubWorkItem) => void + onMutated: () => void + onChecksUpdated: (checks: PRCheckDetail[]) => void onCommentAdded: (comment: PRComment) => void }): React.JSX.Element { const authorLabel = item.author ?? 'unknown' @@ -1527,6 +1585,15 @@ function ConversationTab({ item.type === 'pr' ? (
{startWorkspaceButton} +
) : null @@ -1751,7 +1826,7 @@ function ConversationTab({ ) : comments.length === 0 ? ( -
+
No comments yet.
) : visibleComments.length === 0 ? ( @@ -1780,6 +1855,194 @@ function ConversationTab({ ) } +function PRActionsPanel({ + item, + repoPath, + repoId, + projectOrigin, + localState, + onStateChange, + onMutated +}: { + item: GitHubWorkItem + repoPath: string | null + repoId: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + localState: GitHubWorkItem['state'] + onStateChange: (state: GitHubWorkItem['state']) => void + onMutated: () => void +}): React.JSX.Element { + const [statePending, setStatePending] = useState(false) + const [mergePending, setMergePending] = useState(false) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const actionItem = { ...item, state: localState } + const canMutateState = localState !== 'merged' && (!!repoPath || !!projectOrigin) + const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' + const mergeDisabled = + !repoPath || + mergePending || + localState === 'closed' || + localState === 'merged' || + item.mergeable === 'CONFLICTING' + + const patchProjectRowIfNeeded = useCallback( + (state: GitHubWorkItem['state']) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state }) + }, + [patchProjectRowContent, projectOrigin] + ) + + const applyStatePatch = useCallback( + (state: GitHubWorkItem['state']) => { + onStateChange(state) + patchWorkItem(item.id, { state }) + patchProjectRowIfNeeded(state) + }, + [item.id, onStateChange, patchProjectRowIfNeeded, patchWorkItem] + ) + + const handleStateChange = async (): Promise => { + if (!canMutateState || statePending) { + return + } + const label = nextState === 'closed' ? 'Close' : 'Reopen' + if (!window.confirm(`${label} PR #${item.number}?`)) { + return + } + const previousState = localState + setStatePending(true) + applyStatePatch(nextState) + try { + await runPullRequestStateUpdate({ + repoPath, + repoId, + projectOrigin, + number: item.number, + updates: { state: nextState } + }) + toast.success(nextState === 'closed' ? 'Pull request closed' : 'Pull request reopened') + onMutated() + } catch (err) { + applyStatePatch(previousState) + toast.error(err instanceof Error ? err.message : `Failed to ${label.toLowerCase()} PR`) + } finally { + setStatePending(false) + } + } + + const handleMerge = async (method: 'merge' | 'squash' | 'rebase'): Promise => { + if (!repoPath || mergeDisabled) { + return + } + const label = + method === 'squash' ? 'Squash and merge' : method === 'rebase' ? 'Rebase and merge' : 'Merge' + if (!window.confirm(`${label} PR #${item.number}?`)) { + return + } + setMergePending(true) + try { + const result = await window.api.gh.mergePR({ + repoPath, + repoId: repoId ?? undefined, + prNumber: item.number, + method + }) + if (!result.ok) { + toast.error(result.error) + return + } + applyStatePatch('merged') + toast.success('Pull request merged') + onMutated() + } catch { + toast.error('Failed to merge pull request') + } finally { + setMergePending(false) + } + } + + return ( + + ) +} + function CommentReactions({ reactions }: { @@ -1878,56 +2141,195 @@ function CommentReplyForm({ } function ChecksTab({ + item, + repoPath, + repoId, + headSha, checks, - loading + loading, + onChecksUpdated }: { + item: GitHubWorkItem + repoPath: string | null + repoId: string | null + headSha: string | undefined checks: GitHubWorkItemDetails['checks'] loading: boolean + onChecksUpdated: (checks: PRCheckDetail[]) => void }): React.JSX.Element { - const list = checks ?? [] + const [localChecks, setLocalChecks] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const [rerunning, setRerunning] = useState(false) + const list = localChecks ?? checks ?? [] + const failedChecks = list.filter((check) => + ['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '') + ) + + useEffect(() => { + setLocalChecks(null) + }, [checks]) + + const handleRefresh = useCallback(async (): Promise => { + if (!repoPath) { + toast.error('Unable to refresh checks without a repository path.') + return null + } + setRefreshing(true) + try { + const nextChecks = (await window.api.gh.prChecks({ + repoPath, + repoId: repoId ?? undefined, + prNumber: item.number, + headSha, + noCache: true + })) as PRCheckDetail[] + setLocalChecks(nextChecks) + onChecksUpdated(nextChecks) + return nextChecks + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to refresh checks') + return null + } finally { + setRefreshing(false) + } + }, [headSha, item.number, onChecksUpdated, repoId, repoPath]) + + const handleRerun = useCallback( + async (failedOnly: boolean): Promise => { + if (!repoPath || rerunning) { + return + } + setRerunning(true) + try { + const result = await window.api.gh.rerunPRChecks({ + repoPath, + repoId: repoId ?? undefined, + prNumber: item.number, + headSha, + failedOnly + }) + if (!result.ok) { + toast.error(result.error) + return + } + toast.success(result.count === 1 ? 'Check rerun requested' : 'Check reruns requested') + await handleRefresh() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to rerun checks') + } finally { + setRerunning(false) + } + }, + [handleRefresh, headSha, item.number, rerunning, repoId, repoPath] + ) + + const toolbar = ( +
+ + + + + + Refresh checks + + + + + + + + void handleRerun(true)} + > + + Rerun failed checks + + void handleRerun(false)}> + + Rerun all checks + + + +
+ ) + if (loading && list.length === 0) { return ( -
- -
+ <> + {toolbar} +
+ +
+ ) } if (list.length === 0) { return ( -
- No checks configured. -
+ <> + {toolbar} +
+ No checks found. +
+ ) } return ( -
- {list.map((check) => { - const conclusion = check.conclusion ?? 'pending' - const Icon = CHECK_ICON[conclusion] ?? CircleDashed - const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' - return ( - - ) - })} -
+ <> + {toolbar} +
+ {list.map((check) => { + const conclusion = check.conclusion ?? 'pending' + const Icon = CHECK_ICON[conclusion] ?? CircleDashed + const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' + return ( + + ) + })} +
+ ) } @@ -2112,12 +2514,58 @@ async function runIssueUpdate(args: { if (!args.repoPath) { throw new Error('No repo context available for this edit.') } - await window.api.gh.updateIssue({ + const res = await window.api.gh.updateIssue({ repoPath: args.repoPath, repoId: args.repoId ?? undefined, number: args.number, updates: args.updates }) + if (!res.ok) { + throw new Error(res.error) + } +} + +async function runPullRequestStateUpdate(args: { + repoPath: string | null + repoId?: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + number: number + updates: { state: 'open' | 'closed' } +}): Promise { + if (args.projectOrigin) { + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const updateArgs = { + owner: args.projectOrigin.owner, + repo: args.projectOrigin.repo, + number: args.number, + updates: args.updates + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.project.updatePullRequestBySlug', + updateArgs, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePullRequestBySlug(updateArgs) + if (!res.ok) { + throw new Error(res.error.message) + } + return + } + if (!args.repoPath) { + throw new Error('No repo context available for this pull request.') + } + const res = await window.api.gh.updatePRState({ + repoPath: args.repoPath, + repoId: args.repoId ?? undefined, + prNumber: args.number, + updates: args.updates + }) + if (!res.ok) { + throw new Error(res.error) + } } function GHEditSection({ @@ -3279,6 +3727,7 @@ export default function GitHubItemDialog({ { + if (repoPath) { + invalidateWorkItemDetailsCacheByMatch({ + repoPath, + repoId: effectiveRepoId ?? undefined, + type: workItem.type, + number: workItem.number + }) + } + }} + onChecksUpdated={(nextChecks) => { + if (detailsCacheKey) { + patchCachedPRChecks(detailsCacheKey, nextChecks) + } + }} onCommentAdded={appendOptimisticComment} /> diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index f0160b84a47..2bd9a89ceed 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -5,21 +5,29 @@ place while this surface is still evolving. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { + AlertCircle, ArrowRight, ChevronDown, ChevronLeft, ChevronRight, + CheckCircle2, CircleDot, + Clock3, EllipsisVertical, ExternalLink, + Files, Github, Gitlab, + GitMerge, GitPullRequest, LoaderCircle, Lock, + Minus, Plus, RefreshCw, Search, + UserPlus, + Users, X } from 'lucide-react' import { toast } from 'sonner' @@ -59,6 +67,8 @@ import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/I import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection' import { stripRepoQualifiers } from '../../../shared/task-query' +import { parseGitHubIssueOrPRLink } from '@/lib/github-links' +import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import GitHubItemDialog from '@/components/GitHubItemDialog' import GitLabItemDialog from '@/components/GitLabItemDialog' import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper' @@ -81,6 +91,7 @@ import { } from '@/components/task-page-cache-selectors' import type { GitHubOwnerRepo, + GitHubAssignableUser, GitHubWorkItem, GitLabTodo, GitLabWorkItem, @@ -113,6 +124,7 @@ type TaskQueryPreset = { label: string query: string } +type GitHubTaskKind = 'issues' | 'prs' function getRuntimeTargetForRepoId(repoId: string | null | undefined) { if (!repoId) { @@ -159,13 +171,15 @@ const SOURCE_OPTIONS: SourceOption[] = [ } ] -const TASK_QUERY_PRESETS: TaskQueryPreset[] = [ - { id: 'all', label: 'All', query: getTaskPresetQuery('all') }, - { id: 'issues', label: 'Issues', query: getTaskPresetQuery('issues') }, - { id: 'my-issues', label: 'My Issues', query: getTaskPresetQuery('my-issues') }, - { id: 'review', label: 'Needs My Review', query: getTaskPresetQuery('review') }, - { id: 'prs', label: 'PRs', query: getTaskPresetQuery('prs') }, - { id: 'my-prs', label: 'My PRs', query: getTaskPresetQuery('my-prs') } +const ISSUE_TASK_QUERY_PRESETS: TaskQueryPreset[] = [ + { id: 'issues', label: 'Open', query: getTaskPresetQuery('issues') }, + { id: 'my-issues', label: 'Assigned to me', query: getTaskPresetQuery('my-issues') } +] + +const PR_TASK_QUERY_PRESETS: TaskQueryPreset[] = [ + { id: 'prs', label: 'Open', query: getTaskPresetQuery('prs') }, + { id: 'my-prs', label: 'Mine', query: getTaskPresetQuery('my-prs') }, + { id: 'review', label: 'Needs review', query: getTaskPresetQuery('review') } ] type LinearPresetId = 'assigned' | 'created' | 'all' | 'completed' @@ -181,6 +195,56 @@ const LINEAR_PRESETS: LinearPreset[] = [ const TASK_SEARCH_DEBOUNCE_MS = 300 const LINEAR_ITEM_LIMIT = 36 +const GITHUB_TASK_GRID_CLASS = + 'min-w-[860px] grid-cols-[72px_minmax(260px,2fr)_minmax(130px,0.8fr)_100px_92px_158px]' +const GITHUB_PR_TASK_GRID_CLASS = + 'min-w-[1270px] grid-cols-[72px_minmax(260px,2fr)_minmax(130px,0.8fr)_100px_132px_128px_132px_92px_158px]' + +type GitHubModeButton = { id: GitHubTaskKind | 'project'; label: string } + +const GITHUB_MODE_BUTTONS: GitHubModeButton[] = [ + { id: 'issues', label: 'Issues' }, + { id: 'prs', label: 'PRs' }, + { id: 'project', label: 'Projects' } +] + +function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): boolean { + if (preset === 'prs' || preset === 'my-prs' || preset === 'review') { + return true + } + const normalized = query.toLowerCase() + return /\bis:pr\b/.test(normalized) && !/\bis:issue\b/.test(normalized) +} + +function normalizeGitHubTaskPreset(preset: TaskViewPresetId | null | undefined): TaskViewPresetId { + // Why: the split Issues/PRs tabs no longer have a mixed "All" view, so + // legacy saved defaults should land on the first tab instead of mixing rows. + return !preset || preset === 'all' ? 'issues' : preset +} + +function getGitHubTaskKind(preset: TaskViewPresetId | null, query: string): GitHubTaskKind { + return isPRFocusedTaskView(preset, query) ? 'prs' : 'issues' +} + +function getDefaultPresetForGitHubTaskKind(kind: GitHubTaskKind): TaskViewPresetId { + return kind === 'prs' ? 'prs' : 'issues' +} + +function getGitHubTaskKindPresets(kind: GitHubTaskKind): TaskQueryPreset[] { + return kind === 'prs' ? PR_TASK_QUERY_PRESETS : ISSUE_TASK_QUERY_PRESETS +} + +function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { + const trimmed = query.trim() + if (!trimmed) { + return getTaskPresetQuery(getDefaultPresetForGitHubTaskKind(kind)) + } + if (/\bis:(?:issue|pr)\b/i.test(trimmed)) { + return trimmed + } + return `${kind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` +} + // Why: Intl.RelativeTimeFormat allocation is non-trivial, and previously we // built a new formatter per work-item row render. Hoisting to module scope // means all rows share one instance — zero per-row allocation cost. @@ -380,6 +444,726 @@ function GHStatusCell({ ) } +function formatPRDelta(item: GitHubWorkItem): string | null { + const parts: string[] = [] + if (typeof item.additions === 'number') { + parts.push(`+${item.additions}`) + } + if (typeof item.deletions === 'number') { + parts.push(`-${item.deletions}`) + } + if (typeof item.changedFiles === 'number') { + parts.push(`${item.changedFiles} ${item.changedFiles === 1 ? 'file' : 'files'}`) + } + return parts.length > 0 ? parts.join(' ') : null +} + +function getReviewLabel(item: GitHubWorkItem): string { + if ( + item.reviewDecision === undefined && + item.reviewRequests === undefined && + item.latestReviews === undefined + ) { + return 'Reviewers' + } + if (item.reviewDecision === 'APPROVED') { + return 'Approved' + } + if (item.reviewDecision === 'CHANGES_REQUESTED') { + return 'Changes requested' + } + if (item.reviewRequests && item.reviewRequests.length > 0) { + return `${item.reviewRequests.length} requested` + } + if (item.latestReviews && item.latestReviews.length > 0) { + return `${item.latestReviews.length} reviewed` + } + return 'No reviewers' +} + +function getReviewTone(item: GitHubWorkItem): string { + if (item.reviewDecision === 'APPROVED') { + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' + } + if (item.reviewDecision === 'CHANGES_REQUESTED') { + return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200' + } + if (item.reviewRequests && item.reviewRequests.length > 0) { + return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200' + } + return 'border-border/60 bg-background/70 text-muted-foreground' +} + +function getChecksLabel(item: GitHubWorkItem): string { + const summary = item.checksSummary + if (!summary) { + return 'Checks' + } + if (summary.total === 0) { + return 'No checks' + } + if (summary.failed > 0) { + return `${summary.failed} failing` + } + if (summary.pending > 0) { + return `${summary.pending} pending` + } + return `${summary.passed}/${summary.total} passed` +} + +function getChecksTone(item: GitHubWorkItem): string { + const state = item.checksSummary?.state + if (state === 'success') { + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' + } + if (state === 'failure') { + return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200' + } + if (state === 'pending') { + return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200' + } + return 'border-border/60 bg-background/70 text-muted-foreground' +} + +function getMergeLabel(item: GitHubWorkItem): string { + if (item.mergeable === undefined && item.mergeStateStatus === undefined) { + return 'Merge' + } + if (item.state === 'merged') { + return 'Merged' + } + if (item.state === 'closed') { + return 'Closed' + } + if (item.mergeable === 'CONFLICTING') { + return 'Conflicts' + } + if (item.mergeStateStatus === 'BEHIND') { + return 'Behind' + } + if (item.mergeStateStatus === 'BLOCKED') { + return 'Blocked' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'Able to merge' + } + return 'Unknown' +} + +function getMergeTone(item: GitHubWorkItem): string { + if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') { + return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200' + } + if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') { + return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' + } + return 'border-border/60 bg-background/70 text-muted-foreground' +} + +function getMergeTooltip(item: GitHubWorkItem): string { + if (item.mergeable === undefined && item.mergeStateStatus === undefined) { + return 'Merge status has not loaded yet' + } + if (item.state === 'merged') { + return 'This pull request is already merged' + } + if (item.state === 'closed') { + return 'This pull request is closed' + } + if (item.mergeable === 'CONFLICTING') { + return 'GitHub reports merge conflicts' + } + if (item.mergeStateStatus === 'BEHIND') { + return 'Update the branch before merging' + } + if (item.mergeStateStatus === 'BLOCKED') { + return 'GitHub reports this pull request is blocked' + } + if (item.checksSummary?.state === 'pending') { + return 'GitHub says this PR can merge, but checks are still running' + } + if (item.checksSummary?.state === 'success') { + return 'GitHub says this PR can merge and checks passed' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'GitHub says this PR can merge' + } + return 'GitHub has not reported a final merge status' +} + +function getReviewerInputToken( + value: string, + caret: number +): { start: number; end: number; query: string } { + let start = Math.max(0, Math.min(caret, value.length)) + let end = start + + while (start > 0 && !/[\s,]/.test(value[start - 1])) { + start-- + } + while (end < value.length && !/[\s,]/.test(value[end])) { + end++ + } + + return { + start, + end, + query: value.slice(start, end).replace(/^@/, '').toLowerCase() + } +} + +function mergeReviewerSuggestions( + users: GitHubAssignableUser[], + seedUsers: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of [...seedUsers, ...users]) { + const key = user.login.toLowerCase() + const existing = byLogin.get(key) + if (!existing) { + byLogin.set(key, user) + continue + } + if (!existing.avatarUrl && user.avatarUrl) { + byLogin.set(key, { ...existing, avatarUrl: user.avatarUrl }) + } + } + return Array.from(byLogin.values()).sort((a, b) => a.login.localeCompare(b.login)) +} + +function buildRequestedReviewUsers( + logins: string[], + candidates: GitHubAssignableUser[], + existingRequests: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of existingRequests) { + byLogin.set(user.login.toLowerCase(), user) + } + const candidatesByLogin = new Map(candidates.map((user) => [user.login.toLowerCase(), user])) + for (const login of logins) { + const key = login.toLowerCase() + if (byLogin.has(key)) { + continue + } + byLogin.set(key, candidatesByLogin.get(key) ?? { login, name: null, avatarUrl: '' }) + } + return Array.from(byLogin.values()) +} + +function PRReviewCell({ + item, + repo +}: { + item: GitHubWorkItem + repo: Repo | null +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const [reviewerInput, setReviewerInput] = useState('') + const [localReviewRequests, setLocalReviewRequests] = useState( + () => item.reviewRequests ?? [] + ) + const [reviewerInputCaret, setReviewerInputCaret] = useState(0) + const [reviewerSuggestionsOpen, setReviewerSuggestionsOpen] = useState(false) + const [activeReviewerSuggestionIndex, setActiveReviewerSuggestionIndex] = useState(0) + const [submitting, setSubmitting] = useState(false) + const settings = useAppStore((s) => s.settings) + const reviewerInputRef = useRef(null) + + useEffect(() => { + setLocalReviewRequests(item.reviewRequests ?? []) + }, [item.id, item.reviewRequests]) + + const reviewerSeedUsers = useMemo(() => { + const byLogin = new Map() + const add = (user: GitHubAssignableUser): void => { + if (!user.login) { + return + } + byLogin.set(user.login.toLowerCase(), user) + } + for (const user of localReviewRequests) { + add(user) + } + for (const review of item.latestReviews ?? []) { + add({ + login: review.login, + name: null, + avatarUrl: review.avatarUrl ?? '' + }) + } + if (item.author) { + add({ login: item.author, name: null, avatarUrl: '' }) + } + return Array.from(byLogin.values()) + }, [item.author, item.latestReviews, localReviewRequests]) + + const reviewSlug = useMemo(() => parseGitHubIssueOrPRLink(item.url)?.slug ?? null, [item.url]) + const reviewerMetadata = useRepoAssigneesBySlug( + open && reviewSlug ? reviewSlug.owner : null, + open && reviewSlug ? reviewSlug.repo : null, + reviewerSeedUsers.map((user) => user.login), + settings + ) + + const reviewerCandidates = useMemo( + () => mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers), + [reviewerMetadata.data, reviewerSeedUsers] + ) + const reviewerInputToken = useMemo( + () => getReviewerInputToken(reviewerInput, reviewerInputCaret), + [reviewerInput, reviewerInputCaret] + ) + const alreadyEnteredReviewerLogins = useMemo( + () => + new Set( + reviewerInput + .split(/[\s,]+/) + .map((login) => login.trim().replace(/^@/, '').toLowerCase()) + .filter(Boolean) + ), + [reviewerInput] + ) + const reviewerSuggestions = useMemo(() => { + const query = reviewerInputToken.query + return reviewerCandidates + .filter((user) => { + const login = user.login.toLowerCase() + if (alreadyEnteredReviewerLogins.has(login) && login !== query) { + return false + } + return ( + query.length === 0 || + login.includes(query) || + (user.name ?? '').toLowerCase().includes(query) + ) + }) + .sort((a, b) => { + const aLogin = a.login.toLowerCase() + const bLogin = b.login.toLowerCase() + const aStarts = aLogin.startsWith(query) + const bStarts = bLogin.startsWith(query) + if (aStarts !== bStarts) { + return aStarts ? -1 : 1 + } + return a.login.localeCompare(b.login) + }) + .slice(0, 8) + }, [alreadyEnteredReviewerLogins, reviewerCandidates, reviewerInputToken.query]) + const showReviewerSuggestions = + reviewerSuggestionsOpen && + !submitting && + !!repo && + (reviewerMetadata.loading || reviewerSuggestions.length > 0 || reviewerInput.trim().length > 0) + + useEffect(() => { + setActiveReviewerSuggestionIndex(0) + }, [reviewerInputToken.query, reviewerSuggestions.length]) + + if (item.type !== 'pr') { + return Issue + } + + const itemWithLocalReviewRequests = { ...item, reviewRequests: localReviewRequests } + const reviewers = [ + ...localReviewRequests.map((user) => ({ ...user, state: 'Requested' })), + ...(item.latestReviews ?? []).map((review) => ({ + login: review.login, + name: null, + avatarUrl: review.avatarUrl ?? '', + state: review.state ?? 'Reviewed' + })) + ] + const hasReviewerMetadata = + item.reviewDecision !== undefined || + localReviewRequests.length > 0 || + item.reviewRequests !== undefined || + item.latestReviews !== undefined + + const handleRequestReview = async (): Promise => { + if (!repo || submitting) { + return + } + const logins = reviewerInput + .split(/[\s,]+/) + .map((login) => login.trim().replace(/^@/, '')) + .filter(Boolean) + if (logins.length === 0) { + toast.error('Enter a reviewer login') + return + } + setSubmitting(true) + try { + const result = await window.api.gh.requestPRReviewers({ + repoPath: repo.path, + repoId: repo.id, + prNumber: item.number, + reviewers: logins + }) + if (result.ok) { + toast.success('Reviewer requested') + setLocalReviewRequests((current) => + buildRequestedReviewUsers(logins, reviewerCandidates, current) + ) + setReviewerInput('') + setReviewerSuggestionsOpen(false) + } else { + toast.error(result.error) + } + } catch { + toast.error('Failed to request reviewer') + } finally { + setSubmitting(false) + } + } + + const insertReviewerSuggestion = (suggestion: GitHubAssignableUser): void => { + const token = getReviewerInputToken( + reviewerInput, + reviewerInputRef.current?.selectionStart ?? reviewerInputCaret + ) + const inserted = `@${suggestion.login}` + const nextValue = `${reviewerInput.slice(0, token.start)}${inserted}${reviewerInput.slice(token.end)}` + const nextCaret = token.start + inserted.length + setReviewerInput(nextValue) + setReviewerInputCaret(nextCaret) + setReviewerSuggestionsOpen(false) + requestAnimationFrame(() => { + reviewerInputRef.current?.focus() + reviewerInputRef.current?.setSelectionRange(nextCaret, nextCaret) + }) + } + + return ( + + + + + event.stopPropagation()} + > +
+
+
Reviewers
+
+ {reviewers.length > 0 ? ( + reviewers.slice(0, 6).map((reviewer) => ( +
+ @{reviewer.login} + + {reviewer.state} + +
+ )) + ) : ( +
+ {hasReviewerMetadata + ? 'No reviewers requested yet.' + : 'Open the PR details to view current reviewers.'} +
+ )} +
+
+
+
+ { + setReviewerInput(event.target.value) + setReviewerInputCaret( + event.currentTarget.selectionStart ?? event.target.value.length + ) + setReviewerSuggestionsOpen(true) + }} + onClick={(event) => { + setReviewerInputCaret(event.currentTarget.selectionStart ?? reviewerInput.length) + setReviewerSuggestionsOpen(true) + }} + onFocus={(event) => { + setReviewerInputCaret(event.currentTarget.selectionStart ?? reviewerInput.length) + setReviewerSuggestionsOpen(true) + }} + onBlur={() => setReviewerSuggestionsOpen(false)} + onKeyUp={(event) => { + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) { + setReviewerInputCaret( + event.currentTarget.selectionStart ?? reviewerInput.length + ) + } + }} + placeholder="login or @login" + disabled={!repo || submitting} + className="h-8 text-xs" + aria-expanded={showReviewerSuggestions} + aria-autocomplete="list" + onKeyDown={(event) => { + if (showReviewerSuggestions && reviewerSuggestions.length > 0) { + if (event.key === 'ArrowDown') { + event.preventDefault() + setActiveReviewerSuggestionIndex( + (current) => (current + 1) % reviewerSuggestions.length + ) + return + } + if (event.key === 'ArrowUp') { + event.preventDefault() + setActiveReviewerSuggestionIndex( + (current) => + (current - 1 + reviewerSuggestions.length) % reviewerSuggestions.length + ) + return + } + if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault() + insertReviewerSuggestion( + reviewerSuggestions[activeReviewerSuggestionIndex] ?? reviewerSuggestions[0] + ) + return + } + } + if (event.key === 'Escape' && reviewerSuggestionsOpen) { + event.preventDefault() + setReviewerSuggestionsOpen(false) + return + } + if (event.key === 'Enter') { + event.preventDefault() + void handleRequestReview() + } + }} + /> + +
+ {showReviewerSuggestions && ( +
+ {reviewerMetadata.loading ? ( +
Loading…
+ ) : reviewerSuggestions.length > 0 ? ( + reviewerSuggestions.map((suggestion, index) => ( + + )) + ) : ( +
+ {reviewerMetadata.error ?? 'No matching reviewers.'} +
+ )} +
+ )} +
+
+
+
+ ) +} + +function PRChecksCell({ + item, + onOpen +}: { + item: GitHubWorkItem + onOpen: () => void +}): React.JSX.Element { + if (item.type !== 'pr') { + return Issue + } + const summary = item.checksSummary + const Icon = + summary?.state === 'success' + ? CheckCircle2 + : summary?.state === 'failure' + ? AlertCircle + : summary?.state === 'pending' + ? Clock3 + : Minus + return ( + + + + + + Open PR conversation and checks + + + ) +} + +function PRMergeCell({ + item, + repo, + onRefresh +}: { + item: GitHubWorkItem + repo: Repo | null + onRefresh: () => void +}): React.JSX.Element { + const [merging, setMerging] = useState(false) + if (item.type !== 'pr') { + return Issue + } + const mergeDisabled = + !repo || + merging || + item.state === 'closed' || + item.state === 'merged' || + item.mergeable === 'CONFLICTING' + + const handleMerge = async (method: 'merge' | 'squash' | 'rebase'): Promise => { + if (!repo || mergeDisabled) { + return + } + const confirmed = window.confirm( + method === 'squash' + ? `Squash and merge PR #${item.number}?` + : `${method === 'rebase' ? 'Rebase and merge' : 'Merge'} PR #${item.number}?` + ) + if (!confirmed) { + return + } + setMerging(true) + try { + const result = await window.api.gh.mergePR({ + repoPath: repo.path, + repoId: repo.id, + prNumber: item.number, + method + }) + if (result.ok) { + toast.success('Pull request merged') + onRefresh() + } else { + toast.error(result.error) + } + } catch { + toast.error('Failed to merge pull request') + } finally { + setMerging(false) + } + } + + return ( + + + + + + + + + {getMergeTooltip(item)} + + + event.stopPropagation()}> + void handleMerge('squash')}> + + Squash and merge + + void handleMerge('merge')}> + + Create merge commit + + void handleMerge('rebase')}> + + Rebase and merge + + window.api.shell.openUrl(item.url)}> + + Open GitHub merge box + + + + ) +} + // Why: builds the page number array with ellipsis gaps, matching GitHub's // pagination pattern: always show first page, last page, and a window of // pages around the current page with "..." gaps between distant ranges. @@ -638,7 +1422,7 @@ export default function TaskPage(): React.JSX.Element { // query. Previously a separate effect "re-seeded" these after mount, which // caused a throwaway empty-query fetch followed by a second fetch for the // real default — doubling the time-to-first-paint of the list. - const defaultTaskViewPreset = settings?.defaultTaskViewPreset ?? 'all' + const defaultTaskViewPreset = normalizeGitHubTaskPreset(settings?.defaultTaskViewPreset ?? 'all') const initialTaskQuery = getTaskPresetQuery(defaultTaskViewPreset) const defaultTaskSource = settings?.defaultTaskSource ?? 'github' @@ -952,7 +1736,7 @@ export default function TaskPage(): React.JSX.Element { setAppliedTaskSearch(query) setActiveTaskPreset(null) } else { - const presetId = preset ?? settings.defaultTaskViewPreset + const presetId = normalizeGitHubTaskPreset(preset ?? settings.defaultTaskViewPreset) const query = getTaskPresetQuery(presetId) setTaskSearchInput(query) setAppliedTaskSearch(query) @@ -1215,30 +1999,18 @@ export default function TaskPage(): React.JSX.Element { ) const [linearConnectError, setLinearConnectError] = useState(null) + const activeGithubTaskKind = getGitHubTaskKind(activeTaskPreset, appliedTaskSearch) + // Why: defense-in-depth safety net applied to the current page's items. - // The server-side query now includes is:issue / is:pr qualifiers so this - // filter is a no-op in the happy path. Kept as a guard against parser - // regressions or stale cache contamination. + // The active tab scopes requests to issues or PRs, and this keeps stale + // cache rows from leaking across the split tabs. const applyTypeFilter = useCallback( (items: GitHubWorkItem[]) => { - if (!activeTaskPreset) { - return items - } return items.filter((item) => { - if (activeTaskPreset === 'issues' || activeTaskPreset === 'my-issues') { - return item.type === 'issue' - } - if ( - activeTaskPreset === 'prs' || - activeTaskPreset === 'my-prs' || - activeTaskPreset === 'review' - ) { - return item.type === 'pr' - } - return true + return activeGithubTaskKind === 'prs' ? item.type === 'pr' : item.type === 'issue' }) }, - [activeTaskPreset] + [activeGithubTaskKind] ) const currentPageItems = useMemo(() => pages[currentPage] ?? [], [pages, currentPage]) @@ -1247,6 +2019,10 @@ export default function TaskPage(): React.JSX.Element { () => applyTypeFilter(currentPageItems), [applyTypeFilter, currentPageItems] ) + const showPRManagementColumns = activeGithubTaskKind === 'prs' + const githubTaskGridClass = showPRManagementColumns + ? GITHUB_PR_TASK_GRID_CLASS + : GITHUB_TASK_GRID_CLASS // Why: totalPages is derived from the search API count when available, // so the pagination bar shows the full range (with ellipsis) upfront. @@ -1318,10 +2094,10 @@ export default function TaskPage(): React.JSX.Element { return } const timeout = window.setTimeout(() => { - setAppliedTaskSearch(taskSearchInput) + setAppliedTaskSearch(scopeGitHubTaskSearch(taskSearchInput, activeGithubTaskKind)) }, TASK_SEARCH_DEBOUNCE_MS) return () => window.clearTimeout(timeout) - }, [taskSearchInput, taskResumeApplied]) + }, [activeGithubTaskKind, taskSearchInput, taskResumeApplied]) useEffect(() => { if (!taskResumeApplied) { @@ -1501,13 +2277,13 @@ export default function TaskPage(): React.JSX.Element { ]) const handleApplyTaskSearch = useCallback((): void => { - const trimmed = taskSearchInput.trim() - setTaskSearchInput(trimmed) - setAppliedTaskSearch(trimmed) + const scoped = scopeGitHubTaskSearch(taskSearchInput, activeGithubTaskKind) + setTaskSearchInput(scoped) + setAppliedTaskSearch(scoped) setActiveTaskPreset(null) - setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: trimmed }) + setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: scoped }) setTaskRefreshNonce((current) => current + 1) - }, [setTaskResumeState, taskSearchInput]) + }, [activeGithubTaskKind, setTaskResumeState, taskSearchInput]) const handleTaskSearchChange = useCallback((event: React.ChangeEvent): void => { const next = event.target.value @@ -1527,6 +2303,26 @@ export default function TaskPage(): React.JSX.Element { [updateSettings] ) + const handleSelectGithubTaskKind = useCallback( + (kind: GitHubTaskKind): void => { + const preset = getDefaultPresetForGitHubTaskKind(kind) + const query = getTaskPresetQuery(preset) + setTaskSearchInput(query) + setAppliedTaskSearch(query) + setActiveTaskPreset(preset) + setTaskResumeState({ + githubItemsPreset: preset, + githubItemsQuery: query + }) + setTaskRefreshNonce((current) => current + 1) + }, + [setTaskResumeState] + ) + + const handleResetGithubTaskSearch = useCallback((): void => { + handleSelectGithubTaskKind(activeGithubTaskKind) + }, [activeGithubTaskKind, handleSelectGithubTaskKind]) + const handleTaskSearchKeyDown = useCallback( (event: React.KeyboardEvent): void => { if (event.key === 'Enter') { @@ -2154,15 +2950,24 @@ export default function TaskPage(): React.JSX.Element {
{projectModeVisible ? (
- {(['items', 'project'] as const).map((mode) => { - const active = githubMode === mode + {GITHUB_MODE_BUTTONS.map((mode) => { + const active = + mode.id === 'project' + ? githubMode === 'project' + : githubMode === 'items' && activeGithubTaskKind === mode.id return ( ) })} @@ -2210,39 +3015,41 @@ export default function TaskPage(): React.JSX.Element { {taskSource === 'github' && githubMode === 'items' ? (
-
- {TASK_QUERY_PRESETS.map((option) => { - const active = activeTaskPreset === option.id - return ( - - ) - })} +
+
+ {getGitHubTaskKindPresets(activeGithubTaskKind).map((option) => { + const active = activeTaskPreset === option.id + return ( + + ) + })} +
@@ -2301,20 +3108,18 @@ export default function TaskPage(): React.JSX.Element { value={taskSearchInput} onChange={handleTaskSearchChange} onKeyDown={handleTaskSearchKeyDown} - placeholder="GitHub search, e.g. assignee:@me is:open" + placeholder={ + activeGithubTaskKind === 'prs' + ? 'Search GitHub PRs...' + : 'Search GitHub issues...' + } className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs" /> {taskSearchInput || appliedTaskSearch ? (
) : taskSource === 'github' ? (
-
- ID - Title / Context - Source Branch - Status - Updated - -
-
+
+ ID + Title / Context + Branch + Status + {showPRManagementColumns ? ( + <> + Reviewers + Checks + Merge + + ) : null} + Updated + +
+ {tasksError ? (
{tasksError} @@ -2696,10 +3513,7 @@ export default function TaskPage(): React.JSX.Element { // items — on revalidate we keep the stale list visible.
{Array.from({ length: 3 }).map((_, i) => ( -
+
@@ -2713,6 +3527,19 @@ export default function TaskPage(): React.JSX.Element {
+ {showPRManagementColumns ? ( + <> +
+
+
+
+
+
+
+
+
+ + ) : null}
@@ -2769,7 +3596,10 @@ export default function TaskPage(): React.JSX.Element { setDialogWorkItem(item) } }} - className="grid w-full cursor-pointer gap-2 px-3 py-2 text-left transition hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 grid-cols-[80px_minmax(0,3fr)_minmax(110px,0.8fr)_100px_110px_112px]" + className={cn( + 'grid cursor-pointer gap-2 px-3 py-2 text-left transition hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50', + githubTaskGridClass + )} >
@@ -2786,7 +3616,7 @@ export default function TaskPage(): React.JSX.Element {
-

+

{item.title}

{selectedRepos.length > 1 && itemRepo ? ( @@ -2805,6 +3635,12 @@ export default function TaskPage(): React.JSX.Element { {selectedRepos.length === 1 && itemRepo ? ( {itemRepo.displayName} ) : null} + {item.type === 'pr' && formatPRDelta(item) ? ( + + + {formatPRDelta(item)} + + ) : null} {item.labels.slice(0, 3).map((label) => (
- - {item.branchName || item.baseRefName || 'workspace/default'} - + {item.type === 'pr' ? ( +
+
+ {item.branchName || 'unknown head'} +
+
+ into {item.baseRefName || 'base'} +
+
+ ) : ( + workspace/default + )}
+ {showPRManagementColumns ? ( + <> +
+ +
+ +
+ setDialogWorkItem(item)} /> +
+ +
+ setTaskRefreshNonce((current) => current + 1)} + /> +
+ + ) : null} +
@@ -2846,7 +3711,7 @@ export default function TaskPage(): React.JSX.Element { }} className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/80 px-2 py-1 text-[11px] text-foreground transition hover:bg-muted/60" > - Use + Start workspace diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index 59ccf7ea2d2..708d91eb005 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -4,7 +4,7 @@ // through to `fieldValuesByFieldId[field.id].kind` as a safety net so a // fetched value is never silently dropped. import React, { useState } from 'react' -import { CircleDot, FileText, GitPullRequest, Lock } from 'lucide-react' +import { CircleDot, FileText, GitPullRequest, Lock, Plus } from 'lucide-react' import { TYPE_FIELD_DATA_TYPE } from './columns' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Input } from '@/components/ui/input' @@ -110,6 +110,7 @@ export default function ProjectCell({ { if (next === '') { onEditField?.(field.id, null) @@ -127,6 +128,7 @@ export default function ProjectCell({ value={num} editable={editable && !isRedacted} numeric + placeholder="Add number" onCommit={(next) => { if (next === '') { onEditField?.(field.id, null) @@ -436,7 +438,7 @@ function SingleSelectCell({ aria-label={field.name} className="flex h-full w-full cursor-pointer items-center px-1 text-left" > - {label} + {label ?? } @@ -505,7 +507,7 @@ function IterationCell({ aria-label={field.name} className="flex h-full w-full cursor-pointer items-center px-1 text-left" > - {label} + {label ?? } @@ -579,11 +581,13 @@ function TextCell({ value, editable, numeric, + placeholder, onCommit }: { value: string editable: boolean numeric?: boolean + placeholder: string onCommit: (next: string) => void }): React.JSX.Element { const [editing, setEditing] = useState(false) @@ -601,7 +605,7 @@ function TextCell({ }} className="flex h-full w-full cursor-pointer items-center px-1 text-left text-xs hover:underline" > - {value} + {value || } ) } @@ -772,7 +776,7 @@ function AssigneesCell({ 'flex h-full w-full flex-wrap items-center gap-1 cursor-pointer px-1 text-xs text-muted-foreground hover:text-foreground' )} > - {labelContent} + {labelContent ?? } @@ -846,7 +850,7 @@ function LabelsCell({ aria-label="Labels" className={cn('flex h-full w-full flex-wrap items-center gap-1 cursor-pointer px-1')} > - {labelContent} + {labelContent ?? } @@ -888,6 +892,15 @@ function LabelsCell({ ) } +function EmptyCellPrompt({ label }: { label: string }): React.JSX.Element { + return ( + + + {label} + + ) +} + function colorHex(color: string): string { if (!color) { return 'inherit' diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index 46417d6b204..9f92c3bc8dc 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -24,6 +24,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import GitHubItemDialog, { type GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' import { GhAuthErrorHelp } from '@/components/github-project/GhAuthErrorHelp' import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' @@ -50,6 +51,8 @@ import { filterProjectTableRowsByOpenRepos } from './project-row-filtering' type Props = Record +const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new' + function listProjectViewsForRuntime( settings: Parameters[0], args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number } @@ -695,9 +698,6 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J ? (() => { const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` const views = viewListByProject[projectKey] ?? [] - if (views.length === 0) { - return null - } const activeViewId = lastViewByProject[projectKey]?.viewId ?? null return ( +
{views.map((v) => { const supported = v.layout === 'TABLE_LAYOUT' const active = v.id === activeViewId + const layoutLabel = + v.layout === 'BOARD_LAYOUT' + ? 'Board' + : v.layout === 'ROADMAP_LAYOUT' + ? 'Roadmap' + : 'Table' const Icon = v.layout === 'BOARD_LAYOUT' ? KanbanSquare : v.layout === 'ROADMAP_LAYOUT' ? MapIcon : TableIcon - return ( + const tab = ( ) + if (supported) { + return tab + } + const unsupportedMessage = `Orca doesn't support ${layoutLabel} project views yet.` + return ( + + + + {tab} + + + +
+

+ {unsupportedMessage} Switch to a Table view to work with this project in Orca. +

+ +
+
+
+ ) })}
) diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 583d0b5f9e3..2fb5f37277f 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -18,6 +18,7 @@ export { PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT } from '../../../shared/ export function getTaskPresetQuery(presetId: TaskViewPresetId | null): string { switch (presetId) { + case 'all': case 'issues': return 'is:issue is:open' case 'my-issues': @@ -29,7 +30,7 @@ export function getTaskPresetQuery(presetId: TaskViewPresetId | null): string { case 'review': return 'review-requested:@me is:pr is:open' default: - return 'is:open' + return 'is:issue is:open' } } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 41386ef3a55..a74f23d0dfa 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -56,6 +56,7 @@ function clampPetSize(size: number): number { // openTaskPage warm exactly the cache key the page will read on mount. function presetToQuery(presetId: TaskViewPresetId | null): string { switch (presetId) { + case 'all': case 'issues': return 'is:issue is:open' case 'my-issues': @@ -67,7 +68,7 @@ function presetToQuery(presetId: TaskViewPresetId | null): string { case 'my-prs': return 'author:@me is:pr is:open' default: - return 'is:open' + return 'is:issue is:open' } } @@ -547,10 +548,10 @@ export const createUISlice: StateCreator = (set, get) const resume = state.taskResumeState const defaultPreset = state.settings?.defaultTaskViewPreset ?? 'all' // Why: must match the exact query TaskPage's resume effect mounts with, - // otherwise the warm cache key (e.g. 'is:open') misses the page's actual - // fetch key (e.g. '') and the prefetch is wasted. When the user has an - // explicit cleared custom search (preset === null), preserve the empty - // query so both sides agree. + // otherwise the warm cache key (e.g. 'is:issue is:open') misses the + // page's actual fetch key and the prefetch is wasted. When the user has + // an explicit custom search (preset === null), preserve it so both sides + // agree. const query = resume?.githubItemsPreset === null ? (resume.githubItemsQuery ?? '').trim() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index be0c06b3fda..352e4743ebe 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -675,11 +675,14 @@ function createGitHubApi(): NonNullable['gh']> { countWorkItems: direct('github.countWorkItems'), listWorkItems: direct('github.listWorkItems'), prChecks: direct('github.prChecks'), + rerunPRChecks: direct('github.rerunPRChecks'), prComments: direct('github.prComments'), resolveReviewThread: direct('github.resolveReviewThread'), setPRFileViewed: direct('github.setPRFileViewed'), updatePRTitle: direct('github.updatePRTitle'), mergePR: direct('github.mergePR'), + updatePRState: direct('github.updatePRState'), + requestPRReviewers: direct('github.requestPRReviewers'), updateIssue: direct('github.updateIssue'), addIssueComment: direct('github.addIssueComment'), addPRReviewCommentReply: direct('github.addPRReviewCommentReply'), diff --git a/src/shared/github-project-types.ts b/src/shared/github-project-types.ts index 789d7272f1c..3067aa95260 100644 --- a/src/shared/github-project-types.ts +++ b/src/shared/github-project-types.ts @@ -374,7 +374,7 @@ export type UpdatePullRequestBySlugArgs = { owner: string repo: string number: number - updates: { title?: string; body?: string } + updates: { title?: string; body?: string; state?: 'open' | 'closed' } } export type AddIssueCommentBySlugArgs = { diff --git a/src/shared/types.ts b/src/shared/types.ts index 81360ef3e9b..dcbe378bf54 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -613,8 +613,12 @@ export type PRCheckDetail = { | 'pending' | null url: string | null + checkRunId?: number + workflowRunId?: number } +export type GitHubRerunPRChecksResult = { ok: true; count: number } | { ok: false; error: string } + export type GitHubReactionContent = | '+1' | '-1' @@ -678,6 +682,20 @@ export type GitHubAssignableUser = { avatarUrl: string } +export type GitHubPRCheckSummary = { + state: 'success' | 'failure' | 'pending' | 'none' + total: number + passed: number + failed: number + pending: number +} + +export type GitHubPRReviewSummary = { + login: string + state?: string | null + avatarUrl?: string | null +} + export type GitHubPRFileViewedState = 'DISMISSED' | 'VIEWED' | 'UNVIEWED' export type GitHubWorkItem = { @@ -692,6 +710,17 @@ export type GitHubWorkItem = { author: string | null branchName?: string baseRefName?: string + additions?: number + deletions?: number + changedFiles?: number + reviewDecision?: string | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + assignees?: GitHubAssignableUser[] + checksSummary?: GitHubPRCheckSummary + mergeable?: PRMergeableState + mergeStateStatus?: string | null + maintainerCanModify?: boolean // Why: true when a PR's head lives on a fork (headRepositoryOwner !== selected repo owner). // The Start-from picker passes this to resolvePrBase so fork heads use // refs/pull//head for creation and a separate PR-head push target. @@ -830,6 +859,10 @@ export type GitHubIssueUpdate = { removeAssignees?: string[] } +export type GitHubPullRequestStateUpdate = { + state: 'open' | 'closed' +} + export type LinearIssueUpdate = { stateId?: string title?: string