methodology-framework 0.1.0__tar.gz → 0.1.2__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 (31) hide show
  1. {methodology_framework-0.1.0/src/methodology_framework.egg-info → methodology_framework-0.1.2}/PKG-INFO +81 -2
  2. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/README.md +79 -1
  3. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/pyproject.toml +2 -1
  4. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/bootstrap_jira.py +214 -23
  5. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/custom_fields.yaml +4 -3
  6. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/workflow.yaml +8 -0
  7. {methodology_framework-0.1.0 → methodology_framework-0.1.2/src/methodology_framework.egg-info}/PKG-INFO +81 -2
  8. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/requires.txt +1 -0
  9. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_bootstrap_jira.py +105 -1
  10. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/LICENSE +0 -0
  11. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/setup.cfg +0 -0
  12. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/__init__.py +0 -0
  13. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/__main__.py +0 -0
  14. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/build_playbook.py +0 -0
  15. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/__init__.py +0 -0
  16. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/automation_rules.yaml +0 -0
  17. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/playbooks/scrum-router.body.md +0 -0
  18. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/populate_acus.py +0 -0
  19. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/register_playbook_with_devin.py +0 -0
  20. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/specs/devin-story-format.md +0 -0
  21. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/sync_stories_to_jira.py +0 -0
  22. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/templates/github_workflows/populate-story-acus-caller.yml +0 -0
  23. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/templates/story.md +0 -0
  24. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/SOURCES.txt +0 -0
  25. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/dependency_links.txt +0 -0
  26. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/top_level.txt +0 -0
  27. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_build_playbook.py +0 -0
  28. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_populate_acus.py +0 -0
  29. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_register_playbook_with_devin.py +0 -0
  30. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_sync_stories_to_jira.py +0 -0
  31. {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodology-framework
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Portable process tooling for agent-driven delivery — scripts, playbooks, templates, and specs.
5
5
  Author: whiteout59
6
6
  License-Expression: Apache-2.0
@@ -17,6 +17,7 @@ Requires-Dist: ruff==0.15.14; extra == "dev"
17
17
  Requires-Dist: mypy<2,>=1.10; extra == "dev"
18
18
  Requires-Dist: types-requests<3,>=2.31; extra == "dev"
19
19
  Requires-Dist: types-PyYAML<7,>=6.0; extra == "dev"
20
+ Requires-Dist: vcrpy<9,>=7.0; extra == "dev"
20
21
  Dynamic: license-file
21
22
 
22
23
  # methodology-framework
@@ -50,7 +51,8 @@ instead of manually configuring 30+ admin UI screens.
50
51
  - Python 3.12+
51
52
  - `pip install methodology-framework` (or `pip install -e .` from source)
52
53
  - For `--apply` mode: `JIRA_ADMIN_TOKEN` environment variable set to a Jira
53
- admin API token or OAuth Bearer token
54
+ admin API token, and `JIRA_USER_EMAIL` set to the email of the Atlassian
55
+ account that owns that token
54
56
 
55
57
  ### Usage
56
58
 
@@ -69,6 +71,7 @@ python -m methodology_framework bootstrap-jira \
69
71
 
70
72
  # Apply — provision the Jira project via admin API
71
73
  export JIRA_ADMIN_TOKEN="<your-token>"
74
+ export JIRA_USER_EMAIL="<your-email>"
72
75
  python -m methodology_framework bootstrap-jira \
73
76
  --project-key=MYPROJ \
74
77
  --jira-host=myorg.atlassian.net \
@@ -97,6 +100,7 @@ python -m methodology_framework bootstrap-jira \
97
100
  | Variable | When needed | Description |
98
101
  |----------|-------------|-------------|
99
102
  | `JIRA_ADMIN_TOKEN` | `--apply` mode | Jira admin API token. **Never** accepted as a CLI flag. |
103
+ | `JIRA_USER_EMAIL` | `--apply` mode | Email of the Atlassian account that owns the API token. Used with the token for HTTP Basic auth. **Never** accepted as a CLI flag. |
100
104
 
101
105
  ### Shape definitions
102
106
 
@@ -225,6 +229,45 @@ to the GitHub repo `whiteout59/methodology-framework`, workflow
225
229
  `pypi-release.yml`, environment `pypi` at
226
230
  [PyPI Trusted Publishers](https://pypi.org/manage/account/publishing/).
227
231
 
232
+ ## Release lifecycle
233
+
234
+ Version bumps in `pyproject.toml` drive the entire release cycle
235
+ automatically. The operator's only decision surface is PR review.
236
+
237
+ ### Normal flow
238
+
239
+ 1. A PR bumps the `version` field in `pyproject.toml` (e.g. `"0.1.1"` → `"0.1.2"`).
240
+ 2. Reviewer approves and merges the PR to `main`.
241
+ 3. The `auto-tag.yml` workflow detects the `pyproject.toml` change, extracts the
242
+ version, validates it as stable SemVer (`^[0-9]+\.[0-9]+\.[0-9]+$`), and
243
+ creates an annotated tag `v0.1.2`.
244
+ 4. The `pypi-release.yml` workflow fires on the new tag and publishes the
245
+ package to PyPI via OIDC Trusted Publishers.
246
+
247
+ **Operator surface = PR review only.** No manual `git tag` step required.
248
+
249
+ ### Manual fallback
250
+
251
+ If `auto-tag.yml` doesn't fire (workflow disabled, branch protection
252
+ blocking the tag push, GitHub Actions outage, etc.), the manual fallback
253
+ still works:
254
+
255
+ ```bash
256
+ git tag -a v0.1.2 -m "Release v0.1.2"
257
+ git push origin v0.1.2
258
+ ```
259
+
260
+ `pypi-release.yml` doesn't care how the tag arrived — it fires on any
261
+ tag matching `v[0-9]+.[0-9]+.[0-9]+`.
262
+
263
+ ### Pre-release versions
264
+
265
+ The `auto-tag.yml` workflow only tags **stable** SemVer versions. Versions
266
+ containing `-rc`, `-beta`, `-alpha`, `.dev`, or `.pre` suffixes (or any
267
+ string not matching `^[0-9]+\.[0-9]+\.[0-9]+$`) are logged and skipped.
268
+ Pre-release publishing is out of scope; if needed, a separate workflow
269
+ would handle pre-release tag patterns.
270
+
228
271
  ## Cost tracking via `agent_acus`
229
272
 
230
273
  The methodology's post-execution notes include an `agent_acus` field that
@@ -259,6 +302,42 @@ ruff format --check src/methodology_framework
259
302
  mypy --strict src/methodology_framework
260
303
  ```
261
304
 
305
+ ### Shape-def expansion (v0.1.2+)
306
+
307
+ `workflow.yaml` now includes a `statusCategory` field on each status
308
+ (`TODO`, `IN_PROGRESS`, or `DONE`). The `--apply` handler validates
309
+ this field at load time and exits with a clear error if any status is
310
+ missing it.
311
+
312
+ `custom_fields.yaml` uses canonical shorthand types (`text`, `string`,
313
+ `multi-value`, `numeric`) that are mapped to fully-qualified Jira
314
+ identifiers at apply time. Fields may also specify a fully-qualified
315
+ type directly (any value containing `:` is passed through unchanged).
316
+
317
+ ### Recording new VCR cassettes
318
+
319
+ Integration tests under `tests/integration/` replay pre-recorded VCR
320
+ cassettes in CI (no network access required). To record fresh cassettes
321
+ against a real Jira instance:
322
+
323
+ ```bash
324
+ export JIRA_USER_EMAIL="<your-email>"
325
+ export JIRA_ADMIN_TOKEN="<your-api-token>"
326
+ PYTEST_VCR_MODE=record pytest tests/integration/ -v
327
+ ```
328
+
329
+ Cassettes are stored at `tests/fixtures/vcr/*.yaml`. The VCR config in
330
+ `tests/integration/conftest.py` automatically strips `Authorization`
331
+ headers from recorded interactions. **Never commit a cassette with a
332
+ real token in any header.**
333
+
334
+ After recording, verify no credentials leaked:
335
+
336
+ ```bash
337
+ grep -i 'authorization' tests/fixtures/vcr/*.yaml
338
+ # Expected: no output
339
+ ```
340
+
262
341
  ## License
263
342
 
264
343
  Apache-2.0
@@ -29,7 +29,8 @@ instead of manually configuring 30+ admin UI screens.
29
29
  - Python 3.12+
30
30
  - `pip install methodology-framework` (or `pip install -e .` from source)
31
31
  - For `--apply` mode: `JIRA_ADMIN_TOKEN` environment variable set to a Jira
32
- admin API token or OAuth Bearer token
32
+ admin API token, and `JIRA_USER_EMAIL` set to the email of the Atlassian
33
+ account that owns that token
33
34
 
34
35
  ### Usage
35
36
 
@@ -48,6 +49,7 @@ python -m methodology_framework bootstrap-jira \
48
49
 
49
50
  # Apply — provision the Jira project via admin API
50
51
  export JIRA_ADMIN_TOKEN="<your-token>"
52
+ export JIRA_USER_EMAIL="<your-email>"
51
53
  python -m methodology_framework bootstrap-jira \
52
54
  --project-key=MYPROJ \
53
55
  --jira-host=myorg.atlassian.net \
@@ -76,6 +78,7 @@ python -m methodology_framework bootstrap-jira \
76
78
  | Variable | When needed | Description |
77
79
  |----------|-------------|-------------|
78
80
  | `JIRA_ADMIN_TOKEN` | `--apply` mode | Jira admin API token. **Never** accepted as a CLI flag. |
81
+ | `JIRA_USER_EMAIL` | `--apply` mode | Email of the Atlassian account that owns the API token. Used with the token for HTTP Basic auth. **Never** accepted as a CLI flag. |
79
82
 
80
83
  ### Shape definitions
81
84
 
@@ -204,6 +207,45 @@ to the GitHub repo `whiteout59/methodology-framework`, workflow
204
207
  `pypi-release.yml`, environment `pypi` at
205
208
  [PyPI Trusted Publishers](https://pypi.org/manage/account/publishing/).
206
209
 
210
+ ## Release lifecycle
211
+
212
+ Version bumps in `pyproject.toml` drive the entire release cycle
213
+ automatically. The operator's only decision surface is PR review.
214
+
215
+ ### Normal flow
216
+
217
+ 1. A PR bumps the `version` field in `pyproject.toml` (e.g. `"0.1.1"` → `"0.1.2"`).
218
+ 2. Reviewer approves and merges the PR to `main`.
219
+ 3. The `auto-tag.yml` workflow detects the `pyproject.toml` change, extracts the
220
+ version, validates it as stable SemVer (`^[0-9]+\.[0-9]+\.[0-9]+$`), and
221
+ creates an annotated tag `v0.1.2`.
222
+ 4. The `pypi-release.yml` workflow fires on the new tag and publishes the
223
+ package to PyPI via OIDC Trusted Publishers.
224
+
225
+ **Operator surface = PR review only.** No manual `git tag` step required.
226
+
227
+ ### Manual fallback
228
+
229
+ If `auto-tag.yml` doesn't fire (workflow disabled, branch protection
230
+ blocking the tag push, GitHub Actions outage, etc.), the manual fallback
231
+ still works:
232
+
233
+ ```bash
234
+ git tag -a v0.1.2 -m "Release v0.1.2"
235
+ git push origin v0.1.2
236
+ ```
237
+
238
+ `pypi-release.yml` doesn't care how the tag arrived — it fires on any
239
+ tag matching `v[0-9]+.[0-9]+.[0-9]+`.
240
+
241
+ ### Pre-release versions
242
+
243
+ The `auto-tag.yml` workflow only tags **stable** SemVer versions. Versions
244
+ containing `-rc`, `-beta`, `-alpha`, `.dev`, or `.pre` suffixes (or any
245
+ string not matching `^[0-9]+\.[0-9]+\.[0-9]+$`) are logged and skipped.
246
+ Pre-release publishing is out of scope; if needed, a separate workflow
247
+ would handle pre-release tag patterns.
248
+
207
249
  ## Cost tracking via `agent_acus`
208
250
 
209
251
  The methodology's post-execution notes include an `agent_acus` field that
@@ -238,6 +280,42 @@ ruff format --check src/methodology_framework
238
280
  mypy --strict src/methodology_framework
239
281
  ```
240
282
 
283
+ ### Shape-def expansion (v0.1.2+)
284
+
285
+ `workflow.yaml` now includes a `statusCategory` field on each status
286
+ (`TODO`, `IN_PROGRESS`, or `DONE`). The `--apply` handler validates
287
+ this field at load time and exits with a clear error if any status is
288
+ missing it.
289
+
290
+ `custom_fields.yaml` uses canonical shorthand types (`text`, `string`,
291
+ `multi-value`, `numeric`) that are mapped to fully-qualified Jira
292
+ identifiers at apply time. Fields may also specify a fully-qualified
293
+ type directly (any value containing `:` is passed through unchanged).
294
+
295
+ ### Recording new VCR cassettes
296
+
297
+ Integration tests under `tests/integration/` replay pre-recorded VCR
298
+ cassettes in CI (no network access required). To record fresh cassettes
299
+ against a real Jira instance:
300
+
301
+ ```bash
302
+ export JIRA_USER_EMAIL="<your-email>"
303
+ export JIRA_ADMIN_TOKEN="<your-api-token>"
304
+ PYTEST_VCR_MODE=record pytest tests/integration/ -v
305
+ ```
306
+
307
+ Cassettes are stored at `tests/fixtures/vcr/*.yaml`. The VCR config in
308
+ `tests/integration/conftest.py` automatically strips `Authorization`
309
+ headers from recorded interactions. **Never commit a cassette with a
310
+ real token in any header.**
311
+
312
+ After recording, verify no credentials leaked:
313
+
314
+ ```bash
315
+ grep -i 'authorization' tests/fixtures/vcr/*.yaml
316
+ # Expected: no output
317
+ ```
318
+
241
319
  ## License
242
320
 
243
321
  Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "methodology-framework"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Portable process tooling for agent-driven delivery — scripts, playbooks, templates, and specs."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -26,6 +26,7 @@ dev = [
26
26
  "mypy>=1.10,<2",
27
27
  "types-requests>=2.31,<3",
28
28
  "types-PyYAML>=6.0,<7",
29
+ "vcrpy>=7.0,<9",
29
30
  ]
30
31
 
31
32
  [tool.setuptools.packages.find]
@@ -12,17 +12,71 @@ from __future__ import annotations
12
12
 
13
13
  import argparse
14
14
  import importlib.resources
15
+ import json
15
16
  import logging
16
17
  import os
17
18
  import re
18
19
  import sys
20
+ from pathlib import Path
19
21
  from typing import Any
20
22
 
21
23
  import requests
22
24
  import yaml
25
+ from requests.auth import HTTPBasicAuth
23
26
 
24
27
  logger = logging.getLogger(__name__)
25
28
 
29
+ # ---------------------------------------------------------------------------
30
+ # Custom-field type mapping: shorthand → Jira fully-qualified identifiers
31
+ # ---------------------------------------------------------------------------
32
+
33
+ _VALID_STATUS_CATEGORIES = frozenset({"TODO", "IN_PROGRESS", "DONE"})
34
+
35
+ _FIELD_TYPE_MAP: dict[str, tuple[str, str]] = {
36
+ "text": (
37
+ "com.atlassian.jira.plugin.system.customfieldtypes:textfield",
38
+ "com.atlassian.jira.plugin.system.customfieldtypes:textsearcher",
39
+ ),
40
+ "string": (
41
+ "com.atlassian.jira.plugin.system.customfieldtypes:textfield",
42
+ "com.atlassian.jira.plugin.system.customfieldtypes:textsearcher",
43
+ ),
44
+ "multi-value": (
45
+ "com.atlassian.jira.plugin.system.customfieldtypes:labels",
46
+ "com.atlassian.jira.plugin.system.customfieldtypes:labelsearcher",
47
+ ),
48
+ "numeric": (
49
+ "com.atlassian.jira.plugin.system.customfieldtypes:float",
50
+ "com.atlassian.jira.plugin.system.customfieldtypes:exactnumber",
51
+ ),
52
+ }
53
+
54
+
55
+ def resolve_field_type(raw_type: str | None) -> tuple[str, str | None]:
56
+ """Map a shorthand type to (FQ type, FQ searcherKey).
57
+
58
+ Returns (fq_type, searcher_key). If *raw_type* is already a FQ
59
+ identifier (contains ``:``) or is ``None``, it is passed through unchanged
60
+ with ``searcher_key=None``.
61
+
62
+ Raises ``SystemExit`` for unrecognized shorthands.
63
+ """
64
+ if raw_type is None:
65
+ return ("", None)
66
+ if ":" in raw_type:
67
+ return (raw_type, None)
68
+ if raw_type in _FIELD_TYPE_MAP:
69
+ return _FIELD_TYPE_MAP[raw_type]
70
+ supported = ", ".join(sorted(_FIELD_TYPE_MAP.keys()))
71
+ print(
72
+ f"ERROR: Unrecognized custom-field type '{raw_type}'. "
73
+ f"Supported shorthand types: {supported}. "
74
+ "Use a fully-qualified Jira type identifier (containing ':') for other types.",
75
+ file=sys.stderr,
76
+ )
77
+ raise SystemExit(1)
78
+
79
+
26
80
  # ---------------------------------------------------------------------------
27
81
  # Shape-def loading
28
82
  # ---------------------------------------------------------------------------
@@ -84,22 +138,56 @@ def substitute_project_key(
84
138
  class JiraAdminClient:
85
139
  """Minimal Jira Cloud admin client for bootstrap operations."""
86
140
 
87
- def __init__(self, jira_host: str, token: str) -> None:
141
+ def __init__(self, jira_host: str, token: str, email: str) -> None:
88
142
  self._base = f"https://{jira_host}/rest/api/3"
89
143
  self._session = requests.Session()
144
+ # Jira Cloud user API tokens require HTTP Basic auth: base64(email:token)
145
+ self._session.auth = HTTPBasicAuth(email, token)
90
146
  self._session.headers.update(
91
147
  {
92
- "Authorization": f"Bearer {token}",
93
148
  "Content-Type": "application/json",
94
149
  "Accept": "application/json",
95
150
  }
96
151
  )
152
+ self._project_id_cache: dict[str, str] = {}
153
+
154
+ # -- project ID resolution -----------------------------------------------
155
+
156
+ def _resolve_project_id(self, project_key: str) -> str:
157
+ """GET /rest/api/3/project/{key} and return the numeric project ID.
158
+
159
+ Result is cached on the instance for the duration of the session.
160
+ On 404, prints a clear error and exits with code 1.
161
+ """
162
+ cached = self._project_id_cache.get(project_key)
163
+ if cached is not None:
164
+ return cached
165
+ resp = self._session.get(f"{self._base}/project/{project_key}")
166
+ if resp.status_code == 404:
167
+ print(
168
+ f"ERROR: Project '{project_key}' not found at "
169
+ f"{self._base.split('/rest')[0]}. "
170
+ "Confirm project key and JIRA_USER_EMAIL has admin scope.",
171
+ file=sys.stderr,
172
+ )
173
+ raise SystemExit(1)
174
+ resp.raise_for_status()
175
+ project_id: str = str(resp.json()["id"])
176
+ self._project_id_cache[project_key] = project_id
177
+ return project_id
97
178
 
98
179
  # -- connectivity check --------------------------------------------------
99
180
 
100
181
  def check_connectivity(self) -> None:
101
182
  """Validate reachability via GET /rest/api/3/myself."""
102
183
  resp = self._session.get(f"{self._base}/myself")
184
+ if resp.status_code in (401, 403):
185
+ raise requests.HTTPError(
186
+ f"{resp.status_code} {resp.reason} for url: {resp.url} "
187
+ "(check that JIRA_USER_EMAIL is the email that owns "
188
+ "JIRA_ADMIN_TOKEN and that the account has Jira admin permissions)",
189
+ response=resp,
190
+ )
103
191
  resp.raise_for_status()
104
192
  logger.info("Connected as %s", resp.json().get("displayName", "unknown"))
105
193
 
@@ -118,7 +206,13 @@ class JiraAdminClient:
118
206
  return statuses
119
207
 
120
208
  def create_status(self, spec: dict[str, Any]) -> None:
121
- """Create a workflow status from *spec*."""
209
+ """Create workflow statuses from *spec*.
210
+
211
+ *spec* must be the full envelope shape::
212
+
213
+ {"scope": {"type": "PROJECT", "project": {"id": "<numeric>"}},
214
+ "statuses": [{"name": ..., "statusCategory": ..., "description": ...}]}
215
+ """
122
216
  resp = self._session.post(f"{self._base}/statuses", json=spec)
123
217
  resp.raise_for_status()
124
218
 
@@ -132,7 +226,11 @@ class JiraAdminClient:
132
226
  return result
133
227
 
134
228
  def create_custom_field(self, spec: dict[str, Any]) -> dict[str, Any]:
135
- """Create a custom field from *spec*."""
229
+ """Create a custom field from *spec*.
230
+
231
+ *spec* must contain ``name``, ``description``, ``type`` (FQ Jira type
232
+ identifier), and optionally ``searcherKey`` (FQ searcher identifier).
233
+ """
136
234
  resp = self._session.post(f"{self._base}/field", json=spec)
137
235
  resp.raise_for_status()
138
236
  result: dict[str, Any] = resp.json()
@@ -154,11 +252,25 @@ class JiraAdminClient:
154
252
  return result
155
253
 
156
254
  def create_automation_rule(self, project_key: str, spec: dict[str, Any]) -> dict[str, Any]:
157
- """Create an automation rule from *spec*."""
255
+ """Attempt to create an automation rule from *spec*.
256
+
257
+ Jira Cloud's automation API is not publicly available on Free/Standard
258
+ tiers and requires scopes beyond the standard OAuth grants. If the
259
+ endpoint returns 401/403/404, returns an empty dict and logs a warning
260
+ so the caller can fall back to the graceful-degradation path.
261
+ """
158
262
  resp = self._session.post(
159
263
  f"{self._base}/project/{project_key}/automation/rule",
160
264
  json=spec,
161
265
  )
266
+ if resp.status_code in (401, 403, 404):
267
+ logger.warning(
268
+ "Automation rule API returned %d — rule '%s' cannot be created "
269
+ "via API. Use the graceful-degradation output instead.",
270
+ resp.status_code,
271
+ spec.get("name", "?"),
272
+ )
273
+ return {}
162
274
  resp.raise_for_status()
163
275
  result: dict[str, Any] = resp.json()
164
276
  return result
@@ -240,10 +352,11 @@ def _handle_apply(
240
352
  project_key: str,
241
353
  jira_host: str,
242
354
  token: str,
355
+ email: str,
243
356
  force: bool = False,
244
357
  ) -> int:
245
358
  """Apply the shape definitions to a Jira project via the admin API."""
246
- client = JiraAdminClient(jira_host, token)
359
+ client = JiraAdminClient(jira_host, token, email)
247
360
 
248
361
  logger.info("Validating connectivity to %s ...", jira_host)
249
362
  try:
@@ -252,12 +365,38 @@ def _handle_apply(
252
365
  logger.error("Connectivity check failed: %s", exc)
253
366
  return 1
254
367
 
368
+ # -- resolve project ID --------------------------------------------------
369
+ logger.info("Resolving project ID for '%s' ...", project_key)
370
+ project_id = client._resolve_project_id(project_key)
371
+ logger.info(" Project ID: %s", project_id)
372
+
255
373
  # -- statuses -----------------------------------------------------------
256
374
  logger.info("Checking workflow statuses ...")
257
375
  existing_statuses = client.get_statuses(project_key)
258
376
  existing_names = {s.get("name", "").lower() for s in existing_statuses}
259
377
  desired = shapes.get("workflow", {}).get("statuses", [])
260
- statuses_created = 0
378
+
379
+ # Validate statusCategory on every status in the shape def
380
+ for status_spec in desired:
381
+ cat = status_spec.get("statusCategory")
382
+ if cat is None:
383
+ print(
384
+ f"ERROR: Status '{status_spec['name']}' in workflow.yaml is missing "
385
+ f"'statusCategory'. Add one of: {', '.join(sorted(_VALID_STATUS_CATEGORIES))}. "
386
+ "This field is required since v0.1.2.",
387
+ file=sys.stderr,
388
+ )
389
+ return 1
390
+ if cat not in _VALID_STATUS_CATEGORIES:
391
+ print(
392
+ f"ERROR: Status '{status_spec['name']}' has statusCategory '{cat}'. "
393
+ f"Valid values: {', '.join(sorted(_VALID_STATUS_CATEGORIES))}.",
394
+ file=sys.stderr,
395
+ )
396
+ return 1
397
+
398
+ # Collect statuses that need creation
399
+ to_create: list[dict[str, Any]] = []
261
400
  for status_spec in desired:
262
401
  if status_spec["name"].lower() in existing_names:
263
402
  logger.info(" Status '%s' already exists — skipping.", status_spec["name"])
@@ -267,11 +406,24 @@ def _handle_apply(
267
406
  if answer != "y":
268
407
  logger.info(" Skipped '%s'.", status_spec["name"])
269
408
  continue
270
- logger.info(" Creating status '%s' ...", status_spec["name"])
271
- client.create_status(
272
- {"name": status_spec["name"], "description": status_spec.get("description", "")}
409
+ to_create.append(
410
+ {
411
+ "name": status_spec["name"],
412
+ "statusCategory": status_spec["statusCategory"],
413
+ "description": status_spec.get("description", ""),
414
+ }
273
415
  )
274
- statuses_created += 1
416
+
417
+ statuses_created = 0
418
+ if to_create:
419
+ # Batch all statuses into one POST per the Jira /statuses API
420
+ envelope: dict[str, Any] = {
421
+ "scope": {"type": "PROJECT", "project": {"id": project_id}},
422
+ "statuses": to_create,
423
+ }
424
+ logger.info(" Creating %d status(es) in one batch ...", len(to_create))
425
+ client.create_status(envelope)
426
+ statuses_created = len(to_create)
275
427
 
276
428
  # -- custom fields ------------------------------------------------------
277
429
  logger.info("Checking custom fields ...")
@@ -290,14 +442,19 @@ def _handle_apply(
290
442
  if answer != "y":
291
443
  logger.info(" Skipped '%s'.", field_spec["name"])
292
444
  continue
293
- logger.info(" Creating field '%s' ...", field_spec["name"])
294
- client.create_custom_field(
295
- {
296
- "name": field_spec["name"],
297
- "type": field_spec.get("type", "string"),
298
- "description": field_spec.get("description", ""),
299
- }
300
- )
445
+ raw_type = field_spec.get("type")
446
+ fq_type, searcher_key = resolve_field_type(raw_type)
447
+ # If the shape def already specifies a searcherKey, prefer it
448
+ searcher_key = field_spec.get("searcherKey", searcher_key)
449
+ payload: dict[str, str] = {
450
+ "name": field_spec["name"],
451
+ "type": fq_type,
452
+ "description": field_spec.get("description", ""),
453
+ }
454
+ if searcher_key is not None:
455
+ payload["searcherKey"] = searcher_key
456
+ logger.info(" Creating field '%s' (type: %s) ...", field_spec["name"], fq_type)
457
+ client.create_custom_field(payload)
301
458
  fields_created += 1
302
459
 
303
460
  # -- automation rules ---------------------------------------------------
@@ -306,6 +463,7 @@ def _handle_apply(
306
463
  existing_rule_names = {r.get("name", "").lower() for r in existing_rules}
307
464
  desired_rules = shapes.get("automation_rules", {}).get("automation_rules", [])
308
465
  rules_created = 0
466
+ rules_degraded: list[dict[str, Any]] = []
309
467
  for rule_spec in desired_rules:
310
468
  if rule_spec["name"].lower() in existing_rule_names:
311
469
  logger.info(" Rule '%s' already exists — skipping.", rule_spec["name"])
@@ -320,8 +478,30 @@ def _handle_apply(
320
478
  logger.info(" Skipped '%s'.", rule_spec["name"])
321
479
  continue
322
480
  logger.info(" Creating rule '%s' ...", rule_spec["name"])
323
- client.create_automation_rule(project_key, rule_spec)
324
- rules_created += 1
481
+ result = client.create_automation_rule(project_key, rule_spec)
482
+ if result:
483
+ rules_created += 1
484
+ else:
485
+ rules_degraded.append(rule_spec)
486
+
487
+ # Graceful degradation: emit rules that couldn't be created via API
488
+ if rules_degraded:
489
+ degradation_path = Path.cwd() / "jira-automation-rules-to-create-manually.json"
490
+ degradation_path.write_text(
491
+ json.dumps(rules_degraded, indent=2, ensure_ascii=False),
492
+ encoding="utf-8",
493
+ )
494
+ logger.warning(
495
+ "NOTE: %d automation rule(s) could not be created via API "
496
+ "(Jira Cloud automation API not available at this tier). "
497
+ "Rule definitions written to %s for manual creation via the Jira UI.",
498
+ len(rules_degraded),
499
+ degradation_path,
500
+ )
501
+ print("\n--- Automation rules requiring manual creation ---")
502
+ for rule in rules_degraded:
503
+ print(f" • {rule['name']}: {rule.get('description', '')[:120]}")
504
+ print(f"\nFull definitions: {degradation_path}\n")
325
505
 
326
506
  logger.info(
327
507
  "Apply complete: %d statuses, %d fields, %d rules created.",
@@ -329,7 +509,9 @@ def _handle_apply(
329
509
  fields_created,
330
510
  rules_created,
331
511
  )
332
- if statuses_created == 0 and fields_created == 0 and rules_created == 0:
512
+ if rules_degraded:
513
+ logger.info("%d rule(s) written to file for manual creation.", len(rules_degraded))
514
+ if statuses_created == 0 and fields_created == 0 and rules_created == 0 and not rules_degraded:
333
515
  logger.info("Already up to date.")
334
516
  return 0
335
517
 
@@ -407,7 +589,15 @@ def main(argv: list[str] | None = None) -> int:
407
589
  if not token:
408
590
  logger.error(
409
591
  "JIRA_ADMIN_TOKEN env var required for --apply mode. "
410
- "Set it to a Jira admin API token or OAuth Bearer token."
592
+ "Set it to a Jira admin API token."
593
+ )
594
+ return 1
595
+ email = os.environ.get("JIRA_USER_EMAIL", "")
596
+ if not email:
597
+ print(
598
+ "ERROR: JIRA_USER_EMAIL env var is required for --apply mode. "
599
+ "Set it to the email of the Atlassian account that owns the API token.",
600
+ file=sys.stderr,
411
601
  )
412
602
  return 1
413
603
  return _handle_apply(
@@ -415,6 +605,7 @@ def main(argv: list[str] | None = None) -> int:
415
605
  project_key=args.project_key,
416
606
  jira_host=args.jira_host,
417
607
  token=token,
608
+ email=email,
418
609
  force=args.force,
419
610
  )
420
611
  elif args.export is not None:
@@ -11,7 +11,8 @@ schema_version: "1.0"
11
11
  custom_fields:
12
12
  - name: "Story File"
13
13
  key: "story_file"
14
- type: "url"
14
+ type: "com.atlassian.jira.plugin.system.customfieldtypes:url"
15
+ searcherKey: "com.atlassian.jira.plugin.system.customfieldtypes:exacttextsearcher"
15
16
  description: >-
16
17
  URL to the canonical story file in the repo. Set by the outbound sync
17
18
  workflow on story creation. Format:
@@ -22,7 +23,7 @@ custom_fields:
22
23
 
23
24
  - name: "Requirement IDs"
24
25
  key: "requirement_ids"
25
- type: "labels"
26
+ type: "multi-value"
26
27
  description: >-
27
28
  Multi-value field containing the REQ-id(s) this story implements.
28
29
  Values come from the project's use-case registry (e.g. REQ-VIZ-12,
@@ -33,7 +34,7 @@ custom_fields:
33
34
 
34
35
  - name: "Agent Estimate"
35
36
  key: "agent_estimate"
36
- type: "number"
37
+ type: "numeric"
37
38
  description: >-
38
39
  Estimated agent-hours for the story. Populated from the story frontmatter
39
40
  estimate field (converted to numeric hours). Used for capacity planning
@@ -14,6 +14,7 @@ statuses:
14
14
  Backlog state. Stories with manual-gate label sit here.
15
15
  Initial state for stories that are not yet ready for agent pickup.
16
16
  status_id: 10000
17
+ statusCategory: "TODO"
17
18
  transitions:
18
19
  - name: "Ready for AI agent"
19
20
  transition_id: 2
@@ -29,6 +30,7 @@ statuses:
29
30
  Trigger-firing state. Devin picks up tickets from this status via the
30
31
  automation trigger. Stories land here when all depends_on are resolved.
31
32
  status_id: 10070
33
+ statusCategory: "TODO"
32
34
  transitions:
33
35
  - name: "In Progress"
34
36
  transition_id: 21
@@ -48,6 +50,7 @@ statuses:
48
50
  Agent is actively implementing. Read-only for humans during this state.
49
51
  Agent transitions here on session start.
50
52
  status_id: 10001
53
+ statusCategory: "IN_PROGRESS"
51
54
  transitions:
52
55
  - name: "In Review"
53
56
  transition_id: 31
@@ -67,6 +70,7 @@ statuses:
67
70
  PR is open and awaiting human review. Agent transitions here on PR open.
68
71
  PR link is posted as a remote link by the GitHub-Jira integration.
69
72
  status_id: 10002
73
+ statusCategory: "IN_PROGRESS"
70
74
  transitions:
71
75
  - name: "Done"
72
76
  transition_id: 41
@@ -86,6 +90,7 @@ statuses:
86
90
  Dependency not yet resolved. Auto-resolves when blocking issues reach Done.
87
91
  Sync-driven; distinct from Blocked (which requires human intervention).
88
92
  status_id: 10137
93
+ statusCategory: "IN_PROGRESS"
89
94
  transitions:
90
95
  - name: "Ready for AI agent"
91
96
  transition_id: 2
@@ -106,6 +111,7 @@ statuses:
106
111
  ambiguity_escalation, ac_verification_fail, ci_repeat_fail,
107
112
  estimate_overrun (3x), file_overlap_with_open_story.
108
113
  status_id: 10103
114
+ statusCategory: "IN_PROGRESS"
109
115
  transitions:
110
116
  - name: "Ready for AI agent"
111
117
  transition_id: 2
@@ -125,6 +131,7 @@ statuses:
125
131
  Terminal success state. Reached via Jira automation rule on PR merge
126
132
  (gated by pullRequest.title contains issue.key).
127
133
  status_id: 10003
134
+ statusCategory: "DONE"
128
135
  transitions: []
129
136
 
130
137
  - name: "Won't do"
@@ -132,4 +139,5 @@ statuses:
132
139
  Terminal rejected state. Reached via sync workflow on story file rename
133
140
  (prefixed with underscore) or by operator decision.
134
141
  status_id: 10104
142
+ statusCategory: "DONE"
135
143
  transitions: []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodology-framework
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Portable process tooling for agent-driven delivery — scripts, playbooks, templates, and specs.
5
5
  Author: whiteout59
6
6
  License-Expression: Apache-2.0
@@ -17,6 +17,7 @@ Requires-Dist: ruff==0.15.14; extra == "dev"
17
17
  Requires-Dist: mypy<2,>=1.10; extra == "dev"
18
18
  Requires-Dist: types-requests<3,>=2.31; extra == "dev"
19
19
  Requires-Dist: types-PyYAML<7,>=6.0; extra == "dev"
20
+ Requires-Dist: vcrpy<9,>=7.0; extra == "dev"
20
21
  Dynamic: license-file
21
22
 
22
23
  # methodology-framework
@@ -50,7 +51,8 @@ instead of manually configuring 30+ admin UI screens.
50
51
  - Python 3.12+
51
52
  - `pip install methodology-framework` (or `pip install -e .` from source)
52
53
  - For `--apply` mode: `JIRA_ADMIN_TOKEN` environment variable set to a Jira
53
- admin API token or OAuth Bearer token
54
+ admin API token, and `JIRA_USER_EMAIL` set to the email of the Atlassian
55
+ account that owns that token
54
56
 
55
57
  ### Usage
56
58
 
@@ -69,6 +71,7 @@ python -m methodology_framework bootstrap-jira \
69
71
 
70
72
  # Apply — provision the Jira project via admin API
71
73
  export JIRA_ADMIN_TOKEN="<your-token>"
74
+ export JIRA_USER_EMAIL="<your-email>"
72
75
  python -m methodology_framework bootstrap-jira \
73
76
  --project-key=MYPROJ \
74
77
  --jira-host=myorg.atlassian.net \
@@ -97,6 +100,7 @@ python -m methodology_framework bootstrap-jira \
97
100
  | Variable | When needed | Description |
98
101
  |----------|-------------|-------------|
99
102
  | `JIRA_ADMIN_TOKEN` | `--apply` mode | Jira admin API token. **Never** accepted as a CLI flag. |
103
+ | `JIRA_USER_EMAIL` | `--apply` mode | Email of the Atlassian account that owns the API token. Used with the token for HTTP Basic auth. **Never** accepted as a CLI flag. |
100
104
 
101
105
  ### Shape definitions
102
106
 
@@ -225,6 +229,45 @@ to the GitHub repo `whiteout59/methodology-framework`, workflow
225
229
  `pypi-release.yml`, environment `pypi` at
226
230
  [PyPI Trusted Publishers](https://pypi.org/manage/account/publishing/).
227
231
 
232
+ ## Release lifecycle
233
+
234
+ Version bumps in `pyproject.toml` drive the entire release cycle
235
+ automatically. The operator's only decision surface is PR review.
236
+
237
+ ### Normal flow
238
+
239
+ 1. A PR bumps the `version` field in `pyproject.toml` (e.g. `"0.1.1"` → `"0.1.2"`).
240
+ 2. Reviewer approves and merges the PR to `main`.
241
+ 3. The `auto-tag.yml` workflow detects the `pyproject.toml` change, extracts the
242
+ version, validates it as stable SemVer (`^[0-9]+\.[0-9]+\.[0-9]+$`), and
243
+ creates an annotated tag `v0.1.2`.
244
+ 4. The `pypi-release.yml` workflow fires on the new tag and publishes the
245
+ package to PyPI via OIDC Trusted Publishers.
246
+
247
+ **Operator surface = PR review only.** No manual `git tag` step required.
248
+
249
+ ### Manual fallback
250
+
251
+ If `auto-tag.yml` doesn't fire (workflow disabled, branch protection
252
+ blocking the tag push, GitHub Actions outage, etc.), the manual fallback
253
+ still works:
254
+
255
+ ```bash
256
+ git tag -a v0.1.2 -m "Release v0.1.2"
257
+ git push origin v0.1.2
258
+ ```
259
+
260
+ `pypi-release.yml` doesn't care how the tag arrived — it fires on any
261
+ tag matching `v[0-9]+.[0-9]+.[0-9]+`.
262
+
263
+ ### Pre-release versions
264
+
265
+ The `auto-tag.yml` workflow only tags **stable** SemVer versions. Versions
266
+ containing `-rc`, `-beta`, `-alpha`, `.dev`, or `.pre` suffixes (or any
267
+ string not matching `^[0-9]+\.[0-9]+\.[0-9]+$`) are logged and skipped.
268
+ Pre-release publishing is out of scope; if needed, a separate workflow
269
+ would handle pre-release tag patterns.
270
+
228
271
  ## Cost tracking via `agent_acus`
229
272
 
230
273
  The methodology's post-execution notes include an `agent_acus` field that
@@ -259,6 +302,42 @@ ruff format --check src/methodology_framework
259
302
  mypy --strict src/methodology_framework
260
303
  ```
261
304
 
305
+ ### Shape-def expansion (v0.1.2+)
306
+
307
+ `workflow.yaml` now includes a `statusCategory` field on each status
308
+ (`TODO`, `IN_PROGRESS`, or `DONE`). The `--apply` handler validates
309
+ this field at load time and exits with a clear error if any status is
310
+ missing it.
311
+
312
+ `custom_fields.yaml` uses canonical shorthand types (`text`, `string`,
313
+ `multi-value`, `numeric`) that are mapped to fully-qualified Jira
314
+ identifiers at apply time. Fields may also specify a fully-qualified
315
+ type directly (any value containing `:` is passed through unchanged).
316
+
317
+ ### Recording new VCR cassettes
318
+
319
+ Integration tests under `tests/integration/` replay pre-recorded VCR
320
+ cassettes in CI (no network access required). To record fresh cassettes
321
+ against a real Jira instance:
322
+
323
+ ```bash
324
+ export JIRA_USER_EMAIL="<your-email>"
325
+ export JIRA_ADMIN_TOKEN="<your-api-token>"
326
+ PYTEST_VCR_MODE=record pytest tests/integration/ -v
327
+ ```
328
+
329
+ Cassettes are stored at `tests/fixtures/vcr/*.yaml`. The VCR config in
330
+ `tests/integration/conftest.py` automatically strips `Authorization`
331
+ headers from recorded interactions. **Never commit a cassette with a
332
+ real token in any header.**
333
+
334
+ After recording, verify no credentials leaked:
335
+
336
+ ```bash
337
+ grep -i 'authorization' tests/fixtures/vcr/*.yaml
338
+ # Expected: no output
339
+ ```
340
+
262
341
  ## License
263
342
 
264
343
  Apache-2.0
@@ -9,3 +9,4 @@ ruff==0.15.14
9
9
  mypy<2,>=1.10
10
10
  types-requests<3,>=2.31
11
11
  types-PyYAML<7,>=6.0
12
+ vcrpy<9,>=7.0
@@ -11,10 +11,12 @@ import pytest
11
11
  import yaml
12
12
 
13
13
  from methodology_framework.bootstrap_jira import (
14
+ _VALID_STATUS_CATEGORIES,
14
15
  JiraAdminClient,
15
16
  build_parser,
16
17
  load_all_shapes,
17
18
  main,
19
+ resolve_field_type,
18
20
  substitute_project_key,
19
21
  )
20
22
 
@@ -50,6 +52,11 @@ class TestWorkflowYamlLoads:
50
52
  assert "name" in status
51
53
  assert "description" in status
52
54
  assert "status_id" in status
55
+ assert "statusCategory" in status, f"Status '{status['name']}' missing statusCategory"
56
+ assert status["statusCategory"] in _VALID_STATUS_CATEGORIES, (
57
+ f"Status '{status['name']}' has invalid statusCategory "
58
+ f"'{status['statusCategory']}'"
59
+ )
53
60
  assert "transitions" in status
54
61
  for t in status["transitions"]:
55
62
  assert "name" in t
@@ -77,6 +84,9 @@ class TestCustomFieldsYamlLoads:
77
84
  assert "type" in field
78
85
  assert "description" in field
79
86
  assert "required_at_create" in field
87
+ # Every type must be resolvable (shorthand or FQ)
88
+ fq_type, _ = resolve_field_type(field["type"])
89
+ assert fq_type, f"Field '{field['name']}' has unresolvable type"
80
90
 
81
91
 
82
92
  class TestAutomationRulesYamlLoads:
@@ -144,6 +154,23 @@ class TestCliTokenRequiredForApply:
144
154
  def test_cli_token_required_for_apply(self) -> None:
145
155
  env = os.environ.copy()
146
156
  env.pop("JIRA_ADMIN_TOKEN", None)
157
+ env.pop("JIRA_USER_EMAIL", None)
158
+ with patch.dict(os.environ, env, clear=True):
159
+ result = main(
160
+ [
161
+ "--project-key=TESTPROJ",
162
+ "--jira-host=test.atlassian.net",
163
+ "--apply",
164
+ ]
165
+ )
166
+ assert result == 1
167
+
168
+
169
+ class TestApplyRequiresJiraUserEmail:
170
+ """test_apply_requires_jira_user_email — --apply without JIRA_USER_EMAIL exits 1."""
171
+
172
+ def test_apply_requires_jira_user_email(self, capsys: pytest.CaptureFixture[str]) -> None:
173
+ env = {"JIRA_ADMIN_TOKEN": "fake-token"}
147
174
  with patch.dict(os.environ, env, clear=True):
148
175
  result = main(
149
176
  [
@@ -153,6 +180,9 @@ class TestCliTokenRequiredForApply:
153
180
  ]
154
181
  )
155
182
  assert result == 1
183
+ captured = capsys.readouterr()
184
+ assert "JIRA_USER_EMAIL" in captured.err
185
+ assert "--apply mode" in captured.err
156
186
 
157
187
 
158
188
  # ---------------------------------------------------------------------------
@@ -178,6 +208,45 @@ class TestDryRunNoApiCalls:
178
208
  assert "dry-run" in captured.out.lower()
179
209
 
180
210
 
211
+ class TestDryRunDoesNotRequireEmail:
212
+ """test_dry_run_does_not_require_email — --dry-run without JIRA_USER_EMAIL succeeds."""
213
+
214
+ def test_dry_run_does_not_require_email(self) -> None:
215
+ env = os.environ.copy()
216
+ env.pop("JIRA_USER_EMAIL", None)
217
+ env.pop("JIRA_ADMIN_TOKEN", None)
218
+ with patch.dict(os.environ, env, clear=True):
219
+ result = main(
220
+ [
221
+ "--project-key=TESTPROJ",
222
+ "--jira-host=test.atlassian.net",
223
+ "--dry-run",
224
+ ]
225
+ )
226
+ assert result == 0
227
+
228
+
229
+ class TestExportDoesNotRequireEmail:
230
+ """test_export_does_not_require_email — --export without JIRA_USER_EMAIL succeeds."""
231
+
232
+ def test_export_does_not_require_email(self, tmp_path: Path) -> None:
233
+ export_path = str(tmp_path / "test-bundle.yaml")
234
+ env = os.environ.copy()
235
+ env.pop("JIRA_USER_EMAIL", None)
236
+ env.pop("JIRA_ADMIN_TOKEN", None)
237
+ with patch.dict(os.environ, env, clear=True):
238
+ result = main(
239
+ [
240
+ "--project-key=TESTPROJ",
241
+ "--jira-host=test.atlassian.net",
242
+ "--export",
243
+ export_path,
244
+ ]
245
+ )
246
+ assert result == 0
247
+ assert Path(export_path).exists()
248
+
249
+
181
250
  class TestExportEmitsBundle:
182
251
  """test_export_emits_bundle — --export <tmpfile> produces a file."""
183
252
 
@@ -214,6 +283,8 @@ def _build_mock_client(existing_statuses: list[dict[str, Any]] | None = None) ->
214
283
  """Build a mock JiraAdminClient with canned responses."""
215
284
  client = MagicMock(spec=JiraAdminClient)
216
285
  client.check_connectivity.return_value = None
286
+ client._resolve_project_id.return_value = "10000"
287
+ client._project_id_cache = {}
217
288
  if existing_statuses is None:
218
289
  existing_statuses = [
219
290
  {"id": "10000", "name": "To Do"},
@@ -239,6 +310,36 @@ def _build_mock_client(existing_statuses: list[dict[str, Any]] | None = None) ->
239
310
  return client
240
311
 
241
312
 
313
+ class TestApplyUsesBasicAuthWithEmailAndToken:
314
+ """test_apply_uses_basic_auth_with_email_and_token — mocked client receives HTTP Basic auth."""
315
+
316
+ def test_apply_uses_basic_auth_with_email_and_token(self) -> None:
317
+ mock_client = _build_mock_client()
318
+ test_email = "admin@example.com"
319
+ test_token = "my-api-token"
320
+
321
+ with (
322
+ patch(
323
+ "methodology_framework.bootstrap_jira.JiraAdminClient",
324
+ ) as mock_cls,
325
+ patch.dict(
326
+ os.environ,
327
+ {"JIRA_ADMIN_TOKEN": test_token, "JIRA_USER_EMAIL": test_email},
328
+ ),
329
+ ):
330
+ mock_cls.return_value = mock_client
331
+ result = main(
332
+ [
333
+ "--project-key=TESTPROJ",
334
+ "--jira-host=test.atlassian.net",
335
+ "--apply",
336
+ "--force",
337
+ ]
338
+ )
339
+ assert result == 0
340
+ mock_cls.assert_called_once_with("test.atlassian.net", test_token, test_email)
341
+
342
+
242
343
  class TestApplyIdempotent:
243
344
  """test_apply_idempotent — second apply is a no-op."""
244
345
 
@@ -250,7 +351,10 @@ class TestApplyIdempotent:
250
351
  "methodology_framework.bootstrap_jira.JiraAdminClient",
251
352
  return_value=mock_client,
252
353
  ),
253
- patch.dict(os.environ, {"JIRA_ADMIN_TOKEN": "fake-token"}),
354
+ patch.dict(
355
+ os.environ,
356
+ {"JIRA_ADMIN_TOKEN": "fake-token", "JIRA_USER_EMAIL": "user@example.com"},
357
+ ),
254
358
  ):
255
359
  result1 = main(
256
360
  [