Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.6 aa33a359b9 Use Contents API for implicit branch creation in map editor PRs
Both REST /git/refs and GraphQL createRef return 403 with fine-grained
PATs. The Contents API (PUT /contents) creates a branch implicitly when
you commit to a non-existent branch name, which works with just the
contents:write permission. This also simplifies the code by removing
the GraphQL layer entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:04:32 -08:00
@@ -15,10 +15,10 @@ import (
)
var (
githubToken string
githubRepo string
mapsRepoPath string
githubPRReady bool
githubToken string
githubRepo string
mapsRepoPath string
githubPRReady bool
)
func initGitHubPR(repo, repoPath string) {
@@ -71,106 +71,9 @@ func githubAPIRequest(method, path string, body interface{}) (int, []byte, error
return resp.StatusCode, respBody, nil
}
// githubGraphQL makes an authenticated request to the GitHub GraphQL API.
// The REST /git/refs endpoint doesn't work with fine-grained PATs, so we use
// GraphQL for branch creation (createRef) which handles the same permissions correctly.
func githubGraphQL(query string, variables map[string]interface{}) (json.RawMessage, error) {
payload := map[string]interface{}{
"query": query,
"variables": variables,
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal graphql request: %w", err)
}
req, err := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+githubToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("graphql request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var result struct {
Data json.RawMessage `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse graphql response: %w", err)
}
if len(result.Errors) > 0 {
return nil, fmt.Errorf("graphql error: %s", result.Errors[0].Message)
}
return result.Data, nil
}
// githubCreateBranch creates a branch using GraphQL and returns the main branch SHA.
func githubCreateBranch(owner, repo, branchName string) (string, error) {
// Step 1: Get repo node ID and main branch SHA
queryData, err := githubGraphQL(`
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
id
defaultBranchRef {
target { oid }
}
}
}
`, map[string]interface{}{"owner": owner, "repo": repo})
if err != nil {
return "", fmt.Errorf("get repo info: %w", err)
}
var repoInfo struct {
Repository struct {
ID string `json:"id"`
DefaultBranchRef struct {
Target struct {
OID string `json:"oid"`
} `json:"target"`
} `json:"defaultBranchRef"`
} `json:"repository"`
}
if err := json.Unmarshal(queryData, &repoInfo); err != nil {
return "", fmt.Errorf("parse repo info: %w", err)
}
repoID := repoInfo.Repository.ID
mainSHA := repoInfo.Repository.DefaultBranchRef.Target.OID
// Step 2: Create branch via createRef mutation
_, err = githubGraphQL(`
mutation($repoId: ID!, $name: String!, $oid: GitObjectID!) {
createRef(input: { repositoryId: $repoId, name: $name, oid: $oid }) {
ref { name }
}
}
`, map[string]interface{}{
"repoId": repoID,
"name": "refs/heads/" + branchName,
"oid": mainSHA,
})
if err != nil {
return "", fmt.Errorf("create branch: %w", err)
}
return mainSHA, nil
}
// handleCreateMapPR creates a GitHub PR with the current map file contents.
// Uses only the Contents API and Pulls API — no Git Data API or GraphQL needed.
// The Contents API creates a branch implicitly when committing to a non-existent branch.
func handleCreateMapPR(w http.ResponseWriter, r *http.Request, mapName string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -197,21 +100,7 @@ func handleCreateMapPR(w http.ResponseWriter, r *http.Request, mapName string) {
branchSuffix := strings.ReplaceAll(mapName, " ", "-")
branchName := fmt.Sprintf("map-editor/%s-%d", branchSuffix, time.Now().Unix())
// Split "owner/repo" for GraphQL
repoParts := strings.SplitN(githubRepo, "/", 2)
if len(repoParts) != 2 {
http.Error(w, "Invalid github-repo format, expected owner/repo", http.StatusInternalServerError)
return
}
// 2-3. Get main SHA and create branch (via GraphQL — REST /git/refs doesn't work with fine-grained PATs)
_, err = githubCreateBranch(repoParts[0], repoParts[1], branchName)
if err != nil {
http.Error(w, "Failed to create branch: "+err.Error(), http.StatusBadGateway)
return
}
// 4. Get existing file's blob SHA (may 404 for new files)
// 2. Get existing file's blob SHA from main (may 404 for new files)
var existingSHA string
status, body, err := githubAPIRequest("GET", fmt.Sprintf("/repos/%s/contents/%s?ref=main", githubRepo, repoFilePath), nil)
if err != nil {
@@ -229,7 +118,7 @@ func handleCreateMapPR(w http.ResponseWriter, r *http.Request, mapName string) {
existingSHA = contentsResp.SHA
}
// 5. Commit file to branch
// 3. Commit file to new branch (Contents API creates the branch implicitly)
commitBody := map[string]interface{}{
"message": fmt.Sprintf("Update map: %s", mapName),
"content": base64.StdEncoding.EncodeToString(fileData),
@@ -248,7 +137,7 @@ func handleCreateMapPR(w http.ResponseWriter, r *http.Request, mapName string) {
return
}
// 6. Create pull request
// 4. Create pull request
prBody := map[string]string{
"title": fmt.Sprintf("Map editor: update %s", mapName),
"head": branchName,