-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement task filtering by tags and names #246
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 have at least one of these tags. | ||
| */ | ||
| includeTags?: string[]; | ||
| /** | ||
| * Exclude tasks that have any of these tags. | ||
| */ | ||
| excludeTags?: string[]; | ||
| /** | ||
| * Only include tasks with these specific names. | ||
| */ | ||
| includeNames?: string[]; | ||
| /** | ||
| * Exclude tasks with these specific names. | ||
| */ | ||
| excludeNames?: string[]; | ||
| /** | ||
| * If true, dependencies of included tasks will also be included automatically. | ||
| * Default is false. | ||
| */ | ||
| includeDependencies?: boolean; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import { TaskStep } from "../TaskStep.js"; | ||
| import { TaskFilterConfig } from "../contracts/TaskFilterConfig.js"; | ||
|
|
||
| /** | ||
| * Filters an array of tasks based on the provided configuration. | ||
| * | ||
| * @param steps The array of tasks to filter. | ||
| * @param config The filter configuration. | ||
| * @returns A new array of tasks that match the filter criteria. | ||
| */ | ||
| export function filterTasks<TContext>( | ||
|
Check failure on line 11 in src/utils/TaskFilter.ts
|
||
| steps: TaskStep<TContext>[], | ||
| config: TaskFilterConfig | ||
| ): TaskStep<TContext>[] { | ||
| const stepMap = new Map<string, TaskStep<TContext>>(); | ||
| for (const step of steps) { | ||
| stepMap.set(step.name, step); | ||
| } | ||
|
|
||
| const initialMatches = new Set<string>(); | ||
|
|
||
| for (const step of steps) { | ||
| let included = true; | ||
|
|
||
| // Check inclusion criteria | ||
| if (!config.includeNames?.length && !config.includeTags?.length) { | ||
| included = true; | ||
|
Check warning on line 27 in src/utils/TaskFilter.ts
|
||
| } else if (config.includeNames?.length && config.includeTags?.length) { | ||
| // If both are provided, we should match either. | ||
| const nameMatch = config.includeNames.includes(step.name); | ||
| const tagMatch = !!step.tags && step.tags.some(tag => config.includeTags!.includes(tag)); | ||
|
|
||
| included = nameMatch || tagMatch; | ||
| } else if (config.includeNames?.length) { | ||
| included = config.includeNames.includes(step.name); | ||
| } else { | ||
| // Only includeTags is provided | ||
| included = !!step.tags && step.tags.some(tag => config.includeTags!.includes(tag)); | ||
| } | ||
|
|
||
| // Check exclusion criteria | ||
| if (config.excludeNames && config.excludeNames.includes(step.name)) { | ||
|
Check warning on line 42 in src/utils/TaskFilter.ts
|
||
| included = false; | ||
| } | ||
|
|
||
| if (config.excludeTags && !!step.tags && step.tags.some(tag => config.excludeTags!.includes(tag))) { | ||
| included = false; | ||
| } | ||
|
|
||
| if (included) { | ||
| initialMatches.add(step.name); | ||
| } | ||
| } | ||
|
|
||
| // Handle dependencies | ||
| if (config.includeDependencies) { | ||
| const queue = Array.from(initialMatches); | ||
| const visited = new Set<string>(initialMatches); | ||
|
|
||
| while (queue.length > 0) { | ||
| const currentName = queue.shift()!; | ||
| const currentStep = stepMap.get(currentName); | ||
|
|
||
| if (currentStep && currentStep.dependencies) { | ||
|
Check warning on line 64 in src/utils/TaskFilter.ts
|
||
| for (const dep of currentStep.dependencies) { | ||
| // If a dependency is excluded, we might not include it, but | ||
| // standard behavior is usually to override exclusion for explicit dependencies, | ||
| // or fail. Let's include it unless it's explicitly excluded. | ||
| // Wait, if it's explicitly excluded, it might fail the pipeline. | ||
| // For now, if we include dependencies, we'll just add them unless they are in exclusions. | ||
| let depExcluded = false; | ||
| const depStep = stepMap.get(dep); | ||
|
|
||
| if (config.excludeNames && config.excludeNames.includes(dep)) { | ||
|
Check warning on line 74 in src/utils/TaskFilter.ts
|
||
| depExcluded = true; | ||
| } | ||
| if (depStep && config.excludeTags && depStep.tags && depStep.tags.some(tag => config.excludeTags!.includes(tag))) { | ||
|
Check warning on line 77 in src/utils/TaskFilter.ts
|
||
| depExcluded = true; | ||
| } | ||
|
|
||
| if (!visited.has(dep) && !depExcluded) { | ||
| visited.add(dep); | ||
| queue.push(dep); | ||
| initialMatches.add(dep); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Filter the final list, preserving original order | ||
| return steps.filter(step => initialMatches.has(step.name)); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| import { describe, it, expect, vi } from "vitest"; | ||
| import { filterTasks } from "../src/utils/TaskFilter.js"; | ||
| import { TaskStep } from "../src/TaskStep.js"; | ||
| import { TaskResult } from "../src/TaskResult.js"; | ||
|
|
||
| describe("TaskFilter", () => { | ||
| const mockRun = vi.fn().mockResolvedValue({ status: "success" } as TaskResult); | ||
|
|
||
| const steps: TaskStep<unknown>[] = [ | ||
| { name: "taskA", tags: ["frontend", "build"], run: mockRun }, | ||
| { name: "taskB", tags: ["backend", "build"], dependencies: ["taskA"], run: mockRun }, | ||
| { name: "taskC", tags: ["frontend", "test"], run: mockRun }, | ||
| { name: "taskD", dependencies: ["taskB"], run: mockRun }, | ||
| { name: "taskE", tags: ["deploy"], dependencies: ["taskC", "taskD"], run: mockRun }, | ||
| ]; | ||
|
|
||
| it("should include tasks by exact names", () => { | ||
| const result = filterTasks(steps, { includeNames: ["taskA", "taskC"] }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskA", "taskC"]); | ||
| }); | ||
|
|
||
| it("should include tasks by tags", () => { | ||
| const result = filterTasks(steps, { includeTags: ["frontend"] }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskA", "taskC"]); | ||
| }); | ||
|
|
||
| it("should include tasks matching either names or tags", () => { | ||
| const result = filterTasks(steps, { | ||
| includeNames: ["taskD"], | ||
| includeTags: ["test"], | ||
| }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskC", "taskD"]); | ||
| }); | ||
|
|
||
| it("should handle includeTags when includeNames is undefined", () => { | ||
| const result = filterTasks(steps, { | ||
| includeNames: undefined, | ||
| includeTags: ["test"], | ||
| }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskC"]); | ||
| }); | ||
|
|
||
| it("should handle includeNames when includeTags is undefined", () => { | ||
| const result = filterTasks(steps, { | ||
| includeNames: ["taskD"], | ||
| includeTags: undefined, | ||
| }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskD"]); | ||
| }); | ||
|
|
||
| it("should exclude tasks by exact names", () => { | ||
| const result = filterTasks(steps, { excludeNames: ["taskB", "taskC"] }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskA", "taskD", "taskE"]); | ||
| }); | ||
|
|
||
| it("should exclude tasks by tags", () => { | ||
| const result = filterTasks(steps, { excludeTags: ["build"] }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskC", "taskD", "taskE"]); | ||
| }); | ||
|
|
||
| it("should exclude taking precedence over include", () => { | ||
| const result = filterTasks(steps, { | ||
| includeTags: ["frontend"], | ||
| excludeNames: ["taskC"], | ||
| }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskA"]); | ||
| }); | ||
|
|
||
| it("should include dependencies when includeDependencies is true", () => { | ||
| const result = filterTasks(steps, { | ||
| includeNames: ["taskD"], | ||
| includeDependencies: true, | ||
| }); | ||
| // taskD depends on taskB, which depends on taskA | ||
| expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskD"]); | ||
| }); | ||
|
|
||
| it("should not include excluded dependencies even if includeDependencies is true", () => { | ||
| const result = filterTasks(steps, { | ||
| includeNames: ["taskE"], | ||
| includeDependencies: true, | ||
| excludeTags: ["backend"], | ||
| }); | ||
| expect(result.map((s) => s.name).sort()).toEqual(["taskC", "taskD", "taskE"].sort()); | ||
| }); | ||
|
|
||
| it("should not include excluded dependencies by name even if includeDependencies is true", () => { | ||
| const result = filterTasks(steps, { | ||
| includeNames: ["taskD"], | ||
| includeDependencies: true, | ||
| excludeNames: ["taskB"], | ||
| }); | ||
| // taskD depends on taskB, which is excluded by name. | ||
| expect(result.map((s) => s.name)).toEqual(["taskD"]); | ||
| }); | ||
|
|
||
| it("should handle missing dependencies gracefully when includeDependencies is true", () => { | ||
| const missingDepSteps: TaskStep<unknown>[] = [ | ||
| { name: "task1", dependencies: ["missingTask"], run: mockRun } | ||
| ]; | ||
| const result = filterTasks(missingDepSteps, { | ||
| includeNames: ["task1"], | ||
| includeDependencies: true, | ||
| }); | ||
| expect(result.map((s) => s.name)).toEqual(["task1"]); | ||
| }); | ||
|
|
||
| it("should handle default inclusion when neither includeNames nor includeTags are present but there is a truthy config with no properties matching", () => { | ||
| const result = filterTasks(steps, { includeNames: [], includeTags: [] }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]); | ||
| }); | ||
|
|
||
| it("should default to false if both include criteria exist but neither is matched", () => { | ||
| const stepMissingBoth: TaskStep<unknown>[] = [ | ||
| { name: "taskNonMatching", run: mockRun } | ||
| ]; | ||
| const result = filterTasks(stepMissingBoth, { includeNames: ["a"], includeTags: ["b"] }); | ||
| expect(result.map((s) => s.name)).toEqual([]); | ||
| }); | ||
|
|
||
| it("should handle empty exclusions", () => { | ||
| const result = filterTasks(steps, { excludeNames: [], excludeTags: [] }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]); | ||
| }); | ||
|
|
||
| it("should handle exclude tags gracefully if tasks have no tags", () => { | ||
| const taskWithoutTags: TaskStep<unknown>[] = [ | ||
| { name: "noTagTask", run: mockRun } | ||
| ]; | ||
| const result = filterTasks(taskWithoutTags, { excludeTags: ["frontend"] }); | ||
| expect(result.map((s) => s.name)).toEqual(["noTagTask"]); | ||
| }); | ||
|
|
||
| it("should default to included if inclusion conditions exists but are mutually falsy because missing values", () => { | ||
| const result = filterTasks(steps, { includeNames: [], includeTags: [] }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]); | ||
| }); | ||
|
|
||
| it("should handle both includeNames and includeTags logic correctly", () => { | ||
| const mixedSteps: TaskStep<unknown>[] = [ | ||
| { name: "taskA", tags: ["frontend"], run: mockRun }, | ||
| { name: "taskB", tags: ["backend"], run: mockRun }, | ||
| { name: "taskC", tags: ["other"], run: mockRun }, | ||
| ]; | ||
| // taskA matched by name, taskB matched by tag | ||
| const result = filterTasks(mixedSteps, { includeNames: ["taskA"], includeTags: ["backend"] }); | ||
| expect(result.map((s) => s.name)).toEqual(["taskA", "taskB"]); | ||
| }); | ||
|
|
||
| it("should return all tasks when no inclusion/exclusion criteria are provided", () => { | ||
| const result = filterTasks(steps, {}); | ||
| expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]); | ||
| }); | ||
| }); |
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 uses multiple array lookups (.includes()) and iterations (.some()) inside loops, leading to O(N*M) complexity. Converting filter criteria to Set objects provides O(1) lookups, which is significantly more efficient for larger task sets. Additionally, the inclusion and dependency resolution logic can be simplified for better maintainability and performance by avoiding redundant checks and sets.
References