7f12c7fc37
The v3 stack, previously an uncommitted working tree, plus this session's two units and the burn-in instrument. This commit is the burn-in build identity: coordinator and worker must both report this revision before a task is created. Workflow (earlier sessions, uncommitted until now): human decision events and reduction, source cursors and reconcile-before-launch, turn-boundary reconciliation, internal/agentctx as the single renderer, ace-fca phases with sealed artifacts, the trajectory gate, bounded grilling, independent review, task pr enforcement, and human review reflection. Capability restrictions at the agent boundary: an authz.Agent surface at GatedWrite may ask and may not act. It also fixes two bugs the unit exposed -- gated surfaces could not reach the two endpoints written for them, and RequestHumanDecision would block an unowned task while rejecting a question from the session that did own it. Turn-boundary reconcile-failure escalation: a streak of consecutive failures asks the session to hand off, fenced on the lease epoch, with reconcile_failure as a real handoff reason. The worker was dropping the coordinator's verdict on the floor; it now acts on it. Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to <worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md is the runbook. deploy/build.sh stamps both binaries from one commit. go build, go vet and go test ./... pass, 20 packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
7.1 KiB
Go
245 lines
7.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) {
|
|
root := g.Root
|
|
if strings.TrimSpace(root) == "" {
|
|
return "", fmt.Errorf("gitea publisher: worktree root is required")
|
|
}
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|