Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ nothing is discovered.
`phabfive.cli.edit_flow.run_edit`
- `phabfive/cli/editor.py` holds `$EDITOR`, the prompts and `typer.confirm`

### Specs That Write: Create and Edit
### Specs That Write: Create, Edit and Export

- `kind: create` and `kind: edit` are kept apart on purpose. `phabfive.spec.create`
**never** sends an `objectIdentifier`; an edit document cannot bring an object into
Expand All @@ -412,18 +412,22 @@ nothing is discovered.
- `phabfive/spec/edit.py` is the vocabulary (standard library only): the identity key
per object type, the `+`/`-` list grammar, the stamp. The edit keys are **derived**
from the create declarations in `registry._edit_fields()`, so a key cannot drift
between the two kinds
between the two kinds - an export has to be readable as either
- `phabfive/edit/spec.py` is the planner. It builds no transaction: each key goes to
`Maniphest.build_task_edit`, key by key so every problem is reported under its own
key. A full-set list becomes "add these, remove whatever else is there"; nothing is
ever sent as `.set`. Its three invariants are tested: a matching document plans
nothing, applying twice is applying once, and `modified:` is compared **only when the
item would change something** - otherwise the rerun after a successful or partial
apply would be refused for the modification the first run made
- Adding an object type to edit: its section in `spec.edit.EDIT_OBJECT_KEYS` and
`IDENTITY_KEYS`, its keys in `registry._EDITED`, a planner beside `_TaskPlanner`, and
its cases in `tests/e2e/test_edit_spec.py`. Passphrase cannot join: Phorge has no
`passphrase.edit`
- `phabfive/export/` writes values in **input grammar**, never display labels, which is
why it is not a `--format` of `show`. `dump_spec` parses its own output back and falls
back to JSON when YAML would not round-trip. An export says `interpolate: false`
- Adding an object type to edit and export: its section in `spec.edit.EDIT_OBJECT_KEYS`
and `IDENTITY_KEYS`, its keys in `registry._EDITED`, a planner beside `_TaskPlanner`,
an exporter in `phabfive/export/`, and the e2e round trip in
`tests/e2e/test_export_edit.py` - an unchanged export must apply as no change.
Passphrase cannot join: Phorge has no `passphrase.edit`

### Constructing an App

Expand Down
15 changes: 11 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,29 @@
`RepositoryConfig`, a constructed app holds it as `repository`, `Phabfive.arcrc_hosts()`
lists the instances in `~/.arcrc` without their tokens, and `Maniphest.tag_hints()` looks
the hints up
* **`phabfive export` writes a task as a spec, and `phabfive apply -f` applies it back.**
What `show` prints is for a person - `Status: Resolved`, `Visible To: All Users`, no
tags and no subscribers - and cannot be typed back in. `phabfive export T123` writes the
same task in the spelling a spec takes: a status by its key, a project by its hashtag,
a policy by its keyword. Edit the file and `phabfive apply -f` sends the difference.
`--as create` writes a create spec instead, for a new task like the old one, and
monograms can be piped in from a search: `phabfive maniphest search --tag=x | phabfive
export`. Tasks only, for now. See [Exporting and Editing with Specs](docs/edit-specs.md)
* **A third kind of spec, `kind: edit`, changes objects that already exist.** A create
spec can never touch an existing object, by design; an edit spec is the counterpart.
Each item names its task with `task: T123` and states what it should be. A key that is
absent is left alone, so `{task: T123, status: resolved}` is a whole edit. A list is
the full set, or a delta of `+name` and `-name` entries. Only the difference is sent,
through the engine `phabfive edit` uses, so a task that already matches is left alone
and applying a file twice changes nothing the second time. An item carrying
`modified:` is refused as `stale` when the task was modified
`modified:` - an export writes one - is refused as `stale` when the task was modified
after it and the item would change it; `apply --force` overrides. Every item is planned
before any is applied, and a problem with one refuses the file. `kind: edit` is never
inferred, so a file that loses the line is refused rather than applied as a create.
See [Editing with Specs](docs/edit-specs.md).
`phabfive.edit` gains `plan_edit` and `apply_edit_plan` for a program
* **`phabfive apply -f -` and `phabfive spec validate -` read the spec from standard
input**, so a spec another program writes can be piped in. The serialization is told
from the content
input**, so `phabfive export T123 | phabfive apply -f - --dry-run` works. The
serialization is told from the content
* **A spec can switch interpolation off with `interpolate: false`.** Every string in a
spec is a Jinja2 template, so a task description or paste that only quotes one -
`{{ .Values.tag }}` in a Helm example - was refused as an undefined variable, and no
Expand Down
2 changes: 1 addition & 1 deletion docs/create-specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ the old one and none is planned: `v1alpha1` promises nothing about key names, an

