planpilot 2.2.0__tar.gz → 2.3.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 (106) hide show
  1. {planpilot-2.2.0 → planpilot-2.3.0}/PKG-INFO +11 -1
  2. {planpilot-2.2.0 → planpilot-2.3.0}/README.md +10 -0
  3. {planpilot-2.2.0 → planpilot-2.3.0}/pyproject.toml +2 -2
  4. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/__init__.py +3 -2
  5. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/cli.py +163 -38
  6. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/contracts/sync.py +6 -0
  7. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/engine/engine.py +62 -9
  8. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/__init__.py +22 -17
  9. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/client.py +12 -0
  10. planpilot-2.3.0/src/planpilot/providers/github/github_gql/delete_issue.py +19 -0
  11. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/exceptions.py +1 -1
  12. planpilot-2.3.0/src/planpilot/providers/github/github_gql/input_types.py +9 -0
  13. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/operations.py +9 -0
  14. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/item.py +12 -0
  15. planpilot-2.3.0/src/planpilot/providers/github/operations/delete_issue.graphql +5 -0
  16. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/provider.py +30 -2
  17. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/renderers/markdown.py +2 -0
  18. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/sdk.py +228 -2
  19. planpilot-2.2.0/src/planpilot/providers/github/github_gql/input_types.py +0 -4
  20. {planpilot-2.2.0 → planpilot-2.3.0}/LICENSE +0 -0
  21. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/AGENTS.md +0 -0
  22. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/__main__.py +0 -0
  23. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/auth/__init__.py +0 -0
  24. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/auth/base.py +0 -0
  25. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/auth/factory.py +0 -0
  26. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/auth/resolvers/__init__.py +0 -0
  27. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/auth/resolvers/env.py +0 -0
  28. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/auth/resolvers/gh_cli.py +0 -0
  29. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/auth/resolvers/static.py +0 -0
  30. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/contracts/__init__.py +0 -0
  31. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/contracts/config.py +0 -0
  32. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/contracts/exceptions.py +0 -0
  33. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/contracts/item.py +0 -0
  34. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/contracts/plan.py +0 -0
  35. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/contracts/provider.py +0 -0
  36. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/contracts/renderer.py +0 -0
  37. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/engine/__init__.py +0 -0
  38. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/engine/progress.py +0 -0
  39. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/engine/utils.py +0 -0
  40. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/plan/__init__.py +0 -0
  41. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/plan/hasher.py +0 -0
  42. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/plan/loader.py +0 -0
  43. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/plan/validator.py +0 -0
  44. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/progress.py +0 -0
  45. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/AGENTS.md +0 -0
  46. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/__init__.py +0 -0
  47. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/base.py +0 -0
  48. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/dry_run.py +0 -0
  49. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/factory.py +0 -0
  50. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/AGENTS.md +0 -0
  51. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/__init__.py +0 -0
  52. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/_retrying_transport.py +0 -0
  53. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/add_blocked_by.py +0 -0
  54. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/add_labels.py +0 -0
  55. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/add_project_item.py +0 -0
  56. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/add_sub_issue.py +0 -0
  57. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/async_base_client.py +0 -0
  58. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/base_model.py +0 -0
  59. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/close_issue.py +0 -0
  60. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/create_issue.py +0 -0
  61. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/create_label.py +0 -0
  62. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/enums.py +0 -0
  63. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/fetch_org_project.py +0 -0
  64. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/fetch_project_fields.py +0 -0
  65. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/fetch_project_items.py +0 -0
  66. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/fetch_relations.py +0 -0
  67. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/fetch_repo.py +0 -0
  68. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/fetch_user_project.py +0 -0
  69. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/find_labels.py +0 -0
  70. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/fragments.py +0 -0
  71. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/get_issue.py +0 -0
  72. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/remove_blocked_by.py +0 -0
  73. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/remove_labels.py +0 -0
  74. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/remove_sub_issue.py +0 -0
  75. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/search_issues.py +0 -0
  76. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/update_issue.py +0 -0
  77. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/github_gql/update_project_field.py +0 -0
  78. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/mapper.py +0 -0
  79. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/models.py +0 -0
  80. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/add_blocked_by.graphql +0 -0
  81. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/add_labels.graphql +0 -0
  82. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/add_project_item.graphql +0 -0
  83. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/add_sub_issue.graphql +0 -0
  84. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/close_issue.graphql +0 -0
  85. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/create_issue.graphql +0 -0
  86. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/create_label.graphql +0 -0
  87. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/fetch_org_project.graphql +0 -0
  88. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/fetch_project_fields.graphql +0 -0
  89. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/fetch_project_items.graphql +0 -0
  90. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/fetch_relations.graphql +0 -0
  91. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/fetch_repo.graphql +0 -0
  92. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/fetch_user_project.graphql +0 -0
  93. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/find_labels.graphql +0 -0
  94. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/fragments.graphql +0 -0
  95. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/get_issue.graphql +0 -0
  96. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/remove_blocked_by.graphql +0 -0
  97. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/remove_labels.graphql +0 -0
  98. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/remove_sub_issue.graphql +0 -0
  99. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/search_issues.graphql +0 -0
  100. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/update_issue.graphql +0 -0
  101. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/operations/update_project_field.graphql +0 -0
  102. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/providers/github/schema.graphql +0 -0
  103. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/py.typed +0 -0
  104. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/renderers/__init__.py +0 -0
  105. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/renderers/factory.py +0 -0
  106. {planpilot-2.2.0 → planpilot-2.3.0}/src/planpilot/scaffold.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: planpilot
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Sync roadmap plans (epics, stories, tasks) to GitHub Issues and Projects v2
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -221,6 +221,16 @@ planpilot sync --config ./planpilot.json --apply
221
221
  | `--apply` | — | Apply mode |
