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