atlasdocs-jira 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atlasdocs_jira-0.9.0/PKG-INFO +9 -0
- atlasdocs_jira-0.9.0/atlasdocs_jira/__init__.py +11 -0
- atlasdocs_jira-0.9.0/atlasdocs_jira/fetch_jira.py +317 -0
- atlasdocs_jira-0.9.0/atlasdocs_jira/prepare_jira_pages.py +264 -0
- atlasdocs_jira-0.9.0/pixi.toml +19 -0
- atlasdocs_jira-0.9.0/pyproject.toml +21 -0
- atlasdocs_jira-0.9.0/scripts/release.sh +79 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atlasdocs-jira
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Jira integration scripts for atlasdocs-theme
|
|
5
|
+
Author-email: Jason Oliver <jason.oliver@cern.ch>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: python-dotenv>=1.0
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def __getattr__(name: str):
|
|
5
|
+
if name == "create_jira_pages":
|
|
6
|
+
from .prepare_jira_pages import create_jira_pages
|
|
7
|
+
return create_jira_pages
|
|
8
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["create_jira_pages"]
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Fetch Jira project issues and save them as JSON files.
|
|
5
|
+
|
|
6
|
+
Reads which projects to fetch from jira.toml in the hub directory.
|
|
7
|
+
Writes one file per project:
|
|
8
|
+
data/jira/{KEY}.json
|
|
9
|
+
|
|
10
|
+
Credentials: set JIRA_TOKEN (personal access token) or
|
|
11
|
+
JIRA_USER + JIRA_PASSWORD in the environment.
|
|
12
|
+
|
|
13
|
+
Usage examples
|
|
14
|
+
--------------
|
|
15
|
+
Fetch all projects from jira.toml:
|
|
16
|
+
python -m atlasdocs_jira.fetch_jira
|
|
17
|
+
|
|
18
|
+
Specific projects:
|
|
19
|
+
python -m atlasdocs_jira.fetch_jira --projects ATLASDOC
|
|
20
|
+
python -m atlasdocs_jira.fetch_jira --projects ATLASDOC,ATWIKIPHYS
|
|
21
|
+
|
|
22
|
+
Custom JQL query (saves to data/jira/tickets.json):
|
|
23
|
+
python -m atlasdocs_jira.fetch_jira --jql "project = FOO AND status = Open"
|
|
24
|
+
|
|
25
|
+
Only issues created in the last 3 years:
|
|
26
|
+
python -m atlasdocs_jira.fetch_jira --since -3y
|
|
27
|
+
|
|
28
|
+
List accessible projects:
|
|
29
|
+
python -m atlasdocs_jira.fetch_jira --list-projects
|
|
30
|
+
python -m atlasdocs_jira.fetch_jira --list-projects atlas
|
|
31
|
+
|
|
32
|
+
Re-fetch even if cached:
|
|
33
|
+
python -m atlasdocs_jira.fetch_jira --refresh
|
|
34
|
+
|
|
35
|
+
Dry-run (print first 10 tickets, don't save):
|
|
36
|
+
python -m atlasdocs_jira.fetch_jira --dry-run
|
|
37
|
+
|
|
38
|
+
Pixi (args after --):
|
|
39
|
+
pixi run fetch-jira
|
|
40
|
+
pixi run fetch-jira -- --projects ATLASDOC
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import base64
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import re
|
|
48
|
+
import sys
|
|
49
|
+
import time
|
|
50
|
+
import tomllib
|
|
51
|
+
import urllib.parse
|
|
52
|
+
import urllib.request
|
|
53
|
+
from datetime import date, timedelta
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
from dotenv import load_dotenv
|
|
57
|
+
load_dotenv(Path('.env').resolve())
|
|
58
|
+
|
|
59
|
+
# ── Configuration ──────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
JIRA_SITE = "https://its.cern.ch/jira"
|
|
62
|
+
DATA_DIR = "data/jira"
|
|
63
|
+
JIRA_CONFIG_FILE = "jira.toml"
|
|
64
|
+
PAGE_SIZE = 1000
|
|
65
|
+
REQUEST_DELAY = 0.3
|
|
66
|
+
|
|
67
|
+
FIELDS = [
|
|
68
|
+
"summary", "description", "status", "priority",
|
|
69
|
+
"assignee", "reporter", "created", "updated",
|
|
70
|
+
"labels", "components", "issuetype", "resolution",
|
|
71
|
+
"resolutiondate", "comment",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── Config loading ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def load_jira_config(hub_dir: str = ".") -> dict:
|
|
78
|
+
"""Load jira.toml. Returns {projects: [{key, label}], site}."""
|
|
79
|
+
config_path = Path(hub_dir) / JIRA_CONFIG_FILE
|
|
80
|
+
if not config_path.exists():
|
|
81
|
+
return {"projects": [], "site": JIRA_SITE}
|
|
82
|
+
with open(config_path, "rb") as f:
|
|
83
|
+
data = tomllib.load(f)
|
|
84
|
+
return {
|
|
85
|
+
"projects": data.get("projects", []),
|
|
86
|
+
"site": data.get("site", JIRA_SITE),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Credentials ────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def load_credentials() -> tuple[str, str, bool]:
|
|
93
|
+
"""Return (user, secret, is_token). Exits on missing credentials."""
|
|
94
|
+
user = os.environ.get("JIRA_USER", "")
|
|
95
|
+
token = os.environ.get("JIRA_TOKEN")
|
|
96
|
+
password = os.environ.get("JIRA_PASSWORD")
|
|
97
|
+
if token:
|
|
98
|
+
return user, token, True
|
|
99
|
+
if password:
|
|
100
|
+
if not user:
|
|
101
|
+
sys.exit("Error: JIRA_USER must be set when using JIRA_PASSWORD.")
|
|
102
|
+
return user, password, False
|
|
103
|
+
sys.exit("Error: JIRA_TOKEN or JIRA_PASSWORD not set in environment.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _auth_header(user: str, secret: str, is_token: bool) -> str:
|
|
107
|
+
if is_token:
|
|
108
|
+
return f"Bearer {secret}"
|
|
109
|
+
return "Basic " + base64.b64encode(f"{user}:{secret}".encode()).decode()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── HTTP ───────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def _get(auth: str, url: str) -> dict:
|
|
115
|
+
req = urllib.request.Request(url, headers={
|
|
116
|
+
"Authorization": auth,
|
|
117
|
+
"Accept": "application/json",
|
|
118
|
+
})
|
|
119
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
120
|
+
return json.loads(resp.read())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── Date helpers ───────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def _since_to_jql_date(since: str) -> str:
|
|
126
|
+
"""Convert -3y / -6m / -2w / -30d or ISO date to a quoted JQL date string."""
|
|
127
|
+
m = re.fullmatch(r"-?(\d+)([ymwd])", since)
|
|
128
|
+
if not m:
|
|
129
|
+
return f'"{since}"'
|
|
130
|
+
n, unit = int(m.group(1)), m.group(2)
|
|
131
|
+
today = date.today()
|
|
132
|
+
if unit == "y":
|
|
133
|
+
cutoff = today.replace(year=today.year - n)
|
|
134
|
+
elif unit == "m":
|
|
135
|
+
month = today.month - n
|
|
136
|
+
year = today.year + (month - 1) // 12
|
|
137
|
+
month = (month - 1) % 12 + 1
|
|
138
|
+
cutoff = today.replace(year=year, month=month)
|
|
139
|
+
elif unit == "w":
|
|
140
|
+
cutoff = today - timedelta(weeks=n)
|
|
141
|
+
else:
|
|
142
|
+
cutoff = today - timedelta(days=n)
|
|
143
|
+
return f'"{cutoff.isoformat()}"'
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── Fetch ──────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def fetch_projects(auth: str, site: str) -> list[dict]:
|
|
149
|
+
return sorted(_get(auth, f"{site}/rest/api/2/project"), key=lambda p: p["key"])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def fetch_page(auth: str, site: str, jql: str, start_at: int) -> dict:
|
|
153
|
+
params = urllib.parse.urlencode({
|
|
154
|
+
"jql": jql,
|
|
155
|
+
"startAt": start_at,
|
|
156
|
+
"maxResults": PAGE_SIZE,
|
|
157
|
+
"fields": ",".join(FIELDS),
|
|
158
|
+
})
|
|
159
|
+
return _get(auth, f"{site}/rest/api/2/search?{params}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def fetch_all(auth: str, site: str, jql: str) -> list[dict]:
|
|
163
|
+
tickets: list[dict] = []
|
|
164
|
+
start_at = 0
|
|
165
|
+
while True:
|
|
166
|
+
print(f"[jira] {start_at}–{start_at + PAGE_SIZE - 1}…", end="", flush=True)
|
|
167
|
+
data = fetch_page(auth, site, jql, start_at)
|
|
168
|
+
issues = data.get("issues", [])
|
|
169
|
+
total = data.get("total", 0)
|
|
170
|
+
tickets.extend(issues)
|
|
171
|
+
print(f" +{len(issues)} ({len(tickets)}/{total})")
|
|
172
|
+
if not issues or len(tickets) >= total:
|
|
173
|
+
break
|
|
174
|
+
start_at += len(issues)
|
|
175
|
+
time.sleep(REQUEST_DELAY)
|
|
176
|
+
return tickets
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _print_row(t: dict) -> None:
|
|
180
|
+
f = t.get("fields", {})
|
|
181
|
+
status = (f.get("status") or {}).get("name", "?")
|
|
182
|
+
priority = (f.get("priority") or {}).get("name", "?")
|
|
183
|
+
summary = (f.get("summary") or "").strip()
|
|
184
|
+
updated = (f.get("updated") or "")[:10]
|
|
185
|
+
print(f" {t['key']:<18} [{status:<12}] [{priority:<16}] {updated} {summary[:70]}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ── Save ───────────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def save_project(key: str, tickets: list[dict], data_dir: str) -> Path:
|
|
191
|
+
out_dir = Path(data_dir)
|
|
192
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
out_path = out_dir / f"{key}.json"
|
|
194
|
+
out_path.write_text(json.dumps(tickets, indent=2, ensure_ascii=False))
|
|
195
|
+
return out_path
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── Main ───────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
def main() -> None:
|
|
201
|
+
parser = argparse.ArgumentParser(
|
|
202
|
+
description="Fetch Jira project issues and save as JSON.",
|
|
203
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
204
|
+
epilog=__doc__.split("Usage examples")[1] if __doc__ and "Usage examples" in __doc__ else "",
|
|
205
|
+
)
|
|
206
|
+
parser.add_argument("--projects", metavar="KEY1,KEY2",
|
|
207
|
+
help="Comma-separated project keys (default: all projects in jira.toml)")
|
|
208
|
+
parser.add_argument("--jql", help="Custom JQL query (saves to data/jira/tickets.json)")
|
|
209
|
+
parser.add_argument("--since", metavar="DATE_OR_OFFSET",
|
|
210
|
+
help="Only fetch issues created on/after this date (e.g. -3y, 2022-01-01)")
|
|
211
|
+
parser.add_argument("--list-projects", nargs="?", const="", metavar="PATTERN",
|
|
212
|
+
help="List accessible projects, optionally filtered by name/key pattern")
|
|
213
|
+
parser.add_argument("--refresh", action="store_true",
|
|
214
|
+
help="Re-fetch even if cache exists")
|
|
215
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
216
|
+
help="Fetch 10 tickets, print summaries, exit without saving")
|
|
217
|
+
parser.add_argument("--site", default=None,
|
|
218
|
+
help=f"Jira base URL (default: from jira.toml or {JIRA_SITE})")
|
|
219
|
+
parser.add_argument("--data-dir", default=DATA_DIR,
|
|
220
|
+
help=f"Output directory (default: {DATA_DIR})")
|
|
221
|
+
args = parser.parse_args()
|
|
222
|
+
|
|
223
|
+
hub_dir = str(Path.cwd())
|
|
224
|
+
config = load_jira_config(hub_dir)
|
|
225
|
+
site = args.site or config["site"]
|
|
226
|
+
|
|
227
|
+
user, secret, is_token = load_credentials()
|
|
228
|
+
auth = _auth_header(user, secret, is_token)
|
|
229
|
+
|
|
230
|
+
resolved_data_dir = str(Path(hub_dir) / args.data_dir)
|
|
231
|
+
|
|
232
|
+
# ── list-projects ──────────────────────────────────────────────────────────
|
|
233
|
+
if args.list_projects is not None:
|
|
234
|
+
pattern = args.list_projects.lower()
|
|
235
|
+
projects = fetch_projects(auth, site)
|
|
236
|
+
if pattern:
|
|
237
|
+
projects = [p for p in projects
|
|
238
|
+
if pattern in p["key"].lower() or pattern in p["name"].lower()]
|
|
239
|
+
print(f"{'KEY':<24} {'TYPE':<14} {'ID':<8} NAME")
|
|
240
|
+
print("-" * 80)
|
|
241
|
+
for p in projects:
|
|
242
|
+
print(f" {p['key']:<22} {p.get('projectTypeKey',''):<14} {p['id']:<8} {p['name']}")
|
|
243
|
+
print(f"\n{len(projects)} project(s) shown")
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# ── custom JQL mode ────────────────────────────────────────────────────────
|
|
247
|
+
if args.jql:
|
|
248
|
+
out_file = Path(resolved_data_dir) / "tickets.json"
|
|
249
|
+
if out_file.exists() and not args.refresh:
|
|
250
|
+
existing = json.loads(out_file.read_text())
|
|
251
|
+
print(f"Cache hit: {len(existing):,} tickets in {out_file} (use --refresh to re-fetch)")
|
|
252
|
+
return
|
|
253
|
+
if args.dry_run:
|
|
254
|
+
data = fetch_page(auth, site, args.jql, 0)
|
|
255
|
+
tickets = data.get("issues", [])[:10]
|
|
256
|
+
print(f"DRY RUN JQL: {args.jql}\nTotal: {data.get('total',0):,} (showing {len(tickets)})\n")
|
|
257
|
+
for t in tickets:
|
|
258
|
+
_print_row(t)
|
|
259
|
+
return
|
|
260
|
+
tickets = fetch_all(auth, site, args.jql)
|
|
261
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
out_file.write_text(json.dumps(tickets, indent=2, ensure_ascii=False))
|
|
263
|
+
print(f"\nSaved {len(tickets):,} tickets → {out_file}")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# ── per-project mode ───────────────────────────────────────────────────────
|
|
267
|
+
if args.projects:
|
|
268
|
+
keys = [k.strip().upper() for k in args.projects.split(",") if k.strip()]
|
|
269
|
+
else:
|
|
270
|
+
keys = [p["key"] for p in config["projects"]]
|
|
271
|
+
if not keys:
|
|
272
|
+
parser.error("No projects specified — use --projects or add entries to jira.toml")
|
|
273
|
+
|
|
274
|
+
since_clause = f" AND created >= {_since_to_jql_date(args.since)}" if args.since else ""
|
|
275
|
+
|
|
276
|
+
if args.dry_run:
|
|
277
|
+
jql = f"project = {keys[0]}{since_clause} ORDER BY updated DESC"
|
|
278
|
+
data = fetch_page(auth, site, jql, 0)
|
|
279
|
+
tickets = data.get("issues", [])[:10]
|
|
280
|
+
print(f"DRY RUN — {keys[0]} JQL: {jql}\nTotal: {data.get('total',0):,} (showing {len(tickets)})\n")
|
|
281
|
+
for t in tickets:
|
|
282
|
+
_print_row(t)
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
print(f"[jira] Fetching {len(keys)} project(s) → {resolved_data_dir}")
|
|
286
|
+
grand_total = skipped = errors = 0
|
|
287
|
+
for i, key in enumerate(keys, 1):
|
|
288
|
+
out_file = Path(resolved_data_dir) / f"{key}.json"
|
|
289
|
+
if out_file.exists() and not args.refresh:
|
|
290
|
+
existing = json.loads(out_file.read_text())
|
|
291
|
+
skipped += 1
|
|
292
|
+
grand_total += len(existing)
|
|
293
|
+
print(f"[jira] [{i}/{len(keys)}] {key}: {len(existing):,} tickets (cached)")
|
|
294
|
+
continue
|
|
295
|
+
jql = f"project = {key}{since_clause} ORDER BY updated DESC"
|
|
296
|
+
try:
|
|
297
|
+
tickets = fetch_all(auth, site, jql)
|
|
298
|
+
except Exception as e:
|
|
299
|
+
print(f"[jira] [{i}/{len(keys)}] {key}: ERROR — {e}")
|
|
300
|
+
errors += 1
|
|
301
|
+
continue
|
|
302
|
+
if tickets:
|
|
303
|
+
save_project(key, tickets, resolved_data_dir)
|
|
304
|
+
grand_total += len(tickets)
|
|
305
|
+
print(f"[jira] [{i}/{len(keys)}] {key}: {len(tickets):,} tickets → {out_file}")
|
|
306
|
+
else:
|
|
307
|
+
print(f"[jira] [{i}/{len(keys)}] {key}: no tickets")
|
|
308
|
+
|
|
309
|
+
fetched = len(keys) - skipped - errors
|
|
310
|
+
print(f"[jira] Total: {grand_total:,} tickets ({fetched} fetched, {skipped} cached{', ' + str(errors) + ' error(s)' if errors else ''})")
|
|
311
|
+
|
|
312
|
+
if errors:
|
|
313
|
+
sys.exit(1)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
main()
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Generate MkDocs pages for Jira projects from fetched JSON files.
|
|
5
|
+
|
|
6
|
+
Reads which projects to include from jira.toml in the hub directory.
|
|
7
|
+
For each project that has a data/jira/{KEY}.json file:
|
|
8
|
+
- Copies the JSON to docs/jira/projects/{KEY}/{KEY}.json
|
|
9
|
+
- Writes docs/jira/projects/{KEY}/index.md (JS loader page)
|
|
10
|
+
Writes docs/jira/index.md as a hub page linking to all projects.
|
|
11
|
+
Writes config/zensical/nav-jira.toml with the Jira nav section.
|
|
12
|
+
|
|
13
|
+
Usage examples
|
|
14
|
+
--------------
|
|
15
|
+
python -m atlasdocs_jira.prepare_jira_pages
|
|
16
|
+
|
|
17
|
+
Pixi:
|
|
18
|
+
pixi run prepare-jira-pages
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
import tomllib
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Configuration ──────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
JIRA_CONFIG_FILE = "jira.toml"
|
|
33
|
+
DATA_DIR = "data/jira"
|
|
34
|
+
DOCS_OUTPUT_DIR = "docs/jira"
|
|
35
|
+
NAV_OUTPUT_FILE = "config/zensical/nav-jira.toml"
|
|
36
|
+
|
|
37
|
+
JIRA_SITE = "https://its.cern.ch/jira"
|
|
38
|
+
|
|
39
|
+
PAGE_ICON = "lucide/ticket"
|
|
40
|
+
PAGE_TAGS_BASE = ['"★ JIRA"']
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Config loading ─────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def load_jira_config(hub_dir: str = ".") -> list[dict]:
|
|
46
|
+
"""Load jira.toml and return [{key, label}, ...]."""
|
|
47
|
+
config_path = Path(hub_dir) / JIRA_CONFIG_FILE
|
|
48
|
+
if not config_path.exists():
|
|
49
|
+
return []
|
|
50
|
+
with open(config_path, "rb") as f:
|
|
51
|
+
data = tomllib.load(f)
|
|
52
|
+
site = data.get("site", JIRA_SITE)
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
"key": p["key"].upper(),
|
|
56
|
+
"label": p.get("label", p["key"].upper()),
|
|
57
|
+
"site": site,
|
|
58
|
+
}
|
|
59
|
+
for p in data.get("projects", [])
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Stats helpers ──────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def _project_stats(json_path: str) -> dict:
|
|
66
|
+
"""Return {total, open, closed, first_year, year_counts} from a project JSON."""
|
|
67
|
+
if not os.path.exists(json_path):
|
|
68
|
+
return {}
|
|
69
|
+
with open(json_path) as f:
|
|
70
|
+
try:
|
|
71
|
+
issues = json.load(f)
|
|
72
|
+
except Exception:
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
total = len(issues)
|
|
76
|
+
open_ = sum(1 for i in issues if i.get("fields", {}).get("resolution") is None)
|
|
77
|
+
closed = total - open_
|
|
78
|
+
|
|
79
|
+
years = [int(i["fields"]["created"][:4])
|
|
80
|
+
for i in issues if i.get("fields", {}).get("created")]
|
|
81
|
+
first_year = min(years) if years else None
|
|
82
|
+
|
|
83
|
+
year_map: dict[int, int] = {}
|
|
84
|
+
for y in years:
|
|
85
|
+
year_map[y] = year_map.get(y, 0) + 1
|
|
86
|
+
year_counts = sorted(year_map.items())
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
"total": total,
|
|
90
|
+
"open": open_,
|
|
91
|
+
"closed": closed,
|
|
92
|
+
"first_year": first_year,
|
|
93
|
+
"year_counts": year_counts,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Frontmatter ────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def _frontmatter(title: str, tags: list[str] | None = None) -> str:
|
|
100
|
+
lines = [
|
|
101
|
+
"---",
|
|
102
|
+
f"title: {title}",
|
|
103
|
+
f"icon: {PAGE_ICON}",
|
|
104
|
+
"hide:",
|
|
105
|
+
" - toc",
|
|
106
|
+
"pagestatus: script",
|
|
107
|
+
f"dategenerated: \"{datetime.now().strftime('%Y-%m-%d')}\"",
|
|
108
|
+
]
|
|
109
|
+
if tags:
|
|
110
|
+
lines.append("tags:")
|
|
111
|
+
for tag in tags:
|
|
112
|
+
lines.append(f" - {tag}")
|
|
113
|
+
lines += ["---", ""]
|
|
114
|
+
return "\n".join(lines)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Per-project page ───────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def _generate_project_page(
|
|
120
|
+
output_dir: str,
|
|
121
|
+
key: str,
|
|
122
|
+
label: str,
|
|
123
|
+
data_dir: str,
|
|
124
|
+
site: str,
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""Generate docs/jira/projects/{KEY}/index.md and copy JSON. Returns True on success."""
|
|
127
|
+
src_json = os.path.join(data_dir, f"{key}.json")
|
|
128
|
+
if not os.path.exists(src_json):
|
|
129
|
+
print(f"[SKIP] {key}: data/jira/{key}.json not found (run fetch-jira first)")
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
dst_json = os.path.join(output_dir, f"{key}.json")
|
|
135
|
+
shutil.copy2(src_json, dst_json)
|
|
136
|
+
|
|
137
|
+
stats = _project_stats(src_json)
|
|
138
|
+
total = stats.get("total", 0)
|
|
139
|
+
|
|
140
|
+
fm = _frontmatter(
|
|
141
|
+
title=f'"{label}"',
|
|
142
|
+
tags=list(PAGE_TAGS_BASE) + [f'"▶ {key}"'],
|
|
143
|
+
)
|
|
144
|
+
md = [
|
|
145
|
+
fm,
|
|
146
|
+
f'<div id="jira-project"',
|
|
147
|
+
f' data-json="./{key}.json"',
|
|
148
|
+
f' data-project="{key}"',
|
|
149
|
+
f' data-jira-base="{site}"></div>',
|
|
150
|
+
'<script src="/assets/javascripts/jira_project_loader.js"></script>',
|
|
151
|
+
"",
|
|
152
|
+
]
|
|
153
|
+
with open(os.path.join(output_dir, "index.md"), "w") as f:
|
|
154
|
+
f.write("\n".join(md))
|
|
155
|
+
|
|
156
|
+
print(f"[DONE] docs/jira/projects/{key}/index.md — {total} issues")
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── Hub page ───────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def _generate_hub_page(projects: list[dict], output_path: str, data_dir: str) -> None:
|
|
163
|
+
"""Write docs/jira/index.md with grid cards for each project."""
|
|
164
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
165
|
+
|
|
166
|
+
fm = _frontmatter(title='"Jira Projects"')
|
|
167
|
+
lines = [fm, '<div class="grid cards" markdown>', ""]
|
|
168
|
+
|
|
169
|
+
for p in projects:
|
|
170
|
+
key = p["key"]
|
|
171
|
+
label = p["label"]
|
|
172
|
+
site = p["site"]
|
|
173
|
+
stats = _project_stats(os.path.join(data_dir, f"{key}.json"))
|
|
174
|
+
total = stats.get("total", "")
|
|
175
|
+
open_ = stats.get("open", "")
|
|
176
|
+
desc = f"{open_} open · {total} total" if total else ""
|
|
177
|
+
|
|
178
|
+
lines += [
|
|
179
|
+
f"- **[{label}](projects/{key}/)**",
|
|
180
|
+
"",
|
|
181
|
+
f" ---",
|
|
182
|
+
"",
|
|
183
|
+
]
|
|
184
|
+
if desc:
|
|
185
|
+
lines += [f" {desc}", ""]
|
|
186
|
+
lines += [
|
|
187
|
+
f" [:lucide-external-link: {key}]({site}/projects/{key}) "
|
|
188
|
+
f"[:lucide-list: Browse](projects/{key}/)",
|
|
189
|
+
"",
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
lines += ["</div>", ""]
|
|
193
|
+
|
|
194
|
+
with open(output_path, "w") as f:
|
|
195
|
+
f.write("\n".join(lines))
|
|
196
|
+
|
|
197
|
+
print(f"[DONE] {output_path} — {len(projects)} projects")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ── Nav ────────────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def _generate_nav(projects: list[dict], output_path: str) -> None:
|
|
203
|
+
"""Write config/zensical/nav-jira.toml."""
|
|
204
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
205
|
+
|
|
206
|
+
lines = ["nav = [", ' { "Jira" = [']
|
|
207
|
+
lines.append(' { "Overview" = "jira/index.md" },')
|
|
208
|
+
for p in projects:
|
|
209
|
+
key = p["key"]
|
|
210
|
+
label = p["label"]
|
|
211
|
+
lines.append(f' {{ "{label}" = "jira/projects/{key}/index.md" }},')
|
|
212
|
+
lines += [" ]},", "]", ""]
|
|
213
|
+
|
|
214
|
+
with open(output_path, "w") as f:
|
|
215
|
+
f.write("\n".join(lines))
|
|
216
|
+
|
|
217
|
+
print(f"[DONE] {output_path} — {len(projects)} projects")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── Prepare ────────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def create_jira_pages(
|
|
223
|
+
hub_dir: str = ".",
|
|
224
|
+
data_dir: str | None = None,
|
|
225
|
+
docs_output_dir: str | None = None,
|
|
226
|
+
nav_output_file: str | None = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Generate MkDocs pages for Jira projects fetched into data/jira/."""
|
|
229
|
+
hub = Path(hub_dir)
|
|
230
|
+
resolved_data_dir = str(hub / (data_dir or DATA_DIR))
|
|
231
|
+
resolved_docs_dir = str(hub / (docs_output_dir or DOCS_OUTPUT_DIR))
|
|
232
|
+
resolved_nav_file = str(hub / (nav_output_file or NAV_OUTPUT_FILE))
|
|
233
|
+
|
|
234
|
+
config_projects = load_jira_config(hub_dir)
|
|
235
|
+
if not config_projects:
|
|
236
|
+
print("[INFO] No projects found in jira.toml — nothing to generate.")
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
generated: list[dict] = []
|
|
240
|
+
for p in config_projects:
|
|
241
|
+
output_dir = os.path.join(resolved_docs_dir, "projects", p["key"])
|
|
242
|
+
ok = _generate_project_page(
|
|
243
|
+
output_dir=output_dir,
|
|
244
|
+
key=p["key"],
|
|
245
|
+
label=p["label"],
|
|
246
|
+
data_dir=resolved_data_dir,
|
|
247
|
+
site=p["site"],
|
|
248
|
+
)
|
|
249
|
+
if ok:
|
|
250
|
+
generated.append(p)
|
|
251
|
+
|
|
252
|
+
_generate_hub_page(generated, os.path.join(resolved_docs_dir, "index.md"), resolved_data_dir)
|
|
253
|
+
_generate_nav(generated, resolved_nav_file)
|
|
254
|
+
|
|
255
|
+
print(f"[FINISH] Generated {len(generated)} Jira project page(s).")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def main() -> None:
|
|
259
|
+
hub_dir = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd()
|
|
260
|
+
create_jira_pages(hub_dir=str(hub_dir))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[workspace]
|
|
2
|
+
name = "atlasdocs-jira-dev"
|
|
3
|
+
description = "Development environment for the atlasdocs-jira package"
|
|
4
|
+
channels = ["conda-forge"]
|
|
5
|
+
platforms = ["osx-arm64", "linux-64"]
|
|
6
|
+
|
|
7
|
+
[dependencies]
|
|
8
|
+
python = ">=3.11,<3.15"
|
|
9
|
+
uv = ">=0.4"
|
|
10
|
+
requests = ">=2.0"
|
|
11
|
+
|
|
12
|
+
[tasks.build]
|
|
13
|
+
cmd = "uv build"
|
|
14
|
+
description = "Build wheel and sdist"
|
|
15
|
+
|
|
16
|
+
[tasks.newrelease]
|
|
17
|
+
cmd = "bash scripts/release.sh"
|
|
18
|
+
depends-on = []
|
|
19
|
+
description = "Bump version in pyproject.toml, build, and publish to PyPI"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "atlasdocs-jira"
|
|
7
|
+
version = "0.9.0"
|
|
8
|
+
description = "Jira integration scripts for atlasdocs-theme"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Jason Oliver", email = "jason.oliver@cern.ch" }
|
|
11
|
+
]
|
|
12
|
+
license = "Apache-2.0"
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = ["requests>=2.28", "python-dotenv>=1.0"]
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
include = ["atlasdocs_jira"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
fetch-jira = "atlasdocs_jira.fetch_jira:main"
|
|
21
|
+
prepare-jira-pages = "atlasdocs_jira.prepare_jira_pages:main"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
PYPROJECT="$(dirname "$0")/../pyproject.toml"
|
|
5
|
+
PYPROJECT="$(realpath "$PYPROJECT")"
|
|
6
|
+
PKG_DIR="$(dirname "$PYPROJECT")"
|
|
7
|
+
|
|
8
|
+
current_version() {
|
|
9
|
+
grep '^version' "$PYPROJECT" | sed 's/version = "\(.*\)"/\1/'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
bump_version() {
|
|
13
|
+
local part="${1:-patch}"
|
|
14
|
+
local ver
|
|
15
|
+
ver="$(current_version)"
|
|
16
|
+
IFS='.' read -r major minor patch <<< "$ver"
|
|
17
|
+
case "$part" in
|
|
18
|
+
major) major=$((major + 1)); minor=0; patch=0 ;;
|
|
19
|
+
minor) minor=$((minor + 1)); patch=0 ;;
|
|
20
|
+
patch) patch=$((patch + 1)) ;;
|
|
21
|
+
*) echo "Unknown part: $part (use major|minor|patch)"; exit 1 ;;
|
|
22
|
+
esac
|
|
23
|
+
echo "${major}.${minor}.${patch}"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
set_version() {
|
|
27
|
+
local new="$1"
|
|
28
|
+
sed -i '' "s/^version = \".*\"/version = \"${new}\"/" "$PYPROJECT"
|
|
29
|
+
echo "Version set to ${new}"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
PART="${1:-}"
|
|
33
|
+
SKIP_PUBLISH="${2:-}"
|
|
34
|
+
CURRENT="$(current_version)"
|
|
35
|
+
|
|
36
|
+
if [[ -z "$PART" ]]; then
|
|
37
|
+
echo "Current version: ${CURRENT}"
|
|
38
|
+
echo "Increment: [1] patch [2] minor [3] major"
|
|
39
|
+
read -r -p "Choice [1]: " choice
|
|
40
|
+
case "${choice:-1}" in
|
|
41
|
+
1|patch) PART="patch" ;;
|
|
42
|
+
2|minor) PART="minor" ;;
|
|
43
|
+
3|major) PART="major" ;;
|
|
44
|
+
*) echo "Invalid choice."; exit 1 ;;
|
|
45
|
+
esac
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
if [[ "$PART" == exact:* ]]; then
|
|
49
|
+
NEW_VERSION="${PART#exact:}"
|
|
50
|
+
else
|
|
51
|
+
NEW_VERSION="$(bump_version "$PART")"
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
echo "Current: ${CURRENT} → New: ${NEW_VERSION} (${PART})"
|
|
55
|
+
read -r -p "Continue? [y/N] " confirm
|
|
56
|
+
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
|
57
|
+
|
|
58
|
+
set_version "$NEW_VERSION"
|
|
59
|
+
|
|
60
|
+
echo ""
|
|
61
|
+
echo "▶ uv build"
|
|
62
|
+
uv build --project "$PKG_DIR"
|
|
63
|
+
|
|
64
|
+
if [[ "$SKIP_PUBLISH" != "--no-publish" ]]; then
|
|
65
|
+
echo ""
|
|
66
|
+
echo "▶ uv publish"
|
|
67
|
+
uv publish dist/atlasdocs_jira-"${NEW_VERSION}"*.whl dist/atlasdocs_jira-"${NEW_VERSION}".tar.gz
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
echo ""
|
|
71
|
+
echo "▶ git add + commit + tag"
|
|
72
|
+
REPO_ROOT="$(git -C "$PKG_DIR" rev-parse --show-toplevel)"
|
|
73
|
+
git -C "$REPO_ROOT" add "$PYPROJECT"
|
|
74
|
+
git -C "$REPO_ROOT" commit -m "chore(jira): release ${NEW_VERSION}"
|
|
75
|
+
git -C "$REPO_ROOT" tag "atlasdocs-jira/v${NEW_VERSION}"
|
|
76
|
+
|
|
77
|
+
echo ""
|
|
78
|
+
echo "✓ Released atlasdocs-jira ${NEW_VERSION}"
|
|
79
|
+
echo " Push with: git push && git push --tags"
|