Reach the milestone write routes from both CLIs

a3e53b2acc1a · AtlantisPleb · · parent a4a392d3a91d

Reach the milestone write routes from both CLIs

The API has carried the whole GitHub-compatible milestone surface since the
routes landed -- create, update, delete, and `PATCH .../issues/:n` with a
`milestone` key. Neither CLI reached the write half.

`oa issue milestones` listed them and `issue create --milestone` attached one at
creation, so an issue filed without a milestone could never be given one, and a
milestone could only be opened or removed in a browser. `create_milestone` and
`delete_milestone` already existed on the Rust client and were wired to no
subcommand -- dead code that looked like a feature. The TypeScript CLI had no
milestone client functions at all and could not even list them.

The practical effect is that milestones were manageable only by a person at a
browser, which makes them useless to agents -- and agents file most of the issues
here.

Adds, on both CLIs:

  oa issue milestone <issue> --set <n> | --clear
  oa milestone list | create <title> | delete <n>
  openagents issue milestone <issue> --set <n> | --clear
  openagents issue milestones
  openagents milestone list | create <title> | delete <n>

Three things the tests pin, because each is a way to report success without
doing the work:

  - `--clear` sends an explicit `{"milestone": null}`. Omitting the key leaves
    the milestone where it is and still answers 200, which is a clear that does
    nothing and says it worked.
  - `--set` and `--clear` together, or neither, is refused with exit 2 rather
    than resolved by guessing -- guessing is how an issue lands on the milestone
    the caller was trying to remove.
  - The line printed after a set names the milestone the server RETURNED, not
    the number that was passed in, so a server that stored something else is
    visible instead of echoed over.

The listing has one renderer behind both of its names, so `milestone list` and
`issue milestones` cannot describe the same repository differently.

The Rust milestone tests run against a stub server on localhost rather than the
live tracker: these are write paths, and a test that proves `milestone delete`
works by deleting a real milestone is not one anyone can run twice. They assert
the method, path, and body that went on the wire, which is what was wrong -- a
route that was never called, not a response that was misread.

Verified by deletion: making `--clear` omit the key instead of sending null
fails 1 Rust test and 1 TypeScript test by name.

No server change: the routes, the context functions, and their tests were
already there. Confirmed against router.ex before building.

Refs #101

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/tracker.rs
  • added crates/openagents-cli/tests/milestone_test.rs
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/issue-client.ts
  • modified packages/openagents-cli/test/issue-client.test.ts
  • modified packages/openagents-cli/test/issue-command.test.ts

Diff

7 files changed, +961 -19

crates/openagents-cli/src/cli.rs modified +175 -19

@@ -58,6 +58,8 @@ pub enum Commands {

58 58
    Issue(IssueArgs),
59 59
    /// OpenAgents project management
60 60
    Project(ProjectArgs),
61
    /// Repository milestones
62
    Milestone(MilestoneArgs),
61 63
    /// Repository tracking and operations
62 64
    Repo(RepoArgs),
63 65
    /// OpenAgents interactive Coder agent session and autonomous tools

@@ -312,6 +314,53 @@ pub enum IssueAction {

312 314
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
313 315
        repo: Option<String>,
314 316
    },
317
    /// Put an existing issue on a milestone, or take it off one
318
    ///
319
    /// `issue create --milestone` was the only way to attach one, so an issue
320
    /// filed without a milestone could never be given one outside the browser.
321
    Milestone {
322
        #[arg(help = "Issue number")]
323
        number: u64,
324
        #[arg(long, value_name = "NUMBER", help = "Milestone number to put the issue on")]
325
        set: Option<u64>,
326
        #[arg(long, help = "Take the issue off whatever milestone it is on")]
327
        clear: bool,
328
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
329
        repo: Option<String>,
330
    },
331
}
332
333
#[derive(Args, Debug)]
334
pub struct MilestoneArgs {
335
    #[command(subcommand)]
336
    pub action: MilestoneAction,
337
}
338
339
#[derive(Subcommand, Debug)]
340
pub enum MilestoneAction {
341
    /// List the milestones of a repository
342
    List {
343
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
344
        repo: Option<String>,
345
    },
346
    /// Open a new milestone
347
    Create {
348
        #[arg(help = "Milestone title")]
349
        title: String,
350
        #[arg(long, help = "What the milestone is for")]
351
        description: Option<String>,
352
        #[arg(long, value_name = "DATE", help = "Due date, as the server stores it")]
353
        due_on: Option<String>,
354
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
355
        repo: Option<String>,
356
    },
357
    /// Delete a milestone
358
    Delete {
359
        #[arg(help = "Milestone number")]
360
        number: u64,
361
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
362
        repo: Option<String>,
363
    },
315 364
}
316 365
317 366
#[derive(Args, Debug)]

@@ -1022,6 +1071,9 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1022 1071
        Commands::Project(project) => {
1023 1072
            run_project(project.action, &api_base, token, cli.json).await
1024 1073
        }
1074
        Commands::Milestone(milestone) => {
1075
            run_milestone(milestone.action, &api_base, token, cli.json).await
1076
        }
1025 1077
        Commands::Repo(repo) => run_repo(repo.action, &endpoint, &cred_store, cli.json).await,
