tcw-cli 2.5.1__tar.gz → 2.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tcw_cli-2.5.1/tcw_cli.egg-info → tcw_cli-2.6.0}/PKG-INFO +16 -7
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/README.md +15 -6
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/pyproject.toml +1 -1
- tcw_cli-2.6.0/tcw/__init__.py +1 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/base.py +60 -23
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/fs.py +5 -2
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/claim.py +18 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/intake.py +21 -7
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/ownership.py +51 -14
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/progress.py +2 -2
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/sync.py +453 -181
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/cli.py +304 -83
- {tcw_cli-2.5.1 → tcw_cli-2.6.0/tcw_cli.egg-info}/PKG-INFO +16 -7
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw_cli.egg-info/SOURCES.txt +2 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_claimability.py +18 -1
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_cli.py +28 -2
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_comment.py +14 -1
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_config.py +42 -19
- tcw_cli-2.6.0/tests/test_tracker_gate.py +125 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_help.py +22 -5
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_import.py +67 -3
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_inheritance.py +10 -12
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_ownership.py +74 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_pre_backlog.py +97 -14
- tcw_cli-2.6.0/tests/test_tracker_strict.py +1687 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_sync.py +955 -176
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_validate.py +14 -2
- tcw_cli-2.6.0/tests/test_work_start.py +48 -0
- tcw_cli-2.5.1/tcw/__init__.py +0 -1
- tcw_cli-2.5.1/tests/test_tracker_strict.py +0 -834
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/LICENSE +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/setup.cfg +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/capabilities/__init__.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/capabilities/cli.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/cli.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/harness.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/refs.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/__init__.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/dist/client/assets/index-CNxlcI8A.js +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/dist/client/index.html +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/dist/client/theme-init.js +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/dist/server.cjs +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/runtime.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/stdin.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/__init__.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/checkouts.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/config_edit.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/project.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/taxonomy/__init__.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/taxonomy/cli.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/__init__.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/create.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/jira.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/validate.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/__init__.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/generate.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/hooks.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/audit-backlog.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/consolidate-plans.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/create-work.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/decompose.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/delegation.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/documentation-sync.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/post-mortem.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/search.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/triage-issues.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/unattended-work.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/projection.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/implement.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/inbox.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/plan.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/postmortem.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/request.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/spec.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/verify.md +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/recursion.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/resolve.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/templates.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw_cli.egg-info/dependency_links.txt +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw_cli.egg-info/entry_points.txt +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw_cli.egg-info/requires.txt +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw_cli.egg-info/top_level.txt +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_body_prompt.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capabilities.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capabilities_federation.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capabilities_reset.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capabilities_rm.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capabilities_sidecar.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capability_gate_children.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capability_ref_wording.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_check_versions.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_child_status.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_cli_help_coverage.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_committed_paths.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_config_edit.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_config_edit_writers.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_configuration_text_home.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_cut_version.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_documentation_config.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_documentation_prompt.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_documentation_sync_wiring.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_documented_cli_surface.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_dynamic_skill_marker.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_edit_type.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_environment_hardness.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_epic_completable.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_eval_coverage.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_eval_files_changed.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_eval_fixture.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_eval_grading.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_eval_runner.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_external_work_store.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_falsification_rule.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_generate_hook.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_harness.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_inbox_title.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_legacy_store_config.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_lifecycle_baseline.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_lifecycle_hooks.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_lifecycle_inert.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_lifecycle_policy.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_lifecycle_validation.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_multiproject.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_non_git_writes.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_plugin_manifests.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_procedure_config.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_procedure_verb.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_project_overrides.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_project_registry.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_projection.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_prompt_fallback.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_qualified_ref.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_recursion.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_refs.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_remote_session_setup.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_repo_lifecycle.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_resolve.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_resolve_procedure.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_retention.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_scaffold.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve_descendants.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve_projection.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve_resolve.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve_runtime.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve_write.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_session_bootstrap.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_shipped_procedures.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_shipped_prompts.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_show_json.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_skill_flow.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_skill_lifecycle_parity.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_skill_path_pointers.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_smoke.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_stage_validate.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_stage_verb.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_status_parity.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_stdin.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_stdin_cli.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_store_bounds.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_store_editor.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_store_nodes.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_store_provisioning.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_store_publication.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_subprocess_stdin.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_taxonomy.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tombstone.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_absent.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_binding.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_claim.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_client.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_create.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_hold.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_link.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_replay.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_surface.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_sync_config.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_unattended_work_skill.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_unpushed_version_script.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_validate.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_validate_target.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_work.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_work_autocommit.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_work_review.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_work_tags.py +0 -0
- {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_worktree_completion.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tcw-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project.
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -731,10 +731,11 @@ to the transitions.
|
|
|
731
731
|
| `request`, `spec`, `plan` stages | unchanged | unchanged |
|
|
732
732
|
| at any time | — | `tcw work tracker link` / `unlink` records or removes the link between an existing item and a ticket; Jira itself is not touched |
|
|
733
733
|
| an item has no ticket | — | `tcw work tracker create <slug>` makes one for it and binds it; `--all` sweeps every open item without one. Needs `work.tracker.create` and `statuses.backlog`. With `create.on-new`, filing an item makes its ticket |
|
|
734
|
-
| `start` | moves the item | also claims a linked ticket
|
|
735
|
-
| `submit`, `rework
|
|
734
|
+
| `start` | moves the item | also claims a linked ticket (assigns it to you) and moves it to the status mapped for `active`; a ticket already past that stays where it is. Posts a comment if `comments: true` |
|
|
735
|
+
| `submit`, `rework` | moves the item | refused before the item moves unless a linked ticket is assigned to you (`tcw work tracker claim` takes it). Then moves the ticket to the Jira status mapped under `statuses`, and posts a comment if `comments: true` |
|
|
736
|
+
| `complete`, discard | moves the item | moves a linked ticket to its mapped status whoever holds it — finishing work needs no claim — and posts a comment if `comments: true` |
|
|
736
737
|
| the ticket could not follow | — | the item still moves; the command exits 1 and records the ticket as pending or conflicting; `tcw work tracker sync` retries |
|
|
737
|
-
| the ticket is behind its item | — |
|
|
738
|
+
| the ticket is behind its item | — | for a ticket linked to work already under way: `tcw work tracker claim <slug>`, then `tcw work tracker sync <slug>` |
|
|
738
739
|
| the ticket does not match its item | — | `tcw work tracker sync <slug>` puts it where the item's status says, whether the ticket is ahead of the item or behind it; a backwards move says so |
|
|
739
740
|
|
|
740
741
|
Setting `strict: true` changes several steps, so that no work happens without a
|
|
@@ -744,9 +745,17 @@ claimed ticket:
|
|
|
744
745
|
and point you at `tcw work tracker import`. Where `inbox-query` is set, `inbox accept`
|
|
745
746
|
of a ticket is allowed, because it claims the ticket.
|
|
746
747
|
- `start` refuses an item with no ticket. For a linked item it claims the ticket
|
|
747
|
-
first,
|
|
748
|
-
|
|
749
|
-
|
|
748
|
+
first, through the transition `exclusive-claim-transition` names, and moves the
|
|
749
|
+
item only if the claim worked and the workflow would refuse a second person —
|
|
750
|
+
that is, it does not offer that transition again once the ticket has it. Every
|
|
751
|
+
strict claim checks this, `tracker claim` and `tracker import` included.
|
|
752
|
+
- Strict mode needs `exclusive-claim-transition` and `transitions.start` set, and
|
|
753
|
+
`tcw validate` reports either one missing: `tracker import`, the way work is
|
|
754
|
+
created, claims through `transitions.start`.
|
|
755
|
+
- `submit` and `rework` read the ticket first, and refuse unless it is assigned to
|
|
756
|
+
you and where the item's last move left it. `complete --resolution done` refuses
|
|
757
|
+
only if the ticket is not where the item's last move left it: who holds it does
|
|
758
|
+
not matter for finishing work.
|
|
750
759
|
- Discarding is always allowed. `drop` refuses an item that was ever linked, so
|
|
751
760
|
the record stays.
|
|
752
761
|
- While Jira cannot be reached, those commands refuse, and `tcw serve` refuses
|
|
@@ -515,10 +515,11 @@ to the transitions.
|
|
|
515
515
|
| `request`, `spec`, `plan` stages | unchanged | unchanged |
|
|
516
516
|
| at any time | — | `tcw work tracker link` / `unlink` records or removes the link between an existing item and a ticket; Jira itself is not touched |
|
|
517
517
|
| an item has no ticket | — | `tcw work tracker create <slug>` makes one for it and binds it; `--all` sweeps every open item without one. Needs `work.tracker.create` and `statuses.backlog`. With `create.on-new`, filing an item makes its ticket |
|
|
518
|
-
| `start` | moves the item | also claims a linked ticket
|
|
519
|
-
| `submit`, `rework
|
|
518
|
+
| `start` | moves the item | also claims a linked ticket (assigns it to you) and moves it to the status mapped for `active`; a ticket already past that stays where it is. Posts a comment if `comments: true` |
|
|
519
|
+
| `submit`, `rework` | moves the item | refused before the item moves unless a linked ticket is assigned to you (`tcw work tracker claim` takes it). Then moves the ticket to the Jira status mapped under `statuses`, and posts a comment if `comments: true` |
|
|
520
|
+
| `complete`, discard | moves the item | moves a linked ticket to its mapped status whoever holds it — finishing work needs no claim — and posts a comment if `comments: true` |
|
|
520
521
|
| the ticket could not follow | — | the item still moves; the command exits 1 and records the ticket as pending or conflicting; `tcw work tracker sync` retries |
|
|
521
|
-
| the ticket is behind its item | — |
|
|
522
|
+
| the ticket is behind its item | — | for a ticket linked to work already under way: `tcw work tracker claim <slug>`, then `tcw work tracker sync <slug>` |
|
|
522
523
|
| the ticket does not match its item | — | `tcw work tracker sync <slug>` puts it where the item's status says, whether the ticket is ahead of the item or behind it; a backwards move says so |
|
|
523
524
|
|
|
524
525
|
Setting `strict: true` changes several steps, so that no work happens without a
|
|
@@ -528,9 +529,17 @@ claimed ticket:
|
|
|
528
529
|
and point you at `tcw work tracker import`. Where `inbox-query` is set, `inbox accept`
|
|
529
530
|
of a ticket is allowed, because it claims the ticket.
|
|
530
531
|
- `start` refuses an item with no ticket. For a linked item it claims the ticket
|
|
531
|
-
first,
|
|
532
|
-
|
|
533
|
-
|
|
532
|
+
first, through the transition `exclusive-claim-transition` names, and moves the
|
|
533
|
+
item only if the claim worked and the workflow would refuse a second person —
|
|
534
|
+
that is, it does not offer that transition again once the ticket has it. Every
|
|
535
|
+
strict claim checks this, `tracker claim` and `tracker import` included.
|
|
536
|
+
- Strict mode needs `exclusive-claim-transition` and `transitions.start` set, and
|
|
537
|
+
`tcw validate` reports either one missing: `tracker import`, the way work is
|
|
538
|
+
created, claims through `transitions.start`.
|
|
539
|
+
- `submit` and `rework` read the ticket first, and refuse unless it is assigned to
|
|
540
|
+
you and where the item's last move left it. `complete --resolution done` refuses
|
|
541
|
+
only if the ticket is not where the item's last move left it: who holds it does
|
|
542
|
+
not matter for finishing work.
|
|
534
543
|
- Discarding is always allowed. `drop` refuses an item that was ever linked, so
|
|
535
544
|
the record stays.
|
|
536
545
|
- While Jira cannot be reached, those commands refuse, and `tcw serve` refuses
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tcw-cli"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.6.0"
|
|
8
8
|
description = "Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.6.0"
|
|
@@ -1259,11 +1259,11 @@ TRACKER_CREDENTIAL_KEYS = frozenset({"email-env", "token-env"})
|
|
|
1259
1259
|
# These name *how* it gets there, and are needed only where the status cannot say: a
|
|
1260
1260
|
# workflow offering two transitions into one status — a "finished" and an "abandoned"
|
|
1261
1261
|
# route both landing in `Done` is the common shape — is otherwise unreachable, since
|
|
1262
|
-
# TCW will not guess which. A move with no name here keeps the status-derived rule
|
|
1263
|
-
#
|
|
1264
|
-
#
|
|
1265
|
-
#
|
|
1266
|
-
#
|
|
1262
|
+
# TCW will not guess which. A move with no name here keeps the status-derived rule, and
|
|
1263
|
+
# that now includes `start`: a start goes through `assess_move` like the other four, so
|
|
1264
|
+
# it derives its transition from the status it is heading for when nobody names one. An
|
|
1265
|
+
# unknown key is reported, because silently ignoring a key someone set is silently not
|
|
1266
|
+
# doing what they asked.
|
|
1267
1267
|
TRACKER_TRANSITION_KEYS = frozenset({"start", "submit", "rework", "complete", "discard"})
|
|
1268
1268
|
TRACKER_MOVE_TRANSITION_KEYS = ("start", "submit", "rework", "complete", "discard")
|
|
1269
1269
|
# A key that used to exist, with what replaced it, so a config written for an older
|
|
@@ -1275,6 +1275,23 @@ TRACKER_RENAMED_KEYS = {
|
|
|
1275
1275
|
"renamed to work.tracker.transitions.start, the transition the start move "
|
|
1276
1276
|
"applies, alongside submit, rework, complete and discard",
|
|
1277
1277
|
}
|
|
1278
|
+
# Strict mode promises that only one person can take a ticket. A claim that applies
|
|
1279
|
+
# this transition first keeps that promise, because a workflow that will not apply
|
|
1280
|
+
# it to a ticket already taken stops the second person there.
|
|
1281
|
+
STRICT_NEEDS_EXCLUSIVE_CLAIM = (
|
|
1282
|
+
"work.tracker.exclusive-claim-transition: required when strict is true. Strict "
|
|
1283
|
+
"mode promises that only one person can take a ticket, and this transition, "
|
|
1284
|
+
"which your workflow will not apply to a ticket that has already been taken, "
|
|
1285
|
+
"is what stops a second person. Name the transition that takes a ticket into "
|
|
1286
|
+
"work.")
|
|
1287
|
+
# Strict mode creates work only from a ticket, and the two commands that do it claim
|
|
1288
|
+
# through this transition, so a strict project without it can create no work at all.
|
|
1289
|
+
STRICT_NEEDS_START_TRANSITION = (
|
|
1290
|
+
"work.tracker.transitions.start: required when strict is true. Strict mode "
|
|
1291
|
+
"creates work only from a ticket, and the two commands that do that — `tcw work "
|
|
1292
|
+
"tracker import` and `tcw work inbox accept` — claim the ticket through this "
|
|
1293
|
+
"transition. Name the transition that takes a ticket from the backlog status "
|
|
1294
|
+
"into work.")
|
|
1278
1295
|
# `backlog` first: it is where an item begins, and where creation puts its ticket.
|
|
1279
1296
|
# The other four are the statuses a *bound* ticket is moved through afterwards.
|
|
1280
1297
|
TRACKER_STATUS_KEYS = ("backlog", "active", "review", "completed", "discarded")
|
|
@@ -1431,7 +1448,13 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
|
|
|
1431
1448
|
return value
|
|
1432
1449
|
|
|
1433
1450
|
credentials = nested("credentials", TRACKER_CREDENTIAL_KEYS)
|
|
1434
|
-
|
|
1451
|
+
# Optional — every move derives its transition from the status when nobody names
|
|
1452
|
+
# one — so a lone `null` is a wrong value rather than a missing required one, the
|
|
1453
|
+
# shape `inbox-query` above and `exclusive-claim-transition` below both use.
|
|
1454
|
+
if "transitions" in raw and raw["transitions"] is None:
|
|
1455
|
+
problems.append("work.tracker.transitions: expected a mapping, got NoneType")
|
|
1456
|
+
transitions = (nested("transitions", TRACKER_TRANSITION_KEYS)
|
|
1457
|
+
if raw.get("transitions") is not None else {})
|
|
1435
1458
|
|
|
1436
1459
|
def nested_str(block: dict, key: str, path: str) -> str:
|
|
1437
1460
|
value = block.get(key)
|
|
@@ -1453,8 +1476,10 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
|
|
|
1453
1476
|
f"got {type(strict).__name__}")
|
|
1454
1477
|
strict = False
|
|
1455
1478
|
if strict:
|
|
1456
|
-
# Strict mode gates completing and discarding against these, so a
|
|
1457
|
-
# one would be a gate that can never pass or never check.
|
|
1479
|
+
# Strict mode gates completing and discarding against these statuses, so a
|
|
1480
|
+
# missing one would be a gate that can never pass or never check. It also
|
|
1481
|
+
# needs `exclusive-claim-transition`, the only thing that makes a claim
|
|
1482
|
+
# exclusive; that check is after these.
|
|
1458
1483
|
for key in ("active", "completed"):
|
|
1459
1484
|
if not statuses.get(key):
|
|
1460
1485
|
problems.append(f"work.tracker.statuses.{key}: required when "
|
|
@@ -1466,6 +1491,10 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
|
|
|
1466
1491
|
problems.append(f"work.tracker.statuses.discarded: required when strict "
|
|
1467
1492
|
f"is true, as one status or one for each of "
|
|
1468
1493
|
f"{', '.join(sorted(resolutions))}")
|
|
1494
|
+
# Absent only: a key written as `null` or blank already has its own problem
|
|
1495
|
+
# below, and one problem per key is enough.
|
|
1496
|
+
if "exclusive-claim-transition" not in raw:
|
|
1497
|
+
problems.append(STRICT_NEEDS_EXCLUSIVE_CLAIM)
|
|
1469
1498
|
|
|
1470
1499
|
comments = raw.get("comments", False)
|
|
1471
1500
|
if not isinstance(comments, bool):
|
|
@@ -1476,21 +1505,20 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
|
|
|
1476
1505
|
|
|
1477
1506
|
email_env = nested_str(credentials, "email-env", "credentials.email-env")
|
|
1478
1507
|
token_env = nested_str(credentials, "token-env", "credentials.token-env")
|
|
1479
|
-
# Optional
|
|
1480
|
-
# the same shape
|
|
1508
|
+
# Optional unless strict (required above, only when the key is absent), so a lone
|
|
1509
|
+
# `null` is a wrong value rather than a missing required one — the same shape
|
|
1510
|
+
# `inbox-query` uses above.
|
|
1481
1511
|
if "exclusive-claim-transition" in raw and raw["exclusive-claim-transition"] is None:
|
|
1482
1512
|
problems.append("work.tracker.exclusive-claim-transition: expected a "
|
|
1483
1513
|
"non-empty string, got NoneType")
|
|
1484
1514
|
exclusive_claim = (required_str("exclusive-claim-transition")
|
|
1485
1515
|
if raw.get("exclusive-claim-transition") is not None else "")
|
|
1486
1516
|
move_transitions = _parse_tracker_transitions(transitions, problems)
|
|
1487
|
-
# `start` is the one move whose transition is required: it is the only move with no
|
|
1488
|
-
# status-derived fallback, because a start applies its transition through the claim
|
|
1489
|
-
# rather than through `assess_move`. A wrong *value* is reported by the loop above,
|
|
1490
|
-
# like its four siblings; an absent *key* is reported here.
|
|
1491
|
-
if "start" not in transitions:
|
|
1492
|
-
problems.append("work.tracker.transitions.start: required")
|
|
1493
1517
|
start = move_transitions.get("start", "")
|
|
1518
|
+
# After `transitions` is parsed, and absent only — the same rule as
|
|
1519
|
+
# `exclusive-claim-transition`: `null` or blank already has its own problem.
|
|
1520
|
+
if strict and "start" not in transitions:
|
|
1521
|
+
problems.append(STRICT_NEEDS_START_TRANSITION)
|
|
1494
1522
|
|
|
1495
1523
|
timeout: Any = raw.get("timeout-seconds", TRACKER_DEFAULT_TIMEOUT)
|
|
1496
1524
|
# `bool` before `int`, because a bool *is* an int and `timeout-seconds: true`
|
|
@@ -1674,9 +1702,8 @@ def _parse_tracker_pre_backlog(raw: Any, statuses: dict, problems: list[str]) ->
|
|
|
1674
1702
|
def _parse_tracker_transitions(raw: dict, problems: list[str]) -> dict:
|
|
1675
1703
|
"""The per-move keys of `work.tracker.transitions`, appending a problem per defect.
|
|
1676
1704
|
|
|
1677
|
-
All five keys are parsed here, uniformly.
|
|
1678
|
-
|
|
1679
|
-
the status-derived rule.
|
|
1705
|
+
All five keys are parsed here, uniformly. Absent is `{}`, which leaves every
|
|
1706
|
+
move on the status-derived rule; no key here is required.
|
|
1680
1707
|
"""
|
|
1681
1708
|
where = "work.tracker.transitions"
|
|
1682
1709
|
|
|
@@ -1842,7 +1869,7 @@ def attribute_tracker_problems(problems: list[str], record: dict[TrackerKeyPath,
|
|
|
1842
1869
|
"""Prefix each parser problem with the label of the file that caused it.
|
|
1843
1870
|
|
|
1844
1871
|
Matched on the problem's exact key path, never the nearest enclosing
|
|
1845
|
-
mapping: `
|
|
1872
|
+
mapping: `credentials.token-env: required` under a `credentials` an ancestor
|
|
1846
1873
|
supplied is about a key nobody set, and goes to `own_label`. The parser
|
|
1847
1874
|
writes a path as its keys joined with `.` followed by `: `, so the longest
|
|
1848
1875
|
recorded path whose spelling prefixes the problem that way is the one it is
|
|
@@ -2925,13 +2952,21 @@ class MultipleMatch(Exception):
|
|
|
2925
2952
|
|
|
2926
2953
|
|
|
2927
2954
|
class AlreadyClaimed(IllegalTransition):
|
|
2928
|
-
"""A single-winner start lost to an existing active claim.
|
|
2955
|
+
"""A single-winner start lost to an existing active claim.
|
|
2956
|
+
|
|
2957
|
+
Names the command that takes the item over, because a refusal that stops somebody
|
|
2958
|
+
has to say what to do instead. `tcw work tracker claim --take-over` is the one
|
|
2959
|
+
for an item whose work is shared through a tracker; `start --take-over` stays for
|
|
2960
|
+
a project with none, and for recovering an interrupted claim."""
|
|
2929
2961
|
|
|
2930
2962
|
def __init__(self, slug: str, owner: str = "", started: str = ""):
|
|
2931
2963
|
self.slug, self.owner, self.started = slug, owner, started
|
|
2932
2964
|
who = owner or "an unknown owner"
|
|
2933
2965
|
when = started or "an unknown time"
|
|
2934
|
-
super().__init__(f"{slug} is already claimed by {who} since {when}"
|
|
2966
|
+
super().__init__(f"{slug} is already claimed by {who} since {when}. To take "
|
|
2967
|
+
f"it over, run `tcw work tracker claim {slug} --take-over` "
|
|
2968
|
+
f"(or `tcw work start {slug} --take-over` where no tracker "
|
|
2969
|
+
f"is configured)")
|
|
2935
2970
|
|
|
2936
2971
|
|
|
2937
2972
|
@dataclass
|
|
@@ -3876,7 +3911,9 @@ class WorkStore(ABC):
|
|
|
3876
3911
|
take_over: bool = False) -> WorkItem:
|
|
3877
3912
|
item = self._require(slug)
|
|
3878
3913
|
if item.status == "active":
|
|
3879
|
-
|
|
3914
|
+
# Active with nobody holding it — what `tcw work tracker release` leaves —
|
|
3915
|
+
# is taken, not refused: there is nobody to displace.
|
|
3916
|
+
if not take_over and item.owner:
|
|
3880
3917
|
raise AlreadyClaimed(slug, item.owner, item.started)
|
|
3881
3918
|
if not owner:
|
|
3882
3919
|
raise ValueError("takeover requires an owner")
|
|
@@ -4053,7 +4053,9 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
4053
4053
|
if item is None:
|
|
4054
4054
|
raise ValueError(f"no such work item: {slug}")
|
|
4055
4055
|
if item.status == "active":
|
|
4056
|
-
|
|
4056
|
+
# Active with nobody holding it — what `tcw work tracker release` leaves —
|
|
4057
|
+
# is taken, not refused: there is nobody to displace.
|
|
4058
|
+
if not take_over and item.owner:
|
|
4057
4059
|
raise AlreadyClaimed(slug, item.owner, item.started)
|
|
4058
4060
|
if not owner:
|
|
4059
4061
|
raise ValueError("takeover requires an owner")
|
|
@@ -4061,7 +4063,8 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
4061
4063
|
{"owner": owner, "started": started})
|
|
4062
4064
|
if self.auto_commit_transitions():
|
|
4063
4065
|
rel = str(self._require_dir(slug).relative_to(self.store_git_root))
|
|
4064
|
-
|
|
4066
|
+
what = "take over" if item.owner else "claim"
|
|
4067
|
+
err = git_commit_result(self.store_git_root, f"tcw work: {what} {slug}", rel)
|
|
4065
4068
|
if err:
|
|
4066
4069
|
raise TransitionCommitError(f"{slug} was taken over, but committing it failed:\n{err}")
|
|
4067
4070
|
return self._require(slug)
|
|
@@ -48,6 +48,12 @@ CLAIM_NOT_OFFERED = "claim not offered by this ticket"
|
|
|
48
48
|
# reporting one at all.
|
|
49
49
|
MISCONFIGURED = "misconfigured"
|
|
50
50
|
AMBIGUOUS = "ambiguous"
|
|
51
|
+
# The project named no start transition at all. `work.tracker.transitions.start` is
|
|
52
|
+
# optional — a lifecycle start works its transition out from the status it is heading
|
|
53
|
+
# for — but a claim has no target status of its own to derive from. Distinct from
|
|
54
|
+
# `CLAIM_NOT_OFFERED`, which is about a name this ticket does not offer: that one
|
|
55
|
+
# sends the reader to the ticket's workflow, and this one to their own configuration.
|
|
56
|
+
NOT_CONFIGURED = "no start transition configured"
|
|
51
57
|
|
|
52
58
|
|
|
53
59
|
@dataclass(frozen=True)
|
|
@@ -92,6 +98,18 @@ def assess(claim_transition: str, *, current_status: str, offered,
|
|
|
92
98
|
With no `landing_status` and no offered claim, the answer is `NOT_DETERMINED`.
|
|
93
99
|
That is a real limitation, reported rather than papered over.
|
|
94
100
|
"""
|
|
101
|
+
# Nothing configured. Held here rather than in each caller: every reader of
|
|
102
|
+
# `config.start_transition` that has no status to derive from goes through this
|
|
103
|
+
# function, so one guard covers all of them.
|
|
104
|
+
if not claim_transition.strip():
|
|
105
|
+
return Assessment(
|
|
106
|
+
claimable=NOT_CLAIMABLE,
|
|
107
|
+
exclusivity=NOT_DETERMINED,
|
|
108
|
+
verdict=NOT_CONFIGURED,
|
|
109
|
+
detail=("work.tracker.transitions.start names no transition, so there is "
|
|
110
|
+
"none to claim this ticket through."),
|
|
111
|
+
)
|
|
112
|
+
|
|
95
113
|
wanted = _normalize(claim_transition)
|
|
96
114
|
matches = [t for t in offered if _normalize(t.name) == wanted]
|
|
97
115
|
offered_names = [t.name for t in offered]
|
|
@@ -195,15 +195,17 @@ def find_binding(store, *, project: str, provider: str, ticket_id: str,
|
|
|
195
195
|
|
|
196
196
|
def binding_document(*, provider: str, project: str, part: str, ticket_id: str,
|
|
197
197
|
ticket_key: str, ticket_url: str, bound: str,
|
|
198
|
-
unlinked: list, status_synced: bool = True
|
|
199
|
-
catch_up: bool = False) -> str:
|
|
198
|
+
unlinked: list, status_synced: bool = True) -> str:
|
|
200
199
|
"""The `tracker.yaml` text for a new binding. No credential goes in it.
|
|
201
200
|
|
|
202
201
|
The document records that an item and a ticket are the same work and nothing
|
|
203
202
|
more: it names no account, because binding does not claim the ticket.
|
|
204
203
|
`status_synced=False` notes that the item was already past `backlog` and the
|
|
205
|
-
ticket was not brought along
|
|
206
|
-
|
|
204
|
+
ticket was not brought along.
|
|
205
|
+
|
|
206
|
+
Nothing writes `catch-up: true` any more — `link --sync-status` did, and is
|
|
207
|
+
retired — but a binding already carrying it is still read (`Bound.catch_up`), so
|
|
208
|
+
the multi-rung walk it asked for stays reachable until those bindings age out.
|
|
207
209
|
"""
|
|
208
210
|
document = {
|
|
209
211
|
"schema": 1,
|
|
@@ -216,8 +218,6 @@ def binding_document(*, provider: str, project: str, part: str, ticket_id: str,
|
|
|
216
218
|
}
|
|
217
219
|
if not status_synced:
|
|
218
220
|
document["status-synced"] = False
|
|
219
|
-
if catch_up:
|
|
220
|
-
document["catch-up"] = True
|
|
221
221
|
return yaml.safe_dump(document, sort_keys=False, allow_unicode=True)
|
|
222
222
|
|
|
223
223
|
|
|
@@ -641,7 +641,7 @@ def _claim_from(client, ticket: TicketRead) -> ClaimOutcome:
|
|
|
641
641
|
Authentication, permission, rate-limit and not-found errors on the transition
|
|
642
642
|
propagate: the transition did not apply and there is nothing to read back.
|
|
643
643
|
"""
|
|
644
|
-
from tcw.tracker.claim import AMBIGUOUS, _normalize, assess
|
|
644
|
+
from tcw.tracker.claim import AMBIGUOUS, NOT_CONFIGURED, _normalize, assess
|
|
645
645
|
from tcw.tracker.jira import (TrackerAuthError, TrackerError, TrackerNotFound,
|
|
646
646
|
TrackerPermissionError, TrackerRateLimited,
|
|
647
647
|
TrackerRequestInvalid)
|
|
@@ -673,9 +673,23 @@ def _claim_from(client, ticket: TicketRead) -> ClaimOutcome:
|
|
|
673
673
|
f"is ambiguous on this ticket.", assessment.detail)
|
|
674
674
|
matches = [t for t in ticket.offered if _normalize(t.name) == _normalize(name)]
|
|
675
675
|
if not matches:
|
|
676
|
+
# Row `1e` first, and that order is load-bearing: a ticket this account
|
|
677
|
+
# already holds needs no transition, which is what lets a claim that
|
|
678
|
+
# succeeded and then failed locally finish on a re-run, and what lets
|
|
679
|
+
# somebody take a ticket with `tcw work tracker claim` — assignment only —
|
|
680
|
+
# and then import it. A refusal above this row would break both.
|
|
676
681
|
if ticket.assignee_id == ticket.me_id:
|
|
677
682
|
return claimed("1e", f"not claimed by this run: {key} is already in "
|
|
678
683
|
f"'{status}' and assigned to you.", status, False)
|
|
684
|
+
if assessment.verdict == NOT_CONFIGURED:
|
|
685
|
+
# Refused rather than derived: a claim is not a lifecycle move, it has no
|
|
686
|
+
# target status of its own to work a transition out from, and a wrong
|
|
687
|
+
# guess here assigns the ticket as well as moving it.
|
|
688
|
+
return refused("1d", f"{key} cannot be claimed: "
|
|
689
|
+
f"work.tracker.transitions.start is not set, and this "
|
|
690
|
+
f"command claims through it. Set it, or take the "
|
|
691
|
+
f"ticket with `tcw work tracker claim` and bind an "
|
|
692
|
+
f"item to it with `tcw work tracker link`.")
|
|
679
693
|
offers = ", ".join(repr(t.name) for t in ticket.offered) or "nothing"
|
|
680
694
|
return refused("1f", f"{key} is in '{status}', unassigned, and does not "
|
|
681
695
|
f"offer {name!r}. It offers: {offers}."
|
|
@@ -27,8 +27,8 @@ The ceiling, for a project whose workflow genuinely refuses a second claimant:
|
|
|
27
27
|
name a transition in `work.tracker.exclusive-claim-transition` and it is applied
|
|
28
28
|
first, so a second claimant's transition is refused and never reaches the assign.
|
|
29
29
|
That is `intake.claim`'s own reasoning, and it is the one case where claiming
|
|
30
|
-
does move the ticket — which is why it is opt-in
|
|
31
|
-
what opting in costs.
|
|
30
|
+
does move the ticket — which is why it is opt-in (required under strict mode),
|
|
31
|
+
and why the guide says plainly what opting in costs.
|
|
32
32
|
|
|
33
33
|
One rule is carried across from `intake.claim` by hand rather than by import: a
|
|
34
34
|
ticket already assigned to the caller is already held, whatever the workflow now
|
|
@@ -39,7 +39,7 @@ being idempotent the moment a project opted in.
|
|
|
39
39
|
|
|
40
40
|
from __future__ import annotations
|
|
41
41
|
|
|
42
|
-
from dataclasses import dataclass
|
|
42
|
+
from dataclasses import dataclass, replace
|
|
43
43
|
|
|
44
44
|
from tcw.tracker.claim import _normalize
|
|
45
45
|
from tcw.tracker.jira import TrackerError, TrackerRequestInvalid
|
|
@@ -50,8 +50,19 @@ class OwnershipOutcome:
|
|
|
50
50
|
"""What one claim or release attempt established.
|
|
51
51
|
|
|
52
52
|
`settled` is whether the tracker now says what the caller asked it to say —
|
|
53
|
-
held by them for a claim, held by nobody for a release.
|
|
54
|
-
|
|
53
|
+
held by them for a claim, held by nobody for a release. `retry` is whether an
|
|
54
|
+
unsettled claim is worth simply running again: the tracker did not answer the
|
|
55
|
+
assignment or its read-back, rather than answering no. `transitioned` is whether
|
|
56
|
+
the assertion transition was applied — **set on a failure as well as a success**,
|
|
57
|
+
because a claim whose transition landed and whose assignment then did not has
|
|
58
|
+
moved the ticket and left it unassigned, and only the caller that knows this can
|
|
59
|
+
say so. `assigned` is whether this run's own assignment landed: false for every
|
|
60
|
+
refusal up to and including the assignment failing, true from the read-back
|
|
61
|
+
onwards. The two together are what separate *the ticket is unassigned* — the
|
|
62
|
+
assignment did not land — from *who holds it is unknown*, which is all a failed
|
|
63
|
+
read-back establishes. `status` is where the ticket is when the outcome is made,
|
|
64
|
+
so after an applied assertion it is where that transition led, not where the
|
|
65
|
+
ticket was found. Everything else is for the message.
|
|
55
66
|
"""
|
|
56
67
|
settled: bool
|
|
57
68
|
message: str
|
|
@@ -60,6 +71,11 @@ class OwnershipOutcome:
|
|
|
60
71
|
status: str = ""
|
|
61
72
|
detail: str = ""
|
|
62
73
|
transitioned: bool = False
|
|
74
|
+
retry: bool = False
|
|
75
|
+
assigned: bool = False
|
|
76
|
+
# The ticket's key, so a caller holding only the outcome can name the ticket —
|
|
77
|
+
# `claim_refusal` serves this outcome and `intake.claim`'s alike.
|
|
78
|
+
key: str = ""
|
|
63
79
|
|
|
64
80
|
|
|
65
81
|
def _holder(client, ticket):
|
|
@@ -70,6 +86,14 @@ def _holder(client, ticket):
|
|
|
70
86
|
|
|
71
87
|
def assert_ownership(client, ticket, *, assertion: str = "",
|
|
72
88
|
take_over: bool = False) -> OwnershipOutcome:
|
|
89
|
+
"""Make `ticket` held by the account `client` authenticates as — see
|
|
90
|
+
`_assert_ownership`. Every outcome carries the ticket's key."""
|
|
91
|
+
return replace(_assert_ownership(client, ticket, assertion=assertion,
|
|
92
|
+
take_over=take_over), key=ticket.key)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _assert_ownership(client, ticket, *, assertion: str = "",
|
|
96
|
+
take_over: bool = False) -> OwnershipOutcome:
|
|
73
97
|
"""Make `ticket` held by the account `client` authenticates as.
|
|
74
98
|
|
|
75
99
|
Idempotent for whoever already holds it. Refuses a second holder by name,
|
|
@@ -79,11 +103,18 @@ def assert_ownership(client, ticket, *, assertion: str = "",
|
|
|
79
103
|
them is an answer about who holds the ticket.
|
|
80
104
|
"""
|
|
81
105
|
key = ticket.key
|
|
106
|
+
# Where the ticket is as this runs. An applied assertion moves it, and every
|
|
107
|
+
# refusal after that has to report where it left the ticket, not where it found
|
|
108
|
+
# it: a caller told the old status cannot tell the user what to put right.
|
|
109
|
+
landed = ticket.status
|
|
110
|
+
transitioned = False
|
|
82
111
|
|
|
83
|
-
def refused(message: str, detail: str = "", holder=("", "")
|
|
112
|
+
def refused(message: str, detail: str = "", holder=("", ""), retry: bool = False,
|
|
113
|
+
assigned: bool = False) -> OwnershipOutcome:
|
|
84
114
|
return OwnershipOutcome(settled=False, message=message, detail=detail,
|
|
85
115
|
holder_id=holder[0], holder_name=holder[1],
|
|
86
|
-
status=
|
|
116
|
+
status=landed, retry=retry,
|
|
117
|
+
transitioned=transitioned, assigned=assigned)
|
|
87
118
|
|
|
88
119
|
if ticket.category == "done":
|
|
89
120
|
return refused(f"{key} is resolved ('{ticket.status}'), so there is nothing "
|
|
@@ -99,7 +130,6 @@ def assert_ownership(client, ticket, *, assertion: str = "",
|
|
|
99
130
|
holder_id=ticket.me_id, holder_name=ticket.me_name,
|
|
100
131
|
message=f"{key} is already held by you.")
|
|
101
132
|
|
|
102
|
-
transitioned = False
|
|
103
133
|
if assertion:
|
|
104
134
|
matches = [t for t in ticket.offered
|
|
105
135
|
if _normalize(t.name) == _normalize(assertion)]
|
|
@@ -115,7 +145,7 @@ def assert_ownership(client, ticket, *, assertion: str = "",
|
|
|
115
145
|
f"offers: {offers}.")
|
|
116
146
|
try:
|
|
117
147
|
client.apply_transition(ticket.issue_id, matches[0].id)
|
|
118
|
-
transitioned = True
|
|
148
|
+
transitioned, landed = True, matches[0].to_status
|
|
119
149
|
except TrackerRequestInvalid as error:
|
|
120
150
|
# The workflow refused it, which is the whole point of naming one: on a
|
|
121
151
|
# workflow that excludes a second claimant this is where they stop, and
|
|
@@ -126,26 +156,33 @@ def assert_ownership(client, ticket, *, assertion: str = "",
|
|
|
126
156
|
try:
|
|
127
157
|
client.assign(ticket.issue_id, ticket.me_id)
|
|
128
158
|
except TrackerError as error:
|
|
159
|
+
# Worth retrying unless the tracker answered and refused: a 400 is an answer.
|
|
129
160
|
return refused(f"{key} could not be assigned to you, so it is not held.",
|
|
130
|
-
str(error))
|
|
161
|
+
str(error), retry=not isinstance(error, TrackerRequestInvalid))
|
|
131
162
|
|
|
132
163
|
# The read-back. Everything above is what we asked for; this is what is true.
|
|
133
164
|
try:
|
|
134
165
|
status, category, now_id, now_name = _holder(client, ticket)
|
|
135
166
|
except TrackerError as error:
|
|
167
|
+
# `assigned`: the write above landed, so the ticket is **not** known to be
|
|
168
|
+
# unassigned — a caller that says it is contradicts this very message, and
|
|
169
|
+
# replaces advice that works. Running the claim again does recover it, because
|
|
170
|
+
# a ticket already yours is returned as held before anything is sent.
|
|
136
171
|
return refused(f"{key} was assigned to you, but reading it back failed, so "
|
|
137
172
|
f"whether you hold it is unknown. Run this again to find out.",
|
|
138
|
-
str(error))
|
|
173
|
+
str(error), retry=not isinstance(error, TrackerRequestInvalid),
|
|
174
|
+
assigned=True)
|
|
139
175
|
if now_id == ticket.me_id:
|
|
140
176
|
return OwnershipOutcome(settled=True, message=f"{key} is held by you.",
|
|
141
177
|
holder_id=now_id, holder_name=now_name,
|
|
142
|
-
status=status, transitioned=transitioned
|
|
178
|
+
status=status, transitioned=transitioned,
|
|
179
|
+
assigned=True)
|
|
143
180
|
if not now_id:
|
|
144
181
|
# Assigned, then unassigned before the read-back — somebody released it out
|
|
145
182
|
# from under this claim. Nobody holds it, so saying somebody does would be a
|
|
146
183
|
# lie, and there is nobody whose claim re-running could stamp on.
|
|
147
184
|
return OwnershipOutcome(
|
|
148
|
-
settled=False, status=status, transitioned=transitioned,
|
|
185
|
+
settled=False, status=status, transitioned=transitioned, assigned=True,
|
|
149
186
|
message=(f"{key} was assigned to you and then to nobody, so you do not "
|
|
150
187
|
f"hold it: somebody unassigned it while this claim was in "
|
|
151
188
|
f"flight. Run this again to take it."))
|
|
@@ -154,7 +191,7 @@ def assert_ownership(client, ticket, *, assertion: str = "",
|
|
|
154
191
|
# between our write and our read — undoing it would stamp on their claim.
|
|
155
192
|
return OwnershipOutcome(
|
|
156
193
|
settled=False, holder_id=now_id, holder_name=now_name, status=status,
|
|
157
|
-
transitioned=transitioned,
|
|
194
|
+
transitioned=transitioned, assigned=True,
|
|
158
195
|
message=(f"{key} is held by {now_name or 'another account'}, not by you: they "
|
|
159
196
|
f"took it while this claim was in flight. Nothing here is yours; "
|
|
160
197
|
f"run this again if they let it go."))
|
|
@@ -28,7 +28,7 @@ from tcw.store.base import link_for
|
|
|
28
28
|
from tcw.tracker.intake import (BINDING_SIDECAR, Bound, binding_of, read_ticket,
|
|
29
29
|
same_site, with_comment_record)
|
|
30
30
|
from tcw.tracker.jira import TrackerError
|
|
31
|
-
from tcw.tracker.sync import (CONFLICTING, CURRENT,
|
|
31
|
+
from tcw.tracker.sync import (CONFLICTING, CURRENT, MOVES_NEEDING_NO_CLAIM, NONE,
|
|
32
32
|
PENDING, REASON_LIMIT, Outcome, _now, classify_error)
|
|
33
33
|
|
|
34
34
|
SKIPPED, CLEARED = "skipped", "cleared"
|
|
@@ -122,7 +122,7 @@ def _send(store, slug: str, client, config, bound: Bound, move: str, event: str,
|
|
|
122
122
|
# comment is the only record of why the ticket closed, and skipping it would close
|
|
123
123
|
# the ticket silently. A ticket somebody *else* holds is still theirs to annotate.
|
|
124
124
|
if ticket.assignee_id != ticket.me_id and not (
|
|
125
|
-
not ticket.assignee_id and move in
|
|
125
|
+
not ticket.assignee_id and move in MOVES_NEEDING_NO_CLAIM):
|
|
126
126
|
holder = ticket.assignee_name if ticket.assignee_id else "nobody"
|
|
127
127
|
_write(store, slug, None)
|
|
128
128
|
return Outcome(SKIPPED, f"{key} is assigned to {holder}, so no progress comment "
|