diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 2312687f8..4a4571aae 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2688,6 +2688,38 @@ export async function requestPRReviewers( } } +export async function removePRReviewers( + repoPath: string, + prNumber: number, + reviewers: string[], + connectionId?: string | null +): Promise<{ ok: true } | { ok: false; error: string }> { + const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) + if (logins.length === 0) { + return { ok: false, error: 'Enter at least one reviewer' } + } + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ownerRepo = await getOwnerRepo(repoPath, connectionId) + await acquire() + try { + const args = ['pr', 'edit', String(prNumber), '--remove-reviewer', logins.join(',')] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: message } + } finally { + release() + } +} + /** * Update a PR's title. */ diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index bc5a8e0a4..3c2c0a710 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -27,7 +27,7 @@ vi.mock('./gh-utils', async () => { } }) -import { createIssue, getIssue, listIssues } from './issues' +import { createIssue, getIssue, listIssues, updateIssue } from './issues' describe('issue source operations', () => { beforeEach(() => { @@ -125,4 +125,17 @@ describe('issue source operations', () => { { cwd: '/repo-root' } ) }) + + it('updates issue body through the REST issue endpoint', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + await expect(updateIssue('/repo-root', 924, { body: 'Updated body' })).resolves.toEqual({ + ok: true + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/924', '--raw-field', 'body=Updated body'], + { cwd: '/repo-root' } + ) + }) }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index ca722e952..272d61ea1 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -242,6 +242,28 @@ export async function updateIssue( } } + if (updates.body !== undefined) { + await acquire() + try { + await ghExecFileAsync( + [ + 'api', + '-X', + 'PATCH', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}`, + '--raw-field', + `body=${updates.body}` + ], + ghOptions + ) + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err) + errors.push(classifyGhError(stderr).message) + } finally { + release() + } + } + // Field edits (labels, assignees, title) via gh issue edit const editArgs: string[] = ['issue', 'edit', String(issueNumber), '--repo', repo] let hasEditArgs = false diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 65ca8280b..d2e527301 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -39,6 +39,7 @@ import { updatePRState, rerunPRChecks, requestPRReviewers, + removePRReviewers, checkOrcaStarred, starOrca } from '../github/client' @@ -646,6 +647,26 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) + 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 ( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ec755f45a..871990493 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -135,6 +135,7 @@ import { mergePR, updatePRState, requestPRReviewers, + removePRReviewers, createIssue, updateIssue, addIssueComment, @@ -5303,6 +5304,16 @@ export class OrcaRuntimeService { return requestPRReviewers(repo.path, prNumber, reviewers) } + async removeRepoPRReviewers( + repoSelector: string, + prNumber: number, + reviewers: string[] + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_reviewers') + return removePRReviewers(repo.path, prNumber, reviewers) + } + async createRepoIssue( repoSelector: string, title: string, diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index c24bd0a10..b33d4e66e 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -120,6 +120,11 @@ const RequestPrReviewers = RepoSelector.extend({ reviewers: z.array(z.string()).min(1) }) +const RemovePrReviewers = RepoSelector.extend({ + prNumber: z.number().int().positive(), + reviewers: z.array(z.string()).min(1) +}) + const CreateIssue = RepoSelector.extend({ title: requiredString('Missing title'), body: z.string() @@ -405,6 +410,12 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.requestRepoPRReviewers(params.repo, params.prNumber, params.reviewers) }), + defineMethod({ + name: 'github.removePRReviewers', + params: RemovePrReviewers, + handler: async (params, { runtime }) => + runtime.removeRepoPRReviewers(params.repo, params.prNumber, params.reviewers) + }), defineMethod({ name: 'github.createIssue', params: CreateIssue, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a1720c397..89cfa6303 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -889,6 +889,12 @@ export type PreloadApi = { prNumber: number reviewers: string[] }) => Promise<{ ok: true } | { ok: false; error: string }> + removePRReviewers: (args: { + repoPath: string + repoId?: string + prNumber: number + reviewers: string[] + }) => Promise<{ ok: true } | { ok: false; error: string }> updateIssue: (args: { repoPath: string repoId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 77803df15..faf763b41 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -915,6 +915,14 @@ const api = { }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:requestPRReviewers', args), + removePRReviewers: (args: { + repoPath: string + repoId?: string + prNumber: number + reviewers: string[] + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:removePRReviewers', args), + updateIssue: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index cb44b3b46..05805390b 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -27,11 +27,13 @@ import { FolderOpen, GitMerge, GitPullRequest, + GitPullRequestClosed, LayoutList, ListChecks, LoaderCircle, MessageSquare, MessageSquarePlus, + Pencil, RefreshCw, Send, UndoDot, @@ -54,7 +56,7 @@ import { } from '@/components/ui/accordion' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { DropdownMenu, DropdownMenuContent, @@ -90,7 +92,6 @@ import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/u import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import { - appendGitHubPRRequestedReviewers, getGitHubPRReviewerRows, normalizeGitHubReviewerLogins } from '@/components/github-pr-reviewer-display' @@ -117,6 +118,8 @@ import type { } from '../../../shared/types' import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' +const IS_MAC = navigator.userAgent.includes('Mac') + // Why: the GH item dialog can be opened from any work-item list surface and // doesn't have the full owner/repo context the list's cache entry carries. // Parsing the canonical `https://github.com/{owner}/{repo}/...` URL is the @@ -422,6 +425,45 @@ function ReviewerAvatar({ ) } +function mergeReviewerSuggestions( + users: GitHubAssignableUser[], + seedUsers: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of [...seedUsers, ...users]) { + const key = user.login.toLowerCase() + const existing = byLogin.get(key) + if (!existing) { + byLogin.set(key, user) + continue + } + if (!existing.avatarUrl && user.avatarUrl) { + byLogin.set(key, { ...existing, avatarUrl: user.avatarUrl }) + } + } + return Array.from(byLogin.values()).sort((a, b) => a.login.localeCompare(b.login)) +} + +function buildRequestedReviewUsers( + logins: string[], + candidates: GitHubAssignableUser[], + existingRequests: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of existingRequests) { + byLogin.set(user.login.toLowerCase(), user) + } + const candidatesByLogin = new Map(candidates.map((user) => [user.login.toLowerCase(), user])) + for (const login of logins) { + const key = login.toLowerCase() + if (byLogin.has(key)) { + continue + } + byLogin.set(key, candidatesByLogin.get(key) ?? { login, name: null, avatarUrl: '' }) + } + return Array.from(byLogin.values()) +} + function PRReviewersPanel({ item, loading, @@ -433,19 +475,73 @@ function PRReviewersPanel({ repoPath: string | null onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void }): React.JSX.Element { + const [open, setOpen] = useState(false) const [reviewerInput, setReviewerInput] = useState('') + const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') + const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) + const [activeReviewerIndex, setActiveReviewerIndex] = useState(0) const [submitting, setSubmitting] = useState(false) const [localReviewRequests, setLocalReviewRequests] = useState( () => item.reviewRequests ?? [] ) const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const settings = useAppStore((s) => s.settings) + const reviewerInputRef = useRef(null) useEffect(() => { setLocalReviewRequests(item.reviewRequests ?? []) }, [item.id, item.reviewRequests]) + const reviewerSeedUsers = useMemo(() => { + const byLogin = new Map() + const add = (user: GitHubAssignableUser): void => { + if (!user.login) { + return + } + byLogin.set(user.login.toLowerCase(), user) + } + for (const user of localReviewRequests) { + add(user) + } + for (const review of item.latestReviews ?? []) { + add({ + login: review.login, + name: null, + avatarUrl: review.avatarUrl ?? '' + }) + } + if (item.author) { + add({ login: item.author, name: null, avatarUrl: '' }) + } + return Array.from(byLogin.values()) + }, [item.author, item.latestReviews, localReviewRequests]) + + const reviewSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const reviewerMetadataBySlug = useRepoAssigneesBySlug( + open && reviewSlug ? reviewSlug.owner : null, + open && reviewSlug ? reviewSlug.repo : null, + reviewerSeedUsers.map((user) => user.login), + settings + ) + const reviewerMetadataByPath = useRepoAssignees( + open && !reviewSlug ? repoPath : null, + open && !reviewSlug ? item.repoId : null + ) + const reviewerMetadata = reviewSlug ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } const reviewers = getGitHubPRReviewerRows(displayItem) + const authorLogin = item.author?.toLowerCase() ?? null + const reviewerCandidates = useMemo( + () => + mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter( + (user) => user.login.toLowerCase() !== authorLogin + ), + [authorLogin, reviewerMetadata.data, reviewerSeedUsers] + ) + const reviewerCandidatesByLogin = useMemo( + () => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])), + [reviewerCandidates] + ) const selectedReviewerLogins = useMemo( () => new Set( @@ -453,21 +549,94 @@ function PRReviewersPanel({ ), [localReviewRequests] ) + const reviewerQuery = reviewerInput.trim().replace(/^@/, '').toLowerCase() + const filteredReviewerCandidates = useMemo(() => { + const query = reviewerQuery + return reviewerCandidates + .filter((user) => { + const login = user.login.toLowerCase() + return ( + query.length === 0 || + login.includes(query) || + (user.name ?? '').toLowerCase().includes(query) + ) + }) + .sort((a, b) => { + const aLogin = a.login.toLowerCase() + const bLogin = b.login.toLowerCase() + const aStarts = aLogin.startsWith(query) + const bStarts = bLogin.startsWith(query) + if (aStarts !== bStarts) { + return aStarts ? -1 : 1 + } + return a.login.localeCompare(b.login) + }) + }, [reviewerCandidates, reviewerQuery]) + const suggestedReviewerRows = useMemo( + () => + reviewerQuery.length === 0 + ? reviewerSeedUsers + .filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase())) + .filter((user) => user.login.toLowerCase() !== authorLogin) + .map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user) + .slice(0, 1) + : [], + [ + authorLogin, + reviewerCandidatesByLogin, + reviewerQuery.length, + reviewerSeedUsers, + selectedReviewerLogins + ] + ) + const everyoneElseReviewerRows = useMemo(() => { + const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase())) + return filteredReviewerCandidates.filter( + (user) => !suggestedLogins.has(user.login.toLowerCase()) + ) + }, [filteredReviewerCandidates, suggestedReviewerRows]) + const actionableReviewerRows = useMemo( + () => [...suggestedReviewerRows, ...everyoneElseReviewerRows], + [everyoneElseReviewerRows, suggestedReviewerRows] + ) + + useEffect(() => { + setActiveReviewerIndex(0) + }, [reviewerQuery, actionableReviewerRows.length]) + const hasReviewerMetadata = item.reviewDecision !== undefined || localReviewRequests.length > 0 || item.reviewRequests !== undefined || item.latestReviews !== undefined - const canRequestReview = - !!repoPath || getActiveRuntimeTarget(useAppStore.getState().settings).kind === 'environment' + const canRequestReview = !!repoPath || getActiveRuntimeTarget(settings).kind === 'environment' - const handleRequestReview = async (event: React.FormEvent): Promise => { - event.preventDefault() + const measureReviewerPickerPlacement = useCallback(() => { + const rect = reviewerInputRef.current?.getBoundingClientRect() + if (!rect) { + setReviewerPickerSide('bottom') + setReviewerPickerMaxHeight(null) + return + } + + const gap = 8 + const minUsefulHeight = 180 + const availableBelow = window.innerHeight - rect.bottom - gap + const availableAbove = rect.top - gap + const nextSide = + availableBelow < minUsefulHeight && availableAbove > availableBelow ? 'top' : 'bottom' + const available = nextSide === 'top' ? availableAbove : availableBelow + + setReviewerPickerSide(nextSide) + setReviewerPickerMaxHeight(Math.max(120, Math.min(330, available))) + }, []) + + const handleRequestReview = async (requestedLogins?: string[]): Promise => { if (submitting) { return } const logins = normalizeGitHubReviewerLogins( - reviewerInput.split(/[\s,]+/), + requestedLogins ?? reviewerInput.split(/[\s,]+/), selectedReviewerLogins ) if (logins.length === 0) { @@ -478,7 +647,7 @@ function PRReviewersPanel({ toast.error('You can request up to 15 reviewers') return } - const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const target = getActiveRuntimeTarget(settings) if (target.kind !== 'environment' && !repoPath) { toast.error('No repo context available for this pull request.') return @@ -503,7 +672,11 @@ function PRReviewersPanel({ toast.error(result.error ?? 'Failed to request reviewer') return } - const nextReviewRequests = appendGitHubPRRequestedReviewers(localReviewRequests, logins) + const nextReviewRequests = buildRequestedReviewUsers( + logins, + reviewerCandidates, + localReviewRequests + ) setLocalReviewRequests(nextReviewRequests) patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) onReviewersRequested(nextReviewRequests) @@ -516,6 +689,132 @@ function PRReviewersPanel({ } } + const handleRemoveReviewers = async (reviewersToRemove: string[]): Promise => { + if (submitting) { + return + } + const selected = new Set(localReviewRequests.map((reviewer) => reviewer.login.toLowerCase())) + const logins = reviewersToRemove + .map((reviewer) => reviewer.trim().replace(/^@/, '')) + .filter((reviewer) => reviewer.length > 0 && selected.has(reviewer.toLowerCase())) + if (logins.length === 0) { + return + } + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment' && !repoPath) { + toast.error('No repo context available for this pull request.') + return + } + setSubmitting(true) + try { + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.removePRReviewers', + { repo: item.repoId, prNumber: item.number, reviewers: logins }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.removePRReviewers({ + repoPath: repoPath ?? '', + repoId: item.repoId, + prNumber: item.number, + reviewers: logins + }) + if (!result.ok) { + toast.error(result.error ?? 'Failed to remove reviewer') + return + } + const removed = new Set(logins.map((login) => login.toLowerCase())) + const nextReviewRequests = localReviewRequests.filter( + (reviewer) => !removed.has(reviewer.login.toLowerCase()) + ) + setLocalReviewRequests(nextReviewRequests) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId) + onReviewersRequested(nextReviewRequests) + setReviewerInput('') + toast.success(logins.length === 1 ? 'Reviewer removed' : 'Reviewers removed') + } catch { + toast.error('Failed to remove reviewer') + } finally { + setSubmitting(false) + } + } + + const requestReviewer = async (reviewer: GitHubAssignableUser): Promise => { + await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) + ? handleRemoveReviewers([reviewer.login]) + : handleRequestReview([reviewer.login])) + requestAnimationFrame(() => reviewerInputRef.current?.focus()) + } + + const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { + if (nextOpen) { + measureReviewerPickerPlacement() + } + setOpen(nextOpen) + if (nextOpen) { + requestAnimationFrame(() => reviewerInputRef.current?.focus()) + return + } + setReviewerInput('') + } + + const renderReviewerPickerRow = ( + reviewer: GitHubAssignableUser, + options: { suggested: boolean; activeIndex: number } + ): React.JSX.Element => { + const selected = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + const active = actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login + return ( + + ) + } + return ( ) @@ -983,6 +1407,19 @@ function patchCachedPRReviewRequests( }) } +function patchCachedWorkItemBody(cacheKey: string, body: string): void { + const prev = workItemDetailsCache.get(cacheKey) + if (!prev?.details) { + return + } + touchWorkItemDetailsCache(cacheKey, { + ...prev, + details: { ...prev.details, body }, + fetchedAt: Date.now(), + error: undefined + }) +} + // Why: install once at module load — every dialog instance shares the cache, // so a single subscription is enough. The preload bridge re-emits the // main-process broadcast for every window, so each renderer invalidates its @@ -1724,6 +2161,7 @@ function ConversationTab({ projectOrigin, onMutated, onChecksUpdated, + onBodyUpdated, onCommentAdded, onReviewersRequested }: { @@ -1744,12 +2182,17 @@ function ConversationTab({ projectOrigin: GitHubItemDialogProjectOrigin | undefined onMutated: () => void onChecksUpdated: (checks: PRCheckDetail[]) => void + onBodyUpdated: (body: string) => void onCommentAdded: (comment: PRComment) => void onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void }): React.JSX.Element { const authorLabel = item.author ?? 'unknown' const [replyingTo, setReplyingTo] = useState(null) const [commentFilter, setCommentFilter] = useState('all') + const [bodyDraft, setBodyDraft] = useState(body) + const [bodyEditing, setBodyEditing] = useState(false) + const [bodySaving, setBodySaving] = useState(false) + const bodyTextareaRef = useRef(null) const repoAssignees = useRepoAssignees(repoPath, item.repoId) const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) const visibleComments = useMemo( @@ -1774,6 +2217,51 @@ function ConversationTab({ } }, [replyingTo, visibleComments]) + useEffect(() => { + if (!bodyEditing) { + setBodyDraft(body) + } + }, [body, bodyEditing, item.id]) + + useEffect(() => { + if (bodyEditing) { + requestAnimationFrame(() => bodyTextareaRef.current?.focus()) + } + }, [bodyEditing]) + + const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const markdownGitHubRepo = useMemo( + () => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug), + [bodySlug, projectOrigin] + ) + const canEditBody = + item.type === 'pr' ? Boolean(projectOrigin || bodySlug) : Boolean(projectOrigin || repoPath) + const bodyChanged = bodyDraft !== body + + const handleSaveBody = useCallback(async (): Promise => { + if (bodySaving || !bodyChanged) { + setBodyEditing(false) + return + } + setBodySaving(true) + try { + await runWorkItemBodyUpdate({ + item, + repoPath, + projectOrigin, + body: bodyDraft, + parsedSlug: bodySlug + }) + onBodyUpdated(bodyDraft) + setBodyEditing(false) + toast.success('Description updated.') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update description.') + } finally { + setBodySaving(false) + } + }, [bodyChanged, bodyDraft, bodySaving, bodySlug, item, onBodyUpdated, projectOrigin, repoPath]) + const handleReply = useCallback( async (comment: PRComment, replyBody: string): Promise => { if (!repoPath) { @@ -1934,6 +2422,7 @@ function ConversationTab({ @@ -1995,7 +2484,10 @@ function ConversationTab({
@@ -2003,16 +2495,93 @@ function ConversationTab({
{authorLabel} updated {formatRelativeTime(item.updatedAt)} + {canEditBody && !loading && detailsLoaded ? ( + bodyEditing ? ( +
+ + +
+ ) : ( + + + + + Edit description + + ) + ) : null}
{loading && !detailsLoaded ? (
+ ) : bodyEditing ? ( + { + if (event.key === 'Escape') { + event.preventDefault() + setBodyDraft(body) + setBodyEditing(false) + return + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + void handleSaveBody() + } + }} + placeholder="Description" + rows={12} + mentionOptions={mentionOptions} + wrapperClassName="flex min-h-64 w-full items-stretch" + className="scrollbar-sleek block min-h-64 w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> ) : body.trim() ? ( ) : ( @@ -2226,33 +2795,17 @@ function PRActionsPanel({
- -
) @@ -2797,26 +3372,28 @@ function ChecksTab({ [detailsByCheckKey, prRepo, repoId, repoPath] ) - const actions = ( -
- - - - - - Refresh checks - - + const refreshAction = ( + + + + + + Refresh checks + + + ) + const fixBrokenChecksAction = + failedChecks.length > 0 || fixingChecks ? ( - {failedChecks.length > 0 - ? 'Start the default AI agent on these checks' - : 'No broken checks to fix'} + Start the default AI agent on these checks + ) : null + const rerunAction = + list.length > 0 || rerunning ? ( - - - {linkCopied ? 'Copied' : 'Copy GitHub link'} - - - - - - - - Open on GitHub - - - - - - - - Close · Esc - - -
+
{workItem.type === 'pr' && ( )} + + + + + + {linkCopied ? 'Copied' : 'Copy GitHub link'} + + + + + + + + Open on GitHub + + + + + + + + Close · Esc + +
@@ -4680,6 +5339,11 @@ export default function GitHubItemDialog({ patchCachedPRChecks(detailsCacheKey, nextChecks) } }} + onBodyUpdated={(nextBody) => { + if (detailsCacheKey) { + patchCachedWorkItemBody(detailsCacheKey, nextBody) + } + }} onCommentAdded={appendOptimisticComment} onReviewersRequested={(nextReviewRequests) => { if (detailsCacheKey) { diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx index a877361f5..0c6326152 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx @@ -3,6 +3,45 @@ import { describe, expect, it } from 'vitest' import CommentMarkdown from './CommentMarkdown' describe('CommentMarkdown', () => { + it('autolinks same-repo GitHub issue references when repo context is provided', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('href="https://github.com/stablyai/orca/issues/2316"') + expect(markup).toContain(' { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('href="https://github.com/another-org/other-repo/issues/42"') + }) + + it('does not autolink GitHub issue references inside existing links or code', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('href="https://example.com/already-linked"') + expect(markup).not.toContain('href="https://github.com/stablyai/orca/issues/2316"') + expect(markup).not.toContain('href="https://github.com/stablyai/orca/issues/2317"') + }) + it('contains long PR body markdown inside its available width', () => { const markup = renderToStaticMarkup( ['rehypePlugins']> +type GitHubRepoReference = { + owner: string + repo: string +} + +type MarkdownTextNode = { + type: 'text' + value: string +} + +type MarkdownLinkNode = { + type: 'link' + url: string + title: null + children: MarkdownTextNode[] +} + +type MarkdownNode = { + type: string + value?: string + children?: MarkdownNode[] +} + // Why: sidebar comments are rendered at 11px in a narrow card, so we strip // block-level wrappers that add unwanted margins and only keep inline // formatting (bold, italic, code, links) plus compact lists and line breaks. @@ -170,6 +193,94 @@ const documentComponents: Components = { // with existing plain-text comments that rely on newline formatting. const remarkPlugins = [remarkGfm, remarkBreaks] +const GITHUB_REFERENCE_PATTERN = /(?:\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+))?#([1-9][0-9]*)\b/g + +function createGitHubIssueUrl(owner: string, repo: string, number: string): string { + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}` +} + +function isEmbeddedGitHubReference(value: string, index: number): boolean { + if (index === 0) { + return false + } + return /[A-Za-z0-9_./-]/.test(value[index - 1] ?? '') +} + +function createGitHubReferenceLinkNode( + label: string, + owner: string, + repo: string, + number: string +): MarkdownLinkNode { + return { + type: 'link', + url: createGitHubIssueUrl(owner, repo, number), + title: null, + children: [{ type: 'text', value: label }] + } +} + +function splitGitHubReferenceText(value: string, defaultRepo: GitHubRepoReference): MarkdownNode[] { + const parts: MarkdownNode[] = [] + let cursor = 0 + + for (const match of value.matchAll(GITHUB_REFERENCE_PATTERN)) { + const label = match[0] + const index = match.index ?? 0 + if (isEmbeddedGitHubReference(value, index)) { + continue + } + + const owner = match[1] ?? defaultRepo.owner + const repo = match[2] ?? defaultRepo.repo + const number = match[3] + if (!number) { + continue + } + + if (index > cursor) { + parts.push({ type: 'text', value: value.slice(cursor, index) }) + } + parts.push(createGitHubReferenceLinkNode(label, owner, repo, number)) + cursor = index + label.length + } + + if (cursor === 0) { + return [{ type: 'text', value }] + } + if (cursor < value.length) { + parts.push({ type: 'text', value: value.slice(cursor) }) + } + return parts +} + +function transformGitHubReferenceChildren( + node: MarkdownNode, + defaultRepo: GitHubRepoReference +): void { + if (!node.children || node.type === 'link' || node.type === 'image') { + return + } + + const nextChildren: MarkdownNode[] = [] + for (const child of node.children) { + if (child.type === 'text' && child.value !== undefined) { + nextChildren.push(...splitGitHubReferenceText(child.value, defaultRepo)) + } else { + transformGitHubReferenceChildren(child, defaultRepo) + nextChildren.push(child) + } + } + + node.children = nextChildren +} + +export function remarkGitHubReferences( + defaultRepo: GitHubRepoReference +): () => (tree: MarkdownNode) => void { + return () => (tree) => transformGitHubReferenceChildren(tree, defaultRepo) +} + const commentMarkdownSanitizeSchema = { ...defaultSchema, tagNames: [...(defaultSchema.tagNames ?? []), 'details', 'summary', 'sub', 'sup', 'ins', 'kbd'], @@ -191,6 +302,7 @@ const rehypePlugins: MarkdownPlugins = [rehypeRaw, [rehypeSanitize, commentMarkd type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { content: string variant?: 'compact' | 'document' + githubRepo?: GitHubRepoReference | null } // Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref @@ -198,10 +310,14 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { // the child. Without forwarding both, the hover card cannot open or position. const CommentMarkdown = React.memo( React.forwardRef(function CommentMarkdown( - { content, className, variant = 'compact', ...rest }, + { content, className, variant = 'compact', githubRepo, ...rest }, ref ) { const components = variant === 'document' ? documentComponents : compactComponents + const activeRemarkPlugins = React.useMemo( + () => (githubRepo ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] : remarkPlugins), + [githubRepo] + ) return (
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 383d89734..b99b6c7ee 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -705,6 +705,7 @@ function createGitHubApi(): NonNullable['gh']> { mergePR: direct('github.mergePR'), updatePRState: direct('github.updatePRState'), requestPRReviewers: direct('github.requestPRReviewers'), + removePRReviewers: direct('github.removePRReviewers'), updateIssue: direct('github.updateIssue'), addIssueComment: direct('github.addIssueComment'), addPRReviewCommentReply: direct('github.addPRReviewCommentReply'), diff --git a/src/shared/types.ts b/src/shared/types.ts index 2a7fee450..d82f47942 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -916,10 +916,9 @@ export type LinearComment = { export type GitHubIssueUpdate = { state?: 'open' | 'closed' title?: string - // Why: body writes are driven by the Project-mode slug-addressed path - // (`updateIssueBySlug`) because `gh issue edit` does not consistently - // cover every body-edit case the dialog needs; the repoPath-based - // `updateIssue` flow keeps ignoring `body` for backward compatibility. + // Why: body writes use the REST issue endpoint instead of `gh issue edit` + // because that command does not consistently cover every body-edit case the + // dialog needs. body?: string addLabels?: string[] removeLabels?: string[]