mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.
Move each domain into its own folder and drop the now-redundant prefix:
src/shared/github-pr-types.ts -> src/shared/github/pull-request-types.ts
src/shared/worktree-id.ts -> src/shared/worktree/id.ts
src/shared/linear-links.ts -> src/shared/linear/links.ts
This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.
Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.
Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.
Two things `tsc` cannot catch, handled explicitly:
- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
entry is REPOINTED to the new path rather than pruned. Pruning would drop the
bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
(`mobile/node_modules` is empty). Instead every relative specifier in the repo
was resolved against the filesystem: 174 unresolved before this change and 174
after — identical, so nothing broke in mobile either.
The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.
Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
1312 lines
38 KiB
TypeScript
1312 lines
38 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 } from 'electron'
|
|
import { resolve } from 'node:path'
|
|
import type {
|
|
Repo,
|
|
GitHubCreateIssueFields,
|
|
GitHubIssueUpdate,
|
|
GitHubOwnerRepo,
|
|
GitHubPullRequestStateUpdate,
|
|
GitHubPRRefreshCandidate,
|
|
GitHubPRRefreshEnqueueResult,
|
|
GitHubPRRefreshReason,
|
|
GitHubReactionContent,
|
|
PRRefreshOutcome,
|
|
GitHubPRFile
|
|
} from '../../shared/types'
|
|
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
|
import type { TaskSourceContext } from '../../shared/task-source-context'
|
|
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,
|
|
setPRCommentReaction,
|
|
resolveReviewThread,
|
|
setPRFileViewed,
|
|
addPRReviewComment,
|
|
addPRReviewCommentReply,
|
|
updatePRTitle,
|
|
mergePR,
|
|
setPRAutoMerge,
|
|
updatePRState,
|
|
rerunPRChecks,
|
|
requestPRReviewers,
|
|
removePRReviewers,
|
|
checkOrcaStarred,
|
|
starOrca
|
|
} from '../github/client'
|
|
import type { GitHubPRBranchLookupOptions } 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 {
|
|
notePRRefreshValidationDenial,
|
|
type PRRefreshValidationDenialReason
|
|
} from '../github/pr-refresh-validation-backoff'
|
|
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
|
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,
|
|
ListAccessibleProjectsArgs,
|
|
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'
|
|
import { sendToTrustedUIRenderer } from './ui'
|
|
|
|
const prRefreshVisibilityCleanupRegistered = new Set<number>()
|
|
|
|
// Why: the app renderer owns the SWR cache; browser guests cannot consume this
|
|
// event. Skip the origin because it already updated its cache optimistically.
|
|
function broadcastWorkItemMutated(
|
|
payload: {
|
|
repoPath: string
|
|
repoId?: string
|
|
type: 'issue' | 'pr'
|
|
number: number
|
|
},
|
|
senderId?: number
|
|
): void {
|
|
sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId)
|
|
}
|
|
|
|
// 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 | null
|
|
sourceContext?: TaskSourceContext | null
|
|
}
|
|
|
|
type RegisteredRepoValidationResult =
|
|
| { kind: 'ok'; repo: Repo }
|
|
| { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string }
|
|
|
|
function validateRegisteredRepo(
|
|
args: string | RepoScopedArgs,
|
|
store: Store,
|
|
repos = store.getRepos()
|
|
): RegisteredRepoValidationResult {
|
|
const repoPath = typeof args === 'string' ? args : args.repoPath
|
|
const repoId = typeof args === 'string' ? undefined : args.repoId
|
|
const resolvedRepoPath = resolve(repoPath)
|
|
const repo = repos.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath))
|
|
if (!repo) {
|
|
return {
|
|
kind: 'denied',
|
|
reason: 'unknown-repo',
|
|
message: 'Access denied: unknown repository path'
|
|
}
|
|
}
|
|
if (repoId && resolve(repo.path) !== resolvedRepoPath) {
|
|
return {
|
|
kind: 'denied',
|
|
reason: 'repo-path-mismatch',
|
|
message: 'Access denied: repository path does not match repo id'
|
|
}
|
|
}
|
|
if (
|
|
typeof args !== 'string' &&
|
|
args.sourceContext?.provider === 'github' &&
|
|
args.sourceContext.hostId !== getRepoExecutionHostId(repo)
|
|
) {
|
|
return {
|
|
kind: 'denied',
|
|
reason: 'host-mismatch',
|
|
message: 'Access denied: GitHub source host does not match repository host'
|
|
}
|
|
}
|
|
return { kind: 'ok', repo }
|
|
}
|
|
|
|
function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo {
|
|
const result = validateRegisteredRepo(args, store)
|
|
if (result.kind === 'denied') {
|
|
throw new Error(result.message)
|
|
}
|
|
return result.repo
|
|
}
|
|
|
|
function repoConnectionId(repo: Repo): string | null {
|
|
return repo.connectionId ?? null
|
|
}
|
|
|
|
function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: string }] {
|
|
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
|
|
return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : []
|
|
}
|
|
|
|
function applyRepoToPRRefreshCandidate(
|
|
store: Store,
|
|
repo: Repo,
|
|
candidate: GitHubPRRefreshCandidate
|
|
): GitHubPRRefreshCandidate {
|
|
const localGitOptions = localGitOptionArgs(store, repo)[0]
|
|
const appliedCandidate = { ...candidate }
|
|
delete appliedCandidate.localGitOptions
|
|
delete appliedCandidate.connectionId
|
|
delete appliedCandidate.executionHostId
|
|
delete appliedCandidate.connectionState
|
|
return {
|
|
...appliedCandidate,
|
|
repoPath: repo.path,
|
|
repoId: repo.id,
|
|
...(localGitOptions ? { localGitOptions } : {}),
|
|
connectionId: repoConnectionId(repo),
|
|
executionHostId: repo.executionHostId ?? null,
|
|
connectionState: repo.connectionId ? 'connected' : 'unknown'
|
|
}
|
|
}
|
|
|
|
function validateAutomaticPRRefreshCandidate(
|
|
candidate: GitHubPRRefreshCandidate,
|
|
store: Store,
|
|
repos = store.getRepos()
|
|
):
|
|
| { kind: 'ok'; candidate: GitHubPRRefreshCandidate }
|
|
| {
|
|
kind: 'skipped'
|
|
result: Extract<GitHubPRRefreshEnqueueResult, { kind: 'skipped' }>
|
|
} {
|
|
const result = validateRegisteredRepo(candidate, store, repos)
|
|
if (result.kind === 'denied') {
|
|
const skippedReason = notePRRefreshValidationDenial({
|
|
repoId: candidate.repoId,
|
|
repoPath: candidate.repoPath,
|
|
reason: result.reason
|
|
})
|
|
return { kind: 'skipped', result: { kind: 'skipped', skippedReason } }
|
|
}
|
|
return { kind: 'ok', candidate: applyRepoToPRRefreshCandidate(store, result.repo, candidate) }
|
|
}
|
|
|
|
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
|
|
acceptMergedFallbackPR?: boolean
|
|
currentHeadOid?: string | null
|
|
}
|
|
) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
const localGitOptions = localGitOptionArgs(store, repo)[0]
|
|
const hostedReviewOptionArgs: [] | [{ localGitExecOptions: { wslDistro?: string } }] =
|
|
localGitOptions ? [{ localGitExecOptions: localGitOptions }] : []
|
|
const currentHeadOid =
|
|
typeof args.currentHeadOid === 'string' && args.currentHeadOid.trim().length > 0
|
|
? args.currentHeadOid.trim()
|
|
: null
|
|
const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptionArgs[0]
|
|
? { ...hostedReviewOptionArgs[0] }
|
|
: args.acceptMergedFallbackPR === true || currentHeadOid !== null
|
|
? {}
|
|
: undefined
|
|
if (lookupOptions && args.acceptMergedFallbackPR === true) {
|
|
lookupOptions.acceptMergedFallbackPR = true
|
|
}
|
|
if (lookupOptions && currentHeadOid !== null) {
|
|
lookupOptions.currentHeadOid = currentHeadOid
|
|
}
|
|
const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions
|
|
? [lookupOptions]
|
|
: []
|
|
const pr = await getPRForBranch(
|
|
repo.path,
|
|
args.branch,
|
|
args.linkedPRNumber ?? null,
|
|
repoConnectionId(repo),
|
|
args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null,
|
|
...lookupOptionArgs
|
|
)
|
|
// 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, store)
|
|
const outcome = await refreshPRNow(applyRepoToPRRefreshCandidate(store, repo, args.candidate))
|
|
recordPRIfNeeded(repo, outcome)
|
|
return outcome
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:enqueuePRRefresh',
|
|
(
|
|
event,
|
|
args: {
|
|
candidate: GitHubPRRefreshCandidate
|
|
reason: GitHubPRRefreshReason
|
|
priority?: number
|
|
}
|
|
): GitHubPRRefreshEnqueueResult => {
|
|
const validation = validateAutomaticPRRefreshCandidate(args.candidate, store)
|
|
if (validation.kind === 'skipped') {
|
|
return validation.result
|
|
}
|
|
const senderWindowId = event?.sender?.id
|
|
enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0, senderWindowId)
|
|
return { kind: 'queued' }
|
|
}
|
|
)
|
|
|
|
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: GitHubPRRefreshCandidate[] = []
|
|
const repos = store.getRepos()
|
|
for (const candidate of args.candidates) {
|
|
const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos)
|
|
if (validation.kind === 'ok') {
|
|
candidates.push(validation.candidate)
|
|
}
|
|
}
|
|
reportVisiblePRRefreshCandidates(candidates, args.generation, senderId)
|
|
return true
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:issue',
|
|
(
|
|
_event,
|
|
args: {
|
|
repoPath: string
|
|
repoId?: string | null
|
|
sourceContext?: TaskSourceContext | null
|
|
number: number
|
|
}
|
|
) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
return getIssue(
|
|
repo.path,
|
|
args.number,
|
|
repoConnectionId(repo),
|
|
...localGitOptionArgs(store, 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),
|
|
...localGitOptionArgs(store, repo)
|
|
).then((r) => r.items)
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'gh:createIssue',
|
|
(_event, args: RepoScopedArgs & { 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,
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:listWorkItems',
|
|
(
|
|
_event,
|
|
args: {
|
|
repoPath: string
|
|
repoId?: string
|
|
limit?: number
|
|
query?: string
|
|
page?: number
|
|
noCache?: boolean
|
|
}
|
|
) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
return listWorkItems(
|
|
repo.path,
|
|
args.limit,
|
|
args.query,
|
|
args.page,
|
|
repo.issueSourcePreference,
|
|
repoConnectionId(repo),
|
|
args.noCache,
|
|
...localGitOptionArgs(store, 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),
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
})
|
|
|
|
ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
return dispatchWorkItem(args, repo, getWorkItem, localGitOptionArgs(store, repo)[0])
|
|
})
|
|
ipcMain.handle(
|
|
'gh:workItemByOwnerRepo',
|
|
(
|
|
_event,
|
|
args: {
|
|
repoPath: string
|
|
owner: string
|
|
repo: string
|
|
host?: string
|
|
number: number
|
|
type: 'issue' | 'pr'
|
|
}
|
|
) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
return getWorkItemByOwnerRepo(
|
|
repo.path,
|
|
// Why: Enterprise host identity must survive the IPC boundary or the
|
|
// lookup falls back to gh's default host for a same-named repo.
|
|
{ owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) },
|
|
args.number,
|
|
args.type,
|
|
repoConnectionId(repo),
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
}
|
|
)
|
|
ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
return dispatchWorkItem(args, repo, getWorkItemDetails, localGitOptionArgs(store, repo)[0])
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'gh:notifyWorkItemMutated',
|
|
(
|
|
event,
|
|
args: {
|
|
repoPath: string
|
|
repoId?: string
|
|
type: 'issue' | 'pr'
|
|
number: number
|
|
}
|
|
) => {
|
|
const repo = args.repoId
|
|
? store.getRepos().find((candidate) => candidate.id === args.repoId)
|
|
: assertRegisteredRepo(args, store)
|
|
if (!repo) {
|
|
return false
|
|
}
|
|
if (
|
|
(args.type !== 'issue' && args.type !== 'pr') ||
|
|
typeof args.number !== 'number' ||
|
|
!Number.isInteger(args.number) ||
|
|
args.number < 1
|
|
) {
|
|
return false
|
|
}
|
|
broadcastWorkItemMutated(
|
|
{
|
|
repoPath: repo.path,
|
|
repoId: repo.id,
|
|
type: args.type,
|
|
number: args.number
|
|
},
|
|
event.sender.id
|
|
)
|
|
return true
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:prFileContents',
|
|
(
|
|
_event,
|
|
args: {
|
|
repoPath: string
|
|
prNumber: number
|
|
prRepo?: GitHubOwnerRepo | null
|
|
path: string
|
|
oldPath?: string
|
|
status: GitHubPRFile['status']
|
|
headSha: string
|
|
baseSha: string
|
|
}
|
|
) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
return getPRFileContents({
|
|
repoPath: repo.path,
|
|
connectionId: repoConnectionId(repo),
|
|
localGitOptions: localGitOptionArgs(store, repo)[0],
|
|
prRepo: args.prRepo ?? null,
|
|
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)
|
|
const localGitOptions = localGitOptionArgs(store, repo)[0]
|
|
return localGitOptions
|
|
? getRepoSlug(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions })
|
|
: getRepoSlug(repo.path, repoConnectionId(repo))
|
|
})
|
|
|
|
ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
const localGitOptions = localGitOptionArgs(store, repo)[0]
|
|
return localGitOptions
|
|
? getRepoUpstream(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions })
|
|
: getRepoUpstream(repo.path, repoConnectionId(repo))
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'gh:prChecks',
|
|
(
|
|
_event,
|
|
args: {
|
|
repoPath: string
|
|
repoId?: string | null
|
|
sourceContext?: TaskSourceContext | null
|
|
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),
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:prCheckDetails',
|
|
(
|
|
_event,
|
|
args: {
|
|
repoPath: string
|
|
repoId?: string | null
|
|
sourceContext?: TaskSourceContext | null
|
|
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),
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:prComments',
|
|
(
|
|
_event,
|
|
args: {
|
|
repoPath: string
|
|
repoId?: string | null
|
|
sourceContext?: TaskSourceContext | null
|
|
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),
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:setPRCommentReaction',
|
|
(
|
|
_event,
|
|
args: {
|
|
repoPath: string
|
|
repoId?: string | null
|
|
sourceContext?: TaskSourceContext | null
|
|
reactionSubjectId: string
|
|
content: GitHubReactionContent
|
|
reacted: boolean
|
|
prRepo?: GitHubOwnerRepo | null
|
|
}
|
|
) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
if (!args.reactionSubjectId?.trim()) {
|
|
return false
|
|
}
|
|
return setPRCommentReaction(
|
|
repo.path,
|
|
args.reactionSubjectId.trim(),
|
|
args.content,
|
|
args.reacted,
|
|
repoConnectionId(repo),
|
|
args.prRepo ?? null,
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:resolveReviewThread',
|
|
async (
|
|
_event,
|
|
args: {
|
|
repoPath: string
|
|
repoId?: string | null
|
|
sourceContext?: TaskSourceContext | null
|
|
threadId: string
|
|
resolve: boolean
|
|
prRepo?: GitHubOwnerRepo | null
|
|
}
|
|
) => {
|
|
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),
|
|
args.prRepo ?? null,
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:setPRFileViewed',
|
|
async (
|
|
event,
|
|
args: {
|
|
repoPath: string
|
|
repoId?: string
|
|
prNumber: number
|
|
prRepo?: GitHubOwnerRepo | null
|
|
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),
|
|
localGitOptions: localGitOptionArgs(store, repo)[0],
|
|
prRepo: args.prRepo ?? null,
|
|
pullRequestId: args.pullRequestId.trim(),
|
|
path: args.path,
|
|
viewed: Boolean(args.viewed)
|
|
})
|
|
if (ok) {
|
|
broadcastWorkItemMutated(
|
|
{ repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber },
|
|
event.sender.id
|
|
)
|
|
}
|
|
return ok
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:addPRReviewCommentReply',
|
|
async (
|
|
event,
|
|
args: {
|
|
repoPath: string
|
|
repoId?: string
|
|
sourceContext?: TaskSourceContext | null
|
|
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,
|
|
...localGitOptionArgs(store, 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
|
|
prRepo?: GitHubOwnerRepo | null
|
|
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,
|
|
prRepo: args.prRepo ?? null,
|
|
prNumber: args.prNumber,
|
|
commitId: args.commitId.trim(),
|
|
path: args.path,
|
|
line: args.line,
|
|
startLine: args.startLine,
|
|
body: args.body.trim(),
|
|
connectionId: repoConnectionId(repo),
|
|
localGitOptions: localGitOptionArgs(store, repo)[0]
|
|
})
|
|
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,
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
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
|
|
repoId?: string | null
|
|
sourceContext?: TaskSourceContext | null
|
|
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,
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
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
|
|
repoId?: string | null
|
|
sourceContext?: TaskSourceContext | null
|
|
prNumber: number
|
|
enabled: boolean
|
|
method?: 'merge' | 'squash' | 'rebase'
|
|
prRepo?: GitHubOwnerRepo | null
|
|
}
|
|
) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
const result = await setPRAutoMerge(
|
|
repo.path,
|
|
args.prNumber,
|
|
args.enabled,
|
|
args.method,
|
|
repoConnectionId(repo),
|
|
args.prRepo ?? null,
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
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: RepoScopedArgs & {
|
|
prNumber: number
|
|
updates: GitHubPullRequestStateUpdate
|
|
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 pull request number' }
|
|
}
|
|
const result = await updatePRState(
|
|
repo.path,
|
|
args.prNumber,
|
|
args.updates,
|
|
repoConnectionId(repo),
|
|
args.prRepo ?? null,
|
|
...localGitOptionArgs(store, 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: RepoScopedArgs & {
|
|
prNumber: number
|
|
headSha?: string
|
|
failedOnly?: boolean
|
|
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 pull request number' }
|
|
}
|
|
return rerunPRChecks(
|
|
repo.path,
|
|
args.prNumber,
|
|
{ headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null },
|
|
repoConnectionId(repo),
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'gh:requestPRReviewers',
|
|
async (
|
|
event,
|
|
args: RepoScopedArgs & {
|
|
prNumber: number
|
|
reviewers: string[]
|
|
prRepo?: GitHubOwnerRepo | null
|
|
}
|
|
) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
const result = await requestPRReviewers(
|
|
repo.path,
|
|
args.prNumber,
|
|
args.reviewers,
|
|
repoConnectionId(repo),
|
|
args.prRepo ?? null,
|
|
...localGitOptionArgs(store, 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: RepoScopedArgs & {
|
|
prNumber: number
|
|
reviewers: string[]
|
|
prRepo?: GitHubOwnerRepo | null
|
|
}
|
|
) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
const result = await removePRReviewers(
|
|
repo.path,
|
|
args.prNumber,
|
|
args.reviewers,
|
|
repoConnectionId(repo),
|
|
args.prRepo ?? null,
|
|
...localGitOptionArgs(store, 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: RepoScopedArgs & { 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),
|
|
...localGitOptionArgs(store, 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
|
|
sourceContext?: TaskSourceContext | null
|
|
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,
|
|
...localGitOptionArgs(store, 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: RepoScopedArgs) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
return listLabels(
|
|
repo.path,
|
|
repo.issueSourcePreference,
|
|
repoConnectionId(repo),
|
|
...localGitOptionArgs(store, repo)
|
|
)
|
|
})
|
|
|
|
ipcMain.handle('gh:listAssignableUsers', (_event, args: RepoScopedArgs) => {
|
|
const repo = assertRegisteredRepo(args, store)
|
|
return listAssignableUsers(
|
|
repo.path,
|
|
repo.issueSourcePreference,
|
|
repoConnectionId(repo),
|
|
...localGitOptionArgs(store, 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', (_event, args?: { host?: string }) =>
|
|
diagnoseGhAuth(args?.host)
|
|
)
|
|
|
|
// ── 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', (_event, args?: ListAccessibleProjectsArgs) =>
|
|
listAccessibleProjects(args)
|
|
)
|
|
|
|
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`.
|
|
}
|