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.
Files changed (47) hide show
  1. {esys_gitlog-0.2.0/src/esys_gitlog.egg-info → esys_gitlog-0.3.0}/PKG-INFO +44 -2
  2. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/README.md +43 -1
  3. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/pyproject.toml +6 -1
  4. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/__init__.py +1 -1
  5. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/cli.py +96 -6
  6. esys_gitlog-0.3.0/src/esys_gitlog/detection.py +377 -0
  7. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/identifiers.py +11 -1
  8. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/interpret.py +6 -2
  9. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/links.py +21 -5
  10. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/preferences.py +128 -1
  11. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0/src/esys_gitlog.egg-info}/PKG-INFO +44 -2
  12. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog.egg-info/SOURCES.txt +2 -0
  13. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_browser_display.py +21 -0
  14. esys_gitlog-0.3.0/tests/test_coverage_release_030.py +347 -0
  15. esys_gitlog-0.3.0/tests/test_detection.py +238 -0
  16. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_preferences.py +49 -2
  17. esys_gitlog-0.3.0/tests/test_seed_jira_live_fixture.py +29 -0
  18. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_semantic_default.py +9 -0
  19. esys_gitlog-0.2.0/src/esys_gitlog/detection.py +0 -145
  20. esys_gitlog-0.2.0/tests/test_detection.py +0 -77
  21. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/LICENSE.txt +0 -0
  22. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/setup.cfg +0 -0
  23. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/__main__.py +0 -0
  24. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/app.py +0 -0
  25. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/config.py +0 -0
  26. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/display.py +0 -0
  27. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/git_adapter.py +0 -0
  28. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/model.py +0 -0
  29. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/pager.py +0 -0
  30. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/pipeline.py +0 -0
  31. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/qa.py +0 -0
  32. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/render.py +0 -0
  33. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/report.py +0 -0
  34. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog/tool_paths.py +0 -0
  35. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog.egg-info/dependency_links.txt +0 -0
  36. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog.egg-info/entry_points.txt +0 -0
  37. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog.egg-info/requires.txt +0 -0
  38. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/src/esys_gitlog.egg-info/top_level.txt +0 -0
  39. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_cli.py +0 -0
  40. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_coverage_gaps.py +0 -0
  41. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_git_adapter.py +0 -0
  42. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_installed_launcher.py +0 -0
  43. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_pager.py +0 -0
  44. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_render.py +0 -0
  45. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_semantic_p4.py +0 -0
  46. {esys_gitlog-0.2.0 → esys_gitlog-0.3.0}/tests/test_smoke.py +0 -0
  47. {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.2.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/gh/glab/git remote; write missing user keys
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/gh/glab/git remote; write missing user keys
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.2.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",
@@ -1,3 +1,3 @@
1
1
  """esys_gitlog — installable package."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -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 detect_links, dump_detection_debug
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
- discovered = detect_links(cwd, detection=prefs.detection)
307
- if show_debug:
308
- typer.echo(dump_detection_debug())
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
- invoked = Path(sys.argv[0]).name
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
- _SUBJECT_SUFFIX_REF = re.compile(r"(?:\s+|\[)(?P<token>[A-Za-z][A-Za-z0-9_]*-\d+)\]?\s*$")
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
  *,