// Package githubclient is a minimal GitHub REST API client. The hyperguild // project_create flow is gitea-first; this client exists only to create an // empty repo on GitHub before the gitea→github push-mirror is configured, // since the mirror cannot push to a non-existent remote. package githubclient import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" ) const defaultAPI = "https://api.github.com" type Client struct { api string token string http *http.Client } // New returns a Client with the given personal access token (repo scope). func New(token string) *Client { return &Client{ api: defaultAPI, token: token, http: &http.Client{Timeout: 30 * time.Second}, } } // WithBaseURL overrides the API base (test injection). func (c *Client) WithBaseURL(u string) *Client { c.api = u return c } // Repo is the subset of GitHub's repo response we surface upstream. type Repo struct { FullName string `json:"full_name"` HTMLURL string `json:"html_url"` CloneURL string `json:"clone_url"` Private bool `json:"private"` } type createRepoArgs struct { Name string `json:"name"` Description string `json:"description,omitempty"` Private bool `json:"private"` AutoInit bool `json:"auto_init"` } // ErrAlreadyExists is returned by CreateRepo when GitHub responds 422 with // "name already exists". Callers treat it as idempotent success. var ErrAlreadyExists = fmt.Errorf("github repo already exists") // CreateRepo creates a repo under the authenticated user's account. // auto_init is always false — the push-mirror will populate the repo from // gitea, so an auto-generated README would conflict on first push. func (c *Client) CreateRepo(ctx context.Context, name, description string, private bool) (*Repo, error) { if c.token == "" { return nil, fmt.Errorf("github pat not configured") } body, _ := json.Marshal(createRepoArgs{ Name: name, Description: description, Private: private, AutoInit: false, }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.api+"/user/repos", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("new request: %w", err) } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("Content-Type", "application/json") req.Header.Set("X-GitHub-Api-Version", "2022-11-28") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("http: %w", err) } defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) switch resp.StatusCode { case http.StatusCreated: var r Repo if err := json.Unmarshal(raw, &r); err != nil { return nil, fmt.Errorf("decode response: %w", err) } return &r, nil case http.StatusUnprocessableEntity: // 422 covers "name already exists" + a handful of other validation // errors. Treat any 422 that mentions "already exists" as idempotent // success; everything else surfaces verbatim. if bytes.Contains(raw, []byte("already exists")) { return nil, ErrAlreadyExists } return nil, fmt.Errorf("github 422: %s", string(raw)) case http.StatusUnauthorized, http.StatusForbidden: return nil, fmt.Errorf("github auth %d: PAT missing repo scope or invalid", resp.StatusCode) default: return nil, fmt.Errorf("github %d: %s", resp.StatusCode, string(raw)) } }