mvp shell
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testKeyPEM(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateIssueOnBoard(t *testing.T) {
|
||||
var gotIssueBody map[string]string
|
||||
var gotGraphQL map[string]any
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/access_tokens"):
|
||||
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
|
||||
t.Errorf("token request missing app JWT bearer: %q", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"token": "ghs_installationtoken",
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/issues"):
|
||||
if got := r.Header.Get("Authorization"); got != "token ghs_installationtoken" {
|
||||
t.Errorf("issue request wrong auth: %q", got)
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotIssueBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"number": 42,
|
||||
"html_url": "https://github.com/o/r/issues/42",
|
||||
"node_id": "I_issuenode",
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/graphql"):
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotGraphQL)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"addProjectV2ItemById": map[string]any{
|
||||
"item": map[string]any{"id": "PVTI_item"},
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(123, 456, testKeyPEM(t), srv.URL, srv.URL+"/graphql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := c.CreateIssueOnBoard(context.Background(), "o", "r", "PVT_board", "My idea", "body text")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssueOnBoard: %v", err)
|
||||
}
|
||||
if res.IssueNumber != 42 || res.IssueNodeID != "I_issuenode" {
|
||||
t.Errorf("unexpected result: %+v", res)
|
||||
}
|
||||
if gotIssueBody["title"] != "My idea" || gotIssueBody["body"] != "body text" {
|
||||
t.Errorf("issue payload mismatch: %+v", gotIssueBody)
|
||||
}
|
||||
vars, _ := gotGraphQL["variables"].(map[string]any)
|
||||
if vars["projectId"] != "PVT_board" || vars["contentId"] != "I_issuenode" {
|
||||
t.Errorf("graphql variables mismatch: %+v", vars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphQLErrorSurfaced(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/access_tokens") {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"token": "t", "expires_at": "2999-01-01T00:00:00Z"})
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/issues") {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"number": 1, "html_url": "u", "node_id": "n"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"errors": []map[string]any{{"message": "Projects v2 not enabled"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(1, 2, testKeyPEM(t), srv.URL, srv.URL+"/graphql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = c.CreateIssueOnBoard(context.Background(), "o", "r", "PVT", "t", "b")
|
||||
if err == nil || !strings.Contains(err.Error(), "adding to board failed") {
|
||||
t.Errorf("expected board failure error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user