feat(project): manage a project's custom fields #143

Merged
Claude-Run merged 1 commit from feat/YT-89-project-custom-fields into main 2026-08-05 03:24:32 +02:00
Member

yt project could read a project but not configure one: nothing managed which custom fields were attached, so standing a project up stayed a click-path through Administration > Projects > Fields even when the rest of the setup was scripted. The failure mode is silent. Attaching a NEW field named AI Agent instead of the existing instance-level one produces a field that looks correct in the UI, carries its own empty bundle, makes yt issue set-field AUD-1 "AI Agent" Failed return HTTP 400 while the same command succeeds on DEV, and makes the runner's -Resolved AI Agent: Queued query match nothing.

yt project fields <SHORT_NAME> lists the attached fields with their bundle name and id, which is what makes two same-named fields distinguishable at a glance. The bare form is kept here rather than replaced by fields list (both parse) because it is the form a setup script reaches for. fields add --field <NAME> attaches an existing instance-level field: the name is resolved against the paged prototype collection by exact match, and a name matching more than one prototype is refused with the candidate ids instead of guessed at. So is a --bundle name matching more than one bundle, which is the same mistake one level down. With --bundle omitted the bundle is taken from the prototype's existing attachments and only when every one of them agrees, so an attach copies what the other projects already share instead of inventing a fresh empty bundle. --create-type <FIELD_TYPE> creates the instance-level prototype first when no field of that name exists, so a project is not left half-configurable. fields remove --field <NAME> detaches, prompting unless --yes is given, matching the yt issue delete convention.

Resolution is split from the write: api::plan_attach_project_custom_field returns an AttachFieldPlan that api::attach_project_custom_field executes, so --dry-run prints the byte-exact body the live path would post. The ids the body carries are explained in a separate resolved block rather than mixed into the payload, so the preview never shows a key the real request would not send. Both writes re-read the project afterwards and report a request the server accepted and then ignored, the same contract the team membership writes carry.

Every one of the four new collection reads goes through fetch_all_pages, including the prototype's instances, which is read from its own subresource rather than nested in the prototype entity because a nested collection cannot page (YT-83). The value-type maps (bundle_path_segment, project_custom_field_type) are closed lists probed against the live instance; ownedField and build had no attachment to sample, so an existing instance's $type is preferred over the map whenever one exists.

Covered by fifteen new tests in youtrack-client (fourteen against wiremock, one pure map assertion), two clap parse tests, and four integration tests that drive the real binary against a mock server with the POST and DELETE mounted at expect(0), so a --dry-run that started sending a mutation fails the build.

#YT-89

