@grifhinz/logics-manager 2.5.1 → 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
@@ -496,7 +573,38 @@ def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
496
573
  return commits
497
574
 
498
575
 
499
- def _count_unique_git_status_paths(groups: dict[str, list[dict[str, str]]]) -> int:
576
+ def _parse_git_numstat(output: str) -> dict[str, dict[str, int]]:
577
+ stats: dict[str, dict[str, int]] = {}
578
+ for line in output.splitlines():
579
+ parts = line.split("\t")
580
+ if len(parts) < 3:
581
+ continue
582
+ raw_additions, raw_deletions, raw_path = parts[:3]
583
+ try:
584
+ additions = int(raw_additions)
585
+ deletions = int(raw_deletions)
586
+ except ValueError:
587
+ continue
588
+ path = raw_path.strip()
589
+ if " => " in path:
590
+ path = path.split(" => ", 1)[1].strip("{}")
591
+ if path:
592
+ stats[path] = {"additions": additions, "deletions": deletions}
593
+ return stats
594
+
595
+
596
+ def _attach_git_change_stats(groups: dict[str, list[dict[str, Any]]], staged_stats: dict[str, dict[str, int]], worktree_stats: dict[str, dict[str, int]]) -> None:
597
+ for key, entries in groups.items():
598
+ stats_source = staged_stats if key == "staged" else worktree_stats
599
+ for entry in entries:
600
+ path = str(entry.get("path", ""))
601
+ stats = stats_source.get(path) or staged_stats.get(path) or worktree_stats.get(path)
602
+ if stats:
603
+ entry["additions"] = stats["additions"]
604
+ entry["deletions"] = stats["deletions"]
605
+
606
+
607
+ def _count_unique_git_status_paths(groups: dict[str, list[dict[str, Any]]]) -> int:
500
608
  paths: set[str] = set()
501
609
  for entries in groups.values():
502
610
  for entry in entries:
@@ -543,10 +651,12 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
543
651
 
544
652
  try:
545
653
  status = _run_read_only_git(repo_root, ["status", "--porcelain=v1", "-b"], runner=runner)
654
+ staged_numstat = _run_read_only_git(repo_root, ["diff", "--no-ext-diff", "--numstat", "--cached"], runner=runner)
655
+ worktree_numstat = _run_read_only_git(repo_root, ["diff", "--no-ext-diff", "--numstat"], runner=runner)
546
656
  commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
547
657
  recent_commits = _run_read_only_git(
548
658
  repo_root,
549
- ["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"],
550
660
  runner=runner,
551
661
  )
552
662
  unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
@@ -558,12 +668,18 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
558
668
 
559
669
  lines = status.stdout.splitlines()
560
670
  branch_info = _parse_git_branch_line(lines[0]) if lines else {"branch": "HEAD", "tracking": "", "ahead": 0, "behind": 0}
561
- groups: dict[str, list[dict[str, str]]] = {key: [] for key in ("staged", "modified", "deleted", "renamed", "untracked")}
671
+ groups: dict[str, list[dict[str, Any]]] = {key: [] for key in ("staged", "modified", "deleted", "renamed", "untracked")}
562
672
  for line in lines[1:]:
563
673
  classified = _classify_porcelain_entry(line)
564
674
  if classified:
565
675
  group, entry = classified
566
676
  groups[group].append(entry)
677
+ if staged_numstat.returncode == 0 or worktree_numstat.returncode == 0:
678
+ _attach_git_change_stats(
679
+ groups,
680
+ _parse_git_numstat(staged_numstat.stdout if staged_numstat.returncode == 0 else ""),
681
+ _parse_git_numstat(worktree_numstat.stdout if worktree_numstat.returncode == 0 else ""),
682
+ )
567
683
  counts = {key: len(value) for key, value in groups.items()}
568
684
  uncommitted_files = _count_unique_git_status_paths(groups)
569
685
  dirty = any(counts.values())
@@ -639,6 +755,164 @@ def git_diff_payload(
639
755
  }
640
756
 
641
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
+
642
916
  def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
643
917
  cdx_which = which or shutil.which
644
918
  if not cdx_which("cdx"):
@@ -767,6 +1041,9 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
767
1041
  if route == "/api/git-status":
768
1042
  self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
769
1043
  return
1044
+ if route == "/api/ci-status":
1045
+ self._send_json({"ok": True, "payload": ci_status_payload(self.server.repo_root)})
1046
+ return
770
1047
  if route == "/api/cdx-status":
771
1048
  self._send_json({"ok": True, "payload": cdx_status_payload(self.server.repo_root)})
772
1049
  return
@@ -800,6 +1077,12 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
800
1077
  except OSError as exc:
801
1078
  self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
802
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
803
1086
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
804
1087
 
805
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.1",
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.1"
7
+ version = "2.6.0"
8
8
  description = "Canonical Logics CLI"
9
9
  requires-python = ">=3.10"
10
10