plugin-git-manager 1.0.10 → 1.0.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.
@@ -67,39 +67,78 @@ function getPollerStatus() {
67
67
  intervalSec: POLL_INTERVAL_MS / 1e3
68
68
  };
69
69
  }
70
+ const ADVISORY_LOCK_KEY = 777042;
70
71
  async function tryAcquirePollerLock(app) {
71
72
  var _a, _b, _c;
73
+ const noop = { release: async () => void 0 };
74
+ const sequelize = app.db.sequelize;
75
+ if (!sequelize) return noop;
76
+ const dialect = (_a = sequelize.getDialect) == null ? void 0 : _a.call(sequelize);
77
+ if (dialect !== "postgres" && dialect !== "mysql" && dialect !== "mariadb") {
78
+ return noop;
79
+ }
80
+ let transaction;
72
81
  try {
73
- const sequelize = app.db.sequelize;
74
- if (!sequelize) return true;
75
- const dialect = (_a = sequelize.getDialect) == null ? void 0 : _a.call(sequelize);
76
- const lockKey = 777042;
77
- if (dialect === "postgres") {
78
- const [results] = await sequelize.query(`SELECT pg_try_advisory_lock(${lockKey}) AS locked`);
79
- return ((_b = results == null ? void 0 : results[0]) == null ? void 0 : _b.locked) === true;
80
- }
81
- if (dialect === "mysql" || dialect === "mariadb") {
82
- const [results] = await sequelize.query(`SELECT GET_LOCK('git_poller', 0) AS locked`);
83
- return ((_c = results == null ? void 0 : results[0]) == null ? void 0 : _c.locked) === 1;
84
- }
85
- return true;
82
+ transaction = await sequelize.transaction();
86
83
  } catch {
87
- return true;
84
+ return noop;
88
85
  }
89
- }
90
- async function releasePollerLock(app) {
91
- var _a;
92
86
  try {
93
- const sequelize = app.db.sequelize;
94
- if (!sequelize) return;
95
- const dialect = (_a = sequelize.getDialect) == null ? void 0 : _a.call(sequelize);
96
- const lockKey = 777042;
87
+ let acquired = false;
97
88
  if (dialect === "postgres") {
98
- await sequelize.query(`SELECT pg_advisory_unlock(${lockKey})`);
99
- } else if (dialect === "mysql" || dialect === "mariadb") {
100
- await sequelize.query(`SELECT RELEASE_LOCK('git_poller')`);
89
+ const [results] = await sequelize.query(
90
+ `SELECT pg_try_advisory_lock(${ADVISORY_LOCK_KEY}) AS locked`,
91
+ { transaction }
92
+ );
93
+ acquired = ((_b = results == null ? void 0 : results[0]) == null ? void 0 : _b.locked) === true;
94
+ } else {
95
+ const [results] = await sequelize.query(
96
+ `SELECT GET_LOCK('git_poller', 0) AS locked`,
97
+ { transaction }
98
+ );
99
+ const v = (_c = results == null ? void 0 : results[0]) == null ? void 0 : _c.locked;
100
+ acquired = v === 1 || v === "1" || v === true;
101
+ }
102
+ if (!acquired) {
103
+ try {
104
+ await transaction.rollback();
105
+ } catch {
106
+ }
107
+ return null;
101
108
  }
109
+ return {
110
+ release: async () => {
111
+ try {
112
+ if (dialect === "postgres") {
113
+ await sequelize.query(
114
+ `SELECT pg_advisory_unlock(${ADVISORY_LOCK_KEY})`,
115
+ { transaction }
116
+ );
117
+ } else {
118
+ await sequelize.query(
119
+ `SELECT RELEASE_LOCK('git_poller')`,
120
+ { transaction }
121
+ );
122
+ }
123
+ } catch {
124
+ } finally {
125
+ try {
126
+ await transaction.commit();
127
+ } catch {
128
+ try {
129
+ await transaction.rollback();
130
+ } catch {
131
+ }
132
+ }
133
+ }
134
+ }
135
+ };
102
136
  } catch {
137
+ try {
138
+ await transaction.rollback();
139
+ } catch {
140
+ }
141
+ return noop;
103
142
  }
104
143
  }
