diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fb5bb..20810ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + ### 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. diff --git a/docs/docs/linearissues.md b/docs/docs/linearissues.md index 1390435..03d80e4 100644 --- a/docs/docs/linearissues.md +++ b/docs/docs/linearissues.md @@ -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); @@ -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`: diff --git a/src/cli.test.ts b/src/cli.test.ts index c484d73..d302404 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -595,6 +595,7 @@ describe('dependicusCli', () => { cooldownDays: 7, allowNewIssues: true, skipStateNames: ['done'], + teamIssueRateLimit: { windowDays: 7, maxIssuesPerTeam: 3 }, }, }; @@ -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', () => { diff --git a/src/cli.ts b/src/cli.ts index aa41bb0..b405e69 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, 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'; @@ -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?: { @@ -415,6 +417,7 @@ export function dependicusCli(config: DependicusCliConfig): { options.rateLimitDays != null ? Number(options.rateLimitDays) : linearConfig.rateLimitDays, + teamIssueRateLimit: linearConfig.teamIssueRateLimit, }, effectiveGetLinearIssueSpec, ); diff --git a/src/linear/LinearService.test.ts b/src/linear/LinearService.test.ts index 2adda74..f073b41 100644 --- a/src/linear/LinearService.test.ts +++ b/src/linear/LinearService.test.ts @@ -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), @@ -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', @@ -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', () => { diff --git a/src/linear/LinearService.ts b/src/linear/LinearService.ts index c8cd498..4297f10 100644 --- a/src/linear/LinearService.ts +++ b/src/linear/LinearService.ts @@ -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") @@ -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; @@ -100,6 +113,7 @@ export class LinearService { */ async searchDependicusIssues( onProgress?: (fetched: number, page: number) => void, + options: SearchDependicusIssuesOptions = {}, ): Promise { const labelId = await this.ensureLabel(); @@ -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, @@ -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, @@ -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, diff --git a/src/linear/index.ts b/src/linear/index.ts index a7cfeae..cf52262 100644 --- a/src/linear/index.ts +++ b/src/linear/index.ts @@ -12,4 +12,5 @@ export { reconcileIssues, type IssueReconcilerConfig, type ReconciliationResult, + type TeamIssueRateLimitConfig, } from './issueReconciler'; diff --git a/src/linear/issueReconciler.test.ts b/src/linear/issueReconciler.test.ts index a650dbc..fa81f27 100644 --- a/src/linear/issueReconciler.test.ts +++ b/src/linear/issueReconciler.test.ts @@ -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({ diff --git a/src/linear/issueReconciler.ts b/src/linear/issueReconciler.ts index 556a9d8..653426e 100644 --- a/src/linear/issueReconciler.ts +++ b/src/linear/issueReconciler.ts @@ -52,6 +52,15 @@ export interface IssueReconcilerConfig { 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; +} + +export interface TeamIssueRateLimitConfig { + /** Rolling window size in days. */ + windowDays: number; + /** Maximum Dependicus issues a team may receive during the window. */ + maxIssuesPerTeam: number; } export interface ReconciliationResult { @@ -62,6 +71,12 @@ export interface ReconciliationResult { closedDuplicates: number; } +interface TeamIssueRateLimitState { + windowDays: number; + maxIssuesPerTeam: number; + issueCountsByTeam: Map; +} + /** * Check if any package in the list has had a major version published since the given date. * Used for groups where we can't compare version numbers from the issue title. @@ -210,6 +225,64 @@ function findExistingIssue( return undefined; } +function validateTeamIssueRateLimitConfig( + config: TeamIssueRateLimitConfig, +): TeamIssueRateLimitConfig { + if (!Number.isFinite(config.windowDays) || config.windowDays <= 0) { + throw new Error('teamIssueRateLimit.windowDays must be greater than 0'); + } + if (!Number.isFinite(config.maxIssuesPerTeam) || config.maxIssuesPerTeam <= 0) { + throw new Error('teamIssueRateLimit.maxIssuesPerTeam must be greater than 0'); + } + return config; +} + +async function loadTeamIssueRateLimitState( + linearService: LinearService, + config: TeamIssueRateLimitConfig | undefined, +): Promise { + if (!config) return undefined; + + const { windowDays, maxIssuesPerTeam } = validateTeamIssueRateLimitConfig(config); + const createdSince = new Date(); + createdSince.setDate(createdSince.getDate() - windowDays); + + process.stderr.write( + `Searching for Dependicus issues created in the last ${windowDays} days...\n`, + ); + const recentIssues = await linearService.searchDependicusIssues( + (fetched, page) => { + process.stderr.write(` Fetched ${fetched} recent issues (page ${page})...\n`); + }, + { includeClosed: true, createdSince, includeTeamId: true }, + ); + + const issueCountsByTeam = new Map(); + for (const issue of recentIssues) { + if (!issue.teamId) continue; + issueCountsByTeam.set(issue.teamId, (issueCountsByTeam.get(issue.teamId) ?? 0) + 1); + } + + return { windowDays, maxIssuesPerTeam, issueCountsByTeam }; +} + +function shouldSkipDueToTeamIssueRateLimit( + state: TeamIssueRateLimitState | undefined, + teamId: string, +): { count: number; limit: number; windowDays: number } | undefined { + if (!state) return undefined; + + const count = state.issueCountsByTeam.get(teamId) ?? 0; + if (count < state.maxIssuesPerTeam) return undefined; + + return { count, limit: state.maxIssuesPerTeam, windowDays: state.windowDays }; +} + +function recordTeamIssueOpened(state: TeamIssueRateLimitState | undefined, teamId: string): void { + if (!state) return; + state.issueCountsByTeam.set(teamId, (state.issueCountsByTeam.get(teamId) ?? 0) + 1); +} + /** Default policy when the issue spec doesn't specify one. */ const DEFAULT_POLICY: LinearPolicy = { type: 'fyi' }; @@ -475,6 +548,11 @@ export async function reconcileIssues( }); process.stderr.write(`Found ${existingIssues.length} existing issues\n`); + const teamIssueRateLimitState = await loadTeamIssueRateLimitState( + linearService, + allowNewIssues ? config.teamIssueRateLimit : undefined, + ); + // Build maps for deduplication const existingIssuesByName = new Map(); const existingIssuesByTitle = new Set(); @@ -695,6 +773,17 @@ export async function reconcileIssues( continue; } + const teamIssueRateLimit = shouldSkipDueToTeamIssueRateLimit( + teamIssueRateLimitState, + dep.teamId, + ); + if (teamIssueRateLimit) { + process.stderr.write( + `Skipping ${dep.name} - team issue limit reached (${teamIssueRateLimit.count}/${teamIssueRateLimit.limit} in ${teamIssueRateLimit.windowDays} days)\n`, + ); + continue; + } + // Determine delegate from assignment const delegateId = dep.assignment.type === 'delegate' ? dep.assignment.assigneeId : undefined; @@ -727,6 +816,7 @@ export async function reconcileIssues( ); existingIssuesByTitle.add(fullTitle); + recordTeamIssueOpened(teamIssueRateLimitState, dep.teamId); if (!dryRun) { process.stderr.write( @@ -748,6 +838,7 @@ export async function reconcileIssues( }); existingIssuesByTitle.add(fullTitle); + recordTeamIssueOpened(teamIssueRateLimitState, dep.teamId); if (!dryRun) { const delegateNote = delegateId ? ' [delegated]' : ''; @@ -889,6 +980,17 @@ export async function reconcileIssues( continue; } + const teamIssueRateLimit = shouldSkipDueToTeamIssueRateLimit( + teamIssueRateLimitState, + group.teamId, + ); + if (teamIssueRateLimit) { + process.stderr.write( + `Skipping ${group.groupName} group - team issue limit reached (${teamIssueRateLimit.count}/${teamIssueRateLimit.limit} in ${teamIssueRateLimit.windowDays} days)\n`, + ); + continue; + } + // Check if a closed issue with the same title exists — reopen instead of creating const closedIssue = await linearService.findClosedIssue(group.groupName, fullTitle); if (closedIssue) { @@ -916,6 +1018,7 @@ export async function reconcileIssues( ); existingIssuesByTitle.add(fullTitle); + recordTeamIssueOpened(teamIssueRateLimitState, group.teamId); if (!dryRun) { process.stderr.write( @@ -936,6 +1039,7 @@ export async function reconcileIssues( }); existingIssuesByTitle.add(fullTitle); + recordTeamIssueOpened(teamIssueRateLimitState, group.teamId); if (!dryRun) { process.stderr.write(