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.
10package main
11
12import (
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
30const defaultAPI = "https://api.github.com/graphql"
31
32const mutation = `mutation($input: CreateCommitOnBranchInput!) {
33 createCommitOnBranch(input: $input) {
34 commit { oid }
35 }
36}`
37
38type branchInput struct {
39 RepositoryNameWithOwner string `json:"repositoryNameWithOwner"`
40 BranchName string `json:"branchName"`
41}
42
43type messageInput struct {
44 Headline string `json:"headline"`
45 Body string `json:"body,omitempty"`
46}
47
48type addition struct {
49 Path string `json:"path"`
50 Contents string `json:"contents"`
51}
52
53type deletion struct {
54 Path string `json:"path"`
55}
56
57type fileChanges struct {
58 Additions []addition `json:"additions"`
59 Deletions []deletion `json:"deletions"`
60}
61
62type commitInput struct {
63 Branch branchInput `json:"branch"`
64 ExpectedHeadOid string `json:"expectedHeadOid"`
65 Message messageInput `json:"message"`
66 FileChanges fileChanges `json:"fileChanges"`
67}
68
69type request struct {
70 Query string `json:"query"`
71 Variables map[string]commitInput `json:"variables"`
72}
73
74type 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
87func 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
94func 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
154func 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.
170func 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.Mode()&os.ModeSymlink != 0 {
204 return fileChanges{}, fmt.Errorf("%s is a symbolic link, which cannot be committed through the API", p)
205 }
206 if info.IsDir() {
207 continue // e.g. a submodule; not representable
208 }
209 data, err := os.ReadFile(full)
210 if err != nil {
211 return fileChanges{}, err
212 }
213 delete(dels, p)
214 changes.Additions = append(changes.Additions, addition{Path: p, Contents: base64.StdEncoding.EncodeToString(data)})
215 }
216 for _, p := range sortedKeys(dels) {
217 changes.Deletions = append(changes.Deletions, deletion{Path: p})
218 }
219 return changes, nil
220}
221
222func sortedKeys(m map[string]bool) []string {
223 keys := make([]string, 0, len(m))
224 for k := range m {
225 keys = append(keys, k)
226 }
227 sort.Strings(keys)
228 return keys
229}
230
231func splitMessage(msg string) (headline, body string) {
232 msg = strings.TrimSpace(msg)
233 headline, body, _ = strings.Cut(msg, "\n")
234 return strings.TrimSpace(headline), strings.TrimSpace(body)
235}
236
237func post(ctx context.Context, api, token string, payload []byte) (string, error) {
238 ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
239 defer cancel()
240 req, err := http.NewRequestWithContext(ctx, http.MethodPost, api, bytes.NewReader(payload))
241 if err != nil {
242 return "", err
243 }
244 req.Header.Set("Authorization", "Bearer "+token)
245 req.Header.Set("Content-Type", "application/json")
246 req.Header.Set("User-Agent", "wlbcommit")
247 resp, err := http.DefaultClient.Do(req)
248 if err != nil {
249 return "", err
250 }
251 defer resp.Body.Close()
252 data, err := io.ReadAll(resp.Body)
253 if err != nil {
254 return "", err
255 }
256 if resp.StatusCode != http.StatusOK {
257 return "", fmt.Errorf("GitHub API returned %s: %s", resp.Status, truncate(string(data)))
258 }
259 var out response
260 if err := json.Unmarshal(data, &out); err != nil {
261 return "", fmt.Errorf("decode response: %w", err)
262 }
263 if len(out.Errors) > 0 {
264 msgs := make([]string, len(out.Errors))
265 for i, e := range out.Errors {
266 msgs[i] = e.Message
267 }
268 return "", fmt.Errorf("GraphQL errors: %s", strings.Join(msgs, "; "))
269 }
270 oid := out.Data.CreateCommitOnBranch.Commit.Oid
271 if oid == "" {
272 return "", errors.New("response contains no commit oid")
273 }
274 return oid, nil
275}
276
277func truncate(s string) string {
278 const max = 500
279 if len(s) > max {
280 return s[:max] + "..."
281 }
282 return s
283}