mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
* 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>
1015 lines
30 KiB
TypeScript
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`.
|
|
}
|