Files
teabot/internal/prompt/prompt.go
T
unkinben 1b4448afb4
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add teabot daemon implementation
teabot watches Gitea repos and dispatches one-shot Claude Code sessions in
Docker containers to work issues and review PRs, acting as configurable bot
personalities.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-26 23:36:21 +10:00

164 lines
6.4 KiB
Go

// Package prompt builds the task-specific instructions handed to a one-shot
// Claude Code session for each kind of Gitea event.
package prompt
import (
"fmt"
"strings"
"git.unkin.net/unkin/teabot/internal/gitea"
)
// conventions is appended to every prompt so sessions follow the house rules
// (branch naming, PR body shape, and the loop-safety expectation of finishing
// in one pass).
const conventions = `Conventions you MUST follow:
- Work on a branch named benvin/<short-descriptive-name>; never push to the default branch.
- Use HTTPS remotes for git.unkin.net (SSH is blocked). The clone is already authenticated.
- PR descriptions have a short "why" paragraph followed by present-tense "how" bullets.
- Use the tea CLI (already configured for your bot identity) for Gitea actions such as creating PRs and posting comments; never use gh.
- This is a single non-interactive session: complete the task in one pass, then stop. Do not wait for input.`
// IssueContext carries everything needed to prompt an implementer session for a
// newly opened issue.
type IssueContext struct {
Repo string
PersonalityName string
Issue gitea.Issue
Comments []gitea.Comment
}
// Issue builds the prompt for reviewing and possibly implementing an issue.
func Issue(c IssueContext) string {
var b strings.Builder
fmt.Fprintf(&b, "You are %q, an autonomous implementer bot acting on the Gitea repository %s.\n\n",
c.PersonalityName, c.Repo)
fmt.Fprintf(&b, "A new issue was opened. Review it and decide whether it warrants a code change.\n")
fmt.Fprintf(&b, "If it does, implement the change on a new branch and open a pull request that links the issue with \"Closes #%d\".\n", c.Issue.Index)
fmt.Fprintf(&b, "If it does NOT warrant a code change (question, discussion, invalid, needs clarification), post a brief comment on the issue explaining your assessment instead of opening a PR.\n\n")
fmt.Fprintf(&b, "Issue #%d: %s\n", c.Issue.Index, c.Issue.Title)
fmt.Fprintf(&b, "Opened by: %s\n", c.Issue.Poster.Login)
fmt.Fprintf(&b, "URL: %s\n\n", c.Issue.HTMLURL)
b.WriteString("Issue body:\n")
b.WriteString(bodyOrNone(c.Issue.Body))
b.WriteString("\n")
writeComments(&b, c.Comments)
b.WriteString("\n")
b.WriteString(conventions)
return b.String()
}
// PullContext carries everything needed to prompt a reviewer session for a PR.
type PullContext struct {
Repo string
PersonalityName string
Pull gitea.PullRequest
Diff string
Comments []gitea.Comment
}
// Pull builds the prompt for reviewing a pull request.
func Pull(c PullContext) string {
var b strings.Builder
fmt.Fprintf(&b, "You are %q, an autonomous reviewer bot acting on the Gitea repository %s.\n\n",
c.PersonalityName, c.Repo)
fmt.Fprintf(&b, "A new pull request was opened. Review it thoroughly and decide whether the change is correct and ready.\n")
b.WriteString("Judge: does the diff actually satisfy the PR description? Is it correct, tested, and consistent with the codebase's style?\n")
fmt.Fprintf(&b, "Post your review on PR #%d using tea: an approving review if it is good, or a review requesting changes with specific, actionable comments if not.\n\n", c.Pull.Index)
fmt.Fprintf(&b, "PR #%d: %s\n", c.Pull.Index, c.Pull.Title)
fmt.Fprintf(&b, "Opened by: %s\n", c.Pull.Poster.Login)
fmt.Fprintf(&b, "Branch: %s -> %s\n", c.Pull.Head.Ref, c.Pull.Base.Ref)
fmt.Fprintf(&b, "URL: %s\n\n", c.Pull.HTMLURL)
b.WriteString("PR description:\n")
b.WriteString(bodyOrNone(c.Pull.Body))
b.WriteString("\n")
writeComments(&b, c.Comments)
if strings.TrimSpace(c.Diff) != "" {
b.WriteString("\nUnified diff:\n")
b.WriteString(truncate(c.Diff, 60000))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(conventions)
return b.String()
}
// FollowUpKind distinguishes a follow-up on an issue vs a pull request.
type FollowUpKind string
const (
// FollowUpIssue is a new comment on an issue teabot acted on.
FollowUpIssue FollowUpKind = "issue"
// FollowUpPull is a new comment on a pull request teabot acted on.
FollowUpPull FollowUpKind = "pull"
)
// FollowUpContext carries everything needed to prompt a follow-up session for a
// new comment on a thread teabot previously engaged with.
type FollowUpContext struct {
Repo string
PersonalityName string
Kind FollowUpKind
Index int64
Title string
URL string
Comments []gitea.Comment // full thread, oldest first
NewComment gitea.Comment // the comment that triggered this follow-up
}
// FollowUp builds the prompt for responding to a new comment.
func FollowUp(c FollowUpContext) string {
var b strings.Builder
noun := "issue"
if c.Kind == FollowUpPull {
noun = "pull request"
}
fmt.Fprintf(&b, "You are %q, an autonomous bot acting on the Gitea repository %s.\n\n",
c.PersonalityName, c.Repo)
fmt.Fprintf(&b, "A new comment was posted on %s #%d, a thread you previously worked on. Read the full thread and respond or take action as appropriate.\n", noun, c.Index)
if c.Kind == FollowUpPull {
b.WriteString("If the comment requests changes, check out the PR branch, make the changes, and push them; then reply summarising what you did.\n")
} else {
b.WriteString("If the comment asks for a change or clarifies the request, implement it on a branch and open or update the PR; otherwise reply with a helpful comment.\n")
}
fmt.Fprintf(&b, "\n%s #%d: %s\n", strings.ToUpper(noun[:1])+noun[1:], c.Index, c.Title)
fmt.Fprintf(&b, "URL: %s\n\n", c.URL)
b.WriteString("Full thread (oldest first):\n")
writeComments(&b, c.Comments)
fmt.Fprintf(&b, "\nThe new comment that triggered this task was posted by %s:\n", c.NewComment.Poster.Login)
b.WriteString(bodyOrNone(c.NewComment.Body))
b.WriteString("\n\n")
b.WriteString(conventions)
return b.String()
}
func writeComments(b *strings.Builder, comments []gitea.Comment) {
if len(comments) == 0 {
return
}
b.WriteString("\nComments (oldest first):\n")
for _, c := range comments {
fmt.Fprintf(b, "- %s: %s\n", c.Poster.Login, oneLine(c.Body))
}
}
func bodyOrNone(s string) string {
if strings.TrimSpace(s) == "" {
return "(no description provided)"
}
return s
}
func oneLine(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\n", " ")
return strings.TrimSpace(s)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "\n... [diff truncated] ..."
}