gitea: let an agent put a commit on a branch of its own, and read history, compares, reviews, directories and code (issue 375)
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
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.
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
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
|
||||
Reference in New Issue
Block a user