-
Notifications
You must be signed in to change notification settings - Fork 1
feat(task-runner): add task filtering by tags and names #250
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| /** | ||
| * Configuration options for filtering tasks before execution. | ||
| */ | ||
| export interface TaskFilterConfig { | ||
| /** | ||
| * Only include tasks that match any of these tags. | ||
| */ | ||
| includeTags?: string[]; | ||
| /** | ||
| * Exclude tasks that match any of these tags, overriding inclusions. | ||
| */ | ||
| excludeTags?: string[]; | ||
| /** | ||
| * Only include tasks with these specific names. | ||
| */ | ||
| includeNames?: string[]; | ||
| /** | ||
| * Exclude tasks with these specific names, overriding inclusions. | ||
| */ | ||
| excludeNames?: string[]; | ||
| /** | ||
| * If true (default), automatically include all necessary dependencies | ||
| * of the selected tasks, unless those dependencies are explicitly excluded. | ||
| */ | ||
| includeDependencies?: boolean; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import { TaskStep } from "../TaskStep.js"; | ||
| import { TaskFilterConfig } from "../contracts/TaskFilterConfig.js"; | ||
|
|
||
| /** | ||
| * Filters an array of TaskStep objects based on the provided configuration. | ||
| * | ||
| * @param steps The original list of tasks. | ||
| * @param config The filter configuration. | ||
| * @returns A new array of filtered tasks. | ||
| */ | ||
| export function filterTasks<TContext>( | ||
|
Check failure on line 11 in src/utils/TaskFilter.ts
|
||
| steps: TaskStep<TContext>[], | ||
| config: TaskFilterConfig | ||
| ): TaskStep<TContext>[] { | ||
| const { | ||
| includeTags, | ||
| excludeTags, | ||
| includeNames, | ||
| excludeNames, | ||
| includeDependencies = true, | ||
| } = config; | ||
|
|
||
| // Helper sets for quick lookup | ||
| const inclTagsSet = includeTags ? new Set(includeTags) : null; | ||
| const exclTagsSet = excludeTags ? new Set(excludeTags) : null; | ||
| const inclNamesSet = includeNames ? new Set(includeNames) : null; | ||
| const exclNamesSet = excludeNames ? new Set(excludeNames) : null; | ||
|
|
||
| // Map of task names to task objects for quick dependency lookup | ||
| const taskMap = new Map<string, TaskStep<TContext>>(); | ||
| for (let i = 0; i < steps.length; i++) { | ||
| taskMap.set(steps[i].name, steps[i]); | ||
| } | ||
|
Check warning on line 33 in src/utils/TaskFilter.ts
|
||
|
|
||
| // 1. Determine initially included tasks | ||
| const includedTasks = new Set<string>(); | ||
|
|
||
| for (let i = 0; i < steps.length; i++) { | ||
| const task = steps[i]; | ||
| let isIncluded = false; | ||
|
|
||
| // If no inclusion criteria provided, include all initially | ||
| if (!inclTagsSet && !inclNamesSet) { | ||
| isIncluded = true; | ||
| } else { | ||
| // Check if it matches includeNames | ||
| if (inclNamesSet && inclNamesSet.has(task.name)) { | ||
|
Check warning on line 47 in src/utils/TaskFilter.ts
|
||
| isIncluded = true; | ||
| } | ||
| // Check if it matches includeTags | ||
| if (!isIncluded && inclTagsSet && task.tags) { | ||
| for (let j = 0; j < task.tags.length; j++) { | ||
| if (inclTagsSet.has(task.tags[j])) { | ||
| isIncluded = true; | ||
| break; | ||
| } | ||
| } | ||
|
Check warning on line 57 in src/utils/TaskFilter.ts
|
||
| } | ||
| } | ||
|
|
||
| if (isIncluded) { | ||
| includedTasks.add(task.name); | ||
| } | ||
| } | ||
|
Check warning on line 64 in src/utils/TaskFilter.ts
|
||
|
|
||
| // 2. Remove explicitly excluded tasks | ||
| const excludedTasks = new Set<string>(); | ||
| for (let i = 0; i < steps.length; i++) { | ||
| const task = steps[i]; | ||
| let isExcluded = false; | ||
|
|
||
| if (exclNamesSet && exclNamesSet.has(task.name)) { | ||
|
Check warning on line 72 in src/utils/TaskFilter.ts
|
||
| isExcluded = true; | ||
| } | ||
|
|
||
| if (!isExcluded && exclTagsSet && task.tags) { | ||
| for (let j = 0; j < task.tags.length; j++) { | ||
| if (exclTagsSet.has(task.tags[j])) { | ||
| isExcluded = true; | ||
| break; | ||
| } | ||
| } | ||
|
Check warning on line 82 in src/utils/TaskFilter.ts
|
||
| } | ||
|
|
||
| if (isExcluded) { | ||
| excludedTasks.add(task.name); | ||
| includedTasks.delete(task.name); | ||
| } | ||
| } | ||
|
Check warning on line 89 in src/utils/TaskFilter.ts
|
||
|
|
||
| // 3. Resolve dependencies if needed | ||
| if (includeDependencies) { | ||
| const queue = Array.from(includedTasks); | ||
| let queueIdx = 0; | ||
|
|
||
| while (queueIdx < queue.length) { | ||
| const taskName = queue[queueIdx++]; | ||
| const task = taskMap.get(taskName); | ||
|
|
||
| if (task && task.dependencies) { | ||
|
Check warning on line 100 in src/utils/TaskFilter.ts
|
||
| for (let i = 0; i < task.dependencies.length; i++) { | ||
| const depName = task.dependencies[i]; | ||
|
|
||
| // If the dependency isn't already included and isn't explicitly excluded | ||
| if (!includedTasks.has(depName) && !excludedTasks.has(depName)) { | ||
| includedTasks.add(depName); | ||
| queue.push(depName); | ||
| } | ||
| } | ||
|
Check warning on line 109 in src/utils/TaskFilter.ts
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| // 4. Return the filtered tasks in the original order | ||
| const filteredSteps: TaskStep<TContext>[] = []; | ||
| for (let i = 0; i < steps.length; i++) { | ||
| if (includedTasks.has(steps[i].name)) { | ||
| filteredSteps.push(steps[i]); | ||
| } | ||
| } | ||
|
Check warning on line 120 in src/utils/TaskFilter.ts
|
||
|
|
||
| return filteredSteps; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { describe, it, expect, vi } from "vitest"; | ||
| import { TaskRunner } from "../src/TaskRunner.js"; | ||
| import { TaskStep } from "../src/TaskStep.js"; | ||
| import { TaskGraphValidationError } from "../src/TaskGraphValidationError.js"; | ||
|
|
||
| describe("TaskRunner Filtering Integration", () => { | ||
| it("should execute only filtered tasks and their dependencies", async () => { | ||
| const runner = new TaskRunner({}); | ||
|
|
||
| const taskA: TaskStep<unknown> = { name: "taskA", tags: ["core"], run: vi.fn().mockResolvedValue({ status: "success" }) }; | ||
| const taskB: TaskStep<unknown> = { name: "taskB", dependencies: ["taskA"], tags: ["feature"], run: vi.fn().mockResolvedValue({ status: "success" }) }; | ||
| const taskC: TaskStep<unknown> = { name: "taskC", dependencies: ["taskA"], tags: ["feature"], run: vi.fn().mockResolvedValue({ status: "success" }) }; | ||
|
|
||
| const steps = [taskA, taskB, taskC]; | ||
|
|
||
| const results = await runner.execute(steps, { | ||
| filter: { includeNames: ["taskB"] } | ||
| }); | ||
|
|
||
| expect(results.size).toBe(2); | ||
| expect(results.has("taskA")).toBe(true); | ||
| expect(results.has("taskB")).toBe(true); | ||
| expect(results.has("taskC")).toBe(false); | ||
|
|
||
| expect(taskA.run).toHaveBeenCalled(); | ||
| expect(taskB.run).toHaveBeenCalled(); | ||
| expect(taskC.run).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("should fail validation if a required dependency is explicitly excluded", async () => { | ||
| const runner = new TaskRunner({}); | ||
|
|
||
| const taskA: TaskStep<unknown> = { name: "taskA", tags: ["core"], run: vi.fn().mockResolvedValue({ status: "success" }) }; | ||
| const taskB: TaskStep<unknown> = { name: "taskB", dependencies: ["taskA"], tags: ["feature"], run: vi.fn().mockResolvedValue({ status: "success" }) }; | ||
|
|
||
| const steps = [taskA, taskB]; | ||
|
|
||
| // taskB needs taskA, but taskA is excluded by tag | ||
| await expect(runner.execute(steps, { | ||
| filter: { includeNames: ["taskB"], excludeTags: ["core"] } | ||
| })).rejects.toThrow(TaskGraphValidationError); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { describe, it, expect, vi } from "vitest"; | ||
| import { filterTasks } from "../../src/utils/TaskFilter.js"; | ||
| import { TaskStep } from "../../src/TaskStep.js"; | ||
|
|
||
| describe("filterTasks", () => { | ||
| const steps: TaskStep<unknown>[] = [ | ||
| { name: "build", tags: ["backend", "core"], run: vi.fn() }, | ||
| { name: "test", dependencies: ["build"], tags: ["backend", "testing"], run: vi.fn() }, | ||
| { name: "lint", tags: ["frontend", "core"], run: vi.fn() }, | ||
| { name: "deploy", dependencies: ["test", "lint"], tags: ["ops"], run: vi.fn() }, | ||
| { name: "isolated", tags: ["frontend"], run: vi.fn() } | ||
| ]; | ||
|
|
||
| it("should return all tasks if no filter provided", () => { | ||
| const result = filterTasks(steps, {}); | ||
| expect(result.length).toBe(5); | ||
| }); | ||
|
|
||
| it("should include tasks by tag", () => { | ||
| const result = filterTasks(steps, { includeTags: ["backend"] }); | ||
| expect(result.map(s => s.name)).toEqual(["build", "test"]); | ||
| }); | ||
|
|
||
| it("should include tasks by name", () => { | ||
| const result = filterTasks(steps, { includeNames: ["lint"] }); | ||
| expect(result.map(s => s.name)).toEqual(["lint"]); | ||
| }); | ||
|
|
||
| it("should exclude tasks by tag", () => { | ||
| const result = filterTasks(steps, { excludeTags: ["ops"] }); | ||
| expect(result.map(s => s.name)).toEqual(["build", "test", "lint", "isolated"]); | ||
| }); | ||
|
|
||
| it("should exclude tasks by name", () => { | ||
| const result = filterTasks(steps, { excludeNames: ["isolated"] }); | ||
| expect(result.map(s => s.name)).toEqual(["build", "test", "lint", "deploy"]); | ||
| }); | ||
|
|
||
| it("should handle both include and exclude logic", () => { | ||
| const result = filterTasks(steps, { includeTags: ["core"], excludeNames: ["lint"] }); | ||
| expect(result.map(s => s.name)).toEqual(["build"]); | ||
| }); | ||
|
|
||
| it("should automatically include dependencies if includeDependencies is true", () => { | ||
| // 'deploy' depends on 'test' and 'lint', 'test' depends on 'build' | ||
| const result = filterTasks(steps, { includeNames: ["deploy"], includeDependencies: true }); | ||
| expect(result.map(s => s.name)).toEqual(["build", "test", "lint", "deploy"]); | ||
| }); | ||
|
|
||
| it("should not include dependencies if includeDependencies is false", () => { | ||
| const result = filterTasks(steps, { includeNames: ["deploy"], includeDependencies: false }); | ||
| expect(result.map(s => s.name)).toEqual(["deploy"]); | ||
| }); | ||
|
|
||
| it("should exclude dependencies that match exclude rules", () => { | ||
| // Even if 'test' brings in 'build', if 'build' is explicitly excluded, it shouldn't be included. | ||
| const result = filterTasks(steps, { includeNames: ["deploy"], excludeTags: ["backend"], includeDependencies: true }); | ||
| // 'deploy' includes 'test' & 'lint'. 'test' includes 'build'. | ||
| // 'test' has tag 'backend' -> excluded. | ||
| // 'build' has tag 'backend' -> excluded. | ||
| // So only 'lint' and 'deploy' remain. | ||
| expect(result.map(s => s.name)).toEqual(["lint", "deploy"]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation of
filterTasksiterates over thestepsarray multiple times. Consolidating these passes and using aMapfor task lookups ensures O(1) access time, which is more efficient for frequent lookups. However, before merging loops for code simplification, consider the performance impact. Retaining separate logic can be justified if it avoids expensive operations and provides significant, benchmarked performance gains.References