- [The Phorge spec format](phorge-spec.md) — the normative definition: every key, every rule
- [Searching with Specs](search-specs.md) — `kind: search`
- [Editing with Specs](edit-specs.md) — `kind: edit`, for objects that already exist
- [Exporting and Editing with Specs](edit-specs.md) — `kind: edit`, for objects that already exist
- [Maniphest CLI](maniphest-cli.md) — the single-task commands
- [Project CLI](project-cli.md) — the single-project commands
- [Phorge Setup](phorge-setup.md) — the local instance the shipped specs are written against
2 changes: 1 addition & 1 deletion docs/edit-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,7 @@ phabfive edit T123 --priority=high --dry-run
- [Policies](policies.md) - Who can see, edit and interact with an object
- [Searching with Specs](search-specs.md) - Reusable searches in a file
- [Creating with Specs](create-specs.md) - Bulk creation from a file
- [Editing with Specs](edit-specs.md) - Changing tasks with a file instead of flags
- [Exporting and Editing with Specs](edit-specs.md) - A task as a file: export it, change it, apply it
- [Retries](retries.md) - What is retried, how long it waits, and pacing

## Architecture and Future Support
Expand Down
168 changes: 135 additions & 33 deletions docs/edit-specs.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,102 @@
# Editing with Specs
# Exporting and Editing with Specs

An edit spec is a file that says what tasks that already exist should be. `phabfive apply`
reads it and changes each task to match:
`phabfive export` writes an object that already exists as a spec. `phabfive apply` reads the
spec back and changes the object to match:

```bash
phabfive apply -f specs/edit/close-out.yaml --dry-run
phabfive apply -f specs/edit/close-out.yaml
phabfive export T123 > task.yaml # the task, as a file
$EDITOR task.yaml # change what should change
phabfive apply -f task.yaml --dry-run
phabfive apply -f task.yaml
```

