mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
The previous commit was made on a stale base and so reverted work that landed on master before it. This commit puts every file back to the state of its parent's parent. The permission enforcement of the previous commit stays. Assisted-by: Claude:claude-sonnet-5-5 go
449 lines
14 KiB
Go
449 lines
14 KiB
Go
// SPDX-License-Identifier: MIT
|
|
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/mudler/LocalAI/addons/github/internal/ghapi"
|
|
)
|
|
|
|
type person struct {
|
|
Login string `json:"login"`
|
|
}
|
|
|
|
type labelJSON struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func names(ls []labelJSON) []string {
|
|
out := make([]string, 0, len(ls))
|
|
for _, l := range ls {
|
|
out = append(out, l.Name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type listIssuesArgs struct {
|
|
Repo string `json:"repo"`
|
|
State string `json:"state"`
|
|
Kind string `json:"kind"`
|
|
Labels string `json:"labels"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"perPage"`
|
|
}
|
|
|
|
func (c call) listIssues(raw map[string]json.RawMessage) (any, error) {
|
|
var a listIssuesArgs
|
|
if err := decodeArgs(raw, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := c.repo(a.Repo); err != nil {
|
|
return nil, err
|
|
}
|
|
switch a.State {
|
|
case "":
|
|
a.State = "open"
|
|
case "open", "closed", "all":
|
|
default:
|
|
return nil, bad("state is open closed or all")
|
|
}
|
|
switch a.Kind {
|
|
case "":
|
|
a.Kind = "issue"
|
|
case "issue", "pr", "all":
|
|
default:
|
|
return nil, bad("kind is issue pr or all")
|
|
}
|
|
if len(a.Labels) > 200 || strings.ContainsFunc(a.Labels, unicode.IsControl) {
|
|
return nil, bad("labels is not valid")
|
|
}
|
|
page, per := pageOr(a.Page, 1), perPageOr(a.PerPage, 30)
|
|
q := url.Values{"state": {a.State}, "page": {strconv.Itoa(page)}, "per_page": {strconv.Itoa(per)}}
|
|
if a.Labels != "" {
|
|
q.Set("labels", a.Labels)
|
|
}
|
|
var list []struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
State string `json:"state"`
|
|
User person `json:"user"`
|
|
Labels []labelJSON `json:"labels"`
|
|
Comments int `json:"comments"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
HTMLURL string `json:"html_url"`
|
|
PullRequest *struct{} `json:"pull_request"`
|
|
}
|
|
resp, err := c.r.API.GetJSON(c.ctx, c.token, "/repos/"+a.Repo+"/issues", q, &list)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
type item struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
State string `json:"state"`
|
|
Author string `json:"author"`
|
|
Labels []string `json:"labels"`
|
|
Comments int `json:"comments"`
|
|
Created string `json:"created"`
|
|
Updated string `json:"updated"`
|
|
URL string `json:"url"`
|
|
IsPR bool `json:"isPullRequest"`
|
|
TitleCut bool `json:"titleCut,omitempty"`
|
|
}
|
|
items := make([]item, 0, len(list))
|
|
for _, i := range list {
|
|
isPR := i.PullRequest != nil
|
|
if (a.Kind == "issue" && isPR) || (a.Kind == "pr" && !isPR) {
|
|
continue
|
|
}
|
|
title, omitted := cut(i.Title, 300)
|
|
items = append(items, item{Number: i.Number, Title: title, TitleCut: omitted > 0, State: i.State, Author: i.User.Login, Labels: names(i.Labels), Comments: i.Comments,
|
|
Created: i.CreatedAt, Updated: i.UpdatedAt, URL: i.HTMLURL, IsPR: isPR})
|
|
}
|
|
res := map[string]any{"repo": a.Repo, "state": a.State, "kind": a.Kind, "page": page, "items": items}
|
|
if resp.NextPage() {
|
|
res["next"] = page + 1
|
|
res["note"] = fmt.Sprintf("more on page=%d; this page lists the items of GitHub's page, so a page can hold fewer than perPage", page+1)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
type getIssueArgs struct {
|
|
Repo string `json:"repo"`
|
|
Number int `json:"number"`
|
|
CommentsPage int `json:"commentsPage"`
|
|
}
|
|
|
|
// commentsPerPage is how many comments one result asks GitHub for.
|
|
const commentsPerPage = 20
|
|
|
|
type commentOut struct {
|
|
Author string `json:"author"`
|
|
Created string `json:"created"`
|
|
Body string `json:"body"`
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
Omitted int `json:"omittedBytes,omitempty"`
|
|
}
|
|
|
|
// budget hands out the bytes of user text a result may carry.
|
|
type budget struct{ left int }
|
|
|
|
// text cuts s to max bytes and to what is left of the budget.
|
|
func (b *budget) text(s string, max int) (out string, omitted int) {
|
|
out, omitted = cut(s, min(max, max0(b.left)))
|
|
b.left -= len(out)
|
|
return out, omitted
|
|
}
|
|
|
|
func max0(n int) int { return max(n, 0) }
|
|
|
|
func (c call) comments(ctx context.Context, repo string, number, page int, b *budget) (outs []commentOut, next bool, err error) {
|
|
var list []struct {
|
|
User person `json:"user"`
|
|
Body string `json:"body"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
resp, err := c.r.API.GetJSON(ctx, c.token, "/repos/"+repo+"/issues/"+strconv.Itoa(number)+"/comments",
|
|
url.Values{"page": {strconv.Itoa(page)}, "per_page": {strconv.Itoa(commentsPerPage)}}, &list)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
next = resp.NextPage()
|
|
for i, cm := range list {
|
|
if b.left <= 0 {
|
|
// The rest of the page does not fit: the caller asks again for the
|
|
// same page after the ones shown. Say so instead of dropping them.
|
|
outs = append(outs, commentOut{Author: "", Body: "", Truncated: true, Omitted: len(list) - i})
|
|
break
|
|
}
|
|
body, omitted := b.text(cm.Body, MaxCommentBody)
|
|
outs = append(outs, commentOut{Author: cm.User.Login, Created: cm.CreatedAt, Body: body, Truncated: omitted > 0, Omitted: omitted})
|
|
}
|
|
return outs, next, nil
|
|
}
|
|
|
|
func (c call) getIssue(raw map[string]json.RawMessage) (any, error) {
|
|
var a getIssueArgs
|
|
if err := decodeArgs(raw, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := c.repo(a.Repo); err != nil {
|
|
return nil, err
|
|
}
|
|
if a.Number < 1 {
|
|
return nil, bad("number is at least 1")
|
|
}
|
|
var issue struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
User person `json:"user"`
|
|
Labels []labelJSON `json:"labels"`
|
|
Comments int `json:"comments"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
HTMLURL string `json:"html_url"`
|
|
PullRequest *struct{} `json:"pull_request"`
|
|
}
|
|
if _, err := c.r.API.GetJSON(c.ctx, c.token, "/repos/"+a.Repo+"/issues/"+strconv.Itoa(a.Number), nil, &issue); err != nil {
|
|
return nil, err
|
|
}
|
|
b := &budget{left: MaxTextBudget}
|
|
body, omitted := b.text(issue.Body, MaxIssueBody)
|
|
res := map[string]any{"repo": a.Repo, "number": issue.Number, "title": issue.Title, "state": issue.State, "author": issue.User.Login, "labels": names(issue.Labels),
|
|
"commentCount": issue.Comments, "created": issue.CreatedAt, "updated": issue.UpdatedAt, "url": issue.HTMLURL, "isPullRequest": issue.PullRequest != nil, "body": body}
|
|
if omitted > 0 {
|
|
res["bodyTruncated"], res["bodyOmittedBytes"] = true, omitted
|
|
}
|
|
page := pageOr(a.CommentsPage, 1)
|
|
comments, next, err := c.comments(c.ctx, a.Repo, a.Number, page, b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res["comments"], res["commentsPage"] = comments, page
|
|
if comments == nil {
|
|
res["comments"] = []commentOut{}
|
|
}
|
|
if next {
|
|
res["commentsNext"] = page + 1
|
|
res["note"] = fmt.Sprintf("more comments: ask for commentsPage=%d", page+1)
|
|
}
|
|
if n := len(comments); n > 0 && comments[n-1].Truncated && comments[n-1].Author == "" {
|
|
res["note"] = "this page of comments was cut to keep the result small; the comments marked omitted are not shown"
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
type getPRArgs struct {
|
|
Repo string `json:"repo"`
|
|
Number int `json:"number"`
|
|
}
|
|
|
|
func (c call) getPR(raw map[string]json.RawMessage) (any, error) {
|
|
var a getPRArgs
|
|
if err := decodeArgs(raw, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := c.repo(a.Repo); err != nil {
|
|
return nil, err
|
|
}
|
|
if a.Number < 1 {
|
|
return nil, bad("number is at least 1")
|
|
}
|
|
var pr struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
Draft bool `json:"draft"`
|
|
Merged bool `json:"merged"`
|
|
Mergeable *bool `json:"mergeable"`
|
|
User person `json:"user"`
|
|
Base struct {
|
|
Ref string `json:"ref"`
|
|
} `json:"base"`
|
|
Head struct {
|
|
Ref string `json:"ref"`
|
|
} `json:"head"`
|
|
Additions int `json:"additions"`
|
|
Deletions int `json:"deletions"`
|
|
ChangedFiles int `json:"changed_files"`
|
|
Commits int `json:"commits"`
|
|
Comments int `json:"comments"`
|
|
ReviewComments int `json:"review_comments"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
HTMLURL string `json:"html_url"`
|
|
}
|
|
n := strconv.Itoa(a.Number)
|
|
if _, err := c.r.API.GetJSON(c.ctx, c.token, "/repos/"+a.Repo+"/pulls/"+n, nil, &pr); err != nil {
|
|
return nil, err
|
|
}
|
|
var reviews []struct {
|
|
User person `json:"user"`
|
|
State string `json:"state"`
|
|
Body string `json:"body"`
|
|
}
|
|
if _, err := c.r.API.GetJSON(c.ctx, c.token, "/repos/"+a.Repo+"/pulls/"+n+"/reviews", url.Values{"per_page": {"30"}}, &reviews); err != nil {
|
|
return nil, err
|
|
}
|
|
b := &budget{left: MaxTextBudget}
|
|
body, omitted := b.text(pr.Body, MaxIssueBody)
|
|
type reviewOut struct {
|
|
Author string `json:"author"`
|
|
State string `json:"state"`
|
|
Body string `json:"body,omitempty"`
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
}
|
|
rs := make([]reviewOut, 0, len(reviews))
|
|
for _, r := range reviews {
|
|
text, om := b.text(r.Body, 2<<10)
|
|
rs = append(rs, reviewOut{Author: r.User.Login, State: r.State, Body: text, Truncated: om > 0})
|
|
}
|
|
res := map[string]any{"repo": a.Repo, "number": pr.Number, "title": pr.Title, "state": pr.State, "draft": pr.Draft, "merged": pr.Merged, "author": pr.User.Login,
|
|
"base": pr.Base.Ref, "head": pr.Head.Ref, "additions": pr.Additions, "deletions": pr.Deletions, "changedFiles": pr.ChangedFiles, "commits": pr.Commits,
|
|
"conversationComments": pr.Comments, "reviewComments": pr.ReviewComments, "created": pr.CreatedAt, "updated": pr.UpdatedAt, "url": pr.HTMLURL, "body": body, "reviews": rs,
|
|
"note": "the conversation comments are read with get-issue; the diff with get-pr-diff"}
|
|
if pr.Mergeable != nil {
|
|
res["mergeable"] = *pr.Mergeable
|
|
}
|
|
if omitted > 0 {
|
|
res["bodyTruncated"], res["bodyOmittedBytes"] = true, omitted
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
type diffArgs struct {
|
|
Repo string `json:"repo"`
|
|
Number int `json:"number"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"perPage"`
|
|
File string `json:"file"`
|
|
}
|
|
|
|
// maxDiffPages bounds the pages of changed files read to find one file.
|
|
const maxDiffPages = 30
|
|
|
|
type prFile struct {
|
|
Filename string `json:"filename"`
|
|
Status string `json:"status"`
|
|
Additions int `json:"additions"`
|
|
Deletions int `json:"deletions"`
|
|
Changes int `json:"changes"`
|
|
Patch string `json:"patch"`
|
|
}
|
|
|
|
func (c call) getPRDiff(raw map[string]json.RawMessage) (any, error) {
|
|
var a diffArgs
|
|
if err := decodeArgs(raw, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := c.repo(a.Repo); err != nil {
|
|
return nil, err
|
|
}
|
|
if a.Number < 1 {
|
|
return nil, bad("number is at least 1")
|
|
}
|
|
if a.File != "" {
|
|
if err := checkPath(a.File, false); err != nil {
|
|
return nil, err
|
|
}
|
|
return c.diffOfFile(a)
|
|
}
|
|
page, per := pageOr(a.Page, 1), perPageOr(a.PerPage, MaxEntries)
|
|
files, next, err := c.prFiles(a, page, per)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
type fileOut struct {
|
|
Filename string `json:"filename"`
|
|
Status string `json:"status"`
|
|
Additions int `json:"additions"`
|
|
Deletions int `json:"deletions"`
|
|
HasPatch bool `json:"hasPatch"`
|
|
}
|
|
list := make([]fileOut, 0, len(files))
|
|
for _, f := range files {
|
|
list = append(list, fileOut{Filename: f.Filename, Status: f.Status, Additions: f.Additions, Deletions: f.Deletions, HasPatch: f.Patch != ""})
|
|
}
|
|
res := map[string]any{"repo": a.Repo, "number": a.Number, "page": page, "files": list,
|
|
"note": "this lists the changed files; ask for one file's patch with file=<filename>"}
|
|
if next {
|
|
res["next"] = page + 1
|
|
res["note"] = fmt.Sprintf("more files: ask for page=%d; ask for one file's patch with file=<filename>", page+1)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (c call) prFiles(a diffArgs, page, per int) ([]prFile, bool, error) {
|
|
var files []prFile
|
|
resp, err := c.r.API.GetJSON(c.ctx, c.token, "/repos/"+a.Repo+"/pulls/"+strconv.Itoa(a.Number)+"/files",
|
|
url.Values{"page": {strconv.Itoa(page)}, "per_page": {strconv.Itoa(per)}}, &files)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return files, resp.NextPage(), nil
|
|
}
|
|
|
|
func (c call) diffOfFile(a diffArgs) (any, error) {
|
|
for page := 1; page <= maxDiffPages; page++ {
|
|
files, next, err := c.prFiles(a, page, MaxEntries)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, f := range files {
|
|
if f.Filename != a.File {
|
|
continue
|
|
}
|
|
res := map[string]any{"repo": a.Repo, "number": a.Number, "file": f.Filename, "status": f.Status, "additions": f.Additions, "deletions": f.Deletions}
|
|
if f.Patch == "" {
|
|
res["patch"], res["note"] = "", "no patch from GitHub: the file is binary or the diff is too large"
|
|
return res, nil
|
|
}
|
|
patch, omitted := cut(f.Patch, MaxPatchBytes)
|
|
res["patch"] = patch
|
|
if omitted > 0 {
|
|
res["patchTruncated"] = true
|
|
res["omittedLines"] = strings.Count(f.Patch[len(patch):], "\n")
|
|
res["note"] = fmt.Sprintf("patch cut at %d of %d bytes", len(patch), len(f.Patch))
|
|
}
|
|
return res, nil
|
|
}
|
|
if !next {
|
|
break
|
|
}
|
|
}
|
|
return nil, bad("that file is not in this pull request")
|
|
}
|
|
|
|
// check answers the Test button of the settings page: who the token is and
|
|
// whether it reaches the repository. It returns no part of the token.
|
|
func (c call) check(raw map[string]json.RawMessage) (any, error) {
|
|
var a struct {
|
|
Repo string `json:"repo"`
|
|
}
|
|
if err := decodeArgs(raw, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := c.repo(a.Repo); err != nil {
|
|
return nil, err
|
|
}
|
|
var me person
|
|
if _, err := c.r.API.GetJSON(c.ctx, c.token, "/user", nil, &me); err != nil {
|
|
return nil, err
|
|
}
|
|
var repo struct {
|
|
FullName string `json:"full_name"`
|
|
DefaultBranch string `json:"default_branch"`
|
|
Private bool `json:"private"`
|
|
Archived bool `json:"archived"`
|
|
Permissions struct {
|
|
Pull bool `json:"pull"`
|
|
Push bool `json:"push"`
|
|
} `json:"permissions"`
|
|
}
|
|
res := map[string]any{"login": me.Login, "repo": a.Repo}
|
|
if _, err := c.r.API.GetJSON(c.ctx, c.token, "/repos/"+a.Repo, nil, &repo); err != nil {
|
|
switch {
|
|
case isNotFound(err):
|
|
res["reachable"], res["reason"] = false, "not found or the token cannot see it"
|
|
return res, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
res["reachable"], res["fullName"], res["defaultBranch"], res["private"], res["archived"] = true, repo.FullName, repo.DefaultBranch, repo.Private, repo.Archived
|
|
res["canRead"], res["canWrite"] = repo.Permissions.Pull, repo.Permissions.Push
|
|
return res, nil
|
|
}
|
|
|
|
func isNotFound(err error) bool { return errors.Is(err, ghapi.ErrNotFound) }
|