Give both CLIs project edit and delete, and stop guessing at owner kind

cd0c05d46578 · AtlantisPleb · · parent 0a18c54e6b3c

Give both CLIs project edit and delete, and stop guessing at owner kind

Closes the client half of two issues.

A project board could be created from either CLI and never removed or
renamed. The API grew `PATCH` and `DELETE` on `projectsV2/:number`, but
neither CLI exposed them, so `project create` still had no counterpart.
Both now carry `project edit` (title, description, state, `--archive` /
`--unarchive`) and `project delete --yes`. Deletion is two steps because
the API refuses a board that is not archived: an API caller sees no
confirmation prompt, so archiving is the deliberate step that stands in
for one.

`repo create OWNER/NAME` posted to `/api/v1/orgs/OWNER/repos` whenever
the argument contained a slash. A slash says an owner was named, not that
the owner is an organization, so `repo create AtlantisPleb/thing` asked
the organization route to create under a person. Both CLIs now post to
the owner-neutral `POST /api/v1/repos` with the owner in the body and let
the server resolve which kind it is. The Rust port matched the TypeScript
behaviour deliberately, so both move together.

The contract artifact names the new endpoint, and its pinned SHA-256
moves with it.

Tests read the request the stub received rather than asserting that a
subcommand parses. The delete test lists the board before and after, so a
delete that answered 204 without removing anything still fails.

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/repo.rs
  • modified crates/openagents-cli/src/tracker.rs
  • added crates/openagents-cli/tests/project_lifecycle_test.rs
  • modified packages/openagents-cli/README.md
  • modified packages/openagents-cli/contracts/repositories-v1.json
  • modified packages/openagents-cli/src/api-contract.ts
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/project-client.ts
  • modified packages/openagents-cli/src/repository-client.ts
  • modified packages/openagents-cli/test/issue-command.test.ts
  • modified packages/openagents-cli/test/repository-client.test.ts

Diff

12 files changed, +784 -19

crates/openagents-cli/src/cli.rs modified +88

@@ -394,6 +394,32 @@ pub enum ProjectAction {

394 394
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
395 395
        repo: Option<String>,
396 396
    },
397
    /// Edit a project board's title, description, state, or archive
398
    Edit {
399
        #[arg(help = "Project number")]
400
        number: u64,
401
        #[arg(long, help = "New project title")]
402
        title: Option<String>,
403
        #[arg(long, help = "Markdown project description")]
404
        description: Option<String>,
405
        #[arg(long, help = "New project state: open or closed")]
406
        state: Option<String>,
407
        #[arg(long, help = "Move the board out of the working set")]
408
        archive: bool,
409
        #[arg(long, help = "Return the board to the working set")]
410
        unarchive: bool,
411
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
412
        repo: Option<String>,
413
    },
414
    /// Permanently delete a project board (archive it first with project edit --archive)
415
    Delete {
416
        #[arg(help = "Project number")]
417
        number: u64,
418
        #[arg(long, help = "Confirm permanent project deletion")]
419
        yes: bool,
420
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
421
        repo: Option<String>,
422
    },
397 423
    /// List the fields of a project board
398 424
    Fields {
399 425
        #[arg(help = "Project number")]

@@ -2820,6 +2846,68 @@ async fn run_project(action: ProjectAction, api_base: &str, token: Option<String

2820 2846
                )],
2821 2847
            );
2822 2848
        }
2849
        ProjectAction::Edit {
2850
            number,
2851
            title,
2852
            description,
2853
            state,
2854
            archive,
2855
            unarchive,
2856
            repo,
2857
        } => {
2858
            if archive && unarchive {
2859
                fail("Pass either --archive or --unarchive, not both.");
2860
            }
2861
            if let Some(value) = state.as_deref() {
2862
                if value != "open" && value != "closed" {
2863
                    fail("--state accepts open or closed.");
2864
                }
2865
            }
2866
            let archived = if archive {
2867
                Some(true)
2868
            } else if unarchive {
2869
                Some(false)
2870
            } else {
2871
                None
2872
            };
2873
            if title.is_none() && description.is_none() && state.is_none() && archived.is_none() {
2874
                fail("Pass --title, --description, --state, --archive, or --unarchive.");
2875
            }
2876
            let target = target_or_fail(repo);
2877
            let project = or_fail(
2878
                tracker
2879
                    .edit_project(
2880
                        &target,
2881
                        number,
2882
                        title.as_deref(),
2883
                        description.as_deref(),
2884
                        state.as_deref(),
2885
                        archived,
2886
                    )
2887
                    .await,
2888
            );
2889
            emit(
2890
                json,
2891
                &project,
2892
                &[format!(
2893
                    "Updated project #{} {}",
2894
                    number_or_question(&project, "number"),
2895
                    field(&project, "title")
2896
                )],
2897
            );
2898
        }
2899
        ProjectAction::Delete { number, yes, repo } => {
2900
            if !yes {
2901
                fail("Project deletion requires --yes confirmation.");
2902
            }
2903
            let target = target_or_fail(repo);
2904
            or_fail(tracker.delete_project(&target, number).await);
2905
            emit(
2906
                json,
2907
                &serde_json::json!({ "number": number, "deleted": true }),
2908
                &[format!("Deleted project #{number}.")],
2909
            );
2910
        }
