OXYGENHUB
◆ v0.6.1 oxygenhub / crates / oh / src / main.rs
77.6 KiB · Rust · last changed Drops: per-user gists, each a small hidden Oxygen repository (#279) 15 days ago source blame history raw
5c6e92fd Andy McCall ↶ 1
//! `oh`: OxygenHub from the command line, as `gh` is for GitHub. It talks
2
//! to the hub's JSON API (hub spec §12) as the checkout's remote does:
3
//! same hub, same token, same CA.
4
5
mod client;
6
mod target;
7
mod ui;
8
9
use anyhow::{bail, Context, Result};
10
use clap::{Parser, Subcommand};
11
use client::Client;
12
use hub_api::{FlowLogLine, FlowRun, Issue, IssueStateChange, NewComment, NewIssue, NewProposal, NewRelease, Proposal, Release};
13
use std::time::Duration;
2bbe88a8 Andy McCall ↶ 14
use target::{Choice, Config, Target};
5c6e92fd Andy McCall ↶ 15
16
#[derive(Parser)]
166f6e9b Andy McCall 17
#[command(name = "oh", version = env!("OXY_VERSION_LONG"), about = "OxygenHub from the command line: issues, proposals, Flow runs, releases and drops")]
5c6e92fd Andy McCall ↶ 18
struct Cli {
19
    /// The repository, when not inside an Oxygen checkout
20
    #[arg(long, short = 'R', global = true, value_name = "OWNER/NAME")]
21
    repo: Option<String>,
22
    /// The hub, with --repo or outside a checkout (default: OH_HUB or the one `oh auth login` saved)
23
    #[arg(long, global = true, value_name = "URL")]
24
    hub: Option<String>,
25
    /// Which of the checkout's remotes to use (default: origin, or the only one)
26
    #[arg(long, global = true)]
27
    remote: Option<String>,
28
    /// Machine-readable output: the API's JSON, unchanged
29
    #[arg(long, global = true)]
30
    json: bool,
31
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
32
    #[arg(long, global = true)]
33
    plain: bool,
34
    #[command(subcommand)]
35
    cmd: Cmd,
36
}
37
38
#[derive(Subcommand)]
39
enum Cmd {
40
    /// Sign in to a hub, or show who you are signed in as
41
    Auth {
42
        #[command(subcommand)]
43
        cmd: AuthCmd,
44
    },
45
    /// The repository
46
    Repo {
47
        #[command(subcommand)]
48
        cmd: RepoCmd,
49
    },
50
    /// Issues
51
    Issue {
52
        #[command(subcommand)]
53
        cmd: IssueCmd,
54
    },
55
    /// Proposals (pull requests)
56
    Proposal {
57
        #[command(subcommand)]
58
        cmd: ProposalCmd,
59
    },
40dd3f86 Andy McCall ↶ 60
    /// Coverage: upload a report measured off the hub (oxygenhub#136)
61
    Coverage {
62
        #[command(subcommand)]
63
        cmd: CoverageCmd,
64
    },
5c6e92fd Andy McCall ↶ 65
    /// OxygenFlow runs
66
    Run {
67
        #[command(subcommand)]
68
        cmd: RunCmd,
69
    },
70
    /// Releases
71
    Release {
72
        #[command(subcommand)]
73
        cmd: ReleaseCmd,
74
    },
2291f8cc Andy McCall ↶ 75
    /// The repository's labels
76
    Label {
77
        #[command(subcommand)]
78
        cmd: LabelCmd,
79
    },
5ea4872f Andy McCall ↶ 80
    /// Milestones: list, create, edit, close
81
    Milestone {
82
        #[command(subcommand)]
83
        cmd: MilestoneCmd,
84
    },
17a3a84b Andy McCall ↶ 85
    /// Project boards of the repository's owner (or --owner): list,
86
    /// create, view, and cards
87
    Project {
88
        #[command(subcommand)]
89
        cmd: ProjectCmd,
90
        /// A user or organisation other than the repository's owner
91
        #[arg(long, global = true)]
92
        owner: Option<String>,
93
    },
62ceb2ee Andy McCall ↶ 94
    /// AI assistants of the repository (its admins), or with --org or
95
    /// --site of an organisation or the site
96
    Assistant {
97
        #[command(subcommand)]
98
        cmd: AssistantCmd,
99
        /// An organisation's assistants instead of the repository's
100
        #[arg(long, global = true)]
101
        org: Option<String>,
102
        /// The site's assistants (administrators)
103
        #[arg(long, global = true, conflicts_with = "org")]
104
        site: bool,
105
    },
8aa5ef38 Andy McCall ↶ 106
    /// Agents that act for you without a key of their own (keyless
107
    /// signing): grant, list, revoke
108
    Agent {
109
        #[command(subcommand)]
110
        cmd: AgentCmd,
111
    },
166f6e9b Andy McCall 112
    /// Drops: small snippets of files, each its own repository, never
113
    /// listed among your repositories (oxygenhub#279)
114
    Drop {
115
        #[command(subcommand)]
116
        cmd: DropCmd,
117
    },
5c6e92fd Andy McCall ↶ 118
    /// Call the API directly: `oh api /repos/OWNER/NAME/issues`
119
    Api {
120
        /// A path under /api/v1
121
        path: String,
122
        /// The method
123
        #[arg(long, short = 'X', default_value = "GET")]
124
        method: String,
125
        /// A JSON body
126
        #[arg(long, short = 'd')]
127
        data: Option<String>,
128
    },
129
}
130
131
#[derive(Subcommand)]
8aa5ef38 Andy McCall ↶ 132
enum AgentCmd {
133
    /// Let an agent act for you: your oxy key (the one your oxy home names
134
    /// as author.key) signs a grant to agent:NAME@YOU, and the hub gives
135
    /// the agent a token it exchanges for ten-minute certificates. The
136
    /// token is printed once: give it to the agent (`oxy auth grant`)
137
    Grant {
138
        /// The agent's name, as in agent:NAME@YOU
139
        name: String,
140
        /// What its keys may do for you: changes, seal, refs
141
        #[arg(long, default_value = "changes,seal,refs")]
142
        scope: String,
143
        /// How long the grant lasts: 30d, 12h, 90m
144
        #[arg(long = "for", value_name = "DURATION", default_value = "30d")]
145
        lasts: String,
146
    },
147
    /// Your grants, live and past
148
    List,
149
    /// Stop a grant at once: no more certificates under it
150
    Revoke { id: i64 },
151
}
152
153
#[derive(Subcommand)]
166f6e9b Andy McCall 154
enum DropCmd {
155
    /// Make a drop of these files, signed by the hub as your delegate
156
    Create {
157
        /// The files (their names in the drop are their file names)
158
        #[arg(required = true)]
159
        files: Vec<std::path::PathBuf>,
160
        /// Unlisted: readable by anyone with its address, listed nowhere
161
        #[arg(long)]
162
        secret: bool,
163
        /// A description
164
        #[arg(long, short = 'd', value_name = "TEXT")]
165
        description: Option<String>,
166
        /// A title (default: the first file's name)
167
        #[arg(long, short = 't')]
168
        title: Option<String>,
169
    },
170
    /// Your drops, or someone's public ones
171
    List {
172
        /// Whose drops
173
        #[arg(long, short = 'u', value_name = "NAME")]
174
        user: Option<String>,
175
    },
176
    /// A drop's files, printed (by id or address)
177
    View {
178
        /// The drop's id, or its address
179
        id: String,
180
        /// Print only this file's content
181
        #[arg(long, short = 'f', value_name = "NAME")]
182
        file: Option<String>,
183
    },
184
    /// Delete a drop and its history
185
    Delete {
186
        /// The drop's id, or its address
187
        id: String,
188
    },
189
}
190
191
#[derive(Subcommand)]
5c6e92fd Andy McCall ↶ 192
enum AuthCmd {
df71c789 Andy McCall ↶ 193
    /// Sign in to a hub and make it the default: approve a code on the web
194
    /// (device sign-in), or give a token
5c6e92fd Andy McCall ↶ 195
    Login {
df71c789 Andy McCall ↶ 196
        /// The hub, e.g. https://oxygenhub.io
5c6e92fd Andy McCall ↶ 197
        #[arg(long = "with-hub", value_name = "URL")]
198
        url: String,
df71c789 Andy McCall ↶ 199
        /// An API token from the hub's Settings → Tokens, instead of
200
        /// approving a code ("-" reads it from stdin)
5c6e92fd Andy McCall ↶ 201
        #[arg(long)]
202
        token: Option<String>,
203
        /// The hub's CA certificate, when self-signed
204
        #[arg(long)]
205
        ca: Option<std::path::PathBuf>,
206
    },
207
    /// Who the token in use belongs to
208
    Status,
209
}
210
211
#[derive(Subcommand)]
212
enum RepoCmd {
213
    /// Name, description, visibility and default bookmark
214
    View,
215
}
216
217
#[derive(Subcommand)]
218
enum IssueCmd {
2291f8cc Andy McCall ↶ 219
    /// Put labels on an issue or take them off (triage)
220
    Label(LabelChange),
5c6e92fd Andy McCall ↶ 221
    /// Open issues (or --state closed|all)
222
    List {
223
        #[arg(long, default_value = "open")]
224
        state: String,
225
    },
226
    /// One issue with its comments
227
    View { number: i64 },
228
    /// Open an issue
229
    Create {
230
        #[arg(long, short)]
231
        title: String,
232
        #[arg(long, short)]
233
        body: Option<String>,
234
        /// Read the body from a file ("-" for stdin)
235
        #[arg(long, short = 'F')]
236
        body_file: Option<String>,
237
        /// A label (repeatable)
238
        #[arg(long, short)]
239
        label: Vec<String>,
17a3a84b Andy McCall ↶ 240
        /// Also put it on this board of the repository's owner
241
        #[arg(long, short)]
242
        project: Option<i64>,
243
        /// The board's column for it, by name (default: the first)
244
        #[arg(long, requires = "project")]
245
        column: Option<String>,
5c6e92fd Andy McCall ↶ 246
    },
247
    /// Close an issue
248
    Close {
249
        number: i64,
250
        /// `completed` or `not_planned`
251
        #[arg(long, default_value = "completed")]
252
        reason: String,
253
    },
254
    /// Reopen an issue
255
    Reopen { number: i64 },
b5229b56 Andy McCall ↶ 256
    /// Change an issue's title or body (its author or triage), or its
257
    /// labels, assignees or milestone as whole sets (triage)
258
    Edit {
259
        number: i64,
260
        #[arg(long, short)]
261
        title: Option<String>,
262
        #[arg(long, short)]
263
        body: Option<String>,
264
        /// Read the body from a file ("-" for stdin)
265
        #[arg(long, short = 'F', conflicts_with = "body")]
266
        body_file: Option<String>,
267
        /// The labels it should have (repeatable; `oh issue label` adds and removes)
268
        #[arg(long, short)]
269
        label: Vec<String>,
270
        /// Clear its labels
271
        #[arg(long, conflicts_with = "label")]
272
        no_labels: bool,
273
        /// The users it should be assigned to (repeatable)
274
        #[arg(long, short)]
275
        assignee: Vec<String>,
276
        /// Take everyone off it
277
        #[arg(long, conflicts_with = "assignee")]
278
        no_assignees: bool,
279
        /// A milestone's title ("" takes it out of its milestone)
280
        #[arg(long, short)]
281
        milestone: Option<String>,
282
    },
5c6e92fd Andy McCall ↶ 283
    /// Comment on an issue
284
    Comment {
285
        number: i64,
286
        #[arg(long, short)]
bd91edeb Andy McCall ↶ 287
        body: Option<String>,
288
        /// Read the body from a file ("-" for stdin), as proposal comment
289
        /// does (#260)
290
        #[arg(long, short = 'F', conflicts_with = "body")]
291
        body_file: Option<String>,
5c6e92fd Andy McCall ↶ 292
    },
293
}
294
295
#[derive(Subcommand)]
296
enum ProposalCmd {
0b20f1ec Andy McCall ↶ 297
    /// Open proposals (or --state draft|landed|closed|all)
298
    List {
299
        #[arg(long, default_value = "open")]
300
        state: String,
301
    },
5c6e92fd Andy McCall ↶ 302
    /// One proposal: its changes, approvals and whether it can land
303
    View { number: i64 },
304
    /// Propose a bookmark's changes for another bookmark
305
    Create {
306
        #[arg(long, short)]
307
        title: String,
308
        #[arg(long, short)]
309
        body: Option<String>,
739a763a Andy McCall ↶ 310
        /// Read the body from a file ("-" for stdin)
311
        #[arg(long, short = 'F', conflicts_with = "body")]
312
        body_file: Option<String>,
5c6e92fd Andy McCall ↶ 313
        /// The bookmark with the changes (or OWNER/NAME:BOOKMARK of a fork)
314
        #[arg(long)]
315
        from: String,
316
        /// The bookmark to land them on (default: the repository's default bookmark)
317
        #[arg(long)]
318
        into: Option<String>,
319
        #[arg(long)]
320
        draft: bool,
739a763a Andy McCall ↶ 321
    },
322
    /// Change a proposal's title or description (its author or a maintainer)
323
    Edit {
324
        number: i64,
325
        #[arg(long, short)]
326
        title: Option<String>,
327
        #[arg(long, short)]
328
        body: Option<String>,
329
        /// Read the body from a file ("-" for stdin)
330
        #[arg(long, short = 'F', conflicts_with = "body")]
331
        body_file: Option<String>,
5c6e92fd Andy McCall ↶ 332
    },
2291f8cc Andy McCall ↶ 333
    /// Put labels on a proposal or take them off (triage)
334
    Label(LabelChange),
739a763a Andy McCall ↶ 335
    /// Comment on a proposal's conversation
336
    Comment {
337
        number: i64,
338
        #[arg(long, short)]
339
        body: Option<String>,
340
        /// Read the comment from a file ("-" for stdin)
341
        #[arg(long, short = 'F', conflicts_with = "body")]
342
        body_file: Option<String>,
343
    },
5c6e92fd Andy McCall ↶ 344
    /// The checks on a proposal's projected state
345
    Checks { number: i64 },
1f1eb06a Andy McCall ↶ 346
    /// Land a proposal on its target, as the page's Land button does
2a8b6216 Andy McCall ↶ 347
    Land {
348
        number: i64,
349
        /// Land before its required checks pass, saying why (maintainers;
350
        /// recorded and audited, #261)
86a69b53 Andy McCall ↶ 351
        #[arg(long, value_name = "REASON", conflicts_with = "when_ready")]
2a8b6216 Andy McCall ↶ 352
        over_checks: Option<String>,
86a69b53 Andy McCall ↶ 353
        /// Land it by itself once its checks pass (#261); with --off, don't
354
        #[arg(long)]
355
        when_ready: bool,
356
        /// With --when-ready: turn it off
357
        #[arg(long, requires = "when_ready")]
358
        off: bool,
2a8b6216 Andy McCall ↶ 359
    },
71e65fcf Andy McCall ↶ 360
    /// Close a proposal without landing it, with an optional comment
361
    Close {
362
        number: i64,
363
        /// Say why, as a comment before it closes
364
        #[arg(long, short = 'm')]
365
        message: Option<String>,
366
    },
367
    /// Reopen a closed proposal
368
    Reopen { number: i64 },
62ceb2ee Andy McCall ↶ 369
}
370
371
#[derive(Subcommand)]
372
enum AssistantCmd {
373
    /// The assistants this level sees, its own and those inherited
374
    List,
375
    /// Add an assistant, or change the one of that name
376
    Add {
377
        /// How it shows on reviews (default: the provider's name)
378
        #[arg(long, default_value = "")]
379
        name: String,
380
        /// anthropic, openai, gemini, deepseek, mistral, perplexity or ollama
381
        #[arg(long, default_value = "")]
382
        preset: String,
383
        /// The endpoint, when no preset or another one
384
        #[arg(long, default_value = "")]
385
        endpoint: String,
386
        /// chat-completions or anthropic-messages
387
        #[arg(long, default_value = "")]
388
        api: String,
389
        /// One the endpoint lists (`oh assistant models` shows them)
390
        #[arg(long)]
391
        model: String,
392
        /// The API key, read from this environment variable (never typed on the command line)
393
        #[arg(long, value_name = "VAR")]
394
        token_env: Option<String>,
395
        /// Add it turned off
396
        #[arg(long)]
397
        off: bool,
398
    },
399
    /// Remove this level's assistant of that name
400
    Remove { name: String },
401
    /// Show this level's prompt, and those it inherits
402
    Prompt {
403
        /// Set it from this file (`-` for standard input; an empty file clears it)
404
        #[arg(long, value_name = "FILE")]
405
        set: Option<std::path::PathBuf>,
406
    },
2291f8cc Andy McCall ↶ 407
}
408
409
#[derive(Subcommand)]
410
enum LabelCmd {
411
    /// The repository's labels
412
    List,
413
    /// Create a label, or change the one of that name (triage)
414
    Create {
415
        name: String,
416
        /// `#rrggbb` (default: one the hub picks)
417
        #[arg(long)]
418
        colour: Option<String>,
419
        #[arg(long, short, default_value = "")]
420
        description: String,
421
    },
422
    /// Delete a label, taking it off every issue and proposal (triage)
423
    Delete { name: String },
17a3a84b Andy McCall ↶ 424
}
425
426
#[derive(Subcommand)]
427
enum ProjectCmd {
428
    /// The owner's boards
429
    List,
430
    /// One board: its columns and cards
431
    View { number: i64 },
432
    /// Create a board (owner, or an organisation's members)
433
    Create {
434
        title: String,
435
        #[arg(long, short, default_value = "")]
436
        description: String,
437
        /// Only the owner (or the organisation's members) can see it
438
        #[arg(long)]
439
        private: bool,
440
    },
441
    /// Close a board
442
    Close { number: i64 },
443
    /// Reopen a board
444
    Reopen { number: i64 },
b1e92a62 Andy McCall ↶ 445
    /// Move a board to another owner you may make boards under (an
446
    /// organisation, say); it takes the next number there
447
    Transfer {
448
        number: i64,
449
        /// The new owner
450
        #[arg(long)]
451
        to: String,
452
    },
17a3a84b Andy McCall ↶ 453
    /// Cards on a board: add, move, remove
454
    Card {
455
        #[command(subcommand)]
456
        cmd: CardCmd,
457
    },
2291f8cc Andy McCall ↶ 458
}
459
5ea4872f Andy McCall ↶ 460
#[derive(Subcommand)]
17a3a84b Andy McCall ↶ 461
enum CardCmd {
462
    /// Put an issue or proposal on a board (`12` or `#12` in this
463
    /// repository, `name#12` or `owner/name#12` in another of the owner's),
464
    /// or a note with --note
465
    Add {
466
        number: i64,
467
        /// The issue or proposal; a number means this repository's
468
        reference: Option<String>,
469
        /// A note card instead
470
        #[arg(long, conflicts_with = "reference")]
471
        note: Option<String>,
472
        /// The column, by name or id (default: the first)
473
        #[arg(long, short)]
474
        column: Option<String>,
475
    },
476
    /// Move a card to another column (by name or id)
477
    Move {
478
        number: i64,
479
        card: i64,
480
        #[arg(long, short)]
481
        column: String,
482
    },
483
    /// Take a card off a board
484
    Remove { number: i64, card: i64 },
485
}
486
487
#[derive(Subcommand)]
5ea4872f Andy McCall ↶ 488
enum MilestoneCmd {
489
    /// The repository's milestones
490
    List,
491
    /// Create a milestone, or change the one of that title (triage)
492
    Create {
493
        title: String,
494
        #[arg(long, short, default_value = "")]
495
        description: String,
496
        /// Due date, YYYY-MM-DD
497
        #[arg(long)]
498
        due: Option<String>,
499
    },
500
    /// Change a milestone's title, description or due date (triage)
501
    Edit {
502
        title: String,
503
        #[arg(long, short)]
504
        rename: Option<String>,
505
        #[arg(long, short)]
506
        description: Option<String>,
507
        /// Due date, YYYY-MM-DD ("" for none)
508
        #[arg(long)]
509
        due: Option<String>,
510
    },
511
    /// Close a milestone
512
    Close { title: String },
513
    /// Reopen a milestone
514
    Reopen { title: String },
515
}
516
2291f8cc Andy McCall ↶ 517
/// Labels to put on or take off an issue or proposal.
518
#[derive(clap::Args)]
519
struct LabelChange {
520
    number: i64,
521
    /// A label to add (repeatable)
522
    #[arg(long, short)]
523
    add: Vec<String>,
524
    /// A label to take off (repeatable)
525
    #[arg(long, short)]
526
    remove: Vec<String>,
527
}
528
529
impl LabelChange {
530
    /// The set after the change, from the one there now.
531
    fn apply(&self, now: &[hub_api::Label]) -> Result<Vec<String>> {
532
        if self.add.is_empty() && self.remove.is_empty() {
533
            bail!("say what to change: --add NAME or --remove NAME");
534
        }
535
        let mut names: Vec<String> = now.iter().map(|l| l.name.clone()).filter(|n| !self.remove.contains(n)).collect();
536
        for a in &self.add {
537
            if !names.contains(a) {
538
                names.push(a.clone());
539
            }
540
        }
541
        Ok(names)
542
    }
543
}
544
545
fn label_names(labels: &[String]) -> String {
546
    if labels.is_empty() {
547
        ui::dim("no labels")
548
    } else {
549
        labels.iter().map(|l| format!("[{l}]")).collect::<Vec<_>>().join(" ")
550
    }
40dd3f86 Andy McCall ↶ 551
}
552
553
#[derive(Subcommand)]
554
enum CoverageCmd {
555
    /// Upload an LCOV or Cobertura report measured on this machine, as the
556
    /// coverage of a bookmark's current state (default: the default
557
    /// bookmark) or of one change's. The hub shows it as uploaded by you,
558
    /// not measured by a runner, and sets no check from it
559
    Upload {
560
        file: std::path::PathBuf,
561
        #[arg(long)]
562
        bookmark: Option<String>,
563
        /// A change's full hash
564
        #[arg(long, conflicts_with = "bookmark")]
565
        change: Option<String>,
566
    },
5c6e92fd Andy McCall ↶ 567
}
568
569
#[derive(Subcommand)]
570
enum RunCmd {
571
    /// Recent runs, newest first
572
    List {
573
        #[arg(long, default_value_t = 20)]
574
        limit: i64,
575
    },
576
    /// A run and its jobs
577
    View { number: i64 },
578
    /// Run the pipeline without a push: a bookmark's current state (default: the default bookmark), or one change's
579
    Start {
580
        #[arg(long)]
581
        bookmark: Option<String>,
582
        /// A change's full hash
583
        #[arg(long, conflicts_with = "bookmark")]
584
        change: Option<String>,
0650d94e Andy McCall ↶ 585
        /// Only this job, with the jobs it depends on (repeatable; default: every job a manual run starts)
586
        #[arg(long = "job", value_name = "NAME")]
587
        jobs: Vec<String>,
5c6e92fd Andy McCall ↶ 588
        /// Wait for the run and exit 1 unless it passes
589
        #[arg(long)]
590
        watch: bool,
591
    },
592
    /// Follow a run until it finishes; exit 1 unless it passes
593
    Watch { number: i64 },
594
    /// A job's log
595
    Log { number: i64, job: String },
e8fa5369 Andy McCall ↶ 596
    /// Run a finished run's state again; with --job, only that job (and the
597
    /// jobs that depend on it) in the same run
598
    Rerun {
599
        number: i64,
600
        #[arg(long)]
601
        job: Option<String>,
602
    },
745d225d Andy McCall ↶ 603
    /// Cancel a run still queued or running; its running jobs are stopped
604
    Cancel { number: i64 },
dc59d1b5 Andy McCall ↶ 605
    /// Approve a job that waits for a person (oxygenflow#57)
606
    Approve {
607
        number: i64,
608
        #[arg(long)]
609
        job: String,
610
    },
611
    /// Reject a job that waits for a person: it ends cancelled
612
    Reject {
613
        number: i64,
614
        #[arg(long)]
615
        job: String,
616
    },
5c6e92fd Andy McCall ↶ 617
}
618
619
#[derive(Subcommand)]
620
enum ReleaseCmd {
621
    /// Releases, newest first
622
    List,
623
    /// Create a release on an existing tag
624
    Create {
625
        tag: String,
626
        #[arg(long, short)]
627
        title: Option<String>,
628
        /// Notes in Markdown (default: generated from the changes since the previous release)
629
        #[arg(long, short)]
630
        notes: Option<String>,
631
        #[arg(long)]
632
        prerelease: bool,
633
    },
ced6d3af Andy McCall ↶ 634
    /// Change a release's title, notes or prerelease flag
635
    Edit {
636
        tag: String,
637
        #[arg(long, short)]
638
        title: Option<String>,
639
        /// New notes in Markdown ("-" reads them from stdin)
640
        #[arg(long, short)]
641
        notes: Option<String>,
642
        /// Mark it a prerelease (true) or not (false)
643
        #[arg(long)]
644
        prerelease: Option<bool>,
645
    },
5c6e92fd Andy McCall ↶ 646
    /// Attach files to a release
647
    Upload { tag: String, files: Vec<std::path::PathBuf> },
648
    /// The release's trust report: signatures, builds, receipts and audit
649
    Trust { tag: String },
650
}
651
652
fn main() {
b5229b56 Andy McCall ↶ 653
    // `oh issue view 90 | head -1`: die quietly on SIGPIPE, as oxy and
654
    // other command-line tools do, instead of panicking on the failed
655
    // write (oxygenhub#92).
656
    #[cfg(unix)]
657
    unsafe {
658
        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
659
    }
5c6e92fd Andy McCall ↶ 660
    let cli = Cli::parse();
661
    ui::init(cli.plain);
662
    match run(cli) {
663
        Ok(code) => std::process::exit(code),
664
        Err(e) => {
665
            eprintln!("{} {e:#}", ui::bad("error:"));
666
            std::process::exit(2);
667
        }
668
    }
669
}
670
671
fn enc(s: &str) -> String {
672
    let mut out = String::new();
673
    for b in s.bytes() {
674
        if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
675
            out.push(b as char);
676
        } else {
677
            out.push_str(&format!("%{b:02X}"));
678
        }
679
    }
680
    out
681
}
682
683
fn run(cli: Cli) -> Result<i32> {
684
    let cfg = Config::load()?;
685
    if let Cmd::Auth { cmd: AuthCmd::Login { url, token, ca } } = &cli.cmd {
686
        return login(cfg, url, token.clone(), ca.clone());
687
    }
688
    let here = std::env::current_dir()?;
689
    let t = target::resolve(&Choice { repo: cli.repo.as_deref(), hub: cli.hub.as_deref(), remote: cli.remote.as_deref() }, &here, &cfg)?;
690
    let c = Client::new(&t)?;
691
    let json = cli.json;
692
    match cli.cmd {
693
        Cmd::Auth { cmd: AuthCmd::Status } => {
694
            let me: serde_json::Value = c.get("/user")?;
695
            if json {
696
                println!("{me}");
697
            } else {
698
                println!("{} signed in to {} as {}", ui::ok("✔"), t.hub, ui::strong(me["username"].as_str().unwrap_or("?")));
699
            }
700
            Ok(0)
701
        }
702
        Cmd::Auth { .. } => unreachable!(),
703
        Cmd::Api { path, method, data } => {
704
            let path = if path.starts_with('/') { path } else { format!("/{path}") };
1b770a1f Andy McCall ↶ 705
            // The method applies with or without a body (#75): without one, a
706
            // GET reads, anything else sends an empty object.
5c6e92fd Andy McCall ↶ 707
            let text = match data {
708
                Some(d) => c.send_text(
709
                    &method.to_uppercase(),
710
                    &path,
711
                    &serde_json::from_str::<serde_json::Value>(&d).context("--data is not JSON")?,
712
                )?,
1b770a1f Andy McCall ↶ 713
                None if method.eq_ignore_ascii_case("GET") => c.get_text(&path)?,
714
                None => c.send_text(&method.to_uppercase(), &path, &serde_json::json!({}))?,
5c6e92fd Andy McCall ↶ 715
            };
716
            println!("{text}");
717
            Ok(0)
718
        }
719
        Cmd::Repo { cmd: RepoCmd::View } => {
720
            let path = t.repo_path()?;
721
            if json {
722
                println!("{}", c.get_text(&path)?);
723
                return Ok(0);
724
            }
725
            let r: serde_json::Value = c.get(&path)?;
726
            println!("{} {}", ui::strong(&format!("{}/{}", t.owner, t.name)), ui::dim(r["visibility"].as_str().unwrap_or("")));
727
            if let Some(d) = r["description"].as_str().filter(|d| !d.is_empty()) {
728
                println!("{d}");
729
            }
730
            println!("{}", ui::kv("default bookmark", r["default_bookmark"].as_str().unwrap_or("")));
731
            println!("{}", ui::kv("clone", &format!("oxy clone {}/{}/{}", t.hub, t.owner, t.name)));
732
            Ok(0)
733
        }
734
        Cmd::Issue { cmd } => issue(&c, &t, cmd, json),
735
        Cmd::Proposal { cmd } => proposal(&c, &t, cmd, json),
736
        Cmd::Run { cmd } => flow(&c, &t, cmd, json),
40dd3f86 Andy McCall ↶ 737
        Cmd::Coverage { cmd: CoverageCmd::Upload { file, bookmark, change } } => {
738
            let content = std::fs::read_to_string(&file).with_context(|| format!("read {}", file.display()))?;
739
            let mut body = serde_json::json!({ "content": content });
740
            if let Some(b) = bookmark {
741
                body["bookmark"] = b.into();
742
            }
743
            if let Some(ch) = change {
744
                body["change"] = ch.into();
745
            }
746
            let base = t.repo_path()?;
747
            let r: hub_api::CoverageReport = c.send("POST", &format!("{base}/coverage"), &body)?;
748
            if json {
749
                println!("{}", serde_json::to_string(&r)?);
750
            } else {
751
                let pct = r.percent.map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into());
752
                let on = if r.bookmark.is_empty() { format!("state {}", &r.state[..12.min(r.state.len())]) } else { r.bookmark.clone() };
753
                println!(
754
                    "{} uploaded coverage {pct} ({} of {} lines, {} files) for {on} {}",
755
                    ui::ok("✔"),
756
                    r.lines_hit,
757
                    r.lines_found,
758
                    r.files,
759
                    ui::dim(&r.url)
760
                );
761
                if r.dropped > 0 {
762
                    println!("  {}", ui::dim(&format!("{} files it named are not in that state and were left out", r.dropped)));
763
                }
764
            }
765
            Ok(0)
766
        }
5c6e92fd Andy McCall ↶ 767
        Cmd::Release { cmd } => release(&c, &t, cmd, json),
2291f8cc Andy McCall ↶ 768
        Cmd::Label { cmd } => label(&c, &t, cmd, json),
5ea4872f Andy McCall ↶ 769
        Cmd::Milestone { cmd } => milestone(&c, &t, cmd, json),
17a3a84b Andy McCall ↶ 770
        Cmd::Project { cmd, owner } => project(&c, &t, cmd, owner, json),
62ceb2ee Andy McCall ↶ 771
        Cmd::Assistant { cmd, org, site } => assistant(&c, &t, cmd, org, site, json),
8aa5ef38 Andy McCall ↶ 772
        Cmd::Agent { cmd } => agent(&c, cmd, json),
166f6e9b Andy McCall 773
        Cmd::Drop { cmd } => drop_cmd(&c, cmd, json),
774
    }
775
}
776
777
/// A drop's id from an id or its address (`https://hub/owner/drops/ID`).
778
fn drop_id(s: &str) -> Result<String> {
779
    let id = s.trim().trim_end_matches('/').rsplit('/').next().unwrap_or("").to_string();
780
    if id.len() < 16 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
781
        bail!("{s:?} is not a drop's id or address");
782
    }
783
    Ok(id.to_ascii_lowercase())
784
}
785
786
fn drop_line(d: &hub_api::Drop) -> String {
787
    let title = if d.title.is_empty() { d.files.first().map(|f| f.name.clone()).unwrap_or_else(|| d.id.clone()) } else { d.title.clone() };
788
    let files: Vec<&str> = d.files.iter().map(|f| f.name.as_str()).collect();
789
    format!(
790
        "{} {} {}{}",
791
        ui::dim(&d.id),
792
        ui::strong(&title),
793
        if d.visibility == "secret" { ui::warn("secret ") } else { String::new() },
794
        ui::dim(&format!("{} · {}", files.join(", "), ui::ago(d.updated_ms)))
795
    )
796
}
797
798
fn drop_cmd(c: &Client, cmd: DropCmd, json: bool) -> Result<i32> {
799
    match cmd {
800
        DropCmd::Create { files, secret, description, title } => {
801
            let mut out = Vec::with_capacity(files.len());
802
            for f in &files {
803
                let name = f.file_name().and_then(|n| n.to_str()).with_context(|| format!("{} has no file name", f.display()))?;
804
                let content =
805
                    std::fs::read_to_string(f).with_context(|| format!("read {} (a drop made here holds text files)", f.display()))?;
806
                out.push(hub_api::NewDropFile { name: name.to_string(), content });
807
            }
808
            let new =
809
                hub_api::NewDrop { title: title.unwrap_or_default(), description: description.unwrap_or_default(), secret, files: out };
810
            let d: hub_api::Drop = c.send("POST", "/drops", &new)?;
811
            if json {
812
                println!("{}", serde_json::to_string(&d)?);
813
            } else {
814
                println!("{} made {}drop {} {}", ui::ok("✔"), if secret { "secret " } else { "" }, ui::strong(&d.id), ui::dim(&d.url));
815
                println!("  {}", ui::dim(&format!("oxy clone {}", d.clone_url)));
816
            }
817
        }
818
        DropCmd::List { user } => {
819
            let path = match &user {
820
                Some(u) => format!("/users/{}/drops", enc(u)),
821
                None => "/drops".to_string(),
822
            };
823
            if json {
824
                println!("{}", c.get_text(&path)?);
825
                return Ok(0);
826
            }
827
            let list: Vec<hub_api::Drop> = c.get(&path)?;
828
            if list.is_empty() {
829
                println!("{}", ui::dim("No drops."));
830
            }
831
            for d in &list {
832
                println!("{}", drop_line(d));
833
            }
834
        }
835
        DropCmd::View { id, file } => {
836
            let path = format!("/drops/{}", drop_id(&id)?);
837
            if json && file.is_none() {
838
                println!("{}", c.get_text(&path)?);
839
                return Ok(0);
840
            }
841
            let d: hub_api::Drop = c.get(&path)?;
842
            if let Some(name) = file {
843
                let f = d.files.iter().find(|f| f.name == name).with_context(|| format!("the drop has no file {name}"))?;
844
                let content = f.content.as_deref().with_context(|| format!("{name} is not shown as text: download {}", f.raw_url))?;
845
                print!("{content}");
846
                return Ok(0);
847
            }
848
            println!("{}", drop_line(&d));
849
            if !d.description.is_empty() {
850
                println!("{}", d.description);
851
            }
852
            println!("{}", ui::kv("url", &d.url));
853
            println!("{}", ui::kv("clone", &format!("oxy clone {}", d.clone_url)));
854
            for f in &d.files {
855
                println!("\n{} {}", ui::strong(&format!("── {}", f.name)), ui::dim(&format!("({} bytes)", f.size)));
856
                match &f.content {
857
                    Some(text) => {
858
                        print!("{text}");
859
                        if !text.ends_with('\n') && !text.is_empty() {
860
                            println!();
861
                        }
862
                    }
863
                    None => println!("{}", ui::dim(&format!("not text, or too large: {}", f.raw_url))),
864
                }
865
            }
866
        }
867
        DropCmd::Delete { id } => {
868
            let id = drop_id(&id)?;
869
            c.send_text("DELETE", &format!("/drops/{id}"), &serde_json::json!({}))?;
870
            println!("{} drop {id}", ui::ok("✔ deleted"));
871
        }
8aa5ef38 Andy McCall ↶ 872
    }
166f6e9b Andy McCall 873
    Ok(0)
8aa5ef38 Andy McCall ↶ 874
}
875
876
/// `30d`, `12h`, `90m` in milliseconds.
877
fn duration_ms(s: &str) -> Result<i64> {
878
    let s = s.trim();
879
    let (n, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
880
    let n: i64 = n.parse().with_context(|| format!("{s:?} is not a duration such as 30d, 12h or 90m"))?;
881
    let per = match unit {
882
        "d" => 86_400_000,
883
        "h" => 3_600_000,
884
        "m" => 60_000,
885
        _ => bail!("{s:?} is not a duration such as 30d, 12h or 90m"),
886
    };
887
    if n <= 0 {
888
        bail!("a grant must last some time");
889
    }
890
    Ok(n * per)
891
}
892
893
fn agent(c: &Client, cmd: AgentCmd, json: bool) -> Result<i32> {
894
    use oxy_core::cert::{Grant, SignedGrant};
895
    use oxy_core::identity::{Identity, Scope};
896
    match cmd {
897
        AgentCmd::Grant { name, scope, lasts } => {
898
            let home = oxy_core::repo::default_home();
899
            let (me_id, sk) = oxy_core::repo::home_key(&home).map_err(|e| anyhow::anyhow!("{e}"))?.with_context(|| {
900
                format!(
901
                    "{} names no key of yours (author.key) with its secret: the grant is signed with your own key, in your own shell",
902
                    home.display()
903
                )
904
            })?;
905
            let me: serde_json::Value = c.get("/user")?;
906
            let username = me["username"].as_str().context("the hub did not say who you are")?;
907
            let hub = Identity::decode(&c.get_root_bytes("/.well-known/oxyhub-key")?).map_err(|e| anyhow::anyhow!("the hub's key: {e}"))?;
908
            let scope = Scope::parse(&scope).map_err(|e| anyhow::anyhow!("{e}"))?;
909
            let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_millis() as i64;
910
            let grant = Grant {
911
                grantor: me_id.hash(),
912
                subject: format!("agent:{name}@{username}"),
913
                issuer: hub.hash(),
914
                scope,
915
                not_before: (now - 60_000) as u64,
916
                expires: (now + duration_ms(&lasts)?) as u64,
917
            };
918
            let signed = SignedGrant::sign(grant, &me_id, &sk);
919
            let r: serde_json::Value =
920
                c.send("POST", "/agents/grants", &serde_json::json!({ "grant": oxy_core::dsse::base64(&signed.encode()) }))?;
921
            if json {
922
                println!("{r}");
923
                return Ok(0);
924
            }
925
            println!(
926
                "{} {} to act for you as {} ({}), until {}",
927
                ui::ok("✔ granted"),
928
                ui::strong(&name),
929
                r["subject"].as_str().unwrap_or(""),
930
                r["scope"].as_str().unwrap_or(""),
931
                ui::date(r["expires_ms"].as_i64().unwrap_or(0))
932
            );
933
            println!("  token (shown once): {}", r["token"].as_str().unwrap_or(""));
934
            println!(
935
                "  {}",
936
                ui::dim("in the agent's shell: oxy auth grant, paste the token; then oxy config --user author.signer keyless")
937
            );
938
            println!("  {}", ui::dim(&format!("revoke it with: oh agent revoke {}", r["id"])));
939
            Ok(0)
940
        }
941
        AgentCmd::List => {
942
            let grants: Vec<serde_json::Value> = c.get("/agents/grants")?;
943
            if json {
944
                println!("{}", serde_json::to_string(&grants)?);
945
                return Ok(0);
946
            }
947
            if grants.is_empty() {
948
                println!("{}", ui::dim("no agent grants"));
949
            }
950
            let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_millis() as i64;
951
            for g in grants {
952
                let state = if g["revoked_ms"].is_i64() {
953
                    ui::bad("revoked")
954
                } else if g["expires_ms"].as_i64().unwrap_or(0) <= now {
955
                    ui::dim("ended")
956
                } else {
957
                    ui::ok("live")
958
                };
959
                let used = g["last_used_ms"].as_i64().map(|t| format!("used {}", ui::ago(t))).unwrap_or_else(|| "never used".into());
960
                println!(
961
                    "#{:<4} {:<8} {} · {} · until {} · {}",
962
                    g["id"],
963
                    state,
964
                    ui::strong(g["subject"].as_str().unwrap_or("")),
965
                    g["scope"].as_str().unwrap_or(""),
966
                    ui::date(g["expires_ms"].as_i64().unwrap_or(0)),
967
                    ui::dim(&used)
968
                );
969
            }
970
            Ok(0)
971
        }
972
        AgentCmd::Revoke { id } => {
973
            c.send_text("DELETE", &format!("/agents/grants/{id}"), &serde_json::json!({}))?;
974
            println!("{} grant #{id}: no more certificates under it", ui::ok("✔ revoked"));
975
            Ok(0)
976
        }
5c6e92fd Andy McCall ↶ 977
    }
978
}
979
980
fn login(mut cfg: Config, url: &str, token: Option<String>, ca: Option<std::path::PathBuf>) -> Result<i32> {
981
    let hub = url.trim_end_matches('/').to_string();
df71c789 Andy McCall ↶ 982
    let token = match token.as_deref() {
983
        Some("-") => {
5c6e92fd Andy McCall ↶ 984
            eprintln!("Paste a token from {hub}/settings/tokens, then press Enter:");
985
            let mut line = String::new();
986
            std::io::stdin().read_line(&mut line)?;
987
            line.trim().to_string()
988
        }
df71c789 Andy McCall ↶ 989
        Some(t) => t.to_string(),
990
        None => device_sign_in(&hub, ca.clone())?,
5c6e92fd Andy McCall ↶ 991
    };
992
    if token.is_empty() {
993
        bail!("no token given");
994
    }
995
    let t = Target { hub: hub.clone(), token: Some(token.clone()), ca: ca.clone(), owner: String::new(), name: String::new() };
996
    let me: serde_json::Value = Client::new(&t)?.get("/user").context("checking the token")?;
2bbe88a8 Andy McCall ↶ 997
    let account = me["username"].as_str().unwrap_or("?");
998
    // One sign-in for oh and oxy alike: the token goes to oxy's store.
999
    let key = oxy_core::credentials::hub_of(&hub).with_context(|| format!("{hub} is not a hub URL"))?;
1000
    let ca = ca.map(|c| std::fs::canonicalize(&c).unwrap_or(c));
1001
    oxy_core::credentials::Credentials::user().save(&key, &token, Some(account), ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
5c6e92fd Andy McCall ↶ 1002
    cfg.default_hub = Some(hub.clone());
1003
    cfg.save()?;
2bbe88a8 Andy McCall ↶ 1004
    println!("{} signed in to {hub} as {}", ui::ok("✔"), ui::strong(account));
1005
    println!("{}", ui::dim("  oxy uses this sign-in too; oxy auth status lists it, oxy auth logout ends it"));
5c6e92fd Andy McCall ↶ 1006
    Ok(0)
1007
}
1008
df71c789 Andy McCall ↶ 1009
/// Device sign-in (oxygenhub#67): the hub gives a code, the user approves
1010
/// it on the web, and the token arrives here.
1011
fn device_sign_in(hub: &str, ca: Option<std::path::PathBuf>) -> Result<String> {
1012
    let t = Target { hub: hub.to_string(), token: None, ca, owner: String::new(), name: String::new() };
1013
    let c = Client::new(&t)?;
1014
    let host = std::fs::read_to_string("/etc/hostname")
1015
        .ok()
1016
        .or_else(|| std::env::var("COMPUTERNAME").ok())
1017
        .map(|h| h.trim().to_string())
1018
        .filter(|h| !h.is_empty());
1019
    let client = match host {
1020
        Some(h) => format!("oh on {h}"),
1021
        None => "oh".to_string(),
1022
    };
1023
    let scopes = ["repo:read", "repo:write", "proposals:write", "issues:write", "user:read"];
1024
    let (code, body) = c.post_raw("/device/code", &serde_json::json!({ "client": client, "scopes": scopes }))?;
1025
    let v: serde_json::Value = serde_json::from_str(&body).context("the hub's answer to a device code request")?;
1026
    if code != 200 {
1027
        bail!("{hub} cannot sign in a device: {}", v["error_description"].as_str().or(v["detail"].as_str()).unwrap_or(&body));
1028
    }
1029
    let device = v["device_code"].as_str().context("no device code")?.to_string();
1030
    let user = v["user_code"].as_str().unwrap_or("?");
1031
    let url = v["verification_uri_complete"].as_str().or(v["verification_uri"].as_str()).unwrap_or(hub);
1032
    let mut interval = v["interval"].as_u64().unwrap_or(5).max(1);
1033
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(v["expires_in"].as_u64().unwrap_or(900));
1034
    eprintln!("Open {} and approve the code {}", ui::strong(url), ui::strong(user));
1035
    eprintln!("{}", ui::dim("Waiting for approval (Ctrl-C to give up)…"));
1036
    loop {
1037
        std::thread::sleep(std::time::Duration::from_secs(interval));
1038
        if std::time::Instant::now() > deadline {
1039
            bail!("the code expired before it was approved; run oh auth login again");
1040
        }
1041
        let (code, body) = c.post_raw("/device/token", &serde_json::json!({ "device_code": device }))?;
1042
        let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
1043
        if code == 200 {
1044
            return v["access_token"].as_str().map(String::from).context("no token in the hub's answer");
1045
        }
1046
        match v["error"].as_str() {
1047
            Some("authorization_pending") => {}
1048
            Some("slow_down") => interval += 5,
1049
            Some("access_denied") => bail!("the sign-in was denied on the web"),
1050
            Some("expired_token") => bail!("the code expired before it was approved; run oh auth login again"),
1051
            _ => bail!("signing in: HTTP {code}: {body}"),
1052
        }
1053
    }
1054
}
1055
5c6e92fd Andy McCall ↶ 1056
fn body_of(body: Option<String>, file: Option<String>) -> Result<String> {
1057
    match (body, file) {
1058
        (Some(b), _) => Ok(b),
1059
        (None, Some(f)) if f == "-" => {
1060
            let mut s = String::new();
1061
            std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
1062
            Ok(s)
1063
        }
1064
        (None, Some(f)) => std::fs::read_to_string(&f).with_context(|| format!("read {f}")),
1065
        (None, None) => Ok(String::new()),
1066
    }
1067
}
1068
1069
fn issue_line(i: &Issue) -> String {
1070
    let glyph = if i.state == "open" { ui::ok("●") } else { ui::dim("○") };
1071
    let labels: Vec<String> = i.labels.iter().map(|l| format!("[{}]", l.name)).collect();
1072
    format!(
1073
        "{glyph} {} {}{}{}",
1074
        ui::dim(&format!("#{}", i.number)),
1075
        i.title,
1076
        if labels.is_empty() { String::new() } else { format!(" {}", ui::dim(&labels.join(" "))) },
1077
        ui::dim(&format!(" · {} · {}", i.author, ui::ago(i.updated_ms)))
1078
    )
1079
}
1080
1081
fn issue(c: &Client, t: &Target, cmd: IssueCmd, json: bool) -> Result<i32> {
1082
    let base = t.repo_path()?;
1083
    match cmd {
1084
        IssueCmd::List { state } => {
1085
            let path = format!("{base}/issues?state={}", enc(&state));
1086
            if json {
1087
                println!("{}", c.get_text(&path)?);
1088
                return Ok(0);
1089
            }
1090
            let list: Vec<Issue> = c.get(&path)?;
1091
            if list.is_empty() {
1092
                println!("{}", ui::dim(&format!("No {state} issues.")));
1093
            }
1094
            for i in &list {
1095
                println!("{}", issue_line(i));
1096
            }
1097
        }
1098
        IssueCmd::View { number } => {
1099
            let path = format!("{base}/issues/{number}");
1100
            if json {
1101
                println!("{}", c.get_text(&path)?);
1102
                return Ok(0);
1103
            }
1104
            let i: Issue = c.get(&path)?;
1105
            println!("{}", issue_line(&i));
1106
            if !i.body.trim().is_empty() {
1107
                println!("\n{}\n", i.body.trim_end());
1108
            }
1109
            let comments: Vec<hub_api::Comment> = c.get(&format!("{path}/comments"))?;
1110
            for cm in comments {
1111
                println!("{} {}\n{}\n", ui::strong(&cm.author), ui::dim(&ui::ago(cm.created_ms)), cm.body.trim_end());
1112
            }
1113
            println!("{}", ui::dim(&i.url));
1114
        }
17a3a84b Andy McCall ↶ 1115
        IssueCmd::Create { title, body, body_file, label, project, column } => {
5c6e92fd Andy McCall ↶ 1116
            let new = NewIssue { title, body: body_of(body, body_file)?, labels: label, assignees: vec![], milestone: None };
1117
            let i: Issue = c.send("POST", &format!("{base}/issues"), &new)?;
1118
            if json {
1119
                println!("{}", serde_json::to_string(&i)?);
1120
            } else {
1121
                println!("{} opened #{} {}", ui::ok("✔"), i.number, ui::dim(&i.url));
17a3a84b Andy McCall ↶ 1122
            }
1123
            if let Some(n) = project {
1124
                let card = hub_api::NewCard { column, reference: format!("{}#{}", t.name, i.number), note: String::new() };
1125
                let added: hub_api::ProjectCard = c.send("POST", &format!("/projects/{}/{n}/cards", t.owner), &card)?;
1126
                if !json {
1127
                    println!("{} on project {n} as card {}", ui::ok("✔"), added.id);
1128
                }
5c6e92fd Andy McCall ↶ 1129
            }
1130
        }
1131
        IssueCmd::Close { number, reason } => {
1132
            let i: Issue = c.send("POST", &format!("{base}/issues/{number}"), &IssueStateChange { state: "closed".into(), reason })?;
1133
            println!("{} closed #{} {}", ui::ok("✔"), i.number, i.title);
1134
        }
1135
        IssueCmd::Reopen { number } => {
1136
            let i: Issue =
1137
                c.send("POST", &format!("{base}/issues/{number}"), &IssueStateChange { state: "open".into(), reason: String::new() })?;
1138
            println!("{} reopened #{} {}", ui::ok("✔"), i.number, i.title);
b5229b56 Andy McCall ↶ 1139
        }
1140
        IssueCmd::Edit { number, title, body, body_file, label, no_labels, assignee, no_assignees, milestone } => {
1141
            let body = match (&body, &body_file) {
1142
                (None, None) => None,
1143
                _ => Some(body_of(body, body_file)?),
1144
            };
1145
            let labels = if no_labels {
1146
                Some(Vec::new())
1147
            } else if label.is_empty() {
1148
                None
1149
            } else {
1150
                Some(label)
1151
            };
1152
            let assignees = if no_assignees {
1153
                Some(Vec::new())
1154
            } else if assignee.is_empty() {
1155
                None
1156
            } else {
1157
                Some(assignee)
1158
            };
1159
            if title.is_none() && body.is_none() && labels.is_none() && assignees.is_none() && milestone.is_none() {
1160
                bail!("say what to change: --title, --body, --body-file, --label, --assignee or --milestone");
1161
            }
1162
            let edit = hub_api::IssueEdit { title, body, labels, assignees, milestone };
1163
            let i: Issue = c.send("PATCH", &format!("{base}/issues/{number}"), &edit)?;
1164
            if json {
1165
                println!("{}", serde_json::to_string(&i)?);
1166
            } else {
1167
                println!("{} edited #{} {}", ui::ok("✔"), i.number, ui::dim(&i.url));
1168
            }
2291f8cc Andy McCall ↶ 1169
        }
1170
        IssueCmd::Label(change) => {
1171
            let path = format!("{base}/issues/{}", change.number);
1172
            let now: Issue = c.get(&path)?;
1173
            let labels = change.apply(&now.labels)?;
1174
            let edit = hub_api::IssueEdit { title: None, body: None, labels: Some(labels.clone()), assignees: None, milestone: None };
1175
            let i: Issue = c.send("PATCH", &path, &edit)?;
1176
            if json {
1177
                println!("{}", serde_json::to_string(&i)?);
1178
            } else {
1179
                println!("{} #{} {}", ui::ok("✔"), i.number, label_names(&labels));
1180
            }
5c6e92fd Andy McCall ↶ 1181
        }
bd91edeb Andy McCall ↶ 1182
        IssueCmd::Comment { number, body, body_file } => {
1183
            let body = body_of(body, body_file)?;
1184
            if body.trim().is_empty() {
1185
                bail!("give the comment with --body or --body-file");
1186
            }
5c6e92fd Andy McCall ↶ 1187
            c.send_text("POST", &format!("{base}/issues/{number}/comments"), &NewComment { body, path: None, line: None })?;
1188
            println!("{} commented on #{number}", ui::ok("✔"));
1189
        }
1190
    }
1191
    Ok(0)
1192
}
1193
1194
fn source_name(s: &hub_api::ProposalSource) -> String {
1195
    match s {
1196
        hub_api::ProposalSource::Bookmark { bookmark } => bookmark.clone(),
1197
        hub_api::ProposalSource::Fork { owner, name, bookmark } => format!("{owner}/{name}:{bookmark}"),
1198
        hub_api::ProposalSource::Changes { hashes } => format!("{} changes", hashes.len()),
1199
    }
1200
}
1201
1202
fn proposal_line(p: &Proposal) -> String {
1203
    let state = serde_json::to_value(p.state).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default();
1204
    let glyph = match state.as_str() {
1205
        "open" => ui::ok("●"),
1206
        "draft" => ui::dim("◌"),
1207
        "landed" => ui::strong("✔"),
1208
        _ => ui::dim("○"),
1209
    };
1210
    format!(
1211
        "{glyph} {} {} {}{}",
1212
        ui::dim(&format!("#{}", p.number)),
1213
        p.title,
1214
        ui::dim(&format!("{} → {}", source_name(&p.source), p.target)),
1215
        ui::dim(&format!(" · {} · {state}", p.author))
1216
    )
1217
}
1218
1219
fn proposal(c: &Client, t: &Target, cmd: ProposalCmd, json: bool) -> Result<i32> {
1220
    let base = t.repo_path()?;
1221
    match cmd {
0b20f1ec Andy McCall ↶ 1222
        ProposalCmd::List { state } => {
1223
            let path = format!("{base}/proposals?state={state}");
5c6e92fd Andy McCall ↶ 1224
            if json {
1225
                println!("{}", c.get_text(&path)?);
1226
                return Ok(0);
1227
            }
1228
            let list: Vec<Proposal> = c.get(&path)?;
1229
            if list.is_empty() {
0b20f1ec Andy McCall ↶ 1230
                println!("{}", ui::dim(&format!("No {} proposals.", if state == "all" { "" } else { &state }).replace("  ", " ")));
5c6e92fd Andy McCall ↶ 1231
            }
1232
            for p in &list {
1233
                println!("{}", proposal_line(p));
1234
            }
1235
        }
1236
        ProposalCmd::View { number } => {
1237
            let path = format!("{base}/proposals/{number}");
1238
            if json {
1239
                println!("{}", c.get_text(&path)?);
1240
                return Ok(0);
1241
            }
1242
            let p: Proposal = c.get(&path)?;
1243
            println!("{}", proposal_line(&p));
1244
            if !p.body.trim().is_empty() {
1245
                println!("\n{}\n", p.body.trim_end());
1246
            }
1247
            println!("{}", ui::kv("changes", &p.changes.len().to_string()));
1248
            for ch in &p.changes {
1249
                println!("  {} {}", ui::dim(&ch.hash[..12.min(ch.hash.len())]), ch.message.lines().next().unwrap_or(""));
1250
            }
1251
            println!("{}", ui::kv("approvals", &p.approvals.iter().map(|a| a.account.clone()).collect::<Vec<_>>().join(", ")));
1252
            if p.landable {
1253
                println!("{} can land", ui::ok("✔"));
1254
            } else {
1255
                for b in &p.blockers {
1256
                    println!("{} {b}", ui::bad("✘"));
1257
                }
1258
            }
1259
            if p.conflicted {
1260
                println!("{} landing it would leave conflicts", ui::bad("!"));
1261
            }
1262
            println!("{}", ui::dim(&p.url));
1263
        }
739a763a Andy McCall ↶ 1264
        ProposalCmd::Create { title, body, body_file, from, into, draft } => {
5c6e92fd Andy McCall ↶ 1265
            let target = match into {
1266
                Some(i) => i,
1267
                None => c.get::<serde_json::Value>(&base)?["default_bookmark"].as_str().unwrap_or("main").to_string(),
1268
            };
739a763a Andy McCall ↶ 1269
            let new = NewProposal { title, body: body_of(body, body_file)?, target, source: from, draft };
5c6e92fd Andy McCall ↶ 1270
            let p: Proposal = c.send("POST", &format!("{base}/proposals"), &new)?;
1271
            if json {
1272
                println!("{}", serde_json::to_string(&p)?);
1273
            } else {
1274
                println!("{} proposed #{} {}", ui::ok("✔"), p.number, ui::dim(&p.url));
1f1eb06a Andy McCall ↶ 1275
            }
739a763a Andy McCall ↶ 1276
        }
1277
        ProposalCmd::Edit { number, title, body, body_file } => {
1278
            let body = match (&body, &body_file) {
1279
                (None, None) => None,
1280
                _ => Some(body_of(body, body_file)?),
1281
            };
1282
            if title.is_none() && body.is_none() {
1283
                bail!("say what to change: --title, --body or --body-file");
1284
            }
71e65fcf Andy McCall ↶ 1285
            let edit = hub_api::ProposalEdit { title, body, labels: None, state: None };
739a763a Andy McCall ↶ 1286
            let p: Proposal = c.send("PATCH", &format!("{base}/proposals/{number}"), &edit)?;
1287
            if json {
1288
                println!("{}", serde_json::to_string(&p)?);
1289
            } else {
1290
                println!("{} edited #{} {}", ui::ok("✔"), p.number, ui::dim(&p.url));
2291f8cc Andy McCall ↶ 1291
            }
1292
        }
1293
        ProposalCmd::Label(change) => {
1294
            let path = format!("{base}/proposals/{}", change.number);
1295
            let now: Proposal = c.get(&path)?;
1296
            let labels = change.apply(&now.labels)?;
1297
            let edit = hub_api::ProposalEdit { labels: Some(labels.clone()), ..Default::default() };
1298
            let p: Proposal = c.send("PATCH", &path, &edit)?;
1299
            if json {
1300
                println!("{}", serde_json::to_string(&p)?);
1301
            } else {
1302
                println!("{} #{} {}", ui::ok("✔"), p.number, label_names(&labels));
739a763a Andy McCall ↶ 1303
            }
1304
        }
1305
        ProposalCmd::Comment { number, body, body_file } => {
1306
            let body = body_of(body, body_file)?;
1307
            if body.trim().is_empty() {
1308
                bail!("give the comment with --body or --body-file");
1309
            }
1310
            c.send_text("POST", &format!("{base}/proposals/{number}/comments"), &NewComment { body, path: None, line: None })?;
1311
            println!("{} commented on #{number}", ui::ok("✔"));
86a69b53 Andy McCall ↶ 1312
        }
1313
        ProposalCmd::Land { number, when_ready: true, off, .. } => {
1314
            let path = format!("{base}/proposals/{number}/auto-land");
1315
            let p: Proposal =
1316
                if off { c.send("DELETE", &path, &serde_json::json!({}))? } else { c.send("POST", &path, &serde_json::json!({}))? };
1317
            if json {
1318
                println!("{}", serde_json::to_string(&p)?);
1319
            } else if p.landed.is_some() {
1320
                println!("{} landed #{} on {}: its checks had passed", ui::ok("✔"), p.number, p.target);
1321
            } else if p.auto_land.is_some() {
1322
                println!("{} #{} will land when its checks pass", ui::ok("✔"), p.number);
1323
            } else {
1324
                println!("{} #{} will not land by itself", ui::ok("✔"), p.number);
1325
            }
1f1eb06a Andy McCall ↶ 1326
        }
86a69b53 Andy McCall ↶ 1327
        ProposalCmd::Land { number, over_checks, .. } => {
2a8b6216 Andy McCall ↶ 1328
            let body = match &over_checks {
1329
                Some(r) => serde_json::json!({ "over_checks": r }),
1330
                None => serde_json::json!({}),
1331
            };
1332
            let p: Proposal = c.send("POST", &format!("{base}/proposals/{number}/land"), &body)?;
1f1eb06a Andy McCall ↶ 1333
            if json {
1334
                println!("{}", serde_json::to_string(&p)?);
1335
            } else {
1336
                println!("{} landed #{} on {}", ui::ok("✔"), p.number, p.target);
71e65fcf Andy McCall ↶ 1337
            }
1338
        }
1339
        ProposalCmd::Close { number, message } => {
1340
            if let Some(body) = message {
1341
                c.send_text("POST", &format!("{base}/proposals/{number}/comments"), &NewComment { body, path: None, line: None })?;
1342
            }
1343
            let p: Proposal = c.send("PATCH", &format!("{base}/proposals/{number}"), &serde_json::json!({ "state": "closed" }))?;
1344
            if json {
1345
                println!("{}", serde_json::to_string(&p)?);
1346
            } else {
1347
                println!("{} closed #{} {}", ui::ok("✔"), p.number, p.title);
1348
            }
1349
        }
1350
        ProposalCmd::Reopen { number } => {
1351
            let p: Proposal = c.send("PATCH", &format!("{base}/proposals/{number}"), &serde_json::json!({ "state": "open" }))?;
1352
            if json {
1353
                println!("{}", serde_json::to_string(&p)?);
1354
            } else {
1355
                println!("{} reopened #{} {}", ui::ok("✔"), p.number, p.title);
5c6e92fd Andy McCall ↶ 1356
            }
1357
        }
1358
        ProposalCmd::Checks { number } => {
1359
            let path = format!("{base}/proposals/{number}/checks");
1360
            if json {
1361
                println!("{}", c.get_text(&path)?);
1362
                return Ok(0);
1363
            }
1364
            let checks: Vec<hub_api::Check> = c.get(&path)?;
1365
            if checks.is_empty() {
1366
                println!("{}", ui::dim("No checks yet."));
1367
            }
1368
            for ch in checks {
1369
                println!("{} {} {}", ui::status(ch.status.as_str()), ch.name, ui::dim(&ch.summary));
1370
            }
1371
        }
1372
    }
1373
    Ok(0)
1374
}
1375
1376
fn run_line(r: &FlowRun) -> String {
1377
    let what =
1378
        if r.bookmark.is_empty() { format!("change {}", r.heads.first().map(|h| &h[..12]).unwrap_or("")) } else { r.bookmark.clone() };
1379
    format!(
1380
        "{} {} {} {}",
1381
        ui::status(&r.status),
1382
        ui::dim(&format!("#{}", r.number)),
1383
        what,
1384
        ui::dim(&format!("· {} by {} · {}", r.trigger, r.actor, ui::ago(r.created_ms)))
1385
    )
1386
}
1387
1388
fn print_run(r: &FlowRun) {
1389
    println!("{}", run_line(r));
1390
    for j in &r.jobs {
1391
        let dur = match (j.started_ms, j.finished_ms) {
1392
            (Some(a), Some(b)) => format!(" {:.1} s", (b - a) as f64 / 1000.0),
1393
            _ => String::new(),
1394
        };
1395
        let on = if j.runner.is_empty() { String::new() } else { format!(" on {}", j.runner) };
1396
        println!("  {} {}{}", ui::status(&j.status), j.name, ui::dim(&format!("{dur}{on}")));
501d8b26 Andy McCall ↶ 1397
        // Why a queued job no runner can take waits (oxygenhub#28), and
1398
        // why a job ran where it did (oxygenflow#42).
67040b15 Andy McCall ↶ 1399
        if j.status == "queued" && !j.waiting.is_empty() {
1400
            println!("    {}", ui::warn(&format!("waiting: {}", j.waiting)));
501d8b26 Andy McCall ↶ 1401
        } else if !j.placed.is_empty() {
1402
            println!("    {}", ui::dim(&format!("placed: {}", j.placed)));
67040b15 Andy McCall ↶ 1403
        }
5c6e92fd Andy McCall ↶ 1404
    }
1405
    if !r.error.is_empty() {
1406
        println!("{}", ui::bad(&r.error));
1407
    }
1408
    println!("{}", ui::dim(&r.url));
1409
}
1410
1411
fn live(status: &str) -> bool {
1412
    matches!(status, "queued" | "running")
dc59d1b5 Andy McCall ↶ 1413
}
1414
1415
/// `oh run approve|reject N --job NAME` (oxygenflow#57).
1416
fn decide_job(c: &Client, base: &str, number: i64, job: &str, verb: &str, json: bool) -> Result<()> {
1417
    let done: serde_json::Value = c.send("POST", &format!("{base}/flow/runs/{number}/jobs/{job}/{verb}"), &serde_json::json!({}))?;
1418
    if json {
1419
        println!("{done}");
1420
    } else {
1421
        println!("{} run #{number}: {}", ui::ok("✔"), done["said"].as_str().unwrap_or(""));
1422
    }
1423
    Ok(())
5c6e92fd Andy McCall ↶ 1424
}
1425
1426
fn watch(c: &Client, base: &str, number: i64, json: bool) -> Result<i32> {
1427
    let path = format!("{base}/flow/runs/{number}");
1428
    let mut seen: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1429
    loop {
1430
        let r: FlowRun = c.get(&path)?;
1431
        if !json {
1432
            for j in &r.jobs {
67040b15 Andy McCall ↶ 1433
                let shown = format!("{}\u{0}{}", j.status, j.waiting);
1434
                if seen.get(&j.name) != Some(&shown) {
5c6e92fd Andy McCall ↶ 1435
                    println!("{} {}", ui::status(&j.status), j.name);
67040b15 Andy McCall ↶ 1436
                    if j.status == "queued" && !j.waiting.is_empty() {
1437
                        println!("  {}", ui::warn(&format!("waiting: {}", j.waiting)));
1438
                    }
1439
                    seen.insert(j.name.clone(), shown);
5c6e92fd Andy McCall ↶ 1440
                }
1441
            }
1442
        }
1443
        if !live(&r.status) {
1444
            if json {
1445
                println!("{}", serde_json::to_string(&r)?);
1446
            } else {
1447
                println!("{} run #{} {}", ui::status(&r.status), r.number, r.status);
1448
            }
1449
            return Ok(if r.status == "passed" { 0 } else { 1 });
1450
        }
1451
        std::thread::sleep(Duration::from_secs(3));
1452
    }
1453
}
1454
1455
fn flow(c: &Client, t: &Target, cmd: RunCmd, json: bool) -> Result<i32> {
1456
    let base = t.repo_path()?;
1457
    match cmd {
1458
        RunCmd::List { limit } => {
1459
            let path = format!("{base}/flow/runs?limit={limit}");
1460
            if json {
1461
                println!("{}", c.get_text(&path)?);
1462
                return Ok(0);
1463
            }
1464
            let runs: Vec<FlowRun> = c.get(&path)?;
1465
            if runs.is_empty() {
1466
                println!("{}", ui::dim("No runs yet."));
1467
            }
1468
            for r in &runs {
1469
                println!("{}", run_line(r));
1470
            }
1471
        }
1472
        RunCmd::View { number } => {
1473
            let path = format!("{base}/flow/runs/{number}");
1474
            if json {
1475
                println!("{}", c.get_text(&path)?);
1476
                return Ok(0);
1477
            }
1478
            print_run(&c.get(&path)?);
1479
        }
0650d94e Andy McCall ↶ 1480
        RunCmd::Start { bookmark, change, jobs, watch: follow } => {
1481
            let mut body = match (bookmark, change) {
5c6e92fd Andy McCall ↶ 1482
                (_, Some(ch)) => serde_json::json!({ "change": ch }),
1483
                (Some(b), None) => serde_json::json!({ "bookmark": b }),
1484
                (None, None) => serde_json::json!({}),
1485
            };
0650d94e Andy McCall ↶ 1486
            if !jobs.is_empty() {
1487
                body["jobs"] = serde_json::json!(jobs);
1488
            }
5c6e92fd Andy McCall ↶ 1489
            let started: serde_json::Value = c.send("POST", &format!("{base}/flow/runs"), &body)?;
1490
            let n = started["number"].as_i64().context("the hub named no run")?;
1491
            if !json || !follow {
1492
                if json {
1493
                    println!("{started}");
1494
                } else {
1495
                    println!("{} started run #{n} {}", ui::ok("✔"), ui::dim(started["url"].as_str().unwrap_or("")));
1496
                }
1497
            }
1498
            if follow {
1499
                return watch(c, &base, n, json);
1500
            }
1501
        }
1502
        RunCmd::Watch { number } => return watch(c, &base, number, json),
1503
        RunCmd::Log { number, job } => {
1504
            let mut after = 0;
1505
            loop {
1506
                let path = format!("{base}/flow/runs/{number}/jobs/{}/log?after={after}&limit=5000", enc(&job));
1507
                let lines: Vec<FlowLogLine> = c.get(&path)?;
1508
                if lines.is_empty() {
1509
                    break;
1510
                }
1511
                for l in &lines {
1512
                    if json {
1513
                        println!("{}", serde_json::to_string(l)?);
1514
                    } else if l.stream == "flow" {
1515
                        println!("{}", ui::dim(&l.text));
1516
                    } else {
1517
                        println!("{}", l.text);
1518
                    }
1519
                }
1520
                after = lines.last().map(|l| l.seq).unwrap_or(after);
e8fa5369 Andy McCall ↶ 1521
            }
1522
        }
1523
        RunCmd::Rerun { number, job: Some(job) } => {
1524
            let queued: serde_json::Value =
1525
                c.send("POST", &format!("{base}/flow/runs/{number}/jobs/{job}/rerun"), &serde_json::json!({}))?;
1526
            if json {
1527
                println!("{queued}");
1528
            } else {
1529
                let n = queued["queued"].as_i64().unwrap_or(1);
1530
                let with = if n > 1 { format!(" with the {} that depend on it", n - 1) } else { String::new() };
1531
                println!("{} queued {job} of run #{number} again{with}", ui::ok("✔"));
5c6e92fd Andy McCall ↶ 1532
            }
1533
        }
e8fa5369 Andy McCall ↶ 1534
        RunCmd::Rerun { number, job: None } => {
5c6e92fd Andy McCall ↶ 1535
            let started: serde_json::Value = c.send("POST", &format!("{base}/flow/runs/{number}/rerun"), &serde_json::json!({}))?;
1536
            if json {
1537
                println!("{started}");
1538
            } else {
1539
                println!("{} started run #{} {}", ui::ok("✔"), started["number"], ui::dim(started["url"].as_str().unwrap_or("")));
2291f8cc Andy McCall ↶ 1540
            }
1541
        }
dc59d1b5 Andy McCall ↶ 1542
        RunCmd::Approve { number, job } => decide_job(c, &base, number, &job, "approve", json)?,
1543
        RunCmd::Reject { number, job } => decide_job(c, &base, number, &job, "reject", json)?,
745d225d Andy McCall ↶ 1544
        RunCmd::Cancel { number } => {
1545
            let done: serde_json::Value = c.send("POST", &format!("{base}/flow/runs/{number}/cancel"), &serde_json::json!({}))?;
1546
            if json {
1547
                println!("{done}");
1548
            } else {
1549
                println!("{} cancelled run #{number}", ui::ok("✔"));
1550
            }
1551
        }
2291f8cc Andy McCall ↶ 1552
    }
1553
    Ok(0)
1554
}
1555
1556
fn label(c: &Client, t: &Target, cmd: LabelCmd, json: bool) -> Result<i32> {
1557
    let base = t.repo_path()?;
1558
    match cmd {
1559
        LabelCmd::List => {
1560
            let path = format!("{base}/labels");
1561
            if json {
1562
                println!("{}", c.get_text(&path)?);
1563
                return Ok(0);
1564
            }
1565
            let labels: Vec<hub_api::Label> = c.get(&path)?;
1566
            if labels.is_empty() {
1567
                println!("{}", ui::dim("No labels."));
5c6e92fd Andy McCall ↶ 1568
            }
2291f8cc Andy McCall ↶ 1569
            for l in labels {
1570
                println!("{} {}  {}", ui::dim(&l.colour), l.name, ui::dim(&l.description));
1571
            }
1572
        }
1573
        LabelCmd::Create { name, colour, description } => {
1574
            let l: hub_api::Label = c.send("POST", &format!("{base}/labels"), &hub_api::NewLabel { name, colour, description })?;
1575
            if json {
1576
                println!("{}", serde_json::to_string(&l)?);
1577
            } else {
1578
                println!("{} label {} {}", ui::ok("✔"), l.name, ui::dim(&l.colour));
1579
            }
1580
        }
1581
        LabelCmd::Delete { name } => {
1582
            c.send_text("DELETE", &format!("{base}/labels/{}", enc(&name)), &serde_json::json!({}))?;
1583
            println!("{} deleted label {name}", ui::ok("✔"));
62ceb2ee Andy McCall ↶ 1584
        }
1585
    }
1586
    Ok(0)
1587
}
1588
1589
fn assistant(c: &Client, t: &Target, cmd: AssistantCmd, org: Option<String>, site: bool, json: bool) -> Result<i32> {
1590
    let base = match (&org, site) {
1591
        (Some(o), _) => format!("/orgs/{}/assistants", enc(o)),
1592
        (None, true) => "/admin/assistants".to_string(),
1593
        (None, false) => format!("{}/assistants", t.repo_path()?),
1594
    };
1595
    match cmd {
1596
        AssistantCmd::List => {
1597
            if json {
1598
                println!("{}", c.get_text(&base)?);
1599
                return Ok(0);
1600
            }
1601
            let list: Vec<hub_api::Assistant> = c.get(&base)?;
1602
            if list.is_empty() {
1603
                println!("{}", ui::dim("No assistants."));
1604
            }
1605
            for a in list {
1606
                let state = if a.enabled && !a.off_above {
1607
                    ui::ok("on")
1608
                } else if a.off_above {
1609
                    ui::dim("off above")
1610
                } else {
1611
                    ui::dim("off")
1612
                };
1613
                let from = if a.from.is_empty() { String::new() } else { ui::dim(&format!(" · from {}", a.from)) };
1614
                println!("{} {}  {} {}{}", state, a.name, ui::dim(&a.model), ui::dim(&a.endpoint), from);
1615
            }
1616
        }
1617
        AssistantCmd::Add { name, preset, endpoint, api, model, token_env, off } => {
1618
            let token = match token_env {
1619
                Some(var) => std::env::var(&var).map_err(|_| anyhow::anyhow!("${var} is not set"))?,
1620
                None => String::new(),
1621
            };
1622
            let body =
1623
                hub_api::NewAssistant { name, preset, endpoint, api, model, token, headers: String::new(), enabled: !off, budget: None };
1624
            let r: serde_json::Value = c.send("POST", &base, &body)?;
1625
            println!("{} {}", ui::ok("✔"), r["ok"].as_str().unwrap_or("saved"));
1626
        }
1627
        AssistantCmd::Remove { name } => {
1628
            c.send_text("DELETE", &format!("{base}/{}", enc(&name)), &serde_json::json!({}))?;
1629
            println!("{} removed {name}", ui::ok("✔"));
1630
        }
1631
        AssistantCmd::Prompt { set } => {
1632
            let p: hub_api::AssistantPrompt = match set {
1633
                Some(path) => {
1634
                    let text = if path.as_os_str() == "-" {
1635
                        let mut s = String::new();
1636
                        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
1637
                        s
1638
                    } else {
1639
                        std::fs::read_to_string(&path)?
1640
                    };
1641
                    c.send("PUT", &format!("{base}/prompt"), &hub_api::AssistantPrompt { prompt: text, inherited: vec![] })?
1642
                }
1643
                None => c.get(&format!("{base}/prompt"))?,
1644
            };
1645
            if json {
1646
                println!("{}", serde_json::to_string(&p)?);
1647
                return Ok(0);
1648
            }
1649
            for (from, text) in &p.inherited {
1650
                println!("{}\n{}\n", ui::dim(&format!("── from {from}")), text);
1651
            }
1652
            if p.prompt.is_empty() {
1653
                println!("{}", ui::dim("No prompt of its own."));
1654
            } else {
1655
                println!("{}\n{}", ui::dim("── this level"), p.prompt);
1656
            }
17a3a84b Andy McCall ↶ 1657
        }
1658
    }
1659
    Ok(0)
1660
}
1661
1662
fn project_line(p: &hub_api::Project) -> String {
1663
    let glyph = if p.closed { ui::dim("▢") } else { ui::ok("▣") };
1664
    let private = if p.public { "" } else { " · private" };
1665
    format!("{glyph} {} {}  {}", ui::dim(&p.number.to_string()), p.title, ui::dim(&format!("updated {}{private}", ui::ago(p.updated_ms))))
1666
}
1667
1668
fn project(c: &Client, t: &Target, cmd: ProjectCmd, owner: Option<String>, json: bool) -> Result<i32> {
1669
    let owner = match owner {
1670
        Some(o) => o,
1671
        None => t.repo()?.0.to_string(),
1672
    };
1673
    let base = format!("/projects/{}", enc(&owner));
1674
    let state = |number: i64, closed: bool, verb: &str| -> Result<()> {
1675
        let p: hub_api::Project =
1676
            c.send("PATCH", &format!("{base}/{number}"), &hub_api::ProjectEdit { closed: Some(closed), ..Default::default() })?;
1677
        if json {
1678
            println!("{}", serde_json::to_string(&p)?);
1679
        } else {
1680
            println!("{} {verb} project {} {}", ui::ok("✔"), p.number, p.title);
1681
        }
1682
        Ok(())
1683
    };
1684
    match cmd {
1685
        ProjectCmd::List => {
1686
            if json {
1687
                println!("{}", c.get_text(&base)?);
1688
                return Ok(0);
1689
            }
1690
            let list: Vec<hub_api::Project> = c.get(&base)?;
1691
            if list.is_empty() {
1692
                println!("{}", ui::dim(&format!("No projects for {owner}: oh project create TITLE")));
1693
            }
1694
            for p in &list {
1695
                println!("{}", project_line(p));
1696
            }
1697
        }
1698
        ProjectCmd::View { number } => {
1699
            let path = format!("{base}/{number}");
1700
            if json {
1701
                println!("{}", c.get_text(&path)?);
1702
                return Ok(0);
1703
            }
1704
            let b: hub_api::Board = c.get(&path)?;
1705
            println!("{}", project_line(&b.project));
1706
            if !b.project.description.is_empty() {
1707
                println!("{}", b.project.description);
1708
            }
1709
            for col in &b.columns {
1710
                println!();
1711
                println!("{} {}", ui::strong(&col.name), ui::dim(&format!("{} · column {}", col.cards.len(), col.id)));
1712
                if col.cards.is_empty() {
1713
                    println!("  {}", ui::dim("(empty)"));
1714
                }
1715
                for card in &col.cards {
1716
                    let what = if card.reference.is_empty() { ui::dim("note") } else { card.reference.clone() };
1717
                    let st = if card.state.is_empty() { String::new() } else { format!(" {}", ui::dim(&card.state)) };
1718
                    println!("  {} {what}{st}  {}", ui::dim(&format!("{:>4}", card.id)), card.title);
1719
                }
1720
            }
1721
            println!("{}", ui::dim(&format!("\n{}", b.project.url)));
1722
        }
1723
        ProjectCmd::Create { title, description, private } => {
1724
            let p: hub_api::Project = c.send("POST", &base, &hub_api::NewProject { title, description, private })?;
1725
            if json {
1726
                println!("{}", serde_json::to_string(&p)?);
1727
            } else {
1728
                println!("{} project {} {} {}", ui::ok("✔"), p.number, p.title, ui::dim(&p.url));
1729
            }
1730
        }
1731
        ProjectCmd::Close { number } => state(number, true, "closed")?,
1732
        ProjectCmd::Reopen { number } => state(number, false, "reopened")?,
b1e92a62 Andy McCall ↶ 1733
        ProjectCmd::Transfer { number, to } => {
1734
            let p: hub_api::Project =
1735
                c.send("PATCH", &format!("{base}/{number}"), &hub_api::ProjectEdit { owner: Some(to.clone()), ..Default::default() })?;
1736
            if json {
1737
                println!("{}", serde_json::to_string(&p)?);
1738
            } else {
1739
                println!("{} transferred project {} to {to}: now {} {}", ui::ok("✔"), number, p.number, p.url);
1740
            }
1741
        }
17a3a84b Andy McCall ↶ 1742
        ProjectCmd::Card { cmd: CardCmd::Add { number, reference, note, column } } => {
1743
            // The hub names a card's issue as `repo#N` among the owner's
1744
            // repositories.
1745
            let reference = match reference {
1746
                None => String::new(),
1747
                Some(r) if r.contains('#') && !r.starts_with('#') => match r.split_once('/') {
1748
                    Some((o, rest)) if o == owner => rest.to_string(),
1749
                    Some(_) => bail!("{r}: a project holds only {owner}'s issues and proposals"),
1750
                    None => r,
1751
                },
1752
                Some(r) => {
1753
                    let n = r.trim_start_matches('#');
1754
                    if n.parse::<i64>().is_err() {
1755
                        bail!("{r}: give an issue or proposal number, or NAME#N");
1756
                    }
1757
                    format!("{}#{n}", t.repo()?.1)
1758
                }
1759
            };
1760
            let note = note.unwrap_or_default();
1761
            if reference.is_empty() && note.is_empty() {
1762
                bail!("say what to add: an issue or proposal (12, #12, owner/name#12) or --note TEXT");
1763
            }
1764
            let card: hub_api::ProjectCard =
1765
                c.send("POST", &format!("{base}/{number}/cards"), &hub_api::NewCard { column, reference, note })?;
1766
            if json {
1767
                println!("{}", serde_json::to_string(&card)?);
1768
            } else {
1769
                println!("{} card {} {}", ui::ok("✔"), card.id, if card.reference.is_empty() { &card.title } else { &card.reference });
1770
            }
1771
        }
1772
        ProjectCmd::Card { cmd: CardCmd::Move { number, card, column } } => {
1773
            c.send_text("PATCH", &format!("{base}/{number}/cards/{card}"), &hub_api::CardMove { column: column.clone() })?;
1774
            println!("{} card {card} moved to {column}", ui::ok("✔"));
1775
        }
1776
        ProjectCmd::Card { cmd: CardCmd::Remove { number, card } } => {
1777
            c.send_text("DELETE", &format!("{base}/{number}/cards/{card}"), &serde_json::json!({}))?;
1778
            println!("{} card {card} removed", ui::ok("✔"));
5ea4872f Andy McCall ↶ 1779
        }
1780
    }
1781
    Ok(0)
1782
}
1783
1784
fn milestone_line(m: &hub_api::Milestone) -> String {
1785
    let glyph = if m.state == "open" { ui::ok("◆") } else { ui::dim("◇") };
1786
    let due = m.due_ms.map(|d| format!(" · due {}", ui::ago(d))).unwrap_or_default();
1787
    format!("{glyph} {}  {}{}", m.title, ui::dim(&format!("{} open, {} closed", m.open, m.closed)), ui::dim(&due))
1788
}
1789
1790
fn milestone(c: &Client, t: &Target, cmd: MilestoneCmd, json: bool) -> Result<i32> {
1791
    let base = t.repo_path()?;
1792
    let edit = |title: &str, e: hub_api::MilestoneEdit, verb: &str| -> Result<()> {
1793
        let m: hub_api::Milestone = c.send("PATCH", &format!("{base}/milestones/{}", enc(title)), &e)?;
1794
        if json {
1795
            println!("{}", serde_json::to_string(&m)?);
1796
        } else {
1797
            println!("{} {verb} milestone {}", ui::ok("✔"), m.title);
1798
        }
1799
        Ok(())
1800
    };
1801
    match cmd {
1802
        MilestoneCmd::List => {
1803
            let path = format!("{base}/milestones");
1804
            if json {
1805
                println!("{}", c.get_text(&path)?);
1806
                return Ok(0);
1807
            }
1808
            let list: Vec<hub_api::Milestone> = c.get(&path)?;
1809
            if list.is_empty() {
1810
                println!("{}", ui::dim("No milestones."));
1811
            }
1812
            for m in &list {
1813
                println!("{}", milestone_line(m));
1814
            }
1815
        }
1816
        MilestoneCmd::Create { title, description, due } => {
1817
            let m: hub_api::Milestone =
1818
                c.send("POST", &format!("{base}/milestones"), &hub_api::NewMilestone { title, description, due })?;
1819
            if json {
1820
                println!("{}", serde_json::to_string(&m)?);
1821
            } else {
1822
                println!("{} milestone {}", ui::ok("✔"), m.title);
1823
            }
1824
        }
1825
        MilestoneCmd::Edit { title, rename, description, due } => {
1826
            if rename.is_none() && description.is_none() && due.is_none() {
1827
                bail!("say what to change: --rename, --description or --due");
1828
            }
1829
            edit(&title, hub_api::MilestoneEdit { title: rename, description, due, state: None }, "edited")?;
1830
        }
1831
        MilestoneCmd::Close { title } => {
1832
            edit(&title, hub_api::MilestoneEdit { state: Some("closed".into()), ..Default::default() }, "closed")?;
1833
        }
1834
        MilestoneCmd::Reopen { title } => {
1835
            edit(&title, hub_api::MilestoneEdit { state: Some("open".into()), ..Default::default() }, "reopened")?;
5c6e92fd Andy McCall ↶ 1836
        }
1837
    }
1838
    Ok(0)
1839
}
1840
1841
fn release(c: &Client, t: &Target, cmd: ReleaseCmd, json: bool) -> Result<i32> {
1842
    let base = t.repo_path()?;
1843
    match cmd {
1844
        ReleaseCmd::List => {
1845
            let path = format!("{base}/releases");
1846
            if json {
1847
                println!("{}", c.get_text(&path)?);
1848
                return Ok(0);
1849
            }
1850
            let list: Vec<Release> = c.get(&path)?;
1851
            if list.is_empty() {
1852
                println!("{}", ui::dim("No releases."));
1853
            }
1854
            for r in &list {
1855
                println!(
1856
                    "{} {} {}{}",
1857
                    if r.latest { ui::ok("●") } else { ui::dim("○") },
1858
                    ui::strong(&r.tag),
1859
                    r.title,
1860
                    ui::dim(&format!(
1861
                        " · {} asset{} · {}",
1862
                        r.assets.len(),
1863
                        if r.assets.len() == 1 { "" } else { "s" },
1864
                        ui::ago(r.created_ms)
1865
                    ))
1866
                );
1867
            }
1868
        }
1869
        ReleaseCmd::Create { tag, title, notes, prerelease } => {
1870
            let new = NewRelease { title: title.unwrap_or_else(|| tag.clone()), tag, notes, prerelease };
1871
            let r: Release = c.send("POST", &format!("{base}/releases"), &new)?;
1872
            if json {
1873
                println!("{}", serde_json::to_string(&r)?);
1874
            } else {
1875
                println!("{} released {} {}", ui::ok("✔"), r.tag, ui::dim(&r.url));
ced6d3af Andy McCall ↶ 1876
            }
1877
        }
1878
        ReleaseCmd::Edit { tag, title, notes, prerelease } => {
1879
            let notes = match notes.as_deref() {
1880
                Some("-") => Some(body_of(None, Some("-".into()))?),
1881
                _ => notes,
1882
            };
1883
            if title.is_none() && notes.is_none() && prerelease.is_none() {
1884
                bail!("say what to change: --title, --notes or --prerelease");
1885
            }
1886
            let edit = hub_api::ReleaseEdit { title, notes, prerelease };
1887
            let r: Release = c.send("PATCH", &format!("{base}/releases/{tag}"), &edit)?;
1888
            if json {
1889
                println!("{}", serde_json::to_string(&r)?);
1890
            } else {
1891
                println!("{} edited {} {}", ui::ok("✔"), r.tag, ui::dim(&r.url));
5c6e92fd Andy McCall ↶ 1892
            }
1893
        }
1894
        ReleaseCmd::Upload { tag, files } => {
1895
            if files.is_empty() {
1896
                bail!("name the files to attach");
1897
            }
1898
            for f in files {
1899
                let name = f.file_name().and_then(|n| n.to_str()).context("a file name")?.to_string();
1900
                let bytes = std::fs::read(&f).with_context(|| format!("read {}", f.display()))?;
1901
                c.upload(&format!("{base}/releases/{}/assets?name={}", enc(&tag), enc(&name)), &bytes)?;
1902
                println!("{} attached {name} to {tag}", ui::ok("✔"));
1903
            }
1904
        }
1905
        ReleaseCmd::Trust { tag } => {
1906
            let path = format!("{base}/releases/trust/{}", enc(&tag));
1907
            if json {
1908
                println!("{}", c.get_text(&path)?);
1909
                return Ok(0);
1910
            }
1911
            let r: serde_json::Value = c.get(&path)?;
1912
            println!("{} {}", ui::strong(&format!("Trust report for {tag}")), ui::dim(&format!("{} changes", r["changes"])));
1913
            let mut worst = 0;
1914
            for ch in r["audit"].as_array().into_iter().flatten() {
1915
                let v = ch["verdict"].as_str().unwrap_or("");
1916
                worst = worst.max(match v {
1917
                    "fail" => 2,
1918
                    "warn" => 1,
1919
                    _ => 0,
1920
                });
1921
                let g = match v {
1922
                    "pass" => ui::ok("✔"),
1923
                    "warn" => ui::warn("!"),
1924
                    _ => ui::bad("✘"),
1925
                };
1926
                println!("{g} {} {}", ch["title"].as_str().unwrap_or(""), ui::dim(ch["detail"].as_str().unwrap_or("")));
1927
            }
1928
            return Ok(if worst == 2 { 1 } else { 0 });
1929
        }
1930
    }
1931
    Ok(0)
166f6e9b Andy McCall 1932
}
1933
1934
#[cfg(test)]
1935
mod tests {
1936
    use super::*;
1937
1938
    /// `oh drop` (oxygenhub#279): its commands and flags parse as the
1939
    /// help says, and a drop is named by its id or its address.
1940
    #[test]
1941
    fn drop_commands_parse() {
1942
        let cli = Cli::try_parse_from(["oh", "drop", "create", "a.txt", "b.md", "--secret", "-d", "Two files", "-t", "Notes"]).unwrap();
1943
        match cli.cmd {
1944
            Cmd::Drop { cmd: DropCmd::Create { files, secret, description, title } } => {
1945
                assert_eq!(files, vec![std::path::PathBuf::from("a.txt"), std::path::PathBuf::from("b.md")]);
1946
                assert!(secret);
1947
                assert_eq!((description.as_deref(), title.as_deref()), (Some("Two files"), Some("Notes")));
1948
            }
1949
            _ => panic!("not drop create"),
1950
        }
1951
        assert!(Cli::try_parse_from(["oh", "drop", "create"]).is_err(), "a drop needs a file");
1952
        match Cli::try_parse_from(["oh", "drop", "list", "--user", "bob", "--json"]).unwrap() {
1953
            Cli { cmd: Cmd::Drop { cmd: DropCmd::List { user } }, json, .. } => assert_eq!((user.as_deref(), json), (Some("bob"), true)),
1954
            _ => panic!("not drop list"),
1955
        }
1956
        match Cli::try_parse_from(["oh", "drop", "view", "0123456789abcdef01234567", "-f", "a.txt"]).unwrap().cmd {
1957
            Cmd::Drop { cmd: DropCmd::View { id, file } } => {
1958
                assert_eq!((id.as_str(), file.as_deref()), ("0123456789abcdef01234567", Some("a.txt")))
1959
            }
1960
            _ => panic!("not drop view"),
1961
        }
1962
        assert!(matches!(Cli::try_parse_from(["oh", "drop", "delete", "x"]).unwrap().cmd, Cmd::Drop { cmd: DropCmd::Delete { .. } }));
1963
        assert_eq!(drop_id("0123456789ABCDEF01234567").unwrap(), "0123456789abcdef01234567");
1964
        assert_eq!(drop_id("https://hub.example/andy/drops/0123456789abcdef01234567/").unwrap(), "0123456789abcdef01234567");
1965
        assert!(drop_id("andy/notes").is_err());
1966
    }
5c6e92fd Andy McCall ↶ 1967
}