mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
feat: display CI conflict details and improve merge-conflict UX (#234)
- Add PRConflictSummary type and derive conflict metadata (base ref, commits behind, conflicting files) via local git merge-tree - Fix parseUnmergedEntry to use space-separated parsing (porcelain v2 unmerged entries are not tab-separated) - Enhance rebase detection by checking rebase-merge/ and rebase-apply/ directories in addition to REBASE_HEAD - Show conflicting files list and commits-behind count in ChecksPanel - Disable merge button with tooltip when PR has conflicts - Add operation banners (merge/rebase/cherry-pick) in SourceControl and WorktreeCard - Reorder section list so unstaged changes appear above staged - Extract conflict-summary, issues, and gh-utils into separate modules - Add 10s timeout to git fetch in conflict summary derivation - Fix test fixtures to match real porcelain v2 format
This commit is contained in:
@@ -33,7 +33,11 @@ describe('getPRForBranch', () => {
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE'
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feature/test',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -59,7 +63,7 @@ describe('getPRForBranch', () => {
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable'
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: '/repo-root', encoding: 'utf-8' }
|
||||
)
|
||||
@@ -78,7 +82,11 @@ describe('getPRForBranch', () => {
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: true,
|
||||
mergeable: 'CONFLICTING'
|
||||
mergeable: 'CONFLICTING',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feature/test',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,7 +100,7 @@ describe('getPRForBranch', () => {
|
||||
'view',
|
||||
'feature/test',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable'
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{ cwd: '/non-github-repo', encoding: 'utf-8' }
|
||||
)
|
||||
@@ -101,6 +109,115 @@ describe('getPRForBranch', () => {
|
||||
expect(pr?.mergeable).toBe('CONFLICTING')
|
||||
})
|
||||
|
||||
it('derives a read-only conflict summary for conflicting PRs when the base ref exists locally', async () => {
|
||||
execFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'Fix PR discovery',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/42',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'CONFLICTING',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feature/test',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
])
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: '3\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/a.ts\u0000src/b.ts\u0000' })
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test')
|
||||
|
||||
expect(pr?.conflictSummary).toEqual({
|
||||
baseRef: 'main',
|
||||
baseCommit: 'latest-',
|
||||
commitsBehind: 3,
|
||||
files: ['src/a.ts', 'src/b.ts']
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps conflicted file paths when git merge-tree exits 1 with stdout', async () => {
|
||||
execFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'Fix PR discovery',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/42',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'CONFLICTING',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feature/test',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
])
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: '2\n' })
|
||||
.mockRejectedValueOnce({
|
||||
stdout: 'result-tree-oid\u0000src/conflict.ts\u0000'
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test')
|
||||
|
||||
expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts'])
|
||||
})
|
||||
|
||||
it('falls back to GitHub baseRefOid when fetching or resolving the base ref fails', async () => {
|
||||
execFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'Fix PR discovery',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/42',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'CONFLICTING',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feature/test',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'head-oid'
|
||||
}
|
||||
])
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('fetch failed'))
|
||||
.mockRejectedValueOnce(new Error('missing refs/remotes/origin/main'))
|
||||
.mockRejectedValueOnce(new Error('missing origin/main'))
|
||||
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: '1\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/fallback.ts\u0000' })
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test')
|
||||
|
||||
expect(pr?.conflictSummary).toEqual({
|
||||
baseRef: 'main',
|
||||
baseCommit: 'base-oi',
|
||||
commitsBehind: 1,
|
||||
files: ['src/fallback.ts']
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for empty branch (e.g. during rebase with detached HEAD)', async () => {
|
||||
const pr = await getPRForBranch('/repo-root', '')
|
||||
expect(pr).toBeNull()
|
||||
|
||||
+19
-140
@@ -1,74 +1,17 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import type { PRInfo, PRMergeableState, IssueInfo, PRCheckDetail } from '../../shared/types'
|
||||
import type { PRInfo, PRMergeableState, PRCheckDetail } from '../../shared/types'
|
||||
import { getPRConflictSummary } from './conflict-summary'
|
||||
import { execFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
|
||||
export { _resetOwnerRepoCache } from './gh-utils'
|
||||
export { getIssue, listIssues } from './issues'
|
||||
import {
|
||||
mapCheckRunRESTStatus,
|
||||
mapCheckRunRESTConclusion,
|
||||
mapCheckStatus,
|
||||
mapCheckConclusion,
|
||||
mapPRState,
|
||||
deriveCheckStatus,
|
||||
mapIssueInfo
|
||||
deriveCheckStatus
|
||||
} from './mappers'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Concurrency limiter - max 4 parallel gh processes
|
||||
const MAX_CONCURRENT = 4
|
||||
let running = 0
|
||||
const queue: (() => void)[] = []
|
||||
|
||||
function acquire(): Promise<void> {
|
||||
if (running < MAX_CONCURRENT) {
|
||||
running++
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) =>
|
||||
queue.push(() => {
|
||||
running++
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
running--
|
||||
const next = queue.shift()
|
||||
if (next) {
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Owner/repo resolution for gh api --cache ──────────────────────────
|
||||
const ownerRepoCache = new Map<string, { owner: string; repo: string } | null>()
|
||||
|
||||
/** @internal — exposed for tests only */
|
||||
export function _resetOwnerRepoCache(): void {
|
||||
ownerRepoCache.clear()
|
||||
}
|
||||
|
||||
async function getOwnerRepo(repoPath: string): Promise<{ owner: string; repo: string } | null> {
|
||||
if (ownerRepoCache.has(repoPath)) {
|
||||
return ownerRepoCache.get(repoPath)!
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
const match = stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
|
||||
if (match) {
|
||||
const result = { owner: match[1], repo: match[2] }
|
||||
ownerRepoCache.set(repoPath, result)
|
||||
return result
|
||||
}
|
||||
} catch {
|
||||
// ignore — non-GitHub remote or no remote
|
||||
}
|
||||
ownerRepoCache.set(repoPath, null)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR info for a given branch using gh CLI.
|
||||
* Returns null if gh is not installed, or no PR exists for the branch.
|
||||
@@ -95,6 +38,10 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
|
||||
updatedAt: string
|
||||
isDraft?: boolean
|
||||
mergeable: string
|
||||
baseRefName?: string
|
||||
headRefName?: string
|
||||
baseRefOid?: string
|
||||
headRefOid?: string
|
||||
} | null = null
|
||||
|
||||
if (ownerRepo) {
|
||||
@@ -112,7 +59,7 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable'
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{
|
||||
cwd: repoPath,
|
||||
@@ -129,7 +76,7 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
|
||||
'view',
|
||||
branchName,
|
||||
'--json',
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable'
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
],
|
||||
{
|
||||
cwd: repoPath,
|
||||
@@ -143,6 +90,11 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
|
||||
return null
|
||||
}
|
||||
|
||||
const conflictSummary =
|
||||
data.mergeable === 'CONFLICTING' && data.baseRefName && data.baseRefOid && data.headRefOid
|
||||
? await getPRConflictSummary(repoPath, data.baseRefName, data.baseRefOid, data.headRefOid)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
number: data.number,
|
||||
title: data.title,
|
||||
@@ -150,7 +102,8 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
|
||||
url: data.url,
|
||||
checksStatus: deriveCheckStatus(data.statusCheckRollup),
|
||||
updatedAt: data.updatedAt,
|
||||
mergeable: (data.mergeable as PRMergeableState) ?? 'UNKNOWN'
|
||||
mergeable: (data.mergeable as PRMergeableState) ?? 'UNKNOWN',
|
||||
conflictSummary
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -159,80 +112,6 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single issue by number.
|
||||
* Uses gh api --cache so 304 Not Modified responses don't count against the rate limit.
|
||||
*/
|
||||
export async function getIssue(repoPath: string, issueNumber: number): Promise<IssueInfo | null> {
|
||||
const ownerRepo = await getOwnerRepo(repoPath)
|
||||
await acquire()
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'300s',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}`
|
||||
],
|
||||
{ cwd: repoPath, encoding: 'utf-8' }
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
return mapIssueInfo(data)
|
||||
}
|
||||
// Fallback for non-GitHub remotes
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels'],
|
||||
{ cwd: repoPath, encoding: 'utf-8' }
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
return mapIssueInfo(data)
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List issues for a repo.
|
||||
* Uses gh api --cache so 304 Not Modified responses don't count against the rate limit.
|
||||
*/
|
||||
export async function listIssues(repoPath: string, limit = 20): Promise<IssueInfo[]> {
|
||||
const ownerRepo = await getOwnerRepo(repoPath)
|
||||
await acquire()
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'120s',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc`
|
||||
],
|
||||
{ cwd: repoPath, encoding: 'utf-8' }
|
||||
)
|
||||
const data = JSON.parse(stdout) as unknown[]
|
||||
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[0]))
|
||||
}
|
||||
// Fallback for non-GitHub remotes
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
['issue', 'list', '--json', 'number,title,state,url,labels', '--limit', String(limit)],
|
||||
{ cwd: repoPath, encoding: 'utf-8' }
|
||||
)
|
||||
const data = JSON.parse(stdout) as unknown[]
|
||||
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[0]))
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed check statuses for a PR.
|
||||
* When branch is provided, uses gh api --cache with the check-runs REST endpoint
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import type { PRConflictSummary } from '../../shared/types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export async function getPRConflictSummary(
|
||||
repoPath: string,
|
||||
baseRefName: string,
|
||||
baseRefOid: string,
|
||||
headRefOid: string
|
||||
): Promise<PRConflictSummary | undefined> {
|
||||
try {
|
||||
// Why: the renderer only needs a read-only merge-conflict snapshot. We
|
||||
// derive it from local git state so the PR card can show GitHub-style
|
||||
// detail without spending additional gh API calls on every refresh. We use
|
||||
// GitHub's head OID directly because the registered repo path may not have
|
||||
// a matching local branch name for the PR head. For the base side, prefer a
|
||||
// freshly-fetched remote-tracking ref so Orca matches GitHub's portal,
|
||||
// which compares against the latest base branch tip rather than the PR's
|
||||
// older pinned baseRefOid snapshot.
|
||||
const latestBaseOid = await resolveLatestBaseOid(repoPath, baseRefName, baseRefOid)
|
||||
const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid)
|
||||
const [commitsBehind, files] = await Promise.all([
|
||||
countCommits(repoPath, `${headRefOid}..${latestBaseOid}`),
|
||||
loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid)
|
||||
])
|
||||
|
||||
return {
|
||||
baseRef: baseRefName,
|
||||
baseCommit: latestBaseOid.slice(0, 7),
|
||||
commitsBehind,
|
||||
files
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLatestBaseOid(
|
||||
repoPath: string,
|
||||
baseRefName: string,
|
||||
fallbackBaseOid: string
|
||||
): Promise<string> {
|
||||
const remoteName = 'origin'
|
||||
|
||||
try {
|
||||
// Why: cap the fetch at 10 s so slow or unreachable remotes don't block
|
||||
// the conflict-summary derivation indefinitely.
|
||||
await execFileAsync('git', ['fetch', '--quiet', remoteName, baseRefName], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10_000
|
||||
})
|
||||
} catch {
|
||||
// Why: fetching the base ref keeps the conflict list aligned with GitHub's
|
||||
// live mergeability view, but the card must still render offline. If fetch
|
||||
// fails, fall back to the base OID GitHub already gave us.
|
||||
}
|
||||
|
||||
for (const ref of [`refs/remotes/${remoteName}/${baseRefName}`, `${remoteName}/${baseRefName}`]) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', ref], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
const oid = stdout.trim()
|
||||
if (oid) {
|
||||
return oid
|
||||
}
|
||||
} catch {
|
||||
// Try the next ref form before falling back to GitHub's baseRefOid.
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackBaseOid
|
||||
}
|
||||
|
||||
async function resolveMergeBase(
|
||||
repoPath: string,
|
||||
headOid: string,
|
||||
baseOid: string
|
||||
): Promise<string> {
|
||||
const { stdout } = await execFileAsync('git', ['merge-base', headOid, baseOid], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
async function countCommits(repoPath: string, range: string): Promise<number> {
|
||||
const { stdout } = await execFileAsync('git', ['rev-list', '--count', range], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
return Number.parseInt(stdout.trim(), 10) || 0
|
||||
}
|
||||
|
||||
async function loadConflictingFiles(
|
||||
repoPath: string,
|
||||
mergeBase: string,
|
||||
headOid: string,
|
||||
baseOid: string
|
||||
): Promise<string[]> {
|
||||
let stdout = ''
|
||||
try {
|
||||
const result = await execFileAsync(
|
||||
'git',
|
||||
[
|
||||
'merge-tree',
|
||||
'--write-tree',
|
||||
'--name-only',
|
||||
'-z',
|
||||
'--no-messages',
|
||||
'--merge-base',
|
||||
mergeBase,
|
||||
headOid,
|
||||
baseOid
|
||||
],
|
||||
{
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8'
|
||||
}
|
||||
)
|
||||
stdout = result.stdout
|
||||
} catch (error) {
|
||||
const stdoutFromError =
|
||||
typeof error === 'object' && error && 'stdout' in error && typeof error.stdout === 'string'
|
||||
? error.stdout
|
||||
: ''
|
||||
|
||||
// Why: `git merge-tree --write-tree` exits with status 1 when it finds
|
||||
// conflicts, but still writes the conflicted file list to stdout. Treat
|
||||
// that stdout as the useful result instead of dropping the summary.
|
||||
if (!stdoutFromError) {
|
||||
throw error
|
||||
}
|
||||
stdout = stdoutFromError
|
||||
}
|
||||
|
||||
const entries = stdout.split('\0').filter(Boolean)
|
||||
if (entries.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const [, ...files] = entries
|
||||
return files
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
export const execFileAsync = promisify(execFile)
|
||||
|
||||
// Concurrency limiter - max 4 parallel gh processes
|
||||
const MAX_CONCURRENT = 4
|
||||
let running = 0
|
||||
const queue: (() => void)[] = []
|
||||
|
||||
export function acquire(): Promise<void> {
|
||||
if (running < MAX_CONCURRENT) {
|
||||
running++
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) =>
|
||||
queue.push(() => {
|
||||
running++
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function release(): void {
|
||||
running--
|
||||
const next = queue.shift()
|
||||
if (next) {
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Owner/repo resolution for gh api --cache ──────────────────────────
|
||||
const ownerRepoCache = new Map<string, { owner: string; repo: string } | null>()
|
||||
|
||||
/** @internal — exposed for tests only */
|
||||
export function _resetOwnerRepoCache(): void {
|
||||
ownerRepoCache.clear()
|
||||
}
|
||||
|
||||
export async function getOwnerRepo(
|
||||
repoPath: string
|
||||
): Promise<{ owner: string; repo: string } | null> {
|
||||
if (ownerRepoCache.has(repoPath)) {
|
||||
return ownerRepoCache.get(repoPath)!
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
const match = stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
|
||||
if (match) {
|
||||
const result = { owner: match[1], repo: match[2] }
|
||||
ownerRepoCache.set(repoPath, result)
|
||||
return result
|
||||
}
|
||||
} catch {
|
||||
// ignore — non-GitHub remote or no remote
|
||||
}
|
||||
ownerRepoCache.set(repoPath, null)
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { IssueInfo } from '../../shared/types'
|
||||
import { mapIssueInfo } from './mappers'
|
||||
import { execFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
|
||||
|
||||
/**
|
||||
* Get a single issue by number.
|
||||
* Uses gh api --cache so 304 Not Modified responses don't count against the rate limit.
|
||||
*/
|
||||
export async function getIssue(repoPath: string, issueNumber: number): Promise<IssueInfo | null> {
|
||||
const ownerRepo = await getOwnerRepo(repoPath)
|
||||
await acquire()
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'300s',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}`
|
||||
],
|
||||
{ cwd: repoPath, encoding: 'utf-8' }
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
return mapIssueInfo(data)
|
||||
}
|
||||
// Fallback for non-GitHub remotes
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels'],
|
||||
{ cwd: repoPath, encoding: 'utf-8' }
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
return mapIssueInfo(data)
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List issues for a repo.
|
||||
* Uses gh api --cache so 304 Not Modified responses don't count against the rate limit.
|
||||
*/
|
||||
export async function listIssues(repoPath: string, limit = 20): Promise<IssueInfo[]> {
|
||||
const ownerRepo = await getOwnerRepo(repoPath)
|
||||
await acquire()
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'120s',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc`
|
||||
],
|
||||
{ cwd: repoPath, encoding: 'utf-8' }
|
||||
)
|
||||
const data = JSON.parse(stdout) as unknown[]
|
||||
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[0]))
|
||||
}
|
||||
// Fallback for non-GitHub remotes
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
['issue', 'list', '--json', 'number,title,state,url,labels', '--limit', String(limit)],
|
||||
{ cwd: repoPath, encoding: 'utf-8' }
|
||||
)
|
||||
const data = JSON.parse(stdout) as unknown[]
|
||||
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[0]))
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user