loki-mode 10.5.17 → 10.5.19
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/README.md +877 -30
- package/SKILL.md +3 -3
- package/VERSION +1 -1
- package/autonomy/lib/proof-generator.py +251 -0
- package/autonomy/lib/proof-verify.py +1 -0
- package/autonomy/loki +52 -6
- package/autonomy/run.sh +27 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_start.py +463 -0
- package/dashboard/server.py +31 -0
- package/dashboard/static/start.html +100 -0
- package/docs/COMPARISON.md +6 -19
- package/docs/COMPETITIVE-ANALYSIS.md +6 -41
- package/docs/CRASH-REPORTING-PLAN.md +1 -1
- package/docs/ENTERPRISE-IDENTITY-ROADMAP.md +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/PRIVACY.md +1 -1
- package/docs/R8-SHAREABLE-TEAM-ASSETS-PLAN.md +1 -1
- package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
- package/docs/architecture/STATE-MACHINES.md +18 -27
- package/docs/architecture/bmad-integration-validation.md +2 -2
- package/docs/audit-logging.md +12 -86
- package/docs/authentication.md +4 -4
- package/docs/authorization.md +15 -47
- package/docs/auto-claude-comparison.md +1 -33
- package/docs/certification/01-core-concepts/lab.md +3 -3
- package/docs/certification/01-core-concepts/lesson.md +11 -22
- package/docs/certification/01-core-concepts/quiz.md +7 -7
- package/docs/certification/02-enterprise-features/lab.md +0 -2
- package/docs/certification/02-enterprise-features/lesson.md +0 -10
- package/docs/certification/04-production-deployment/lab.md +1 -1
- package/docs/certification/05-troubleshooting/lab.md +2 -2
- package/docs/certification/README.md +1 -1
- package/docs/certification/answer-key.md +37 -20
- package/docs/certification/certification-exam.md +7 -7
- package/docs/competitive/emergence-others-analysis.md +3 -3
- package/docs/competitive/replit-lovable-analysis.md +2 -2
- package/docs/cursor-comparison.md +2 -2
- package/docs/dashboard-guide.md +14 -2
- package/docs/dev/architecture-reference.md +2 -2
- package/docs/dev/project-structure.md +1 -1
- package/docs/enterprise/architecture.md +11 -8
- package/docs/enterprise/integration-cookbook.md +23 -14
- package/docs/enterprise/migration.md +5 -9
- package/docs/enterprise/performance.md +3 -6
- package/docs/enterprise/sdk-guide.md +1 -1
- package/docs/enterprise/security.md +8 -17
- package/docs/exit-codes.md +14 -0
- package/docs/git-workflow.md +4 -62
- package/docs/network-security.md +3 -13
- package/docs/plans/ULTRAPLAN-FORGE-BAAS.md +1 -1
- package/docs/prd-purple-lab-platform-v2.md +1 -1
- package/docs/prd-purple-lab-platform.md +1 -3
- package/docs/research-2026-07/GAP-ANALYSIS-BACKLOG.md +1 -1
- package/docs/show-hn-post.md +3 -4
- package/docs/siem-integration.md +8 -10
- package/docs/test-scenarios/edge-cases.md +2 -2
- package/docs/test-scenarios/enterprise-scenarios.md +4 -4
- package/docs/v10/BOARD.md +2 -0
- package/docs/v10/GUIDE.md +5 -3
- package/docs/v10/METRICS.md +36 -0
- package/docs/v10/PROGRESS.md +35 -0
- package/docs/walkthrough/README.md +1 -7
- package/loki-ts/dist/loki.js +370 -370
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-DxGZSpFg.js → AdminPage-BH_7fpoz.js} +1 -1
- package/web-app/dist/assets/{Avatar-BZEp23D8.js → Avatar-BzyhrX0s.js} +1 -1
- package/web-app/dist/assets/{Badge-DnDwqDLu.js → Badge-CL05xwgC.js} +1 -1
- package/web-app/dist/assets/{Button-C9KDP4c1.js → Button-BVHrGLGH.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-DRlh3LV2.js → CockpitPage-B9BAEqD-.js} +1 -1
- package/web-app/dist/assets/{ComparePage-C_gE45dQ.js → ComparePage-D4grckhY.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-CL-J9-Cs.js → ErrorBoundary-Bg52u7eg.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-DABd176Y.js → EvidenceReceiptPanel-B15kRZbJ.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-C4lYbm6O.js → GitHubIssuesPanel-DP_BhJSC.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CgiUixwo.js → GitHubPRsPanel-DJBu83u9.js} +1 -1
- package/web-app/dist/assets/{HomePage-DC99RIrz.js → HomePage-6mw4uvLs.js} +2 -2
- package/web-app/dist/assets/{LoginPage-Bd72QWNk.js → LoginPage-BWWzFHif.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CBouFs2K.js → MagicPage-CSyo7k5t.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-c9MyDcBp.js → MetricsPage-Buq03cch.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-ZPhiebiW.js → NotFoundPage-BG8vuV4g.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-DKwrALUb.js → ProjectPage-D7eJn--6.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-X1uZKZC_.js → ProjectsPage-DXPycvjA.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-CiZKZRto.js → SettingsPage-Blvav87U.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-k_imKfeh.js → ShowcasePage-ZCAKRsNN.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-Wx9ESLll.js → SystemSettingsPage-ClzjKfI4.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-B7upn-I7.js → TeamsPage-CKp1-WGX.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-oJf_VKgh.js → TemplatesPage-C0yAp2Yl.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-BdfPlgLI.js → TerminalOutput-C3KbV8E7.js} +1 -1
- package/web-app/dist/assets/{activity-BcZTsgWH.js → activity-CG5PtpVI.js} +1 -1
- package/web-app/dist/assets/{bell-JNEkjWQ2.js → bell-CyLp--NR.js} +1 -1
- package/web-app/dist/assets/{bot-DMAJobP_.js → bot-Dp0zY9t7.js} +1 -1
- package/web-app/dist/assets/{check-DPTry-z2.js → check-ipranz8L.js} +1 -1
- package/web-app/dist/assets/{chevron-left-DSqOEtTV.js → chevron-left-DhYs9D3t.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BbL8Vysv.js → circle-alert-BLAxBNgz.js} +1 -1
- package/web-app/dist/assets/{clock-BHEr5lGW.js → clock-DzocnapF.js} +1 -1
- package/web-app/dist/assets/{cloud-C48PunYo.js → cloud-B3YsIUwq.js} +1 -1
- package/web-app/dist/assets/{code-xml-Boa7kRqT.js → code-xml-BPcy_Eqm.js} +1 -1
- package/web-app/dist/assets/{database-Eu35Gv7z.js → database-DOuuclZf.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-BO9fCnHW.js → dollar-sign-o77cj-xB.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-DHsSDIL_.js → file-code-corner-BZ2VMnYA.js} +1 -1
- package/web-app/dist/assets/{file-plus-BYiQLe5l.js → file-plus-DeHfGncj.js} +1 -1
- package/web-app/dist/assets/{globe-BPZ5sQI2.js → globe-DmIW-phf.js} +1 -1
- package/web-app/dist/assets/{hammer-DlOLTsH3.js → hammer-CoW8R8V5.js} +1 -1
- package/web-app/dist/assets/{index-BrgkuqSA.js → index-5rCTAnsF.js} +3 -3
- package/web-app/dist/assets/{layers-DMNdn1GR.js → layers-DGEk-2RQ.js} +1 -1
- package/web-app/dist/assets/{loader-circle-_e9fypHL.js → loader-circle-DXn-F-H5.js} +1 -1
- package/web-app/dist/assets/{lock-D_elxMZb.js → lock-D5nAjeUu.js} +1 -1
- package/web-app/dist/assets/{package-Bw-7g3JI.js → package-D8Drpzi8.js} +1 -1
- package/web-app/dist/assets/{plus-CZeq0pgJ.js → plus-C-0T9dAu.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-D0WMyffs.js → refresh-cw-AyYQQPZG.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CcKyNwXG.js → rotate-ccw-DYbslLfT.js} +1 -1
- package/web-app/dist/assets/{scroll-text-C0L9Vqpm.js → scroll-text-CQFQhF_6.js} +1 -1
- package/web-app/dist/assets/{server-SgwFv6cd.js → server-DxNytQEJ.js} +1 -1
- package/web-app/dist/assets/{shield-alert-D1tESlfI.js → shield-alert-Xj5wGUSK.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-bUtUA15O.js → thumbs-up-DW0j9Yxz.js} +1 -1
- package/web-app/dist/assets/{trash-2-C9Nv1kKk.js → trash-2-VOdpiJ1e.js} +1 -1
- package/web-app/dist/assets/{trending-down-Bh107AJe.js → trending-down-eTizH4_b.js} +1 -1
- package/web-app/dist/assets/{trending-up-25BqhLVq.js → trending-up-agYVl7bU.js} +1 -1
- package/web-app/dist/assets/{upload-DDYtMbN5.js → upload-DQ3NfXA_.js} +1 -1
- package/web-app/dist/assets/{usePolling-BSDT0il_.js → usePolling-BKETnkDT.js} +1 -1
- package/web-app/dist/assets/{user-CKpUEUhK.js → user-Cv6BNmOT.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/docs/README-FULL.md +0 -977
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""First-run onboarding and the issue backlog (D51 Phase A).
|
|
2
|
+
|
|
3
|
+
Three onboarding steps (provider, GitHub PAT, repo) and a backlog that queues
|
|
4
|
+
v10 issue-mode runs through the existing launcher: `bin/loki owner/repo#N
|
|
5
|
+
--json` with LOKI_ENGINE=v10, one git worktree per issue, so each run gets its
|
|
6
|
+
own branch and PR from the engine.
|
|
7
|
+
|
|
8
|
+
Secrets: the PAT and any provider API key live in ~/.loki/credentials/ (dir
|
|
9
|
+
0700, files 0600). No response, log line or event carries them; the PAT reaches
|
|
10
|
+
GitHub only in an Authorization header and a child process env, never a URL or
|
|
11
|
+
argv.
|
|
12
|
+
|
|
13
|
+
ponytail: run state is in memory (a dashboard restart forgets the queue; the
|
|
14
|
+
runs themselves keep going and their worktrees keep the receipts). The
|
|
15
|
+
v10 engine has no resume-with-answer (--resume was removed), so a BLOCKED
|
|
16
|
+
question is shown with the re-run command instead of an answer box.
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
32
|
+
from pydantic import BaseModel
|
|
33
|
+
|
|
34
|
+
from . import auth
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
router = APIRouter(tags=["start"])
|
|
38
|
+
|
|
39
|
+
START_HTML = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "start.html")
|
|
40
|
+
_LOKI_BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "loki")
|
|
41
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
42
|
+
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_\-]{10,255}$")
|
|
43
|
+
_KEY_RE = re.compile(r"^[A-Za-z0-9_\-.]{10,512}$")
|
|
44
|
+
_PROVIDER_KEY_ENV = {"claude": "ANTHROPIC_API_KEY", "codex": "OPENAI_API_KEY"}
|
|
45
|
+
_PROVIDER_BIN = {"claude": "claude", "codex": "codex"}
|
|
46
|
+
_SECRET_FILES = {"github": "github", "claude_api_key": "claude_api_key", "codex_api_key": "codex_api_key"}
|
|
47
|
+
_lock = threading.RLock()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _pick(table: dict, key):
|
|
51
|
+
"""Return the table's own constant equal to key, so request data never reaches a sink."""
|
|
52
|
+
for const in table.values():
|
|
53
|
+
if const == key:
|
|
54
|
+
return const
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _secret_path(name: str) -> Path:
|
|
59
|
+
const = _pick(_SECRET_FILES, name)
|
|
60
|
+
if const is None:
|
|
61
|
+
raise ValueError("unknown secret name")
|
|
62
|
+
base = _creds_dir().resolve()
|
|
63
|
+
dest = (base / const).resolve()
|
|
64
|
+
if dest.parent != base:
|
|
65
|
+
raise ValueError("secret path escapes credentials dir")
|
|
66
|
+
return dest
|
|
67
|
+
_RUNS: dict = {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GitHubError(Exception):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- storage -----------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _home() -> Path:
|
|
77
|
+
return Path(os.path.expanduser("~")) / ".loki"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _creds_dir() -> Path:
|
|
81
|
+
d = _home() / "credentials"
|
|
82
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
os.chmod(d, 0o700)
|
|
84
|
+
return d
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _write_secret(name: str, value: str) -> None:
|
|
88
|
+
dest = _secret_path(name)
|
|
89
|
+
tmp = dest.with_name(".%s.%d.tmp" % (dest.name, os.getpid()))
|
|
90
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
91
|
+
try:
|
|
92
|
+
os.fchmod(fd, 0o600)
|
|
93
|
+
os.write(fd, (value + "\n").encode())
|
|
94
|
+
finally:
|
|
95
|
+
os.close(fd)
|
|
96
|
+
os.replace(tmp, dest)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _read_secret(name: str) -> Optional[str]:
|
|
100
|
+
try:
|
|
101
|
+
return _secret_path(name).read_text().strip() or None
|
|
102
|
+
except (OSError, ValueError):
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cfg() -> dict:
|
|
107
|
+
try:
|
|
108
|
+
return json.loads((_home() / "onboarding.json").read_text())
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _save_cfg(**kv) -> None:
|
|
114
|
+
c = _cfg()
|
|
115
|
+
c.update(kv)
|
|
116
|
+
_home().mkdir(parents=True, exist_ok=True)
|
|
117
|
+
(_home() / "onboarding.json").write_text(json.dumps(c))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- external edges (mocked in tests) ----------------------------------------
|
|
121
|
+
|
|
122
|
+
def _gh_api(path: str, token: str):
|
|
123
|
+
req = urllib.request.Request(
|
|
124
|
+
"https://api.github.com" + path,
|
|
125
|
+
headers={"Authorization": "Bearer " + token, "Accept": "application/vnd.github+json",
|
|
126
|
+
"User-Agent": "loki-mode"})
|
|
127
|
+
try:
|
|
128
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
129
|
+
return json.load(r)
|
|
130
|
+
except urllib.error.HTTPError as e:
|
|
131
|
+
raise GitHubError("GitHub returned HTTP %d" % e.code)
|
|
132
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
133
|
+
raise GitHubError("could not reach GitHub")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _cli_version(name: str) -> Optional[str]:
|
|
137
|
+
"""Version probe only. Never reads the CLI's credential files."""
|
|
138
|
+
binary = _pick(_PROVIDER_BIN, name)
|
|
139
|
+
if binary is None or not shutil.which(binary):
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
out = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=8)
|
|
143
|
+
except (OSError, subprocess.SubprocessError):
|
|
144
|
+
return None
|
|
145
|
+
return (out.stdout.strip().splitlines() or ["unknown"])[0] if out.returncode == 0 else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _git(*args, cwd=None, env=None) -> str:
|
|
149
|
+
r = subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True, timeout=300)
|
|
150
|
+
if r.returncode != 0:
|
|
151
|
+
raise RuntimeError("git %s failed" % args[0])
|
|
152
|
+
return r.stdout.strip()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _project_dir() -> str:
|
|
156
|
+
return os.environ.get("LOKI_PROJECT_DIR") or os.getcwd()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _prepare_workdir(repo: str, number: int, token: str) -> str:
|
|
160
|
+
"""One git worktree per issue, off the repo's default branch."""
|
|
161
|
+
env = dict(os.environ, GH_TOKEN=token, GIT_TERMINAL_PROMPT="0")
|
|
162
|
+
slug = repo.replace("/", "__")
|
|
163
|
+
base = _project_dir()
|
|
164
|
+
try:
|
|
165
|
+
origin = _git("remote", "get-url", "origin", cwd=base)
|
|
166
|
+
except (RuntimeError, OSError):
|
|
167
|
+
origin = ""
|
|
168
|
+
if not re.search(r"[:/]" + re.escape(repo) + r"(\.git)?/?$", origin):
|
|
169
|
+
base = str(_home() / "repos" / slug)
|
|
170
|
+
if not os.path.isdir(base):
|
|
171
|
+
os.makedirs(os.path.dirname(base), exist_ok=True)
|
|
172
|
+
subprocess.run(["gh", "repo", "clone", "--", repo, base], env=env, check=True,
|
|
173
|
+
capture_output=True, timeout=600)
|
|
174
|
+
try:
|
|
175
|
+
_git("fetch", "origin", cwd=base, env=env)
|
|
176
|
+
start = "origin/HEAD"
|
|
177
|
+
_git("rev-parse", "--verify", start, cwd=base)
|
|
178
|
+
except (RuntimeError, OSError):
|
|
179
|
+
start = "HEAD"
|
|
180
|
+
stamp = time.strftime("%Y%m%d%H%M%S")
|
|
181
|
+
wt = _home() / "worktrees" / slug / ("issue-%d-%s" % (number, stamp))
|
|
182
|
+
wt.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
_git("worktree", "add", "-b", "loki/backlog-%d-%s" % (number, stamp), str(wt), start, cwd=base)
|
|
184
|
+
return str(wt)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _out_path(workdir: str) -> Path:
|
|
188
|
+
"""Run output lives beside, never inside, the worktree the engine commits from."""
|
|
189
|
+
w = Path(workdir)
|
|
190
|
+
d = _home() / "backlog"
|
|
191
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
return d / ("%s__%s.out" % (w.parent.name, w.name))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def launch_run(repo: str, number: int, workdir: str, env: dict):
|
|
196
|
+
"""The existing launcher: `loki owner/repo#N --json` in the issue's worktree."""
|
|
197
|
+
with open(_out_path(workdir), "w") as out:
|
|
198
|
+
return subprocess.Popen([_LOKI_BIN, "%s#%d" % (repo, number), "--json"], cwd=workdir, env=env,
|
|
199
|
+
stdout=out, stderr=subprocess.STDOUT, start_new_session=True)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _read_outcome(workdir: str, rc: int) -> dict:
|
|
203
|
+
run_id, outcome = None, None
|
|
204
|
+
try:
|
|
205
|
+
j = json.loads(_out_path(workdir).read_text().strip().splitlines()[-1])
|
|
206
|
+
run_id, outcome = j.get("run_id"), j.get("outcome")
|
|
207
|
+
except (OSError, ValueError, IndexError, AttributeError):
|
|
208
|
+
pass
|
|
209
|
+
pr_url, question = None, None
|
|
210
|
+
runs = Path(workdir, ".loki", "runs")
|
|
211
|
+
ev = runs / run_id / "events.jsonl" if run_id else None
|
|
212
|
+
if ev is None or not ev.is_file():
|
|
213
|
+
found = sorted(runs.glob("*/events.jsonl")) if runs.is_dir() else []
|
|
214
|
+
ev = found[-1] if found else None
|
|
215
|
+
if ev:
|
|
216
|
+
for line in ev.read_text(errors="replace").splitlines():
|
|
217
|
+
try:
|
|
218
|
+
e = json.loads(line)
|
|
219
|
+
except ValueError:
|
|
220
|
+
continue
|
|
221
|
+
d = e.get("data") or {}
|
|
222
|
+
if e.get("type") == "pr.opened" and d.get("url"):
|
|
223
|
+
pr_url = d["url"] if str(d["url"]).startswith("https://") else None
|
|
224
|
+
if e.get("type") == "stage.completed" and e.get("stage") == "implement" and d.get("spec_conflict_reason"):
|
|
225
|
+
question = str(d["spec_conflict_reason"])
|
|
226
|
+
if rc == 0:
|
|
227
|
+
return {"status": "pr_open", "pr_url": pr_url} if pr_url else {"status": "done", "detail": outcome or "no change needed"}
|
|
228
|
+
if rc == 4:
|
|
229
|
+
return {"status": "blocked", "question": question or "see the run receipt"}
|
|
230
|
+
return {"status": "failed", "detail": "%s (exit %s)" % (outcome or "FAILED", rc)}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# --- onboarding --------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def _scope(s):
|
|
236
|
+
return [Depends(auth.require_scope(s))]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _github() -> dict:
|
|
240
|
+
tok = _read_secret("github")
|
|
241
|
+
return {"configured": bool(tok), "login": _cfg().get("github_login") if tok else None}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class ProviderIn(BaseModel):
|
|
245
|
+
provider: str
|
|
246
|
+
api_key: Optional[str] = None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TokenIn(BaseModel):
|
|
250
|
+
token: str
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class RepoIn(BaseModel):
|
|
254
|
+
repo: str
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class RunIn(BaseModel):
|
|
258
|
+
issues: list[int] = []
|
|
259
|
+
all: bool = False
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@router.get("/api/onboarding/state", dependencies=_scope("read"))
|
|
263
|
+
def onboarding_state():
|
|
264
|
+
detected = []
|
|
265
|
+
for n in ("claude", "codex"):
|
|
266
|
+
v = _cli_version(n)
|
|
267
|
+
if v:
|
|
268
|
+
detected.append({"name": n, "version": v})
|
|
269
|
+
c = _cfg()
|
|
270
|
+
return {"provider": {"configured": bool(c.get("provider")), "name": c.get("provider"), "detected": detected},
|
|
271
|
+
"github": _github(), "repo": c.get("repo")}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@router.post("/api/onboarding/provider", dependencies=_scope("control"))
|
|
275
|
+
def set_provider(body: ProviderIn):
|
|
276
|
+
if body.provider not in _PROVIDER_KEY_ENV:
|
|
277
|
+
raise HTTPException(400, "provider must be claude or codex")
|
|
278
|
+
if body.api_key:
|
|
279
|
+
if not _KEY_RE.match(body.api_key):
|
|
280
|
+
raise HTTPException(400, "api key format not accepted")
|
|
281
|
+
_write_secret(body.provider + "_api_key", body.api_key)
|
|
282
|
+
elif not _cli_version(body.provider):
|
|
283
|
+
raise HTTPException(400, "%s CLI not found on PATH; install it or provide an API key" % body.provider)
|
|
284
|
+
_save_cfg(provider=body.provider)
|
|
285
|
+
return {"configured": True, "name": body.provider}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@router.post("/api/onboarding/github", dependencies=_scope("control"))
|
|
289
|
+
def set_github(body: TokenIn):
|
|
290
|
+
tok = body.token.strip()
|
|
291
|
+
if not _TOKEN_RE.match(tok):
|
|
292
|
+
raise HTTPException(400, "token format not accepted")
|
|
293
|
+
try:
|
|
294
|
+
login = _gh_api("/user", tok).get("login")
|
|
295
|
+
except GitHubError as e:
|
|
296
|
+
raise HTTPException(400, str(e))
|
|
297
|
+
_write_secret("github", tok)
|
|
298
|
+
_save_cfg(github_login=login)
|
|
299
|
+
return {"configured": True, "login": login}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _token() -> str:
|
|
303
|
+
tok = _read_secret("github")
|
|
304
|
+
if not tok:
|
|
305
|
+
raise HTTPException(409, "GitHub is not connected yet")
|
|
306
|
+
return tok
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@router.get("/api/onboarding/repos", dependencies=_scope("read"))
|
|
310
|
+
def list_repos():
|
|
311
|
+
try:
|
|
312
|
+
rows = _gh_api("/user/repos?per_page=100&sort=updated", _token())
|
|
313
|
+
except GitHubError as e:
|
|
314
|
+
raise HTTPException(502, str(e))
|
|
315
|
+
return {"repos": [r["full_name"] for r in rows]}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@router.post("/api/onboarding/repo", dependencies=_scope("control"))
|
|
319
|
+
def set_repo(body: RepoIn):
|
|
320
|
+
if not _REPO_RE.match(body.repo) or ".." in body.repo or body.repo.startswith("-"):
|
|
321
|
+
raise HTTPException(400, "repo must look like owner/repo")
|
|
322
|
+
_save_cfg(repo=body.repo)
|
|
323
|
+
return {"repo": body.repo}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --- backlog -----------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
def _size(body: str) -> str:
|
|
329
|
+
# ponytail: no issue-text sizing rule found in autonomy/ or dashboard/ (grep size_estimate,
|
|
330
|
+
# estimate_size); body length is the proxy. Swap in a real rule if one appears.
|
|
331
|
+
n = len(body or "")
|
|
332
|
+
return "S" if n < 500 else "M" if n < 2000 else "L"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _open_issues() -> list:
|
|
336
|
+
repo = _cfg().get("repo")
|
|
337
|
+
if not repo:
|
|
338
|
+
raise HTTPException(409, "choose a repo first")
|
|
339
|
+
try:
|
|
340
|
+
rows = _gh_api("/repos/%s/issues?state=open&per_page=100" % repo, _token())
|
|
341
|
+
except GitHubError as e:
|
|
342
|
+
raise HTTPException(502, str(e))
|
|
343
|
+
return [{"number": r["number"], "title": r.get("title", ""), "url": r.get("html_url"),
|
|
344
|
+
"labels": [l["name"] for l in r.get("labels", [])], "size": _size(r.get("body"))}
|
|
345
|
+
for r in rows if "pull_request" not in r]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@router.get("/api/backlog/issues", dependencies=_scope("read"))
|
|
349
|
+
def backlog_issues():
|
|
350
|
+
return {"repo": _cfg().get("repo"), "issues": _open_issues()}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _concurrency() -> int:
|
|
354
|
+
# ponytail: env now; loki.yaml (D51 item 2) owns this when it lands. No
|
|
355
|
+
# provider rate-limit mechanism exists to honour, so this is the only cap.
|
|
356
|
+
try:
|
|
357
|
+
return max(1, int(os.environ.get("LOKI_BACKLOG_CONCURRENCY", "2")))
|
|
358
|
+
except ValueError:
|
|
359
|
+
return 2
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _child_env(token: str) -> dict:
|
|
363
|
+
e = dict(os.environ, GH_TOKEN=token, GITHUB_TOKEN=token, LOKI_ENGINE="v10", LOKI_NO_BROWSER="1")
|
|
364
|
+
prov = _cfg().get("provider")
|
|
365
|
+
if prov:
|
|
366
|
+
e["LOKI_PROVIDER"] = prov
|
|
367
|
+
key = _read_secret(prov + "_api_key")
|
|
368
|
+
if key:
|
|
369
|
+
e[_PROVIDER_KEY_ENV[prov]] = key
|
|
370
|
+
return e
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _pump() -> None:
|
|
374
|
+
"""Reap finished runs, then start queued ones up to the concurrency cap.
|
|
375
|
+
|
|
376
|
+
Clone/worktree prep happens outside the lock (a first clone can take
|
|
377
|
+
minutes); a row is marked "preparing" so a concurrent pump cannot double-start it.
|
|
378
|
+
"""
|
|
379
|
+
with _lock:
|
|
380
|
+
for r in _RUNS.values():
|
|
381
|
+
if r["status"] == "running" and r["proc"].poll() is not None:
|
|
382
|
+
r.update(_read_outcome(r["workdir"], r["proc"].returncode))
|
|
383
|
+
busy = sum(1 for r in _RUNS.values() if r["status"] in ("running", "preparing"))
|
|
384
|
+
batch = [r for r in _RUNS.values() if r["status"] == "queued"][:max(0, _concurrency() - busy)]
|
|
385
|
+
for r in batch:
|
|
386
|
+
r["status"] = "preparing"
|
|
387
|
+
repo, token = _cfg().get("repo"), _read_secret("github")
|
|
388
|
+
for r in batch:
|
|
389
|
+
try:
|
|
390
|
+
wd = _prepare_workdir(repo, r["number"], token)
|
|
391
|
+
proc = launch_run(repo, r["number"], wd, _child_env(token))
|
|
392
|
+
with _lock:
|
|
393
|
+
r.update(workdir=wd, proc=proc, status="running")
|
|
394
|
+
except Exception as e: # never echo env or token
|
|
395
|
+
logger.error("backlog launch failed for #%s: %s", r["number"], type(e).__name__)
|
|
396
|
+
with _lock:
|
|
397
|
+
r.update(status="failed", detail="could not start: %s" % type(e).__name__)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
_thread = None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _ensure_pump_thread() -> None:
|
|
404
|
+
"""Advance the queue without a browser polling (close the tab, runs still drain)."""
|
|
405
|
+
global _thread
|
|
406
|
+
with _lock:
|
|
407
|
+
if _thread is not None and _thread.is_alive():
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
def loop():
|
|
411
|
+
while True:
|
|
412
|
+
time.sleep(3)
|
|
413
|
+
try:
|
|
414
|
+
_pump()
|
|
415
|
+
except Exception as e:
|
|
416
|
+
logger.error("backlog pump failed: %s", type(e).__name__)
|
|
417
|
+
with _lock:
|
|
418
|
+
if not any(r["status"] in ("queued", "preparing", "running") for r in _RUNS.values()):
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
_thread = threading.Thread(target=loop, daemon=True, name="backlog-pump")
|
|
422
|
+
_thread.start()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@router.post("/api/backlog/run", dependencies=_scope("control"))
|
|
426
|
+
def backlog_run(body: RunIn):
|
|
427
|
+
_token()
|
|
428
|
+
issues = {i["number"]: i for i in _open_issues()}
|
|
429
|
+
nums = list(issues) if body.all else [n for n in body.issues if n in issues]
|
|
430
|
+
queued = []
|
|
431
|
+
with _lock:
|
|
432
|
+
for n in nums:
|
|
433
|
+
if _RUNS.get(n, {}).get("status") in ("queued", "preparing", "running"):
|
|
434
|
+
continue
|
|
435
|
+
_RUNS[n] = {"number": n, "title": issues[n]["title"], "status": "queued", "proc": None, "workdir": None}
|
|
436
|
+
queued.append(n)
|
|
437
|
+
_pump()
|
|
438
|
+
_ensure_pump_thread()
|
|
439
|
+
return {"queued": queued}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@router.get("/api/backlog/status", dependencies=_scope("read"))
|
|
443
|
+
def backlog_status():
|
|
444
|
+
_pump()
|
|
445
|
+
repo = _cfg().get("repo")
|
|
446
|
+
out = []
|
|
447
|
+
with _lock:
|
|
448
|
+
for r in _RUNS.values():
|
|
449
|
+
row = {k: r.get(k) for k in ("number", "title", "status", "pr_url", "question", "detail")}
|
|
450
|
+
if r["status"] == "blocked":
|
|
451
|
+
row["resume_hint"] = ("The engine cannot resume with an answer (--resume was removed). "
|
|
452
|
+
"Answer on the issue, then re-run: loki %s#%d" % (repo, r["number"]))
|
|
453
|
+
out.append(row)
|
|
454
|
+
return {"repo": repo, "concurrency": _concurrency(), "issues": out}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def reset_runs_for_tests() -> None:
|
|
458
|
+
with _lock:
|
|
459
|
+
_RUNS.clear()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def reset_for_tests() -> None:
|
|
463
|
+
reset_runs_for_tests()
|
package/dashboard/server.py
CHANGED
|
@@ -1264,6 +1264,14 @@ class WebSocketBoundaryMiddleware:
|
|
|
1264
1264
|
app.add_middleware(WebSocketBoundaryMiddleware)
|
|
1265
1265
|
|
|
1266
1266
|
|
|
1267
|
+
_bind_host_override: Optional[str] = None
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def _bound_non_loopback() -> bool:
|
|
1271
|
+
bind = (_bind_host_override or os.environ.get("LOKI_DASHBOARD_HOST", "")).strip().lower()
|
|
1272
|
+
return bool(bind) and bind not in ("127.0.0.1", "localhost", "::1", "[::1]")
|
|
1273
|
+
|
|
1274
|
+
|
|
1267
1275
|
@app.middleware("http")
|
|
1268
1276
|
async def dashboard_control_boundary(request: Request, call_next):
|
|
1269
1277
|
# EVERY mutation, plus reads that expose operational or credential-adjacent
|
|
@@ -1273,6 +1281,17 @@ async def dashboard_control_boundary(request: Request, call_next):
|
|
|
1273
1281
|
#
|
|
1274
1282
|
# /health and /metrics are intentionally NOT in the sensitive list, so a
|
|
1275
1283
|
# container health probe and a Prometheus scrape keep working unconfigured.
|
|
1284
|
+
# DNS rebinding: a page rebound to 127.0.0.1 sends its own Host, which the
|
|
1285
|
+
# origin check would trust. Refuse any Host not loopback or explicitly allowed.
|
|
1286
|
+
if request.method == "GET" and request.url.path in ("/health", "/metrics"):
|
|
1287
|
+
return await call_next(request) # probes send Host = pod IP
|
|
1288
|
+
host = request.headers.get("host", "").strip().lower()
|
|
1289
|
+
host = host[:host.index("]") + 1] if host.startswith("[") else host.rsplit(":", 1)[0]
|
|
1290
|
+
extra = {h.strip().lower() for h in os.environ.get("LOKI_DASHBOARD_ALLOWED_HOSTS", "").split(",") if h.strip()}
|
|
1291
|
+
# Non-loopback bind with auth on: any Host is fine (auth is the boundary).
|
|
1292
|
+
open_host = _bound_non_loopback() and (auth.ENTERPRISE_AUTH_ENABLED or auth.OIDC_ENABLED)
|
|
1293
|
+
if not open_host and host not in {"127.0.0.1", "localhost", "[::1]"} | extra:
|
|
1294
|
+
return JSONResponse(status_code=403, content={"detail": "host not allowed"})
|
|
1276
1295
|
gated = request.method in ("POST", "PUT", "PATCH", "DELETE")
|
|
1277
1296
|
if gated and not browser_mutation_origin_allowed(request, _cors_origins):
|
|
1278
1297
|
return JSONResponse(
|
|
@@ -1324,6 +1343,16 @@ except ImportError as _operator_exc: # pragma: no cover - optional dep absent
|
|
|
1324
1343
|
else:
|
|
1325
1344
|
app.include_router(api_operator_router)
|
|
1326
1345
|
|
|
1346
|
+
# D51 Phase A: first-run onboarding + backlog API and its /start page.
|
|
1347
|
+
from .api_start import router as api_start_router, START_HTML as _START_HTML
|
|
1348
|
+
app.include_router(api_start_router)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
@app.get("/start", include_in_schema=False, dependencies=[Depends(auth.require_scope("read"))])
|
|
1352
|
+
async def serve_start_page():
|
|
1353
|
+
return FileResponse(_START_HTML, media_type="text/html")
|
|
1354
|
+
|
|
1355
|
+
|
|
1327
1356
|
# Phase Merge-4: Mount Purple Lab FastAPI app under /lab/ so it appears as a
|
|
1328
1357
|
# sidebar entry in Dashboard. Same `app` is also wrapped by `standalone_app`
|
|
1329
1358
|
# in web-app/server.py for `loki web` (port 57375). One source of truth, no
|
|
@@ -13076,11 +13105,13 @@ async def serve_spa_catchall(full_path: str):
|
|
|
13076
13105
|
def run_server(host: str = None, port: int = None) -> None:
|
|
13077
13106
|
"""Run the dashboard server."""
|
|
13078
13107
|
import uvicorn
|
|
13108
|
+
global _bind_host_override
|
|
13079
13109
|
if host is None:
|
|
13080
13110
|
# Default to localhost-only for security
|
|
13081
13111
|
host = os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1")
|
|
13082
13112
|
if port is None:
|
|
13083
13113
|
port = _safe_int_env("LOKI_DASHBOARD_PORT", 57374)
|
|
13114
|
+
_bind_host_override = host
|
|
13084
13115
|
|
|
13085
13116
|
uvicorn_kwargs = {
|
|
13086
13117
|
"host": host,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Loki Mode - Get started</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{--bg:#fafafa;--fg:#1a1a24;--mut:#6b6b7a;--card:#fff;--bd:#dcdce4;--acc:#553DE9;--ok:#1a7f37;--bad:#c62828;--warn:#9a6700}
|
|
9
|
+
@media (prefers-color-scheme:dark){:root{--bg:#14141b;--fg:#ececf3;--mut:#9a9aab;--card:#1d1d27;--bd:#33333f;--acc:#8b78ff;--ok:#4ac26b;--bad:#ff6b6b;--warn:#e3b341}}
|
|
10
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif}
|
|
11
|
+
main{max-width:860px;margin:0 auto;padding:24px 16px}
|
|
12
|
+
h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:0 0 8px}
|
|
13
|
+
.card{background:var(--card);border:1px solid var(--bd);border-radius:8px;padding:16px;margin:16px 0}
|
|
14
|
+
.card.off{opacity:.55}
|
|
15
|
+
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0}
|
|
16
|
+
input,select{padding:8px;border:1px solid var(--bd);border-radius:6px;background:var(--bg);color:var(--fg);min-width:220px}
|
|
17
|
+
button{padding:8px 14px;border:0;border-radius:6px;background:var(--acc);color:#fff;cursor:pointer}
|
|
18
|
+
button.sec{background:transparent;color:var(--fg);border:1px solid var(--bd)}
|
|
19
|
+
.mut{color:var(--mut)}.ok{color:var(--ok)}.bad{color:var(--bad)}
|
|
20
|
+
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--bd);vertical-align:top}
|
|
21
|
+
.tag{display:inline-block;padding:0 6px;border:1px solid var(--bd);border-radius:10px;font-size:12px;margin-right:4px}
|
|
22
|
+
.st{font-weight:600}.st.blocked,.st.failed{color:var(--bad)}.st.pr_open,.st.done{color:var(--ok)}.st.running{color:var(--acc)}.st.queued{color:var(--warn)}
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<main>
|
|
27
|
+
<h1>Loki Mode</h1>
|
|
28
|
+
<p class="mut">Connect once, then complete your backlog. Each issue gets its own worktree, branch and PR.</p>
|
|
29
|
+
|
|
30
|
+
<section class="card" id="s1"><h2>1. Provider</h2>
|
|
31
|
+
<div id="s1-state" class="mut">Checking...</div>
|
|
32
|
+
<div class="row"><select id="prov"><option value="claude">claude</option><option value="codex">codex</option></select>
|
|
33
|
+
<input id="key" type="password" autocomplete="off" placeholder="API key (optional if the CLI is logged in)">
|
|
34
|
+
<button id="s1-go">Use provider</button></div><div id="s1-msg"></div></section>
|
|
35
|
+
|
|
36
|
+
<section class="card off" id="s2"><h2>2. GitHub</h2>
|
|
37
|
+
<div id="s2-state" class="mut">Not connected</div>
|
|
38
|
+
<div class="row"><input id="pat" type="password" autocomplete="off" placeholder="Personal access token (repo scope)">
|
|
39
|
+
<button id="s2-go">Connect</button></div>
|
|
40
|
+
<div class="mut">Stored on this machine only (mode 0600). It is never shown again.</div><div id="s2-msg"></div></section>
|
|
41
|
+
|
|
42
|
+
<section class="card off" id="s3"><h2>3. Repository</h2>
|
|
43
|
+
<div class="row"><select id="repos"></select><input id="repo" placeholder="or type owner/repo">
|
|
44
|
+
<button id="s3-go">Choose</button></div><div id="s3-msg"></div></section>
|
|
45
|
+
|
|
46
|
+
<section class="card off" id="s4"><h2>Backlog <span id="bl-repo" class="mut"></span></h2>
|
|
47
|
+
<div class="row"><button id="sel">Complete selected</button><button id="all">Complete all</button>
|
|
48
|
+
<button class="sec" id="refresh">Refresh</button><span class="mut" id="conc"></span></div>
|
|
49
|
+
<table><thead><tr><th></th><th>#</th><th>Issue</th><th>Size</th><th>Status</th></tr></thead><tbody id="rows"></tbody></table>
|
|
50
|
+
<div id="bl-msg"></div></section>
|
|
51
|
+
</main>
|
|
52
|
+
<script>
|
|
53
|
+
const $=id=>document.getElementById(id);
|
|
54
|
+
const api=async(m,p,b)=>{const r=await fetch(p,{method:m,headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});
|
|
55
|
+
let j={};try{j=await r.json()}catch(e){}if(!r.ok)throw new Error(j.detail||('HTTP '+r.status));return j};
|
|
56
|
+
const say=(id,t,bad)=>{$(id).textContent=t;$(id).className=bad?'bad':'ok'};
|
|
57
|
+
const el=(t,txt,cls)=>{const e=document.createElement(t);if(txt!=null)e.textContent=txt;if(cls)e.className=cls;return e};
|
|
58
|
+
let issues=[],status={};
|
|
59
|
+
async function state(){
|
|
60
|
+
const s=await api('GET','/api/onboarding/state');
|
|
61
|
+
$('s1-state').textContent=s.provider.configured?('Using '+s.provider.name):
|
|
62
|
+
(s.provider.detected.length?'Detected: '+s.provider.detected.map(d=>d.name+' '+d.version).join(', '):'No claude or codex CLI found; enter an API key.');
|
|
63
|
+
if(s.provider.detected.length&&!s.provider.configured)$('prov').value=s.provider.detected[0].name;
|
|
64
|
+
$('s2').classList.toggle('off',!s.provider.configured);
|
|
65
|
+
$('s2-state').textContent=s.github.configured?('Connected as '+s.github.login):'Not connected';
|
|
66
|
+
$('s3').classList.toggle('off',!s.github.configured);
|
|
67
|
+
if(s.github.configured&&!$('repos').options.length){try{const r=await api('GET','/api/onboarding/repos');
|
|
68
|
+
r.repos.forEach(x=>$('repos').appendChild(el('option',x)))}catch(e){say('s3-msg',e.message,1)}}
|
|
69
|
+
$('s4').classList.toggle('off',!s.repo);
|
|
70
|
+
if(s.repo){$('bl-repo').textContent=s.repo;if(!issues.length)await loadIssues()}
|
|
71
|
+
}
|
|
72
|
+
async function loadIssues(){try{issues=(await api('GET','/api/backlog/issues')).issues;render()}catch(e){say('bl-msg',e.message,1)}}
|
|
73
|
+
function render(){
|
|
74
|
+
const tb=$('rows');tb.replaceChildren();
|
|
75
|
+
issues.forEach(i=>{const st=status[i.number]||{};const tr=el('tr');
|
|
76
|
+
const cb=el('input');cb.type='checkbox';cb.dataset.n=i.number;cb.style.minWidth='0';tr.appendChild(el('td')).appendChild(cb);
|
|
77
|
+
tr.appendChild(el('td','#'+i.number));
|
|
78
|
+
const td=el('td');const a=el('a',i.title);a.href=i.url||'#';a.target='_blank';a.rel='noopener';td.appendChild(a);
|
|
79
|
+
i.labels.forEach(l=>td.appendChild(el('span',l,'tag')));tr.appendChild(td);
|
|
80
|
+
tr.appendChild(el('td',i.size));
|
|
81
|
+
const sc=el('td');sc.appendChild(el('span',st.status||'-','st '+(st.status||'')));
|
|
82
|
+
if(st.pr_url){sc.appendChild(document.createTextNode(' '));const p=el('a','PR');p.href=st.pr_url;p.target='_blank';p.rel='noopener';sc.appendChild(p)}
|
|
83
|
+
if(st.question)sc.appendChild(el('div','Question: '+st.question,'mut'));
|
|
84
|
+
if(st.resume_hint)sc.appendChild(el('div',st.resume_hint,'mut'));
|
|
85
|
+
if(st.detail)sc.appendChild(el('div',st.detail,'mut'));
|
|
86
|
+
tr.appendChild(sc);tb.appendChild(tr)})}
|
|
87
|
+
async function poll(){try{const s=await api('GET','/api/backlog/status');status={};s.issues.forEach(i=>status[i.number]=i);
|
|
88
|
+
$('conc').textContent='concurrency '+s.concurrency;if(issues.length)render()}catch(e){}}
|
|
89
|
+
async function go(btn,msg,fn){btn.disabled=true;try{await fn();say(msg,'Done')}catch(e){say(msg,e.message,1)}btn.disabled=false;await state()}
|
|
90
|
+
$('s1-go').onclick=e=>go(e.target,'s1-msg',async()=>{await api('POST','/api/onboarding/provider',{provider:$('prov').value,api_key:$('key').value||null});$('key').value=''});
|
|
91
|
+
$('s2-go').onclick=e=>go(e.target,'s2-msg',async()=>{await api('POST','/api/onboarding/github',{token:$('pat').value});$('pat').value=''});
|
|
92
|
+
$('s3-go').onclick=e=>go(e.target,'s3-msg',async()=>{await api('POST','/api/onboarding/repo',{repo:$('repo').value||$('repos').value});issues=[]});
|
|
93
|
+
$('sel').onclick=e=>go(e.target,'bl-msg',async()=>{const n=[...document.querySelectorAll('#rows input:checked')].map(c=>+c.dataset.n);
|
|
94
|
+
if(!n.length)throw new Error('Select at least one issue');await api('POST','/api/backlog/run',{issues:n});await poll()});
|
|
95
|
+
$('all').onclick=e=>go(e.target,'bl-msg',async()=>{await api('POST','/api/backlog/run',{all:true});await poll()});
|
|
96
|
+
$('refresh').onclick=loadIssues;
|
|
97
|
+
state();setInterval(poll,3000);
|
|
98
|
+
</script>
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|