ReviewOS

also looking at this

stacks/buddy

test: cover the untested halves of the big integration files

#1443
Merged glennmichael123 wants to merge test/deep-integration-coverage into main
4 files +2344 -83
src/registry/registry-client.tsmodified+0-83
Changes to src/registry/registry-client.ts
@@ -931,21 +931,6 @@ export class RegistryClient {
931931 }
932932 }
933933
934 /**
935 * Extract major version number from a version string
936 */
937 private getMajorVersion(version: string): string {
938 return version.replace(/^[v^~>=<]+/, '').split('.')[0] || '0'
939 }
940
941 /**
942 * Extract minor version number from a version string
943 */
944 private getMinorVersion(version: string): string {
945 const parts = version.replace(/^[v^~>=<]+/, '').split('.')
946 return parts[1] || '0'
947 }
948
949934 /**
950935 * Get outdated Composer packages
951936 */
@@ -1112,74 +1097,6 @@ export class RegistryClient {
11121097 }
11131098 }
11141099
1115 /**
1116 * Find the best patch, minor, and major updates for a package
1117 */
1118 private async findBestUpdates(currentVersion: string, availableVersions: string[], _constraint: string): Promise<{ version: string, type: 'patch' | 'minor' | 'major' }[]> {
1119 const { getUpdateType } = await import('../utils/helpers')
1120 const candidates: { version: string, type: 'patch' | 'minor' | 'major' }[] = []
1121
1122 // Parse current version
1123 const currentParts = this.parseVersion(currentVersion)
1124 if (!currentParts) {
1125 return []
1126 }
1127
1128 let bestPatch: string | null = null
1129 let bestMinor: string | null = null
1130 let bestMajor: string | null = null
1131
1132 for (const version of availableVersions) {
1133 // Skip dev/alpha/beta versions for now (could be enhanced later)
1134 if (version.includes('dev') || version.includes('alpha') || version.includes('beta') || version.includes('RC')) {
1135 continue
1136 }
1137
1138 const versionParts = this.parseVersion(version)
1139 if (!versionParts) {
1140 continue
1141 }
1142
1143 // Skip versions that are not newer
1144 const comparison = this.compareVersions(version, currentVersion)
1145 if (comparison <= 0) {
1146 continue
1147 }
1148
1149 const updateType = getUpdateType(currentVersion, version)
1150
1151 // Find best update for each type
1152 if (updateType === 'patch' && versionParts.major === currentParts.major && versionParts.minor === currentParts.minor) {
1153 if (!bestPatch || this.compareVersions(version, bestPatch) > 0) {
1154 bestPatch = version
1155 }
1156 }
1157 else if (updateType === 'minor' && versionParts.major === currentParts.major) {
1158 if (!bestMinor || this.compareVersions(version, bestMinor) > 0) {
1159 bestMinor = version
1160 }
1161 }
1162 else if (updateType === 'major') {
1163 if (!bestMajor || this.compareVersions(version, bestMajor) > 0) {
1164 bestMajor = version
1165 }
1166 }
1167 }
1168
1169 // Add the best candidates
1170 if (bestPatch) {
1171 candidates.push({ version: bestPatch, type: 'patch' })
1172 }
1173 if (bestMinor) {
1174 candidates.push({ version: bestMinor, type: 'minor' })
1175 }
1176 if (bestMajor) {
1177 candidates.push({ version: bestMajor, type: 'major' })
1178 }
1179
1180 return candidates
1181 }
1182
11831100 /**
11841101 * Parse a version string into major.minor.patch
11851102 */
test/buddy-orchestration.test.tsadded+897-0
Changes to test/buddy-orchestration.test.ts
@@ -0,0 +1,897 @@
1import type { BuddyConfig, Issue, PackageFile, PackageUpdate, PullRequest, UpdateScanResult } from '../src/types'
2import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
3import fs from 'node:fs'
4import os from 'node:os'
5import path from 'node:path'
6import process from 'node:process'
7import { Buddy } from '../src/buddy'
8import { serializeManifest } from '../src/pr/pr-manifest'
9
10/**
11 * Orchestration-layer coverage for src/buddy.ts: the auto-merge fallback loop,
12 * the satisfied-PR auto-close verifier, the dashboard pipeline driven through
13 * the real GitHub provider against a stubbed fetch, and the pure grouping and
14 * dedup helpers. Everything runs offline — the preload fetch guard stays armed
15 * except where a test installs its own stub, and every global touched here
16 * (env tokens, fetch, prototype spies) is restored in afterEach.
17 */
18
19const DASHBOARD_MARKER = 'This issue lists Buddy updates and detected dependencies'
20
21const MANAGED_ENV_VARS = [
22 'GITHUB_TOKEN',
23 'BUDDY_TOKEN',
24 'GH_TOKEN',
25 'GITHUB_REPOSITORY',
26 'GITHUB_API_URL',
27 'CI_PROJECT_PATH',
28 'BITBUCKET_REPO_FULL_NAME',
29] as const
30
31let savedEnv: Record<string, string | undefined> = {}
32let originalFetch: typeof globalThis.fetch
33const restorers: Array<() => void> = []
34
35beforeEach(() => {
36 savedEnv = {}
37 for (const name of MANAGED_ENV_VARS) {
38 savedEnv[name] = process.env[name]
39 delete process.env[name]
40 }
41 originalFetch = globalThis.fetch
42})
43
44afterEach(() => {
45 for (const name of MANAGED_ENV_VARS) {
46 const value = savedEnv[name]
47 if (value === undefined)
48 delete process.env[name]
49 else
50 process.env[name] = value
51 }
52 globalThis.fetch = originalFetch
53 while (restorers.length > 0) {
54 const restore = restorers.pop()
55 restore?.()
56 }
57})
58
59function makeUpdate(overrides: Partial<PackageUpdate> = {}): PackageUpdate {
60 return {
61 name: 'lodash',
62 currentVersion: '4.17.20',
63 newVersion: '4.17.21',
64 updateType: 'patch',
65 dependencyType: 'dependencies',
66 file: 'package.json',
67 ...overrides,
68 }
69}
70
71function makePR(overrides: Partial<PullRequest> = {}): PullRequest {
72 return {
73 number: 101,
74 title: 'chore(deps): update all non-major dependencies',
75 body: `updates${serializeManifest([makeUpdate()])}`,
76 head: 'buddy/update-non-major',
77 base: 'main',
78 state: 'open',
79 url: 'https://github.com/test-owner/test-repo/pull/101',
80 createdAt: new Date(0),
81 updatedAt: new Date(0),
82 author: 'github-actions[bot]',
83 reviewers: [],
84 assignees: [],
85 labels: ['dependencies'],
86 draft: false,
87 ...overrides,
88 }
89}
90
91function makeIssue(overrides: Partial<Issue> = {}): Issue {
92 return {
93 number: 1,
94 title: 'Some issue',
95 body: 'body',
96 state: 'open',
97 url: 'https://github.com/test-owner/test-repo/issues/1',
98 createdAt: new Date(0),
99 updatedAt: new Date(0),
100 author: 'someone',
101 assignees: [],
102 labels: [],
103 ...overrides,
104 }
105}
106
107function makeScanResult(updates: PackageUpdate[]): UpdateScanResult {
108 return {
109 totalPackages: updates.length,
110 updates,
111 groups: [],
112 scannedAt: new Date(0),
113 duration: 1,
114 }
115}
116
117function baseConfig(overrides: Record<string, unknown> = {}): BuddyConfig {
118 return {
119 logLevel: 'silent',
120 repository: { provider: 'github', owner: 'test-owner', name: 'test-repo' },
121 security: { enabled: false },
122 ...overrides,
123 } as BuddyConfig
124}
125
126function autoMergeConfig(overrides: Record<string, unknown> = {}): BuddyConfig {
127 return baseConfig({
128 pullRequest: { autoMerge: { enabled: true, strategy: 'squash', conditions: ['patch-only'] } },
129 ...overrides,
130 })
131}
132
133/** Spy on the private logger without letting output reach the console. */
134function loggerSpy(buddy: Buddy, method: 'error' | 'warn'): any {
135 const spy = spyOn((buddy as any).logger, method).mockImplementation(() => {})
136 restorers.push(() => spy.mockRestore())
137 return spy
138}
139
140/** Mock the inner scan `checkAndCloseSatisfiedPRs` performs, restorably. */
141function stubScan(updates: PackageUpdate[]): void {
142 const spy = spyOn(Buddy.prototype, 'scanForUpdates').mockResolvedValue(makeScanResult(updates))
143 restorers.push(() => spy.mockRestore())
144}
145
146interface RecordedCall {
147 method: string
148 url: string
149 body?: string
150}
151
152let fetchCalls: RecordedCall[] = []
153
154/**
155 * Replace the preload fetch guard with a URL-routed stub for one test.
156 *
157 * Unrouted requests come back as 400 — a non-transient status, so the
158 * provider fails fast with the URL in the error instead of retrying.
159 */
160function stubFetch(route: (method: string, url: string) => unknown): void {
161 fetchCalls = []
162 globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
163 const url = typeof input === 'string' ? input : String((input as { url?: string })?.url ?? input)
164 const method = String(init?.method ?? 'GET').toUpperCase()
165 fetchCalls.push({ method, url, body: typeof init?.body === 'string' ? init.body : undefined })
166 const payload = route(method, url)
167 if (payload === undefined)
168 return new Response(JSON.stringify({ message: `Unrouted test request: ${method} ${url}` }), { status: 400 })
169 return new Response(JSON.stringify(payload), {
170 status: 200,
171 headers: { 'content-type': 'application/json' },
172 })
173 }) as typeof globalThis.fetch
174}
175
176function issueJson(number: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
177 return {
178 number,
179 title: 'Dependency Dashboard',
180 body: 'existing body',
181 state: 'open',
182 html_url: `https://github.com/test-owner/test-repo/issues/${number}`,
183 created_at: '2026-01-01T00:00:00Z',
184 updated_at: '2026-01-01T00:00:00Z',
185 closed_at: null,
186 user: { login: 'github-actions[bot]' },
187 assignees: [],
188 labels: [],
189 ...overrides,
190 }
191}
192
193describe('buddy orchestration', () => {
194 describe('mergeEligiblePullRequests', () => {
195 function makeMergeProvider(prs: PullRequest[], checksState?: string): any {
196 const provider: any = {
197 capabilities: () => ({ nativeAutoMerge: true }),
198 getPullRequests: mock(() => Promise.resolve(prs)),
199 mergePullRequest: mock(() => Promise.resolve()),
200 }
201 if (checksState !== undefined)
202 provider.getPullRequestChecksState = mock(() => Promise.resolve(checksState))
203 return provider
204 }
205
206 it('success case - merges a green patch-only buddy PR and reports its number', async () => {
207 const provider = makeMergeProvider([makePR()], 'success')
208 const buddy = new Buddy(autoMergeConfig())
209 ;(buddy as any).createGitProvider = async () => provider
210
211 const merged = await buddy.mergeEligiblePullRequests()
212
213 expect(merged).toEqual([101])
214 expect(provider.mergePullRequest).toHaveBeenCalledWith(101, 'squash')
215 })
216
217 it('failure case - refuses a PR whose checks are failing', async () => {
218 const provider = makeMergeProvider([makePR()], 'failure')
219 const buddy = new Buddy(autoMergeConfig())
220 ;(buddy as any).createGitProvider = async () => provider
221
222 expect(await buddy.mergeEligiblePullRequests()).toEqual([])
223 expect(provider.mergePullRequest).not.toHaveBeenCalled()
224 })
225
226 it('failure case - refuses a PR whose checks are still pending', async () => {
227 const provider = makeMergeProvider([makePR()], 'pending')
228 const buddy = new Buddy(autoMergeConfig())
229 ;(buddy as any).createGitProvider = async () => provider
230
231 expect(await buddy.mergeEligiblePullRequests()).toEqual([])
232 expect(provider.mergePullRequest).not.toHaveBeenCalled()
233 })
234
235 it('edge case - a repository with no checks at all still merges', async () => {
236 // No getPullRequestChecksState on the provider means checks === 'none':
237 // nothing to wait for, only an explicit failure blocks the merge.
238 const provider = makeMergeProvider([makePR()])
239 const buddy = new Buddy(autoMergeConfig())
240 ;(buddy as any).createGitProvider = async () => provider
241
242 expect(await buddy.mergeEligiblePullRequests()).toEqual([101])
243 expect(provider.mergePullRequest).toHaveBeenCalledWith(101, 'squash')
244 })
245
246 it('success case - dry run lists would-merge PRs without touching the provider', async () => {
247 const provider = makeMergeProvider([makePR()], 'success')
248 const buddy = new Buddy(autoMergeConfig())
249 ;(buddy as any).createGitProvider = async () => provider
250
251 expect(await buddy.mergeEligiblePullRequests(true)).toEqual([101])
252 expect(provider.mergePullRequest).not.toHaveBeenCalled()
253 })
254
255 it('failure case - swallows a provider merge failure and continues to the next PR', async () => {
256 const prs = [
257 makePR({ number: 101, head: 'buddy/update-a' }),
258 makePR({ number: 102, head: 'buddy/update-b' }),
259 ]
260 const provider = makeMergeProvider(prs, 'success')
261 provider.mergePullRequest = mock((prNumber: number) =>
262 prNumber === 101 ? Promise.reject(new Error('merge conflict')) : Promise.resolve())
263 const buddy = new Buddy(autoMergeConfig())
264 ;(buddy as any).createGitProvider = async () => provider
265 loggerSpy(buddy, 'warn')
266
267 const merged = await buddy.mergeEligiblePullRequests()
268
269 expect(merged).toEqual([102])
270 expect(provider.mergePullRequest).toHaveBeenCalledTimes(2)
271 })
272
273 it('edge case - returns empty when auto-merge is disabled, without building a provider', async () => {
274 const buddy = new Buddy(baseConfig())
275 const factory = mock(() => Promise.resolve(makeMergeProvider([makePR()], 'success')))
276 ;(buddy as any).createGitProvider = factory
277
278 expect(await buddy.mergeEligiblePullRequests()).toEqual([])
279 expect(factory).not.toHaveBeenCalled()
280 })
281
282 it('edge case - returns empty when no provider can be built', async () => {
283 // Auto-merge enabled but no repository configured and no env tokens:
284 // createGitProvider degrades to null and the run reports nothing merged.
285 const buddy = new Buddy(autoMergeConfig({ repository: undefined }))
286 loggerSpy(buddy, 'warn')
287
288 expect(await buddy.mergeEligiblePullRequests()).toEqual([])
289 })
290 })
291
292 describe('queueAutoMerge', () => {
293 const pr = { number: 7, title: 't', body: 'b', head: 'buddy/update-x', labels: [], draft: false }
294
295 it('failure case - respects the nativeAutoMerge capability gate', async () => {
296 const provider: any = {
297 capabilities: () => ({ nativeAutoMerge: false }),
298 enableAutoMerge: mock(() => Promise.resolve(true)),
299 }
300 const buddy = new Buddy(autoMergeConfig())
301
302 await (buddy as any).queueAutoMerge(provider, pr, [makeUpdate()], [])
303
304 expect(provider.enableAutoMerge).not.toHaveBeenCalled()
305 })
306
307 it('edge case - a refused queue request does not throw', async () => {
308 const provider: any = {
309 capabilities: () => ({ nativeAutoMerge: true }),
310 enableAutoMerge: mock(() => Promise.resolve(false)),
311 }
312 const buddy = new Buddy(autoMergeConfig())
313
314 await (buddy as any).queueAutoMerge(provider, pr, [makeUpdate()], [])
315
316 expect(provider.enableAutoMerge).toHaveBeenCalledWith(7, 'squash')
317 })
318
319 it('failure case - swallows an enableAutoMerge rejection', async () => {
320 // A PR that could not be queued is still a perfectly good PR.
321 const provider: any = {
322 capabilities: () => ({ nativeAutoMerge: true }),
323 enableAutoMerge: mock(() => Promise.reject(new Error('no required checks'))),
324 }
325 const buddy = new Buddy(autoMergeConfig())
326 loggerSpy(buddy, 'warn')
327
328 await (buddy as any).queueAutoMerge(provider, pr, [makeUpdate()], [])
329
330 expect(provider.enableAutoMerge).toHaveBeenCalledTimes(1)
331 })
332 })
333
334 describe('checkAndCloseSatisfiedPRs', () => {
335 function makeCloseProvider(prs: PullRequest[]): any {
336 return {
337 getPullRequests: mock(() => Promise.resolve(prs)),
338 createComment: mock(() => Promise.resolve()),
339 closePullRequest: mock(() => Promise.resolve()),
340 deleteBranch: mock(() => Promise.resolve()),
341 }
342 }
343
344 function lodashPR(): PullRequest {
345 return makePR({
346 number: 123,
347 head: 'buddy/update-lodash',
348 body: `updates${serializeManifest([makeUpdate({ name: 'lodash', currentVersion: '4.17.20', newVersion: '4.17.21' })])}`,
349 })
350 }
351
352 it('success case - closes a PR whose every package is verifiably at or beyond target', async () => {
353 stubScan([makeUpdate({ name: 'lodash', currentVersion: '4.17.21', newVersion: '4.17.22' })])
354 const provider = makeCloseProvider([lodashPR()])
355 const buddy = new Buddy(baseConfig())
356
357 await buddy.checkAndCloseSatisfiedPRs(provider, false)
358
359 expect(provider.createComment).toHaveBeenCalledTimes(1)
360 const [prNumber, comment] = provider.createComment.mock.calls[0]
361 expect(prNumber).toBe(123)
362 expect(comment).toContain('lodash')
363 expect(provider.closePullRequest).toHaveBeenCalledWith(123)
364 expect(provider.deleteBranch).toHaveBeenCalledWith('buddy/update-lodash')
365 })
366
367 it('failure case - never closes on an empty scan (mass-closure guard)', async () => {
368 // A scan returning nothing may mean bun outdated failed or the registry
369 // rate-limited — closing every PR on that evidence would wipe them out.
370 stubScan([])
371 const provider = makeCloseProvider([lodashPR()])
372 const buddy = new Buddy(baseConfig())
373 loggerSpy(buddy, 'warn')
374
375 await buddy.checkAndCloseSatisfiedPRs(provider, false)
376
377 expect(provider.createComment).not.toHaveBeenCalled()
378 expect(provider.closePullRequest).not.toHaveBeenCalled()
379 })
380
381 it('failure case - keeps a PR open when its package is absent from the scan (unverifiable)', async () => {
382 stubScan([makeUpdate({ name: 'react', currentVersion: '18.0.0', newVersion: '18.2.0' })])
383 const provider = makeCloseProvider([lodashPR()])
384 const buddy = new Buddy(baseConfig())
385
386 await buddy.checkAndCloseSatisfiedPRs(provider, false)
387
388 expect(provider.closePullRequest).not.toHaveBeenCalled()
389 })
390
391 it('failure case - keeps a PR open when the scan still wants the same target version', async () => {
392 stubScan([makeUpdate({ name: 'lodash', currentVersion: '4.17.20', newVersion: '4.17.21' })])
393 const provider = makeCloseProvider([lodashPR()])
394 const buddy = new Buddy(baseConfig())
395
396 await buddy.checkAndCloseSatisfiedPRs(provider, false)
397
398 expect(provider.closePullRequest).not.toHaveBeenCalled()
399 })
400
401 it('failure case - one unverifiable package keeps a multi-package PR open', async () => {
402 const twoPackagePR = makePR({
403 number: 124,
404 head: 'buddy/update-two',
405 body: `updates${serializeManifest([
406 makeUpdate({ name: 'lodash', currentVersion: '4.17.20', newVersion: '4.17.21' }),
407 makeUpdate({ name: 'express', currentVersion: '4.18.0', newVersion: '4.18.2' }),
408 ])}`,
409 })
410 // lodash is verifiably satisfied, express is absent from the scan: the
411 // zero-unverifiable rule must keep the PR open.
412 stubScan([makeUpdate({ name: 'lodash', currentVersion: '4.17.21', newVersion: '4.17.22' })])
413 const provider = makeCloseProvider([twoPackagePR])
414 const buddy = new Buddy(baseConfig())
415
416 await buddy.checkAndCloseSatisfiedPRs(provider, false)
417
418 expect(provider.closePullRequest).not.toHaveBeenCalled()
419 })
420
421 it('edge case - dry run counts closures without performing them', async () => {
422 stubScan([makeUpdate({ name: 'lodash', currentVersion: '4.17.21', newVersion: '4.17.22' })])
423 const provider = makeCloseProvider([lodashPR()])
424 const buddy = new Buddy(baseConfig())
425
426 await buddy.checkAndCloseSatisfiedPRs(provider, true)
427
428 expect(provider.createComment).not.toHaveBeenCalled()
429 expect(provider.closePullRequest).not.toHaveBeenCalled()
430 })
431 })
432
433 describe('compareVersionsSafe', () => {
434 it('distinguishes at-or-beyond, behind, and failed', () => {
435 const buddy = new Buddy(baseConfig()) as any
436
437 expect(buddy.compareVersionsSafe('4.17.21', '4.17.21')).toBe('at-or-beyond')
438 expect(buddy.compareVersionsSafe('4.17.22', '4.17.21')).toBe('at-or-beyond')
439 expect(buddy.compareVersionsSafe('4.17.20', '4.17.21')).toBe('behind')
440 // GH-Actions-style short versions must fail the comparison rather than
441 // report a verdict — a false 'at-or-beyond' would close a live PR.
442 expect(buddy.compareVersionsSafe('v4', '4.2.2')).toBe('failed')
443 expect(buddy.compareVersionsSafe('garbage', '1.0.0')).toBe('failed')
444 })
445 })
446
447 describe('createOrUpdateDashboard', () => {
448 let projectDir: string
449
450 beforeEach(() => {
451 projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'buddy-test-'))
452 fs.writeFileSync(
453 path.join(projectDir, 'package.json'),
454 JSON.stringify({ name: 'dash-fixture', version: '1.0.0' }),
455 )
456 })
457
458 afterEach(() => {
459 fs.rmSync(projectDir, { recursive: true, force: true })
460 })
461
462 function dashboardConfig(overrides: Record<string, unknown> = {}): BuddyConfig {
463 return {
464 logLevel: 'silent',
465 repository: { provider: 'github', owner: 'test-owner', name: 'test-repo', token: 'test-token' },
466 security: { enabled: false },
467 packages: { detectResolutionDrift: false },
468 dashboard: {},
469 ...overrides,
470 } as BuddyConfig
471 }
472
473 it('success case - creates a fresh issue through the real GitHub provider offline', async () => {
474 stubFetch((method, url) => {
475 if (method === 'GET' && url.includes('/pulls?'))
476 return []
477 if (method === 'GET' && url.includes('/issues?'))
478 return []
479 if (method === 'POST' && url.endsWith('/issues'))
480 return issueJson(42)
481 return undefined
482 })
483
484 const buddy = new Buddy(dashboardConfig(), projectDir)
485 const issue = await buddy.createOrUpdateDashboard()
486
487 expect(issue.number).toBe(42)
488
489 const post = fetchCalls.find(call => call.method === 'POST' && call.url.endsWith('/issues'))
490 expect(post).toBeDefined()
491 expect(post?.url).toContain('/repos/test-owner/test-repo/issues')
492 const payload = JSON.parse(post?.body ?? '{}')
493 expect(payload.title).toBe('Dependency Dashboard')
494 expect(payload.body).toContain(DASHBOARD_MARKER)
495 expect(payload.labels).toEqual(['dependencies', 'dashboard'])
496 // pin was not configured, so no pin/unpin GraphQL traffic happens.
497 expect(fetchCalls.some(call => call.url.includes('/graphql'))).toBe(false)
498 })
499
500 it('success case - unpins the dashboard only when config says pin false', async () => {
501 stubFetch((method, url) => {
502 if (method === 'GET' && url.includes('/pulls?'))
503 return []
504 if (method === 'GET' && url.includes('/issues?'))
505 return []
506 if (method === 'POST' && url.endsWith('/issues'))
507 return issueJson(42)
508 if (method === 'GET' && url.includes('/issues/42'))
509 return { ...issueJson(42), node_id: 'node-42' }
510 if (method === 'POST' && url.endsWith('/graphql'))
511 return { data: { unpinIssue: { issue: { number: 42 } } } }
512 return undefined
513 })
514
515 const buddy = new Buddy(dashboardConfig({ dashboard: { pin: false } }), projectDir)
516 await buddy.createOrUpdateDashboard()
517
518 const graphql = fetchCalls.find(call => call.url.endsWith('/graphql'))
519 expect(graphql).toBeDefined()
520 expect(graphql?.body).toContain('unpinIssue')
521 })
522
523 it('success case - updates an existing marker-bearing issue instead of creating a duplicate', async () => {
524 stubFetch((method, url) => {
525 if (method === 'GET' && url.includes('/pulls?'))
526 return []
527 if (method === 'GET' && url.includes('/issues?'))
528 return [issueJson(7, { title: 'Anything', body: `intro\n\n${DASHBOARD_MARKER}. Read the docs.` })]
529 if (method === 'PATCH' && url.includes('/issues/7'))
530 return issueJson(7)
531 return undefined
532 })
533
534 const buddy = new Buddy(dashboardConfig(), projectDir)
535 const issue = await buddy.createOrUpdateDashboard()
536
537 expect(issue.number).toBe(7)
538 expect(fetchCalls.some(call => call.method === 'PATCH' && call.url.includes('/issues/7'))).toBe(true)
539 expect(fetchCalls.some(call => call.method === 'POST' && call.url.endsWith('/issues'))).toBe(false)
540 })
541
542 it('failure case - rejects non-GitHub providers with a clear error', async () => {
543 const buddy = new Buddy(
544 { logLevel: 'silent', repository: { provider: 'gitlab', owner: 'o', name: 'r' } } as BuddyConfig,
545 projectDir,
546 )
547
548 await expect(buddy.createOrUpdateDashboard()).rejects.toThrow('only supported for GitHub')
549 })
550
551 it('failure case - rejects a repository missing owner and name', async () => {
552 const buddy = new Buddy(
553 { logLevel: 'silent', repository: { provider: 'github' } } as unknown as BuddyConfig,
554 projectDir,
555 )
556
557 await expect(buddy.createOrUpdateDashboard()).rejects.toThrow('owner and name are required')
558 })
559 })
560
561 describe('findExistingDashboard', () => {
562 function findDashboard(provider: unknown, issueNumber?: number): Promise<Issue | null> {
563 const buddy = new Buddy(baseConfig())
564 return (buddy as any).findExistingDashboard(provider, issueNumber)
565 }
566
567 it('success case - the body marker alone identifies a dashboard', async () => {
568 const dashboard = makeIssue({ number: 9, title: 'Renamed by hand', body: `${DASHBOARD_MARKER}.`, labels: [] })
569 const provider = { getIssues: mock(() => Promise.resolve([dashboard])) }
570
571 const found = await findDashboard(provider)
572
573 expect(found?.number).toBe(9)
574 })
575
576 it('success case - labels plus title still match a pre-marker dashboard', async () => {
577 const dashboard = makeIssue({
578 number: 10,
579 title: 'Dependency Dashboard',
580 body: 'older body',
581 labels: ['dashboard', 'dependencies'],
582 })
583 const provider = { getIssues: mock(() => Promise.resolve([dashboard])) }
584
585 const found = await findDashboard(provider)
586
587 expect(found?.number).toBe(10)
588 })
589
590 it('failure case - never adopts a foreign title-only dashboard', async () => {
591 // Renovate's dashboard has the same title; adopting it would overwrite
592 // a different tool's issue.
593 const foreign = makeIssue({ number: 11, title: 'Dependency Dashboard', body: 'not ours', labels: [] })
594 const provider = { getIssues: mock(() => Promise.resolve([foreign])) }
595
596 expect(await findDashboard(provider)).toBeNull()
597 })
598
599 it('failure case - a getIssues failure degrades to null instead of throwing', async () => {
600 const provider = { getIssues: mock(() => Promise.reject(new Error('api down'))) }
601
602 expect(await findDashboard(provider)).toBeNull()
603 })
604
605 it('success case - honors an explicit issueNumber even without marker or labels', async () => {
606 const plain = makeIssue({ number: 42, title: 'My deps', body: 'plain', labels: [] })
607 const provider = { getIssues: mock(() => Promise.resolve([plain])) }
608
609 const found = await findDashboard(provider, 42)
610
611 expect(found?.number).toBe(42)
612 })
613
614 it('failure case - reports a configured issueNumber that no longer exists as null', async () => {
615 const provider = { getIssues: mock(() => Promise.resolve([makeIssue({ number: 1 })])) }
616 const buddy = new Buddy(baseConfig())
617 loggerSpy(buddy, 'warn')
618
619 expect(await (buddy as any).findExistingDashboard(provider, 42)).toBeNull()
620 })
621 })
622
623 describe('createPullRequests guard clauses', () => {
624 it('failure case - returns early when no repository is configured', async () => {
625 const buddy = new Buddy({ logLevel: 'silent' } as BuddyConfig)
626 const errorSpy = loggerSpy(buddy, 'error')
627
628 await buddy.createPullRequests(makeScanResult([]))
629
630 expect(errorSpy.mock.calls.some((call: unknown[]) =>
631 String(call[0]).includes('Repository configuration required'))).toBe(true)
632 })
633
634 it('failure case - returns early when owner or name is missing', async () => {
635 const buddy = new Buddy({ logLevel: 'silent', repository: { provider: 'github' } } as unknown as BuddyConfig)
636 const errorSpy = loggerSpy(buddy, 'error')
637
638 await buddy.createPullRequests(makeScanResult([]))
639
640 expect(errorSpy.mock.calls.some((call: unknown[]) =>
641 String(call[0]).includes('owner and name are required'))).toBe(true)
642 })
643
644 it('failure case - returns early when no token is in the environment', async () => {
645 const buddy = new Buddy(baseConfig())
646 const errorSpy = loggerSpy(buddy, 'error')
647
648 await buddy.createPullRequests(makeScanResult([]))
649
650 expect(errorSpy.mock.calls.some((call: unknown[]) =>
651 String(call[0]).includes('GITHUB_TOKEN or BUDDY_TOKEN'))).toBe(true)
652 })
653
654 it('edge case - skips a workflow-file-only group when BUDDY_TOKEN is absent (#1359)', async () => {
655 // GITHUB_TOKEN cannot push workflow files; without the skip, the run
656 // would create a branch plus empty commit and fail to open the PR,
657 // leaving an orphan branch every run.
658 process.env.GITHUB_TOKEN = 'test-token'
659 const buddy = new Buddy(baseConfig())
660 const warnSpy = loggerSpy(buddy, 'warn')
661
662 const update = makeUpdate({
663 name: 'actions/checkout',
664 currentVersion: 'v4',
665 newVersion: 'v4.2.2',
666 updateType: 'minor',
667 dependencyType: 'github-actions',
668 file: '.github/workflows/ci.yml',
669 })
670 const scanResult: UpdateScanResult = {
671 ...makeScanResult([update]),
672 groups: [{
673 name: 'GitHub Actions',
674 updates: [update],
675 updateType: 'minor',
676 title: 'chore(deps): update github actions',
677 body: 'x',
678 }],
679 }
680
681 await buddy.createPullRequests(scanResult)
682
683 expect(warnSpy.mock.calls.some((call: unknown[]) =>
684 String(call[0]).includes('workflow-file updates require BUDDY_TOKEN'))).toBe(true)
685 })
686 })
687
688 describe('run', () => {
689 it('edge case - does not create PRs when no updates exist', async () => {
690 const buddy = new Buddy(baseConfig({ pullRequest: {} }))
691 const scanSpy = spyOn(buddy, 'scanForUpdates').mockResolvedValue(makeScanResult([]))
692 const createSpy = spyOn(buddy, 'createPullRequests').mockResolvedValue(undefined)
693 restorers.push(() => scanSpy.mockRestore(), () => createSpy.mockRestore())
694
695 const result = await buddy.run()
696
697 expect(result.updates).toEqual([])
698 expect(createSpy).not.toHaveBeenCalled()
699 })
700
701 it('success case - creates PRs when updates exist and pullRequest is configured', async () => {
702 const buddy = new Buddy(baseConfig({ pullRequest: {} }))
703 const scanResult = makeScanResult([makeUpdate()])
704 const scanSpy = spyOn(buddy, 'scanForUpdates').mockResolvedValue(scanResult)
705 const createSpy = spyOn(buddy, 'createPullRequests').mockResolvedValue(undefined)
706 restorers.push(() => scanSpy.mockRestore(), () => createSpy.mockRestore())
707
708 const result = await buddy.run()
709
710 expect(result).toBe(scanResult)
711 expect(createSpy).toHaveBeenCalledTimes(1)
712 expect(createSpy).toHaveBeenCalledWith(scanResult)
713 })
714
715 it('edge case - skips PR creation when pullRequest is not configured', async () => {
716 const buddy = new Buddy(baseConfig())
717 const scanSpy = spyOn(buddy, 'scanForUpdates').mockResolvedValue(makeScanResult([makeUpdate()]))
718 const createSpy = spyOn(buddy, 'createPullRequests').mockResolvedValue(undefined)
719 restorers.push(() => scanSpy.mockRestore(), () => createSpy.mockRestore())
720
721 await buddy.run()
722
723 expect(createSpy).not.toHaveBeenCalled()
724 })
725 })
726
727 describe('groupUpdatesByConfig', () => {
728 it('success case - claims by glob, filters by per-group strategy, and falls through to defaults', () => {
729 const config = baseConfig({
730 packages: {
731 groups: [
732 // Multi-star pattern first, so it claims before the broad one —
733 // and pins the fix for the first-star-only regex bug.
734 { name: 'FooTypes', patterns: ['@types/*-foo'] },
735 { name: 'Types', patterns: ['@types/*'], strategy: 'patch' },
736 ],
737 },
738 })
739 const buddy = new Buddy(config) as any
740 const updates = [
741 makeUpdate({ name: '@types/node', updateType: 'minor', newVersion: '18.1.0' }),
742 makeUpdate({ name: '@types/bun-foo', updateType: 'patch' }),
743 makeUpdate({ name: 'react', updateType: 'patch' }),
744 ]
745
746 const groups = buddy.groupUpdatesByConfig(updates)
747
748 const fooTypes = groups.find((group: { name: string }) => group.name === 'FooTypes')
749 expect(fooTypes?.updates.map((update: PackageUpdate) => update.name)).toEqual(['@types/bun-foo'])
750
751 // '@types/node' is claimed by the Types group but its minor update is
752 // filtered out by the group's patch strategy.
753 const types = groups.find((group: { name: string }) => group.name === 'Types')
754 expect(types?.updates).toEqual([])
755
756 const nonMajor = groups.find((group: { name: string }) => group.name === 'Non-Major Updates')
757 expect(nonMajor?.updates.map((update: PackageUpdate) => update.name)).toEqual(['react'])
758 })
759
760 it('edge case - a malformed glob pattern claims nothing and does not throw', () => {
761 const config = baseConfig({
762 packages: { groups: [{ name: 'Bad', patterns: ['['] }] },
763 })
764 const buddy = new Buddy(config) as any
765 const updates = [makeUpdate({ name: 'react', updateType: 'patch' })]
766
767 const groups = buddy.groupUpdatesByConfig(updates)
768
769 expect(groups.find((group: { name: string }) => group.name === 'Bad')).toBeUndefined()
770 const nonMajor = groups.find((group: { name: string }) => group.name === 'Non-Major Updates')
771 expect(nonMajor?.updates.map((update: PackageUpdate) => update.name)).toEqual(['react'])
772 })
773
774 it('success case - rule-named updates form one group before legacy groups run', () => {
775 const config = baseConfig({
776 packages: {
777 rules: [{ matchPackages: ['react*'], groupName: 'React' }],
778 },
779 })
780 const buddy = new Buddy(config) as any
781 const updates = [
782 makeUpdate({ name: 'react', updateType: 'minor', newVersion: '18.3.0' }),
783 makeUpdate({ name: 'react-dom', updateType: 'patch' }),
784 makeUpdate({ name: 'lodash', updateType: 'patch' }),
785 ]
786
787 const groups = buddy.groupUpdatesByConfig(updates)
788
789 const reactGroup = groups.find((group: { name: string }) => group.name === 'React')
790 expect(reactGroup?.updates.map((update: PackageUpdate) => update.name).sort()).toEqual(['react', 'react-dom'])
791 expect(reactGroup?.updateType).toBe('minor')
792
793 const nonMajor = groups.find((group: { name: string }) => group.name === 'Non-Major Updates')
794 expect(nonMajor?.updates.map((update: PackageUpdate) => update.name)).toEqual(['lodash'])
795 })
796 })
797
798 describe('isSimilarPRTitle', () => {
799 function similar(existingTitle: string, newTitle: string): boolean {
800 const buddy = new Buddy(baseConfig()) as any
801 return buddy.isSimilarPRTitle(existingTitle, newTitle)
802 }
803
804 it('separates ecosystems and major vs non-major groups', () => {
805 // Two GitHub Actions titles land in the same PR.
806 expect(similar('chore(deps): update github actions', 'chore(deps): update GitHub Actions dependencies')).toBe(true)
807 // A GH-Actions PR is not the npm grouped PR.
808 expect(similar('chore(deps): update github actions', 'chore(deps): update all non-major dependencies')).toBe(false)
809 // Docker images share one PR.
810 expect(similar('chore(deps): update docker image node', 'chore(deps): update docker image nginx')).toBe(true)
811 // Major and non-major grouped PRs stay separate.
812 expect(similar('chore(deps): update all non-major dependencies', 'chore(deps): update dependencies (major)')).toBe(false)
813 // Same single dependency, new version: update the existing PR.
814 expect(similar('chore(deps): update dependency react to v18', 'chore(deps): update dependency react to v19')).toBe(true)
815 // Different dependencies are different PRs.
816 expect(similar('chore(deps): update dependency react to v18', 'chore(deps): update dependency vue to v3')).toBe(false)
817 })
818 })
819
820 describe('dropOverriddenUpdates', () => {
821 function packageJsonFile(content: string): PackageFile {
822 return { path: 'package.json', type: 'package.json', content, dependencies: [] }
823 }
824
825 it('success case - drops a pinned package but keeps one whose override admits the new version', async () => {
826 const buddy = new Buddy(baseConfig()) as any
827 const updates = [
828 makeUpdate({ name: 'lodash', currentVersion: '4.17.20', newVersion: '4.17.21' }),
829 makeUpdate({ name: 'axios', currentVersion: '1.6.0', newVersion: '1.7.0' }),
830 ]
831 const files = [packageJsonFile(JSON.stringify({
832 overrides: { lodash: '4.17.20' },
833 resolutions: { axios: '^1.0.0' },
834 }))]
835
836 const result = await buddy.dropOverriddenUpdates(updates, files)
837
838 // lodash's exact override excludes the target; axios's range admits it.
839 expect(result.map((update: PackageUpdate) => update.name)).toEqual(['axios'])
840 })
841
842 it('edge case - a manifest with unparseable JSON contributes nothing and does not throw', async () => {
843 const buddy = new Buddy(baseConfig()) as any
844 const updates = [makeUpdate({ name: 'lodash' })]
845
846 const result = await buddy.dropOverriddenUpdates(updates, [packageJsonFile('{ not json')])
847
848 expect(result).toEqual(updates)
849 })
850 })
851
852 describe('checkDockerfilesForUpdates', () => {
853 function dockerFile(version: string): PackageFile {
854 return {
855 path: 'Dockerfile',
856 type: 'Dockerfile',
857 content: `FROM node:${version}`,
858 dependencies: [{ name: 'node', currentVersion: version, type: 'docker-image', file: 'Dockerfile' }],
859 }
860 }
861
862 it('edge case - respects wildcard tags without touching the registry', async () => {
863 // 'latest' short-circuits before any fetch; the preload guard proves no
864 // network call happened by failing the test loudly if one did.
865 const buddy = new Buddy(baseConfig()) as any
866
867 const result = await buddy.checkDockerfilesForUpdates([dockerFile('latest')])
868
869 expect(result).toEqual([])
870 })
871
872 it('failure case - tolerates registry failures per-image instead of failing the scan', async () => {
873 stubFetch(() => undefined) // every registry request comes back 400
874 const buddy = new Buddy(baseConfig()) as any
875
876 const result = await buddy.checkDockerfilesForUpdates([dockerFile('20.11.1')])
877
878 expect(result).toEqual([])
879 })
880 })
881
882 describe('analyzeMajors', () => {
883 it('edge case - returns an empty report when majorUpgrades is not enabled', async () => {
884 // The contract that keeps PRs byte-identical when the feature is off:
885 // no report, no draft, and no AI client constructed (the preload fetch
886 // guard would catch any network attempt).
887 const buddy = new Buddy({ logLevel: 'silent' } as BuddyConfig) as any
888
889 const result = await buddy.analyzeMajors(
890 [makeUpdate({ updateType: 'major', newVersion: '5.0.0' })],
891 ['package.json'],
892 )
893
894 expect(result).toEqual({ report: '', draft: false })
895 })
896 })
897})
test/github-provider-api.test.tsadded+736-0
Changes to test/github-provider-api.test.ts
@@ -0,0 +1,736 @@
1import { Buffer } from 'node:buffer'
2import { afterEach, describe, expect, it } from 'bun:test'
3import { GitHubProvider } from '../src/git/github-provider'
4import { GitHubApiError } from '../src/utils/errors'
5import { FileChangeValidationError } from '../src/utils/file-changes'
6import { Logger } from '../src/utils/logger'
7
8const realFetch = globalThis.fetch
9
10afterEach(() => {
11 globalThis.fetch = realFetch
12})
13
14interface RecordedCall {
15 url: string
16 method: string
17 headers: Record<string, string>
18 body: string | undefined
19}
20
21/**
22 * Replace fetch with a router keyed off the recorded call, recording every
23 * request so tests can assert on URLs, methods, headers and bodies.
24 */
25function stubFetch(route: (call: RecordedCall) => Response | Promise<Response>): RecordedCall[] {
26 const calls: RecordedCall[] = []
27 globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
28 const call: RecordedCall = {
29 url: String(input),
30 method: init?.method ?? 'GET',
31 headers: (init?.headers ?? {}) as Record<string, string>,
32 body: typeof init?.body === 'string' ? init.body : undefined,
33 }
34 calls.push(call)
35 return route(call)
36 }) as unknown as typeof fetch
37 return calls
38}
39
40function json(body: unknown, status = 200): Response {
41 return new Response(JSON.stringify(body), {
42 status,
43 headers: { 'content-type': 'application/json' },
44 })
45}
46
47function github(token = 'token'): GitHubProvider {
48 return new GitHubProvider(token, 'owner', 'repo', false, undefined, 'https://api.github.test', Logger.silent())
49}
50
51function prResponse(number: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
52 return {
53 number,
54 title: `chore(deps): update #${number}`,
55 body: 'pr body',
56 head: { ref: 'buddy/update-x' },
57 base: { ref: 'main' },
58 state: 'open',
59 html_url: `https://github.com/owner/repo/pull/${number}`,
60 created_at: '2026-01-02T03:04:05Z',
61 updated_at: '2026-01-02T03:04:06Z',
62 merged_at: null,
63 draft: false,
64 user: { login: 'github-actions[bot]' },
65 ...overrides,
66 }
67}
68
69function issueResponse(number: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
70 return {
71 number,
72 title: `issue #${number}`,
73 body: 'issue body',
74 state: 'open',
75 html_url: `https://github.com/owner/repo/issues/${number}`,
76 created_at: '2026-01-02T03:04:05Z',
77 updated_at: '2026-01-02T03:04:06Z',
78 closed_at: null,
79 user: { login: 'octocat' },
80 assignees: [],
81 labels: [],
82 ...overrides,
83 }
84}
85
86describe('gitHubProvider API surface', () => {
87 describe('runCommand', () => {
88 it('success case - resolves accumulated stdout on exit 0', async () => {
89 const output = await github().runCommand('sh', ['-c', 'echo hello'])
90 expect(output).toContain('hello')
91 })
92
93 it('failure case - a failed command redacts the token and token-shaped strings from stderr', async () => {
94 const prov = github('supersecrettoken1234')
95 const error = await prov
96 .runCommand('sh', ['-c', '>&2 echo "auth supersecrettoken1234 ghp_ABCDEFGHIJKLMNOPQRST12345"; exit 3'])
97 .catch((e: unknown) => e)
98
99 expect(error).toBeInstanceOf(Error)
100 const message = (error as Error).message
101 expect(message).toContain('code 3')
102 expect(message).toContain('[REDACTED]')
103 expect(message).not.toContain('supersecrettoken1234')
104 expect(message).not.toContain('ghp_ABCDEFGHIJKLMNOPQRST12345')
105 })
106 })
107
108 describe('createPullRequest', () => {
109 const options = { title: 't', body: 'b', head: 'buddy/x', base: 'main' }
110
111 it('failure case - falls back to the API when the gh CLI is missing', async () => {
112 const prov = github()
113 ;(prov as any).runCommand = async () => {
114 throw new Error('gh: not found')
115 }
116 const calls = stubFetch(() => json(prResponse(55), 201))
117
118 const pr = await prov.createPullRequest(options)
119
120 expect(pr.number).toBe(55)
121 expect(pr.head).toBe('buddy/update-x')
122 expect(pr.url).toBe('https://github.com/owner/repo/pull/55')
123 expect(pr.createdAt).toEqual(new Date('2026-01-02T03:04:05Z'))
124 expect(calls.filter(c => c.method === 'POST' && c.url.endsWith('/pulls'))).toHaveLength(1)
125 })
126
127 it('failure case - falls back to the API when gh prints no PR URL', async () => {
128 const prov = github()
129 ;(prov as any).runCommand = async () => 'no url in this output\n'
130 const calls = stubFetch(() => json(prResponse(56), 201))
131
132 const pr = await prov.createPullRequest(options)
133
134 expect(pr.number).toBe(56)
135 expect(calls.filter(c => c.method === 'POST' && c.url.endsWith('/pulls'))).toHaveLength(1)
136 })
137
138 it('success case - parses the PR number from gh output and adds labels via API afterwards', async () => {
139 const prov = github()
140 const commands: Array<{ command: string, args: string[] }> = []
141 ;(prov as any).runCommand = async (command: string, args: string[]) => {
142 commands.push({ command, args })
143 return 'https://github.com/owner/repo/pull/123\n'
144 }
145 const apiCalls: Array<{ endpoint: string, data: unknown }> = []
146 ;(prov as any).apiRequest = async (endpoint: string, data?: unknown) => {
147 apiCalls.push({ endpoint, data })
148 return {}
149 }
150
151 const pr = await prov.createPullRequest({
152 ...options,
153 labels: ['dependencies'],
154 reviewers: ['alice'],
155 })
156
157 expect(pr.number).toBe(123)
158 expect(pr.url).toBe('https://github.com/owner/repo/pull/123')
159 expect(commands[0].command).toBe('gh')
160 expect(commands[0].args).toContain('--reviewer')
161 const labelCall = apiCalls.find(c => c.endpoint === 'POST /repos/owner/repo/issues/123/labels')
162 expect(labelCall?.data).toEqual({ labels: ['dependencies'] })
163 })
164
165 it('failure case - the API path survives a reviewer-add failure and retries labels one-by-one', async () => {
166 const prov = github()
167 ;(prov as any).runCommand = async () => {
168 throw new Error('gh missing')
169 }
170 const labelBodies: string[] = []
171 stubFetch((call) => {
172 if (call.url.includes('/requested_reviewers'))
173 return json({ message: 'Validation Failed' }, 422)
174 if (call.url.includes('/labels')) {
175 labelBodies.push(call.body ?? '')
176 const labels = JSON.parse(call.body ?? '{}').labels ?? []
177 return labels.length > 1 ? json({ message: 'Validation Failed' }, 422) : json({})
178 }
179 return json(prResponse(9), 201)
180 })
181
182 const pr = await prov.createPullRequest({
183 ...options,
184 reviewers: ['alice'],
185 labels: ['a', 'b'],
186 })
187
188 expect(pr.number).toBe(9)
189 expect(labelBodies.map(body => JSON.parse(body).labels)).toEqual([['a', 'b'], ['a'], ['b']])
190 })
191 })
192
193 describe('updatePullRequest', () => {
194 it('success case - PATCHes only the provided fields and falls back per label', async () => {
195 const calls = stubFetch((call) => {
196 if (call.method === 'PATCH')
197 return json(prResponse(7))
198 if (call.method === 'PUT')
199 return json({ message: 'Forbidden' }, 403)
200 return json({})
201 })
202
203 const pr = await github().updatePullRequest(7, { title: 'x', labels: ['a'] })
204
205 const patch = calls.find(c => c.method === 'PATCH')
206 expect(JSON.parse(patch?.body ?? '')).toEqual({ title: 'x' })
207 const put = calls.find(c => c.method === 'PUT')
208 expect(put?.url).toContain('/issues/7/labels')
209 const post = calls.find(c => c.method === 'POST')
210 expect(JSON.parse(post?.body ?? '')).toEqual({ labels: ['a'] })
211 expect(pr.number).toBe(7)
212 expect(pr.title).toBe('chore(deps): update #7')
213 expect(pr.labels).toEqual(['a'])
214 })
215 })
216
217 describe('closePullRequest', () => {
218 it('edge case - tolerates a non-JSON success response', async () => {
219 const calls = stubFetch(() =>
220 new Response('', { status: 200, headers: { 'content-type': 'text/plain' } }))
221
222 await github().closePullRequest(7)
223
224 expect(calls[0].method).toBe('PATCH')
225 expect(JSON.parse(calls[0].body ?? '')).toEqual({ state: 'closed' })
226 })
227 })
228
229 describe('mergePullRequest', () => {
230 it('success case - maps the strategy, deletes the head branch, and swallows cleanup failure', async () => {
231 const prov = github()
232 const apiCalls: Array<{ endpoint: string, data: unknown }> = []
233 ;(prov as any).apiRequest = async (endpoint: string, data?: unknown) => {
234 apiCalls.push({ endpoint, data })
235 if (endpoint.startsWith('GET'))
236 return { head: { ref: 'buddy/x' } }
237 return {}
238 }
239 const deleted: string[] = []
240 ;(prov as any).deleteBranch = async (name: string) => {
241 deleted.push(name)
242 throw new Error('branch is protected')
243 }
244
245 await prov.mergePullRequest(9, 'squash')
246
247 const merge = apiCalls.find(c => c.endpoint === 'PUT /repos/owner/repo/pulls/9/merge')
248 expect(merge?.data).toEqual({ merge_method: 'squash' })
249 expect(deleted).toEqual(['buddy/x'])
250 })
251 })
252
253 describe('hasWriteAccess', () => {
254 it('success case - grants admin access', async () => {
255 stubFetch(() => json({ permission: 'admin' }))
256 expect(await github().hasWriteAccess('alice')).toBe(true)
257 })
258
259 it('success case - denies read-only collaborators', async () => {
260 stubFetch(() => json({ permission: 'read' }))
261 expect(await github().hasWriteAccess('alice')).toBe(false)
262 })
263
264 it('failure case - a 404 (not a collaborator) is a definitive no', async () => {
265 stubFetch(() => json({ message: 'Not Found' }, 404))
266 expect(await github().hasWriteAccess('stranger')).toBe(false)
267 })
268
269 it('failure case - an unknown error must not grant access', async () => {
270 stubFetch(() => json({ message: 'boom' }, 500))
271 expect(await github().hasWriteAccess('alice')).toBe(false)
272 })
273
274 it('edge case - the username is URL-encoded', async () => {
275 const calls = stubFetch(() => json({ permission: 'admin' }))
276 await github().hasWriteAccess('a b')
277 expect(calls[0].url).toContain('/collaborators/a%20b/permission')
278 })
279 })
280
281 describe('getWorkflowRunLogs', () => {
282 it('success case - collapses non-printable bytes to spaces but keeps tabs and newlines', async () => {
283 stubFetch(() => new Response(new Uint8Array([...Buffer.from('ok\x00\x01text\tend\n')])))
284
285 const logs = await github().getWorkflowRunLogs(5)
286
287 expect(logs).toBe('ok text\tend\n')
288 })
289
290 it('failure case - unavailable logs read as null', async () => {
291 stubFetch(() => json({ message: 'Not Found' }, 404))
292 expect(await github().getWorkflowRunLogs(5)).toBeNull()
293 })
294
295 it('failure case - an unreadable body reads as null rather than throwing', async () => {
296 stubFetch(() => ({
297 ok: true,
298 status: 200,
299 headers: new Headers(),
300 arrayBuffer: () => Promise.reject(new Error('corrupt archive')),
301 }) as unknown as Response)
302
303 expect(await github().getWorkflowRunLogs(5)).toBeNull()
304 })
305 })
306
307 describe('createCheckRun', () => {
308 it('failure case - logs instead of throwing when checks:write is missing', async () => {
309 stubFetch(() => json({ message: 'Resource not accessible by integration' }, 403))
310
311 await expect(
312 github().createCheckRun('buddy gate', 'abc', { conclusion: 'failure', title: 't', summary: 's' }),
313 ).resolves.toBeUndefined()
314 })
315
316 it('success case - posts a completed check run with the result output', async () => {
317 const calls = stubFetch(() => json({}, 201))
318
319 await github().createCheckRun('buddy gate', 'abc123', {
320 conclusion: 'success',
321 title: 'All gates passed',
322 summary: 'details',
323 })
324
325 expect(calls[0].url).toBe('https://api.github.test/repos/owner/repo/check-runs')
326 const body = JSON.parse(calls[0].body ?? '')
327 expect(body.name).toBe('buddy gate')
328 expect(body.head_sha).toBe('abc123')
329 expect(body.status).toBe('completed')
330 expect(body.conclusion).toBe('success')
331 expect(body.output).toEqual({ title: 'All gates passed', summary: 'details' })
332 })
333 })
334
335 describe('getFileContent', () => {
336 it('success case - decodes base64 content', async () => {
337 const calls = stubFetch(() => json({
338 content: Buffer.from('hello').toString('base64'),
339 encoding: 'base64',
340 }))
341
342 const content = await github().getFileContent('docs/read me.md', 'feat/x')
343
344 expect(content).toBe('hello')
345 expect(calls[0].url).toContain('/contents/docs%2Fread%20me.md?ref=feat%2Fx')
346 })
347
348 it('edge case - a response with no content reads as null', async () => {
349 stubFetch(() => json({}))
350 expect(await github().getFileContent('a.txt', 'main')).toBeNull()
351 })
352
353 it('failure case - a 404 reads as null', async () => {
354 stubFetch(() => json({ message: 'Not Found' }, 404))
355 expect(await github().getFileContent('missing.txt', 'main')).toBeNull()
356 })
357
358 it('failure case - other failures rethrow as typed errors', async () => {
359 stubFetch(() => json({ message: 'boom' }, 500))
360 const error = await github().getFileContent('a.txt', 'main').catch((e: unknown) => e)
361 expect(error).toBeInstanceOf(GitHubApiError)
362 expect((error as GitHubApiError).status).toBe(500)
363 })
364 })
365
366 describe('getPullRequestDiff', () => {
367 it('success case - asks for the diff media type and returns the text verbatim', async () => {
368 const calls = stubFetch(() => new Response('diff --git a/x b/x'))
369
370 const diff = await github().getPullRequestDiff(3)
371
372 expect(diff).toBe('diff --git a/x b/x')
373 expect(calls[0].headers.Accept).toBe('application/vnd.github.v3.diff')
374 })
375
376 it('failure case - throws a typed error on failure', async () => {
377 stubFetch(() => json({ message: 'Not Found' }, 404))
378 const error = await github().getPullRequestDiff(3).catch((e: unknown) => e)
379 expect(error).toBeInstanceOf(GitHubApiError)
380 expect((error as GitHubApiError).status).toBe(404)
381 })
382 })
383
384 describe('createReview', () => {
385 it('failure case - reposts the summary alone when inline anchors are rejected', async () => {
386 const calls = stubFetch((call) => {
387 const body = JSON.parse(call.body ?? '{}')
388 return Array.isArray(body.comments)
389 ? json({ message: 'Validation Failed' }, 422)
390 : json({})
391 })
392
393 const result = await github().createReview(4, {
394 body: 'summary',
395 event: 'COMMENT',
396 comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }],
397 })
398
399 expect(result).toEqual({ posted: true, inlineComments: 0 })
400 expect(calls).toHaveLength(2)
401 })
402
403 it('failure case - a rejected review with no comments is not silently retried', async () => {
404 const calls = stubFetch(() => json({ message: 'Validation Failed' }, 422))
405 const error = await github()
406 .createReview(4, { body: 'summary', event: 'COMMENT' })
407 .catch((e: unknown) => e)
408
409 expect(error).toBeInstanceOf(GitHubApiError)
410 expect(calls).toHaveLength(1)
411 })
412 })
413
414 describe('enableAutoMerge', () => {
415 it('failure case - treats GraphQL errors as a fallback signal', async () => {
416 stubFetch(call => call.url.includes('/graphql')
417 ? json({ errors: [{ message: 'Pull request is in clean status' }] })
418 : json({ node_id: 'PR_abc' }))
419
420 expect(await github().enableAutoMerge(5)).toBe(false)
421 })
422
423 it('success case - uppercases the merge method in the mutation variables', async () => {
424 const calls = stubFetch(call => call.url.includes('/graphql')
425 ? json({ data: {} })
426 : json({ node_id: 'PR_abc' }))
427
428 expect(await github().enableAutoMerge(5, 'squash')).toBe(true)
429
430 const gql = calls.find(c => c.url.includes('/graphql'))
431 expect(JSON.parse(gql?.body ?? '').variables).toEqual({
432 pullRequestId: 'PR_abc',
433 mergeMethod: 'SQUASH',
434 })
435 })
436 })
437
438 describe('listReviewThreads', () => {
439 it('success case - maps thread nodes and drops the unusable ones', async () => {
440 stubFetch(() => json({
441 data: {
442 repository: {
443 pullRequest: {
444 reviewThreads: {
445 nodes: [
446 {
447 id: 'T1',
448 isResolved: true,
449 path: 'src/a.ts',
450 comments: { nodes: [{ author: { login: 'alice' } }, { author: null }] },
451 },
452 null,
453 { id: 'T2', isResolved: false, comments: { nodes: [] } },
454 ],
455 },
456 },
457 },
458 },
459 }))
460
461 const threads = await github().listReviewThreads(8)
462
463 expect(threads).toHaveLength(2)
464 expect(threads[0]).toEqual({ id: 'T1', isResolved: true, path: 'src/a.ts', authorLogins: ['alice'] })
465 expect(threads[1]).toEqual({ id: 'T2', isResolved: false, authorLogins: [] })
466 expect('path' in threads[1]).toBe(false)
467 })
468
469 it('failure case - GraphQL errors read as no threads', async () => {
470 stubFetch(() => json({ errors: [{ message: 'boom' }] }))
471 expect(await github().listReviewThreads(8)).toEqual([])
472 })
473
474 it('failure case - an unreadable conversation reads as nothing to tidy', async () => {
475 stubFetch(() => json({ message: 'boom' }, 500))
476 expect(await github().listReviewThreads(8)).toEqual([])
477 })
478 })
479
480 describe('getPullRequestChecksState', () => {
481 function stubChecks(checkRuns: unknown, status: unknown): void {
482 stubFetch((call) => {
483 if (call.url.includes('/check-runs'))
484 return json(checkRuns)
485 if (call.url.includes('/status'))
486 return json(status)
487 return json({ head: { sha: 'abc' } })
488 })
489 }
490
491 it('edge case - no runs and no statuses read as none', async () => {
492 stubChecks({ check_runs: [] }, { statuses: [], state: 'pending' })
493 expect(await github().getPullRequestChecksState(9)).toBe('none')
494 })
495
496 it('failure case - a timed-out run reads as failure', async () => {
497 stubChecks(
498 { check_runs: [{ status: 'completed', conclusion: 'timed_out' }] },
499 { statuses: [], state: 'success' },
500 )
501 expect(await github().getPullRequestChecksState(9)).toBe('failure')
502 })
503
504 it('edge case - green runs with a pending legacy status read as pending', async () => {
505 stubChecks(
506 { check_runs: [{ status: 'completed', conclusion: 'success' }] },
507 { statuses: [{ state: 'pending' }], state: 'pending' },
508 )
509 expect(await github().getPullRequestChecksState(9)).toBe('pending')
510 })
511
512 it('success case - everything green reads as success', async () => {
513 stubChecks(
514 { check_runs: [{ status: 'completed', conclusion: 'success' }] },
515 { statuses: [{ state: 'success' }], state: 'success' },
516 )
517 expect(await github().getPullRequestChecksState(9)).toBe('success')
518 })
519
520 it('failure case - an unreadable check state reads as pending, never success', async () => {
521 stubFetch(() => json({ message: 'boom' }, 500))
522 expect(await github().getPullRequestChecksState(9)).toBe('pending')
523 })
524 })
525
526 describe('getBuddyBranches API fallback', () => {
527 it('success case - paginates branches and epoch-falls-back on commit-date failures', async () => {
528 const prov = github()
529 ;(prov as any).runCommand = async () => {
530 throw new Error('git missing')
531 }
532 ;(prov as any).apiRequest = async (endpoint: string) => {
533 if (endpoint.includes('/branches?')) {
534 return [
535 { name: 'buddy/a', commit: { sha: 'sha-a' } },
536 { name: 'buddy/b', commit: { sha: 'sha-b' } },
537 { name: 'feature/c', commit: { sha: 'sha-c' } },
538 ]
539 }
540 if (endpoint.includes('/commits/sha-a'))
541 return { commit: { committer: { date: '2026-01-01T00:00:00Z' } } }
542 throw new Error('commit lookup failed')
543 }
544
545 const branches = await prov.getBuddyBranches()
546
547 expect(branches).toEqual([
548 { name: 'buddy/a', sha: 'sha-a', lastCommitDate: new Date('2026-01-01T00:00:00Z') },
549 { name: 'buddy/b', sha: 'sha-b', lastCommitDate: new Date(0) },
550 ])
551 })
552
553 it('failure case - an unlistable repository reads as no buddy branches', async () => {
554 const prov = github()
555 ;(prov as any).runCommand = async () => {
556 throw new Error('git missing')
557 }
558 ;(prov as any).apiRequest = async () => {
559 throw new Error('branch listing failed')
560 }
561
562 expect(await prov.getBuddyBranches()).toEqual([])
563 })
564 })
565
566 describe('commitChanges via API', () => {
567 function providerForCommit(contentsError: Error): { prov: GitHubProvider, endpoints: string[] } {
568 const prov = github()
569 ;(prov as any).runCommand = async () => {
570 throw new Error('git unavailable')
571 }
572 const endpoints: string[] = []
573 ;(prov as any).apiRequest = async (endpoint: string) => {
574 endpoints.push(endpoint)
575 if (endpoint.includes('/git/ref/heads/'))
576 return { object: { sha: 'base-sha' } }
577 if (endpoint.includes('/git/commits/'))
578 return { tree: { sha: 'tree-sha' } }
579 if (endpoint.includes('/contents/'))
580 throw contentsError
581 return {}
582 }
583 return { prov, endpoints }
584 }
585
586 it('failure case - refuses an update target missing from base', async () => {
587 const { prov, endpoints } = providerForCommit(new Error('404 not found'))
588
589 const error = await prov
590 .commitChanges('buddy/x', 'msg', [{ path: 'package.json', content: '{"name":"y"}', type: 'update' }])
591 .catch((e: unknown) => e)
592
593 expect(error).toBeInstanceOf(FileChangeValidationError)
594 expect((error as Error).message).toContain('Refusing to create missing update target')
595 expect(endpoints.some(e => e.includes('/git/blobs'))).toBe(false)
596 })
597
598 it('failure case - a non-404 lookup failure rethrows instead of masquerading as missing', async () => {
599 const { prov } = providerForCommit(new Error('500 server error'))
600
601 const error = await prov
602 .commitChanges('buddy/x', 'msg', [{ path: 'package.json', content: '{"name":"y"}', type: 'update' }])
603 .catch((e: unknown) => e)
604
605 expect(error).not.toBeInstanceOf(FileChangeValidationError)
606 expect((error as Error).message).toContain('500 server error')
607 })
608 })
609
610 describe('getIssues', () => {
611 it('success case - filters out pull requests, walks pages, and serves the cache on the second call', async () => {
612 const prov = github()
613 const calls = stubFetch((call) => {
614 if (call.url.includes('&page=1')) {
615 const items = Array.from({ length: 100 }, (_, i) =>
616 issueResponse(i + 1, i === 0 ? { pull_request: { url: 'x' } } : {}))
617 return json(items)
618 }
619 return json([issueResponse(101)])
620 })
621
622 const issues = await prov.getIssues('open')
623
624 expect(issues).toHaveLength(100)
625 expect(issues.some(issue => issue.number === 1)).toBe(false)
626 expect(issues.some(issue => issue.number === 101)).toBe(true)
627 expect(calls.some(c => c.url.includes('&page=1'))).toBe(true)
628 expect(calls.some(c => c.url.includes('&page=2'))).toBe(true)
629
630 const callCount = calls.length
631 const again = await prov.getIssues('open')
632 expect(again).toHaveLength(100)
633 expect(calls).toHaveLength(callCount)
634 })
635 })
636
637 describe('updateIssue', () => {
638 it('edge case - passes explicit empty arrays through so labels can be cleared', async () => {
639 const calls = stubFetch(() => json(issueResponse(3)))
640
641 const issue = await github().updateIssue(3, { labels: [], assignees: [] })
642
643 expect(calls[0].method).toBe('PATCH')
644 expect(JSON.parse(calls[0].body ?? '')).toEqual({ labels: [], assignees: [] })
645 expect(issue.number).toBe(3)
646 expect(issue.author).toBe('octocat')
647 })
648 })
649
650 describe('rate-limit retry', () => {
651 it('failure case - a rate-limited list retries through the transport with zero delay', async () => {
652 let attempt = 0
653 const calls = stubFetch(() => {
654 attempt++
655 return attempt === 1
656 ? new Response('', { status: 429, headers: { 'retry-after': '0' } })
657 : json([])
658 })
659
660 const prs = await github().getPullRequests('open')
661
662 expect(prs).toEqual([])
663 expect(calls).toHaveLength(2)
664 })
665 })
666
667 describe('issue pinning', () => {
668 it('failure case - returns false on GraphQL refusal and never throws', async () => {
669 stubFetch(call => call.url.includes('/graphql')
670 ? json({ errors: [{ message: 'Only 3 issues can be pinned' }] })
671 : json({ node_id: 'I_x' }))
672
673 expect(await github().pinIssue(11)).toBe(false)
674 })
675
676 it('success case - pins and unpins with the matching mutation', async () => {
677 const prov = github()
678 const calls = stubFetch(call => call.url.includes('/graphql')
679 ? json({ data: {} })
680 : json({ node_id: 'I_x' }))
681
682 expect(await prov.pinIssue(11)).toBe(true)
683 expect(await prov.unpinIssue(11)).toBe(true)
684
685 const bodies = calls.filter(c => c.url.includes('/graphql')).map(c => c.body ?? '')
686 expect(bodies[0]).toContain('pinIssue(input')
687 expect(bodies[0]).not.toContain('unpinIssue')
688 expect(bodies[1]).toContain('unpinIssue(input')
689 })
690
691 it('failure case - an unreadable issue reads as not pinned rather than a thrown error', async () => {
692 stubFetch(() => json({ message: 'boom' }, 500))
693 expect(await github().pinIssue(11)).toBe(false)
694 })
695 })
696
697 describe('branchExists', () => {
698 it('success case - an existing ref reads as true', async () => {
699 stubFetch(() => json({ ref: 'refs/heads/main' }))
700 expect(await github().branchExists('main')).toBe(true)
701 })
702
703 it('success case - a 404 reads as absent', async () => {
704 stubFetch(() => json({ message: 'Not Found' }, 404))
705 expect(await github().branchExists('gone')).toBe(false)
706 })
707
708 it('failure case - any other failure reads as conservative false', async () => {
709 stubFetch(() => json({ message: 'boom' }, 500))
710 expect(await github().branchExists('main')).toBe(false)
711 })
712 })
713
714 describe('createBranch', () => {
715 it('success case - creates the ref from the base branch SHA', async () => {
716 const calls = stubFetch(call => call.method === 'POST'
717 ? json({}, 201)
718 : json({ object: { sha: 'abc' } }))
719
720 await github().createBranch('buddy/new', 'main')
721
722 const post = calls.find(c => c.method === 'POST')
723 expect(post?.url).toBe('https://api.github.test/repos/owner/repo/git/refs')
724 expect(JSON.parse(post?.body ?? '')).toEqual({ ref: 'refs/heads/buddy/new', sha: 'abc' })
725 })
726
727 it('failure case - a failing ref creation rejects', async () => {
728 stubFetch(call => call.method === 'POST'
729 ? json({ message: 'Reference already exists' }, 400)
730 : json({ object: { sha: 'abc' } }))
731
732 const error = await github().createBranch('buddy/new', 'main').catch((e: unknown) => e)
733 expect(error).toBeInstanceOf(GitHubApiError)
734 })
735 })
736})
test/registry-client-http.test.tsadded+711-0
Changes to test/registry-client-http.test.ts
@@ -0,0 +1,711 @@
1import type { BuddyConfig, PackageMetadata } from '../src/types'
2import type { Logger } from '../src/utils/logger'
3import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
4import fs from 'node:fs'
5import os from 'node:os'
6import path from 'node:path'
7import process from 'node:process'
8import { RegistryClient } from '../src/registry/registry-client'
9import { PackageRegistryError } from '../src/types'
10
11function stubFetch(impl: (input?: unknown, init?: unknown) => Promise<Response>) {
12 return spyOn(globalThis, 'fetch').mockImplementation(impl as unknown as typeof fetch)
13}
14
15/**
16 * Tests for the real HTTP and subprocess paths of RegistryClient.
17 *
18 * Every network call is served by a `spyOn(globalThis, 'fetch')` stub (the
19 * preload guard throws on anything unstubbed), and `runCommand` is spied per
20 * instance so no test but the dedicated runCommand suite spawns a process.
21 */
22
23const NPM_REGISTRY = 'https://registry.example.test'
24const COMPOSER_REGISTRY = 'https://packagist.example.test'
25const GITHUB_API = 'https://ghe.example.test/api/v3'
26
27/** Env vars that would otherwise leak real endpoints or tokens into tests. */
28const ENV_VARS = [
29 'BUDDY_TOKEN',
30 'GITHUB_TOKEN',
31 'GH_TOKEN',
32 'GITHUB_API_URL',
33 'NPM_CONFIG_REGISTRY',
34 'COMPOSER_REGISTRY_URL',
35] as const
36
37function makeLogger(): Logger {
38 return {
39 info: mock(),
40 warn: mock(),
41 error: mock(),
42 success: mock(),
43 debug: mock(),
44 } as unknown as Logger
45}
46
47function jsonResponse(body: unknown, status = 200): Response {
48 return new Response(JSON.stringify(body), {
49 status,
50 headers: { 'content-type': 'application/json' },
51 })
52}
53
54function baseConfig(overrides: Partial<BuddyConfig> = {}): BuddyConfig {
55 return {
56 registries: { npm: NPM_REGISTRY, composer: COMPOSER_REGISTRY },
57 ...overrides,
58 }
59}
60
61describe('RegistryClient HTTP behavior', () => {
62 let testDir: string
63 let mockLogger: Logger
64 let fetchSpy: any
65 let savedEnv: Record<string, string | undefined>
66
67 function makeClient(config: BuddyConfig | undefined = baseConfig(), logger: Logger = mockLogger): RegistryClient {
68 return new RegistryClient(testDir, logger, config)
69 }
70
71 beforeEach(() => {
72 testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'buddy-test-'))
73 mockLogger = makeLogger()
74
75 savedEnv = {}
76 for (const key of ENV_VARS) {
77 savedEnv[key] = process.env[key]
78 delete process.env[key]
79 }
80 })
81
82 afterEach(() => {
83 fetchSpy?.mockRestore?.()
84 fetchSpy = undefined
85
86 for (const key of ENV_VARS) {
87 if (savedEnv[key] === undefined)
88 delete process.env[key]
89 else
90 process.env[key] = savedEnv[key]
91 }
92
93 if (fs.existsSync(testDir))
94 fs.rmSync(testDir, { recursive: true, force: true })
95 })
96
97 describe('getPackageMetadata', () => {
98 it('maps a full packument into PackageMetadata, unwrapping repository/license/author objects', async () => {
99 const packument = {
100 'name': 'demo-pkg',
101 'dist-tags': { latest: '2.0.0' },
102 'versions': {
103 '1.0.0': {},
104 '2.0.0': {
105 name: 'demo-pkg',
106 description: 'Latest description',
107 homepage: 'https://demo.example.test',
108 repository: { url: 'git+https://github.com/demo/demo-pkg.git' },
109 license: { type: 'MIT' },
110 author: { name: 'Demo Author' },
111 keywords: ['demo'],
112 dependencies: { dep: '^1.0.0' },
113 devDependencies: { devdep: '^2.0.0' },
114 peerDependencies: { peerdep: '^3.0.0' },
115 },
116 },
117 }
118
119 fetchSpy = stubFetch(async () => jsonResponse(packument))
120
121 const client = makeClient()
122 const metadata = await client.getPackageMetadata('demo-pkg')
123
124 expect(fetchSpy).toHaveBeenCalledTimes(1)
125 expect(String(fetchSpy.mock.calls[0][0])).toBe(`${NPM_REGISTRY}/demo-pkg`)
126
127 expect(metadata).toBeDefined()
128 expect(metadata!.name).toBe('demo-pkg')
129 expect(metadata!.description).toBe('Latest description')
130 expect(metadata!.repository).toBe('git+https://github.com/demo/demo-pkg.git')
131 expect(metadata!.homepage).toBe('https://demo.example.test')
132 expect(metadata!.license).toBe('MIT')
133 expect(metadata!.author).toBe('Demo Author')
134 expect(metadata!.keywords).toEqual(['demo'])
135 expect(metadata!.latestVersion).toBe('2.0.0')
136 expect(metadata!.versions).toEqual(['1.0.0', '2.0.0'])
137 expect(metadata!.dependencies).toEqual({ dep: '^1.0.0' })
138 expect(metadata!.devDependencies).toEqual({ devdep: '^2.0.0' })
139 expect(metadata!.peerDependencies).toEqual({ peerdep: '^3.0.0' })
140 })
141
142 it('falls back to packument-root fields and last version key when dist-tags and the latest manifest are missing', async () => {
143 const packument = {
144 description: 'Root description',
145 homepage: 'https://root.example.test',
146 versions: { '1.0.0': {}, '1.1.0': {} },
147 }
148
149 fetchSpy = stubFetch(async () => jsonResponse(packument))
150
151 const metadata = await makeClient().getPackageMetadata('rooty-pkg')
152
153 expect(metadata).toBeDefined()
154 expect(metadata!.latestVersion).toBe('1.1.0')
155 expect(metadata!.description).toBe('Root description')
156 expect(metadata!.homepage).toBe('https://root.example.test')
157 })
158
159 it('refuses shell-unsafe package names before any network call', async () => {
160 fetchSpy = stubFetch(async () => {
161 throw new Error('unexpected network call')
162 })
163
164 const client = makeClient()
165
166 const metadata = await client.getPackageMetadata('foo; rm -rf /')
167 expect(metadata).toBeUndefined()
168 expect(mockLogger.warn).toHaveBeenCalledWith(
169 'Failed to get metadata for foo; rm -rf /:',
170 expect.any(PackageRegistryError),
171 )
172
173 const exists = await client.packageExists('--cwd=/tmp')
174 expect(exists).toBe(false)
175
176 expect(fetchSpy).not.toHaveBeenCalled()
177 })
178 })
179
180 describe('packument caching and error handling', () => {
181 it('memoizes the packument across metadata, latest-version and release-date lookups; clearCache forces a refetch', async () => {
182 const packument = {
183 'name': 'cached-pkg',
184 'dist-tags': { latest: '1.0.0' },
185 'versions': { '1.0.0': {} },
186 'time': { '1.0.0': '2024-01-05T00:00:00.000Z' },
187 }
188
189 fetchSpy = stubFetch(async () => jsonResponse(packument))
190
191 const client = makeClient()
192 await client.getPackageMetadata('cached-pkg')
193 await client.getLatestVersion('cached-pkg')
194 const releaseDate = await client.getPackageVersionReleaseDate('cached-pkg', '1.0.0')
195
196 expect(releaseDate?.toISOString()).toBe('2024-01-05T00:00:00.000Z')
197 expect(fetchSpy).toHaveBeenCalledTimes(1)
198
199 client.clearCache()
200 await client.getLatestVersion('cached-pkg')
201 expect(fetchSpy).toHaveBeenCalledTimes(2)
202 })
203
204 it('treats 404 as an ordinary miss with a debug log, and caches the null result', async () => {
205 fetchSpy = stubFetch(async () => new Response('not found', { status: 404 }))
206
207 const client = makeClient()
208 expect(await client.packageExists('missing-pkg')).toBe(false)
209 expect(mockLogger.debug).toHaveBeenCalledWith('No registry data for missing-pkg: HTTP 404')
210 expect(mockLogger.warn).not.toHaveBeenCalled()
211
212 // The failure is cached as null, so a second ask costs no request.
213 expect(await client.packageExists('missing-pkg')).toBe(false)
214 expect(fetchSpy).toHaveBeenCalledTimes(1)
215 })
216
217 it('warns on non-404 HTTP errors', async () => {
218 // 403 without rate-limit headers is non-transient, so no retries occur.
219 fetchSpy = stubFetch(async () => new Response('forbidden', { status: 403 }))
220
221 const client = makeClient()
222 expect(await client.packageExists('gone-pkg')).toBe(false)
223 expect(mockLogger.warn).toHaveBeenCalledWith('No registry data for gone-pkg: HTTP 403')
224 expect(fetchSpy).toHaveBeenCalledTimes(1)
225 })
226 })
227
228 describe('getLatestVersion', () => {
229 it('prefers the latest dist-tag and honours includePrerelease', async () => {
230 const packument = {
231 'dist-tags': { latest: '2.0.0-beta.1' },
232 'versions': { '1.0.0': {}, '1.1.0': {}, '2.0.0-beta.1': {} },
233 }
234
235 fetchSpy = stubFetch(async () => jsonResponse(packument))
236
237 // Default config: the prerelease dist-tag is skipped, highest stable wins.
238 const stableClient = makeClient()
239 expect(await stableClient.getLatestVersion('pre-pkg')).toBe('1.1.0')
240
241 // Fresh client (fresh cache) with prereleases allowed: dist-tag wins.
242 const preClient = makeClient(baseConfig({
243 packages: { strategy: 'all', includePrerelease: true },
244 }))
245 expect(await preClient.getLatestVersion('pre-pkg')).toBe('2.0.0-beta.1')
246 })
247
248 it('yields null when only prereleases exist and prereleases are excluded', async () => {
249 const packument = {
250 versions: { '1.0.0-beta.1': {}, '1.0.0-rc.1': {} },
251 }
252
253 fetchSpy = stubFetch(async () => jsonResponse(packument))
254
255 expect(await makeClient().getLatestVersion('only-pre-pkg')).toBeNull()
256 })
257 })
258
259 describe('runBunOutdated', () => {
260 it('parses object-keyed bun outdated --json output with nested {version} fields', async () => {
261 const client = makeClient()
262 const runCommandSpy = spyOn(client as any, 'runCommand').mockResolvedValueOnce(JSON.stringify({
263 'react': { current: { version: '18.0.0 ' }, latest: '18.2.0', workspace: 'apps/web' },
264 'half-formed': { current: '1.0.0' },
265 }))
266
267 const results = await (client as any).runBunOutdated()
268
269 expect(runCommandSpy).toHaveBeenCalledWith('bun', ['outdated', '--json'])
270 expect(results).toEqual([
271 {
272 name: 'react',
273 current: '18.0.0',
274 update: '18.2.0',
275 latest: '18.2.0',
276 workspace: 'apps/web',
277 },
278 ])
279 })
280
281 it('falls back to table parsing when --json output is not JSON, stripping ANSI codes and (dev) suffixes', async () => {
282 const esc = String.fromCharCode(27)
283 const table = [
284 '│ Package │ Current │ Update │ Latest │ Workspace │',
285 '├─────────┼─────────┼────────┼────────┼───────────┤',
286 `│ ${esc}[31mtypescript (dev)${esc}[0m │ 5.0.0 │ 5.1.0 │ 5.4.0 │ web │`,
287 '└─────────┴─────────┴────────┴────────┴───────────┘',
288 ].join('\n')
289
290 const client = makeClient()
291 const runCommandSpy = spyOn(client as any, 'runCommand')
292 .mockResolvedValueOnce('error: unknown flag --json')
293 .mockResolvedValueOnce(table)
294
295 const results = await (client as any).runBunOutdated()
296
297 expect(runCommandSpy).toHaveBeenCalledTimes(2)
298 expect(runCommandSpy.mock.calls[0][1]).toEqual(['outdated', '--json'])
299 expect(runCommandSpy.mock.calls[1][1]).toEqual(['outdated'])
300 expect(results).toEqual([
301 {
302 name: 'typescript',
303 current: '5.0.0',
304 update: '5.1.0',
305 latest: '5.4.0',
306 workspace: 'web',
307 },
308 ])
309 })
310 })
311
312 describe('getOutdatedPackages error handling', () => {
313 it('wraps a total bun outdated failure in PackageRegistryError', async () => {
314 const client = makeClient()
315 spyOn(client as any, 'runCommand').mockRejectedValue(new Error('spawn failed'))
316
317 const error = await client.getOutdatedPackages().catch((caught: unknown) => caught)
318
319 expect(error).toBeInstanceOf(PackageRegistryError)
320 expect((error as Error).message).toContain('Failed to check for outdated packages')
321 expect((error as Error).message).toContain('spawn failed')
322 })
323 })
324
325 describe('getUpdatesForPackages', () => {
326 it('passes validated filter tokens through to bun outdated', async () => {
327 const client = makeClient()
328 const runCommandSpy = spyOn(client as any, 'runCommand').mockResolvedValue('[]')
329
330 const updates = await client.getUpdatesForPackages(['react', '@types/node'])
331
332 expect(updates).toEqual([])
333 expect(runCommandSpy).toHaveBeenCalledWith('bun', ['outdated', '--json', 'react', '@types/node'])
334 })
335
336 it('rejects flag injection before any command runs', async () => {
337 const client = makeClient()
338 const runCommandSpy = spyOn(client as any, 'runCommand').mockResolvedValue('[]')
339
340 const error = await client.getUpdatesForPackages(['--cwd=/tmp']).catch((caught: unknown) => caught)
341
342 expect(error).toBeInstanceOf(PackageRegistryError)
343 expect((error as Error).message).toContain('untrusted package filter')
344 expect(runCommandSpy).not.toHaveBeenCalled()
345 })
346 })
347
348 describe('getPackageJsonOutdated', () => {
349 it('reports packages behind latest and honours ignore, excludeMajor, filter, and workspace: protocol', async () => {
350 fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({
351 dependencies: {
352 a: '^1.0.0',
353 b: 'workspace:*',
354 c: '2.0.0',
355 ignored: '^1.0.0',
356 },
357 devDependencies: { d: '~3.0.0' },
358 }))
359
360 const client = makeClient(baseConfig({
361 packages: { strategy: 'all', ignore: ['ignored'], excludeMajor: true },
362 }))
363 const latestSpy = spyOn(client, 'getLatestVersion').mockImplementation(
364 async name => ({ a: '1.2.0', c: '2.0.0', d: '4.0.0' } as Record<string, string>)[name] ?? null,
365 )
366
367 const results = await (client as any).getPackageJsonOutdated()
368
369 // b skipped (workspace:), c up to date, d excluded as major, ignored ignored.
370 expect(results).toEqual([
371 { name: 'a', current: '1.0.0', update: '1.2.0', latest: '1.2.0' },
372 ])
373
374 latestSpy.mockClear()
375 const filtered = await (client as any).getPackageJsonOutdated('a')
376 expect(filtered.map((result: { name: string }) => result.name)).toEqual(['a'])
377 expect(latestSpy.mock.calls.map(call => call[0])).toEqual(['a'])
378
379 // findPackageLocation reads the same tree.
380 expect(await (client as any).findPackageLocation('a')).toBe('package.json')
381 expect(await (client as any).findPackageLocation('not-anywhere')).toBeNull()
382 })
383
384 it('warns and returns [] when package.json is valid JSON but not an object', async () => {
385 fs.writeFileSync(path.join(testDir, 'package.json'), '[1,2,3]')
386
387 const results = await (makeClient() as any).getPackageJsonOutdated()
388
389 expect(results).toEqual([])
390 expect(mockLogger.warn).toHaveBeenCalledWith(
391 'Failed to check package.json versions:',
392 expect.any(PackageRegistryError),
393 )
394 })
395
396 it('warns and returns [] when package.json is not JSON at all', async () => {
397 fs.writeFileSync(path.join(testDir, 'package.json'), '{invalid json')
398
399 const results = await (makeClient() as any).getPackageJsonOutdated()
400
401 expect(results).toEqual([])
402 expect(mockLogger.warn).toHaveBeenCalledWith(
403 'Failed to check package.json versions:',
404 expect.any(PackageRegistryError),
405 )
406 })
407 })
408
409 describe('getUpdatesForWorkspace', () => {
410 it('builds workspace-scoped updates with release-notes and changelog links', async () => {
411 const table = [
412 '| Package | Current | Update | Latest |',
413 '|---------|---------|--------|--------|',
414 '| react | 18.0.0 | 18.1.0 | 18.2.0 |',
415 ].join('\n')
416
417 const client = makeClient()
418 const runCommandSpy = spyOn(client as any, 'runCommand').mockResolvedValue(table)
419 const metadata: PackageMetadata = {
420 name: 'react',
421 repository: 'https://github.com/foo/bar.git',
422 homepage: 'https://react.dev',
423 latestVersion: '18.2.0',
424 versions: ['18.0.0', '18.2.0'],
425 }
426 spyOn(client, 'getPackageMetadata').mockResolvedValue(metadata)
427
428 const updates = await client.getUpdatesForWorkspace('my-app')
429
430 expect(runCommandSpy).toHaveBeenCalledWith('bun', ['outdated', '--filter', 'my-app'])
431 expect(updates).toHaveLength(1)
432 expect(updates[0].name).toBe('react')
433 expect(updates[0].currentVersion).toBe('18.0.0')
434 expect(updates[0].newVersion).toBe('18.2.0')
435 expect(updates[0].updateType).toBe('minor')
436 expect(updates[0].file).toBe('my-app/package.json')
437 expect(updates[0].releaseNotesUrl).toBe('https://github.com/foo/bar/releases')
438 expect(updates[0].changelogUrl).toBe('https://github.com/foo/bar/blob/main/CHANGELOG.md')
439 expect(updates[0].homepage).toBe('https://react.dev')
440 })
441
442 it('degrades to [] with a warning when the command fails', async () => {
443 const client = makeClient()
444 spyOn(client as any, 'runCommand').mockRejectedValue(new Error('bun exploded'))
445
446 const updates = await client.getUpdatesForWorkspace('my-app')
447
448 expect(updates).toEqual([])
449 expect(mockLogger.warn).toHaveBeenCalledWith(
450 'Failed to get updates for workspace my-app:',
451 expect.any(Error),
452 )
453 })
454
455 it('refuses a malformed workspace name without spawning anything', async () => {
456 const client = makeClient()
457 const runCommandSpy = spyOn(client as any, 'runCommand').mockResolvedValue('')
458
459 const updates = await client.getUpdatesForWorkspace('bad name!')
460
461 expect(updates).toEqual([])
462 expect(runCommandSpy).not.toHaveBeenCalled()
463 })
464 })
465
466 describe('searchPackages', () => {
467 it('maps registry search results from the configured registry', async () => {
468 fetchSpy = stubFetch(async () => jsonResponse({
469 objects: [
470 {
471 package: {
472 name: 'react',
473 version: '18.2.0',
474 description: 'A library',
475 keywords: ['ui'],
476 },
477 },
478 ],
479 }))
480
481 const results = await makeClient().searchPackages('react', 5)
482
483 expect(String(fetchSpy.mock.calls[0][0])).toBe(`${NPM_REGISTRY}/-/v1/search?text=react&size=5`)
484 expect(results).toEqual([
485 { name: 'react', version: '18.2.0', description: 'A library', keywords: ['ui'] },
486 ])
487 })
488
489 it('returns [] with a warning on HTTP failure', async () => {
490 fetchSpy = stubFetch(async () => new Response('bad', { status: 400 }))
491
492 const results = await makeClient().searchPackages('react')
493
494 expect(results).toEqual([])
495 expect(mockLogger.warn).toHaveBeenCalledWith(
496 'Failed to search packages via registry API:',
497 expect.any(Error),
498 )
499 })
500
501 it('returns [] when the body has no objects array', async () => {
502 fetchSpy = stubFetch(async () => jsonResponse({}))
503
504 expect(await makeClient().searchPackages('react')).toEqual([])
505 })
506 })
507
508 describe('getPackageVersionReleaseDate', () => {
509 it('reads the packument time map and rejects invalid dates', async () => {
510 const packument = {
511 versions: { '1.2.3': {} },
512 time: {
513 '1.2.3': '2024-01-05T00:00:00.000Z',
514 '9.9.9': 'not-a-date',
515 },
516 }
517
518 fetchSpy = stubFetch(async () => jsonResponse(packument))
519
520 const client = makeClient()
521 const date = await client.getPackageVersionReleaseDate('dated-pkg', '1.2.3')
522 expect(date?.toISOString()).toBe('2024-01-05T00:00:00.000Z')
523
524 expect(await client.getPackageVersionReleaseDate('dated-pkg', '5.5.5')).toBeNull()
525 expect(await client.getPackageVersionReleaseDate('dated-pkg', '9.9.9')).toBeNull()
526
527 // All three reads share the memoized packument.
528 expect(fetchSpy).toHaveBeenCalledTimes(1)
529 })
530 })
531
532 describe('getGitHubActionReleaseDate', () => {
533 const githubConfig = (token?: string): BuddyConfig => baseConfig({
534 repository: {
535 provider: 'github',
536 owner: 'acme',
537 name: 'app',
538 token,
539 apiUrl: GITHUB_API,
540 },
541 })
542
543 it('hits the releases-tag endpoint with auth headers from config', async () => {
544 let capturedUrl = ''
545 let capturedHeaders: Record<string, string> = {}
546 fetchSpy = stubFetch(async (input: any, init?: any) => {
547 capturedUrl = String(input)
548 capturedHeaders = (init?.headers ?? {}) as Record<string, string>
549 return jsonResponse({ published_at: '2024-03-01T12:00:00Z' })
550 })
551
552 const date = await makeClient(githubConfig('cfg-token'))
553 .getGitHubActionReleaseDate('actions/checkout', 'v4.1.0')
554
555 expect(capturedUrl).toBe(`${GITHUB_API}/repos/actions/checkout/releases/tags/v4.1.0`)
556 expect(capturedHeaders.Authorization).toBe('Bearer cfg-token')
557 expect(date?.toISOString()).toBe('2024-03-01T12:00:00.000Z')
558 })
559
560 it('yields null when the release carries no published_at', async () => {
561 fetchSpy = stubFetch(async () => jsonResponse({}))
562
563 const date = await makeClient(githubConfig('cfg-token'))
564 .getGitHubActionReleaseDate('actions/checkout', 'v4.1.0')
565
566 expect(date).toBeNull()
567 })
568
569 it('guards malformed action names without any request', async () => {
570 fetchSpy = stubFetch(async () => jsonResponse({}))
571
572 const date = await makeClient(githubConfig('cfg-token'))
573 .getGitHubActionReleaseDate('noslash', 'v1')
574
575 expect(date).toBeNull()
576 expect(fetchSpy).not.toHaveBeenCalled()
577 })
578
579 it('falls through BUDDY_TOKEN, GITHUB_TOKEN, GH_TOKEN, then anonymous', async () => {
580 const captured: Array<Record<string, string>> = []
581 fetchSpy = stubFetch(async (_input: any, init?: any) => {
582 captured.push((init?.headers ?? {}) as Record<string, string>)
583 return jsonResponse({ published_at: '2024-03-01T12:00:00Z' })
584 })
585
586 const client = makeClient(githubConfig())
587
588 process.env.BUDDY_TOKEN = 'buddy-tok'
589 process.env.GITHUB_TOKEN = 'gh-tok'
590 await client.getGitHubActionReleaseDate('a/b', 'v1')
591
592 delete process.env.BUDDY_TOKEN
593 await client.getGitHubActionReleaseDate('a/b', 'v1')
594
595 delete process.env.GITHUB_TOKEN
596 process.env.GH_TOKEN = 'cli-tok'
597 await client.getGitHubActionReleaseDate('a/b', 'v1')
598
599 delete process.env.GH_TOKEN
600 await client.getGitHubActionReleaseDate('a/b', 'v1')
601
602 expect(captured[0].Authorization).toBe('Bearer buddy-tok')
603 expect(captured[1].Authorization).toBe('Bearer gh-tok')
604 expect(captured[2].Authorization).toBe('Bearer cli-tok')
605 expect(captured[3].Authorization).toBeUndefined()
606 expect(captured[3].Accept).toBe('application/vnd.github.v3+json')
607 expect(captured[3]['User-Agent']).toBe('buddy')
608 })
609 })
610
611 describe('getComposerPackageReleaseDate', () => {
612 it('reads Packagist version timestamps', async () => {
613 fetchSpy = stubFetch(async (input: any) => {
614 expect(String(input)).toBe(`${COMPOSER_REGISTRY}/packages/laravel/framework.json`)
615 return jsonResponse({
616 package: {
617 versions: {
618 'v10.0.0': { time: '2023-02-14T10:00:00+00:00' },
619 },
620 },
621 })
622 })
623
624 const client = makeClient()
625 const date = await client.getComposerPackageReleaseDate('laravel/framework', 'v10.0.0')
626 expect(date?.toISOString()).toBe('2023-02-14T10:00:00.000Z')
627
628 expect(await client.getComposerPackageReleaseDate('laravel/framework', 'v9.0.0')).toBeNull()
629 })
630
631 it('yields null when Packagist has no record', async () => {
632 fetchSpy = stubFetch(async () => new Response('missing', { status: 404 }))
633
634 const date = await makeClient().getComposerPackageReleaseDate('unknown/package', 'v1.0.0')
635
636 expect(date).toBeNull()
637 })
638 })
639
640 describe('minimum release age for docker images', () => {
641 it('is conservatively null so minimumReleaseAge allows docker updates', async () => {
642 fetchSpy = stubFetch(async () => {
643 throw new Error('unexpected network call')
644 })
645
646 const client = makeClient(baseConfig({
647 packages: { strategy: 'all', minimumReleaseAge: 1440 },
648 }))
649
650 expect(await client.getDockerImageReleaseDate('nginx', '1.25')).toBeNull()
651
652 const allowed = await client.meetsMinimumReleaseAge('nginx', '1.25', 'docker-image')
653 expect(allowed).toBe(true)
654 expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('nginx@1.25'))
655 expect(fetchSpy).not.toHaveBeenCalled()
656 })
657 })
658
659 describe('runCommand', () => {
660 it('captures stdout from a successful command', async () => {
661 const output = await (makeClient(undefined) as any).runCommand('bun', ['-e', 'console.log("out")'])
662 expect(output).toBe('out\n')
663 })
664
665 it('surfaces stderr and the exit code on failure', async () => {
666 const error = await (makeClient(undefined) as any)
667 .runCommand('bun', ['-e', 'console.error("boom"); process.exit(3)'])
668 .catch((caught: unknown) => caught)
669
670 expect(error).toBeInstanceOf(Error)
671 expect((error as Error).message).toContain('code 3')
672 expect((error as Error).message).toContain('boom')
673 })
674
675 it('rejects when the binary does not exist', async () => {
676 const error = await (makeClient(undefined) as any)
677 .runCommand('buddy-definitely-not-a-real-binary-xyz', [])
678 .catch((caught: unknown) => caught)
679
680 // Depending on runtime, the miss reports via the error event (ENOENT,
681 // or Bun's "Executable not found") or via close with a negative exit
682 // code — assert the observable message, not the channel.
683 expect(error).toBeInstanceOf(Error)
684 expect((error as Error).message).toMatch(/ENOENT|code -\d+|Executable not found/)
685 })
686 })
687
688 describe('getComposerOutdatedPackages', () => {
689 it('returns [] with a warning when composer.json cannot be parsed', async () => {
690 const client = makeClient()
691 spyOn(client as any, 'runCommand').mockResolvedValueOnce('Composer version 2.5.8')
692
693 const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true)
694 const readSpy = spyOn(fs, 'readFileSync').mockReturnValue('{invalid json')
695
696 try {
697 const updates = await client.getComposerOutdatedPackages()
698
699 expect(updates).toEqual([])
700 expect(mockLogger.warn).toHaveBeenCalledWith(
701 'Failed to check for outdated Composer packages:',
702 expect.any(Error),
703 )
704 }
705 finally {
706 existsSpy.mockRestore()
707 readSpy.mockRestore()
708 }
709 })
710 })
711})