1026 1078
        Commands::Coder(coder) => {
1027 1079
            // The session talks to the selected endpoint like every other

@@ -2251,26 +2303,130 @@ async fn run_issue(action: IssueAction, api_base: &str, token: Option<String>, j

2251 2303
        IssueAction::Milestones { repo } => {
2252 2304
            let target = target_or_fail(repo);
2253 2305
            let value = or_fail(tracker.list_milestones(&target).await);
2254
            let rows = value
2255
                .get("milestones")
2256
                .and_then(serde_json::Value::as_array)
2257
                .cloned()
2258
                .unwrap_or_default();
2259
            let human: Vec<String> = if rows.is_empty() {
2260
                vec!["No milestones found.".to_string()]
2261
            } else {
2262
                rows.iter()
2263
                    .map(|row| {
2264
                        format!(
2265
                            "{}{}{}",
2266
                            pad(&format!("#{}", number_or_question(row, "number")), 7),
2267
                            pad(&field(row, "state"), 8),
2268
                            field(row, "title")
2269
                        )
2270
                    })
2271
                    .collect()
2306
            emit(json, &value, &milestone_listing(&value));
2307
        }
2308
        IssueAction::Milestone {
2309
            number,
2310
            set,
2311
            clear,
2312
            repo,
2313
        } => {
2314
            // Two ways to say what the milestone should become, and they
2315
            // disagree. Guessing which one was meant is how an issue ends up on
2316
            // a milestone the caller was trying to take it off.
2317
            let milestone = match (set, clear) {
2318
                (Some(_), true) => {
2319
                    fail("Use either --set or --clear, not both.");
2320
                }
2321
                (None, false) => {
2322
                    fail(
2323
                        "Say what the milestone should become: --set <number> to put the issue on one, or --clear to take it off.",
2324
                    );
2325
                }
2326
                (Some(number), false) => Some(number),
2327
                (None, true) => None,
2272 2328
            };
2273
            emit(json, &value, &human);
2329
            let target = target_or_fail(repo);
2330
            let value = or_fail(tracker.set_issue_milestone(&target, number, milestone).await);
2331
            // Report what came BACK, not what was asked for. A server that
2332
            // accepted the request and stored something else is the case a
2333
            // printed echo of the argument would hide.
2334
            let stored = value.get("milestone").and_then(|m| {
2335
                if m.is_null() {
2336
                    None
2337
                } else {
2338
                    Some(m)
2339
                }
2340
            });
2341
            let human = match stored {
2342
                Some(m) => format!(
2343
                    "Issue #{} is on milestone #{} {}",
2344
                    number,
2345
                    number_or_question(m, "number"),
2346
                    field(m, "title")
2347
                ),
2348
                None => format!("Issue #{} is on no milestone.", number),
2349
            };
2350
            emit(json, &value, &[human]);
2351
        }
2352
    }
2353
}
2354
2355
/// The rows `milestone list` and `issue milestones` both print.
2356
///
2357
/// One renderer, so the two entry points cannot describe the same milestone
2358
/// differently.
2359
fn milestone_listing(value: &serde_json::Value) -> Vec<String> {
2360
    let rows = value
2361
        .get("milestones")
2362
        .and_then(serde_json::Value::as_array)
2363
        .cloned()
2364
        .unwrap_or_default();
2365
    if rows.is_empty() {
2366
        return vec!["No milestones found.".to_string()];
2367
    }
2368
    rows.iter()
2369
        .map(|row| {
2370
            format!(
2371
                "{}{}{}",
2372
                pad(&format!("#{}", number_or_question(row, "number")), 7),
2373
                pad(&field(row, "state"), 8),
2374
                field(row, "title")
2375
            )
2376
        })
2377
        .collect()
2378
}
2379
2380
/// `oa milestone`: the write half the API has always had and neither CLI reached.
2381
///
2382
/// `create` and `delete` existed on the client and were wired to nothing, so a
2383
/// milestone could only be opened or removed in a browser -- which made
2384
/// milestones useless to agents, and agents file most of the issues here.
2385
async fn run_milestone(
2386
    action: MilestoneAction,
2387
    api_base: &str,
2388
    token: Option<String>,
2389
    json: bool,
2390
) {
2391
    let tracker = crate::tracker::TrackerClient::new(api_base, token);
2392
    match action {
2393
        MilestoneAction::List { repo } => {
2394
            let target = target_or_fail(repo);
2395
            let value = or_fail(tracker.list_milestones(&target).await);
2396
            emit(json, &value, &milestone_listing(&value));
2397
        }
2398
        MilestoneAction::Create {
2399
            title,
2400
            description,
2401
            due_on,
2402
            repo,
2403
        } => {
2404
            let target = target_or_fail(repo);
2405
            let value = or_fail(
2406
                tracker
2407
                    .create_milestone(
2408
                        &target,
2409
                        &title,
2410
                        description.as_deref(),
2411
                        due_on.as_deref(),
2412
                    )
2413
                    .await,
2414
            );
2415
            // The server assigns the number. Printing the one it returned is
2416
            // the only way the caller learns what to pass to `--set`.
2417
            let human = format!(
2418
                "Opened milestone #{} {}",
2419
                number_or_question(&value, "number"),
2420
                field(&value, "title")
2421
            );
2422
            emit(json, &value, &[human]);
2423
        }
2424
        MilestoneAction::Delete { number, repo } => {
2425
            let target = target_or_fail(repo);
2426
            // A 204 carries no body, so there is nothing to report back but the
2427
            // number that was asked for and the fact that the server accepted it.
2428
            let value = or_fail(tracker.delete_milestone(&target, number).await);
2429
            emit(json, &value, &[format!("Deleted milestone #{}.", number)]);
2274 2430
        }
2275 2431
    }
2276 2432
}
crates/openagents-cli/src/tracker.rs modified +24

@@ -523,6 +523,30 @@ impl TrackerClient {

523 523
        .await
524 524
    }
525 525
526
    /// Put an existing issue on a milestone, or take it off one.
527
    ///
528
    /// `None` sends an explicit JSON `null`, which is how the server is told to
529
    /// clear the field; omitting the key would leave the milestone where it is
530
    /// and report success, which is the shape of "clear" that does nothing.
531
    ///
532
    /// Like [`Self::set_issue_state`], this sends only the one field. A `PATCH`
533
    /// carrying `body` would replace the issue text.
534
    pub async fn set_issue_milestone(
535
        &self,
536
        target: &RepoTarget,
537
        number: u64,
538
        milestone: Option<u64>,
539
    ) -> Result<Value, ApiError> {
540
        self.request(
541
            "set the milestone of an issue",
542
            "PATCH",
543
            &Self::issue_path(target, number),
544
            Some(json!({ "milestone": milestone })),
545
            &[200],
546
        )
547
        .await
548
    }
549
526 550
    pub async fn list_comments(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
527 551
        self.request(
528 552
            "list issue comments",
crates/openagents-cli/tests/milestone_test.rs added +231

@@ -0,0 +1,231 @@

1
//! The milestone write half of the tracker client, asserted on the request it sends.
2
//!
3
//! `create_milestone` and `delete_milestone` existed on `TrackerClient` and were
4
//! wired to no subcommand, and there was no way at all to put an existing issue on
5
//! a milestone: `issue create --milestone` was the only write. So milestones could
6
//! only be managed in a browser, which makes them useless to agents — and agents
7
//! file most of the issues here.
8
//!
9
//! These run against a stub server on localhost rather than the live tracker,
10
//! because they are WRITE paths: a test that proves `milestone delete` works by
11
//! deleting a real milestone is not a test anyone can run twice. The assertions are
12
//! on the method, path, and body the client actually put on the wire, which is what
13
//! was wrong — a route that was never called, not a response that was misread.
14
15
use openagents_cli::tracker::{RepoTarget, TrackerClient};
16
use std::io::{BufRead, BufReader, Read, Write};
17
use std::net::TcpListener;
18
use std::sync::mpsc::{channel, Receiver};
19
20
/// One request the stub server saw, reduced to what these tests assert on.
21
#[derive(Debug, Clone)]
22
struct SeenRequest {
23
    method: String,
24
    path: String,
25
    body: serde_json::Value,
26
}
27
28
struct StubApi {
29
    base: String,
30
    seen: Receiver<SeenRequest>,
31
}
32
33
/// Serve exactly one request with `status` and `body`, and report what was asked.
34
///
35
/// Deliberately minimal: it reads the request line, the headers, and exactly
36
/// `Content-Length` bytes of body. Anything it cannot parse it reports as a null
37
/// body rather than guessing, so a client that sent nothing is distinguishable
38
/// from one that sent something unreadable.
39
fn start_stub_api(status: u16, body: serde_json::Value) -> StubApi {
40
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
41
    let port = listener.local_addr().unwrap().port();
42
    let (sender, seen) = channel();
43
44
    std::thread::spawn(move || {
45
        let Ok((stream, _)) = listener.accept() else {
46
            return;
47
        };
48
        let mut reader = BufReader::new(stream);
49
50
        let mut request_line = String::new();
51
        if reader.read_line(&mut request_line).is_err() {
52
            return;
53
        }
54
        let mut parts = request_line.split_whitespace();
55
        let method = parts.next().unwrap_or_default().to_string();
56
        let path = parts.next().unwrap_or_default().to_string();
57
58
        let mut content_length = 0usize;
59
        loop {
60
            let mut header = String::new();
61
            match reader.read_line(&mut header) {
62
                Ok(0) => break,
63
                Ok(_) => {}
64
                Err(_) => return,
65
            }
66
            let trimmed = header.trim_end();
67
            if trimmed.is_empty() {
68
                break;
69
            }
70
            if let Some((name, value)) = trimmed.split_once(':') {
71
                if name.eq_ignore_ascii_case("content-length") {
72
                    content_length = value.trim().parse().unwrap_or(0);
73
                }
74
            }
75
        }
76
77
        let mut raw = vec![0u8; content_length];
78
        if content_length > 0 && reader.read_exact(&mut raw).is_err() {
79
            return;
80
        }
81
        let parsed = if raw.is_empty() {
82
            serde_json::Value::Null
83
        } else {
84
            serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null)
85
        };
86
        let _ = sender.send(SeenRequest {
87
            method,
88
            path,
89
            body: parsed,
90
        });
91
92
        let payload = if body.is_null() {
93
            String::new()
94
        } else {
95
            body.to_string()
96
        };
97
        let response = format!(
98
            "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}",
99
            payload.len()
100
        );
101
        let stream = reader.get_mut();
102
        let _ = stream.write_all(response.as_bytes());
103
        let _ = stream.flush();
104
    });
105
106
    StubApi {
107
        base: format!("http://127.0.0.1:{port}/api/v1"),
108
        seen,
109
    }
110
}
111
112
fn target() -> RepoTarget {
113
    RepoTarget::parse("octavia/project").unwrap()
114
}
115
116
fn seen(stub: &StubApi) -> SeenRequest {
117
    stub.seen
118
        .recv_timeout(std::time::Duration::from_secs(10))
119
        .expect("the client never sent a request")
120
}
121
122
#[tokio::test]
123
async fn create_milestone_posts_to_the_repository_route() {
124
    let stub = start_stub_api(
125
        201,
126
        serde_json::json!({ "number": 7, "title": "Ship it", "state": "open" }),
127
    );
128
    let client = TrackerClient::new(&stub.base, None);
129
130
    let value = client
131
        .create_milestone(&target(), "Ship it", Some("the tail of #76"), None)
132
        .await
133
        .expect("the stub answered 201");
134
135
    let request = seen(&stub);
136
    assert_eq!(request.method, "POST");
137
    assert_eq!(request.path, "/api/v1/repos/octavia/project/milestones");
138
    assert_eq!(
139
        request.body,
140
        serde_json::json!({ "title": "Ship it", "description": "the tail of #76" }),
141
        "an unset due date must be omitted, not sent as null"
142
    );
143
    // The number is the server's. It is what `issue milestone --set` has to be
144
    // given, so a client that invented one would be sending callers to a
145
    // milestone that does not exist.
146
    assert_eq!(value.get("number").and_then(|n| n.as_u64()), Some(7));
147
}
148
149
#[tokio::test]
150
async fn delete_milestone_uses_the_numbered_route_and_accepts_an_empty_204() {
151
    let stub = start_stub_api(204, serde_json::Value::Null);
152
    let client = TrackerClient::new(&stub.base, None);
153
154
    client
155
        .delete_milestone(&target(), 7)
156
        .await
157
        .expect("a 204 with no body is success, not a parse failure");
158
159
    let request = seen(&stub);
160
    assert_eq!(request.method, "DELETE");
161
    assert_eq!(request.path, "/api/v1/repos/octavia/project/milestones/7");
162
}
163
164
#[tokio::test]
165
async fn setting_a_milestone_patches_that_field_and_no_other() {
166
    let stub = start_stub_api(
167
        200,
168
        serde_json::json!({ "number": 129, "milestone": { "number": 7, "title": "Ship it" } }),
169
    );
170
    let client = TrackerClient::new(&stub.base, None);
171
172
    client
173
        .set_issue_milestone(&target(), 129, Some(7))
174
        .await
175
        .unwrap();
176
177
    let request = seen(&stub);
178
    assert_eq!(request.method, "PATCH");
179
    assert_eq!(request.path, "/api/v1/repos/octavia/project/issues/129");
180
    // A PATCH carrying `body` would replace the issue text.
181
    assert_eq!(request.body, serde_json::json!({ "milestone": 7 }));
182
}
183
184
#[tokio::test]
185
async fn clearing_a_milestone_sends_an_explicit_null_rather_than_omitting_the_key() {
186
    let stub = start_stub_api(200, serde_json::json!({ "number": 129, "milestone": null }));
187
    let client = TrackerClient::new(&stub.base, None);
188
189
    client
190
        .set_issue_milestone(&target(), 129, None)
191
        .await
192
        .unwrap();
193
194
    let request = seen(&stub);
195
    // Omitting the key would leave the milestone where it is and still answer
196
    // 200 — a clear that does nothing and reports success.
197
    assert!(
198
        request.body.get("milestone").is_some(),
199
        "the key must be present; the body was {}",
200
        request.body
201
    );
202
    assert!(
203
        request.body["milestone"].is_null(),
204
        "the key must carry null; the body was {}",
205
        request.body
206
    );
207
}
208
209
#[tokio::test]
210
async fn a_refused_milestone_number_is_an_error_not_a_quiet_success() {
211
    let stub = start_stub_api(
212
        422,
213
        serde_json::json!({
214
            "message": "Validation Failed",
215
            "code": "validation_failed",
216
            "errors": { "milestone": ["Milestone #99999 does not exist in this repository"] }
217
        }),
218
    );
219
    let client = TrackerClient::new(&stub.base, None);
220
221
    let refused = client
222
        .set_issue_milestone(&target(), 129, Some(99_999))
223
        .await;
224
225
    let error = refused.expect_err("a 422 must not be reported as a stored milestone");
226
    let text = error.to_string();
227
    assert!(
228
        text.contains("99999") || text.contains("Validation Failed"),
229
        "the refusal must name what the server rejected; got {text}"
230
    );
231
}
packages/openagents-cli/src/cli.ts modified +197

@@ -3914,6 +3914,200 @@ const issueDepsCommand = Command.make(

3914 3914
    }),
3915 3915
).pipe(Command.withDescription("Read, add, or remove the prerequisites of an issue"));
3916 3916
3917
/**
3918
 * The milestone surface, on both CLIs, because the API always had it and
3919
 * neither client reached it.
3920
 *
3921
 * `issue create --milestone` was the only write: an issue filed without one
3922
 * could never be given one, and a milestone could only be opened or removed in
3923
 * a browser, which made milestones useless to agents -- and agents file most of
3924
 * the issues here.
3925
 */
