e8d04d719d
The completion tail ended at TaskCompleted with no pull request. Nothing in the running system ever called the review or submission endpoints: the whole event log holds zero ReviewRecorded and zero TaskSubmitted, so the merge reflection, the publisher and the human trust boundary had no entry point. Four links, in the order the tail needs them: - finalize commits first and runs the quality gate against the committed tree, so the gate result is bound to the commit being submitted. CheckSubmission requires gate sha, review sha and head sha to be one commit, which a gate run on the pre-commit tree can never satisfy. - The worker seals the reviewer's findings and submits, through a new /v1/federation/workers/<id>/submit. A blocking review returns the task to implementation instead; a project with no forge still completes directly. - The reviewing session is told where findings go. The brief asked for findings and named no file, and it described a diff nobody supplied. - GiteaPublisher.Push asks the forge what the branch holds before reaching for a local checkout. A worker-owned worktree is on another machine and has already pushed the commit; the coordinator has no such directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
268 lines
8.1 KiB
Go
268 lines
8.1 KiB
Go
package provider
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/human"
|
|
"orchestra/internal/operations"
|
|
)
|
|
|
|
// GiteaPublisher performs the two side effects of a submission: publish the
|
|
// exact commit, then create or update one pull request for its branch.
|
|
//
|
|
// It never creates a second pull request for a branch that already has an open
|
|
// one. A repeated `task pr` has to refresh the same review, not open a new one.
|
|
type GiteaPublisher struct {
|
|
Gitea
|
|
// Base is the branch the pull request targets. Empty means the repo default.
|
|
Base string
|
|
// Root is the local checkout to push from. The caller constructs one
|
|
// publisher per submission, because the checkout is per task.
|
|
Root string
|
|
}
|
|
|
|
func (g GiteaPublisher) Push(ctx context.Context, remote, branch, sha string) (string, error) {
|
|
// A worker-owned checkout lives on another machine, and it has already
|
|
// pushed the commit it is submitting. Ask the forge what the branch holds
|
|
// before reaching for a local repository the coordinator may not have: if
|
|
// the remote is already at the submitted commit, publishing is done. This
|
|
// is the same read the caller performs afterwards, so nothing is taken on
|
|
// trust that a push would have proven.
|
|
if head, err := g.branchHead(ctx, branch); err == nil && head == sha {
|
|
return head, nil
|
|
}
|
|
root := g.Root
|
|
if strings.TrimSpace(root) == "" {
|
|
return "", fmt.Errorf("gitea publisher: %s does not hold %s and there is no local checkout to push from", branch, sha)
|
|
}
|
|
if out, err := exec.CommandContext(ctx, "git", "-C", root, "push", remote, sha+":refs/heads/"+branch).CombinedOutput(); err != nil {
|
|
return "", fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
// Read back what the remote actually holds. A push that reported success
|
|
// is not proof the ref points where it should.
|
|
out, err := exec.CommandContext(ctx, "git", "-C", root, "ls-remote", remote, "refs/heads/"+branch).Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("verify pushed ref: %w", err)
|
|
}
|
|
fields := strings.Fields(string(out))
|
|
if len(fields) == 0 {
|
|
return "", fmt.Errorf("remote has no %s", branch)
|
|
}
|
|
return fields[0], nil
|
|
}
|
|
|
|
// branchHead reports the commit the forge holds for a branch, or an error if
|
|
// the branch does not exist.
|
|
func (g GiteaPublisher) branchHead(ctx context.Context, branch string) (string, error) {
|
|
var out struct {
|
|
Commit struct {
|
|
ID string `json:"id"`
|
|
} `json:"commit"`
|
|
}
|
|
if err := g.get(ctx, "/branches/"+branch, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Commit.ID, nil
|
|
}
|
|
|
|
type giteaPR struct {
|
|
Number int `json:"number"`
|
|
State string `json:"state"`
|
|
URL string `json:"html_url"`
|
|
}
|
|
|
|
func (g GiteaPublisher) EnsurePR(ctx context.Context, plan operations.SubmissionPlan) (domain.ExternalRef, error) {
|
|
existing, err := g.findPR(ctx, plan.Branch)
|
|
if err != nil {
|
|
return domain.ExternalRef{}, err
|
|
}
|
|
body, _ := json.Marshal(map[string]any{
|
|
"title": plan.PRTitle, "body": plan.PRBody,
|
|
"head": plan.Branch, "base": g.base(),
|
|
})
|
|
method, path := http.MethodPost, "/pulls"
|
|
if existing != nil {
|
|
method, path = http.MethodPatch, fmt.Sprintf("/pulls/%d", existing.Number)
|
|
body, _ = json.Marshal(map[string]any{"title": plan.PRTitle, "body": plan.PRBody})
|
|
}
|
|
pr, err := g.call(ctx, method, path, body)
|
|
if err != nil {
|
|
return domain.ExternalRef{}, err
|
|
}
|
|
return domain.ExternalRef{Provider: g.SourceName(), ID: fmt.Sprint(pr.Number), URL: pr.URL}, nil
|
|
}
|
|
|
|
func (g GiteaPublisher) base() string {
|
|
if strings.TrimSpace(g.Base) != "" {
|
|
return g.Base
|
|
}
|
|
return "master"
|
|
}
|
|
|
|
func (g GiteaPublisher) findPR(ctx context.Context, branch string) (*giteaPR, error) {
|
|
u := g.repoURL() + "/pulls?state=open&limit=50"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
g.authorize(req)
|
|
resp, err := g.client().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
return nil, fmt.Errorf("gitea list pulls: %s", resp.Status)
|
|
}
|
|
var open []struct {
|
|
giteaPR
|
|
Head struct {
|
|
Ref string `json:"ref"`
|
|
} `json:"head"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&open); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, pr := range open {
|
|
if pr.Head.Ref == branch {
|
|
found := pr.giteaPR
|
|
return &found, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (g GiteaPublisher) call(ctx context.Context, method, path string, body []byte) (giteaPR, error) {
|
|
req, err := http.NewRequestWithContext(ctx, method, g.repoURL()+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return giteaPR{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
g.authorize(req)
|
|
resp, err := g.client().Do(req)
|
|
if err != nil {
|
|
return giteaPR{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
return giteaPR{}, fmt.Errorf("gitea %s %s: %s", method, path, resp.Status)
|
|
}
|
|
var pr giteaPR
|
|
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
|
|
return giteaPR{}, err
|
|
}
|
|
return pr, nil
|
|
}
|
|
|
|
func (g GiteaPublisher) repoURL() string {
|
|
return strings.TrimRight(g.BaseURL, "/") + "/api/v1/repos/" + url.PathEscape(g.Owner) + "/" + url.PathEscape(g.Repo)
|
|
}
|
|
|
|
func (g GiteaPublisher) authorize(req *http.Request) {
|
|
if g.Token != "" {
|
|
req.Header.Set("Authorization", "token "+g.Token)
|
|
}
|
|
}
|
|
|
|
type giteaPRDetail struct {
|
|
Number int `json:"number"`
|
|
State string `json:"state"`
|
|
Merged bool `json:"merged"`
|
|
MergeSHA string `json:"merge_commit_sha"`
|
|
MergedAt *time.Time `json:"merged_at"`
|
|
Head struct {
|
|
SHA string `json:"sha"`
|
|
} `json:"head"`
|
|
}
|
|
|
|
type giteaPRReview struct {
|
|
State string `json:"state"`
|
|
Body string `json:"body"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
Submitted time.Time `json:"submitted_at"`
|
|
}
|
|
|
|
// PullRequest reads the submitted pull request's current state, its comments,
|
|
// and its reviews. It reports what the forge says rather than deciding what it
|
|
// means: the trust boundary and the lifecycle rules live in operations.
|
|
func (g GiteaPublisher) PullRequest(ctx context.Context, task domain.Task) (human.PullRequestState, error) {
|
|
if task.Submission == nil || task.Submission.PR.ID == "" {
|
|
return human.PullRequestState{}, fmt.Errorf("task %s has no submitted pull request", task.ID)
|
|
}
|
|
number := task.Submission.PR.ID
|
|
var detail giteaPRDetail
|
|
if err := g.get(ctx, "/pulls/"+url.PathEscape(number), &detail); err != nil {
|
|
return human.PullRequestState{}, err
|
|
}
|
|
out := human.PullRequestState{ID: number, HeadSHA: detail.Head.SHA, MergeSHA: detail.MergeSHA}
|
|
switch {
|
|
case detail.Merged:
|
|
out.State = "merged"
|
|
case detail.State == "closed":
|
|
out.State = "closed"
|
|
default:
|
|
out.State = "open"
|
|
}
|
|
if detail.MergedAt != nil {
|
|
out.MergedAt = *detail.MergedAt
|
|
}
|
|
|
|
// Pull request comments live on the issue endpoint in Gitea.
|
|
var comments []giteaComment
|
|
if err := g.get(ctx, "/issues/"+url.PathEscape(number)+"/comments", &comments); err != nil {
|
|
return human.PullRequestState{}, err
|
|
}
|
|
for _, c := range comments {
|
|
out.Comments = append(out.Comments, human.Input{
|
|
Provider: g.SourceName(), ExternalID: strconv.FormatInt(c.ID, 10),
|
|
Author: c.User.Login, At: c.CreatedAt, Body: c.Body,
|
|
})
|
|
}
|
|
var reviews []giteaPRReview
|
|
if err := g.get(ctx, "/pulls/"+url.PathEscape(number)+"/reviews", &reviews); err != nil {
|
|
return human.PullRequestState{}, err
|
|
}
|
|
for _, r := range reviews {
|
|
state := "commented"
|
|
switch strings.ToUpper(r.State) {
|
|
case "APPROVED":
|
|
state = "approved"
|
|
case "REQUEST_CHANGES", "CHANGES_REQUESTED":
|
|
state = "changes_requested"
|
|
}
|
|
out.Reviews = append(out.Reviews, human.ReviewObservation{
|
|
Actor: r.User.Login, State: state, At: r.Submitted, Body: r.Body,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (g GiteaPublisher) get(ctx context.Context, path string, into any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.repoURL()+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
g.authorize(req)
|
|
resp, err := g.client().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
return fmt.Errorf("gitea GET %s: %s", path, resp.Status)
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(into)
|
|
}
|