plugin-git-manager 1.1.10 → 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.
@@ -26,17 +26,30 @@ var __copyProps = (to, from, except, desc) => {
26
26
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
27
  var review_exports = {};
28
28
  __export(review_exports, {
29
+ WORKER_JOB_GIT_REVIEW_PROCESS: () => WORKER_JOB_GIT_REVIEW_PROCESS,
29
30
  branchMatches: () => branchMatches,
30
31
  pickFlowMatchingBranch: () => pickFlowMatchingBranch,
31
32
  recoverStuckReviews: () => recoverStuckReviews,
33
+ registerReviewQueue: () => registerReviewQueue,
32
34
  reviewApprovePost: () => reviewApprovePost,
33
35
  reviewReject: () => reviewReject,
34
36
  triggerReview: () => triggerReview,
35
- triggerReviewInternal: () => triggerReviewInternal
37
+ triggerReviewInternal: () => triggerReviewInternal,
38
+ unregisterReviewQueue: () => unregisterReviewQueue
36
39
  });
37
40
  module.exports = __toCommonJS(review_exports);
38
41
  var import_gitlab_url = require("../utils/gitlab-url");
39
42
  var import_redact = require("../utils/redact");
43
+ const WORKER_JOB_GIT_REVIEW_PROCESS = "git-review:process";
44
+ const REVIEW_QUEUE_CHANNEL = "plugin-git-manager.review";
45
+ const REVIEW_QUEUE_CONCURRENCY = Math.max(
46
+ 1,
47
+ Number.parseInt(process.env.GIT_REVIEW_QUEUE_CONCURRENCY || process.env.GIT_REVIEW_MAX_CONCURRENCY || "3", 10) || 3
48
+ );
49
+ const REVIEW_QUEUE_TIMEOUT_MS = Math.max(
50
+ 6e4,
51
+ Number.parseInt(process.env.GIT_REVIEW_QUEUE_TIMEOUT_MS || "", 10) || 10 * 60 * 1e3
52
+ );
40
53
  function getActionParams(ctx) {
41
54
  var _a, _b;
42
55
  return { ...ctx.action.params, ...(_a = ctx.action.params) == null ? void 0 : _a.values, ...((_b = ctx.request) == null ? void 0 : _b.body) || {} };
@@ -142,13 +155,12 @@ async function triggerReviewInternalLocked(app, args) {
142
155
  latestSha: headSha || existingLatestSha || null,
143
156
  triggeredBy: args.triggeredBy || "manual",
144
157
  status: "pending",
145
- // Stamp startedAt synchronously so `recoverStuckReviews` can sweep rows
146
- // that get stuck in `pending` (process died before runReview ran).
147
- // runReview's own update will refresh this on actual start.
148
- startedAt: /* @__PURE__ */ new Date(),
158
+ // `startedAt` is stamped by the queue worker when execution actually
159
+ // starts. Pending rows may be legitimately waiting in Redis.
160
+ startedAt: null,
149
161
  finishedAt: null,
150
162
  durationMs: null,
151
- postStatus: flow.get("postMode") === "disabled" ? "skipped" : "pending_approval",
163
+ postStatus: getInitialPostStatus(flow, args.targetType),
152
164
  error: null
153
165
  };
154
166
  let reviewId;
@@ -166,25 +178,129 @@ async function triggerReviewInternalLocked(app, args) {
166
178
  const review = await reviewsRepo.create({ values: baseValues });
167
179
  reviewId = review.get("id");
168
180
  }
169
- setImmediate(
170
- () => runReview(app, {
171
- reviewId,
181
+ await enqueueReview(app, {
182
+ reviewId,
183
+ repositoryId: args.repositoryId,
184
+ targetType: args.targetType,
185
+ mrIid: args.targetType === "mr" ? args.mrIid : null,
186
+ commitSha: args.targetType === "commit" ? args.commitSha : null,
187
+ branch: args.branch || void 0,
188
+ headSha,
189
+ aiEmployeeUsername,
190
+ extraInstructions: args.extraInstructions,
191
+ userId: args.userId ?? null,
192
+ flowSnapshot: createFlowSnapshot(flow)
193
+ });
194
+ return reviewId;
195
+ }
196
+ function registerReviewQueue(app) {
197
+ app.eventQueue.subscribe(REVIEW_QUEUE_CHANNEL, {
198
+ concurrency: REVIEW_QUEUE_CONCURRENCY,
199
+ idle: () => isGitReviewWorker(app),
200
+ process: async (message) => {
201
+ await processQueuedReview(app, message);
202
+ }
203
+ });
204
+ }
205
+ function unregisterReviewQueue(app) {
206
+ app.eventQueue.unsubscribe(REVIEW_QUEUE_CHANNEL);
207
+ }
208
+ function createFlowSnapshot(flow) {
209
+ return {
210
+ id: Number(flow.get("id")),
211
+ name: flow.get("name"),
212
+ postMode: flow.get("postMode"),
213
+ llmService: flow.get("llmService"),
214
+ model: flow.get("model"),
215
+ instructions: flow.get("instructions")
216
+ };
217
+ }
218
+ function createFlowFromSnapshot(snapshot, fallback) {
219
+ return {
220
+ get(name) {
221
+ var _a;
222
+ if (snapshot && Object.prototype.hasOwnProperty.call(snapshot, name)) {
223
+ return snapshot[name];
224
+ }
225
+ return (_a = fallback == null ? void 0 : fallback.get) == null ? void 0 : _a.call(fallback, name);
226
+ }
227
+ };
228
+ }
229
+ function isGitReviewWorker(app) {
230
+ const workerMode = process.env.WORKER_MODE || "";
231
+ return app.serving(WORKER_JOB_GIT_REVIEW_PROCESS) || workerMode === "worker" || workerMode === "task" || process.env.APP_ROLE === "worker";
232
+ }
233
+ async function enqueueReview(app, message) {
234
+ try {
235
+ await app.eventQueue.publish(REVIEW_QUEUE_CHANNEL, message, {
236
+ timeout: REVIEW_QUEUE_TIMEOUT_MS,
237
+ maxRetries: 1
238
+ });
239
+ } catch (err) {
240
+ const safeMessage = (0, import_redact.redactPat)((err == null ? void 0 : err.message) || String(err));
241
+ await app.db.getRepository("gitCodeReviews").update({
242
+ filterByTk: message.reviewId,
243
+ values: {
244
+ status: "failed",
245
+ error: `Failed to enqueue review: ${safeMessage}`,
246
+ finishedAt: /* @__PURE__ */ new Date()
247
+ }
248
+ });
249
+ throw err;
250
+ }
251
+ }
252
+ async function failQueuedReview(app, reviewId, err) {
253
+ const safeMessage = (0, import_redact.redactPat)((err == null ? void 0 : err.message) || String(err));
254
+ await app.db.getRepository("gitCodeReviews").update({
255
+ filterByTk: reviewId,
256
+ values: {
257
+ status: "failed",
258
+ error: safeMessage,
259
+ finishedAt: /* @__PURE__ */ new Date()
260
+ }
261
+ });
262
+ }
263
+ async function processQueuedReview(app, message) {
264
+ var _a, _b, _c, _d, _e, _f, _g;
265
+ const db = app.db;
266
+ const reviewsRepo = db.getRepository("gitCodeReviews");
267
+ const review = await reviewsRepo.findOne({ filterByTk: message.reviewId });
268
+ if (!review) {
269
+ (_b = (_a = app.log) == null ? void 0 : _a.warn) == null ? void 0 : _b.call(_a, `git review queue: review ${message.reviewId} not found, skipping`);
270
+ return;
271
+ }
272
+ if (review.get("status") !== "pending") {
273
+ (_d = (_c = app.log) == null ? void 0 : _c.info) == null ? void 0 : _d.call(_c, `git review queue: review ${message.reviewId} is ${review.get("status")}, skipping`);
274
+ return;
275
+ }
276
+ try {
277
+ const repo = await db.getRepository("gitRepositories").findOne({
278
+ filterByTk: message.repositoryId || review.get("repositoryId")
279
+ });
280
+ if (!repo) throw new Error("Repository not found");
281
+ const storedFlow = await db.getRepository("gitReviewFlows").findOne({
282
+ filterByTk: ((_e = message.flowSnapshot) == null ? void 0 : _e.id) || review.get("flowId")
283
+ });
284
+ const flow = createFlowFromSnapshot(message.flowSnapshot, storedFlow);
285
+ const aiEmployeeUsername = message.aiEmployeeUsername || flow.get("aiEmployeeUsername");
286
+ if (!aiEmployeeUsername) throw new Error("Flow has no AI employee configured");
287
+ await runReview(app, {
288
+ reviewId: message.reviewId,
172
289
  flow,
173
290
  repo,
174
- targetType: args.targetType,
175
- mrIid: args.targetType === "mr" ? args.mrIid : null,
176
- commitSha: args.targetType === "commit" ? args.commitSha : null,
177
- branch: args.branch || void 0,
178
- headSha,
291
+ targetType: message.targetType || review.get("targetType"),
292
+ mrIid: message.targetType === "mr" ? message.mrIid ?? Number(review.get("mrIid")) : null,
293
+ commitSha: message.targetType === "commit" ? message.commitSha || review.get("commitSha") : null,
294
+ branch: message.branch || review.get("branch"),
295
+ headSha: message.headSha || review.get("headSha"),
179
296
  aiEmployeeUsername,
180
- extraInstructions: args.extraInstructions,
181
- userId: args.userId ?? null
182
- }).catch((err) => {
183
- var _a, _b;
184
- (_b = (_a = app.log) == null ? void 0 : _a.error) == null ? void 0 : _b.call(_a, "runReview background error", err);
185
- })
186
- );
187
- return reviewId;
297
+ extraInstructions: message.extraInstructions,
298
+ userId: message.userId ?? null
299
+ });
300
+ } catch (err) {
301
+ (_g = (_f = app.log) == null ? void 0 : _f.error) == null ? void 0 : _g.call(_f, "git review queue: failed before review execution", err);
302
+ await failQueuedReview(app, message.reviewId, err);
303
+ }
188
304
  }
189
305
  async function reviewApprovePost(ctx, next) {
190
306
  var _a, _b;
@@ -211,7 +327,8 @@ async function reviewApprovePost(ctx, next) {
211
327
  postStatus: "posted",
212
328
  postedNoteId: String(noteId),
213
329
  approvedBy: userId ? String(userId) : null,
214
- approvedAt: /* @__PURE__ */ new Date()
330
+ approvedAt: /* @__PURE__ */ new Date(),
331
+ error: null
215
332
  }
216
333
  });