2823 2911
        ProjectAction::Fields { number, repo } => {
2824 2912
            let target = target_or_fail(repo);
2825 2913
            let value = or_fail(tracker.project_fields(&target, number).await);
crates/openagents-cli/src/repo.rs modified +9 -5

@@ -344,23 +344,27 @@ impl RepoClient {

344 344
    ) -> Result<Repository, AuthError> {
345 345
        let name = validate_repository_name(name)?;
346 346
        let owner = owner.map(validate_owner).transpose()?;
347
        // `OWNER/NAME` says an owner was named, not that the owner is an
348
        // organization. Sending a named owner to the organization route on the
349
        // strength of a slash is what made `repo create AtlantisPleb/thing` ask
350
        // the org route to create under a person. The owner travels in the body
351
        // and the server resolves which kind it is.
347 352
        let mut body = serde_json::json!({
348 353
            "name": name,
349 354
            "private": private,
350 355
            "default_branch": default_branch,
351 356
        });
357
        if let Some(owner) = &owner {
358
            body["owner"] = serde_json::Value::String(owner.clone());
359
        }
352 360
        if let Some(description) = description {
353 361
            body["description"] = serde_json::Value::String(description.to_string());
354 362
        }
355
        let path = match &owner {
356
            None => "/api/v1/user/repos".to_string(),
357
            Some(owner) => format!("/api/v1/orgs/{}/repos", urlencode(owner)),
358
        };
359 363
        let value = self
360 364
            .request(
361 365
                "create the repository",
362 366
                reqwest::Method::POST,
363
                &path,
367
                "/api/v1/repos",
364 368
                Some(body),
365 369
                Some(&idempotency_key()),
366 370
                &[200, 201, 202],
crates/openagents-cli/src/tracker.rs modified +50

@@ -831,6 +831,56 @@ impl TrackerClient {

831 831
        .await
832 832
    }
833 833
834
    /// Edits a board's title, description, state, or archive standing.
835
    ///
836
    /// The payload is built from whichever fields the caller set. The API
837
    /// refuses an empty edit, so the command validates before it gets here.
838
    pub async fn edit_project(
839
        &self,
840
        target: &RepoTarget,
841
        number: u64,
842
        title: Option<&str>,
843
        description: Option<&str>,
844
        state: Option<&str>,
845
        archived: Option<bool>,
846
    ) -> Result<Value, ApiError> {
847
        let mut payload = json!({});
848
        if let Some(text) = title {
849
            payload["title"] = json!(text);
850
        }
851
        if let Some(text) = description {
852
            payload["description"] = json!(text);
853
        }
854
        if let Some(text) = state {
855
            payload["state"] = json!(text);
856
        }
857
        if let Some(flag) = archived {
858
            payload["archived"] = json!(flag);
859
        }
860
        self.request(
861
            "edit a project",
862
            "PATCH",
863
            &Self::project_path(target, number),
864
            Some(payload),
865
            &[200],
866
        )
867
        .await
868
    }
869
870
    /// Deletes a board. The API refuses one that is not archived, so archiving
871
    /// is the deliberate step that stands in for a prompt an API caller has no
872
    /// way to see.
873
    pub async fn delete_project(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
874
        self.request(
875
            "delete a project",
876
            "DELETE",
877
            &Self::project_path(target, number),
878
            None,
879
            &[200, 204],
880
        )
881
        .await
882
    }
883
834 884
    pub async fn project_fields(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
835 885
        self.request(
836 886
            "list project fields",
crates/openagents-cli/tests/project_lifecycle_test.rs added +341

@@ -0,0 +1,341 @@

1
//! What `oa project edit`, `oa project delete`, and `oa repo create` put on
2
//! the wire, asserted by running the binary against a server this test owns.
3
//!
4
//! Asserting that a subcommand parses would pass against a binary that parsed
5
//! it and sent nothing, so every test here reads the request the stub actually
6
//! received: method, path, and body. The delete test lists the board before
7
//! and after, because a delete that answers `204` without removing anything
8
//! would otherwise look identical to one that worked.
9
10
use std::io::{BufRead, BufReader, Read, Write};
11
use std::net::{TcpListener, TcpStream};
12
use std::process::Command;
13
use std::sync::mpsc;
14
use std::thread;
15
16
/// One request the stub received.
17
#[derive(Debug, Clone)]
18
struct Hit {
19
    method: String,
20
    path: String,
21
    body: String,
22
}
23
24
impl Hit {
25
    fn route(&self) -> String {
26
        format!("{} {}", self.method, self.path)
27
    }
28
29
    fn json(&self) -> serde_json::Value {
30
        serde_json::from_str(&self.body).unwrap_or(serde_json::Value::Null)
31
    }
32
}
33
34
/// A server that answers from a script and records every request.
35
///
36
/// The script is a list of `(status, body)` answered in order, with the last
37
/// entry repeating. Ordering matters here: the board list has to answer
38
/// differently before and after the delete, which a single canned body cannot
39
/// do.
40
struct StubServer {
41
    port: u16,
42
    hits: mpsc::Receiver<Hit>,
43
}
44
45
impl StubServer {
46
    fn start(script: Vec<(u16, String)>) -> Self {
47
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind a port");
48
        let port = listener.local_addr().expect("read the port").port();
49
        let (tx, hits) = mpsc::channel();
50
        thread::spawn(move || {
51
            for (answered, stream) in listener.incoming().enumerate() {
52
                let Ok(stream) = stream else { break };
53
                let index = answered.min(script.len().saturating_sub(1));
54
                let (code, body) = script[index].clone();
55
                serve_one(stream, code, &body, tx.clone());
56
            }
57
        });
58
        Self { port, hits }
59
    }
60
61
    fn origin(&self) -> String {
62
        format!("http://127.0.0.1:{}", self.port)
63
    }
64
65
    fn hits(&self) -> Vec<Hit> {
66
        self.hits.try_iter().collect()
67
    }
68
}
69
70
fn serve_one(mut stream: TcpStream, code: u16, body: &str, hits: mpsc::Sender<Hit>) {
71
    let mut reader = BufReader::new(stream.try_clone().expect("clone the stream"));
72
    let mut request_line = String::new();
73
    if reader.read_line(&mut request_line).is_err() {
74
        return;
75
    }
76
    let mut parts = request_line.split_whitespace();
77
    let method = parts.next().unwrap_or("").to_string();
78
    let path = parts.next().unwrap_or("").to_string();
79
    let mut length = 0usize;
80
    loop {
81
        let mut header = String::new();
82
        if reader.read_line(&mut header).unwrap_or(0) == 0 {
83
            break;
84
        }
85
        if header.trim().is_empty() {
86
            break;
87
        }
88
        if let Some(value) = header.to_lowercase().strip_prefix("content-length:") {
89
            length = value.trim().parse().unwrap_or(0);
90
        }
91
    }
92
    let mut payload = vec![0u8; length];
93
    if length > 0 && reader.read_exact(&mut payload).is_err() {
94
        return;
95
    }
96
    let _ = hits.send(Hit {
97
        method,
98
        path,
99
        body: String::from_utf8_lossy(&payload).into_owned(),
100
    });
101
    let reason = if code == 204 { "No Content" } else { "OK" };
102
    let response = format!(
103
        "HTTP/1.1 {code} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
104
        body.len(),
105
        body
106
    );
107
    let _ = stream.write_all(response.as_bytes());
108
    let _ = stream.flush();
109
}
110
111
struct Run {
112
    stdout: String,
113
    stderr: String,
114
    status: Option<i32>,
115
}
116
117
fn oa(origin: &str, args: &[&str]) -> Run {
118
    let mut full = vec!["--api-url", origin];
119
    full.extend(args.iter().copied());
120
    let result = Command::new(env!("CARGO_BIN_EXE_oa"))
121
        .args(&full)
122
        .env("NO_COLOR", "")
123
        // `repo create` refuses without a credential rather than sending an
124
        // unauthenticated request, so the stub needs one to be reached at all.
125
        .env("OPENAGENTS_TOKEN", "oa_pat_stub")
126
        .output()
127
        .expect("run oa");
128
    Run {
129
        stdout: String::from_utf8_lossy(&result.stdout).into_owned(),
130
        stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
131
        status: result.status.code(),
132
    }
133
}
134
135
fn board_list(archived: bool) -> String {
136
    if archived {
137
        r#"{"projects":[{"number":5,"title":"Scratch","state":"open","archived":true}]}"#
138
            .to_string()
139
    } else {
140
        r#"{"projects":[]}"#.to_string()
141
    }
142
}
143
144
const ARCHIVED_BOARD: &str = r#"{"number":5,"title":"Scratch","state":"open","archived":true,"archived_at":"2026-08-26T00:00:00Z"}"#;
145
146
/// The board is listed, archived, deleted, and then gone from the list.
147
///
148
/// The two listings are the proof. The `DELETE` on its own only shows that the
149
/// CLI asked; the second listing shows the board is no longer there to ask
150
/// about.
151
#[test]
152
fn a_board_is_archived_deleted_and_absent_from_the_list_afterwards() {
153
    let server = StubServer::start(vec![
154
        (200, board_list(true)),
155
        (200, ARCHIVED_BOARD.to_string()),
156
        (204, String::new()),
157
        (200, board_list(false)),
158
    ]);
159
    let origin = server.origin();
160
161
    let before = oa(
162
        &origin,
163
        &[
164
            "--json",
165
            "project",
166
            "list",
167
            "--archived",
168
            "-R",
169
            "owner/repo",
170
        ],
171
    );
172
    assert_eq!(before.status, Some(0), "stderr: {}", before.stderr);
173
    assert!(
174
        before.stdout.contains("Scratch"),
175
        "the board was not listed before the delete: {}",
176
        before.stdout
177
    );
178
179
    let archive = oa(
180
        &origin,
181
        &["project", "edit", "5", "--archive", "-R", "owner/repo"],
182
    );
183
    assert_eq!(archive.status, Some(0), "stderr: {}", archive.stderr);
184
185
    let removed = oa(
186
        &origin,
187
        &["project", "delete", "5", "--yes", "-R", "owner/repo"],
188
    );
189
    assert_eq!(removed.status, Some(0), "stderr: {}", removed.stderr);
190
    assert!(
191
        removed.stdout.contains("Deleted project #5"),
192
        "unexpected output: {}",
193
        removed.stdout
194
    );
195
196
    let after = oa(
197
        &origin,
198
        &[
199
            "--json",
200
            "project",
201
            "list",
202
            "--archived",
203
            "-R",
204
            "owner/repo",
205
        ],
206
    );
207
    assert_eq!(after.status, Some(0), "stderr: {}", after.stderr);
208
    assert!(
209
        !after.stdout.contains("Scratch"),
210
        "the board was still listed after the delete: {}",
211
        after.stdout
212
    );
213
214
    let hits = server.hits();
215
    assert_eq!(
216
        hits.iter().map(Hit::route).collect::<Vec<_>>(),
217
        vec![
218
            "GET /api/v1/repos/owner/repo/projectsV2?archived=true".to_string(),
219
            "PATCH /api/v1/repos/owner/repo/projectsV2/5".to_string(),
220
            "DELETE /api/v1/repos/owner/repo/projectsV2/5".to_string(),
221
            "GET /api/v1/repos/owner/repo/projectsV2?archived=true".to_string(),
222
        ]
223
    );
224
    assert_eq!(hits[1].json(), serde_json::json!({ "archived": true }));
225
}
226
227
/// Deleting without `--yes` refuses before anything is sent.
228
#[test]
229
fn deleting_a_board_without_confirmation_sends_nothing() {
230
    let server = StubServer::start(vec![(204, String::new())]);
231
    let origin = server.origin();
232
233
    let run = oa(&origin, &["project", "delete", "5", "-R", "owner/repo"]);
234
235
    assert_ne!(run.status, Some(0));
236
    assert!(run.stderr.contains("--yes"), "stderr: {}", run.stderr);
237
    assert!(
238
        server.hits().is_empty(),
239
        "an unconfirmed delete reached the server"
240
    );
241
}
242
243
/// An edit that names no field refuses before anything is sent.
244
#[test]
245
fn editing_a_board_with_no_field_sends_nothing() {
246
    let server = StubServer::start(vec![(200, ARCHIVED_BOARD.to_string())]);
247
    let origin = server.origin();
248
249
    let run = oa(&origin, &["project", "edit", "5", "-R", "owner/repo"]);
250
251
    assert_ne!(run.status, Some(0));
252
    assert!(run.stderr.contains("--title"), "stderr: {}", run.stderr);
253
    assert!(server.hits().is_empty(), "an empty edit reached the server");
254
}
255
256
/// Title, description, and state travel together in one PATCH.
257
#[test]
258
fn an_edit_sends_every_field_the_caller_named() {
259
    let server = StubServer::start(vec![(200, ARCHIVED_BOARD.to_string())]);
260
    let origin = server.origin();
261
262
    let run = oa(
263
        &origin,
264
        &[
265
            "project",
266
            "edit",
267
            "5",
268
            "--title",
269
            "Renamed",
270
            "--description",
271
            "Why it exists",
272
            "--state",
273
            "closed",
274
            "-R",
275
            "owner/repo",
276
        ],
277
    );
278
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
279
280
    let hits = server.hits();
281
    assert_eq!(
282
        hits[0].route(),
283
        "PATCH /api/v1/repos/owner/repo/projectsV2/5"
284
    );
285
    assert_eq!(
286
        hits[0].json(),
287
        serde_json::json!({
288
            "title": "Renamed",
289
            "description": "Why it exists",
290
            "state": "closed"
291
        })
292
    );
293
}
294
295
fn ready_repository(full_name: &str) -> String {
296
    let (owner, name) = full_name.split_once('/').expect("owner/name");
297
    format!(
298
        r#"{{"id":"11111111-1111-1111-1111-111111111111","name":"{name}","full_name":"{full_name}","owner":{{"id":1,"login":"{owner}","type":"User"}},"private":true,"visibility":"private","description":null,"default_branch":"main","lifecycle_state":"ready","provision_error_code":null,"clone_url":"http://example.test/{full_name}.git","html_url":"http://example.test/{full_name}","permissions":{{"admin":true,"push":true,"pull":true}},"created_at":"2026-08-26T00:00:00Z","updated_at":"2026-08-26T00:00:00Z"}}"#
299
    )
300
}
301
302
/// A named owner goes to the owner-neutral route with the owner in the body.
303
///
304
/// The bug this replaces read a slash as proof the owner is an organization
305
/// and posted to `/api/v1/orgs/{owner}/repos`, which is wrong whenever the
306
/// owner is a person. Both owners below take the same route now.
307
#[test]
308
fn a_named_owner_is_sent_to_the_server_rather_than_routed_on_a_guess() {
309
    for owner in ["AtlantisPleb", "OpenAgentsInc"] {
310
        let full_name = format!("{owner}/thing");
311
        let server = StubServer::start(vec![(201, ready_repository(&full_name))]);
312
        let origin = server.origin();
313
314
        let run = oa(&origin, &["repo", "create", &full_name]);
315
        assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
316
317
        let hits = server.hits();
318
        assert_eq!(hits.len(), 1, "unexpected requests: {hits:?}");
319
        assert_eq!(hits[0].route(), "POST /api/v1/repos");
320
        assert!(
321
            !hits[0].path.contains("/orgs/"),
322
            "a named owner still went to the organization route"
323
        );
324
        assert_eq!(hits[0].json()["owner"], serde_json::json!(owner));
325
        assert_eq!(hits[0].json()["name"], serde_json::json!("thing"));
326
    }
327
}
328
329
/// A bare name carries no owner, so the server uses the caller's namespace.
330
#[test]
331
fn a_bare_name_carries_no_owner() {
332
    let server = StubServer::start(vec![(201, ready_repository("octavia/thing"))]);
333
    let origin = server.origin();
334
335
    let run = oa(&origin, &["repo", "create", "thing"]);
336
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
337
338
    let hits = server.hits();
339
    assert_eq!(hits[0].route(), "POST /api/v1/repos");
340
    assert_eq!(hits[0].json().get("owner"), None);
341
}
packages/openagents-cli/README.md modified +16

@@ -208,6 +208,11 @@ Your OpenAgents namespace is your GitHub user or organization namespace. You

208 208
sign in with GitHub, and organization creation requires an active GitHub
209 209
membership that can create repositories.
210 210
211
`repo create OWNER/NAME` works for either kind of owner. The owner travels to
212
the server, which resolves whether it is a person or an organization; the CLI
213
does not decide that from the shape of the argument. `repo create NAME` uses
214
your own namespace.
215
211 216
`repo create --source <directory>` verifies the Git worktree and adds the
212 217
server-provided clone URL as a remote. It refuses to overwrite an unrelated
213 218
remote and prints the next `git push` command. The first release never pushes

@@ -418,6 +423,10 @@ openagents project list

418 423
openagents project list --archived
419 424
openagents project view 2
420 425
openagents project create --title "Issues and Projects delivery"
426
openagents project edit 2 --title "Renamed" --description "Why it exists"
427
openagents project edit 2 --state closed
428
openagents project edit 2 --archive
429
openagents project delete 2 --yes
421 430
openagents project fields 2
422 431
openagents project items 2
423 432
openagents project item-add 2 --issue 129

@@ -429,6 +438,13 @@ openagents project item-remove 2 175

429 438
Projects are repository-scoped, so every project command takes the same
430 439
`-R, --repo` and remote inference the issue commands take.
431 440
441
A board deletes in two steps. `project edit --archive` takes it out of the
442
working set, and `project delete --yes` removes it and everything on it. The
443
API refuses to delete a board that is not archived, so archiving is the
444
deliberate step that stands in for the confirmation an API caller never sees.
445
Deleting a board leaves the issues it pointed at alone: an item is a reference
446
to canonical work, not the work itself.
447
432 448
## Deploy the fleet (operators)
433 449
434 450
The `deploy` commands drive the operator-only fleet promotion API
packages/openagents-cli/contracts/repositories-v1.json modified +2

@@ -9,6 +9,7 @@

9 9
  "idempotency": {
10 10
    "header": "Idempotency-Key",
11 11
    "required_for": [
12
      "POST /api/v1/repos",
12 13
      "POST /api/v1/user/repos",
13 14
      "POST /api/v1/orgs/{org}/repos",
14 15
      "POST /api/v1/user/repos/imports",

@@ -17,6 +18,7 @@

17 18
  },
18 19
  "endpoints": {
19 20
    "get_authenticated_user": "GET /api/v1/user",
21
    "create_repository": "POST /api/v1/repos",
20 22
    "create_user_repository": "POST /api/v1/user/repos",
21 23
    "create_organization_repository": "POST /api/v1/orgs/{org}/repos",
22 24
    "import_user_repository": "POST /api/v1/user/repos/imports",
packages/openagents-cli/src/api-contract.ts modified +1 -1

@@ -3,7 +3,7 @@ import { Option, Schema } from "effect";

3 3
export const REPOSITORY_CONTRACT_NAME = "openagents.repositories.v1";
4 4
export const REPOSITORY_CONTRACT_VERSION = 1;
5 5
export const REPOSITORY_CONTRACT_SHA256 =
6
  "96a71ee0a3d19eb77ffa0721cc76876e1e514bb821368c469f0a6d91f21c3870";
6
  "1e65a325a028a7cd71d239f932a20f84981781f728cd17c5450fd318bc4a592b";
7 7
8 8
export const AuthenticatedNamespace = Schema.Struct({
9 9
  id: Schema.Union([Schema.Number, Schema.String]),
packages/openagents-cli/src/cli.ts modified +124

@@ -4236,6 +4236,128 @@ const projectCreateCommand = Command.make(

4236 4236
    }),
4237 4237
).pipe(Command.withDescription("Create a project board"));
4238 4238
4239
const projectEditTitleFlag = Flag.string("title").pipe(
4240
  Flag.optional,
4241
  Flag.withDescription("New project title"),
4242
);
4243
4244
const projectStateFlag = Flag.string("state").pipe(
4245
  Flag.optional,
4246
  Flag.withDescription("New project state: open or closed"),
4247
);
4248
4249
const projectArchiveFlag = Flag.boolean("archive").pipe(
4250
  Flag.withDescription("Move the board out of the working set"),
4251
);
4252
4253
const projectUnarchiveFlag = Flag.boolean("unarchive").pipe(
4254
  Flag.withDescription("Return the board to the working set"),
4255
);
4256
4257
const projectEditCommand = Command.make(
4258
  "edit",
4259
  {
4260
    number: projectNumberArgument,
4261
    repo: repositoryOverrideFlag,
4262
    title: projectEditTitleFlag,
4263
    description: projectDescriptionFlag,
4264
    state: projectStateFlag,
4265
    archive: projectArchiveFlag,
4266
    unarchive: projectUnarchiveFlag,
4267
  },
4268
  ({ archive, description, number, repo, state, title, unarchive }) =>
4269
    Effect.gen(function* () {
4270
      const projectNumber = yield* parseTrackerNumber("A project number", number);
4271
      if (archive && unarchive) {
4272
        return yield* new InputError({
4273
          message: "Pass either --archive or --unarchive, not both.",
4274
        });
4275
      }
4276
      const stateValue = Option.isNone(state) ? undefined : state.value;
4277
      if (stateValue !== undefined && stateValue !== "open" && stateValue !== "closed") {
4278
        return yield* new InputError({ message: "--state accepts open or closed." });
4279
      }
4280
      const archived = archive ? true : unarchive ? false : undefined;
4281
      const titleValue = Option.isNone(title) ? undefined : title.value;
4282
      const descriptionValue = Option.isNone(description) ? undefined : description.value;
4283
      if (
4284
        titleValue === undefined &&
4285
        descriptionValue === undefined &&
4286
        stateValue === undefined &&
4287
        archived === undefined
4288
      ) {
4289
        return yield* new InputError({
4290
          message: "Pass --title, --description, --state, --archive, or --unarchive.",
4291
        });
4292
      }
4293
      const flags = yield* rootCommand;
4294
      const session = yield* resolveApiSession(endpointOverrides(flags));
4295
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
4296
      const projects = yield* ProjectClient;
4297
      const output = yield* Output;
4298
      const value = yield* projects.edit({
4299
        origin: session.endpoint.origin,
4300
        token: session.token,
4301
        ...target,
4302
        number: projectNumber,
4303
        ...(titleValue === undefined ? {} : { title: titleValue }),
4304
        ...(descriptionValue === undefined ? {} : { description: descriptionValue }),
4305
        ...(stateValue === undefined ? {} : { state: stateValue }),
4306
        ...(archived === undefined ? {} : { archived }),
4307
      });
4308
      const project = record(value);
4309
      yield* output.write(
4310
        {
4311
          value,
4312
          human: [
4313
            `Updated project #${String(project["number"] ?? "?")} ${String(project["title"] ?? "")}`,
4314
          ],
4315
        },
4316
        outputMode(flags.json),
4317
      );
4318
    }),
4319
).pipe(Command.withDescription("Edit a project board's title, description, state, or archive"));
4320
4321
const projectDeleteYesFlag = Flag.boolean("yes").pipe(
4322
  Flag.withDescription("Confirm permanent project deletion"),
4323
);
4324
4325
const projectDeleteCommand = Command.make(
4326
  "delete",
4327
  { number: projectNumberArgument, repo: repositoryOverrideFlag, yes: projectDeleteYesFlag },
4328
  ({ number, repo, yes }) =>
4329
    Effect.gen(function* () {
4330
      const projectNumber = yield* parseTrackerNumber("A project number", number);
4331
      if (!yes) {
4332
        return yield* new InputError({
4333
          message: "Project deletion requires --yes confirmation.",
4334
        });
4335
      }
4336
      const flags = yield* rootCommand;
4337
      const session = yield* resolveApiSession(endpointOverrides(flags));
4338
      const target = yield* resolveTrackerTarget(repo, session.endpoint.origin);
4339
      const projects = yield* ProjectClient;
4340
      const output = yield* Output;
4341
      yield* projects.delete({
4342
        origin: session.endpoint.origin,
4343
        token: session.token,
4344
        ...target,
4345
        number: projectNumber,
4346
      });
4347
      yield* output.write(
4348
        {
4349
          value: { number: projectNumber, deleted: true },
4350
          human: [`Deleted project #${projectNumber}.`],
4351
        },
4352
        outputMode(flags.json),
4353
      );
4354
    }),
4355
).pipe(
4356
  Command.withDescription(
4357
    "Permanently delete a project board (archive it first with project edit --archive)",
4358
  ),
4359
);
4360
4239 4361
const projectItemRow = (item: Record<string, unknown>): string => {
4240 4362
  const issue = record(item["issue"]);
4241 4363
  const values = record(item["values"]);

@@ -4436,6 +4558,8 @@ const projectCommand = Command.make("project").pipe(

4436 4558
    projectListCommand,
4437 4559
    projectViewCommand,
4438 4560
    projectCreateCommand,
4561
    projectEditCommand,
4562
    projectDeleteCommand,
4439 4563
    projectFieldsCommand,
4440 4564
    projectItemsCommand,
4441 4565
    projectItemAddCommand,
packages/openagents-cli/src/project-client.ts modified +35

@@ -27,6 +27,13 @@ export interface ProjectCreateInput extends AuthenticatedApi, RepositoryTarget {

27 27
  readonly description?: string;
28 28
}
29 29
30
export interface ProjectEditInput extends ProjectNumberInput {
31
  readonly title?: string;
32
  readonly description?: string;
33
  readonly state?: string;
34
  readonly archived?: boolean;
35
}
36
30 37
export interface ProjectItemInput extends ProjectNumberInput {
31 38
  readonly itemId: string;
32 39
}

@@ -35,6 +42,8 @@ interface ProjectClientInterface {

35 42
  readonly list: (input: ProjectListInput) => Effect.Effect<unknown, CliError>;
36 43
  readonly view: (input: ProjectNumberInput) => Effect.Effect<unknown, CliError>;
37 44
  readonly create: (input: ProjectCreateInput) => Effect.Effect<unknown, CliError>;
45
  readonly edit: (input: ProjectEditInput) => Effect.Effect<unknown, CliError>;
46
  readonly delete: (input: ProjectNumberInput) => Effect.Effect<unknown, CliError>;
38 47
  readonly fields: (input: ProjectNumberInput) => Effect.Effect<unknown, CliError>;
39 48
  readonly items: (input: ProjectNumberInput) => Effect.Effect<unknown, CliError>;
40 49
  readonly addItem: (

@@ -103,6 +112,32 @@ export const projectClientLayer = Layer.effect(

103 112
          acceptedStatuses: [201],
104 113
        }),
105 114
115
      edit: (input) =>
116
        request("edit a project", {
117
          origin: input.origin,
118
          token: input.token,
119
          method: "PATCH",
120
          path: projectPath(input),
121
          body: {
122
            ...(input.title === undefined ? {} : { title: input.title }),
123
            ...(input.description === undefined ? {} : { description: input.description }),
124
            ...(input.state === undefined ? {} : { state: input.state }),
125
            ...(input.archived === undefined ? {} : { archived: input.archived }),
126
          },
127
          acceptedStatuses: [200],
128
        }),
129
130
      // The API refuses a board that is not archived, so the two-step is the
131
      // server's policy rather than a client convention. See `project archive`.
132
      delete: (input) =>
133
        request("delete a project", {
134
          origin: input.origin,
135
          token: input.token,
136
          method: "DELETE",
137
          path: projectPath(input),
138
          acceptedStatuses: [200, 204],
139
        }),
140
106 141
      fields: (input) =>
107 142
        request("list project fields", {
108 143
          origin: input.origin,
packages/openagents-cli/src/repository-client.ts modified +7 -5

@@ -320,21 +320,23 @@ export const repositoryClientLayer = Layer.effect(

320 320
      const name = yield* validateRepositoryName(input.name);
321 321
      const owner = input.owner === undefined ? undefined : yield* validateOwner(input.owner);
322 322
      const idempotencyKey = input.idempotencyKey ?? globalThis.crypto.randomUUID();
323
      // `OWNER/NAME` says an owner was named, not that the owner is an
324
      // organization. Sending a named owner to the organization route on the
325
      // strength of a slash is what made `repo create AtlantisPleb/thing` ask
326
      // the org route to create under a person. The owner travels in the body
327
      // and the server resolves which kind it is.
323 328
      const body = {
324 329
        name,
325 330
        private: input.private,
331
        ...(owner === undefined ? {} : { owner }),
326 332
        ...(input.description === undefined ? {} : { description: input.description }),
327 333
        ...(input.defaultBranch === undefined ? {} : { default_branch: input.defaultBranch }),
328 334
      };
329
      const path =
330
        owner === undefined
331
          ? `${API_VERSION_PATH}/user/repos`
332
          : `${API_VERSION_PATH}/orgs/${encoded(owner)}/repos`;
333 335
      const value = yield* retryMutation(
334 336
        request("create repository", {
335 337
          ...input,
336 338
          method: "POST",
337
          path,
339
          path: `${API_VERSION_PATH}/repos`,
338 340
          body,
339 341
          headers: { "idempotency-key": idempotencyKey },
340 342
          acceptedStatuses: [201, 202],
packages/openagents-cli/test/issue-command.test.ts modified +90

@@ -336,4 +336,94 @@ describe("issue and project commands", () => {

336 336
    expect(requests[0]?.path).toBe("/api/v1/repos/octavia/project/projectsV2/2/items");
337 337
    expect(requests[0]?.body).toEqual({ issue_number: 155 });
338 338
  });
339
340
  it("archives and then deletes a board, and the list stops carrying it", async () => {
341
    // Listing on either side of the delete is what makes this a proof: the
342
    // board is present before and absent after, so a delete that answered 204
343
    // without removing anything would still fail here.
344
    const requests: Array<ApiRequest> = [];
345
    const board = { number: 5, title: "Scratch", state: "open", archived: false };
346
    let deleted = false;
347
    const { run, written } = harness((input) =>
348
      Effect.sync(() => {
349
        requests.push(input);
350
        if (input.method === "DELETE") {
351
          deleted = true;
352
          return { status: 204, body: {} };
353
        }
354
        if (input.method === "PATCH") return { status: 200, body: { ...board, archived: true } };
355
        return { status: 200, body: { projects: deleted ? [] : [board] } };
356
      }),
357
    );
358
359
    await run(["--json", "project", "list", "--archived"]);
360
    await run(["project", "edit", "5", "--archive"]);
361
    await run(["project", "delete", "5", "--yes"]);
362
    await run(["--json", "project", "list", "--archived"]);
363
364
    expect(requests.map((entry) => `${entry.method} ${entry.path}`)).toEqual([
365
      "GET /api/v1/repos/octavia/project/projectsV2?archived=true",
366
      "PATCH /api/v1/repos/octavia/project/projectsV2/5",
367
      "DELETE /api/v1/repos/octavia/project/projectsV2/5",
368
      "GET /api/v1/repos/octavia/project/projectsV2?archived=true",
369
    ]);
370
    expect(requests[1]?.body).toEqual({ archived: true });
371
    expect(written[0]?.document.value).toEqual({ projects: [board] });
372
    expect(written[3]?.document.value).toEqual({ projects: [] });
373
  });
374
375
  it("refuses to delete a board without --yes and sends no request", async () => {
376
    const requests: Array<ApiRequest> = [];
377
    const { run } = harness((input) =>
378
      Effect.sync(() => {
379
        requests.push(input);
380
        return { status: 204, body: {} };
381
      }),
382
    );
383
384
    await expect(run(["project", "delete", "5"])).rejects.toThrow(/--yes/u);
385
    expect(requests).toEqual([]);
386
  });
387
388
  it("refuses an edit that names no field and sends no request", async () => {
389
    const requests: Array<ApiRequest> = [];
390
    const { run } = harness((input) =>
391
      Effect.sync(() => {
392
        requests.push(input);
393
        return { status: 200, body: {} };
394
      }),
395
    );
396
397
    await expect(run(["project", "edit", "5"])).rejects.toThrow(/--title/u);
398
    expect(requests).toEqual([]);
399
  });
400
401
  it("sends a title, description, and state together in one edit", async () => {
402
    const requests: Array<ApiRequest> = [];
403
    const { run } = harness((input) =>
404
      Effect.sync(() => {
405
        requests.push(input);
406
        return { status: 200, body: { number: 5, title: "Renamed" } };
407
      }),
408
    );
409
410
    await run([
411
      "project",
412
      "edit",
413
      "5",
414
      "--title",
415
      "Renamed",
416
      "--description",
417
      "Why it exists",
418
      "--state",
419
      "closed",
420
    ]);
421
422
    expect(requests[0]?.method).toBe("PATCH");
423
    expect(requests[0]?.body).toEqual({
424
      title: "Renamed",
425
      description: "Why it exists",
426
      state: "closed",
427
    });
428
  });
339 429
});
packages/openagents-cli/test/repository-client.test.ts modified +21 -8

@@ -106,32 +106,45 @@ describe("repository client", () => {

106 106
107 107
    expect(repository.full_name).toBe("octavia/project");
108 108
    expect(requests).toHaveLength(1);
109
    expect(requests[0]?.path).toBe("/api/v1/user/repos");
109
    expect(requests[0]?.path).toBe("/api/v1/repos");
110 110
    expect(requests[0]?.body).toEqual({ name: "project", private: true });
111 111
    expect(requests[0]?.headers?.["idempotency-key"]).toBeTypeOf("string");
112 112
  });
113 113
114
  it("creates an organization repository through the organization route", async () => {
114
  it("sends a named owner to the server rather than guessing its kind", async () => {
115
    // The bug this replaces: a slash in `repo create OWNER/NAME` was read as
116
    // proof the owner is an organization, so a personal namespace went to
117
    // `/api/v1/orgs/{owner}/repos`. An organization owner and a personal owner
118
    // are the same request now, and the server resolves which is which.
115 119
    const requests: Array<ApiRequest> = [];
116 120
    const layer = layerFromHandler((input) =>
117 121
      Effect.sync(() => {
118 122
        requests.push(input);
119
        return { status: 201, body: repositoryFixture("acme/project") };
123
        const sent = input.body as Record<string, unknown>;
124
        return { status: 201, body: repositoryFixture(`${String(sent["owner"])}/project`) };
120 125
      }),
121 126
    );
122
    await Effect.runPromise(
127
    const create = (owner: string) =>
123 128
      Effect.gen(function* () {
124 129
        const client = yield* RepositoryClient;
125 130
        return yield* client.create({
126 131
          origin: "http://localhost:4000",
127 132
          token,
128
          owner: "acme",
133
          owner,
129 134
          name: "project",
130 135
          private: true,
131 136
        });
132
      }).pipe(Effect.provide(layer)),
133
    );
134
    expect(requests[0]?.path).toBe("/api/v1/orgs/acme/repos");
137
      }).pipe(Effect.provide(layer));
138
139
    await Effect.runPromise(create("acme"));
140
    await Effect.runPromise(create("AtlantisPleb"));
141
142
    expect(requests.map((entry) => entry.path)).toEqual(["/api/v1/repos", "/api/v1/repos"]);
143
    expect(requests.map((entry) => (entry.body as Record<string, unknown>)["owner"])).toEqual([
144
      "acme",
145
      "AtlantisPleb",
146
    ]);
147
    expect(requests.every((entry) => !entry.path.includes("/orgs/"))).toBe(true);
135 148
  });
136 149
137 150
  it("reports repository provisioning progress before completion", async () => {

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