@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.
@@ -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", "-8", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
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.2",
5
+ "version": "2.6.0",
6
6
  "publisher": "cdx-logics",
7
7
  "icon": "clients/shared-web/media/icon.png",
8
8
  "repository": {
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logics-manager"
7
- version = "2.5.2"
7
+ version = "2.6.0"
8
8
  description = "Canonical Logics CLI"
9
9
  requires-python = ">=3.10"
10
10