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