Files
idea-collector/backend/internal/github/github.go
T
2026-06-20 19:00:11 -05:00

252 lines
7.6 KiB
Go

// Package github talks to GitHub as a GitHub App: it mints an installation token,
// creates issues via REST, and adds them to a Projects v2 board via GraphQL.
package github
import (
"bytes"
"context"
"crypto/rsa"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Result is the outcome of publishing a submission to GitHub.
type Result struct {
IssueNumber int
IssueURL string
IssueNodeID string
}
type Client struct {
appID int64
installationID int64
signKey *rsa.PrivateKey
apiBase string
graphqlURL string
http *http.Client
mu sync.Mutex
token string
tokenExp time.Time
}
// New builds a Client from the GitHub App credentials.
func New(appID, installationID int64, privateKeyPEM []byte, apiBase, graphqlURL string) (*Client, error) {
key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM)
if err != nil {
return nil, fmt.Errorf("parsing app private key: %w", err)
}
if apiBase == "" {
apiBase = "https://api.github.com"
}
if graphqlURL == "" {
graphqlURL = "https://api.github.com/graphql"
}
return &Client{
appID: appID,
installationID: installationID,
signKey: key,
apiBase: apiBase,
graphqlURL: graphqlURL,
http: &http.Client{Timeout: 15 * time.Second},
}, nil
}
// CreateIssueOnBoard creates an issue in owner/repo and adds it to the given
// ProjectV2 (node id). The project step is best-effort-reported via the error.
func (c *Client) CreateIssueOnBoard(ctx context.Context, owner, repo, projectNodeID, title, body string) (Result, error) {
token, err := c.installationToken(ctx)
if err != nil {
return Result{}, err
}
res, err := c.createIssue(ctx, token, owner, repo, title, body)
if err != nil {
return Result{}, err
}
if err := c.addToProject(ctx, token, projectNodeID, res.IssueNodeID); err != nil {
// Issue exists; surface the board failure so the caller can retry that step.
return res, fmt.Errorf("issue #%d created but adding to board failed: %w", res.IssueNumber, err)
}
return res, nil
}
// ResolveProjectNodeID finds the ProjectV2 node id for an org or user project number.
func (c *Client) ResolveProjectNodeID(ctx context.Context, login string, number int) (string, error) {
token, err := c.installationToken(ctx)
if err != nil {
return "", err
}
query := `query($login:String!,$number:Int!){
organization(login:$login){ projectV2(number:$number){ id } }
user(login:$login){ projectV2(number:$number){ id } }
}`
var resp struct {
Data struct {
Organization *struct {
ProjectV2 *struct{ ID string } `json:"projectV2"`
} `json:"organization"`
User *struct {
ProjectV2 *struct{ ID string } `json:"projectV2"`
} `json:"user"`
} `json:"data"`
Errors []graphqlError `json:"errors"`
}
if err := c.graphql(ctx, token, query, map[string]any{"login": login, "number": number}, &resp); err != nil {
return "", err
}
if resp.Data.Organization != nil && resp.Data.Organization.ProjectV2 != nil {
return resp.Data.Organization.ProjectV2.ID, nil
}
if resp.Data.User != nil && resp.Data.User.ProjectV2 != nil {
return resp.Data.User.ProjectV2.ID, nil
}
return "", fmt.Errorf("project number %d not found for %q", number, login)
}
func (c *Client) createIssue(ctx context.Context, token, owner, repo, title, body string) (Result, error) {
payload, _ := json.Marshal(map[string]string{"title": title, "body": body})
url := fmt.Sprintf("%s/repos/%s/%s/issues", c.apiBase, owner, repo)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return Result{}, err
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return Result{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return Result{}, apiError("create issue", resp)
}
var out struct {
Number int `json:"number"`
HTMLURL string `json:"html_url"`
NodeID string `json:"node_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return Result{}, fmt.Errorf("decoding issue response: %w", err)
}
return Result{IssueNumber: out.Number, IssueURL: out.HTMLURL, IssueNodeID: out.NodeID}, nil
}
func (c *Client) addToProject(ctx context.Context, token, projectNodeID, contentNodeID string) error {
mutation := `mutation($projectId:ID!,$contentId:ID!){
addProjectV2ItemById(input:{projectId:$projectId,contentId:$contentId}){ item { id } }
}`
var resp struct {
Errors []graphqlError `json:"errors"`
}
return c.graphql(ctx, token, mutation,
map[string]any{"projectId": projectNodeID, "contentId": contentNodeID}, &resp)
}
// installationToken returns a cached installation access token, refreshing when stale.
func (c *Client) installationToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && time.Now().Before(c.tokenExp.Add(-time.Minute)) {
return c.token, nil
}
appJWT, err := c.appJWT()
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/app/installations/%d/access_tokens", c.apiBase, c.installationID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+appJWT)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return "", apiError("installation token", resp)
}
var out struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("decoding token response: %w", err)
}
c.token, c.tokenExp = out.Token, out.ExpiresAt
return c.token, nil
}
func (c *Client) appJWT() (string, error) {
now := time.Now()
claims := jwt.RegisteredClaims{
Issuer: strconv.FormatInt(c.appID, 10),
IssuedAt: jwt.NewNumericDate(now.Add(-30 * time.Second)),
ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)),
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return tok.SignedString(c.signKey)
}
func (c *Client) graphql(ctx context.Context, token, query string, vars map[string]any, out any) error {
payload, _ := json.Marshal(map[string]any{"query": query, "variables": vars})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return apiError("graphql", resp)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("decoding graphql response: %w", err)
}
// Surface top-level GraphQL errors if the out struct exposes them.
if errs := extractErrors(body); len(errs) > 0 {
return fmt.Errorf("graphql error: %s", errs[0].Message)
}
return nil
}
type graphqlError struct {
Message string `json:"message"`
}
func extractErrors(body []byte) []graphqlError {
var e struct {
Errors []graphqlError `json:"errors"`
}
_ = json.Unmarshal(body, &e)
return e.Errors
}
func apiError(op string, resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("github %s: status %d: %s", op, resp.StatusCode, bytes.TrimSpace(body))
}