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 }