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:
parent
3b6427f324
commit
20f25ca062
5 changed files with 1359 additions and 1 deletions
|
|
@ -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
338
pkg/agentic/completion.go
Normal 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
|
||||
}
|
||||
198
pkg/agentic/completion_test.go
Normal file
198
pkg/agentic/completion_test.go
Normal 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
335
pkg/agentic/context.go
Normal 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
214
pkg/agentic/context_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue