Source file b0_github.ml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
open B0_std
open Result.Syntax
open B0_json
open B0_http
module Auth = struct
let conf_dir () =
let* c = Os.Dir.config () in
Ok Fpath.(c / "b0" / "github")
let token_scope = "repo"
let token_env = "B0_GITHUB_TOKEN"
let token_help_uri =
"https://docs.github.com/en/github/authenticating-to-github/\
creating-a-personal-access-token"
type token_src = [ `Env | `File of Fpath.t ]
let pp_token_src ppf = function
| `Env -> Fmt.pf ppf "environment variable %s" token_env
| `File file -> Fmt.pf ppf "file %a" Fpath.pp_quoted file
let parse_token s = match String.trim s with
| "" -> Error "Token can't be empty."
| s -> Ok s
let get_tokens conf_dir =
let is_token_file n = String.ends_with ~suffix:".token" n in
let add _ fname p acc = if is_token_file fname then p :: acc else acc in
Os.Dir.fold_files ~recurse:false add conf_dir []
let get_token conf_dir ~user =
match Os.Env.var ~empty_is_none:true token_env with
| Some token -> Result.map (fun t -> t, `Env) (parse_token token)
| None ->
let tokfile = Fpath.(conf_dir / Fmt.str "%s.token" user) in
let* exists = Os.File.exists tokfile in
match exists with
| false ->
Fmt.error
"@[<v>No GitHub personal access token found for user '%s' in@,\
environment variable %s or in file@,%a@,@,\
Create a GitHub personal access token with scope '%s' by@,\
following the instructions here:@,@, %s@,@,\
Save the token in the file above e.g. by running:@,@,\
\ \ # Make sure the directory exists@,\
\ \ mkdir -p %a@,@,\
\ \ # Paste the token from clipboard@,\
\ \ cat - > %a@,@,\
\ \ # Restrict access to yourself@,\
\ \ chmod 600 %a@,"
user token_env Fpath.pp_quoted tokfile token_scope token_help_uri
Fpath.pp_quoted conf_dir Fpath.pp_quoted tokfile Fpath.pp_quoted
tokfile
| true ->
Result.map_error (Fmt.str "%a: %s" Fpath.pp_quoted tokfile) @@
let* tok = Result.bind (Os.File.read tokfile) parse_token in
Ok (tok, `File tokfile)
let user_env = "B0_GITHUB_USER"
let default_user_file = "default-user"
let parse_user s = match String.trim s with
| "" -> Error "User can't be empty."
| s when String.exists Char.Ascii.is_blank s -> Error "User can't have space."
| s -> Ok s
let user_of_default_file default_user_file =
Result.map_error (Fmt.str "%a: %s" Fpath.pp_quoted default_user_file) @@
Result.bind (Os.File.read default_user_file) parse_user
let user_of_token_file f =
Result.map_error (Fmt.str "%a: %s" Fpath.pp_quoted f) @@
parse_user (Fpath.basename ~strip_exts:true f)
let get_user conf_dir ~user =
let err default_file =
Fmt.error "@[<v>Could not determine a default GitHub user.@,\
Write the user to use in the file %a@]" Fpath.pp
default_file
in
match user with
| Some user -> Ok user
| None ->
match Os.Env.var ~empty_is_none:true user_env with
| Some user -> Ok user
| None ->
let default_user_file = Fpath.(conf_dir / default_user_file) in
let* dir_exists = Os.Dir.exists conf_dir in
let* file_exists = Os.File.exists default_user_file in
if not dir_exists then err default_user_file else
if file_exists then user_of_default_file default_user_file else
let* tokens = get_tokens conf_dir in
match tokens with
| [] -> err default_user_file
| [t] -> user_of_token_file t
| ts -> err default_user_file
type t = { user : string; token : string; token_src : token_src }
let user a = a.user
let token a = a.token
let make ~user () =
let* conf_dir = conf_dir () in
let* user = get_user conf_dir ~user in
let* token, token_src = get_token conf_dir ~user in
Ok { user; token; token_src }
let envs =
[ Cmdliner.Cmd.Env.info user_env ~doc:"The GitHub user.";
Cmdliner.Cmd.Env.info token_env ~doc:"The GitHub personal access token." ]
let cli ?opts:(o = ["u";"github-user"]) () =
let auth user = make ~user () in
let doc = "The GitHub $(docv). If unspecified this is, \
in order, the value of the $(b,B0_GITHUB_USER) variable, \
the contents of $(b,XDG_CONFIG_HOME/b0/github/default-user), \
$(docv) if a single file path has the form
$(b,XDG_CONFIG_HOME/b0/github/)$(docv)$(b,.token).\n\n\
The password for $(docv) is, in order, the value of the \
$(b,B0_GITHUB_TOKEN) variable, the contents of the \
$(b,XDG_CONFIG_HOME/b0/github/)$(docv)$(b,.token) \
file."
in
let none = "See below" and docv = "USER" in
let user =
Cmdliner.Arg.(value & opt (some ~none string) None & info o ~doc ~docv)
in
Cmdliner.Term.(const auth $ user)
end
let v4_api_url = "https://api.github.com/graphql"
let v3_api_url = "https://api.github.com"
let response_success auth request response =
match Http.Response.status response with
| 200 | 201 -> Json.of_string (Http.Response.body response)
| 401 ->
Fmt.error "GitHub authentication failure on %s.\n\
Are you sure the token in %a\n\
is valid for user '%s' and has scope '%s' ?\n"
(Http.Request.url request) Auth.pp_token_src auth.Auth.token_src
auth.Auth.user Auth.token_scope
| st ->
Fmt.error "GitHub API request returned unexpected status %d for %s on %s"
st (Http.method_to_string (Http.Request.method' request))
(Http.Request.url request)
type content_type = string
let content_type c = ("Content-Type", c)
type v3_body =
[ `Json of Jsong.t | `Other of content_type * string | `Empty ]
let req_json_v3 ?( = []) http auth ~path m body =
let auth =
("Authorization", Fmt.str "token %s" auth.Auth.token) ::
("Accept", "application/vnd.github.v3+json") :: headers
in
let = req_v3_headers auth in
let , body = match body with
| `Json j -> (content_type "application/json") :: headers, Jsong.to_string j
| `Other (c, body) -> (content_type c) :: headers, body
| `Empty -> headers, ""
in
let url = v3_api_url ^ path in
let request = Http.Request.make ~url m ~headers ~body in
let* response = Http_client.request ~follow:true http request in
response_success auth request response
let query_v4 http auth q =
let auth = ["Authorization",
Fmt.str "bearer %s" auth.Auth.token] in
let query = Jsong.(obj |> mem "query" (string q) |> obj_end) in
let = req_v4_headers auth in
let body = Jsong.to_string query in
let request = Http.Request.make ~url:v4_api_url `POST ~headers ~body in
let* response = Http_client.request ~follow:true http request in
response_success auth request response
module Repo = struct
type t = { owner : string; name : string }
let make ~owner name = { owner; name }
let of_url url =
let err () = Fmt.error "%S: Can't parse GitHub owner and repo." url in
match B0_url.path url with
| None -> err ()
| Some p ->
match String.split_on_char '/' p with
| ("" :: owner :: repo :: _ ) ->
let repo = match String.split_last ~sep:"." repo with
| Some (r, "git") -> r | _ -> repo
in
Ok (make ~owner repo)
| _ -> err ()
let owner r = r.owner
let name r = r.name
let query_v4 http auth repo q =
query_v4 http auth @@
Fmt.str "query { repository(owner:%s, name:%s) { %s }}"
(Json.to_string (Json.string repo.owner))
(Json.to_string (Json.string repo.name)) q
let req_json_v3 ? http auth repo ~path m body =
let path = Fmt.str "/repos/%s/%s%s" repo.owner repo.name path in
req_json_v3 ?headers http auth ~path m body
end
module Issue = struct
type num = int
type uri = string
type t = { number : num; title : string; body : string; url : uri; }
let v number title body url = { number; title; body; url }
let number i = i.number
let title i = i.title
let body i = i.body
let url i = i.url
let pp =
Fmt.record @@
[ Fmt.field "number" number Fmt.int;
Fmt.field "title" title Fmt.string;
Fmt.field "body" body Fmt.string;
Fmt.field "url" url Fmt.string ]
let pp_short ppf i = Fmt.pf ppf "@[%d %s@]" i.number i.title
let issue_list_gql =
"issues(first:100 states:OPEN, orderBy:{direction:DESC, field:UPDATED_AT})
{ totalCount edges { node { number title bodyText url }}}"
let issue_list_q =
let open Jsonq in
let issue =
succeed v $ mem "number" int
$ mem "title" string $ mem "bodyText" string $ mem "url" string
in
mem "data" @@ mem "repository" @@ mem "issues" @@
(succeed (fun count is -> count, is)
$ mem "totalCount" int $ mem "edges" (array (mem "node" issue)))
let issue_id_q =
let issue n uri = n, uri in
Jsonq.(succeed issue $ mem "number" int $ mem "url" string)
let open_g ~title ~body =
Jsong.(obj |> mem "title" (string title) |> mem "body" (string body) |>
obj_end)
let close_g = Jsong.(obj |> mem "state" (string "close") |> obj_end)
let list http auth repo =
let* resp = Repo.query_v4 http auth repo issue_list_gql in
Jsonq.query issue_list_q resp
let open' http auth repo ~title ~body () =
let body = `Json (open_g ~title ~body) in
let* resp = Repo.req_json_v3 http auth repo ~path:"/issues" `POST body in
Jsonq.query issue_id_q resp
let close http auth repo num =
let path = Fmt.str "/issues/%d" num in
let* resp = Repo.req_json_v3 http auth repo ~path `PATCH (`Json close_g) in
Jsonq.query issue_id_q resp
end
module Release = struct
type t = { id : int; tag_name : string; body : string; html_url : string;
assets_url : string }
let v id tag_name body html_url assets_url =
{ id; tag_name; body; html_url; assets_url }
let id r = r.id
let tag_name r = r.tag_name
let body r = r.body
let html_url r = r.html_url
let assets_url r = r.assets_url
let pp_short ppf i = Fmt.pf ppf "@[%s %s@]" i.tag_name i.html_url
let pp =
Fmt.record @@
[ Fmt.field "id" id Fmt.int;
Fmt.field "tag_name" tag_name Fmt.string;
Fmt.field "body" body Fmt.string;
Fmt.field "html_url" html_url Fmt.string;
Fmt.field "assets_url" assets_url Fmt.string; ]
let release_q =
Jsonq.(succeed v $ mem "id" int $ mem "tag_name" string $
mem "body" string $ mem "html_url" string $ mem "assets_url" string)
let create_g ~tag_name ~body =
Jsong.(obj |> mem "tag_name" (string tag_name) |>
mem "body" (string body) |> obj_end)
let create http auth repo ~tag_name ~body () =
let body = `Json (create_g ~tag_name ~body) in
let* resp = Repo.req_json_v3 http auth repo ~path:"/releases" `POST body in
Jsonq.query release_q resp
let get http auth repo ~tag_name () =
let path = Fmt.str "/releases/tags/%s" tag_name in
let* resp = Repo.req_json_v3 http auth repo ~path `GET `Empty in
Jsonq.query release_q resp
let upload_asset http auth repo r ~content_type ~name asset =
let path = Fmt.str "%s?name=%s" r.assets_url name in
let body = `Other (content_type, asset) in
let* resp = Repo.req_json_v3 http auth repo ~path `POST body in
Ok ()
end
module Pages = struct
let = "gh-pages"
type update =
{ dst : Fpath.t;
src : Fpath.t option;
follow_symlinks : bool }
let update ?(follow_symlinks = true) src ~dst = { dst; src; follow_symlinks }
let nojekyll = update (Some Fpath.null) ~dst:(Fpath.v ".nojekyll")
let fetch_branch r ~log ~remote ~branch =
let* exists = B0_vcs_repo.Git.remote_branch_exists r ~remote ~branch in
match exists with
| false -> Ok None
| true ->
Log.msg log begin fun m ->
m ~header
"Fetching %a" B0_vcs_repo.Git.pp_remote_branch (remote, branch)
end;
let fetch = B0_vcs_repo.Git.remote_branch_fetch r ~remote ~branch in
Result.bind fetch @@ fun () -> Ok (Some (Fmt.str "%s/%s" remote branch))
let do_commit r ~log ~amend ~msg =
Result.bind (B0_vcs_repo.Git.has_staged_changes r) @@ function
| true ->
Log.msg log begin fun m ->
m ~header "%s changes." (if amend then "Amending" else "Commiting")
end;
let* has_commit = B0_vcs_repo.Git.commit_exists r "HEAD" in
let amend = has_commit && amend in
let reset_author = amend in
let stdout = Os.Cmd.out_null in
let* () = B0_vcs_repo.Git.commit ~amend ~reset_author ~stdout ~msg r in
Ok true
| false ->
Log.msg log (fun m -> m ~header "No changes to commit.");
Ok false
let perform_updates ~log ~amend ~msg us r =
Log.msg log (fun m -> m ~header "Copying updates.");
let rm p =
let stdout = Os.Cmd.out_null in
B0_vcs_repo.Git.rm r ~stdout ~force:true ~recurse:true
~ignore_unmatch:true [p]
in
let cp r ~follow_symlinks src dst =
let dst = Fpath.(B0_vcs_repo.(work_dir r) // dst) in
Os.Path.copy ~follow_symlinks ~make_path:true ~recurse:true src ~dst
in
let rec loop r = function
| [] -> do_commit r ~log ~amend ~msg
| u :: us ->
match u.src with
| None -> (rm u.dst |> Result.error_to_failure); loop r us
| Some src ->
(rm u.dst |> Result.error_to_failure);
(cp r ~follow_symlinks:u.follow_symlinks src u.dst
|> Result.error_to_failure);
(B0_vcs_repo.Git.add r ~force:false [u.dst]
|> Result.error_to_failure);
loop r us
in
try loop r us with
| Failure e -> Error e
let update_in_branch r ~log ~amend ~force ~branch cish ~msg us =
Result.join @@
B0_vcs_repo.Git.with_transient_checkout r ~force ~branch cish
(perform_updates ~log ~amend ~msg us)
let default_branch = "gh-pages"
let ubr = "_b0-gh-pages-update"
let commit_updates
?(log = Log.Stdout) ?branch:(br = default_branch) r ~amend ~force ~remote
~msg us
=
let cleanup ~commited r =
let stdout = Os.Cmd.out_null in
let* () = B0_vcs_repo.Git.branch_delete r ~stdout ~force ~branch:ubr in
Ok commited
in
let* () = B0_vcs_repo.Git.check_kind r in
let* cish = fetch_branch r ~log ~remote ~branch:br in
match
update_in_branch r ~log ~amend ~force ~branch:ubr cish ~msg us
with
| Error _ as e-> ignore (cleanup ~commited:false r) ; e
| Ok false -> cleanup ~commited:false r
| Ok true ->
Log.msg log begin fun m ->
m ~header "Pushing %a" B0_vcs_repo.Git.pp_remote_branch (remote, br)
end;
match
B0_vcs_repo.Git.remote_branch_push r ~force ~src:ubr ~remote ~dst:br
with
| Error _ as e -> ignore (cleanup ~commited:true r); e
| Ok () -> cleanup ~commited:true r
end
module Pull_request = struct
let info =
let info url id = url, id in
Jsonq.(succeed info $ mem "html_url" string $ mem "number" int)
let head_of_src ~src_repo ~src_branch =
Fmt.str "%s:%s" (Repo.owner src_repo) src_branch
let find httpr ~auth ~dst_repo ~dst_branch ~src_repo ~src_branch =
let params =
Fmt.str "?state=open&head=%s&base=%s"
(head_of_src ~src_repo ~src_branch) dst_branch
in
let path = Fmt.str "/pulls%s" params in
let* r = Repo.req_json_v3 httpr auth dst_repo ~path `GET `Empty in
let* infos = Jsonq.query (Jsonq.array info) r in
if infos = [] then Ok None else Ok (Some (List.hd infos))
let update httpr ~auth ~dst_repo ~pr_id ~msg =
let params = `Json Jsong.(obj |> mem "body" (string msg) |> obj_end) in
let path = Fmt.str "/pulls/%d" pr_id in
let* r = Repo.req_json_v3 httpr auth dst_repo ~path `PATCH params in
Jsonq.query info r
let create
httpr ~auth ~dst_repo ~dst_branch ~src_repo ~src_branch ~title ~msg
=
let params =
`Json Jsong.(obj
|> mem "title" (string title)
|> mem "head" (string (head_of_src ~src_repo ~src_branch))
|> mem "base" (string dst_branch)
|> mem "body" (string msg)
|> mem "maintainer_can_modify" (bool true)
|> obj_end)
in
let path = "/pulls" in
let* r = Repo.req_json_v3 httpr auth dst_repo ~path `POST params in
Jsonq.query info r
let ensure
httpr ~auth ~dst_repo ~dst_branch ~src_repo ~src_branch ~title ~msg
=
let* dst_repo = Repo.of_url dst_repo in
let* src_repo = Repo.of_url src_repo in
let* pr = find httpr ~auth ~dst_repo ~dst_branch ~src_repo ~src_branch in
let* action, url = match pr with
| Some (url, pr_id) ->
let* url, _id = update httpr ~auth ~dst_repo ~pr_id ~msg in
Ok ("Updated", url)
| None ->
let* url, _id =
create
httpr ~auth ~dst_repo ~dst_branch ~src_repo ~src_branch ~title ~msg
in
Ok ("Opened", url)
in
let pp_action ppf a = Fmt.st [`Fg `Green] ppf a in
Log.stdout begin fun m ->
m "%a pull request %a" pp_action action Fmt.code url
end;
Ok ()
end