From aad93650e72126545f85e4e21759598843185ac4 Mon Sep 17 00:00:00 2001 From: Henrik Holmboe Date: Thu, 12 Nov 2026 22:31:19 +0100 Subject: [PATCH] feat: add phabfive export, a task written as a spec 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. The file is a `kind: edit` spec, so `phabfive apply -f` sends whatever is changed in it, and an unchanged export applies as no change. - `--as create` writes a create spec instead, for a new task like the old one, without the identity, the stamp and the relations - Monograms are arguments or piped in from a search - Each task is stamped with `modified:`, so a change to a task somebody else has touched since is refused as `stale` - The file says `interpolate: false`, and the YAML is parsed back before it is printed, so the text in the file is the text in the task - Left out, because a spec cannot spell them: the column of a task on several workboards, a priority outside the stock table, and the "Subscribers" and "Task Author" policies. An absent key is left alone Co-Authored-By: Claude Fable 5.2 --- AGENTS.md | 16 +- CHANGELOG.md | 15 +- docs/create-specs.md | 2 +- docs/edit-cli.md | 2 +- docs/edit-specs.md | 168 +++++++-- docs/index.md | 2 +- docs/phorge-spec.md | 3 +- mkdocs.yml | 2 +- phabfive/SKILL.md | 20 +- phabfive/__init__.py | 1 + phabfive/cli/__init__.py | 2 + phabfive/cli/export.py | 212 +++++++++++ phabfive/cli/spec_flags.py | 3 +- phabfive/cli/spec_run.py | 2 +- phabfive/edit/spec.py | 4 +- phabfive/export/__init__.py | 241 ++++++++++++ phabfive/export/tasks.py | 302 +++++++++++++++ specs/edit/close-out.yaml | 4 + tests/e2e/test_export_edit.py | 277 ++++++++++++++ tests/test_export.py | 600 ++++++++++++++++++++++++++++++ tests/test_monogram_completion.py | 1 + 21 files changed, 1821 insertions(+), 58 deletions(-) create mode 100644 phabfive/cli/export.py create mode 100644 phabfive/export/__init__.py create mode 100644 phabfive/export/tasks.py create mode 100644 tests/e2e/test_export_edit.py create mode 100644 tests/test_export.py diff --git a/AGENTS.md b/AGENTS.md index 4ae01881..9b9618b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -412,7 +412,7 @@ 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 @@ -420,10 +420,14 @@ nothing is discovered. 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e67cf759..cfdfbc2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,14 @@ `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 @@ -41,15 +49,14 @@ 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 diff --git a/docs/create-specs.md b/docs/create-specs.md index cb0b717d..e12a6b56 100644 --- a/docs/create-specs.md +++ b/docs/create-specs.md @@ -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 diff --git a/docs/edit-cli.md b/docs/edit-cli.md index d120892e..92f25e14 100644 --- a/docs/edit-cli.md +++ b/docs/edit-cli.md @@ -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 diff --git a/docs/edit-specs.md b/docs/edit-specs.md index b3d61932..280f8b23 100644 --- a/docs/edit-specs.md +++ b/docs/edit-specs.md @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 diff --git a/docs/index.md b/docs/index.md index 28803159..2775524b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/phorge-spec.md b/docs/phorge-spec.md index 9daa5feb..78dd254b 100644 --- a/docs/phorge-spec.md +++ b/docs/phorge-spec.md @@ -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. diff --git a/mkdocs.yml b/mkdocs.yml index b0baacd3..0f9f3c47 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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' diff --git a/phabfive/SKILL.md b/phabfive/SKILL.md index 12278550..62dd6054 100644 --- a/phabfive/SKILL.md +++ b/phabfive/SKILL.md @@ -968,22 +968,30 @@ linked to each other by the `$local-id`s the file gives them. `search` runs ever a `kind: search` spec holds, in document order, through one client. Handed the other's kind, each refuses by name and prints the command that does run it, exit 1. -`apply` also runs a `kind: edit` spec, which changes tasks that already exist: +`apply` also runs a `kind: edit` spec, which changes tasks that already exist, and +`export` writes one from a task as it is: ```bash -phabfive apply -f specs/edit/close-out.yaml --dry-run # only the difference -phabfive apply -f specs/edit/close-out.yaml +phabfive export T123 > task.yaml # the task in the spelling a spec takes +phabfive export T123 | phabfive apply -f - --dry-run # reports no change +phabfive apply -f task.yaml --dry-run # after editing: only the difference +phabfive apply -f task.yaml +phabfive export T123 --as create | phabfive apply -f - # a new task like T123 +phabfive maniphest search --tag=backend | phabfive export > backend.yaml ``` -An edit item names its task with `task: T123` and states what the task +Use `export`, not `show`, when the output is going to be edited and sent back: `show` +prints display labels (`Status: Resolved`) that no option accepts, and leaves out tags +and subscribers. An edit item names its task with `task: T123` and states what the task 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 (`[alice, bob]`, anyone else is removed) or a delta (`["+carol", "-bob"]`), never a mix. `kind: edit` must be written; it is never inferred. Only the difference is sent, so applying a file twice changes nothing the second time and still exits 0, each record saying `edited` or `unchanged`. -An item may carry `modified:`, an ISO 8601 timestamp. `apply` refuses, exit 2 and code -`stale`, to change a task modified after it; pass `--force` to let the file win. Any problem with any item - a name that does not resolve, a task that does +An export stamps each task with `modified:`. `apply` refuses, exit 2 and code `stale`, +to change a task modified after its stamp; export again, or pass `--force` to let the +file win. Any problem with any item - a name that does not resolve, a task that does not exist - refuses the whole file before anything is written. `apply -f -` reads the spec from standard input. Tasks only, for now. diff --git a/phabfive/__init__.py b/phabfive/__init__.py index 44885929..2d49fc97 100644 --- a/phabfive/__init__.py +++ b/phabfive/__init__.py @@ -156,6 +156,7 @@ "diffusion", "edit", "exceptions", + "export", "fulltext", "maniphest", "options", diff --git a/phabfive/cli/__init__.py b/phabfive/cli/__init__.py index 60136c50..b51a935a 100644 --- a/phabfive/cli/__init__.py +++ b/phabfive/cli/__init__.py @@ -33,6 +33,7 @@ from phabfive.cli.shell_completion import MonogramGroup, install_bash_escaping from phabfive.cli.spec import spec_app from phabfive.cli.spec_run import apply_command, search_command +from phabfive.cli.export import export_command from phabfive.cli.user import user_app from phabfive.constants import ( AutoOption, @@ -354,6 +355,7 @@ def main( app.command(name="apply")(apply_command) app.command(name="search")(search_command) # The mirror of `apply`: what exists, written as a spec `apply -f` reads. +app.command(name="export")(export_command) app.command(name="profile")(profile_command) app.command(name="hints")(hints_command) # Show repl command only if ptpython is installed (phabfive[repl]) diff --git a/phabfive/cli/export.py b/phabfive/cli/export.py new file mode 100644 index 00000000..f981d669 --- /dev/null +++ b/phabfive/cli/export.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +"""``phabfive export``: an object that exists, written as a spec. + +The mirror of ``phabfive apply -f``. What a show command prints is for a +person and cannot be typed back in; what this prints is the same object in +the grammar a spec takes, so it can be edited and applied: + + phabfive export T123 > task.yaml + $EDITOR task.yaml + phabfive apply -f task.yaml + +The data is `phabfive.export`'s. What is added here is where the monograms +come from - arguments, or the output of a search on standard input - and +where the document goes. +""" + +import json +import re +import sys +from enum import Enum +from pathlib import Path +from typing import Any, List, Optional + +import typer + +from phabfive.exceptions import PhabfiveException + +__all__ = ["ExportKind", "export_command"] + +#: A monogram on its own, or as the last thing in a link to the object. +_MONOGRAM = re.compile(r"(?:^|/)([A-Za-z][0-9]+)$") + +#: The formats a spec can be written in, which are the ones one is read in. +_FORMATS = ("yaml", "json", "jsonl") + + +class ExportKind(str, Enum): + """What ``--as`` accepts.""" + + edit = "edit" + create = "create" + + +def _monogram(text: Any) -> str: + """The monogram in `T123` or in a link to it. + + Raises + ------ + PhabfiveException + The text holds neither. + """ + from phabfive.exceptions import PhabfiveInputException + + match = _MONOGRAM.search(str(text).strip()) + + if match is None: + raise PhabfiveInputException(f"{text!r} is not a monogram such as T123") + + return match.group(1).upper() + + +def _piped(text: str) -> List[str]: + """The monograms on standard input, however they were written. + + Bare monograms separated by white space, or what a search prints in + ``--format=yaml``, ``json`` or ``jsonl``: records carrying a ``Link``. + The second is what makes ``phabfive maniphest search ... | phabfive + export`` work, the same way it feeds ``phabfive edit``. + """ + from ruamel.yaml import YAML + + from phabfive.exceptions import PhabfiveDataException + + words = text.split() + + if words and all(_MONOGRAM.search(word) for word in words): + return [_monogram(word) for word in words] + + found: List[str] = [] + + try: + # JSON Lines first: one record per line with nothing between them is + # not one YAML stream, and it is what `--format=jsonl` prints + documents = [json.loads(line) for line in text.splitlines() if line.strip()] + except ValueError: + documents = None + + try: + if documents is None: + documents = [ + document for document in YAML(typ="safe").load_all(text) if document + ] + except Exception as error: # noqa: BLE001 - any parser failure is one answer + raise PhabfiveDataException( + f"Standard input is neither monograms nor search output: {error}" + ) from None + + for document in documents: + for entry in document if isinstance(document, list) else [document]: + if not isinstance(entry, dict) or "Link" not in entry: + raise PhabfiveDataException( + "Standard input holds a record with no Link to say what it " + "is. Pipe in monograms, or the output of a search in " + "--format=yaml, json or jsonl" + ) + + found.append(_monogram(entry["Link"])) + + return found + + +def export_command( + ctx: typer.Context, + monograms: Optional[List[str]] = typer.Argument( + None, + metavar="[MONOGRAM]...", + help="What to export, e.g. T123 T124. Read from standard input when absent.", + ), + as_kind: ExportKind = typer.Option( + ExportKind.edit, + "--as", + help=( + "edit: a spec that changes these objects, applied unchanged it " + "changes nothing. create: a spec that creates new objects like " + "them." + ), + ), + output: Optional[str] = typer.Option( + None, + "-o", + "--output", + metavar="FILE", + help="Write the spec here instead of to standard output.", + ), +) -> None: + """Write objects that exist as a spec `phabfive apply -f` reads. + + Every value is written the way a spec takes it rather than the way a + show command displays it: a status by its key, a project by its + hashtag, a policy by its keyword. + + As an edit spec (the default) each object is named and stamped with when + it was last modified. Applying the file unchanged changes nothing; edit + it and apply it, and only the difference is sent. If somebody else has + changed the object in between, the apply is refused rather than + overwriting them. + + As a create spec the identity, the stamp and the links to other objects + are left out, so applying it creates a new object like the old one. + + The output is YAML unless --format says json or jsonl. Tasks are + exported today. + + \b + Examples: + phabfive export T123 + phabfive export T123 T124 -o tasks.yaml + phabfive export T123 --as create | phabfive apply -f - + phabfive maniphest search --tag=backend | phabfive export + phabfive export T123 | phabfive apply -f - --dry-run + """ + from phabfive.cli.apps import get_app + from phabfive.constants import FORMAT_ALIASES + + asked = (ctx.obj or {}).get("format") + output_format = FORMAT_ALIASES.get(asked, asked) if asked else "yaml" + + if output_format not in _FORMATS: + typer.echo( + f"Error: a spec is written as {', '.join(_FORMATS)}, not as " + f"{output_format}.", + err=True, + ) + raise typer.Exit(1) + + try: + if monograms: + wanted = [_monogram(one) for one in monograms] + elif sys.stdin.isatty(): + typer.echo( + "Error: nothing to export. Name an object, e.g. phabfive " + "export T123, or pipe in a search.", + err=True, + ) + raise typer.Exit(1) + else: + wanted = _piped(sys.stdin.read()) + + if not wanted: + typer.echo("Error: standard input named nothing to export.", err=True) + raise typer.Exit(1) + + # Imported here rather than at module import, like the spec engine + # in `apply`: every other command would otherwise pay for it + from phabfive.export import dump_spec, export_spec + from phabfive.maniphest import Maniphest + + spec = export_spec(get_app(Maniphest), wanted, kind=as_kind.value) + text = dump_spec(spec, output_format) + except PhabfiveException as error: + typer.echo(f"Error: {error}", err=True) + raise typer.Exit(1) from None + + if output is None or output == "-": + sys.stdout.write(text) + return + + try: + Path(output).write_text(text, encoding="utf-8") + except OSError as error: + typer.echo(f"Error: could not write {output}: {error}", err=True) + raise typer.Exit(1) from None diff --git a/phabfive/cli/spec_flags.py b/phabfive/cli/spec_flags.py index f4c2e251..8ebf943e 100644 --- a/phabfive/cli/spec_flags.py +++ b/phabfive/cli/spec_flags.py @@ -58,7 +58,8 @@ } #: The FILE that means "read the spec from standard input", so the output of -#: one command can be the spec of the next. The format is told from the content, there being no suffix. +#: one command can be the spec of the next: `phabfive export T1 | phabfive +#: apply -f -`. The format is told from the content, there being no suffix. STDIN = "-" #: The sentence that differs between the two kinds, and the only part of diff --git a/phabfive/cli/spec_run.py b/phabfive/cli/spec_run.py index 565d5167..c41095ce 100644 --- a/phabfive/cli/spec_run.py +++ b/phabfive/cli/spec_run.py @@ -433,7 +433,7 @@ def apply_command( phabfive apply -f specs/create/sprint-tasks.yaml --set sprint=42 phabfive --format=json apply -f specs/create/sprint-tasks.yaml phabfive apply -f specs/edit/close-out.yaml --dry-run - cat spec.yaml | phabfive apply -f - --dry-run + phabfive export T123 | phabfive apply -f - --dry-run """ _setup_output_options(ctx) output_format = _get_output_format(ctx) diff --git a/phabfive/edit/spec.py b/phabfive/edit/spec.py index ec98111f..579fe834 100644 --- a/phabfive/edit/spec.py +++ b/phabfive/edit/spec.py @@ -685,8 +685,8 @@ def stale(self) -> Optional[Problem]: ( f"{self.monogram} was modified at {format_stamp(modified)}, " f"after the {format_stamp(stamp)} this item was written " - f"against, and the item would change it. Write it against the " - f"task as it is now, or apply with --force to overwrite" + f"against, and the item would change it. Export it again, or " + f"apply with --force to overwrite" ), code="stale", ) diff --git a/phabfive/export/__init__.py b/phabfive/export/__init__.py new file mode 100644 index 00000000..7711ce19 --- /dev/null +++ b/phabfive/export/__init__.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +"""Writing a spec from objects that exist. + +`phabfive show` prints an object for a person: `Status: Open`, `Visible To: +All Users`. None of that can be typed back in. An export is the same object +in the grammar a spec takes - `status: open`, `visible-to: users` - as a +document `phabfive apply -f` reads: + + from phabfive import Maniphest + from phabfive.export import dump_spec, export_spec + + app = Maniphest(url=URL, token=TOKEN) + spec = export_spec(app, ["T123", "T124"]) # kind: edit + print(dump_spec(spec)) + +Two kinds can be asked for: + +- ``edit`` (the default) names each object and stamps it with when it was + last modified. Applied unchanged it changes nothing; edit the file and + apply it, and the difference is sent - see `phabfive.edit.spec`. +- ``create`` describes a *new* object like the old one: the identity, the + stamp and the relations are left out, because a copy is not the same node + in the graph. + +Either way the document says ``interpolate: false``. Its text is whatever +the object held, and a description that quotes a template must not be read +as one. + +Nothing here prints, prompts or writes a file; the command is `phabfive +export`. +""" + +from __future__ import annotations + +import io +import json +from collections.abc import Iterable, Mapping, Sequence +from typing import TYPE_CHECKING, Any + +from phabfive.exceptions import PhabfiveInputException +from phabfive.export.tasks import export_tasks +from phabfive.spec.envelope import SPEC_VERSION, Kind, Spec, as_kind + +if TYPE_CHECKING: # pragma: no cover - imported for annotations only + pass + +__all__ = [ + "EXPORTED_TYPES", + "EXPORT_KINDS", + "dump_spec", + "export_spec", +] + +#: The object types an export writes, by the letter of their monogram, with +#: the body key each is written under. A type joins when its exporter and +#: the edit planner that reads it back both exist. +EXPORTED_TYPES: Mapping[str, tuple[str, str]] = {"T": ("task", "tasks")} + +#: The kinds of document an export writes. +EXPORT_KINDS = (Kind.EDIT, Kind.CREATE) + +_EXPORTERS = {"task": export_tasks} + + +def _split(monograms: Iterable[str]) -> dict[str, list[str]]: + """The ids asked for, per object type, in the order given and once each.""" + wanted: dict[str, list[str]] = {} + + for monogram in monograms: + text = str(monogram).strip() + letter, number = text[:1].upper(), text[1:] + named = EXPORTED_TYPES.get(letter) + + if named is None or not number.isdigit(): + known = ", ".join(f"{letter}123" for letter in sorted(EXPORTED_TYPES)) + raise PhabfiveInputException( + f"{text!r} is not something an export writes. It takes: {known}" + ) + + ids = wanted.setdefault(named[0], []) + + if number not in ids: + ids.append(number) + + return wanted + + +def export_spec(app: Any, monograms: Sequence[str], *, kind: Any = Kind.EDIT) -> Spec: + """A spec describing the objects `monograms` name, as they are now. + + Parameters + ---------- + app : Phabfive + An already-constructed app; a sibling sharing its client is built + for each object type that needs one. + monograms : sequence of str + What to export, e.g. ``["T123", "T124"]``. A repeat is exported + once. + kind : Kind or str, optional + "edit" (the default) or "create". + + Returns + ------- + Spec + One document holding every object, in the order asked for. + + Raises + ------ + PhabfiveInputException + A monogram of a type an export does not write, or a kind that is + neither "edit" nor "create". + PhabfiveNotFoundException + An object that does not exist, naming every one that does not. + """ + from phabfive.create.dispatch import app_for + + resolved = as_kind(kind, where="kind argument") + + if resolved not in EXPORT_KINDS: + raise PhabfiveInputException( + f"An export is written as " + f"{' or '.join(one.value for one in EXPORT_KINDS)}, not as " + f"{resolved.value}" + ) + + document: dict[str, Any] = { + "spec": SPEC_VERSION, + "kind": resolved.value, + "interpolate": False, + } + + for object_type, ids in _split(monograms).items(): + section = next( + key for name, key in EXPORTED_TYPES.values() if name == object_type + ) + document[section] = _EXPORTERS[object_type]( + app_for(object_type, app), ids, kind=resolved + ) + + return Spec.from_data(document) + + +def _literal(value: Any) -> Any: + """Multi-line text as a YAML block, where a block can hold it exactly. + + A block scalar reads far better than a quoted string full of `\\n`, and + it is what somebody editing a description wants in front of them. It + cannot carry everything, though: a carriage return, a line ending in + white space or a leading space on the first line are normalized or lost + in one. Those stay quoted. `dump_spec` checks the result parses back to + the same spec either way, so this is about readability, never fidelity. + """ + from ruamel.yaml.scalarstring import LiteralScalarString + + if isinstance(value, Mapping): + return {key: _literal(item) for key, item in value.items()} + + if isinstance(value, list): + return [_literal(item) for item in value] + + if not isinstance(value, str) or "\n" not in value: + return value + + lines = value.split("\n") + + if ( + "\r" in value + or "\t" in value + or lines[0][:1] == " " + or any(line != line.rstrip() for line in lines) + or not value.strip("\n") + ): + return value + + return LiteralScalarString(value) + + +def _yaml(data: Mapping[str, Any]) -> str: + from ruamel.yaml import YAML + + yaml = YAML() + yaml.default_flow_style = False + # Never fold: a long title wrapped over two lines is still one string to + # YAML and two lines to whoever is editing it + yaml.width = 1 << 20 + yaml.indent(mapping=2, sequence=4, offset=2) + + buffer = io.StringIO() + yaml.dump(_literal(dict(data)), buffer) + + return buffer.getvalue() + + +def dump_spec(spec: Spec, format: str = "yaml") -> str: + """The spec as text that parses back to exactly this spec. + + Parameters + ---------- + spec : Spec + format : str, optional + "yaml" (the default), "json" or "jsonl". + + Returns + ------- + str + The document, ending in a newline. + + Notes + ----- + **The text is checked before it is returned.** It is parsed back and + compared with `spec`, and YAML that does not survive the trip - there is + always some string a prettier style cannot hold - is replaced by the + JSON form, which is YAML too and holds anything. An export that applied + as a change nobody made would be worse than one that reads less well. + """ + from phabfive.spec.loader import parse_spec + + data = spec.to_data() + + if format == "json": + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + if format == "jsonl": + return json.dumps(data, ensure_ascii=False) + "\n" + + if format != "yaml": + raise PhabfiveInputException( + f"A spec is written as yaml, json or jsonl, not as {format!r}" + ) + + text = _yaml(data) + + try: + same = parse_spec(text, format="yaml") == spec + except Exception: # noqa: BLE001 - any failure means "not the same" + same = False + + if not same: + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + return text diff --git a/phabfive/export/tasks.py b/phabfive/export/tasks.py new file mode 100644 index 00000000..e4d6a085 --- /dev/null +++ b/phabfive/export/tasks.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +"""Tasks, as the items of a spec. + +Every value is written in the grammar the key takes on the way back in, not +the way `maniphest show` displays it: a status is its key (`resolved`, not +"Resolved"), a project its hashtag (`#security`, which names one project +where "Security" might name two), a policy its keyword. That is the whole +difference between this and a show command, and the reason this cannot be a +`--format`. + +What a spec has no key for is not exported: points, custom fields, the +subtype, comments, the author. And three things are exported only when they +can be read back as written: + +- **A priority** outside the stock table has no keyword the edit engine + takes, so the key is left out. Absent means untouched, so nothing is lost + on the way back. +- **A column** is exported for a task on exactly one workboard. `column:` + names no board, so with several there is no saying which one it means. +- **A subscriber that is not a user** - a project, a package - is written as + its PHID. It is kept as it is on the way back, but it cannot be added or + removed through a spec. +- **A policy** the grammar has no spelling for - "Subscribers", "Task + Author" - is left out, like the priority. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from typing import Any + +from phabfive.exceptions import PhabfiveNotFoundException +from phabfive.spec.edit import IDENTITY_KEYS, STAMP_KEY, format_stamp +from phabfive.spec.envelope import Kind + +__all__ = ["export_tasks"] + +#: How many objects one request asks about. +_CHUNK = 100 + +#: What `assignment:` says for a task nobody is assigned. +_NOBODY = "@none" + +_RELATIONS = ("parents", "subtasks", "commits") + + +def _chunks(values: Sequence[Any]) -> list[Sequence[Any]]: + return [values[start : start + _CHUNK] for start in range(0, len(values), _CHUNK)] + + +def _fetch(phab: Any, task_ids: Sequence[str]) -> dict[str, dict[str, Any]]: + """The tasks, by id, with what an export reads off them.""" + found: dict[str, dict[str, Any]] = {} + + for chunk in _chunks([int(task_id) for task_id in task_ids]): + result = phab.maniphest.search( + constraints={"ids": list(chunk)}, + attachments={"columns": True, "projects": True, "subscribers": True}, + ) + + for task in result.get("data") or []: + found[str(task["id"])] = task + + missing = [f"T{task_id}" for task_id in task_ids if task_id not in found] + + if missing: + raise PhabfiveNotFoundException( + f"No such task: {', '.join(missing)}. A task the token may not " + f"see is reported the same way" + ) + + return found + + +def _handles(phab: Any, phids: Sequence[str]) -> dict[str, Mapping[str, Any]]: + """What the instance calls each PHID. Raises rather than guessing.""" + handles: dict[str, Mapping[str, Any]] = {} + + for chunk in _chunks(sorted(set(phids))): + found = phab.phid.query(phids=list(chunk)) + + # A phabricator.Result maps like a dict without being one + for phid, data in found.items(): + if hasattr(data, "get"): + handles[phid] = data + + return handles + + +def _spelling( + phid: str, handles: Mapping[str, Mapping[str, Any]], *, at: bool = False +) -> str: + """One PHID as a spec writes it, or the PHID when it has no better name. + + Every spelling here is one the resolvers take back: a username, a + project's `#hashtag`, a Space's or task's monogram, a commit's name. A + PHID the token cannot name is written as the PHID, which they take too. + """ + handle = handles.get(phid) + + if not handle: + return phid + + kind = handle.get("type") + name = handle.get("name") or "" + last = (handle.get("uri") or "").rstrip("/").rsplit("/", 1)[-1] + + if kind == "USER" and name: + return f"@{name}" if at else name + + if kind == "PROJ" and last: + return f"#{last}" + + if kind == "SPCE" and last: + return last + + if kind in ("TASK", "CMIT") and name: + return name + + return phid + + +def _natural(text: str) -> tuple: + """A sort key that puts T9 before T10.""" + return tuple( + (0, int(part)) if part.isdigit() else (1, part.casefold()) + for part in re.split(r"(\d+)", text) + if part + ) + + +def _listed(phids: Sequence[str], handles: Mapping[str, Mapping[str, Any]]) -> list: + """A list of PHIDs as a spec writes it, in an order that does not move. + + The instance answers in whatever order it holds them, which changes + when a project is removed and added back. A document kept in a + repository should differ when the object does and not otherwise, so the + list is sorted: it is a set, and the order was never information. + """ + return sorted({_spelling(phid, handles) for phid in phids}, key=_natural) + + +def _policy(value: Any, handles: Mapping[str, Mapping[str, Any]]) -> Any: + """A policy as `visible-to:` takes it, or None for one it does not take. + + A keyword, `#project`, `@user` or a PHID. Phorge has policies outside + that grammar - "Subscribers" and "Task Author" are stored as + `obj.subscriptions.subscribers` and `obj.maniphest.author` - and a spec + has no spelling for them, so the key is left out. Absent means + untouched, so the task keeps the policy it has. + """ + from phabfive.constants import POLICY_KEYWORDS + + if isinstance(value, str) and value.startswith("PHID-"): + return _spelling(value, handles, at=True) + + return value if value in POLICY_KEYWORDS else None + + +def _priority(task: Mapping[str, Any]) -> Any: + """The priority's keyword, or None for one the edit engine cannot set.""" + from phabfive.constants import PRIORITY_VALUES + + value = (task["fields"].get("priority") or {}).get("value") + + for keyword, number in PRIORITY_VALUES.items(): + if number == value: + return keyword + + return None + + +def _column(task: Mapping[str, Any]) -> Any: + """The column of a task on exactly one board, else None.""" + boards = task.get("attachments", {}).get("columns", {}).get("boards") or {} + + if len(boards) != 1: + return None + + [board] = boards.values() + columns = board.get("columns") or [] + + return columns[0].get("name") if columns else None + + +def export_tasks( + maniphest: Any, task_ids: Sequence[str], *, kind: Kind = Kind.EDIT +) -> list[dict[str, Any]]: + """The spec items of the tasks `task_ids` name, in the order given. + + Parameters + ---------- + maniphest : Maniphest + task_ids : sequence of str + Numeric ids, e.g. ``["123", "124"]``. + kind : Kind, optional + `Kind.EDIT` writes every key, the task's identity and its stamp, so + the item states the whole task and a list that is empty is written + as empty. `Kind.CREATE` writes what a new task like it would be + created with: no identity, no stamp, no relations, and no key whose + value is "nothing". + + Raises + ------ + PhabfiveNotFoundException + A task that does not exist, naming every one that does not. + """ + from phabfive.maniphest.fetchers import fetch_edges_for_tasks + + phab = maniphest.phab + tasks = _fetch(phab, task_ids) + editing = kind is Kind.EDIT + + edges: Mapping[str, Mapping[str, list]] = {} + + if editing: + edges = fetch_edges_for_tasks( + phab, [task["phid"] for task in tasks.values()], _RELATIONS + ) + + named: list[str] = [] + + for task in tasks.values(): + fields = task["fields"] + attachments = task.get("attachments", {}) + + named += [fields.get("ownerPHID"), fields.get("spacePHID")] + named += list((fields.get("policy") or {}).values()) + named += attachments.get("projects", {}).get("projectPHIDs") or [] + named += attachments.get("subscribers", {}).get("subscriberPHIDs") or [] + + for relation in _RELATIONS: + named += edges.get(task["phid"], {}).get(relation) or [] + + handles = _handles( + phab, + [phid for phid in named if isinstance(phid, str) and phid.startswith("PHID-")], + ) + + items = [] + + for task_id in task_ids: + task = tasks[task_id] + fields = task["fields"] + attachments = task.get("attachments", {}) + policy = fields.get("policy") or {} + item: dict[str, Any] = {} + + def write(key: str, value: Any) -> None: + """Every key of an edit; only the ones that say something of a create.""" + if value is None: + return + + if not editing and value in ([], "", _NOBODY): + return + + item[key] = value + + if editing: + item[IDENTITY_KEYS["task"]] = f"T{task['id']}" + item[STAMP_KEY] = format_stamp(fields.get("dateModified") or 0) + + write("title", fields.get("name") or "") + write("status", (fields.get("status") or {}).get("value")) + write("priority", _priority(task)) + + owner = fields.get("ownerPHID") + write("assignment", _spelling(owner, handles) if owner else _NOBODY) + + write( + "projects", + _listed(attachments.get("projects", {}).get("projectPHIDs") or [], handles), + ) + write("column", _column(task)) + write( + "subscribers", + _listed( + attachments.get("subscribers", {}).get("subscriberPHIDs") or [], + handles, + ), + ) + + space = fields.get("spacePHID") + write("space", _spelling(space, handles) if space else None) + + write("visible-to", _policy(policy.get("view"), handles)) + write("editable-by", _policy(policy.get("edit"), handles)) + + if editing: + for relation in _RELATIONS: + write( + relation, + _listed(edges.get(task["phid"], {}).get(relation) or [], handles), + ) + + # Last: it is the long one, and everything above it fits on a screen + write("description", (fields.get("description") or {}).get("raw") or "") + + items.append(item) + + return items diff --git a/specs/edit/close-out.yaml b/specs/edit/close-out.yaml index 150393b3..687451f0 100644 --- a/specs/edit/close-out.yaml +++ b/specs/edit/close-out.yaml @@ -12,6 +12,10 @@ # `projects:` below is every project T1 should be tagged into, and anything # else is removed - or a delta: T2's `subscribers:` adds one person and # leaves whoever else is subscribed. +# +# `phabfive export T1` writes a document of this kind from the task as it +# is, with a `modified:` stamp that refuses a change to a task somebody else +# has touched since. spec: phorge/v1alpha1 kind: edit metadata: diff --git a/tests/e2e/test_export_edit.py b/tests/e2e/test_export_edit.py new file mode 100644 index 00000000..1485644f --- /dev/null +++ b/tests/e2e/test_export_edit.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +"""`phabfive export` and `phabfive apply -f` with an edit spec, against Phorge. + +The unit tests stand in for the edit engine. These do not, because the +promises worth making about an export are promises about a round trip +through a real server: + +- **an export applied unchanged changes nothing** - every value is written + in a spelling the server reads back as the value it already has; +- **an edited export sends the difference**, and applying it again sends + nothing; +- **a task somebody else changed is not overwritten** without `--force`; +- **a create export makes a task like the old one**, whose own export + matches the original apart from what a copy does not share. + +Each test makes its own task, so nothing here depends on, or disturbs, the +seed data beyond the users and projects it names. +""" + +import json +import time + +import pytest + +pytestmark = pytest.mark.usefixtures("live_env") + +PROJECTS = ("Security", "Infrastructure") + + +def export(phabfive_raw, *args): + """The exported document as data, via the JSON serialization.""" + result = phabfive_raw("--format=json", "export", *args) + + assert result.returncode == 0, result.stderr + + return json.loads(result.stdout) + + +def write(tmp_path, document, name="spec.json"): + path = tmp_path / name + path.write_text(json.dumps(document), encoding="utf-8") + + return str(path) + + +def apply(phabfive_raw, path, *args): + """Apply a spec and hand back (exit status, records).""" + result = phabfive_raw("--format=json", "apply", "-f", path, *args) + records = json.loads(result.stdout) if result.stdout.strip() else [] + + return result, records + + +def a_task(create_task, phabfive): + """A task with something in every list an export writes.""" + monogram, title = create_task("--tag", PROJECTS[0], "--subscribe", "tommy.svensson") + phabfive( + "maniphest", + "edit", + monogram, + "--description", + "First line\nSecond line with {{ a.template }}\n", + "--assign", + "viola.larsson", + "--yes", + ) + + return monogram, title + + +def test_an_export_applied_unchanged_changes_nothing( + create_task, phabfive, phabfive_raw +): + """The invariant everything else rests on, through the pipe it is used with.""" + monogram, _ = a_task(create_task, phabfive) + + exported = phabfive_raw("export", monogram) + assert exported.returncode == 0, exported.stderr + assert exported.stdout.startswith("spec: phorge/v1alpha1\nkind: edit\n") + + result = phabfive_raw( + "--format=json", "apply", "-f", "-", "--dry-run", input=exported.stdout + ) + + assert result.returncode == 0, result.stderr + assert [(one["monogram"], one["status"]) for one in json.loads(result.stdout)] == [ + (monogram, "unchanged") + ] + + # And for real: nothing is sent, so the task's modified time does not move + before = export(phabfive_raw, monogram)["tasks"][0]["modified"] + applied = phabfive_raw("apply", "-f", "-", input=exported.stdout) + + assert applied.returncode == 0, applied.stderr + assert export(phabfive_raw, monogram)["tasks"][0]["modified"] == before + + +def test_an_export_validates_on_both_layers( + create_task, phabfive, phabfive_raw, tmp_path +): + monogram, _ = a_task(create_task, phabfive) + path = tmp_path / "task.yaml" + + assert phabfive_raw("export", monogram, "-o", str(path)).returncode == 0 + + result = phabfive_raw("spec", "validate", str(path)) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_an_edited_export_sends_the_difference_once( + create_task, phabfive, phabfive_raw, tmp_path +): + monogram, _ = a_task(create_task, phabfive) + document = export(phabfive_raw, monogram) + [item] = document["tasks"] + + item["status"] = "resolved" + item["projects"] = [f"#{PROJECTS[1].lower()}"] + item["subscribers"] = ["+sebastian.soderberg"] + item["assignment"] = "@none" + item["description"] = "Rewritten.\nStill holds {{ a.template }}\n" + path = write(tmp_path, document) + + result, records = apply(phabfive_raw, path) + + assert result.returncode == 0, result.stderr + assert [one["status"] for one in records] == ["edited"] + # `Column` is there because the exported item still says `column: + # Backlog`, and the board it now means is the one project the item + # leaves the task tagged into + assert {change["field"] for change in records[0]["changes"]} == { + "Status", + "Tags", + "Column", + "Assignee", + "Subscribers", + "Description", + } + + after = export(phabfive_raw, monogram)["tasks"][0] + + assert after["status"] == "resolved" + assert after["projects"] == [f"#{PROJECTS[1].lower()}"] + assert after["assignment"] == "@none" + assert "sebastian.soderberg" in after["subscribers"] + assert "tommy.svensson" in after["subscribers"] # a delta leaves the rest + assert after["description"] == "Rewritten.\nStill holds {{ a.template }}\n" + + # The same file again. Its stamp is now older than the task, and that is + # fine: there is nothing left to change, so the stamp is not consulted + again, records = apply(phabfive_raw, path) + + assert again.returncode == 0, again.stderr + assert [one["status"] for one in records] == ["unchanged"] + + +def test_a_task_changed_since_the_export_is_not_overwritten( + create_task, phabfive, phabfive_raw, tmp_path +): + monogram, _ = a_task(create_task, phabfive) + document = export(phabfive_raw, monogram) + + # Somebody else, a moment later. The stamp is to the second, so the + # moment has to be a whole one + time.sleep(1.1) + phabfive( + "maniphest", "edit", monogram, "--subscribe", "sebastian.soderberg", "--yes" + ) + + document["tasks"][0]["status"] = "resolved" + path = write(tmp_path, document) + + refused, records = apply(phabfive_raw, path) + + assert refused.returncode == 2, refused.stdout + refused.stderr + assert [(one["field"], one["code"]) for one in records] == [("modified", "stale")] + assert export(phabfive_raw, monogram)["tasks"][0]["status"] == "open" + + forced, records = apply(phabfive_raw, path, "--force") + + assert forced.returncode == 0, forced.stderr + assert [one["status"] for one in records] == ["edited"] + + after = export(phabfive_raw, monogram)["tasks"][0] + + assert after["status"] == "resolved" + # The full subscriber list in the file was written before the other + # change, so forcing it took that subscriber off again: --force means + # the file wins + assert "sebastian.soderberg" not in after["subscribers"] + + +def test_every_problem_is_reported_and_nothing_is_applied( + create_task, phabfive, phabfive_raw, tmp_path +): + monogram, _ = a_task(create_task, phabfive) + path = write( + tmp_path, + { + "kind": "edit", + "tasks": [ + {"task": monogram, "title": "Must not be applied"}, + { + "task": monogram.replace("T", "T99999"), + "status": "open", + }, + { + "task": "T1", + "status": "no-such-status", + "subscribers": ["+nobody.x"], + }, + ], + }, + ) + + result, records = apply(phabfive_raw, path) + + assert result.returncode == 2, result.stdout + result.stderr + assert [(one["object"], one["field"], one["code"]) for one in records] == [ + ("tasks[1]", "task", "unknown-reference"), + ("tasks[2]", "status", "unknown-status"), + ("tasks[2]", "subscribers", "unknown-user"), + ] + assert export(phabfive_raw, monogram)["tasks"][0]["title"] != "Must not be applied" + + +def test_a_create_export_makes_a_task_like_the_old_one( + create_task, phabfive, phabfive_raw +): + monogram, _ = a_task(create_task, phabfive) + + copied = phabfive_raw("export", monogram, "--as", "create") + assert copied.returncode == 0, copied.stderr + assert "kind: create" in copied.stdout + assert monogram not in copied.stdout + + created = phabfive_raw("--format=json", "apply", "-f", "-", input=copied.stdout) + assert created.returncode == 0, created.stderr + + [record] = json.loads(created.stdout) + assert record["status"] == "created" + + def comparable(item): + """What a copy shares with its original: everything but who it is.""" + return { + key: value + for key, value in item.items() + if key not in ("task", "modified", "parents", "subtasks", "commits") + } + + original = export(phabfive_raw, monogram)["tasks"][0] + copy = export(phabfive_raw, record["monogram"])["tasks"][0] + + # The one difference a create cannot avoid: whoever creates a task is + # subscribed to it + copy["subscribers"] = [ + name for name in copy["subscribers"] if name in original["subscribers"] + ] + + assert comparable(copy) == comparable(original) + + +def test_a_search_can_be_piped_into_an_export(create_task, phabfive, phabfive_raw): + first, title = create_task() + second, _ = create_task() + + searched = phabfive_raw("--format=yaml", "maniphest", "show", first, second) + assert searched.returncode == 0, searched.stderr + + result = phabfive_raw("--format=json", "export", input=searched.stdout) + + assert result.returncode == 0, result.stderr + assert [item["task"] for item in json.loads(result.stdout)["tasks"]] == [ + first, + second, + ] diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 00000000..d355ace2 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,600 @@ +# -*- coding: utf-8 -*- + +"""Writing a spec from objects that exist (`phabfive.export`, `phabfive export`). + +An export is only worth having if it can be read back, so most of what is +pinned here is one property seen from several sides: **the document says +what the object is, in the grammar a spec takes, and nothing else.** + +- every value is the *input* spelling - a status key, a `#hashtag`, a policy + keyword - never the label a show command prints; +- the document validates offline and against the published schema; +- the text parses back to exactly the spec it was written from, whatever + the description holds; +- an edit export names and stamps each object, a create export does neither. + +That an unchanged export applies as no change at all needs the edit engine +and a server, and is `tests/e2e`. +""" + +import json +from unittest.mock import patch + +import jsonschema +import pytest +from typer.testing import CliRunner + +from phabfive.cli import app, preprocess_monograms +from phabfive.cli.export import _monogram, _piped +from phabfive.core import Phabfive +from phabfive.exceptions import ( + PhabfiveDataException, + PhabfiveInputException, + PhabfiveNotFoundException, +) +from phabfive.export import dump_spec, export_spec +from phabfive.spec import Spec, build_schema, parse_spec +from phabfive.spec.registry import spec_keys + +runner = CliRunner() + +HANDLES = { + "PHID-USER-alice": {"type": "USER", "name": "alice", "uri": "/p/alice/"}, + "PHID-USER-bob": {"type": "USER", "name": "bob", "uri": "/p/bob/"}, + "PHID-PROJ-infra": {"type": "PROJ", "name": "Infrastructure", "uri": "/tag/infra/"}, + "PHID-PROJ-sec": {"type": "PROJ", "name": "Security", "uri": "/tag/security/"}, + "PHID-SPCE-1": {"type": "SPCE", "name": "S1 Default", "uri": "/S1"}, + "PHID-TASK-2": {"type": "TASK", "name": "T2", "uri": "/T2"}, + "PHID-TASK-10": {"type": "TASK", "name": "T10", "uri": "/T10"}, + "PHID-CMIT-1": {"type": "CMIT", "name": "rXabc123", "uri": "/rXabc123"}, +} + + +def a_task(task_id=1, **fields): + base = { + "name": "Fix the login flow", + "description": {"raw": "First line\nSecond line\n"}, + "ownerPHID": "PHID-USER-alice", + "status": {"value": "open", "name": "Open"}, + "priority": {"value": 80, "name": "High"}, + "spacePHID": "PHID-SPCE-1", + "dateModified": 1769851800, + "policy": {"view": "users", "interact": "users", "edit": "PHID-PROJ-sec"}, + } + base.update(fields) + + return { + "id": task_id, + "phid": f"PHID-TASK-{task_id}", + "fields": base, + "attachments": { + "projects": {"projectPHIDs": ["PHID-PROJ-sec", "PHID-PROJ-infra"]}, + "subscribers": {"subscriberPHIDs": ["PHID-USER-bob", "PHID-USER-alice"]}, + "columns": { + "boards": {"PHID-PROJ-sec": {"columns": [{"name": "In Progress"}]}} + }, + }, + } + + +class FakePhab: + """A Conduit client answering from tables, and counting what it is asked.""" + + def __init__(self, tasks, edges=None, handles=None): + self.tasks = {task["id"]: task for task in tasks} + self.edges = edges or {} + self.handles = HANDLES if handles is None else handles + self.calls = [] + self.maniphest = self + self.phid = self + + def search(self, constraints, attachments): + self.calls.append("maniphest.search") + assert attachments == {"columns": True, "projects": True, "subscribers": True} + + return { + "data": [ + self.tasks[task_id] + for task_id in constraints["ids"] + if task_id in self.tasks + ] + } + + def query(self, phids): + self.calls.append("phid.query") + + return {phid: self.handles[phid] for phid in phids if phid in self.handles} + + +class FakeApp: + def __init__(self, *tasks, **more): + self.phab = FakePhab(tasks, **more) + + +@pytest.fixture(autouse=True) +def edges(monkeypatch): + def fetch(phab, task_phids, kinds): + phab.calls.append("edge.search") + + return { + phid: {kind: list(phab.edges.get((phid, kind), [])) for kind in kinds} + for phid in task_phids + } + + monkeypatch.setattr("phabfive.maniphest.fetchers.fetch_edges_for_tasks", fetch) + + +def exported(*tasks, kind="edit", monograms=None, **more): + application = FakeApp(*tasks, **more) + wanted = monograms or [f"T{task['id']}" for task in tasks] + + return export_spec(application, wanted, kind=kind), application + + +class TestAnEditExport: + def test_it_states_the_whole_task_in_the_grammar_a_spec_takes(self): + spec, _ = exported( + a_task(), + edges={ + ("PHID-TASK-1", "parents"): ["PHID-TASK-10", "PHID-TASK-2"], + ("PHID-TASK-1", "commits"): ["PHID-CMIT-1"], + }, + ) + + assert spec.to_data() == { + "spec": "phorge/v1alpha1", + "kind": "edit", + "interpolate": False, + "tasks": [ + { + "task": "T1", + "modified": "2026-01-31T09:30:00Z", + "title": "Fix the login flow", + "status": "open", + "priority": "high", + "assignment": "alice", + "projects": ["#infra", "#security"], + "column": "In Progress", + "subscribers": ["alice", "bob"], + "space": "S1", + "visible-to": "users", + "editable-by": "#security", + "parents": ["T2", "T10"], + "subtasks": [], + "commits": ["rXabc123"], + "description": "First line\nSecond line\n", + } + ], + } + + def test_every_key_it_writes_is_a_key_an_edit_takes(self): + spec, _ = exported(a_task()) + + assert set(spec.items("task")[0]) <= spec_keys("task", "edit") + + def test_it_is_clean_offline_and_against_the_schema(self): + spec, _ = exported(a_task(), a_task(2, ownerPHID=None)) + validator = jsonschema.Draft202012Validator(build_schema("edit")) + + assert spec.validate_offline() == [] + assert list(validator.iter_errors(spec.to_data())) == [] + + def test_what_is_empty_is_written_as_empty(self): + """An edit states the task, so "no subscribers" is said, not skipped.""" + bare = a_task(ownerPHID=None, description={"raw": ""}) + bare["attachments"] = {} + [item] = exported(bare)[0].items("task") + + assert item["assignment"] == "@none" + assert item["projects"] == [] + assert item["subscribers"] == [] + assert item["description"] == "" + assert "column" not in item + + def test_lists_are_sorted_so_the_file_moves_only_when_the_task_does(self): + first = a_task() + second = a_task() + second["attachments"]["projects"]["projectPHIDs"].reverse() + second["attachments"]["subscribers"]["subscriberPHIDs"].reverse() + + assert exported(first)[0] == exported(second)[0] + + def test_the_tasks_come_out_in_the_order_asked_for_and_once_each(self): + spec, _ = exported(a_task(1), a_task(2), monograms=["T2", "t1", "T2"]) + + assert [item["task"] for item in spec.items("task")] == ["T2", "T1"] + + def test_it_asks_once_however_many_tasks(self): + _, application = exported(a_task(1), a_task(2), a_task(3)) + + assert application.phab.calls == [ + "maniphest.search", + "edge.search", + "phid.query", + ] + + def test_it_says_interpolate_false(self): + """A description quoting a template must not be read as one.""" + spec, _ = exported(a_task(description={"raw": "Set {{ .Values.tag }}"})) + + assert spec.interpolates is False + assert spec.validate_offline() == [] + + +class TestWhatCannotBeReadBackIsLeftOut: + def test_a_priority_outside_the_stock_table(self): + [item] = exported(a_task(priority={"value": 42, "name": "Odd"}))[0].items( + "task" + ) + + assert "priority" not in item + + def test_a_column_when_the_task_is_on_several_boards(self): + both = a_task() + both["attachments"]["columns"]["boards"]["PHID-PROJ-infra"] = { + "columns": [{"name": "Backlog"}] + } + + assert "column" not in exported(both)[0].items("task")[0] + + def test_a_name_the_token_cannot_see_is_its_phid(self): + spec, _ = exported(a_task(), handles={}) + [item] = spec.items("task") + + assert item["assignment"] == "PHID-USER-alice" + assert item["projects"] == ["PHID-PROJ-infra", "PHID-PROJ-sec"] + assert spec.validate_offline() == [] + + def test_a_subscriber_that_is_not_a_user_is_its_phid(self): + mixed = a_task() + mixed["attachments"]["subscribers"]["subscriberPHIDs"] = [ + "PHID-USER-bob", + "PHID-OPKG-1", + ] + + assert exported(mixed)[0].items("task")[0]["subscribers"] == [ + "bob", + "PHID-OPKG-1", + ] + + @pytest.mark.parametrize( + "stored", ["obj.subscriptions.subscribers", "obj.maniphest.author"] + ) + def test_a_policy_the_grammar_has_no_spelling_for(self, stored): + """Written verbatim it is `bad-policy`, and the export would not apply.""" + spec, _ = exported(a_task(policy={"view": stored, "edit": "users"})) + [item] = spec.items("task") + + assert "visible-to" not in item + assert item["editable-by"] == "users" + assert spec.validate_offline() == [] + + @pytest.mark.parametrize("keyword", ["public", "users", "admin", "no-one"]) + def test_every_policy_keyword_is_written(self, keyword): + spec, _ = exported(a_task(policy={"view": keyword, "edit": keyword})) + + assert spec.items("task")[0]["visible-to"] == keyword + assert spec.validate_offline() == [] + + def test_a_user_policy_is_written_with_its_at(self): + owned = a_task(policy={"view": "PHID-USER-bob", "edit": "PHID-PLCY-custom"}) + [item] = exported(owned)[0].items("task") + + assert item["visible-to"] == "@bob" + assert item["editable-by"] == "PHID-PLCY-custom" + + +class TestACreateExport: + def test_it_describes_a_new_task_like_the_old_one(self): + spec, application = exported( + a_task(), kind="create", edges={("PHID-TASK-1", "parents"): ["PHID-TASK-2"]} + ) + + assert spec.to_data() == { + "spec": "phorge/v1alpha1", + "kind": "create", + "interpolate": False, + "tasks": [ + { + "title": "Fix the login flow", + "status": "open", + "priority": "high", + "assignment": "alice", + "projects": ["#infra", "#security"], + "column": "In Progress", + "subscribers": ["alice", "bob"], + "space": "S1", + "visible-to": "users", + "editable-by": "#security", + "description": "First line\nSecond line\n", + } + ], + } + # The relations are not written, so they are not even asked for + assert "edge.search" not in application.phab.calls + + def test_it_is_a_valid_create_spec(self): + spec, _ = exported(a_task(), kind="create") + validator = jsonschema.Draft202012Validator(build_schema("create")) + + assert spec.validate_offline() == [] + assert list(validator.iter_errors(spec.to_data())) == [] + assert set(spec.items("task")[0]) <= spec_keys("task", "create") + + def test_what_is_nothing_is_not_written(self): + """`assignment: "@none"` and `projects: []` mean nothing to a create.""" + bare = a_task(ownerPHID=None, description={"raw": ""}) + bare["attachments"] = {} + [item] = exported(bare, kind="create")[0].items("task") + + assert set(item) == { + "title", + "status", + "priority", + "space", + "visible-to", + "editable-by", + } + + +class TestRefusals: + def test_a_task_that_does_not_exist_names_every_one(self): + with pytest.raises(PhabfiveNotFoundException, match="T404, T405"): + exported(a_task(), monograms=["T1", "T404", "T405"]) + + @pytest.mark.parametrize("monogram", ["P1", "K7", "R2", "T", "task", "T1x"]) + def test_a_type_it_does_not_write_is_refused_by_name(self, monogram): + with pytest.raises(PhabfiveInputException, match="It takes: T123"): + exported(a_task(), monograms=[monogram]) + + def test_a_search_spec_is_not_something_to_export_as(self): + with pytest.raises(PhabfiveInputException, match="edit or create"): + exported(a_task(), kind="search") + + +TEXTS = [ + "one line", + "two\nlines", + "two\nlines\n", + "trailing blank lines\n\n\n", + "\nleading newline", + " leading spaces\nsecond", + "trailing space \nsecond", + "windows\r\nline endings\r\n", + "tab\tseparated\nsecond", + "- looks: like yaml\n- [1, 2]\n", + "# not a comment\n", + "{{ a.template }}\n{% raw %}", + "colon: at the end:", + "\n", + "\n\n", + " ", + "", + "unicode: åäö ☃\nsecond", + "'quoted' and \"double\"", + "back\\slash\nsecond", +] + +TITLES = ["yes", "no", "null", "123", "1.5", "2026-01-31", "T12", "#tag", "@none", "~"] + + +class TestTheTextParsesBackExactly: + """Fidelity: an export that reads back differently is a change nobody made.""" + + @pytest.mark.parametrize("text", TEXTS) + @pytest.mark.parametrize("format", ["yaml", "json", "jsonl"]) + def test_a_description(self, text, format): + spec, _ = exported(a_task(description={"raw": text})) + back = parse_spec(dump_spec(spec, format), format=format) + + assert back.items("task")[0]["description"] == text + assert back == spec + + @pytest.mark.parametrize("title", TITLES) + def test_a_title_that_looks_like_something_else(self, title): + spec, _ = exported(a_task(name=title)) + back = parse_spec(dump_spec(spec), format="yaml") + + assert back.items("task")[0]["title"] == title + assert back.validate_offline() == [] + + def test_the_stamp_stays_text(self): + """Unquoted it would be a native timestamp; equal, but not this spec.""" + spec, _ = exported(a_task()) + + assert "modified: '2026-01-31T09:30:00Z'" in dump_spec(spec) + + def test_ordinary_text_is_a_block_a_person_can_edit(self): + spec, _ = exported(a_task()) + + assert " description: |\n First line\n Second line\n" in dump_spec( + spec + ) + + def test_text_a_block_cannot_hold_is_still_exact(self): + spec, _ = exported(a_task(description={"raw": "trailing space \nsecond"})) + text = dump_spec(spec) + + assert "description: |" not in text + assert parse_spec(text, format="yaml") == spec + + def test_a_long_title_is_not_folded(self): + long = "word " * 60 + spec, _ = exported(a_task(name=long.strip())) + + assert f"title: {long.strip()}\n" in dump_spec(spec) + + def test_it_ends_in_one_newline(self): + spec, _ = exported(a_task()) + + for format in ("yaml", "json", "jsonl"): + assert dump_spec(spec, format).endswith("\n") + + def test_jsonl_is_one_line(self): + spec, _ = exported(a_task()) + + assert dump_spec(spec, "jsonl").count("\n") == 1 + + def test_a_format_it_does_not_write(self): + with pytest.raises(PhabfiveInputException, match="yaml, json or jsonl"): + dump_spec(exported(a_task())[0], "toml") + + def test_yaml_that_would_not_survive_falls_back_to_json(self): + """The check is real: break the pretty writer and the text is still right.""" + spec, _ = exported(a_task()) + + with patch("phabfive.export._yaml", return_value="kind: edit\ntasks: []\n"): + text = dump_spec(spec) + + assert json.loads(text) == spec.to_data() + assert parse_spec(text, format="yaml") == spec + + +class TestAnExportWithItsKindRemoved: + def test_it_is_refused_rather_than_applied_as_a_create(self): + spec, _ = exported(a_task()) + data = spec.to_data() + del data["kind"] + + problems = Spec.from_data(data).validate_offline() + + assert problems + assert {problem.code for problem in problems} == {"unknown-key"} + + +# -------------------------------------------------------------------------- +# The command +# -------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def restore_output_format(): + original = Phabfive._output_format + try: + yield + finally: + Phabfive._output_format = original + + +@pytest.fixture +def application(): + one = FakeApp(a_task(1), a_task(2)) + + with patch("phabfive.cli.apps.get_app", return_value=one): + yield one + + +def invoke(*args, **kwargs): + return runner.invoke(app, list(args), **kwargs) + + +class TestTheCommand: + def test_it_writes_yaml_to_stdout(self, application): + result = invoke("export", "T1") + + assert result.exit_code == 0, result.output + assert result.stdout.startswith("spec: phorge/v1alpha1\nkind: edit\n") + assert parse_spec(result.stdout, format="yaml").items("task")[0]["task"] == "T1" + + def test_yaml_is_the_default_whatever_the_terminal(self, application): + """Not rich on a TTY and not PHORGE_FALLBACK off one: it is a file.""" + with patch.object(Phabfive, "_get_auto_format", return_value="rich"): + result = invoke("export", "T1") + + assert result.stdout.startswith("spec: phorge/v1alpha1\n") + + @pytest.mark.parametrize("format", ["json", "jsonl"]) + def test_format_picks_another_serialization(self, application, format): + result = invoke(f"--format={format}", "export", "T1") + + assert json.loads(result.stdout)["kind"] == "edit" + + @pytest.mark.parametrize("format", ["rich", "tree", "table", "value"]) + def test_a_terminal_format_is_refused(self, application, format): + result = invoke(f"--format={format}", "export", "T1") + + assert result.exit_code == 1 + assert "yaml, json, jsonl" in result.output + + def test_as_create(self, application): + result = invoke("export", "T1", "--as", "create") + spec = parse_spec(result.stdout, format="yaml") + + assert spec.kind.value == "create" + assert "task" not in spec.items("task")[0] + + def test_output_writes_a_file_and_nothing_else(self, application, tmp_path): + target = tmp_path / "task.yaml" + result = invoke("export", "T1", "T2", "-o", str(target)) + + assert result.exit_code == 0 + assert result.stdout == "" + assert len(parse_spec(target.read_text(), format="yaml").items("task")) == 2 + + def test_a_link_is_a_monogram(self, application): + result = invoke("export", "https://phorge.example.com/T2") + + assert parse_spec(result.stdout, format="yaml").items("task")[0]["task"] == "T2" + + def test_a_missing_task_is_an_error_and_no_document(self, application): + result = invoke("export", "T1", "T404") + + assert result.exit_code == 1 + assert result.stdout == "" + assert "T404" in result.output + + def test_a_type_it_does_not_write(self, application): + result = invoke("export", "P1") + + assert result.exit_code == 1 + assert "It takes: T123" in result.output + + def test_the_monogram_shortcut_leaves_it_alone(self): + """`phabfive T1` expands; `phabfive export T1` must not.""" + argv = ["phabfive", "export", "T1", "T2"] + + assert preprocess_monograms(list(argv)) == argv + + +class TestStandardInput: + def test_monograms(self, application): + result = invoke("export", input="T2\nT1\n") + + tasks = parse_spec(result.stdout, format="yaml").items("task") + + assert [item["task"] for item in tasks] == ["T2", "T1"] + + def test_the_yaml_a_search_prints(self, application): + piped = "- Link: https://phorge.example.com/T1\n Task:\n Name: x\n" + result = invoke("export", input=piped) + + assert result.exit_code == 0, result.output + assert parse_spec(result.stdout, format="yaml").items("task")[0]["task"] == "T1" + + def test_parsing(self): + assert _piped("T1 T2\nT3") == ["T1", "T2", "T3"] + assert _piped('[{"Link": "http://h/T4"}, {"Link": "http://h/T5"}]') == [ + "T4", + "T5", + ] + assert _piped('{"Link": "http://h/T6"}\n---\n{"Link": "http://h/T7"}') == [ + "T6", + "T7", + ] + assert _monogram("https://phorge.example.com/T12") == "T12" + + def test_json_lines_with_nothing_between_the_records(self): + """What `--format=jsonl` prints, which is not one YAML stream.""" + assert _piped('{"Link": "http://h/T8"}\n{"Link": "http://h/T9"}\n') == [ + "T8", + "T9", + ] + + def test_a_record_with_no_link_is_refused(self): + with pytest.raises(PhabfiveDataException, match="no Link"): + _piped("- Task:\n Name: x\n") + + def test_nothing_piped_is_an_error(self, application): + result = invoke("export", input="") + + assert result.exit_code == 1 diff --git a/tests/test_monogram_completion.py b/tests/test_monogram_completion.py index 98e85caf..fa4e0de7 100644 --- a/tests/test_monogram_completion.py +++ b/tests/test_monogram_completion.py @@ -16,6 +16,7 @@ "apply", "search", # The mirror of `apply`: objects that exist, written as a spec + "export", # Opens a recorded profile, a command like the two above "profile", # What to know before working here, aware of the repository it runs in