780 lines
23 KiB
TypeScript
780 lines
23 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,
|
|
GitHubIssueUpdate,
|
|
GitHubOwnerRepo,
|
|
GitHubPullRequestStateUpdate
|
|
} from '../../shared/types'
|
|
import type { Store } from '../persistence'
|
|
import type { StatsCollector } from '../stats/collector'
|
|
import {
|
|
getPRForBranch,
|
|
getIssue,
|
|
getRepoSlug,
|
|
listIssues,
|
|
listWorkItems,
|
|
countWorkItems,
|
|
getWorkItem,
|
|
getWorkItemByOwnerRepo,
|
|
createIssue,
|
|
updateIssue,
|
|
addIssueComment,
|
|
listLabels,
|
|
listAssignableUsers,
|
|
getAuthenticatedViewer,
|
|
getPRChecks,
|
|
getPRComments,
|
|
resolveReviewThread,
|
|
setPRFileViewed,
|
|
addPRReviewComment,
|
|
addPRReviewCommentReply,
|
|
updatePRTitle,
|
|
mergePR,
|
|
updatePRState,
|
|
rerunPRChecks,
|
|
requestPRReviewers,
|
|
checkOrcaStarred,
|
|
starOrca
|
|
} from '../github/client'
|
|
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'
|
|
|
|
// 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 {
|
|
ipcMain.handle(
|
|
'gh:prForBranch',
|
|
async (_event, args: { repoPath: string; branch: string; linkedPRNumber?: number | null }) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
const pr = await getPRForBranch(
|
|
repo.path,
|
|
args.branch,
|
|
args.linkedPRNumber ?? null,
|
|
repoConnectionId(repo)
|
|
)
|
|
// 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: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 }) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
return createIssue(
|
|
repo.path,
|
|
args.title,
|
|
args.body,
|
|
repo.issueSourcePreference,
|
|
repoConnectionId(repo)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:listWorkItems',
|
|
(
|
|
_event,
|
|
args: { repoPath: string; repoId?: string; limit?: number; query?: string; before?: string }
|
|
) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
return listWorkItems(
|
|
repo.path,
|
|
args.limit,
|
|
args.query,
|
|
args.before,
|
|
repo.issueSourcePreference,
|
|
repoConnectionId(repo)
|
|
)
|
|
}
|
|
)
|
|
|
|
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: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: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
|
|
prNumber: number
|
|
commentId: number
|
|
body: string
|
|
threadId?: string
|
|
path?: string
|
|
line?: number
|
|
}
|
|
) => {
|
|
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)
|
|
)
|
|
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: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 (
|
|
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; number: number; body: string; type?: 'issue' | 'pr' }
|
|
) => {
|
|
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)
|
|
)
|
|
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', () => starOrca())
|
|
|
|
// 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`.
|
|
}
|