222
222
  | `--verbose` | off | Enable verbose logging |
223
223
 
224
+ ### `planpilot clean`
225
+
226
+ | Flag | Default | Description |
227
+ |------|---------|-------------|
228
+ | `--config` | `./planpilot.json` | Path to `planpilot.json` |
229
+ | `--dry-run` | — | Preview which issues would be deleted |
230
+ | `--apply` | — | Execute deletions |
231
+ | `--all` | off | Delete all planpilot-managed issues by label, regardless of current plan hash |
232
+ | `--verbose` | off | Enable verbose logging |
233
+
224
234
  ### `planpilot map sync`
225
235
 
226
236
  | Flag | Default | Description |
@@ -187,6 +187,16 @@ planpilot sync --config ./planpilot.json --apply
187
187
  | `--apply` | — | Apply mode |
188
188
  | `--verbose` | off | Enable verbose logging |
189
189
 
190
+ ### `planpilot clean`
191
+
192
+ | Flag | Default | Description |
193
+ |------|---------|-------------|
194
+ | `--config` | `./planpilot.json` | Path to `planpilot.json` |
195
+ | `--dry-run` | — | Preview which issues would be deleted |
196
+ | `--apply` | — | Execute deletions |
197
+ | `--all` | off | Delete all planpilot-managed issues by label, regardless of current plan hash |
198
+ | `--verbose` | off | Enable verbose logging |
199
+
190
200
  ### `planpilot map sync`
191
201
 
192
202
  | Flag | Default | Description |
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "planpilot"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "Sync roadmap plans (epics, stories, tasks) to GitHub Issues and Projects v2"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -69,7 +69,7 @@ test-e2e = "pytest -v tests/e2e/test_cli_e2e.py"
69
69
  coverage = "pytest -v --cov-report=html:.coverage/html"
70
70
  coverage-e2e = "pytest -v tests/e2e/test_cli_e2e.py --cov-report=term-missing --cov-report=xml:.coverage/coverage-e2e.xml"
71
71
  typecheck = "mypy src/planpilot"
72
- check = ["lint", "format-check", "test"]
72
+ check = ["lint", "format-check", "typecheck", "test"]
73
73
 
74
74
  [tool.poe.tasks.gen-schema]
75
75
  help = "Introspect GitHub GraphQL API and download schema"