`yt project` could read a project but not configure one: nothing managed which custom fields were attached, so standing a project up stayed a click-path through Administration > Projects > Fields even when the rest of the setup was scripted. The failure mode is silent. Attaching a NEW field named `AI Agent` instead of the existing instance-level one produces a field that looks correct in the UI, carries its own empty bundle, makes `yt issue set-field AUD-1 "AI Agent" Failed` return HTTP 400 while the same command succeeds on `DEV`, and makes the runner's `-Resolved AI Agent: Queued` query match nothing. `yt project fields <SHORT_NAME>` lists the attached fields with their bundle name and id, which is what makes two same-named fields distinguishable at a glance. The bare form is kept here rather than replaced by `fields list` (both parse) because it is the form a setup script reaches for. `fields add --field <NAME>` attaches an existing instance-level field: the name is resolved against the paged prototype collection by exact match, and a name matching more than one prototype is refused with the candidate ids instead of guessed at. So is a `--bundle` name matching more than one bundle, which is the same mistake one level down. With `--bundle` omitted the bundle is taken from the prototype's existing attachments and only when every one of them agrees, so an attach copies what the other projects already share instead of inventing a fresh empty bundle. `--create-type <FIELD_TYPE>` creates the instance-level prototype first when no field of that name exists, so a project is not left half-configurable. `fields remove --field <NAME>` detaches, prompting unless `--yes` is given, matching the `yt issue delete` convention. Resolution is split from the write: `api::plan_attach_project_custom_field` returns an `AttachFieldPlan` that `api::attach_project_custom_field` executes, so `--dry-run` prints the byte-exact body the live path would post. The ids the body carries are explained in a separate `resolved` block rather than mixed into the payload, so the preview never shows a key the real request would not send. Both writes re-read the project afterwards and report a request the server accepted and then ignored, the same contract the team membership writes carry. Every one of the four new collection reads goes through `fetch_all_pages`, including the prototype's `instances`, which is read from its own subresource rather than nested in the prototype entity because a nested collection cannot page (YT-83). The value-type maps (`bundle_path_segment`, `project_custom_field_type`) are closed lists probed against the live instance; `ownedField` and `build` had no attachment to sample, so an existing instance's `$type` is preferred over the map whenever one exists. Covered by fifteen new tests in `youtrack-client` (fourteen against wiremock, one pure map assertion), two clap parse tests, and four integration tests that drive the real binary against a mock server with the POST and DELETE mounted at `expect(0)`, so a `--dry-run` that started sending a mutation fails the build. #YT-89
feat(project): manage a project's custom fields
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 27s
Create release / Create release from merged PR (pull_request) Has been skipped
cf93a5b6f9
`yt project` could read a project but not configure one: nothing managed which custom fields were attached, so standing a project up stayed a click-path through Administration > Projects > Fields even when the rest of the setup was scripted. The failure mode is silent. Attaching a NEW field named `AI Agent` instead of the existing instance-level one produces a field that looks correct in the UI, carries its own empty bundle, makes `yt issue set-field AUD-1 "AI Agent" Failed` return HTTP 400 while the same command succeeds on `DEV`, and makes the runner's `-Resolved AI Agent: Queued` query match nothing.

`yt project fields <SHORT_NAME>` lists the attached fields with their bundle name and id, which is what makes two same-named fields distinguishable at a glance. The bare form is kept here rather than replaced by `fields list` (both parse) because it is the form a setup script reaches for. `fields add --field <NAME>` attaches an existing instance-level field: the name is resolved against the paged prototype collection by exact match, and a name matching more than one prototype is refused with the candidate ids instead of guessed at. So is a `--bundle` name matching more than one bundle, which is the same mistake one level down. With `--bundle` omitted the bundle is taken from the prototype's existing attachments and only when every one of them agrees, so an attach copies what the other projects already share instead of inventing a fresh empty bundle. `--create-type <FIELD_TYPE>` creates the instance-level prototype first when no field of that name exists, so a project is not left half-configurable. `fields remove --field <NAME>` detaches, prompting unless `--yes` is given, matching the `yt issue delete` convention.

Resolution is split from the write: `api::plan_attach_project_custom_field` returns an `AttachFieldPlan` that `api::attach_project_custom_field` executes, so `--dry-run` prints the byte-exact body the live path would post. The ids the body carries are explained in a separate `resolved` block rather than mixed into the payload, so the preview never shows a key the real request would not send. Both writes re-read the project afterwards and report a request the server accepted and then ignored, the same contract the team membership writes carry.

Every one of the four new collection reads goes through `fetch_all_pages`, including the prototype's `instances`, which is read from its own subresource rather than nested in the prototype entity because a nested collection cannot page (YT-83). The value-type maps (`bundle_path_segment`, `project_custom_field_type`) are closed lists probed against the live instance; `ownedField` and `build` had no attachment to sample, so an existing instance's `$type` is preferred over the map whenever one exists.

Covered by fifteen new tests in `youtrack-client` (fourteen against wiremock, one pure map assertion), two clap parse tests, and four integration tests that drive the real binary against a mock server with the POST and DELETE mounted at `expect(0)`, so a `--dry-run` that started sending a mutation fails the build.

#YT-89
Claude-Run deleted branch feat/YT-89-project-custom-fields 2026-08-05 03:24:32 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pandoras-box/youtrack-cli!143
No description provided.