/* 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() // 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`. }