From 8a22a34547ca71e0afa50fe2e407c3ed0f850666 Mon Sep 17 00:00:00 2001 From: Steve Landey Date: Mon, 12 Oct 2026 11:55:23 -0700 Subject: [PATCH 1/2] Fix the due date on an issue that covers two versions Say react 17 needs 17.0.5 with a 28-day threshold and react 18 needs 18.2.1 with a 10-day threshold. The issue asked for 18.2.1 but added the 28 days to its release date. It now uses the threshold that belongs to the version it asks for. VersionContext.dependencyVersions lists every version of the dependency with the packages on each, so a spec filing one issue per team can see what else that team is on. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 + docs/docs/githubissues.md | 2 +- docs/docs/linearissues.md | 4 ++ src/github-issues/issueReconciler.test.ts | 54 +++++++++++++++++++++++ src/github-issues/issueReconciler.ts | 18 +++++++- src/github-issues/types.ts | 7 +++ src/linear/issueReconciler.test.ts | 49 ++++++++++++++++++++ src/linear/issueReconciler.ts | 19 ++++++-- src/linear/types.ts | 7 +++ 9 files changed, 156 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f86dd..544cf8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ - Plugin specs merge per scope. A plugin returning a single spec, like `SecurityPlugin`'s description sections, reaches every scoped issue. - Issue specs can set `minimumVersion`, the lowest release that resolves the issue. The title asks for at least that version, and the due date counts from when it was published. Without it, issues still ask for the first release of the update type needed, which for a vulnerability is often not the release that fixes it. - The Snyk source records which versions fix each advisory, and the lowest version that fixes them all. Tickets and dependency pages show them, and `getFixVersion(store, name, version)` returns it for use as `minimumVersion`. +- `VersionContext.dependencyVersions` lists every version of the dependency in the repo, with the packages on each, so a spec filing one issue per team can see what else that team is on. ### Changed @@ -57,6 +58,7 @@ - Links from a grouping page to a dependency's page work when the grouping spans ecosystems. Those pages sit at the site root, so the `../details/…` path a plugin builds leads nowhere, which broke `BasicCompliancePlugin`'s flagged entries. `GroupingFlag.detailLink` is optional now and Dependicus resolves it, and `GroupingDetailContext.detailLinkFor` resolves one for a link inside a section's own HTML. +- The due date was wrong on an issue that covers two versions of one dependency. Say `react` 17 needs 17.0.5 with a 28-day threshold and `react` 18 needs 18.2.1 with a 10-day threshold. The issue asked for 18.2.1 but added the 28 days to its release date. It now uses the threshold that belongs to the version it asks for. - Deprecation detection now works on pnpm 11 and pnpm 12, not just pnpm 10. - pnpm 12 removed the `pnpm install --resolution-only` flag Dependicus used to find deprecated packages, so `dependicus update` failed outright against any pnpm 12 workspace and produced no output at all. - pnpm 11 and pnpm 12 skip re-resolving when the lockfile and `node_modules` already agree, which left every package looking undeprecated. Dependicus now asks pnpm to resolve anyway. diff --git a/docs/docs/githubissues.md b/docs/docs/githubissues.md index 058a989..365e413 100644 --- a/docs/docs/githubissues.md +++ b/docs/docs/githubissues.md @@ -57,7 +57,7 @@ void dependicusCli({ ## One issue per team, and security fixes -GitHub issue specs accept the same `scope`, `usedBy` and `minimumVersion` fields as Linear ones. Return one scoped spec per team to file an issue for each team, and set `minimumVersion` to ask for the release that fixes a vulnerability. [Creating and Updating Linear Issues](linearissues.md#one-issue-per-team) covers both. +GitHub issue specs accept the same `scope`, `usedBy` and `minimumVersion` fields as Linear ones, and get the same `dependencyVersions` in their context. Return one scoped spec per team to file an issue for each team, and set `minimumVersion` to ask for the release that fixes a vulnerability. [Creating and Updating Linear Issues](linearissues.md#one-issue-per-team) covers them. ## CLI flags diff --git a/docs/docs/linearissues.md b/docs/docs/linearissues.md index 49784b4..dd3727a 100644 --- a/docs/docs/linearissues.md +++ b/docs/docs/linearissues.md @@ -94,6 +94,10 @@ getLinearIssueSpec: (context) => { `context.usedBy` lists the packages that use the version. A spec's `usedBy` limits its issue to the packages that team owns. +`context.dependencyVersions` lists every version of the dependency in the repo, with the packages on each, so a spec can see what else a team is on. + +If two versions get specs with the same scope, they share one issue. It lists both versions and asks for the higher of their two `minimumVersion`s. Its due date is that version's release date plus that spec's `thresholdDays`. + Dependicus puts the scope in the title, like `[Dependicus] [npm] [Payments] Update react from ...`, and uses it to find the issue on later runs. Each team's issue is updated and closed separately. A scope can't contain square brackets. A spec without a scope works as it did before, and so do the issues it filed. A plugin that returns a single spec, like `SecurityPlugin` with its advisory sections, is merged into every scoped spec. diff --git a/src/github-issues/issueReconciler.test.ts b/src/github-issues/issueReconciler.test.ts index 5eba4a6..66954b2 100644 --- a/src/github-issues/issueReconciler.test.ts +++ b/src/github-issues/issueReconciler.test.ts @@ -690,4 +690,58 @@ describe('reconcileGitHubIssues with scoped specs', () => { expect(result).toMatchObject({ created: 0, updated: 2, closed: 0, closedDuplicates: 0 }); }); + + it("counts a merged issue's due date from the release it asks for", async () => { + setupMocks(); + const v1 = makeVersion({ version: '1.2.0', latestVersion: '3.0.0' }); + const v2 = makeVersion({ version: '2.0.0', latestVersion: '3.0.0' }); + const between: PackageVersionInfo[] = [ + { version: '1.2.5', publishDate: '2024-02-01', isPrerelease: false }, + { version: '2.0.3', publishDate: '2024-05-01', isPrerelease: false }, + { version: '3.0.0', publishDate: '2024-06-01', isPrerelease: false }, + ]; + const root = new RootFactStore(); + const scoped = root.scoped('npm'); + scoped.setVersionFact('test-pkg', '1.2.0', FactKeys.VERSIONS_BETWEEN, between); + scoped.setVersionFact('test-pkg', '2.0.0', FactKeys.VERSIONS_BETWEEN, between.slice(1)); + const fixes: Record = { + '1.2.0': { minimumVersion: '1.2.5', thresholdDays: 28 }, + '2.0.0': { minimumVersion: '2.0.3', thresholdDays: 10 }, + }; + + await reconcileGitHubIssues( + [{ name: 'test-pkg', ecosystem: 'npm', versions: [v1, v2] }], + root, + baseConfig, + (ctx) => makeSpec({ policy: { type: 'dueDate' }, ...fixes[ctx.currentVersion] }), + ); + + const createCall = mockOctokit.issues.create.mock.calls[0]![0]; + expect(createCall.title).toContain('to at least 2.0.3'); + // 2.0.3 shipped 2024-05-01 and its spec said 10 days, not the first + // version's 28. + expect(createCall.title).toContain('(due 2024-05-11)'); + }); + + it('hands the spec every version of the dependency', async () => { + setupMocks(); + const v1 = makeVersion({ version: '1.0.0', usedBy: ['web'] }); + const v2 = makeVersion({ version: '1.5.0', usedBy: ['admin'] }); + const seen: string[][] = []; + + await reconcileGitHubIssues( + [{ name: 'test-pkg', ecosystem: 'npm', versions: [v1, v2] }], + makeStore(), + baseConfig, + (ctx) => { + seen.push( + (ctx.dependencyVersions ?? []).map((v) => `${v.version}:${v.usedBy.join(',')}`), + ); + return undefined; + }, + ); + + const both = ['1.0.0:web', '1.5.0:admin']; + expect(seen).toEqual([both, both]); + }); }); diff --git a/src/github-issues/issueReconciler.ts b/src/github-issues/issueReconciler.ts index 24e653c..d3a49d2 100644 --- a/src/github-issues/issueReconciler.ts +++ b/src/github-issues/issueReconciler.ts @@ -287,6 +287,7 @@ export async function reconcileGitHubIssues( currentVersion: version.version, latestVersion: version.latestVersion, usedBy: version.usedBy, + dependencyVersions: dep.versions, }; const scopedStore = store.scoped(dep.ecosystem); @@ -380,11 +381,23 @@ export async function reconcileGitHubIssues( } // Ask for the highest minimum any version needs, so the - // title's version gets its fix too. - existing.minimumVersion = higherVersion( + // title's version gets its fix too. The issue is due + // thresholdDays after that release, so the threshold + // has to be the one the spec set for that release. A + // threshold chosen for 1.2.5, added to 2.0.3's release + // date, lands on the wrong day. + const minimumVersion = higherVersion( existing.minimumVersion, ctx.minimumVersion, ); + if ( + minimumVersion !== existing.minimumVersion && + thresholdDays !== undefined && + !isNotificationsOnly + ) { + existing.worstCompliance = { ...existing.worstCompliance, thresholdDays }; + } + existing.minimumVersion = minimumVersion; if (availableMajorVersion && !existing.availableMajorVersion) { existing.availableMajorVersion = availableMajorVersion; @@ -477,6 +490,7 @@ export async function reconcileGitHubIssues( currentVersion: version.version, latestVersion: version.latestVersion, usedBy: version.usedBy, + dependencyVersions: dep.versions, }, store.scoped(dep.ecosystem), ), diff --git a/src/github-issues/types.ts b/src/github-issues/types.ts index a509b3e..65ee667 100644 --- a/src/github-issues/types.ts +++ b/src/github-issues/types.ts @@ -116,6 +116,13 @@ export interface VersionContext { latestVersion: string; /** The packages that use this version. */ usedBy?: readonly string[]; + /** + * Every version of the dependency in the repo, this one included, each + * with the packages that use it. A spec that files one issue per team can + * see what else that team is on, so the versions it covers agree on what + * to ask for. + */ + dependencyVersions?: readonly DependencyVersion[]; } // ── Internal types (not plugin-facing) ─────────────────────────────── diff --git a/src/linear/issueReconciler.test.ts b/src/linear/issueReconciler.test.ts index 19c19b7..fa99126 100644 --- a/src/linear/issueReconciler.test.ts +++ b/src/linear/issueReconciler.test.ts @@ -1690,4 +1690,53 @@ describe('reconcileIssues with scoped specs', () => { ); expect(input.dueDate).toBe('2024-05-29'); }); + + it("counts a merged issue's due date from the release it asks for", async () => { + const v1 = makeVersion({ version: '1.2.0', latestVersion: '3.0.0' }); + const v2 = makeVersion({ version: '2.0.0', latestVersion: '3.0.0' }); + const between: PackageVersionInfo[] = [ + { version: '1.2.5', publishDate: '2024-02-01', isPrerelease: false }, + { version: '2.0.3', publishDate: '2024-05-01', isPrerelease: false }, + { version: '3.0.0', publishDate: '2024-06-01', isPrerelease: false }, + ]; + populateFacts(store, 'test-pkg', v1, { versionsBetween: between }); + populateFacts(store, 'test-pkg', v2, { versionsBetween: between.slice(1) }); + // Each version's spec sets the threshold that suits its own fix. + const fixes: Record = { + '1.2.0': { minimumVersion: '1.2.5', thresholdDays: 28 }, + '2.0.0': { minimumVersion: '2.0.3', thresholdDays: 10 }, + }; + + await reconcileIssues([makeDep('test-pkg', [v1, v2])], store, liveConfig, (ctx) => ({ + teamId: 'team-web', + policy: { type: 'dueDate' }, + ...fixes[ctx.currentVersion], + })); + + const [input] = mockClient.createIssue.mock.calls[0] as [ + { title: string; dueDate: string }, + ]; + expect(input.title).toContain('to at least 2.0.3'); + // 2.0.3 shipped 2024-05-01 and its spec said 10 days. The first + // version's 28 days would have put this on 05-29. + expect(input.dueDate).toBe('2024-05-11'); + }); + + it('hands the spec every version of the dependency', async () => { + const v1 = makeVersion({ version: '1.0.0', usedBy: ['@app/web'] }); + const v2 = makeVersion({ version: '1.5.0', usedBy: ['@app/admin'] }); + populateFacts(store, 'test-pkg', v1, { versionsBetween: [] }); + populateFacts(store, 'test-pkg', v2, { versionsBetween: [] }); + const seen: string[][] = []; + + await reconcileIssues([makeDep('test-pkg', [v1, v2])], store, liveConfig, (ctx) => { + seen.push( + (ctx.dependencyVersions ?? []).map((v) => `${v.version}:${v.usedBy.join(',')}`), + ); + return undefined; + }); + + const both = ['1.0.0:@app/web', '1.5.0:@app/admin']; + expect(seen).toEqual([both, both]); + }); }); diff --git a/src/linear/issueReconciler.ts b/src/linear/issueReconciler.ts index d31c151..6ec56a7 100644 --- a/src/linear/issueReconciler.ts +++ b/src/linear/issueReconciler.ts @@ -297,6 +297,7 @@ export async function reconcileIssues( currentVersion: version.version, latestVersion: version.latestVersion, usedBy: version.usedBy, + dependencyVersions: dep.versions, }; const scopedStore = store.scoped(dep.ecosystem); @@ -393,13 +394,24 @@ export async function reconcileIssues( existing.policy = aggregatePolicy(existing.policy, effectivePolicy); } - // Keep track of major version if available // Ask for the highest minimum any version needs, so the - // title's version gets its fix too. - existing.minimumVersion = higherVersion( + // title's version gets its fix too. The issue is due + // thresholdDays after that release, so the threshold + // has to be the one the spec set for that release. A + // threshold chosen for 1.2.5, added to 2.0.3's release + // date, lands on the wrong day. + const minimumVersion = higherVersion( existing.minimumVersion, ctx.minimumVersion, ); + if ( + minimumVersion !== existing.minimumVersion && + thresholdDays !== undefined && + !isNotificationsOnly + ) { + existing.worstCompliance = { ...existing.worstCompliance, thresholdDays }; + } + existing.minimumVersion = minimumVersion; if (availableMajorVersion && !existing.availableMajorVersion) { existing.availableMajorVersion = availableMajorVersion; @@ -497,6 +509,7 @@ export async function reconcileIssues( currentVersion: version.version, latestVersion: version.latestVersion, usedBy: version.usedBy, + dependencyVersions: dep.versions, }, store.scoped(dep.ecosystem), ), diff --git a/src/linear/types.ts b/src/linear/types.ts index 0f2a79c..f0997c5 100644 --- a/src/linear/types.ts +++ b/src/linear/types.ts @@ -114,6 +114,13 @@ export interface VersionContext { latestVersion: string; /** The packages that use this version. */ usedBy?: readonly string[]; + /** + * Every version of the dependency in the repo, this one included, each + * with the packages that use it. A spec that files one issue per team can + * see what else that team is on, so the versions it covers agree on what + * to ask for. + */ + dependencyVersions?: readonly DependencyVersion[]; } // ── Internal types (not plugin-facing) ─────────────────────────────── From 44d2bab6563d3e26ac2ff2d8e62b3d8e6f5c1faf Mon Sep 17 00:00:00 2001 From: Steve Landey Date: Tue, 13 Oct 2026 10:14:29 -0700 Subject: [PATCH 2/2] Let getFixVersion count only advisories at least a severity A policy that files tickets for high and critical advisories wants the release that fixes those, due four weeks after it shipped. The fix version on a finding is the lowest release that fixes every advisory, so a medium advisory fixed later moved the ask and the deadline to its release. getFixVersion(store, name, version, { minSeverity: 'high' }) leaves the medium out. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + docs/docs/linearissues.md | 4 ++- src/index.ts | 1 + src/security/SecurityPlugin.test.ts | 39 +++++++++++++++++++++ src/security/SecurityPlugin.ts | 53 ++++++++++++++++++++++++++--- src/security/index.ts | 1 + 6 files changed, 94 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 544cf8a..b50bb3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ - Plugin specs merge per scope. A plugin returning a single spec, like `SecurityPlugin`'s description sections, reaches every scoped issue. - Issue specs can set `minimumVersion`, the lowest release that resolves the issue. The title asks for at least that version, and the due date counts from when it was published. Without it, issues still ask for the first release of the update type needed, which for a vulnerability is often not the release that fixes it. - The Snyk source records which versions fix each advisory, and the lowest version that fixes them all. Tickets and dependency pages show them, and `getFixVersion(store, name, version)` returns it for use as `minimumVersion`. +- `getFixVersion` takes `{ minSeverity }`, to ask for the release that fixes the high and critical advisories and not wait for one that also fixes a medium. - `VersionContext.dependencyVersions` lists every version of the dependency in the repo, with the packages on each, so a spec filing one issue per team can see what else that team is on. ### Changed diff --git a/docs/docs/linearissues.md b/docs/docs/linearissues.md index dd3727a..afeac12 100644 --- a/docs/docs/linearissues.md +++ b/docs/docs/linearissues.md @@ -126,11 +126,13 @@ getLinearIssueSpec: (context, store) => { teamId: 'your-team-uuid', policy: { type: 'dueDate' }, thresholdDays: 28, - minimumVersion: getFixVersion(store, context.name, context.currentVersion), + minimumVersion: getFixVersion(store, context.name, context.currentVersion, { minSeverity: 'high' }), }; }, ``` +`minSeverity` counts only the advisories at least that severe. Without it, a medium advisory fixed later than the high one moves the ask, and the due date, to its release. + ## CLI flags The `make-linear-issues` command accepts these flags in addition to `--dry-run`, `--json-file`, and `--linear-team-id`: diff --git a/src/index.ts b/src/index.ts index 944b5cf..7b3ccee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,7 @@ export type { GitHubIssueAssignment } from './github-issues/index'; /** @group Security */ export { SecurityPlugin, getFixVersion } from './security/index'; +export type { FixVersionOptions } from './security/index'; /** @group Security */ export type { diff --git a/src/security/SecurityPlugin.test.ts b/src/security/SecurityPlugin.test.ts index c131249..132595d 100644 --- a/src/security/SecurityPlugin.test.ts +++ b/src/security/SecurityPlugin.test.ts @@ -324,6 +324,45 @@ describe('fix versions', () => { expect(getFixVersion(storeWith([]), 'body-parser', '1.20.1')).toBeUndefined(); }); + it('getFixVersion can count only advisories at least a severity', () => { + const mixed: SecurityFinding[] = [ + { + source: 'snyk', + sourceLabel: 'Snyk', + severity: 'high', + fixAvailable: true, + fixVersion: '1.20.8', + advisories: [ + { + id: 'SNYK-1', + severity: 'high', + fixAvailable: true, + fixVersions: ['1.20.6'], + url: 'https://security.snyk.io/vuln/SNYK-1', + }, + { + id: 'SNYK-2', + severity: 'medium', + fixAvailable: true, + fixVersions: ['1.20.8'], + url: 'https://security.snyk.io/vuln/SNYK-2', + }, + ], + }, + ]; + expect(getFixVersion(storeWith(mixed), 'body-parser', '1.20.1')).toBe('1.20.8'); + expect( + getFixVersion(storeWith(mixed), 'body-parser', '1.20.1', { minSeverity: 'high' }), + ).toBe('1.20.6'); + // A finding without per-advisory detail counts by its own severity. + const whole: SecurityFinding[] = [ + { source: 'osv', sourceLabel: 'OSV', severity: 'medium', fixVersion: '1.20.9' }, + ]; + expect( + getFixVersion(storeWith(whole), 'body-parser', '1.20.1', { minSeverity: 'high' }), + ).toBeUndefined(); + }); + it('puts the fix versions in ticket descriptions', () => { const plugin = new SecurityPlugin({}); const spec = plugin.getLinearIssueSpec( diff --git a/src/security/SecurityPlugin.ts b/src/security/SecurityPlugin.ts index 88fe7e2..a1e6542 100644 --- a/src/security/SecurityPlugin.ts +++ b/src/security/SecurityPlugin.ts @@ -17,7 +17,7 @@ import { compareVersions } from '../core/utils/versionUtils'; import { OsvSource } from './sources/OsvSource'; import { DepsDevSource } from './sources/DepsDevSource'; import { GitHubAdvisorySource } from './sources/GitHubAdvisorySource'; -import { SnykSource } from './sources/SnykSource'; +import { SnykSource, lowestFixVersion } from './sources/SnykSource'; // ── DependicusPlugin implementation ───────────────────────────────── @@ -537,6 +537,17 @@ function highestFixVersion(findings: readonly SecurityFinding[]): string | undef return highest; } +/** @group Security */ +export interface FixVersionOptions { + /** + * Count only advisories at least this severe. The fix returned is the + * lowest release that resolves them, even when a less severe advisory is + * fixed later. An advisory with no severity of its own takes the + * severity of its finding. + */ + minSeverity?: Severity; +} + /** * The lowest version that fixes the known vulnerabilities in a dependency * version, for issue specs to pass as `minimumVersion`. Undefined when no @@ -545,13 +556,47 @@ function highestFixVersion(findings: readonly SecurityFinding[]): string | undef * @example * getLinearIssueSpec: (ctx, store) => ({ * teamId, - * minimumVersion: getFixVersion(store, ctx.name, ctx.currentVersion), + * minimumVersion: getFixVersion(store, ctx.name, ctx.currentVersion, { minSeverity: 'high' }), * }) * @group Security */ -export function getFixVersion(store: FactStore, name: string, version: string): string | undefined { +export function getFixVersion( + store: FactStore, + name: string, + version: string, + options: FixVersionOptions = {}, +): string | undefined { + const findings = + store.getVersionFact(name, version, SECURITY_FINDINGS_KEY) ?? []; + const { minSeverity } = options; + if (!minSeverity) return highestFixVersion(findings); return highestFixVersion( - store.getVersionFact(name, version, SECURITY_FINDINGS_KEY) ?? [], + findings.map((f) => ({ ...f, fixVersion: fixVersionAtLeast(f, minSeverity, version) })), + ); +} + +function atLeast(severity: Severity | undefined, floor: Severity): boolean { + return ( + severity !== undefined && SEVERITY_ORDER.indexOf(severity) >= SEVERITY_ORDER.indexOf(floor) + ); +} + +/** + * A finding's fix version counting only its advisories at least `floor` + * severe. A finding without per-advisory detail counts or not as a whole. + */ +function fixVersionAtLeast( + finding: SecurityFinding, + floor: Severity, + version: string, +): string | undefined { + if (!finding.advisories?.length) { + return atLeast(finding.severity, floor) ? finding.fixVersion : undefined; + } + const kept = finding.advisories.filter((a) => atLeast(a.severity ?? finding.severity, floor)); + return lowestFixVersion( + version, + kept.map((a) => a.fixVersions ?? []), ); } diff --git a/src/security/index.ts b/src/security/index.ts index ffdadde..d030af1 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -1,4 +1,5 @@ export { SecurityPlugin, getFixVersion } from './SecurityPlugin'; +export type { FixVersionOptions } from './SecurityPlugin'; export type { SecurityPluginConfig, OsvConfig,