ci: add test workflow, daily upstream sync and wlbcommit tool
- commit
- 891299a9cd9bab354d1403e63c5980f626b269ad
- author
- brice · · 2026-10-01 03:27 UTC
- parent
- 7ad88b7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +639 -0
A .github/workflows/ci.yml +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | name: ci | |
| 2 | + | ||
| 3 | + | on: | |
| 4 | + | push: | |
| 5 | + | branches: [main] | |
| 6 | + | pull_request: | |
| 7 | + | ||
| 8 | + | permissions: | |
| 9 | + | contents: read | |
| 10 | + | ||
| 11 | + | jobs: | |
| 12 | + | test: | |
| 13 | + | runs-on: ubuntu-latest | |
| 14 | + | steps: | |
| 15 | + | - uses: actions/checkout@v4 | |
| 16 | + | - uses: actions/setup-go@v5 | |
| 17 | + | with: | |
| 18 | + | go-version-file: go.mod | |
| 19 | + | - name: Install Wayland libraries | |
| 20 | + | run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils | |
| 21 | + | - name: Vet | |
| 22 | + | run: go vet ./... | |
| 23 | + | - name: Staticcheck | |
| 24 | + | run: go run honnef.co/go/tools/cmd/staticcheck@latest ./... | |
| 25 | + | - name: Test (CGO disabled) | |
| 26 | + | run: CGO_ENABLED=0 go test ./... | |
| 27 | + | - name: Test (race) | |
| 28 | + | run: go test -race ./... | |
| 29 | + | - name: Generated code is up to date | |
| 30 | + | run: | | |
| 31 | + | go generate ./... | |
| 32 | + | git diff --exit-code | |
| 33 | + | test -z "$(git status --porcelain)" | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | name: ci | |||
| 2 | + | ||||
| 3 | + | on: | |||
| 4 | + | push: | |||
| 5 | + | branches: [main] | |||
| 6 | + | pull_request: | |||
| 7 | + | ||||
| 8 | + | permissions: | |||
| 9 | + | contents: read | |||
| 10 | + | ||||
| 11 | + | jobs: | |||
| 12 | + | test: | |||
| 13 | + | runs-on: ubuntu-latest | |||
| 14 | + | steps: | |||
| 15 | + | - uses: actions/checkout@v4 | |||
| 16 | + | - uses: actions/setup-go@v5 | |||
| 17 | + | with: | |||
| 18 | + | go-version-file: go.mod | |||
| 19 | + | - name: Install Wayland libraries | |||
| 20 | + | run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils | |||
| 21 | + | - name: Vet | |||
| 22 | + | run: go vet ./... | |||
| 23 | + | - name: Staticcheck | |||
| 24 | + | run: go run honnef.co/go/tools/cmd/staticcheck@latest ./... | |||
| 25 | + | - name: Test (CGO disabled) | |||
| 26 | + | run: CGO_ENABLED=0 go test ./... | |||
| 27 | + | - name: Test (race) | |||
| 28 | + | run: go test -race ./... | |||
| 29 | + | - name: Generated code is up to date | |||
| 30 | + | run: | | |||
| 31 | + | go generate ./... | |||
| 32 | + | git diff --exit-code | |||
| 33 | + | test -z "$(git status --porcelain)" | |||
A .github/workflows/sync.yml +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | name: sync | |
| 2 | + | ||
| 3 | + | on: | |
| 4 | + | schedule: | |
| 5 | + | - cron: '30 4 * * *' | |
| 6 | + | workflow_dispatch: | |
| 7 | + | ||
| 8 | + | permissions: | |
| 9 | + | contents: write | |
| 10 | + | ||
| 11 | + | concurrency: | |
| 12 | + | group: sync | |
| 13 | + | cancel-in-progress: false | |
| 14 | + | ||
| 15 | + | jobs: | |
| 16 | + | sync: | |
| 17 | + | runs-on: ubuntu-latest | |
| 18 | + | steps: | |
| 19 | + | - uses: actions/checkout@v4 | |
| 20 | + | with: | |
| 21 | + | fetch-depth: 0 | |
| 22 | + | - uses: actions/setup-go@v5 | |
| 23 | + | with: | |
| 24 | + | go-version-file: go.mod | |
| 25 | + | - name: Configure git | |
| 26 | + | run: | | |
| 27 | + | git config --global --add safe.directory "$GITHUB_WORKSPACE" | |
| 28 | + | git config --global core.autocrlf false | |
| 29 | + | - name: Install Wayland libraries | |
| 30 | + | run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils | |
| 31 | + | - name: Sync upstream protocols | |
| 32 | + | id: sync | |
| 33 | + | run: | | |
| 34 | + | go run ./cmd/wlbsync -notes "$RUNNER_TEMP/notes.md" | |
| 35 | + | if [ -z "$(git status --porcelain)" ]; then | |
| 36 | + | echo "up to date" | |
| 37 | + | echo "changed=false" >> "$GITHUB_OUTPUT" | |
| 38 | + | else | |
| 39 | + | echo "changed=true" >> "$GITHUB_OUTPUT" | |
| 40 | + | fi | |
| 41 | + | - name: Generate | |
| 42 | + | if: steps.sync.outputs.changed == 'true' | |
| 43 | + | run: go generate ./... | |
| 44 | + | - name: Vet | |
| 45 | + | if: steps.sync.outputs.changed == 'true' | |
| 46 | + | run: go vet ./... | |
| 47 | + | - name: Test (CGO disabled) | |
| 48 | + | if: steps.sync.outputs.changed == 'true' | |
| 49 | + | run: CGO_ENABLED=0 go test ./... | |
| 50 | + | - name: Test (race) | |
| 51 | + | if: steps.sync.outputs.changed == 'true' | |
| 52 | + | run: go test -race ./... | |
| 53 | + | - name: Compute next version | |
| 54 | + | id: version | |
| 55 | + | if: steps.sync.outputs.changed == 'true' | |
| 56 | + | run: | | |
| 57 | + | latest="$(git tag -l 'v0.*' --sort=-v:refname | grep -E '^v0\.[0-9]+\.[0-9]+$' | sed -n 1p || true)" | |
| 58 | + | if [ -z "$latest" ]; then | |
| 59 | + | tag="v0.1.0" | |
| 60 | + | else | |
| 61 | + | minor="${latest#v0.}" | |
| 62 | + | minor="${minor%%.*}" | |
| 63 | + | tag="v0.$((minor + 1)).0" | |
| 64 | + | fi | |
| 65 | + | echo "latest tag: ${latest:-none}; next tag: $tag" | |
| 66 | + | echo "tag=$tag" >> "$GITHUB_OUTPUT" | |
| 67 | + | - name: Commit via GitHub API | |
| 68 | + | id: commit | |
| 69 | + | if: steps.sync.outputs.changed == 'true' | |
| 70 | + | env: | |
| 71 | + | GITHUB_TOKEN: ${{ github.token }} | |
| 72 | + | run: | | |
| 73 | + | message="$(printf 'feat(spec): sync upstream protocols\n\n'; head -n 20 "$RUNNER_TEMP/notes.md")" | |
| 74 | + | sha="$(go run ./cmd/wlbcommit -repo "$GITHUB_REPOSITORY" -branch main -message "$message")" | |
| 75 | + | echo "commit: $sha" | |
| 76 | + | echo "sha=$sha" >> "$GITHUB_OUTPUT" | |
| 77 | + | - name: Create release | |
| 78 | + | if: steps.sync.outputs.changed == 'true' | |
| 79 | + | env: | |
| 80 | + | GH_TOKEN: ${{ github.token }} | |
| 81 | + | TAG: ${{ steps.version.outputs.tag }} | |
| 82 | + | SHA: ${{ steps.commit.outputs.sha }} | |
| 83 | + | run: | | |
| 84 | + | gh release create "$TAG" --target "$SHA" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" | |
| 85 | + | - name: Warm Go module proxy | |
| 86 | + | if: steps.sync.outputs.changed == 'true' | |
| 87 | + | env: | |
| 88 | + | TAG: ${{ steps.version.outputs.tag }} | |
| 89 | + | run: | | |
| 90 | + | curl -fsS "https://proxy.golang.org/github.com/bnema/go-wayland-bindings/@v/$TAG.info" || true | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | name: sync | |||
| 2 | + | ||||
| 3 | + | on: | |||
| 4 | + | schedule: | |||
| 5 | + | - cron: '30 4 * * *' | |||
| 6 | + | workflow_dispatch: | |||
| 7 | + | ||||
| 8 | + | permissions: | |||
| 9 | + | contents: write | |||
| 10 | + | ||||
| 11 | + | concurrency: | |||
| 12 | + | group: sync | |||
| 13 | + | cancel-in-progress: false | |||
| 14 | + | ||||
| 15 | + | jobs: | |||
| 16 | + | sync: | |||
| 17 | + | runs-on: ubuntu-latest | |||
| 18 | + | steps: | |||
| 19 | + | - uses: actions/checkout@v4 | |||
| 20 | + | with: | |||
| 21 | + | fetch-depth: 0 | |||
| 22 | + | - uses: actions/setup-go@v5 | |||
| 23 | + | with: | |||
| 24 | + | go-version-file: go.mod | |||
| 25 | + | - name: Configure git | |||
| 26 | + | run: | | |||
| 27 | + | git config --global --add safe.directory "$GITHUB_WORKSPACE" | |||
| 28 | + | git config --global core.autocrlf false | |||
| 29 | + | - name: Install Wayland libraries | |||
| 30 | + | run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils | |||
| 31 | + | - name: Sync upstream protocols | |||
| 32 | + | id: sync | |||
| 33 | + | run: | | |||
| 34 | + | go run ./cmd/wlbsync -notes "$RUNNER_TEMP/notes.md" | |||
| 35 | + | if [ -z "$(git status --porcelain)" ]; then | |||
| 36 | + | echo "up to date" | |||
| 37 | + | echo "changed=false" >> "$GITHUB_OUTPUT" | |||
| 38 | + | else | |||
| 39 | + | echo "changed=true" >> "$GITHUB_OUTPUT" | |||
| 40 | + | fi | |||
| 41 | + | - name: Generate | |||
| 42 | + | if: steps.sync.outputs.changed == 'true' | |||
| 43 | + | run: go generate ./... | |||
| 44 | + | - name: Vet | |||
| 45 | + | if: steps.sync.outputs.changed == 'true' | |||
| 46 | + | run: go vet ./... | |||
| 47 | + | - name: Test (CGO disabled) | |||
| 48 | + | if: steps.sync.outputs.changed == 'true' | |||
| 49 | + | run: CGO_ENABLED=0 go test ./... | |||
| 50 | + | - name: Test (race) | |||
| 51 | + | if: steps.sync.outputs.changed == 'true' | |||
| 52 | + | run: go test -race ./... | |||
| 53 | + | - name: Compute next version | |||
| 54 | + | id: version | |||
| 55 | + | if: steps.sync.outputs.changed == 'true' | |||
| 56 | + | run: | | |||
| 57 | + | latest="$(git tag -l 'v0.*' --sort=-v:refname | grep -E '^v0\.[0-9]+\.[0-9]+$' | sed -n 1p || true)" | |||
| 58 | + | if [ -z "$latest" ]; then | |||
| 59 | + | tag="v0.1.0" | |||
| 60 | + | else | |||
| 61 | + | minor="${latest#v0.}" | |||
| 62 | + | minor="${minor%%.*}" | |||
| 63 | + | tag="v0.$((minor + 1)).0" | |||
| 64 | + | fi | |||
| 65 | + | echo "latest tag: ${latest:-none}; next tag: $tag" | |||
| 66 | + | echo "tag=$tag" >> "$GITHUB_OUTPUT" | |||
| 67 | + | - name: Commit via GitHub API | |||
| 68 | + | id: commit | |||
| 69 | + | if: steps.sync.outputs.changed == 'true' | |||
| 70 | + | env: | |||
| 71 | + | GITHUB_TOKEN: ${{ github.token }} | |||
| 72 | + | run: | | |||
| 73 | + | message="$(printf 'feat(spec): sync upstream protocols\n\n'; head -n 20 "$RUNNER_TEMP/notes.md")" | |||
| 74 | + | sha="$(go run ./cmd/wlbcommit -repo "$GITHUB_REPOSITORY" -branch main -message "$message")" | |||
| 75 | + | echo "commit: $sha" | |||
| 76 | + | echo "sha=$sha" >> "$GITHUB_OUTPUT" | |||
| 77 | + | - name: Create release | |||
| 78 | + | if: steps.sync.outputs.changed == 'true' | |||
| 79 | + | env: | |||
| 80 | + | GH_TOKEN: ${{ github.token }} | |||
| 81 | + | TAG: ${{ steps.version.outputs.tag }} | |||
| 82 | + | SHA: ${{ steps.commit.outputs.sha }} | |||
| 83 | + | run: | | |||
| 84 | + | gh release create "$TAG" --target "$SHA" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" | |||
| 85 | + | - name: Warm Go module proxy | |||
| 86 | + | if: steps.sync.outputs.changed == 'true' | |||
| 87 | + | env: | |||
| 88 | + | TAG: ${{ steps.version.outputs.tag }} | |||
| 89 | + | run: | | |||
| 90 | + | curl -fsS "https://proxy.golang.org/github.com/bnema/go-wayland-bindings/@v/$TAG.info" || true | |||
A cmd/wlbcommit/main.go +285 -0
| @@ -0,0 +1,285 @@ | |||
| 1 | + | // Command wlbcommit commits the working-tree changes of the current git | |
| 2 | + | // repository to a branch on GitHub through the GraphQL createCommitOnBranch | |
| 3 | + | // mutation, so that the resulting commit is signed (verified) by GitHub. | |
| 4 | + | // | |
| 5 | + | // Usage: | |
| 6 | + | // | |
| 7 | + | // GITHUB_TOKEN=... wlbcommit -repo owner/name -branch main -message "headline\n\nbody" | |
| 8 | + | // | |
| 9 | + | // The new commit oid is printed on stdout. | |
| 10 | + | package main | |
| 11 | + | ||
| 12 | + | import ( | |
| 13 | + | "bytes" | |
| 14 | + | "context" | |
| 15 | + | "encoding/base64" | |
| 16 | + | "encoding/json" | |
| 17 | + | "errors" | |
| 18 | + | "flag" | |
| 19 | + | "fmt" | |
| 20 | + | "io" | |
| 21 | + | "net/http" | |
| 22 | + | "os" | |
| 23 | + | "os/exec" | |
| 24 | + | "path/filepath" | |
| 25 | + | "sort" | |
| 26 | + | "strings" | |
| 27 | + | "time" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | const defaultAPI = "https://api.github.com/graphql" | |
| 31 | + | ||
| 32 | + | const mutation = `mutation($input: CreateCommitOnBranchInput!) { | |
| 33 | + | createCommitOnBranch(input: $input) { | |
| 34 | + | commit { oid } | |
| 35 | + | } | |
| 36 | + | }` | |
| 37 | + | ||
| 38 | + | type branchInput struct { | |
| 39 | + | RepositoryNameWithOwner string `json:"repositoryNameWithOwner"` | |
| 40 | + | BranchName string `json:"branchName"` | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type messageInput struct { | |
| 44 | + | Headline string `json:"headline"` | |
| 45 | + | Body string `json:"body,omitempty"` | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type addition struct { | |
| 49 | + | Path string `json:"path"` | |
| 50 | + | Contents string `json:"contents"` | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | type deletion struct { | |
| 54 | + | Path string `json:"path"` | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | type fileChanges struct { | |
| 58 | + | Additions []addition `json:"additions"` | |
| 59 | + | Deletions []deletion `json:"deletions"` | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | type commitInput struct { | |
| 63 | + | Branch branchInput `json:"branch"` | |
| 64 | + | ExpectedHeadOid string `json:"expectedHeadOid"` | |
| 65 | + | Message messageInput `json:"message"` | |
| 66 | + | FileChanges fileChanges `json:"fileChanges"` | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | type request struct { | |
| 70 | + | Query string `json:"query"` | |
| 71 | + | Variables map[string]commitInput `json:"variables"` | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | type response struct { | |
| 75 | + | Data struct { | |
| 76 | + | CreateCommitOnBranch struct { | |
| 77 | + | Commit struct { | |
| 78 | + | Oid string `json:"oid"` | |
| 79 | + | } `json:"commit"` | |
| 80 | + | } `json:"createCommitOnBranch"` | |
| 81 | + | } `json:"data"` | |
| 82 | + | Errors []struct { | |
| 83 | + | Message string `json:"message"` | |
| 84 | + | } `json:"errors"` | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func main() { | |
| 88 | + | if err := run(context.Background(), os.Args[1:], os.Getenv, os.Stdout, "."); err != nil { | |
| 89 | + | fmt.Fprintln(os.Stderr, "wlbcommit:", err) | |
| 90 | + | os.Exit(1) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func run(ctx context.Context, args []string, getenv func(string) string, stdout io.Writer, dir string) error { | |
| 95 | + | fs := flag.NewFlagSet("wlbcommit", flag.ContinueOnError) | |
| 96 | + | repo := fs.String("repo", "", "repository as owner/name") | |
| 97 | + | branch := fs.String("branch", "", "branch to commit to") | |
| 98 | + | message := fs.String("message", "", "commit message (first line is the headline)") | |
| 99 | + | api := fs.String("api", defaultAPI, "GraphQL endpoint") | |
| 100 | + | if err := fs.Parse(args); err != nil { | |
| 101 | + | return err | |
| 102 | + | } | |
| 103 | + | if *repo == "" || *branch == "" || strings.TrimSpace(*message) == "" { | |
| 104 | + | return errors.New("-repo, -branch and -message are required") | |
| 105 | + | } | |
| 106 | + | token := getenv("GITHUB_TOKEN") | |
| 107 | + | if token == "" { | |
| 108 | + | return errors.New("GITHUB_TOKEN is not set") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | root, err := git(ctx, dir, "rev-parse", "--show-toplevel") | |
| 112 | + | if err != nil { | |
| 113 | + | return err | |
| 114 | + | } | |
| 115 | + | root = strings.TrimSpace(root) | |
| 116 | + | head, err := git(ctx, root, "rev-parse", "HEAD") | |
| 117 | + | if err != nil { | |
| 118 | + | return err | |
| 119 | + | } | |
| 120 | + | status, err := git(ctx, root, "status", "--porcelain=v1", "-z", "--untracked-files=all") | |
| 121 | + | if err != nil { | |
| 122 | + | return err | |
| 123 | + | } | |
| 124 | + | changes, err := collectChanges(root, status) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | if len(changes.Additions) == 0 && len(changes.Deletions) == 0 { | |
| 129 | + | return errors.New("no changes to commit") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | headline, body := splitMessage(*message) | |
| 133 | + | payload, err := json.Marshal(request{ | |
| 134 | + | Query: mutation, | |
| 135 | + | Variables: map[string]commitInput{"input": { | |
| 136 | + | Branch: branchInput{RepositoryNameWithOwner: *repo, BranchName: *branch}, | |
| 137 | + | ExpectedHeadOid: strings.TrimSpace(head), | |
| 138 | + | Message: messageInput{Headline: headline, Body: body}, | |
| 139 | + | FileChanges: changes, | |
| 140 | + | }}, | |
| 141 | + | }) | |
| 142 | + | if err != nil { | |
| 143 | + | return err | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | oid, err := post(ctx, *api, token, payload) | |
| 147 | + | if err != nil { | |
| 148 | + | return err | |
| 149 | + | } | |
| 150 | + | _, err = fmt.Fprintln(stdout, oid) | |
| 151 | + | return err | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func git(ctx context.Context, dir string, args ...string) (string, error) { | |
| 155 | + | cmd := exec.CommandContext(ctx, "git", args...) | |
| 156 | + | cmd.Dir = dir | |
| 157 | + | var stdout, stderr bytes.Buffer | |
| 158 | + | cmd.Stdout = &stdout | |
| 159 | + | cmd.Stderr = &stderr | |
| 160 | + | if err := cmd.Run(); err != nil { | |
| 161 | + | return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) | |
| 162 | + | } | |
| 163 | + | return stdout.String(), nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // collectChanges turns `git status --porcelain=v1 -z` output into GraphQL file | |
| 167 | + | // changes. A path present in the working tree is an addition (which also | |
| 168 | + | // covers modifications); an absent one is a deletion. Renames and copies yield | |
| 169 | + | // an addition for the new path and, for renames, a deletion of the old one. | |
| 170 | + | func collectChanges(root, status string) (fileChanges, error) { | |
| 171 | + | adds := map[string]bool{} | |
| 172 | + | dels := map[string]bool{} | |
| 173 | + | entries := strings.Split(status, "\x00") | |
| 174 | + | for i := 0; i < len(entries); i++ { | |
| 175 | + | e := entries[i] | |
| 176 | + | if len(e) < 4 { | |
| 177 | + | continue | |
| 178 | + | } | |
| 179 | + | x, y, path := e[0], e[1], e[3:] | |
| 180 | + | adds[path] = true | |
| 181 | + | if x == 'R' || x == 'C' || y == 'R' || y == 'C' { | |
| 182 | + | i++ | |
| 183 | + | if i >= len(entries) { | |
| 184 | + | return fileChanges{}, errors.New("malformed git status output") | |
| 185 | + | } | |
| 186 | + | if x == 'R' || y == 'R' { | |
| 187 | + | dels[entries[i]] = true | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | changes := fileChanges{Additions: []addition{}, Deletions: []deletion{}} | |
| 193 | + | for _, p := range sortedKeys(adds) { | |
| 194 | + | full := filepath.Join(root, filepath.FromSlash(p)) | |
| 195 | + | info, err := os.Lstat(full) | |
| 196 | + | if err != nil { | |
| 197 | + | if os.IsNotExist(err) { | |
| 198 | + | dels[p] = true | |
| 199 | + | continue | |
| 200 | + | } | |
| 201 | + | return fileChanges{}, err | |
| 202 | + | } | |
| 203 | + | if info.IsDir() { | |
| 204 | + | continue // e.g. a submodule; not representable | |
| 205 | + | } | |
| 206 | + | data, err := os.ReadFile(full) | |
| 207 | + | if err != nil { | |
| 208 | + | return fileChanges{}, err | |
| 209 | + | } | |
| 210 | + | delete(dels, p) | |
| 211 | + | changes.Additions = append(changes.Additions, addition{Path: p, Contents: base64.StdEncoding.EncodeToString(data)}) | |
| 212 | + | } | |
| 213 | + | for _, p := range sortedKeys(dels) { | |
| 214 | + | if adds[p] { | |
| 215 | + | if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(p))); err == nil { | |
| 216 | + | continue | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | changes.Deletions = append(changes.Deletions, deletion{Path: p}) | |
| 220 | + | } | |
| 221 | + | return changes, nil | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func sortedKeys(m map[string]bool) []string { | |
| 225 | + | keys := make([]string, 0, len(m)) | |
| 226 | + | for k := range m { | |
| 227 | + | keys = append(keys, k) | |
| 228 | + | } | |
| 229 | + | sort.Strings(keys) | |
| 230 | + | return keys | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func splitMessage(msg string) (headline, body string) { | |
| 234 | + | msg = strings.TrimSpace(msg) | |
| 235 | + | headline, body, _ = strings.Cut(msg, "\n") | |
| 236 | + | return strings.TrimSpace(headline), strings.TrimSpace(body) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func post(ctx context.Context, api, token string, payload []byte) (string, error) { | |
| 240 | + | ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) | |
| 241 | + | defer cancel() | |
| 242 | + | req, err := http.NewRequestWithContext(ctx, http.MethodPost, api, bytes.NewReader(payload)) | |
| 243 | + | if err != nil { | |
| 244 | + | return "", err | |
| 245 | + | } | |
| 246 | + | req.Header.Set("Authorization", "Bearer "+token) | |
| 247 | + | req.Header.Set("Content-Type", "application/json") | |
| 248 | + | req.Header.Set("User-Agent", "wlbcommit") | |
| 249 | + | resp, err := http.DefaultClient.Do(req) | |
| 250 | + | if err != nil { | |
| 251 | + | return "", err | |
| 252 | + | } | |
| 253 | + | defer resp.Body.Close() | |
| 254 | + | data, err := io.ReadAll(resp.Body) | |
| 255 | + | if err != nil { | |
| 256 | + | return "", err | |
| 257 | + | } | |
| 258 | + | if resp.StatusCode != http.StatusOK { | |
| 259 | + | return "", fmt.Errorf("GitHub API returned %s: %s", resp.Status, truncate(string(data))) | |
| 260 | + | } | |
| 261 | + | var out response | |
| 262 | + | if err := json.Unmarshal(data, &out); err != nil { | |
| 263 | + | return "", fmt.Errorf("decode response: %w", err) | |
| 264 | + | } | |
| 265 | + | if len(out.Errors) > 0 { | |
| 266 | + | msgs := make([]string, len(out.Errors)) | |
| 267 | + | for i, e := range out.Errors { | |
| 268 | + | msgs[i] = e.Message | |
| 269 | + | } | |
| 270 | + | return "", fmt.Errorf("GraphQL errors: %s", strings.Join(msgs, "; ")) | |
| 271 | + | } | |
| 272 | + | oid := out.Data.CreateCommitOnBranch.Commit.Oid | |
| 273 | + | if oid == "" { | |
| 274 | + | return "", errors.New("response contains no commit oid") | |
| 275 | + | } | |
| 276 | + | return oid, nil | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func truncate(s string) string { | |
| 280 | + | const max = 500 | |
| 281 | + | if len(s) > max { | |
| 282 | + | return s[:max] + "..." | |
| 283 | + | } | |
| 284 | + | return s | |
| 285 | + | } | |
| @@ -0,0 +1,285 @@ | |||||
| 1 | + | // Command wlbcommit commits the working-tree changes of the current git | |||
| 2 | + | // repository to a branch on GitHub through the GraphQL createCommitOnBranch | |||
| 3 | + | // mutation, so that the resulting commit is signed (verified) by GitHub. | |||
| 4 | + | // | |||
| 5 | + | // Usage: | |||
| 6 | + | // | |||
| 7 | + | // GITHUB_TOKEN=... wlbcommit -repo owner/name -branch main -message "headline\n\nbody" | |||
| 8 | + | // | |||
| 9 | + | // The new commit oid is printed on stdout. | |||
| 10 | + | package main | |||
| 11 | + | ||||
| 12 | + | import ( | |||
| 13 | + | "bytes" | |||
| 14 | + | "context" | |||
| 15 | + | "encoding/base64" | |||
| 16 | + | "encoding/json" | |||
| 17 | + | "errors" | |||
| 18 | + | "flag" | |||
| 19 | + | "fmt" | |||
| 20 | + | "io" | |||
| 21 | + | "net/http" | |||
| 22 | + | "os" | |||
| 23 | + | "os/exec" | |||
| 24 | + | "path/filepath" | |||
| 25 | + | "sort" | |||
| 26 | + | "strings" | |||
| 27 | + | "time" | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | const defaultAPI = "https://api.github.com/graphql" | |||
| 31 | + | ||||
| 32 | + | const mutation = `mutation($input: CreateCommitOnBranchInput!) { | |||
| 33 | + | createCommitOnBranch(input: $input) { | |||
| 34 | + | commit { oid } | |||
| 35 | + | } | |||
| 36 | + | }` | |||
| 37 | + | ||||
| 38 | + | type branchInput struct { | |||
| 39 | + | RepositoryNameWithOwner string `json:"repositoryNameWithOwner"` | |||
| 40 | + | BranchName string `json:"branchName"` | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type messageInput struct { | |||
| 44 | + | Headline string `json:"headline"` | |||
| 45 | + | Body string `json:"body,omitempty"` | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type addition struct { | |||
| 49 | + | Path string `json:"path"` | |||
| 50 | + | Contents string `json:"contents"` | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | type deletion struct { | |||
| 54 | + | Path string `json:"path"` | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | type fileChanges struct { | |||
| 58 | + | Additions []addition `json:"additions"` | |||
| 59 | + | Deletions []deletion `json:"deletions"` | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | type commitInput struct { | |||
| 63 | + | Branch branchInput `json:"branch"` | |||
| 64 | + | ExpectedHeadOid string `json:"expectedHeadOid"` | |||
| 65 | + | Message messageInput `json:"message"` | |||
| 66 | + | FileChanges fileChanges `json:"fileChanges"` | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | type request struct { | |||
| 70 | + | Query string `json:"query"` | |||
| 71 | + | Variables map[string]commitInput `json:"variables"` | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | type response struct { | |||
| 75 | + | Data struct { | |||
| 76 | + | CreateCommitOnBranch struct { | |||
| 77 | + | Commit struct { | |||
| 78 | + | Oid string `json:"oid"` | |||
| 79 | + | } `json:"commit"` | |||
| 80 | + | } `json:"createCommitOnBranch"` | |||
| 81 | + | } `json:"data"` | |||
| 82 | + | Errors []struct { | |||
| 83 | + | Message string `json:"message"` | |||
| 84 | + | } `json:"errors"` | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func main() { | |||
| 88 | + | if err := run(context.Background(), os.Args[1:], os.Getenv, os.Stdout, "."); err != nil { | |||
| 89 | + | fmt.Fprintln(os.Stderr, "wlbcommit:", err) | |||
| 90 | + | os.Exit(1) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func run(ctx context.Context, args []string, getenv func(string) string, stdout io.Writer, dir string) error { | |||
| 95 | + | fs := flag.NewFlagSet("wlbcommit", flag.ContinueOnError) | |||
| 96 | + | repo := fs.String("repo", "", "repository as owner/name") | |||
| 97 | + | branch := fs.String("branch", "", "branch to commit to") | |||
| 98 | + | message := fs.String("message", "", "commit message (first line is the headline)") | |||
| 99 | + | api := fs.String("api", defaultAPI, "GraphQL endpoint") | |||
| 100 | + | if err := fs.Parse(args); err != nil { | |||
| 101 | + | return err | |||
| 102 | + | } | |||
| 103 | + | if *repo == "" || *branch == "" || strings.TrimSpace(*message) == "" { | |||
| 104 | + | return errors.New("-repo, -branch and -message are required") | |||
| 105 | + | } | |||
| 106 | + | token := getenv("GITHUB_TOKEN") | |||
| 107 | + | if token == "" { | |||
| 108 | + | return errors.New("GITHUB_TOKEN is not set") | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | root, err := git(ctx, dir, "rev-parse", "--show-toplevel") | |||
| 112 | + | if err != nil { | |||
| 113 | + | return err | |||
| 114 | + | } | |||
| 115 | + | root = strings.TrimSpace(root) | |||
| 116 | + | head, err := git(ctx, root, "rev-parse", "HEAD") | |||
| 117 | + | if err != nil { | |||
| 118 | + | return err | |||
| 119 | + | } | |||
| 120 | + | status, err := git(ctx, root, "status", "--porcelain=v1", "-z", "--untracked-files=all") | |||
| 121 | + | if err != nil { | |||
| 122 | + | return err | |||
| 123 | + | } | |||
| 124 | + | changes, err := collectChanges(root, status) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | if len(changes.Additions) == 0 && len(changes.Deletions) == 0 { | |||
| 129 | + | return errors.New("no changes to commit") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | headline, body := splitMessage(*message) | |||
| 133 | + | payload, err := json.Marshal(request{ | |||
| 134 | + | Query: mutation, | |||
| 135 | + | Variables: map[string]commitInput{"input": { | |||
| 136 | + | Branch: branchInput{RepositoryNameWithOwner: *repo, BranchName: *branch}, | |||
| 137 | + | ExpectedHeadOid: strings.TrimSpace(head), | |||
| 138 | + | Message: messageInput{Headline: headline, Body: body}, | |||
| 139 | + | FileChanges: changes, | |||
| 140 | + | }}, | |||
| 141 | + | }) | |||
| 142 | + | if err != nil { | |||
| 143 | + | return err | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | oid, err := post(ctx, *api, token, payload) | |||
| 147 | + | if err != nil { | |||
| 148 | + | return err | |||
| 149 | + | } | |||
| 150 | + | _, err = fmt.Fprintln(stdout, oid) | |||
| 151 | + | return err | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func git(ctx context.Context, dir string, args ...string) (string, error) { | |||
| 155 | + | cmd := exec.CommandContext(ctx, "git", args...) | |||
| 156 | + | cmd.Dir = dir | |||
| 157 | + | var stdout, stderr bytes.Buffer | |||
| 158 | + | cmd.Stdout = &stdout | |||
| 159 | + | cmd.Stderr = &stderr | |||
| 160 | + | if err := cmd.Run(); err != nil { | |||
| 161 | + | return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) | |||
| 162 | + | } | |||
| 163 | + | return stdout.String(), nil | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // collectChanges turns `git status --porcelain=v1 -z` output into GraphQL file | |||
| 167 | + | // changes. A path present in the working tree is an addition (which also | |||
| 168 | + | // covers modifications); an absent one is a deletion. Renames and copies yield | |||
| 169 | + | // an addition for the new path and, for renames, a deletion of the old one. | |||
| 170 | + | func collectChanges(root, status string) (fileChanges, error) { | |||
| 171 | + | adds := map[string]bool{} | |||
| 172 | + | dels := map[string]bool{} | |||
| 173 | + | entries := strings.Split(status, "\x00") | |||
| 174 | + | for i := 0; i < len(entries); i++ { | |||
| 175 | + | e := entries[i] | |||
| 176 | + | if len(e) < 4 { | |||
| 177 | + | continue | |||
| 178 | + | } | |||
| 179 | + | x, y, path := e[0], e[1], e[3:] | |||
| 180 | + | adds[path] = true | |||
| 181 | + | if x == 'R' || x == 'C' || y == 'R' || y == 'C' { | |||
| 182 | + | i++ | |||
| 183 | + | if i >= len(entries) { | |||
| 184 | + | return fileChanges{}, errors.New("malformed git status output") | |||
| 185 | + | } | |||
| 186 | + | if x == 'R' || y == 'R' { | |||
| 187 | + | dels[entries[i]] = true | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | changes := fileChanges{Additions: []addition{}, Deletions: []deletion{}} | |||
| 193 | + | for _, p := range sortedKeys(adds) { | |||
| 194 | + | full := filepath.Join(root, filepath.FromSlash(p)) | |||
| 195 | + | info, err := os.Lstat(full) | |||
| 196 | + | if err != nil { | |||
| 197 | + | if os.IsNotExist(err) { | |||
| 198 | + | dels[p] = true | |||
| 199 | + | continue | |||
| 200 | + | } | |||
| 201 | + | return fileChanges{}, err | |||
| 202 | + | } | |||
| 203 | + | if info.IsDir() { | |||
| 204 | + | continue // e.g. a submodule; not representable | |||
| 205 | + | } | |||
| 206 | + | data, err := os.ReadFile(full) | |||
| 207 | + | if err != nil { | |||
| 208 | + | return fileChanges{}, err | |||
| 209 | + | } | |||
| 210 | + | delete(dels, p) | |||
| 211 | + | changes.Additions = append(changes.Additions, addition{Path: p, Contents: base64.StdEncoding.EncodeToString(data)}) | |||
| 212 | + | } | |||
| 213 | + | for _, p := range sortedKeys(dels) { | |||
| 214 | + | if adds[p] { | |||
| 215 | + | if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(p))); err == nil { | |||
| 216 | + | continue | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | changes.Deletions = append(changes.Deletions, deletion{Path: p}) | |||
| 220 | + | } | |||
| 221 | + | return changes, nil | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func sortedKeys(m map[string]bool) []string { | |||
| 225 | + | keys := make([]string, 0, len(m)) | |||
| 226 | + | for k := range m { | |||
| 227 | + | keys = append(keys, k) | |||
| 228 | + | } | |||
| 229 | + | sort.Strings(keys) | |||
| 230 | + | return keys | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func splitMessage(msg string) (headline, body string) { | |||
| 234 | + | msg = strings.TrimSpace(msg) | |||
| 235 | + | headline, body, _ = strings.Cut(msg, "\n") | |||
| 236 | + | return strings.TrimSpace(headline), strings.TrimSpace(body) | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func post(ctx context.Context, api, token string, payload []byte) (string, error) { | |||
| 240 | + | ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) | |||
| 241 | + | defer cancel() | |||
| 242 | + | req, err := http.NewRequestWithContext(ctx, http.MethodPost, api, bytes.NewReader(payload)) | |||
| 243 | + | if err != nil { | |||
| 244 | + | return "", err | |||
| 245 | + | } | |||
| 246 | + | req.Header.Set("Authorization", "Bearer "+token) | |||
| 247 | + | req.Header.Set("Content-Type", "application/json") | |||
| 248 | + | req.Header.Set("User-Agent", "wlbcommit") | |||
| 249 | + | resp, err := http.DefaultClient.Do(req) | |||
| 250 | + | if err != nil { | |||
| 251 | + | return "", err | |||
| 252 | + | } | |||
| 253 | + | defer resp.Body.Close() | |||
| 254 | + | data, err := io.ReadAll(resp.Body) | |||
| 255 | + | if err != nil { | |||
| 256 | + | return "", err | |||
| 257 | + | } | |||
| 258 | + | if resp.StatusCode != http.StatusOK { | |||
| 259 | + | return "", fmt.Errorf("GitHub API returned %s: %s", resp.Status, truncate(string(data))) | |||
| 260 | + | } | |||
| 261 | + | var out response | |||
| 262 | + | if err := json.Unmarshal(data, &out); err != nil { | |||
| 263 | + | return "", fmt.Errorf("decode response: %w", err) | |||
| 264 | + | } | |||
| 265 | + | if len(out.Errors) > 0 { | |||
| 266 | + | msgs := make([]string, len(out.Errors)) | |||
| 267 | + | for i, e := range out.Errors { | |||
| 268 | + | msgs[i] = e.Message | |||
| 269 | + | } | |||
| 270 | + | return "", fmt.Errorf("GraphQL errors: %s", strings.Join(msgs, "; ")) | |||
| 271 | + | } | |||
| 272 | + | oid := out.Data.CreateCommitOnBranch.Commit.Oid | |||
| 273 | + | if oid == "" { | |||
| 274 | + | return "", errors.New("response contains no commit oid") | |||
| 275 | + | } | |||
| 276 | + | return oid, nil | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func truncate(s string) string { | |||
| 280 | + | const max = 500 | |||
| 281 | + | if len(s) > max { | |||
| 282 | + | return s[:max] + "..." | |||
| 283 | + | } | |||
| 284 | + | return s | |||
| 285 | + | } | |||
A cmd/wlbcommit/main_test.go +231 -0
| @@ -0,0 +1,231 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "encoding/json" | |
| 8 | + | "net/http" | |
| 9 | + | "net/http/httptest" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func gitT(t *testing.T, dir string, args ...string) string { | |
| 17 | + | t.Helper() | |
| 18 | + | full := append([]string{ | |
| 19 | + | "-c", "commit.gpgsign=false", | |
| 20 | + | "-c", "user.name=test", "-c", "user.email=test@example.com", | |
| 21 | + | }, args...) | |
| 22 | + | out, err := git(context.Background(), dir, full...) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | return out | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func writeFile(t *testing.T, dir, name, content string) { | |
| 30 | + | t.Helper() | |
| 31 | + | p := filepath.Join(dir, filepath.FromSlash(name)) | |
| 32 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | if err := os.WriteFile(p, []byte(content), 0o644); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func newRepo(t *testing.T) (dir, head string) { | |
| 41 | + | t.Helper() | |
| 42 | + | dir = t.TempDir() | |
| 43 | + | gitT(t, dir, "init", "-q") | |
| 44 | + | writeFile(t, dir, "keep.txt", "keep\n") | |
| 45 | + | writeFile(t, dir, "modify.txt", "old\n") | |
| 46 | + | writeFile(t, dir, "delete.txt", "bye\n") | |
| 47 | + | writeFile(t, dir, "sub/rename-me.txt", "rename content that is long enough to be detected as a rename\n") | |
| 48 | + | gitT(t, dir, "add", "-A") | |
| 49 | + | gitT(t, dir, "commit", "-q", "-m", "init") | |
| 50 | + | return dir, strings.TrimSpace(gitT(t, dir, "rev-parse", "HEAD")) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func env(m map[string]string) func(string) string { | |
| 54 | + | return func(k string) string { return m[k] } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestRunRequestShape(t *testing.T) { | |
| 58 | + | dir, head := newRepo(t) | |
| 59 | + | writeFile(t, dir, "modify.txt", "new\n") | |
| 60 | + | writeFile(t, dir, "added/dir/new.bin", "\x00\x01binary") | |
| 61 | + | if err := os.Remove(filepath.Join(dir, "delete.txt")); err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | gitT(t, dir, "mv", "sub/rename-me.txt", "renamed.txt") | |
| 65 | + | ||
| 66 | + | var gotAuth string | |
| 67 | + | var got request | |
| 68 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 69 | + | gotAuth = r.Header.Get("Authorization") | |
| 70 | + | if r.Method != http.MethodPost { | |
| 71 | + | t.Errorf("method = %s", r.Method) | |
| 72 | + | } | |
| 73 | + | if err := json.NewDecoder(r.Body).Decode(&got); err != nil { | |
| 74 | + | t.Error(err) | |
| 75 | + | } | |
| 76 | + | _, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"abc123"}}}}`)) | |
| 77 | + | })) | |
| 78 | + | defer srv.Close() | |
| 79 | + | ||
| 80 | + | var out bytes.Buffer | |
| 81 | + | err := run(context.Background(), []string{ | |
| 82 | + | "-repo", "o/r", "-branch", "main", "-api", srv.URL, | |
| 83 | + | "-message", "feat: headline\n\nbody line 1\nbody line 2\n", | |
| 84 | + | }, env(map[string]string{"GITHUB_TOKEN": "tok"}), &out, dir) | |
| 85 | + | if err != nil { | |
| 86 | + | t.Fatal(err) | |
| 87 | + | } | |
| 88 | + | if out.String() != "abc123\n" { | |
| 89 | + | t.Errorf("stdout = %q", out.String()) | |
| 90 | + | } | |
| 91 | + | if gotAuth != "Bearer tok" { | |
| 92 | + | t.Errorf("auth = %q", gotAuth) | |
| 93 | + | } | |
| 94 | + | if !strings.Contains(got.Query, "createCommitOnBranch") { | |
| 95 | + | t.Errorf("query = %q", got.Query) | |
| 96 | + | } | |
| 97 | + | in := got.Variables["input"] | |
| 98 | + | if in.Branch.RepositoryNameWithOwner != "o/r" || in.Branch.BranchName != "main" { | |
| 99 | + | t.Errorf("branch = %+v", in.Branch) | |
| 100 | + | } | |
| 101 | + | if in.ExpectedHeadOid != head { | |
| 102 | + | t.Errorf("expectedHeadOid = %q, want %q", in.ExpectedHeadOid, head) | |
| 103 | + | } | |
| 104 | + | if in.Message.Headline != "feat: headline" || in.Message.Body != "body line 1\nbody line 2" { | |
| 105 | + | t.Errorf("message = %+v", in.Message) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | adds := map[string]string{} | |
| 109 | + | for _, a := range in.FileChanges.Additions { | |
| 110 | + | b, err := base64.StdEncoding.DecodeString(a.Contents) | |
| 111 | + | if err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | adds[a.Path] = string(b) | |
| 115 | + | } | |
| 116 | + | wantAdds := map[string]string{ | |
| 117 | + | "modify.txt": "new\n", | |
| 118 | + | "added/dir/new.bin": "\x00\x01binary", | |
| 119 | + | "renamed.txt": "rename content that is long enough to be detected as a rename\n", | |
| 120 | + | } | |
| 121 | + | if len(adds) != len(wantAdds) { | |
| 122 | + | t.Errorf("additions = %v", adds) | |
| 123 | + | } | |
| 124 | + | for p, c := range wantAdds { | |
| 125 | + | if adds[p] != c { | |
| 126 | + | t.Errorf("addition %s = %q, want %q", p, adds[p], c) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | var dels []string | |
| 130 | + | for _, d := range in.FileChanges.Deletions { | |
| 131 | + | dels = append(dels, d.Path) | |
| 132 | + | } | |
| 133 | + | if strings.Join(dels, ",") != "delete.txt,sub/rename-me.txt" { | |
| 134 | + | t.Errorf("deletions = %v", dels) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestRunEmptyBodyOmitted(t *testing.T) { | |
| 139 | + | dir, _ := newRepo(t) | |
| 140 | + | writeFile(t, dir, "x.txt", "x") | |
| 141 | + | var raw map[string]any | |
| 142 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 143 | + | _ = json.NewDecoder(r.Body).Decode(&raw) | |
| 144 | + | _, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"o"}}}}`)) | |
| 145 | + | })) | |
| 146 | + | defer srv.Close() | |
| 147 | + | err := run(context.Background(), []string{"-repo", "o/r", "-branch", "main", "-api", srv.URL, "-message", "only headline"}, | |
| 148 | + | env(map[string]string{"GITHUB_TOKEN": "t"}), &bytes.Buffer{}, dir) | |
| 149 | + | if err != nil { | |
| 150 | + | t.Fatal(err) | |
| 151 | + | } | |
| 152 | + | msg := raw["variables"].(map[string]any)["input"].(map[string]any)["message"].(map[string]any) | |
| 153 | + | if _, ok := msg["body"]; ok { | |
| 154 | + | t.Errorf("body should be omitted: %v", msg) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestRunErrors(t *testing.T) { | |
| 159 | + | ok := env(map[string]string{"GITHUB_TOKEN": "t"}) | |
| 160 | + | base := func(api string) []string { | |
| 161 | + | return []string{"-repo", "o/r", "-branch", "main", "-api", api, "-message", "m"} | |
| 162 | + | } | |
| 163 | + | serve := func(status int, body string) *httptest.Server { | |
| 164 | + | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 165 | + | w.WriteHeader(status) | |
| 166 | + | _, _ = w.Write([]byte(body)) | |
| 167 | + | })) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | t.Run("missing token", func(t *testing.T) { | |
| 171 | + | dir, _ := newRepo(t) | |
| 172 | + | writeFile(t, dir, "x.txt", "x") | |
| 173 | + | err := run(context.Background(), base("http://127.0.0.1:1"), env(nil), &bytes.Buffer{}, dir) | |
| 174 | + | if err == nil || !strings.Contains(err.Error(), "GITHUB_TOKEN") { | |
| 175 | + | t.Errorf("err = %v", err) | |
| 176 | + | } | |
| 177 | + | }) | |
| 178 | + | t.Run("missing flags", func(t *testing.T) { | |
| 179 | + | dir, _ := newRepo(t) | |
| 180 | + | err := run(context.Background(), []string{"-repo", "o/r"}, ok, &bytes.Buffer{}, dir) | |
| 181 | + | if err == nil { | |
| 182 | + | t.Error("expected error") | |
| 183 | + | } | |
| 184 | + | }) | |
| 185 | + | t.Run("no changes", func(t *testing.T) { | |
| 186 | + | dir, _ := newRepo(t) | |
| 187 | + | called := false | |
| 188 | + | srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) | |
| 189 | + | defer srv.Close() | |
| 190 | + | err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir) | |
| 191 | + | if err == nil || !strings.Contains(err.Error(), "no changes") { | |
| 192 | + | t.Errorf("err = %v", err) | |
| 193 | + | } | |
| 194 | + | if called { | |
| 195 | + | t.Error("API called despite no changes") | |
| 196 | + | } | |
| 197 | + | }) | |
| 198 | + | t.Run("non-200", func(t *testing.T) { | |
| 199 | + | dir, _ := newRepo(t) | |
| 200 | + | writeFile(t, dir, "x.txt", "x") | |
| 201 | + | srv := serve(http.StatusUnauthorized, `{"message":"Bad credentials"}`) | |
| 202 | + | defer srv.Close() | |
| 203 | + | err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir) | |
| 204 | + | if err == nil || !strings.Contains(err.Error(), "401") { | |
| 205 | + | t.Errorf("err = %v", err) | |
| 206 | + | } | |
| 207 | + | }) | |
| 208 | + | t.Run("graphql errors", func(t *testing.T) { | |
| 209 | + | dir, _ := newRepo(t) | |
| 210 | + | writeFile(t, dir, "x.txt", "x") | |
| 211 | + | srv := serve(http.StatusOK, `{"data":null,"errors":[{"message":"Expected head oid mismatch"}]}`) | |
| 212 | + | defer srv.Close() | |
| 213 | + | var out bytes.Buffer | |
| 214 | + | err := run(context.Background(), base(srv.URL), ok, &out, dir) | |
| 215 | + | if err == nil || !strings.Contains(err.Error(), "Expected head oid mismatch") { | |
| 216 | + | t.Errorf("err = %v", err) | |
| 217 | + | } | |
| 218 | + | if out.Len() != 0 { | |
| 219 | + | t.Errorf("stdout = %q", out.String()) | |
| 220 | + | } | |
| 221 | + | }) | |
| 222 | + | t.Run("no oid", func(t *testing.T) { | |
| 223 | + | dir, _ := newRepo(t) | |
| 224 | + | writeFile(t, dir, "x.txt", "x") | |
| 225 | + | srv := serve(http.StatusOK, `{"data":{}}`) | |
| 226 | + | defer srv.Close() | |
| 227 | + | if err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir); err == nil { | |
| 228 | + | t.Error("expected error") | |
| 229 | + | } | |
| 230 | + | }) | |
| 231 | + | } | |
| @@ -0,0 +1,231 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/base64" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/http/httptest" | |||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "strings" | |||
| 13 | + | "testing" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func gitT(t *testing.T, dir string, args ...string) string { | |||
| 17 | + | t.Helper() | |||
| 18 | + | full := append([]string{ | |||
| 19 | + | "-c", "commit.gpgsign=false", | |||
| 20 | + | "-c", "user.name=test", "-c", "user.email=test@example.com", | |||
| 21 | + | }, args...) | |||
| 22 | + | out, err := git(context.Background(), dir, full...) | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | return out | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func writeFile(t *testing.T, dir, name, content string) { | |||
| 30 | + | t.Helper() | |||
| 31 | + | p := filepath.Join(dir, filepath.FromSlash(name)) | |||
| 32 | + | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | if err := os.WriteFile(p, []byte(content), 0o644); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func newRepo(t *testing.T) (dir, head string) { | |||
| 41 | + | t.Helper() | |||
| 42 | + | dir = t.TempDir() | |||
| 43 | + | gitT(t, dir, "init", "-q") | |||
| 44 | + | writeFile(t, dir, "keep.txt", "keep\n") | |||
| 45 | + | writeFile(t, dir, "modify.txt", "old\n") | |||
| 46 | + | writeFile(t, dir, "delete.txt", "bye\n") | |||
| 47 | + | writeFile(t, dir, "sub/rename-me.txt", "rename content that is long enough to be detected as a rename\n") | |||
| 48 | + | gitT(t, dir, "add", "-A") | |||
| 49 | + | gitT(t, dir, "commit", "-q", "-m", "init") | |||
| 50 | + | return dir, strings.TrimSpace(gitT(t, dir, "rev-parse", "HEAD")) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func env(m map[string]string) func(string) string { | |||
| 54 | + | return func(k string) string { return m[k] } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestRunRequestShape(t *testing.T) { | |||
| 58 | + | dir, head := newRepo(t) | |||
| 59 | + | writeFile(t, dir, "modify.txt", "new\n") | |||
| 60 | + | writeFile(t, dir, "added/dir/new.bin", "\x00\x01binary") | |||
| 61 | + | if err := os.Remove(filepath.Join(dir, "delete.txt")); err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | gitT(t, dir, "mv", "sub/rename-me.txt", "renamed.txt") | |||
| 65 | + | ||||
| 66 | + | var gotAuth string | |||
| 67 | + | var got request | |||
| 68 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 69 | + | gotAuth = r.Header.Get("Authorization") | |||
| 70 | + | if r.Method != http.MethodPost { | |||
| 71 | + | t.Errorf("method = %s", r.Method) | |||
| 72 | + | } | |||
| 73 | + | if err := json.NewDecoder(r.Body).Decode(&got); err != nil { | |||
| 74 | + | t.Error(err) | |||
| 75 | + | } | |||
| 76 | + | _, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"abc123"}}}}`)) | |||
| 77 | + | })) | |||
| 78 | + | defer srv.Close() | |||
| 79 | + | ||||
| 80 | + | var out bytes.Buffer | |||
| 81 | + | err := run(context.Background(), []string{ | |||
| 82 | + | "-repo", "o/r", "-branch", "main", "-api", srv.URL, | |||
| 83 | + | "-message", "feat: headline\n\nbody line 1\nbody line 2\n", | |||
| 84 | + | }, env(map[string]string{"GITHUB_TOKEN": "tok"}), &out, dir) | |||
| 85 | + | if err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | if out.String() != "abc123\n" { | |||
| 89 | + | t.Errorf("stdout = %q", out.String()) | |||
| 90 | + | } | |||
| 91 | + | if gotAuth != "Bearer tok" { | |||
| 92 | + | t.Errorf("auth = %q", gotAuth) | |||
| 93 | + | } | |||
| 94 | + | if !strings.Contains(got.Query, "createCommitOnBranch") { | |||
| 95 | + | t.Errorf("query = %q", got.Query) | |||
| 96 | + | } | |||
| 97 | + | in := got.Variables["input"] | |||
| 98 | + | if in.Branch.RepositoryNameWithOwner != "o/r" || in.Branch.BranchName != "main" { | |||
| 99 | + | t.Errorf("branch = %+v", in.Branch) | |||
| 100 | + | } | |||
| 101 | + | if in.ExpectedHeadOid != head { | |||
| 102 | + | t.Errorf("expectedHeadOid = %q, want %q", in.ExpectedHeadOid, head) | |||
| 103 | + | } | |||
| 104 | + | if in.Message.Headline != "feat: headline" || in.Message.Body != "body line 1\nbody line 2" { | |||
| 105 | + | t.Errorf("message = %+v", in.Message) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | adds := map[string]string{} | |||
| 109 | + | for _, a := range in.FileChanges.Additions { | |||
| 110 | + | b, err := base64.StdEncoding.DecodeString(a.Contents) | |||
| 111 | + | if err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | adds[a.Path] = string(b) | |||
| 115 | + | } | |||
| 116 | + | wantAdds := map[string]string{ | |||
| 117 | + | "modify.txt": "new\n", | |||
| 118 | + | "added/dir/new.bin": "\x00\x01binary", | |||
| 119 | + | "renamed.txt": "rename content that is long enough to be detected as a rename\n", | |||
| 120 | + | } | |||
| 121 | + | if len(adds) != len(wantAdds) { | |||
| 122 | + | t.Errorf("additions = %v", adds) | |||
| 123 | + | } | |||
| 124 | + | for p, c := range wantAdds { | |||
| 125 | + | if adds[p] != c { | |||
| 126 | + | t.Errorf("addition %s = %q, want %q", p, adds[p], c) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | var dels []string | |||
| 130 | + | for _, d := range in.FileChanges.Deletions { | |||
| 131 | + | dels = append(dels, d.Path) | |||
| 132 | + | } | |||
| 133 | + | if strings.Join(dels, ",") != "delete.txt,sub/rename-me.txt" { | |||
| 134 | + | t.Errorf("deletions = %v", dels) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestRunEmptyBodyOmitted(t *testing.T) { | |||
| 139 | + | dir, _ := newRepo(t) | |||
| 140 | + | writeFile(t, dir, "x.txt", "x") | |||
| 141 | + | var raw map[string]any | |||
| 142 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 143 | + | _ = json.NewDecoder(r.Body).Decode(&raw) | |||
| 144 | + | _, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"o"}}}}`)) | |||
| 145 | + | })) | |||
| 146 | + | defer srv.Close() | |||
| 147 | + | err := run(context.Background(), []string{"-repo", "o/r", "-branch", "main", "-api", srv.URL, "-message", "only headline"}, | |||
| 148 | + | env(map[string]string{"GITHUB_TOKEN": "t"}), &bytes.Buffer{}, dir) | |||
| 149 | + | if err != nil { | |||
| 150 | + | t.Fatal(err) | |||
| 151 | + | } | |||
| 152 | + | msg := raw["variables"].(map[string]any)["input"].(map[string]any)["message"].(map[string]any) | |||
| 153 | + | if _, ok := msg["body"]; ok { | |||
| 154 | + | t.Errorf("body should be omitted: %v", msg) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestRunErrors(t *testing.T) { | |||
| 159 | + | ok := env(map[string]string{"GITHUB_TOKEN": "t"}) | |||
| 160 | + | base := func(api string) []string { | |||
| 161 | + | return []string{"-repo", "o/r", "-branch", "main", "-api", api, "-message", "m"} | |||
| 162 | + | } | |||
| 163 | + | serve := func(status int, body string) *httptest.Server { | |||
| 164 | + | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 165 | + | w.WriteHeader(status) | |||
| 166 | + | _, _ = w.Write([]byte(body)) | |||
| 167 | + | })) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | t.Run("missing token", func(t *testing.T) { | |||
| 171 | + | dir, _ := newRepo(t) | |||
| 172 | + | writeFile(t, dir, "x.txt", "x") | |||
| 173 | + | err := run(context.Background(), base("http://127.0.0.1:1"), env(nil), &bytes.Buffer{}, dir) | |||
| 174 | + | if err == nil || !strings.Contains(err.Error(), "GITHUB_TOKEN") { | |||
| 175 | + | t.Errorf("err = %v", err) | |||
| 176 | + | } | |||
| 177 | + | }) | |||
| 178 | + | t.Run("missing flags", func(t *testing.T) { | |||
| 179 | + | dir, _ := newRepo(t) | |||
| 180 | + | err := run(context.Background(), []string{"-repo", "o/r"}, ok, &bytes.Buffer{}, dir) | |||
| 181 | + | if err == nil { | |||
| 182 | + | t.Error("expected error") | |||
| 183 | + | } | |||
| 184 | + | }) | |||
| 185 | + | t.Run("no changes", func(t *testing.T) { | |||
| 186 | + | dir, _ := newRepo(t) | |||
| 187 | + | called := false | |||
| 188 | + | srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) | |||
| 189 | + | defer srv.Close() | |||
| 190 | + | err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir) | |||
| 191 | + | if err == nil || !strings.Contains(err.Error(), "no changes") { | |||
| 192 | + | t.Errorf("err = %v", err) | |||
| 193 | + | } | |||
| 194 | + | if called { | |||
| 195 | + | t.Error("API called despite no changes") | |||
| 196 | + | } | |||
| 197 | + | }) | |||
| 198 | + | t.Run("non-200", func(t *testing.T) { | |||
| 199 | + | dir, _ := newRepo(t) | |||
| 200 | + | writeFile(t, dir, "x.txt", "x") | |||
| 201 | + | srv := serve(http.StatusUnauthorized, `{"message":"Bad credentials"}`) | |||
| 202 | + | defer srv.Close() | |||
| 203 | + | err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir) | |||
| 204 | + | if err == nil || !strings.Contains(err.Error(), "401") { | |||
| 205 | + | t.Errorf("err = %v", err) | |||
| 206 | + | } | |||
| 207 | + | }) | |||
| 208 | + | t.Run("graphql errors", func(t *testing.T) { | |||
| 209 | + | dir, _ := newRepo(t) | |||
| 210 | + | writeFile(t, dir, "x.txt", "x") | |||
| 211 | + | srv := serve(http.StatusOK, `{"data":null,"errors":[{"message":"Expected head oid mismatch"}]}`) | |||
| 212 | + | defer srv.Close() | |||
| 213 | + | var out bytes.Buffer | |||
| 214 | + | err := run(context.Background(), base(srv.URL), ok, &out, dir) | |||
| 215 | + | if err == nil || !strings.Contains(err.Error(), "Expected head oid mismatch") { | |||
| 216 | + | t.Errorf("err = %v", err) | |||
| 217 | + | } | |||
| 218 | + | if out.Len() != 0 { | |||
| 219 | + | t.Errorf("stdout = %q", out.String()) | |||
| 220 | + | } | |||
| 221 | + | }) | |||
| 222 | + | t.Run("no oid", func(t *testing.T) { | |||
| 223 | + | dir, _ := newRepo(t) | |||
| 224 | + | writeFile(t, dir, "x.txt", "x") | |||
| 225 | + | srv := serve(http.StatusOK, `{"data":{}}`) | |||
| 226 | + | defer srv.Close() | |||
| 227 | + | if err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir); err == nil { | |||
| 228 | + | t.Error("expected error") | |||
| 229 | + | } | |||
| 230 | + | }) | |||
| 231 | + | } | |||