@wmfs/lerna-sync 1.37.1 → 1.39.0

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.
@@ -0,0 +1,182 @@
1
+ const ACTIONS = Object.freeze({
2
+ CLONE: 'CLONE',
3
+ FAST_FORWARD: 'FAST_FORWARD',
4
+ FETCH_ONLY: 'FETCH_ONLY',
5
+ NONE: 'NONE'
6
+ })
7
+
8
+ const REASONS = Object.freeze({
9
+ NEW: 'NEW',
10
+ BEHIND: 'BEHIND',
11
+ UP_TO_DATE: 'UP_TO_DATE',
12
+ ON_BRANCH: 'ON_BRANCH',
13
+ DETACHED: 'DETACHED',
14
+ LOCAL_CHANGES: 'LOCAL_CHANGES',
15
+ UNSAFE_STATE: 'UNSAFE_STATE',
16
+ NOT_A_REPO: 'NOT_A_REPO',
17
+ FOREIGN_ORIGIN: 'FOREIGN_ORIGIN',
18
+ ORPHANED: 'ORPHANED',
19
+ NO_LONGER_QUALIFIES: 'NO_LONGER_QUALIFIES',
20
+ DUPLICATE: 'DUPLICATE'
21
+ })
22
+
23
+ function planSync ({ remoteRepos, localRepos, router, orgName, packageFolders }) {
24
+ const key = name => name.toLowerCase() // GitHub repo names are case-insensitive
25
+ const knownFolders = new Set(packageFolders)
26
+
27
+ // 1. Route every remote repo once
28
+ const remoteByName = new Map()
29
+ const ignored = []
30
+
31
+ for (const remote of remoteRepos) {
32
+ const route = routeRepo(remote, router, knownFolders)
33
+ remoteByName.set(key(remote.name), { remote, route })
34
+ if (!route.folder) {
35
+ ignored.push({ repoName: remote.name, reason: route.reason })
36
+ }
37
+ }
38
+
39
+ // 2. Index local repos by the name origin points at, falling back to folder name
40
+ const localByName = new Map()
41
+ const decisions = []
42
+
43
+ for (const local of localRepos) {
44
+ const name = key(local.origin?.name || local.repoName)
45
+ if (localByName.has(name)) {
46
+ decisions.push(
47
+ decision(
48
+ local.repoName,
49
+ ACTIONS.NONE,
50
+ REASONS.DUPLICATE,
51
+ {
52
+ local,
53
+ folder: local.folder,
54
+ warnings: [`Also found at ${localByName.get(name).folder}/${localByName.get(name).repoName}`]
55
+ }
56
+ )
57
+ )
58
+ continue
59
+ }
60
+ localByName.set(name, local)
61
+ }
62
+
63
+ // 3. Repos that exist locally
64
+ for (const [name, local] of localByName) {
65
+ const entry = remoteByName.get(name)
66
+ decisions.push(planLocal(local, entry, orgName))
67
+ }
68
+
69
+ // 4. Qualifying remote repos with no local copy
70
+ for (const [name, { remote, route }] of remoteByName) {
71
+ if (!route.folder || localByName.has(name)) continue
72
+ decisions.push(decision(remote.name, ACTIONS.CLONE, REASONS.NEW, {
73
+ remote,
74
+ folder: route.folder,
75
+ defaultBranch: remote.defaultBranch
76
+ }))
77
+ }
78
+
79
+ decisions.sort((a, b) =>
80
+ a.folder.localeCompare(b.folder) || a.repoName.localeCompare(b.repoName)
81
+ )
82
+
83
+ return { decisions, ignored }
84
+ }
85
+
86
+ function planLocal (local, entry, orgName) {
87
+ const base = { local, folder: local.folder }
88
+
89
+ if (!local.isGitRepo) {
90
+ // A plain folder where a qualifying repo would be cloned: don't overwrite it
91
+ return decision(local.repoName, ACTIONS.NONE, REASONS.NOT_A_REPO, {
92
+ ...base,
93
+ remote: entry?.remote || null,
94
+ warnings: entry?.route.folder ? ['Folder exists but is not a git repo, so it cannot be cloned into'] : []
95
+ })
96
+ }
97
+
98
+ if (local.origin?.owner && local.origin.owner.toLowerCase() !== orgName.toLowerCase()) {
99
+ return decision(local.repoName, ACTIONS.NONE, REASONS.FOREIGN_ORIGIN, {
100
+ ...base,
101
+ warnings: [`origin points at ${local.origin.url}`]
102
+ })
103
+ }
104
+
105
+ if (!entry) {
106
+ return decision(local.repoName, ACTIONS.NONE, REASONS.ORPHANED, base)
107
+ }
108
+
109
+ const { remote, route } = entry
110
+ const withRemote = { ...base, remote, defaultBranch: remote.defaultBranch, warnings: [] }
111
+
112
+ if (!route.folder) {
113
+ return decision(local.repoName, ACTIONS.NONE, REASONS.NO_LONGER_QUALIFIES, {
114
+ ...withRemote,
115
+ warnings: [route.reason]
116
+ })
117
+ }
118
+
119
+ if (route.folder !== local.folder) {
120
+ withRemote.warnings.push(`Lives in /${local.folder} but is now routed to /${route.folder}`)
121
+ }
122
+
123
+ if (!local.statusKnown || local.operationInProgress || !local.headSha) {
124
+ return decision(local.repoName, ACTIONS.NONE, REASONS.UNSAFE_STATE, withRemote)
125
+ }
126
+
127
+ if (local.isDetached) {
128
+ return decision(local.repoName, ACTIONS.FETCH_ONLY, REASONS.DETACHED, withRemote)
129
+ }
130
+
131
+ if (local.branch !== remote.defaultBranch) {
132
+ return decision(local.repoName, ACTIONS.FETCH_ONLY, REASONS.ON_BRANCH, withRemote)
133
+ }
134
+
135
+ if (local.headSha === remote.sha) {
136
+ return decision(local.repoName, ACTIONS.NONE, REASONS.UP_TO_DATE, withRemote)
137
+ }
138
+
139
+ if (local.trackedChanges > 0) {
140
+ return decision(local.repoName, ACTIONS.FETCH_ONLY, REASONS.LOCAL_CHANGES, withRemote)
141
+ }
142
+
143
+ // On the default branch, clean, and HEAD differs from GitHub.
144
+ // Usually that means behind. If it has local commits instead, --ff-only
145
+ // will refuse and the executor reports it as diverged.
146
+ return decision(local.repoName, ACTIONS.FAST_FORWARD, REASONS.BEHIND, withRemote)
147
+ }
148
+
149
+ function routeRepo (remote, router, knownFolders) {
150
+ if (!remote.packageJson) return { folder: null, reason: 'No package.json' }
151
+
152
+ let result
153
+ try {
154
+ result = router(remote.packageJson)
155
+ } catch (e) {
156
+ return { folder: null, reason: `Router threw: ${e.message}` }
157
+ }
158
+
159
+ // Backwards compatible: a string means "route here", null/undefined means "not ours".
160
+ // A router can also return { skip: 'reason' } so the report can say why.
161
+ if (result == null) return { folder: null, reason: 'Not selected by router' }
162
+ if (typeof result === 'object' && result.skip) return { folder: null, reason: result.skip }
163
+ if (typeof result !== 'string') return { folder: null, reason: `Router returned ${JSON.stringify(result)}` }
164
+ if (!knownFolders.has(result)) return { folder: null, reason: `Router chose "${result}", which is not in lerna.json` }
165
+
166
+ return { folder: result, reason: null }
167
+ }
168
+
169
+ function decision (repoName, action, reason, extra = {}) {
170
+ return {
171
+ repoName,
172
+ action,
173
+ reason,
174
+ folder: extra.folder,
175
+ defaultBranch: extra.defaultBranch || extra.remote?.defaultBranch || null,
176
+ remote: extra.remote || null,
177
+ local: extra.local || null,
178
+ warnings: extra.warnings || []
179
+ }
180
+ }
181
+
182
+ module.exports = { planSync, ACTIONS, REASONS }
package/lib/report.js ADDED
@@ -0,0 +1,195 @@
1
+ const chalk = require('chalk')
2
+ const { ACTIONS, REASONS } = require('./plan-sync')
3
+ const { OUTCOMES } = require('./execute-plan')
4
+
5
+ const attention = (name, detail, commits) => ({ group: 'attention', name, detail, commits })
6
+ const housekeeping = (name, detail) => ({ group: 'housekeeping', name, detail })
7
+ const firstLine = s => (s || '').split('\n')[0]
8
+
9
+ function report (plan, results, { verbose = false } = {}) {
10
+ const resultFor = new Map(results.map(r => [r.decision, r]))
11
+ const groups = { branches: [], cloned: [], updated: [], attention: [], housekeeping: [], errors: [] }
12
+ let okCount = 0
13
+ let depsMayHaveChanged = false
14
+
15
+ for (const d of plan.decisions) {
16
+ const r = resultFor.get(d)
17
+ const entry = classify(d, r)
18
+ if (!entry) {
19
+ okCount++
20
+ } else {
21
+ groups[entry.group].push({ ...entry, warnings: d.warnings })
22
+ }
23
+ if (r && (r.outcome === OUTCOMES.CLONED || r.packageJsonChanged)) depsMayHaveChanged = true
24
+ }
25
+
26
+ section('Cloned', groups.cloned, chalk.green)
27
+ section('Updated', groups.updated, chalk.cyan)
28
+ section('On branches (left alone)', groups.branches, chalk.blue)
29
+ section('Needs your attention', groups.attention, chalk.yellow)
30
+ section('Housekeeping', groups.housekeeping, chalk.gray)
31
+ section('Errors', groups.errors, chalk.red)
32
+
33
+ if (verbose && plan.ignored.length) {
34
+ console.log(chalk.gray(`\nIgnored ${plan.ignored.length} repos:`))
35
+ plan.ignored.forEach(i => console.log(chalk.gray(` ${i.repoName}: ${i.reason}`)))
36
+ }
37
+
38
+ console.log('')
39
+ console.log(
40
+ `${chalk.green(okCount)} up to date, ${groups.cloned.length} cloned, ${groups.updated.length} updated, ` +
41
+ `${chalk.yellow(groups.attention.length)} need attention, ${chalk.red(groups.errors.length)} errors`
42
+ )
43
+
44
+ if (depsMayHaveChanged) {
45
+ console.log(chalk.yellow('\nDependencies may have changed. Reinstall/relink before running anything.'))
46
+ }
47
+
48
+ return { errorCount: groups.errors.length, attentionCount: groups.attention.length }
49
+ }
50
+
51
+ function classify (d, r) {
52
+ const name = `${d.folder}/${d.repoName}`
53
+
54
+ if (!r) {
55
+ switch (d.reason) {
56
+ case REASONS.UP_TO_DATE: return null
57
+ case REASONS.UNSAFE_STATE: return attention(name, 'Mid-merge/rebase or unreadable status, skipped')
58
+ case REASONS.FOREIGN_ORIGIN: return attention(name, 'origin is not this org, skipped')
59
+ case REASONS.NOT_A_REPO:
60
+ return d.warnings.length
61
+ ? attention(name, 'Folder exists but is not a git repo, cannot clone')
62
+ : housekeeping(name, 'Not a git repo')
63
+ case REASONS.ORPHANED: return housekeeping(name, 'No longer in the org (deleted, archived or renamed)')
64
+ case REASONS.NO_LONGER_QUALIFIES: return housekeeping(name, 'No longer selected for sync')
65
+ case REASONS.DUPLICATE: return housekeeping(name, 'Duplicate clone')
66
+ default: return housekeeping(name, d.reason)
67
+ }
68
+ }
69
+
70
+ const branch = d.local?.branch
71
+ switch (r.outcome) {
72
+ case OUTCOMES.CLONED:
73
+ return { group: 'cloned', name, detail: d.remote.packageJson?.description }
74
+ case OUTCOMES.UPDATED:
75
+ return { group: 'updated', name, detail: `${r.behind} new commit${r.behind === 1 ? '' : 's'}`, commits: r.commits }
76
+ case OUTCOMES.CURRENT:
77
+ if (d.reason === REASONS.ON_BRANCH) return { group: 'branches', name, detail: `on ${branch}, nothing new on ${d.defaultBranch}` }
78
+ if (d.reason === REASONS.DETACHED) return { group: 'branches', name, detail: 'detached HEAD, nothing new' }
79
+ return null
80
+ case OUTCOMES.BEHIND:
81
+ if (d.reason === REASONS.ON_BRANCH) return attention(name, `On ${branch}: ${d.defaultBranch} has ${r.behind} commit(s) you don't`, r.commits)
82
+ if (d.reason === REASONS.DETACHED) return attention(name, `Detached HEAD: ${d.defaultBranch} is ${r.behind} commit(s) ahead`, r.commits)
83
+ return attention(name, `Uncommitted changes on ${d.defaultBranch}: ${r.behind} commit(s) not pulled`, r.commits)
84
+ case OUTCOMES.LOCAL_AHEAD:
85
+ return attention(name, `${r.ahead} unpushed commit(s) on ${d.defaultBranch}`)
86
+ case OUTCOMES.DIVERGED:
87
+ return attention(name, `${d.defaultBranch} has diverged (${r.ahead} ahead, ${r.behind} behind), not pulled`, r.commits)
88
+ case OUTCOMES.BLOCKED_BY_UNTRACKED:
89
+ return attention(name, 'Untracked files would be overwritten, not pulled', r.commits)
90
+ case OUTCOMES.FAILED:
91
+ return { group: 'errors', name, detail: r.error }
92
+ }
93
+ }
94
+
95
+ function section (title, entries, colour) {
96
+ if (!entries.length) return
97
+ console.log('')
98
+ console.log(chalk.underline(`${title} (${entries.length})`))
99
+ for (const e of entries) {
100
+ console.log(` ${colour(e.name)}${e.detail ? chalk.gray(` ${e.detail}`) : ''}`)
101
+ for (const w of e.warnings || []) console.log(chalk.yellow(` ! ${w}`))
102
+ for (const c of e.commits || []) console.log(chalk.gray(` ${c.sha} ${c.subject} (${c.author}, ${c.when})`))
103
+ }
104
+ }
105
+
106
+ function printPlan (plan, { verbose = false } = {}) {
107
+ const groups = { clone: [], fastForward: [], fetchOnly: [], attention: [], housekeeping: [] }
108
+
109
+ let okCount = 0
110
+
111
+ for (const d of plan.decisions) {
112
+ const name = `${d.folder}/${d.repoName}`
113
+ const warnings = d.warnings
114
+
115
+ switch (d.action) {
116
+ case ACTIONS.CLONE:
117
+ groups.clone.push({ name, detail: d.remote.packageJson?.description, warnings })
118
+ break
119
+ case ACTIONS.FAST_FORWARD:
120
+ groups.fastForward.push({ name, warnings })
121
+ break
122
+ case ACTIONS.FETCH_ONLY:
123
+ groups.fetchOnly.push({ name, detail: fetchOnlyDetail(d), warnings })
124
+ break
125
+ default: {
126
+ // NONE decisions: the same wording as the real report
127
+ const entry = classify(d, undefined)
128
+ if (!entry) okCount++
129
+ else groups[entry.group].push({ ...entry, warnings })
130
+ }
131
+ }
132
+ }
133
+
134
+ console.log(chalk.bold('\nDry run: nothing has been changed'))
135
+ section('Would clone', groups.clone, chalk.green)
136
+ section('Would fast-forward', groups.fastForward, chalk.cyan)
137
+ section('Would fetch only (your branch is left alone)', groups.fetchOnly, chalk.yellow)
138
+ section('Needs your attention', groups.attention, chalk.yellow)
139
+ section('Housekeeping', groups.housekeeping, chalk.gray)
140
+
141
+ if (verbose && plan.ignored.length) {
142
+ console.log(chalk.gray(`\nIgnored ${plan.ignored.length} repos:`))
143
+ plan.ignored.forEach(i => console.log(chalk.gray(` ${i.repoName}: ${i.reason}`)))
144
+ }
145
+
146
+ console.log('')
147
+ console.log(
148
+ `${chalk.green(okCount)} up to date, ${groups.clone.length} to clone, ` +
149
+ `${groups.fastForward.length} to fast-forward, ${groups.fetchOnly.length} to fetch`
150
+ )
151
+ }
152
+
153
+ function fetchOnlyDetail (d) {
154
+ switch (d.reason) {
155
+ case REASONS.ON_BRANCH: return `on ${d.local.branch}`
156
+ case REASONS.DETACHED: return 'detached HEAD'
157
+ case REASONS.LOCAL_CHANGES: return `uncommitted changes on ${d.defaultBranch}`
158
+ default: return d.reason
159
+ }
160
+ }
161
+
162
+ function describeResult (r) {
163
+ const d = r.decision
164
+ const base = d.defaultBranch || 'master'
165
+ const branch = d.local?.branch
166
+ const n = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`
167
+
168
+ switch (r.outcome) {
169
+ case OUTCOMES.CLONED:
170
+ return { label: 'Cloned', colour: chalk.green }
171
+ case OUTCOMES.UPDATED:
172
+ return { label: 'Updated', colour: chalk.cyan, detail: `pulled ${n(r.behind, 'commit')}` }
173
+ case OUTCOMES.CURRENT:
174
+ if (d.reason === REASONS.ON_BRANCH) return { label: 'On branch', colour: chalk.blue, detail: `${branch}, nothing new on ${base}` }
175
+ if (d.reason === REASONS.DETACHED) return { label: 'Detached', colour: chalk.blue, detail: `nothing new on ${base}` }
176
+ if (d.reason === REASONS.LOCAL_CHANGES) return { label: 'Up to date', colour: chalk.green, detail: 'has uncommitted changes' }
177
+ return { label: 'Up to date', colour: chalk.green }
178
+ case OUTCOMES.BEHIND:
179
+ if (d.reason === REASONS.ON_BRANCH) return { label: 'On branch', colour: chalk.yellow, detail: `${branch}, ${base} has ${n(r.behind, 'new commit')}` }
180
+ if (d.reason === REASONS.DETACHED) return { label: 'Detached', colour: chalk.yellow, detail: `${base} has ${n(r.behind, 'new commit')}` }
181
+ return { label: 'Not pulled', colour: chalk.yellow, detail: `uncommitted changes, ${base} has ${n(r.behind, 'new commit')}` }
182
+ case OUTCOMES.LOCAL_AHEAD:
183
+ return { label: 'Unpushed', colour: chalk.yellow, detail: `${n(r.ahead, 'local commit')} on ${base}` }
184
+ case OUTCOMES.DIVERGED:
185
+ return { label: 'Diverged', colour: chalk.yellow, detail: `${r.ahead} ahead, ${r.behind} behind, not pulled` }
186
+ case OUTCOMES.BLOCKED_BY_UNTRACKED:
187
+ return { label: 'Not pulled', colour: chalk.yellow, detail: 'untracked files would be overwritten' }
188
+ case OUTCOMES.FAILED:
189
+ return { label: 'Failed', colour: chalk.red, detail: firstLine(r.error) }
190
+ default:
191
+ return { label: r.outcome, colour: chalk.gray }
192
+ }
193
+ }
194
+
195
+ module.exports = { report, printPlan, describeResult }
@@ -0,0 +1,59 @@
1
+ const { execFile } = require('child_process')
2
+
3
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000
4
+
5
+ // Sync runs many git commands in parallel with no one watching their output,
6
+ // so git must fail fast rather than wait for input that will never come.
7
+ function nonInteractiveEnv () {
8
+ const env = {
9
+ ...process.env,
10
+ // Never prompt in the terminal for a username/password
11
+ GIT_TERMINAL_PROMPT: '0'
12
+ }
13
+
14
+ // Never prompt for an SSH passphrase or to accept an unknown host key.
15
+ // Keys loaded in ssh-agent still work. Respect a command the user already set.
16
+ if (!env.GIT_SSH_COMMAND && !env.GIT_SSH) {
17
+ env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
18
+ }
19
+
20
+ return env
21
+ }
22
+
23
+ function git (args, cwd, { timeout = DEFAULT_TIMEOUT_MS } = {}) {
24
+ return new Promise((resolve, reject) => {
25
+ execFile(
26
+ 'git',
27
+ ['-c', 'core.quotepath=false', ...args],
28
+ {
29
+ cwd,
30
+ timeout,
31
+ windowsHide: true,
32
+ maxBuffer: 10 * 1024 * 1024,
33
+ env: nonInteractiveEnv()
34
+ },
35
+ (err, stdout, stderr) => {
36
+ if (err) {
37
+ err.stderr = stderr
38
+ if (err.killed) {
39
+ const limit = timeout >= 1000 ? `${Math.round(timeout / 1000)}s` : `${timeout}ms`
40
+ err.message = `git ${args[0]} timed out after ${limit}`
41
+ }
42
+ return reject(err)
43
+ }
44
+ // trimEnd, not trim: `git status --porcelain` lines can start with a space
45
+ resolve(stdout.trimEnd())
46
+ }
47
+ )
48
+ })
49
+ }
50
+
51
+ async function tryGit (args, cwd, options) {
52
+ try {
53
+ return await git(args, cwd, options)
54
+ } catch (e) {
55
+ return null
56
+ }
57
+ }
58
+
59
+ module.exports = { git, tryGit, DEFAULT_TIMEOUT_MS }
@@ -0,0 +1,17 @@
1
+ module.exports = async function mapLimit (items, limit, fn) {
2
+ // A NaN or 0 limit would start no workers and silently return an array of holes
3
+ if (!Number.isInteger(limit) || limit < 1) {
4
+ throw new RangeError(`mapLimit: limit must be a positive integer, got ${limit}`)
5
+ }
6
+ const results = new Array(items.length)
7
+ let next = 0
8
+ async function worker () {
9
+ while (next < items.length) {
10
+ const i = next++
11
+ results[i] = await fn(items[i], i)
12
+ }
13
+ }
14
+ const workers = Array.from({ length: Math.min(limit, items.length) }, worker)
15
+ await Promise.all(workers)
16
+ return results
17
+ }
@@ -0,0 +1,25 @@
1
+ const GLOB_CHARS = /[*?[\]{}!]/
2
+
3
+ function packageFoldersFromPatterns (patterns) {
4
+ const folders = new Set()
5
+
6
+ for (const raw of patterns) {
7
+ const pattern = raw.trim().replace(/\\/g, '/').replace(/\/+$/, '')
8
+
9
+ // Negations like "!packages/legacy" exclude folders, they don't add one
10
+ if (pattern.startsWith('!')) continue
11
+
12
+ // Only "<folder>/*" can be a clone target: lerna-sync clones to <folder>/<repoName>
13
+ const match = pattern.match(/^(.+)\/\*$/)
14
+ if (!match || GLOB_CHARS.test(match[1])) {
15
+ console.warn(`lerna-sync: ignoring package pattern "${raw}", expected "<folder>/*"`)
16
+ continue
17
+ }
18
+
19
+ folders.add(match[1].replace(/^\.\//, ''))
20
+ }
21
+
22
+ return [...folders]
23
+ }
24
+
25
+ module.exports = packageFoldersFromPatterns
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wmfs/lerna-sync",
3
- "version": "1.37.1",
3
+ "version": "1.39.0",
4
4
  "description": "A package to synchronize distributed GitHub repos inside a Lerna monorepo.",
5
5
  "author": "West Midlands Fire Service",
6
6
  "homepage": "https://github.com/wmfs/lerna-sync#readme",
@@ -22,22 +22,22 @@
22
22
  "directories": {
23
23
  "lib": "lib"
24
24
  },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
25
28
  "dependencies": {
26
- "@octokit/graphql": "5.0.4",
29
+ "@octokit/graphql": "7.1.1",
27
30
  "chalk": "4.1.2",
28
- "dottie": "2.0.6",
29
- "git-repo-info": "2.1.1",
30
31
  "glob": "11.1.0",
31
32
  "humanize-duration": "3.33.0",
32
- "jsonfile": "6.1.0",
33
- "lodash": "4.17.21"
33
+ "jsonfile": "6.1.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "chai": "4.5.0",
37
37
  "codecov": "3.8.3",
38
- "conventional-changelog-conventionalcommits": "10.3.0",
38
+ "conventional-changelog-conventionalcommits": "9.3.1",
39
39
  "cz-conventional-changelog": "3.3.0",
40
- "mocha": "11.8.0",
40
+ "mocha": "12.0.3",
41
41
  "nyc": "18.0.0",
42
42
  "semantic-release": "25.0.9",
43
43
  "standard": "17.1.2",
package/renovate.json CHANGED
@@ -18,6 +18,10 @@
18
18
  "semanticCommitScope": "deps",
19
19
  "labels": ["dependencies"],
20
20
  "reviewers": ["team:tymly-reviewers"]
21
+ },
22
+ {
23
+ "matchPackageNames": ["conventional-changelog-conventionalcommits"],
24
+ "allowedVersions": "<10"
21
25
  }
22
26
  ],
23
27
  "vulnerabilityAlerts": {
@@ -0,0 +1,128 @@
1
+ /* eslint-env mocha */
2
+
3
+ const { expect } = require('chai')
4
+ const { captureConsole } = require('./helpers/fixtures')
5
+ const discover = require('../lib/discover')
6
+
7
+ // A GraphQL node as GitHub returns it
8
+ function node (name, overrides = {}) {
9
+ return {
10
+ name,
11
+ isArchived: false,
12
+ isPrivate: false,
13
+ packageJson: { text: JSON.stringify({ name, keywords: ['tymly'] }), isTruncated: false },
14
+ defaultBranchRef: {
15
+ name: 'master',
16
+ target: {
17
+ oid: `${name}-sha`,
18
+ messageHeadline: `Commit on ${name}`,
19
+ author: { name: 'Someone', date: '2026-01-01T00:00:00Z' }
20
+ }
21
+ },
22
+ ...overrides
23
+ }
24
+ }
25
+
26
+ // Stand-in for the octokit client: serves pages in order and records calls
27
+ function fakeGraphql (pages) {
28
+ const calls = []
29
+ const fn = async (query, variables) => {
30
+ calls.push(variables)
31
+ const index = calls.length - 1
32
+ const nodes = pages[index]
33
+ const hasNextPage = index < pages.length - 1
34
+ return {
35
+ organization: {
36
+ repositories: {
37
+ nodes,
38
+ pageInfo: { hasNextPage, endCursor: hasNextPage ? `cursor-${index + 1}` : null }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ fn.calls = calls
44
+ return fn
45
+ }
46
+
47
+ describe('discover', () => {
48
+ let output
49
+ beforeEach(() => { output = captureConsole() })
50
+ afterEach(() => output.restore())
51
+
52
+ it('maps GitHub nodes to plain remote records', async () => {
53
+ const [repo] = await discover(fakeGraphql([[node('a')]]), 'wmfs')
54
+ expect(repo).to.deep.equal({
55
+ name: 'a',
56
+ isPrivate: false,
57
+ defaultBranch: 'master',
58
+ sha: 'a-sha',
59
+ messageHeadline: 'Commit on a',
60
+ author: { name: 'Someone', date: '2026-01-01T00:00:00Z' },
61
+ packageJson: { name: 'a', keywords: ['tymly'] }
62
+ })
63
+ })
64
+
65
+ it('follows pagination cursors until the last page', async () => {
66
+ const graphql = fakeGraphql([[node('a'), node('b')], [node('c')], [node('d')]])
67
+ const repos = await discover(graphql, 'wmfs')
68
+
69
+ expect(repos.map(r => r.name)).to.deep.equal(['a', 'b', 'c', 'd'])
70
+ expect(graphql.calls).to.deep.equal([
71
+ { org: 'wmfs', after: null },
72
+ { org: 'wmfs', after: 'cursor-1' },
73
+ { org: 'wmfs', after: 'cursor-2' }
74
+ ])
75
+ })
76
+
77
+ it('uses each repo\'s real default branch', async () => {
78
+ const main = node('a')
79
+ main.defaultBranchRef.name = 'main'
80
+ const [repo] = await discover(fakeGraphql([[main]]), 'wmfs')
81
+ expect(repo.defaultBranch).to.equal('main')
82
+ })
83
+
84
+ it('skips archived and empty repos', async () => {
85
+ const repos = await discover(fakeGraphql([[
86
+ node('archived', { isArchived: true }),
87
+ node('empty', { defaultBranchRef: null }),
88
+ node('kept')
89
+ ]]), 'wmfs')
90
+ expect(repos.map(r => r.name)).to.deep.equal(['kept'])
91
+ })
92
+
93
+ it('keeps repos with no package.json, with packageJson null', async () => {
94
+ const [repo] = await discover(fakeGraphql([[node('a', { packageJson: null })]]), 'wmfs')
95
+ expect(repo.packageJson).to.equal(null)
96
+ })
97
+
98
+ it('treats invalid JSON as no package.json, with a warning', async () => {
99
+ const [repo] = await discover(fakeGraphql([[
100
+ node('broken', { packageJson: { text: '{ not json', isTruncated: false } })
101
+ ]]), 'wmfs')
102
+ expect(repo.packageJson).to.equal(null)
103
+ expect(output.text()).to.include('Unable to parse package.json for broken')
104
+ })
105
+
106
+ it('treats a truncated blob as no package.json, with a warning', async () => {
107
+ const [repo] = await discover(fakeGraphql([[
108
+ node('huge', { packageJson: { text: '{"name":', isTruncated: true } })
109
+ ]]), 'wmfs')
110
+ expect(repo.packageJson).to.equal(null)
111
+ expect(output.text()).to.include('truncated')
112
+ })
113
+
114
+ it('returns an empty list for an org with no repos', async () => {
115
+ expect(await discover(fakeGraphql([[]]), 'wmfs')).to.deep.equal([])
116
+ })
117
+
118
+ it('lets GraphQL errors propagate', async () => {
119
+ const failing = async () => { throw new Error('Bad credentials') }
120
+ let error
121
+ try {
122
+ await discover(failing, 'wmfs')
123
+ } catch (e) {
124
+ error = e
125
+ }
126
+ expect(error && error.message).to.equal('Bad credentials')
127
+ })
128
+ })