217
334
  ctx.body = { success: true, data: { reviewId, postedNoteId: noteId } };
@@ -357,9 +474,10 @@ async function runReview(app, args) {
357
474
  }
358
475
  const finishedAt = /* @__PURE__ */ new Date();
359
476
  const durationMs = finishedAt.getTime() - startedAt.getTime();
360
- const postMode = args.flow.get("postMode");
361
- let postStatus = "pending_approval";
477
+ const postMode = getFlowPostMode(args.flow);
478
+ let postStatus = getInitialPostStatus(args.flow, args.targetType);
362
479
  let postedNoteId = null;
480
+ let autoPostError = null;
363
481
  if (postMode === "disabled") {
364
482
  postStatus = "skipped";
365
483
  } else if (postMode === "auto" && args.targetType === "mr" && args.mrIid) {
@@ -367,6 +485,8 @@ async function runReview(app, args) {
367
485
  postedNoteId = String(await postNoteToGitLab(args.repo, args.mrIid, content));
368
486
  postStatus = "posted";
369
487
  } catch (err) {
488
+ autoPostError = (0, import_redact.redactPat)((err == null ? void 0 : err.message) || String(err));
489
+ postStatus = "post_failed";
370
490
  (_c = (_b = app.log) == null ? void 0 : _b.error) == null ? void 0 : _c.call(_b, "Auto-post review note failed", err);
371
491
  }
372
492
  }
