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.
- {methodology_framework-0.1.0/src/methodology_framework.egg-info → methodology_framework-0.1.2}/PKG-INFO +81 -2
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/README.md +79 -1
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/pyproject.toml +2 -1
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/bootstrap_jira.py +214 -23
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/custom_fields.yaml +4 -3
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/workflow.yaml +8 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2/src/methodology_framework.egg-info}/PKG-INFO +81 -2
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/requires.txt +1 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_bootstrap_jira.py +105 -1
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/LICENSE +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/setup.cfg +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/__init__.py +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/__main__.py +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/build_playbook.py +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/__init__.py +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/jira_shapes/automation_rules.yaml +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/playbooks/scrum-router.body.md +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/populate_acus.py +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/register_playbook_with_devin.py +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/specs/devin-story-format.md +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/sync_stories_to_jira.py +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/templates/github_workflows/populate-story-acus-caller.yml +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/templates/story.md +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/SOURCES.txt +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/dependency_links.txt +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework.egg-info/top_level.txt +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_build_playbook.py +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_populate_acus.py +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_register_playbook_with_devin.py +0 -0
- {methodology_framework-0.1.0 → methodology_framework-0.1.2}/tests/test_sync_stories_to_jira.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
@@ -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
|
|
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
|
|
@@ -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(
|
|
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
|
[
|
|
File without changes
|
|
File without changes
|
{methodology_framework-0.1.0 → methodology_framework-0.1.2}/src/methodology_framework/__init__.py
RENAMED
|
File without changes
|
{methodology_framework-0.1.0 → 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.0 → methodology_framework-0.1.2}/tests/test_sync_stories_to_jira.py
RENAMED
|
File without changes
|
|
File without changes
|