buddy-workbench 0.2.9 → 0.2.11
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/package.json +1 -1
- package/server/routes/branch-health.js +117 -41
- package/server/routes/jira-filters.js +44 -21
- package/server/routes/package-upgrade.js +17 -6
- package/server/routes/pr-conflicts.js +32 -27
- package/ui/dist/assets/index-DCok4z0M.css +1 -0
- package/ui/dist/assets/{index-Bq_WnKRX.js → index-RqsYJmXj.js} +110 -110
- package/ui/dist/assets/{xmindAdapter-XREAQgtg.js → xmindAdapter-fzErSp_R.js} +1 -1
- package/ui/dist/index.html +2 -2
- package/ui/dist/sw.js +2 -2
- package/ui/dist/assets/index-BdK43fGD.css +0 -1
package/package.json
CHANGED
|
@@ -8,13 +8,14 @@ import { addBranchHealthHistory, listBranchHealthHistory } from '../repositories
|
|
|
8
8
|
const router = Router();
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
const GIT_TIMEOUT = 30 * 1000;
|
|
11
|
+
const COMPLEX_ANALYSIS_MAX_AGE_DAYS = 365;
|
|
11
12
|
|
|
12
13
|
async function git(folder, args, options = {}) {
|
|
13
14
|
try {
|
|
14
15
|
const result = await execFileAsync('git', args, {
|
|
15
16
|
cwd: folder,
|
|
16
17
|
timeout: GIT_TIMEOUT,
|
|
17
|
-
maxBuffer: 4 * 1024 * 1024
|
|
18
|
+
maxBuffer: options.maxBuffer || 4 * 1024 * 1024
|
|
18
19
|
});
|
|
19
20
|
return result.stdout.trim();
|
|
20
21
|
} catch (error) {
|
|
@@ -49,9 +50,8 @@ function workspaceRepositoryCandidates(workspaceDir) {
|
|
|
49
50
|
|
|
50
51
|
function parseRefList(raw) {
|
|
51
52
|
return raw.split(/\r?\n/).filter(Boolean).map((line) => {
|
|
52
|
-
const
|
|
53
|
-
const ref =
|
|
54
|
-
const hash = separatorIndex === -1 ? '' : line.slice(separatorIndex + 1).trim();
|
|
53
|
+
const refFields = line.split('\t', 1)[0].trim().split(/\s+/);
|
|
54
|
+
const [ref, hash] = refFields;
|
|
55
55
|
if (!ref || !hash) return null;
|
|
56
56
|
const remote = ref.startsWith('refs/remotes/');
|
|
57
57
|
const remoteMatch = ref.match(/^refs\/remotes\/([^/]+)\/(.+)$/);
|
|
@@ -75,13 +75,61 @@ function parseCommit(raw) {
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
function parseCommitMetadata(raw) {
|
|
79
|
+
const commits = new Map();
|
|
80
|
+
raw.split(/\r?\n/).filter(Boolean).forEach((line) => {
|
|
81
|
+
const separatorIndex = line.indexOf('\t');
|
|
82
|
+
if (separatorIndex < 0) return;
|
|
83
|
+
const metadata = parseCommit(line.slice(separatorIndex + 1));
|
|
84
|
+
if (metadata?.hash) commits.set(metadata.hash, metadata);
|
|
85
|
+
});
|
|
86
|
+
return commits;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseCommitGraph(raw) {
|
|
90
|
+
const parentsByCommit = new Map();
|
|
91
|
+
const childrenByCommit = new Map();
|
|
92
|
+
const commitsByHash = new Map();
|
|
93
|
+
raw.split(/\r?\n/).filter(Boolean).forEach((line) => {
|
|
94
|
+
const fields = line.split('\x1f');
|
|
95
|
+
const commit = fields[0];
|
|
96
|
+
if (!commit) return;
|
|
97
|
+
const parents = (fields[1] || '').split(' ').filter(Boolean);
|
|
98
|
+
parentsByCommit.set(commit, parents);
|
|
99
|
+
const metadata = parseCommit([commit, ...fields.slice(2)].join('\x1f'));
|
|
100
|
+
if (metadata) commitsByHash.set(commit, metadata);
|
|
101
|
+
parents.forEach((parent) => {
|
|
102
|
+
if (!childrenByCommit.has(parent)) childrenByCommit.set(parent, new Set());
|
|
103
|
+
childrenByCommit.get(parent).add(commit);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
return { parentsByCommit, childrenByCommit, commitsByHash };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function reachableCommits(tip, parentsByCommit) {
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const pending = [tip];
|
|
112
|
+
while (pending.length) {
|
|
113
|
+
const commit = pending.pop();
|
|
114
|
+
if (!commit || seen.has(commit)) continue;
|
|
115
|
+
seen.add(commit);
|
|
116
|
+
for (const parent of parentsByCommit.get(commit) || []) pending.push(parent);
|
|
117
|
+
}
|
|
118
|
+
return seen;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isAncestor(ancestor, descendant, childrenByCommit) {
|
|
122
|
+
if (!ancestor || !descendant) return false;
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
const pending = [ancestor];
|
|
125
|
+
while (pending.length) {
|
|
126
|
+
const commit = pending.pop();
|
|
127
|
+
if (commit === descendant) return true;
|
|
128
|
+
if (seen.has(commit)) continue;
|
|
129
|
+
seen.add(commit);
|
|
130
|
+
for (const child of childrenByCommit.get(commit) || []) pending.push(child);
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
85
133
|
}
|
|
86
134
|
|
|
87
135
|
function isProtectedBranch(name) {
|
|
@@ -111,19 +159,17 @@ function healthForBranch({ merged, ahead, ageDays, isCurrent, isProtected, hasRe
|
|
|
111
159
|
return { status: 'keep', label: 'Keep', score };
|
|
112
160
|
}
|
|
113
161
|
|
|
114
|
-
async function readBranchDetails(folder, branch, refs, targetRef, currentBranch) {
|
|
162
|
+
async function readBranchDetails(folder, branch, refs, targetRef, currentBranch, commit, creator, branchCount, parentsByCommit, childrenByCommit, branchAncestors, targetAncestors) {
|
|
115
163
|
const ref = branch.localRef || branch.remoteRef;
|
|
116
|
-
const commit = parseCommit(await git(folder, [
|
|
117
|
-
'show', '-s', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%cn%x1f%ce', ref
|
|
118
|
-
], { allowFailure: true }));
|
|
119
164
|
if (!commit) return null;
|
|
120
165
|
|
|
121
|
-
let firstCommit =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
166
|
+
let firstCommit = creator ? { creator } : null;
|
|
167
|
+
let createdAtSource = 'not available from bulk scan';
|
|
168
|
+
const ageDays = dateAgeDays(commit.committedAt || commit.authoredAt);
|
|
169
|
+
const runComplexAnalysis = ageDays === null || ageDays <= COMPLEX_ANALYSIS_MAX_AGE_DAYS;
|
|
170
|
+
if (!runComplexAnalysis) createdAtSource = 'not analyzed (last commit older than 365 days)';
|
|
125
171
|
|
|
126
|
-
if (branch.localRef) {
|
|
172
|
+
if (runComplexAnalysis && branch.localRef) {
|
|
127
173
|
const reflog = await git(folder, ['reflog', 'show', '--format=%ct%x1f%cI%x1f%gs', branch.localRef], { allowFailure: true });
|
|
128
174
|
const earliestEntry = reflog.split(/\r?\n/).filter(Boolean).at(-1);
|
|
129
175
|
if (earliestEntry) {
|
|
@@ -139,27 +185,23 @@ async function readBranchDetails(folder, branch, refs, targetRef, currentBranch)
|
|
|
139
185
|
}
|
|
140
186
|
}
|
|
141
187
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
], { allowFailure: true }));
|
|
146
|
-
const containingNames = [...new Set(containingRefs.map((item) => item.name).filter((name) => name !== branch.name))];
|
|
188
|
+
const containingNames = runComplexAnalysis
|
|
189
|
+
? [...new Set(refs.filter((item) => item.name !== branch.name && isAncestor(commit.hash, item.hash, childrenByCommit)).map((item) => item.name))]
|
|
190
|
+
: [];
|
|
147
191
|
const protectedContaining = containingNames.filter(isProtectedBranch);
|
|
148
192
|
// A child branch containing main's commit is not evidence that main was
|
|
149
193
|
// merged into that child. Only protected/default targets count as merge
|
|
150
194
|
// evidence for cleanup decisions in this local-only phase.
|
|
151
195
|
const mergedInto = protectedContaining[0] || null;
|
|
152
|
-
const merged = Boolean(mergedInto);
|
|
153
|
-
|
|
154
|
-
let ahead = 0;
|
|
155
|
-
let behind = 0;
|
|
156
|
-
if (targetRef && ref !== targetRef) {
|
|
157
|
-
|
|
158
|
-
behind =
|
|
159
|
-
ahead = Number.isFinite(counts[1]) ? counts[1] : 0;
|
|
196
|
+
const merged = runComplexAnalysis ? Boolean(mergedInto) : false;
|
|
197
|
+
|
|
198
|
+
let ahead = runComplexAnalysis ? 0 : null;
|
|
199
|
+
let behind = runComplexAnalysis ? 0 : null;
|
|
200
|
+
if (runComplexAnalysis && targetRef && ref !== targetRef) {
|
|
201
|
+
ahead = [...branchAncestors].filter((hash) => !targetAncestors.has(hash)).length;
|
|
202
|
+
behind = [...targetAncestors].filter((hash) => !branchAncestors.has(hash)).length;
|
|
160
203
|
}
|
|
161
204
|
|
|
162
|
-
const ageDays = dateAgeDays(commit.committedAt || commit.authoredAt);
|
|
163
205
|
const isCurrent = !branch.remote && currentBranch === branch.name;
|
|
164
206
|
const isProtected = isProtectedBranch(branch.name);
|
|
165
207
|
const health = healthForBranch({ merged, ahead, ageDays, isCurrent, isProtected, hasRemote: Boolean(branch.remoteRef) });
|
|
@@ -178,16 +220,17 @@ async function readBranchDetails(folder, branch, refs, targetRef, currentBranch)
|
|
|
178
220
|
lastCommit: commit,
|
|
179
221
|
ageDays,
|
|
180
222
|
commitCount: branchCount,
|
|
223
|
+
analysisSkipped: !runComplexAnalysis,
|
|
181
224
|
ahead,
|
|
182
225
|
behind,
|
|
183
226
|
merged,
|
|
184
227
|
mergedInto,
|
|
185
228
|
mergedIntoAll: protectedContaining,
|
|
186
|
-
mergeSource: merged ? 'local ancestry (inferred)' : 'not found in local refs',
|
|
229
|
+
mergeSource: !runComplexAnalysis ? 'skipped (last commit older than 365 days)' : merged ? 'local ancestry (inferred)' : 'not found in local refs',
|
|
187
230
|
openPullRequest: null,
|
|
188
231
|
health,
|
|
189
232
|
reasons: [
|
|
190
|
-
merged ? `Merged into ${mergedInto}` : 'No containing target branch found',
|
|
233
|
+
!runComplexAnalysis ? 'Complex ancestry analysis skipped for branches inactive over one year' : merged ? `Merged into ${mergedInto}` : 'No containing target branch found',
|
|
191
234
|
ageDays >= 90 ? `${ageDays} days since last commit` : null,
|
|
192
235
|
ahead > 0 ? `${ahead} commits not in the default branch` : null,
|
|
193
236
|
isCurrent ? 'Currently checked out' : null,
|
|
@@ -201,12 +244,22 @@ async function scanRepository(folder) {
|
|
|
201
244
|
const root = resolve(folder);
|
|
202
245
|
if (!existsSync(join(root, '.git'))) throw new Error(`Not a Git repository: ${root}`);
|
|
203
246
|
|
|
204
|
-
const [currentBranch, remoteUrl,
|
|
247
|
+
const [currentBranch, remoteUrl, refsAndMetadataRaw] = await Promise.all([
|
|
205
248
|
git(root, ['branch', '--show-current'], { allowFailure: true }),
|
|
206
249
|
git(root, ['remote', 'get-url', 'origin'], { allowFailure: true }),
|
|
207
|
-
git(root, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes'])
|
|
250
|
+
git(root, ['for-each-ref', '--format=%(refname) %(objectname)%09%(objectname)%1f%(objectname:short)%1f%(subject)%1f%(authorname)%1f%(authoremail:trim)%1f%(authordate:iso-strict)%1f%(committerdate:iso-strict)%1f%(committername)%1f%(committeremail:trim)', 'refs/heads', 'refs/remotes'])
|
|
208
251
|
]);
|
|
209
|
-
const refs = parseRefList(
|
|
252
|
+
const refs = parseRefList(refsAndMetadataRaw);
|
|
253
|
+
const commitMetadata = parseCommitMetadata(refsAndMetadataRaw);
|
|
254
|
+
const graphTips = [...new Set(refs.filter((item) => {
|
|
255
|
+
const commit = commitMetadata.get(item.hash);
|
|
256
|
+
const ageDays = dateAgeDays(commit?.committedAt || commit?.authoredAt);
|
|
257
|
+
return isProtectedBranch(item.name) || ageDays === null || ageDays <= COMPLEX_ANALYSIS_MAX_AGE_DAYS;
|
|
258
|
+
}).map((item) => item.hash))];
|
|
259
|
+
const graphRaw = graphTips.length > 0
|
|
260
|
+
? await git(root, ['log', '--parents', '--format=%H%x1f%P%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%cn%x1f%ce', ...graphTips], { maxBuffer: 128 * 1024 * 1024 })
|
|
261
|
+
: '';
|
|
262
|
+
const { parentsByCommit, childrenByCommit, commitsByHash } = parseCommitGraph(graphRaw);
|
|
210
263
|
|
|
211
264
|
// Remote refs are normalized to their branch name so local and origin/main
|
|
212
265
|
// are presented as one row when both are available.
|
|
@@ -223,9 +276,30 @@ async function scanRepository(folder) {
|
|
|
223
276
|
const targetName = preferredTargets.find((name) => normalized.has(name)) || [...normalized.keys()].find(isProtectedBranch) || null;
|
|
224
277
|
const targetBranch = targetName ? normalized.get(targetName) : null;
|
|
225
278
|
const targetRef = targetBranch?.localRef || targetBranch?.remoteRef || null;
|
|
279
|
+
const targetHash = refs.find((item) => item.ref === targetRef)?.hash;
|
|
280
|
+
const targetAncestors = targetHash ? reachableCommits(targetHash, parentsByCommit) : new Set();
|
|
226
281
|
const details = [];
|
|
227
282
|
for (const branch of normalized.values()) {
|
|
228
|
-
const
|
|
283
|
+
const ref = branch.localRef || branch.remoteRef;
|
|
284
|
+
const tip = refs.find((item) => item.ref === ref)?.hash;
|
|
285
|
+
const commit = commitMetadata.get(tip) || null;
|
|
286
|
+
const ageDays = dateAgeDays(commit?.committedAt || commit?.authoredAt);
|
|
287
|
+
const runComplexAnalysis = ageDays === null || ageDays <= COMPLEX_ANALYSIS_MAX_AGE_DAYS;
|
|
288
|
+
const branchAncestors = runComplexAnalysis ? reachableCommits(tip, parentsByCommit) : null;
|
|
289
|
+
const branchCount = branchAncestors?.size ?? null;
|
|
290
|
+
let creator = null;
|
|
291
|
+
if (branchAncestors) {
|
|
292
|
+
let firstCommitTimestamp = Infinity;
|
|
293
|
+
for (const hash of branchAncestors) {
|
|
294
|
+
const candidate = commitsByHash.get(hash);
|
|
295
|
+
const timestamp = Date.parse(candidate?.authoredAt || '');
|
|
296
|
+
if (candidate && Number.isFinite(timestamp) && timestamp < firstCommitTimestamp) {
|
|
297
|
+
firstCommitTimestamp = timestamp;
|
|
298
|
+
creator = candidate.author;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const detail = await readBranchDetails(root, branch, refs, targetRef, currentBranch, commit, creator, branchCount, parentsByCommit, childrenByCommit, branchAncestors, targetAncestors);
|
|
229
303
|
if (detail) details.push(detail);
|
|
230
304
|
}
|
|
231
305
|
|
|
@@ -237,8 +311,9 @@ async function scanRepository(folder) {
|
|
|
237
311
|
scannedAt: new Date().toISOString(),
|
|
238
312
|
analysis: {
|
|
239
313
|
source: 'local Git',
|
|
314
|
+
complexAnalysisMaxAgeDays: COMPLEX_ANALYSIS_MAX_AGE_DAYS,
|
|
240
315
|
mergeEvidence: 'Local ref ancestry; remote PR history is not queried in this phase.',
|
|
241
|
-
creationEvidence: 'Local reflog
|
|
316
|
+
creationEvidence: 'Local reflog for recently active local branches; older branches skip creation and ancestry analysis.'
|
|
242
317
|
}
|
|
243
318
|
};
|
|
244
319
|
}
|
|
@@ -262,6 +337,7 @@ function deletionChecks(branch) {
|
|
|
262
337
|
if (!branch.hasLocal && !branch.hasRemote) blockers.push('Branch no longer exists in local or origin refs.');
|
|
263
338
|
if (branch.current) blockers.push('The current branch cannot be deleted.');
|
|
264
339
|
if (branch.protected) blockers.push('Protected branch patterns cannot be deleted.');
|
|
340
|
+
if (branch.analysisSkipped) blockers.push('Complex ancestry analysis was skipped for this branch, so deletion cannot be verified.');
|
|
265
341
|
if (!branch.merged) blockers.push('The branch head is not contained in another local ref.');
|
|
266
342
|
if (branch.ahead > 0) blockers.push(`${branch.ahead} commit(s) are not in the default branch.`);
|
|
267
343
|
|
|
@@ -354,24 +354,49 @@ async function searchJiraWithFallbacksPaginated(jiraHost, headers, jqlCandidates
|
|
|
354
354
|
}
|
|
355
355
|
|
|
356
356
|
async function fetchCommentsForIssue(jiraHost, headers, issueKey, maxResults = 50) {
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const latestResponse = await httpClient.get(latestUrl, { headers });
|
|
367
|
-
if (latestResponse.status === 200 && Array.isArray(latestResponse.data?.comments)) {
|
|
368
|
-
return latestResponse.data.comments;
|
|
357
|
+
const requestComments = async (url) => {
|
|
358
|
+
const maxRetries = 4;
|
|
359
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
360
|
+
const response = await httpClient.get(url, { headers });
|
|
361
|
+
if (response.status === 200 && Array.isArray(response.data?.comments)) return response;
|
|
362
|
+
if (response.status !== 429 || attempt >= maxRetries) {
|
|
363
|
+
const error = new Error(`Jira comments request for ${issueKey} returned HTTP ${response.status}`);
|
|
364
|
+
error.status = response.status;
|
|
365
|
+
throw error;
|
|
369
366
|
}
|
|
367
|
+
|
|
368
|
+
const retryAfter = response.headers?.['retry-after'] ?? response.headers?.['Retry-After'];
|
|
369
|
+
const retryAfterSeconds = Number(retryAfter);
|
|
370
|
+
const retryAfterDate = Date.parse(retryAfter);
|
|
371
|
+
const delayMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0
|
|
372
|
+
? retryAfterSeconds * 1000
|
|
373
|
+
: Number.isFinite(retryAfterDate)
|
|
374
|
+
? Math.max(0, retryAfterDate - Date.now())
|
|
375
|
+
: Math.min(1000 * (2 ** attempt), 10000);
|
|
376
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
370
377
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const url = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment?maxResults=${maxResults}`;
|
|
381
|
+
const response = await requestComments(url);
|
|
382
|
+
const total = response.data?.total || 0;
|
|
383
|
+
const comments = response.data.comments;
|
|
384
|
+
if (total > comments.length && total > maxResults) {
|
|
385
|
+
const startAt = Math.max(0, total - maxResults);
|
|
386
|
+
const latestUrl = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment?startAt=${startAt}&maxResults=${maxResults}`;
|
|
387
|
+
const latestResponse = await requestComments(latestUrl);
|
|
388
|
+
return latestResponse.data.comments;
|
|
389
|
+
}
|
|
390
|
+
return comments;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function mapInBatches(items, batchSize, mapper) {
|
|
394
|
+
const results = [];
|
|
395
|
+
for (let index = 0; index < items.length; index += batchSize) {
|
|
396
|
+
const batchResults = await Promise.all(items.slice(index, index + batchSize).map(mapper));
|
|
397
|
+
results.push(...batchResults);
|
|
374
398
|
}
|
|
399
|
+
return results;
|
|
375
400
|
}
|
|
376
401
|
|
|
377
402
|
router.get('/recently-created', (_req, res) => {
|
|
@@ -409,7 +434,7 @@ router.get('/recently-commented', async (_req, res) => {
|
|
|
409
434
|
const issues = await searchJiraWithFallbacksPaginated(jiraHost, headers, jqlCandidates, 'summary,priority,status,updated,assignee,reporter');
|
|
410
435
|
const results = [];
|
|
411
436
|
|
|
412
|
-
const
|
|
437
|
+
const issueComments = await mapInBatches(issues, 5, async (issue) => {
|
|
413
438
|
const key = issue.key;
|
|
414
439
|
const fields = issue.fields || {};
|
|
415
440
|
const comments = await fetchCommentsForIssue(jiraHost, headers, key, 50);
|
|
@@ -436,8 +461,7 @@ router.get('/recently-commented', async (_req, res) => {
|
|
|
436
461
|
return null;
|
|
437
462
|
});
|
|
438
463
|
|
|
439
|
-
const
|
|
440
|
-
for (const item of settled) {
|
|
464
|
+
for (const item of issueComments) {
|
|
441
465
|
if (item) results.push(item);
|
|
442
466
|
}
|
|
443
467
|
|
|
@@ -478,7 +502,7 @@ router.get('/recently-mentioned', async (_req, res) => {
|
|
|
478
502
|
const issues = await searchJiraWithFallbacks(jiraHost, headers, jqlCandidates, 'summary,priority,status,updated,assignee,reporter', 40);
|
|
479
503
|
const results = [];
|
|
480
504
|
|
|
481
|
-
const
|
|
505
|
+
const issueMentions = await mapInBatches(issues.slice(0, 30), 5, async (issue) => {
|
|
482
506
|
const key = issue.key;
|
|
483
507
|
const fields = issue.fields || {};
|
|
484
508
|
const comments = await fetchCommentsForIssue(jiraHost, headers, key, 50);
|
|
@@ -511,8 +535,7 @@ router.get('/recently-mentioned', async (_req, res) => {
|
|
|
511
535
|
return null;
|
|
512
536
|
});
|
|
513
537
|
|
|
514
|
-
const
|
|
515
|
-
for (const item of settled) {
|
|
538
|
+
for (const item of issueMentions) {
|
|
516
539
|
if (item) results.push(item);
|
|
517
540
|
}
|
|
518
541
|
|
|
@@ -12,6 +12,7 @@ import { recordApiError } from '../lib/api-errors.js';
|
|
|
12
12
|
const router = Router();
|
|
13
13
|
const exec = promisify(execFile);
|
|
14
14
|
const running = new Set();
|
|
15
|
+
const BITBUCKET_REQUEST_BATCH_SIZE = 5;
|
|
15
16
|
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
16
17
|
const httpClient = axios.create({
|
|
17
18
|
httpsAgent,
|
|
@@ -239,14 +240,24 @@ router.post('/repos/branches', async (req, res) => {
|
|
|
239
240
|
const repos = repoIds.map((repoId) => data.repos.find((repo) => repo.id === repoId)).filter(Boolean);
|
|
240
241
|
if (!repos.length) return res.json([]);
|
|
241
242
|
try {
|
|
242
|
-
const remote =
|
|
243
|
+
const remote = [];
|
|
244
|
+
for (let index = 0; index < repos.length; index += BITBUCKET_REQUEST_BATCH_SIZE) {
|
|
245
|
+
remote.push(...await Promise.all(repos.slice(index, index + BITBUCKET_REQUEST_BATCH_SIZE).map(fetchRemoteBranches)));
|
|
246
|
+
}
|
|
243
247
|
const branchSets = remote.map(({ branches }) => new Set(branches.map((branch) => branch.name)));
|
|
244
248
|
const commonNames = remote[0].branches.map((branch) => branch.name).filter((name) => branchSets.every((branches) => branches.has(name)));
|
|
245
|
-
const commonBranches =
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
249
|
+
const commonBranches = [];
|
|
250
|
+
for (let index = 0; index < commonNames.length; index += BITBUCKET_REQUEST_BATCH_SIZE) {
|
|
251
|
+
const batch = await Promise.all(commonNames.slice(index, index + BITBUCKET_REQUEST_BATCH_SIZE).map(async (name) => {
|
|
252
|
+
const branches = remote.map(({ base, branches: items }) => ({ base, branch: items.find((item) => item.name === name) })).filter(({ branch }) => branch);
|
|
253
|
+
const updated = [];
|
|
254
|
+
for (let branchIndex = 0; branchIndex < branches.length; branchIndex += BITBUCKET_REQUEST_BATCH_SIZE) {
|
|
255
|
+
updated.push(...await Promise.all(branches.slice(branchIndex, branchIndex + BITBUCKET_REQUEST_BATCH_SIZE).map(({ base, branch }) => fetchBranchUpdatedAt(base, branch))));
|
|
256
|
+
}
|
|
257
|
+
return { name, updatedAt: Math.max(...updated.map((item) => item.updatedAt)), rank: Math.min(...updated.map((item) => item.rank)) };
|
|
258
|
+
}));
|
|
259
|
+
commonBranches.push(...batch);
|
|
260
|
+
}
|
|
250
261
|
commonBranches.sort((a, b) => b.updatedAt - a.updatedAt || a.rank - b.rank || a.name.localeCompare(b.name));
|
|
251
262
|
res.json(commonBranches.map(({ name, updatedAt }) => ({ name, updatedAt: updatedAt ? new Date(updatedAt).toISOString() : null })));
|
|
252
263
|
} catch (error) {
|
|
@@ -10,6 +10,7 @@ import { readPackageUpgradeData } from '../repositories/package-upgrade.js';
|
|
|
10
10
|
import { getGitRemoteUrl } from '../services/git.js';
|
|
11
11
|
|
|
12
12
|
const router = Router();
|
|
13
|
+
const CONFLICT_CHECK_BATCH_SIZE = 5;
|
|
13
14
|
const execFileAsync = promisify(execFile);
|
|
14
15
|
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
15
16
|
const client = axios.create({ httpsAgent, validateStatus: () => true });
|
|
@@ -335,35 +336,39 @@ router.get('/my-conflicts', async (_req, res) => {
|
|
|
335
336
|
}
|
|
336
337
|
});
|
|
337
338
|
|
|
338
|
-
const conflicts =
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return {
|
|
356
|
-
...baseConflict,
|
|
357
|
-
resolutionPrUrl: webPullRequestUrl(host, resolutionPr),
|
|
358
|
-
resolutionPrId: resolutionPr.id
|
|
339
|
+
const conflicts = [];
|
|
340
|
+
for (let index = 0; index < values.length; index += CONFLICT_CHECK_BATCH_SIZE) {
|
|
341
|
+
const batch = await Promise.all(values.slice(index, index + CONFLICT_CHECK_BATCH_SIZE).map(async (pr) => {
|
|
342
|
+
const project = pr.toRef?.repository?.project?.key;
|
|
343
|
+
const repository = pr.toRef?.repository?.slug;
|
|
344
|
+
if (!project || !repository || !pr.id) return null;
|
|
345
|
+
if (String(pr.description || '').match(/This branch resolves conflicts for Pull Request #\d+/i)) return null;
|
|
346
|
+
|
|
347
|
+
const resolutionPr = resolutionPrs.get(pullRequestKey(project, repository, pr.id));
|
|
348
|
+
const baseConflict = {
|
|
349
|
+
id: pr.id,
|
|
350
|
+
title: pr.title || `Pull Request #${pr.id}`,
|
|
351
|
+
repositoryName: pr.toRef?.repository?.name || repository,
|
|
352
|
+
sourceBranch: pr.fromRef?.displayId || pr.fromRef?.id,
|
|
353
|
+
targetBranch: pr.toRef?.displayId || pr.toRef?.id,
|
|
354
|
+
updatedDate: pr.updatedDate,
|
|
355
|
+
url: webPullRequestUrl(host, pr)
|
|
359
356
|
};
|
|
360
|
-
|
|
357
|
+
if (resolutionPr) {
|
|
358
|
+
return {
|
|
359
|
+
...baseConflict,
|
|
360
|
+
resolutionPrUrl: webPullRequestUrl(host, resolutionPr),
|
|
361
|
+
resolutionPrId: resolutionPr.id
|
|
362
|
+
};
|
|
363
|
+
}
|
|
361
364
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
365
|
+
const mergeUrl = `https://${host}/rest/api/latest/projects/${encodeURIComponent(project)}/repos/${encodeURIComponent(repository)}/pull-requests/${pr.id}/merge`;
|
|
366
|
+
const mergeResponse = await client.get(mergeUrl, { headers: authHeaders() });
|
|
367
|
+
if (mergeResponse.status < 200 || mergeResponse.status >= 300 || !mergeResponse.data?.conflicted) return null;
|
|
368
|
+
return baseConflict;
|
|
369
|
+
}));
|
|
370
|
+
conflicts.push(...batch);
|
|
371
|
+
}
|
|
367
372
|
const sorted = conflicts.filter(Boolean).sort((a, b) => (b.updatedDate || 0) - (a.updatedDate || 0));
|
|
368
373
|
const unresolved = sorted.filter((pr) => !pr.resolutionPrUrl);
|
|
369
374
|
const resolved = sorted.filter((pr) => pr.resolutionPrUrl);
|