3926
const milestoneNumberArgument = Argument.string("milestone").pipe(
3927
  Argument.withDescription("Milestone number"),
3928
);
3929
3930
const milestoneRow = (value: Record<string, unknown>): string =>
3931
  `${`#${String(value["number"] ?? "?")}`.padEnd(7)}${String(value["state"] ?? "").padEnd(8)}${String(
3932
    value["title"] ?? "",
3933
  )}`;
3934
3935
/** One renderer, so `milestone list` and any other listing cannot disagree. */
3936
const milestoneListHuman = (value: unknown): ReadonlyArray<string> => {
3937
  const listed = rows(value, "milestones");
3938
  return listed.length === 0 ? ["No milestones found."] : listed.map(milestoneRow);
3939
};
3940
3941
/**
3942
 * The listing, under two names.
3943
 *
3944
 * `oa issue milestones` is the name the Rust CLI already published, and
3945
 * `openagents milestone list` is where the write half lives. Both are the same
3946
 * handler, so the two cannot describe the same repository differently.
3947
 */
3948
const makeMilestoneListCommand = (name: "list" | "milestones") =>
3949
  Command.make(name, { repo: repositoryOverrideFlag }, ({ repo }) =>
3950
    Effect.gen(function* () {
3951
      const flags = yield* rootCommand;
3952
      const session = yield* resolveApiSession(endpointOverrides(flags));
3953
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
3954
      const issues = yield* IssueClient;
3955
      const output = yield* Output;
3956
      const value = yield* issues.milestones({
3957
        origin: session.endpoint.origin,
3958
        token: session.token,
3959
        ...target,
3960
      });
3961
      yield* output.write({ value, human: milestoneListHuman(value) }, outputMode(flags.json));
3962
    }),
3963
  ).pipe(Command.withDescription("List the milestones of a repository"));
3964
3965
const milestoneListCommand = makeMilestoneListCommand("list");
3966
const issueMilestonesCommand = makeMilestoneListCommand("milestones");
3967
3968
const milestoneTitleArgument = Argument.string("title").pipe(
3969
  Argument.withDescription("Milestone title"),
3970
);
3971
const milestoneDescriptionFlag = Flag.string("description").pipe(
3972
  Flag.optional,
3973
  Flag.withDescription("What the milestone is for"),
3974
);
3975
const milestoneDueOnFlag = Flag.string("due-on").pipe(
3976
  Flag.optional,
3977
  Flag.withDescription("Due date, as the server stores it"),
3978
);
3979
3980
const milestoneCreateCommand = Command.make(
3981
  "create",
3982
  {
3983
    title: milestoneTitleArgument,
3984
    repo: repositoryOverrideFlag,
3985
    description: milestoneDescriptionFlag,
3986
    dueOn: milestoneDueOnFlag,
3987
  },
3988
  ({ description, dueOn, repo, title }) =>
3989
    Effect.gen(function* () {
3990
      if (title.trim() === "") {
3991
        return yield* new InputError({ message: "Pass the milestone title." });
3992
      }
3993
      const flags = yield* rootCommand;
3994
      const session = yield* resolveApiSession(endpointOverrides(flags));
3995
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
3996
      const issues = yield* IssueClient;
3997
      const output = yield* Output;
3998
      const value = yield* issues.createMilestone({
3999
        origin: session.endpoint.origin,
4000
        token: session.token,
4001
        ...target,
4002
        title,
4003
        ...(Option.isNone(description) ? {} : { description: description.value }),
4004
        ...(Option.isNone(dueOn) ? {} : { dueOn: dueOn.value }),
4005
      });
4006
      // The server assigns the number. Printing the one it returned is how the
4007
      // caller learns what to pass to `issue milestone --set`.
4008
      const created = record(value);
4009
      yield* output.write(
4010
        {
4011
          value,
4012
          human: [
4013
            `Opened milestone #${String(created["number"] ?? "?")} ${String(created["title"] ?? "")}`,
