mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
899 lines
28 KiB
TypeScript
899 lines
28 KiB
TypeScript
/* eslint-disable max-lines -- Why: slug-addressed mutations + work-item
|
|
details share the validate/runRest/runGraphql plumbing with the read path.
|
|
Keeping them together preserves a single review surface for the write side. */
|
|
import {
|
|
acquire,
|
|
release,
|
|
extractExecError,
|
|
ghExecFileAsync,
|
|
rateLimitGuard,
|
|
noteRateLimitSpend,
|
|
classifyProjectError,
|
|
rateLimitedError,
|
|
runGraphql,
|
|
runRest,
|
|
validateSlugArgs,
|
|
assertPositiveInt,
|
|
type GraphqlVars
|
|
} from './internals'
|
|
import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types'
|
|
import type {
|
|
AddIssueCommentBySlugArgs,
|
|
ClearProjectItemFieldArgs,
|
|
DeleteIssueCommentBySlugArgs,
|
|
GitHubProjectCommentMutationResult,
|
|
GitHubProjectFieldMutationValue,
|
|
GitHubProjectMutationResult,
|
|
ListAssignableUsersBySlugArgs,
|
|
ListAssignableUsersBySlugResult,
|
|
ListIssueTypesBySlugArgs,
|
|
ListIssueTypesBySlugResult,
|
|
ListLabelsBySlugArgs,
|
|
ListLabelsBySlugResult,
|
|
ProjectWorkItemDetailsBySlugArgs,
|
|
ProjectWorkItemDetailsBySlugResult,
|
|
UpdateIssueBySlugArgs,
|
|
UpdateIssueCommentBySlugArgs,
|
|
UpdateIssueTypeBySlugArgs,
|
|
UpdatePullRequestBySlugArgs,
|
|
UpdateProjectItemFieldArgs
|
|
} from '../../../shared/github-project-types'
|
|
|
|
// ─── Project field mutations ──────────────────────────────────────────
|
|
|
|
class UnknownFieldMutationKindError extends Error {
|
|
constructor(kind: string) {
|
|
super(`Unknown project field mutation kind: ${kind}`)
|
|
}
|
|
}
|
|
|
|
function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string {
|
|
// Serialize the value fragment for the GraphQL mutation. We use GraphQL
|
|
// variables for every dynamic piece, so here we only pick the variable name
|
|
// to reference per value kind.
|
|
switch (value.kind) {
|
|
case 'single-select':
|
|
return 'singleSelectOptionId: $value'
|
|
case 'iteration':
|
|
return 'iterationId: $value'
|
|
case 'text':
|
|
return 'text: $value'
|
|
case 'number':
|
|
return 'number: $value'
|
|
case 'date':
|
|
return 'date: $value'
|
|
}
|
|
// Why: keep a runtime guard for malformed IPC payloads while lint enforces
|
|
// that every typed mutation kind is handled above.
|
|
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
|
|
}
|
|
|
|
function mutationValueVar(value: GitHubProjectFieldMutationValue): {
|
|
type: string
|
|
val: string | number
|
|
} {
|
|
switch (value.kind) {
|
|
case 'single-select':
|
|
return { type: 'String!', val: value.optionId }
|
|
case 'iteration':
|
|
return { type: 'String!', val: value.iterationId }
|
|
case 'text':
|
|
return { type: 'String!', val: value.text }
|
|
case 'number':
|
|
return { type: 'Float!', val: value.number }
|
|
case 'date':
|
|
return { type: 'Date!', val: value.date }
|
|
}
|
|
// Why: see graphqlValueForFieldMutation — surface unknown kinds loudly
|
|
// instead of returning undefined and dispatching an invalid mutation.
|
|
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
|
|
}
|
|
|
|
export async function updateProjectItemFieldValue(
|
|
args: UpdateProjectItemFieldArgs
|
|
): Promise<GitHubProjectMutationResult> {
|
|
if (!args.projectId || !args.itemId || !args.fieldId) {
|
|
return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } }
|
|
}
|
|
let valFrag: string
|
|
let valVar: { type: string; val: string | number }
|
|
try {
|
|
valFrag = graphqlValueForFieldMutation(args.value)
|
|
valVar = mutationValueVar(args.value)
|
|
} catch (err) {
|
|
if (err instanceof UnknownFieldMutationKindError) {
|
|
return { ok: false, error: { type: 'validation_error', message: err.message } }
|
|
}
|
|
throw err
|
|
}
|
|
const query = `
|
|
mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valVar.type}) {
|
|
updateProjectV2ItemFieldValue(input: {
|
|
projectId: $projectId
|
|
itemId: $itemId
|
|
fieldId: $fieldId
|
|
value: { ${valFrag} }
|
|
}) { projectV2Item { id } }
|
|
}
|
|
`
|
|
const vars: GraphqlVars = {
|
|
projectId: args.projectId,
|
|
itemId: args.itemId,
|
|
fieldId: args.fieldId,
|
|
value: valVar.val
|
|
}
|
|
const res = await runGraphql<unknown>(query, vars)
|
|
if (!res.ok) {
|
|
return { ok: false, error: res.error }
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
export async function clearProjectItemFieldValue(
|
|
args: ClearProjectItemFieldArgs
|
|
): Promise<GitHubProjectMutationResult> {
|
|
if (!args.projectId || !args.itemId || !args.fieldId) {
|
|
return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } }
|
|
}
|
|
const query = `
|
|
mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) {
|
|
clearProjectV2ItemFieldValue(input: {
|
|
projectId: $projectId
|
|
itemId: $itemId
|
|
fieldId: $fieldId
|
|
}) { projectV2Item { id } }
|
|
}
|
|
`
|
|
const res = await runGraphql<unknown>(query, {
|
|
projectId: args.projectId,
|
|
itemId: args.itemId,
|
|
fieldId: args.fieldId
|
|
})
|
|
if (!res.ok) {
|
|
return { ok: false, error: res.error }
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
// ─── Slug-addressed issue/PR mutations ────────────────────────────────
|
|
|
|
export async function updateIssueBySlug(
|
|
args: UpdateIssueBySlugArgs
|
|
): Promise<GitHubProjectMutationResult> {
|
|
const v = validateSlugArgs(args.owner, args.repo)
|
|
if (!v.ok) {
|
|
return v
|
|
}
|
|
const n = assertPositiveInt(args.number, 'number')
|
|
if (!n.ok) {
|
|
return { ok: false, error: n.error }
|
|
}
|
|
if (!args.updates || typeof args.updates !== 'object') {
|
|
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
|
|
}
|
|
const {
|
|
title,
|
|
body,
|
|
state,
|
|
stateReason,
|
|
duplicateOf,
|
|
addLabels,
|
|
removeLabels,
|
|
addAssignees,
|
|
removeAssignees
|
|
} = args.updates
|
|
|
|
if (duplicateOf !== undefined && (state !== 'closed' || stateReason !== 'duplicate')) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
type: 'validation_error',
|
|
message: 'Duplicate target is only valid when closing as duplicate.'
|
|
}
|
|
}
|
|
}
|
|
if (state === 'closed' && stateReason === 'duplicate' && duplicateOf === undefined) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
type: 'validation_error',
|
|
message: 'Duplicate target issue number is required.'
|
|
}
|
|
}
|
|
}
|
|
if (duplicateOf !== undefined) {
|
|
const duplicate = assertPositiveInt(duplicateOf, 'duplicateOf')
|
|
if (!duplicate.ok) {
|
|
return { ok: false, error: duplicate.error }
|
|
}
|
|
}
|
|
|
|
// Title/body go through PATCH /repos/{owner}/{repo}/issues/{n}.
|
|
// State uses gh issue close/reopen so duplicate closes can record a target.
|
|
// Labels/assignees go through their dedicated endpoints.
|
|
const base = `repos/${args.owner}/${args.repo}/issues/${args.number}`
|
|
|
|
if (state !== undefined) {
|
|
const stateArgs =
|
|
state === 'closed'
|
|
? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`]
|
|
: ['issue', 'reopen', String(args.number), '--repo', `${args.owner}/${args.repo}`]
|
|
if (state === 'closed') {
|
|
if (stateReason === 'completed') {
|
|
stateArgs.push('--reason', 'completed')
|
|
} else if (stateReason === 'not_planned') {
|
|
stateArgs.push('--reason', 'not planned')
|
|
} else if (stateReason === 'duplicate') {
|
|
stateArgs.push('--duplicate-of', String(duplicateOf))
|
|
}
|
|
}
|
|
await acquire()
|
|
try {
|
|
await ghExecFileAsync(stateArgs, { encoding: 'utf-8' })
|
|
} catch (err) {
|
|
const { stderr, stdout } = extractExecError(err)
|
|
return { ok: false, error: classifyProjectError(stderr, stdout) }
|
|
} finally {
|
|
release()
|
|
}
|
|
}
|
|
|
|
// 1) PATCH body
|
|
if (title !== undefined || body !== undefined) {
|
|
const patchArgs: string[] = ['-X', 'PATCH', base]
|
|
if (title !== undefined) {
|
|
patchArgs.push('--raw-field', `title=${title}`)
|
|
}
|
|
if (body !== undefined) {
|
|
patchArgs.push('--raw-field', `body=${body}`)
|
|
}
|
|
const r = await runRest<unknown>(patchArgs)
|
|
if (!r.ok) {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
}
|
|
|
|
// 2) Labels — collapse multi-delete fan-out into a single PUT when removing
|
|
// >1 label. PUT /labels replaces the entire label set, so we fetch the
|
|
// current labels first and compute the resulting set client-side. This
|
|
// turns an N-delete + 1-add (=N+1 calls) into 1-fetch + 1-PUT (=2 calls)
|
|
// once removeLabels has more than one entry, capping the cost at 2 even
|
|
// for a "remove all 20 labels" mutation.
|
|
const removeCount = removeLabels?.length ?? 0
|
|
const addCount = addLabels?.length ?? 0
|
|
if (removeCount > 1) {
|
|
type RawLabelResp = { name?: string }[]
|
|
const fetched = await runRest<RawLabelResp>(['-X', 'GET', `${base}/labels`])
|
|
if (!fetched.ok) {
|
|
return { ok: false, error: fetched.error }
|
|
}
|
|
const currentNames = new Set(
|
|
fetched.data.map((l) => l.name).filter((n): n is string => typeof n === 'string')
|
|
)
|
|
for (const l of removeLabels ?? []) {
|
|
currentNames.delete(l)
|
|
}
|
|
for (const l of addLabels ?? []) {
|
|
currentNames.add(l)
|
|
}
|
|
if (currentNames.size === 0) {
|
|
// Why: `gh api -X PUT` with no `--raw-field` arguments sends an empty
|
|
// body — GitHub does NOT interpret that as "clear labels". The
|
|
// dedicated DELETE endpoint is the documented way to remove all
|
|
// labels in a single call.
|
|
const r = await runRest<unknown>(['-X', 'DELETE', `${base}/labels`], undefined, 'core', {
|
|
expectEmpty: true
|
|
})
|
|
if (!r.ok && r.error.type !== 'not_found') {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
} else {
|
|
const putArgs = ['-X', 'PUT', `${base}/labels`]
|
|
for (const name of currentNames) {
|
|
putArgs.push('--raw-field', `labels[]=${name}`)
|
|
}
|
|
const r = await runRest<unknown>(putArgs)
|
|
if (!r.ok) {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
}
|
|
} else {
|
|
if (addCount > 0) {
|
|
const restArgs = ['-X', 'POST', `${base}/labels`]
|
|
for (const l of addLabels ?? []) {
|
|
restArgs.push('--raw-field', `labels[]=${l}`)
|
|
}
|
|
const r = await runRest<unknown>(restArgs)
|
|
if (!r.ok) {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
}
|
|
if (removeCount === 1) {
|
|
const r = await runRest<unknown>(
|
|
['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`],
|
|
undefined,
|
|
'core',
|
|
{ expectEmpty: true }
|
|
)
|
|
if (!r.ok && r.error.type !== 'not_found') {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) Assignees — POST and DELETE both accept arrays in a single call, so
|
|
// add/remove are at most 2 calls regardless of array size.
|
|
if (addAssignees && addAssignees.length > 0) {
|
|
const restArgs = ['-X', 'POST', `${base}/assignees`]
|
|
for (const u of addAssignees) {
|
|
restArgs.push('--raw-field', `assignees[]=${u}`)
|
|
}
|
|
const r = await runRest<unknown>(restArgs)
|
|
if (!r.ok) {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
}
|
|
if (removeAssignees && removeAssignees.length > 0) {
|
|
const restArgs = ['-X', 'DELETE', `${base}/assignees`]
|
|
for (const u of removeAssignees) {
|
|
restArgs.push('--raw-field', `assignees[]=${u}`)
|
|
}
|
|
const r = await runRest<unknown>(restArgs)
|
|
if (!r.ok) {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
export async function updatePullRequestBySlug(
|
|
args: UpdatePullRequestBySlugArgs
|
|
): Promise<GitHubProjectMutationResult> {
|
|
const v = validateSlugArgs(args.owner, args.repo)
|
|
if (!v.ok) {
|
|
return v
|
|
}
|
|
const n = assertPositiveInt(args.number, 'number')
|
|
if (!n.ok) {
|
|
return { ok: false, error: n.error }
|
|
}
|
|
if (!args.updates || typeof args.updates !== 'object') {
|
|
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
|
|
}
|
|
const patchArgs: string[] = [
|
|
'-X',
|
|
'PATCH',
|
|
`repos/${args.owner}/${args.repo}/pulls/${args.number}`
|
|
]
|
|
// Why: count fields explicitly rather than inferring from patchArgs.length —
|
|
// adding a future header/flag arg silently breaks an array-length check.
|
|
let fieldCount = 0
|
|
if (args.updates.title !== undefined) {
|
|
patchArgs.push('--raw-field', `title=${args.updates.title}`)
|
|
fieldCount++
|
|
}
|
|
if (args.updates.body !== undefined) {
|
|
patchArgs.push('--raw-field', `body=${args.updates.body}`)
|
|
fieldCount++
|
|
}
|
|
if (args.updates.state !== undefined) {
|
|
patchArgs.push('--raw-field', `state=${args.updates.state}`)
|
|
fieldCount++
|
|
}
|
|
if (fieldCount === 0) {
|
|
// No fields to update — nothing to do.
|
|
return { ok: true }
|
|
}
|
|
const r = await runRest<unknown>(patchArgs)
|
|
if (!r.ok) {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
type RawIssueCommentResponse = {
|
|
id?: number
|
|
user?: { login?: string; avatar_url?: string; type?: string } | null
|
|
body?: string
|
|
created_at?: string
|
|
html_url?: string
|
|
}
|
|
|
|
function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment {
|
|
return {
|
|
id: data.id ?? Date.now(),
|
|
author: data.user?.login ?? 'You',
|
|
authorAvatarUrl: data.user?.avatar_url ?? '',
|
|
body: data.body ?? fallbackBody,
|
|
createdAt: data.created_at ?? new Date().toISOString(),
|
|
url: data.html_url ?? '',
|
|
isBot: data.user?.type === 'Bot'
|
|
}
|
|
}
|
|
|
|
export async function addIssueCommentBySlug(
|
|
args: AddIssueCommentBySlugArgs
|
|
): Promise<GitHubProjectCommentMutationResult> {
|
|
const v = validateSlugArgs(args.owner, args.repo)
|
|
if (!v.ok) {
|
|
return v
|
|
}
|
|
const n = assertPositiveInt(args.number, 'number')
|
|
if (!n.ok) {
|
|
return { ok: false, error: n.error }
|
|
}
|
|
if (typeof args.body !== 'string' || !args.body.trim()) {
|
|
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
|
|
}
|
|
const r = await runRest<RawIssueCommentResponse>([
|
|
'-X',
|
|
'POST',
|
|
`repos/${args.owner}/${args.repo}/issues/${args.number}/comments`,
|
|
'--raw-field',
|
|
`body=${args.body}`
|
|
])
|
|
if (!r.ok) {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
return { ok: true, comment: mapIssueComment(r.data, args.body) }
|
|
}
|
|
|
|
export async function updateIssueCommentBySlug(
|
|
args: UpdateIssueCommentBySlugArgs
|
|
): Promise<GitHubProjectMutationResult> {
|
|
const v = validateSlugArgs(args.owner, args.repo)
|
|
if (!v.ok) {
|
|
return v
|
|
}
|
|
const n = assertPositiveInt(args.commentId, 'commentId')
|
|
if (!n.ok) {
|
|
return { ok: false, error: n.error }
|
|
}
|
|
if (typeof args.body !== 'string' || !args.body.trim()) {
|
|
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
|
|
}
|
|
const r = await runRest<unknown>([
|
|
'-X',
|
|
'PATCH',
|
|
`repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`,
|
|
'--raw-field',
|
|
`body=${args.body}`
|
|
])
|
|
if (!r.ok) {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
export async function deleteIssueCommentBySlug(
|
|
args: DeleteIssueCommentBySlugArgs
|
|
): Promise<GitHubProjectMutationResult> {
|
|
const v = validateSlugArgs(args.owner, args.repo)
|
|
if (!v.ok) {
|
|
return v
|
|
}
|
|
const n = assertPositiveInt(args.commentId, 'commentId')
|
|
if (!n.ok) {
|
|
return { ok: false, error: n.error }
|
|
}
|
|
const r = await runRest<unknown>(
|
|
['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`],
|
|
undefined,
|
|
'core',
|
|
{ expectEmpty: true }
|
|
)
|
|
if (!r.ok) {
|
|
return { ok: false, error: r.error }
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
// ─── Slug-addressed picker sources ────────────────────────────────────
|
|
|
|
export async function listLabelsBySlug(
|
|
args: ListLabelsBySlugArgs
|
|
): Promise<ListLabelsBySlugResult> {
|
|
const v = validateSlugArgs(args.owner, args.repo)
|
|
if (!v.ok) {
|
|
return v
|
|
}
|
|
const guard = rateLimitGuard('core')
|
|
if (guard.blocked) {
|
|
return { ok: false, error: rateLimitedError(guard) }
|
|
}
|
|
await acquire()
|
|
// Why: `--paginate` may fan out to multiple pages; we can only reasonably
|
|
// estimate a 1-call spend up front. The next probe will reconcile.
|
|
noteRateLimitSpend('core')
|
|
try {
|
|
const { stdout } = await ghExecFileAsync(
|
|
['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'],
|
|
{ encoding: 'utf-8' }
|
|
)
|
|
return {
|
|
ok: true,
|
|
labels: stdout
|
|
.trim()
|
|
.split('\n')
|
|
.filter((l) => l.length > 0)
|
|
}
|
|
} catch (err) {
|
|
const { stderr, stdout: maybeStdout } = extractExecError(err)
|
|
return { ok: false, error: classifyProjectError(stderr, maybeStdout) }
|
|
} finally {
|
|
release()
|
|
}
|
|
}
|
|
|
|
export async function listAssignableUsersBySlug(
|
|
args: ListAssignableUsersBySlugArgs
|
|
): Promise<ListAssignableUsersBySlugResult> {
|
|
const v = validateSlugArgs(args.owner, args.repo)
|
|
if (!v.ok) {
|
|
return v
|
|
}
|
|
// Seed logins merge after the fetch so callers can include currently-visible
|
|
// assignees even if the repo participant search is sparse.
|
|
const result: GitHubAssignableUser[] = []
|
|
const guard = rateLimitGuard('core')
|
|
if (guard.blocked) {
|
|
return { ok: false, error: rateLimitedError(guard) }
|
|
}
|
|
await acquire()
|
|
noteRateLimitSpend('core')
|
|
try {
|
|
const { stdout } = await ghExecFileAsync(
|
|
[
|
|
'api',
|
|
'--paginate',
|
|
`repos/${args.owner}/${args.repo}/assignees`,
|
|
'--jq',
|
|
'.[] | {login: .login, name: null, avatarUrl: .avatar_url}'
|
|
],
|
|
{ encoding: 'utf-8' }
|
|
)
|
|
for (const line of stdout
|
|
.trim()
|
|
.split('\n')
|
|
.filter((l) => l.length > 0)) {
|
|
try {
|
|
const u = JSON.parse(line) as { login?: string; avatarUrl?: string; name?: string | null }
|
|
if (typeof u.login === 'string') {
|
|
result.push({ login: u.login, name: u.name ?? null, avatarUrl: u.avatarUrl ?? '' })
|
|
}
|
|
} catch {
|
|
// skip malformed jq line
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const { stderr } = extractExecError(err)
|
|
return { ok: false, error: classifyProjectError(stderr, '') }
|
|
} finally {
|
|
release()
|
|
}
|
|
if (args.seedLogins) {
|
|
const seen = new Set(result.map((u) => u.login))
|
|
for (const login of args.seedLogins) {
|
|
if (typeof login === 'string' && !seen.has(login)) {
|
|
result.push({ login, name: null, avatarUrl: '' })
|
|
seen.add(login)
|
|
}
|
|
}
|
|
}
|
|
return { ok: true, users: result }
|
|
}
|
|
|
|
// Why: Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc) only
|
|
// available on repos opted into typed-issues. Empty list (or schema_drift on
|
|
// older GitHub deployments) is the legitimate "this repo doesn't use issue
|
|
// types" signal — callers should treat it as "no editor".
|
|
export async function listIssueTypesBySlug(
|
|
args: ListIssueTypesBySlugArgs
|
|
): Promise<ListIssueTypesBySlugResult> {
|
|
const v = validateSlugArgs(args.owner, args.repo)
|
|
if (!v.ok) {
|
|
return v
|
|
}
|
|
const query = `
|
|
query($owner:String!, $repo:String!) {
|
|
repository(owner:$owner, name:$repo) {
|
|
issueTypes(first:50) {
|
|
nodes { id name color description }
|
|
}
|
|
}
|
|
}
|
|
`
|
|
const res = await runGraphql<{
|
|
repository?: {
|
|
issueTypes?: {
|
|
nodes?: ({
|
|
id?: string
|
|
name?: string
|
|
color?: string | null
|
|
description?: string | null
|
|
} | null)[]
|
|
} | null
|
|
} | null
|
|
}>(query, { owner: args.owner, repo: args.repo })
|
|
if (!res.ok) {
|
|
// Why: repos without issue types respond with a GraphQL error claiming the
|
|
// `issueTypes` field is unknown. Map that to an empty list so the UI shows
|
|
// "no editor" instead of an angry banner.
|
|
if (res.error.type === 'schema_drift' || res.error.type === 'validation_error') {
|
|
return { ok: true, types: [] }
|
|
}
|
|
return { ok: false, error: res.error }
|
|
}
|
|
const nodes = res.data.repository?.issueTypes?.nodes ?? []
|
|
const types = nodes
|
|
.filter(
|
|
(n): n is NonNullable<typeof n> =>
|
|
n !== null && typeof n.id === 'string' && typeof n.name === 'string'
|
|
)
|
|
.map((n) => ({
|
|
id: n.id as string,
|
|
name: n.name as string,
|
|
color: typeof n.color === 'string' ? n.color : null,
|
|
description: typeof n.description === 'string' ? n.description : null
|
|
}))
|
|
return { ok: true, types }
|
|
}
|
|
|
|
export async function updateIssueTypeBySlug(
|
|
args: UpdateIssueTypeBySlugArgs
|
|
): Promise<GitHubProjectMutationResult> {
|
|
const v = validateSlugArgs(args.owner, args.repo)
|
|
if (!v.ok) {
|
|
return v
|
|
}
|
|
const n = assertPositiveInt(args.number, 'number')
|
|
if (!n.ok) {
|
|
return { ok: false, error: n.error }
|
|
}
|
|
// Why: `updateIssueIssueType` is the dedicated mutation; passing null for
|
|
// `issueTypeId` clears the type. We resolve the issue id via a lightweight
|
|
// GraphQL lookup because the REST endpoint doesn't accept issue types.
|
|
const lookup = await runGraphql<{
|
|
repository?: { issue?: { id?: string } | null } | null
|
|
}>(
|
|
`query($owner:String!, $repo:String!, $num:Int!) {
|
|
repository(owner:$owner, name:$repo) { issue(number:$num) { id } }
|
|
}`,
|
|
{ owner: args.owner, repo: args.repo, num: args.number }
|
|
)
|
|
if (!lookup.ok) {
|
|
return { ok: false, error: lookup.error }
|
|
}
|
|
const issueId = lookup.data.repository?.issue?.id
|
|
if (!issueId) {
|
|
return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } }
|
|
}
|
|
// Why: build the mutation conditionally so a null clear doesn't have to
|
|
// smuggle a null GraphQL variable through `gh api graphql -f`. The
|
|
// mutation accepts a literal `null` in the input object directly.
|
|
const query = args.issueTypeId
|
|
? `
|
|
mutation($issueId:ID!, $issueTypeId:ID!) {
|
|
updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) {
|
|
issue { id }
|
|
}
|
|
}
|
|
`
|
|
: `
|
|
mutation($issueId:ID!) {
|
|
updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) {
|
|
issue { id }
|
|
}
|
|
}
|
|
`
|
|
const vars: GraphqlVars = args.issueTypeId
|
|
? { issueId, issueTypeId: args.issueTypeId }
|
|
: { issueId }
|
|
const res = await runGraphql<unknown>(query, vars)
|
|
if (!res.ok) {
|
|
return { ok: false, error: res.error }
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
// ─── Slug-addressed work-item details ─────────────────────────────────
|
|
|
|
type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null }
|
|
type RawLabel = { name?: string; color?: string }
|
|
type RawWorkItemContent = {
|
|
id?: string
|
|
number?: number
|
|
title?: string
|
|
url?: string
|
|
state?: string
|
|
stateReason?: string | null
|
|
isDraft?: boolean
|
|
labels?: { nodes?: RawLabel[] }
|
|
assignees?: { nodes?: RawUser[] }
|
|
}
|
|
|
|
export async function getWorkItemDetailsBySlug(
|
|
args: ProjectWorkItemDetailsBySlugArgs
|
|
): Promise<ProjectWorkItemDetailsBySlugResult> {
|
|
const v = validateSlugArgs(args.owner, args.repo)
|
|
if (!v.ok) {
|
|
return v
|
|
}
|
|
const n = assertPositiveInt(args.number, 'number')
|
|
if (!n.ok) {
|
|
return { ok: false, error: n.error }
|
|
}
|
|
if (args.type !== 'issue' && args.type !== 'pr') {
|
|
return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } }
|
|
}
|
|
|
|
// Single GraphQL round-trip to fetch the issue/PR summary + comments + labels + assignees.
|
|
const contentFrag =
|
|
args.type === 'issue'
|
|
? `
|
|
issue(number:$num) {
|
|
id number title url state stateReason updatedAt
|
|
body
|
|
author { login }
|
|
labels(first:50) { nodes { name } }
|
|
assignees(first:50) { nodes { login } }
|
|
participants(first:50) { nodes { login name avatarUrl } }
|
|
comments(first:100) {
|
|
nodes {
|
|
databaseId
|
|
author { login avatarUrl __typename }
|
|
body createdAt url
|
|
}
|
|
}
|
|
}
|
|
`
|
|
: `
|
|
pullRequest(number:$num) {
|
|
id number title url state isDraft updatedAt headRefName baseRefName
|
|
body
|
|
author { login }
|
|
labels(first:50) { nodes { name } }
|
|
assignees(first:50) { nodes { login } }
|
|
participants(first:50) { nodes { login name avatarUrl } }
|
|
comments(first:100) {
|
|
nodes {
|
|
databaseId
|
|
author { login avatarUrl __typename }
|
|
body createdAt url
|
|
}
|
|
}
|
|
}
|
|
`
|
|
const query = `
|
|
query($owner:String!, $repo:String!, $num:Int!) {
|
|
repository(owner:$owner, name:$repo) {
|
|
${contentFrag}
|
|
}
|
|
}
|
|
`
|
|
const res = await runGraphql<{
|
|
repository?: {
|
|
issue?:
|
|
| (RawWorkItemContent & {
|
|
updatedAt?: string
|
|
body?: string
|
|
author?: { login?: string } | null
|
|
participants?: { nodes?: RawUser[] }
|
|
comments?: {
|
|
nodes?: ({
|
|
databaseId?: number
|
|
author?: { login?: string; avatarUrl?: string; __typename?: string } | null
|
|
body?: string
|
|
createdAt?: string
|
|
url?: string
|
|
} | null)[]
|
|
}
|
|
})
|
|
| null
|
|
pullRequest?:
|
|
| (RawWorkItemContent & {
|
|
updatedAt?: string
|
|
body?: string
|
|
headRefName?: string
|
|
baseRefName?: string
|
|
author?: { login?: string } | null
|
|
participants?: { nodes?: RawUser[] }
|
|
comments?: {
|
|
nodes?: ({
|
|
databaseId?: number
|
|
author?: { login?: string; avatarUrl?: string; __typename?: string } | null
|
|
body?: string
|
|
createdAt?: string
|
|
url?: string
|
|
} | null)[]
|
|
}
|
|
})
|
|
| null
|
|
} | null
|
|
}>(query, { owner: args.owner, repo: args.repo, num: args.number })
|
|
if (!res.ok) {
|
|
return { ok: false, error: res.error }
|
|
}
|
|
const raw = args.type === 'issue' ? res.data.repository?.issue : res.data.repository?.pullRequest
|
|
if (!raw) {
|
|
return { ok: false, error: { type: 'not_found', message: 'Item not found.' } }
|
|
}
|
|
|
|
const labels = (raw.labels?.nodes ?? [])
|
|
.map((l) => l?.name)
|
|
.filter((n): n is string => typeof n === 'string')
|
|
const assignees = (raw.assignees?.nodes ?? [])
|
|
.map((a) => a?.login)
|
|
.filter((l): l is string => typeof l === 'string')
|
|
const comments: PRComment[] = []
|
|
for (const c of raw.comments?.nodes ?? []) {
|
|
if (!c || typeof c.body !== 'string') {
|
|
continue
|
|
}
|
|
comments.push({
|
|
id: typeof c.databaseId === 'number' ? c.databaseId : Date.now(),
|
|
author: c.author?.login ?? '',
|
|
authorAvatarUrl: c.author?.avatarUrl ?? '',
|
|
body: c.body,
|
|
createdAt: typeof c.createdAt === 'string' ? c.createdAt : '',
|
|
url: typeof c.url === 'string' ? c.url : '',
|
|
isBot: c.author?.__typename === 'Bot'
|
|
})
|
|
}
|
|
const participants: GitHubAssignableUser[] = []
|
|
for (const p of raw.participants?.nodes ?? []) {
|
|
if (p && typeof p.login === 'string') {
|
|
participants.push({ login: p.login, name: p.name ?? null, avatarUrl: p.avatarUrl ?? '' })
|
|
}
|
|
}
|
|
|
|
const state: 'open' | 'closed' | 'merged' | 'draft' =
|
|
args.type === 'pr'
|
|
? raw.isDraft
|
|
? 'draft'
|
|
: raw.state === 'MERGED'
|
|
? 'merged'
|
|
: raw.state === 'CLOSED'
|
|
? 'closed'
|
|
: 'open'
|
|
: raw.state === 'CLOSED'
|
|
? 'closed'
|
|
: 'open'
|
|
|
|
const details: GitHubWorkItemDetails = {
|
|
item: {
|
|
id: typeof raw.id === 'string' ? raw.id : '',
|
|
type: args.type,
|
|
number: typeof raw.number === 'number' ? raw.number : args.number,
|
|
title: typeof raw.title === 'string' ? raw.title : '',
|
|
state,
|
|
url: typeof raw.url === 'string' ? raw.url : '',
|
|
labels,
|
|
updatedAt:
|
|
typeof (raw as { updatedAt?: string }).updatedAt === 'string'
|
|
? (raw as { updatedAt: string }).updatedAt
|
|
: '',
|
|
author:
|
|
typeof (raw as { author?: { login?: string } | null }).author?.login === 'string'
|
|
? ((raw as { author: { login: string } }).author.login as string)
|
|
: null,
|
|
branchName:
|
|
args.type === 'pr' && typeof (raw as { headRefName?: string }).headRefName === 'string'
|
|
? ((raw as { headRefName: string }).headRefName as string)
|
|
: undefined,
|
|
baseRefName:
|
|
args.type === 'pr' && typeof (raw as { baseRefName?: string }).baseRefName === 'string'
|
|
? ((raw as { baseRefName: string }).baseRefName as string)
|
|
: undefined
|
|
},
|
|
body: typeof raw.body === 'string' ? raw.body : '',
|
|
comments,
|
|
participants,
|
|
// Why: PR files/checks/review-thread tabs depend on a local repo path and
|
|
// are out of Project-mode slug scope for v1. Omit them here; the dialog
|
|
// branches on their absence and hides those tabs.
|
|
assignees
|
|
}
|
|
return { ok: true, details }
|
|
}
|