All files / src/llm-orchestration/action-handlers write-todo.handler.ts

39.13% Statements 18/46
33.33% Branches 4/12
37.5% Functions 3/8
36.36% Lines 16/44

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 1816x           6x 6x 6x 6x               6x 6x     6x 6x 6x 6x                                                                     2x           2x                   2x                 2x                                   2x                                                                                                                                                            
import {
  BadRequestException,
  Injectable,
  InternalServerErrorException,
  Logger,
} from '@nestjs/common';
import * as fs from 'fs/promises';
import * as path from 'path';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';
import { ActionHandler } from './action-handler.interface';
import {
  ActionExecutionResult,
  PlanExecutionContext,
  ToolMetadata,
} from '../llm-orchestration.interfaces';
 
import { WriteTodoArgsDto } from './dto/write-todo.args.dto';
import { generateToolCall, generateToolCallJson } from '../../utils';
 
@Injectable()
export class WriteTodoHandler implements ActionHandler {
  readonly toolName = 'write_todo';
  private readonly logger = new Logger(WriteTodoHandler.name);
  private readonly projectRoot: string =
    process.env.REPOBURG_PROJECT_PATH || process.cwd();
 
  getMetadata(): ToolMetadata {
    return {
      name: this.toolName,
      description: this.getDefinition(true),
      arguments: [
        {
          name: 'file_path',
          type: 'string',
          description:
            'The relative path to the todo file (e.g., ".repoburg/tasks/feature-x.md").',
          required: true,
        },
        {
          name: 'content',
          type: 'string',
          description: 'The new, complete content for the file.',
          required: true,
        },
      ],
    };
  }
 
  /**
   * Generates a tool call example in the specified format.
   * @param toolCall - The tool call object to format
   * @param useJson - If true, uses JSON format; otherwise uses XML-style format
   * @returns Formatted tool call string
   */
  private generateExample(
    toolCall: Record<string, any>,
    useJson: boolean = false,
  ): string {
    return useJson
      ? generateToolCallJson(toolCall)
      : generateToolCall(toolCall);
  }
 
  getDefinition(useJsonFormat: boolean = false): string {
    const exampleContent = `\`\`markdown
:# Project TODOs
 
:- [x] Setup project structure
:- [ ] Implement login feature
  - [ ] Create login form component
  - [ ] Integrate with auth API
:- [ ] Write documentation
\`\``.trim();
 
    const example = this.generateExample(
      {
        tool_name: this.toolName,
        file_path: '.repoburg/tasks/project-setup.md',
        content: exampleContent,
      },
      useJsonFormat,
    );
 
    const definition = `
-------------### ${this.toolName}
  The **all-in-one tool** for managing TODOs and task lists.
  Use this tool for ANY task-related operation: creating new lists, **updating progress** (checking off items), adding new tasks, or modifying existing ones.
  Technically, it works exactly like \`overwrite_file\` (overwriting **entire file**), but it acts as a clear semantic signal that you are managing project tasks.
  **Rules for File Path:**
  - All task files MUST be placed in \`.repoburg/tasks/\` directory.
  - The filename should be meaningful and relevant to the current task (e.g., \`auth-refactor.md\`, \`bug-fix-123.md\`).
 
  #### Parameters
  - "file_path": (string) The relative path to the todo file (e.g., ".repoburg/tasks/feature-x.md").
  - "content": (string) The new, complete content for the file.
 
  #### Example
    Explanation: Update the project's TODO list to mark setup as done.
:${example}
-------------
`;
    return `\n${definition.trim()}\n`;
  }
 
  private resolveAndValidatePath(unsafePath: string): string {
    const normalizedPath = path.normalize(unsafePath);
    Iif (path.isAbsolute(normalizedPath)) {
      throw new BadRequestException(
        `Absolute paths are not allowed: ${unsafePath}`,
      );
    }
    const resolvedPath = path.resolve(this.projectRoot, normalizedPath);
    Iif (!resolvedPath.startsWith(this.projectRoot)) {
      throw new BadRequestException(
        `Path traversal is not allowed: ${unsafePath}`,
      );
    }
    return resolvedPath;
  }
 
  private async readFileContent(filePath: string): Promise<string | null> {
    try {
      return await fs.readFile(filePath, 'utf8');
    } catch (error) {
      Iif (error.code === 'ENOENT') {
        return null; // File doesn't exist, which is valid.
      }
      throw error;
    }
  }
 
  async execute(
    args: { [key: string]: any },
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    _context: PlanExecutionContext,
  ): Promise<ActionExecutionResult> {
    const validatedArgs = plainToClass(WriteTodoArgsDto, args);
    const errors = await validate(validatedArgs);
    Iif (errors.length > 0) {
      const errorMessages = errors
        .map((err) => Object.values(err.constraints || {}).join(', '))
        .join('; ');
      return {
        status: 'FAILURE',
        summary: `Invalid arguments for ${this.toolName}.`,
        error_message: errorMessages,
        persisted_args: args,
        execution_log: { output: '', error_message: errorMessages },
      };
    }
 
    const { file_path, content } = validatedArgs;
    const safePath = this.resolveAndValidatePath(file_path);
 
    const originalContent = await this.readFileContent(safePath);
 
    try {
      await fs.mkdir(path.dirname(safePath), { recursive: true });
      await fs.writeFile(safePath, content, 'utf8');
      return {
        status: 'SUCCESS',
        summary: `TODO file "${file_path}" updated.`,
        persisted_args: { file_path, content },
        original_content_for_revert: originalContent,
        execution_log: {
          output: `${file_path} has been updated.`,
          error_message: '',
        },
      };
    } catch (error) {
      this.logger.error(
        `Failed to write todo file at ${file_path}: ${error.message}`,
      );
      throw new InternalServerErrorException(
        `Failed to write todo file "${file_path}": ${error.message}`,
      );
    }
  }
}