4014
          ],
4015
        },
4016
        outputMode(flags.json),
4017
      );
4018
    }),
4019
).pipe(Command.withDescription("Open a new milestone"));
4020
4021
const milestoneDeleteCommand = Command.make(
4022
  "delete",
4023
  { milestone: milestoneNumberArgument, repo: repositoryOverrideFlag },
4024
  ({ milestone, repo }) =>
4025
    Effect.gen(function* () {
4026
      const number = yield* parseTrackerNumber("A milestone number", milestone);
4027
      const flags = yield* rootCommand;
4028
      const session = yield* resolveApiSession(endpointOverrides(flags));
4029
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
4030
      const issues = yield* IssueClient;
4031
      const output = yield* Output;
4032
      // A 204 carries no body, so there is nothing to report but the number
4033
      // that was asked for and the fact that the server accepted it.
4034
      const value = yield* issues.deleteMilestone({
4035
        origin: session.endpoint.origin,
4036
        token: session.token,
4037
        ...target,
4038
        milestone: number,
4039
      });
4040
      yield* output.write(
4041
        { value, human: [`Deleted milestone #${String(number)}.`] },
4042
        outputMode(flags.json),
4043
      );
4044
    }),
4045
).pipe(Command.withDescription("Delete a milestone"));
4046
4047
const milestoneCommand = Command.make("milestone").pipe(
4048
  Command.withDescription("Repository milestones"),
4049
  Command.withSubcommands([milestoneListCommand, milestoneCreateCommand, milestoneDeleteCommand]),
4050
);
4051
4052
const issueMilestoneSetFlag = Flag.string("set").pipe(
4053
  Flag.optional,
4054
  Flag.withDescription("Milestone number to put the issue on"),
4055
);
4056
const issueMilestoneClearFlag = Flag.boolean("clear").pipe(
4057
  Flag.withDescription("Take the issue off whatever milestone it is on"),
4058
);
4059
4060
const issueMilestoneCommand = Command.make(
4061
  "milestone",
4062
  {
4063
    number: issueNumberArgument,
4064
    repo: repositoryOverrideFlag,
4065
    set: issueMilestoneSetFlag,
4066
    clear: issueMilestoneClearFlag,
4067
  },
4068
  ({ clear, number, repo, set }) =>
4069
    Effect.gen(function* () {
4070
      // Two ways to say what the milestone should become, and they disagree.
4071
      // Guessing which was meant is how an issue lands on a milestone the
4072
      // caller was trying to take it off.
4073
      if (Option.isSome(set) && clear) {
4074
        return yield* new InputError({ message: "Use either --set or --clear, not both." });
4075
      }
4076
      if (Option.isNone(set) && !clear) {
4077
        return yield* new InputError({
4078
          message:
4079
            "Say what the milestone should become: --set <number> to put the issue on one, or --clear to take it off.",
4080
        });
4081
      }
4082
      const issueNumber = yield* parseTrackerNumber("An issue number", number);
4083
      const milestone = Option.isNone(set)
4084
        ? null
4085
        : yield* parseTrackerNumber("A milestone number", set.value);
4086
      const flags = yield* rootCommand;
4087
      const session = yield* resolveApiSession(endpointOverrides(flags));
4088
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
4089
      const issues = yield* IssueClient;
4090
      const output = yield* Output;
4091
      const value = yield* issues.setMilestone({
4092
        origin: session.endpoint.origin,
4093
        token: session.token,
4094
        ...target,
4095
        number: issueNumber,
4096
        milestone,
4097
      });
4098
      // Report what came BACK, not what was asked for. A server that accepted
4099
      // the request and stored something else is exactly what an echo hides.
4100
      const stored = record(value)["milestone"];
4101
      const human =
4102
        stored === null || stored === undefined
4103
          ? `Issue #${String(issueNumber)} is on no milestone.`
4104
          : `Issue #${String(issueNumber)} is on milestone #${String(
4105
              record(stored)["number"] ?? "?",
4106
            )} ${String(record(stored)["title"] ?? "")}`;
4107
      yield* output.write({ value, human: [human] }, outputMode(flags.json));
4108
    }),
4109
).pipe(Command.withDescription("Put an existing issue on a milestone, or take it off one"));
4110
3917 4111
const issueCommand = Command.make("issue").pipe(
3918 4112
  Command.withDescription("Read and write issues"),
3919 4113
  Command.withSubcommands([

@@ -3927,6 +4121,8 @@ const issueCommand = Command.make("issue").pipe(

3927 4121
    issueAssignRunCommand,
3928 4122
    issueUnassignRunCommand,
3929 4123
    issueDepsCommand,
4124
    issueMilestoneCommand,
4125
    issueMilestonesCommand,
3930 4126
  ]),
3931 4127
);
3932 4128

@@ -4647,6 +4843,7 @@ export const openagentsCommand = rootCommand.pipe(

4647 4843
    identityCommand,
4648 4844
    issueCommand,
4649 4845
    memoryCommand,
4846
    milestoneCommand,
4650 4847
    projectCommand,
4651 4848
    providerCommand,
4652 4849
    repoCommand,
packages/openagents-cli/src/issue-client.ts modified +74

@@ -55,6 +55,16 @@ export interface IssueNumberInput extends AuthenticatedApi, RepositoryTarget {

55 55
  readonly number: number;
56 56
}
57 57
58
export interface MilestoneCreateInput extends AuthenticatedApi, RepositoryTarget {
59
  readonly title: string;
60
  readonly description?: string;
61
  readonly dueOn?: string;
62
}
63
64
export interface MilestoneNumberInput extends AuthenticatedApi, RepositoryTarget {
65
  readonly milestone: number;
66
}
67
58 68
interface IssueClientInterface {
59 69
  readonly list: (input: IssueListInput) => Effect.Effect<IssueListResult, CliError>;
60 70
  readonly view: (input: IssueNumberInput) => Effect.Effect<unknown, CliError>;

@@ -87,6 +97,22 @@ interface IssueClientInterface {

87 97
  readonly removeDependency: (
88 98
    input: IssueNumberInput & { readonly blockedBy: number },
89 99
  ) => Effect.Effect<unknown, CliError>;
100
  readonly milestones: (
101
    input: AuthenticatedApi & RepositoryTarget,
102
  ) => Effect.Effect<unknown, CliError>;
103
  readonly createMilestone: (input: MilestoneCreateInput) => Effect.Effect<unknown, CliError>;
104
  readonly deleteMilestone: (input: MilestoneNumberInput) => Effect.Effect<unknown, CliError>;
105
  /**
106
   * Put an existing issue on a milestone, or take it off one.
107
   *
108
   * `milestone: null` is sent explicitly, because that is how the server is
109
   * told to clear the field. Omitting the key would leave the milestone where
110
   * it is and still answer 200, which is a "clear" that does nothing and
111
   * reports success.
112
   */
113
  readonly setMilestone: (
114
    input: IssueNumberInput & { readonly milestone: number | null },
115
  ) => Effect.Effect<unknown, CliError>;
90 116
}
91 117
92 118
export class IssueClient extends Context.Service<IssueClient, IssueClientInterface>()(

@@ -98,6 +124,9 @@ const issuesPath = (input: RepositoryTarget) => `${repositoryPath(input.owner, i

98 124
const issuePath = (input: RepositoryTarget & { readonly number: number }) =>
99 125
  `${issuesPath(input)}/${input.number}`;
100 126
127
const milestonesPath = (input: RepositoryTarget) =>
128
  `${repositoryPath(input.owner, input.repo)}/milestones`;
129
101 130
const listQuery = (input: IssueListInput, page: number): string => {
102 131
  const parameters = new URLSearchParams({ state: input.state ?? "open", page: String(page) });
103 132
  // The list route names its search parameter `q` and its label parameter

@@ -308,6 +337,51 @@ export const issueClientLayer = Layer.effect(

308 337
          path: `${issuePath(input)}/dependencies/${input.blockedBy}`,
309 338
          acceptedStatuses: [200],
310 339
        }),
340
341
      milestones: (input) =>
342
        request("list milestones", {
343
          origin: input.origin,
344
          token: input.token,
345
          method: "GET",
346
          path: milestonesPath(input),
347
          acceptedStatuses: [200],
348
        }),
349
350
      createMilestone: (input) =>
351
        request("create a milestone", {
352
          origin: input.origin,
353
          token: input.token,
354
          method: "POST",
355
          path: milestonesPath(input),
356
          body: {
357
            title: input.title,
358
            ...(input.description === undefined ? {} : { description: input.description }),
359
            ...(input.dueOn === undefined ? {} : { due_on: input.dueOn }),
360
          },
361
          acceptedStatuses: [201],
362
        }),
363
364
      // The route answers 204 with no body when the milestone is gone.
365
      deleteMilestone: (input) =>
366
        request("delete a milestone", {
367
          origin: input.origin,
368
          token: input.token,
369
          method: "DELETE",
370
          path: `${milestonesPath(input)}/${input.milestone}`,
371
          acceptedStatuses: [200, 204],
372
        }),
373
374
      // Like `setState`, this sends the one field. A `PATCH` carrying `body`
375
      // would replace the issue text.
376
      setMilestone: (input) =>
377
        request("set the milestone of an issue", {
378
          origin: input.origin,
379
          token: input.token,
380
          method: "PATCH",
381
          path: issuePath(input),
382
          body: { milestone: input.milestone },
383
          acceptedStatuses: [200],
384
        }),
311 385
    });
312 386
  }),
313 387
);
packages/openagents-cli/test/issue-client.test.ts modified +157

@@ -198,3 +198,160 @@ describe("issue client", () => {

198 198
    );
199 199
  });
200 200
});
201
202
/**
203
 * The milestone write half. The API has carried all of it since the milestone
204
 * routes landed; neither CLI reached it, so a milestone could only be opened,
205
 * removed, or attached to an existing issue in a browser.
206
 *
207
 * Every assertion here is on the REQUEST the client actually built -- method,
208
 * path, and body -- because the bug being fixed is a route that was never
209
 * called, not a response that was misread.
210
 */
211
describe("the milestone client", () => {
212
  const captured = (
213
    response: ApiResponse,
214
  ): { readonly requests: Array<ApiRequest>; readonly layer: Layer.Layer<IssueClient> } => {
215
    const requests: Array<ApiRequest> = [];
216
    return {
217
      requests,
218
      layer: layerFromHandler((input) =>
219
        Effect.sync(() => {
220
          requests.push(input);
221
          return response;
222
        }),
223
      ),
224
    };
225
  };
226
227
  it("lists milestones from the repository route", async () => {
228
    const { layer, requests } = captured({
229
      status: 200,
230
      body: { milestones: [{ number: 3, title: "Ship it", state: "open" }] },
231
    });
232
    const value = await Effect.runPromise(
233
      Effect.gen(function* () {
234
        const issues = yield* IssueClient;
235
        return yield* issues.milestones(target);
236
      }).pipe(Effect.provide(layer)),
237
    );
238
239
    expect(requests[0]?.method).toBe("GET");
240
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/milestones");
241
    expect(value).toEqual({ milestones: [{ number: 3, title: "Ship it", state: "open" }] });
242
  });
243
244
  it("creates a milestone and reports the number the server assigned", async () => {
245
    const { layer, requests } = captured({
246
      status: 201,
247
      body: { number: 7, title: "Ship it", state: "open" },
248
    });
249
    const value = await Effect.runPromise(
250
      Effect.gen(function* () {
251
        const issues = yield* IssueClient;
252
        return yield* issues.createMilestone({
253
          ...target,
254
          title: "Ship it",
255
          description: "the tail of #76",
256
        });
257
      }).pipe(Effect.provide(layer)),
258
    );
259
260
    expect(requests[0]?.method).toBe("POST");
261
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/milestones");
262
    expect(requests[0]?.body).toEqual({ title: "Ship it", description: "the tail of #76" });
263
    // The number is the server's, not one the CLI made up: it is what `issue
264
    // milestone --set` has to be given.
265
    expect((value as Record<string, unknown>)["number"]).toBe(7);
266
  });
267
268
  it("omits an unset description and due date rather than sending an empty one", async () => {
269
    const { layer, requests } = captured({ status: 201, body: { number: 8, title: "Bare" } });
270
    await Effect.runPromise(
271
      Effect.gen(function* () {
272
        const issues = yield* IssueClient;
273
        return yield* issues.createMilestone({ ...target, title: "Bare" });
274
      }).pipe(Effect.provide(layer)),
275
    );
276
277
    expect(requests[0]?.body).toEqual({ title: "Bare" });
278
  });
279
280
  it("accepts the 204 a delete answers with", async () => {
281
    const { layer, requests } = captured({ status: 204, body: undefined });
282
    await Effect.runPromise(
283
      Effect.gen(function* () {
284
        const issues = yield* IssueClient;
285
        return yield* issues.deleteMilestone({ ...target, milestone: 7 });
286
      }).pipe(Effect.provide(layer)),
287
    );
288
289
    expect(requests[0]?.method).toBe("DELETE");
290
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/milestones/7");
291
  });
292
293
  it("puts an existing issue on a milestone, sending that field and no other", async () => {
294
    const { layer, requests } = captured({
295
      status: 200,
296
      body: { number: 129, milestone: { number: 7, title: "Ship it" } },
297
    });
298
    await Effect.runPromise(
299
      Effect.gen(function* () {
300
        const issues = yield* IssueClient;
301
        return yield* issues.setMilestone({ ...target, number: 129, milestone: 7 });
302
      }).pipe(Effect.provide(layer)),
303
    );
304
305
    expect(requests[0]?.method).toBe("PATCH");
306
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/issues/129");
307
    // A PATCH carrying `body` would replace the issue text.
308
    expect(requests[0]?.body).toEqual({ milestone: 7 });
309
  });
310
311
  it("clears a milestone by sending an explicit null, not by omitting the key", async () => {
312
    const { layer, requests } = captured({ status: 200, body: { number: 129, milestone: null } });
313
    await Effect.runPromise(
314
      Effect.gen(function* () {
315
        const issues = yield* IssueClient;
316
        return yield* issues.setMilestone({ ...target, number: 129, milestone: null });
317
      }).pipe(Effect.provide(layer)),
318
    );
319
320
    // Omitting the key would leave the milestone where it is and still answer
321
    // 200 -- a clear that does nothing and reports success.
322
    expect(requests[0]?.body).toEqual({ milestone: null });
323
    expect(Object.hasOwn(requests[0]?.body as object, "milestone")).toBe(true);
324
  });
325
326
  it("reports a rejected milestone number by the field the server named", async () => {
327
    const failure = await Effect.runPromise(
328
      Effect.gen(function* () {
329
        const issues = yield* IssueClient;
330
        return yield* issues.setMilestone({ ...target, number: 129, milestone: 99999 });
331
      }).pipe(
332
        Effect.provide(
333
          layerFromHandler(() =>
334
            Effect.succeed({
335
              status: 422,
336
              body: {
337
                message: "Validation Failed",
338
                code: "validation_failed",
339
                status: 422,
340
                documentation_url: "http://localhost:4000/api/v1",
341
                request_id: "request-2",
342
                errors: { milestone: ["Milestone #99999 does not exist in this repository"] },
343
              },
344
            }),
345
          ),
346
        ),
347
        Effect.flip,
348
      ),
349
    );
350
351
    expect(failure).toBeInstanceOf(ApiError);
352
    expect((failure as ApiError).status).toBe(422);
353
    expect((failure as ApiError).message).toBe(
354
      "Validation Failed (milestone: Milestone #99999 does not exist in this repository)",
355
    );
356
  });
357
});
packages/openagents-cli/test/issue-command.test.ts modified +103

