Details and log output for this agent run.
{"command":"cd /project && git status && git log --oneline -10 && git branch -a"}
On branch issue-76-greg nothing to commit, working tree clean 1da1d20 Allow agents to merge approved pull requests f33fed4 Merge pull request 'Move main.go from cmd/zoo/ to root directory' (#75) from issue-74-mika into main ec2fe2d Move main.go from cmd/zoo/ to root directory a00979b Add makefile. 1a3cc51 Merge pull request 'Ignore comments by the master token owner everywhere' (#73) from issue-72-greg into main 72de1d5 Merge pull request 'Add Meet the Team page with agent cards' (#71) from issue-70-anna into main dfc5cb6 Swap Agents and LLMs sections on dashboard 81330d0 Ignore comments by the master token owner everywhere 13c787e Move team cards into dashboard Agents section 86b7967 Add Meet the Team page with agent cards * issue-76-greg main remotes/origin/main
{"path":"."}
drwxr-xr-x 5 user user 4096 Aug 27 08:31 . drwxr-xr-x 1 root root 4096 Aug 27 08:31 .. drwxr-xr-x 7 user user 4096 Aug 27 08:31 .git -rw-r--r-- 1 user user 22 Aug 27 08:31 .gitignore -rw-r--r-- 1 user user 343 Aug 27 08:31 Makefile -rw-r--r-- 1 user user 6589 Aug 27 08:31 README.md drwxr-xr-x 2 user user 4096 Aug 27 08:31 docker -rw-r--r-- 1 user user 2628 Aug 27 08:31 go.mod -rw-r--r-- 1 user user 16578 Aug 27 08:31 go.sum drwxr-xr-x 11 user user 4096 Aug 27 08:31 internal -rw-r--r-- 1 user user 4812 Aug 27 08:31 main.go -rw-r--r-- 1 user user 3092 Aug 27 08:31 zoo.hcl.example
{"command":"cd /project && git diff main...HEAD --stat"}
internal/agentrun/forgejo.go | 33 ++++++ internal/agentrun/system.md | 19 +++- internal/forgejo/client.go | 89 +++++++++++++++ internal/forgejo/client_test.go | 230 ++++++++++++++++++++++++++++++++++++++ internal/tools/context.go | 29 +++++ internal/tools/forgejo.go | 152 ++++++++++++++++++++++++++ internal/tools/tools_test.go | 237 +++++++++++++++++++++++++++++++++++++++- 7 files changed, 781 insertions(+), 8 deletions(-)
{"command":"cd /project && git diff main...HEAD"}
diff --git a/internal/agentrun/forgejo.go b/internal/agentrun/forgejo.go
index 34a48a4..63c9e0d 100644
--- a/internal/agentrun/forgejo.go
+++ b/internal/agentrun/forgejo.go
@@ -4,6 +4,7 @@ import (
"log/slog"
"github.com/abrander/zoo/internal/forgejo"
+ "github.com/abrander/zoo/internal/tools"
)
// runForgejoActions implements tools.ForgejoActions, binding the
@@ -92,3 +93,35 @@ func (a *runForgejoActions) SubmitReview(state, body string) error {
return err
}
+
+func (a *runForgejoActions) PullRequest() (tools.PullRequest, error) {
+ state, body, err := a.client.PullRequestState(a.owner, a.repo, a.index)
+ if err != nil {
+ a.logResult("pull_request", err)
+ return tools.PullRequest{}, err
+ }
+ a.logResult("pull_request", nil, "state", state)
+
+ return tools.PullRequest{State: state, Body: body}, nil
+}
+
+func (a *runForgejoActions) PullRequestApproved() (bool, error) {
+ approved, err := a.client.PullRequestApproved(a.owner, a.repo, a.index)
+ a.logResult("pull_request_approved", err, "approved", approved)
+
+ return approved, err
+}
+
+func (a *runForgejoActions) MergePullRequest(style string, deleteBranch bool) error {
+ err := a.client.MergePullRequest(a.owner, a.repo, a.index, style, deleteBranch)
+ a.logResult("merge_pull_request", err, "style", style, "delete_branch", deleteBranch)
+
+ return err
+}
+
+func (a *runForgejoActions) CloseIssueByNumber(index int64) error {
+ err := a.client.CloseIssue(a.owner, a.repo, index)
+ a.logResult("close_issue_by_number", err, "index", index)
+
+ return err
+}
diff --git a/internal/agentrun/system.md b/internal/agentrun/system.md
index 7b8c8cb..e972595 100644
--- a/internal/agentrun/system.md
+++ b/internal/agentrun/system.md
@@ -20,11 +20,13 @@ triggers you from Forgejo (a Gitea-family forge) issue/PR events.
- `bash`, `read_file`, `write_file`, `list_files`, `grep_search`,
`move_file`, `remove_file` operate on the project container.
- `comment`, `open_pull_request`, `request_review`, `submit_review`,
- `add_label`, `remove_label`, `close_issue`, `reopen_issue`,
- `assign_issue` act directly on the Forgejo issue/PR that triggered
- this run — use `comment` to report back to the person who filed it,
- `open_pull_request` once you've pushed a branch with your changes,
- and `submit_review` to leave a review verdict.
+ `merge_pull_request`, `add_label`, `remove_label`, `close_issue`,
+ `reopen_issue`, `assign_issue` act directly on the Forgejo issue/PR
+ that triggered this run — use `comment` to report back to the person
+ who filed it, `open_pull_request` once you've pushed a branch with
+ your changes, `submit_review` to leave a review verdict, and
+ `merge_pull_request` to merge your pull request once it has an
+ approved review (it also closes the issue the PR resolves).
Below, in "Your identity" and "Other agents", you'll find your own role
(from your Forgejo profile) and a roster of the other agents zoo runs,
@@ -47,3 +49,10 @@ you and simply work.
- Always leave a `comment` summarizing what you did (or why you couldn't
finish), and use `open_pull_request` when you have a change ready for
review. Don't leave the issue without a response.
+- When opening a pull request for an issue, reference that issue in the
+ PR body with a closing keyword (e.g. "Closes #76") so the issue is
+ closed when the PR merges.
+- Merge your pull request with `merge_pull_request` once it has an
+ approved review. Don't merge a PR that only has comments or
+ change requests, and don't approve and merge your own work without a
+ real review.
diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go
index 9420f22..8d0da3b 100644
--- a/internal/forgejo/client.go
+++ b/internal/forgejo/client.go
@@ -348,6 +348,95 @@ func (c *Client) PullRequestInfo(owner, repo string, index int64) (PullRequestIn
return info, nil
}
+// PullRequestState returns the pull request's merge-relevant state —
+// "open", "closed", or "merged" (a merged PR reports State "closed" on
+// the wire, so the merged flag is folded in here) — plus its body,
+// which is how the merge tool resolves the original issue.
+func (c *Client) PullRequestState(owner, repo string, index int64) (state, body string, err error) {
+ pr, _, err := c.sdk.GetPullRequest(owner, repo, index)
+ if err != nil {
+ return "", "", fmt.Errorf("get pull request %s/%s#%d: %w", owner, repo, index, err)
+ }
+
+ state = string(pr.State)
+ if pr.HasMerged {
+ state = "merged"
+ }
+
+ return state, pr.Body, nil
+}
+
+// PullRequestApproved reports whether the pull request has at least one
+// approved, non-dismissed review from someone other than the pull
+// request's author. This is the gate the merge tool uses: agents may
+// merge a PR only once it carries a real approval — from another agent
+// or a human. The author's own approval doesn't count, since the review
+// gate exists to be a second pair of eyes, not the author rubber-
+// stamping their own work.
+func (c *Client) PullRequestApproved(owner, repo string, index int64) (bool, error) {
+ pr, _, err := c.sdk.GetPullRequest(owner, repo, index)
+ if err != nil {
+ return false, fmt.Errorf("get pull request %s/%s#%d: %w", owner, repo, index, err)
+ }
+
+ author := ""
+ if pr.Poster != nil {
+ author = pr.Poster.UserName
+ }
+
+ reviews, _, err := c.sdk.ListPullReviews(owner, repo, index, sdk.ListPullReviewsOptions{})
+ if err != nil {
+ return false, fmt.Errorf("list reviews on %s/%s#%d: %w", owner, repo, index, err)
+ }
+
+ for _, r := range reviews {
+ if r.State != sdk.ReviewStateApproved || r.Dismissed {
+ continue
+ }
+
+ reviewer := ""
+ if r.Reviewer != nil {
+ reviewer = r.Reviewer.UserName
+ }
+
+ if reviewer != "" && reviewer != author {
+ return true, nil
+ }
+ }
+
+ return false, nil
+}
+
+// MergePullRequest merges the pull request with the given index. style
+// is one of "merge", "rebase", "rebase-merge", or "squash"; when
+// deleteBranch is true the head branch is removed after the merge.
+func (c *Client) MergePullRequest(owner, repo string, index int64, style string, deleteBranch bool) error {
+ var sdkStyle sdk.MergeStyle
+
+ switch style {
+ case "merge":
+ sdkStyle = sdk.MergeStyleMerge
+ case "rebase":
+ sdkStyle = sdk.MergeStyleRebase
+ case "rebase-merge":
+ sdkStyle = sdk.MergeStyleRebaseMerge
+ case "squash":
+ sdkStyle = sdk.MergeStyleSquash
+ default:
+ return fmt.Errorf("merge %s/%s#%d: unknown merge style %q", owner, repo, index, style)
+ }
+
+ _, _, err := c.sdk.MergePullRequest(owner, repo, index, sdk.MergePullRequestOption{
+ Style: sdkStyle,
+ DeleteBranchAfterMerge: deleteBranch,
+ })
+ if err != nil {
+ return fmt.Errorf("merge %s/%s#%d: %w", owner, repo, index, err)
+ }
+
+ return nil
+}
+
// CloseIssue closes the given issue or pull request.
func (c *Client) CloseIssue(owner, repo string, index int64) error {
return c.setIssueState(owner, repo, index, sdk.StateClosed)
diff --git a/internal/forgejo/client_test.go b/internal/forgejo/client_test.go
index c1d03fe..9dd8a8c 100644
--- a/internal/forgejo/client_test.go
+++ b/internal/forgejo/client_test.go
@@ -279,3 +279,233 @@ func TestOwner(t *testing.T) {
t.Fatalf("expected owner %q, got %q", "abrander", owner)
}
}
+
+func TestPullRequestState(t *testing.T) {
+ server, mux := newTestServer(t)
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "id": 202,
+ "number": 9,
+ "state": "open",
+ "body": "Closes #7",
+ "merged": false
+ }`))
+ })
+
+ client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ state, body, err := client.PullRequestState("acme", "widgets", 9)
+ if err != nil {
+ t.Fatalf("pull request state: %v", err)
+ }
+ if state != "open" || body != "Closes #7" {
+ t.Fatalf("unexpected state/body: %q %q", state, body)
+ }
+}
+
+// A merged PR reports state "closed" on the wire; the merged flag must
+// fold into "merged" so callers can tell the two apart.
+func TestPullRequestStateMerged(t *testing.T) {
+ server, mux := newTestServer(t)
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "id": 202,
+ "number": 9,
+ "state": "closed",
+ "body": "Closes #7",
+ "merged": true
+ }`))
+ })
+
+ client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ state, _, err := client.PullRequestState("acme", "widgets", 9)
+ if err != nil {
+ t.Fatalf("pull request state: %v", err)
+ }
+ if state != "merged" {
+ t.Fatalf("expected state %q, got %q", "merged", state)
+ }
+}
+
+func TestPullRequestApproved(t *testing.T) {
+ server, mux := newTestServer(t)
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id": 202, "number": 9, "user": {"login": "greg"}}`))
+ })
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9/reviews", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"id": 1, "user": {"login": "mika"}, "state": "COMMENT", "dismissed": false},
+ {"id": 2, "user": {"login": "leon"}, "state": "APPROVED", "dismissed": false}
+ ]`))
+ })
+
+ client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ approved, err := client.PullRequestApproved("acme", "widgets", 9)
+ if err != nil {
+ t.Fatalf("pull request approved: %v", err)
+ }
+ if !approved {
+ t.Fatal("expected the PR to be approved")
+ }
+}
+
+// A dismissed approval must not count: the reviewer's approval was
+// withdrawn, so the PR is not approved.
+func TestPullRequestApprovedDismissed(t *testing.T) {
+ server, mux := newTestServer(t)
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id": 202, "number": 9, "user": {"login": "greg"}}`))
+ })
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9/reviews", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"id": 2, "user": {"login": "leon"}, "state": "APPROVED", "dismissed": true}
+ ]`))
+ })
+
+ client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ approved, err := client.PullRequestApproved("acme", "widgets", 9)
+ if err != nil {
+ t.Fatalf("pull request approved: %v", err)
+ }
+ if approved {
+ t.Fatal("expected a dismissed approval not to count")
+ }
+}
+
+func TestPullRequestApprovedNone(t *testing.T) {
+ server, mux := newTestServer(t)
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id": 202, "number": 9, "user": {"login": "greg"}}`))
+ })
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9/reviews", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[]`))
+ })
+
+ client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ approved, err := client.PullRequestApproved("acme", "widgets", 9)
+ if err != nil {
+ t.Fatalf("pull request approved: %v", err)
+ }
+ if approved {
+ t.Fatal("expected no approval")
+ }
+}
+
+// Self-approval must not count: the author approving their own PR is not
+// a second pair of eyes, so the PR is not approved.
+func TestPullRequestApprovedSelfApproval(t *testing.T) {
+ server, mux := newTestServer(t)
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id": 202, "number": 9, "user": {"login": "greg"}}`))
+ })
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9/reviews", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"id": 2, "user": {"login": "greg"}, "state": "APPROVED", "dismissed": false}
+ ]`))
+ })
+
+ client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ approved, err := client.PullRequestApproved("acme", "widgets", 9)
+ if err != nil {
+ t.Fatalf("pull request approved: %v", err)
+ }
+ if approved {
+ t.Fatal("expected a self-approval not to count")
+ }
+}
+
+func TestMergePullRequest(t *testing.T) {
+ server, mux := newTestServer(t)
+
+ var gotStyle string
+ var gotDelete bool
+
+ mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9/merge", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Fatalf("expected POST, got %s", r.Method)
+ }
+
+ var payload struct {
+ Do string `json:"Do"`
+ Delete bool `json:"delete_branch_after_merge"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode merge payload: %v", err)
+ }
+ gotStyle, gotDelete = payload.Do, payload.Delete
+
+ w.WriteHeader(http.StatusOK)
+ })
+
+ client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ if err := client.MergePullRequest("acme", "widgets", 9, "squash", true); err != nil {
+ t.Fatalf("merge pull request: %v", err)
+ }
+
+ if gotStyle != "squash" {
+ t.Fatalf("expected style squash, got %q", gotStyle)
+ }
+ if !gotDelete {
+ t.Fatal("expected delete_branch_after_merge to be true")
+ }
+}
+
+func TestMergePullRequestUnknownStyle(t *testing.T) {
+ server, _ := newTestServer(t)
+
+ client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ if err := client.MergePullRequest("acme", "widgets", 9, "fast-forward", false); err == nil {
+ t.Fatal("expected an error for an unknown merge style")
+ }
+}
diff --git a/internal/tools/context.go b/internal/tools/context.go
index 9445d29..66551a7 100644
--- a/internal/tools/context.go
+++ b/internal/tools/context.go
@@ -21,6 +21,14 @@ type Context interface {
Forgejo() ForgejoActions
}
+// PullRequest is the slice of the triggering pull request's state the
+// merge tool needs: whether it's open, closed, or already merged, and
+// its body (from which the original issue is resolved).
+type PullRequest struct {
+ State string
+ Body string
+}
+
// ForgejoActions are the actions available to an agent against the
// triggering issue/PR. Implemented by internal/agentrun, backed by
// internal/forgejo.Client plus the run's owner/repo/issue.
@@ -42,4 +50,25 @@ type ForgejoActions interface {
// the given verdict (forgejo.ReviewStateApproved,
// ReviewStateChangesRequest, or ReviewStateComment) and body.
SubmitReview(state, body string) error
+
+ // PullRequest returns the triggering pull request's state ("open",
+ // "closed", or "merged") and body. An error is returned when the
+ // triggering issue/PR is not a pull request.
+ PullRequest() (PullRequest, error)
+
+ // PullRequestApproved reports whether the triggering pull request
+ // has at least one approved, non-dismissed review from someone
+ // other than the pull request's author.
+ PullRequestApproved() (bool, error)
+
+ // MergePullRequest merges the triggering pull request with the
+ // given style ("merge", "rebase", "rebase-merge", or "squash"),
+ // optionally deleting the head branch afterwards.
+ MergePullRequest(style string, deleteBranch bool) error
+
+ // CloseIssueByNumber closes the issue or pull request with the
+ // given number in the run's repository — not necessarily the
+ // triggering one, which is how the merge tool closes the original
+ // issue a PR resolves.
+ CloseIssueByNumber(index int64) error
}
diff --git a/internal/tools/forgejo.go b/internal/tools/forgejo.go
index 0d36ed0..6f408d8 100644
--- a/internal/tools/forgejo.go
+++ b/internal/tools/forgejo.go
@@ -2,6 +2,8 @@ package tools
import (
"fmt"
+ "regexp"
+ "strconv"
"strings"
"github.com/abrander/zoo/internal/llm"
@@ -202,3 +204,153 @@ func reopenIssue(ctx Context, _ noParams) (string, error) {
return "Issue reopened", nil
}
+
+type mergePullRequestParams struct {
+ Style string `json:"style"`
+ DeleteBranch *bool `json:"delete_branch"`
+}
+
+func init() {
+ tool := llm.NewTool(
+ "merge_pull_request",
+ "Merge the pull request that triggered this run, then close the original issue it resolves. Refuses to merge unless the pull request has been approved by someone other than its author. The original issue is resolved from the pull request body: an issue reference marked with a closing keyword such as 'Closes #76' (or, when no closing keyword is present, the body's single issue reference). Use it once an approval review has landed on your PR.")
+
+ tool.AddEnumProperty("style", "How to merge: 'merge' (default), 'rebase', 'rebase-merge', or 'squash'", []string{"merge", "rebase", "rebase-merge", "squash"}, false)
+ tool.AddBooleanProperty("delete_branch", "Delete the pull request's head branch after merging (default true)", false)
+
+ Register(tool, mergePullRequest)
+}
+
+func mergePullRequest(ctx Context, params mergePullRequestParams) (string, error) {
+ fg := ctx.Forgejo()
+
+ pr, err := fg.PullRequest()
+ if err != nil {
+ return "", err
+ }
+
+ issues := originalIssue(pr.Body)
+
+ switch pr.State {
+ case "merged":
+ // Idempotent: the merge already happened (e.g. a human merged
+ // it). Still close the resolved issue in case Forgejo's own
+ // auto-close didn't fire, then report.
+ return finishMergeReport("Pull request already merged", issues, fg)
+ case "closed":
+ return "", fmt.Errorf("pull request is closed and not merged; nothing to merge")
+ }
+
+ approved, err := fg.PullRequestApproved()
+ if err != nil {
+ return "", err
+ }
+ if !approved {
+ return "", fmt.Errorf("pull request is not approved: it needs an approval from someone other than the author; wait for a review before merging")
+ }
+
+ style := params.Style
+ if style == "" {
+ style = "merge"
+ }
+
+ deleteBranch := true
+ if params.DeleteBranch != nil {
+ deleteBranch = *params.DeleteBranch
+ }
+
+ if err := fg.MergePullRequest(style, deleteBranch); err != nil {
+ return "", err
+ }
+
+ return finishMergeReport("Pull request merged", issues, fg)
+}
+
+// finishMergeReport closes the issues a merged pull request resolves
+// and renders the tool's result. A failed close is reported in the
+// result rather than as an error: the merge — the primary action —
+// succeeded, and retrying the tool would only re-hit the already-merged
+// PR.
+func finishMergeReport(merged string, issues []int64, fg ForgejoActions) (string, error) {
+ if len(issues) == 0 {
+ return merged + "; no issue reference found in the pull request body, so no issue was closed", nil
+ }
+
+ var closed, failed []int64
+
+ for _, n := range issues {
+ if err := fg.CloseIssueByNumber(n); err != nil {
+ failed = append(failed, n)
+ continue
+ }
+ closed = append(closed, n)
+ }
+
+ report := merged
+
+ if len(closed) > 0 {
+ report += "; closed issue " + issueList(closed)
+ }
+ if len(failed) > 0 {
+ report += fmt.Sprintf("; WARNING: failed to close issue %s, close it manually if it is still open", issueList(failed))
+ }
+
+ return report, nil
+}
+
+// issueList renders issue numbers as "#76, #77".
+func issueList(numbers []int64) string {
+ parts := make([]string, len(numbers))
+ for i, n := range numbers {
+ parts[i] = fmt.Sprintf("#%d", n)
+ }
+
+ return strings.Join(parts, ", ")
+}
+
+// closingKeywordRef matches an issue reference that a closing keyword
+// ("close", "fix", "resolve", and their inflections) marks as resolved
+// by the pull request, e.g. "Closes #76" or "fixes: abrander/zoo#76".
+// The keywords mirror the ones Forgejo itself honors when auto-closing
+// issues on merge.
+var closingKeywordRef = regexp.MustCompile(`(?i)\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s*:?\s*(?:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#(\d+)`)
+
+// bareIssueRef matches any issue reference in a pull request body,
+// e.g. "#76" or "abrander/zoo#76".
+var bareIssueRef = regexp.MustCompile(`(?:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#(\d+)`)
+
+// originalIssue resolves the issues a pull request resolves from its
+// body. An issue reference marked with a closing keyword ("Closes #76",
+// "Fixes #76", ...) always counts. When no closing keyword is present,
+// a body with exactly one issue reference counts as well — a lone
+// "#76" in a PR body is almost certainly the issue the PR addresses.
+//
+// The result is deduplicated and in order of appearance.
+func originalIssue(body string) []int64 {
+ var (
+ out []int64
+ seen = map[int64]bool{}
+ add = func(n int64) {
+ if !seen[n] {
+ seen[n] = true
+ out = append(out, n)
+ }
+ }
+ )
+
+ for _, m := range closingKeywordRef.FindAllStringSubmatch(body, -1) {
+ if n, err := strconv.ParseInt(m[1], 10, 64); err == nil {
+ add(n)
+ }
+ }
+
+ if len(out) == 0 {
+ if refs := bareIssueRef.FindAllStringSubmatch(body, -1); len(refs) == 1 {
+ if n, err := strconv.ParseInt(refs[0][1], 10, 64); err == nil {
+ add(n)
+ }
+ }
+ }
+
+ return out
+}
diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go
index beee4ed..31f81fe 100644
--- a/internal/tools/tools_test.go
+++ b/internal/tools/tools_test.go
@@ -12,6 +12,17 @@ type fakeForgejoActions struct {
comments []string
assigned []string
reviews []string
+
+ prState string
+ prBody string
+ prErr error
+ approved bool
+ approvedOK bool
+
+ merged []string
+ mergedErr error
+ closedIssues []int64
+ closeErr error
}
func (f *fakeForgejoActions) Comment(body string) error {
@@ -32,6 +43,32 @@ func (f *fakeForgejoActions) SubmitReview(state, body string) error {
f.reviews = append(f.reviews, state)
return nil
}
+func (f *fakeForgejoActions) PullRequest() (PullRequest, error) {
+ if f.prErr != nil {
+ return PullRequest{}, f.prErr
+ }
+ return PullRequest{State: f.prState, Body: f.prBody}, nil
+}
+func (f *fakeForgejoActions) PullRequestApproved() (bool, error) {
+ if !f.approvedOK {
+ return false, fmt.Errorf("no reviews")
+ }
+ return f.approved, nil
+}
+func (f *fakeForgejoActions) MergePullRequest(style string, deleteBranch bool) error {
+ if f.mergedErr != nil {
+ return f.mergedErr
+ }
+ f.merged = append(f.merged, style)
+ return nil
+}
+func (f *fakeForgejoActions) CloseIssueByNumber(index int64) error {
+ if f.closeErr != nil {
+ return f.closeErr
+ }
+ f.closedIssues = append(f.closedIssues, index)
+ return nil
+}
type fakeContext struct {
lastCmd string
@@ -204,9 +241,9 @@ func TestGrepSearchGlobStillUsesInclude(t *testing.T) {
func TestIsGlobPattern(t *testing.T) {
cases := map[string]bool{
"*.go": true,
- "src/?*.go": true,
- "src/[abc].go": true,
- "store.go": false,
+ "src/?*.go": true,
+ "src/[abc].go": true,
+ "store.go": false,
"internal/store/store.go": false,
}
for in, want := range cases {
@@ -333,3 +370,197 @@ func TestToolSchemas(t *testing.T) {
}
}
}
+
+func TestOriginalIssue(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ want []int64
+ }{
+ {"closing keyword", "Closes #76", []int64{76}},
+ {"fixes keyword", "This fixes #76.", []int64{76}},
+ {"resolved keyword", "All feedback addressed. Resolves #76", []int64{76}},
+ {"case insensitive", "closes #76", []int64{76}},
+ {"colon separator", "Closes: #76", []int64{76}},
+ {"qualified reference", "Closes abrander/zoo#76", []int64{76}},
+ {"multiple keywords", "Closes #76 and Fixes #77", []int64{76, 77}},
+ {"deduplicated", "Closes #76. Closes #76.", []int64{76}},
+ {"lone reference fallback", "Implements the feature from #76.", []int64{76}},
+ {"no reference", "Just some changes.", nil},
+ {"multiple bare references", "See #76 and #77 for context.", nil},
+ // A keyword not adjacent to a reference doesn't count, and two
+ // bare references are too ambiguous for the fallback.
+ {"keyword without adjacent reference", "Fixes a bug introduced in #50. The issue is #76.", nil},
+ {"markdown heading not a reference", "## Changes\n\nCloses #76", []int64{76}},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got := originalIssue(c.body)
+ if len(got) != len(c.want) {
+ t.Fatalf("originalIssue(%q) = %v, want %v", c.body, got, c.want)
+ }
+ for i := range got {
+ if got[i] != c.want[i] {
+ t.Fatalf("originalIssue(%q) = %v, want %v", c.body, got, c.want)
+ }
+ }
+ })
+ }
+}
+
+func TestMergePullRequestMergesAndClosesIssue(t *testing.T) {
+ fg := &fakeForgejoActions{
+ prState: "open",
+ prBody: "Closes #76",
+ approved: true,
+ approvedOK: true,
+ }
+ fc := &fakeContext{fg: fg}
+
+ out, err := mergePullRequest(fc, mergePullRequestParams{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(fg.merged) != 1 || fg.merged[0] != "merge" {
+ t.Fatalf("expected one merge with default style, got %v", fg.merged)
+ }
+ if len(fg.closedIssues) != 1 || fg.closedIssues[0] != 76 {
+ t.Fatalf("expected issue 76 closed, got %v", fg.closedIssues)
+ }
+ if out != "Pull request merged; closed issue #76" {
+ t.Fatalf("unexpected result: %q", out)
+ }
+}
+
+func TestMergePullRequestStyleAndDeleteBranch(t *testing.T) {
+ fg := &fakeForgejoActions{
+ prState: "open",
+ prBody: "Closes #76",
+ approved: true,
+ approvedOK: true,
+ }
+ fc := &fakeContext{fg: fg}
+
+ keep := false
+ if _, err := mergePullRequest(fc, mergePullRequestParams{Style: "squash", DeleteBranch: &keep}); err != nil {
+ t.Fatal(err)
+ }
+ if len(fg.merged) != 1 || fg.merged[0] != "squash" {
+ t.Fatalf("expected squash merge, got %v", fg.merged)
+ }
+}
+
+// The whole point of the feature: an unapproved PR must not merge.
+func TestMergePullRequestRefusesUnapproved(t *testing.T) {
+ fg := &fakeForgejoActions{
+ prState: "open",
+ prBody: "Closes #76",
+ approved: false,
+ approvedOK: true,
+ }
+ fc := &fakeContext{fg: fg}
+
+ _, err := mergePullRequest(fc, mergePullRequestParams{})
+ if err == nil {
+ t.Fatal("expected an error for an unapproved pull request")
+ }
+ if !strings.Contains(err.Error(), "not approved") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(fg.merged) != 0 {
+ t.Fatalf("expected no merge, got %v", fg.merged)
+ }
+}
+
+// Merging is idempotent: an already-merged PR reports success and still
+// closes the resolved issue (in case Forgejo's auto-close didn't fire).
+func TestMergePullRequestAlreadyMerged(t *testing.T) {
+ fg := &fakeForgejoActions{
+ prState: "merged",
+ prBody: "Closes #76",
+ }
+ fc := &fakeContext{fg: fg}
+
+ out, err := mergePullRequest(fc, mergePullRequestParams{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(fg.merged) != 0 {
+ t.Fatalf("expected no merge call, got %v", fg.merged)
+ }
+ if len(fg.closedIssues) != 1 || fg.closedIssues[0] != 76 {
+ t.Fatalf("expected issue 76 closed, got %v", fg.closedIssues)
+ }
+ if out != "Pull request already merged; closed issue #76" {
+ t.Fatalf("unexpected result: %q", out)
+ }
+}
+
+// A closed (not merged) PR has nothing to merge.
+func TestMergePullRequestClosedPR(t *testing.T) {
+ fg := &fakeForgejoActions{prState: "closed", prBody: "Closes #76"}
+ fc := &fakeContext{fg: fg}
+
+ _, err := mergePullRequest(fc, mergePullRequestParams{})
+ if err == nil {
+ t.Fatal("expected an error for a closed pull request")
+ }
+ if len(fg.merged) != 0 {
+ t.Fatalf("expected no merge, got %v", fg.merged)
+ }
+}
+
+// The triggering index is not a pull request at all.
+func TestMergePullRequestNotAPullRequest(t *testing.T) {
+ fg := &fakeForgejoActions{prErr: fmt.Errorf("get pull request: 404")}
+ fc := &fakeContext{fg: fg}
+
+ if _, err := mergePullRequest(fc, mergePullRequestParams{}); err == nil {
+ t.Fatal("expected an error when the triggering issue is not a PR")
+ }
+}
+
+// A failed close after a successful merge is reported in the result,
+// not as an error: the merge is the primary action and it succeeded.
+func TestMergePullRequestCloseFailureReported(t *testing.T) {
+ fg := &fakeForgejoActions{
+ prState: "open",
+ prBody: "Closes #76",
+ approved: true,
+ approvedOK: true,
+ closeErr: fmt.Errorf("boom"),
+ }
+ fc := &fakeContext{fg: fg}
+
+ out, err := mergePullRequest(fc, mergePullRequestParams{})
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if !strings.Contains(out, "WARNING") || !strings.Contains(out, "#76") {
+ t.Fatalf("expected a warning about issue 76, got %q", out)
+ }
+}
+
+// No issue reference in the body: the PR merges, nothing is closed, and
+// the result says so.
+func TestMergePullRequestNoIssueReference(t *testing.T) {
+ fg := &fakeForgejoActions{
+ prState: "open",
+ prBody: "Just some changes.",
+ approved: true,
+ approvedOK: true,
+ }
+ fc := &fakeContext{fg: fg}
+
+ out, err := mergePullRequest(fc, mergePullRequestParams{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(fg.closedIssues) != 0 {
+ t.Fatalf("expected no issue closed, got %v", fg.closedIssues)
+ }
+ if !strings.Contains(out, "no issue reference") {
+ t.Fatalf("unexpected result: %q", out)
+ }
+}
{"end_line":442,"path":"internal/forgejo/client.go","start_line":1}
{"path":"internal/forgejo/client.go","content":"// Package forgejo wraps the Forgejo SDK and turns webhook deliveries and\n// polling results into a common Event stream for the scheduler.\npackage forgejo\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n)\n\n// Client is zoo's single shared Forgejo identity, used both for the\n// event sources (webhook/poll) and for actions agents/scheduler take\n// (comments, labels, PRs).\ntype Client struct {\n\tsdk *sdk.Client\n\n\tbaseURL string\n\ttoken string\n}\n\nfunc NewClient(cfg config.Forgejo) (*Client, error) {\n\tc, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"forgejo client: %w\", err)\n\t}\n\n\treturn \u0026Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil\n}\n\n// Token returns the shared zoo Forgejo identity's token, e.g. for\n// authenticating a host-side git clone/push against Forgejo (see\n// internal/agentrun) without ever writing the credential into a working\n// tree an agent's container can read.\nfunc (c *Client) Token() string {\n\treturn c.token\n}\n\n// Owner returns the Forgejo username that owns this client's token. For\n// the shared master client that's the human operator whose comments are\n// always directed at humans, never at agents, and which zoo therefore\n// ignores everywhere (see Watcher.dispatch and agentrun's briefing).\nfunc (c *Client) Owner() (string, error) {\n\tu, _, err := c.sdk.GetMyUserInfo()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"get current user: %w\", err)\n\t}\n\n\treturn u.UserName, nil\n}\n\n// As returns a new Client that authenticates as the given token.\n// This is used to create per-agent clients so each agent acts as\n// themselves on Forgejo, without needing a global token with sudo\n// privileges.\nfunc (c *Client) As(token string) *Client {\n\tclient, _ := sdk.NewClient(c.baseURL, sdk.SetToken(token))\n\treturn \u0026Client{sdk: client, baseURL: c.baseURL, token: token}\n}\n\n// Sudo returns a new Client that impersonates username (via Forgejo's\n// \"Sudo:\" header) on every API call it makes, using the same underlying\n// token. Actions an agent takes through it — comments, labels, PRs,\n// assignment — are attributed to that agent's own Forgejo account\n// instead of the shared zoo identity. The token must belong to a user\n// with sudo scope/admin rights for this to work; Forgejo rejects the\n// header otherwise.\n//\n// Deprecated: use As(token) with a per-agent token instead. Kept for\n// backward compatibility during migration.\nfunc (c *Client) Sudo(username string) (*Client, error) {\n\tsudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"forgejo client sudo %q: %w\", username, err)\n\t}\n\n\treturn \u0026Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\n}\n\n// CreateIssueComment posts a comment on the given issue or pull request\n// (Forgejo/Gitea treat PRs as issues for commenting purposes).\nfunc (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {\n\t_, _, err := c.sdk.CreateIssueComment(owner, repo, index, sdk.CreateIssueCommentOption{Body: body})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"comment on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// IssueComment is one comment on an issue or pull request, in the\n// shape zoo needs when briefing an agent: who said what, and when.\ntype IssueComment struct {\n\tAuthor string\n\tBody string\n\tCreated time.Time\n}\n\n// ListIssueComments fetches every comment on the given issue or pull\n// request, oldest first. PRs are issues under the hood in Forgejo, so\n// the same endpoint serves both. Pages are walked until exhausted so\n// the result isn't capped by the server's default page size.\nfunc (c *Client) ListIssueComments(owner, repo string, index int64) ([]IssueComment, error) {\n\tconst pageSize = 50\n\n\tvar all []*sdk.Comment\n\n\tfor page := 1; ; page++ {\n\t\tbatch, _, err := c.sdk.ListIssueComments(owner, repo, index, sdk.ListIssueCommentOptions{\n\t\t\tListOptions: sdk.ListOptions{Page: page, PageSize: pageSize},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"list comments on %s/%s#%d (page %d): %w\", owner, repo, index, page, err)\n\t\t}\n\n\t\tall = append(all, batch...)\n\n\t\tif len(batch) \u003c pageSize {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tout := make([]IssueComment, 0, len(all))\n\tfor _, cm := range all {\n\t\tauthor := \"\"\n\t\tif cm.Poster != nil {\n\t\t\tauthor = cm.Poster.UserName\n\t\t}\n\n\t\tout = append(out, IssueComment{Author: author, Body: cm.Body, Created: cm.Created})\n\t}\n\n\treturn out, nil\n}\n\n// AddLabel attaches the label with the given name to an issue/PR,\n// creating the label (with a default color) on the repo first if it\n// doesn't already exist.\nfunc (c *Client) AddLabel(owner, repo string, index int64, name string) error {\n\tid, err := c.labelID(owner, repo, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, _, err = c.sdk.AddIssueLabels(owner, repo, index, sdk.IssueLabelsOption{Labels: []int64{id}})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"add label %q to %s/%s#%d: %w\", name, owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// RemoveLabel detaches the label with the given name from an issue/PR, if\n// both the label and the attachment exist.\nfunc (c *Client) RemoveLabel(owner, repo string, index int64, name string) error {\n\tlabels, _, err := c.sdk.GetIssueLabels(owner, repo, index, sdk.ListLabelsOptions{})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"list labels on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tfor _, l := range labels {\n\t\tif l.Name == name {\n\t\t\t_, err := c.sdk.DeleteIssueLabel(owner, repo, index, l.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"remove label %q from %s/%s#%d: %w\", name, owner, repo, index, err)\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) labelID(owner, repo, name string) (int64, error) {\n\tlabels, _, err := c.sdk.ListRepoLabels(owner, repo, sdk.ListLabelsOptions{})\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"list labels on %s/%s: %w\", owner, repo, err)\n\t}\n\n\tfor _, l := range labels {\n\t\tif l.Name == name {\n\t\t\treturn l.ID, nil\n\t\t}\n\t}\n\n\tcreated, _, err := c.sdk.CreateLabel(owner, repo, sdk.CreateLabelOption{\n\t\tName: name,\n\t\tColor: \"#ee0000\",\n\t})\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"create label %q on %s/%s: %w\", name, owner, repo, err)\n\t}\n\n\treturn created.ID, nil\n}\n\n// CreatePullRequest opens a PR from head into base.\nfunc (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {\n\t_, _, err := c.sdk.CreatePullRequest(owner, repo, sdk.CreatePullRequestOption{\n\t\tHead: head,\n\t\tBase: base,\n\t\tTitle: title,\n\t\tBody: body,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"create pull request %s/%s %s-\u003e%s: %w\", owner, repo, head, base, err)\n\t}\n\n\treturn nil\n}\n\n// RequestReview asks the given users to review the pull request.\nfunc (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {\n\t_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"request review on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// Review states an agent can submit, in the friendly names the tools\n// expose. SubmitReview maps them onto the SDK's ReviewStateType.\nconst (\n\tReviewStateApproved = \"approved\"\n\tReviewStateChangesRequest = \"changes_requested\"\n\tReviewStateComment = \"comment\"\n)\n\n// SubmitReview submits a review on the pull request with the given\n// verdict and body. state is one of ReviewStateApproved,\n// ReviewStateChangesRequest, or ReviewStateComment. A body is required\n// for anything other than an approval (Forgejo enforces this too).\nfunc (c *Client) SubmitReview(owner, repo string, index int64, state, body string) error {\n\tvar sdkState sdk.ReviewStateType\n\n\tswitch state {\n\tcase ReviewStateApproved:\n\t\tsdkState = sdk.ReviewStateApproved\n\tcase ReviewStateChangesRequest:\n\t\tsdkState = sdk.ReviewStateRequestChanges\n\tcase ReviewStateComment:\n\t\tsdkState = sdk.ReviewStateComment\n\tdefault:\n\t\treturn fmt.Errorf(\"submit review on %s/%s#%d: unknown review state %q\", owner, repo, index, state)\n\t}\n\n\tif _, _, err := c.sdk.CreatePullReview(owner, repo, index, sdk.CreatePullReviewOptions{State: sdkState, Body: body}); err != nil {\n\t\treturn fmt.Errorf(\"submit review on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// ReviewCommentDetail is one inline comment on a pull request review,\n// in the shape zoo needs when briefing an agent: where it points, what\n// it says, and its id (so the agent can refer to it in its reply).\ntype ReviewCommentDetail struct {\n\tID int64\n\tPath string\n\tLine int\n\tBody string\n\tAuthor string\n}\n\n// ReviewDetail is the review context zoo briefs an agent with when a\n// pr:review event fires: the review's verdict and body, plus its inline\n// comments.\ntype ReviewDetail struct {\n\tID int64\n\tState string\n\tBody string\n\tReviewer string\n\tComments []ReviewCommentDetail\n}\n\n// ReviewDetail fetches a pull request review and its inline comments.\n// The webhook payload carries the review but not its inline comments,\n// so this is how a reacting agent gets the full feedback.\nfunc (c *Client) ReviewDetail(owner, repo string, index, reviewID int64) (*ReviewDetail, error) {\n\treview, _, err := c.sdk.GetPullReview(owner, repo, index, reviewID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get review %d on %s/%s#%d: %w\", reviewID, owner, repo, index, err)\n\t}\n\n\tcomments, _, err := c.sdk.ListPullReviewComments(owner, repo, index, reviewID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"list comments on review %d of %s/%s#%d: %w\", reviewID, owner, repo, index, err)\n\t}\n\n\tdetail := \u0026ReviewDetail{\n\t\tID: review.ID,\n\t\tState: string(review.State),\n\t\tBody: review.Body,\n\t}\n\n\tif review.Reviewer != nil {\n\t\tdetail.Reviewer = review.Reviewer.UserName\n\t}\n\n\tfor _, cm := range comments {\n\t\tauthor := \"\"\n\t\tif cm.Reviewer != nil {\n\t\t\tauthor = cm.Reviewer.UserName\n\t\t}\n\n\t\tline := int(cm.LineNum)\n\t\tif cm.OldLineNum != 0 \u0026\u0026 cm.LineNum == 0 {\n\t\t\tline = int(cm.OldLineNum)\n\t\t}\n\n\t\tdetail.Comments = append(detail.Comments, ReviewCommentDetail{\n\t\t\tID: cm.ID,\n\t\t\tPath: cm.Path,\n\t\t\tLine: line,\n\t\t\tBody: cm.Body,\n\t\t\tAuthor: author,\n\t\t})\n\t}\n\n\treturn detail, nil\n}\n\n// PullRequestInfo is the branch metadata agentrun needs to check out a\n// pull request's head.\ntype PullRequestInfo struct {\n\tHeadRef string\n\tBaseRef string\n}\n\n// PullRequestInfo returns the pull request's head and base branch refs.\nfunc (c *Client) PullRequestInfo(owner, repo string, index int64) (PullRequestInfo, error) {\n\tpr, _, err := c.sdk.GetPullRequest(owner, repo, index)\n\tif err != nil {\n\t\treturn PullRequestInfo{}, fmt.Errorf(\"get pull request %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tinfo := PullRequestInfo{}\n\tif pr.Head != nil {\n\t\tinfo.HeadRef = pr.Head.Ref\n\t}\n\tif pr.Base != nil {\n\t\tinfo.BaseRef = pr.Base.Ref\n\t}\n\n\treturn info, nil\n}\n\n// PullRequestState returns the pull request's merge-relevant state —\n// \"open\", \"closed\", or \"merged\" (a merged PR reports State \"closed\" on\n// the wire, so the merged flag is folded in here) — plus its body,\n// which is how the merge tool resolves the original issue.\nfunc (c *Client) PullRequestState(owner, repo string, index int64) (state, body string, err error) {\n\tpr, _, err := c.sdk.GetPullRequest(owner, repo, index)\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"get pull request %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tstate = string(pr.State)\n\tif pr.HasMerged {\n\t\tstate = \"merged\"\n\t}\n\n\treturn state, pr.Body, nil\n}\n\n// PullRequestApproved reports whether the pull request has at least one\n// approved, non-dismissed review from someone other than the pull\n// request's author. This is the gate the merge tool uses: agents may\n// merge a PR only once it carries a real approval — from another agent\n// or a human. The author's own approval doesn't count, since the review\n// gate exists to be a second pair of eyes, not the author rubber-\n// stamping their own work.\nfunc (c *Client) PullRequestApproved(owner, repo string, index int64) (bool, error) {\n\tpr, _, err := c.sdk.GetPullRequest(owner, repo, index)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"get pull request %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tauthor := \"\"\n\tif pr.Poster != nil {\n\t\tauthor = pr.Poster.UserName\n\t}\n\n\treviews, _, err := c.sdk.ListPullReviews(owner, repo, index, sdk.ListPullReviewsOptions{})\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"list reviews on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tfor _, r := range reviews {\n\t\tif r.State != sdk.ReviewStateApproved || r.Dismissed {\n\t\t\tcontinue\n\t\t}\n\n\t\treviewer := \"\"\n\t\tif r.Reviewer != nil {\n\t\t\treviewer = r.Reviewer.UserName\n\t\t}\n\n\t\tif reviewer != \"\" \u0026\u0026 reviewer != author {\n\t\t\treturn true, nil\n\t\t}\n\t}\n\n\treturn false, nil\n}\n\n// MergePullRequest merges the pull request with the given index. style\n// is one of \"merge\", \"rebase\", \"rebase-merge\", or \"squash\"; when\n// deleteBranch is true the head branch is removed after the merge.\nfunc (c *Client) MergePullRequest(owner, repo string, index int64, style string, deleteBranch bool) error {\n\tvar sdkStyle sdk.MergeStyle\n\n\tswitch style {\n\tcase \"merge\":\n\t\tsdkStyle = sdk.MergeStyleMerge\n\tcase \"rebase\":\n\t\tsdkStyle = sdk.MergeStyleRebase\n\tcase \"rebase-merge\":\n\t\tsdkStyle = sdk.MergeStyleRebaseMerge\n\tcase \"squash\":\n\t\tsdkStyle = sdk.MergeStyleSquash\n\tdefault:\n\t\treturn fmt.Errorf(\"merge %s/%s#%d: unknown merge style %q\", owner, repo, index, style)\n\t}\n\n\t_, _, err := c.sdk.MergePullRequest(owner, repo, index, sdk.MergePullRequestOption{\n\t\tStyle: sdkStyle,\n\t\tDeleteBranchAfterMerge: deleteBranch,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"merge %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// CloseIssue closes the given issue or pull request.\nfunc (c *Client) CloseIssue(owner, repo string, index int64) error {\n\treturn c.setIssueState(owner, repo, index, sdk.StateClosed)","start_line":1,"end_line":442,"total_lines":534,"truncated":true}
{"command":"cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -20","timeout":30}
ERROR CALLING TOOL: command timed out after 30 seconds
{"command":"cd /project && ls $(go env GOMODCACHE 2>/dev/null) 2>/dev/null | head; go env GOFLAGS GOPROXY"}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 1ae9dc48e118a3c6d3b98720fb66272c7531aec3792c5fcceb4e14d983972ab8
{"command":"cd /project && go env GOMODCACHE GOPROXY GOFLAGS && ls $(go env GOMODCACHE) 2>/dev/null | head","timeout":60}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 1ae9dc48e118a3c6d3b98720fb66272c7531aec3792c5fcceb4e14d983972ab8
{"command":"sleep 5 && cd /project && pwd && ls","timeout":60}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 1ae9dc48e118a3c6d3b98720fb66272c7531aec3792c5fcceb4e14d983972ab8
{"command":"sleep 20 && echo alive && pwd","timeout":120}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 1ae9dc48e118a3c6d3b98720fb66272c7531aec3792c5fcceb4e14d983972ab8
{"command":"echo retry && date","timeout":120}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 1ae9dc48e118a3c6d3b98720fb66272c7531aec3792c5fcceb4e14d983972ab8
{"path":"internal"}
ERROR CALLING TOOL: list "internal": exec create: Error response from daemon: No such container: 1ae9dc48e118a3c6d3b98720fb66272c7531aec3792c5fcceb4e14d983972ab8
{"command":"sleep 60 && echo recovered && ls /project","timeout":120}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 1ae9dc48e118a3c6d3b98720fb66272c7531aec3792c5fcceb4e14d983972ab8
{"command":"sleep 120 && echo recovered && ls /project","timeout":180}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 1ae9dc48e118a3c6d3b98720fb66272c7531aec3792c5fcceb4e14d983972ab8
{"command":"sleep 240 && echo recovered && ls /project","timeout":300}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 1ae9dc48e118a3c6d3b98720fb66272c7531aec3792c5fcceb4e14d983972ab8
{"command":"echo still-down-check && ls /project 2>&1 | head -5","timeout":30}
ERROR CALLING TOOL: exec create: Error response from daemon: No such container: 1ae9dc48e118a3c6d3b98720fb66272c7531aec3792c5fcceb4e14d983972ab8