fix: address review findings (#2206)

This commit is contained in:
Jinjing
2026-05-17 20:15:05 -07:00
committed by GitHub
parent 4634ffb4d3
commit e4a794ec68
20 changed files with 2244 additions and 193 deletions
+57 -3
View File
@@ -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' } }
)
})
})
@@ -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',
+371 -26
View File
@@ -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<GitHubViewer | null> {
// single-repo and cross-repo items are uniform downstream.
type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
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<string, unknown>): MainWorkItem {
return {
id: `issue:${String(item.number)}`,
@@ -277,6 +288,142 @@ function extractHeadOwnerLogin(item: Record<string, unknown>): 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<string, unknown>
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<string, unknown>
const nested = userFromUnknown(raw.requestedReviewer ?? raw.user ?? raw.author)
if (nested) {
users.push(nested)
}
}
}
return users
}
function latestReviewsFromUnknown(value: unknown): NonNullable<GitHubWorkItem['latestReviews']> {
if (!Array.isArray(value)) {
return []
}
const reviews: NonNullable<GitHubWorkItem['latestReviews']> = []
for (const entry of value) {
if (typeof entry !== 'object' || entry === null) {
continue
}
const raw = entry as Record<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>,
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<string, unknown>)
@@ -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<PRCheckDetail[]> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId))
const ownerRepo = await getOwnerRepo(repoPath, connectionId)
const fallbackToPRChecks = async (): Promise<PRCheckDetail[]> => {
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<GitHubRerunPRChecksResult> {
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.
*/
@@ -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 }
+77 -1
View File
@@ -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 (
+34
View File
@@ -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<Awaited<ReturnType<typeof rerunPRChecks>>> {
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<Awaited<ReturnType<typeof updatePRState>>> {
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<Awaited<ReturnType<typeof requestPRReviewers>>> {
const repo = await this.resolveRepoSelector(repoSelector)
this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_reviewers')
return requestPRReviewers(repo.path, prNumber, reviewers)
}
async createRepoIssue(
repoSelector: string,
title: string,
@@ -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',
+39
View File
@@ -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,
+19
View File
@@ -799,6 +799,13 @@ export type PreloadApi = {
headSha?: string
noCache?: boolean
}) => Promise<PRCheckDetail[]>
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
+25
View File
@@ -817,6 +817,15 @@ const api = {
noCache?: boolean
}): Promise<unknown[]> => 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
+12
View File
@@ -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
+506 -39
View File
@@ -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' ? (
<div className="flex h-fit flex-col gap-3 xl:sticky xl:top-4">
{startWorkspaceButton}
<PRActionsPanel
item={item}
repoPath={repoPath}
repoId={item.repoId}
projectOrigin={projectOrigin}
localState={localState}
onStateChange={onStateChange}
onMutated={onMutated}
/>
<aside className="rounded-lg border border-border/50 bg-card/50 shadow-xs">
<div className="flex h-10 items-center gap-2 border-b border-border/50 px-3">
<CircleDashed className="size-3.5 text-muted-foreground" />
@@ -1535,7 +1602,15 @@ function ConversationTab({
{(checks ?? []).length}
</span>
</div>
<ChecksTab checks={checks} loading={loading} />
<ChecksTab
item={item}
repoPath={repoPath}
repoId={item.repoId}
headSha={headSha}
checks={checks}
loading={loading}
onChecksUpdated={onChecksUpdated}
/>
</aside>
</div>
) : null
@@ -1751,7 +1826,7 @@ function ConversationTab({
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-center text-[13px] text-muted-foreground">
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
No comments yet.
</div>
) : 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<void> => {
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<void> => {
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 (
<aside className="rounded-lg border border-border/50 bg-card/50 p-3 shadow-xs">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<GitPullRequest className="size-3.5 text-muted-foreground" />
<span className="text-[13px] font-medium text-foreground">Pull request</span>
</div>
<WorkItemStateBadge item={actionItem} />
</div>
<div className="grid gap-2">
<Button
type="button"
variant={nextState === 'closed' ? 'destructive' : 'secondary'}
size="sm"
className="w-full justify-center gap-2"
disabled={!canMutateState || statePending}
onClick={() => void handleStateChange()}
>
{statePending ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : nextState === 'closed' ? (
<CircleDashed className="size-3.5" />
) : (
<CircleDot className="size-3.5" />
)}
{nextState === 'closed' ? 'Close PR' : 'Reopen PR'}
</Button>
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-center gap-2"
disabled={mergePending || localState === 'closed' || localState === 'merged'}
>
{mergePending ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<GitMerge className="size-3.5" />
)}
Merge
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{!repoPath ? 'Merge requires a registered local repo' : getPRMergeTooltip(actionItem)}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem disabled={mergeDisabled} onSelect={() => void handleMerge('squash')}>
<GitMerge className="size-4" />
Squash and merge
</DropdownMenuItem>
<DropdownMenuItem disabled={mergeDisabled} onSelect={() => void handleMerge('merge')}>
<GitMerge className="size-4" />
Create merge commit
</DropdownMenuItem>
<DropdownMenuItem disabled={mergeDisabled} onSelect={() => void handleMerge('rebase')}>
<GitMerge className="size-4" />
Rebase and merge
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(item.url)}>
<ExternalLink className="size-4" />
Open GitHub merge box
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
)
}
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<PRCheckDetail[] | null>(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<PRCheckDetail[] | null> => {
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<void> => {
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 = (
<div className="flex items-center justify-end gap-1 border-b border-border/40 px-2 py-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7"
disabled={!repoPath || refreshing}
onClick={() => void handleRefresh()}
aria-label="Refresh checks"
>
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh checks
</TooltipContent>
</Tooltip>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 gap-1.5 px-2 text-[11px]"
disabled={!repoPath || rerunning || list.length === 0}
>
{rerunning ? <LoaderCircle className="size-3 animate-spin" /> : null}
Rerun
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
disabled={failedChecks.length === 0 || rerunning}
onSelect={() => void handleRerun(true)}
>
<RefreshCw className="size-4" />
Rerun failed checks
</DropdownMenuItem>
<DropdownMenuItem disabled={rerunning} onSelect={() => void handleRerun(false)}>
<RefreshCw className="size-4" />
Rerun all checks
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
if (loading && list.length === 0) {
return (
<div className="flex items-center justify-center py-10">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
<>
{toolbar}
<div className="flex items-center justify-center py-10">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
</>
)
}
if (list.length === 0) {
return (
<div className="px-4 py-10 text-center text-[12px] text-muted-foreground">
No checks configured.
</div>
<>
{toolbar}
<div className="px-4 py-10 text-center text-[12px] text-muted-foreground">
No checks found.
</div>
</>
)
}
return (
<div className="px-2 py-2">
{list.map((check) => {
const conclusion = check.conclusion ?? 'pending'
const Icon = CHECK_ICON[conclusion] ?? CircleDashed
const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground'
return (
<button
key={check.name}
type="button"
onClick={() => {
if (check.url) {
window.api.shell.openUrl(check.url)
}
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition',
check.url ? 'hover:bg-muted/40' : ''
)}
>
<Icon
className={cn('size-3.5 shrink-0', color, conclusion === 'pending' && 'animate-spin')}
/>
<span className="flex-1 truncate text-[12px] text-foreground">{check.name}</span>
{check.url && <ExternalLink className="size-3 shrink-0 text-muted-foreground/40" />}
</button>
)
})}
</div>
<>
{toolbar}
<div className="px-2 py-2">
{list.map((check) => {
const conclusion = check.conclusion ?? 'pending'
const Icon = CHECK_ICON[conclusion] ?? CircleDashed
const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground'
return (
<button
key={check.name}
type="button"
onClick={() => {
if (check.url) {
window.api.shell.openUrl(check.url)
}
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition',
check.url ? 'hover:bg-muted/40' : ''
)}
>
<Icon
className={cn(
'size-3.5 shrink-0',
color,
conclusion === 'pending' && 'animate-spin'
)}
/>
<span className="flex-1 truncate text-[12px] text-foreground">{check.name}</span>
{check.url && <ExternalLink className="size-3 shrink-0 text-muted-foreground/40" />}
</button>
)
})}
</div>
</>
)
}
@@ -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<void> {
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<Awaited<ReturnType<typeof window.api.gh.updatePullRequestBySlug>>>(
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({
<ConversationTab
item={workItem}
repoPath={repoPath}
repoId={effectiveRepoId}
body={body}
comments={comments}
files={files}
@@ -3287,7 +3736,25 @@ export default function GitHubItemDialog({
loading={loading}
checks={checks}
participants={details?.participants ?? []}
localState={localState}
onStateChange={setLocalState}
projectOrigin={projectOrigin}
onUse={onUse}
onMutated={() => {
if (repoPath) {
invalidateWorkItemDetailsCacheByMatch({
repoPath,
repoId: effectiveRepoId ?? undefined,
type: workItem.type,
number: workItem.number
})
}
}}
onChecksUpdated={(nextChecks) => {
if (detailsCacheKey) {
patchCachedPRChecks(detailsCacheKey, nextChecks)
}
}}
onCommentAdded={appendOptimisticComment}
/>
</TabsContent>
File diff suppressed because it is too large Load Diff
@@ -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({
<TextCell
value={text}
editable={editable && !isRedacted}
placeholder="Add text"
onCommit={(next) => {
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 ?? <EmptyCellPrompt label="Select" />}
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1">
@@ -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 ?? <EmptyCellPrompt label="Select" />}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1">
@@ -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 || <EmptyCellPrompt label={placeholder} />}
</button>
)
}
@@ -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 ?? <EmptyCellPrompt label="Add assignee" />}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1">
@@ -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 ?? <EmptyCellPrompt label="Add label" />}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1">
@@ -888,6 +892,15 @@ function LabelsCell({
)
}
function EmptyCellPrompt({ label }: { label: string }): React.JSX.Element {
return (
<span className="inline-flex h-6 max-w-full items-center gap-1 rounded-md border border-dashed border-border/70 bg-input/30 px-2 text-xs text-muted-foreground/80 shadow-xs hover:border-border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:hover:bg-input/50">
<Plus className="size-3 shrink-0" />
<span className="truncate">{label}</span>
</span>
)
}
function colorHex(color: string): string {
if (!color) {
return 'inherit'
@@ -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<string, never>
const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new'
function listProjectViewsForRuntime(
settings: Parameters<typeof getActiveRuntimeTarget>[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 (
<ViewTabStrip
@@ -962,17 +962,23 @@ function ViewTabStrip({
// tabs are flat text; active gets a card background + outline. Disabled
// (non-table) layouts stay visible at low opacity.
return (
<div className="flex flex-none items-end gap-1 overflow-x-auto border-b border-border/50 bg-muted/20 px-3 pt-3">
<div className="project-view-tab-strip flex min-h-[41px] min-w-0 flex-none items-end gap-1 overflow-x-auto overflow-y-hidden border-b border-border/50 bg-muted/20 px-3 pt-3">
{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 = (
<button
key={v.id}
type="button"
@@ -981,23 +987,54 @@ function ViewTabStrip({
title={
supported
? v.name
: `${v.name}${
v.layout === 'BOARD_LAYOUT' ? 'Board' : 'Roadmap'
} layouts aren't supported in Orca yet. Open this view on GitHub to see it, or switch to a Table view to work with it here.`
: `${v.name}Orca doesn't support ${layoutLabel} project views yet. File a feature request at ${ORCA_FEATURE_REQUEST_URL}.`
}
className={cn(
'inline-flex shrink-0 items-center gap-1.5 rounded-t-md border-x border-t px-3 py-1.5 text-xs',
'inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-t-md border-x border-t px-3 py-1.5 text-xs',
active
? '-mb-px border-border/60 bg-background text-foreground'
: 'border-transparent text-muted-foreground hover:bg-background/40 hover:text-foreground',
!supported &&
'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-muted-foreground'
'pointer-events-none cursor-not-allowed opacity-50 hover:bg-transparent hover:text-muted-foreground'
)}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className={cn(active && 'font-medium')}>{v.name}</span>
</button>
)
if (supported) {
return tab
}
const unsupportedMessage = `Orca doesn't support ${layoutLabel} project views yet.`
return (
<HoverCard key={v.id} openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<span
tabIndex={0}
aria-label={`${v.name}. ${unsupportedMessage} File a feature request at ${ORCA_FEATURE_REQUEST_URL}.`}
className="inline-flex shrink-0 cursor-not-allowed rounded-t-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{tab}
</span>
</HoverCardTrigger>
<HoverCardContent side="bottom" align="start" sideOffset={8} className="w-72 p-3">
<div className="space-y-2">
<p className="text-xs leading-5 text-muted-foreground">
{unsupportedMessage} Switch to a Table view to work with this project in Orca.
</p>
<Button
type="button"
size="xs"
variant="outline"
onClick={() => void window.api.shell.openUrl(ORCA_FEATURE_REQUEST_URL)}
>
File feature request
<ExternalLink className="size-3" />
</Button>
</div>
</HoverCardContent>
</HoverCard>
)
})}
</div>
)
+2 -1
View File
@@ -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'
}
}
+6 -5
View File
@@ -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<AppState, [], [], UISlice> = (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()
+3
View File
@@ -675,11 +675,14 @@ function createGitHubApi(): NonNullable<Partial<PreloadApi>['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'),
+1 -1
View File
@@ -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 = {
+33
View File
@@ -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/<N>/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