Files
2026-06-22 08:55:57 +08:00

124 lines
4.1 KiB
Go

package docs
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
"github.com/yan1h/agent-coding-workflow/internal/jobs"
)
// maxDiffChars bounds the diffstat fed to the LLM so a huge commit can't blow the
// prompt budget.
const maxDiffChars = 12000
// WorkspaceLocator resolves the on-disk directory holding a commit's files.
type WorkspaceLocator interface {
LocateWorkspace(ctx context.Context, wsID uuid.UUID) (mainPath string, err error)
LocateWorktree(ctx context.Context, worktreeID uuid.UUID) (path string, err error)
}
// Summarizer turns a diffstat into a markdown summary. Implemented in app.go over
// the llm registry (default chat endpoint); the SummaryRunner only sees this
// narrow contract so tests can inject a canned-output fake.
type Summarizer interface {
// Summarize returns (title, bodyMarkdown, modelID, error).
Summarize(ctx context.Context, branch, commitSHA, diff string) (title, bodyMD, model string, err error)
}
// SummaryRunner is the jobs.Handler that generates a commit_summaries row. It is
// event-driven (enqueued post-commit), never a periodic ticker.
type SummaryRunner struct {
repo Repository
locator WorkspaceLocator
gitr git.Runner
summ Summarizer
log *slog.Logger
}
// NewSummaryRunner constructs a SummaryRunner. log may be nil.
func NewSummaryRunner(repo Repository, locator WorkspaceLocator, gitr git.Runner, summ Summarizer, log *slog.Logger) *SummaryRunner {
if log == nil {
log = slog.Default()
}
return &SummaryRunner{repo: repo, locator: locator, gitr: gitr, summ: summ, log: log}
}
var _ jobs.Handler = (*SummaryRunner)(nil)
// Type returns the job type this handler processes.
func (h *SummaryRunner) Type() jobs.JobType { return JobTypeCommitSummary }
// Handle generates and upserts a commit summary. Idempotent: a completed summary
// for the same (workspace, commit, kind) is left untouched.
func (h *SummaryRunner) Handle(ctx context.Context, job jobs.Job) error {
var p summaryPayload
if err := json.Unmarshal(job.Payload, &p); err != nil {
return jobs.Permanent(fmt.Errorf("docs.commit_summary: bad payload: %w", err))
}
// Skip if a completed summary already exists (idempotent re-enqueue).
if existing, err := h.repo.Get(ctx, p.WorkspaceID, p.CommitSHA, KindCommit); err == nil && existing != nil && existing.Status == StatusCompleted {
return nil
}
dir := ""
if p.WorktreeID != nil {
path, lerr := h.locator.LocateWorktree(ctx, *p.WorktreeID)
if lerr != nil {
return fmt.Errorf("locate worktree: %w", lerr)
}
dir = path
} else {
path, lerr := h.locator.LocateWorkspace(ctx, p.WorkspaceID)
if lerr != nil {
return fmt.Errorf("locate workspace: %w", lerr)
}
dir = path
}
diff, err := h.gitr.DiffStat(ctx, dir, p.CommitSHA)
if err != nil {
return fmt.Errorf("git show --stat: %w", err)
}
if len(diff) > maxDiffChars {
diff = diff[:maxDiffChars] + "\n...[truncated]"
}
title, body, model, serr := h.summ.Summarize(ctx, p.Branch, p.CommitSHA, diff)
if serr != nil {
// Persist a failed row so the failure is visible, then return for retry.
msg := serr.Error()
if _, uerr := h.repo.Upsert(ctx, UpsertInput{
WorkspaceID: p.WorkspaceID, WorktreeID: p.WorktreeID, Branch: p.Branch,
CommitSHA: p.CommitSHA, Kind: KindCommit, BodyMD: "",
Status: StatusFailed, Error: &msg,
}); uerr != nil {
h.log.Error("docs.summary.upsert_failed_row", "commit", p.CommitSHA, "err", uerr.Error())
}
return fmt.Errorf("summarize: %w", serr)
}
var titlePtr, modelPtr *string
if t := strings.TrimSpace(title); t != "" {
titlePtr = &t
}
if m := strings.TrimSpace(model); m != "" {
modelPtr = &m
}
if _, err := h.repo.Upsert(ctx, UpsertInput{
WorkspaceID: p.WorkspaceID, WorktreeID: p.WorktreeID, Branch: p.Branch,
CommitSHA: p.CommitSHA, Kind: KindCommit, Title: titlePtr,
BodyMD: body, Model: modelPtr, Status: StatusCompleted,
}); err != nil {
return fmt.Errorf("upsert summary: %w", err)
}
h.log.Info("docs.summary.completed", "workspace_id", p.WorkspaceID, "commit", p.CommitSHA)
return nil
}