mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
The GitHub add-on refused classic tokens in the page only, and its repository list took exact owner/name entries. A classic token (ghp_) is now an explicit choice of the connection. The kind declares a token guard: the host refuses a token with that prefix unless the connection sets the option classicToken to allow, and the request that turns it on must carry confirm_wider_token. It is checked when the token is saved and again each time it is used, so switching the option off stops a stored token. The list and the write mode still apply. The page shows a warning and two ticks. The list takes owner/* and *. The host matches them before the add-on runs, without regard to case and on whole names, so other-owner/x and owner-evil/x are not covered by acme/*, and a key that holds a star is never a name. A kind opts in with wildcardResources; only GitHub does, so the other add-ons keep exact matching. Saving * needs confirm_any_resource, and the write mode stays separate. A read tool that declares enumerates (the new list-repos) runs only for a list of * and asks GitHub which repositories the token reaches. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
725 lines
20 KiB
Go
725 lines
20 KiB
Go
// SPDX-License-Identifier: MIT
|
|
|
|
// Package githubtest is a fake of the part of the GitHub REST API that the
|
|
// GitHub add-on reads, like slacktest for Slack. It checks the token of each
|
|
// call against the tokens it knows, can answer with rate limit headers, and
|
|
// keeps the calls so a test can see what was asked and with which token.
|
|
package githubtest
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Server is a fake API.
|
|
type Server struct {
|
|
*httptest.Server
|
|
|
|
mu sync.Mutex
|
|
tokens map[string]string // token -> login
|
|
repos map[string]*Repo // lower-case full name
|
|
calls []Call
|
|
limit *limit
|
|
failing map[string]int // path prefix -> status
|
|
// dropWrites makes the fake apply a change and then cut the connection
|
|
// before it answers, as a network failure does.
|
|
dropWrites bool
|
|
}
|
|
|
|
// Call is one request the fake served.
|
|
type Call struct {
|
|
Path string
|
|
Query string
|
|
Token string
|
|
// Accept is the media type the caller asked for.
|
|
Accept string
|
|
// Method is the HTTP method and Body the request body of a change.
|
|
Method string
|
|
Body string
|
|
}
|
|
|
|
type limit struct {
|
|
status int
|
|
headers map[string]string
|
|
body string
|
|
times int // 0: always
|
|
}
|
|
|
|
// Repo is a repository of the fake.
|
|
type Repo struct {
|
|
FullName string
|
|
DefaultBranch string
|
|
Private bool
|
|
Files map[string][]byte // path -> content
|
|
Issues []*Issue
|
|
PRs []*PR
|
|
// Hits answers code search: query text -> paths.
|
|
Hits map[string][]string
|
|
}
|
|
|
|
// Issue is an issue of the fake. A pull request is also one.
|
|
type Issue struct {
|
|
Number int
|
|
Title string
|
|
Body string
|
|
State string
|
|
User string
|
|
Labels []string
|
|
Comments []string
|
|
IsPR bool
|
|
Locked bool
|
|
// Reason is the state_reason set when the issue was closed.
|
|
Reason string
|
|
}
|
|
|
|
// PR is a pull request of the fake.
|
|
type PR struct {
|
|
Number int
|
|
Title string
|
|
Body string
|
|
State string
|
|
User string
|
|
Base string
|
|
Head string
|
|
Files []PRFile
|
|
Reviews []Review
|
|
// HeadSHA is the commit of the head; empty means "abc123".
|
|
HeadSHA string
|
|
// Inline are the review comments posted on the diff.
|
|
Inline []InlineComment
|
|
}
|
|
|
|
// InlineComment is a comment on a line of a pull request.
|
|
type InlineComment struct {
|
|
Body, Path, Side, CommitID string
|
|
Line int
|
|
}
|
|
|
|
// PRFile is a changed file.
|
|
type PRFile struct {
|
|
Name string
|
|
Status string
|
|
Additions int
|
|
Deletions int
|
|
Patch string
|
|
}
|
|
|
|
// Review is a review of a pull request.
|
|
type Review struct {
|
|
User string
|
|
State string
|
|
Body string
|
|
}
|
|
|
|
// New starts a fake API.
|
|
func New() *Server {
|
|
s := &Server{tokens: map[string]string{}, repos: map[string]*Repo{}, failing: map[string]int{}}
|
|
s.Server = httptest.NewServer(http.HandlerFunc(s.serve))
|
|
return s
|
|
}
|
|
|
|
// DropWrites makes the fake apply each change and then cut the connection
|
|
// without an answer.
|
|
func (s *Server) DropWrites(on bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.dropWrites = on
|
|
}
|
|
|
|
// AddToken makes a token valid for a login.
|
|
func (s *Server) AddToken(token, login string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.tokens[token] = login
|
|
}
|
|
|
|
// AddRepo adds a repository and returns it for the test to fill.
|
|
func (s *Server) AddRepo(fullName string) *Repo {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
r := &Repo{FullName: fullName, DefaultBranch: "main", Files: map[string][]byte{}, Hits: map[string][]string{}}
|
|
s.repos[strings.ToLower(fullName)] = r
|
|
return r
|
|
}
|
|
|
|
// Calls returns the requests served, oldest first.
|
|
func (s *Server) Calls() []Call {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]Call(nil), s.calls...)
|
|
}
|
|
|
|
// Reset forgets the calls.
|
|
func (s *Server) Reset() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.calls = nil
|
|
}
|
|
|
|
// LimitPrimary makes every call answer 403 with the primary rate limit
|
|
// headers, resetting at reset.
|
|
func (s *Server) LimitPrimary(reset time.Time) {
|
|
s.setLimit(&limit{status: 403, headers: map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": strconv.FormatInt(reset.Unix(), 10)}, body: `{"message":"API rate limit exceeded"}`})
|
|
}
|
|
|
|
// LimitSecondary makes every call answer 403 with a retry-after wait, or with
|
|
// the secondary limit message only when wait is zero.
|
|
func (s *Server) LimitSecondary(wait int) {
|
|
l := &limit{status: 403, headers: map[string]string{}, body: `{"message":"You have exceeded a secondary rate limit."}`}
|
|
if wait > 0 {
|
|
l.headers["Retry-After"] = strconv.Itoa(wait)
|
|
}
|
|
s.setLimit(l)
|
|
}
|
|
|
|
// Limit429 makes every call answer 429.
|
|
func (s *Server) Limit429() {
|
|
s.setLimit(&limit{status: 429, headers: map[string]string{}, body: `{"message":"slow down"}`})
|
|
}
|
|
|
|
// Unlimit removes a rate limit.
|
|
func (s *Server) Unlimit() { s.setLimit(nil) }
|
|
|
|
func (s *Server) setLimit(l *limit) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.limit = l
|
|
}
|
|
|
|
// Fail makes the paths that start with prefix answer with status.
|
|
func (s *Server) Fail(prefix string, status int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.failing[prefix] = status
|
|
}
|
|
|
|
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
|
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
var reqBody []byte
|
|
if r.Body != nil {
|
|
reqBody, _ = io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
r.Body = io.NopCloser(bytes.NewReader(reqBody))
|
|
}
|
|
s.mu.Lock()
|
|
s.calls = append(s.calls, Call{Path: r.URL.Path, Query: r.URL.RawQuery, Token: token, Accept: r.Header.Get("Accept"), Method: r.Method, Body: string(reqBody)})
|
|
login, ok := s.tokens[token]
|
|
lim := s.limit
|
|
var fail int
|
|
for prefix, status := range s.failing {
|
|
if strings.HasPrefix(r.URL.Path, prefix) {
|
|
fail = status
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
if !ok {
|
|
jsonError(w, http.StatusUnauthorized, "Bad credentials")
|
|
return
|
|
}
|
|
if lim != nil {
|
|
for k, v := range lim.headers {
|
|
w.Header().Set(k, v)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(lim.status)
|
|
_, _ = w.Write([]byte(lim.body))
|
|
return
|
|
}
|
|
if fail != 0 {
|
|
jsonError(w, fail, "failed")
|
|
return
|
|
}
|
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
|
switch {
|
|
case path == "user":
|
|
writeJSON(w, map[string]any{"login": login, "id": 4242, "type": "User"})
|
|
case path == "user/repos":
|
|
s.userRepos(w, r)
|
|
case path == "search/code":
|
|
s.searchCode(w, r)
|
|
case strings.HasPrefix(path, "repos/"):
|
|
s.repoRoute(w, r, strings.TrimPrefix(path, "repos/"))
|
|
default:
|
|
jsonError(w, http.StatusNotFound, "Not Found")
|
|
}
|
|
}
|
|
|
|
func (s *Server) repoRoute(w http.ResponseWriter, r *http.Request, rest string) {
|
|
parts := strings.SplitN(rest, "/", 3)
|
|
if len(parts) < 2 {
|
|
jsonError(w, http.StatusNotFound, "Not Found")
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
repo := s.repos[strings.ToLower(parts[0]+"/"+parts[1])]
|
|
s.mu.Unlock()
|
|
if repo == nil {
|
|
jsonError(w, http.StatusNotFound, "Not Found")
|
|
return
|
|
}
|
|
if len(parts) == 2 {
|
|
writeJSON(w, map[string]any{"full_name": repo.FullName, "default_branch": repo.DefaultBranch, "private": repo.Private, "archived": false,
|
|
"permissions": map[string]bool{"pull": true, "push": false}})
|
|
return
|
|
}
|
|
sub := parts[2]
|
|
switch {
|
|
case strings.HasPrefix(sub, "contents"):
|
|
s.contents(w, r, repo, strings.TrimPrefix(strings.TrimPrefix(sub, "contents"), "/"))
|
|
case strings.HasPrefix(sub, "git/trees/"):
|
|
s.tree(w, r, repo)
|
|
case sub == "issues" && r.Method == http.MethodPost:
|
|
s.createIssue(w, r, repo)
|
|
case sub == "issues":
|
|
s.listIssues(w, r, repo)
|
|
case strings.HasPrefix(sub, "issues/"):
|
|
s.issue(w, r, repo, strings.TrimPrefix(sub, "issues/"))
|
|
case strings.HasPrefix(sub, "pulls/"):
|
|
s.pull(w, r, repo, strings.TrimPrefix(sub, "pulls/"))
|
|
default:
|
|
jsonError(w, http.StatusNotFound, "Not Found")
|
|
}
|
|
}
|
|
|
|
func (s *Server) contents(w http.ResponseWriter, r *http.Request, repo *Repo, path string) {
|
|
if body, ok := repo.Files[path]; ok {
|
|
if strings.Contains(r.Header.Get("Accept"), "raw") {
|
|
w.Header().Set("Content-Type", "application/vnd.github.raw+json")
|
|
_, _ = w.Write(body)
|
|
return
|
|
}
|
|
out := map[string]any{"type": "file", "name": path[strings.LastIndex(path, "/")+1:], "path": path, "sha": "sha-" + path, "size": len(body)}
|
|
if len(body) <= 1<<20 {
|
|
out["encoding"], out["content"] = "base64", base64.StdEncoding.EncodeToString(body)
|
|
} else {
|
|
out["encoding"], out["content"] = "none", ""
|
|
}
|
|
writeJSON(w, out)
|
|
return
|
|
}
|
|
prefix := path
|
|
if prefix != "" {
|
|
prefix += "/"
|
|
}
|
|
seen := map[string]map[string]any{}
|
|
for name, body := range repo.Files {
|
|
if !strings.HasPrefix(name, prefix) {
|
|
continue
|
|
}
|
|
rest := strings.TrimPrefix(name, prefix)
|
|
if i := strings.Index(rest, "/"); i >= 0 {
|
|
seen[rest[:i]] = map[string]any{"type": "dir", "name": rest[:i], "path": prefix + rest[:i], "size": 0}
|
|
} else {
|
|
seen[rest] = map[string]any{"type": "file", "name": rest, "path": name, "size": len(body)}
|
|
}
|
|
}
|
|
if len(seen) == 0 {
|
|
jsonError(w, http.StatusNotFound, "Not Found")
|
|
return
|
|
}
|
|
names := make([]string, 0, len(seen))
|
|
for n := range seen {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
list := make([]map[string]any, 0, len(names))
|
|
for _, n := range names {
|
|
list = append(list, seen[n])
|
|
}
|
|
writeJSON(w, list)
|
|
}
|
|
|
|
func (s *Server) tree(w http.ResponseWriter, _ *http.Request, repo *Repo) {
|
|
var names []string
|
|
for n := range repo.Files {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
var list []map[string]any
|
|
for _, n := range names {
|
|
list = append(list, map[string]any{"path": n, "type": "blob", "size": len(repo.Files[n])})
|
|
}
|
|
writeJSON(w, map[string]any{"sha": "tree", "tree": list, "truncated": false})
|
|
}
|
|
|
|
func (s *Server) searchCode(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query().Get("q")
|
|
var repoName, text string
|
|
for _, f := range strings.Fields(q) {
|
|
if v, ok := strings.CutPrefix(f, "repo:"); ok {
|
|
repoName = v
|
|
} else {
|
|
text += f + " "
|
|
}
|
|
}
|
|
text = strings.TrimSpace(text)
|
|
s.mu.Lock()
|
|
repo := s.repos[strings.ToLower(repoName)]
|
|
s.mu.Unlock()
|
|
if repo == nil {
|
|
jsonError(w, http.StatusUnprocessableEntity, "Validation Failed")
|
|
return
|
|
}
|
|
var items []map[string]any
|
|
for _, p := range repo.Hits[text] {
|
|
items = append(items, map[string]any{"path": p, "name": p[strings.LastIndex(p, "/")+1:], "sha": "sha-" + p, "html_url": "https://github.com/" + repo.FullName + "/blob/main/" + p,
|
|
"text_matches": []map[string]any{{"fragment": "…" + text + "…"}}})
|
|
}
|
|
page, per := pageOf(r)
|
|
lo, hi := (page-1)*per, page*per
|
|
total := len(items)
|
|
if lo > total {
|
|
lo = total
|
|
}
|
|
if hi > total {
|
|
hi = total
|
|
}
|
|
if hi < total {
|
|
w.Header().Set("Link", `<https://api.github.com/search/code?page=`+strconv.Itoa(page+1)+`>; rel="next"`)
|
|
}
|
|
writeJSON(w, map[string]any{"total_count": total, "incomplete_results": false, "items": items[lo:hi]})
|
|
}
|
|
|
|
func (s *Server) listIssues(w http.ResponseWriter, r *http.Request, repo *Repo) {
|
|
state := r.URL.Query().Get("state")
|
|
var list []map[string]any
|
|
for _, i := range repo.Issues {
|
|
if state != "" && state != "all" && i.State != state {
|
|
continue
|
|
}
|
|
e := issueJSON(i, false)
|
|
e["body"] = i.Body
|
|
list = append(list, e)
|
|
}
|
|
page, per := pageOf(r)
|
|
lo, hi := (page-1)*per, page*per
|
|
if lo > len(list) {
|
|
lo = len(list)
|
|
}
|
|
if hi > len(list) {
|
|
hi = len(list)
|
|
}
|
|
if hi < len(list) {
|
|
w.Header().Set("Link", `<https://api.github.com/x?page=`+strconv.Itoa(page+1)+`>; rel="next"`)
|
|
}
|
|
writeJSON(w, list[lo:hi])
|
|
}
|
|
|
|
func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo *Repo, rest string) {
|
|
num, tail, _ := strings.Cut(rest, "/")
|
|
n, _ := strconv.Atoi(num)
|
|
for _, i := range repo.Issues {
|
|
if i.Number != n {
|
|
continue
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
s.changeIssue(w, r, repo, i, tail)
|
|
return
|
|
}
|
|
if tail == "comments" {
|
|
var list []map[string]any
|
|
for k, c := range i.Comments {
|
|
list = append(list, map[string]any{"id": 1000 + k, "user": map[string]any{"login": "commenter"}, "body": c, "created_at": "2026-01-01T00:00:00Z"})
|
|
}
|
|
page, per := pageOf(r)
|
|
lo, hi := (page-1)*per, page*per
|
|
if lo > len(list) {
|
|
lo = len(list)
|
|
}
|
|
if hi > len(list) {
|
|
hi = len(list)
|
|
}
|
|
if hi < len(list) {
|
|
w.Header().Set("Link", `<https://api.github.com/x?page=`+strconv.Itoa(page+1)+`>; rel="next"`)
|
|
}
|
|
writeJSON(w, list[lo:hi])
|
|
return
|
|
}
|
|
writeJSON(w, issueJSON(i, true))
|
|
return
|
|
}
|
|
jsonError(w, http.StatusNotFound, "Not Found")
|
|
}
|
|
|
|
func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo *Repo, rest string) {
|
|
num, tail, _ := strings.Cut(rest, "/")
|
|
n, _ := strconv.Atoi(num)
|
|
for _, p := range repo.PRs {
|
|
if p.Number != n {
|
|
continue
|
|
}
|
|
if r.Method == http.MethodPost && tail == "comments" {
|
|
s.inlineComment(w, r, repo, p)
|
|
return
|
|
}
|
|
switch tail {
|
|
case "":
|
|
add, del := 0, 0
|
|
for _, f := range p.Files {
|
|
add += f.Additions
|
|
del += f.Deletions
|
|
}
|
|
writeJSON(w, map[string]any{"number": p.Number, "title": p.Title, "body": p.Body, "state": p.State, "draft": false, "merged": false,
|
|
"user": map[string]any{"login": p.User}, "base": map[string]any{"ref": p.Base}, "head": map[string]any{"ref": p.Head, "sha": headSHA(p)},
|
|
"additions": add, "deletions": del, "changed_files": len(p.Files), "commits": 1, "comments": 0, "review_comments": 0,
|
|
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z", "html_url": "https://github.com/" + repo.FullName + "/pull/" + num})
|
|
case "files":
|
|
var list []map[string]any
|
|
for _, f := range p.Files {
|
|
e := map[string]any{"filename": f.Name, "status": f.Status, "additions": f.Additions, "deletions": f.Deletions, "changes": f.Additions + f.Deletions}
|
|
if f.Patch != "" {
|
|
e["patch"] = f.Patch
|
|
}
|
|
list = append(list, e)
|
|
}
|
|
page, per := pageOf(r)
|
|
lo, hi := (page-1)*per, page*per
|
|
if lo > len(list) {
|
|
lo = len(list)
|
|
}
|
|
if hi > len(list) {
|
|
hi = len(list)
|
|
}
|
|
if hi < len(list) {
|
|
w.Header().Set("Link", `<https://api.github.com/x?page=`+strconv.Itoa(page+1)+`>; rel="next"`)
|
|
}
|
|
writeJSON(w, list[lo:hi])
|
|
case "reviews":
|
|
var list []map[string]any
|
|
for k, v := range p.Reviews {
|
|
list = append(list, map[string]any{"id": 2000 + k, "user": map[string]any{"login": v.User}, "state": v.State, "body": v.Body})
|
|
}
|
|
writeJSON(w, list)
|
|
default:
|
|
jsonError(w, http.StatusNotFound, "Not Found")
|
|
}
|
|
return
|
|
}
|
|
jsonError(w, http.StatusNotFound, "Not Found")
|
|
}
|
|
|
|
func issueJSON(i *Issue, full bool) map[string]any {
|
|
labels := make([]map[string]any, 0, len(i.Labels))
|
|
for _, l := range i.Labels {
|
|
labels = append(labels, map[string]any{"name": l})
|
|
}
|
|
out := map[string]any{"number": i.Number, "title": i.Title, "state": i.State, "user": map[string]any{"login": i.User}, "labels": labels,
|
|
"comments": len(i.Comments), "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z", "html_url": "https://github.com/x/y/issues/" + strconv.Itoa(i.Number)}
|
|
if i.IsPR {
|
|
out["pull_request"] = map[string]any{"url": "x"}
|
|
}
|
|
out["locked"] = i.Locked
|
|
if full {
|
|
out["body"] = i.Body
|
|
}
|
|
return out
|
|
}
|
|
|
|
func headSHA(p *PR) string {
|
|
if p.HeadSHA != "" {
|
|
return p.HeadSHA
|
|
}
|
|
return "abc123"
|
|
}
|
|
|
|
func (s *Server) dropIfAsked(w http.ResponseWriter) bool {
|
|
s.mu.Lock()
|
|
drop := s.dropWrites
|
|
s.mu.Unlock()
|
|
if !drop {
|
|
return false
|
|
}
|
|
if h, ok := w.(http.Hijacker); ok {
|
|
if conn, _, err := h.Hijack(); err == nil {
|
|
_ = conn.Close()
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func readBody(r *http.Request) map[string]any {
|
|
var in map[string]any
|
|
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&in)
|
|
return in
|
|
}
|
|
|
|
func str(in map[string]any, k string) string { v, _ := in[k].(string); return v }
|
|
|
|
func (s *Server) createIssue(w http.ResponseWriter, r *http.Request, repo *Repo) {
|
|
in := readBody(r)
|
|
if str(in, "title") == "" {
|
|
jsonError(w, http.StatusUnprocessableEntity, "Validation Failed")
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
n := 1
|
|
for _, i := range repo.Issues {
|
|
n = max(n, i.Number+1)
|
|
}
|
|
i := &Issue{Number: n, Title: str(in, "title"), Body: str(in, "body"), State: "open", User: "octo"}
|
|
if ls, ok := in["labels"].([]any); ok {
|
|
for _, l := range ls {
|
|
if name, ok := l.(string); ok {
|
|
i.Labels = append(i.Labels, name)
|
|
}
|
|
}
|
|
}
|
|
repo.Issues = append(repo.Issues, i)
|
|
s.mu.Unlock()
|
|
if s.dropIfAsked(w) {
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
writeJSON(w, issueJSON(i, true))
|
|
}
|
|
|
|
func (s *Server) changeIssue(w http.ResponseWriter, r *http.Request, repo *Repo, i *Issue, tail string) {
|
|
in := readBody(r)
|
|
s.mu.Lock()
|
|
switch {
|
|
case tail == "comments" && r.Method == http.MethodPost:
|
|
if i.Locked {
|
|
s.mu.Unlock()
|
|
jsonError(w, http.StatusForbidden, "Issue is locked")
|
|
return
|
|
}
|
|
i.Comments = append(i.Comments, str(in, "body"))
|
|
id := 1000 + len(i.Comments) - 1
|
|
s.mu.Unlock()
|
|
if s.dropIfAsked(w) {
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
writeJSON(w, map[string]any{"id": id, "body": str(in, "body"), "html_url": "https://github.com/" + repo.FullName + "/issues/" + strconv.Itoa(i.Number) + "#issuecomment-" + strconv.Itoa(id)})
|
|
return
|
|
case tail == "labels" && r.Method == http.MethodPost:
|
|
if ls, ok := in["labels"].([]any); ok {
|
|
for _, l := range ls {
|
|
name, _ := l.(string)
|
|
have := false
|
|
for _, x := range i.Labels {
|
|
have = have || strings.EqualFold(x, name)
|
|
}
|
|
if name != "" && !have {
|
|
i.Labels = append(i.Labels, name)
|
|
}
|
|
}
|
|
}
|
|
out := issueLabels(i)
|
|
s.mu.Unlock()
|
|
if s.dropIfAsked(w) {
|
|
return
|
|
}
|
|
writeJSON(w, out)
|
|
return
|
|
case tail == "" && r.Method == http.MethodPatch:
|
|
if v := str(in, "title"); v != "" {
|
|
i.Title = v
|
|
}
|
|
if v := str(in, "state"); v != "" {
|
|
i.State = v
|
|
i.Reason = str(in, "state_reason")
|
|
}
|
|
out := issueJSON(i, true)
|
|
s.mu.Unlock()
|
|
if s.dropIfAsked(w) {
|
|
return
|
|
}
|
|
writeJSON(w, out)
|
|
return
|
|
}
|
|
s.mu.Unlock()
|
|
jsonError(w, http.StatusNotFound, "Not Found")
|
|
}
|
|
|
|
func issueLabels(i *Issue) []map[string]any {
|
|
out := make([]map[string]any, 0, len(i.Labels))
|
|
for _, l := range i.Labels {
|
|
out = append(out, map[string]any{"name": l})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) inlineComment(w http.ResponseWriter, r *http.Request, repo *Repo, p *PR) {
|
|
in := readBody(r)
|
|
line, _ := in["line"].(float64)
|
|
if str(in, "path") == "" || line < 1 || str(in, "commit_id") == "" {
|
|
jsonError(w, http.StatusUnprocessableEntity, "Validation Failed")
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
p.Inline = append(p.Inline, InlineComment{Body: str(in, "body"), Path: str(in, "path"), Side: str(in, "side"), CommitID: str(in, "commit_id"), Line: int(line)})
|
|
id := 3000 + len(p.Inline)
|
|
s.mu.Unlock()
|
|
if s.dropIfAsked(w) {
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
writeJSON(w, map[string]any{"id": id, "body": str(in, "body"), "html_url": "https://github.com/" + repo.FullName + "/pull/" + strconv.Itoa(p.Number) + "#discussion_r" + strconv.Itoa(id)})
|
|
}
|
|
|
|
func pageOf(r *http.Request) (page, per int) {
|
|
page, _ = strconv.Atoi(r.URL.Query().Get("page"))
|
|
per, _ = strconv.Atoi(r.URL.Query().Get("per_page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if per < 1 || per > 100 {
|
|
per = 30
|
|
}
|
|
return
|
|
}
|
|
|
|
func jsonError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"message": msg})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// userRepos answers GET /user/repos: every repository of the fake, sorted by
|
|
// name, one page at a time. The fake knows no ownership, so all are visible.
|
|
func (s *Server) userRepos(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
var names []string
|
|
for _, repo := range s.repos {
|
|
names = append(names, repo.FullName)
|
|
}
|
|
s.mu.Unlock()
|
|
sort.Strings(names)
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
per, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if per < 1 {
|
|
per = 30
|
|
}
|
|
start := min((page-1)*per, len(names))
|
|
end := min(start+per, len(names))
|
|
if end < len(names) {
|
|
w.Header().Set("Link", `<https://api.github.com/user/repos?page=`+strconv.Itoa(page+1)+`>; rel="next"`)
|
|
}
|
|
out := []map[string]any{}
|
|
for _, n := range names[start:end] {
|
|
s.mu.Lock()
|
|
repo := s.repos[strings.ToLower(n)]
|
|
s.mu.Unlock()
|
|
out = append(out, map[string]any{"full_name": repo.FullName, "default_branch": repo.DefaultBranch, "private": repo.Private, "archived": false,
|
|
"html_url": "https://github.com/" + repo.FullName, "permissions": map[string]bool{"pull": true, "push": true}})
|
|
}
|
|
writeJSON(w, out)
|
|
}
|