diff --git a/CHANGELOG.md b/CHANGELOG.md index b50bb3c..1ed905e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ - 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. +- `linear.label` sets the label Dependicus puts on its Linear issues, and finds them by, instead of "Dependicus". Given by id, the label is used as is, so a read-only key is enough. Given by name, it is found or created. ### Changed diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index cb751b3..e75bbc7 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -46,6 +46,24 @@ This field is optional because [plugins](./plugins.md) can provide the same func If you do not write a plugin or pass a `getLinearIssueSpec()` function, then Dependicus cannot create Linear tickets for you. +### `linear.label` (optional, defaults to the name `"Dependicus"`) + +The Linear label Dependicus puts on every issue it files. It is also how Dependicus finds its issues again, so changing it on a workspace with open Dependicus issues leaves those issues behind. + +Give the label by id or by name. By id, Dependicus uses the label as is, so a read-only API key is enough for a dry run. By name, Dependicus finds the label in the workspace and creates it if it is missing, which needs an API key with write access. + +```ts +linear: { + label: { id: 'a1b2c3d4-…' }, +} +``` + +```ts +linear: { + label: { name: 'Dependency Bump' }, +} +``` + ### `linear.cooldownDays` (optional, defaults to `0`) Wait at least this many days after a release before creating or updating tickets to include it. diff --git a/src/cli.ts b/src/cli.ts index 656fa23..3bd9300 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,7 +20,7 @@ import { UvProvider } from './providers-python/index'; import { GoProvider } from './provider-go/index'; import { CargoProvider } from './provider-rust/index'; import { reconcileIssues } from './linear/index'; -import type { VersionContext, LinearIssueSpec } from './linear/index'; +import type { VersionContext, LinearIssueSpec, LinearLabelConfig } from './linear/index'; import { reconcileGitHubIssues } from './github-issues/index'; import type { GitHubIssueSpec } from './github-issues/index'; import type { VersionContext as GitHubVersionContext } from './github-issues/index'; @@ -60,6 +60,13 @@ export interface DependicusCliConfig { context: VersionContext, store: FactStore, ) => LinearIssueSpec | LinearIssueSpec[] | undefined; + /** + * The Linear label on every issue Dependicus files, and how it finds them again. + * `{ id }` uses that label as is. `{ name }` finds the label in the workspace and + * creates it if it is missing, which needs an API key with write access. + * Defaults to the name "Dependicus". + */ + label?: LinearLabelConfig; /** Number of days to wait before creating a new issue for a newly-published version. */ cooldownDays?: number; /** Whether to allow new issue creation. Defaults to `true`. */ @@ -432,6 +439,7 @@ export function dependicusCli(config: DependicusCliConfig): { store, { linearApiKey, + label: linearConfig.label, dryRun: options.dryRun, getDetailUrl, providerInfoMap, diff --git a/src/linear/LinearService.test.ts b/src/linear/LinearService.test.ts index 2adda74..f733ebb 100644 --- a/src/linear/LinearService.test.ts +++ b/src/linear/LinearService.test.ts @@ -51,6 +51,34 @@ describe('LinearService', () => { }); }); + it('uses a label id as given, with no lookup', async () => { + const byId = new LinearService('test-api-key', { label: { id: 'bump-789' } }); + + expect(await byId.ensureLabel()).toBe('bump-789'); + expect(mockClient.issueLabels).not.toHaveBeenCalled(); + expect(mockClient.createIssueLabel).not.toHaveBeenCalled(); + }); + + it('uses the label name it was given', async () => { + mockClient.issueLabels.mockResolvedValue({ nodes: [] }); + mockClient.createIssueLabel.mockResolvedValue({ + issueLabel: Promise.resolve({ id: 'bump-789' }), + }); + const named = new LinearService('test-api-key', { + label: { name: 'Dependency Bump' }, + }); + + const labelId = await named.ensureLabel(); + expect(labelId).toBe('bump-789'); + expect(mockClient.issueLabels).toHaveBeenCalledWith({ + filter: { name: { eq: 'Dependency Bump' } }, + }); + expect(mockClient.createIssueLabel).toHaveBeenCalledWith({ + name: 'Dependency Bump', + color: '#6B7280', + }); + }); + it('caches label id after first call', async () => { mockClient.issueLabels.mockResolvedValue({ nodes: [{ id: 'label-123', name: 'Dependicus' }], diff --git a/src/linear/LinearService.ts b/src/linear/LinearService.ts index 94ad400..2e211a8 100644 --- a/src/linear/LinearService.ts +++ b/src/linear/LinearService.ts @@ -7,7 +7,7 @@ import { type IssueCreateInput = LinearDocument.IssueCreateInput; -const DEPENDICUS_LABEL_NAME = 'Dependicus'; +const DEFAULT_LABEL_NAME = 'Dependicus'; const TITLE_PREFIX = '[Dependicus]'; export interface DependicusIssue { @@ -47,21 +47,35 @@ export interface CreateIssueParams { delegateId?: string; } +/** + * The label on every issue Dependicus files, and how it finds them again. + * By id, nothing is looked up or created, so a read-only API key is enough. + * By name, the label is found in the workspace, or created if it is missing. + */ +export type LinearLabelConfig = { id: string } | { name: string }; + export class LinearService { private client: LinearClient; private labelId: string | undefined; + private labelName: string; private dryRun: boolean; - constructor(apiKey: string, options?: { dryRun?: boolean }) { + constructor(apiKey: string, options?: { dryRun?: boolean; label?: LinearLabelConfig }) { if (!apiKey) { throw new Error('LINEAR_API_KEY is required'); } this.client = new LinearClient({ apiKey }); + const label = options?.label; + this.labelId = label && 'id' in label ? label.id : undefined; + this.labelName = label && 'name' in label ? label.name : DEFAULT_LABEL_NAME; this.dryRun = options?.dryRun ?? false; } /** - * Get or create the "Dependicus" label (workspace-level). + * The id of the label that marks an issue as Dependicus's. Given an id, + * that is it. Otherwise the label is found by name, "Dependicus" unless + * the constructor was given another, and created if the workspace has + * none. */ async ensureLabel(): Promise { if (this.labelId) { @@ -70,11 +84,11 @@ export class LinearService { // Search for existing label const labels = await this.client.issueLabels({ - filter: { name: { eq: DEPENDICUS_LABEL_NAME } }, + filter: { name: { eq: this.labelName } }, }); const existingLabel = labels.nodes.find( - (l) => l.name.toLowerCase() === DEPENDICUS_LABEL_NAME.toLowerCase(), + (l) => l.name.toLowerCase() === this.labelName.toLowerCase(), ); if (existingLabel) { @@ -84,13 +98,13 @@ export class LinearService { // Create new label const result = await this.client.createIssueLabel({ - name: DEPENDICUS_LABEL_NAME, + name: this.labelName, color: '#6B7280', // gray }); const newLabel = await result.issueLabel; if (!newLabel) { - throw new Error('Failed to create Dependicus label'); + throw new Error(`Failed to create ${this.labelName} label`); } this.labelId = newLabel.id; diff --git a/src/linear/index.ts b/src/linear/index.ts index a7cfeae..1f15997 100644 --- a/src/linear/index.ts +++ b/src/linear/index.ts @@ -1,5 +1,5 @@ export { LinearService } from './LinearService'; -export type { DependicusIssue, CreateIssueParams } from './LinearService'; +export type { DependicusIssue, CreateIssueParams, LinearLabelConfig } from './LinearService'; export type { OutdatedDependency, OutdatedGroup, LinearPolicy, IssueAssignment } from './types'; export type { LinearIssueSpec, VersionContext, DescriptionSection } from './types'; export { diff --git a/src/linear/issueReconciler.ts b/src/linear/issueReconciler.ts index 6ec56a7..6767f01 100644 --- a/src/linear/issueReconciler.ts +++ b/src/linear/issueReconciler.ts @@ -23,7 +23,7 @@ import { toSpecList, higherVersion, } from '../core/index'; -import { LinearService, DependicusIssue } from './LinearService'; +import { LinearService, DependicusIssue, type LinearLabelConfig } from './LinearService'; import type { OutdatedDependency, OutdatedGroup, @@ -42,6 +42,8 @@ import { export interface IssueReconcilerConfig { linearApiKey: string; + /** The label on every issue Dependicus files, by id or by name. Defaults to "Dependicus". */ + label?: LinearLabelConfig; dryRun?: boolean; /** Builds the full detail page URL for a given package version. */ getDetailUrl: DetailUrlFn; @@ -264,7 +266,10 @@ export async function reconcileIssues( const skipStateNamesSet = new Set((config.skipStateNames ?? []).map((s) => s.toLowerCase())); - const linearService = new LinearService(config.linearApiKey, { dryRun }); + const linearService = new LinearService(config.linearApiKey, { + dryRun, + label: config.label, + }); // Find out-of-date dependencies (group by ecosystem::name to avoid // merging packages that share a name across different registries, and by