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 <id> --context - show with AI context
- core dev task:commit <id> - auto-commit
- core dev task:pr <id> - create PR

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-28 20:04:45 +00:00
parent 3b6427f324
commit 20f25ca062
5 changed files with 1359 additions and 1 deletions

View file

@ -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 <id> - mark task complete
addTaskCompleteCommand(parent)
// core dev task:commit <id> - auto-commit with task reference
addTaskCommitCommand(parent)
// core dev task:pr <id> - 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 <noreply@anthropic.com>\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
}

338
pkg/agentic/completion.go Normal file
View file

@ -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 <noreply@anthropic.com>
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 <noreply@anthropic.com>\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
}

View file

@ -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 <noreply@anthropic.com>")
}
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")
}

335
pkg/agentic/context.go Normal file
View file

@ -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()
}

214
pkg/agentic/context_test.go Normal file
View file

@ -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")
}