1b4448afb4
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
164 lines
6.4 KiB
Go
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] ..."
|
|
}
|