From 9ca42f558233867746ef4f7bc5be46fb5f1e20b7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 30 May 2026 18:25:31 -0700 Subject: [PATCH] Create GitHub issues with markdown and metadata (#3947) * Create GitHub issues with markdown and metadata - Add a reusable rich markdown composer for GitHub issue bodies and comments - Let new GitHub issues include selected labels and assignees through IPC/RPC - Preserve created issue metadata in the task list and cover the new API path with tests * fix: address review findings --- src/main/browser/agent-browser-bridge.test.ts | 6 +- src/main/github/issues.test.ts | 40 ++ src/main/github/issues.ts | 35 +- src/main/ipc/github.ts | 10 +- src/main/runtime/orca-runtime.ts | 7 +- src/main/runtime/rpc/methods/github.test.ts | 24 ++ src/main/runtime/rpc/methods/github.ts | 15 +- src/preload/api-types.ts | 2 + src/preload/index.ts | 2 + .../src/assets/rich-markdown-editor.css | 28 ++ .../src/components/GitHubItemDialog.tsx | 367 +----------------- src/renderer/src/components/TaskPage.tsx | 298 ++++++++++++-- .../github/GitHubMarkdownComposer.tsx | 348 +++++++++++++++++ .../sidebar/DeleteWorktreeDialog.tsx | 46 ++- src/renderer/src/hooks/useIssueMetadata.ts | 58 ++- src/shared/types.ts | 5 + 16 files changed, 857 insertions(+), 434 deletions(-) create mode 100644 src/renderer/src/components/github/GitHubMarkdownComposer.tsx diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index 0df1e379752..9373a9f7eb7 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -526,9 +526,9 @@ describe('AgentBrowserBridge', () => { expect(wc.on).toHaveBeenCalledWith('did-finish-load', expect.any(Function)) }) - const finishListener = wc.on.mock.calls.find( - ([event]) => event === 'did-finish-load' - )?.[1] as (() => void) | undefined + const finishListener = wc.on.mock.calls.find(([event]) => event === 'did-finish-load')?.[1] as + | (() => void) + | undefined const failListener = wc.on.mock.calls.find(([event]) => event === 'did-fail-load')?.[1] as | (() => void) | undefined diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 3c2c0a7109e..1f857ca02fc 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -126,6 +126,46 @@ describe('issue source operations', () => { ) }) + it('creates issues with labels and assignees', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 925, + html_url: 'https://github.com/stablyai/orca/issues/925' + }) + }) + + await expect( + createIssue('/repo-root', 'New issue', 'Body', undefined, undefined, { + labels: ['bug', 'frontend'], + assignees: ['octo'] + }) + ).resolves.toEqual({ + ok: true, + number: 925, + url: 'https://github.com/stablyai/orca/issues/925' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '-X', + 'POST', + 'repos/stablyai/orca/issues', + '--raw-field', + 'title=New issue', + '--raw-field', + 'body=Body', + '--raw-field', + 'labels[]=bug', + '--raw-field', + 'labels[]=frontend', + '--raw-field', + 'assignees[]=octo' + ], + { cwd: '/repo-root' } + ) + }) + it('updates issue body through the REST issue endpoint', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 272d61ea14d..de9ed513a5b 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -5,6 +5,7 @@ surface is broad. */ import type { ClassifiedError, GitHubAssignableUser, + GitHubCreateIssueFields, GitHubCommentResult, GitHubIssueUpdate, IssueInfo, @@ -150,7 +151,8 @@ export async function createIssue( title: string, body: string, preference?: IssueSourcePreference, - connectionId?: string | null + connectionId?: string | null, + fields?: GitHubCreateIssueFields ): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> { const trimmedTitle = title.trim() if (!trimmedTitle) { @@ -164,19 +166,24 @@ export async function createIssue( } await acquire() try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - '-X', - 'POST', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, - '--raw-field', - `title=${trimmedTitle}`, - '--raw-field', - `body=${body}` - ], - ghOptions - ) + const args = [ + 'api', + '-X', + 'POST', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, + '--raw-field', + `title=${trimmedTitle}`, + '--raw-field', + `body=${body}` + ] + for (const label of fields?.labels ?? []) { + args.push('--raw-field', `labels[]=${label}`) + } + for (const assignee of fields?.assignees ?? []) { + args.push('--raw-field', `assignees[]=${assignee}`) + } + + const { stdout } = await ghExecFileAsync(args, ghOptions) const data = JSON.parse(stdout) as { number?: number; html_url?: string; url?: string } if (typeof data.number !== 'number') { return { ok: false, error: 'Unexpected response from GitHub' } diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 4a7fe5cb36c..e4cd9346939 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -6,6 +6,7 @@ import { ipcMain, webContents } from 'electron' import { resolve } from 'path' import type { Repo, + GitHubCreateIssueFields, GitHubIssueUpdate, GitHubOwnerRepo, GitHubPullRequestStateUpdate, @@ -282,14 +283,19 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:createIssue', - (_event, args: { repoPath: string; title: string; body: string }) => { + (_event, args: { repoPath: string; title: string; body: string } & GitHubCreateIssueFields) => { const repo = assertRegisteredRepo(args, store) + const fields = + args.labels !== undefined || args.assignees !== undefined + ? { labels: args.labels, assignees: args.assignees } + : undefined return createIssue( repo.path, args.title, args.body, repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + fields ) } ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 709e28670e0..57e2c37572f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -36,6 +36,7 @@ import type { ForceDeleteWorktreeBranchResult, GitPushTarget, GitWorktreeInfo, + GitHubCreateIssueFields, GitHubOwnerRepo, GlobalSettings, PersistedUIState, @@ -6507,7 +6508,8 @@ export class OrcaRuntimeService { async createRepoIssue( repoSelector: string, title: string, - body: string + body: string, + fields?: GitHubCreateIssueFields ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return createIssue( @@ -6515,7 +6517,8 @@ export class OrcaRuntimeService { title, body, repo.issueSourcePreference, - repo.connectionId ?? null + repo.connectionId ?? null, + fields ) } diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 37bf218a10f..c8b5a37a53e 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -461,6 +461,30 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ok: true, number: 3 } }) }) + it('creates issues with metadata on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 4, url: 'https://gh/4' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.createIssue', { + repo: 'repo-1', + title: 'Bug', + body: 'Body', + labels: ['bug'], + assignees: ['octo'] + }) + ) + + expect(runtime.createRepoIssue).toHaveBeenCalledWith('repo-1', 'Bug', 'Body', { + labels: ['bug'], + assignees: ['octo'] + }) + expect(response).toMatchObject({ ok: true, result: { ok: true, number: 4 } }) + }) + it('updates issues on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index ed48d4c6a53..599956f404b 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -148,7 +148,9 @@ const RemovePrReviewers = RepoSelector.extend({ const CreateIssue = RepoSelector.extend({ title: requiredString('Missing title'), - body: z.string() + body: z.string(), + labels: z.array(z.string()).optional(), + assignees: z.array(z.string()).optional() }) const IssueUpdate = z.object({ @@ -478,8 +480,15 @@ export const GITHUB_METHODS: RpcMethod[] = [ defineMethod({ name: 'github.createIssue', params: CreateIssue, - handler: async (params, { runtime }) => - runtime.createRepoIssue(params.repo, params.title, params.body) + handler: async (params, { runtime }) => { + const fields = + params.labels !== undefined || params.assignees !== undefined + ? { labels: params.labels, assignees: params.assignees } + : undefined + return fields + ? runtime.createRepoIssue(params.repo, params.title, params.body, fields) + : runtime.createRepoIssue(params.repo, params.title, params.body) + } }), defineMethod({ name: 'github.updateIssue', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 6afed1c9a93..2f8792668e3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -959,6 +959,8 @@ export type PreloadApi = { repoId?: string title: string body: string + labels?: string[] + assignees?: string[] }) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> countWorkItems: (args: { repoPath: string; repoId?: string; query?: string }) => Promise listWorkItems: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 245b67967be..c48133e05ea 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -890,6 +890,8 @@ const api = { repoId?: string title: string body: string + labels?: string[] + assignees?: string[] }): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> => ipcRenderer.invoke('gh:createIssue', args), diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 23dd55cd508..7a10bbdb269 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -59,6 +59,34 @@ color: var(--foreground); } +.github-markdown-composer .rich-markdown-editor-toolbar { + padding: 6px 8px; + background: color-mix(in srgb, var(--background) 92%, transparent); +} + +.github-markdown-composer-editor { + min-height: 8rem; + padding: 12px 14px 18px; + font-size: 13px; + line-height: 1.55; +} + +.github-markdown-composer-editor p, +.github-markdown-composer-editor ul, +.github-markdown-composer-editor ol, +.github-markdown-composer-editor blockquote { + margin: 0.55em 0; +} + +.github-markdown-composer-image-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: color-mix(in srgb, var(--muted) 38%, var(--background)); +} + .orca-diff-comment-add-btn.rich-markdown-comment-add-btn { display: flex; width: 22px; diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 1ae92dd075c..3e05db361e0 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -69,7 +69,6 @@ import { } from '@/components/ui/dropdown-menu' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { detectLanguage } from '@/lib/language-detect' -import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' import { DiffSectionItem } from '@/components/editor/DiffSectionItem' import type { DecoratedDiffComment } from '@/components/diff-comments/useDiffCommentDecorator' @@ -108,6 +107,7 @@ import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import { getGitHubPRReviewerRows, @@ -172,18 +172,6 @@ const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcer export type ItemDialogTab = 'conversation' | 'checks' | 'files' -type MentionOption = { - login: string - name?: string | null - avatarUrl?: string - source: string -} - -type MentionQuery = { - atIndex: number - query: string -} - const CODE_CONTEXT_EXPAND_STEP = 5 const CODE_CONTEXT_FALLBACK_LINES = 20 const CODE_CONTEXT_MAX_BLOCK_LINES = CODE_CONTEXT_FALLBACK_LINES * 2 + 1 @@ -267,80 +255,6 @@ function formatRelativeTime(input: string): string { return formatter.format(diffDays, 'day') } -function findMentionQuery(value: string, caret: number): MentionQuery | null { - const beforeCaret = value.slice(0, caret) - const match = /(^|[\s([{,])@([A-Za-z0-9-]*)$/.exec(beforeCaret) - if (!match) { - return null - } - const query = match[2] ?? '' - return { - atIndex: beforeCaret.length - query.length - 1, - query - } -} - -function buildMentionOptions({ - item, - comments, - participants, - assignableUsers -}: { - item: GitHubWorkItem - comments: PRComment[] - participants: GitHubAssignableUser[] - assignableUsers: GitHubAssignableUser[] -}): MentionOption[] { - const byLogin = new Map() - const add = ( - login: string | null | undefined, - source: string, - avatarUrl?: string, - name?: string | null - ): void => { - if (!login || login === 'ghost') { - return - } - const key = login.toLowerCase() - const existing = byLogin.get(key) - if (existing) { - if (!existing.avatarUrl && avatarUrl) { - existing.avatarUrl = avatarUrl - } - if (!existing.name && name) { - existing.name = name - } - return - } - byLogin.set(key, { login, source, avatarUrl, name }) - } - - add(item.author, item.type === 'pr' ? 'PR author' : 'Issue author') - for (const comment of comments) { - add(comment.author, 'Commenter', comment.authorAvatarUrl) - } - for (const user of participants) { - add(user.login, 'Participant', user.avatarUrl, user.name) - } - for (const user of assignableUsers) { - add(user.login, 'Team member', user.avatarUrl, user.name) - } - - return Array.from(byLogin.values()) -} - -function filterMentionOptions(options: MentionOption[], query: string): MentionOption[] { - const normalizedQuery = query.toLowerCase() - const filtered = normalizedQuery - ? options.filter( - (option) => - option.login.toLowerCase().includes(normalizedQuery) || - (option.name ?? '').toLowerCase().includes(normalizedQuery) - ) - : options - return filtered.slice(0, 8) -} - function getStateLabel(item: GitHubWorkItem): string { if (item.type === 'pr') { if (item.state === 'merged') { @@ -2333,7 +2247,6 @@ function ConversationTab({ loading, detailsLoaded, checks, - participants: detailsParticipants, localState, onStateChange, projectOrigin, @@ -2354,7 +2267,6 @@ function ConversationTab({ loading: boolean detailsLoaded: boolean checks: GitHubWorkItemDetails['checks'] - participants: GitHubAssignableUser[] localState: GitHubWorkItem['state'] onStateChange: (state: GitHubWorkItem['state']) => void projectOrigin: GitHubItemDialogProjectOrigin | undefined @@ -2370,32 +2282,12 @@ function ConversationTab({ const [bodyDraft, setBodyDraft] = useState(body) const [bodyEditing, setBodyEditing] = useState(false) const [bodySaving, setBodySaving] = useState(false) - const bodyTextareaRef = useRef(null) - const bodyTextareaFocusFrameRef = useRef(null) - const repoAssignees = useRepoAssignees(repoPath, item.repoId) const commentCounts = useMemo(() => getPRCommentAudienceCounts(comments), [comments]) const visibleComments = useMemo( () => filterPRCommentsByAudience(comments, commentFilter), [commentFilter, comments] ) const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) - const mentionOptions = useMemo( - () => - buildMentionOptions({ - item, - comments, - participants: detailsParticipants, - assignableUsers: repoAssignees.data - }), - [comments, detailsParticipants, item, repoAssignees.data] - ) - - const cancelBodyTextareaFocusFrame = useCallback((): void => { - if (bodyTextareaFocusFrameRef.current !== null) { - cancelAnimationFrame(bodyTextareaFocusFrameRef.current) - bodyTextareaFocusFrameRef.current = null - } - }, []) useEffect(() => { if (replyingTo !== null && !visibleComments.some((comment) => comment.id === replyingTo)) { @@ -2409,19 +2301,6 @@ function ConversationTab({ } }, [body, bodyEditing, item.id]) - useEffect(() => { - if (!bodyEditing) { - cancelBodyTextareaFocusFrame() - return cancelBodyTextareaFocusFrame - } - cancelBodyTextareaFocusFrame() - bodyTextareaFocusFrameRef.current = requestAnimationFrame(() => { - bodyTextareaFocusFrameRef.current = null - bodyTextareaRef.current?.focus() - }) - return cancelBodyTextareaFocusFrame - }, [bodyEditing, cancelBodyTextareaFocusFrame]) - const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) const markdownGitHubRepo = useMemo( () => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug), @@ -2625,7 +2504,6 @@ function ConversationTab({ placeholder={ comment.path ? 'Reply in this review thread' : `Reply to @${comment.author}` } - mentionOptions={mentionOptions} onCancel={() => setReplyingTo(null)} onSubmit={(replyBody) => handleReply(comment, replyBody)} /> @@ -2748,27 +2626,14 @@ function ConversationTab({ ) : bodyEditing ? ( - { - if (event.key === 'Escape') { - event.preventDefault() - setBodyDraft(body) - setBodyEditing(false) - return - } - if (isScreenSubmitShortcut(event)) { - event.preventDefault() - void handleSaveBody() - } - }} + onChange={setBodyDraft} 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" + disabled={bodySaving} + autoFocus + minHeightClassName="min-h-64" + onSubmitShortcut={() => void handleSaveBody()} /> ) : body.trim() ? ( )} @@ -3124,25 +2988,18 @@ function CommentReactions({ function CommentReplyForm({ className, placeholder, - mentionOptions, onCancel, onSubmit }: { className?: string placeholder: string - mentionOptions: MentionOption[] onCancel: () => void onSubmit: (body: string) => Promise }): React.JSX.Element { const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) - const textareaRef = useRef(null) const mountedRef = useMountedRef() - useEffect(() => { - textareaRef.current?.focus() - }, []) - const submit = useCallback(async () => { const trimmed = body.trim() if (!trimmed || submitting) { @@ -3166,25 +3023,14 @@ function CommentReplyForm({ return (
- { - if (e.key === 'Escape') { - e.preventDefault() - onCancel() - return - } - if (isScreenSubmitShortcut(e)) { - e.preventDefault() - void submit() - } - }} + onChange={setBody} placeholder={placeholder} - rows={3} - mentionOptions={mentionOptions} - className="scrollbar-sleek min-h-20 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-[13px] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + disabled={submitting} + autoFocus + minHeightClassName="min-h-24" + onSubmitShortcut={() => void submit()} />
- ))} -
- )} -