esys-gitlog 0.2.0__tar.gz → 0.3.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.
- {esys_gitlog-0.2.0/src/esys_gitlog.egg-info → esys_gitlog-0.3.0}/PKG-INFO +44 -2
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/README.md +43 -1
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/pyproject.toml +6 -1
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/__init__.py +1 -1
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/cli.py +96 -6
- esys_gitlog-0.3.0/src/esys_gitlog/detection.py +377 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/identifiers.py +11 -1
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/interpret.py +6 -2
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/links.py +21 -5
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/preferences.py +128 -1
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0/src/esys_gitlog.egg-info}/PKG-INFO +44 -2
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog.egg-info/SOURCES.txt +2 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_browser_display.py +21 -0
- esys_gitlog-0.3.0/tests/test_coverage_release_030.py +347 -0
- esys_gitlog-0.3.0/tests/test_detection.py +238 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_preferences.py +49 -2
- esys_gitlog-0.3.0/tests/test_seed_jira_live_fixture.py +29 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_semantic_default.py +9 -0
- esys_gitlog-0.2.0/src/esys_gitlog/detection.py +0 -145
- esys_gitlog-0.2.0/tests/test_detection.py +0 -77
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/LICENSE.txt +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/setup.cfg +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/__main__.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/app.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/config.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/display.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/git_adapter.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/model.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/pager.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/pipeline.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/qa.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/render.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/report.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/tool_paths.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog.egg-info/dependency_links.txt +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog.egg-info/entry_points.txt +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog.egg-info/requires.txt +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog.egg-info/top_level.txt +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_cli.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_coverage_gaps.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_git_adapter.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_installed_launcher.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_pager.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_render.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_semantic_p4.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_smoke.py +0 -0
- {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_tool_paths.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: esys-gitlog
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python implementation of git elog
|
|
5
5
|
Author: Project maintainers
|
|
6
6
|
License-Expression: MIT
|
|
@@ -55,9 +55,51 @@ and named profiles follow root SADR-0009 / SADR-0010 and contract CADR-0012.
|
|
|
55
55
|
elog --preferences path.json # preference overlay
|
|
56
56
|
elog --profile acme # built-in or profiles/acme.json
|
|
57
57
|
elog --config path/profile.json # one-off profile file
|
|
58
|
-
elog preferences detect # probe jira/
|
|
58
|
+
elog preferences detect # probe jira-cli/acli + git remote; write missing user keys
|
|
59
|
+
elog preferences detect --json --no-write # machine-readable candidates (extension setup)
|
|
60
|
+
elog preferences set-link jira-id 'https://host/browse/{id}'
|
|
61
|
+
elog preferences clear-link jira-id
|
|
62
|
+
elog preferences set-tool jira 'C:\tools\jira.exe' # optional absolute path
|
|
63
|
+
elog preferences set-tool acli /usr/local/bin/acli
|
|
59
64
|
```
|
|
60
65
|
|
|
66
|
+
Jira browse bases come from local **jira-cli** config and/or **Atlassian acli**
|
|
67
|
+
(`acli jira config get site`), never from per-commit HTTP (SADR-0010). Detection
|
|
68
|
+
only fills unset user keys unless you use `set-link`. Absolute paths live under
|
|
69
|
+
`detection.jiraExecutable` / `detection.acliExecutable` when the tools are not
|
|
70
|
+
on `PATH`.
|
|
71
|
+
|
|
72
|
+
### Live Jira fixture (SCRUM)
|
|
73
|
+
|
|
74
|
+
The free site [libesys.atlassian.net](https://libesys.atlassian.net/browse/SCRUM-1)
|
|
75
|
+
uses project key **`SCRUM`** only (do not invent other project keys).
|
|
76
|
+
|
|
77
|
+
1. Configure acli or jira-cli against that site (optional absolute paths via
|
|
78
|
+
`elog preferences set-tool`).
|
|
79
|
+
2. Seed once (creates 10 new issues, writes the pin — not part of every CI run):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python scripts/seed_jira_live_fixture.py --project SCRUM --count 10
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
3. Commit `tests/integration/jira_live.pin.json`.
|
|
86
|
+
4. Materialize a durable Git repo for the VS Code extension / manual checks:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
python scripts/materialize_jira_live_repo.py
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Output: `tests/integration/jira_live_repo/` (gitignored except a placeholder
|
|
93
|
+
README). Open that folder, store the browse template, refresh the commit browser.
|
|
94
|
+
5. Run the live test locally or enable GitLab variable `ESYS_GITLOG_JIRA_LIVE=1`
|
|
95
|
+
for job `test:jira-live`:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
ESYS_GITLOG_JIRA_LIVE=1 pytest -m jira_live -o addopts= --no-cov
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Default `pytest` excludes `@pytest.mark.jira_live` so coverage stays offline.
|
|
102
|
+
|
|
61
103
|
## Standalone binary (optional)
|
|
62
104
|
|
|
63
105
|
The primary install remains the wheel + `git-elog` / `elog` console scripts. For a
|
|
@@ -33,9 +33,51 @@ and named profiles follow root SADR-0009 / SADR-0010 and contract CADR-0012.
|
|
|
33
33
|
elog --preferences path.json # preference overlay
|
|
34
34
|
elog --profile acme # built-in or profiles/acme.json
|
|
35
35
|
elog --config path/profile.json # one-off profile file
|
|
36
|
-
elog preferences detect # probe jira/
|
|
36
|
+
elog preferences detect # probe jira-cli/acli + git remote; write missing user keys
|
|
37
|
+
elog preferences detect --json --no-write # machine-readable candidates (extension setup)
|
|
38
|
+
elog preferences set-link jira-id 'https://host/browse/{id}'
|
|
39
|
+
elog preferences clear-link jira-id
|
|
40
|
+
elog preferences set-tool jira 'C:\tools\jira.exe' # optional absolute path
|
|
41
|
+
elog preferences set-tool acli /usr/local/bin/acli
|
|
37
42
|
```
|
|
38
43
|
|
|
44
|
+
Jira browse bases come from local **jira-cli** config and/or **Atlassian acli**
|
|
45
|
+
(`acli jira config get site`), never from per-commit HTTP (SADR-0010). Detection
|
|
46
|
+
only fills unset user keys unless you use `set-link`. Absolute paths live under
|
|
47
|
+
`detection.jiraExecutable` / `detection.acliExecutable` when the tools are not
|
|
48
|
+
on `PATH`.
|
|
49
|
+
|
|
50
|
+
### Live Jira fixture (SCRUM)
|
|
51
|
+
|
|
52
|
+
The free site [libesys.atlassian.net](https://libesys.atlassian.net/browse/SCRUM-1)
|
|
53
|
+
uses project key **`SCRUM`** only (do not invent other project keys).
|
|
54
|
+
|
|
55
|
+
1. Configure acli or jira-cli against that site (optional absolute paths via
|
|
56
|
+
`elog preferences set-tool`).
|
|
57
|
+
2. Seed once (creates 10 new issues, writes the pin — not part of every CI run):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python scripts/seed_jira_live_fixture.py --project SCRUM --count 10
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
3. Commit `tests/integration/jira_live.pin.json`.
|
|
64
|
+
4. Materialize a durable Git repo for the VS Code extension / manual checks:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
python scripts/materialize_jira_live_repo.py
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Output: `tests/integration/jira_live_repo/` (gitignored except a placeholder
|
|
71
|
+
README). Open that folder, store the browse template, refresh the commit browser.
|
|
72
|
+
5. Run the live test locally or enable GitLab variable `ESYS_GITLOG_JIRA_LIVE=1`
|
|
73
|
+
for job `test:jira-live`:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
ESYS_GITLOG_JIRA_LIVE=1 pytest -m jira_live -o addopts= --no-cov
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Default `pytest` excludes `@pytest.mark.jira_live` so coverage stays offline.
|
|
80
|
+
|
|
39
81
|
## Standalone binary (optional)
|
|
40
82
|
|
|
41
83
|
The primary install remains the wheel + `git-elog` / `elog` console scripts. For a
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "esys-gitlog"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Python implementation of git elog"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -41,7 +41,12 @@ where = ["src"]
|
|
|
41
41
|
[tool.pytest.ini_options]
|
|
42
42
|
testpaths = ["tests"]
|
|
43
43
|
pythonpath = [".", "src"]
|
|
44
|
+
markers = [
|
|
45
|
+
"jira_live: live Jira fixture (ESYS_GITLOG_JIRA_LIVE=1 + seeded pin)",
|
|
46
|
+
]
|
|
44
47
|
addopts = [
|
|
48
|
+
"-m",
|
|
49
|
+
"not jira_live",
|
|
45
50
|
"--cov=esys_gitlog",
|
|
46
51
|
"--cov-report=term-missing",
|
|
47
52
|
"--cov-fail-under=95.01",
|
|
@@ -11,13 +11,17 @@ from typer.core import TyperGroup
|
|
|
11
11
|
|
|
12
12
|
from esys_gitlog import app as application
|
|
13
13
|
from esys_gitlog.config import DisplayMode, QaMode
|
|
14
|
-
from esys_gitlog.detection import
|
|
14
|
+
from esys_gitlog.detection import detect_report, dump_detection_debug
|
|
15
15
|
from esys_gitlog.git_adapter import GitError, filter_git_log_args
|
|
16
16
|
from esys_gitlog.preferences import (
|
|
17
|
+
LinkPreferences,
|
|
18
|
+
clear_user_ref_link,
|
|
17
19
|
fill_link_gaps,
|
|
18
20
|
load_layered_preferences,
|
|
19
21
|
merge_missing_links_into_user_file,
|
|
20
22
|
preferences_to_object,
|
|
23
|
+
set_user_detection_executables,
|
|
24
|
+
set_user_ref_link,
|
|
21
25
|
)
|
|
22
26
|
from esys_gitlog.tool_paths import user_preferences_path
|
|
23
27
|
|
|
@@ -299,13 +303,40 @@ def preferences_detect(
|
|
|
299
303
|
"--debug",
|
|
300
304
|
help="Print which vendor CLIs are on PATH.",
|
|
301
305
|
),
|
|
306
|
+
as_json: bool = typer.Option(
|
|
307
|
+
False,
|
|
308
|
+
"--json",
|
|
309
|
+
help="Print a machine-readable detection report (candidates, sources, tools).",
|
|
310
|
+
),
|
|
311
|
+
probe_auth: bool = typer.Option(
|
|
312
|
+
False,
|
|
313
|
+
"--probe-auth",
|
|
314
|
+
help="Optionally probe jira-cli/acli auth (setup only; may touch the network).",
|
|
315
|
+
),
|
|
302
316
|
) -> None:
|
|
303
|
-
"""Probe local Jira/GitHub/GitLab CLIs and git remote; optionally persist."""
|
|
317
|
+
"""Probe local Jira (jira-cli/acli), GitHub/GitLab CLIs and git remote; optionally persist."""
|
|
304
318
|
cwd = Path.cwd()
|
|
305
319
|
prefs = load_layered_preferences(cwd)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
320
|
+
report = detect_report(
|
|
321
|
+
cwd,
|
|
322
|
+
detection=prefs.detection,
|
|
323
|
+
probe_auth=probe_auth,
|
|
324
|
+
)
|
|
325
|
+
discovered = LinkPreferences(
|
|
326
|
+
ref_links=dict(report.get("refLinks") or {}),
|
|
327
|
+
commit_link=report.get("commitLink"),
|
|
328
|
+
)
|
|
329
|
+
if show_debug and not as_json:
|
|
330
|
+
typer.echo(dump_detection_debug(prefs.detection))
|
|
331
|
+
if as_json:
|
|
332
|
+
if write:
|
|
333
|
+
path = user_preferences_path()
|
|
334
|
+
merge_missing_links_into_user_file(discovered=discovered, user_path=path)
|
|
335
|
+
report["wrote"] = str(path)
|
|
336
|
+
else:
|
|
337
|
+
report["wrote"] = None
|
|
338
|
+
typer.echo(json.dumps(report, indent=2))
|
|
339
|
+
return
|
|
309
340
|
merged = fill_link_gaps(prefs, discovered)
|
|
310
341
|
typer.echo(json.dumps(preferences_to_object(merged), indent=2))
|
|
311
342
|
if write:
|
|
@@ -314,9 +345,68 @@ def preferences_detect(
|
|
|
314
345
|
typer.echo(f"wrote missing keys to {path}", err=True)
|
|
315
346
|
|
|
316
347
|
|
|
348
|
+
@preferences_app.command("set-link")
|
|
349
|
+
def preferences_set_link(
|
|
350
|
+
family: str = typer.Argument(help="Identifier family, e.g. jira-id"),
|
|
351
|
+
template: str = typer.Argument(
|
|
352
|
+
help="URL template with {id}, e.g. https://host/browse/{id}",
|
|
353
|
+
),
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Create or overwrite a user-layer ref link template (explicit settings edit)."""
|
|
356
|
+
path = user_preferences_path()
|
|
357
|
+
set_user_ref_link(family, template, user_path=path)
|
|
358
|
+
typer.echo(json.dumps({"family": family, "template": template, "path": str(path)}, indent=2))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@preferences_app.command("clear-link")
|
|
362
|
+
def preferences_clear_link(
|
|
363
|
+
family: str = typer.Argument(help="Identifier family, e.g. jira-id"),
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Remove a user-layer ref link template."""
|
|
366
|
+
path = user_preferences_path()
|
|
367
|
+
clear_user_ref_link(family, user_path=path)
|
|
368
|
+
typer.echo(json.dumps({"family": family, "cleared": True, "path": str(path)}, indent=2))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@preferences_app.command("set-tool")
|
|
372
|
+
def preferences_set_tool(
|
|
373
|
+
tool: str = typer.Argument(help="Tool name: jira or acli"),
|
|
374
|
+
executable: str = typer.Argument(help="Absolute path to the executable"),
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Store an absolute path for jira-cli or acli (when not on PATH)."""
|
|
377
|
+
name = tool.strip().lower()
|
|
378
|
+
path = user_preferences_path()
|
|
379
|
+
if name in ("jira", "jira-cli"):
|
|
380
|
+
set_user_detection_executables(jira_executable=executable, user_path=path)
|
|
381
|
+
elif name == "acli":
|
|
382
|
+
set_user_detection_executables(acli_executable=executable, user_path=path)
|
|
383
|
+
else:
|
|
384
|
+
typer.echo("tool must be 'jira' or 'acli'", err=True)
|
|
385
|
+
raise typer.Exit(code=2)
|
|
386
|
+
typer.echo(json.dumps({"tool": name, "executable": executable, "path": str(path)}, indent=2))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@preferences_app.command("clear-tool")
|
|
390
|
+
def preferences_clear_tool(
|
|
391
|
+
tool: str = typer.Argument(help="Tool name: jira or acli"),
|
|
392
|
+
) -> None:
|
|
393
|
+
"""Remove a configured jira-cli or acli path (fall back to PATH)."""
|
|
394
|
+
name = tool.strip().lower()
|
|
395
|
+
path = user_preferences_path()
|
|
396
|
+
if name in ("jira", "jira-cli"):
|
|
397
|
+
set_user_detection_executables(clear_jira=True, user_path=path)
|
|
398
|
+
elif name == "acli":
|
|
399
|
+
set_user_detection_executables(clear_acli=True, user_path=path)
|
|
400
|
+
else:
|
|
401
|
+
typer.echo("tool must be 'jira' or 'acli'", err=True)
|
|
402
|
+
raise typer.Exit(code=2)
|
|
403
|
+
typer.echo(json.dumps({"tool": name, "cleared": True, "path": str(path)}, indent=2))
|
|
404
|
+
|
|
405
|
+
|
|
317
406
|
def main() -> None:
|
|
318
407
|
"""Console script entry point."""
|
|
319
|
-
|
|
408
|
+
# Normalize Windows separators so ``Path.name`` works when run under POSIX.
|
|
409
|
+
invoked = Path(sys.argv[0].replace("\\", "/")).name
|
|
320
410
|
if invoked.lower().endswith(".exe"):
|
|
321
411
|
invoked = invoked[:-4]
|
|
322
412
|
prog_name = invoked or "git-elog"
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Optional local CLI / git-remote link-base detection (SADR-0010).
|
|
2
|
+
|
|
3
|
+
Jira browse bases may come from ankitpokhrel/jira-cli and/or Atlassian acli.
|
|
4
|
+
Discovery prefers local config files, then non-network CLI config gets. Optional
|
|
5
|
+
auth probes are setup-only and never run per commit refresh.
|
|
6
|
+
|
|
7
|
+
Configured absolute paths (``detection.jiraExecutable`` /
|
|
8
|
+
``detection.acliExecutable``) win over PATH lookup.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from esys_gitlog.links import detect_commit_link_from_remote, read_origin_url
|
|
22
|
+
from esys_gitlog.preferences import DetectionSettings, LinkPreferences
|
|
23
|
+
|
|
24
|
+
_JIRA_SERVER = re.compile(r"""(?mx)^\s*(?:server|endpoint|url)\s*[:=]\s*["']?(https?://[^\s"']+)""")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class JiraDetectResult:
|
|
29
|
+
"""Outcome of local Jira browse-base detection."""
|
|
30
|
+
|
|
31
|
+
template: str | None
|
|
32
|
+
source: str | None
|
|
33
|
+
auth_ok: bool | None = None
|
|
34
|
+
auth_source: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ResolvedToolPaths:
|
|
39
|
+
"""Resolved absolute executables (or None when unavailable)."""
|
|
40
|
+
|
|
41
|
+
jira: str | None = None
|
|
42
|
+
acli: str | None = None
|
|
43
|
+
jira_configured: str | None = None
|
|
44
|
+
acli_configured: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_executable(name: str, configured: str | None = None) -> str | None:
|
|
48
|
+
"""Prefer an absolute configured path when it exists; else ``PATH`` lookup."""
|
|
49
|
+
if configured:
|
|
50
|
+
candidate = Path(configured)
|
|
51
|
+
if candidate.is_file():
|
|
52
|
+
return str(candidate.resolve())
|
|
53
|
+
# Windows often omits ``.exe`` in settings.
|
|
54
|
+
for suffix in ("", ".exe", ".cmd", ".bat"):
|
|
55
|
+
alt = Path(str(candidate) + suffix) if suffix else candidate
|
|
56
|
+
if alt.is_file():
|
|
57
|
+
return str(alt.resolve())
|
|
58
|
+
return shutil.which(name)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_tool_paths(detection: DetectionSettings | None = None) -> ResolvedToolPaths:
|
|
62
|
+
settings = detection or DetectionSettings()
|
|
63
|
+
return ResolvedToolPaths(
|
|
64
|
+
jira=resolve_executable("jira", settings.jira_executable),
|
|
65
|
+
acli=resolve_executable("acli", settings.acli_executable),
|
|
66
|
+
jira_configured=settings.jira_executable,
|
|
67
|
+
acli_configured=settings.acli_executable,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _run_text(
|
|
72
|
+
argv: list[str],
|
|
73
|
+
*,
|
|
74
|
+
timeout: float = 5.0,
|
|
75
|
+
executable: str | None = None,
|
|
76
|
+
) -> str | None:
|
|
77
|
+
exe = executable or shutil.which(argv[0])
|
|
78
|
+
if not exe:
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
completed = subprocess.run(
|
|
82
|
+
[exe, *argv[1:]],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
check=False,
|
|
85
|
+
text=True,
|
|
86
|
+
timeout=timeout,
|
|
87
|
+
)
|
|
88
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
89
|
+
return None
|
|
90
|
+
if completed.returncode != 0:
|
|
91
|
+
return None
|
|
92
|
+
text = (completed.stdout or "").strip()
|
|
93
|
+
return text or None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _browse_template(base: str) -> str:
|
|
97
|
+
return base.rstrip("/") + "/browse/{id}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _http_base(value: str) -> str | None:
|
|
101
|
+
text = value.strip().strip("\"'")
|
|
102
|
+
if not text.startswith("http"):
|
|
103
|
+
return None
|
|
104
|
+
# acli may print a bare site host URL; strip trailing paths that are not browse.
|
|
105
|
+
return text.rstrip("/")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def jira_config_candidates() -> list[Path]:
|
|
109
|
+
"""Known ankitpokhrel/jira-cli config paths (local, no network)."""
|
|
110
|
+
home = Path.home()
|
|
111
|
+
return [
|
|
112
|
+
home / ".config" / ".jira" / ".config.yml",
|
|
113
|
+
home / ".jira" / ".config.yml",
|
|
114
|
+
home / ".config" / "jira" / "config.yml",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Back-compat alias used by tests.
|
|
119
|
+
_jira_config_candidates = jira_config_candidates
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _jira_from_config_files() -> tuple[str | None, str | None]:
|
|
123
|
+
for candidate in jira_config_candidates():
|
|
124
|
+
if not candidate.is_file():
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
text = candidate.read_text(encoding="utf-8", errors="replace")
|
|
128
|
+
except OSError:
|
|
129
|
+
continue
|
|
130
|
+
match = _JIRA_SERVER.search(text)
|
|
131
|
+
if match:
|
|
132
|
+
base = _http_base(match.group(1))
|
|
133
|
+
if base:
|
|
134
|
+
return _browse_template(base), f"jira-cli-config:{candidate}"
|
|
135
|
+
return None, None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _jira_from_jira_cli(tools: ResolvedToolPaths) -> tuple[str | None, str | None]:
|
|
139
|
+
value = _run_text(
|
|
140
|
+
["jira", "config", "get", "server"],
|
|
141
|
+
executable=tools.jira,
|
|
142
|
+
)
|
|
143
|
+
if value:
|
|
144
|
+
base = _http_base(value)
|
|
145
|
+
if base:
|
|
146
|
+
return _browse_template(base), "jira-cli:config-get-server"
|
|
147
|
+
return None, None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _jira_from_acli(tools: ResolvedToolPaths) -> tuple[str | None, str | None]:
|
|
151
|
+
value = _run_text(
|
|
152
|
+
["acli", "jira", "config", "get", "site"],
|
|
153
|
+
executable=tools.acli,
|
|
154
|
+
)
|
|
155
|
+
if value:
|
|
156
|
+
base = _http_base(value)
|
|
157
|
+
if base:
|
|
158
|
+
return _browse_template(base), "acli:config-get-site"
|
|
159
|
+
return None, None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def probe_jira_auth(
|
|
163
|
+
detection: DetectionSettings | None = None,
|
|
164
|
+
) -> tuple[bool | None, str | None]:
|
|
165
|
+
"""Best-effort auth check (setup only). Does not invent browse URLs.
|
|
166
|
+
|
|
167
|
+
Tries jira-cli ``me`` then acli identity-style commands. ``None`` means
|
|
168
|
+
neither CLI is available or no conclusive result.
|
|
169
|
+
"""
|
|
170
|
+
tools = resolve_tool_paths(detection)
|
|
171
|
+
tried = False
|
|
172
|
+
if tools.jira:
|
|
173
|
+
tried = True
|
|
174
|
+
text = _run_text(
|
|
175
|
+
["jira", "me", "--plain"],
|
|
176
|
+
timeout=8.0,
|
|
177
|
+
executable=tools.jira,
|
|
178
|
+
)
|
|
179
|
+
if text:
|
|
180
|
+
return True, "jira-cli:me"
|
|
181
|
+
return False, "jira-cli:me"
|
|
182
|
+
if tools.acli:
|
|
183
|
+
tried = True
|
|
184
|
+
for argv in (
|
|
185
|
+
["acli", "jira", "auth", "status"],
|
|
186
|
+
["acli", "jira", "me"],
|
|
187
|
+
):
|
|
188
|
+
text = _run_text(argv, timeout=8.0, executable=tools.acli)
|
|
189
|
+
if text:
|
|
190
|
+
return True, f"acli:{argv[-1]}"
|
|
191
|
+
return False, "acli"
|
|
192
|
+
return (False, None) if tried else (None, None)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def detect_jira_ref_link_detailed(
|
|
196
|
+
*,
|
|
197
|
+
probe_auth: bool = False,
|
|
198
|
+
detection: DetectionSettings | None = None,
|
|
199
|
+
) -> JiraDetectResult:
|
|
200
|
+
"""Detect a ``jira-id`` browse template; optionally probe CLI auth."""
|
|
201
|
+
tools = resolve_tool_paths(detection)
|
|
202
|
+
finders = (
|
|
203
|
+
_jira_from_config_files,
|
|
204
|
+
lambda: _jira_from_jira_cli(tools),
|
|
205
|
+
lambda: _jira_from_acli(tools),
|
|
206
|
+
)
|
|
207
|
+
for finder in finders:
|
|
208
|
+
template, source = finder()
|
|
209
|
+
if template:
|
|
210
|
+
auth_ok: bool | None = None
|
|
211
|
+
auth_source: str | None = None
|
|
212
|
+
if probe_auth:
|
|
213
|
+
auth_ok, auth_source = probe_jira_auth(detection)
|
|
214
|
+
return JiraDetectResult(
|
|
215
|
+
template=template,
|
|
216
|
+
source=source,
|
|
217
|
+
auth_ok=auth_ok,
|
|
218
|
+
auth_source=auth_source,
|
|
219
|
+
)
|
|
220
|
+
auth_ok = None
|
|
221
|
+
auth_source = None
|
|
222
|
+
if probe_auth:
|
|
223
|
+
auth_ok, auth_source = probe_jira_auth(detection)
|
|
224
|
+
return JiraDetectResult(
|
|
225
|
+
template=None,
|
|
226
|
+
source=None,
|
|
227
|
+
auth_ok=auth_ok,
|
|
228
|
+
auth_source=auth_source,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def detect_jira_ref_link(
|
|
233
|
+
*,
|
|
234
|
+
detection: DetectionSettings | None = None,
|
|
235
|
+
) -> str | None:
|
|
236
|
+
"""Return a ``jira-id`` browse template from local jira CLI config if possible."""
|
|
237
|
+
return detect_jira_ref_link_detailed(probe_auth=False, detection=detection).template
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def detect_github_commit_link(*, cwd: Path | None = None) -> str | None:
|
|
241
|
+
"""Prefer ``gh`` host config; fall back to origin remote when appropriate."""
|
|
242
|
+
host = _run_text(["gh", "config", "get", "host"])
|
|
243
|
+
if host and "github" in host:
|
|
244
|
+
# Repo-specific commit URL still needs remote path; leave to git remote.
|
|
245
|
+
return None
|
|
246
|
+
# ``gh repo view --json url`` needs network; skip.
|
|
247
|
+
_ = cwd
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def detect_gitlab_commit_link() -> str | None:
|
|
252
|
+
host = _run_text(["glab", "config", "get", "host"])
|
|
253
|
+
if host and host.startswith("http"):
|
|
254
|
+
# Project path still needs git remote; host alone is not a commit template.
|
|
255
|
+
return None
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def detect_links(
|
|
260
|
+
cwd: Path,
|
|
261
|
+
*,
|
|
262
|
+
detection: DetectionSettings | None = None,
|
|
263
|
+
git_executable: str = "git",
|
|
264
|
+
) -> LinkPreferences:
|
|
265
|
+
"""Discover link templates for unset preference gaps (best-effort, local)."""
|
|
266
|
+
settings = detection or DetectionSettings()
|
|
267
|
+
cli_on, git_on, _persist = settings.resolved()
|
|
268
|
+
ref_links: dict[str, str] = {}
|
|
269
|
+
commit_link: str | None = None
|
|
270
|
+
|
|
271
|
+
if cli_on:
|
|
272
|
+
jira = detect_jira_ref_link(detection=settings)
|
|
273
|
+
if jira:
|
|
274
|
+
ref_links["jira-id"] = jira
|
|
275
|
+
# gh/glab currently contribute via git remote for commit URLs.
|
|
276
|
+
detect_github_commit_link(cwd=cwd)
|
|
277
|
+
detect_gitlab_commit_link()
|
|
278
|
+
|
|
279
|
+
if git_on:
|
|
280
|
+
remote = read_origin_url(cwd, git_executable=git_executable)
|
|
281
|
+
if remote:
|
|
282
|
+
commit_link = detect_commit_link_from_remote(remote)
|
|
283
|
+
|
|
284
|
+
return LinkPreferences(ref_links=ref_links, commit_link=commit_link)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def detect_report(
|
|
288
|
+
cwd: Path,
|
|
289
|
+
*,
|
|
290
|
+
detection: DetectionSettings | None = None,
|
|
291
|
+
git_executable: str = "git",
|
|
292
|
+
probe_auth: bool = False,
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
"""Machine-readable detection summary for CLI / extension (no write)."""
|
|
295
|
+
settings = detection or DetectionSettings()
|
|
296
|
+
cli_on, git_on, _persist = settings.resolved()
|
|
297
|
+
tools = resolve_tool_paths(settings)
|
|
298
|
+
jira = (
|
|
299
|
+
detect_jira_ref_link_detailed(probe_auth=probe_auth, detection=settings)
|
|
300
|
+
if cli_on
|
|
301
|
+
else JiraDetectResult(None, None)
|
|
302
|
+
)
|
|
303
|
+
commit_link: str | None = None
|
|
304
|
+
commit_source: str | None = None
|
|
305
|
+
if git_on:
|
|
306
|
+
remote = read_origin_url(cwd, git_executable=git_executable)
|
|
307
|
+
if remote:
|
|
308
|
+
commit_link = detect_commit_link_from_remote(remote)
|
|
309
|
+
if commit_link:
|
|
310
|
+
commit_source = "git-remote:origin"
|
|
311
|
+
|
|
312
|
+
ref_links: dict[str, str] = {}
|
|
313
|
+
sources: dict[str, str] = {}
|
|
314
|
+
if jira.template:
|
|
315
|
+
ref_links["jira-id"] = jira.template
|
|
316
|
+
if jira.source:
|
|
317
|
+
sources["jira-id"] = jira.source
|
|
318
|
+
if commit_link:
|
|
319
|
+
sources["commitLink"] = commit_source or "git-remote:origin"
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
"refLinks": ref_links,
|
|
323
|
+
"commitLink": commit_link,
|
|
324
|
+
"sources": sources,
|
|
325
|
+
"auth": {
|
|
326
|
+
"ok": jira.auth_ok,
|
|
327
|
+
"source": jira.auth_source,
|
|
328
|
+
"probed": probe_auth,
|
|
329
|
+
},
|
|
330
|
+
"tools": {
|
|
331
|
+
"jiraOnPath": bool(shutil.which("jira")),
|
|
332
|
+
"acliOnPath": bool(shutil.which("acli")),
|
|
333
|
+
"jiraResolved": tools.jira,
|
|
334
|
+
"acliResolved": tools.acli,
|
|
335
|
+
"jiraConfigured": tools.jira_configured,
|
|
336
|
+
"acliConfigured": tools.acli_configured,
|
|
337
|
+
"ghOnPath": bool(shutil.which("gh")),
|
|
338
|
+
"glabOnPath": bool(shutil.which("glab")),
|
|
339
|
+
},
|
|
340
|
+
"jiraConfigCandidates": [str(p) for p in jira_config_candidates()],
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def read_gh_host_file() -> str | None:
|
|
345
|
+
"""Test helper: parse ``gh`` hosts.yml for a github.com entry."""
|
|
346
|
+
path = Path.home() / ".config" / "gh" / "hosts.yml"
|
|
347
|
+
if not path.is_file():
|
|
348
|
+
return None
|
|
349
|
+
try:
|
|
350
|
+
# Avoid requiring PyYAML; look for github.com entry.
|
|
351
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
352
|
+
except OSError:
|
|
353
|
+
return None
|
|
354
|
+
if "github.com:" in text or "github.com" in text:
|
|
355
|
+
return "https://github.com"
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def dump_detection_debug(
|
|
360
|
+
detection: DetectionSettings | None = None,
|
|
361
|
+
) -> str:
|
|
362
|
+
"""Return a short JSON summary of what would be detected (for CLI)."""
|
|
363
|
+
tools = resolve_tool_paths(detection)
|
|
364
|
+
return json.dumps(
|
|
365
|
+
{
|
|
366
|
+
"jiraConfigCandidates": [str(p) for p in jira_config_candidates()],
|
|
367
|
+
"ghOnPath": bool(shutil.which("gh")),
|
|
368
|
+
"glabOnPath": bool(shutil.which("glab")),
|
|
369
|
+
"jiraOnPath": bool(shutil.which("jira")),
|
|
370
|
+
"acliOnPath": bool(shutil.which("acli")),
|
|
371
|
+
"jiraResolved": tools.jira,
|
|
372
|
+
"acliResolved": tools.acli,
|
|
373
|
+
"jiraConfigured": tools.jira_configured,
|
|
374
|
+
"acliConfigured": tools.acli_configured,
|
|
375
|
+
},
|
|
376
|
+
indent=2,
|
|
377
|
+
)
|
|
@@ -10,7 +10,12 @@ _JIRA_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_]*-\d+$")
|
|
|
10
10
|
# Subject-edge reference tokens (CADR-0005): bare id or optional square brackets.
|
|
11
11
|
# Prefix may use a trailing colon instead of whitespace.
|
|
12
12
|
_SUBJECT_PREFIX_REF = re.compile(r"^(?:\[)?(?P<token>[A-Za-z][A-Za-z0-9_]*-\d+)(?:\])?(?::|\s+)\s*")
|
|
13
|
-
|
|
13
|
+
# Suffix: prefer ``[KEY-123]`` (optional space before '['), else bare `` KEY-123``.
|
|
14
|
+
# Separate group names — Python ``re`` does not allow the same name in both arms.
|
|
15
|
+
_SUBJECT_SUFFIX_REF = re.compile(
|
|
16
|
+
r"(?:\s*\[(?P<bracket>[A-Za-z][A-Za-z0-9_]*-\d+)\]"
|
|
17
|
+
r"|\s+(?P<bare>[A-Za-z][A-Za-z0-9_]*-\d+))\s*$"
|
|
18
|
+
)
|
|
14
19
|
|
|
15
20
|
# Shapes from references.md that any-id accepts beyond the Jira key.
|
|
16
21
|
_ANY_EXTRA = (
|
|
@@ -61,6 +66,11 @@ def match_subject_suffix_ref(subject_line: str) -> re.Match[str] | None:
|
|
|
61
66
|
return _SUBJECT_SUFFIX_REF.search(subject_line)
|
|
62
67
|
|
|
63
68
|
|
|
69
|
+
def subject_suffix_token(match: re.Match[str]) -> str:
|
|
70
|
+
"""Return the captured suffix id from a ``match_subject_suffix_ref`` match."""
|
|
71
|
+
return match.group("bracket") or match.group("bare") or ""
|
|
72
|
+
|
|
73
|
+
|
|
64
74
|
def strip_subject_edge_refs(
|
|
65
75
|
subject_line: str,
|
|
66
76
|
*,
|