gitroster 0.2.0__py3-none-any.whl
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.
- gitroster/__init__.py +1 -0
- gitroster/__main__.py +4 -0
- gitroster/agent.py +157 -0
- gitroster/cli.py +43 -0
- gitroster/demo.py +140 -0
- gitroster/deps.py +207 -0
- gitroster/export.py +40 -0
- gitroster/opener.py +218 -0
- gitroster/pathscan.py +34 -0
- gitroster/procs.py +179 -0
- gitroster/repoinfo.py +228 -0
- gitroster/scanner.py +454 -0
- gitroster/server.py +234 -0
- gitroster/static/icon.svg +12 -0
- gitroster/static/index.html +833 -0
- gitroster/store.py +80 -0
- gitroster-0.2.0.dist-info/METADATA +138 -0
- gitroster-0.2.0.dist-info/RECORD +22 -0
- gitroster-0.2.0.dist-info/WHEEL +5 -0
- gitroster-0.2.0.dist-info/entry_points.txt +2 -0
- gitroster-0.2.0.dist-info/licenses/LICENSE +9 -0
- gitroster-0.2.0.dist-info/top_level.txt +1 -0
gitroster/server.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Local HTTP server: JSON API + one static page. Binds to 127.0.0.1 only."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
from urllib.parse import parse_qs, urlparse
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__, agent, export, opener, procs, repoinfo, scanner
|
|
15
|
+
from .store import Store
|
|
16
|
+
|
|
17
|
+
STATIC = Path(__file__).parent / "static"
|
|
18
|
+
READ = {"graph", "overview", "tree", "file", "commit", "search"}
|
|
19
|
+
TYPES = {".html": "text/html; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class App:
|
|
23
|
+
def __init__(self, roots, port, store=None, agent_cmd=None, demo=False):
|
|
24
|
+
self.roots, self.port, self.agent_cmd, self.demo = roots, port, agent_cmd, demo
|
|
25
|
+
self.store = store or Store()
|
|
26
|
+
self.scan = self.store.cached_scan()
|
|
27
|
+
self.scanning = False
|
|
28
|
+
self.lock = threading.Lock()
|
|
29
|
+
threading.Thread(target=opener.detect, daemon=True).start() # warm the editor/terminal cache
|
|
30
|
+
|
|
31
|
+
def settings(self):
|
|
32
|
+
return {"settings": self.store.settings(), **opener.detect(), "agents": agent.catalog(), "version": __version__,
|
|
33
|
+
"agent_override": bool(self.agent_cmd or os.environ.get("GITROSTER_AGENT"))}
|
|
34
|
+
|
|
35
|
+
def summarize(self, path):
|
|
36
|
+
repo = self.repo(path)
|
|
37
|
+
if not repo:
|
|
38
|
+
raise ValueError("unknown repo")
|
|
39
|
+
summary = agent.summarize(repo, self.store.settings(), self.agent_cmd)
|
|
40
|
+
return self.store.update_meta(path, summary=summary)
|
|
41
|
+
|
|
42
|
+
def repo(self, path):
|
|
43
|
+
return next((r for r in self.scan.get("repos", []) if r["path"] == path), None)
|
|
44
|
+
|
|
45
|
+
def do_scan(self):
|
|
46
|
+
with self.lock:
|
|
47
|
+
if self.scanning: # a scan is already running: don't start a second one
|
|
48
|
+
return self.state()
|
|
49
|
+
self.scanning = True
|
|
50
|
+
try:
|
|
51
|
+
result = scanner.scan(self.roots)
|
|
52
|
+
self.scan = result
|
|
53
|
+
self.store.save_scan(result)
|
|
54
|
+
finally:
|
|
55
|
+
self.scanning = False
|
|
56
|
+
return self.state()
|
|
57
|
+
|
|
58
|
+
def live_ports(self):
|
|
59
|
+
ports, containers = procs.listening(), procs.docker_containers()
|
|
60
|
+
procs.link(ports, containers, self.scan.get("repos", []), self.scan.get("envs", []))
|
|
61
|
+
return ports, containers
|
|
62
|
+
|
|
63
|
+
def state(self):
|
|
64
|
+
s = dict(self.scan)
|
|
65
|
+
s["meta"] = self.store.meta()
|
|
66
|
+
s["scanning"] = self.scanning
|
|
67
|
+
s["platform"] = {"win32": "windows", "darwin": "macos"}.get(sys.platform, "linux")
|
|
68
|
+
s["home"] = str(self.store.config.parent) if self.demo else str(Path.home()) # demo paths read as ~/projects/...
|
|
69
|
+
s["demo"] = self.demo
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
def start(self, path, cmd):
|
|
73
|
+
repo = self.repo(path)
|
|
74
|
+
if not repo or cmd not in repo["run"]:
|
|
75
|
+
raise ValueError("command is not a detected run command for this repo")
|
|
76
|
+
log = self.store.log_path(path)
|
|
77
|
+
with open(log, "ab") as f:
|
|
78
|
+
f.write(f"\n$ {cmd} [{time.ctime()}]\n".encode())
|
|
79
|
+
if sys.platform == "win32": # cmd comes from the detected list, never from the request
|
|
80
|
+
proc = subprocess.Popen(cmd, cwd=path, stdout=f, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
|
81
|
+
shell=True, creationflags=0x08000200) # new process group, no console window (DETACHED_PROCESS loses the log)
|
|
82
|
+
else:
|
|
83
|
+
proc = subprocess.Popen(shlex.split(cmd), cwd=path, stdout=f, stderr=subprocess.STDOUT,
|
|
84
|
+
stdin=subprocess.DEVNULL, start_new_session=True)
|
|
85
|
+
threading.Thread(target=proc.wait, daemon=True).start() # reap it when it exits
|
|
86
|
+
return {"pid": proc.pid, "log": str(log)}
|
|
87
|
+
|
|
88
|
+
def tail(self, path, n=4000):
|
|
89
|
+
log = self.store.log_path(path)
|
|
90
|
+
try:
|
|
91
|
+
with open(log, "rb") as f:
|
|
92
|
+
f.seek(0, 2)
|
|
93
|
+
f.seek(max(0, f.tell() - n))
|
|
94
|
+
return f.read().decode(errors="replace")
|
|
95
|
+
except OSError:
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def make_handler(app):
|
|
100
|
+
hosts = {f"127.0.0.1:{app.port}", f"localhost:{app.port}"}
|
|
101
|
+
|
|
102
|
+
class Handler(BaseHTTPRequestHandler):
|
|
103
|
+
def log_message(self, *a):
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def send(self, code, body, ctype="application/json"):
|
|
107
|
+
data = body if isinstance(body, bytes) else body.encode()
|
|
108
|
+
self.send_response(code)
|
|
109
|
+
self.send_header("Content-Type", ctype)
|
|
110
|
+
self.send_header("Content-Length", str(len(data)))
|
|
111
|
+
self.send_header("Cache-Control", "no-store")
|
|
112
|
+
self.end_headers()
|
|
113
|
+
self.wfile.write(data)
|
|
114
|
+
|
|
115
|
+
def ok(self, obj):
|
|
116
|
+
self.send(200, json.dumps(obj))
|
|
117
|
+
|
|
118
|
+
def err(self, code, msg):
|
|
119
|
+
self.send(code, json.dumps({"error": msg}))
|
|
120
|
+
|
|
121
|
+
def read_api(self, name):
|
|
122
|
+
q = {k: v[0] for k, v in parse_qs(urlparse(self.path).query).items()}
|
|
123
|
+
repos = [r for r in app.scan.get("repos", []) if not app.store.meta().get(r["path"], {}).get("ignored")]
|
|
124
|
+
try:
|
|
125
|
+
if name == "search":
|
|
126
|
+
scope = tuple(x for x in q.get("scope", "code,files,commits").split(",") if x in ("code", "files", "commits"))
|
|
127
|
+
return self.ok({"results": repoinfo.search(repos, q.get("q", ""), scope, q.get("regex") == "1")})
|
|
128
|
+
p = q.get("path", "")
|
|
129
|
+
if not app.repo(p):
|
|
130
|
+
return self.err(404, "unknown repo")
|
|
131
|
+
if name == "graph":
|
|
132
|
+
return self.ok({"commits": scanner.graph(p, min(int(q.get("n", 300)), 1000))})
|
|
133
|
+
if name == "overview":
|
|
134
|
+
return self.ok(repoinfo.overview(p))
|
|
135
|
+
if name == "tree":
|
|
136
|
+
return self.ok({"entries": repoinfo.tree(p, q.get("ref"), q.get("dir"))})
|
|
137
|
+
if name == "file":
|
|
138
|
+
return self.ok(repoinfo.read_file(p, q.get("ref"), q.get("file")))
|
|
139
|
+
if name == "commit":
|
|
140
|
+
return self.ok(repoinfo.commit(p, q.get("hash")))
|
|
141
|
+
except ValueError as e:
|
|
142
|
+
return self.err(400, str(e))
|
|
143
|
+
self.err(404, "not found")
|
|
144
|
+
|
|
145
|
+
def do_GET(self):
|
|
146
|
+
if self.headers.get("Host") not in hosts: # DNS rebinding guard
|
|
147
|
+
return self.err(403, "bad host")
|
|
148
|
+
path = self.path.split("?")[0]
|
|
149
|
+
if path in ("/", "/index.html"):
|
|
150
|
+
return self.send(200, (STATIC / "index.html").read_bytes(), TYPES[".html"])
|
|
151
|
+
if path in ("/icon.svg", "/favicon.svg", "/favicon.ico"):
|
|
152
|
+
return self.send(200, (STATIC / "icon.svg").read_bytes(), TYPES[".svg"])
|
|
153
|
+
if path == "/api/state":
|
|
154
|
+
return self.ok(app.state())
|
|
155
|
+
if path.startswith("/api/") and path.split("/")[2] in READ:
|
|
156
|
+
return self.read_api(path.split("/")[2])
|
|
157
|
+
if path == "/api/settings":
|
|
158
|
+
return self.ok(app.settings())
|
|
159
|
+
if path == "/api/export.md":
|
|
160
|
+
return self.send(200, export.markdown(app.scan, app.store.meta()), "text/markdown; charset=utf-8")
|
|
161
|
+
if path == "/api/ports":
|
|
162
|
+
ports, containers = app.live_ports()
|
|
163
|
+
return self.ok({"ports": ports, "containers": containers})
|
|
164
|
+
if path == "/api/log":
|
|
165
|
+
q = dict(x.split("=", 1) for x in self.path.partition("?")[2].split("&") if "=" in x)
|
|
166
|
+
from urllib.parse import unquote
|
|
167
|
+
p = unquote(q.get("path", ""))
|
|
168
|
+
return self.ok({"log": app.tail(p) if app.repo(p) else ""})
|
|
169
|
+
self.err(404, "not found")
|
|
170
|
+
|
|
171
|
+
def do_POST(self):
|
|
172
|
+
origin = self.headers.get("Origin", "").split("//")[-1]
|
|
173
|
+
if self.headers.get("Host") not in hosts or origin not in hosts:
|
|
174
|
+
return self.err(403, "forbidden")
|
|
175
|
+
try:
|
|
176
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
177
|
+
if not 0 <= length <= 1_000_000:
|
|
178
|
+
return self.err(413, "body too large")
|
|
179
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
180
|
+
except ValueError:
|
|
181
|
+
return self.err(400, "bad json")
|
|
182
|
+
path = self.path
|
|
183
|
+
if app.demo and path in ("/api/kill", "/api/start", "/api/open", "/api/summarize"):
|
|
184
|
+
return self.err(403, "disabled in demo mode")
|
|
185
|
+
try:
|
|
186
|
+
if path == "/api/scan": # POST only: a page on another site must not be able to trigger a scan
|
|
187
|
+
return self.ok(app.do_scan())
|
|
188
|
+
if path == "/api/kill":
|
|
189
|
+
pid = int(body["pid"])
|
|
190
|
+
if pid not in {p["pid"] for p in procs.listening()}:
|
|
191
|
+
return self.err(404, "not a listening process")
|
|
192
|
+
os.kill(pid, signal.SIGTERM)
|
|
193
|
+
return self.ok({"ok": True})
|
|
194
|
+
if path == "/api/settings":
|
|
195
|
+
fields = {k: str(body[k])[:300] for k in ("editor", "editor_custom", "terminal", "theme", "agent", "agent_custom") if k in body}
|
|
196
|
+
if fields.get("theme") not in (None, "system", "light", "dark"):
|
|
197
|
+
return self.err(400, "theme must be system, light or dark")
|
|
198
|
+
app.store.update_settings(**fields)
|
|
199
|
+
return self.ok(app.settings())
|
|
200
|
+
if path == "/api/open":
|
|
201
|
+
if not app.repo(body["path"]):
|
|
202
|
+
return self.err(404, "unknown repo")
|
|
203
|
+
return self.ok({"with": opener.open_repo(body["path"], body["target"], app.store.settings())})
|
|
204
|
+
if path == "/api/start":
|
|
205
|
+
return self.ok(app.start(body["path"], body["cmd"]))
|
|
206
|
+
if path == "/api/meta":
|
|
207
|
+
if not app.repo(body["path"]):
|
|
208
|
+
return self.err(404, "unknown repo")
|
|
209
|
+
fields = {k: body[k] for k in ("note", "pinned", "ignored") if k in body}
|
|
210
|
+
if "note" in fields:
|
|
211
|
+
fields["note"] = str(fields["note"])[:4000]
|
|
212
|
+
return self.ok(app.store.update_meta(body["path"], **fields))
|
|
213
|
+
if path == "/api/summarize":
|
|
214
|
+
return self.ok(app.summarize(body["path"]))
|
|
215
|
+
except (KeyError, ValueError, TypeError) as e:
|
|
216
|
+
return self.err(400, f"bad request: {e}")
|
|
217
|
+
except RuntimeError as e:
|
|
218
|
+
return self.err(500, str(e))
|
|
219
|
+
except OSError as e:
|
|
220
|
+
return self.err(500, str(e))
|
|
221
|
+
self.err(404, "not found")
|
|
222
|
+
|
|
223
|
+
return Handler
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def serve(app):
|
|
227
|
+
opener.warm()
|
|
228
|
+
srv = ThreadingHTTPServer(("127.0.0.1", app.port), make_handler(app))
|
|
229
|
+
try:
|
|
230
|
+
srv.serve_forever()
|
|
231
|
+
except KeyboardInterrupt:
|
|
232
|
+
pass
|
|
233
|
+
finally:
|
|
234
|
+
srv.server_close()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#111"/>
|
|
3
|
+
<!-- git history line -->
|
|
4
|
+
<path d="M17 14v36" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".5"/>
|
|
5
|
+
<!-- roster rows: one commit dot + one entry per repo -->
|
|
6
|
+
<circle cx="17" cy="18" r="4.2" fill="#fff"/>
|
|
7
|
+
<rect x="28" y="15" width="26" height="6" rx="3" fill="#fff"/>
|
|
8
|
+
<circle cx="17" cy="32" r="4.2" fill="#fff"/>
|
|
9
|
+
<rect x="28" y="29" width="20" height="6" rx="3" fill="#fff" opacity=".7"/>
|
|
10
|
+
<circle cx="17" cy="46" r="4.2" fill="#fff"/>
|
|
11
|
+
<rect x="28" y="43" width="23" height="6" rx="3" fill="#fff" opacity=".45"/>
|
|
12
|
+
</svg>
|