@grifhinz/logics-manager 2.5.2 → 2.7.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 +898 -68
- package/clients/viewer/index.html +48 -22
- package/clients/viewer/viewer.css +536 -8
- package/logics_manager/assist.py +1 -0
- package/logics_manager/flow.py +6 -2
- package/logics_manager/lint.py +11 -7
- package/logics_manager/viewer.py +603 -14
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/logics_manager/viewer.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import hashlib
|
|
4
5
|
import json
|
|
5
6
|
import mimetypes
|
|
6
7
|
import os
|
|
@@ -20,6 +21,7 @@ from typing import Any
|
|
|
20
21
|
from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
|
|
21
22
|
|
|
22
23
|
from .audit import audit_payload
|
|
24
|
+
from .bootstrap import bootstrap_payload
|
|
23
25
|
from .config import find_repo_root
|
|
24
26
|
from .lint import lint_payload
|
|
25
27
|
from .update_check import get_update_info
|
|
@@ -342,18 +344,28 @@ def viewer_data_payload(
|
|
|
342
344
|
selected_id: str | None = None,
|
|
343
345
|
*,
|
|
344
346
|
auto_refresh_interval_seconds: int = 15,
|
|
347
|
+
projects: list[dict[str, Any]] | None = None,
|
|
345
348
|
) -> dict[str, Any]:
|
|
349
|
+
capabilities = viewer_project_capabilities(repo_root)
|
|
350
|
+
active_root = repo_root.resolve()
|
|
351
|
+
has_logics = capabilities["logics"]["available"] is True
|
|
346
352
|
return {
|
|
347
|
-
"root": str(
|
|
348
|
-
"repoName":
|
|
353
|
+
"root": str(active_root),
|
|
354
|
+
"repoName": active_root.name,
|
|
355
|
+
"repository": {
|
|
356
|
+
"root": str(active_root),
|
|
357
|
+
"githubUrl": github_repo_url(repo_root),
|
|
358
|
+
},
|
|
359
|
+
"capabilities": capabilities,
|
|
360
|
+
"projects": projects if projects is not None else viewer_project_registry(repo_root),
|
|
349
361
|
"autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
|
|
350
362
|
"items": collect_viewer_items(repo_root),
|
|
351
363
|
"updateInfo": get_update_info(_current_version()).to_payload(),
|
|
352
364
|
"selectedId": selected_id,
|
|
353
365
|
"changedPaths": [],
|
|
354
366
|
"canResetProjectRoot": False,
|
|
355
|
-
"canBootstrapLogics":
|
|
356
|
-
"bootstrapLogicsTitle": "
|
|
367
|
+
"canBootstrapLogics": not has_logics,
|
|
368
|
+
"bootstrapLogicsTitle": "Bootstrap Logics in this project." if not has_logics else "Logics is already bootstrapped.",
|
|
357
369
|
"canLaunchCodex": False,
|
|
358
370
|
"canLaunchClaude": False,
|
|
359
371
|
"canRepairLogicsKit": False,
|
|
@@ -362,6 +374,155 @@ def viewer_data_payload(
|
|
|
362
374
|
}
|
|
363
375
|
|
|
364
376
|
|
|
377
|
+
def _viewer_project_id(repo_root: Path) -> str:
|
|
378
|
+
normalized = str(repo_root.resolve())
|
|
379
|
+
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _looks_like_viewer_project(path: Path) -> bool:
|
|
383
|
+
if not path.is_dir():
|
|
384
|
+
return False
|
|
385
|
+
return any((path / marker).exists() for marker in ("logics", ".git", "package.json", "pyproject.toml", "logics.yaml"))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def discover_viewer_project_roots(repo_root: Path, *, max_projects: int = 40) -> list[Path]:
|
|
389
|
+
active = repo_root.resolve()
|
|
390
|
+
candidates: list[Path] = [active]
|
|
391
|
+
parent = active.parent
|
|
392
|
+
try:
|
|
393
|
+
siblings = sorted(parent.iterdir(), key=lambda path: path.name.lower())
|
|
394
|
+
except OSError:
|
|
395
|
+
siblings = []
|
|
396
|
+
for sibling in siblings:
|
|
397
|
+
try:
|
|
398
|
+
resolved = sibling.resolve()
|
|
399
|
+
except OSError:
|
|
400
|
+
continue
|
|
401
|
+
if resolved == active or not _looks_like_viewer_project(resolved):
|
|
402
|
+
continue
|
|
403
|
+
candidates.append(resolved)
|
|
404
|
+
if len(candidates) >= max_projects:
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
unique: dict[str, Path] = {}
|
|
408
|
+
for candidate in candidates:
|
|
409
|
+
unique[str(candidate)] = candidate
|
|
410
|
+
return list(unique.values())
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def viewer_project_entry(repo_root: Path, *, active_root: Path | None = None) -> dict[str, Any]:
|
|
414
|
+
root = repo_root.resolve()
|
|
415
|
+
active = active_root.resolve() if active_root else root
|
|
416
|
+
has_logics = (root / "logics").is_dir()
|
|
417
|
+
available = root.is_dir()
|
|
418
|
+
return {
|
|
419
|
+
"id": _viewer_project_id(root),
|
|
420
|
+
"name": root.name,
|
|
421
|
+
"root": str(root),
|
|
422
|
+
"active": root == active,
|
|
423
|
+
"available": available,
|
|
424
|
+
"hasLogics": has_logics,
|
|
425
|
+
"message": "Logics corpus found." if has_logics else "No Logics corpus found.",
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def viewer_project_registry(repo_root: Path, *, project_roots: list[Path] | None = None) -> list[dict[str, Any]]:
|
|
430
|
+
active = repo_root.resolve()
|
|
431
|
+
roots = project_roots if project_roots is not None else discover_viewer_project_roots(active)
|
|
432
|
+
return [viewer_project_entry(root, active_root=active) for root in roots]
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _viewer_capability(state: str, *, available: bool, message: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
436
|
+
payload: dict[str, Any] = {
|
|
437
|
+
"state": state,
|
|
438
|
+
"available": available,
|
|
439
|
+
"message": message,
|
|
440
|
+
}
|
|
441
|
+
if detail:
|
|
442
|
+
payload["detail"] = detail
|
|
443
|
+
return payload
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _git_is_repository(repo_root: Path, *, runner: Any | None = None) -> bool | None:
|
|
447
|
+
try:
|
|
448
|
+
result = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
|
|
449
|
+
except (OSError, subprocess.SubprocessError):
|
|
450
|
+
return None
|
|
451
|
+
if result.returncode != 0:
|
|
452
|
+
return False
|
|
453
|
+
return result.stdout.strip().lower() == "true"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def viewer_project_capabilities(
|
|
457
|
+
repo_root: Path,
|
|
458
|
+
*,
|
|
459
|
+
git_runner: Any | None = None,
|
|
460
|
+
which: Any | None = None,
|
|
461
|
+
) -> dict[str, Any]:
|
|
462
|
+
which_command = which or shutil.which
|
|
463
|
+
logics_dir = repo_root / "logics"
|
|
464
|
+
has_logics = logics_dir.is_dir()
|
|
465
|
+
git_path = which_command("git")
|
|
466
|
+
cdx_path = which_command("cdx")
|
|
467
|
+
|
|
468
|
+
if has_logics:
|
|
469
|
+
logics = _viewer_capability("ready", available=True, message="Logics corpus found.")
|
|
470
|
+
else:
|
|
471
|
+
logics = _viewer_capability("missing", available=False, message="No Logics corpus found.")
|
|
472
|
+
|
|
473
|
+
if not git_path:
|
|
474
|
+
git = _viewer_capability("unavailable", available=False, message="Git executable is not available.")
|
|
475
|
+
github_url = ""
|
|
476
|
+
has_workflows = False
|
|
477
|
+
else:
|
|
478
|
+
is_repo = _git_is_repository(repo_root, runner=git_runner)
|
|
479
|
+
if is_repo is True:
|
|
480
|
+
git = _viewer_capability("ready", available=True, message="Git repository detected.")
|
|
481
|
+
github_url = github_repo_url(repo_root, runner=git_runner, which=which_command)
|
|
482
|
+
has_workflows = _has_github_actions_workflows(repo_root)
|
|
483
|
+
elif is_repo is False:
|
|
484
|
+
git = _viewer_capability("missing", available=False, message="Project is not a Git repository.")
|
|
485
|
+
github_url = ""
|
|
486
|
+
has_workflows = False
|
|
487
|
+
else:
|
|
488
|
+
git = _viewer_capability("error", available=False, message="Unable to inspect Git repository state.")
|
|
489
|
+
github_url = ""
|
|
490
|
+
has_workflows = False
|
|
491
|
+
|
|
492
|
+
if not github_url:
|
|
493
|
+
ci = _viewer_capability("hidden", available=False, message="No GitHub remote detected for this project.")
|
|
494
|
+
elif not has_workflows:
|
|
495
|
+
ci = _viewer_capability("hidden", available=False, message="No GitHub Actions workflows detected for this project.")
|
|
496
|
+
elif not which_command("gh"):
|
|
497
|
+
ci = _viewer_capability("unavailable", available=False, message="GitHub CLI is not available.")
|
|
498
|
+
else:
|
|
499
|
+
ci = _viewer_capability(
|
|
500
|
+
"ready",
|
|
501
|
+
available=True,
|
|
502
|
+
message="GitHub Actions can be inspected.",
|
|
503
|
+
detail={"githubUrl": github_url},
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
if cdx_path:
|
|
507
|
+
cdx = _viewer_capability("ready", available=True, message="CDX executable detected.")
|
|
508
|
+
cdx_runs = _viewer_capability(
|
|
509
|
+
"unsupported",
|
|
510
|
+
available=False,
|
|
511
|
+
message="CDX assistant run registry is not available yet.",
|
|
512
|
+
)
|
|
513
|
+
else:
|
|
514
|
+
cdx = _viewer_capability("missing", available=False, message="CDX executable is not available.")
|
|
515
|
+
cdx_runs = _viewer_capability("missing", available=False, message="CDX is required before assistant runs can be tracked.")
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
"logics": logics,
|
|
519
|
+
"git": git,
|
|
520
|
+
"ci": ci,
|
|
521
|
+
"cdx": cdx,
|
|
522
|
+
"cdxRuns": cdx_runs,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
365
526
|
def read_doc_payload(repo_root: Path, rel_path: str) -> dict[str, Any]:
|
|
366
527
|
normalized, absolute = _resolve_repo_doc_path(repo_root, rel_path)
|
|
367
528
|
return {
|
|
@@ -392,6 +553,17 @@ def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = N
|
|
|
392
553
|
}
|
|
393
554
|
|
|
394
555
|
|
|
556
|
+
def open_repo_folder_payload(repo_root: Path, *, launcher: Any | None = None) -> dict[str, str]:
|
|
557
|
+
root = repo_root.resolve()
|
|
558
|
+
command = _system_editor_command(root)
|
|
559
|
+
runner = launcher or subprocess.Popen
|
|
560
|
+
runner(command)
|
|
561
|
+
return {
|
|
562
|
+
"path": str(root),
|
|
563
|
+
"command": command[0],
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
395
567
|
def _system_editor_command(path: Path) -> list[str]:
|
|
396
568
|
if sys.platform == "darwin":
|
|
397
569
|
return ["open", str(path)]
|
|
@@ -412,6 +584,12 @@ def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None =
|
|
|
412
584
|
return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
|
|
413
585
|
|
|
414
586
|
|
|
587
|
+
def _run_read_only_gh(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
588
|
+
command = ["gh", *args]
|
|
589
|
+
gh_runner = runner or subprocess.run
|
|
590
|
+
return gh_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=8)
|
|
591
|
+
|
|
592
|
+
|
|
415
593
|
def _logics_doc_type(rel_path: str) -> str:
|
|
416
594
|
normalized = rel_path.replace("\\", "/").lstrip("/")
|
|
417
595
|
for family in DOC_FAMILIES:
|
|
@@ -427,6 +605,62 @@ def _sanitize_git_ref(value: str) -> str:
|
|
|
427
605
|
return ref[:200]
|
|
428
606
|
|
|
429
607
|
|
|
608
|
+
def _github_web_url_from_remote(value: str) -> str:
|
|
609
|
+
remote = value.strip()
|
|
610
|
+
if not remote:
|
|
611
|
+
return ""
|
|
612
|
+
remote = re.sub(r"^git\+", "", remote)
|
|
613
|
+
match = re.match(r"^(?:https://|http://)(?:[^/@\s]+@)?github\.com[:/]+([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", remote)
|
|
614
|
+
if not match:
|
|
615
|
+
match = re.match(r"^(?:ssh://)?git@github\.com[:/]+([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", remote)
|
|
616
|
+
if not match:
|
|
617
|
+
return ""
|
|
618
|
+
owner, repo = match.groups()
|
|
619
|
+
if not owner or not repo:
|
|
620
|
+
return ""
|
|
621
|
+
return f"https://github.com/{owner}/{repo}"
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _github_owner_repo_from_web_url(value: str) -> tuple[str, str] | None:
|
|
625
|
+
match = re.match(r"^https://github\.com/([^/\s]+)/([^/\s]+?)/?$", value.strip())
|
|
626
|
+
if not match:
|
|
627
|
+
return None
|
|
628
|
+
owner, repo = match.groups()
|
|
629
|
+
return owner, repo
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def github_repo_url(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> str:
|
|
633
|
+
git_which = which or shutil.which
|
|
634
|
+
if not git_which("git"):
|
|
635
|
+
return ""
|
|
636
|
+
try:
|
|
637
|
+
remotes = _run_read_only_git(repo_root, ["remote", "-v"], runner=runner)
|
|
638
|
+
except (OSError, subprocess.SubprocessError):
|
|
639
|
+
return ""
|
|
640
|
+
if remotes.returncode != 0:
|
|
641
|
+
return ""
|
|
642
|
+
|
|
643
|
+
candidates: list[tuple[int, str]] = []
|
|
644
|
+
for line in remotes.stdout.splitlines():
|
|
645
|
+
parts = line.split()
|
|
646
|
+
if len(parts) < 2:
|
|
647
|
+
continue
|
|
648
|
+
name, url = parts[0], parts[1]
|
|
649
|
+
web_url = _github_web_url_from_remote(url)
|
|
650
|
+
if web_url:
|
|
651
|
+
candidates.append((0 if name == "origin" else 1, web_url))
|
|
652
|
+
if not candidates:
|
|
653
|
+
return ""
|
|
654
|
+
return sorted(candidates, key=lambda entry: entry[0])[0][1]
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _has_github_actions_workflows(repo_root: Path) -> bool:
|
|
658
|
+
workflows_dir = repo_root / ".github" / "workflows"
|
|
659
|
+
if not workflows_dir.is_dir():
|
|
660
|
+
return False
|
|
661
|
+
return any(path.is_file() and path.suffix.lower() in {".yml", ".yaml"} for path in workflows_dir.iterdir())
|
|
662
|
+
|
|
663
|
+
|
|
430
664
|
def _classify_porcelain_entry(line: str) -> tuple[str, dict[str, str]] | None:
|
|
431
665
|
if not line or line.startswith("## "):
|
|
432
666
|
return None
|
|
@@ -579,7 +813,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
579
813
|
commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
|
|
580
814
|
recent_commits = _run_read_only_git(
|
|
581
815
|
repo_root,
|
|
582
|
-
["log", "-
|
|
816
|
+
["log", "-50", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
|
|
583
817
|
runner=runner,
|
|
584
818
|
)
|
|
585
819
|
unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
|
|
@@ -678,6 +912,164 @@ def git_diff_payload(
|
|
|
678
912
|
}
|
|
679
913
|
|
|
680
914
|
|
|
915
|
+
def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
|
|
916
|
+
context = {"branch": "", "headSha": "", "subject": "", "author": ""}
|
|
917
|
+
commands = {
|
|
918
|
+
"branch": ["rev-parse", "--abbrev-ref", "HEAD"],
|
|
919
|
+
"headSha": ["rev-parse", "HEAD"],
|
|
920
|
+
"subject": ["log", "-1", "--pretty=format:%s"],
|
|
921
|
+
"author": ["log", "-1", "--pretty=format:%an"],
|
|
922
|
+
}
|
|
923
|
+
for key, args in commands.items():
|
|
924
|
+
try:
|
|
925
|
+
result = _run_read_only_git(repo_root, args, runner=runner)
|
|
926
|
+
except (OSError, subprocess.SubprocessError):
|
|
927
|
+
continue
|
|
928
|
+
if result.returncode == 0:
|
|
929
|
+
context[key] = result.stdout.strip()[:240]
|
|
930
|
+
if context["branch"] == "HEAD":
|
|
931
|
+
context["branch"] = ""
|
|
932
|
+
return context
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _ci_badge_state(status: str, conclusion: str) -> str:
|
|
936
|
+
normalized_status = status.strip().lower()
|
|
937
|
+
normalized_conclusion = conclusion.strip().lower()
|
|
938
|
+
if normalized_status in {"queued", "in_progress", "waiting", "requested", "pending"}:
|
|
939
|
+
return "running" if normalized_status == "in_progress" else "queued"
|
|
940
|
+
if normalized_conclusion == "success":
|
|
941
|
+
return "passing"
|
|
942
|
+
if normalized_conclusion in {"failure", "timed_out", "action_required"}:
|
|
943
|
+
return "failing"
|
|
944
|
+
if normalized_conclusion == "cancelled":
|
|
945
|
+
return "cancelled"
|
|
946
|
+
return "unknown"
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
|
|
950
|
+
status = str(run.get("status") or "")
|
|
951
|
+
conclusion = str(run.get("conclusion") or "")
|
|
952
|
+
commit = run.get("head_commit") if isinstance(run.get("head_commit"), dict) else {}
|
|
953
|
+
author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
|
|
954
|
+
return {
|
|
955
|
+
"id": run.get("id"),
|
|
956
|
+
"name": str(run.get("name") or run.get("display_title") or "GitHub Actions"),
|
|
957
|
+
"workflowName": str(run.get("name") or "GitHub Actions"),
|
|
958
|
+
"status": status,
|
|
959
|
+
"conclusion": conclusion,
|
|
960
|
+
"badgeState": _ci_badge_state(status, conclusion),
|
|
961
|
+
"branch": str(run.get("head_branch") or ""),
|
|
962
|
+
"headSha": str(run.get("head_sha") or ""),
|
|
963
|
+
"event": str(run.get("event") or ""),
|
|
964
|
+
"htmlUrl": str(run.get("html_url") or ""),
|
|
965
|
+
"createdAt": str(run.get("created_at") or ""),
|
|
966
|
+
"updatedAt": str(run.get("updated_at") or ""),
|
|
967
|
+
"runStartedAt": str(run.get("run_started_at") or ""),
|
|
968
|
+
"commitMessage": str(commit.get("message") or run.get("display_title") or "").splitlines()[0][:240],
|
|
969
|
+
"author": str(author.get("name") or ""),
|
|
970
|
+
"matchSource": match_source,
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _parse_github_actions_jobs(output: str) -> list[dict[str, str]]:
|
|
975
|
+
try:
|
|
976
|
+
parsed = json.loads(output or "{}")
|
|
977
|
+
except json.JSONDecodeError:
|
|
978
|
+
return []
|
|
979
|
+
if not isinstance(parsed, dict):
|
|
980
|
+
return []
|
|
981
|
+
jobs = parsed.get("jobs")
|
|
982
|
+
if not isinstance(jobs, list):
|
|
983
|
+
return []
|
|
984
|
+
rows: list[dict[str, str]] = []
|
|
985
|
+
for job in jobs[:30]:
|
|
986
|
+
if not isinstance(job, dict):
|
|
987
|
+
continue
|
|
988
|
+
rows.append(
|
|
989
|
+
{
|
|
990
|
+
"name": str(job.get("name") or "Job"),
|
|
991
|
+
"status": str(job.get("status") or ""),
|
|
992
|
+
"conclusion": str(job.get("conclusion") or ""),
|
|
993
|
+
"htmlUrl": str(job.get("html_url") or ""),
|
|
994
|
+
"startedAt": str(job.get("started_at") or ""),
|
|
995
|
+
"completedAt": str(job.get("completed_at") or ""),
|
|
996
|
+
}
|
|
997
|
+
)
|
|
998
|
+
return rows
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
1002
|
+
git_which = which or shutil.which
|
|
1003
|
+
github_url = github_repo_url(repo_root, runner=git_runner, which=git_which)
|
|
1004
|
+
if not github_url:
|
|
1005
|
+
return {"state": "hidden", "visible": False, "message": "No GitHub remote detected."}
|
|
1006
|
+
owner_repo = _github_owner_repo_from_web_url(github_url)
|
|
1007
|
+
if not owner_repo:
|
|
1008
|
+
return {"state": "hidden", "visible": False, "message": "GitHub remote could not be parsed."}
|
|
1009
|
+
if not _has_github_actions_workflows(repo_root):
|
|
1010
|
+
return {"state": "hidden", "visible": False, "message": "No GitHub Actions workflows detected."}
|
|
1011
|
+
if not git_which("gh"):
|
|
1012
|
+
return {
|
|
1013
|
+
"state": "unavailable",
|
|
1014
|
+
"visible": True,
|
|
1015
|
+
"message": "GitHub CLI is not available on PATH.",
|
|
1016
|
+
"repositoryUrl": github_url,
|
|
1017
|
+
"badgeState": "unavailable",
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
owner, repo = owner_repo
|
|
1021
|
+
context = _current_git_ci_context(repo_root, runner=git_runner)
|
|
1022
|
+
branch = context.get("branch", "")
|
|
1023
|
+
head_sha = context.get("headSha", "")
|
|
1024
|
+
endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=10"
|
|
1025
|
+
if branch:
|
|
1026
|
+
endpoint = f"{endpoint}&branch={quote(branch, safe='')}"
|
|
1027
|
+
try:
|
|
1028
|
+
runs_result = _run_read_only_gh(repo_root, ["api", endpoint], runner=gh_runner)
|
|
1029
|
+
except subprocess.TimeoutExpired:
|
|
1030
|
+
return {"state": "timeout", "visible": True, "message": "GitHub Actions status timed out.", "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
|
|
1031
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1032
|
+
return {"state": "error", "visible": True, "message": f"Unable to collect GitHub Actions status: {exc}", "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
|
|
1033
|
+
if runs_result.returncode != 0:
|
|
1034
|
+
message = (runs_result.stderr or runs_result.stdout or "GitHub Actions status failed.").strip().splitlines()[0]
|
|
1035
|
+
return {"state": "unavailable", "visible": True, "message": message, "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
|
|
1036
|
+
|
|
1037
|
+
try:
|
|
1038
|
+
parsed = json.loads(runs_result.stdout or "{}")
|
|
1039
|
+
except json.JSONDecodeError:
|
|
1040
|
+
return {"state": "invalid-json", "visible": True, "message": "GitHub Actions status returned invalid JSON.", "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
|
|
1041
|
+
workflow_runs = parsed.get("workflow_runs") if isinstance(parsed, dict) else None
|
|
1042
|
+
runs = [run for run in workflow_runs if isinstance(run, dict)] if isinstance(workflow_runs, list) else []
|
|
1043
|
+
if not runs:
|
|
1044
|
+
return {"state": "ok", "visible": True, "message": "No GitHub Actions runs found for the current branch.", "repositoryUrl": github_url, **context, "badgeState": "unknown", "run": None, "jobs": []}
|
|
1045
|
+
|
|
1046
|
+
selected = next((run for run in runs if head_sha and str(run.get("head_sha") or "") == head_sha), None)
|
|
1047
|
+
match_source = "head" if selected else "branch-latest"
|
|
1048
|
+
if selected is None:
|
|
1049
|
+
selected = runs[0]
|
|
1050
|
+
run_payload = _parse_github_actions_run(selected, match_source=match_source)
|
|
1051
|
+
jobs: list[dict[str, str]] = []
|
|
1052
|
+
run_id = run_payload.get("id")
|
|
1053
|
+
if run_id:
|
|
1054
|
+
try:
|
|
1055
|
+
jobs_result = _run_read_only_gh(repo_root, ["api", f"repos/{owner}/{repo}/actions/runs/{run_id}/jobs?per_page=100"], runner=gh_runner)
|
|
1056
|
+
except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
|
|
1057
|
+
jobs_result = None
|
|
1058
|
+
if jobs_result is not None and jobs_result.returncode == 0:
|
|
1059
|
+
jobs = _parse_github_actions_jobs(jobs_result.stdout)
|
|
1060
|
+
|
|
1061
|
+
return {
|
|
1062
|
+
"state": "ok",
|
|
1063
|
+
"visible": True,
|
|
1064
|
+
"message": "",
|
|
1065
|
+
"repositoryUrl": github_url,
|
|
1066
|
+
**context,
|
|
1067
|
+
"badgeState": run_payload["badgeState"],
|
|
1068
|
+
"run": run_payload,
|
|
1069
|
+
"jobs": jobs,
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
|
|
681
1073
|
def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
682
1074
|
cdx_which = which or shutil.which
|
|
683
1075
|
if not cdx_which("cdx"):
|
|
@@ -704,6 +1096,123 @@ def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
704
1096
|
return {"state": "ok", "message": "", "status": parsed}
|
|
705
1097
|
|
|
706
1098
|
|
|
1099
|
+
def cdx_runs_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
1100
|
+
cdx_which = which or shutil.which
|
|
1101
|
+
if not cdx_which("cdx"):
|
|
1102
|
+
return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "runs": []}
|
|
1103
|
+
try:
|
|
1104
|
+
result = _run_read_only_cdx(repo_root, ["runs", "--json"], runner=runner)
|
|
1105
|
+
except subprocess.TimeoutExpired:
|
|
1106
|
+
return {"state": "timeout", "message": "CDX runs timed out.", "runs": []}
|
|
1107
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1108
|
+
return {"state": "error", "message": f"Unable to run CDX runs: {exc}", "runs": []}
|
|
1109
|
+
if result.returncode != 0:
|
|
1110
|
+
message = (result.stderr or result.stdout or "CDX runs failed.").strip().splitlines()[0]
|
|
1111
|
+
return {"state": "error", "message": message, "runs": []}
|
|
1112
|
+
try:
|
|
1113
|
+
parsed = json.loads(result.stdout or "{}")
|
|
1114
|
+
except json.JSONDecodeError:
|
|
1115
|
+
return {"state": "invalid-json", "message": "CDX runs returned invalid JSON.", "runs": []}
|
|
1116
|
+
runs = parsed.get("runs") if isinstance(parsed, dict) else None
|
|
1117
|
+
if not isinstance(runs, list):
|
|
1118
|
+
return {"state": "invalid-json", "message": "CDX runs JSON must include a runs array.", "runs": []}
|
|
1119
|
+
return {"state": "ok", "message": "", "runs": [run for run in runs if isinstance(run, dict)]}
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
1123
|
+
cdx_which = which or shutil.which
|
|
1124
|
+
if not run_id:
|
|
1125
|
+
return {"state": "error", "message": "Missing CDX run id.", "report": None}
|
|
1126
|
+
if not cdx_which("cdx"):
|
|
1127
|
+
return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "report": None}
|
|
1128
|
+
try:
|
|
1129
|
+
result = _run_read_only_cdx(repo_root, ["run-report", run_id, "--json"], runner=runner)
|
|
1130
|
+
except subprocess.TimeoutExpired:
|
|
1131
|
+
return {"state": "timeout", "message": "CDX run report timed out.", "report": None}
|
|
1132
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1133
|
+
return {"state": "error", "message": f"Unable to run CDX run-report: {exc}", "report": None}
|
|
1134
|
+
if result.returncode != 0:
|
|
1135
|
+
message = (result.stderr or result.stdout or "CDX run-report failed.").strip().splitlines()[0]
|
|
1136
|
+
return {"state": "error", "message": message, "report": None}
|
|
1137
|
+
try:
|
|
1138
|
+
parsed = json.loads(result.stdout or "{}")
|
|
1139
|
+
except json.JSONDecodeError:
|
|
1140
|
+
return {"state": "invalid-json", "message": "CDX run-report returned invalid JSON.", "report": None}
|
|
1141
|
+
report = parsed.get("report") if isinstance(parsed, dict) else None
|
|
1142
|
+
if not isinstance(report, dict):
|
|
1143
|
+
return {"state": "invalid-json", "message": "CDX run-report JSON must include a report object.", "report": None}
|
|
1144
|
+
return {"state": "ok", "message": "", "report": report}
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _slugify_viewer_doc(text: str) -> str:
|
|
1148
|
+
slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
|
|
1149
|
+
return slug[:80] or "cdx_code_review_findings"
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _next_viewer_request_ref(repo_root: Path, title: str) -> str:
|
|
1153
|
+
request_dir = repo_root / "logics" / "request"
|
|
1154
|
+
highest = -1
|
|
1155
|
+
if request_dir.is_dir():
|
|
1156
|
+
for path in request_dir.glob("req_*.md"):
|
|
1157
|
+
match = re.match(r"^req_(\d{3})_", path.stem)
|
|
1158
|
+
if match:
|
|
1159
|
+
highest = max(highest, int(match.group(1)))
|
|
1160
|
+
return f"req_{highest + 1:03d}_{_slugify_viewer_doc(title)}"
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, Any]) -> dict[str, Any]:
|
|
1164
|
+
report = report_payload.get("report") if isinstance(report_payload.get("report"), dict) else report_payload
|
|
1165
|
+
run = report.get("run") if isinstance(report.get("run"), dict) else {}
|
|
1166
|
+
task_report = report.get("task_report") if isinstance(report.get("task_report"), dict) else {}
|
|
1167
|
+
run_id = str(run.get("run_id") or task_report.get("run_id") or "unknown")
|
|
1168
|
+
findings = task_report.get("findings") if isinstance(task_report.get("findings"), list) else []
|
|
1169
|
+
title = f"Address CDX code review findings for {run_id}"
|
|
1170
|
+
ref = _next_viewer_request_ref(repo_root, title)
|
|
1171
|
+
request_dir = repo_root / "logics" / "request"
|
|
1172
|
+
request_dir.mkdir(parents=True, exist_ok=True)
|
|
1173
|
+
rel_path = f"logics/request/{ref}.md"
|
|
1174
|
+
path = repo_root / rel_path
|
|
1175
|
+
finding_lines = []
|
|
1176
|
+
for index, finding in enumerate(findings, start=1):
|
|
1177
|
+
if not isinstance(finding, dict):
|
|
1178
|
+
continue
|
|
1179
|
+
location = finding.get("path") or finding.get("file") or "unknown path"
|
|
1180
|
+
if finding.get("line"):
|
|
1181
|
+
location = f"{location}:{finding['line']}"
|
|
1182
|
+
severity = finding.get("severity") or "unknown"
|
|
1183
|
+
message = finding.get("message") or finding.get("title") or "Review finding"
|
|
1184
|
+
finding_lines.append(f"- F{index} [{severity}] `{location}`: {message}")
|
|
1185
|
+
if not finding_lines:
|
|
1186
|
+
finding_lines.append("- No structured findings were reported. Review the CDX artifacts linked below.")
|
|
1187
|
+
text = "\n".join([
|
|
1188
|
+
f"## {ref} - {title}",
|
|
1189
|
+
"> Status: Draft",
|
|
1190
|
+
"> Understanding: 70%",
|
|
1191
|
+
"> Confidence: 70%",
|
|
1192
|
+
"> Complexity: Medium",
|
|
1193
|
+
"> Theme: Code review follow-up",
|
|
1194
|
+
"",
|
|
1195
|
+
"# Needs",
|
|
1196
|
+
f"- Follow up on CDX code-review run `{run_id}`.",
|
|
1197
|
+
f"- Summary: {task_report.get('summary') or 'No structured summary provided.'}",
|
|
1198
|
+
"",
|
|
1199
|
+
"# Findings",
|
|
1200
|
+
*finding_lines,
|
|
1201
|
+
"",
|
|
1202
|
+
"# Traceability",
|
|
1203
|
+
f"- CDX run id: `{run_id}`",
|
|
1204
|
+
f"- Transcript: `{(report.get('artifacts') or {}).get('transcript_path') or ''}`",
|
|
1205
|
+
f"- Stdout: `{(report.get('artifacts') or {}).get('stdout_path') or ''}`",
|
|
1206
|
+
"",
|
|
1207
|
+
"# Acceptance Criteria",
|
|
1208
|
+
"- AC1: Each actionable finding is reviewed and either fixed, documented as not applicable, or split into follow-up work.",
|
|
1209
|
+
"- AC2: Validation evidence is added before closing this request.",
|
|
1210
|
+
"",
|
|
1211
|
+
])
|
|
1212
|
+
path.write_text(text, encoding="utf-8")
|
|
1213
|
+
return {"id": ref, "path": rel_path, "title": title}
|
|
1214
|
+
|
|
1215
|
+
|
|
707
1216
|
def _json_bytes(payload: Any) -> bytes:
|
|
708
1217
|
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
|
709
1218
|
|
|
@@ -716,10 +1225,35 @@ class LogicsViewerServer(ThreadingHTTPServer):
|
|
|
716
1225
|
*,
|
|
717
1226
|
auto_refresh_interval_seconds: int = 15,
|
|
718
1227
|
):
|
|
719
|
-
self.
|
|
1228
|
+
self.launch_repo_root = repo_root.resolve()
|
|
1229
|
+
self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
|
|
1230
|
+
self.project_root_by_id = {_viewer_project_id(root): root.resolve() for root in self.project_roots}
|
|
1231
|
+
self.active_project_id = _viewer_project_id(self.launch_repo_root)
|
|
1232
|
+
self.repo_root = self.launch_repo_root
|
|
720
1233
|
self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
|
|
721
1234
|
super().__init__(server_address, LogicsViewerRequestHandler)
|
|
722
1235
|
|
|
1236
|
+
def project_registry_payload(self) -> list[dict[str, Any]]:
|
|
1237
|
+
return viewer_project_registry(self.repo_root, project_roots=self.project_roots)
|
|
1238
|
+
|
|
1239
|
+
def viewer_payload(self, *, selected_id: str | None = None) -> dict[str, Any]:
|
|
1240
|
+
return viewer_data_payload(
|
|
1241
|
+
self.repo_root,
|
|
1242
|
+
selected_id=selected_id,
|
|
1243
|
+
auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
|
|
1244
|
+
projects=self.project_registry_payload(),
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
def switch_project(self, project_id: str) -> dict[str, Any]:
|
|
1248
|
+
target = self.project_root_by_id.get(project_id)
|
|
1249
|
+
if target is None:
|
|
1250
|
+
raise ValueError("Unknown project id.")
|
|
1251
|
+
if not target.is_dir():
|
|
1252
|
+
raise FileNotFoundError(str(target))
|
|
1253
|
+
self.active_project_id = project_id
|
|
1254
|
+
self.repo_root = target
|
|
1255
|
+
return self.viewer_payload()
|
|
1256
|
+
|
|
723
1257
|
|
|
724
1258
|
class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
725
1259
|
server: LogicsViewerServer
|
|
@@ -783,13 +1317,13 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
783
1317
|
self._send_json(
|
|
784
1318
|
{
|
|
785
1319
|
"ok": True,
|
|
786
|
-
"payload":
|
|
787
|
-
self.server.repo_root,
|
|
788
|
-
auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
|
|
789
|
-
),
|
|
1320
|
+
"payload": self.server.viewer_payload(),
|
|
790
1321
|
}
|
|
791
1322
|
)
|
|
792
1323
|
return
|
|
1324
|
+
if route == "/api/projects":
|
|
1325
|
+
self._send_json({"ok": True, "payload": {"projects": self.server.project_registry_payload()}})
|
|
1326
|
+
return
|
|
793
1327
|
if route == "/api/doc":
|
|
794
1328
|
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
795
1329
|
try:
|
|
@@ -803,12 +1337,25 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
803
1337
|
if route == "/api/audit":
|
|
804
1338
|
self._send_json({"ok": True, "payload": audit_payload(self.server.repo_root)})
|
|
805
1339
|
return
|
|
1340
|
+
if route == "/api/capabilities":
|
|
1341
|
+
self._send_json({"ok": True, "payload": viewer_project_capabilities(self.server.repo_root)})
|
|
1342
|
+
return
|
|
806
1343
|
if route == "/api/git-status":
|
|
807
1344
|
self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
|
|
808
1345
|
return
|
|
1346
|
+
if route == "/api/ci-status":
|
|
1347
|
+
self._send_json({"ok": True, "payload": ci_status_payload(self.server.repo_root)})
|
|
1348
|
+
return
|
|
809
1349
|
if route == "/api/cdx-status":
|
|
810
1350
|
self._send_json({"ok": True, "payload": cdx_status_payload(self.server.repo_root)})
|
|
811
1351
|
return
|
|
1352
|
+
if route == "/api/cdx-runs":
|
|
1353
|
+
self._send_json({"ok": True, "payload": cdx_runs_payload(self.server.repo_root)})
|
|
1354
|
+
return
|
|
1355
|
+
if route == "/api/cdx-run-report":
|
|
1356
|
+
run_id = parse_qs(parsed.query).get("runId", [""])[0]
|
|
1357
|
+
self._send_json({"ok": True, "payload": cdx_run_report_payload(self.server.repo_root, run_id)})
|
|
1358
|
+
return
|
|
812
1359
|
if route == "/api/git-diff":
|
|
813
1360
|
params = parse_qs(parsed.query)
|
|
814
1361
|
rel_path = params.get("path", [""])[0]
|
|
@@ -823,13 +1370,49 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
823
1370
|
self._send_json(
|
|
824
1371
|
{
|
|
825
1372
|
"ok": True,
|
|
826
|
-
"payload":
|
|
827
|
-
self.server.repo_root,
|
|
828
|
-
auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
|
|
829
|
-
),
|
|
1373
|
+
"payload": self.server.viewer_payload(),
|
|
830
1374
|
}
|
|
831
1375
|
)
|
|
832
1376
|
return
|
|
1377
|
+
if parsed.path == "/api/switch-project":
|
|
1378
|
+
try:
|
|
1379
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
1380
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
1381
|
+
body = json.loads(raw_body or "{}")
|
|
1382
|
+
project_id = str(body.get("projectId") or "")
|
|
1383
|
+
self._send_json({"ok": True, "payload": self.server.switch_project(project_id)})
|
|
1384
|
+
except json.JSONDecodeError:
|
|
1385
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
1386
|
+
except ValueError as exc:
|
|
1387
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
1388
|
+
except FileNotFoundError as exc:
|
|
1389
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
1390
|
+
return
|
|
1391
|
+
if parsed.path == "/api/bootstrap-logics":
|
|
1392
|
+
try:
|
|
1393
|
+
bootstrap = bootstrap_payload(self.server.repo_root, check=False)
|
|
1394
|
+
self._send_json({"ok": True, "payload": self.server.viewer_payload(), "bootstrap": bootstrap})
|
|
1395
|
+
except SystemExit as exc:
|
|
1396
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
|
1397
|
+
except OSError as exc:
|
|
1398
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
1399
|
+
return
|
|
1400
|
+
if parsed.path == "/api/cdx-report-request":
|
|
1401
|
+
try:
|
|
1402
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
1403
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
1404
|
+
body = json.loads(raw_body or "{}")
|
|
1405
|
+
report_payload = cdx_run_report_payload(self.server.repo_root, str(body.get("runId") or ""))
|
|
1406
|
+
if report_payload.get("state") != "ok":
|
|
1407
|
+
self._send_error_json(HTTPStatus.BAD_GATEWAY, str(report_payload.get("message") or "Unable to load CDX report."))
|
|
1408
|
+
return
|
|
1409
|
+
created = create_request_from_cdx_report(self.server.repo_root, report_payload)
|
|
1410
|
+
self._send_json({"ok": True, "created": created, "payload": self.server.viewer_payload(selected_id=created["id"])})
|
|
1411
|
+
except json.JSONDecodeError:
|
|
1412
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
1413
|
+
except OSError as exc:
|
|
1414
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
1415
|
+
return
|
|
833
1416
|
if parsed.path == "/api/edit":
|
|
834
1417
|
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
835
1418
|
try:
|
|
@@ -839,6 +1422,12 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
839
1422
|
except OSError as exc:
|
|
840
1423
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
841
1424
|
return
|
|
1425
|
+
if parsed.path == "/api/open-repo-folder":
|
|
1426
|
+
try:
|
|
1427
|
+
self._send_json({"ok": True, "payload": open_repo_folder_payload(self.server.repo_root)})
|
|
1428
|
+
except OSError as exc:
|
|
1429
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
1430
|
+
return
|
|
842
1431
|
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
843
1432
|
|
|
844
1433
|
|