105
144
  async function pollAllRepos(app) {
@@ -113,8 +152,8 @@ async function pollAllRepos(app) {
113
152
  return { scanned: 0, triggered: 0 };
114
153
  }
115
154
  }
116
- const lockAcquired = await tryAcquirePollerLock(app);
117
- if (!lockAcquired) {
155
+ const lock = await tryAcquirePollerLock(app);
156
+ if (!lock) {
118
157
  (_d = (_c = app.log) == null ? void 0 : _c.debug) == null ? void 0 : _d.call(_c, "poller: another node holds the advisory lock \u2014 skipping");
119
158
  return { scanned: 0, triggered: 0 };
120
159
  }
@@ -142,7 +181,7 @@ async function pollAllRepos(app) {
142
181
  } finally {
143
182
  isPolling = false;
144
183
  pollStartedAt = null;
145
- await releasePollerLock(app);
184
+ await lock.release();
146
185
  }
147
186
  return { scanned, triggered };
148
187
  }
@@ -211,6 +250,43 @@ async function pollOneRepo(app, repo) {
211
250
  async function listMergeRequests(repo, updatedAfter) {
212
251
  const repoUrl = repo.get("repoUrl");
213
252
  const pat = repo.get("pat");
253
+ const isGitHub = typeof repoUrl === "string" && repoUrl.includes("github.com");
254
+ if (isGitHub) {
255
+ const { projectPath } = (0, import_gitlab_url.parseGitLabProject)(repoUrl);
256
+ const params2 = new URLSearchParams({
257
+ state: "open",
258
+ per_page: String(MR_PAGE_SIZE),
259
+ sort: "updated",
260
+ direction: "desc"
261
+ });
262
+ const headers = { Accept: "application/vnd.github.v3+json" };
263
+ if (pat) headers["Authorization"] = `Bearer ${pat}`;
264
+ const response2 = await fetch(
265
+ `https://api.github.com/repos/${projectPath}/pulls?${params2.toString()}`,
266
+ { headers }
267
+ );
268
+ if (!response2.ok) {
269
+ const body = await response2.text().catch(() => "");
270
+ throw new Error(`GitHub API error ${response2.status}: ${body}`);
271
+ }
272
+ let prs = await response2.json();
273
+ if (Array.isArray(prs) && updatedAfter) {
274
+ const cutoff = updatedAfter.getTime() - 1e3;
275
+ prs = prs.filter((pr) => {
276
+ const t = (pr == null ? void 0 : pr.updated_at) ? new Date(pr.updated_at).getTime() : 0;
277
+ return t >= cutoff;
278
+ });
279
+ }
280
+ return (prs || []).map((pr) => {
281
+ var _a, _b, _c;
282
+ return {
283
+ iid: pr.number,
284
+ sha: (_a = pr.head) == null ? void 0 : _a.sha,
285
+ source_branch: (_b = pr.head) == null ? void 0 : _b.ref,
286
+ target_branch: (_c = pr.base) == null ? void 0 : _c.ref
287
+ };
288
+ });
289
+ }
214
290
  const { apiBase, encodedProject } = (0, import_gitlab_url.parseGitLabProject)(repoUrl);
215
291
  const params = new URLSearchParams({
216
292
  state: "opened",
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Redact embedded credentials from URLs in arbitrary strings.
3
+ * Matches `scheme://user:password@host` and replaces with `scheme://***:***@host`.
4
+ * Used to scrub error messages before persisting them to the DB or
5
+ * returning them to the client, since simple-git often echoes the
6
+ * authenticated remote URL in stderr.
7
+ */
8
+ export declare function redactPat(s: unknown): string;
9
+ /**
10
+ * Mutate `err.message` (and common fields where simple-git stashes stderr)
11
+ * to remove any embedded PAT before the error propagates further.
12
+ */
13
+ export declare function redactError<T>(err: T): T;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __export = (target, all) => {
15
+ for (var name in all)
16
+ __defProp(target, name, { get: all[name], enumerable: true });
17
+ };
18
+ var __copyProps = (to, from, except, desc) => {
19
+ if (from && typeof from === "object" || typeof from === "function") {
20
+ for (let key of __getOwnPropNames(from))
21
+ if (!__hasOwnProp.call(to, key) && key !== except)
22
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
23
+ }
24
+ return to;
25
+ };
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+ var redact_exports = {};
28
+ __export(redact_exports, {
29
+ redactError: () => redactError,
30
+ redactPat: () => redactPat
31
+ });
32
+ module.exports = __toCommonJS(redact_exports);
33
+ function redactPat(s) {
34
+ if (typeof s !== "string") return s == null ? "" : String(s);
35
+ return s.replace(/(https?:\/\/)([^\/:@\s]+):([^@\s]+)@/g, "$1***:***@");
36
+ }
37
+ function redactError(err) {
38
+ if (!err || typeof err !== "object") return err;
39
+ const e = err;
40
+ if (typeof e.message === "string") e.message = redactPat(e.message);
41
+ if (typeof e.stderr === "string") e.stderr = redactPat(e.stderr);
42
+ if (typeof e.stdout === "string") e.stdout = redactPat(e.stdout);
43
+ return err;
44
+ }
45
+ // Annotate the CommonJS export names for ESM import in node:
46
+ 0 && (module.exports = {
47
+ redactError,
48
+ redactPat
49
+ });
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.0.10",
6
+ "version": "1.0.12",
7
7
  "license": "Apache-2.0",
8
8
  "main": "dist/server/index.js",
9
9
  "files": [
@@ -2,8 +2,11 @@ import simpleGit, { SimpleGit } from 'simple-git';
2
2
  import { Context } from '@nocobase/actions';
3
3
  import * as path from 'path';
4
4
  import * as fs from 'fs';
5
+ import { redactPat, redactError } from '../utils/redact';
5
6
 
6
- const REF_PATTERN = /^[a-zA-Z0-9._\-\/]+$/;
7
+ // Disallow leading `-` to prevent argument-injection (e.g. `--upload-pack=...`)
8
+ // when refs are passed as positional args to git.
9
+ const REF_PATTERN = /^(?!-)[a-zA-Z0-9._\-\/]+$/;
7
10
 
8
11
  // Per-repo mutex to prevent PAT race conditions in withAuth
9
12
  const repoLocks = new Map<string, Promise<any>>();
@@ -45,14 +48,21 @@ function validateRepoUrl(repoUrl: string): void {
45
48
  }
46
49
  }
47
50
 
48
- async function withAuth(git: ReturnType<typeof simpleGit>, repoUrl: string, pat: string, fn: () => Promise<any>, username?: string) {
49
- const lockKey = repoUrl;
51
+ async function withAuth(git: ReturnType<typeof simpleGit>, localPath: string, repoUrl: string, pat: string, fn: () => Promise<any>, username?: string) {
52
+ // Lock by local working tree — that's what `git.remote('set-url', ...)`
53
+ // mutates. Two repo records sharing a `repoUrl` but cloned to different
54
+ // paths can run in parallel safely; conversely, two repos pointed at the
55
+ // same `localPath` (config error) must NOT run concurrent set-url's.
56
+ const lockKey = localPath;
50
57
  const lock = acquireLock(lockKey);
51
58
  await lock.promise;
52
59
  const authUrl = getAuthUrl(repoUrl, pat, username);
53
60
  await git.remote(['set-url', 'origin', authUrl]);
54
61
  try {
55
62
  return await fn();
63
+ } catch (err) {
64
+ // simple-git often echoes the authenticated URL in stderr — scrub before re-throw
65
+ throw redactError(err);
56
66
  } finally {
57
67
  // H-2 fix: guard PAT cleanup — if reset fails, the PAT-embedded URL persists on disk
58
68
  try {
@@ -64,9 +74,9 @@ async function withAuth(git: ReturnType<typeof simpleGit>, repoUrl: string, pat:
64
74
  } catch {
65
75
  // Log but don't throw — the original operation already completed
66
76
  console.error(
67
- `[plugin-git-manager] CRITICAL: failed to remove PAT from remote URL for ${repoUrl}. ` +
77
+ `[plugin-git-manager] CRITICAL: failed to remove PAT from remote URL for ${redactPat(repoUrl)}. ` +
68
78
  `Manual cleanup of .git/config may be required.`,
69
- cleanupErr,
79
+ redactError(cleanupErr),
70
80
  );
71
81
  }
72
82
  }
@@ -75,9 +85,9 @@ async function withAuth(git: ReturnType<typeof simpleGit>, repoUrl: string, pat:
75
85
  }
76
86
 
77
87
  function getAuthUrl(repoUrl: string, pat: string, username?: string): string {
78
- const url = new URL(repoUrl);
79
- url.username = username || 'oauth2';
80
- url.password = pat;
88
+ const url = new URL(repoUrl.trim());
89
+ url.username = (username || 'oauth2').trim();
90
+ url.password = pat.trim();
81
91
  return url.toString();
82
92
  }
83
93
 
@@ -114,11 +124,14 @@ function validateLocalPath(localPath: string): string {
114
124
  export async function clone(ctx: Context, next: () => Promise<void>) {
115
125
  const repo = await getRepo(ctx);
116
126
  const localPath = validateLocalPath(repo.get('localPath'));
117
- const repoUrl = repo.get('repoUrl') as string;
118
- const pat = repo.get('pat') as string;
119
- const username = repo.get('username') as string;
127
+ const repoUrl = (repo.get('repoUrl') as string || '').trim();
128
+ const pat = (repo.get('pat') as string || '').trim();
129
+ const username = (repo.get('username') as string || '').trim();
130
+ const defaultBranch = ((repo.get('defaultBranch') as string) || 'main').trim() || 'main';
120
131
 
121
132
  validateRepoUrl(repoUrl);
133
+ // Prevent argument-injection through `defaultBranch` (e.g. `--upload-pack=...`)
134
+ validateBranch(defaultBranch);
122
135
 
123
136
  // Check if directory already exists
124
137
  if (fs.existsSync(localPath)) {
@@ -131,7 +144,7 @@ export async function clone(ctx: Context, next: () => Promise<void>) {
131
144
 
132
145
  const authUrl = getAuthUrl(repoUrl, pat, username);
133
146
  try {
134
- await simpleGit().clone(authUrl, localPath, ['--branch', repo.get('defaultBranch') || 'main']);
147
+ await simpleGit().clone(authUrl, localPath, ['--branch', defaultBranch]);
135
148
  // Remove PAT from the cloned repo's remote URL
136
149
  await simpleGit(localPath).remote(['set-url', 'origin', repoUrl]);
137
150
  await ctx.db.getRepository('gitRepositories').update({
@@ -144,7 +157,8 @@ export async function clone(ctx: Context, next: () => Promise<void>) {
144
157
  filterByTk: repo.get('id'),
145
158
  values: { status: 'error' },
146
159
  });
147
- throw err;
160
+ // Redact embedded PAT before the error reaches the client / log
161
+ throw redactError(err);
148
162
  }
149
163
  await next();
150
164
  }
@@ -152,12 +166,12 @@ export async function clone(ctx: Context, next: () => Promise<void>) {
152
166
  export async function pull(ctx: Context, next: () => Promise<void>) {
153
167
  const repo = await getRepo(ctx);
154
168
  const localPath = validateLocalPath(repo.get('localPath'));
155
- const pat = repo.get('pat') as string;
156
- const repoUrl = repo.get('repoUrl') as string;
157
- const username = repo.get('username') as string;
169
+ const pat = (repo.get('pat') as string || '').trim();
170
+ const repoUrl = (repo.get('repoUrl') as string || '').trim();
171
+ const username = (repo.get('username') as string || '').trim();
158
172
 
159
173
  const git = getGit(localPath);
160
- const result = await withAuth(git, repoUrl, pat, () => git.pull(), username);
174
+ const result = await withAuth(git, localPath, repoUrl, pat, () => git.pull(), username);
161
175
 
162
176
  ctx.body = { success: true, data: result };
163
177
  await next();
@@ -166,12 +180,12 @@ export async function pull(ctx: Context, next: () => Promise<void>) {
166
180
  export async function push(ctx: Context, next: () => Promise<void>) {
167
181
  const repo = await getRepo(ctx);
168
182
  const localPath = validateLocalPath(repo.get('localPath'));
169
- const pat = repo.get('pat') as string;
170
- const repoUrl = repo.get('repoUrl') as string;
171
- const username = repo.get('username') as string;
183
+ const pat = (repo.get('pat') as string || '').trim();
184
+ const repoUrl = (repo.get('repoUrl') as string || '').trim();
185
+ const username = (repo.get('username') as string || '').trim();
172
186
 
173
187
  const git = getGit(localPath);
174
- const result = await withAuth(git, repoUrl, pat, () => git.push(), username);
188
+ const result = await withAuth(git, localPath, repoUrl, pat, () => git.push(), username);
175
189
 
176
190
  ctx.body = { success: true, data: result };
177
191
  await next();
@@ -180,12 +194,12 @@ export async function push(ctx: Context, next: () => Promise<void>) {
180
194
  export async function fetch(ctx: Context, next: () => Promise<void>) {
181
195
  const repo = await getRepo(ctx);
182
196
  const localPath = validateLocalPath(repo.get('localPath'));
183
- const pat = repo.get('pat') as string;
184
- const repoUrl = repo.get('repoUrl') as string;
185
- const username = repo.get('username') as string;
197
+ const pat = (repo.get('pat') as string || '').trim();
198
+ const repoUrl = (repo.get('repoUrl') as string || '').trim();
199
+ const username = (repo.get('username') as string || '').trim();
186
200
 
187
201
  const git = getGit(localPath);
188
- const result = await withAuth(git, repoUrl, pat, () => git.fetch(), username);
202
+ const result = await withAuth(git, localPath, repoUrl, pat, () => git.fetch(), username);
189
203
 
190
204
  ctx.body = { success: true, data: result };
191
205
  await next();
@@ -42,8 +42,8 @@ async function getRepoApiContext(ctx: Context) {
42
42
  if (!repo) {
43
43
  ctx.throw(404, 'Repository not found');
44
44
  }
45
- const pat = repo.get('pat') as string;
46
- const repoUrl = repo.get('repoUrl') as string;
45
+ const pat = (repo.get('pat') as string || '').trim();
46
+ const repoUrl = (repo.get('repoUrl') as string || '').trim();
47
47
  const isGitHub = repoUrl.includes('github.com');
48
48
  const { apiBase, encodedProject, projectPath } = parseGitLabProject(repoUrl);
49
49
  return { repo, pat, apiBase, encodedProject, projectPath, isGitHub };
@@ -104,7 +104,8 @@ export async function mergeRequests(ctx: Context, next: () => Promise<void>) {
104
104
  let items;
105
105
 
106
106
  if (isGitHub) {
107
- // Map GitLab state to GitHub state
107
+ // Map GitLab state to GitHub state. `merged` requires fetching `closed`
108
+ // and filtering client-side, since GitHub has no dedicated state.
108
109
  const ghState = state === 'opened' ? 'open' : state === 'closed' ? 'closed' : state === 'merged' ? 'closed' : 'all';
109
110
  result = await githubFetch(`/repos/${projectPath}/pulls`, pat, {
110
111
  state: ghState,
@@ -113,11 +114,19 @@ export async function mergeRequests(ctx: Context, next: () => Promise<void>) {
113
114
  sort: orderBy === 'updated_at' ? 'updated' : 'created',
114
115
  direction: sort,
115
116
  });
116
-
117
+
117
118
  let pullRequests = result.data || [];
118
- // If state is merged, filter the closed ones that are merged
119
+ // If state is merged, filter the closed ones that are merged.
120
+ // Pagination metadata from GitHub still reflects the unfiltered `closed`
121
+ // total — null it out to avoid misleading the UI.
122
+ let mergedFilterApplied = false;
119
123
  if (state === 'merged') {
120
124
  pullRequests = pullRequests.filter((pr: any) => pr.merged_at);
125
+ mergedFilterApplied = true;
126
+ }
127
+ if (mergedFilterApplied) {
128
+ result.totalPages = null;
129
+ result.total = null;
121
130
  }
122
131
 
123
132
  items = pullRequests.map((pr: any) => ({
@@ -133,16 +142,18 @@ export async function mergeRequests(ctx: Context, next: () => Promise<void>) {
133
142
  reviewers: (pr.requested_reviewers || []).map((r: any) => ({ name: r.login, username: r.login, avatarUrl: r.avatar_url })),
134
143
  labels: (pr.labels || []).map((l: any) => l.name),
135
144
  draft: pr.draft || false,
136
- mergedBy: null, // Not returned in list API
145
+ mergedBy: null, // Not returned by the PR list endpoint
137
146
  mergedAt: pr.merged_at,
138
147
  createdAt: pr.created_at,
139
148
  updatedAt: pr.updated_at,
140
- userNotesCount: 0, // Not returned in pull list API
149
+ userNotesCount: typeof pr.comments === 'number' ? pr.comments : 0,
141
150
  upvotes: 0,
142
151
  downvotes: 0,
143
152
  webUrl: pr.html_url,
144
- hasConflicts: false, // Not guaranteed in list
145
- changesCount: 0,
153
+ // GitHub's PR list does not include mergeability info — surface as
154
+ // `null` (unknown) so the UI doesn't render a misleading "no conflicts".
155
+ hasConflicts: null,
156
+ changesCount: typeof pr.changed_files === 'number' ? pr.changed_files : null,
146
157
  }));
147
158
  } else {
148
159
  if (!pat) ctx.throw(400, 'Personal Access Token is required for GitLab API access');
@@ -240,8 +251,20 @@ export async function mergeRequestDetail(ctx: Context, next: () => Promise<void>
240
251
  closedBy: null, // Not always readily available on GitHub without extra call
241
252
  closedAt: pr.closed_at,
242
253
  webUrl: pr.html_url,
243
- hasConflicts: pr.mergeable_state === 'dirty',
244
- diffStats: { additions: pr.additions },
254
+ // `mergeable_state === 'unknown'` when GitHub is still computing — surface
255
+ // `null` instead of `false` so the UI can distinguish "no conflicts" from
256
+ // "not yet known".
257
+ hasConflicts:
258
+ pr.mergeable_state === 'dirty'
259
+ ? true
260
+ : pr.mergeable_state === 'unknown' || pr.mergeable === null
261
+ ? null
262
+ : false,
263
+ diffStats: {
264
+ additions: typeof pr.additions === 'number' ? pr.additions : null,
265
+ deletions: typeof pr.deletions === 'number' ? pr.deletions : null,
266
+ changedFiles: typeof pr.changed_files === 'number' ? pr.changed_files : null,
267
+ },
245
268
  changes,
246
269
  };
247
270
  } else {