mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix: address review findings (#2206)
This commit is contained in:
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user