- Fix remaining 187 pkg/ files referencing core/cli → core/go - Move SDK library code from internal/cmd/sdk/ → pkg/sdk/ (new package) - Create pkg/rag/helpers.go with convenience functions from internal/cmd/rag/ - Fix pkg/mcp/tools_rag.go to use pkg/rag instead of internal/cmd/rag - Fix pkg/build/buildcmd/cmd_sdk.go and pkg/release/sdk.go to use pkg/sdk - Remove all non-library content: main.go, internal/, cmd/, docker/, scripts/, tasks/, tools/, .core/, .forgejo/, .woodpecker/, Taskfile.yml - Run go mod tidy to trim unused dependencies core/go is now a pure Go package suite (library only). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
338 lines
8.1 KiB
Go
338 lines
8.1 KiB
Go
// Package agentic provides AI collaboration features for task management.
|
|
package agentic
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"forge.lthn.ai/core/go/pkg/log"
|
|
)
|
|
|
|
// 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 log.E(op, "task is required", nil)
|
|
}
|
|
|
|
if message == "" {
|
|
return log.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 log.E(op, "failed to stage changes", err)
|
|
}
|
|
|
|
// Create commit
|
|
if _, err := runGitCommandCtx(ctx, dir, "commit", "-m", fullMessage); err != nil {
|
|
return log.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 "", log.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 "", log.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 log.E(op, "client is required", nil)
|
|
}
|
|
|
|
if task == nil {
|
|
return log.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 log.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 log.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 log.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 "", log.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 "", log.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 "", log.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, log.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 "", log.E(op, "failed to get diff", err)
|
|
}
|
|
|
|
return output, nil
|
|
}
|