From 20f25ca062503abdc72cceb866a1d6665ed6cccd Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 28 Jan 2026 20:04:45 +0000 Subject: [PATCH] feat(agentic): add AI collaboration features Context gathering: - BuildTaskContext for AI consumption - GatherRelatedFiles from task references - Keyword search for related code - Git status and recent commits Task completion: - AutoCommit with task reference and Co-Authored-By - CreatePR using gh CLI - SyncStatus back to agentic service - CreateBranch with {type}/{id}-{title} format CLI commands: - core dev task --context - show with AI context - core dev task:commit - auto-commit - core dev task:pr - create PR Co-Authored-By: Claude Opus 4.5 --- cmd/core/cmd/agentic.go | 275 ++++++++++++++++++++++++++- pkg/agentic/completion.go | 338 +++++++++++++++++++++++++++++++++ pkg/agentic/completion_test.go | 198 +++++++++++++++++++ pkg/agentic/context.go | 335 ++++++++++++++++++++++++++++++++ pkg/agentic/context_test.go | 214 +++++++++++++++++++++ 5 files changed, 1359 insertions(+), 1 deletion(-) create mode 100644 pkg/agentic/completion.go create mode 100644 pkg/agentic/completion_test.go create mode 100644 pkg/agentic/context.go create mode 100644 pkg/agentic/context_test.go diff --git a/cmd/core/cmd/agentic.go b/cmd/core/cmd/agentic.go index 852427d..909528c 100644 --- a/cmd/core/cmd/agentic.go +++ b/cmd/core/cmd/agentic.go @@ -1,9 +1,11 @@ package cmd import ( + "bytes" "context" "fmt" "os" + "os/exec" "sort" "strings" "time" @@ -60,6 +62,12 @@ func AddAgenticCommands(parent *clir.Command) { // core dev task:complete - mark task complete addTaskCompleteCommand(parent) + + // core dev task:commit - auto-commit with task reference + addTaskCommitCommand(parent) + + // core dev task:pr - create PR for task + addTaskPRCommand(parent) } func addTasksCommand(parent *clir.Command) { @@ -134,16 +142,19 @@ func addTasksCommand(parent *clir.Command) { func addTaskCommand(parent *clir.Command) { var autoSelect bool var claim bool + var showContext bool cmd := parent.NewSubCommand("task", "Show task details or auto-select a task") cmd.LongDescription("Shows details of a specific task or auto-selects the highest priority task.\n\n" + "Examples:\n" + " core dev task abc123 # Show task details\n" + " core dev task abc123 --claim # Show and claim the task\n" + + " core dev task abc123 --context # Show task with gathered context\n" + " core dev task --auto # Auto-select highest priority pending task") cmd.BoolFlag("auto", "Auto-select highest priority pending task", &autoSelect) cmd.BoolFlag("claim", "Claim the task after showing details", &claim) + cmd.BoolFlag("context", "Show gathered context for AI collaboration", &showContext) cmd.Action(func() error { cfg, err := agentic.LoadConfig("") @@ -210,7 +221,18 @@ func addTaskCommand(parent *clir.Command) { } } - printTaskDetails(task) + // Show context if requested + if showContext { + cwd, _ := os.Getwd() + taskCtx, err := agentic.BuildTaskContext(task, cwd) + if err != nil { + fmt.Printf("%s Failed to build context: %s\n", errorStyle.Render(">>"), err) + } else { + fmt.Println(taskCtx.FormatContext()) + } + } else { + printTaskDetails(task) + } if claim && task.Status == agentic.StatusPending { fmt.Println() @@ -440,3 +462,254 @@ func formatTaskStatus(s agentic.TaskStatus) string { return dimStyle.Render(string(s)) } } + +func addTaskCommitCommand(parent *clir.Command) { + var message string + var scope string + var push bool + + cmd := parent.NewSubCommand("task:commit", "Auto-commit changes with task reference") + cmd.LongDescription("Creates a git commit with a task reference and co-author attribution.\n\n" + + "Commit message format:\n" + + " feat(scope): description\n" + + "\n" + + " Task: #123\n" + + " Co-Authored-By: Claude \n\n" + + "Examples:\n" + + " core dev task:commit abc123 --message 'add user authentication'\n" + + " core dev task:commit abc123 -m 'fix login bug' --scope auth\n" + + " core dev task:commit abc123 -m 'update docs' --push") + + cmd.StringFlag("message", "Commit message (without task reference)", &message) + cmd.StringFlag("m", "Commit message (short form)", &message) + cmd.StringFlag("scope", "Scope for the commit type (e.g., auth, api, ui)", &scope) + cmd.BoolFlag("push", "Push changes after committing", &push) + + cmd.Action(func() error { + // Find task ID from args + args := os.Args + var taskID string + for i, arg := range args { + if arg == "task:commit" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + taskID = args[i+1] + break + } + } + + if taskID == "" { + return fmt.Errorf("task ID required") + } + + if message == "" { + return fmt.Errorf("commit message required (--message or -m)") + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Get task details + task, err := client.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + + // Build commit message with optional scope + commitType := inferCommitType(task.Labels) + var fullMessage string + if scope != "" { + fullMessage = fmt.Sprintf("%s(%s): %s", commitType, scope, message) + } else { + fullMessage = fmt.Sprintf("%s: %s", commitType, message) + } + + // Get current directory + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Check for uncommitted changes + hasChanges, err := agentic.HasUncommittedChanges(ctx, cwd) + if err != nil { + return fmt.Errorf("failed to check git status: %w", err) + } + + if !hasChanges { + fmt.Println("No uncommitted changes to commit.") + return nil + } + + // Create commit + fmt.Printf("%s Creating commit for task %s...\n", dimStyle.Render(">>"), taskID) + if err := agentic.AutoCommit(ctx, task, cwd, fullMessage); err != nil { + return fmt.Errorf("failed to commit: %w", err) + } + + fmt.Printf("%s Committed: %s\n", successStyle.Render(">>"), fullMessage) + + // Push if requested + if push { + fmt.Printf("%s Pushing changes...\n", dimStyle.Render(">>")) + if err := agentic.PushChanges(ctx, cwd); err != nil { + return fmt.Errorf("failed to push: %w", err) + } + fmt.Printf("%s Changes pushed successfully\n", successStyle.Render(">>")) + } + + return nil + }) +} + +func addTaskPRCommand(parent *clir.Command) { + var title string + var draft bool + var labels string + var base string + + cmd := parent.NewSubCommand("task:pr", "Create a pull request for a task") + cmd.LongDescription("Creates a GitHub pull request linked to a task.\n\n" + + "Requires the GitHub CLI (gh) to be installed and authenticated.\n\n" + + "Examples:\n" + + " core dev task:pr abc123\n" + + " core dev task:pr abc123 --title 'Add authentication feature'\n" + + " core dev task:pr abc123 --draft --labels 'enhancement,needs-review'\n" + + " core dev task:pr abc123 --base develop") + + cmd.StringFlag("title", "PR title (defaults to task title)", &title) + cmd.BoolFlag("draft", "Create as draft PR", &draft) + cmd.StringFlag("labels", "Labels to add (comma-separated)", &labels) + cmd.StringFlag("base", "Base branch (defaults to main)", &base) + + cmd.Action(func() error { + // Find task ID from args + args := os.Args + var taskID string + for i, arg := range args { + if arg == "task:pr" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + taskID = args[i+1] + break + } + } + + if taskID == "" { + return fmt.Errorf("task ID required") + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Get task details + task, err := client.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + + // Get current directory + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Check current branch + branch, err := agentic.GetCurrentBranch(ctx, cwd) + if err != nil { + return fmt.Errorf("failed to get current branch: %w", err) + } + + if branch == "main" || branch == "master" { + return fmt.Errorf("cannot create PR from %s branch; create a feature branch first", branch) + } + + // Push current branch + fmt.Printf("%s Pushing branch %s...\n", dimStyle.Render(">>"), branch) + if err := agentic.PushChanges(ctx, cwd); err != nil { + // Try setting upstream + if _, err := runGitCommand(cwd, "push", "-u", "origin", branch); err != nil { + return fmt.Errorf("failed to push branch: %w", err) + } + } + + // Build PR options + opts := agentic.PROptions{ + Title: title, + Draft: draft, + Base: base, + } + + if labels != "" { + opts.Labels = strings.Split(labels, ",") + } + + // Create PR + fmt.Printf("%s Creating pull request...\n", dimStyle.Render(">>")) + prURL, err := agentic.CreatePR(ctx, task, cwd, opts) + if err != nil { + return fmt.Errorf("failed to create PR: %w", err) + } + + fmt.Printf("%s Pull request created!\n", successStyle.Render(">>")) + fmt.Printf(" URL: %s\n", prURL) + + return nil + }) +} + +// inferCommitType infers the commit type from task labels. +func inferCommitType(labels []string) string { + for _, label := range labels { + switch strings.ToLower(label) { + case "bug", "bugfix", "fix": + return "fix" + case "docs", "documentation": + return "docs" + case "refactor", "refactoring": + return "refactor" + case "test", "tests", "testing": + return "test" + case "chore": + return "chore" + case "style": + return "style" + case "perf", "performance": + return "perf" + case "ci": + return "ci" + case "build": + return "build" + } + } + return "feat" +} + +// runGitCommand runs a git command in the specified directory. +func runGitCommand(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return "", fmt.Errorf("%w: %s", err, stderr.String()) + } + return "", err + } + + return stdout.String(), nil +} diff --git a/pkg/agentic/completion.go b/pkg/agentic/completion.go new file mode 100644 index 0000000..9e50a50 --- /dev/null +++ b/pkg/agentic/completion.go @@ -0,0 +1,338 @@ +// Package agentic provides AI collaboration features for task management. +package agentic + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/host-uk/core/pkg/core" +) + +// PROptions contains options for creating a pull request. +type PROptions struct { + // Title is the PR title. + Title string `json:"title"` + // Body is the PR description. + Body string `json:"body"` + // Draft marks the PR as a draft. + Draft bool `json:"draft"` + // Labels are labels to add to the PR. + Labels []string `json:"labels"` + // Base is the base branch (defaults to main). + Base string `json:"base"` +} + +// AutoCommit creates a git commit with a task reference. +// The commit message follows the format: +// +// feat(scope): description +// +// Task: #123 +// Co-Authored-By: Claude +func AutoCommit(ctx context.Context, task *Task, dir string, message string) error { + const op = "agentic.AutoCommit" + + if task == nil { + return core.E(op, "task is required", nil) + } + + if message == "" { + return core.E(op, "commit message is required", nil) + } + + // Build full commit message + fullMessage := buildCommitMessage(task, message) + + // Stage all changes + if _, err := runGitCommandCtx(ctx, dir, "add", "-A"); err != nil { + return core.E(op, "failed to stage changes", err) + } + + // Create commit + if _, err := runGitCommandCtx(ctx, dir, "commit", "-m", fullMessage); err != nil { + return core.E(op, "failed to create commit", err) + } + + return nil +} + +// buildCommitMessage formats a commit message with task reference. +func buildCommitMessage(task *Task, message string) string { + var sb strings.Builder + + // Write the main message + sb.WriteString(message) + sb.WriteString("\n\n") + + // Add task reference + sb.WriteString("Task: #") + sb.WriteString(task.ID) + sb.WriteString("\n") + + // Add co-author + sb.WriteString("Co-Authored-By: Claude \n") + + return sb.String() +} + +// CreatePR creates a pull request using the gh CLI. +func CreatePR(ctx context.Context, task *Task, dir string, opts PROptions) (string, error) { + const op = "agentic.CreatePR" + + if task == nil { + return "", core.E(op, "task is required", nil) + } + + // Build title if not provided + title := opts.Title + if title == "" { + title = task.Title + } + + // Build body if not provided + body := opts.Body + if body == "" { + body = buildPRBody(task) + } + + // Build gh command arguments + args := []string{"pr", "create", "--title", title, "--body", body} + + if opts.Draft { + args = append(args, "--draft") + } + + if opts.Base != "" { + args = append(args, "--base", opts.Base) + } + + for _, label := range opts.Labels { + args = append(args, "--label", label) + } + + // Run gh pr create + output, err := runCommandCtx(ctx, dir, "gh", args...) + if err != nil { + return "", core.E(op, "failed to create PR", err) + } + + // Extract PR URL from output + prURL := strings.TrimSpace(output) + + return prURL, nil +} + +// buildPRBody creates a PR body from task details. +func buildPRBody(task *Task) string { + var sb strings.Builder + + sb.WriteString("## Summary\n\n") + sb.WriteString(task.Description) + sb.WriteString("\n\n") + + sb.WriteString("## Task Reference\n\n") + sb.WriteString("- Task ID: #") + sb.WriteString(task.ID) + sb.WriteString("\n") + sb.WriteString("- Priority: ") + sb.WriteString(string(task.Priority)) + sb.WriteString("\n") + + if len(task.Labels) > 0 { + sb.WriteString("- Labels: ") + sb.WriteString(strings.Join(task.Labels, ", ")) + sb.WriteString("\n") + } + + sb.WriteString("\n---\n") + sb.WriteString("Generated with AI assistance\n") + + return sb.String() +} + +// SyncStatus syncs the task status back to the agentic service. +func SyncStatus(ctx context.Context, client *Client, task *Task, update TaskUpdate) error { + const op = "agentic.SyncStatus" + + if client == nil { + return core.E(op, "client is required", nil) + } + + if task == nil { + return core.E(op, "task is required", nil) + } + + return client.UpdateTask(ctx, task.ID, update) +} + +// CommitAndSync commits changes and syncs task status. +func CommitAndSync(ctx context.Context, client *Client, task *Task, dir string, message string, progress int) error { + const op = "agentic.CommitAndSync" + + // Create commit + if err := AutoCommit(ctx, task, dir, message); err != nil { + return core.E(op, "failed to commit", err) + } + + // Sync status if client provided + if client != nil { + update := TaskUpdate{ + Status: StatusInProgress, + Progress: progress, + Notes: "Committed: " + message, + } + + if err := SyncStatus(ctx, client, task, update); err != nil { + // Log but don't fail on sync errors + return core.E(op, "commit succeeded but sync failed", err) + } + } + + return nil +} + +// PushChanges pushes committed changes to the remote. +func PushChanges(ctx context.Context, dir string) error { + const op = "agentic.PushChanges" + + _, err := runGitCommandCtx(ctx, dir, "push") + if err != nil { + return core.E(op, "failed to push changes", err) + } + + return nil +} + +// CreateBranch creates a new branch for the task. +func CreateBranch(ctx context.Context, task *Task, dir string) (string, error) { + const op = "agentic.CreateBranch" + + if task == nil { + return "", core.E(op, "task is required", nil) + } + + // Generate branch name from task + branchName := generateBranchName(task) + + // Create and checkout branch + _, err := runGitCommandCtx(ctx, dir, "checkout", "-b", branchName) + if err != nil { + return "", core.E(op, "failed to create branch", err) + } + + return branchName, nil +} + +// generateBranchName creates a branch name from task details. +func generateBranchName(task *Task) string { + // Determine prefix based on labels + prefix := "feat" + for _, label := range task.Labels { + switch strings.ToLower(label) { + case "bug", "bugfix", "fix": + prefix = "fix" + case "docs", "documentation": + prefix = "docs" + case "refactor": + prefix = "refactor" + case "test", "tests": + prefix = "test" + case "chore": + prefix = "chore" + } + } + + // Sanitize title for branch name + title := strings.ToLower(task.Title) + title = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + return r + } + if r == ' ' || r == '-' || r == '_' { + return '-' + } + return -1 + }, title) + + // Remove consecutive dashes + for strings.Contains(title, "--") { + title = strings.ReplaceAll(title, "--", "-") + } + title = strings.Trim(title, "-") + + // Truncate if too long + if len(title) > 40 { + title = title[:40] + title = strings.TrimRight(title, "-") + } + + return fmt.Sprintf("%s/%s-%s", prefix, task.ID, title) +} + +// runGitCommandCtx runs a git command with context. +func runGitCommandCtx(ctx context.Context, dir string, args ...string) (string, error) { + return runCommandCtx(ctx, dir, "git", args...) +} + +// runCommandCtx runs an arbitrary command with context. +func runCommandCtx(ctx context.Context, dir string, command string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, command, args...) + cmd.Dir = dir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return "", fmt.Errorf("%w: %s", err, stderr.String()) + } + return "", err + } + + return stdout.String(), nil +} + +// GetCurrentBranch returns the current git branch name. +func GetCurrentBranch(ctx context.Context, dir string) (string, error) { + const op = "agentic.GetCurrentBranch" + + output, err := runGitCommandCtx(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", core.E(op, "failed to get current branch", err) + } + + return strings.TrimSpace(output), nil +} + +// HasUncommittedChanges checks if there are uncommitted changes. +func HasUncommittedChanges(ctx context.Context, dir string) (bool, error) { + const op = "agentic.HasUncommittedChanges" + + output, err := runGitCommandCtx(ctx, dir, "status", "--porcelain") + if err != nil { + return false, core.E(op, "failed to get git status", err) + } + + return strings.TrimSpace(output) != "", nil +} + +// GetDiff returns the current diff for staged and unstaged changes. +func GetDiff(ctx context.Context, dir string, staged bool) (string, error) { + const op = "agentic.GetDiff" + + args := []string{"diff"} + if staged { + args = append(args, "--staged") + } + + output, err := runGitCommandCtx(ctx, dir, args...) + if err != nil { + return "", core.E(op, "failed to get diff", err) + } + + return output, nil +} diff --git a/pkg/agentic/completion_test.go b/pkg/agentic/completion_test.go new file mode 100644 index 0000000..068b640 --- /dev/null +++ b/pkg/agentic/completion_test.go @@ -0,0 +1,198 @@ +package agentic + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildCommitMessage(t *testing.T) { + task := &Task{ + ID: "ABC123", + Title: "Test Task", + } + + message := buildCommitMessage(task, "add new feature") + + assert.Contains(t, message, "add new feature") + assert.Contains(t, message, "Task: #ABC123") + assert.Contains(t, message, "Co-Authored-By: Claude ") +} + +func TestBuildPRBody(t *testing.T) { + task := &Task{ + ID: "PR-456", + Title: "Add authentication", + Description: "Implement user authentication with OAuth2", + Priority: PriorityHigh, + Labels: []string{"enhancement", "security"}, + } + + body := buildPRBody(task) + + assert.Contains(t, body, "## Summary") + assert.Contains(t, body, "Implement user authentication with OAuth2") + assert.Contains(t, body, "## Task Reference") + assert.Contains(t, body, "Task ID: #PR-456") + assert.Contains(t, body, "Priority: high") + assert.Contains(t, body, "Labels: enhancement, security") + assert.Contains(t, body, "Generated with AI assistance") +} + +func TestBuildPRBody_NoLabels(t *testing.T) { + task := &Task{ + ID: "PR-789", + Title: "Fix bug", + Description: "Fix the login bug", + Priority: PriorityMedium, + Labels: nil, + } + + body := buildPRBody(task) + + assert.Contains(t, body, "## Summary") + assert.Contains(t, body, "Fix the login bug") + assert.NotContains(t, body, "Labels:") +} + +func TestGenerateBranchName(t *testing.T) { + tests := []struct { + name string + task *Task + expected string + }{ + { + name: "feature task", + task: &Task{ + ID: "123", + Title: "Add user authentication", + Labels: []string{"enhancement"}, + }, + expected: "feat/123-add-user-authentication", + }, + { + name: "bug fix task", + task: &Task{ + ID: "456", + Title: "Fix login error", + Labels: []string{"bug"}, + }, + expected: "fix/456-fix-login-error", + }, + { + name: "docs task", + task: &Task{ + ID: "789", + Title: "Update README", + Labels: []string{"documentation"}, + }, + expected: "docs/789-update-readme", + }, + { + name: "refactor task", + task: &Task{ + ID: "101", + Title: "Refactor auth module", + Labels: []string{"refactor"}, + }, + expected: "refactor/101-refactor-auth-module", + }, + { + name: "test task", + task: &Task{ + ID: "202", + Title: "Add unit tests", + Labels: []string{"test"}, + }, + expected: "test/202-add-unit-tests", + }, + { + name: "chore task", + task: &Task{ + ID: "303", + Title: "Update dependencies", + Labels: []string{"chore"}, + }, + expected: "chore/303-update-dependencies", + }, + { + name: "long title truncated", + task: &Task{ + ID: "404", + Title: "This is a very long title that should be truncated to fit the branch name limit", + Labels: nil, + }, + expected: "feat/404-this-is-a-very-long-title-that-should-be", + }, + { + name: "special characters removed", + task: &Task{ + ID: "505", + Title: "Fix: user's auth (OAuth2) [important]", + Labels: nil, + }, + expected: "feat/505-fix-users-auth-oauth2-important", + }, + { + name: "no labels defaults to feat", + task: &Task{ + ID: "606", + Title: "New feature", + Labels: nil, + }, + expected: "feat/606-new-feature", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generateBranchName(tt.task) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestAutoCommit_Bad_NilTask(t *testing.T) { + err := AutoCommit(nil, nil, ".", "test message") + assert.Error(t, err) + assert.Contains(t, err.Error(), "task is required") +} + +func TestAutoCommit_Bad_EmptyMessage(t *testing.T) { + task := &Task{ID: "123", Title: "Test"} + err := AutoCommit(nil, task, ".", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "commit message is required") +} + +func TestSyncStatus_Bad_NilClient(t *testing.T) { + task := &Task{ID: "123", Title: "Test"} + update := TaskUpdate{Status: StatusInProgress} + + err := SyncStatus(nil, nil, task, update) + assert.Error(t, err) + assert.Contains(t, err.Error(), "client is required") +} + +func TestSyncStatus_Bad_NilTask(t *testing.T) { + client := &Client{BaseURL: "http://test"} + update := TaskUpdate{Status: StatusInProgress} + + err := SyncStatus(nil, client, nil, update) + assert.Error(t, err) + assert.Contains(t, err.Error(), "task is required") +} + +func TestCreateBranch_Bad_NilTask(t *testing.T) { + branch, err := CreateBranch(nil, nil, ".") + assert.Error(t, err) + assert.Empty(t, branch) + assert.Contains(t, err.Error(), "task is required") +} + +func TestCreatePR_Bad_NilTask(t *testing.T) { + url, err := CreatePR(nil, nil, ".", PROptions{}) + assert.Error(t, err) + assert.Empty(t, url) + assert.Contains(t, err.Error(), "task is required") +} diff --git a/pkg/agentic/context.go b/pkg/agentic/context.go new file mode 100644 index 0000000..62c7b87 --- /dev/null +++ b/pkg/agentic/context.go @@ -0,0 +1,335 @@ +// Package agentic provides AI collaboration features for task management. +package agentic + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/host-uk/core/pkg/core" +) + +// FileContent represents the content of a file for AI context. +type FileContent struct { + // Path is the relative path to the file. + Path string `json:"path"` + // Content is the file content. + Content string `json:"content"` + // Language is the detected programming language. + Language string `json:"language"` +} + +// TaskContext contains gathered context for AI collaboration. +type TaskContext struct { + // Task is the task being worked on. + Task *Task `json:"task"` + // Files is a list of relevant file contents. + Files []FileContent `json:"files"` + // GitStatus is the current git status output. + GitStatus string `json:"git_status"` + // RecentCommits is the recent commit log. + RecentCommits string `json:"recent_commits"` + // RelatedCode contains code snippets related to the task. + RelatedCode []FileContent `json:"related_code"` +} + +// BuildTaskContext gathers context for AI collaboration on a task. +func BuildTaskContext(task *Task, dir string) (*TaskContext, error) { + const op = "agentic.BuildTaskContext" + + if task == nil { + return nil, core.E(op, "task is required", nil) + } + + if dir == "" { + cwd, err := os.Getwd() + if err != nil { + return nil, core.E(op, "failed to get working directory", err) + } + dir = cwd + } + + ctx := &TaskContext{ + Task: task, + } + + // Gather files mentioned in the task + files, err := GatherRelatedFiles(task, dir) + if err != nil { + // Non-fatal: continue without files + files = nil + } + ctx.Files = files + + // Get git status + gitStatus, _ := runGitCommand(dir, "status", "--porcelain") + ctx.GitStatus = gitStatus + + // Get recent commits + recentCommits, _ := runGitCommand(dir, "log", "--oneline", "-10") + ctx.RecentCommits = recentCommits + + // Find related code by searching for keywords + relatedCode, err := findRelatedCode(task, dir) + if err != nil { + relatedCode = nil + } + ctx.RelatedCode = relatedCode + + return ctx, nil +} + +// GatherRelatedFiles reads files mentioned in the task. +func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) { + const op = "agentic.GatherRelatedFiles" + + if task == nil { + return nil, core.E(op, "task is required", nil) + } + + var files []FileContent + + // Read files explicitly mentioned in the task + for _, relPath := range task.Files { + fullPath := filepath.Join(dir, relPath) + + content, err := os.ReadFile(fullPath) + if err != nil { + // Skip files that don't exist + continue + } + + files = append(files, FileContent{ + Path: relPath, + Content: string(content), + Language: detectLanguage(relPath), + }) + } + + return files, nil +} + +// findRelatedCode searches for code related to the task by keywords. +func findRelatedCode(task *Task, dir string) ([]FileContent, error) { + const op = "agentic.findRelatedCode" + + if task == nil { + return nil, core.E(op, "task is required", nil) + } + + // Extract keywords from title and description + keywords := extractKeywords(task.Title + " " + task.Description) + if len(keywords) == 0 { + return nil, nil + } + + var files []FileContent + seen := make(map[string]bool) + + // Search for each keyword using git grep + for _, keyword := range keywords { + if len(keyword) < 3 { + continue + } + + output, err := runGitCommand(dir, "grep", "-l", "-i", keyword, "--", "*.go", "*.ts", "*.js", "*.py") + if err != nil { + continue + } + + // Parse matched files + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || seen[line] { + continue + } + seen[line] = true + + // Limit to 10 related files + if len(files) >= 10 { + break + } + + fullPath := filepath.Join(dir, line) + content, err := os.ReadFile(fullPath) + if err != nil { + continue + } + + // Truncate large files + contentStr := string(content) + if len(contentStr) > 5000 { + contentStr = contentStr[:5000] + "\n... (truncated)" + } + + files = append(files, FileContent{ + Path: line, + Content: contentStr, + Language: detectLanguage(line), + }) + } + + if len(files) >= 10 { + break + } + } + + return files, nil +} + +// extractKeywords extracts meaningful words from text for searching. +func extractKeywords(text string) []string { + // Remove common words and extract identifiers + text = strings.ToLower(text) + + // Split by non-alphanumeric characters + re := regexp.MustCompile(`[^a-zA-Z0-9]+`) + words := re.Split(text, -1) + + // Filter stop words and short words + stopWords := map[string]bool{ + "the": true, "a": true, "an": true, "and": true, "or": true, "but": true, + "in": true, "on": true, "at": true, "to": true, "for": true, "of": true, + "with": true, "by": true, "from": true, "is": true, "are": true, "was": true, + "be": true, "been": true, "being": true, "have": true, "has": true, "had": true, + "do": true, "does": true, "did": true, "will": true, "would": true, "could": true, + "should": true, "may": true, "might": true, "must": true, "shall": true, + "this": true, "that": true, "these": true, "those": true, "it": true, + "add": true, "create": true, "update": true, "fix": true, "remove": true, + "implement": true, "new": true, "file": true, "code": true, + } + + var keywords []string + for _, word := range words { + word = strings.TrimSpace(word) + if len(word) >= 3 && !stopWords[word] { + keywords = append(keywords, word) + } + } + + // Limit to first 5 keywords + if len(keywords) > 5 { + keywords = keywords[:5] + } + + return keywords +} + +// detectLanguage detects the programming language from a file extension. +func detectLanguage(path string) string { + ext := strings.ToLower(filepath.Ext(path)) + + languages := map[string]string{ + ".go": "go", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".py": "python", + ".rs": "rust", + ".java": "java", + ".kt": "kotlin", + ".swift": "swift", + ".c": "c", + ".cpp": "cpp", + ".h": "c", + ".hpp": "cpp", + ".rb": "ruby", + ".php": "php", + ".cs": "csharp", + ".fs": "fsharp", + ".scala": "scala", + ".sh": "bash", + ".bash": "bash", + ".zsh": "zsh", + ".yaml": "yaml", + ".yml": "yaml", + ".json": "json", + ".xml": "xml", + ".html": "html", + ".css": "css", + ".scss": "scss", + ".sql": "sql", + ".md": "markdown", + } + + if lang, ok := languages[ext]; ok { + return lang + } + return "text" +} + +// runGitCommand runs a git command and returns the output. +func runGitCommand(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", err + } + + return stdout.String(), nil +} + +// FormatContext formats the TaskContext for AI consumption. +func (tc *TaskContext) FormatContext() string { + var sb strings.Builder + + sb.WriteString("# Task Context\n\n") + + // Task info + sb.WriteString("## Task\n") + sb.WriteString("ID: " + tc.Task.ID + "\n") + sb.WriteString("Title: " + tc.Task.Title + "\n") + sb.WriteString("Priority: " + string(tc.Task.Priority) + "\n") + sb.WriteString("Status: " + string(tc.Task.Status) + "\n") + sb.WriteString("\n### Description\n") + sb.WriteString(tc.Task.Description + "\n\n") + + // Files + if len(tc.Files) > 0 { + sb.WriteString("## Task Files\n") + for _, f := range tc.Files { + sb.WriteString("### " + f.Path + " (" + f.Language + ")\n") + sb.WriteString("```" + f.Language + "\n") + sb.WriteString(f.Content) + sb.WriteString("\n```\n\n") + } + } + + // Git status + if tc.GitStatus != "" { + sb.WriteString("## Git Status\n") + sb.WriteString("```\n") + sb.WriteString(tc.GitStatus) + sb.WriteString("\n```\n\n") + } + + // Recent commits + if tc.RecentCommits != "" { + sb.WriteString("## Recent Commits\n") + sb.WriteString("```\n") + sb.WriteString(tc.RecentCommits) + sb.WriteString("\n```\n\n") + } + + // Related code + if len(tc.RelatedCode) > 0 { + sb.WriteString("## Related Code\n") + for _, f := range tc.RelatedCode { + sb.WriteString("### " + f.Path + " (" + f.Language + ")\n") + sb.WriteString("```" + f.Language + "\n") + sb.WriteString(f.Content) + sb.WriteString("\n```\n\n") + } + } + + return sb.String() +} diff --git a/pkg/agentic/context_test.go b/pkg/agentic/context_test.go new file mode 100644 index 0000000..5ef102d --- /dev/null +++ b/pkg/agentic/context_test.go @@ -0,0 +1,214 @@ +package agentic + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildTaskContext_Good(t *testing.T) { + // Create a temp directory with some files + tmpDir := t.TempDir() + + // Create a test file + testFile := filepath.Join(tmpDir, "main.go") + err := os.WriteFile(testFile, []byte("package main\n\nfunc main() {}\n"), 0644) + require.NoError(t, err) + + task := &Task{ + ID: "test-123", + Title: "Test Task", + Description: "A test task description", + Priority: PriorityMedium, + Status: StatusPending, + Files: []string{"main.go"}, + CreatedAt: time.Now(), + } + + ctx, err := BuildTaskContext(task, tmpDir) + require.NoError(t, err) + assert.NotNil(t, ctx) + assert.Equal(t, task, ctx.Task) + assert.Len(t, ctx.Files, 1) + assert.Equal(t, "main.go", ctx.Files[0].Path) + assert.Equal(t, "go", ctx.Files[0].Language) +} + +func TestBuildTaskContext_Bad_NilTask(t *testing.T) { + ctx, err := BuildTaskContext(nil, ".") + assert.Error(t, err) + assert.Nil(t, ctx) + assert.Contains(t, err.Error(), "task is required") +} + +func TestGatherRelatedFiles_Good(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files + files := map[string]string{ + "app.go": "package app\n\nfunc Run() {}\n", + "config.ts": "export const config = {};\n", + "README.md": "# Project\n", + } + + for name, content := range files { + path := filepath.Join(tmpDir, name) + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + } + + task := &Task{ + ID: "task-1", + Title: "Test", + Files: []string{"app.go", "config.ts"}, + } + + gathered, err := GatherRelatedFiles(task, tmpDir) + require.NoError(t, err) + assert.Len(t, gathered, 2) + + // Check languages detected correctly + foundGo := false + foundTS := false + for _, f := range gathered { + if f.Path == "app.go" { + foundGo = true + assert.Equal(t, "go", f.Language) + } + if f.Path == "config.ts" { + foundTS = true + assert.Equal(t, "typescript", f.Language) + } + } + assert.True(t, foundGo, "should find app.go") + assert.True(t, foundTS, "should find config.ts") +} + +func TestGatherRelatedFiles_Bad_NilTask(t *testing.T) { + files, err := GatherRelatedFiles(nil, ".") + assert.Error(t, err) + assert.Nil(t, files) +} + +func TestGatherRelatedFiles_Good_MissingFiles(t *testing.T) { + tmpDir := t.TempDir() + + task := &Task{ + ID: "task-1", + Title: "Test", + Files: []string{"nonexistent.go", "also-missing.ts"}, + } + + // Should not error, just return empty list + gathered, err := GatherRelatedFiles(task, tmpDir) + require.NoError(t, err) + assert.Empty(t, gathered) +} + +func TestDetectLanguage(t *testing.T) { + tests := []struct { + path string + expected string + }{ + {"main.go", "go"}, + {"app.ts", "typescript"}, + {"app.tsx", "typescript"}, + {"script.js", "javascript"}, + {"script.jsx", "javascript"}, + {"main.py", "python"}, + {"lib.rs", "rust"}, + {"App.java", "java"}, + {"config.yaml", "yaml"}, + {"config.yml", "yaml"}, + {"data.json", "json"}, + {"index.html", "html"}, + {"styles.css", "css"}, + {"styles.scss", "scss"}, + {"query.sql", "sql"}, + {"README.md", "markdown"}, + {"unknown.xyz", "text"}, + {"", "text"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := detectLanguage(tt.path) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractKeywords(t *testing.T) { + tests := []struct { + name string + text string + expected int // minimum number of keywords expected + }{ + { + name: "simple title", + text: "Add user authentication feature", + expected: 2, + }, + { + name: "with stop words", + text: "The quick brown fox jumps over the lazy dog", + expected: 3, + }, + { + name: "technical text", + text: "Implement OAuth2 authentication with JWT tokens", + expected: 3, + }, + { + name: "empty", + text: "", + expected: 0, + }, + { + name: "only stop words", + text: "the a an and or but in on at", + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keywords := extractKeywords(tt.text) + assert.GreaterOrEqual(t, len(keywords), tt.expected) + // Keywords should not exceed 5 + assert.LessOrEqual(t, len(keywords), 5) + }) + } +} + +func TestTaskContext_FormatContext(t *testing.T) { + task := &Task{ + ID: "test-456", + Title: "Test Formatting", + Description: "This is a test description", + Priority: PriorityHigh, + Status: StatusInProgress, + } + + ctx := &TaskContext{ + Task: task, + Files: []FileContent{{Path: "main.go", Content: "package main", Language: "go"}}, + GitStatus: " M main.go", + RecentCommits: "abc123 Initial commit", + RelatedCode: []FileContent{{Path: "util.go", Content: "package util", Language: "go"}}, + } + + formatted := ctx.FormatContext() + + assert.Contains(t, formatted, "# Task Context") + assert.Contains(t, formatted, "test-456") + assert.Contains(t, formatted, "Test Formatting") + assert.Contains(t, formatted, "## Task Files") + assert.Contains(t, formatted, "## Git Status") + assert.Contains(t, formatted, "## Recent Commits") + assert.Contains(t, formatted, "## Related Code") +}