@@ -199,6 +199,109 @@ describe("issue and project commands", () => {

199 199
    );
200 200
  });
201 201
202
  it("puts an existing issue on a milestone from the command line", async () => {
203
    const requests: Array<ApiRequest> = [];
204
    const { run, written } = harness((input) =>
205
      Effect.sync(() => {
206
        requests.push(input);
207
        return {
208
          status: 200,
209
          body: { ...issueBody(129), milestone: { number: 7, title: "Ship it" } },
210
        };
211
      }),
212
    );
213
214
    await run(["issue", "milestone", "129", "--set", "7", "-R", "octavia/project"]);
215
216
    expect(requests[0]?.method).toBe("PATCH");
217
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/issues/129");
218
    expect(requests[0]?.body).toEqual({ milestone: 7 });
219
    // The line names the milestone the server RETURNED, so a server that stored
220
    // something other than what was asked for is visible instead of echoed over.
221
    expect(written[0]?.document.human).toEqual(["Issue #129 is on milestone #7 Ship it"]);
222
  });
223
224
  it("says the issue is on no milestone when the server reports none", async () => {
225
    const { run, written } = harness(() =>
226
      Effect.succeed({ status: 200, body: { ...issueBody(129), milestone: null } }),
227
    );
228
229
    await run(["issue", "milestone", "129", "--clear", "-R", "octavia/project"]);
230
231
    expect(written[0]?.document.human).toEqual(["Issue #129 is on no milestone."]);
232
  });