This page is about running one. **The format — every key of an edit document, the list
This page is about running them. **The format — every key of an edit document, the list
grammar, the stamp — is defined in [the Phorge spec format](phorge-spec.md#edit-documents)**,
and is not repeated here. When a sentence on this page and a sentence on that one disagree,
that one is right.

## The command
## Why not `show`

`phabfive T123` prints a task for a person: `Status: Resolved`, `Visible To: All Users`. None
of it can be typed back in — `--status` takes `resolved`, `--visible-to` takes `users`, and
the tags and subscribers are not printed at all.

An export is the same task in the spelling a spec takes:

```console
$ phabfive export T123
spec: phorge/v1alpha1
kind: edit
interpolate: false
tasks:
- task: T123
modified: '2026-01-31T09:30:00Z'
title: Fix the login flow
status: open
priority: high
assignment: viola.larsson
projects:
- '#infrastructure'
- '#security'
column: In Progress
subscribers:
- tommy.svensson
space: S1
visible-to: users
editable-by: users
parents:
- T100
subtasks: []
commits: []
description: |
Sessions are dropped when the token is refreshed.
Reproduce with two tabs open.
```

A project is its `#hashtag`, which names one project where a display name might name two. A
list is sorted, so the file changes when the task does and not when the server happens to
answer in another order.

## The commands

```
phabfive export [MONOGRAM]... [--as edit|create] [-o FILE]
phabfive apply -f FILE [--dry-run] [--force]
```

`apply -f -` reads the spec from standard input. Tasks can be edited today. Pastes, projects
and repositories have edit endpoints and will follow. A passphrase cannot be edited through
Conduit at all.
- `export` takes monograms or links, or reads them from standard input: bare monograms, or
what a search prints in `--format=yaml`, `json` or `jsonl`.

```bash
phabfive export T123 T124 -o tasks.yaml
phabfive maniphest search --tag=backend | phabfive export > backend.yaml
```

- The output is YAML, on a terminal and off one. `--format=json` and `--format=jsonl` write
the same document in those serializations; the terminal formats are refused, because the
output is a file.
- `apply -f -` reads the spec from standard input, so the two compose:

```bash
phabfive export T123 | phabfive apply -f - --dry-run
```

## Only the difference is sent
- Tasks are exported today. Pastes, projects and repositories have edit endpoints and will
follow. A passphrase cannot be created or edited through Conduit at all, so it never will
be more than readable.

## An unchanged export changes nothing

Each item of an edit spec is compared with the task as the server has it, and only the
difference is sent:
difference is sent. Straight out of `export` there is no difference:

```console
$ phabfive export T123 | phabfive apply -f - --dry-run
[DRY RUN] -: would edit 0 objects, 1 already as written.
(unchanged) task T123 'Fix the login flow'
```

Change two lines and that is what is sent:

```console
$ phabfive apply -f task.yaml --dry-run
Expand All @@ -50,10 +120,10 @@ already matches is left alone, and the exit status is still 0.
There is no confirmation prompt, as for a create spec. The file is the statement of intent
and `--dry-run` is the preview.

## Writing one
## Writing one by hand

An edit spec states only what should change. **A key that is absent is left alone**, so this
is a complete spec:
An export states the whole task, but an edit spec does not have to. **A key that is absent is
left alone**, so this is a complete spec:

```yaml
spec: phorge/v1alpha1
Expand All @@ -78,7 +148,7 @@ A list is either the full set or a delta:
| `[]` | Nobody |
| `["+carol", "-bob"]` | Add `carol`, remove `bob`, leave the rest |

Mixing the two forms in one list is refused. Quote an
`export` always writes the full set. Mixing the two forms in one list is refused. Quote an
entry YAML would read as a number: `"+1"`.

`assignment: "@none"` clears the assignee. `priority: raise` and `column: forward` are
Expand All @@ -89,11 +159,11 @@ refused: they are moves, and a file that is applied twice has to name where the
### The column follows the projects

`column:` names a column but not a board, so it means the one workboard the task is on once
the item is applied. That has a consequence for an item that says both `column:` and
`projects:`:
the item is applied. An exported task on one board carries its `column:`, and that has a
consequence when only `projects:` is edited:

- retag the task into a **different** project and the column is looked for on that
project's board. The task is placed there if a column of that name exists, and the item is refused
- retag it into a **different** project and the column is looked for on that project's
board. The task is placed there if a column of that name exists, and the item is refused
as `unknown-column` if it does not;
- remove its **only** board project and the item is refused, because there is no board left
for the column to be on.
Expand All @@ -103,38 +173,70 @@ the board place the task.

## When somebody else changed the task

An item can say when the task was last modified as its writer saw it, with `modified:` and
an ISO 8601 timestamp. If the task has been modified since and the item would change it,
`apply` refuses:
An export stamps each task with when it was last modified. If the task has been modified
since and the file would change it, `apply` refuses:

```console
$ phabfive apply -f task.yaml
tasks[0]
modified: T123 was modified at 2026-02-02T14:05:10Z, after the 2026-01-31T09:30:00Z this item was written against, and the item would change it. Write it against the task as it is now, or apply with --force to overwrite [stale]
modified: T123 was modified at 2026-02-02T14:05:10Z, after the 2026-01-31T09:30:00Z this item was written against, and the item would change it. Export it again, or apply with --force to overwrite [stale]

task.yaml: 1 error, 0 warnings
```

Exit 2, and nothing is written — not to that task and not to any other in the file. Look
at the task again and redo the edit, or pass `--force`.
Exit 2, and nothing is written — not to that task and not to any other in the file. Export
again and redo the edit, or pass `--force`.

`--force` means the file wins. Its lists are full sets written before the other change, so a
subscriber somebody added in between is removed again. Use a `+`/`-` list, or leave the key
out, for anything the file should not own.

The stamp is only consulted for an item that would change something, so applying the same
file a second time is not refused for the modification the first run made. An item with no
`modified:` is never checked. The stamp is to the second, which is how precisely
file a second time is not refused for the modification the first run made. A hand-written
item with no `modified:` is never checked. The stamp is to the second, which is how precisely
the server reports it.

## Copying a task

`--as create` writes a create spec instead: no `task:`, no `modified:`, and no parents,
subtasks or commits, because a copy is not the same node in the graph.

```bash
phabfive export T123 --as create > template.yaml
phabfive apply -f template.yaml # a new task like T123
```

The result is an ordinary [create spec](create-specs.md). Applying it twice creates two
tasks.

## What is not exported

A spec has no key for points, custom fields, the subtype, comments or the author, so an
export does not carry them and an edit cannot change them.

Three things are exported only when they can be read back as written:

- **A column**, for a task on exactly one workboard. `column:` names no board, so with
several there is no saying which it means. Move such a task with
`phabfive edit T123 --tag BOARD --column COLUMN`.
- **A priority** in the stock table. An instance that defines its own has no keyword the
edit takes, so the key is left out — and an absent key is left alone.
- **A subscriber that is a project or a package** is written as its PHID and kept as it is.
A spec subscribes and unsubscribes users only.
- **A policy** that is a keyword, a project, a user or a custom policy. "Subscribers" and
"Task Author" have no spelling in a spec, so the key is left out and the task keeps the
policy it has.

## Text is sent as written

Every string in a spec is a template. A description quoting `{{ .Values.tag }}` is refused as
an undefined variable unless the file says `interpolate: false`, which sends every string
exactly as written. Such a file takes no `variables:` and no `--set`.
Every export says `interpolate: false`. A description quoting `{{ .Values.tag }}` is text,
not a variable, and without that line it would be refused as an undefined one. Such a file
takes no `variables:` and no `--set`.

A spec has no key for points, custom fields, the subtype, comments or the author, so an edit
cannot change them.
A multi-line description is written as a YAML block where a block holds it exactly. Text a
block would alter — a line ending in a space, a carriage return — is written quoted instead.
Either way `export` parses its own output back before printing it, so what is in the file is
what was in the task.

## Machine-readable output

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ A spec is one file that says what should exist, or what to look for, across appl

- **[The Phorge spec format](phorge-spec.md)** - The normative definition: the envelope, every key, the reference grammar, the variables, the serializations and the two validation layers
- **[Creating with Specs](create-specs.md)** - Writing a create spec, previewing it with `--dry-run`, and fixing what it reports
- **[Editing with Specs](edit-specs.md)** - Writing an edit spec for tasks that already exist, previewing it with `--dry-run`, and applying it
- **[Exporting and Editing with Specs](edit-specs.md)** - Writing an existing task as a spec with `phabfive export`, changing it, and applying it back
- **[Searching with Specs](search-specs.md)** - Writing a search spec, running several searches and several applications from one file

### Behaviour
Expand Down
3 changes: 2 additions & 1 deletion docs/phorge-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,8 @@ tasks:

Each item is compared with the object as the instance has it, and **only the
difference is sent**. An object that already matches is left alone, so applying
a document twice changes nothing the second time.
a document twice changes nothing the second time, and a document written from an
object - an export - applies as no change at all until somebody edits it.

An edit item is flat. It has no `id:`, no nested `tasks:` and no `$local-id`
reference: those link objects that do not exist yet, and every object here does.
Expand Down
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ nav:
- Policies: 'policies.md'
- Phorge Spec Format: 'phorge-spec.md'
- Creating with Specs: 'create-specs.md'
- Editing with Specs: 'edit-specs.md'
- Exporting and Editing with Specs: 'edit-specs.md'
- Searching with Specs: 'search-specs.md'
- Caching: 'caching.md'
- Retries: 'retries.md'
Expand Down
Loading
Loading