mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
An agent could not get a finished change onto the forge without someone pushing by hand, so the branch / pull request / approval rule could not be followed. A Go bundle serves gitea_commit, held to gitea_api's rule (never the default branch, never a protected one), and the summarised reads agents otherwise made through gitea_api as raw JSON.
335 lines
10 KiB
Go
335 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The forge's reads an agent otherwise made through gitea_api, answered summarised rather than as the forge's
|
|
// JSON (novox/hq issue 375): a commit's history, a compare, a pull request's reviews with their line comments,
|
|
// a directory's entries.
|
|
|
|
// CommitLine is one commit, summarised.
|
|
type CommitLine struct {
|
|
SHA string `json:"sha"`
|
|
Author string `json:"author"`
|
|
Date string `json:"date"`
|
|
Subject string `json:"subject"`
|
|
Files int `json:"files,omitempty"`
|
|
}
|
|
|
|
type forgeCommit struct {
|
|
SHA string `json:"sha"`
|
|
Commit struct {
|
|
Message string `json:"message"`
|
|
Author struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Date string `json:"date"`
|
|
} `json:"author"`
|
|
} `json:"commit"`
|
|
Files []struct {
|
|
Filename string `json:"filename"`
|
|
Status string `json:"status"`
|
|
} `json:"files"`
|
|
}
|
|
|
|
func (c forgeCommit) line() CommitLine {
|
|
subject, _, _ := strings.Cut(strings.TrimSpace(c.Commit.Message), "\n")
|
|
return CommitLine{SHA: c.SHA, Author: c.Commit.Author.Name, Date: c.Commit.Author.Date, Subject: subject, Files: len(c.Files)}
|
|
}
|
|
|
|
// History is a ref's commits, newest first.
|
|
type History struct {
|
|
Repository string `json:"repository"`
|
|
Ref string `json:"ref,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
Page int `json:"page"`
|
|
Commits []CommitLine `json:"commits"`
|
|
More bool `json:"more"`
|
|
}
|
|
|
|
func history(ctx context.Context, f *Forge, owner, name, ref, path string, limit, page int) (*History, error) {
|
|
repo, err := repoPath(owner, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
q := url.Values{}
|
|
if ref != "" {
|
|
q.Set("sha", ref)
|
|
}
|
|
if path != "" {
|
|
q.Set("path", path)
|
|
}
|
|
q.Set("limit", fmt.Sprint(limit))
|
|
q.Set("page", fmt.Sprint(page))
|
|
q.Set("stat", "false")
|
|
q.Set("verification", "false")
|
|
q.Set("files", "false")
|
|
var got []forgeCommit
|
|
if err := f.getJSON(ctx, repo+"/commits?"+q.Encode(), &got); err != nil {
|
|
return nil, err
|
|
}
|
|
h := &History{Repository: owner + "/" + name, Ref: ref, Path: path, Page: page, Commits: []CommitLine{}, More: len(got) >= limit}
|
|
for _, c := range got {
|
|
l := c.line()
|
|
l.Files = 0
|
|
h.Commits = append(h.Commits, l)
|
|
}
|
|
return h, nil
|
|
}
|
|
|
|
// Comparison is what head holds that base does not.
|
|
type Comparison struct {
|
|
Repository string `json:"repository"`
|
|
Base string `json:"base"`
|
|
Head string `json:"head"`
|
|
Total int `json:"total_commits"`
|
|
Commits []CommitLine `json:"commits"`
|
|
Files []FileLine `json:"files"`
|
|
Said []string `json:"said"`
|
|
}
|
|
|
|
// FileLine is one file a compare touched, and how the last commit that touched it changed it.
|
|
type FileLine struct {
|
|
Path string `json:"path"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
func compare(ctx context.Context, f *Forge, owner, name, base, head string) (*Comparison, error) {
|
|
repo, err := repoPath(owner, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b, err := segments("base", base)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h, err := segments("head", head)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.Contains(base, "...") || strings.Contains(head, "...") {
|
|
return nil, refuse("a base or head holding ... cannot be compared: the forge splits on it. Nothing was sent")
|
|
}
|
|
var got struct {
|
|
Total int `json:"total_commits"`
|
|
Commits []forgeCommit `json:"commits"`
|
|
}
|
|
if err := f.getJSON(ctx, repo+"/compare/"+b+"..."+h, &got); err != nil {
|
|
return nil, err
|
|
}
|
|
c := &Comparison{Repository: owner + "/" + name, Base: base, Head: head, Total: got.Total, Commits: []CommitLine{}, Files: []FileLine{}}
|
|
at := map[string]int{}
|
|
for _, fc := range got.Commits {
|
|
c.Commits = append(c.Commits, fc.line())
|
|
for _, file := range fc.Files {
|
|
if i, seen := at[file.Filename]; seen {
|
|
c.Files[i].Status = file.Status
|
|
continue
|
|
}
|
|
at[file.Filename] = len(c.Files)
|
|
c.Files = append(c.Files, FileLine{Path: file.Filename, Status: file.Status})
|
|
}
|
|
}
|
|
c.Said = append(c.Said, fmt.Sprintf("%s holds %d commits %s does not, touching %d files", head, got.Total, base, len(c.Files)))
|
|
if got.Total > len(got.Commits) {
|
|
c.Said = append(c.Said, fmt.Sprintf("the forge listed %d of the %d commits; the files are those of the commits listed", len(got.Commits), got.Total))
|
|
}
|
|
c.Said = append(c.Said, "a file's status is the last listed commit's change to it; for the lines, gitea_pull_request_diff or gitea_get_file")
|
|
return c, nil
|
|
}
|
|
|
|
// Review is one review of a pull request with its line comments.
|
|
type Review struct {
|
|
ID int64 `json:"id"`
|
|
By string `json:"by"`
|
|
State string `json:"state"`
|
|
Commit string `json:"commit"`
|
|
AtHead bool `json:"at_head"`
|
|
Submitted string `json:"submitted,omitempty"`
|
|
Stale bool `json:"stale,omitempty"`
|
|
Dismissed bool `json:"dismissed,omitempty"`
|
|
Official bool `json:"official,omitempty"`
|
|
Body string `json:"body,omitempty"`
|
|
Comments []LineComment `json:"comments"`
|
|
}
|
|
|
|
// LineComment is one comment a review left on a line.
|
|
type LineComment struct {
|
|
Path string `json:"path"`
|
|
Line int `json:"line,omitempty"`
|
|
OldLine int `json:"old_line,omitempty"`
|
|
By string `json:"by"`
|
|
Body string `json:"body"`
|
|
Commit string `json:"commit,omitempty"`
|
|
Resolved string `json:"resolved_by,omitempty"`
|
|
}
|
|
|
|
// Reviews is a pull request's reviews, oldest first.
|
|
type Reviews struct {
|
|
Repository string `json:"repository"`
|
|
Number int `json:"number"`
|
|
Head string `json:"head"`
|
|
Reviews []Review `json:"reviews"`
|
|
Said []string `json:"said"`
|
|
}
|
|
|
|
type forgeUser struct {
|
|
Login string `json:"login"`
|
|
}
|
|
|
|
func reviews(ctx context.Context, f *Forge, owner, name string, number int) (*Reviews, error) {
|
|
repo, err := repoPath(owner, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pr := fmt.Sprintf("%s/pulls/%d", repo, number)
|
|
var pull struct {
|
|
Head struct {
|
|
SHA string `json:"sha"`
|
|
} `json:"head"`
|
|
}
|
|
if err := f.getJSON(ctx, pr, &pull); err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return nil, refuse("%s/%s has no pull request %d", owner, name, number)
|
|
}
|
|
return nil, err
|
|
}
|
|
out := &Reviews{Repository: owner + "/" + name, Number: number, Head: pull.Head.SHA, Reviews: []Review{}}
|
|
type forgeReview struct {
|
|
ID int64 `json:"id"`
|
|
User forgeUser `json:"user"`
|
|
State string `json:"state"`
|
|
Body string `json:"body"`
|
|
CommitID string `json:"commit_id"`
|
|
Submitted string `json:"submitted_at"`
|
|
Stale bool `json:"stale"`
|
|
Dismissed bool `json:"dismissed"`
|
|
Official bool `json:"official"`
|
|
Comments int `json:"comments_count"`
|
|
}
|
|
const page = 50
|
|
var all []forgeReview
|
|
for p := 1; ; p++ {
|
|
var batch []forgeReview
|
|
if err := f.getJSON(ctx, fmt.Sprintf("%s/reviews?limit=%d&page=%d", pr, page, p), &batch); err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, batch...)
|
|
if len(batch) < page || p >= 20 {
|
|
break
|
|
}
|
|
}
|
|
approvedAtHead := 0
|
|
for _, r := range all {
|
|
rv := Review{ID: r.ID, By: r.User.Login, State: r.State, Commit: r.CommitID, AtHead: r.CommitID != "" && r.CommitID == pull.Head.SHA,
|
|
Submitted: r.Submitted, Stale: r.Stale, Dismissed: r.Dismissed, Official: r.Official, Body: r.Body, Comments: []LineComment{}}
|
|
if r.Comments > 0 {
|
|
var cs []struct {
|
|
Path string `json:"path"`
|
|
Position int `json:"position"`
|
|
Original int `json:"original_position"`
|
|
User forgeUser `json:"user"`
|
|
Body string `json:"body"`
|
|
CommitID string `json:"commit_id"`
|
|
Resolver *forgeUser `json:"resolver"`
|
|
}
|
|
if err := f.getJSON(ctx, fmt.Sprintf("%s/reviews/%d/comments", pr, r.ID), &cs); err != nil {
|
|
return nil, fmt.Errorf("review %d's line comments could not be read: %w", r.ID, err)
|
|
}
|
|
for _, c := range cs {
|
|
lc := LineComment{Path: c.Path, Line: c.Position, OldLine: c.Original, By: c.User.Login, Body: c.Body, Commit: c.CommitID}
|
|
if c.Resolver != nil {
|
|
lc.Resolved = c.Resolver.Login
|
|
}
|
|
rv.Comments = append(rv.Comments, lc)
|
|
}
|
|
}
|
|
if rv.AtHead && r.State == "APPROVED" && !r.Dismissed && !r.Stale {
|
|
approvedAtHead++
|
|
}
|
|
out.Reviews = append(out.Reviews, rv)
|
|
}
|
|
out.Said = append(out.Said, fmt.Sprintf("%d reviews; %d approve the head %s", len(out.Reviews), approvedAtHead, short(pull.Head.SHA)))
|
|
return out, nil
|
|
}
|
|
|
|
// Entry is one entry of a directory.
|
|
type Entry struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Size int64 `json:"size,omitempty"`
|
|
}
|
|
|
|
// Listing is a directory's entries at a ref.
|
|
type Listing struct {
|
|
Repository string `json:"repository"`
|
|
Path string `json:"path"`
|
|
Ref string `json:"ref,omitempty"`
|
|
Entries []Entry `json:"entries"`
|
|
}
|
|
|
|
func listDir(ctx context.Context, f *Forge, owner, name, path, ref string) (*Listing, error) {
|
|
repo, err := repoPath(owner, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
path = strings.Trim(path, "/")
|
|
at := repo + "/contents"
|
|
if path != "" {
|
|
seg, err := segments("directory path", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
at += "/" + seg
|
|
}
|
|
if ref != "" {
|
|
at += "?ref=" + queryEscape(ref)
|
|
}
|
|
body, err := f.do(ctx, "GET", at, nil, maxBody)
|
|
if err != nil {
|
|
if errors.Is(err, errNotFound) {
|
|
return nil, refuse("%s/%s has no %q at %s", owner, name, path, orDefault(ref))
|
|
}
|
|
return nil, err
|
|
}
|
|
var got []struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
if err := jsonInto(body, &got); err != nil {
|
|
return nil, refuse("%s is a file, not a directory: gitea_get_file reads it", path)
|
|
}
|
|
l := &Listing{Repository: owner + "/" + name, Path: "/" + path, Ref: ref, Entries: []Entry{}}
|
|
for _, e := range got {
|
|
en := Entry{Name: e.Name, Type: e.Type}
|
|
if e.Type == "file" {
|
|
en.Size = e.Size
|
|
}
|
|
l.Entries = append(l.Entries, en)
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
func orDefault(ref string) string {
|
|
if ref == "" {
|
|
return "the default branch"
|
|
}
|
|
return ref
|
|
}
|
|
|
|
func jsonInto(body []byte, into any) error { return json.Unmarshal(body, into) }
|
|
|
|
// queryEscape is a query value as the forge reads it.
|
|
func queryEscape(v string) string { return url.QueryEscape(v) }
|
|
|
|
// deadline is how long one tool call may take: inside a module's 60-second ask.
|
|
const deadline = 50 * time.Second
|