@wmfs/lerna-sync 1.38.0 → 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.
@@ -65,6 +65,27 @@ jobs:
65
65
  key: v1-deps-{{ checksum "package.json" }}
66
66
  paths:
67
67
  - node_modules
68
+ - run:
69
+ name: get github app token
70
+ command: |
71
+ node -e '
72
+ const crypto = require("crypto");
73
+ const key = Buffer.from(process.env.GH_APP_PRIVATE_KEY_B64, "base64").toString();
74
+ const b64 = o => Buffer.from(JSON.stringify(o)).toString("base64url");
75
+ const now = Math.floor(Date.now() / 1000);
76
+ const unsigned = b64({ alg: "RS256", typ: "JWT" }) + "." +
77
+ b64({ iat: now - 60, exp: now + 540, iss: process.env.GH_APP_ID });
78
+ const jwt = unsigned + "." + crypto.sign("RSA-SHA256", Buffer.from(unsigned), key).toString("base64url");
79
+ const headers = { Authorization: `Bearer ${jwt}`, Accept: "application/vnd.github+json" };
80
+ const repo = `${process.env.CIRCLE_PROJECT_USERNAME}/${process.env.CIRCLE_PROJECT_REPONAME}`;
81
+ (async () => {
82
+ const inst = await (await fetch(`https://api.github.com/repos/${repo}/installation`, { headers })).json();
83
+ const res = await (await fetch(`https://api.github.com/app/installations/${inst.id}/access_tokens`, { method: "POST", headers })).json();
84
+ if (!res.token) { console.error(inst, res); process.exit(1); }
85
+ console.log(`export GH_TOKEN=${res.token}`);
86
+ console.log(`export GIT_CREDENTIALS=x-access-token:${res.token}`);
87
+ })();
88
+ ' >> "$BASH_ENV"
68
89
  - run:
69
90
  name: release
