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.
Files changed (187) hide show
  1. {tcw_cli-2.5.1/tcw_cli.egg-info → tcw_cli-2.6.0}/PKG-INFO +16 -7
  2. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/README.md +15 -6
  3. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/pyproject.toml +1 -1
  4. tcw_cli-2.6.0/tcw/__init__.py +1 -0
  5. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/base.py +60 -23
  6. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/fs.py +5 -2
  7. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/claim.py +18 -0
  8. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/intake.py +21 -7
  9. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/ownership.py +51 -14
  10. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/progress.py +2 -2
  11. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/sync.py +453 -181
  12. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/cli.py +304 -83
  13. {tcw_cli-2.5.1 → tcw_cli-2.6.0/tcw_cli.egg-info}/PKG-INFO +16 -7
  14. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw_cli.egg-info/SOURCES.txt +2 -0
  15. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_claimability.py +18 -1
  16. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_cli.py +28 -2
  17. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_comment.py +14 -1
  18. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_config.py +42 -19
  19. tcw_cli-2.6.0/tests/test_tracker_gate.py +125 -0
  20. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_help.py +22 -5
  21. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_import.py +67 -3
  22. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_inheritance.py +10 -12
  23. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_ownership.py +74 -0
  24. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_pre_backlog.py +97 -14
  25. tcw_cli-2.6.0/tests/test_tracker_strict.py +1687 -0
  26. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_sync.py +955 -176
  27. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_validate.py +14 -2
  28. tcw_cli-2.6.0/tests/test_work_start.py +48 -0
  29. tcw_cli-2.5.1/tcw/__init__.py +0 -1
  30. tcw_cli-2.5.1/tests/test_tracker_strict.py +0 -834
  31. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/LICENSE +0 -0
  32. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/setup.cfg +0 -0
  33. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/capabilities/__init__.py +0 -0
  34. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/capabilities/cli.py +0 -0
  35. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/cli.py +0 -0
  36. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/harness.py +0 -0
  37. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/refs.py +0 -0
  38. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/__init__.py +0 -0
  39. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/dist/client/assets/index-CNxlcI8A.js +0 -0
  40. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
  41. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/dist/client/index.html +0 -0
  42. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/dist/client/theme-init.js +0 -0
  43. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/dist/server.cjs +0 -0
  44. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/serve/runtime.py +0 -0
  45. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/stdin.py +0 -0
  46. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/__init__.py +0 -0
  47. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/checkouts.py +0 -0
  48. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/config_edit.py +0 -0
  49. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/store/project.py +0 -0
  50. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/taxonomy/__init__.py +0 -0
  51. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/taxonomy/cli.py +0 -0
  52. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/__init__.py +0 -0
  53. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/create.py +0 -0
  54. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/tracker/jira.py +0 -0
  55. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/validate.py +0 -0
  56. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/__init__.py +0 -0
  57. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/generate.py +0 -0
  58. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/hooks.py +0 -0
  59. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/audit-backlog.md +0 -0
  60. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/consolidate-plans.md +0 -0
  61. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/create-work.md +0 -0
  62. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/decompose.md +0 -0
  63. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/delegation.md +0 -0
  64. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/documentation-sync.md +0 -0
  65. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/post-mortem.md +0 -0
  66. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/search.md +0 -0
  67. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/triage-issues.md +0 -0
  68. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/procedures/unattended-work.md +0 -0
  69. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/projection.py +0 -0
  70. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/implement.md +0 -0
  71. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/inbox.md +0 -0
  72. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/plan.md +0 -0
  73. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/postmortem.md +0 -0
  74. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/request.md +0 -0
  75. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/spec.md +0 -0
  76. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/prompts/verify.md +0 -0
  77. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/recursion.py +0 -0
  78. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/resolve.py +0 -0
  79. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw/work/templates.py +0 -0
  80. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw_cli.egg-info/dependency_links.txt +0 -0
  81. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw_cli.egg-info/entry_points.txt +0 -0
  82. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw_cli.egg-info/requires.txt +0 -0
  83. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tcw_cli.egg-info/top_level.txt +0 -0
  84. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_body_prompt.py +0 -0
  85. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capabilities.py +0 -0
  86. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capabilities_federation.py +0 -0
  87. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capabilities_reset.py +0 -0
  88. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capabilities_rm.py +0 -0
  89. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capabilities_sidecar.py +0 -0
  90. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capability_gate_children.py +0 -0
  91. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_capability_ref_wording.py +0 -0
  92. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_check_versions.py +0 -0
  93. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_child_status.py +0 -0
  94. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_cli_help_coverage.py +0 -0
  95. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_committed_paths.py +0 -0
  96. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_config_edit.py +0 -0
  97. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_config_edit_writers.py +0 -0
  98. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_configuration_text_home.py +0 -0
  99. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_cut_version.py +0 -0
  100. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_documentation_config.py +0 -0
  101. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_documentation_prompt.py +0 -0
  102. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_documentation_sync_wiring.py +0 -0
  103. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_documented_cli_surface.py +0 -0
  104. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_dynamic_skill_marker.py +0 -0
  105. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_edit_type.py +0 -0
  106. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_environment_hardness.py +0 -0
  107. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_epic_completable.py +0 -0
  108. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_eval_coverage.py +0 -0
  109. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_eval_files_changed.py +0 -0
  110. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_eval_fixture.py +0 -0
  111. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_eval_grading.py +0 -0
  112. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_eval_runner.py +0 -0
  113. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_external_work_store.py +0 -0
  114. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_falsification_rule.py +0 -0
  115. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_generate_hook.py +0 -0
  116. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_harness.py +0 -0
  117. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_inbox_title.py +0 -0
  118. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_legacy_store_config.py +0 -0
  119. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_lifecycle_baseline.py +0 -0
  120. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_lifecycle_hooks.py +0 -0
  121. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_lifecycle_inert.py +0 -0
  122. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_lifecycle_policy.py +0 -0
  123. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_lifecycle_validation.py +0 -0
  124. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_multiproject.py +0 -0
  125. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_non_git_writes.py +0 -0
  126. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_plugin_manifests.py +0 -0
  127. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_procedure_config.py +0 -0
  128. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_procedure_verb.py +0 -0
  129. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_project_overrides.py +0 -0
  130. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_project_registry.py +0 -0
  131. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_projection.py +0 -0
  132. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_prompt_fallback.py +0 -0
  133. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_qualified_ref.py +0 -0
  134. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_recursion.py +0 -0
  135. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_refs.py +0 -0
  136. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_remote_session_setup.py +0 -0
  137. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_repo_lifecycle.py +0 -0
  138. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_resolve.py +0 -0
  139. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_resolve_procedure.py +0 -0
  140. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_retention.py +0 -0
  141. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_scaffold.py +0 -0
  142. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve.py +0 -0
  143. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve_descendants.py +0 -0
  144. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve_projection.py +0 -0
  145. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve_resolve.py +0 -0
  146. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve_runtime.py +0 -0
  147. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_serve_write.py +0 -0
  148. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_session_bootstrap.py +0 -0
  149. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_shipped_procedures.py +0 -0
  150. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_shipped_prompts.py +0 -0
  151. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_show_json.py +0 -0
  152. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_skill_flow.py +0 -0
  153. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_skill_lifecycle_parity.py +0 -0
  154. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_skill_path_pointers.py +0 -0
  155. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_smoke.py +0 -0
  156. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_stage_validate.py +0 -0
  157. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_stage_verb.py +0 -0
  158. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_status_parity.py +0 -0
  159. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_stdin.py +0 -0
  160. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_stdin_cli.py +0 -0
  161. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_store_bounds.py +0 -0
  162. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_store_editor.py +0 -0
  163. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_store_nodes.py +0 -0
  164. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_store_provisioning.py +0 -0
  165. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_store_publication.py +0 -0
  166. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_subprocess_stdin.py +0 -0
  167. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_taxonomy.py +0 -0
  168. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tombstone.py +0 -0
  169. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_absent.py +0 -0
  170. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_binding.py +0 -0
  171. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_claim.py +0 -0
  172. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_client.py +0 -0
  173. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_create.py +0 -0
  174. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_hold.py +0 -0
  175. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_link.py +0 -0
  176. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_replay.py +0 -0
  177. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_surface.py +0 -0
  178. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_tracker_sync_config.py +0 -0
  179. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_unattended_work_skill.py +0 -0
  180. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_unpushed_version_script.py +0 -0
  181. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_validate.py +0 -0
  182. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_validate_target.py +0 -0
  183. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_work.py +0 -0
  184. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_work_autocommit.py +0 -0
  185. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_work_review.py +0 -0
  186. {tcw_cli-2.5.1 → tcw_cli-2.6.0}/tests/test_work_tags.py +0 -0
  187. {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.5.1
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, and posts a comment if `comments: true` |
735
- | `submit`, `rework`, `complete`, discard | moves the item | also moves a linked ticket to the Jira status mapped under `statuses`, and posts a comment if `comments: true` |
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 | — | with `tcw work tracker link --sync-status`, a ticket linked to work already under way is claimed and moved forward to where the item is |
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, and moves the item only if the claim worked.
748
- - `submit`, `rework` and `complete --resolution done` read the ticket first, and
749
- refuse unless it is assigned to you and where the item's last move left it.
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, and posts a comment if `comments: true` |
519
- | `submit`, `rework`, `complete`, discard | moves the item | also moves a linked ticket to the Jira status mapped under `statuses`, and posts a comment if `comments: true` |
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 | — | with `tcw work tracker link --sync-status`, a ticket linked to work already under way is claimed and moved forward to where the item is |
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, and moves the item only if the claim worked.
532
- - `submit`, `rework` and `complete --resolution done` read the ticket first, and
533
- refuse unless it is assigned to you and where the item's last move left it.
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.5.1"
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
- # `start` is the exception and is required: a start applies its transition through the
1264
- # claim rather than through `assess_move`, so it has no status-derived rule to fall
1265
- # back to. An unknown key is reported, because silently ignoring a key someone set is
1266
- # silently not doing what they asked.
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
- transitions = nested("transitions", TRACKER_TRANSITION_KEYS)
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 missing
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, so a lone `null` is a wrong value rather than a missing required one —
1480
- # the same shape `inbox-query` uses above.
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. That `start` is required is the
1678
- caller's check, not this one's: absent is `{}` here, which leaves every move on
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: `transitions.start: required` under a `transitions` an ancestor
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
- if not take_over:
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
- if not take_over:
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
- err = git_commit_result(self.store_git_root, f"tcw work: take over {slug}", rel)
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; `catch_up=True` that `link --sync-status` asked for
206
- it to be.
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, and why the guide says plainly
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. Everything else is
54
- for the message.
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=("", "")) -> OwnershipOutcome:
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=ticket.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, MOVES_ALLOWING_UNASSIGNED, NONE,
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 MOVES_ALLOWING_UNASSIGNED):
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 "