Repository navigation
deps: report script and refresh skill for recurring dependency updates #1440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
0d65798
deps: a report script and a refresh skill for recurring dependency up…
Stvad 65b8061
deps-report: name every same-major target; refuse a failed scan
Stvad 8565226
deps-report: take the inventory from the lockfile, not pnpm outdated
Stvad 7649067
deps-report: read every package's history; say why an Action lookup f…
Stvad 1cb44b8
Merge remote-tracking branch 'origin/master' into claude/dependencies…
Stvad bed75e2
Merge remote-tracking branch 'origin/master' into claude/dependencies…
Stvad 8b47e6a
deps-refresh skill: React is an ordinary dependency now
Stvad 1f2bd6b
deps: @types/better-sqlite3 9.6.0
Stvad b5a0290
deps-refresh skill: esm.sh pins belong to extensions, not the refresh
Stvad 8c36e12
deps-report: per-project bump commands; skip deprecated and unpublish…
Stvad 54fa555
deps-refresh skill: baseline, flake and hold-on-failure rules; extens…
Stvad File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| --- | ||
| name: deps-refresh | ||
| description: Refresh this repo's dependencies to the newest versions its release cooldown allows, and open a PR. Use for the scheduled dependency update, or when asked to update / upgrade / bump dependencies or run a dependency audit. Covers the same-major refresh, the override floors, the scaffold pins, GitHub Actions, the standalone extension install, and when to take or hold a major. | ||
| --- | ||
|
|
||
| # Dependency refresh | ||
|
|
||
| The output is one PR with one commit per concern. It takes everything safe under the cooldown and names everything it holds back, with the reason. Never merge it, and never push to master. | ||
|
|
||
| ## Ground rules | ||
|
|
||
| - `minimumReleaseAge` in `pnpm-workspace.yaml` (7 days) governs every resolution. | ||
| - Never lower it, and never add a `minimumReleaseAgeExclude` to pull something in sooner. | ||
| - A security fix still inside the cooldown goes in the PR's Audit section with the date it clears. Whether to make an exclusion is Vlad's call. | ||
| - Start from an up-to-date master on a fresh branch, with a clean tree. | ||
| - Snapshot first: | ||
| - Run `node scripts/deps-report.mjs`. Run it again with `--cwd agent-extensions/social-media-publisher`, a standalone install with its own lockfile. | ||
| - Record `pnpm audit` (counts by severity) and the test count from the last `pnpm run check`. | ||
| - The PR compares against these. | ||
|
|
||
| ## 1. Same-major refresh (first commit) | ||
|
|
||
| 1. Run the two commands the report prints. | ||
| - `pnpm up -r --latest <names>` covers only packages whose newest eligible version stays in their major. `--latest` also moves exact pins. | ||
| - Then `pnpm up -r` updates everything in range, transitive dependencies included. | ||
| 2. Set each `overrides` entry in `pnpm-workspace.yaml` to the specifier its direct dependency now carries. A lower override floor makes the gate's `pnpm install --frozen-lockfile` fail with "specifiers in the lockfile don't match". | ||
| 3. Mirror the toolchain pins in `packages/agent-cli/src/scaffold.ts`. The scaffolded extension's devDependencies follow the app's. | ||
| 4. React: if `vite-plugins/reactImportMapMode.ts` exists, production loads React from esm.sh through SRI-pinned URLs. Those URLs live in that file, `index.html` and `minimal-editor.html`. | ||
| - In that case keep `react`/`react-dom` at the version those URLs pin, and list them as held. | ||
| - Once that file is gone, React is an ordinary dependency. | ||
| 5. In the standalone install, run `pnpm -C agent-extensions/social-media-publisher up`. CI installs it with `--frozen-lockfile`. | ||
| 6. Remove a direct dependency nothing references instead of bumping it. | ||
| - Check with `git grep` for its imports, config keys, and CSS `@import`/`@plugin`. | ||
| - An `@types/*` package counts as used when its runtime package is. | ||
|
|
||
| ## 2. Majors (one commit each, or held) | ||
|
|
||
| For each row under "Eligible majors", read the release notes or migration guide from the current version to the target. Grep this repo for every API, option and config key the breaking changes touch. | ||
|
|
||
| - **Take it** only when all three hold: | ||
| - it is dev, test or build tooling; | ||
| - the breaking changes miss how this repo uses it, or the fix is small and mechanical; | ||
| - the gate and the build pass. | ||
|
|
||
| The commit message says what changed and what was checked. | ||
| - **Hold it, always**, when it can change stored data or how stored user content runs. Name it in the PR with what a dedicated PR would need: | ||
| - `@powersync/*`, `@journeyapps/wa-sqlite` and `better-sqlite3`: the local DB format and core schema migrations. | ||
| - `@babel/standalone`: it compiles stored user extensions, so an output change breaks them on recompile. | ||
| - Crypto (`@noble/*`, `@scure/*`), and `@supabase/*`. | ||
| - **Hold it** when a peer range blocks it. Check the peer each run instead of assuming it still blocks. | ||
| - Example: typescript-eslint's `typescript` peer. `typescript` stays on the newest version that peer allows, and the native compiler is a separate `typescript-native` alias. | ||
| - **Hold it** when you could not verify a behaviour change. | ||
|
|
||
| ## 3. GitHub Actions and pnpm | ||
|
|
||
| - For each action behind its latest major (see the report table), read the breaking changes and bump the `uses:` refs across `.github/workflows/`. | ||
| - Leave a SHA-pinned ref alone unless a comment beside it says which tag it tracks. | ||
| - `packageManager`: a pnpm patch or minor goes in the first commit. A pnpm major changes installs for every worktree and agent, so hold it for its own PR. | ||
|
|
||
| ## 4. Verify (all of it, before the PR) | ||
|
|
||
| - `pnpm install --frozen-lockfile` passes. | ||
| - `pnpm run check` passes. Run it in the background and read its last line, `check: PASS`, not a pipe's exit code. The test count must not drop below the snapshot. | ||
| - `pnpm build` passes (it includes `check-dist-exports`). | ||
| - The extension checks CI runs pass: `pnpm agent types --force`, then each `agent-extensions/<name>` package's `check` script. | ||
| - Run `pnpm audit` again and compare it with the snapshot. | ||
|
|
||
| ## 5. The PR | ||
|
|
||
| - Title it `deps: refresh <YYYY-MM-DD>`. | ||
| - Body sections: | ||
| - **Changes**, per commit. | ||
| - **Majors taken**, with what was verified. | ||
| - **Held**: package, target, reason. | ||
| - **Audit**: before → after. For anything left, give the date its fix clears the cooldown and whether the shipped app can reach it. | ||
| - **Verification**, with counts. | ||
| - Paste the report's tables as the appendix. | ||
| - Published text carries no local paths and no workspace or private names. | ||
|
|
||
| ## In a cloud session | ||
|
|
||
| - Read `docs/cloud-sessions.md` first. Don't run `bd`; follow-ups go in the PR body. | ||
| - Use the Node major in `.nvmrc`. Node 25+ ships no corepack, so install pnpm at the `packageManager` version with `npm i -g pnpm@<version>` if it's missing. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,7 @@ | ||
| # Root marker: this extension installs standalone (it is outside the repo's | ||
| # workspace on purpose — see agent-extensions/AGENTS.md). Without this file pnpm climbs to the | ||
| # repo root's pnpm-workspace.yaml and installs the wrong project. | ||
|
|
||
| # The repo's release cooldown (see the root pnpm-workspace.yaml); this | ||
| # standalone install doesn't inherit it. | ||
| minimumReleaseAge: 10080 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| // Hand-written declarations for deps-report.mjs (runtime must stay plain | ||
| // node-runnable JS — the skill runs it with no loader). | ||
| export interface VersionPick { | ||
| version: string | ||
| published: string | ||
| eligibleAt: string | ||
| } | ||
| export interface PackageRow { | ||
| name: string | ||
| current: string | ||
| prerelease: boolean | ||
| sameMajor: VersionPick | null | ||
| major: VersionPick | null | ||
| held: VersionPick | null | ||
| } | ||
| export declare const classifyPackage: ( | ||
| pkg: {name: string; current: string}, | ||
| times: Record<string, string>, | ||
| policy: {now: number; cooldownMs: number}, | ||
| ) => PackageRow | ||
| export declare const latestUpCommand: (rows: Pick<PackageRow, 'name' | 'sameMajor' | 'major'>[]) => string | null | ||
| export declare const parseWorkflowUses: (text: string) => {action: string; ref: string}[] | ||
| export declare const parseEsmShPins: (text: string) => {name: string; version: string}[] | ||
| export declare const actionRefMajor: (ref: string) => number | null |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,234 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * Dependency report — `node scripts/deps-report.mjs [--cwd <dir>]`. | ||
| * | ||
| * Prints, as markdown, what a dependency refresh could take TODAY under the | ||
| * project's release cooldown (`minimumReleaseAge` in pnpm-workspace.yaml): | ||
| * the newest eligible version inside each package's current major, the newest | ||
| * eligible next major, and what the cooldown still holds back (with the date | ||
| * it clears). Also the pinned pnpm (`packageManager`), version-pinned esm.sh | ||
| * URL imports, and GitHub Actions refs behind their latest major. | ||
| * | ||
| * `pnpm outdated` reports the registry's `latest` tag, which ignores the | ||
| * cooldown, so eligibility comes from each package's publish times instead. | ||
| * It changes nothing; the deps-refresh skill (.claude/skills/deps-refresh) | ||
| * says what to do with the report. | ||
| */ | ||
| import { spawnSync } from 'node:child_process' | ||
| import { existsSync, readFileSync, readdirSync } from 'node:fs' | ||
| import { join, resolve } from 'node:path' | ||
| import { isMainModule } from './is-main-module.mjs' | ||
|
|
||
| const STABLE = /^(\d+)\.(\d+)\.(\d+)$/ | ||
|
|
||
| /** A version's major LINE: `x` for x >= 1, `0.y` below 1 (caret semantics). */ | ||
| const lineOf = version => { | ||
| const [, major, minor] = STABLE.exec(version) | ||
| return major === '0' ? `0.${minor}` : major | ||
| } | ||
|
|
||
| const compareLines = (a, b) => { | ||
| const [aMajor, aMinor = 0] = a.split('.').map(Number) | ||
| const [bMajor, bMinor = 0] = b.split('.').map(Number) | ||
| return aMajor - bMajor || aMinor - bMinor | ||
| } | ||
|
|
||
| const compareVersions = (a, b) => { | ||
| const pa = STABLE.exec(a).slice(1).map(Number) | ||
| const pb = STABLE.exec(b).slice(1).map(Number) | ||
| return pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2] | ||
| } | ||
|
|
||
| /** | ||
| * `times` is the registry's `time` map (version -> ISO publish date, plus | ||
| * `created`/`modified`). Prereleases never count as targets. | ||
| */ | ||
| export const classifyPackage = ({name, current}, times, {now, cooldownMs}) => { | ||
| const row = {name, current, prerelease: !STABLE.test(current), sameMajor: null, major: null, held: null} | ||
| if (row.prerelease) return row | ||
| const newer = Object.entries(times) | ||
| .filter(([version]) => STABLE.test(version) && compareVersions(version, current) > 0) | ||
| .map(([version, published]) => ({version, published, eligibleAt: new Date(Date.parse(published) + cooldownMs).toISOString()})) | ||
| .sort((a, b) => compareVersions(a.version, b.version)) | ||
| const eligible = newer.filter(v => Date.parse(v.eligibleAt) <= now) | ||
| const line = lineOf(current) | ||
| row.sameMajor = eligible.filter(v => lineOf(v.version) === line).at(-1) ?? null | ||
| row.major = eligible.filter(v => compareLines(lineOf(v.version), line) > 0).at(-1) ?? null | ||
| const newest = newer.at(-1) | ||
| const best = row.major ?? row.sameMajor | ||
| if (newest && (!best || compareVersions(newest.version, best.version) > 0)) row.held = newest | ||
| return row | ||
| } | ||
|
|
||
| /** | ||
| * The bump that is safe to apply mechanically: `--latest` moves exact pins | ||
| * too, so it may only name packages whose newest eligible version stays in | ||
| * their major. Everything else in range is `pnpm up -r`'s job. | ||
| */ | ||
| export const latestUpCommand = rows => { | ||
| const names = rows.filter(r => r.sameMajor && !r.major).map(r => r.name) | ||
| return names.length ? `pnpm up -r --latest ${names.join(' ')}` : null | ||
|
Stvad marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| export const parseWorkflowUses = text => | ||
| [...text.matchAll(/^[ \t]*(?:-[ \t]*)?uses:[ \t]*['"]?([^'"\s#]+)['"]?/gm)] | ||
| .map(m => m[1]) | ||
| .filter(spec => !spec.startsWith('./') && !spec.startsWith('docker://') && spec.includes('@')) | ||
| .map(spec => { | ||
| const [path, ref] = spec.split('@') | ||
| return {action: path.split('/').slice(0, 2).join('/'), ref} | ||
| }) | ||
|
|
||
| /** A tag's major (`v4`, `v4.1.0`, `6`); null for a commit SHA or a branch. */ | ||
| export const actionRefMajor = ref => { | ||
| const match = /^v?(\d+)(?:\.\d+)*$/.exec(ref) | ||
| return match ? Number(match[1]) : null | ||
| } | ||
|
|
||
| export const parseEsmShPins = text => | ||
| [...text.matchAll(/https:\/\/esm\.sh\/((?:@[\w.-]+\/)?[\w.-]+)@(\d+\.\d+\.\d+)/g)].map(m => ({name: m[1], version: m[2]})) | ||
|
|
||
| /** null when the project sets none (pnpm's own default then applies). */ | ||
| const readCooldownMinutes = dir => { | ||
| const file = join(dir, 'pnpm-workspace.yaml') | ||
| const match = existsSync(file) && /^minimumReleaseAge:\s*(\d+)/m.exec(readFileSync(file, 'utf8')) | ||
| return match ? Number(match[1]) : null | ||
| } | ||
|
|
||
| const pnpmOutdated = dir => { | ||
| const result = spawnSync('pnpm', ['outdated', '-r', '--format', 'json'], {cwd: dir, encoding: 'utf8'}) | ||
| // Exit 1 means "something is outdated"; only unparseable output is an error. | ||
| try { | ||
| return JSON.parse(result.stdout || '{}') | ||
|
Stvad marked this conversation as resolved.
Resolved
|
||
| } catch { | ||
| throw new Error(`pnpm outdated printed no JSON (exit ${result.status}): ${result.stderr.trim().slice(0, 500)}`) | ||
| } | ||
| } | ||
|
|
||
| /** null when npmjs doesn't have it (a private scope, a typo'd URL import). */ | ||
| const registryTimes = async name => { | ||
| const response = await fetch(`https://registry.npmjs.org/${name.replace('/', '%2F')}`) | ||
| if (response.status === 404) return null | ||
| if (!response.ok) throw new Error(`registry ${response.status} for ${name}`) | ||
| return (await response.json()).time ?? {} | ||
| } | ||
|
|
||
| const mapLimit = async (items, limit, fn) => { | ||
| const out = new Array(items.length) | ||
| let next = 0 | ||
| await Promise.all(Array.from({length: Math.min(limit, items.length)}, async () => { | ||
| while (next < items.length) { | ||
| const i = next++ | ||
| out[i] = await fn(items[i]) | ||
| } | ||
| })) | ||
| return out | ||
| } | ||
|
|
||
| const latestActionMajor = async action => { | ||
| const headers = process.env.GITHUB_TOKEN ? {authorization: `Bearer ${process.env.GITHUB_TOKEN}`} : {} | ||
| const response = await fetch(`https://api.github.com/repos/${action}/releases/latest`, {headers}) | ||
| if (!response.ok) return null | ||
| const {tag_name: tag} = await response.json() | ||
| const major = /^v?(\d+)/.exec(tag ?? '')?.[1] | ||
| return major ? {tag, major: Number(major)} : null | ||
| } | ||
|
|
||
| const trackedFiles = (dir, pattern) => { | ||
| // Tests and docs hold example URLs, not dependencies. | ||
| const exclude = [':!pnpm-lock.yaml', ':!.beads', ':!docs', ':!*.test.*', ':!**/test/**'] | ||
| const result = spawnSync('git', ['grep', '-l', '-E', pattern, '--', ...exclude], {cwd: dir, encoding: 'utf8'}) | ||
| return result.stdout.split('\n').filter(Boolean).map(file => join(dir, file)) | ||
| } | ||
|
|
||
| const day = iso => iso.slice(0, 10) | ||
|
|
||
| const render = ({label, cooldownMinutes, rows, actions, esmPins}) => { | ||
| const cooldown = cooldownMinutes === null | ||
| ? 'NONE set in pnpm-workspace.yaml: every newer version below counts as eligible.' | ||
| : `${cooldownMinutes} minutes (${(cooldownMinutes / 1440).toFixed(1)} days).` | ||
| const lines = [`# Dependency report: ${label}`, '', `Cooldown: ${cooldown}`, ''] | ||
| const table = (title, filtered, columns) => { | ||
| if (!filtered.length) return | ||
| lines.push(`## ${title}`, '', `| ${columns.map(c => c[0]).join(' | ')} |`, `|${columns.map(() => '---').join('|')}|`) | ||
| for (const row of filtered) lines.push(`| ${columns.map(c => c[1](row)).join(' | ')} |`) | ||
| lines.push('') | ||
| } | ||
| table('Same-major updates', rows.filter(r => r.sameMajor), [ | ||
| ['package', r => r.name], ['current', r => r.current], ['target', r => r.sameMajor.version], ['used by', r => r.usedBy], | ||
| ]) | ||
| const command = latestUpCommand(rows) | ||
| if (command) lines.push('Apply with:', '', '```bash', command, 'pnpm up -r', '```', '') | ||
| table('Eligible majors (research before taking)', rows.filter(r => r.major), [ | ||
| ['package', r => r.name], ['current', r => r.current], ['major', r => r.major.version], ['published', r => day(r.major.published)], ['used by', r => r.usedBy], | ||
| ]) | ||
| table('Held by the cooldown', rows.filter(r => r.held), [ | ||
| ['package', r => r.name], ['newest', r => r.held.version], ['published', r => day(r.held.published)], ['eligible from', r => r.held.eligibleAt.slice(0, 16)], | ||
| ]) | ||
| table('Check by hand (prerelease, or not on npmjs)', rows.filter(r => r.prerelease || r.notOnNpm), [ | ||
| ['package', r => r.name], ['current', r => r.current], ['why', r => r.notOnNpm ? 'not on npmjs' : 'prerelease'], | ||
| ]) | ||
| table('esm.sh URL pins', esmPins, [ | ||
| ['package', r => r.name], ['pinned', r => r.version], ['newest eligible', r => r.target ?? 'current'], ['files', r => r.files.join(', ')], | ||
| ]) | ||
| table('GitHub Actions behind their latest major', actions.filter(a => a.major !== null && a.latest && a.latest.major > a.major), [ | ||
| ['action', a => a.action], ['used', a => a.ref], ['latest', a => a.latest.tag], | ||
| ]) | ||
| const bySha = actions.filter(a => a.major === null) | ||
| if (bySha.length) lines.push(`Actions pinned to a SHA or branch (check by hand): ${bySha.map(a => `${a.action}@${a.ref.slice(0, 12)}`).join(', ')}`, '') | ||
| const unknown = actions.filter(a => a.major !== null && !a.latest) | ||
| if (unknown.length) lines.push(`Actions with no readable latest release: ${unknown.map(a => a.action).join(', ')}`, '') | ||
| return lines.join('\n') | ||
| } | ||
|
|
||
| const main = async argv => { | ||
| const cwdFlag = argv.indexOf('--cwd') | ||
| const label = cwdFlag >= 0 ? argv[cwdFlag + 1] : '.' | ||
| const dir = resolve(label) | ||
| const cooldownMinutes = readCooldownMinutes(dir) | ||
| const policy = {now: Date.now(), cooldownMs: (cooldownMinutes ?? 0) * 60_000} | ||
|
|
||
| const outdated = Object.entries(pnpmOutdated(dir)).map(([name, info]) => ({ | ||
| name, current: info.current, usedBy: (info.dependentPackages ?? []).map(d => d.name).join(', '), | ||
|
Stvad marked this conversation as resolved.
Resolved
|
||
| })) | ||
| const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) | ||
| const pinnedPnpm = /^pnpm@(\d+\.\d+\.\d+)/.exec(manifest.packageManager ?? '') | ||
| if (pinnedPnpm) outdated.push({name: 'pnpm', current: pinnedPnpm[1], usedBy: 'packageManager'}) | ||
| const rows = (await mapLimit(outdated, 8, async pkg => { | ||
| const times = await registryTimes(pkg.name) | ||
| return times ? {...classifyPackage(pkg, times, policy), usedBy: pkg.usedBy} : {...pkg, notOnNpm: true} | ||
| })).filter(r => r.sameMajor || r.major || r.held || r.prerelease || r.notOnNpm) | ||
|
|
||
| const pinsByName = new Map() | ||
| for (const file of trackedFiles(dir, 'https://esm\\.sh/')) { | ||
| for (const pin of parseEsmShPins(readFileSync(file, 'utf8'))) { | ||
| const key = `${pin.name}@${pin.version}` | ||
| const entry = pinsByName.get(key) ?? {...pin, files: []} | ||
| const relative = file.slice(dir.length + 1) | ||
| if (!entry.files.includes(relative)) entry.files.push(relative) | ||
| pinsByName.set(key, entry) | ||
| } | ||
| } | ||
| const esmPins = await mapLimit([...pinsByName.values()], 8, async pin => { | ||
| const times = await registryTimes(pin.name) | ||
| if (!times) return {...pin, target: 'not on npm'} | ||
| const row = classifyPackage({name: pin.name, current: pin.version}, times, policy) | ||
| return {...pin, target: (row.major ?? row.sameMajor)?.version} | ||
| }) | ||
|
|
||
| const workflowDir = join(dir, '.github', 'workflows') | ||
| const uses = existsSync(workflowDir) | ||
| ? readdirSync(workflowDir).filter(f => /\.ya?ml$/.test(f)).flatMap(f => parseWorkflowUses(readFileSync(join(workflowDir, f), 'utf8'))) | ||
| : [] | ||
| const distinct = [...new Map(uses.map(u => [`${u.action}@${u.ref}`, u])).values()] | ||
| const actions = await mapLimit(distinct, 4, async u => ({...u, major: actionRefMajor(u.ref), latest: await latestActionMajor(u.action)})) | ||
|
|
||
| console.log(render({label, cooldownMinutes, rows, actions, esmPins})) | ||
| } | ||
|
|
||
| if (isMainModule(import.meta.url)) { | ||
| main(process.argv.slice(2)).catch(error => { | ||
| console.error(error.message) | ||
| process.exit(1) | ||
| }) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.