/* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ package main import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" dp "idio.link/go/depager/v2" ) func NewGogsClient( ctx context.Context, accessToken string, ) *GogsClient { t := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, TLSHandshakeTimeout: 15 * time.Second, IdleConnTimeout: 30 * time.Minute, ResponseHeaderTimeout: 180 * time.Second, ExpectContinueTimeout: 10 * time.Second, } c := &http.Client{ Transport: t, Timeout: 30 * time.Minute, } baseURI, _ := url.Parse("https://git.idiolink.net/api/v1/") return &GogsClient{ baseURI: baseURI, ctx: ctx, httpClient: c, retries: 3, retryWait: 10 * time.Second, token: accessToken, } } type GogsClient struct { ctx context.Context httpClient *http.Client baseURI *url.URL blockReqsBefore time.Time retries int retryWait time.Duration token string } func (c *GogsClient) expandResource( resource *url.URL, ) *url.URL { ex, err := url.JoinPath(c.baseURI.String(), resource.EscapedPath()) if err != nil { panic(fmt.Sprintf("BUG: gogs client: expand resource: failed to join path: '%s' + '%s': %v", c.baseURI, resource.RequestURI(), err)) } next, err := url.Parse(ex) if err != nil { panic(fmt.Sprintf("BUG: gogs client: expand resource: failed to parse expanded resource '%s': %v", ex, err)) } return next } func (c *GogsClient) sendRequestWithRetry( req *http.Request, ) (resp *http.Response, err error) { for i := 0; i < c.retries+1; i++ { resp, err = c.httpClient.Do(req) if err != nil { err = fmt.Errorf("send request with retry: %w", err) return } } if resp == nil { err = fmt.Errorf("send request with retry: unknown failure") return } return } func (c *GogsClient) request( method string, resource *url.URL, body io.Reader, ) (respHead http.Header, respBody []byte, err error) { req, err := http.NewRequestWithContext( c.ctx, method, resource.String(), body, ) if err != nil { err = fmt.Errorf("request %v: %w", resource, err) return } req.Header. Add("Authorization", fmt.Sprintf("token %s", c.token)) resp, err := c.sendRequestWithRetry(req) if err != nil { err = fmt.Errorf("request '%s': %+v: %w", req.URL, req, err) return } defer resp.Body.Close() respHead = resp.Header respBody, err = io.ReadAll(resp.Body) if err != nil { err = fmt.Errorf("request %v: failed to read response body: %w", resource, err) return } // Success response if 200 <= resp.StatusCode && resp.StatusCode <= 299 { return } err = fmt.Errorf("request %v: %s", resource, http.StatusText(resp.StatusCode)) return } func (c *GogsClient) get( resource *url.URL, ) (http.Header, []byte, error) { return c.request(http.MethodGet, resource, nil) } func (c *GogsClient) post( resource *url.URL, body io.Reader, ) (http.Header, []byte, error) { return c.request(http.MethodPost, resource, body) } func newGogsSubclient[T any]( c *GogsClient, resource *url.URL, ) *GogsSubclient[T] { expanded := c.expandResource(resource) return &GogsSubclient[T]{ GogsClient: *c, uri: expanded, } } type GogsSubclient[T any] struct { GogsClient uri *url.URL } func (c *GogsSubclient[T]) NextPage( offset uint64, ) (page dp.Page[T], _ uint64, err error) { aggr := make([]T, 0, 32) head, body, err := c.get(c.uri) if err != nil { err = fmt.Errorf("gogs client: next page: %w", err) return } err = json.Unmarshal(body, &aggr) if err != nil { err = fmt.Errorf("gogs client: next page: unmarshal response: %w", err) return } if next := getLinkNext(head.Get("link")); next != "" { c.uri, err = url.Parse(next) if err != nil { err = fmt.Errorf("gogs client: next page: unable to parse next link '%s': %w", next, err) return } } page = GogsAggregate[T](aggr) return } func getLinkNext(link string) (next string) { // this could be made faster, but doesn't seem necessary // See https://www.w3.org/wiki/LinkHeader for details. // basic format: `; rel=meta, ...` before, _, found := strings.Cut(link, `rel="next"`) if !found { return } idx := strings.LastIndex(before, "<") if idx == -1 { return } r := strings.NewReader(before) _, err := r.ReadAt([]byte(next), int64(idx+1)) if err != nil { return } parts := strings.Split(next, ">") if len(parts) != 2 { return } next = parts[0] return } type GogsAggregate[T any] []T func (a GogsAggregate[T]) Elems() []T { return a } type GogsRepo struct { Id int `json:"id"` Owner struct { Id int `json:"id"` Username string `json:"username"` FullName string `json:"full_name"` EMail string `json:"email"` AvatarURL string `json:"avatar_url"` } `json:"owner"` FullName string `json:"full_name"` CloneURL string `json:"clone_url"` HTMLURL string `json:"html_url"` SSHURL string `json:"ssh_url"` DefaultBranch string `json:"default_branch"` Private string `json:"private"` Fork string `json:"fork"` Permissions struct { Admin bool `json:"admin"` Push bool `json:"push"` Pull bool `json:"pull"` } `json:"permissions"` } func FetchGogsOrganizationRepos[T *GogsRepo]( c *GogsClient, group string, ) dp.Pager[T] { resStrFmt := "/orgs/%s/repos" escaped := url.PathEscape(group) resStr := fmt.Sprintf(resStrFmt, escaped) resource, err := url.Parse(resStr) if err != nil { // should only occur in case of bugs panic(err) } return dp.NewPager[T]( newGogsSubclient[T](c, resource), 100, ) } type MigrateToGogsRepoInput struct { SrcURI string `query:"clone_addr"` // Required Remote Git address (HTTP/HTTPS URL or local path) SrcUsername string `query:"auth_username"` // Authorization username [for remote] SrcPassword string `query:"auth_password"` // Authorization password [for remote] TgtUID int `query:"uid"` // Required User ID who takes ownership of this repository TgtRepoName string `query:"repo_name"` // Required Repository name TgtRepoMirror bool `query:"mirror"` // Repository will be a mirror. Default is false TgtRepoPrivate bool `query:"private"` // Repository will be private. Default is false TgtRepoDescr string `query:"description"` // Repository description } func MigrateToGogsRepo( c *GogsClient, input *MigrateToGogsRepoInput, repo *GogsRepo, ) (err error) { errBase := "migrate to gogs repo" resource, err := url.Parse("/repos/migrate") if err != nil { // should only occur in case of bugs panic(err) } expanded := c.expandResource(resource) req, err := json.Marshal(input) if err != nil { err = fmt.Errorf("%s: marshal input '%#v': %w", errBase, string(req), err) return } _, resp, err := c.post(expanded, bytes.NewReader(req)) if err != nil { err = fmt.Errorf("%s: post request '%s': %w", errBase, string(req), err) return } err = json.Unmarshal(resp, repo) if err != nil { err = fmt.Errorf("%s: parse response '%s': %w", errBase, string(resp), err) return } return } type GogsUser struct { Id int `json:"id"` Username string `json:"username"` FullName string `json:"full_name"` EMail string `json:"email"` AvatarURL string `json:"avatar_url"` } func FetchGogsUserAuthenticated( c *GogsClient, user *GogsUser, ) (err error) { errBase := "fetch gogs user authenticated" resource, err := url.Parse("/user") if err != nil { // should only occur in case of bugs err = fmt.Errorf("%s: %w", errBase, err) panic(err) } expanded := c.expandResource(resource) _, resp, err := c.get(expanded) if err != nil { err = fmt.Errorf("%s: get request '%s': %w", errBase, expanded, err) return } err = json.Unmarshal(resp, user) if err != nil { err = fmt.Errorf("%s: parse response '%s': %w", errBase, string(resp), err) return } return }