-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Add Task Filtering and Tagging Capabilities #242
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 |
|---|---|---|
| @@ -1,13 +1,13 @@ | ||
| ## 1. Implementation | ||
|
|
||
| - [ ] 1.1 Update `TaskStep` interface in `src/TaskStep.ts` to include an optional `tags?: string[]` property. | ||
| - [ ] 1.2 Define a new type or interface `TaskFilterConfig` in `src/contracts/TaskFilterConfig.ts` with optional properties for `includeTags`, `excludeTags`, `includeNames`, `excludeNames`, and `includeDependencies`. | ||
| - [ ] 1.3 Create a utility module `src/utils/TaskFilter.ts`. | ||
| - [x] 1.1 Update `TaskStep` interface in `src/TaskStep.ts` to include an optional `tags?: string[]` property. | ||
| - [x] 1.2 Define a new type or interface `TaskFilterConfig` in `src/contracts/TaskFilterConfig.ts` with optional properties for `includeTags`, `excludeTags`, `includeNames`, `excludeNames`, and `includeDependencies`. | ||
| - [x] 1.3 Create a utility module `src/utils/TaskFilter.ts`. | ||
| - Implement a pure function `filterTasks(steps: TaskStep<any>[], config: TaskFilterConfig): TaskStep<any>[]`. | ||
| - Ensure filtering handles both names and tags. | ||
| - [ ] 1.4 Handle Dependencies during filtering. Implement a configurable flag in `TaskFilterConfig` (e.g., `includeDependencies?: boolean`). | ||
| - [x] 1.4 Handle Dependencies during filtering. Implement a configurable flag in `TaskFilterConfig` (e.g., `includeDependencies?: boolean`). | ||
| - If `true`, recursively include all tasks that the explicitly selected tasks depend on. | ||
| - [ ] 1.5 Update `TaskRunnerExecutionConfig` in `src/TaskRunnerExecutionConfig.ts` to optionally accept a `filter` of type `TaskFilterConfig`. | ||
| - [ ] 1.6 Update `TaskRunner.ts` in the `execute` method to apply `filterTasks` to the input steps if `config.filter` is provided, *before* passing the subset to `WorkflowExecutor`. | ||
| - [ ] 1.7 Write unit tests for `TaskFilter.ts` ensuring inclusion, exclusion, and dependency resolution work correctly. | ||
| - [ ] 1.8 Write integration tests for `TaskRunner` filtering in `tests/TaskRunnerFiltering.test.ts` to verify end-to-end filtering execution. | ||
| - [x] 1.5 Update `TaskRunnerExecutionConfig` in `src/TaskRunnerExecutionConfig.ts` to optionally accept a `filter` of type `TaskFilterConfig`. | ||
| - [x] 1.6 Update `TaskRunner.ts` in the `execute` method to apply `filterTasks` to the input steps if `config.filter` is provided, *before* passing the subset to `WorkflowExecutor`. | ||
| - [x] 1.7 Write unit tests for `TaskFilter.ts` ensuring inclusion, exclusion, and dependency resolution work correctly. | ||
| - [x] 1.8 Write integration tests for `TaskRunner` filtering in `tests/TaskRunnerFiltering.test.ts` to verify end-to-end filtering execution. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| /** | ||
| * Configuration options for filtering tasks during execution. | ||
| */ | ||
| export interface TaskFilterConfig { | ||
| /** | ||
| * Run only tasks with these tags. | ||
| */ | ||
| includeTags?: string[]; | ||
| /** | ||
| * Exclude tasks with these tags. | ||
| */ | ||
| excludeTags?: string[]; | ||
| /** | ||
| * Run only tasks with these names. | ||
| */ | ||
| includeNames?: string[]; | ||
| /** | ||
| * Exclude tasks with these names. | ||
| */ | ||
| excludeNames?: string[]; | ||
| /** | ||
| * If true, automatically include dependencies of selected tasks. | ||
| * Default is false. | ||
| */ | ||
| includeDependencies?: boolean; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { TaskStep } from "../TaskStep.js"; | ||
| import { TaskFilterConfig } from "../contracts/TaskFilterConfig.js"; | ||
|
|
||
| /** | ||
| * Filters a list of tasks based on the provided configuration. | ||
| * | ||
| * @param steps The original array of tasks. | ||
| * @param config The filtering configuration. | ||
| * @returns A new array containing the filtered tasks. | ||
| */ | ||
| 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(steps.map((step) => [step.name, step])); | ||
|
|
||
| const filteredSteps = steps.filter((step) => { | ||
| // 1. Check exclusions first (highest priority) | ||
| if ( | ||
| config.excludeNames?.includes(step.name) || | ||
| (step.tags && config.excludeTags?.some((tag) => step.tags!.includes(tag))) | ||
| ) { | ||
| return false; | ||
| } | ||
|
|
||
| // 2. Check inclusions (if both are provided, satisfying either is enough or requires both? Usually OR semantics for inclusions) | ||
| // Actually, usually if include is present, it MUST match one of the inclusions. | ||
| // Let's implement OR logic: if included by name OR included by tag. | ||
| const hasIncludeNames = | ||
| config.includeNames && config.includeNames.length > 0; | ||
| const hasIncludeTags = config.includeTags && config.includeTags.length > 0; | ||
|
|
||
| if (!hasIncludeNames && !hasIncludeTags) { | ||
| return true; // No inclusion filters, so keep it if it passed exclusion | ||
| } | ||
|
|
||
| const includedByName = hasIncludeNames && config.includeNames!.includes(step.name); | ||
| const includedByTag = | ||
| hasIncludeTags && | ||
| step.tags && | ||
| config.includeTags!.some((tag) => step.tags!.includes(tag)); | ||
|
|
||
| return includedByName || includedByTag; | ||
| }); | ||
|
Comment on lines
+17
to
+44
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The filtering logic uses several non-null assertion operators ( const filteredSteps = steps.filter((step) => {
// 1. Check exclusions first (highest priority)
const isExcludedByName = config.excludeNames?.includes(step.name) ?? false;
const isExcludedByTag = step.tags?.some((tag) => config.excludeTags?.includes(tag)) ?? false;
if (isExcludedByName || isExcludedByTag) {
return false;
}
// 2. Check inclusions
const hasIncludeNames = (config.includeNames?.length ?? 0) > 0;
const hasIncludeTags = (config.includeTags?.length ?? 0) > 0;
if (!hasIncludeNames && !hasIncludeTags) {
return true;
}
const includedByName = config.includeNames?.includes(step.name) ?? false;
const includedByTag = step.tags?.some((tag) => config.includeTags?.includes(tag)) ?? false;
return includedByName || includedByTag;
});References
|
||
|
|
||
| if (!config.includeDependencies) { | ||
| return filteredSteps; | ||
| } | ||
|
|
||
| // Include dependencies recursively | ||
| const resultSet = new Set<string>(filteredSteps.map((s) => s.name)); | ||
| const queue = [...filteredSteps]; | ||
|
|
||
| while (queue.length > 0) { | ||
| const current = queue.shift()!; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid using the non-null assertion operator ( const current = queue.shift();
if (!current) continue;References
|
||
| if (current.dependencies) { | ||
| for (const depName of current.dependencies) { | ||
| if (!resultSet.has(depName)) { | ||
| resultSet.add(depName); | ||
| const depStep = stepMap.get(depName); | ||
| if (depStep) { | ||
| queue.push(depStep); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Preserve original order and map names back to steps | ||
| return steps.filter((step) => resultSet.has(step.name)); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { TaskRunner } from "../src/TaskRunner.js"; | ||
| import { TaskStep } from "../src/TaskStep.js"; | ||
|
|
||
| interface TestContext { | ||
| executedTasks: string[]; | ||
| } | ||
|
|
||
| const createTestTask = ( | ||
| name: string, | ||
| dependencies?: string[], | ||
| tags?: string[] | ||
| ): TaskStep<TestContext> => ({ | ||
| name, | ||
| dependencies, | ||
| tags, | ||
| run: async (ctx) => { | ||
| ctx.executedTasks.push(name); | ||
| return { status: "success" }; | ||
| }, | ||
| }); | ||
|
|
||
| describe("TaskRunner Filtering End-to-End", () => { | ||
| const steps: TaskStep<TestContext>[] = [ | ||
| createTestTask("task-1", [], ["setup"]), | ||
| createTestTask("task-2", ["task-1"], ["build"]), | ||
| createTestTask("task-3", ["task-2"], ["test", "unit"]), | ||
| createTestTask("task-4", ["task-2"], ["test", "e2e"]), | ||
| ]; | ||
|
|
||
| it("should run all tasks when no filter is provided", async () => { | ||
| const context: TestContext = { executedTasks: [] }; | ||
| const runner = new TaskRunner(context); | ||
|
|
||
| await runner.execute(steps); | ||
|
|
||
| expect(context.executedTasks.length).toBe(4); | ||
| expect(context.executedTasks).toContain("task-1"); | ||
| expect(context.executedTasks).toContain("task-2"); | ||
| expect(context.executedTasks).toContain("task-3"); | ||
| expect(context.executedTasks).toContain("task-4"); | ||
| }); | ||
|
|
||
| it("should execute only the filtered tasks by tag", async () => { | ||
| const context: TestContext = { executedTasks: [] }; | ||
| const runner = new TaskRunner(context); | ||
|
|
||
| // Filter only "test" tasks, omitting dependencies | ||
| // task-3 and task-4 depend on task-2. In reality, the graph would fail validation if we omit dependencies, | ||
| // but our execution config should handle it or fail validation if dependencies are missing and includeDependencies is false. | ||
| // Wait, TaskGraphValidator checks if dependencies exist in the passed graph. | ||
| // Let's test with includeDependencies: true to ensure valid execution graph. | ||
| await runner.execute(steps, { | ||
| filter: { | ||
| includeTags: ["test"], | ||
| includeDependencies: true, | ||
| }, | ||
| }); | ||
|
|
||
| expect(context.executedTasks.length).toBe(4); // includes 1, 2, 3, 4 | ||
| }); | ||
|
|
||
| it("should execute only the explicitly included task when valid isolated graph", async () => { | ||
| const context: TestContext = { executedTasks: [] }; | ||
| const runner = new TaskRunner(context); | ||
|
|
||
| await runner.execute(steps, { | ||
| filter: { | ||
| includeNames: ["task-1"], | ||
| }, | ||
| }); | ||
|
|
||
| expect(context.executedTasks.length).toBe(1); | ||
| expect(context.executedTasks).toEqual(["task-1"]); | ||
| }); | ||
|
|
||
| it("should fail validation if filtering creates broken dependency graph", async () => { | ||
| const context: TestContext = { executedTasks: [] }; | ||
| const runner = new TaskRunner(context); | ||
|
|
||
| // This will include task-2 which depends on task-1, but task-1 is filtered out. | ||
| // TaskGraphValidator should throw. | ||
| await expect( | ||
| runner.execute(steps, { | ||
| filter: { | ||
| includeNames: ["task-2"], | ||
| includeDependencies: false, | ||
| }, | ||
| }) | ||
| ).rejects.toThrow(); | ||
| }); | ||
| }); |
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 JSDoc block at the top of the file is redundant as it is repeated immediately before the interface declaration. Removing it keeps the code concise and avoids duplication.