plugin-git-manager 1.1.9 → 1.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/client/187.d5545b7cc8b90bfc.js +10 -0
  2. package/dist/client/components/RunReviewButton.d.ts +1 -1
  3. package/dist/client/context/GitManagerContext.d.ts +2 -0
  4. package/dist/client/index.js +1 -1
  5. package/dist/externalVersion.js +6 -4
  6. package/dist/locale/en-US.json +10 -1
  7. package/dist/locale/vi-VN.json +2 -0
  8. package/dist/server/actions/git-actions.js +15 -12
  9. package/dist/server/actions/gitlab-api.js +14 -13
  10. package/dist/server/actions/review.d.ts +5 -2
  11. package/dist/server/actions/review.js +184 -37
  12. package/dist/server/ai-tools.js +2 -0
  13. package/dist/server/collections/gitCodeReviews.js +1 -0
  14. package/dist/server/collections/gitRepositories.js +12 -0
  15. package/dist/server/migrations/20260508000000-add-auto-review-flow-id.d.ts +6 -0
  16. package/dist/server/migrations/20260508000000-add-auto-review-flow-id.js +57 -0
  17. package/dist/server/plugin.d.ts +4 -0
  18. package/dist/server/plugin.js +43 -6
  19. package/dist/server/poller.js +3 -1
  20. package/package.json +1 -1
  21. package/src/client/components/CommitHistory.tsx +21 -3
  22. package/src/client/components/FileExplorer.tsx +29 -24
  23. package/src/client/components/GitOperations.tsx +32 -16
  24. package/src/client/components/PollingStatus.tsx +27 -1
  25. package/src/client/components/RepositoryConfig.tsx +76 -3
  26. package/src/client/components/ReviewFlows.tsx +11 -1
  27. package/src/client/components/ReviewHistory.tsx +14 -1
  28. package/src/client/components/RunReviewButton.tsx +375 -278
  29. package/src/client/context/GitManagerContext.tsx +2 -0
  30. package/src/client/index.tsx +31 -31
  31. package/src/locale/en-US.json +10 -1
  32. package/src/locale/vi-VN.json +2 -0
  33. package/src/server/actions/git-actions.ts +15 -12
  34. package/src/server/actions/gitlab-api.ts +8 -4
  35. package/src/server/actions/review.ts +226 -41
  36. package/src/server/ai-tools.ts +1 -0
  37. package/src/server/collections/gitCodeReviews.ts +1 -0
  38. package/src/server/collections/gitRepositories.ts +12 -0
  39. package/src/server/migrations/20260508000000-add-auto-review-flow-id.ts +29 -0
  40. package/src/server/plugin.ts +205 -164
  41. package/src/server/poller.ts +11 -2
  42. package/dist/client/187.eec7be93247463d7.js +0 -10