70
91
  command: npx semantic-release
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [1.39.0](https://github.com/wmfs/lerna-sync/compare/v1.38.0...v1.39.0) (2026-10-22)
2
+
3
+ ### New Feature(s) :rocket:
4
+
5
+ * [sc-33783] Improve Lerna Sync scalability ([#222](https://github.com/wmfs/lerna-sync/issues/222)) ([74ee189](https://github.com/wmfs/lerna-sync/commit/74ee189080c367190ae28c7c405158c310c94b14))
6
+ * try new token approach ([#225](https://github.com/wmfs/lerna-sync/issues/225)) ([bff962d](https://github.com/wmfs/lerna-sync/commit/bff962d8ebdced954dd10833dc2e89edb63c7326))
7
+
8
+ ### Build system dependencies :hammer:
9
+
10
+ * **deps-dev:** update dependency mocha to v12.0.3 ([#224](https://github.com/wmfs/lerna-sync/issues/224)) ([c4ab8d3](https://github.com/wmfs/lerna-sync/commit/c4ab8d3f7adffd28be01616edc71422687a232f8))
11
+
12
+ ### CICD Configuration Changes :construction_worker:
13
+
14
+ * new step in config.yml ([76154e6](https://github.com/wmfs/lerna-sync/commit/76154e6c6962dcd938d7d3c2e94019849251fe3a))
15
+
1
16
  ## [1.38.0](https://github.com/wmfs/lerna-sync/compare/v1.37.1...v1.38.0) (2026-09-30)
2
17
 
3
18
  ### Bug Fix(es) :bug:
package/README.md CHANGED
@@ -21,99 +21,187 @@ A package to synchronize distributed GitHub repos inside a Lerna monorepo.
21
21
 
22
22
  **This is the situation *lerna-sync* is designed to help with.** :smiley:
23
23
 
24
- ## Install Lerna
24
+ ## How it works
25
25
 
26
- You'll need to have [Node.js](https://nodejs.org/en/download/) installed and [Lerna](https://github.com/lerna/lerna) available **globally**:
26
+ You keep an **empty husk** of a monorepo: a `lerna.json` and a set of empty package directories. Our [tymly](https://github.com/wmfs/tymly) repo is an example. Each time you sync, lerna-sync:
27
+
28
+ 1. **Discovers** every repo in your GitHub organization with one paginated GraphQL query, collecting each repo's default branch, latest commit and `package.json`. Archived repos and forks are skipped.
29
+ 2. **Inspects** the directories already in your monorepo: which are git repos, which branch is checked out, and whether there are local changes or a merge or rebase in progress.
30
+ 3. **Plans** what to do with every repo, using your [routing function](#routing-function) to decide which repos belong in the monorepo and where.
31
+ 4. **Executes** the plan, running git in parallel, and prints a summary of what happened and what needs your attention.
32
+
33
+ ### What happens to each repo
34
+
35
+ lerna-sync never creates merge commits and never touches a branch you're working on.
36
+
37
+ | Local state | What lerna-sync does |
38
+ | ----------- | -------------------- |
39
+ | Not cloned yet | Clones it into the directory the routing function chose |
40
+ | On the default branch, clean | Fetches, then fast-forwards (`git merge --ff-only`) |
41
+ | On another branch | Fetches only. Your branch and working tree are left alone, and the report lists any new commits on the default branch for you to merge or rebase yourself. Your local default branch is fast-forwarded in the background when that's safe. |
42
+ | Detached HEAD | Fetches only, as above |
43
+ | Uncommitted changes on the default branch | Fetches only, and reports how far behind it is |
44
+ | Unpushed commits, or diverged from GitHub | Fetches only, and reports it |
45
+ | Untracked files that an update would overwrite | Doesn't update, and reports it |
46
+ | Mid-merge or mid-rebase | Leaves it alone, and reports it |
47
+ | A directory that isn't a git repo | Leaves it alone. Never clones over it. |
48
+ | `origin` points at a different owner (e.g. a personal fork) | Leaves it alone, and reports it |
49
+ | No longer in the org, or no longer selected by the routing function | Leaves it alone, and lists it under *Housekeeping* |
50
+ | In a different directory from the one the routing function now chooses | Updates it as normal, with a warning. Never moves it. |
51
+
52
+ Each repo's **default branch is read from GitHub**, so repos using `main` work as well as those using `master`.
53
+
54
+ ## Requirements
55
+
56
+ * [Node.js](https://nodejs.org/en/download/) 20 or later.
57
+ * [Git](https://git-scm.com/downloads) on your `PATH`.
58
+ * A **[GitHub access token](https://github.com/settings/tokens)** that can read your organization's repos (see [Credentials](#credentials)).
59
+
60
+ ## Installation
27
61
 
28
62
  ``` bash
29
- npm install lerna -g
63
+ npm install @wmfs/lerna-sync --save
30
64
  ```
31
65
 
32
- ## The monorepo husk
66
+ ## Usage
33
67
 
34
- Create an **empty husk** of a Lerna Monorepo (our [tymly](https://github.com/wmfs/tymly) repo is an example).
68
+ ``` javascript
69
+ const path = require('path')
70
+ const LernaSync = require('@wmfs/lerna-sync')
71
+
72
+ async function main () {
73
+ const lernaSync = new LernaSync({
74
+ monorepoPath: path.resolve(__dirname, '..'),
75
+ gitHubToken: process.env.MY_GITHUB_TOKEN,
76
+ gitHubOrgName: 'wmfs',
77
+ lernaPackageRouterFunction: routePackage
78
+ })
79
+
80
+ const { errorCount } = await lernaSync.sync()
81
+ if (errorCount > 0) process.exitCode = 1
82
+ }
35
83
 
36
- *Some things:*
84
+ main().catch(err => {
85
+ console.error(err)
86
+ process.exitCode = 1
87
+ })
88
+ ```
37
89
 
38
- * Note that the directories you'd ordinarily expect Lerna packages to be living inside, are empty.
39
- * The [lerna.json](https://github.com/wmfs/tymly/blob/master/lerna.json) file is pretty basic stuff.
90
+ See [tymly/lib/sync.js](https://github.com/wmfs/tymly/blob/master/lib/sync.js) for a complete example, including command-line flags.
40
91
 
41
- This is the the empty husk of your monorepo, it should be committed and pretty much forgotten about after this point.
92
+ ### Options
42
93
 
43
- ## GitHub Credentials
94
+ | Option | Required | Default | Notes |
95
+ | ------ | -------- | ------- | ----- |
96
+ | `monorepoPath` | Yes | | Path to the monorepo husk: the directory containing `lerna.json`. |
97
+ | `gitHubToken` | Yes | | Token used to query the GitHub API. |
98
+ | `gitHubOrgName` | Yes | | The GitHub organization to sync from, e.g. `wmfs`. |
99
+ | `lernaPackageRouterFunction` | Yes | | Decides which repos to sync and where. See [Routing function](#routing-function). |
100
+ | `dryRun` | No | `false` | Plan and print what would happen, without running any git commands that change anything. |
101
+ | `verbose` | No | `false` | Also list every repo that was ignored, with the reason. |
102
+ | `concurrency` | No | `8` when inspecting, `6` for git network operations | How many repos to work on at once. Lower it on slow disks or connections. |
103
+ | `cloneUrl` | No | `https://github.com/<org>/<repo>.git` | `(orgName, repoName) => url`. Use it to clone over SSH, e.g. `` (org, name) => `git@github.com:${org}/${name}.git` ``. |
104
+ | `updateLocalDefaultBranch` | No | `true` | When you're on another branch, also fast-forward your local default branch if it can be done safely. |
44
105
 
45
- Lerna-sync will take-on the work to clone/pull your separate GitHub repos and keep them in-sync within your monorepo.
106
+ ### Return value
46
107
 
47
- **You'll need to provide some GitHub credentials to hand, namely:**
108
+ `sync()` resolves with:
48
109
 
49
- * Your GitHub **username**.
50
- * A **[GitHub Access Token](https://github.com/settings/tokens)** associated with the provided **username**. Giving your token a description of "*Lerna Sync Access*" will be fine. This is necessary to avoid certain rate-limits and access private repos. Remember to keep tokens private and treat them as if they were passwords.
51
- * A name of a GitHub **organization** that your Lerna packages will be synchronized with (for example `wmfs`).
110
+ ``` javascript
111
+ {
112
+ plan, // every decision lerna-sync made, with its reason
113
+ results, // what happened for each repo that git was run on (empty on a dry run)
114
+ errorCount, // repos where git failed
115
+ attentionCount // repos that need you to look at them
116
+ }
117
+ ```
52
118
 
119
+ Use `errorCount` to set your script's exit code.
53
120
 
54
121
  ## Routing function
55
122
 
56
- You may have many GitHub repos in the organization you specify that shouldn't come anywhere near your monorepo.
57
- Also, you might like to take advantage of Lerna's support for multiple-package directories to help structure things a bit better.
123
+ Your organization probably has repos that shouldn't come anywhere near your monorepo. You might also want to use Lerna's support for multiple package directories to give things some structure.
58
124
 
59
- In **lerna-sync** this filtering/routing can be achieved via a simple Javascript function. Here's an example:
125
+ The routing function handles both. lerna-sync calls it once for each repo in the organization that has a valid `package.json`, passing that `package.json` as a parsed object. Repos with no `package.json` are skipped without calling it.
126
+
127
+ It should return one of:
128
+
129
+ * **A directory name**, such as `'plugins'`, to sync the repo into that directory. It must match an entry in the `packages` array of the monorepo's `lerna.json` (`'plugins'` for `"plugins/*"`). If it doesn't, the repo is ignored and the reason says so.
130
+ * **`{ skip: 'reason' }`** to leave the repo out and say why. The reason appears in the report, which helps when someone expects a repo that isn't there.
131
+ * **`null` or `undefined`** to leave the repo out silently. Use this for repos that are nothing to do with the monorepo.
132
+
133
+ If the function throws, that repo is ignored with the error as its reason, and the rest of the sync carries on.
60
134
 
61
135
  ``` javascript
62
- // Here we use look at the keywords array in the repo's package.json
63
- // to decide which monorepo package directory (if any) it should belong.
64
-
65
- function (gitHubPackageObj) {
66
- // [string-in-keywords, monorepo-directory-name]
67
- const keywordToPackageMap = [
68
- ['package', 'packages'],
69
- ['plugin', 'plugins'],
70
- ['blueprint', 'blueprints'],
71
- ['cardscript', 'cardscript'],
72
- ['app', 'apps']
73
- ]
74
- if (gitHubPackageObj.hasOwnProperty('keywords') && gitHubPackageObj.keywords.indexOf('tymly') !== -1) {
75
- let lernaPackageName = null
76
- const keywords = gitHubPackageObj.keywords
77
- keywordToPackageMap.forEach(
78
- tuple => {
79
- if (keywords.indexOf(tuple[0]) !== -1) {
80
- lernaPackageName = tuple[1]
81
- }
82
- }
83
- )
84
- return lernaPackageName
136
+ // Use keywords in each repo's package.json to pick its directory.
137
+ // The first matching keyword wins.
138
+ const KEYWORD_FOLDERS = [
139
+ ['blueprint', 'blueprints'],
140
+ ['plugin', 'plugins'],
141
+ ['package', 'packages']
142
+ ]
143
+
144
+ function routePackage (pkg) {
145
+ const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : []
146
+ if (!keywords.includes('tymly')) return null
147
+
148
+ if (pkg.config && pkg.config.tymly && pkg.config.tymly.sync === false) {
149
+ return { skip: 'Opted out (config.tymly.sync = false)' }
85
150
  }
151
+
152
+ const match = KEYWORD_FOLDERS.find(([keyword]) => keywords.includes(keyword))
153
+ return match ? match[1] : { skip: 'No folder keyword' }
86
154
  }
87
155
  ```
88
156
 
89
- Lerna-sync will call this function with the `package.json` content (already parsed as a JavaScript object) of each repo in the source GitHub organization.
157
+ ## The report
158
+
159
+ As each repo finishes, lerna-sync prints a progress line, then a summary grouped by what you need to do:
160
+
161
+ ```
162
+ Downloading repo information from https://github.com/wmfs
163
+ Cloned plugins/tymly-rbac-plugin
164
+ On branch plugins/tymly-pg-plugin (feature/connection-pool, master has 1 new commit)
165
+ Updated plugins/tymly-core (pulled 1 commit)
166
+
167
+ Cloned (1)
168
+ plugins/tymly-rbac-plugin Plugin for Tymly
169
+
170
+ Updated (1)
171
+ plugins/tymly-core 1 new commit
172
+ ff3037f fix: handle missing state machine (Jane Smith, 2 hours ago)
90
173
 
91
- * Any repos with no `package.json` will be skipped entirely due to not being a suitable candidate for a Lerna Monorepo.
174
+ Needs your attention (1)
175
+ plugins/tymly-pg-plugin On feature/connection-pool: master has 1 commit(s) you don't
176
+ 8ccdfe1 feat: support schema search paths (Jane Smith, 3 hours ago)
92
177
 
93
- The function should return the name of a directory registered in the `packages` array in the monorepo's [`lerna.json`](https://github.com/wmfs/tymly/blob/master/lerna.json) file.
178
+ 1 up to date, 1 cloned, 1 updated, 1 need attention, 0 errors
94
179
 
95
- * If no value is returned by this function, then **lerna-sync** will know not to route the repo to any package directory (i.e. it's filtered out).
180
+ Dependencies may have changed. Reinstall/relink before running anything.
181
+
182
+ Done (14 seconds).
183
+ ```
96
184
 
97
- -----
185
+ The possible sections are *Cloned*, *Updated*, *On branches (left alone)*, *Needs your attention*, *Housekeeping* and *Errors*. The reminder about dependencies only appears when a repo was cloned or an update changed a `package.json`.
98
186
 
99
- WIP
187
+ ## Credentials
100
188
 
101
- # Environment variables
189
+ lerna-sync uses credentials in two separate places:
102
190
 
103
- | Variable | Notes |
104
- | -------- | ----- |
105
- | `LERNA_SYNC_MONOREPO_PATH` | Should be set to a directory where a `lerna.json` can be found. |
106
- | `LERNA_SYNC_GITHUB_TOKEN` | Generate a new GitHub token [here](https://github.com/settings/tokens), a description of "lerna-sync connection" will do.
107
- | `LERNA_SYNC_GITHUB_ORG` | Name of the GitHub organization holding all your repos. |
108
- | `LERNA_SYNC_GITHUB_USER` | Your GitHub username (used in conjunction with `LERNA_SYNC_GITHUB_TOKEN` to fetch/pull repos. |
191
+ * **The GitHub API** (discovering repos) uses `gitHubToken`. A classic token needs the **repo** scope to see private repos. A fine-grained token needs read access to **Contents** and **Metadata** for the organization's repos. Without that, private repos are silently missing from the results.
192
+ * **Git** (clone and fetch) uses **your own git credentials**, not the token. For HTTPS, set up a credential helper such as [Git Credential Manager](https://github.com/git-ecosystem/git-credential-manager) or run `gh auth setup-git`. For SSH, use the `cloneUrl` option and make sure your key is loaded in `ssh-agent`.
109
193
 
110
- # Installation
194
+ Because many git commands run in parallel with their output captured, lerna-sync runs git non-interactively. A missing credential makes that repo fail with an error instead of waiting for a password prompt nobody can see. Each git command also has a 5-minute timeout. A credential manager that opens its own sign-in window can still do so.
195
+
196
+ ## Development
111
197
 
112
198
  ``` bash
113
- npm install @wmfs/lerna-sync --save
199
+ npm test # mocha with coverage (nyc)
200
+ npm run lint # standard
114
201
  ```
115
202
 
116
- ## <a name="Usage"></a> Usage
203
+ The tests need git on your `PATH` but no network access or GitHub token. Tests that exercise git create throwaway repos in your temp directory, using local bare repos as the "GitHub" origin, and ignore your global git config.
204
+
205
+ ## License
117
206
 
118
- ## <a name='license'></a>License
119
- [MIT](https://github.com/wmfs/lerna-sync/blob/master/LICENSE)
207
+ [MIT](https://github.com/wmfs/lerna-sync/blob/master/LICENSE)
@@ -0,0 +1,52 @@
1
+ const query = require('./github-graphql-query')
2
+ const chalk = require('chalk')
3
+
4
+ function parsePackageJson (repoName, blob) {
5
+ if (!blob || typeof blob.text !== 'string') return null
6
+ if (blob.isTruncated) {
7
+ console.warn(chalk.yellow(`package.json for ${repoName} was truncated, skipping`))
8
+ return null
9
+ }
10
+ try {
11
+ return JSON.parse(blob.text)
12
+ } catch (e) {
13
+ console.warn(chalk.yellow(`Unable to parse package.json for ${repoName}`))
14
+ return null
15
+ }
16
+ }
17
+
18
+ module.exports = async function discover (graphql, org) {
19
+ const repos = []
20
+ let after = null
21
+
22
+ console.log(chalk.gray(`Downloading repo information from https://github.com/${org}`))
23
+
24
+ do {
25
+ const { organization } = await graphql(query.orgRepos, { org, after })
26
+ const { nodes, pageInfo } = organization.repositories
27
+
28
+ for (const node of nodes) {
29
+ if (node.isArchived) continue // belt and braces
30
+
31
+ const branch = node.defaultBranchRef
32
+ if (!branch) continue // empty repo, nothing to sync
33
+
34
+ const commit = branch.target
35
+ const blob = node.packageJson
36
+
37
+ repos.push({
38
+ name: node.name,
39
+ isPrivate: node.isPrivate,
40
+ defaultBranch: branch.name,
41
+ sha: commit.oid,
42
+ messageHeadline: commit.messageHeadline,
43
+ author: commit.author,
44
+ packageJson: parsePackageJson(node.name, blob)
45
+ })
46
+ }
47
+
48
+ after = pageInfo.hasNextPage ? pageInfo.endCursor : null
49
+ } while (after)
50
+
51
+ return repos
52
+ }
@@ -0,0 +1,141 @@
1
+ const path = require('path')
2
+ const { git, tryGit } = require('./utils/git')
3
+ const mapLimit = require('./utils/map-limit')
4
+ const { ACTIONS, REASONS } = require('./plan-sync')
5
+
6
+ const OUTCOMES = Object.freeze({
7
+ CLONED: 'CLONED',
8
+ UPDATED: 'UPDATED',
9
+ CURRENT: 'CURRENT',
10
+ BEHIND: 'BEHIND', // fetched only; the default branch has commits HEAD doesn't
11
+ LOCAL_AHEAD: 'LOCAL_AHEAD', // unpushed commits on the default branch
12
+ DIVERGED: 'DIVERGED',
13
+ BLOCKED_BY_UNTRACKED: 'BLOCKED_BY_UNTRACKED',
14
+ FAILED: 'FAILED'
15
+ })
16
+
17
+ const HANDLERS = {
18
+ [ACTIONS.CLONE]: cloneRepo,
19
+ [ACTIONS.FAST_FORWARD]: fastForward,
20
+ [ACTIONS.FETCH_ONLY]: fetchOnly
21
+ }
22
+
23
+ async function executePlan (plan, options) {
24
+ const runnable = plan.decisions.filter(d => d.action !== ACTIONS.NONE)
25
+
26
+ return mapLimit(runnable, options.concurrency || 6, async decision => {
27
+ let result
28
+ try {
29
+ result = { decision, ...(await HANDLERS[decision.action](decision, options)) }
30
+ } catch (err) {
31
+ result = { decision, outcome: OUTCOMES.FAILED, error: cleanError(err) }
32
+ }
33
+ if (options.onResult) options.onResult(result) // progress line as each one finishes
34
+ return result
35
+ })
36
+ }
37
+
38
+ async function cloneRepo (d, { monorepoPath, gitHubOrgName, cloneUrl }) {
39
+ const dest = path.join(monorepoPath, ...d.folder.split('/'), d.remote.name)
40
+ const url = cloneUrl
41
+ ? cloneUrl(gitHubOrgName, d.remote.name) // e.g. allow SSH: git@github.com:wmfs/x.git
42
+ : `https://github.com/${gitHubOrgName}/${d.remote.name}.git`
43
+
44
+ await git(['clone', '--quiet', url, dest], monorepoPath)
45
+ return { outcome: OUTCOMES.CLONED }
46
+ }
47
+
48
+ async function fastForward (d) {
49
+ const cwd = d.local.path
50
+ const upstream = `origin/${d.defaultBranch}`
51
+
52
+ await gitFetch(cwd)
53
+ const { ahead, behind } = await aheadBehind(cwd, upstream)
54
+
55
+ if (behind === 0) {
56
+ return { outcome: ahead > 0 ? OUTCOMES.LOCAL_AHEAD : OUTCOMES.CURRENT, ahead, behind }
57
+ }
58
+ if (ahead > 0) {
59
+ return { outcome: OUTCOMES.DIVERGED, ahead, behind, commits: await incoming(cwd, upstream) }
60
+ }
61
+
62
+ const commits = await incoming(cwd, upstream)
63
+ const before = await git(['rev-parse', 'HEAD'], cwd)
64
+
65
+ try {
66
+ await git(['merge', '--ff-only', '--quiet', upstream], cwd)
67
+ } catch (err) {
68
+ if (/untracked working tree files would be overwritten/i.test(err.stderr || '')) {
69
+ return { outcome: OUTCOMES.BLOCKED_BY_UNTRACKED, ahead, behind, commits }
70
+ }
71
+ throw err
72
+ }
73
+
74
+ const changed = await git(['diff', '--name-only', before, 'HEAD', '--', 'package.json'], cwd)
75
+ return { outcome: OUTCOMES.UPDATED, ahead, behind, commits, packageJsonChanged: changed.length > 0 }
76
+ }
77
+
78
+ async function fetchOnly (d, { updateLocalDefaultBranch = true }) {
79
+ const cwd = d.local.path
80
+ const upstream = `origin/${d.defaultBranch}`
81
+
82
+ await gitFetch(cwd)
83
+
84
+ // On a feature branch or detached HEAD, local master isn't checked out, so it can be
85
+ // fast-forwarded without touching the working tree. Skip it for LOCAL_CHANGES:
86
+ // there, master *is* checked out.
87
+ if (updateLocalDefaultBranch && d.reason !== REASONS.LOCAL_CHANGES) {
88
+ await fastForwardRef(cwd, d.defaultBranch, upstream)
89
+ }
90
+
91
+ const { ahead, behind } = await aheadBehind(cwd, upstream)
92
+ return {
93
+ outcome: behind > 0 ? OUTCOMES.BEHIND : OUTCOMES.CURRENT,
94
+ ahead,
95
+ behind,
96
+ commits: behind > 0 ? await incoming(cwd, upstream) : []
97
+ }
98
+ }
99
+
100
+ // Helpers
101
+
102
+ const gitFetch = cwd => git(['fetch', '--quiet', '--prune', 'origin'], cwd)
103
+
104
+ async function aheadBehind (cwd, upstream) {
105
+ const out = await git(['rev-list', '--left-right', '--count', `HEAD...${upstream}`], cwd)
106
+ const [ahead, behind] = out.split(/\s+/).map(Number)
107
+ return { ahead, behind }
108
+ }
109
+
110
+ async function incoming (cwd, upstream, limit = 10) {
111
+ const out = await git(
112
+ ['log', `--max-count=${limit}`, '--format=%h%x09%an%x09%ar%x09%s', `HEAD..${upstream}`],
113
+ cwd
114
+ )
115
+ if (!out) return []
116
+ return out.split('\n').map(line => {
117
+ const [sha, author, when, subject] = line.split('\t')
118
+ return { sha, author, when, subject }
119
+ })
120
+ }
121
+
122
+ async function fastForwardRef (cwd, branch, upstream) {
123
+ const ref = `refs/heads/${branch}`
124
+ const current = await tryGit(['rev-parse', '--verify', '--quiet', ref], cwd)
125
+ if (!current) return // no local master branch, nothing to update
126
+
127
+ // Exit code 0 only if local master is an ancestor of origin/master, i.e. a fast-forward
128
+ const isFastForward = await tryGit(['merge-base', '--is-ancestor', ref, upstream], cwd)
129
+ if (isFastForward === null) return
130
+
131
+ // Passing the old SHA makes this a compare-and-swap
132
+ await tryGit(['update-ref', ref, upstream, current], cwd)
133
+ }
134
+
135
+ function cleanError (err) {
136
+ if (err.killed) return err.message // e.g. "git fetch timed out after 300s"
137
+ const text = (err.stderr || err.message || String(err)).trim()
138
+ return text.split('\n').slice(-3).join('\n')
139
+ }
140
+
141
+ module.exports = { executePlan, OUTCOMES }
@@ -1,41 +1,44 @@
1
- module.exports = `
2
- query myOrgRepos($queryString: String!, $afterValue: String) {
3
- search(query: $queryString, type: REPOSITORY, first: 100, after: $afterValue) {
4
- pageInfo {
5
- startCursor
6
- hasNextPage
7
- endCursor
1
+ module.exports = {
2
+ orgRepos: `
3
+ query orgRepos($org: String!, $after: String) {
4
+ organization(login: $org) {
5
+ repositories(
6
+ first: 100
7
+ after: $after
8
+ isFork: false
9
+ isArchived: false
10
+ orderBy: { field: NAME, direction: ASC }
11
+ ) {
12
+ pageInfo {
13
+ hasNextPage
14
+ endCursor
15
+ }
16
+ nodes {
17
+ name
18
+ isArchived
19
+ isPrivate
20
+ packageJson: object(expression: "HEAD:package.json") {
21
+ ... on Blob {
22
+ text
23
+ isTruncated
24
+ }
8
25
  }
9
- edges {
10
- node {
11
- ... on Repository {
12
- name
13
- object(expression: "master:package.json") {
14
- ... on Blob {
15
- text
16
- }
17
- }
18
- ref(qualifiedName: "master") {
19
- target {
20
- ... on Commit {
21
- history(first: 1) {
22
- edges {
23
- node {
24
- oid
25
- messageHeadline,
26
- author {
27
- name
28
- date
29
- }
30
- }
31
- }
32
- }
33
- }
34
- }
35
- }
36
- }
26
+ defaultBranchRef {
27
+ name
28
+ target {
29
+ ... on Commit {
30
+ oid
31
+ messageHeadline
32
+ author {
33
+ name
34
+ date
35
+ }
37
36
  }
37
+ }
38
38
  }
39
+ }
39
40
  }
41
+ }
40
42
  }
41
- `
43
+ `
44
+ }