Files
orca/src/main/ipc/github.ts
T
Trevin ChowandNeil abfe9bd329 Default repo avatars to the GitHub upstream owner and flag forks (#4522)
* Default repo avatars to the GitHub upstream owner and flag forks

Make the GitHub owner avatar the default repo icon and surface it as the
primary choice in the icon picker (the "Image" tab becomes "Avatar",
moves first, and opens by default). For forks, resolve the upstream/
parent owner so the avatar reflects the source repo instead of the
personal fork, and show a fork indicator (GitFork glyph + "Fork of
owner/repo" tooltip) next to the repo in the sidebar and settings.

Fork detection prefers the offline `upstream` remote, falling back to a
`gh repo view --json isFork,parent` lookup; the resolved upstream is
stored on the repo. Existing repos self-correct via a one-time startup
backfill (local repos) and a lazy backfill when their icon settings open
(SSH repos); new repos resolve at add-time. Reset now restores the
default (re-detecting the upstream) instead of clearing to the Folder
icon.

* Harden repo upstream avatar handling

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-06-02 20:55:35 -07:00

1015 lines
30 KiB
TypeScript

/* eslint-disable max-lines -- Why: all GitHub IPC handlers stay co-located so
the repo-path validation, preference-threading, and stats wiring patterns are
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,
GitHubCreateIssueFields,
GitHubIssueUpdate,
GitHubOwnerRepo,
GitHubPullRequestStateUpdate,
GitHubPRRefreshCandidate,
GitHubPRRefreshReason,
PRRefreshOutcome
} from '../../shared/types'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
import {
getPRForBranch,
getIssue,
getRepoSlug,
getRepoUpstream,
listIssues,
listWorkItems,
countWorkItems,
getWorkItem,
getWorkItemByOwnerRepo,
createIssue,
updateIssue,
addIssueComment,
listLabels,
listAssignableUsers,
getAuthenticatedViewer,
getPRChecks,
getPRCheckDetails,
getPRComments,
resolveReviewThread,
setPRFileViewed,
addPRReviewComment,
addPRReviewCommentReply,
updatePRTitle,
mergePR,
setPRAutoMerge,
updatePRState,
rerunPRChecks,
requestPRReviewers,
removePRReviewers,
checkOrcaStarred,
starOrca
} from '../github/client'
import {
clearVisiblePRRefreshWindow,
enqueuePRRefresh,
refreshPRNow,
reportVisiblePRRefreshCandidates,
setPRRefreshOutcomeObserver
} from '../github/pr-refresh-coordinator'
import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details'
import { getRateLimit } from '../github/rate-limit'
import { diagnoseGhAuth } from '../github/auth-diagnose'
import type { GitHubPRFile } from '../../shared/types'
import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args'
import {
getProjectViewTable,
listAccessibleProjects,
resolveProjectRef,
listProjectViews,
getWorkItemDetailsBySlug,
updateProjectItemFieldValue,
clearProjectItemFieldValue,
updateIssueBySlug,
updatePullRequestBySlug,
addIssueCommentBySlug,
updateIssueCommentBySlug,
deleteIssueCommentBySlug,
listLabelsBySlug,
listAssignableUsersBySlug,
listIssueTypesBySlug,
updateIssueTypeBySlug
} from '../github/project-view'
import type {
AddIssueCommentBySlugArgs,
ClearProjectItemFieldArgs,
DeleteIssueCommentBySlugArgs,
GetProjectViewTableArgs,
ListAssignableUsersBySlugArgs,
ListIssueTypesBySlugArgs,
ListLabelsBySlugArgs,
ListProjectViewsArgs,
ProjectWorkItemDetailsBySlugArgs,
ResolveProjectRefArgs,
UpdateIssueBySlugArgs,
UpdateIssueCommentBySlugArgs,
UpdateIssueTypeBySlugArgs,
UpdateProjectItemFieldArgs,
UpdatePullRequestBySlugArgs
} from '../../shared/github-project-types'
import { appStarSourceSchema } from '../../shared/gh-star-source'
import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
const prRefreshVisibilityCleanupRegistered = new Set<number>()
// Why: notify every renderer (each window has its own SWR cache instance)
// that a work item was mutated locally so they can drop their cached entry
// and refetch on the next open. Only emitted after a successful mutation.
// We skip the originating webContents because that renderer already updated
// its cache optimistically — re-broadcasting would race the optimistic write
// and erase it.
function broadcastWorkItemMutated(
payload: {
repoPath: string
repoId?: string
type: 'issue' | 'pr'
number: number
},
senderId?: number
): void {
for (const wc of webContents.getAllWebContents()) {
if (wc.isDestroyed()) {
continue
}
if (senderId !== undefined && wc.id === senderId) {
continue
}
wc.send('gh:workItemMutated', payload)
}
}
// Why: returns the full Repo object instead of just the path string so that
// callers have access to repo.id for stat tracking and other context.
type RepoScopedArgs = { repoPath: string; repoId?: string }
function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo {
const repoPath = typeof args === 'string' ? args : args.repoPath
const repoId = typeof args === 'string' ? undefined : args.repoId
const resolvedRepoPath = resolve(repoPath)
const repo = store
.getRepos()
.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath))
if (!repo) {
throw new Error('Access denied: unknown repository path')
}
if (repoId && resolve(repo.path) !== resolvedRepoPath) {
throw new Error('Access denied: repository path does not match repo id')
}
return repo
}
function repoConnectionId(repo: Repo): string | null {
return repo.connectionId ?? null
}
export function registerGitHubHandlers(store: Store, stats: StatsCollector): void {
function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void {
if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) {
stats.record({
type: 'pr_created',
at: Date.now(),
repoId: repo.id,
meta: { prNumber: outcome.pr.number, prUrl: outcome.pr.url }
})
}
}
setPRRefreshOutcomeObserver((candidate, outcome) => {
const repo =
store.getRepos().find((r) => r.id === candidate.repoId) ??
store.getRepos().find((r) => resolve(r.path) === resolve(candidate.repoPath))
if (repo) {
recordPRIfNeeded(repo, outcome)
}
})
ipcMain.handle(
'gh:prForBranch',
async (
_event,
args: {
repoPath: string
branch: string
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
}
) => {
const repo = assertRegisteredRepo(args, store)
const pr = await getPRForBranch(
repo.path,
args.branch,
args.linkedPRNumber ?? null,
repoConnectionId(repo),
args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null
)
// Emit pr_created when a PR is first detected for a branch.
// Why here: the renderer polls gh:prForBranch to check PR status per worktree.
// This captures PRs opened from any workflow (Orca UI, gh CLI, github.com).
if (pr && !stats.hasCountedPR(pr.url)) {
stats.record({
type: 'pr_created',
at: Date.now(),
repoId: repo.id,
meta: { prNumber: pr.number, prUrl: pr.url }
})
}
return pr
}
)
ipcMain.handle(
'gh:refreshPRNow',
async (_event, args: { candidate: GitHubPRRefreshCandidate }) => {
const repo = assertRegisteredRepo(args.candidate.repoPath, store)
const outcome = await refreshPRNow({
...args.candidate,
repoPath: repo.path,
repoId: repo.id,
connectionId: repo.connectionId ?? args.candidate.connectionId,
connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState
})
recordPRIfNeeded(repo, outcome)
return outcome
}
)
ipcMain.handle(
'gh:enqueuePRRefresh',
(
_event,
args: {
candidate: GitHubPRRefreshCandidate
reason: GitHubPRRefreshReason
priority?: number
}
) => {
const repo = assertRegisteredRepo(args.candidate.repoPath, store)
enqueuePRRefresh(
{
...args.candidate,
repoPath: repo.path,
repoId: repo.id,
connectionId: repo.connectionId ?? args.candidate.connectionId,
connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState
},
args.reason,
args.priority ?? 0
)
return true
}
)
ipcMain.handle(
'gh:reportVisiblePRRefreshCandidates',
(event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => {
const senderId = event.sender.id
if (!prRefreshVisibilityCleanupRegistered.has(senderId)) {
prRefreshVisibilityCleanupRegistered.add(senderId)
event.sender.once('destroyed', () => {
prRefreshVisibilityCleanupRegistered.delete(senderId)
clearVisiblePRRefreshWindow(senderId)
})
}
const candidates = args.candidates.map((candidate) => {
const repo = assertRegisteredRepo(candidate.repoPath, store)
return {
...candidate,
repoPath: repo.path,
repoId: repo.id,
connectionId: repo.connectionId ?? candidate.connectionId,
connectionState: repo.connectionId ? 'connected' : candidate.connectionState
}
})
reportVisiblePRRefreshCandidates(candidates, args.generation, senderId)
return true
}
)
ipcMain.handle('gh:issue', (_event, args: { repoPath: string; number: number }) => {
const repo = assertRegisteredRepo(args, store)
return getIssue(repo.path, args.number, repoConnectionId(repo))
})
ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => {
const repo = assertRegisteredRepo(args, store)
// Why: listIssues now returns { items, error? }. The IPC handler unwraps to
// the items array for the existing contract; feature 1's UI consumes the
// richer envelope through `gh:listWorkItems` instead.
return listIssues(
repo.path,
args.limit,
repo.issueSourcePreference,
repoConnectionId(repo)
).then((r) => r.items)
})
ipcMain.handle(
'gh:createIssue',
(_event, args: { repoPath: string; title: string; body: string } & GitHubCreateIssueFields) => {
const repo = assertRegisteredRepo(args, store)
const fields =
args.labels !== undefined || args.assignees !== undefined
? { labels: args.labels, assignees: args.assignees }
: undefined
return createIssue(
repo.path,
args.title,
args.body,
repo.issueSourcePreference,
repoConnectionId(repo),
fields
)
}
)
ipcMain.handle(
'gh:listWorkItems',
(
_event,
args: {
repoPath: string
repoId?: string
limit?: number
query?: string
before?: string
noCache?: boolean
}
) => {
const repo = assertRegisteredRepo(args, store)
return listWorkItems(
repo.path,
args.limit,
args.query,
args.before,
repo.issueSourcePreference,
repoConnectionId(repo),
args.noCache
)
}
)
ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => {
const repo = assertRegisteredRepo(args, store)
return countWorkItems(repo.path, args.query, repo.issueSourcePreference, repoConnectionId(repo))
})
ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) =>
dispatchWorkItem(args, assertRegisteredRepo(args, store), getWorkItem)
)
ipcMain.handle(
'gh:workItemByOwnerRepo',
(
_event,
args: {
repoPath: string
owner: string
repo: string
number: number
type: 'issue' | 'pr'
}
) => {
const repo = assertRegisteredRepo(args, store)
return getWorkItemByOwnerRepo(
repo.path,
{ owner: args.owner, repo: args.repo },
args.number,
args.type,
repoConnectionId(repo)
)
}
)
ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) =>
dispatchWorkItem(args, assertRegisteredRepo(args, store), getWorkItemDetails)
)
ipcMain.handle(
'gh:prFileContents',
(
_event,
args: {
repoPath: string
prNumber: number
path: string
oldPath?: string
status: GitHubPRFile['status']
headSha: string
baseSha: string
}
) => {
const repo = assertRegisteredRepo(args, store)
return getPRFileContents({
repoPath: repo.path,
connectionId: repoConnectionId(repo),
prNumber: args.prNumber,
path: args.path,
oldPath: args.oldPath,
status: args.status,
headSha: args.headSha,
baseSha: args.baseSha
})
}
)
ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args, store)
return getRepoSlug(repo.path, repoConnectionId(repo))
})
ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args, store)
return getRepoUpstream(repo.path, repoConnectionId(repo))
})
ipcMain.handle(
'gh:prChecks',
(
_event,
args: {
repoPath: string
prNumber: number
headSha?: string
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}
) => {
const repo = assertRegisteredRepo(args, store)
return getPRChecks(
repo.path,
args.prNumber,
args.headSha,
args.prRepo ?? null,
{
noCache: args.noCache
},
repoConnectionId(repo)
)
}
)
ipcMain.handle(
'gh:prCheckDetails',
(
_event,
args: {
repoPath: string
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: GitHubOwnerRepo | null
}
) => {
const repo = assertRegisteredRepo(args, store)
return getPRCheckDetails(
repo.path,
{
checkRunId: args.checkRunId,
workflowRunId: args.workflowRunId,
checkName: args.checkName,
url: args.url,
prRepo: args.prRepo ?? null
},
repoConnectionId(repo)
)
}
)
ipcMain.handle(
'gh:prComments',
(
_event,
args: {
repoPath: string
prNumber: number
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}
) => {
const repo = assertRegisteredRepo(args, store)
return getPRComments(
repo.path,
args.prNumber,
{ noCache: args.noCache, prRepo: args.prRepo ?? null },
repoConnectionId(repo)
)
}
)
ipcMain.handle(
'gh:resolveReviewThread',
async (_event, args: { repoPath: string; threadId: string; resolve: boolean }) => {
const repo = assertRegisteredRepo(args, store)
// Why: thread resolve doesn't carry the PR number, so we cannot target
// a specific cache entry. The renderer cache stores per-(repo, type, number)
// entries — emitting a path-wide invalidation here would require a new
// event shape; instead, the drawer's existing thread-resolve UI updates
// its local state immediately and the next reopen pays one fresh fetch.
return resolveReviewThread(repo.path, args.threadId, args.resolve, repoConnectionId(repo))
}
)
ipcMain.handle(
'gh:setPRFileViewed',
async (
event,
args: {
repoPath: string
repoId?: string
prNumber: number
pullRequestId: string
path: string
viewed: boolean
}
) => {
const repo = assertRegisteredRepo(args, store)
if (
typeof args.prNumber !== 'number' ||
!Number.isInteger(args.prNumber) ||
args.prNumber < 1
) {
return false
}
if (!args.pullRequestId?.trim() || !args.path?.trim()) {
return false
}
const ok = await setPRFileViewed({
repoPath: repo.path,
connectionId: repoConnectionId(repo),
pullRequestId: args.pullRequestId.trim(),
path: args.path,
viewed: Boolean(args.viewed)
})
if (ok) {
broadcastWorkItemMutated(
{ repoPath: repo.path, type: 'pr', number: args.prNumber },
event.sender.id
)
}
return ok
}
)
ipcMain.handle(
'gh:addPRReviewCommentReply',
async (
event,
args: {
repoPath: string
repoId?: string
prNumber: number
commentId: number
body: string
threadId?: string
path?: string
line?: number
prRepo?: GitHubOwnerRepo | null
}
) => {
const repo = assertRegisteredRepo(args, store)
if (
typeof args.prNumber !== 'number' ||
!Number.isInteger(args.prNumber) ||
args.prNumber < 1
) {
return { ok: false, error: 'Invalid PR number' }
}
if (
typeof args.commentId !== 'number' ||
!Number.isInteger(args.commentId) ||
args.commentId < 1
) {
return { ok: false, error: 'Invalid comment ID' }
}
if (!args.body?.trim()) {
return { ok: false, error: 'Comment body required' }
}
const result = await addPRReviewCommentReply(
repo.path,
args.prNumber,
args.commentId,
args.body.trim(),
args.threadId,
args.path,
args.line,
repoConnectionId(repo),
args.prRepo ?? null
)
if (result.ok) {
broadcastWorkItemMutated(
{ repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber },
event.sender.id
)
}
return result
}
)
ipcMain.handle(
'gh:addPRReviewComment',
async (
event,
args: {
repoPath: string
prNumber: number
commitId: string
path: string
line: number
startLine?: number
body: string
}
) => {
const repo = assertRegisteredRepo(args, store)
if (
typeof args.prNumber !== 'number' ||
!Number.isInteger(args.prNumber) ||
args.prNumber < 1
) {
return { ok: false, error: 'Invalid PR number' }
}
if (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 1) {
return { ok: false, error: 'Invalid line number' }
}
if (
args.startLine !== undefined &&
(typeof args.startLine !== 'number' ||
!Number.isInteger(args.startLine) ||
args.startLine < 1 ||
args.startLine > args.line)
) {
return { ok: false, error: 'Invalid start line' }
}
if (!args.commitId?.trim()) {
return { ok: false, error: 'Missing PR head SHA' }
}
if (!args.path?.trim()) {
return { ok: false, error: 'File path required' }
}
if (!args.body?.trim()) {
return { ok: false, error: 'Comment body required' }
}
const result = await addPRReviewComment({
repoPath: repo.path,
prNumber: args.prNumber,
commitId: args.commitId.trim(),
path: args.path,
line: args.line,
startLine: args.startLine,
body: args.body.trim(),
connectionId: 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:updatePRTitle',
async (
event,
args: { repoPath: string; prNumber: number; title: string; prRepo?: GitHubOwnerRepo | null }
) => {
const repo = assertRegisteredRepo(args, store)
const ok = await updatePRTitle(
repo.path,
args.prNumber,
args.title,
repoConnectionId(repo),
args.prRepo ?? null
)
if (ok) {
broadcastWorkItemMutated(
{ repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber },
event.sender.id
)
}
return ok
}
)
ipcMain.handle(
'gh:mergePR',
async (
event,
args: {
repoPath: string
prNumber: number
method?: 'merge' | 'squash' | 'rebase'
prRepo?: GitHubOwnerRepo | null
}
) => {
const repo = assertRegisteredRepo(args, store)
const result = await mergePR(
repo.path,
args.prNumber,
args.method,
repoConnectionId(repo),
args.prRepo ?? null
)
if (result.ok) {
broadcastWorkItemMutated(
{ repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber },
event.sender.id
)
}
return result
}
)
ipcMain.handle(
'gh:setPRAutoMerge',
async (
event,
args: {
repoPath: string
prNumber: number
enabled: boolean
prRepo?: GitHubOwnerRepo | null
}
) => {
const repo = assertRegisteredRepo(args, store)
const result = await setPRAutoMerge(
repo.path,
args.prNumber,
args.enabled,
repoConnectionId(repo),
args.prRepo ?? null
)
if (result.ok) {
broadcastWorkItemMutated(
{ repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber },
event.sender.id
)
}
return result
}
)
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:removePRReviewers',
async (event, args: { repoPath: string; prNumber: number; reviewers: string[] }) => {
const repo = assertRegisteredRepo(args, store)
const result = await removePRReviewers(
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 (
event,
args: { repoPath: string; repoId?: string; number: number; updates: GitHubIssueUpdate }
) => {
const repo = assertRegisteredRepo(args, store)
if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) {
return { ok: false, error: 'Invalid issue number' }
}
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: 'Updates object is required' }
}
const result = await updateIssue(repo.path, args.number, args.updates, repoConnectionId(repo))
if (result.ok) {
broadcastWorkItemMutated(
{ repoPath: repo.path, repoId: repo.id, type: 'issue', number: args.number },
event.sender.id
)
}
return result
}
)
ipcMain.handle(
'gh:addIssueComment',
async (
event,
args: {
repoPath: string
repoId?: string
number: number
body: string
type?: 'issue' | 'pr'
prRepo?: GitHubOwnerRepo | null
}
) => {
const repo = assertRegisteredRepo(args, store)
if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) {
return { ok: false, error: 'Invalid issue number' }
}
if (!args.body?.trim()) {
return { ok: false, error: 'Comment body required' }
}
const result = await addIssueComment(
repo.path,
args.number,
args.body.trim(),
repoConnectionId(repo),
args.prRepo ?? null
)
if (result.ok) {
// Why: PR conversation comments hit `/issues/N/comments` too, but the
// drawer's cache key uses type='pr'. The caller passes through which
// drawer they're posting from so we only invalidate the matching key
// — broadcasting both would evict an unrelated PR/issue that happens
// to share the number.
broadcastWorkItemMutated(
{ repoPath: repo.path, repoId: repo.id, type: args.type ?? 'issue', number: args.number },
event.sender.id
)
}
return result
}
)
ipcMain.handle('gh:listLabels', (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args, store)
return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
})
ipcMain.handle('gh:listAssignableUsers', (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args, store)
return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
})
// Star operations target the Orca repo itself — no repoPath validation needed
ipcMain.handle('gh:viewer', () => getAuthenticatedViewer())
ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred())
ipcMain.handle('gh:starOrca', async (_event, source: unknown) => {
const sourceParse = appStarSourceSchema.safeParse(source)
const starred = await starOrca()
if (starred && sourceParse.success) {
// Why: this main-owned event bypasses renderer telemetry IPC, so cohort
// context must be attached here on the successful star path.
track('app_starred_orca', {
source: sourceParse.data,
...getCohortAtEmit()
})
}
return starred
})
// Why: `rate_limit` is exempt from GitHub's rate-limit accounting, so
// polling is cheap. A 30s in-process cache still avoids the gh subprocess
// cost on every render — see getRateLimit for the ttl rationale. Force
// parameter lets the renderer bust the cache after a known-expensive op
// (e.g. post-ProjectPicker discovery) without waiting out the ttl.
ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) =>
getRateLimit(args?.force ? { force: true } : undefined)
)
ipcMain.handle('gh:diagnoseAuth', () => diagnoseGhAuth())
// ── GitHub ProjectV2 view handlers ─────────────────────────────────
// Why: registered unconditionally so enabling the experimental flag at
// runtime takes effect without a restart. The renderer gates entry points.
// Handlers never throw across IPC — every failure mode resolves through the
// GitHubProjectViewError envelope.
ipcMain.handle('gh:listAccessibleProjects', () => listAccessibleProjects())
ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) =>
resolveProjectRef(args)
)
ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) =>
listProjectViews(args)
)
ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) =>
getProjectViewTable(args)
)
ipcMain.handle(
'gh:projectWorkItemDetailsBySlug',
(_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args)
)
ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) =>
updateProjectItemFieldValue(args)
)
ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) =>
clearProjectItemFieldValue(args)
)
ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) =>
updateIssueBySlug(args)
)
ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) =>
updatePullRequestBySlug(args)
)
ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) =>
addIssueCommentBySlug(args)
)
ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) =>
updateIssueCommentBySlug(args)
)
ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) =>
deleteIssueCommentBySlug(args)
)
ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) =>
listLabelsBySlug(args)
)
ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) =>
listAssignableUsersBySlug(args)
)
ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) =>
listIssueTypesBySlug(args)
)
ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) =>
updateIssueTypeBySlug(args)
)
// Why: issue-source preference writes go through the generic `repos:update`
// IPC (extended in this PR to accept `issueSourcePreference`). Routing
// through the same channel keeps a single write path, guarantees the
// `repos:changed` broadcast is emitted, and avoids two channels racing to
// persist the same field with different validation and eviction semantics.
// Reads piggyback on the `Repo` record already delivered by `repos:list`.
}