@@ -1,278 +1,375 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
2
- import { Button, Dropdown, Modal, Form, Select, Input, message, Space, Tooltip, Tag, Alert } from 'antd';
3
- import { RobotOutlined, MessageOutlined, ThunderboltOutlined, ReloadOutlined } from '@ant-design/icons';
4
- import { useAPIClient } from '@nocobase/client';
5
- import { useT } from '../locale';
6
-
7
- interface ReviewFlow {
8
- id: number;
9
- name: string;
10
- enabled: boolean;
11
- triggerMode: string;
12
- aiEmployeeUsername?: string;
13
- postMode: string;
14
- repositoryId?: number;
15
- }
16
-
17
- type Target =
18
- | { type: 'mr'; repositoryId: number; mrIid: number; title?: string }
19
- | { type: 'commit'; repositoryId: number; commitSha: string; title?: string }
20
- | { type: 'branch'; repositoryId: number; branch: string; title?: string };
21
-
22
- /**
23
- * Button that lets the user kick off a code review for a given target.
24
- * Two paths:
25
- * - "Run review" → POST gitManager:triggerReview (server-side, async)
26
- * - "Open chat" → call useChatBoxActions().triggerTask if plugin-ai is loaded,
27
- * so the user can chat with the AI employee using the same context.
28
- */
29
- export const RunReviewButton: React.FC<{
30
- target: Target;
31
- size?: 'small' | 'middle' | 'large';
32
- type?: 'default' | 'primary' | 'link';
33
- onTriggered?: (reviewId: number) => void;
34
- }> = ({ target, size = 'small', type = 'default', onTriggered }) => {
35
- const t = useT();
36
- const api = useAPIClient();
37
- const [open, setOpen] = useState(false);
38
- const [flows, setFlows] = useState<ReviewFlow[]>([]);
39
- const [submitting, setSubmitting] = useState(false);
40
- const [existingReview, setExistingReview] = useState<any | null>(null);
41
- const [form] = Form.useForm();
42
-
43
- const loadExistingReview = useCallback(() => {
44
- let cancelled = false;
45
- const filter: any = {
46
- repositoryId: target.repositoryId,
47
- targetType: target.type,
48
- };
49
- if (target.type === 'mr') filter.mrIid = target.mrIid;
50
- if (target.type === 'commit') filter.commitSha = target.commitSha;
51
- if (target.type === 'branch') filter.branch = target.branch;
52
-
53
- api
54
- .request({
55
- url: 'gitCodeReviews:list',
56
- params: {
57
- pageSize: 1,
58
- sort: ['-id'],
59
- filter,
60
- },
61
- })
62
- .then((res) => {
63
- if (cancelled) return;
64
- const list = res?.data?.data || [];
65
- setExistingReview(list[0] || null);
66
- })
67
- .catch(() => undefined);
68
- return () => {
69
- cancelled = true;
70
- };
71
- }, [api, target]);
72
-
73
- useEffect(() => {
74
- return loadExistingReview();
75
- }, [loadExistingReview]);
76
-
77
- useEffect(() => {
78
- if (!open) return;
79
- let cancelled = false;
80
- api
81
- .request({
82
- url: 'gitReviewFlows:list',
83
- params: {
84
- pageSize: 100,
85
- filter: {
86
- enabled: true,
87
- $or: [{ repositoryId: target.repositoryId }, { repositoryId: null }],
88
- },
89
- },
90
- })
91
- .then((res) => {
92
- if (cancelled) return;
93
- const list: ReviewFlow[] = res?.data?.data || [];
94
- setFlows(list);
95
- // Pre-select the most specific enabled flow
96
- const repoFlow = list.find((f) => f.repositoryId === target.repositoryId);
97
- const fallback = list[0];
98
- const flowId = repoFlow?.id ?? fallback?.id;
99
- if (flowId) form.setFieldValue('flowId', flowId);
100
- })
101
- .catch(() => undefined);
102
- return () => {
103
- cancelled = true;
104
- };
105
- }, [open, api, target.repositoryId, form]);
106
-
107
- const handleRun = async () => {
108
- try {
109
- const values = await form.validateFields();
110
- setSubmitting(true);
111
- const params: any = {
112
- repositoryId: target.repositoryId,
113
- targetType: target.type,
114
- flowId: values.flowId,
115
- extraInstructions: values.extraInstructions || undefined,
116
- };
117
- if (target.type === 'mr') params.mrIid = target.mrIid;
118
- if (target.type === 'commit') params.commitSha = target.commitSha;
119
- if (target.type === 'branch') params.branch = target.branch;
120
- const res = await api.request({
121
- url: 'gitManager:triggerReview',
122
- method: 'post',
123
- data: params,
124
- });
125
- const reviewId = res?.data?.data?.reviewId;
126
- message.success(t('Review started'));
127
- setOpen(false);
128
- form.resetFields();
129
- loadExistingReview();
130
- onTriggered?.(reviewId);
131
- } catch (err: any) {
132
- if (err?.errorFields) return; // validation error
133
- message.error(err?.response?.data?.errors?.[0]?.message || err?.message || t('Failed to trigger review'));
134
- } finally {
135
- setSubmitting(false);
136
- }
137
- };
138
-
139
- const handleOpenChat = () => {
140
- const aiManager = (api.app as any)?.aiManager;
141
- if (!aiManager) {
142
- message.warning(t('AI plugin is not available'));
143
- return;
144
- }
145
- message.info(t('Open the AI Employee floating button and select a workflow'));
146
- };
147
-
148
- const buildContextHint = () => {
149
- if (target.type === 'mr') return `MR !${target.mrIid}`;
150
- if (target.type === 'commit') return `Commit ${String(target.commitSha).slice(0, 7)}`;
151
- return `Branch ${target.branch}`;
152
- };
153
-
154
- // Derive re-run state for MR targets
155
- const hasNewCommits =
156
- existingReview &&
157
- existingReview.headSha &&
158
- existingReview.latestSha &&
159
- existingReview.headSha !== existingReview.latestSha;
160
- const isReReview = !!existingReview && existingReview.status !== 'pending';
161
- const buttonLabel = isReReview
162
- ? hasNewCommits
163
- ? t('Re-review (new commits)')
164
- : t('Re-run review')
165
- : t('Code Review');
166
- const ButtonIcon = isReReview ? ReloadOutlined : RobotOutlined;
167
-
168
- const items = [
169
- {
170
- key: 'run',
171
- icon: <ThunderboltOutlined />,
172
- label: isReReview ? t('Re-run automated review') : t('Run automated review'),
173
- onClick: () => setOpen(true),
174
- },
175
- ...(existingReview ? [{
176
- key: 'view',
177
- icon: <RobotOutlined />,
178
- label: t(`Status: ${existingReview.status}`) + ' - ' + t('View in Review History tab'),
179
- onClick: () => {
180
- message.info(t('Please switch to the "Review History" tab to see the details.'));
181
- },
182
- }] : []),
183
- {
184
- key: 'chat',
185
- icon: <MessageOutlined />,
186
- label: t('Ask AI Employee'),
187
- onClick: handleOpenChat,
188
- },
189
- ];
190
-
191
- return (
192
- <>
193
- <Dropdown menu={{ items }} placement="bottomRight" trigger={['click']}>
194
- <Button
195
- size={size}
196
- type={hasNewCommits ? 'primary' : type}
197
- icon={<ButtonIcon />}
198
- danger={!!hasNewCommits}
199
- >
200
- {buttonLabel}
201
- </Button>
202
- </Dropdown>
203
- <Modal
204
- title={
205
- <Space>
206
- <RobotOutlined />
207
- <span>{isReReview ? t('Re-run code review') : t('Run code review')}</span>
208
- <span style={{ color: '#999', fontSize: 12, fontWeight: 400 }}>{buildContextHint()}</span>
209
- </Space>
210
- }
211
- open={open}
212
- onCancel={() => setOpen(false)}
213
- onOk={handleRun}
214
- okText={isReReview ? t('Re-run') : t('Start Review')}
215
- confirmLoading={submitting}
216
- destroyOnClose
217
- >
218
- {isReReview && (
219
- <Alert
220
- type={hasNewCommits ? 'warning' : 'info'}
221
- showIcon
222
- style={{ marginBottom: 12 }}
223
- message={
224
- hasNewCommits
225
- ? t('New commits detected since last review. Re-running will overwrite the existing review.')
226
- : t('A review already exists for this target. Re-running will overwrite it.')
227
- }
228
- description={
229
- <Space size={8} wrap style={{ fontSize: 12 }}>
230
- {existingReview?.headSha && (
231
- <span>
232
- {t('Reviewed at')}:&nbsp;
233
- <Tag style={{ fontFamily: 'monospace' }}>{String(existingReview.headSha).slice(0, 7)}</Tag>
234
- </span>
235
- )}
236
- {hasNewCommits && existingReview?.latestSha && (
237
- <span>
238
- {t('Latest')}:&nbsp;
239
- <Tag color="orange" style={{ fontFamily: 'monospace' }}>
240
- {String(existingReview.latestSha).slice(0, 7)}
241
- </Tag>
242
- </span>
243
- )}
244
- </Space>
245
- }
246
- />
247
- )}
248
- <Form form={form} layout="vertical">
249
- <Form.Item
250
- name="flowId"
251
- label={t('Review Flow')}
252
- rules={[{ required: true, message: t('Please select a review flow') }]}
253
- extra={
254
- flows.length === 0 ? (
255
- <Tooltip title={t('Create a review flow in the Review Flows tab first')}>
256
- <span style={{ color: '#faad14' }}>{t('No enabled flow found for this repository')}</span>
257
- </Tooltip>
258
- ) : null
259
- }
260
- >
261
- <Select
262
- options={flows.map((f) => ({
263
- value: f.id,
264
- label: `${f.name}${f.aiEmployeeUsername ? ` — @${f.aiEmployeeUsername}` : ''}${
265
- f.repositoryId == null ? ` (${t('global')})` : ''
266
- }`,
267
- }))}
268
- placeholder={t('Select a review flow')}
269
- />
270
- </Form.Item>
271
- <Form.Item name="extraInstructions" label={t('Extra instructions (optional)')}>
272
- <Input.TextArea rows={4} placeholder={t('e.g. Focus on security issues in authentication')} />
273
- </Form.Item>
274
- </Form>
275
- </Modal>
276
- </>
277
- );
278
- };
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { Button, Dropdown, Modal, Form, Select, Input, message, Space, Tooltip, Tag, Alert } from 'antd';
3
+ import { RobotOutlined, MessageOutlined, ThunderboltOutlined, ReloadOutlined } from '@ant-design/icons';
4
+ import { useAPIClient } from '@nocobase/client';
5
+ import * as aiClient from '@nocobase/plugin-ai/client';
6
+ import { useT } from '../locale';
7
+
8
+ interface ReviewFlow {
9
+ id: number;
10
+ name: string;
11
+ enabled: boolean;
12
+ triggerMode: string;
13
+ aiEmployeeUsername?: string;
14
+ postMode: string;
15
+ repositoryId?: number;
16
+ llmService?: string;
17
+ model?: string;
18
+ instructions?: string;
19
+ }
20
+
21
+ type Target =
22
+ | { type: 'mr'; repositoryId: number; mrIid: number; title?: string }
23
+ | { type: 'commit'; repositoryId: number; commitSha: string; title?: string }
24
+ | { type: 'branch'; repositoryId: number; branch: string; title?: string };
25
+
26
+ /**
27
+ * Button that lets the user kick off a code review for a given target.
28
+ * Two paths:
29
+ * - "Run review" → POST gitManager:triggerReview (server-side, async)
30
+ * - "Open chat" → call (aiClient as any).useChatBoxActions?.() || {}.triggerTask if plugin-ai is loaded,
31
+ * so the user can chat with the AI employee using the same context.
32
+ */
33
+ export const RunReviewButton: React.FC<{
34
+ target: Target;
35
+ size?: 'small' | 'middle' | 'large';
36
+ type?: 'default' | 'primary' | 'link';
37
+ onTriggered?: (reviewId: number) => void;
38
+ }> = ({ target, size = 'small', type = 'default', onTriggered }) => {
39
+ const t = useT();
40
+ const api = useAPIClient();
41
+ const aiConfigRepository = (aiClient as any).useAIConfigRepository?.() || {};
42
+ const { triggerTask } = (aiClient as any).useChatBoxActions?.() || {};
43
+ const [open, setOpen] = useState(false);
44
+ const [flows, setFlows] = useState<ReviewFlow[]>([]);
45
+ const [submitting, setSubmitting] = useState(false);
46
+ const [asking, setAsking] = useState(false);
47
+ const [existingReview, setExistingReview] = useState<any | null>(null);
48
+ const [form] = Form.useForm();
49
+
50
+ const loadExistingReview = useCallback(() => {
51
+ let cancelled = false;
52
+ const filter: any = {
53
+ repositoryId: target.repositoryId,
54
+ targetType: target.type,
55
+ };
56
+ if (target.type === 'mr') filter.mrIid = target.mrIid;
57
+ if (target.type === 'commit') filter.commitSha = target.commitSha;
58
+ if (target.type === 'branch') filter.branch = target.branch;
59
+
60
+ api
61
+ .request({
62
+ url: 'gitCodeReviews:list',
63
+ params: {
64
+ pageSize: 1,
65
+ sort: ['-id'],
66
+ filter,
67
+ },
68
+ })
69
+ .then((res) => {
70
+ if (cancelled) return;
71
+ const list = res?.data?.data || [];
72
+ setExistingReview(list[0] || null);
73
+ })
74
+ .catch(() => undefined);
75
+ return () => {
76
+ cancelled = true;
77
+ };
78
+ }, [api, target]);
79
+
80
+ useEffect(() => {
81
+ return loadExistingReview();
82
+ }, [loadExistingReview]);
83
+
84
+ const loadFlows = useCallback(async () => {
85
+ const { data } = await api.request({
86
+ url: 'gitReviewFlows:list',
87
+ params: {
88
+ pageSize: 100,
89
+ filter: {
90
+ enabled: true,
91
+ $or: [{ repositoryId: target.repositoryId }, { repositoryId: null }],
92
+ },
93
+ },
94
+ });
95
+ const list: ReviewFlow[] = data?.data || [];
96
+ setFlows(list);
97
+ return list;
98
+ }, [api, target.repositoryId]);
99
+
100
+ const pickFlow = useCallback((list: ReviewFlow[]) => {
101
+ const repoFlow = list.find((f) => f.repositoryId === target.repositoryId);
102
+ return repoFlow ?? list[0] ?? null;
103
+ }, [target.repositoryId]);
104
+
105
+ useEffect(() => {
106
+ if (!open) return;
107
+ let cancelled = false;
108
+ loadFlows()
109
+ .then((list) => {
110
+ if (cancelled) return;
111
+ const flow = pickFlow(list);
112
+ if (flow?.id) form.setFieldValue('flowId', flow.id);
113
+ })
114
+ .catch(() => undefined);
115
+ return () => {
116
+ cancelled = true;
117
+ };
118
+ }, [open, loadFlows, pickFlow, form]);
119
+
120
+ const handleRun = async () => {
121
+ try {
122
+ const values = await form.validateFields();
123
+ setSubmitting(true);
124
+ const params: any = {
125
+ repositoryId: target.repositoryId,
126
+ targetType: target.type,
127
+ flowId: values.flowId,
128
+ extraInstructions: values.extraInstructions || undefined,
129
+ };
130
+ if (target.type === 'mr') params.mrIid = target.mrIid;
131
+ if (target.type === 'commit') params.commitSha = target.commitSha;
132
+ if (target.type === 'branch') params.branch = target.branch;
133
+ const res = await api.request({
134
+ url: 'gitManager:triggerReview',
135
+ method: 'post',
136
+ data: params,
137
+ });
138
+ const reviewId = res?.data?.data?.reviewId;
139
+ message.success(t('Review started'));
140
+ setOpen(false);
141
+ form.resetFields();
142
+ loadExistingReview();
143
+ onTriggered?.(reviewId);
144
+ } catch (err: any) {
145
+ if (err?.errorFields) return; // validation error
146
+ message.error(err?.response?.data?.errors?.[0]?.message || err?.message || t('Failed to trigger review'));
147
+ } finally {
148
+ setSubmitting(false);
149
+ }
150
+ };
151
+
152
+ const buildWorkContext = () => {
153
+ const title = target.title || buildContextHint();
154
+ if (target.type === 'mr') {
155
+ return {
156
+ type: 'git-merge-request',
157
+ uid: `${target.repositoryId}:${target.mrIid}`,
158
+ title,
159
+ content: {
160
+ repositoryId: target.repositoryId,
161
+ mrIid: target.mrIid,
162
+ title,
163
+ },
164
+ };
165
+ }
166
+ if (target.type === 'commit') {
167
+ return {
168
+ type: 'git-commit',
169
+ uid: `${target.repositoryId}:${target.commitSha}`,
170
+ title,
171
+ content: {
172
+ repositoryId: target.repositoryId,
173
+ commitSha: target.commitSha,
174
+ title,
175
+ },
176
+ };
177
+ }
178
+ return {
179
+ type: 'git-repository',
180
+ uid: String(target.repositoryId),
181
+ title,
182
+ content: {
183
+ repositoryId: target.repositoryId,
184
+ branch: target.branch,
185
+ title,
186
+ },
187
+ };
188
+ };
189
+
190
+ const buildChatPrompt = () => {
191
+ if (target.type === 'mr') {
192
+ return `Please review merge request !${target.mrIid} (${target.title || 'untitled'}). Use the attached Git merge request context and the available git tools when you need more detail.`;
193
+ }
194
+ if (target.type === 'commit') {
195
+ return `Please review commit ${target.commitSha} (${target.title || 'untitled'}). Use the attached Git commit context and the available git tools when you need more detail.`;
196
+ }
197
+ return `Please help me inspect branch ${target.branch}. Use the attached Git repository context and the available git tools when you need more detail.`;
198
+ };
199
+
200
+ const handleOpenChat = async () => {
201
+ if (!triggerTask || !aiConfigRepository?.getAIEmployees) {
202
+ message.warning(t('AI plugin is not available'));
203
+ return;
204
+ }
205
+ setAsking(true);
206
+ try {
207
+ const list = flows.length ? flows : await loadFlows();
208
+ const flow = pickFlow(list);
209
+ if (!flow?.aiEmployeeUsername) {
210
+ message.warning(t('No matching flow available'));
211
+ return;
212
+ }
213
+ const employees: any[] = aiConfigRepository.aiEmployees?.length
214
+ ? aiConfigRepository.aiEmployees
215
+ : await aiConfigRepository.getAIEmployees();
216
+ const aiEmployee = employees.find((item) => item.username === flow.aiEmployeeUsername);
217
+ if (!aiEmployee) {
218
+ message.warning(t('AI employee not found'));
219
+ return;
220
+ }
221
+ await triggerTask({
222
+ aiEmployee,
223
+ tasks: [
224
+ {
225
+ title: `${flow.name}: ${buildContextHint()}`,
226
+ message: {
227
+ user: buildChatPrompt(),
228
+ system: flow.instructions || undefined,
229
+ workContext: [buildWorkContext()],
230
+ },
231
+ model: flow.llmService && flow.model ? { llmService: flow.llmService, model: flow.model } : null,
232
+ autoSend: false,
233
+ },
234
+ ],
235
+ });
236
+ } catch (err: any) {
237
+ message.error(err?.message || t('Failed to open AI chat'));
238
+ } finally {
239
+ setAsking(false);
240
+ }
241
+ };
242
+
243
+ const buildContextHint = () => {
244
+ if (target.type === 'mr') return `MR !${target.mrIid}`;
245
+ if (target.type === 'commit') return `Commit ${String(target.commitSha).slice(0, 7)}`;
246
+ return `Branch ${target.branch}`;
247
+ };
248
+
249
+ // Derive re-run state for MR targets
250
+ const hasNewCommits =
251
+ existingReview &&
252
+ existingReview.headSha &&
253
+ existingReview.latestSha &&
254
+ existingReview.headSha !== existingReview.latestSha;
255
+ const isReReview = !!existingReview && existingReview.status !== 'pending';
256
+ const buttonLabel = isReReview
257
+ ? hasNewCommits
258
+ ? t('Re-review (new commits)')
259
+ : t('Re-run review')
260
+ : t('Code Review');
261
+ const ButtonIcon = isReReview ? ReloadOutlined : RobotOutlined;
262
+
263
+ const items = useMemo(() => [
264
+ {
265
+ key: 'run',
266
+ icon: <ThunderboltOutlined />,
267
+ label: isReReview ? t('Re-run automated review') : t('Run automated review'),
268
+ onClick: () => setOpen(true),
269
+ },
270
+ ...(existingReview ? [{
271
+ key: 'view',
272
+ icon: <RobotOutlined />,
273
+ label: t(`Status: ${existingReview.status}`) + ' - ' + t('View in Review History tab'),
274
+ onClick: () => {
275
+ message.info(t('Please switch to the "Review History" tab to see the details.'));
276
+ },
277
+ }] : []),
278
+ {
279
+ key: 'chat',
280
+ icon: <MessageOutlined />,
281
+ label: t('Ask AI Employee'),
282
+ onClick: handleOpenChat,
283
+ disabled: asking,
284
+ },
285
+ ], [isReReview, existingReview, asking, handleOpenChat]);
286
+
287
+ return (
288
+ <>
289
+ <Dropdown menu={{ items }} placement="bottomRight" trigger={['click']}>
290
+ <Button
291
+ size={size}
292
+ type={hasNewCommits ? 'primary' : type}
293
+ icon={<ButtonIcon />}
294
+ danger={!!hasNewCommits}
295
+ loading={asking}
296
+ >
297
+ {buttonLabel}
298
+ </Button>
299
+ </Dropdown>
300
+ <Modal
301
+ title={
302
+ <Space>
303
+ <RobotOutlined />
304
+ <span>{isReReview ? t('Re-run code review') : t('Run code review')}</span>
305
+ <span style={{ color: '#999', fontSize: 12, fontWeight: 400 }}>{buildContextHint()}</span>
306
+ </Space>
307
+ }
308
+ open={open}
309
+ onCancel={() => setOpen(false)}
310
+ onOk={handleRun}
311
+ okText={isReReview ? t('Re-run') : t('Start Review')}
312
+ confirmLoading={submitting}
313
+ destroyOnClose
314
+ >
315
+ {isReReview && (
316
+ <Alert
317
+ type={hasNewCommits ? 'warning' : 'info'}
318
+ showIcon
319
+ style={{ marginBottom: 12 }}
320
+ message={
321
+ hasNewCommits
322
+ ? t('New commits detected since last review. Re-running will overwrite the existing review.')
323
+ : t('A review already exists for this target. Re-running will overwrite it.')
324
+ }
325
+ description={
326
+ <Space size={8} wrap style={{ fontSize: 12 }}>
327
+ {existingReview?.headSha && (
328
+ <span>
329
+ {t('Reviewed at')}:&nbsp;
330
+ <Tag style={{ fontFamily: 'monospace' }}>{String(existingReview.headSha).slice(0, 7)}</Tag>
331
+ </span>
332
+ )}
333
+ {hasNewCommits && existingReview?.latestSha && (
334
+ <span>
335
+ {t('Latest')}:&nbsp;
336
+ <Tag color="orange" style={{ fontFamily: 'monospace' }}>
337
+ {String(existingReview.latestSha).slice(0, 7)}
338
+ </Tag>
339
+ </span>
340
+ )}
341
+ </Space>
342
+ }
343
+ />
344
+ )}
345
+ <Form form={form} layout="vertical">
346
+ <Form.Item
347
+ name="flowId"
348
+ label={t('Review Flow')}
349
+ rules={[{ required: true, message: t('Please select a review flow') }]}
350
+ extra={
351
+ flows.length === 0 ? (
352
+ <Tooltip title={t('Create a review flow in the Review Flows tab first')}>
353
+ <span style={{ color: '#faad14' }}>{t('No enabled flow found for this repository')}</span>
354
+ </Tooltip>
355
+ ) : null
356
+ }
357
+ >
358
+ <Select
359
+ options={flows.map((f) => ({
360
+ value: f.id,
361
+ label: `${f.name}${f.aiEmployeeUsername ? ` — @${f.aiEmployeeUsername}` : ''}${
362
+ f.repositoryId == null ? ` (${t('global')})` : ''
363
+ }`,
364
+ }))}
365
+ placeholder={t('Select a review flow')}
366
+ />
367
+ </Form.Item>
368
+ <Form.Item name="extraInstructions" label={t('Extra instructions (optional)')}>
369
+ <Input.TextArea rows={4} placeholder={t('e.g. Focus on security issues in authentication')} />
370
+ </Form.Item>
371
+ </Form>
372
+ </Modal>
373
+ </>
374
+ );
375
+ };