From 8f5cfdcd622cba87a805351122687c47227e81f3 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Fri, 14 Aug 2026 07:21:38 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20forge=20write=20operations=20?= =?UTF-8?q?=E2=80=94=20comments,=20replies,=20close/reopen,=20edit,=20revi?= =?UTF-8?q?ews,=20draft,=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server: write routes for all three providers (issue/PR comments, inline review-comment replies, issue/MR updates w/ labels-assignees-milestone, review submit, draft toggle) - ui: ForgeProvider gains six write ops; shared action components (composer, thread reply, state/review/draft/metadata/edit) wired into ForgeEntityDetailView and GitHub PR Overview --- .../views/forge/ForgeEntityDetailView.tsx | 87 ++- .../views/forge/ForgeTimelineSection.tsx | 20 +- .../forge/actions/ForgeCommentComposer.tsx | 75 +++ .../views/forge/actions/ForgeDraftToggle.tsx | 63 ++ .../views/forge/actions/ForgeEditForm.tsx | 84 +++ .../forge/actions/ForgeEntityActions.tsx | 106 ++++ .../forge/actions/ForgeMetadataEditor.tsx | 245 ++++++++ .../forge/actions/ForgeReviewActions.tsx | 122 ++++ .../views/forge/actions/ForgeStateActions.tsx | 65 +++ .../views/forge/actions/ForgeThreadReply.tsx | 89 +++ .../components/views/forge/actions/index.ts | 18 + .../views/git/PullRequestSection.tsx | 53 ++ packages/ui/src/lib/api/types.ts | 224 +++++++- packages/ui/src/lib/forge/adapters.ts | 476 ++++++++++++++++ packages/ui/src/lib/forge/forge.test.ts | 537 ++++++++++++++++++ packages/ui/src/lib/forge/normalize.ts | 57 ++ packages/ui/src/lib/forge/provider.ts | 135 +++++ packages/ui/src/lib/i18n/messages/de.ts | 29 + packages/ui/src/lib/i18n/messages/en.ts | 29 + packages/ui/src/lib/i18n/messages/es.ts | 29 + packages/ui/src/lib/i18n/messages/fr.ts | 29 + packages/ui/src/lib/i18n/messages/ja.ts | 29 + packages/ui/src/lib/i18n/messages/ko.ts | 29 + packages/ui/src/lib/i18n/messages/pl.ts | 29 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 29 + packages/ui/src/lib/i18n/messages/uk.ts | 29 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 29 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 29 + .../web/server/lib/gitea/DOCUMENTATION.md | 23 +- packages/web/server/lib/gitea/client.js | 10 + packages/web/server/lib/gitea/client.test.js | 64 +++ packages/web/server/lib/gitea/routes.js | 333 ++++++++++- packages/web/server/lib/gitea/routes.test.js | 295 ++++++++++ .../web/server/lib/github/DOCUMENTATION.md | 11 + packages/web/server/lib/github/routes.js | 469 ++++++++++++++- packages/web/server/lib/github/routes.test.js | 500 +++++++++++++++- .../web/server/lib/gitlab/DOCUMENTATION.md | 20 +- packages/web/server/lib/gitlab/client.js | 10 + packages/web/server/lib/gitlab/client.test.js | 65 +++ packages/web/server/lib/gitlab/routes.js | 333 ++++++++++- packages/web/server/lib/gitlab/routes.test.js | 326 +++++++++++ packages/web/src/api/gitea.ts | 78 +++ packages/web/src/api/github.ts | 73 +++ packages/web/src/api/gitlab.ts | 60 ++ 44 files changed, 5383 insertions(+), 62 deletions(-) create mode 100644 packages/ui/src/components/views/forge/actions/ForgeCommentComposer.tsx create mode 100644 packages/ui/src/components/views/forge/actions/ForgeDraftToggle.tsx create mode 100644 packages/ui/src/components/views/forge/actions/ForgeEditForm.tsx create mode 100644 packages/ui/src/components/views/forge/actions/ForgeEntityActions.tsx create mode 100644 packages/ui/src/components/views/forge/actions/ForgeMetadataEditor.tsx create mode 100644 packages/ui/src/components/views/forge/actions/ForgeReviewActions.tsx create mode 100644 packages/ui/src/components/views/forge/actions/ForgeStateActions.tsx create mode 100644 packages/ui/src/components/views/forge/actions/ForgeThreadReply.tsx create mode 100644 packages/ui/src/components/views/forge/actions/index.ts diff --git a/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx b/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx index 6d02962c..6a36b065 100644 --- a/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx +++ b/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; @@ -7,6 +7,7 @@ import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import type { ForgeChecksResult, ForgeCommitsResult, + ForgeEntityRef, ForgeIssueDetail, ForgeProvider, ForgePullRequestContext, @@ -18,6 +19,12 @@ import { ForgeCommitsSection } from './ForgeCommitsSection'; import { ForgeFilesDiffSection } from './ForgeFilesDiffSection'; import { ForgeTimelineSection } from './ForgeTimelineSection'; import { ForgeChecksSection } from './ForgeChecksSection'; +import { + ForgeCommentComposer, + ForgeEntityActions, + ForgeMetadataEditor, + ForgeThreadReply, +} from './actions'; interface ForgeEntityDetailViewProps { provider: ForgeProvider; @@ -95,11 +102,32 @@ export const ForgeEntityDetailView: React.FC = ({ pr const [pull, setPull] = useState(null); const [issueDetail, setIssueDetail] = useState(null); const [isLoading, setIsLoading] = useState(true); + // Bumped after a successful write so the owning load effect re-runs; never + // bumped on render, so writes are the only trigger. + const [reloadToken, setReloadToken] = useState(0); + // Comments posted through this view are appended locally so they appear + // immediately; a later context refresh reconciles them with authoritative + // server data (and the load effect clears the local list). + const [localComments, setLocalComments] = useState([]); + // Id of the thread root the user is replying to (renders ForgeThreadReply + // under that thread card). + const [replyingTo, setReplyingTo] = useState(null); + + const reload = useCallback(() => { + setReloadToken((value) => value + 1); + }, []); + + const ref = useMemo(() => ({ kind: isIssue ? 'issue' : 'pull', number }), [isIssue, number]); + + const appendComment = useCallback((comment: ForgeComment) => { + setLocalComments((previous) => [...previous, comment]); + }, []); useEffect(() => { let cancelled = false; setPull(null); setIssueDetail(null); + setLocalComments([]); setIsLoading(true); if (isIssue) { @@ -147,13 +175,14 @@ export const ForgeEntityDetailView: React.FC = ({ pr return () => { cancelled = true; }; - }, [directory, isIssue, number, provider, sourceRepo]); + }, [directory, isIssue, number, provider, reloadToken, sourceRepo]); const mergedComments = useMemo(() => { - if (isIssue) return issueDetail?.comments ?? []; - const context = pull?.context; - return [...(context?.issueComments ?? []), ...(context?.reviewComments ?? [])]; - }, [isIssue, issueDetail?.comments, pull?.context]); + const derived = isIssue + ? issueDetail?.comments ?? [] + : [...(pull?.context?.issueComments ?? []), ...(pull?.context?.reviewComments ?? [])]; + return [...localComments, ...derived]; + }, [isIssue, issueDetail?.comments, localComments, pull?.context]); const timelineEvents = useMemo(() => pull?.timeline?.events ?? [], [pull?.timeline]); @@ -170,6 +199,32 @@ export const ForgeEntityDetailView: React.FC = ({ pr return null; }, [pull?.context, provider.capabilities.checks, pull?.checks]); + const canReply = typeof provider.replyToThread === 'function'; + + const handleReply = useCallback((comment: ForgeComment) => { + setReplyingTo(comment.id); + }, []); + + const renderThreadReply = useCallback( + (comment: ForgeComment): React.ReactNode => { + if (comment.id !== replyingTo) return null; + return ( + { + appendComment(created); + setReplyingTo(null); + }} + onCancel={() => setReplyingTo(null)} + /> + ); + }, + [appendComment, directory, provider, ref, replyingTo], + ); + if (isLoading) { return ; } @@ -194,7 +249,17 @@ export const ForgeEntityDetailView: React.FC = ({ pr {t(`forge.state.${issueState}`)} + + {issue.body ? ( ) : null} @@ -202,10 +267,13 @@ export const ForgeEntityDetailView: React.FC = ({ pr {t('forge.section.timeline')} + ); } @@ -242,6 +310,8 @@ export const ForgeEntityDetailView: React.FC = ({ pr ) : null} + + {checksForPull ? ( @@ -273,8 +343,11 @@ export const ForgeEntityDetailView: React.FC = ({ pr events={timelineEvents} comments={mergedComments} error={pull.timeline?.error ?? null} + onReply={canReply ? handleReply : undefined} + renderReply={canReply ? renderThreadReply : undefined} /> + ); }; diff --git a/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx b/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx index 32486503..fdb89211 100644 --- a/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx +++ b/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { useI18n } from '@/lib/i18n'; import { formatDateTimeForPreference } from '@/lib/timeFormat'; @@ -13,6 +14,10 @@ interface ForgeTimelineSectionProps { comments: ForgeComment[]; loading?: boolean; error?: string | null; + /** Optional: asked when the user hits Reply on an inline-comment thread (its root comment). */ + onReply?: (comment: ForgeComment) => void; + /** Optional: rendered under a thread card the parent is replying to. */ + renderReply?: (comment: ForgeComment) => React.ReactNode; } const EVENT_ICONS: Record = { @@ -83,7 +88,7 @@ type TimelineItem = * by `inReplyToId` chains or (path, line) buckets; a thread renders as one * card with its comments stacked. Pure presentation. */ -export const ForgeTimelineSection = React.memo(function ForgeTimelineSection({ events, comments, loading, error }) { +export const ForgeTimelineSection = React.memo(function ForgeTimelineSection({ events, comments, loading, error, onReply, renderReply }) { const { t } = useI18n(); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); @@ -252,6 +257,19 @@ export const ForgeTimelineSection = React.memo(functi ))} + {root.path && onReply ? ( +
+ +
+ ) : null} + {renderReply ? renderReply(root) : null} ); diff --git a/packages/ui/src/components/views/forge/actions/ForgeCommentComposer.tsx b/packages/ui/src/components/views/forge/actions/ForgeCommentComposer.tsx new file mode 100644 index 00000000..4a86ddee --- /dev/null +++ b/packages/ui/src/components/views/forge/actions/ForgeCommentComposer.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from '@/components/ui/toast'; +import { useI18n } from '@/lib/i18n'; +import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider'; +import type { ForgeComment } from '@/lib/forge/types'; + +interface ForgeCommentComposerProps { + provider: ForgeProvider; + directory: string; + ref: ForgeEntityRef; + onPosted?: (comment: ForgeComment) => void; +} + +/** + * Comment composer for an issue or pull request thread. Renders nothing when + * the provider has no `addComment` method. Posts through the facade and reports + * the created comment via `onPosted`; failures toast a stable message. + */ +export const ForgeCommentComposer: React.FC = ({ provider, directory, ref, onPosted }) => { + const { t } = useI18n(); + const [body, setBody] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const addComment = provider.addComment; + if (!addComment) return null; + + const canSubmit = body.trim().length > 0 && !submitting; + + const submit = async (): Promise => { + if (!canSubmit) return; + setSubmitting(true); + try { + const result = await addComment(directory, ref, { body: body.trim() }); + if (!result.ok) { + toast.error(t('forge.actions.error')); + return; + } + setBody(''); + if (result.comment) onPosted?.(result.comment); + } finally { + setSubmitting(false); + } + }; + + return ( +
+