233
234
  it("refuses --set and --clear together instead of guessing which was meant", async () => {
235
    const { run } = harness(() => Effect.succeed({ status: 200, body: {} }));
236
237
    await expect(
238
      run(["issue", "milestone", "129", "--set", "7", "--clear", "-R", "octavia/project"]),
239
    ).rejects.toThrow(/either --set or --clear, not both/u);
240
  });
241
242
  it("refuses a milestone change that says nothing about what to change it to", async () => {
243
    const { run } = harness(() => Effect.succeed({ status: 200, body: {} }));
244
245
    await expect(run(["issue", "milestone", "129", "-R", "octavia/project"])).rejects.toThrow(
246
      /--set <number> to put the issue on one, or --clear/u,
247
    );
248
  });
249
250
  it("opens a milestone and reports the number the server assigned", async () => {
251
    const requests: Array<ApiRequest> = [];
252
    const { run, written } = harness((input) =>
253
      Effect.sync(() => {
254
        requests.push(input);
255
        return { status: 201, body: { number: 7, title: "Ship it", state: "open" } };
256
      }),
257
    );
258
259
    await run(["milestone", "create", "Ship it", "-R", "octavia/project"]);
260
261
    expect(requests[0]?.method).toBe("POST");
262
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/milestones");
263
    expect(requests[0]?.body).toEqual({ title: "Ship it" });
264
    expect(written[0]?.document.human).toEqual(["Opened milestone #7 Ship it"]);
265
  });
