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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 separatorIndex = line.indexOf(' ');
53
- const ref = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
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 parseFirstCommit(raw) {
79
- const [timestamp, date, name, email] = raw.split('\x1f');
80
- const numericTimestamp = Number(timestamp);
81
- return {
82
- createdAt: Number.isFinite(numericTimestamp) ? new Date(numericTimestamp * 1000).toISOString() : date || null,
83
- creator: { name: name || '', email: email || '' }
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 = parseFirstCommit(await git(folder, [
122
- 'log', '--reverse', '--format=%ct%x1f%aI%x1f%an%x1f%ae', '-1', ref
123
- ], { allowFailure: true }));
124
- let createdAtSource = 'first commit (inferred)';
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 branchCount = Number(await git(folder, ['rev-list', '--count', ref], { allowFailure: true })) || 0;
143
- const containingRefs = parseRefList(await git(folder, [
144
- 'for-each-ref', '--contains', commit.hash, '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes'
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
- const counts = (await git(folder, ['rev-list', '--left-right', '--count', `${targetRef}...${ref}`], { allowFailure: true })).split(/\s+/).map(Number);
158
- behind = Number.isFinite(counts[0]) ? counts[0] : 0;
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, refsRaw] = await Promise.all([
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(refsRaw);
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 detail = await readBranchDetails(root, branch, refs, targetRef, currentBranch);
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 when available, otherwise first commit date.'
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
- try {
358
- const url = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment?maxResults=${maxResults}`;
359
- const response = await httpClient.get(url, { headers });
360
- if (response.status !== 200) return [];
361
- const total = response.data?.total || 0;
362
- const comments = Array.isArray(response.data?.comments) ? response.data.comments : [];
363
- if (total > comments.length && total > maxResults) {
364
- const startAt = Math.max(0, total - maxResults);
365
- const latestUrl = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment?startAt=${startAt}&maxResults=${maxResults}`;
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
- return comments;
372
- } catch {
373
- return [];
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 issueCommentsPromises = issues.map(async (issue) => {
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 settled = await Promise.all(issueCommentsPromises);
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 issueMentionsPromises = issues.slice(0, 30).map(async (issue) => {
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 settled = await Promise.all(issueMentionsPromises);
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 = await Promise.all(repos.map((repo) => fetchRemoteBranches(repo)));
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 = await Promise.all(commonNames.map(async (name) => {
246
- const branches = remote.map(({ base, branches: items }) => ({ base, branch: items.find((item) => item.name === name) })).filter(({ branch }) => branch);
247
- const updated = await Promise.all(branches.map(({ base, branch }) => fetchBranchUpdatedAt(base, branch)));
248
- return { name, updatedAt: Math.max(...updated.map((item) => item.updatedAt)), rank: Math.min(...updated.map((item) => item.rank)) };
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 = await Promise.all(values.map(async (pr) => {
339
- const project = pr.toRef?.repository?.project?.key;
340
- const repository = pr.toRef?.repository?.slug;
341
- if (!project || !repository || !pr.id) return null;
342
- if (String(pr.description || '').match(/This branch resolves conflicts for Pull Request #\d+/i)) return null;
343
-
344
- const resolutionPr = resolutionPrs.get(pullRequestKey(project, repository, pr.id));
345
- const baseConflict = {
346
- id: pr.id,
347
- title: pr.title || `Pull Request #${pr.id}`,
348
- repositoryName: pr.toRef?.repository?.name || repository,
349
- sourceBranch: pr.fromRef?.displayId || pr.fromRef?.id,
350
- targetBranch: pr.toRef?.displayId || pr.toRef?.id,
351
- updatedDate: pr.updatedDate,
352
- url: webPullRequestUrl(host, pr)
353
- };
354
- if (resolutionPr) {
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
- const mergeUrl = `https://${host}/rest/api/latest/projects/${encodeURIComponent(project)}/repos/${encodeURIComponent(repository)}/pull-requests/${pr.id}/merge`;
363
- const mergeResponse = await client.get(mergeUrl, { headers: authHeaders() });
364
- if (mergeResponse.status < 200 || mergeResponse.status >= 300 || !mergeResponse.data?.conflicted) return null;
365
- return baseConflict;
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);