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.
- {methodology_framework-0.1.1/src/methodology_framework.egg-info → methodology_framework-0.1.2}/PKG-INFO +77 -1
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/README.md +75 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/pyproject.toml +2 -1
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/bootstrap_jira.py +191 -19
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/custom_fields.yaml +4 -3
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/workflow.yaml +8 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2/src/methodology_framework.egg-info}/PKG-INFO +77 -1
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/requires.txt +1 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_bootstrap_jira.py +12 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/LICENSE +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/setup.cfg +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/__init__.py +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/__main__.py +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/build_playbook.py +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/__init__.py +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/automation_rules.yaml +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/playbooks/scrum-router.body.md +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/populate_acus.py +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/register_playbook_with_devin.py +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/specs/devin-story-format.md +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/sync_stories_to_jira.py +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/templates/github_workflows/populate-story-acus-caller.yml +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/templates/story.md +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/SOURCES.txt +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/dependency_links.txt +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/top_level.txt +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_build_playbook.py +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_populate_acus.py +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_register_playbook_with_devin.py +0 -0
- {methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_sync_stories_to_jira.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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: "
|
|
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.
|
|
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
|
|
@@ -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"},
|
|
File without changes
|
|
File without changes
|
{methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/__init__.py
RENAMED
|
File without changes
|
{methodology_framework-0.1.1 → methodology_framework-0.1.2}/src/methodology_framework/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{methodology_framework-0.1.1 → methodology_framework-0.1.2}/tests/test_sync_stories_to_jira.py
RENAMED
|
File without changes
|
|
File without changes
|