Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

### Added

- Linear issue creation can now be rate-limited per team with `linear.teamIssueRateLimit`, capping how many new or reopened Dependicus tickets one Linear team receives during a rolling window while still updating and closing existing tickets.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR metadata is a stub description

Low Severity

The PR title contains a typo ("limting" → "limiting") and the description is "No description provided," which is clearly a stub. Consider running /fix-pr-metadata <pr number> from Claude Code to generate a proper title and description.

Fix in Cursor Fix in Web

Triggered by team rule: metadata

Reviewed by Cursor Bugbot for commit 33993b4. Configure here.


### Changed

- `searchDependicusIssues` (in `@dependicus/github-issues`) now skips every pull request returned by GitHub's issues endpoint — drafts and ready-to-review alike — and also skips anything flagged as a draft. Only real, non-draft issues are returned, so notification bots and reports built on this helper stop counting pull requests as open Dependicus items.
Expand Down
17 changes: 17 additions & 0 deletions docs/docs/linearissues.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ void dependicusCli({
repoRoot,
dependicusBaseUrl: 'https://mycompany.internal/dependicus',
linear: {
teamIssueRateLimit: {
windowDays: 7,
maxIssuesPerTeam: 5,
},
getLinearIssueSpec: (context, store) => {
const { name, currentVersion, latestVersion } = context;
const updateType = getUpdateType(currentVersion, latestVersion);
Expand Down Expand Up @@ -61,6 +65,19 @@ void dependicusCli({
}).run(process.argv);
```

## Team Issue Rate Limits

Set `linear.teamIssueRateLimit` to cap how many new or reopened Dependicus issues any one Linear team receives during a rolling window. Existing issue updates, comments, and closures still run, so the rate limit reduces ticket noise without making existing tickets stale.

```ts
linear: {
teamIssueRateLimit: {
windowDays: 7,
maxIssuesPerTeam: 5,
},
}
```

## CLI flags

The `make-linear-issues` command accepts these flags in addition to `--dry-run`, `--json-file`, and `--linear-team-id`:
Expand Down
15 changes: 15 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ describe('dependicusCli', () => {
cooldownDays: 7,
allowNewIssues: true,
skipStateNames: ['done'],
teamIssueRateLimit: { windowDays: 7, maxIssuesPerTeam: 3 },
},
};

Expand Down Expand Up @@ -661,6 +662,20 @@ describe('dependicusCli', () => {
const config = mockReconcileIssues.mock.calls[0]![2];
expect(config).toHaveProperty('rateLimitDays', 7);
});

it('passes team issue rate limit config through', async () => {
setEnv('LINEAR_API_KEY', 'test-key');
setupLinearMocks();

const cli = dependicusCli(linearConfig);
await cli.run(argv('make-linear-issues'));

const config = mockReconcileIssues.mock.calls[0]![2];
expect(config).toHaveProperty('teamIssueRateLimit', {
windowDays: 7,
maxIssuesPerTeam: 3,
});
});
});

describe('make-github-issues flags', () => {
Expand Down
5 changes: 4 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, TeamIssueRateLimitConfig } 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 @@ -64,6 +64,8 @@ export interface DependicusCliConfig {
skipStateNames?: string[];
/** Default rate limit days for notification throttling. Used when per-policy rateLimitDays is not set. */
rateLimitDays?: number;
/** Limit new/reopened Dependicus issues per Linear team over a rolling window. */
teamIssueRateLimit?: TeamIssueRateLimitConfig;
};
/** GitHub Issues integration configuration. */
github?: {
Expand Down Expand Up @@ -415,6 +417,7 @@ export function dependicusCli(config: DependicusCliConfig): {
options.rateLimitDays != null
? Number(options.rateLimitDays)
: linearConfig.rateLimitDays,
teamIssueRateLimit: linearConfig.teamIssueRateLimit,
},
effectiveGetLinearIssueSpec,
);
Expand Down
47 changes: 47 additions & 0 deletions src/linear/LinearService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ describe('LinearService', () => {
id: 'issue-1',
identifier: 'CORE-100',
title: '[Dependicus] Update react from 18.2.0 to 19.0.0',
createdAt: new Date('2025-01-10'),
dueDate: '2025-06-01',
updatedAt: new Date('2025-01-15'),
state: Promise.resolve(mockState),
Expand All @@ -90,6 +91,8 @@ describe('LinearService', () => {
id: 'issue-1',
identifier: 'CORE-100',
title: '[Dependicus] Update react from 18.2.0 to 19.0.0',
createdAt: '2025-01-10T00:00:00.000Z',
teamId: undefined,
dependencyName: 'react',
isGroup: false,
dueDate: '2025-06-01',
Expand All @@ -112,6 +115,50 @@ describe('LinearService', () => {
await service.searchDependicusIssues(onProgress);
expect(onProgress).toHaveBeenCalledWith(0, 1);
});

it('can include closed issues, created-date filtering, and team ids', async () => {
mockClient.issueLabels.mockResolvedValue({
nodes: [{ id: 'label-123', name: 'Dependicus' }],
});

const mockState = { type: 'completed', name: 'Done' };
mockClient.issues.mockResolvedValue({
nodes: [
{
id: 'issue-1',
identifier: 'CORE-100',
title: '[Dependicus] Update react from 18.2.0 to 19.0.0',
createdAt: new Date('2025-01-10'),
dueDate: undefined,
updatedAt: new Date('2025-01-15'),
state: Promise.resolve(mockState),
team: Promise.resolve({ id: 'team-1' }),
},
],
pageInfo: { hasNextPage: false, endCursor: undefined },
});

const issues = await service.searchDependicusIssues(undefined, {
includeClosed: true,
createdSince: new Date('2025-01-01'),
includeTeamId: true,
});

expect(mockClient.issues).toHaveBeenCalledWith({
filter: {
labels: { id: { eq: 'label-123' } },
createdAt: { gte: '2025-01-01T00:00:00.000Z' },
},
first: 100,
after: undefined,
});
expect(issues[0]).toMatchObject({
id: 'issue-1',
teamId: 'team-1',
createdAt: '2025-01-10T00:00:00.000Z',
state: { type: 'completed', name: 'Done' },
});
});
});

describe('createIssue', () => {
Expand Down
32 changes: 29 additions & 3 deletions src/linear/LinearService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export interface DependicusIssue {
id: string;
identifier: string; // e.g., "CORE-123"
title: string;
/** ISO date string when the issue was created */
createdAt?: string;
/** Linear team UUID for the issue, when requested by the caller */
teamId?: string;
/**
* For single-dependency issues: the dependency name (e.g., "react")
* For group issues: the group name (e.g., "sentry")
Expand Down Expand Up @@ -41,6 +45,15 @@ export interface CreateIssueParams {
delegateId?: string;
}

export interface SearchDependicusIssuesOptions {
/** Include completed and canceled issues in addition to open issues. */
includeClosed?: boolean;
/** Only return issues created at or after this timestamp. */
createdSince?: Date;
/** Populate `teamId` on returned issues. */
includeTeamId?: boolean;
}

export class LinearService {
private client: LinearClient;
private labelId: string | undefined;
Expand Down Expand Up @@ -100,6 +113,7 @@ export class LinearService {
*/
async searchDependicusIssues(
onProgress?: (fetched: number, page: number) => void,
options: SearchDependicusIssuesOptions = {},
): Promise<DependicusIssue[]> {
const labelId = await this.ensureLabel();

Expand All @@ -116,9 +130,16 @@ export class LinearService {
const issues = await this.client.issues({
filter: {
labels: { id: { eq: labelId } },
state: {
type: { nin: ['completed', 'canceled'] },
},
...(options.includeClosed
? {}
: {
state: {
type: { nin: ['completed', 'canceled'] },
},
}),
...(options.createdSince
? { createdAt: { gte: options.createdSince.toISOString() } }
: {}),
},
first: 100, // Max page size for efficiency
after: afterCursor,
Expand All @@ -131,11 +152,14 @@ export class LinearService {
if (!dependencyName) continue;

const state = await issue.state;
const team = options.includeTeamId ? await issue.team : undefined;

existingIssues.push({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
createdAt: issue.createdAt?.toISOString(),
teamId: team?.id,
dependencyName,
isGroup: groupName !== undefined,
dueDate: issue.dueDate ?? undefined,
Expand Down Expand Up @@ -303,6 +327,8 @@ export class LinearService {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
createdAt: issue.createdAt?.toISOString(),
teamId: undefined,
dependencyName: extractedName,
isGroup: groupName !== undefined,
dueDate: issue.dueDate ?? undefined,
Expand Down
1 change: 1 addition & 0 deletions src/linear/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export {
reconcileIssues,
type IssueReconcilerConfig,
type ReconciliationResult,
type TeamIssueRateLimitConfig,
} from './issueReconciler';
84 changes: 84 additions & 0 deletions src/linear/issueReconciler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,90 @@ describe('reconcileIssues', () => {
expect(result.created).toBe(0);
});

it('skips new issues when the team issue limit is already reached', async () => {
const mockState = { type: 'unstarted', name: 'Todo' };
mockClient.issues
.mockResolvedValueOnce({
nodes: [],
pageInfo: { hasNextPage: false, endCursor: undefined },
})
.mockResolvedValueOnce({
nodes: [
{
id: 'recent-issue-1',
identifier: 'TEST-10',
title: '[Dependicus] Update recent-pkg from 1.0.0 to 2.0.0',
createdAt: new Date('2026-05-20'),
dueDate: undefined,
updatedAt: new Date('2026-05-20'),
state: Promise.resolve(mockState),
team: Promise.resolve({ id: 'linear-team-123' }),
},
],
pageInfo: { hasNextPage: false, endCursor: undefined },
});

const v = makeVersion();
populateFacts(store, 'test-pkg', v);
const deps: DirectDependency[] = [makeDep('test-pkg', [v])];

const result = await reconcileIssues(
deps,
store,
{
...defaultConfig,
dryRun: false,
teamIssueRateLimit: { windowDays: 7, maxIssuesPerTeam: 1 },
},
testGetLinearIssueSpec,
);

expect(result.created).toBe(0);
expect(mockClient.createIssue).not.toHaveBeenCalled();
});

it('counts new issues opened earlier in the same run against the team limit', async () => {
mockClient.issues
.mockResolvedValueOnce({
nodes: [],
pageInfo: { hasNextPage: false, endCursor: undefined },
})
.mockResolvedValueOnce({
nodes: [],
pageInfo: { hasNextPage: false, endCursor: undefined },
})
.mockResolvedValueOnce({
nodes: [],
pageInfo: { hasNextPage: false, endCursor: undefined },
});

const first = makeVersion();
const second = makeVersion();
populateFacts(store, 'first-pkg', first);
populateFacts(store, 'second-pkg', second);
const deps: DirectDependency[] = [
makeDep('first-pkg', [first]),
makeDep('second-pkg', [second]),
];

const result = await reconcileIssues(
deps,
store,
{
...defaultConfig,
dryRun: false,
teamIssueRateLimit: { windowDays: 7, maxIssuesPerTeam: 1 },
},
testGetLinearIssueSpec,
);

expect(result.created).toBe(1);
expect(mockClient.createIssue).toHaveBeenCalledTimes(1);
expect(mockClient.createIssue.mock.calls[0]![0]).toMatchObject({
title: expect.stringContaining('first-pkg'),
});
});

it('updates existing issues when package is still outdated', async () => {
const mockState = { type: 'unstarted', name: 'Todo' };
mockClient.issues.mockResolvedValue({
Expand Down
Loading
Loading