Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -432,6 +439,7 @@ export function dependicusCli(config: DependicusCliConfig): {
store,
{
linearApiKey,
label: linearConfig.label,
dryRun: options.dryRun,
getDetailUrl,
providerInfoMap,
Expand Down
28 changes: 28 additions & 0 deletions src/linear/LinearService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
Expand Down
28 changes: 21 additions & 7 deletions src/linear/LinearService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string> {
if (this.labelId) {
Expand All @@ -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) {
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/linear/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions src/linear/issueReconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading