diff --git a/backend/internal/controller/task_controller.go b/backend/internal/controller/task_controller.go index 865e448..e325172 100644 --- a/backend/internal/controller/task_controller.go +++ b/backend/internal/controller/task_controller.go @@ -8,6 +8,7 @@ package controller import ( "fmt" "strconv" + "strings" "time" "github.com/codetasker/backend/internal/domain" @@ -407,25 +408,60 @@ func (tc *TaskController) InjectTODO(c *fiber.Ctx) error { }) } + // Normalise single-location requests into Locations slice for backward compatibility + if len(req.Locations) == 0 && req.FilePath != "" { + req.Locations = []domain.TaskLocation{ + { + FilePath: req.FilePath, + LineNumber: req.LineNumber, + Description: req.Description, + IsNewFile: false, + }, + } + } + // Validate required fields. - if req.RepoOwner == "" || req.RepoName == "" || req.FilePath == "" || - req.Description == "" || req.Branch == "" { + if req.RepoOwner == "" || req.RepoName == "" || req.Branch == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "error": "missing_fields", - "message": "repo_owner, repo_name, file_path, description, and branch are required", + "message": "repo_owner, repo_name, and branch are required", }) } - if req.LineNumber < 1 { + if len(req.Locations) == 0 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "invalid_field", - "message": "line_number must be >= 1", + "error": "missing_locations", + "message": "at least one task location (file_path and description) is required", }) } + // Validate each location + for i, loc := range req.Locations { + if strings.TrimSpace(loc.FilePath) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid_location", + "message": fmt.Sprintf("location #%d: file_path is required", i+1), + }) + } + if strings.TrimSpace(loc.Description) == "" && strings.TrimSpace(req.Description) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid_location", + "message": fmt.Sprintf("location #%d: description is required", i+1), + }) + } + if !loc.IsNewFile && loc.LineNumber < 1 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid_location", + "message": fmt.Sprintf("location #%d: line_number must be >= 1 for existing files", i+1), + }) + } + } + // Verify collaborator permissions before injecting TODO. + var repoID int64 synced, err := tc.syncedRepoRepo.FindByRepoName(c.Context(), req.RepoOwner+"/"+req.RepoName) if err == nil && synced != nil { + repoID = synced.RepoID if synced.UserID != userID { collab, _ := tc.collaboratorRepo.FindByUserAndRepo(c.Context(), userID, synced.RepoID) if collab == nil || (collab.Role != domain.RoleOwner && collab.Role != domain.RoleMaintainer && collab.Role != domain.RoleDeveloper) { @@ -437,7 +473,7 @@ func (tc *TaskController) InjectTODO(c *fiber.Ctx) error { } } - // Inject the TODO via the GitHub API pipeline and get back the PR URL. + // Inject the TODO(s) via the GitHub API pipeline and get back the PR URL. prURL, err := tc.githubService.InjectTODO(c.Context(), userID, &req) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ @@ -448,21 +484,6 @@ func (tc *TaskController) InjectTODO(c *fiber.Ctx) error { // Look up the actor so we can set creator fields on the task. actor, _ := tc.userRepo.FindByObjectID(c.Context(), userID) - - // Upsert the new task in MongoDB so it appears immediately without waiting - // for the webhook to fire and process the PR merge. - taskType := req.Type - if taskType == "" { - taskType = "TODO" - } - - // Resolve maintainer via CODEOWNERS before building the task struct. - maintainerUsername, maintainerEmail := tc.codeOwnerService.ResolveMaintainer( - c.Context(), userID, req.RepoOwner, req.RepoName, req.FilePath, - ) - - // Build task with creator and maintainer fields already populated so they - // are written into $setOnInsert on first insert. createdByUsername := "" createdByAvatarURL := "" if actor != nil { @@ -470,30 +491,50 @@ func (tc *TaskController) InjectTODO(c *fiber.Ctx) error { createdByAvatarURL = actor.AvatarURL } - task := &domain.Task{ - RepoID: synced.RepoID, - RepoName: req.RepoOwner + "/" + req.RepoName, - FilePath: req.FilePath, - LineNumber: req.LineNumber, - Content: req.Description, - Type: taskType, - Status: domain.TaskStatusOpen, - IssueURL: req.IssueURL, - CreatedByUsername: createdByUsername, - CreatedByAvatarURL: createdByAvatarURL, - MaintainerUsername: maintainerUsername, - MaintainerEmail: maintainerEmail, + taskType := req.Type + if taskType == "" { + taskType = "TODO" } - if err := tc.taskService.UpsertInjectedTask(c.Context(), task); err != nil { - // Log but don't fail — the PR was created successfully; the task will - // appear in the DB after the webhook processes the merged commit. - _ = err + // Upsert all new tasks in MongoDB so they appear immediately in the dashboard + for _, loc := range req.Locations { + locDesc := strings.TrimSpace(loc.Description) + if locDesc == "" { + locDesc = req.Description + } + lineNum := loc.LineNumber + if lineNum < 1 { + lineNum = 1 + } + + maintainerUsername, maintainerEmail := tc.codeOwnerService.ResolveMaintainer( + c.Context(), userID, req.RepoOwner, req.RepoName, loc.FilePath, + ) + + task := &domain.Task{ + RepoID: repoID, + RepoName: req.RepoOwner + "/" + req.RepoName, + FilePath: loc.FilePath, + LineNumber: lineNum, + Content: locDesc, + Type: taskType, + Status: domain.TaskStatusOpen, + IssueURL: req.IssueURL, + CreatedByUsername: createdByUsername, + CreatedByAvatarURL: createdByAvatarURL, + MaintainerUsername: maintainerUsername, + MaintainerEmail: maintainerEmail, + } + + if err := tc.taskService.UpsertInjectedTask(c.Context(), task); err != nil { + _ = err + } } return c.Status(fiber.StatusCreated).JSON(fiber.Map{ - "message": "TODO injected successfully", - "pr_url": prURL, + "message": "TODO injected successfully", + "pr_url": prURL, + "locations_count": len(req.Locations), }) } diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index d763111..e10a8f4 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -168,9 +168,24 @@ type Task struct { MaintainerEmail string `bson:"maintainer_email,omitempty" json:"maintainer_email,omitempty"` } +// TaskLocation represents a single file+line target within an inject task request. +type TaskLocation struct { + // FilePath is the repository-relative path of the file to modify or create. + FilePath string `json:"file_path"` + + // LineNumber is the 1-based line number for insertion (defaults to 1 for new files). + LineNumber int `json:"line_number"` + + // Description is the specific note/description for this location. + Description string `json:"description"` + + // IsNewFile indicates whether this file should be created from scratch. + IsNewFile bool `json:"is_new_file"` +} + // InjectTaskRequest is the request body for POST /api/tasks/inject. // It describes where and what TODO comment the user wants to insert -// into their repository via the GitHub API. +// into their repository via the GitHub API. Supports single and multi-location injections. type InjectTaskRequest struct { // RepoOwner is the GitHub username or organisation that owns the repository. RepoOwner string `json:"repo_owner" validate:"required"` @@ -178,14 +193,14 @@ type InjectTaskRequest struct { // RepoName is the repository name (not full name, just the repo part). RepoName string `json:"repo_name" validate:"required"` - // FilePath is the repository-relative path of the file to modify. - FilePath string `json:"file_path" validate:"required"` + // FilePath is the repository-relative path of the file to modify (for single-location requests). + FilePath string `json:"file_path,omitempty"` - // LineNumber is the 1-based line number at which the TODO comment is inserted. - LineNumber int `json:"line_number" validate:"required,min=1"` + // LineNumber is the 1-based line number at which the TODO comment is inserted (for single-location requests). + LineNumber int `json:"line_number,omitempty"` // Description is the human-readable text that will appear after the TODO keyword. - Description string `json:"description" validate:"required"` + Description string `json:"description,omitempty"` // Branch is the base branch to read from and create the PR against. Branch string `json:"branch" validate:"required"` @@ -195,6 +210,9 @@ type InjectTaskRequest struct { // IssueURL is the linked GitHub Issue URL. IssueURL string `json:"issue_url,omitempty"` + + // Locations contains multiple target files/lines/descriptions if multi-location injection is used. + Locations []TaskLocation `json:"locations,omitempty"` } // UpdateTaskRequest is the request body for PATCH /api/tasks/:id. diff --git a/backend/internal/service/github_service.go b/backend/internal/service/github_service.go index cd1d254..947e6eb 100644 --- a/backend/internal/service/github_service.go +++ b/backend/internal/service/github_service.go @@ -16,6 +16,7 @@ import ( "fmt" "net/http" "regexp" + "sort" "strings" "time" @@ -326,13 +327,20 @@ func (s *GithubService) GetContents(ctx context.Context, userID primitive.Object // 2. Get the latest commit SHA on the target branch. // 3. Get the tree SHA from that commit. // 4. Fetch and decode the target file's current content. -// 5. Insert the TODO comment at the requested line number. -// 6. Create a new blob with the modified content. -// 7. Create a new tree that replaces the old blob with the new one. -// 8. Create a new commit pointing at the new tree. -// 9. Create a new branch `codetasker/inject-`. -// 10. Open a PR from that branch into the base branch. -// 11. Return the PR URL. +// InjectTODO creates a new branch, commits the requested TODO comments into the +// targeted files (supporting multi-location and creating new files), opens a pull request, and returns the PR HTML URL. +// +// The pipeline executes the following steps: +// 1. Validate repository, branch, and file paths (SSRF guards). +// 2. Resolve the latest commit on the base branch and its tree. +// 3. Group locations by file path, fetching existing files or creating new ones. +// 4. Insert TODO comments at the specified line numbers with appropriate comment syntax. +// 5. Create blobs for all modified and newly created files. +// 6. Create a new tree referencing all updated/created blobs. +// 7. Create a new commit pointing at the new tree. +// 8. Create a new branch `codetasker/inject-`. +// 9. Open a structured PR with details of all modified and created files. +// 10. Return the PR URL. func (s *GithubService) InjectTODO(ctx context.Context, userID primitive.ObjectID, req *domain.InjectTaskRequest) (string, error) { // ── SSRF guards ───────────────────────────────────────────────────────── if err := validateName(req.RepoOwner, "repo_owner"); err != nil { @@ -345,13 +353,40 @@ func (s *GithubService) InjectTODO(ctx context.Context, userID primitive.ObjectI return "", err } - // file_path may contain slashes — validate each segment individually. - for _, segment := range strings.Split(req.FilePath, "/") { - if segment == "" { - continue + // Normalise single-location requests into Locations slice for backward compatibility + if len(req.Locations) == 0 && req.FilePath != "" { + req.Locations = []domain.TaskLocation{ + { + FilePath: req.FilePath, + LineNumber: req.LineNumber, + Description: req.Description, + IsNewFile: false, + }, + } + } + + if len(req.Locations) == 0 { + return "", fmt.Errorf("no task locations provided") + } + + tagType := req.Type + if tagType == "" { + tagType = "TODO" + } + + // Validate file paths for all locations (SSRF check) + for _, loc := range req.Locations { + path := strings.TrimSpace(loc.FilePath) + if path == "" { + return "", fmt.Errorf("file_path cannot be empty") } - if err := validateName(segment, "file_path segment"); err != nil { - return "", fmt.Errorf("invalid file_path %q: %w", req.FilePath, err) + for _, segment := range strings.Split(path, "/") { + if segment == "" { + continue + } + if err := validateName(segment, "file_path segment"); err != nil { + return "", fmt.Errorf("invalid file_path %q: %w", loc.FilePath, err) + } } } @@ -380,74 +415,148 @@ func (s *GithubService) InjectTODO(ctx context.Context, userID primitive.ObjectI commitTreeSHA := commit.GetTree().GetSHA() - // ── Step 3 & 4: Fetch and decode the target file ───────────────────────── - opts := &github.RepositoryContentGetOptions{Ref: branch} - fileContent, _, _, err := client.Repositories.GetContents(ctx, owner, repo, req.FilePath, opts) - if err != nil { - return "", fmt.Errorf("InjectTODO GetContents(%s): %w", req.FilePath, err) + // ── Step 3: Group locations by FilePath ───────────────────────────────── + type fileGroup struct { + isNew bool + locations []domain.TaskLocation } + fileMap := make(map[string]*fileGroup) + var orderedFiles []string - existingContent, err := fileContent.GetContent() - if err != nil { - return "", fmt.Errorf("InjectTODO decode file content: %w", err) + for _, loc := range req.Locations { + path := strings.TrimSpace(loc.FilePath) + if path == "" { + continue + } + fg, exists := fileMap[path] + if !exists { + fg = &fileGroup{ + isNew: loc.IsNewFile, + locations: nil, + } + fileMap[path] = fg + orderedFiles = append(orderedFiles, path) + } else if loc.IsNewFile { + fg.isNew = true + } + fg.locations = append(fg.locations, loc) } - // ── Step 5: Insert the TODO comment at the requested line ───────────────── - lines := strings.Split(existingContent, "\n") + // ── Step 4: Process each file and generate modified contents ───────────── + fileContents := make(map[string]string) - commentSymbol := getCommentPrefix(req.FilePath) - tagType := req.Type - if tagType == "" { - tagType = "TODO" - } - todoLine := fmt.Sprintf("%s %s: %s", commentSymbol, tagType, req.Description) + for _, filePath := range orderedFiles { + fg := fileMap[filePath] + commentSymbol := getCommentPrefix(filePath) - insertAt := req.LineNumber - 1 // convert to 0-based index - if insertAt < 0 { - insertAt = 0 - } - if insertAt > len(lines) { - insertAt = len(lines) - } + if fg.isNew { + // For new files, build content from locations + var lines []string + for _, loc := range fg.locations { + desc := strings.TrimSpace(loc.Description) + if desc == "" { + desc = req.Description + } + todoLine := fmt.Sprintf("%s %s: %s", commentSymbol, tagType, desc) + lines = append(lines, todoLine) + } + fileContents[filePath] = strings.Join(lines, "\n") + "\n" + } else { + // Fetch existing file content from GitHub + opts := &github.RepositoryContentGetOptions{Ref: branch} + fc, _, _, err := client.Repositories.GetContents(ctx, owner, repo, filePath, opts) + if err != nil { + return "", fmt.Errorf("InjectTODO GetContents(%s): %w", filePath, err) + } - // Insert by growing the slice. - lines = append(lines, "") - copy(lines[insertAt+1:], lines[insertAt:]) - lines[insertAt] = todoLine + existingContent, err := fc.GetContent() + if err != nil { + return "", fmt.Errorf("InjectTODO decode file content (%s): %w", filePath, err) + } - modifiedContent := strings.Join(lines, "\n") + lines := strings.Split(existingContent, "\n") - // ── Step 6: Create a new blob for the modified file ─────────────────────── - encodingStr := "base64" - encodedContent := base64.StdEncoding.EncodeToString([]byte(modifiedContent)) + // Sort locations descending by line number so earlier insertions don't alter target line numbers + locs := make([]domain.TaskLocation, len(fg.locations)) + copy(locs, fg.locations) + sort.SliceStable(locs, func(i, j int) bool { + return locs[i].LineNumber > locs[j].LineNumber + }) - blob, _, err := client.Git.CreateBlob(ctx, owner, repo, &github.Blob{ - Content: &encodedContent, - Encoding: &encodingStr, - }) - if err != nil { - return "", fmt.Errorf("InjectTODO CreateBlob: %w", err) + for _, loc := range locs { + desc := strings.TrimSpace(loc.Description) + if desc == "" { + desc = req.Description + } + todoLine := fmt.Sprintf("%s %s: %s", commentSymbol, tagType, desc) + + lineNum := loc.LineNumber + if lineNum < 1 { + lineNum = 1 + } + insertAt := lineNum - 1 + if insertAt < 0 { + insertAt = 0 + } + if insertAt > len(lines) { + insertAt = len(lines) + } + + // Insert line + lines = append(lines, "") + copy(lines[insertAt+1:], lines[insertAt:]) + lines[insertAt] = todoLine + } + + fileContents[filePath] = strings.Join(lines, "\n") + } } - // ── Step 7: Create a new tree referencing the updated blob ──────────────── - fileMode := "100644" // regular file mode + // ── Step 5: Create Blobs and Tree Entries for all files ─────────────────── + var treeEntries []*github.TreeEntry + encodingStr := "base64" + fileMode := "100644" blobType := "blob" - filePath := req.FilePath // local var so we can take its address - newTree, _, err := client.Git.CreateTree(ctx, owner, repo, commitTreeSHA, []*github.TreeEntry{ - { - Path: &filePath, + for _, filePath := range orderedFiles { + content := fileContents[filePath] + encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) + + blob, _, err := client.Git.CreateBlob(ctx, owner, repo, &github.Blob{ + Content: &encodedContent, + Encoding: &encodingStr, + }) + if err != nil { + return "", fmt.Errorf("InjectTODO CreateBlob(%s): %w", filePath, err) + } + + pathCopy := filePath + treeEntries = append(treeEntries, &github.TreeEntry{ + Path: &pathCopy, Mode: &fileMode, Type: &blobType, SHA: blob.SHA, - }, - }) + }) + } + + // ── Step 6: Create a new tree referencing all updated blobs ─────────────── + newTree, _, err := client.Git.CreateTree(ctx, owner, repo, commitTreeSHA, treeEntries) if err != nil { return "", fmt.Errorf("InjectTODO CreateTree: %w", err) } - // ── Step 8: Create the new commit ───────────────────────────────────────── - commitMsg := fmt.Sprintf("[CodeTasker] Add TODO at %s:%d", req.FilePath, req.LineNumber) + // ── Step 7: Create the new commit ───────────────────────────────────────── + var commitMsg string + if len(req.Locations) == 1 { + loc := req.Locations[0] + if loc.IsNewFile { + commitMsg = fmt.Sprintf("[CodeTasker] Create %s with %s", loc.FilePath, tagType) + } else { + commitMsg = fmt.Sprintf("[CodeTasker] Add %s at %s:%d", tagType, loc.FilePath, loc.LineNumber) + } + } else { + commitMsg = fmt.Sprintf("[CodeTasker] Add %s across %d location(s)", tagType, len(req.Locations)) + } newCommit, _, err := client.Git.CreateCommit(ctx, owner, repo, &github.Commit{ Message: &commitMsg, @@ -458,7 +567,7 @@ func (s *GithubService) InjectTODO(ctx context.Context, userID primitive.ObjectI return "", fmt.Errorf("InjectTODO CreateCommit: %w", err) } - // ── Step 9: Create the new branch ───────────────────────────────────────── + // ── Step 8: Create the new branch ───────────────────────────────────────── newBranchName := fmt.Sprintf("codetasker/inject-%d", time.Now().Unix()) newRefName := "refs/heads/" + newBranchName @@ -470,14 +579,48 @@ func (s *GithubService) InjectTODO(ctx context.Context, userID primitive.ObjectI return "", fmt.Errorf("InjectTODO CreateRef (%s): %w", newBranchName, err) } - // ── Step 10: Open the pull request ──────────────────────────────────────── - prTitle := fmt.Sprintf("[CodeTasker] Add TODO: %s", req.Description) - prBody := fmt.Sprintf( - "This PR was automatically generated by **CodeTasker**.\n\n"+ - "**File:** `%s` \n**Line:** %d \n**TODO:** %s\n", - req.FilePath, req.LineNumber, req.Description, - ) + // ── Step 9: Open the pull request with structured details ──────────────── + var prTitle string + if len(req.Locations) == 1 { + loc := req.Locations[0] + desc := loc.Description + if desc == "" { + desc = req.Description + } + if loc.IsNewFile { + prTitle = fmt.Sprintf("[CodeTasker] Create %s: %s", loc.FilePath, desc) + } else { + prTitle = fmt.Sprintf("[CodeTasker] Add %s: %s", tagType, desc) + } + } else { + prTitle = fmt.Sprintf("[CodeTasker] Add %d %s task(s)", len(req.Locations), tagType) + } + + var bodyBuilder strings.Builder + bodyBuilder.WriteString("This PR was automatically generated by **CodeTasker**.\n\n") + bodyBuilder.WriteString("### 📋 Task Injection Details\n\n") + bodyBuilder.WriteString("| # | Type | Action | Target / File | Line | Description |\n") + bodyBuilder.WriteString("|---|---|---|---|---|---|\n") + + for i, loc := range req.Locations { + action := "Modified file" + lineStr := fmt.Sprintf("L%d", loc.LineNumber) + if loc.IsNewFile { + action = "✨ Created new file" + lineStr = "L1" + } + desc := loc.Description + if desc == "" { + desc = req.Description + } + bodyBuilder.WriteString(fmt.Sprintf("| %d | `%s` | %s | `%s` | %s | %s |\n", i+1, tagType, action, loc.FilePath, lineStr, desc)) + } + + if req.IssueURL != "" { + bodyBuilder.WriteString(fmt.Sprintf("\n**Linked Issue:** %s\n", req.IssueURL)) + } + prBody := bodyBuilder.String() pr, _, err := client.PullRequests.Create(ctx, owner, repo, &github.NewPullRequest{ Title: &prTitle, Body: &prBody, @@ -488,14 +631,13 @@ func (s *GithubService) InjectTODO(ctx context.Context, userID primitive.ObjectI return "", fmt.Errorf("InjectTODO CreatePullRequest: %w", err) } - s.log.Info("TODO injected via PR", + s.log.Info("TODO(s) injected via PR", zap.String("repo", owner+"/"+repo), - zap.String("file", req.FilePath), - zap.Int("line", req.LineNumber), + zap.Int("locations_count", len(req.Locations)), zap.String("pr_url", pr.GetHTMLURL()), ) - // ── Step 11: Return PR URL ──────────────────────────────────────────────── + // ── Step 10: Return PR URL ─────────────────────────────────────────────── return pr.GetHTMLURL(), nil } diff --git a/frontend/src/components/TaskBoard.tsx b/frontend/src/components/TaskBoard.tsx index 67770db..d3bf59c 100644 --- a/frontend/src/components/TaskBoard.tsx +++ b/frontend/src/components/TaskBoard.tsx @@ -235,7 +235,6 @@ function TaskDetailModal({ } }; - const displayPath = task.file_path.split('/').slice(-2).join('/'); const shortSha = task.commit_sha.slice(0, 7); return ( @@ -253,10 +252,10 @@ function TaskDetailModal({

{task.content}

-
- {displayPath} - L{task.line_number} - {shortSha} +
+ {task.file_path} + L{task.line_number} + {shortSha}
@@ -728,12 +727,12 @@ function TaskCard({ onClick={() => onTaskClick ? onTaskClick(task.file_path, task.line_number) : onInjectClick(task.line_number)} title="Click to view code at this line" > - {/* Top row: type badge + file path */} + {/* Top row: type badge + file path & line */}
@@ -741,7 +740,7 @@ function TaskCard({
L{task.line_number} diff --git a/frontend/src/components/TaskInjector.tsx b/frontend/src/components/TaskInjector.tsx index 2f13bb9..2c4d365 100644 --- a/frontend/src/components/TaskInjector.tsx +++ b/frontend/src/components/TaskInjector.tsx @@ -1,15 +1,17 @@ /** - * TaskInjector — Slide-out panel for injecting a TODO comment into a repo file. + * TaskInjector — Slide-out panel for injecting task annotations into repo files. * - * Slides in from the right edge. Accepts pre-filled file path and line number - * (e.g. from clicking a line in CodeViewer). On submit, calls the inject API - * and shows the resulting PR URL with a 3-second auto-close timer. + * Supports: + * - Single or multiple file/line targets in one pull request. + * - Creating brand new files with task comments. + * - Distinct per-location descriptions. + * - Linking GitHub issues and selecting custom branches. */ import { useState, useEffect, useRef } from 'react'; -import { X, ExternalLink } from 'lucide-react'; +import { X, ExternalLink, Plus, Trash2, FilePlus, FileCode } from 'lucide-react'; import { tasksApi } from '../api/client'; -import type { InjectTaskRequest, ApiError, Issue } from '../types'; +import type { InjectTaskRequest, TaskLocation, ApiError, Issue } from '../types'; import Spinner from './ui/Spinner'; // ── Props ──────────────────────────────────────────────────────────────────── @@ -27,6 +29,14 @@ interface TaskInjectorProps { prefilledFile?: string; } +interface FormLocation { + id: string; + filePath: string; + lineNumber: string; + description: string; + isNewFile: boolean; +} + // ── Component ──────────────────────────────────────────────────────────────── export default function TaskInjector({ @@ -40,10 +50,16 @@ export default function TaskInjector({ prefilledFile, }: TaskInjectorProps) { // ── Form state ──────────────────────────────────────────────────────────── - const [filePath, setFilePath] = useState(''); - const [lineNumber, setLineNumber] = useState(''); + const [locations, setLocations] = useState([ + { + id: 'loc-1', + filePath: '', + lineNumber: '', + description: '', + isNewFile: false, + }, + ]); const [taskType, setTaskType] = useState('TODO'); - const [description, setDescription] = useState(''); const [branch, setBranch] = useState(''); const [selectedIssueUrl, setSelectedIssueUrl] = useState(''); @@ -58,11 +74,17 @@ export default function TaskInjector({ // ── Pre-fill whenever panel opens or prefilled values change ────────────── useEffect(() => { if (isOpen) { - setFilePath(prefilledFile ?? ''); - setLineNumber(prefilledLine != null ? String(prefilledLine) : ''); + setLocations([ + { + id: `loc-${Date.now()}`, + filePath: prefilledFile ?? '', + lineNumber: prefilledLine != null ? String(prefilledLine) : '1', + description: '', + isNewFile: false, + }, + ]); setTaskType('TODO'); - setBranch(defaultBranch); - setDescription(''); + setBranch(defaultBranch || 'main'); setSelectedIssueUrl(''); setPrUrl(null); setFormError(null); @@ -77,39 +99,94 @@ export default function TaskInjector({ }; }, []); + // ── Location management ─────────────────────────────────────────────────── + + const handleAddLocation = () => { + setLocations((prev) => [ + ...prev, + { + id: `loc-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`, + filePath: '', + lineNumber: '1', + description: '', + isNewFile: false, + }, + ]); + }; + + const handleRemoveLocation = (id: string) => { + if (locations.length <= 1) return; + setLocations((prev) => prev.filter((loc) => loc.id !== id)); + }; + + const handleUpdateLocation = (id: string, updates: Partial) => { + setLocations((prev) => + prev.map((loc) => (loc.id === id ? { ...loc, ...updates } : loc)) + ); + }; + // ── Form submit ─────────────────────────────────────────────────────────── const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setFormError(null); - const lineNum = parseInt(lineNumber, 10); - if (!filePath.trim()) { - setFormError('File path is required.'); - return; - } - if (isNaN(lineNum) || lineNum < 1) { - setFormError('Line number must be a positive integer.'); + if (!branch.trim()) { + setFormError('Target branch name is required.'); return; } - if (!description.trim()) { - setFormError('Description is required.'); + + if (locations.length === 0) { + setFormError('At least one file location is required.'); return; } - if (!branch.trim()) { - setFormError('Branch name is required.'); - return; + + const payloadLocations: TaskLocation[] = []; + + for (let i = 0; i < locations.length; i++) { + const loc = locations[i]; + const trimmedPath = loc.filePath.trim(); + const trimmedDesc = loc.description.trim(); + + if (!trimmedPath) { + setFormError(`Location #${i + 1}: File path is required.`); + return; + } + + let lineNum = parseInt(loc.lineNumber, 10); + if (loc.isNewFile) { + if (isNaN(lineNum) || lineNum < 1) lineNum = 1; + } else { + if (isNaN(lineNum) || lineNum < 1) { + setFormError(`Location #${i + 1} (${trimmedPath}): Line number must be >= 1.`); + return; + } + } + + if (!trimmedDesc) { + setFormError(`Location #${i + 1} (${trimmedPath}): Description is required.`); + return; + } + + payloadLocations.push({ + file_path: trimmedPath, + line_number: lineNum, + description: trimmedDesc, + is_new_file: loc.isNewFile, + }); } + const firstLoc = payloadLocations[0]; const req: InjectTaskRequest = { repo_owner: repoOwner, repo_name: repoName, - file_path: filePath.trim(), - line_number: lineNum, - description: description.trim(), + file_path: firstLoc.file_path, + line_number: firstLoc.line_number, + description: firstLoc.description, branch: branch.trim(), type: taskType, issue_url: selectedIssueUrl || undefined, + locations: payloadLocations, }; setIsSubmitting(true); @@ -117,13 +194,13 @@ export default function TaskInjector({ const { pr_url } = await tasksApi.inject(req); setPrUrl(pr_url); - // Auto-close after 3 seconds + // Auto-close after 4 seconds closeTimerRef.current = setTimeout(() => { onClose(); - }, 3000); + }, 4000); } catch (err) { const apiErr = err as ApiError; - setFormError(apiErr.message ?? 'Failed to inject TODO. Please try again.'); + setFormError(apiErr.message ?? 'Failed to inject task. Please try again.'); } finally { setIsSubmitting(false); } @@ -157,10 +234,10 @@ export default function TaskInjector({