tcw-cli 2.1.2__tar.gz → 2.1.3__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.1.2 → tcw_cli-2.1.3}/PKG-INFO +34 -6
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/README.md +33 -5
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/pyproject.toml +1 -1
- tcw_cli-2.1.3/tcw/__init__.py +1 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/store/base.py +137 -2
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/store/fs.py +58 -8
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/store/project.py +1 -1
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/cli.py +14 -6
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/PKG-INFO +34 -6
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/SOURCES.txt +1 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_import.py +61 -0
- tcw_cli-2.1.3/tests/test_tracker_inheritance.py +686 -0
- tcw_cli-2.1.2/tcw/__init__.py +0 -1
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/LICENSE +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/setup.cfg +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/capabilities/__init__.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/capabilities/cli.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/cli.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/refs.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/__init__.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/dist/client/index.html +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/dist/client/theme-init.js +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/dist/server.cjs +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/serve/runtime.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/stdin.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/store/__init__.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/store/checkouts.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/taxonomy/__init__.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/taxonomy/cli.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/tracker/__init__.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/tracker/claim.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/tracker/intake.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/tracker/jira.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/validate.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/__init__.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/generate.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/hooks.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/projection.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/implement.md +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/inbox.md +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/plan.md +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/postmortem.md +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/request.md +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/spec.md +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/prompts/verify.md +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/recursion.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/resolve.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw/work/templates.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/dependency_links.txt +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/entry_points.txt +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/requires.txt +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tcw_cli.egg-info/top_level.txt +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_body_prompt.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capabilities.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capabilities_federation.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capabilities_reset.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capabilities_rm.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capabilities_sidecar.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_capability_ref_wording.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_committed_paths.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_cut_version.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_documentation_config.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_documentation_prompt.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_documentation_sync_wiring.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_documented_cli_surface.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_environment_hardness.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_epic_completable.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_eval_coverage.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_eval_files_changed.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_eval_fixture.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_eval_grading.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_eval_runner.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_external_work_store.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_falsification_rule.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_generate_hook.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_inbox_title.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_lifecycle_baseline.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_lifecycle_hooks.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_lifecycle_inert.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_lifecycle_policy.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_lifecycle_validation.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_multiproject.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_non_git_writes.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_plugin_manifests.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_project_overrides.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_project_registry.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_projection.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_prompt_fallback.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_qualified_ref.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_recursion.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_refs.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_remote_session_setup.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_repo_lifecycle.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_resolve.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_retention.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_scaffold.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve_descendants.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve_projection.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve_resolve.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve_runtime.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_serve_write.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_session_bootstrap.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_shipped_prompts.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_show_json.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_skill_flow.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_skill_lifecycle_parity.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_smoke.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_stage_verb.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_status_parity.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_stdin.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_stdin_cli.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_store_bounds.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_store_editor.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_store_nodes.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_store_provisioning.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_store_publication.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_subprocess_stdin.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_taxonomy.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tombstone.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_absent.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_binding.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_claim.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_claimability.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_cli.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_client.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_config.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_link.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_replay.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_tracker_validate.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_unpushed_version_script.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_validate.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_validate_target.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_work.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_work_autocommit.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_work_review.py +0 -0
- {tcw_cli-2.1.2 → tcw_cli-2.1.3}/tests/test_work_tags.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tcw-cli
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.3
|
|
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
|
|
@@ -603,6 +603,32 @@ work:
|
|
|
603
603
|
environment variables; TCW reads them at the moment it makes a request, so a token
|
|
604
604
|
cannot be committed by accident.
|
|
605
605
|
|
|
606
|
+
**Write the shared settings once.** In a workspace of connected nodes, a node's
|
|
607
|
+
`tracker` block takes any setting it leaves out from its parent nodes, all the way
|
|
608
|
+
up. So the site, credentials and claim transition can live in the workspace root,
|
|
609
|
+
and each package writes only the tickets it wants to see:
|
|
610
|
+
|
|
611
|
+
```yaml
|
|
612
|
+
# packages/api/tcw-config.yaml
|
|
613
|
+
work:
|
|
614
|
+
tracker:
|
|
615
|
+
candidate-query: project = EX AND component = api AND status = "To Do"
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
The nearest file wins each setting, and `credentials` and `transitions` merge
|
|
619
|
+
setting by setting too. A node with no `tracker` block of its own has no tracker,
|
|
620
|
+
whatever its parents say. Two rules keep this safe:
|
|
621
|
+
|
|
622
|
+
- **`credentials` must sit beside `base-url`.** A node that sets its own `base-url`
|
|
623
|
+
must also set both of its own `credentials` keys, even if the address is the same
|
|
624
|
+
as its parent's, so a token is never sent to a site chosen in a different file.
|
|
625
|
+
- **A node with its own board is checked like any tracking node.** If it holds the
|
|
626
|
+
shared settings, it needs a `candidate-query` of its own. Keeping shared settings
|
|
627
|
+
in a node without a board avoids that.
|
|
628
|
+
|
|
629
|
+
A mistake in a parent's settings is reported by `tcw validate` in every node that
|
|
630
|
+
inherits it, naming the parent's file.
|
|
631
|
+
|
|
606
632
|
`tracker show` reports two different things, and the distinction matters:
|
|
607
633
|
|
|
608
634
|
- **claimable** — whether this ticket currently offers the transition you
|
|
@@ -625,15 +651,17 @@ ticket's text goes into the item as its raw input, with a link back, and the ite
|
|
|
625
651
|
request still gets written the usual way. A ticket assigned to someone else is
|
|
626
652
|
refused, and so is one that is already closed.
|
|
627
653
|
|
|
628
|
-
Running `import` again for the same ticket gives you the same item
|
|
629
|
-
second one, and if the first run took the ticket but stopped before creating the
|
|
654
|
+
Running `import` again for the same ticket in the same node gives you the same item
|
|
655
|
+
rather than a second one, and if the first run took the ticket but stopped before creating the
|
|
630
656
|
item, the second run finishes the job. One ticket can deliberately become several
|
|
631
657
|
items with `--part api`, `--part web`, and so on. `unlink` removes a wrong binding
|
|
632
658
|
and keeps a record of it with your reason; it never changes the ticket in Jira.
|
|
633
659
|
|
|
634
|
-
|
|
635
|
-
two people can both take the same ticket, and TCW does not stop that.
|
|
636
|
-
|
|
660
|
+
Three limits to know. On a workflow that lets anyone start a ticket from any status,
|
|
661
|
+
two people can both take the same ticket, and TCW does not stop that. Two runs by the
|
|
662
|
+
same Jira account at the same moment can both create an item. And each node keeps its
|
|
663
|
+
own bindings: importing one ticket in two nodes of a workspace, even nodes sharing
|
|
664
|
+
inherited settings, gives two items, one in each.
|
|
637
665
|
|
|
638
666
|
Two guarantees worth stating plainly. A project with no `tracker` block behaves
|
|
639
667
|
exactly as before, with no new required setting and no network access. And
|
|
@@ -387,6 +387,32 @@ work:
|
|
|
387
387
|
environment variables; TCW reads them at the moment it makes a request, so a token
|
|
388
388
|
cannot be committed by accident.
|
|
389
389
|
|
|
390
|
+
**Write the shared settings once.** In a workspace of connected nodes, a node's
|
|
391
|
+
`tracker` block takes any setting it leaves out from its parent nodes, all the way
|
|
392
|
+
up. So the site, credentials and claim transition can live in the workspace root,
|
|
393
|
+
and each package writes only the tickets it wants to see:
|
|
394
|
+
|
|
395
|
+
```yaml
|
|
396
|
+
# packages/api/tcw-config.yaml
|
|
397
|
+
work:
|
|
398
|
+
tracker:
|
|
399
|
+
candidate-query: project = EX AND component = api AND status = "To Do"
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
The nearest file wins each setting, and `credentials` and `transitions` merge
|
|
403
|
+
setting by setting too. A node with no `tracker` block of its own has no tracker,
|
|
404
|
+
whatever its parents say. Two rules keep this safe:
|
|
405
|
+
|
|
406
|
+
- **`credentials` must sit beside `base-url`.** A node that sets its own `base-url`
|
|
407
|
+
must also set both of its own `credentials` keys, even if the address is the same
|
|
408
|
+
as its parent's, so a token is never sent to a site chosen in a different file.
|
|
409
|
+
- **A node with its own board is checked like any tracking node.** If it holds the
|
|
410
|
+
shared settings, it needs a `candidate-query` of its own. Keeping shared settings
|
|
411
|
+
in a node without a board avoids that.
|
|
412
|
+
|
|
413
|
+
A mistake in a parent's settings is reported by `tcw validate` in every node that
|
|
414
|
+
inherits it, naming the parent's file.
|
|
415
|
+
|
|
390
416
|
`tracker show` reports two different things, and the distinction matters:
|
|
391
417
|
|
|
392
418
|
- **claimable** — whether this ticket currently offers the transition you
|
|
@@ -409,15 +435,17 @@ ticket's text goes into the item as its raw input, with a link back, and the ite
|
|
|
409
435
|
request still gets written the usual way. A ticket assigned to someone else is
|
|
410
436
|
refused, and so is one that is already closed.
|
|
411
437
|
|
|
412
|
-
Running `import` again for the same ticket gives you the same item
|
|
413
|
-
second one, and if the first run took the ticket but stopped before creating the
|
|
438
|
+
Running `import` again for the same ticket in the same node gives you the same item
|
|
439
|
+
rather than a second one, and if the first run took the ticket but stopped before creating the
|
|
414
440
|
item, the second run finishes the job. One ticket can deliberately become several
|
|
415
441
|
items with `--part api`, `--part web`, and so on. `unlink` removes a wrong binding
|
|
416
442
|
and keeps a record of it with your reason; it never changes the ticket in Jira.
|
|
417
443
|
|
|
418
|
-
|
|
419
|
-
two people can both take the same ticket, and TCW does not stop that.
|
|
420
|
-
|
|
444
|
+
Three limits to know. On a workflow that lets anyone start a ticket from any status,
|
|
445
|
+
two people can both take the same ticket, and TCW does not stop that. Two runs by the
|
|
446
|
+
same Jira account at the same moment can both create an item. And each node keeps its
|
|
447
|
+
own bindings: importing one ticket in two nodes of a workspace, even nodes sharing
|
|
448
|
+
inherited settings, gives two items, one in each.
|
|
421
449
|
|
|
422
450
|
Two guarantees worth stating plainly. A project with no `tracker` block behaves
|
|
423
451
|
exactly as before, with no new required setting and no network access. And
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tcw-cli"
|
|
7
|
-
version = "2.1.
|
|
7
|
+
version = "2.1.3"
|
|
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.1.3"
|
|
@@ -14,6 +14,7 @@ extracted in Phase 4 — not pre-abstracted here.
|
|
|
14
14
|
# time. Python 3.14 defers natively (PEP 649); this keeps <3.14 working too.
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import copy
|
|
17
18
|
import re
|
|
18
19
|
from abc import ABC, abstractmethod
|
|
19
20
|
from dataclasses import dataclass, field
|
|
@@ -943,7 +944,7 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
|
|
|
943
944
|
|
|
944
945
|
problems: list[str] = []
|
|
945
946
|
|
|
946
|
-
for key in sorted(set(raw) - TRACKER_KEYS):
|
|
947
|
+
for key in sorted(set(raw) - TRACKER_KEYS, key=str):
|
|
947
948
|
problems.append(f"work.tracker.{key}: unknown key")
|
|
948
949
|
|
|
949
950
|
def required_str(key: str) -> str:
|
|
@@ -974,7 +975,7 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
|
|
|
974
975
|
problems.append(f"work.tracker.{key}: expected a mapping, "
|
|
975
976
|
f"got {type(value).__name__}")
|
|
976
977
|
return {}
|
|
977
|
-
for sub in sorted(set(value) - allowed):
|
|
978
|
+
for sub in sorted(set(value) - allowed, key=str):
|
|
978
979
|
problems.append(f"work.tracker.{key}.{sub}: unknown key")
|
|
979
980
|
return value
|
|
980
981
|
|
|
@@ -1021,6 +1022,135 @@ def parse_tracker_config(raw: Any) -> tuple["TrackerConfig | None", list[str]]:
|
|
|
1021
1022
|
), []
|
|
1022
1023
|
|
|
1023
1024
|
|
|
1025
|
+
TrackerKeyPath = tuple[Any, ...]
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def merge_tracker_blocks(
|
|
1029
|
+
blocks: list[tuple[str, Any]],
|
|
1030
|
+
) -> tuple[Any, dict[TrackerKeyPath, str], str | None]:
|
|
1031
|
+
"""Lay `work.tracker` blocks over each other, nearest first, key by key.
|
|
1032
|
+
|
|
1033
|
+
Pure, like `parse_tracker_config`: the caller gathers the blocks from its own
|
|
1034
|
+
project graph, so a store that is not a filesystem merges the same way. Each
|
|
1035
|
+
block is `(label, raw)`, and labels must be distinct — the node itself first,
|
|
1036
|
+
then its ancestors, direct parent first. Whether a node takes part at all
|
|
1037
|
+
(opt-in) is the caller's decision; this only merges what it is given.
|
|
1038
|
+
|
|
1039
|
+
Returns `(merged, record, whole_block_label)`:
|
|
1040
|
+
|
|
1041
|
+
- `merged` goes to `parse_tracker_config` unchanged.
|
|
1042
|
+
- `record` maps each key path in `merged` to the label that supplied its
|
|
1043
|
+
value. A nearer null that let a farther value show through does not move
|
|
1044
|
+
it: the credentials rule reads it, and naming the null's file there would
|
|
1045
|
+
let a token follow a changed `base-url`.
|
|
1046
|
+
- `whole_block_label` is set when an ancestor's block is not a mapping. The
|
|
1047
|
+
merge stops there and returns that block, so the parser's "expected a
|
|
1048
|
+
mapping" is reported against the ancestor and every node beneath it fails
|
|
1049
|
+
closed.
|
|
1050
|
+
"""
|
|
1051
|
+
if not blocks:
|
|
1052
|
+
return None, {}, None
|
|
1053
|
+
own = blocks[0][1]
|
|
1054
|
+
if not isinstance(own, dict):
|
|
1055
|
+
return own, {}, None
|
|
1056
|
+
layers = [blocks[0]]
|
|
1057
|
+
for label, raw in blocks[1:]:
|
|
1058
|
+
if raw is None or raw == {}:
|
|
1059
|
+
continue
|
|
1060
|
+
if not isinstance(raw, dict):
|
|
1061
|
+
return raw, {}, label
|
|
1062
|
+
layers.append((label, raw))
|
|
1063
|
+
|
|
1064
|
+
merged: dict = {}
|
|
1065
|
+
record: dict[TrackerKeyPath, str] = {}
|
|
1066
|
+
for label, raw in reversed(layers):
|
|
1067
|
+
_lay_tracker_block(merged, copy.deepcopy(raw), label, (), record)
|
|
1068
|
+
return merged, record, None
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _lay_tracker_block(target: dict, source: dict, label: str,
|
|
1072
|
+
path: TrackerKeyPath, record: dict[TrackerKeyPath, str]) -> None:
|
|
1073
|
+
for key, value in source.items():
|
|
1074
|
+
key_path = (*path, key)
|
|
1075
|
+
if value is None:
|
|
1076
|
+
if key not in target: # nothing farther: kept, for the parser
|
|
1077
|
+
target[key] = None
|
|
1078
|
+
record[key_path] = label
|
|
1079
|
+
continue # otherwise the farther value shows through
|
|
1080
|
+
if isinstance(value, dict) and isinstance(target.get(key), dict):
|
|
1081
|
+
record[key_path] = label
|
|
1082
|
+
_lay_tracker_block(target[key], value, label, key_path, record)
|
|
1083
|
+
continue
|
|
1084
|
+
for stale in [p for p in record if p[:len(key_path)] == key_path]:
|
|
1085
|
+
del record[stale]
|
|
1086
|
+
target[key] = value
|
|
1087
|
+
record[key_path] = label
|
|
1088
|
+
if isinstance(value, dict):
|
|
1089
|
+
_record_tracker_paths(value, label, key_path, record)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _record_tracker_paths(block: dict, label: str, path: TrackerKeyPath,
|
|
1093
|
+
record: dict[TrackerKeyPath, str]) -> None:
|
|
1094
|
+
for key, value in block.items():
|
|
1095
|
+
key_path = (*path, key)
|
|
1096
|
+
record[key_path] = label
|
|
1097
|
+
if isinstance(value, dict):
|
|
1098
|
+
_record_tracker_paths(value, label, key_path, record)
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def tracker_credentials_problem(record: dict[TrackerKeyPath, str],
|
|
1102
|
+
labels: list[str]) -> str | None:
|
|
1103
|
+
"""A problem when `credentials` came from farther up than `base-url`.
|
|
1104
|
+
|
|
1105
|
+
`JiraClient` sends a token built from the named variables to whatever
|
|
1106
|
+
`base-url` names. Before inheritance both sat in one block, so whoever chose
|
|
1107
|
+
the site chose the token. This keeps that true: a node that sets `base-url`
|
|
1108
|
+
also sets `credentials`, even when the URL repeats its parent's. Credentials
|
|
1109
|
+
partly inherited count by their farthest key. Run on a config that parsed.
|
|
1110
|
+
"""
|
|
1111
|
+
position = {label: index for index, label in enumerate(labels)}
|
|
1112
|
+
base_url = record.get(("base-url",))
|
|
1113
|
+
credentials = [label for key_path, label in record.items()
|
|
1114
|
+
if len(key_path) > 1 and key_path[0] == "credentials"]
|
|
1115
|
+
if base_url is None or not credentials:
|
|
1116
|
+
return None
|
|
1117
|
+
if max(position[label] for label in credentials) <= position[base_url]:
|
|
1118
|
+
return None
|
|
1119
|
+
return (f"work.tracker.credentials: inherited from a parent node, but base-url "
|
|
1120
|
+
f"is set nearer, in {base_url}; set credentials in the same file as "
|
|
1121
|
+
f"base-url")
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def attribute_tracker_problems(problems: list[str], record: dict[TrackerKeyPath, str],
|
|
1125
|
+
own_label: str, whole_block_label: str | None) -> list[str]:
|
|
1126
|
+
"""Prefix each parser problem with the label of the file that caused it.
|
|
1127
|
+
|
|
1128
|
+
Matched on the problem's exact key path, never the nearest enclosing
|
|
1129
|
+
mapping: `transitions.claim: required` under a `transitions` an ancestor
|
|
1130
|
+
supplied is about a key nobody set, and goes to `own_label`. The parser
|
|
1131
|
+
writes a path as its keys joined with `.` followed by `: `, so the longest
|
|
1132
|
+
recorded path whose spelling prefixes the problem that way is the one it is
|
|
1133
|
+
about — which also holds for a key whose own name contains `.` or `: `.
|
|
1134
|
+
|
|
1135
|
+
Two recorded paths can share a spelling (an integer key `5` and a string key
|
|
1136
|
+
`"5"`, or a key named `credentials.x` beside a nested one). Such a block is
|
|
1137
|
+
already invalid and every problem is still reported; only the file named for
|
|
1138
|
+
it may be the other one's.
|
|
1139
|
+
"""
|
|
1140
|
+
spelled = sorted(((".".join(map(str, key_path)), label)
|
|
1141
|
+
for key_path, label in record.items()),
|
|
1142
|
+
key=lambda entry: len(entry[0]), reverse=True)
|
|
1143
|
+
attributed = []
|
|
1144
|
+
for problem in problems:
|
|
1145
|
+
if problem.startswith("work.tracker: "):
|
|
1146
|
+
label = whole_block_label or own_label
|
|
1147
|
+
else:
|
|
1148
|
+
label = next((label for spelling, label in spelled
|
|
1149
|
+
if problem.startswith(f"work.tracker.{spelling}: ")), own_label)
|
|
1150
|
+
attributed.append(f"{label}: {problem}")
|
|
1151
|
+
return attributed
|
|
1152
|
+
|
|
1153
|
+
|
|
1024
1154
|
@dataclass(frozen=True)
|
|
1025
1155
|
class RepositoryDeclaration:
|
|
1026
1156
|
"""Where a component store comes from, as the project records it.
|
|
@@ -2285,6 +2415,11 @@ class WorkStore(ABC):
|
|
|
2285
2415
|
def tracker_config(self) -> "TrackerConfig | None":
|
|
2286
2416
|
"""The node's external-tracker settings, or `None` when none is configured.
|
|
2287
2417
|
|
|
2418
|
+
A node whose own `work.tracker` is a non-empty mapping inherits the keys it
|
|
2419
|
+
does not set from its ancestors: an adapter gathers their blocks from its
|
|
2420
|
+
project graph, lays them with `merge_tracker_blocks`, parses the result,
|
|
2421
|
+
and refuses a config `tracker_credentials_problem` objects to.
|
|
2422
|
+
|
|
2288
2423
|
Problems discarded, so a malformed key can never break a board read. Fails
|
|
2289
2424
|
*closed*: the parser returns `None` on any problem, so a half-read config
|
|
2290
2425
|
is one nobody declared. `tracker_problems` is where a user learns why.
|
|
@@ -47,7 +47,8 @@ from tcw.store.base import (
|
|
|
47
47
|
Binding, DocEntry, body_title, frontmatter_end,
|
|
48
48
|
parse_documentation_entries, parse_lifecycle_policy,
|
|
49
49
|
parse_connected_entry, parse_repository_declaration, parse_retention,
|
|
50
|
-
parse_tracker_config, TrackerConfig,
|
|
50
|
+
parse_tracker_config, TrackerConfig, attribute_tracker_problems,
|
|
51
|
+
merge_tracker_blocks, tracker_credentials_problem,
|
|
51
52
|
ProvisionResult,
|
|
52
53
|
RepositoryDeclaration,
|
|
53
54
|
PublicationError, StoreDeclarationError, StoreLocationUnusable,
|
|
@@ -5358,15 +5359,64 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
5358
5359
|
def tracker_config(self) -> "TrackerConfig | None":
|
|
5359
5360
|
"""Configured tracker settings, problems discarded — same contract as
|
|
5360
5361
|
`documentation`: a malformed key must not break `tcw work list`."""
|
|
5361
|
-
|
|
5362
|
-
return config
|
|
5362
|
+
return self._resolved_tracker()[0]
|
|
5363
5363
|
|
|
5364
5364
|
def tracker_problems(self) -> list[str]:
|
|
5365
|
-
"""Tracker-configuration problems, prefixed with the file
|
|
5366
|
-
Shares
|
|
5367
|
-
disagree about what is legal."""
|
|
5368
|
-
|
|
5369
|
-
|
|
5365
|
+
"""Tracker-configuration problems, each prefixed with the file it came from.
|
|
5366
|
+
Shares `_resolved_tracker` with `tracker_config`, so the two surfaces can
|
|
5367
|
+
never disagree about what is legal."""
|
|
5368
|
+
return self._resolved_tracker()[1]
|
|
5369
|
+
|
|
5370
|
+
def _resolved_tracker(self) -> "tuple[TrackerConfig | None, list[str]]":
|
|
5371
|
+
"""This node's `work.tracker`, merged over its ancestors' when it opts in.
|
|
5372
|
+
|
|
5373
|
+
Opt-in: only a node whose own block is a non-empty mapping consults its
|
|
5374
|
+
ancestors. A node that writes nothing has no tracker whatever they hold, so
|
|
5375
|
+
a node that never asked for one neither gains one nor fails `tcw validate`
|
|
5376
|
+
because a parent keeps shared settings.
|
|
5377
|
+
|
|
5378
|
+
Ancestors are read through the project registry, not by walking
|
|
5379
|
+
directories, and a graph with problems falls back to the node's own block:
|
|
5380
|
+
both commands that read this refuse on graph problems first, so only a
|
|
5381
|
+
direct caller reaches the fallback, and it must still not raise.
|
|
5382
|
+
"""
|
|
5383
|
+
own = self._work_config().get("tracker")
|
|
5384
|
+
if own is None or own == {}:
|
|
5385
|
+
return None, []
|
|
5386
|
+
blocks: list[tuple[str, object]] = [(SENTINEL, own)]
|
|
5387
|
+
unreachable: str | None = None
|
|
5388
|
+
if isinstance(own, dict):
|
|
5389
|
+
try:
|
|
5390
|
+
registry = FsProjectRegistry.open(self.node_root).require_valid()
|
|
5391
|
+
except ValueError:
|
|
5392
|
+
registry = None
|
|
5393
|
+
if registry is not None:
|
|
5394
|
+
ancestors = registry.ancestors()
|
|
5395
|
+
for ancestor in ancestors:
|
|
5396
|
+
work = registry.config(ancestor.id).get("work")
|
|
5397
|
+
if isinstance(work, dict):
|
|
5398
|
+
blocks.append((f"{Path(ancestor.locator) / SENTINEL} "
|
|
5399
|
+
f"(project '{ancestor.id}')", work.get("tracker")))
|
|
5400
|
+
# `ancestors()` stops at the first parent this checkout lacks, at
|
|
5401
|
+
# any depth, so ask who the last one reached declares.
|
|
5402
|
+
parent_id = registry.declared_parent_id(
|
|
5403
|
+
ancestors[-1].id if ancestors else None)
|
|
5404
|
+
if parent_id is not None and registry.get(parent_id) is None:
|
|
5405
|
+
unreachable = parent_id
|
|
5406
|
+
|
|
5407
|
+
merged, record, whole_block_label = merge_tracker_blocks(blocks)
|
|
5408
|
+
config, parsed = parse_tracker_config(merged)
|
|
5409
|
+
problems = attribute_tracker_problems(parsed, record, SENTINEL, whole_block_label)
|
|
5410
|
+
if config is not None:
|
|
5411
|
+
credentials = tracker_credentials_problem(record, [label for label, _ in blocks])
|
|
5412
|
+
if credentials is not None:
|
|
5413
|
+
config, problems = None, [f"{SENTINEL}: {credentials}"]
|
|
5414
|
+
if problems and unreachable is not None:
|
|
5415
|
+
problems.append(
|
|
5416
|
+
f"{SENTINEL}: work.tracker: declared parent '{unreachable}' is not "
|
|
5417
|
+
f"available in this checkout, so any tracker settings it holds were "
|
|
5418
|
+
f"not read (run tcw provision)")
|
|
5419
|
+
return config, problems
|
|
5370
5420
|
|
|
5371
5421
|
def documentation_problems(self) -> list[str]:
|
|
5372
5422
|
"""Documentation-entry problems, prefixed with the file they came from —
|
|
@@ -445,7 +445,7 @@ class FsProjectRegistry(ProjectRegistry):
|
|
|
445
445
|
connected = {}
|
|
446
446
|
unknown = set(connected) - {"parent", "children"}
|
|
447
447
|
if unknown:
|
|
448
|
-
self._problem(path, f"unknown connected-projects keys: {', '.join(sorted(unknown))}")
|
|
448
|
+
self._problem(path, f"unknown connected-projects keys: {', '.join(sorted(map(str, unknown)))}")
|
|
449
449
|
children = self._relation(path, connected.get("children"), "children")
|
|
450
450
|
parent = self._relation(path, connected.get("parent"), "parent")
|
|
451
451
|
if len(parent) > 1:
|
|
@@ -1711,10 +1711,15 @@ def _intake_text(outcome, description: str, today: str) -> str:
|
|
|
1711
1711
|
f"{body}\n")
|
|
1712
1712
|
|
|
1713
1713
|
|
|
1714
|
-
def _binding_for(
|
|
1714
|
+
def _binding_for(provider: str, project: str, outcome, part: str, today: str,
|
|
1715
|
+
unlinked: list) -> str:
|
|
1716
|
+
"""The binding document. `provider` and `project` are the values the claim was
|
|
1717
|
+
looked up and made with, passed in rather than read again: tracker settings can
|
|
1718
|
+
come from parent nodes' files, and one changed or broken mid-run would otherwise
|
|
1719
|
+
fail here, after the ticket is already claimed."""
|
|
1715
1720
|
from tcw.tracker.intake import binding_document
|
|
1716
1721
|
return binding_document(
|
|
1717
|
-
provider=
|
|
1722
|
+
provider=provider, project=project, part=part,
|
|
1718
1723
|
ticket_id=outcome.issue_id, ticket_key=outcome.key, ticket_url=outcome.url,
|
|
1719
1724
|
account_id=outcome.account_id, account_name=outcome.account_name,
|
|
1720
1725
|
bound=today, unlinked=unlinked)
|
|
@@ -1761,7 +1766,8 @@ def _tracker_import(args: argparse.Namespace) -> int:
|
|
|
1761
1766
|
today = date.today().isoformat()
|
|
1762
1767
|
try:
|
|
1763
1768
|
ticket = read_ticket(client, args.ticket)
|
|
1764
|
-
|
|
1769
|
+
project = _project_id(st)
|
|
1770
|
+
existing = find_binding(st, project=project,
|
|
1765
1771
|
provider=client.config.provider,
|
|
1766
1772
|
ticket_id=ticket.issue_id, part=part)
|
|
1767
1773
|
if existing is not None:
|
|
@@ -1799,7 +1805,8 @@ def _tracker_import(args: argparse.Namespace) -> int:
|
|
|
1799
1805
|
return 1
|
|
1800
1806
|
try:
|
|
1801
1807
|
st.write_sidecar(slug, BINDING_SIDECAR,
|
|
1802
|
-
_binding_for(
|
|
1808
|
+
_binding_for(client.config.provider, project, outcome, part,
|
|
1809
|
+
today, []), revision="")
|
|
1803
1810
|
except _LOCAL_WRITE_ERRORS as e:
|
|
1804
1811
|
try:
|
|
1805
1812
|
st.drop(slug)
|
|
@@ -1869,7 +1876,8 @@ def _tracker_link(args: argparse.Namespace) -> int:
|
|
|
1869
1876
|
return 1
|
|
1870
1877
|
try:
|
|
1871
1878
|
ticket = read_ticket(client, args.ticket)
|
|
1872
|
-
|
|
1879
|
+
project = _project_id(st)
|
|
1880
|
+
holder = find_binding(st, project=project,
|
|
1873
1881
|
provider=client.config.provider,
|
|
1874
1882
|
ticket_id=ticket.issue_id, part=part)
|
|
1875
1883
|
if holder is not None:
|
|
@@ -1885,7 +1893,7 @@ def _tracker_link(args: argparse.Namespace) -> int:
|
|
|
1885
1893
|
return 1
|
|
1886
1894
|
existing = st.read_sidecar(args.slug, BINDING_SIDECAR)
|
|
1887
1895
|
today = date.today().isoformat()
|
|
1888
|
-
document = _binding_for(
|
|
1896
|
+
document = _binding_for(client.config.provider, project, outcome, part, today,
|
|
1889
1897
|
unlinked_history(existing.content if existing else None))
|
|
1890
1898
|
try:
|
|
1891
1899
|
st.write_sidecar(args.slug, BINDING_SIDECAR, document, revision=revision or "")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tcw-cli
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.3
|
|
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
|
|
@@ -603,6 +603,32 @@ work:
|
|
|
603
603
|
environment variables; TCW reads them at the moment it makes a request, so a token
|
|
604
604
|
cannot be committed by accident.
|
|
605
605
|
|
|
606
|
+
**Write the shared settings once.** In a workspace of connected nodes, a node's
|
|
607
|
+
`tracker` block takes any setting it leaves out from its parent nodes, all the way
|
|
608
|
+
up. So the site, credentials and claim transition can live in the workspace root,
|
|
609
|
+
and each package writes only the tickets it wants to see:
|
|
610
|
+
|
|
611
|
+
```yaml
|
|
612
|
+
# packages/api/tcw-config.yaml
|
|
613
|
+
work:
|
|
614
|
+
tracker:
|
|
615
|
+
candidate-query: project = EX AND component = api AND status = "To Do"
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
The nearest file wins each setting, and `credentials` and `transitions` merge
|
|
619
|
+
setting by setting too. A node with no `tracker` block of its own has no tracker,
|
|
620
|
+
whatever its parents say. Two rules keep this safe:
|
|
621
|
+
|
|
622
|
+
- **`credentials` must sit beside `base-url`.** A node that sets its own `base-url`
|
|
623
|
+
must also set both of its own `credentials` keys, even if the address is the same
|
|
624
|
+
as its parent's, so a token is never sent to a site chosen in a different file.
|
|
625
|
+
- **A node with its own board is checked like any tracking node.** If it holds the
|
|
626
|
+
shared settings, it needs a `candidate-query` of its own. Keeping shared settings
|
|
627
|
+
in a node without a board avoids that.
|
|
628
|
+
|
|
629
|
+
A mistake in a parent's settings is reported by `tcw validate` in every node that
|
|
630
|
+
inherits it, naming the parent's file.
|
|
631
|
+
|
|
606
632
|
`tracker show` reports two different things, and the distinction matters:
|
|
607
633
|
|
|
608
634
|
- **claimable** — whether this ticket currently offers the transition you
|
|
@@ -625,15 +651,17 @@ ticket's text goes into the item as its raw input, with a link back, and the ite
|
|
|
625
651
|
request still gets written the usual way. A ticket assigned to someone else is
|
|
626
652
|
refused, and so is one that is already closed.
|
|
627
653
|
|
|
628
|
-
Running `import` again for the same ticket gives you the same item
|
|
629
|
-
second one, and if the first run took the ticket but stopped before creating the
|
|
654
|
+
Running `import` again for the same ticket in the same node gives you the same item
|
|
655
|
+
rather than a second one, and if the first run took the ticket but stopped before creating the
|
|
630
656
|
item, the second run finishes the job. One ticket can deliberately become several
|
|
631
657
|
items with `--part api`, `--part web`, and so on. `unlink` removes a wrong binding
|
|
632
658
|
and keeps a record of it with your reason; it never changes the ticket in Jira.
|
|
633
659
|
|
|
634
|
-
|
|
635
|
-
two people can both take the same ticket, and TCW does not stop that.
|
|
636
|
-
|
|
660
|
+
Three limits to know. On a workflow that lets anyone start a ticket from any status,
|
|
661
|
+
two people can both take the same ticket, and TCW does not stop that. Two runs by the
|
|
662
|
+
same Jira account at the same moment can both create an item. And each node keeps its
|
|
663
|
+
own bindings: importing one ticket in two nodes of a workspace, even nodes sharing
|
|
664
|
+
inherited settings, gives two items, one in each.
|
|
637
665
|
|
|
638
666
|
Two guarantees worth stating plainly. A project with no `tracker` block behaves
|
|
639
667
|
exactly as before, with no new required setting and no network access. And
|
|
@@ -123,6 +123,7 @@ tests/test_tracker_cli.py
|
|
|
123
123
|
tests/test_tracker_client.py
|
|
124
124
|
tests/test_tracker_config.py
|
|
125
125
|
tests/test_tracker_import.py
|
|
126
|
+
tests/test_tracker_inheritance.py
|
|
126
127
|
tests/test_tracker_link.py
|
|
127
128
|
tests/test_tracker_replay.py
|
|
128
129
|
tests/test_tracker_validate.py
|
|
@@ -352,3 +352,64 @@ def test_a_description_that_cannot_be_read_says_the_ticket_is_claimed(node, fake
|
|
|
352
352
|
assert code == 1 and f"claimed {TICKET}" in err
|
|
353
353
|
assert_no_item(node)
|
|
354
354
|
_re_run_finishes(node, fake)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ── with tracker settings inherited from a parent node ──────────────────────
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _inheriting_child(tmp_path: Path) -> tuple[Path, Path]:
|
|
361
|
+
"""A root holding the whole tracker block, and a child holding only its query."""
|
|
362
|
+
root = make_node(tmp_path, "root", email_env="TCW_A_EMAIL")
|
|
363
|
+
child = make_node(tmp_path, "child", email_env=None)
|
|
364
|
+
root_cfg = yaml.safe_load((root / "tcw-config.yaml").read_text())
|
|
365
|
+
root_cfg["connected-projects"] = {"children": {"child": str(child)}}
|
|
366
|
+
(root / "tcw-config.yaml").write_text(yaml.safe_dump(root_cfg, sort_keys=False))
|
|
367
|
+
child_cfg = yaml.safe_load((child / "tcw-config.yaml").read_text())
|
|
368
|
+
child_cfg["connected-projects"] = {"parent": {"root": str(root)}}
|
|
369
|
+
child_cfg.setdefault("work", {})["tracker"] = {"candidate-query": "project = EX"}
|
|
370
|
+
(child / "tcw-config.yaml").write_text(yaml.safe_dump(child_cfg, sort_keys=False))
|
|
371
|
+
from tcw.store.project import FsProjectRegistry
|
|
372
|
+
assert FsProjectRegistry.open(child).check() == []
|
|
373
|
+
assert FsWorkStore.open(child).tracker_config() is not None
|
|
374
|
+
return root, child
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _break_tracker_settings(root: Path):
|
|
378
|
+
def run():
|
|
379
|
+
cfg = yaml.safe_load((root / "tcw-config.yaml").read_text())
|
|
380
|
+
cfg["work"]["tracker"]["base-url"] = 42
|
|
381
|
+
(root / "tcw-config.yaml").write_text(yaml.safe_dump(cfg, sort_keys=False))
|
|
382
|
+
return run
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_import_from_an_inheriting_child_binds_with_the_childs_project(tmp_path, fake):
|
|
386
|
+
root, child = _inheriting_child(tmp_path)
|
|
387
|
+
code, out, err = run(child, "import", TICKET)
|
|
388
|
+
assert code == 0, err
|
|
389
|
+
(item,) = items(child)
|
|
390
|
+
assert binding(child, item.slug)["project"] == "child"
|
|
391
|
+
assert items(root) == []
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_import_binds_with_the_settings_it_claimed_with_if_a_parent_changes_meanwhile(
|
|
395
|
+
tmp_path, fake):
|
|
396
|
+
"""The binding's provider comes from the client that made the claim. Re-reading
|
|
397
|
+
the settings after the claim returned None once a parent's file broke, and
|
|
398
|
+
`None.provider` escaped as a traceback, leaving an unbound item behind."""
|
|
399
|
+
root, child = _inheriting_child(tmp_path)
|
|
400
|
+
fake.before("POST", "/transitions", _break_tracker_settings(root))
|
|
401
|
+
code, out, err = run(child, "import", TICKET)
|
|
402
|
+
assert code == 0, err
|
|
403
|
+
assert "Traceback" not in err and "AttributeError" not in err
|
|
404
|
+
(item,) = items(child)
|
|
405
|
+
assert binding(child, item.slug)["provider"] == "jira-cloud"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_link_binds_with_the_settings_it_claimed_with_if_a_parent_changes_meanwhile(
|
|
409
|
+
tmp_path, fake):
|
|
410
|
+
root, child = _inheriting_child(tmp_path)
|
|
411
|
+
slug = FsWorkStore.open(child).create("An existing item").slug
|
|
412
|
+
fake.before("POST", "/transitions", _break_tracker_settings(root))
|
|
413
|
+
code, out, err = run(child, "link", slug, TICKET)
|
|
414
|
+
assert code == 0, err
|
|
415
|
+
assert binding(child, slug)["provider"] == "jira-cloud"
|