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.
- package/dist/externalVersion.js +4 -4
- package/dist/server/actions/git-actions.js +30 -25
- package/dist/server/actions/gitlab-api.js +23 -10
- package/dist/server/actions/review.d.ts +1 -0
- package/dist/server/actions/review.js +106 -14
- package/dist/server/ai-tools.js +57 -9
- package/dist/server/collections/gitRepositories.js +1 -1
- package/dist/server/plugin.js +19 -0
- package/dist/server/poller.js +103 -27
- package/dist/server/utils/redact.d.ts +13 -0
- package/dist/server/utils/redact.js +49 -0
- package/package.json +1 -1
- package/src/server/actions/git-actions.ts +39 -25
- package/src/server/actions/gitlab-api.ts +34 -11
- package/src/server/actions/review.ts +162 -15
- package/src/server/ai-tools.ts +67 -8
- package/src/server/collections/gitRepositories.ts +1 -1
- package/src/server/plugin.ts +24 -0
- package/src/server/poller.ts +121 -35
- package/src/server/utils/redact.ts +24 -0
package/dist/server/poller.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
84
|
+
return noop;
|
|
88
85
|
}
|
|
89
|
-
}
|
|
90
|
-
async function releasePollerLock(app) {
|
|
91
|
-
var _a;
|
|
92
86
|
try {
|
|
93
|
-
|
|
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(
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
117
|
-
if (!
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
|
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:
|
|
149
|
+
userNotesCount: typeof pr.comments === 'number' ? pr.comments : 0,
|
|
141
150
|
upvotes: 0,
|
|
142
151
|
downvotes: 0,
|
|
143
152
|
webUrl: pr.html_url,
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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 {
|