Skip to content

Commit ee67bc8

Browse files
authored
feat(tracker): add assignee and label filters to candidate issues (#34)
* feat(tracker): add assignee and label filters to candidate issues Add `filter.assignee` and `filter.label` configuration to the tracker section in WORKFLOW.md. Candidate issues are filtered before dispatch: - `filter.assignee`: issue must be assigned to any one of the listed users (OR semantics); GitHub login or Asana email - `filter.label`: issue must have at least one of the listed labels (OR) - Both filters AND together; empty/missing = no filter (all pass) - Case-insensitive matching; CSV string or YAML array accepted New file `src/filter.ts` provides a pure `matchesFilter()` function. Asana adapter fetches `assignee.email`; GitHub adapter fetches `assignees(first:1){nodes{login}}` in all three GraphQL queries. Both `fetchCandidateIssues()` implementations apply the filter. 326 tests across 13 files — all passing. * fix(tracker): fix label comparison, variable shadowing, and use isTrackerError - filter.ts: apply toLowerCase() to both sides of label comparison for symmetric case-insensitive matching (previously only filter side was lowercased) - filter.test.ts: add missing mirror test for uppercase issue label vs lowercase filter value - github.ts: rename local `matchesFilter` boolean to `statusMatches` to eliminate shadowing of the imported matchesFilter function - asana.ts, github.ts: replace `'code' in issues` with isTrackerError() type guard for correct and idiomatic error discrimination - asana.ts, github.ts: add comment to fetchIssuesByStates explaining that filter is intentionally not applied (reconciliation must see all issues) * refactor(tracker): extract IssueFilter type, make filter required, update docs - types.ts: add named IssueFilter interface; change TrackerConfig.filter from optional inline type to required IssueFilter (buildConfig always populates it, so optional was misleading) - filter.ts: import IssueFilter directly instead of NonNullable<TrackerConfig['filter']>, removing the structural coupling to TrackerConfig - config.ts: use IssueFilter as buildFilterConfig return type - asana.ts, github.ts: remove dead `?? { assignee: [], label: [] }` fallbacks (filter is now always present from buildConfig) - server.test.ts, tools.test.ts: add filter field to direct TrackerConfig constructions to satisfy the now-required field - README.md: clarify filter semantics (dispatch-time only, unassigned exclusion, YAML array form, primary assignee note) - WORKFLOW.md, apps/work-please/WORKFLOW.md: expand filter comments with CSV/array form, case-sensitivity, AND/OR semantics, dispatch-time scope * chore(tracker): apply code review suggestions - refactor(types): rename assignee: string|null to assignees: string[] to support multiple assignees (cubic-dev-ai feedback) - refactor(tracker/github): use ProjectV2 items query param for server-side filtering (assignee:a,b label:x OR syntax); fetch all assignee logins instead of only the first - perf(filter): use Set for O(1) label lookups in matchesFilter (gemini-code-assist feedback)
1 parent 85aabfb commit ee67bc8

19 files changed

Lines changed: 595 additions & 18 deletions

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ For full technical details, see [SPEC.md](SPEC.md).
3636

3737
- **Multi-tracker support** — Dispatch work from Asana tasks or GitHub Projects v2 items on a
3838
fixed cadence.
39+
- **Assignee & label filters** — Filter eligible issues by assignee and/or label. Multiple values
40+
within each filter use OR logic; assignee and label filters are ANDed when both are specified.
41+
Applies at dispatch time only — already-running issues are unaffected. Configured per-tracker
42+
in `WORKFLOW.md`.
3943
- **Isolated workspaces** — Each issue gets a dedicated directory; workspaces persist across runs.
4044
- **`WORKFLOW.md` config** — Version agent prompt and runtime settings alongside your code.
4145
- **Bounded concurrency** — Global and per-state concurrent agent limits.
@@ -272,6 +276,13 @@ tracker:
272276
# private_key: $GITHUB_APP_PRIVATE_KEY # Optional: GitHub App private key or $ENV_VAR
273277
# installation_id: 67890 # Optional: GitHub App installation ID
274278

279+
# --- Shared filter fields (both trackers) ---
280+
# filter:
281+
# assignee: user1, user2 # Optional: CSV or YAML array; case-insensitive OR match
282+
# # (unassigned issues are excluded when this filter is set)
283+
# label: bug, feature # Optional: CSV or YAML array; case-insensitive OR match
284+
# Both filters AND together when both are specified. Applies at dispatch time only.
285+
275286
polling:
276287
interval_ms: 30000 # Optional: poll cadence in ms, default 30000
277288

@@ -318,6 +329,7 @@ Your prompt template goes here. Available variables:
318329
- {{ issue.description }} — Issue body/description
319330
- {{ issue.state }} — Current tracker state name
320331
- {{ issue.url }} — Issue URL
332+
- {{ issue.assignee }} — Primary assignee login (GitHub) or email (Asana), or null if unassigned
321333
- {{ issue.labels }} — Array of label strings (normalized to lowercase)
322334
- {{ issue.blocked_by }} — Array of blocker refs (each has id, identifier, state)
323335
- {{ issue.priority }} — Numeric priority or null

‎WORKFLOW.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ tracker:
1010
terminal_states:
1111
- Done
1212
- Cancelled
13+
# filter: # optional: applies at dispatch time only
14+
# assignee: user1, user2 # CSV or YAML array; case-insensitive OR; unassigned issues excluded
15+
# label: bug, feature # CSV or YAML array; case-insensitive OR; at least one label must match
16+
# (Both fields AND together when both are set)
1317
polling:
1418
interval_ms: 30000 # default: 30s
1519
workspace:

‎apps/work-please/WORKFLOW.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ tracker:
1111
terminal_states:
1212
- Done
1313
- Cancelled
14+
# filter: # optional: applies at dispatch time only
15+
# assignee: user1, user2 # CSV or YAML array; case-insensitive OR; unassigned issues excluded
16+
# label: bug, feature # CSV or YAML array; case-insensitive OR; at least one label must match
17+
# (Both fields AND together when both are set)
1418
polling:
1519
interval_ms: 30000
1620
workspace:

‎apps/work-please/src/agent-runner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ function makeIssue() {
217217
state: 'In Progress',
218218
branch_name: null,
219219
url: null,
220+
assignees: [],
220221
labels: [],
221222
blocked_by: [],
222223
created_at: null,

‎apps/work-please/src/config.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,55 @@ describe('per-state concurrency limits', () => {
413413
expect(maxConcurrentForState(config, 'In Progress')).toBe(2)
414414
})
415415
})
416+
417+
describe('tracker filter config', () => {
418+
it('defaults to empty assignee and label arrays when filter section absent', () => {
419+
const config = buildConfig(makeWorkflow({
420+
tracker: { kind: 'asana', api_key: 'tok', project_gid: 'gid' },
421+
}))
422+
expect(config.tracker.filter).toEqual({ assignee: [], label: [] })
423+
})
424+
425+
it('defaults to empty arrays when filter fields are missing', () => {
426+
const config = buildConfig(makeWorkflow({
427+
tracker: { kind: 'github_projects', api_key: 'tok', owner: 'org', project_number: 1, filter: {} },
428+
}))
429+
expect(config.tracker.filter).toEqual({ assignee: [], label: [] })
430+
})
431+
432+
it('parses filter.assignee from CSV string', () => {
433+
const config = buildConfig(makeWorkflow({
434+
tracker: { kind: 'asana', api_key: 'tok', project_gid: 'gid', filter: { assignee: 'user1, user2' } },
435+
}))
436+
expect(config.tracker.filter?.assignee).toEqual(['user1', 'user2'])
437+
})
438+
439+
it('parses filter.assignee from YAML array', () => {
440+
const config = buildConfig(makeWorkflow({
441+
tracker: { kind: 'github_projects', api_key: 'tok', owner: 'org', project_number: 1, filter: { assignee: ['alice', 'bob'] } },
442+
}))
443+
expect(config.tracker.filter?.assignee).toEqual(['alice', 'bob'])
444+
})
445+
446+
it('parses filter.label from CSV string', () => {
447+
const config = buildConfig(makeWorkflow({
448+
tracker: { kind: 'asana', api_key: 'tok', project_gid: 'gid', filter: { label: 'bug, feature' } },
449+
}))
450+
expect(config.tracker.filter?.label).toEqual(['bug', 'feature'])
451+
})
452+
453+
it('parses filter.label from YAML array', () => {
454+
const config = buildConfig(makeWorkflow({
455+
tracker: { kind: 'github_projects', api_key: 'tok', owner: 'org', project_number: 1, filter: { label: ['bug', 'enhancement'] } },
456+
}))
457+
expect(config.tracker.filter?.label).toEqual(['bug', 'enhancement'])
458+
})
459+
460+
it('parses both assignee and label together', () => {
461+
const config = buildConfig(makeWorkflow({
462+
tracker: { kind: 'asana', api_key: 'tok', project_gid: 'gid', filter: { assignee: 'user1', label: 'bug' } },
463+
}))
464+
expect(config.tracker.filter?.assignee).toEqual(['user1'])
465+
expect(config.tracker.filter?.label).toEqual(['bug'])
466+
})
467+
})