@@ -1,6 +1,6 @@
1
1
  """Public API surface for PlanPilot."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.3.0"
4
4
 
5
5
  from planpilot.auth import create_token_resolver
6
6
  from planpilot.contracts.config import FieldConfig, PlanPaths, PlanPilotConfig
@@ -16,7 +16,7 @@ from planpilot.contracts.exceptions import (
16
16
  from planpilot.contracts.plan import Plan, PlanItem, PlanItemType
17
17
  from planpilot.contracts.provider import Provider
18
18
  from planpilot.contracts.renderer import BodyRenderer, RenderContext
19
- from planpilot.contracts.sync import MapSyncResult, SyncEntry, SyncMap, SyncResult
19
+ from planpilot.contracts.sync import CleanResult, MapSyncResult, SyncEntry, SyncMap, SyncResult
20
20
  from planpilot.providers import create_provider
21
21
  from planpilot.renderers import create_renderer
22
22
  from planpilot.scaffold import create_plan_stubs, detect_plan_paths, detect_target, scaffold_config, write_config
@@ -25,6 +25,7 @@ from planpilot.sdk import PlanPilot, load_config, load_plan
25
25
  __all__ = [
26
26
  "AuthenticationError",
27
27
  "BodyRenderer",
28
+ "CleanResult",
28
29
  "ConfigError",
29
30
  "FieldConfig",
30
31
  "MapSyncResult",
@@ -13,6 +13,7 @@ import httpx
13
13
 
14
14
  from planpilot import (
15
15
  AuthenticationError,
16
+ CleanResult,
16
17
  ConfigError,
17
18
  MapSyncResult,
18
19
  PlanItemType,
@@ -31,6 +32,7 @@ from planpilot import (
31
32
  write_config,
32
33
  )
33
34
  from planpilot.contracts.exceptions import ProjectURLError
35
+ from planpilot.engine.progress import SyncProgress
34
36
  from planpilot.providers.github.mapper import parse_project_url
35
37
 
36
38
  _REQUIRED_CLASSIC_SCOPES = {"repo", "project"}
@@ -85,20 +87,43 @@ def _check_classic_scopes(*, scopes_header: str | None) -> None:
85
87
  raise AuthenticationError(f"Token is missing required GitHub scopes: {needed}")
86
88
 
87
89
 
88
- def _validate_github_auth_for_init(*, token: str, target: str) -> str | None:
90
+ def _validate_github_auth_for_init(*, token: str, target: str, progress: SyncProgress | None = None) -> str | None:
89
91
  owner, repo = target.split("/", 1)
90
92
  with httpx.Client(timeout=10.0) as client:
93
+ if progress is not None:
94
+ progress.phase_start("Init Auth")
91
95
  user_resp = client.get("https://api.github.com/user", headers=_github_headers(token))
92
96
  if user_resp.status_code != 200:
93
- raise AuthenticationError("GitHub authentication failed; verify your token/gh login and network access")
94
- _check_classic_scopes(scopes_header=user_resp.headers.get("x-oauth-scopes"))
95
-
97
+ auth_error = AuthenticationError(
98
+ "GitHub authentication failed; verify your token/gh login and network access"
99
+ )
100
+ if progress is not None:
101
+ progress.phase_error("Init Auth", auth_error)
102
+ raise auth_error
103
+ try:
104
+ _check_classic_scopes(scopes_header=user_resp.headers.get("x-oauth-scopes"))
105
+ except AuthenticationError as error:
106
+ if progress is not None:
107
+ progress.phase_error("Init Auth", error)
108
+ raise
109
+ if progress is not None:
110
+ progress.phase_done("Init Auth")
111
+
112
+ if progress is not None:
113
+ progress.phase_start("Init Repo")
96
114
  repo_resp = client.get(f"https://api.github.com/repos/{owner}/{repo}", headers=_github_headers(token))
97
115
  if repo_resp.status_code != 200:
98
- raise AuthenticationError(
116
+ repo_error = AuthenticationError(
99
117
  f"Cannot access target repository '{target}'; verify repo scope/permissions and repo visibility"
100
118
  )
101
-
119
+ if progress is not None:
120
+ progress.phase_error("Init Repo", repo_error)
121
+ raise repo_error
122
+ if progress is not None:
123
+ progress.phase_done("Init Repo")
124
+
125
+ if progress is not None:
126
+ progress.phase_start("Init Projects")
102
127
  viewer_projects_query = {"query": "query { viewer { projectsV2(first: 1) { nodes { id } } } }"}
103
128
  projects_resp = client.post(
104
129
  "https://api.github.com/graphql",
@@ -109,19 +134,34 @@ def _validate_github_auth_for_init(*, token: str, target: str) -> str | None:
109
134
  projects_resp.json() if projects_resp.headers.get("content-type", "").startswith("application/json") else {}
110
135
  )
111
136
  if projects_resp.status_code != 200 or payload.get("errors"):
112
- raise AuthenticationError(
137
+ projects_error = AuthenticationError(
113
138
  "Token does not have sufficient project permissions; ensure project access is granted"
114
139
  )
115
-
140
+ if progress is not None:
141
+ progress.phase_error("Init Projects", projects_error)
142
+ raise projects_error
143
+ if progress is not None:
144
+ progress.phase_done("Init Projects")
145
+
146
+ if progress is not None:
147
+ progress.phase_start("Init Owner")
116
148
  owner_resp = client.get(f"https://api.github.com/users/{owner}", headers=_github_headers(token))
117
149
  if owner_resp.status_code != 200:
150
+ if progress is not None:
151
+ progress.phase_done("Init Owner")
118
152
  return None
119
153
  owner_payload = owner_resp.json()
120
154
  owner_type = owner_payload.get("type")
121
155
  if owner_type == "Organization":
156
+ if progress is not None:
157
+ progress.phase_done("Init Owner")
122
158
  return "org"
123
159
  if owner_type == "User":
160
+ if progress is not None:
161
+ progress.phase_done("Init Owner")
124
162
  return "user"
163
+ if progress is not None:
164
+ progress.phase_done("Init Owner")
125
165
  return None
126
166
 
127
167
 
@@ -173,6 +213,19 @@ def build_parser() -> argparse.ArgumentParser:
173
213
  )
174
214
  init_parser.add_argument("--defaults", action="store_true", help="Use defaults without prompting")
175
215
 
216
+ clean_parser = subparsers.add_parser("clean", help="Delete all issues belonging to a plan")
217
+ clean_parser.add_argument("--config", default="./planpilot.json", help="Path to planpilot.json")
218
+ clean_mode = clean_parser.add_mutually_exclusive_group(required=True)
219
+ clean_mode.add_argument("--dry-run", action="store_true", help="Preview mode")
220
+ clean_mode.add_argument("--apply", action="store_true", help="Apply mode")
221
+ clean_parser.add_argument(
222
+ "--all",
223
+ action="store_true",
224
+ default=False,
225
+ help="Delete all planpilot-managed issues (by label), not just the current plan version",
226
+ )
227
+ clean_parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging")
228
+
176
229
  map_parser = subparsers.add_parser("map", help="Sync-map operations")
177
230
  map_subparsers = map_parser.add_subparsers(dest="map_command", required=True)
178
231
 
@@ -210,13 +263,27 @@ async def _run_sync(args: argparse.Namespace) -> SyncResult:
210
263
 
211
264
  async def _run_map_sync(args: argparse.Namespace) -> MapSyncResult:
212
265
  config = load_config(args.config)
213
- pp = await PlanPilot.from_config(config)
214
- candidate_plan_ids = await pp.discover_remote_plan_ids()
215
- selected_plan_id = _resolve_selected_plan_id(
216
- explicit_plan_id=args.plan_id,
217
- candidate_plan_ids=candidate_plan_ids,
218
- )
219
- result = await pp.map_sync(plan_id=selected_plan_id, dry_run=args.dry_run)
266
+ if not args.verbose:
267
+ from planpilot.progress import RichSyncProgress
268
+
269
+ with RichSyncProgress() as progress:
270
+ pp = await PlanPilot.from_config(config, progress=progress)
271
+ candidate_plan_ids = await pp.discover_remote_plan_ids()
272
+ selected_plan_id = _resolve_selected_plan_id(
273
+ explicit_plan_id=args.plan_id,
274
+ candidate_plan_ids=candidate_plan_ids,
275
+ )
276
+ with RichSyncProgress() as progress:
277
+ pp = await PlanPilot.from_config(config, progress=progress)
278
+ result = await pp.map_sync(plan_id=selected_plan_id, dry_run=args.dry_run)
279
+ else:
280
+ pp = await PlanPilot.from_config(config)
281
+ candidate_plan_ids = await pp.discover_remote_plan_ids()
282
+ selected_plan_id = _resolve_selected_plan_id(
283
+ explicit_plan_id=args.plan_id,
284
+ candidate_plan_ids=candidate_plan_ids,
285
+ )
286
+ result = await pp.map_sync(plan_id=selected_plan_id, dry_run=args.dry_run)
220
287
  result = result.model_copy(update={"candidate_plan_ids": candidate_plan_ids})
221
288
  print(_format_map_sync_summary(result, config))
222
289
  return result
@@ -253,15 +320,29 @@ def _format_summary(result: SyncResult, config: PlanPilotConfig) -> str:
253
320
  created_epics = result.items_created.get(PlanItemType.EPIC, 0)
254
321
  created_stories = result.items_created.get(PlanItemType.STORY, 0)
255
322
  created_tasks = result.items_created.get(PlanItemType.TASK, 0)
323
+ total_created = created_epics + created_stories + created_tasks
256
324
 
257
325
  total_by_type = {PlanItemType.EPIC: 0, PlanItemType.STORY: 0, PlanItemType.TASK: 0}
258
326
  for entry in result.sync_map.entries.values():
259
327
  if entry.item_type in total_by_type:
260
328
  total_by_type[entry.item_type] += 1
261
329
 
262
- existing_epics = total_by_type[PlanItemType.EPIC] - created_epics
263
- existing_stories = total_by_type[PlanItemType.STORY] - created_stories
264
- existing_tasks = total_by_type[PlanItemType.TASK] - created_tasks
330
+ total_items = len(result.sync_map.entries)
331
+ total_matched = total_items - total_created
332
+
333
+ matched_epics = total_by_type[PlanItemType.EPIC] - created_epics
334
+ matched_stories = total_by_type[PlanItemType.STORY] - created_stories
335
+ matched_tasks = total_by_type[PlanItemType.TASK] - created_tasks
336
+
337
+ def _type_breakdown(epics: int, stories: int, tasks: int) -> str:
338
+ parts: list[str] = []
339
+ if epics:
340
+ parts.append(f"{epics} epic{'s' if epics != 1 else ''}")
341
+ if stories:
342
+ parts.append(f"{stories} stor{'ies' if stories != 1 else 'y'}")
343
+ if tasks:
344
+ parts.append(f"{tasks} task{'s' if tasks != 1 else ''}")
345
+ return ", ".join(parts) if parts else "none"
265
346
 
266
347
  lines = [
267
348
  "",
@@ -271,25 +352,22 @@ def _format_summary(result: SyncResult, config: PlanPilotConfig) -> str:
271
352
  f" Target: {result.sync_map.target}",
272
353
  f" Board: {result.sync_map.board_url}",
273
354
  "",
274
- f" Created: {created_epics} epic(s), {created_stories} story(s), {created_tasks} task(s)",
355
+ " Items: {} total ({})".format(
356
+ total_items,
357
+ _type_breakdown(
358
+ total_by_type[PlanItemType.EPIC],
359
+ total_by_type[PlanItemType.STORY],
360
+ total_by_type[PlanItemType.TASK],
361
+ ),
362
+ ),
275
363
  ]
276
364
 
277
- if any(count > 0 for count in (existing_epics, existing_stories, existing_tasks)):
278
- lines.append(f" Existing: {existing_epics} epic(s), {existing_stories} story(s), {existing_tasks} task(s)")
279
-
280
- lines.append("")
281
-
282
- label_map = {
283
- PlanItemType.EPIC: "Epic",
284
- PlanItemType.STORY: "Story",
285
- PlanItemType.TASK: "Task",
286
- }
287
- for item_type in (PlanItemType.EPIC, PlanItemType.STORY, PlanItemType.TASK):
288
- for item_id in sorted(result.sync_map.entries):
289
- entry = result.sync_map.entries[item_id]
290
- if entry.item_type != item_type:
291
- continue
292
- lines.append(f" {label_map[item_type]:<5} {item_id:<6} {entry.key:<6} {entry.url}")
365
+ if total_created > 0:
366
+ lines.append(f" Created: {total_created} ({_type_breakdown(created_epics, created_stories, created_tasks)})")
367
+ if total_matched > 0:
368
+ lines.append(f" Matched: {total_matched} ({_type_breakdown(matched_epics, matched_stories, matched_tasks)})")
369
+ if total_created == 0:
370
+ lines.append(" Status: all items up to date")
293
371
 
294
372
  lines.append("")
295
373
  sync_map_path = f"{config.sync_path}.dry-run" if result.dry_run else str(config.sync_path)
@@ -303,6 +381,43 @@ def _format_summary(result: SyncResult, config: PlanPilotConfig) -> str:
303
381
  return "\n".join(lines)
304
382
 
305
383
 
384
+ async def _run_clean(args: argparse.Namespace) -> CleanResult:
385
+ config = load_config(args.config)
386
+ all_plans: bool = getattr(args, "all", False)
387
+
388
+ if not args.verbose:
389
+ from planpilot.progress import RichSyncProgress
390
+
391
+ with RichSyncProgress() as progress:
392
+ pp = await PlanPilot.from_config(config, progress=progress)
393
+ result = await pp.clean(dry_run=args.dry_run, all_plans=all_plans)
394
+ else:
395
+ pp = await PlanPilot.from_config(config)
396
+ result = await pp.clean(dry_run=args.dry_run, all_plans=all_plans)
397
+
398
+ print(_format_clean_summary(result))
399
+ return result
400
+
401
+
402
+ def _format_clean_summary(result: CleanResult) -> str:
403
+ mode = "dry-run" if result.dry_run else "apply"
404
+
405
+ lines = [
406
+ "",
407
+ f"planpilot - clean complete ({mode})",
408
+ "",
409
+ f" Plan ID: {result.plan_id}",
410
+ f" Deleted: {result.items_deleted} issue{'s' if result.items_deleted != 1 else ''}",
411
+ "",
412
+ ]
413
+
414
+ if result.dry_run:
415
+ lines.append(" [dry-run] No issues were deleted")
416
+ lines.append("")
417
+
418
+ return "\n".join(lines)
419
+
420
+
306
421
  def _format_map_sync_summary(result: MapSyncResult, config: PlanPilotConfig) -> str:
307
422
  mode = "dry-run" if result.dry_run else "apply"
308
423
 
@@ -434,8 +549,15 @@ def _run_init_interactive(output: Path) -> int:
434
549
 
435
550
  owner_type: str | None = None
436
551
  if provider == "github":
437
- resolved_token = _resolve_init_token(auth=auth, target=target, static_token=auth_token)
438
- owner_type = _validate_github_auth_for_init(token=resolved_token, target=target)
552
+ if sys.stderr.isatty():
553
+ from planpilot.progress import RichSyncProgress
554
+
555
+ with RichSyncProgress() as progress:
556
+ resolved_token = _resolve_init_token(auth=auth, target=target, static_token=auth_token)
557
+ owner_type = _validate_github_auth_for_init(token=resolved_token, target=target, progress=progress)
558
+ else:
559
+ resolved_token = _resolve_init_token(auth=auth, target=target, static_token=auth_token)
560
+ owner_type = _validate_github_auth_for_init(token=resolved_token, target=target)
439
561
 
440
562
  # --- 4. Board URL ---
441
563
  default_board_url = _default_board_url_with_owner_type(target, owner_type)
@@ -623,7 +745,10 @@ def main(argv: list[str] | None = None) -> int:
623
745
  logging.basicConfig(level=logging.DEBUG, format="%(name)s %(message)s", stream=sys.stderr)
624
746
 
625
747
  try:
626
- asyncio.run(_run_sync(args))
748
+ if args.command == "sync":
749
+ asyncio.run(_run_sync(args))
750
+ elif args.command == "clean":
751
+ asyncio.run(_run_clean(args))
627
752
  return 0
628
753
  except (ConfigError, PlanLoadError, PlanValidationError) as exc:
629
754
  print(f"error: {exc}", file=sys.stderr)
@@ -28,6 +28,12 @@ class SyncResult(BaseModel):
28
28
  dry_run: bool = False
29
29
 
30
30
 
31
+ class CleanResult(BaseModel):
32
+ plan_id: str
33
+ items_deleted: int
34
+ dry_run: bool = False
35
+
36
+
31
37
  class MapSyncResult(BaseModel):
32
38
  sync_map: SyncMap
33
39
  added: list[str] = Field(default_factory=list)
@@ -44,14 +44,19 @@ class SyncEngine:
44
44
 
45
45
  existing_map = await self._discover(plan_id)
46
46
  item_objects: dict[str, Item] = {}
47
+ plan_type_by_id = {item.id: item.type for item in plan.items}
47
48
  for item_id, existing_item in existing_map.items():
48
- sync_map.entries[item_id] = to_sync_entry(existing_item)
49
+ entry = to_sync_entry(existing_item)
50
+ entry.item_type = plan_type_by_id.get(item_id, entry.item_type)
51
+ sync_map.entries[item_id] = entry
49
52
  item_objects[item_id] = existing_item
50
53
 
51
54
  try:
52
- await self._upsert(plan, plan_id, existing_map, sync_map, item_objects, items_created)
53
- await self._enrich(plan, plan_id, sync_map, item_objects)
54
- await self._set_relations(plan, item_objects)
55
+ created_ids: set[str] = set()
56
+ updated_ids: set[str] = set()
57
+ await self._upsert(plan, plan_id, existing_map, sync_map, item_objects, items_created, created_ids)
58
+ await self._enrich(plan, plan_id, sync_map, item_objects, updated_ids)
59
+ await self._set_relations(plan, item_objects, created_ids, updated_ids)
55
60
  except* (SyncError, ProviderError) as error_group:
56
61
  first_error = error_group.exceptions[0]
57
62
  raise first_error from error_group
@@ -88,6 +93,7 @@ class SyncEngine:
88
93
  sync_map: SyncMap,
89
94
  item_objects: dict[str, Item],
90
95
  items_created: dict[PlanItemType, int],
96
+ created_ids: set[str],
91
97
  ) -> None:
92
98
  self._progress.phase_start("Create", total=len(plan.items))
93
99
  try:
@@ -104,6 +110,7 @@ class SyncEngine:
104
110
  sync_map,
105
111
  item_objects,
106
112
  items_created,
113
+ created_ids,
107
114
  )
108
115
  )
109
116
  self._progress.phase_done("Create")
@@ -120,10 +127,13 @@ class SyncEngine:
120
127
  sync_map: SyncMap,
121
128
  item_objects: dict[str, Item],
122
129
  items_created: dict[PlanItemType, int],
130
+ created_ids: set[str],
123
131
  ) -> None:
124
132
  if plan_item.id in existing_map:
125
133
  existing = existing_map[plan_item.id]
126
- sync_map.entries[plan_item.id] = to_sync_entry(existing)
134
+ entry = to_sync_entry(existing)
135
+ entry.item_type = plan_item.type
136
+ sync_map.entries[plan_item.id] = entry
127
137
  item_objects[plan_item.id] = existing
128
138
  self._progress.item_done("Create")
129
139
  return
@@ -146,6 +156,7 @@ class SyncEngine:
146
156
  sync_map.entries[plan_item.id] = to_sync_entry(created_item)
147
157
  item_objects[plan_item.id] = created_item
148
158
  items_created[plan_item.type] += 1
159
+ created_ids.add(plan_item.id)
149
160
  self._progress.item_done("Create")
150
161
 
151
162
  async def _enrich(
@@ -154,12 +165,13 @@ class SyncEngine:
154
165
  plan_id: str,
155
166
  sync_map: SyncMap,
156
167
  item_objects: dict[str, Item],
168
+ updated_ids: set[str] | None = None,
157
169
  ) -> None:
158
170
  self._progress.phase_start("Enrich", total=len(plan.items))
159
171
  try:
160
172
  async with asyncio.TaskGroup() as tg:
161
173
  for plan_item in plan.items:
162
- tg.create_task(self._enrich_item(plan, plan_item, plan_id, sync_map, item_objects))
174
+ tg.create_task(self._enrich_item(plan, plan_item, plan_id, sync_map, item_objects, updated_ids))
163
175
  self._progress.phase_done("Enrich")
164
176
  except BaseException as exc:
165
177
  self._progress.phase_error("Enrich", exc)
@@ -172,6 +184,7 @@ class SyncEngine:
172
184
  plan_id: str,
173
185
  sync_map: SyncMap,
174
186
  item_objects: dict[str, Item],
187
+ updated_ids: set[str] | None = None,
175
188
  ) -> None:
176
189
  entry = sync_map.entries.get(plan_item.id)
177
190
  if entry is None:
@@ -180,19 +193,51 @@ class SyncEngine:
180
193
 
181
194
  context = self._build_context(plan, plan_item, plan_id, sync_map)
182
195
  body = self._renderer.render(plan_item, context)
196
+ desired_labels = [self._config.label]
197
+ desired_size = plan_item.estimate.tshirt if plan_item.estimate is not None else None
198
+
199
+ existing_item = item_objects.get(plan_item.id)
200
+ labels_match = True
201
+ size_match = True
202
+ if existing_item is not None:
203
+ existing_labels = getattr(existing_item, "labels", None)
204
+ existing_size = getattr(existing_item, "size", None)
205
+ if existing_labels is not None:
206
+ labels_match = set(existing_labels) == set(desired_labels)
207
+ if existing_size is not None:
208
+ size_match = existing_size == desired_size
209
+ if (
210
+ existing_item is not None
211
+ and existing_item.title == plan_item.title
212
+ and existing_item.body.strip() == body.strip()
213
+ and existing_item.item_type == plan_item.type
214
+ and labels_match
215
+ and size_match
216
+ ):
217
+ self._progress.item_done("Enrich")
218
+ return
219
+
183
220
  update_input = UpdateItemInput(
184
221
  title=plan_item.title,
185
222
  body=body,
186
223
  item_type=plan_item.type,
187
- labels=[self._config.label],
188
- size=plan_item.estimate.tshirt if plan_item.estimate is not None else None,
224
+ labels=desired_labels,
225
+ size=desired_size,
189
226
  )
190
227
 
191
228
  updated_item = await self._guarded(self._provider.update_item(entry.id, update_input))
192
229
  item_objects[plan_item.id] = updated_item
230
+ if updated_ids is not None:
231
+ updated_ids.add(plan_item.id)
193
232
  self._progress.item_done("Enrich")
194
233
 
195
- async def _set_relations(self, plan: Plan, item_objects: dict[str, Item]) -> None:
234
+ async def _set_relations(
235
+ self,
236
+ plan: Plan,
237
+ item_objects: dict[str, Item],
238
+ created_ids: set[str],
239
+ updated_ids: set[str] | None = None,
240
+ ) -> None:
196
241
  by_id = {item.id: item for item in plan.items}
197
242
  plan_ids = set(by_id)
198
243
  parent_pairs: set[tuple[str, str]] = set()
@@ -249,6 +294,14 @@ class SyncEngine:
249
294
  if child_parent in item_objects and blocker_parent in item_objects and child_parent != blocker_parent:
250
295
  dependency_pairs.add((child_parent, blocker_parent))
251
296
 
297
+ # Skip relation pairs where both sides are untouched in this run.
298
+ # If nothing was touched (e.g., custom renderer omits relation context),
299
+ # keep all pairs so relation-only updates still apply.
300
+ touched_ids = created_ids.union(updated_ids or set())
301
+ if touched_ids:
302
+ parent_pairs = {(c, p) for c, p in parent_pairs if c in touched_ids or p in touched_ids}
303
+ dependency_pairs = {(b, k) for b, k in dependency_pairs if b in touched_ids or k in touched_ids}
304
+
252
305
  total_relations = len(parent_pairs) + len(dependency_pairs)
253
306
  self._progress.phase_start("Relations", total=total_relations)
254
307
  try:
@@ -31,6 +31,7 @@ from .create_label import (
31
31
  CreateLabelCreateLabel,
32
32
  CreateLabelCreateLabelLabel,
33
33
  )
34
+ from .delete_issue import DeleteIssue, DeleteIssueDeleteIssue
34
35
  from .enums import IssueClosedStateReason, ProjectV2FieldType
35
36
  from .exceptions import (
36
37
  GraphQLClientError,
@@ -104,6 +105,7 @@ from .operations import (
104
105
  CLOSE_ISSUE_GQL,
105
106
  CREATE_ISSUE_GQL,
106
107
  CREATE_LABEL_GQL,
108
+ DELETE_ISSUE_GQL,
107
109
  FETCH_ORG_PROJECT_GQL,
108
110
  FETCH_PROJECT_FIELDS_GQL,
109
111
  FETCH_PROJECT_ITEMS_GQL,
@@ -160,23 +162,6 @@ __all__ = [
160
162
  "ADD_LABELS_GQL",
161
163
  "ADD_PROJECT_ITEM_GQL",
162
164
  "ADD_SUB_ISSUE_GQL",
163
- "CLOSE_ISSUE_GQL",
164
- "CREATE_ISSUE_GQL",
165
- "CREATE_LABEL_GQL",
166
- "FETCH_ORG_PROJECT_GQL",
167
- "FETCH_PROJECT_FIELDS_GQL",
168
- "FETCH_PROJECT_ITEMS_GQL",
169
- "FETCH_RELATIONS_GQL",
170
- "FETCH_REPO_GQL",
171
- "FETCH_USER_PROJECT_GQL",
172
- "FIND_LABELS_GQL",
173
- "GET_ISSUE_GQL",
174
- "REMOVE_BLOCKED_BY_GQL",
175
- "REMOVE_LABELS_GQL",
176
- "REMOVE_SUB_ISSUE_GQL",
177
- "SEARCH_ISSUES_GQL",
178
- "UPDATE_ISSUE_GQL",
179
- "UPDATE_PROJECT_FIELD_GQL",
180
165
  "AddBlockedBy",
181
166
  "AddBlockedByAddBlockedBy",
182
167
  "AddBlockedByAddBlockedByIssue",
@@ -191,6 +176,9 @@ __all__ = [
191
176
  "AddSubIssueAddSubIssueSubIssue",
192
177
  "AsyncBaseClient",
193
178
  "BaseModel",
179
+ "CLOSE_ISSUE_GQL",
180
+ "CREATE_ISSUE_GQL",
181
+ "CREATE_LABEL_GQL",
194
182
  "CloseIssue",
195
183
  "CloseIssueCloseIssue",
196
184
  "CloseIssueCloseIssueIssue",
@@ -200,6 +188,16 @@ __all__ = [
200
188
  "CreateLabel",
201
189
  "CreateLabelCreateLabel",
202
190
  "CreateLabelCreateLabelLabel",
191
+ "DELETE_ISSUE_GQL",
192
+ "DeleteIssue",
193
+ "DeleteIssueDeleteIssue",
194
+ "FETCH_ORG_PROJECT_GQL",
195
+ "FETCH_PROJECT_FIELDS_GQL",
196
+ "FETCH_PROJECT_ITEMS_GQL",
197
+ "FETCH_RELATIONS_GQL",
198
+ "FETCH_REPO_GQL",
199
+ "FETCH_USER_PROJECT_GQL",
200
+ "FIND_LABELS_GQL",
203
201
  "FetchOrgProject",
204
202
  "FetchOrgProjectOrganization",
205
203
  "FetchOrgProjectOrganizationProjectV2",
@@ -241,6 +239,7 @@ __all__ = [
241
239
  "FindLabelsRepository",
242
240
  "FindLabelsRepositoryLabels",
243
241
  "FindLabelsRepositoryLabelsNodes",
242
+ "GET_ISSUE_GQL",
244
243
  "GetIssue",
245
244
  "GetIssueNodeIssue",
246
245
  "GetIssueNodeNode",
@@ -255,6 +254,9 @@ __all__ = [
255
254
  "IssueCoreLabels",
256
255
  "IssueCoreLabelsNodes",
257
256
  "ProjectV2FieldType",
257
+ "REMOVE_BLOCKED_BY_GQL",
258
+ "REMOVE_LABELS_GQL",
259
+ "REMOVE_SUB_ISSUE_GQL",
258
260
  "RemoveBlockedBy",
259
261
  "RemoveBlockedByRemoveBlockedBy",
260
262
  "RemoveBlockedByRemoveBlockedByIssue",
@@ -264,6 +266,7 @@ __all__ = [
264
266
  "RemoveSubIssueRemoveSubIssue",
265
267
  "RemoveSubIssueRemoveSubIssueIssue",
266
268
  "RemoveSubIssueRemoveSubIssueSubIssue",
269
+ "SEARCH_ISSUES_GQL",
267
270
  "SearchIssues",
268
271
  "SearchIssuesSearch",
269
272
  "SearchIssuesSearchNodesApp",
@@ -275,6 +278,8 @@ __all__ = [
275
278
  "SearchIssuesSearchNodesRepository",
276
279
  "SearchIssuesSearchNodesUser",
277
280
  "SearchIssuesSearchPageInfo",
281
+ "UPDATE_ISSUE_GQL",
282
+ "UPDATE_PROJECT_FIELD_GQL",
278
283
  "UpdateIssue",
279
284
  "UpdateIssueUpdateIssue",
280
285
  "UpdateIssueUpdateIssueIssue",