@grifhinz/logics-manager 2.5.2 → 2.6.0
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.
- package/VERSION +1 -1
- package/clients/shared-web/media/css/layout.css +6 -0
- package/clients/viewer/browser-host.js +491 -60
- package/clients/viewer/index.html +39 -19
- package/clients/viewer/viewer.css +425 -8
- package/logics_manager/viewer.py +245 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/logics_manager/viewer.py
CHANGED
|
@@ -346,6 +346,10 @@ def viewer_data_payload(
|
|
|
346
346
|
return {
|
|
347
347
|
"root": str(repo_root.resolve()),
|
|
348
348
|
"repoName": repo_root.resolve().name,
|
|
349
|
+
"repository": {
|
|
350
|
+
"root": str(repo_root.resolve()),
|
|
351
|
+
"githubUrl": github_repo_url(repo_root),
|
|
352
|
+
},
|
|
349
353
|
"autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
|
|
350
354
|
"items": collect_viewer_items(repo_root),
|
|
351
355
|
"updateInfo": get_update_info(_current_version()).to_payload(),
|
|
@@ -392,6 +396,17 @@ def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = N
|
|
|
392
396
|
}
|
|
393
397
|
|
|
394
398
|
|
|
399
|
+
def open_repo_folder_payload(repo_root: Path, *, launcher: Any | None = None) -> dict[str, str]:
|
|
400
|
+
root = repo_root.resolve()
|
|
401
|
+
command = _system_editor_command(root)
|
|
402
|
+
runner = launcher or subprocess.Popen
|
|
403
|
+
runner(command)
|
|
404
|
+
return {
|
|
405
|
+
"path": str(root),
|
|
406
|
+
"command": command[0],
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
395
410
|
def _system_editor_command(path: Path) -> list[str]:
|
|
396
411
|
if sys.platform == "darwin":
|
|
397
412
|
return ["open", str(path)]
|
|
@@ -412,6 +427,12 @@ def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None =
|
|
|
412
427
|
return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
|
|
413
428
|
|
|
414
429
|
|
|
430
|
+
def _run_read_only_gh(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
431
|
+
command = ["gh", *args]
|
|
432
|
+
gh_runner = runner or subprocess.run
|
|
433
|
+
return gh_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=8)
|
|
434
|
+
|
|
435
|
+
|
|
415
436
|
def _logics_doc_type(rel_path: str) -> str:
|
|
416
437
|
normalized = rel_path.replace("\\", "/").lstrip("/")
|
|
417
438
|
for family in DOC_FAMILIES:
|
|
@@ -427,6 +448,62 @@ def _sanitize_git_ref(value: str) -> str:
|
|
|
427
448
|
return ref[:200]
|
|
428
449
|
|
|
429
450
|
|
|
451
|
+
def _github_web_url_from_remote(value: str) -> str:
|
|
452
|
+
remote = value.strip()
|
|
453
|
+
if not remote:
|
|
454
|
+
return ""
|
|
455
|
+
remote = re.sub(r"^git\+", "", remote)
|
|
456
|
+
match = re.match(r"^(?:https://|http://)(?:[^/@\s]+@)?github\.com[:/]+([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", remote)
|
|
457
|
+
if not match:
|
|
458
|
+
match = re.match(r"^(?:ssh://)?git@github\.com[:/]+([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", remote)
|
|
459
|
+
if not match:
|
|
460
|
+
return ""
|
|
461
|
+
owner, repo = match.groups()
|
|
462
|
+
if not owner or not repo:
|
|
463
|
+
return ""
|
|
464
|
+
return f"https://github.com/{owner}/{repo}"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _github_owner_repo_from_web_url(value: str) -> tuple[str, str] | None:
|
|
468
|
+
match = re.match(r"^https://github\.com/([^/\s]+)/([^/\s]+?)/?$", value.strip())
|
|
469
|
+
if not match:
|
|
470
|
+
return None
|
|
471
|
+
owner, repo = match.groups()
|
|
472
|
+
return owner, repo
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def github_repo_url(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> str:
|
|
476
|
+
git_which = which or shutil.which
|
|
477
|
+
if not git_which("git"):
|
|
478
|
+
return ""
|
|
479
|
+
try:
|
|
480
|
+
remotes = _run_read_only_git(repo_root, ["remote", "-v"], runner=runner)
|
|
481
|
+
except (OSError, subprocess.SubprocessError):
|
|
482
|
+
return ""
|
|
483
|
+
if remotes.returncode != 0:
|
|
484
|
+
return ""
|
|
485
|
+
|
|
486
|
+
candidates: list[tuple[int, str]] = []
|
|
487
|
+
for line in remotes.stdout.splitlines():
|
|
488
|
+
parts = line.split()
|
|
489
|
+
if len(parts) < 2:
|
|
490
|
+
continue
|
|
491
|
+
name, url = parts[0], parts[1]
|
|
492
|
+
web_url = _github_web_url_from_remote(url)
|
|
493
|
+
if web_url:
|
|
494
|
+
candidates.append((0 if name == "origin" else 1, web_url))
|
|
495
|
+
if not candidates:
|
|
496
|
+
return ""
|
|
497
|
+
return sorted(candidates, key=lambda entry: entry[0])[0][1]
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _has_github_actions_workflows(repo_root: Path) -> bool:
|
|
501
|
+
workflows_dir = repo_root / ".github" / "workflows"
|
|
502
|
+
if not workflows_dir.is_dir():
|
|
503
|
+
return False
|
|
504
|
+
return any(path.is_file() and path.suffix.lower() in {".yml", ".yaml"} for path in workflows_dir.iterdir())
|
|
505
|
+
|
|
506
|
+
|
|
430
507
|
def _classify_porcelain_entry(line: str) -> tuple[str, dict[str, str]] | None:
|
|
431
508
|
if not line or line.startswith("## "):
|
|
432
509
|
return None
|
|
@@ -579,7 +656,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
579
656
|
commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
|
|
580
657
|
recent_commits = _run_read_only_git(
|
|
581
658
|
repo_root,
|
|
582
|
-
["log", "-
|
|
659
|
+
["log", "-50", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
|
|
583
660
|
runner=runner,
|
|
584
661
|
)
|
|
585
662
|
unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
|
|
@@ -678,6 +755,164 @@ def git_diff_payload(
|
|
|
678
755
|
}
|
|
679
756
|
|
|
680
757
|
|
|
758
|
+
def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
|
|
759
|
+
context = {"branch": "", "headSha": "", "subject": "", "author": ""}
|
|
760
|
+
commands = {
|
|
761
|
+
"branch": ["rev-parse", "--abbrev-ref", "HEAD"],
|
|
762
|
+
"headSha": ["rev-parse", "HEAD"],
|
|
763
|
+
"subject": ["log", "-1", "--pretty=format:%s"],
|
|
764
|
+
"author": ["log", "-1", "--pretty=format:%an"],
|
|
765
|
+
}
|
|
766
|
+
for key, args in commands.items():
|
|
767
|
+
try:
|
|
768
|
+
result = _run_read_only_git(repo_root, args, runner=runner)
|
|
769
|
+
except (OSError, subprocess.SubprocessError):
|
|
770
|
+
continue
|
|
771
|
+
if result.returncode == 0:
|
|
772
|
+
context[key] = result.stdout.strip()[:240]
|
|
773
|
+
if context["branch"] == "HEAD":
|
|
774
|
+
context["branch"] = ""
|
|
775
|
+
return context
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _ci_badge_state(status: str, conclusion: str) -> str:
|
|
779
|
+
normalized_status = status.strip().lower()
|
|
780
|
+
normalized_conclusion = conclusion.strip().lower()
|
|
781
|
+
if normalized_status in {"queued", "in_progress", "waiting", "requested", "pending"}:
|
|
782
|
+
return "running" if normalized_status == "in_progress" else "queued"
|
|
783
|
+
if normalized_conclusion == "success":
|
|
784
|
+
return "passing"
|
|
785
|
+
if normalized_conclusion in {"failure", "timed_out", "action_required"}:
|
|
786
|
+
return "failing"
|
|
787
|
+
if normalized_conclusion == "cancelled":
|
|
788
|
+
return "cancelled"
|
|
789
|
+
return "unknown"
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
|
|
793
|
+
status = str(run.get("status") or "")
|
|
794
|
+
conclusion = str(run.get("conclusion") or "")
|
|
795
|
+
commit = run.get("head_commit") if isinstance(run.get("head_commit"), dict) else {}
|
|
796
|
+
author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
|
|
797
|
+
return {
|
|
798
|
+
"id": run.get("id"),
|
|
799
|
+
"name": str(run.get("name") or run.get("display_title") or "GitHub Actions"),
|
|
800
|
+
"workflowName": str(run.get("name") or "GitHub Actions"),
|
|
801
|
+
"status": status,
|
|
802
|
+
"conclusion": conclusion,
|
|
803
|
+
"badgeState": _ci_badge_state(status, conclusion),
|
|
804
|
+
"branch": str(run.get("head_branch") or ""),
|
|
805
|
+
"headSha": str(run.get("head_sha") or ""),
|
|
806
|
+
"event": str(run.get("event") or ""),
|
|
807
|
+
"htmlUrl": str(run.get("html_url") or ""),
|
|
808
|
+
"createdAt": str(run.get("created_at") or ""),
|
|
809
|
+
"updatedAt": str(run.get("updated_at") or ""),
|
|
810
|
+
"runStartedAt": str(run.get("run_started_at") or ""),
|
|
811
|
+
"commitMessage": str(commit.get("message") or run.get("display_title") or "").splitlines()[0][:240],
|
|
812
|
+
"author": str(author.get("name") or ""),
|
|
813
|
+
"matchSource": match_source,
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _parse_github_actions_jobs(output: str) -> list[dict[str, str]]:
|
|
818
|
+
try:
|
|
819
|
+
parsed = json.loads(output or "{}")
|
|
820
|
+
except json.JSONDecodeError:
|
|
821
|
+
return []
|
|
822
|
+
if not isinstance(parsed, dict):
|
|
823
|
+
return []
|
|
824
|
+
jobs = parsed.get("jobs")
|
|
825
|
+
if not isinstance(jobs, list):
|
|
826
|
+
return []
|
|
827
|
+
rows: list[dict[str, str]] = []
|
|
828
|
+
for job in jobs[:30]:
|
|
829
|
+
if not isinstance(job, dict):
|
|
830
|
+
continue
|
|
831
|
+
rows.append(
|
|
832
|
+
{
|
|
833
|
+
"name": str(job.get("name") or "Job"),
|
|
834
|
+
"status": str(job.get("status") or ""),
|
|
835
|
+
"conclusion": str(job.get("conclusion") or ""),
|
|
836
|
+
"htmlUrl": str(job.get("html_url") or ""),
|
|
837
|
+
"startedAt": str(job.get("started_at") or ""),
|
|
838
|
+
"completedAt": str(job.get("completed_at") or ""),
|
|
839
|
+
}
|
|
840
|
+
)
|
|
841
|
+
return rows
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
845
|
+
git_which = which or shutil.which
|
|
846
|
+
github_url = github_repo_url(repo_root, runner=git_runner, which=git_which)
|
|
847
|
+
if not github_url:
|
|
848
|
+
return {"state": "hidden", "visible": False, "message": "No GitHub remote detected."}
|
|
849
|
+
owner_repo = _github_owner_repo_from_web_url(github_url)
|
|
850
|
+
if not owner_repo:
|
|
851
|
+
return {"state": "hidden", "visible": False, "message": "GitHub remote could not be parsed."}
|
|
852
|
+
if not _has_github_actions_workflows(repo_root):
|
|
853
|
+
return {"state": "hidden", "visible": False, "message": "No GitHub Actions workflows detected."}
|
|
854
|
+
if not git_which("gh"):
|
|
855
|
+
return {
|
|
856
|
+
"state": "unavailable",
|
|
857
|
+
"visible": True,
|
|
858
|
+
"message": "GitHub CLI is not available on PATH.",
|
|
859
|
+
"repositoryUrl": github_url,
|
|
860
|
+
"badgeState": "unavailable",
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
owner, repo = owner_repo
|
|
864
|
+
context = _current_git_ci_context(repo_root, runner=git_runner)
|
|
865
|
+
branch = context.get("branch", "")
|
|
866
|
+
head_sha = context.get("headSha", "")
|
|
867
|
+
endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=10"
|
|
868
|
+
if branch:
|
|
869
|
+
endpoint = f"{endpoint}&branch={quote(branch, safe='')}"
|
|
870
|
+
try:
|
|
871
|
+
runs_result = _run_read_only_gh(repo_root, ["api", endpoint], runner=gh_runner)
|
|
872
|
+
except subprocess.TimeoutExpired:
|
|
873
|
+
return {"state": "timeout", "visible": True, "message": "GitHub Actions status timed out.", "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
|
|
874
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
875
|
+
return {"state": "error", "visible": True, "message": f"Unable to collect GitHub Actions status: {exc}", "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
|
|
876
|
+
if runs_result.returncode != 0:
|
|
877
|
+
message = (runs_result.stderr or runs_result.stdout or "GitHub Actions status failed.").strip().splitlines()[0]
|
|
878
|
+
return {"state": "unavailable", "visible": True, "message": message, "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
|
|
879
|
+
|
|
880
|
+
try:
|
|
881
|
+
parsed = json.loads(runs_result.stdout or "{}")
|
|
882
|
+
except json.JSONDecodeError:
|
|
883
|
+
return {"state": "invalid-json", "visible": True, "message": "GitHub Actions status returned invalid JSON.", "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
|
|
884
|
+
workflow_runs = parsed.get("workflow_runs") if isinstance(parsed, dict) else None
|
|
885
|
+
runs = [run for run in workflow_runs if isinstance(run, dict)] if isinstance(workflow_runs, list) else []
|
|
886
|
+
if not runs:
|
|
887
|
+
return {"state": "ok", "visible": True, "message": "No GitHub Actions runs found for the current branch.", "repositoryUrl": github_url, **context, "badgeState": "unknown", "run": None, "jobs": []}
|
|
888
|
+
|
|
889
|
+
selected = next((run for run in runs if head_sha and str(run.get("head_sha") or "") == head_sha), None)
|
|
890
|
+
match_source = "head" if selected else "branch-latest"
|
|
891
|
+
if selected is None:
|
|
892
|
+
selected = runs[0]
|
|
893
|
+
run_payload = _parse_github_actions_run(selected, match_source=match_source)
|
|
894
|
+
jobs: list[dict[str, str]] = []
|
|
895
|
+
run_id = run_payload.get("id")
|
|
896
|
+
if run_id:
|
|
897
|
+
try:
|
|
898
|
+
jobs_result = _run_read_only_gh(repo_root, ["api", f"repos/{owner}/{repo}/actions/runs/{run_id}/jobs?per_page=100"], runner=gh_runner)
|
|
899
|
+
except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
|
|
900
|
+
jobs_result = None
|
|
901
|
+
if jobs_result is not None and jobs_result.returncode == 0:
|
|
902
|
+
jobs = _parse_github_actions_jobs(jobs_result.stdout)
|
|
903
|
+
|
|
904
|
+
return {
|
|
905
|
+
"state": "ok",
|
|
906
|
+
"visible": True,
|
|
907
|
+
"message": "",
|
|
908
|
+
"repositoryUrl": github_url,
|
|
909
|
+
**context,
|
|
910
|
+
"badgeState": run_payload["badgeState"],
|
|
911
|
+
"run": run_payload,
|
|
912
|
+
"jobs": jobs,
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
|
|
681
916
|
def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
682
917
|
cdx_which = which or shutil.which
|
|
683
918
|
if not cdx_which("cdx"):
|
|
@@ -806,6 +1041,9 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
806
1041
|
if route == "/api/git-status":
|
|
807
1042
|
self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
|
|
808
1043
|
return
|
|
1044
|
+
if route == "/api/ci-status":
|
|
1045
|
+
self._send_json({"ok": True, "payload": ci_status_payload(self.server.repo_root)})
|
|
1046
|
+
return
|
|
809
1047
|
if route == "/api/cdx-status":
|
|
810
1048
|
self._send_json({"ok": True, "payload": cdx_status_payload(self.server.repo_root)})
|
|
811
1049
|
return
|
|
@@ -839,6 +1077,12 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
839
1077
|
except OSError as exc:
|
|
840
1078
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
841
1079
|
return
|
|
1080
|
+
if parsed.path == "/api/open-repo-folder":
|
|
1081
|
+
try:
|
|
1082
|
+
self._send_json({"ok": True, "payload": open_repo_folder_payload(self.server.repo_root)})
|
|
1083
|
+
except OSError as exc:
|
|
1084
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
1085
|
+
return
|
|
842
1086
|
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
843
1087
|
|
|
844
1088
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@grifhinz/logics-manager",
|
|
3
3
|
"displayName": "Logics Orchestrator",
|
|
4
4
|
"description": "Visual orchestration for Logics workflows inside VS Code.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.6.0",
|
|
6
6
|
"publisher": "cdx-logics",
|
|
7
7
|
"icon": "clients/shared-web/media/icon.png",
|
|
8
8
|
"repository": {
|