methodology-framework 0.1.1__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.1/src/methodology_framework.egg-info → methodology_framework-0.1.2}/PKG-INFO +77 -1
  2. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/README.md +75 -0
  3. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/pyproject.toml +2 -1
  4. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/bootstrap_jira.py +191 -19
  5. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/custom_fields.yaml +4 -3
  6. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/workflow.yaml +8 -0
  7. {methodology_framework-0.1.1 → methodology_framework-0.1.2/src/methodology_framework.egg-info}/PKG-INFO +77 -1
  8. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/requires.txt +1 -0
  9. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_bootstrap_jira.py +12 -0
  10. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/LICENSE +0 -0
  11. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/setup.cfg +0 -0
  12. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/__init__.py +0 -0
  13. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/__main__.py +0 -0
  14. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/build_playbook.py +0 -0
  15. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/__init__.py +0 -0
  16. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/automation_rules.yaml +0 -0
  17. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/playbooks/scrum-router.body.md +0 -0
  18. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/populate_acus.py +0 -0
  19. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/register_playbook_with_devin.py +0 -0
  20. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/specs/devin-story-format.md +0 -0
  21. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/sync_stories_to_jira.py +0 -0
  22. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/templates/github_workflows/populate-story-acus-caller.yml +0 -0
  23. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/templates/story.md +0 -0
  24. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/SOURCES.txt +0 -0
  25. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/dependency_links.txt +0 -0
  26. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/top_level.txt +0 -0
  27. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_build_playbook.py +0 -0
  28. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_populate_acus.py +0 -0
  29. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_register_playbook_with_devin.py +0 -0
  30. {methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_sync_stories_to_jira.py +0 -0
  31. {methodology_framework-0.1.1 → 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.1
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
@@ -228,6 +229,45 @@ to the GitHub repo `whiteout59/methodology-framework`, workflow
228
229
  `pypi-release.yml`, environment `pypi` at
229
230
  [PyPI Trusted Publishers](https://pypi.org/manage/account/publishing/).
230
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
+
231
271
  ## Cost tracking via `agent_acus`
232
272
 
233
273
  The methodology's post-execution notes include an `agent_acus` field that
@@ -262,6 +302,42 @@ ruff format --check src/methodology_framework
262
302
  mypy --strict src/methodology_framework
263
303
  ```
264
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
+
265
341
  ## License
266
342
 
267
343
  Apache-2.0
@@ -207,6 +207,45 @@ to the GitHub repo `whiteout59/methodology-framework`, workflow
207
207
  `pypi-release.yml`, environment `pypi` at
208
208
  [PyPI Trusted Publishers](https://pypi.org/manage/account/publishing/).
209
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
+
210
249
  ## Cost tracking via `agent_acus`
211
250
 
212
251
  The methodology's post-execution notes include an `agent_acus` field that
@@ -241,6 +280,42 @@ ruff format --check src/methodology_framework
241
280
  mypy --strict src/methodology_framework
242
281
  ```
243
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
+
244
319
  ## License
245
320
 
246
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.1"
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,10 +12,12 @@ 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
@@ -24,6 +26,57 @@ from requests.auth import HTTPBasicAuth
24
26
 
25
27
  logger = logging.getLogger(__name__)
26
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
+
27
80
  # ---------------------------------------------------------------------------
28
81
  # Shape-def loading
29
82
  # ---------------------------------------------------------------------------
@@ -96,6 +149,32 @@ class JiraAdminClient:
96
149
  "Accept": "application/json",
97
150
  }
98
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
99
178
 
100
179
  # -- connectivity check --------------------------------------------------
101
180
 
@@ -127,7 +206,13 @@ class JiraAdminClient:
127
206
  return statuses
128
207
 
129
208
  def create_status(self, spec: dict[str, Any]) -> None:
130
- """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
+ """
131
216
  resp = self._session.post(f"{self._base}/statuses", json=spec)
132
217
  resp.raise_for_status()
133
218
 
@@ -141,7 +226,11 @@ class JiraAdminClient:
141
226
  return result
142
227
 
143
228
  def create_custom_field(self, spec: dict[str, Any]) -> dict[str, Any]:
144
- """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
+ """
145
234
  resp = self._session.post(f"{self._base}/field", json=spec)
146
235
  resp.raise_for_status()
147
236
  result: dict[str, Any] = resp.json()
@@ -163,11 +252,25 @@ class JiraAdminClient:
163
252
  return result
164
253
 
165
254
  def create_automation_rule(self, project_key: str, spec: dict[str, Any]) -> dict[str, Any]:
166
- """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
+ """
167
262
  resp = self._session.post(
168
263
  f"{self._base}/project/{project_key}/automation/rule",
169
264
  json=spec,
170
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 {}
171
274
  resp.raise_for_status()
172
275
  result: dict[str, Any] = resp.json()
173
276
  return result
@@ -262,12 +365,38 @@ def _handle_apply(
262
365
  logger.error("Connectivity check failed: %s", exc)
263
366
  return 1
264
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
+
265
373
  # -- statuses -----------------------------------------------------------
266
374
  logger.info("Checking workflow statuses ...")
267
375
  existing_statuses = client.get_statuses(project_key)
268
376
  existing_names = {s.get("name", "").lower() for s in existing_statuses}
269
377
  desired = shapes.get("workflow", {}).get("statuses", [])
270
- 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]] = []
271
400
  for status_spec in desired:
272
401
  if status_spec["name"].lower() in existing_names:
273
402
  logger.info(" Status '%s' already exists — skipping.", status_spec["name"])
@@ -277,11 +406,24 @@ def _handle_apply(
277
406
  if answer != "y":
278
407
  logger.info(" Skipped '%s'.", status_spec["name"])
279
408
  continue
280
- logger.info(" Creating status '%s' ...", status_spec["name"])
281
- client.create_status(
282
- {"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
+ }
283
415
  )
284
- 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)
285
427
 
286
428
  # -- custom fields ------------------------------------------------------
287
429
  logger.info("Checking custom fields ...")
@@ -300,14 +442,19 @@ def _handle_apply(
300
442
  if answer != "y":
301
443
  logger.info(" Skipped '%s'.", field_spec["name"])
302
444
  continue
303
- logger.info(" Creating field '%s' ...", field_spec["name"])
304
- client.create_custom_field(
305
- {
306
- "name": field_spec["name"],
307
- "type": field_spec.get("type", "string"),
308
- "description": field_spec.get("description", ""),
309
- }
310
- )
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)
311
458
  fields_created += 1
312
459
 
313
460
  # -- automation rules ---------------------------------------------------
@@ -316,6 +463,7 @@ def _handle_apply(
316
463
  existing_rule_names = {r.get("name", "").lower() for r in existing_rules}
317
464
  desired_rules = shapes.get("automation_rules", {}).get("automation_rules", [])
318
465
  rules_created = 0
466
+ rules_degraded: list[dict[str, Any]] = []
319
467
  for rule_spec in desired_rules:
320
468
  if rule_spec["name"].lower() in existing_rule_names:
321
469
  logger.info(" Rule '%s' already exists — skipping.", rule_spec["name"])
@@ -330,8 +478,30 @@ def _handle_apply(
330
478
  logger.info(" Skipped '%s'.", rule_spec["name"])
331
479
  continue
332
480
  logger.info(" Creating rule '%s' ...", rule_spec["name"])
333
- client.create_automation_rule(project_key, rule_spec)
334
- 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")
335
505
 
336
506
  logger.info(
337
507
  "Apply complete: %d statuses, %d fields, %d rules created.",
@@ -339,7 +509,9 @@ def _handle_apply(
339
509
  fields_created,
340
510
  rules_created,
341
511
  )
342
- 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:
343
515
  logger.info("Already up to date.")
344
516
  return 0
345
517
 
@@ -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.1
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
@@ -228,6 +229,45 @@ to the GitHub repo `whiteout59/methodology-framework`, workflow
228
229
  `pypi-release.yml`, environment `pypi` at
229
230
  [PyPI Trusted Publishers](https://pypi.org/manage/account/publishing/).
230
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
+
231
271
  ## Cost tracking via `agent_acus`
232
272
 
233
273
  The methodology's post-execution notes include an `agent_acus` field that
@@ -262,6 +302,42 @@ ruff format --check src/methodology_framework
262
302
  mypy --strict src/methodology_framework
263
303
  ```
264
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
+
265
341
  ## License
266
342
 
267
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:
@@ -273,6 +283,8 @@ def _build_mock_client(existing_statuses: list[dict[str, Any]] | None = None) ->
273
283
  """Build a mock JiraAdminClient with canned responses."""
274
284
  client = MagicMock(spec=JiraAdminClient)
275
285
  client.check_connectivity.return_value = None
286
+ client._resolve_project_id.return_value = "10000"
287
+ client._project_id_cache = {}
276
288
  if existing_statuses is None:
277
289
  existing_statuses = [
278
290
  {"id": "10000", "name": "To Do"},