266
267
  it("deletes a milestone through the numbered route", async () => {
268
    const requests: Array<ApiRequest> = [];
269
    const { run, written } = harness((input) =>
270
      Effect.sync(() => {
271
        requests.push(input);
272
        return { status: 204, body: undefined };
273
      }),
274
    );
275
276
    await run(["milestone", "delete", "7", "-R", "octavia/project"]);
277
278
    expect(requests[0]?.method).toBe("DELETE");
279
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/milestones/7");
280
    expect(written[0]?.document.human).toEqual(["Deleted milestone #7."]);
281
  });
282
283
  it("lists milestones under both names, with one renderer behind them", async () => {
284
    const body = { milestones: [{ number: 3, title: "Ship it", state: "open" }] };
285
    const first = harness(() => Effect.succeed({ status: 200, body }));
286
    await first.run(["milestone", "list", "-R", "octavia/project"]);
287
288
    const second = harness(() => Effect.succeed({ status: 200, body }));
289
    await second.run(["issue", "milestones", "-R", "octavia/project"]);
290
291
    expect(first.written[0]?.document.human).toEqual(second.written[0]?.document.human);
292
    expect(first.written[0]?.document.human).toEqual(["#3     open    Ship it"]);
293
  });
294
295
  it("says a repository has no milestones rather than printing an empty table", async () => {
296
    const { run, written } = harness(() =>
297
      Effect.succeed({ status: 200, body: { milestones: [] } }),
298
    );
299
300
    await run(["milestone", "list", "-R", "octavia/project"]);
301
302
    expect(written[0]?.document.human).toEqual(["No milestones found."]);
303
  });
304
202 305
  it("resolves projects through the repository-scoped route", async () => {
203 306
    const requests: Array<ApiRequest> = [];
204 307
    const { run, written } = harness((input) =>

This page updates live while a promote is in flight · changelog