‎apps/work-please/src/config.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ServiceConfig, WorkflowDefinition } from './types'
1+
import type { IssueFilter, ServiceConfig, WorkflowDefinition } from './types'
22
import { tmpdir } from 'node:os'
33
import { join, sep } from 'node:path'
44
import process from 'node:process'
@@ -75,6 +75,7 @@ export function buildConfig(workflow: WorkflowDefinition): ServiceConfig {
7575

7676
function buildTrackerConfig(kind: string | null, tracker: Record<string, unknown>): ServiceConfig['tracker'] {
7777
const label_prefix = stringValue(tracker.label_prefix) ?? null
78+
const filter = buildFilterConfig(sectionMap(tracker, 'filter'))
7879

7980
if (kind === 'asana') {
8081
return {
@@ -85,6 +86,7 @@ function buildTrackerConfig(kind: string | null, tracker: Record<string, unknown
8586
active_sections: csvValue(tracker.active_sections) ?? csvValue(tracker.active_states) ?? DEFAULTS.ASANA_ACTIVE_SECTIONS,
8687
terminal_sections: csvValue(tracker.terminal_sections) ?? csvValue(tracker.terminal_states) ?? DEFAULTS.ASANA_TERMINAL_SECTIONS,
8788
label_prefix,
89+
filter,
8890
}
8991
}
9092

@@ -102,6 +104,7 @@ function buildTrackerConfig(kind: string | null, tracker: Record<string, unknown
102104
private_key: resolveEnvValue(stringValue(tracker.private_key), process.env.GITHUB_APP_PRIVATE_KEY),
103105
installation_id: resolveInstallationId(tracker.installation_id),
104106
label_prefix,
107+
filter,
105108
}
106109
}
107110

@@ -110,6 +113,14 @@ function buildTrackerConfig(kind: string | null, tracker: Record<string, unknown
110113
endpoint: stringValue(tracker.endpoint) ?? '',
111114
api_key: resolveEnvValue(stringValue(tracker.api_key), undefined),
112115
label_prefix,
116+
filter,
117+
}
118+
}
119+
120+
function buildFilterConfig(filter: Record<string, unknown>): IssueFilter {
121+
return {
122+
assignee: csvValue(filter.assignee) ?? [],
123+
label: csvValue(filter.label) ?? [],
113124
}
114125
}
115126

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { Issue } from './types'
2+
import { describe, expect, it } from 'bun:test'
3+
import { matchesFilter } from './filter'
4+
5+
function makeIssue(overrides: Partial<Issue> = {}): Issue {
6+
return {
7+
id: 'i1',
8+
identifier: '#1',
9+
title: 'Test',
10+
description: null,
11+
priority: null,
12+
state: 'In Progress',
13+
branch_name: null,
14+
url: null,
15+
assignees: [],
16+
labels: [],
17+
blocked_by: [],
18+
created_at: null,
19+
updated_at: null,
20+
...overrides,
21+
}
22+
}
23+
24+
describe('matchesFilter', () => {
25+
it('empty filter passes all issues', () => {
26+
const issue = makeIssue()
27+
expect(matchesFilter(issue, { assignee: [], label: [] })).toBe(true)
28+
})
29+
30+
it('assignee filter: matches when issue assignee is in list (OR)', () => {
31+
const issue = makeIssue({ assignees: ['user1'] })
32+
expect(matchesFilter(issue, { assignee: ['user1', 'user2'], label: [] })).toBe(true)
33+
})
34+
35+
it('assignee filter: matches second item in list (OR)', () => {
36+
const issue = makeIssue({ assignees: ['user2'] })
37+
expect(matchesFilter(issue, { assignee: ['user1', 'user2'], label: [] })).toBe(true)
38+
})
39+
40+
it('assignee filter: no match when assignee not in list', () => {
41+
const issue = makeIssue({ assignees: ['other'] })
42+
expect(matchesFilter(issue, { assignee: ['user1', 'user2'], label: [] })).toBe(false)
43+
})
44+
45+
it('assignee filter: no match when issue has no assignee', () => {
46+
const issue = makeIssue({ assignees: [] })
47+
expect(matchesFilter(issue, { assignee: ['user1'], label: [] })).toBe(false)
48+
})
49+
50+
it('assignee filter: case-insensitive matching', () => {
51+
const issue = makeIssue({ assignees: ['User1'] })
52+
expect(matchesFilter(issue, { assignee: ['user1'], label: [] })).toBe(true)
53+
})
54+
55+
it('assignee filter: case-insensitive (filter uppercase, assignee lowercase)', () => {
56+
const issue = makeIssue({ assignees: ['alice'] })
57+
expect(matchesFilter(issue, { assignee: ['Alice'], label: [] })).toBe(true)
58+
})
59+
60+
it('assignee filter: matches when any of multiple issue assignees is in filter list', () => {
61+
const issue = makeIssue({ assignees: ['alice', 'bob'] })
62+
expect(matchesFilter(issue, { assignee: ['bob'], label: [] })).toBe(true)
63+
})
64+
65+
it('label filter: matches when issue has at least one matching label (OR)', () => {
66+
const issue = makeIssue({ labels: ['bug', 'urgent'] })
67+
expect(matchesFilter(issue, { assignee: [], label: ['bug', 'feature'] })).toBe(true)
68+
})
69+
70+
it('label filter: matches second item in list (OR)', () => {
71+
const issue = makeIssue({ labels: ['feature'] })
72+
expect(matchesFilter(issue, { assignee: [], label: ['bug', 'feature'] })).toBe(true)
73+
})
74+
75+
it('label filter: no match when issue has none of the required labels', () => {
76+
const issue = makeIssue({ labels: ['docs'] })
77+
expect(matchesFilter(issue, { assignee: [], label: ['bug', 'feature'] })).toBe(false)
78+
})
79+
80+
it('label filter: no match when issue has no labels', () => {
81+
const issue = makeIssue({ labels: [] })
82+
expect(matchesFilter(issue, { assignee: [], label: ['bug'] })).toBe(false)
83+
})
84+
85+
it('label filter: case-insensitive (filter uppercase, labels already lowercase)', () => {
86+
const issue = makeIssue({ labels: ['bug'] })
87+
expect(matchesFilter(issue, { assignee: [], label: ['Bug'] })).toBe(true)
88+
})
89+
90+
it('label filter: case-insensitive (issue label uppercase, filter lowercase)', () => {
91+
const issue = makeIssue({ labels: ['Bug'] })
92+
expect(matchesFilter(issue, { assignee: [], label: ['bug'] })).toBe(true)
93+
})
94+
95+
it('combined assignee + label: both must match (AND)', () => {
96+
const issue = makeIssue({ assignees: ['user1'], labels: ['bug'] })
97+
expect(matchesFilter(issue, { assignee: ['user1'], label: ['bug'] })).toBe(true)
98+
})
99+
100+
it('combined: fails when assignee matches but label does not', () => {
101+
const issue = makeIssue({ assignees: ['user1'], labels: ['docs'] })
102+
expect(matchesFilter(issue, { assignee: ['user1'], label: ['bug'] })).toBe(false)
103+
})
104+
105+
it('combined: fails when label matches but assignee does not', () => {
106+
const issue = makeIssue({ assignees: ['other'], labels: ['bug'] })
107+
expect(matchesFilter(issue, { assignee: ['user1'], label: ['bug'] })).toBe(false)
108+
})
109+
})

‎apps/work-please/src/filter.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Issue, IssueFilter } from './types'
2+
3+
export function matchesFilter(issue: Issue, filter: IssueFilter): boolean {
4+
if (filter.assignee.length > 0) {
5+
const filterAssignees = new Set(filter.assignee.map(a => a.toLowerCase()))
6+
if (!issue.assignees.some(a => filterAssignees.has(a.toLowerCase()))) {
7+
return false
8+
}
9+
}
10+
if (filter.label.length > 0) {
11+
const filterLabels = new Set(filter.label.map(l => l.toLowerCase()))
12+
if (!issue.labels.some(l => filterLabels.has(l.toLowerCase()))) {
13+
return false
14+
}
15+
}
16+
return true
17+
}

‎apps/work-please/src/label.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
1212
state: 'In Progress',
1313
branch_name: null,
1414
url: null,
15+
assignees: [],
1516
labels: [],
1617
blocked_by: [],
1718
created_at: null,
@@ -29,6 +30,7 @@ function makeGithubConfig(labelPrefix: string | null): ServiceConfig {
2930
owner: 'myorg',
3031
project_number: 1,
3132
label_prefix: labelPrefix,
33+
filter: { assignee: [], label: [] },
3234
},
3335
polling: { interval_ms: 30000 },
3436
workspace: { root: '/tmp' },
@@ -47,6 +49,7 @@ function makeAsanaConfig(): ServiceConfig {
4749
api_key: 'asana-token',
4850
project_gid: 'gid123',
4951
label_prefix: 'work-please',
52+
filter: { assignee: [], label: [] },
5053
},
5154
polling: { interval_ms: 30000 },
5255
workspace: { root: '/tmp' },

‎apps/work-please/src/orchestrator.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
5757
state: 'In Progress',
5858
branch_name: null,
5959
url: null,
60+
assignees: [],
6061
labels: [],
6162
blocked_by: [],
6263
created_at: null,

0 commit comments

Comments
 (0)