@@ -384,11 +504,14 @@ async function runReview(app, args) {
384
504
  finishedAt,
385
505
  postStatus,
386
506
  postedNoteId,
507
+ error: autoPostError ? `Auto-post failed: ${autoPostError}` : null,
387
508
  metadata: {
388
509
  flowName: args.flow.get("name"),
389
510
  aiEmployeeUsername: args.aiEmployeeUsername,
390
511
  llmService,
391
- model
512
+ model,
513
+ postMode,
514
+ autoPostError
392
515
  }
393
516
  }
394
517
  });
@@ -559,6 +682,23 @@ async function postNoteToGitLab(repo, mrIid, body) {
559
682
  }
560
683
  const MAX_BRANCH_FILTER_LENGTH = 200;
561
684
  const loggedBadFilters = /* @__PURE__ */ new Set();
685
+ function getFlowPostMode(flow) {
686
+ var _a;
687
+ const rawValue = (_a = flow == null ? void 0 : flow.get) == null ? void 0 : _a.call(flow, "postMode");
688
+ const value = rawValue && typeof rawValue === "object" && "value" in rawValue ? rawValue.value : rawValue;
689
+ const normalized = String(value || "manual").trim().toLowerCase().replace(/[\s-]+/g, "_");
690
+ if (["auto", "auto_post", "autopost", "auto_post_to_mr"].includes(normalized)) return "auto";
691
+ if (["disabled", "disable", "do_not_post", "dont_post", "none", "skip", "skipped"].includes(normalized)) {
692
+ return "disabled";
693
+ }
694
+ return "manual";
695
+ }
696
+ function getInitialPostStatus(flow, targetType) {
697
+ const postMode = getFlowPostMode(flow);
698
+ if (postMode === "disabled") return "skipped";
699
+ if (postMode === "auto" && targetType !== "mr") return "skipped";
700
+ return "pending_approval";
701
+ }
562
702
  function warnInvalidBranchFilter(filter, reason) {
563
703
  if (loggedBadFilters.has(filter)) return;
564
704
  loggedBadFilters.add(filter);
@@ -601,7 +741,7 @@ async function recoverStuckReviews(app) {
601
741
  const cutoff = new Date(Date.now() - STUCK_REVIEW_CUTOFF_MS);
602
742
  const stuck = await reviewsRepo.find({
603
743
  filter: {
604
- status: { $in: ["running", "pending"] },
744
+ status: "running",
605
745
  startedAt: { $lt: cutoff }
606
746
  }
607
747
  });
@@ -629,11 +769,14 @@ async function recoverStuckReviews(app) {
629
769
  }
630
770
  // Annotate the CommonJS export names for ESM import in node:
631
771
  0 && (module.exports = {
772
+ WORKER_JOB_GIT_REVIEW_PROCESS,
632
773
  branchMatches,
633
774
  pickFlowMatchingBranch,
634
775
  recoverStuckReviews,
776
+ registerReviewQueue,
635
777
  reviewApprovePost,
636
778
  reviewReject,
637
779
  triggerReview,
638
- triggerReviewInternal
780
+ triggerReviewInternal,
781
+ unregisterReviewQueue
639
782
  });
@@ -1,2 +1,2 @@
1
- declare const _default: any;
1
+ declare const _default: import("@nocobase/database").CollectionOptions;
2
2
  export default _default;
@@ -166,6 +166,7 @@ var gitCodeReviews_default = (0, import_database.defineCollection)({
166
166
  { value: "pending_approval", label: "Pending Approval" },
167
167
  { value: "approved", label: "Approved" },
168
168
  { value: "posted", label: "Posted" },
169
+ { value: "post_failed", label: "Post Failed" },
169
170
  { value: "skipped", label: "Skipped" },
170
171
  { value: "rejected", label: "Rejected" }
171
172
  ]
@@ -1,2 +1,2 @@
1
- declare const _default: any;
1
+ declare const _default: import("@nocobase/database").CollectionOptions;
2
2
  export default _default;
@@ -1,2 +1,2 @@
1
- declare const _default: any;
1
+ declare const _default: import("@nocobase/database").CollectionOptions;
2
2
  export default _default;
@@ -105,6 +105,7 @@ class PluginGitManagerServer extends import_server.Plugin {
105
105
  }
106
106
  return next();
107
107
  });
108
+ (0, import_review.registerReviewQueue)(this.app);
108
109
  (0, import_ai_tools.registerGitReviewAiTools)(this.app);
109
110
  this.app.on("afterStart", async () => {
110
111
  await ensureAutoReviewFlowSchema(this.app).catch(
@@ -122,9 +123,11 @@ class PluginGitManagerServer extends import_server.Plugin {
122
123
  (0, import_poller.startPoller)(this.app);
123
124
  });
124
125
  this.app.on("beforeStop", () => {
126
+ (0, import_review.unregisterReviewQueue)(this.app);
125
127
  (0, import_poller.stopPoller)();
126
128
  });
127
129
  this.app.on("beforeDestroy", () => {
130
+ (0, import_review.unregisterReviewQueue)(this.app);
128
131
  (0, import_poller.stopPoller)();
129
132
  });
130
133
  this.app.acl.registerSnippet({
@@ -207,9 +210,11 @@ class PluginGitManagerServer extends import_server.Plugin {
207
210
  await ((_a = this.app.db.getCollection("gitRepositories")) == null ? void 0 : _a.sync());
208
211
  }
209
212
  async beforeDisable() {
213
+ (0, import_review.unregisterReviewQueue)(this.app);
210
214
  (0, import_poller.stopPoller)();
211
215
  }
212
216
  async beforeUnload() {
217
+ (0, import_review.unregisterReviewQueue)(this.app);
213
218
  (0, import_poller.stopPoller)();
214
219
  }
215
220
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "displayName": "Git Manager",
4
4
  "displayName.zh-CN": "Git 管理器",
5
5
  "description": "Manage Git repositories with PAT authentication - pull, push, fetch, diff, file browsing",
6
- "version": "1.1.10",
6
+ "version": "1.1.12",
7
7
  "license": "Apache-2.0",
8
8
  "main": "dist/server/index.js",
9
9
  "files": [
@@ -49,6 +49,9 @@ export const CommitHistory: React.FC = () => {
49
49
  });
50
50
  const responseData = data?.data?.data || data?.data;
51
51
  setCommits(responseData?.all || []);
52
+ } catch (error) {
53
+ console.warn('Failed to load commit history:', error);
54
+ setCommits([]);
52
55
  } finally {
53
56
  setLoading(false);
54
57
  }
@@ -75,30 +75,35 @@ export const FileExplorer: React.FC = () => {
75
75
  const loadTree = useCallback(
76
76
  async (treePath = '', ref = currentRef) => {
77
77
  if (!selectedRepo) return [];
78
- const { data } = await api.request({
79
- url: 'gitManager:fileTree',
80
- params: { repositoryId: selectedRepo.id, ref, treePath },
81
- });
82
- const responseData = data?.data || data || [];
83
- const list = Array.isArray(responseData) ? responseData : (Array.isArray(responseData?.data) ? responseData.data : []);
84
-
85
- return list.map((item: any) => ({
86
- key: item.path,
87
- title: (
88
- <Text style={{ fontSize: 13 }}>
89
- {item.name}
90
- {item.type === 'blob' && item.size > 0 && (
91
- <Text type="secondary" style={{ fontSize: 11, marginLeft: 8 }}>
92
- {item.size > 1024 ? `${(item.size / 1024).toFixed(1)}KB` : `${item.size}B`}
93
- </Text>
94
- )}
95
- </Text>
96
- ),
97
- icon: getFileIcon(item.name, item.type),
98
- isLeaf: item.type === 'blob',
99
- filePath: item.path,
100
- fileType: item.type,
101
- }));
78
+ try {
79
+ const { data } = await api.request({
80
+ url: 'gitManager:fileTree',
81
+ params: { repositoryId: selectedRepo.id, ref, treePath },
82
+ });
83
+ const responseData = data?.data || data || [];
84
+ const list = Array.isArray(responseData) ? responseData : (Array.isArray(responseData?.data) ? responseData.data : []);
85
+
86
+ return list.map((item: any) => ({
87
+ key: item.path,
88
+ title: (
89
+ <Text style={{ fontSize: 13 }}>
90
+ {item.name}
91
+ {item.type === 'blob' && item.size > 0 && (
92
+ <Text type="secondary" style={{ fontSize: 11, marginLeft: 8 }}>
93
+ {item.size > 1024 ? `${(item.size / 1024).toFixed(1)}KB` : `${item.size}B`}
94
+ </Text>
95
+ )}
96
+ </Text>
97
+ ),
98
+ icon: getFileIcon(item.name, item.type),
99
+ isLeaf: item.type === 'blob',
100
+ filePath: item.path,
101
+ fileType: item.type,
102
+ }));
103
+ } catch (error) {
104
+ console.warn('Failed to load file tree:', error);
105
+ return [];
106
+ }
102
107
  },
103
108
  [api, selectedRepo, currentRef],
104
109
  );
@@ -50,6 +50,9 @@ export const GitOperations: React.FC = () => {
50
50
  });
51
51
  const responseData = data?.data?.data || data?.data;
52
52
  setStatusData(responseData);
53
+ } catch (error) {
54
+ console.warn('Failed to load git status:', error);
55
+ setStatusData(null);
53
56
  } finally {
54
57
  setActionLoading(null);
55
58
  }
@@ -22,6 +22,15 @@ const POST_LABELS: Record<string, string> = {
22
22
  disabled: 'Disabled',
23
23
  };
24
24
 
25
+ const DEFAULT_INSTRUCTION = `Please review the code changes with a focus on:
26
+ 1. Best Practices & Style:
27
+ - C#/VB.NET: Follow standard naming conventions (PascalCase for methods/properties, camelCase for fields). Ensure proper use of async/await (avoid .Result/.Wait()). Use LINQ efficiently.
28
+ - JS/TS: Use strict equality (===), prefer const/let. Ensure TypeScript types are explicit and avoid 'any'.
29
+ 2. Performance: Watch out for N+1 queries, unnecessary loops, and memory leaks.
30
+ 3. Security: Identify potential SQL injections, XSS vulnerabilities, and improper data validation.
31
+ 4. Maintainability: Check for readability, SOLID principles, and DRY. Ensure meaningful naming.
32
+ 5. Error Handling: Verify that exceptions are properly caught, handled, and logged.`;
33
+
25
34
  export const ReviewFlows: React.FC = () => {
26
35
  const t = useT();
27
36
  const api = useAPIClient();
@@ -92,6 +101,7 @@ export const ReviewFlows: React.FC = () => {
92
101
  enabled: true,
93
102
  triggerMode: 'manual',
94
103
  postMode: 'manual',
104
+ instructions: DEFAULT_INSTRUCTION,
95
105
  });
96
106
  setOpen(true);
97
107
  };
@@ -259,7 +269,7 @@ export const ReviewFlows: React.FC = () => {
259
269
  <Input placeholder="^(feature|hotfix)/.*$" />
260
270
  </Form.Item>
261
271
  <Form.Item name="instructions" label={t('Additional Instructions (optional)')}>
262
- <Input.TextArea rows={4} placeholder={t('Extra guidance appended to every review prompt')} />
272
+ <Input.TextArea rows={8} placeholder={t('Extra guidance appended to every review prompt')} />
263
273
  </Form.Item>
264
274
  <Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
265
275
  <Switch />
@@ -25,6 +25,7 @@ const POST_STATUS_COLOR: Record<string, string> = {
25
25
  pending_approval: 'orange',
26
26
  approved: 'cyan',
27
27
  posted: 'green',
28
+ post_failed: 'red',
28
29
  skipped: 'default',
29
30
  rejected: 'red',
30
31
  };
@@ -278,6 +279,7 @@ export const ReviewHistory: React.FC<{ initialFilter?: 'all' | 'pending_approval
278
279
  { value: 'pending_approval', label: t('pending_approval') },
279
280
  { value: 'approved', label: t('approved') },
280
281
  { value: 'posted', label: t('posted') },
282
+ { value: 'post_failed', label: t('post_failed') },
281
283
  { value: 'skipped', label: t('skipped') },
282
284
  { value: 'rejected', label: t('rejected') },
283
285
  ]}
@@ -395,7 +397,9 @@ const ReviewDetailView: React.FC<{
395
397
  const canApprove =
396
398
  review.status === 'completed' &&
397
399
  review.targetType === 'mr' &&
398
- (review.postStatus === 'pending_approval' || review.postStatus === 'approved');
400
+ (review.postStatus === 'pending_approval' ||
401
+ review.postStatus === 'approved' ||
402
+ review.postStatus === 'post_failed');
399
403
 
400
404
  return (
401
405
  <div>
@@ -417,6 +421,15 @@ const ReviewDetailView: React.FC<{
417
421
  style={{ marginBottom: 16 }}
418
422
  />
419
423
  )}
424
+ {review.status === 'completed' && review.postStatus === 'post_failed' && review.error && (
425
+ <Alert
426
+ type="error"
427
+ showIcon
428
+ message={t('Post Failed')}
429
+ description={<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{review.error}</pre>}
430
+ style={{ marginBottom: 16 }}
431
+ />
432
+ )}
420
433
 
421
434
  {/* Action bar */}
422
435
  {canApprove && (
@@ -125,6 +125,8 @@
125
125
  "Posted": "Posted",
126
126
  "Pending Approval": "Pending Approval",
127
127
  "Approved": "Approved",
128
+ "Post Failed": "Post Failed",
129
+ "post_failed": "Post failed",
128
130
  "Rejected": "Rejected",
129
131
  "Skipped": "Skipped",
130
132
  "Running": "Running",
@@ -125,6 +125,8 @@
125
125
  "Posted": "Đã đăng",
126
126
  "Pending Approval": "Chờ duyệt",
127
127
  "Approved": "Đã duyệt",
128
+ "Post Failed": "Đăng thất bại",
129
+ "post_failed": "Đăng thất bại",
128
130
  "Rejected": "Đã từ chối",
129
131
  "Skipped": "Đã bỏ qua",
130
132
  "Running": "Đang chạy",
@@ -91,7 +91,10 @@ function getAuthUrl(repoUrl: string, pat: string, username?: string): string {
91
91
  return url.toString();
92
92
  }
93
93
 
94
- function getGit(localPath: string): SimpleGit {
94
+ function getGit(ctx: Context, localPath: string): SimpleGit {
95
+ if (!fs.existsSync(localPath)) {
96
+ ctx.throw(400, 'Repository directory does not exist. Please clone the repository first.');
97
+ }
95
98
  return simpleGit(localPath);
96
99
  }
97
100
 
@@ -170,7 +173,7 @@ export async function pull(ctx: Context, next: () => Promise<void>) {
170
173
  const repoUrl = (repo.get('repoUrl') as string || '').trim();
171
174
  const username = (repo.get('username') as string || '').trim();
172
175
 
173
- const git = getGit(localPath);
176
+ const git = getGit(ctx, localPath);
174
177
  const result = await withAuth(git, localPath, repoUrl, pat, () => git.pull(), username);
175
178
 
176
179
  ctx.body = { success: true, data: result };
@@ -184,7 +187,7 @@ export async function push(ctx: Context, next: () => Promise<void>) {
184
187
  const repoUrl = (repo.get('repoUrl') as string || '').trim();
185
188
  const username = (repo.get('username') as string || '').trim();
186
189
 
187
- const git = getGit(localPath);
190
+ const git = getGit(ctx, localPath);
188
191
  const result = await withAuth(git, localPath, repoUrl, pat, () => git.push(), username);
189
192
 
190
193
  ctx.body = { success: true, data: result };
@@ -198,7 +201,7 @@ export async function fetch(ctx: Context, next: () => Promise<void>) {
198
201
  const repoUrl = (repo.get('repoUrl') as string || '').trim();
199
202
  const username = (repo.get('username') as string || '').trim();
200
203
 
201
- const git = getGit(localPath);
204
+ const git = getGit(ctx, localPath);
202
205
  const result = await withAuth(git, localPath, repoUrl, pat, () => git.fetch(), username);
203
206
 
204
207
  ctx.body = { success: true, data: result };
@@ -210,7 +213,7 @@ export async function diff(ctx: Context, next: () => Promise<void>) {
210
213
  const localPath = validateLocalPath(repo.get('localPath'));
211
214
  const { file, commitHash, compareHash } = ctx.action.params;
212
215
 
213
- const git = getGit(localPath);
216
+ const git = getGit(ctx, localPath);
214
217
  const args: string[] = [];
215
218
  if (commitHash && compareHash) {
216
219
  args.push(validateRef(commitHash), validateRef(compareHash));
@@ -230,7 +233,7 @@ export async function diff(ctx: Context, next: () => Promise<void>) {
230
233
  export async function status(ctx: Context, next: () => Promise<void>) {
231
234
  const repo = await getRepo(ctx);
232
235
  const localPath = validateLocalPath(repo.get('localPath'));
233
- const result = await getGit(localPath).status();
236
+ const result = await getGit(ctx, localPath).status();
234
237
  ctx.body = { success: true, data: result };
235
238
  await next();
236
239
  }
@@ -247,7 +250,7 @@ export async function log(ctx: Context, next: () => Promise<void>) {
247
250
  options.file = file;
248
251
  }
249
252
 
250
- const result = await getGit(localPath).log(options);
253
+ const result = await getGit(ctx, localPath).log(options);
251
254
  ctx.body = { success: true, data: result };
252
255
  await next();
253
256
  }
@@ -255,7 +258,7 @@ export async function log(ctx: Context, next: () => Promise<void>) {
255
258
  export async function branches(ctx: Context, next: () => Promise<void>) {
256
259
  const repo = await getRepo(ctx);
257
260
  const localPath = validateLocalPath(repo.get('localPath'));
258
- const result = await getGit(localPath).branch();
261
+ const result = await getGit(ctx, localPath).branch();
259
262
  ctx.body = { success: true, data: result };
260
263
  await next();
261
264
  }
@@ -265,7 +268,7 @@ export async function checkout(ctx: Context, next: () => Promise<void>) {
265
268
  const localPath = validateLocalPath(repo.get('localPath'));
266
269
  const { branch } = ctx.action.params;
267
270
  validateBranch(branch);
268
- await getGit(localPath).checkout(branch);
271
+ await getGit(ctx, localPath).checkout(branch);
269
272
  ctx.body = { success: true, message: `Switched to branch ${branch}` };
270
273
  await next();
271
274
  }
@@ -275,7 +278,7 @@ export async function fileTree(ctx: Context, next: () => Promise<void>) {
275
278
  const localPath = validateLocalPath(repo.get('localPath'));
276
279
  const { ref = 'HEAD', treePath = '' } = ctx.action.params;
277
280
 
278
- const git = getGit(localPath);
281
+ const git = getGit(ctx, localPath);
279
282
  validateRef(ref);
280
283
  if (treePath && treePath.includes('..')) {
281
284
  ctx.throw(400, 'Invalid tree path');
@@ -324,7 +327,7 @@ export async function fileContent(ctx: Context, next: () => Promise<void>) {
324
327
  }
325
328
 
326
329
  validateRef(ref);
327
- const git = getGit(localPath);
330
+ const git = getGit(ctx, localPath);
328
331
  const content = await git.show([`${ref}:${filePath}`]);
329
332
  ctx.body = { success: true, data: { content, filePath, ref } };
330
333
  await next();
@@ -339,7 +342,7 @@ export async function commitDetail(ctx: Context, next: () => Promise<void>) {
339
342
  ctx.throw(400, 'commitHash is required');
340
343
  }
341
344
 
342
- const git = getGit(localPath);
345
+ const git = getGit(ctx, localPath);
343
346
  validateRef(commitHash);
344
347
 
345
348
  // Use %x00 in format string to tell git to output null bytes, avoiding null bytes in args