Files
orchestra/internal/provider/gitea_pr.go
T
kami 7f12c7fc37 v3 workflow: intent, phases, review, submission, enforcement, burn-in
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>
2026-08-26 18:31:20 +04:00

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)
}