loopcaptain 0.3.1__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.
Files changed (50) hide show
  1. loopcaptain/__init__.py +0 -0
  2. loopcaptain/agents.py +162 -0
  3. loopcaptain/board/AGENTS.md +39 -0
  4. loopcaptain/board/__init__.py +0 -0
  5. loopcaptain/board/app.py +430 -0
  6. loopcaptain/board/discover.py +249 -0
  7. loopcaptain/board/files.py +94 -0
  8. loopcaptain/board/filters.py +48 -0
  9. loopcaptain/board/graph.py +139 -0
  10. loopcaptain/board/overview.py +59 -0
  11. loopcaptain/board/static/board.js +41 -0
  12. loopcaptain/board/static/fonts/OFL.txt +94 -0
  13. loopcaptain/board/static/fonts/inter-latin-wght-normal.woff2 +0 -0
  14. loopcaptain/board/static/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
  15. loopcaptain/board/static/mark.svg +5 -0
  16. loopcaptain/board/static/style.css +425 -0
  17. loopcaptain/board/steer.py +207 -0
  18. loopcaptain/board/templates/base.html +27 -0
  19. loopcaptain/board/templates/connect.html +35 -0
  20. loopcaptain/board/templates/file.html +9 -0
  21. loopcaptain/board/templates/loop.html +357 -0
  22. loopcaptain/board/templates/overview.html +108 -0
  23. loopcaptain/board/templates/unreadable.html +17 -0
  24. loopcaptain/board/trend.py +185 -0
  25. loopcaptain/cli.py +204 -0
  26. loopcaptain/demo/__init__.py +59 -0
  27. loopcaptain/demo/loop.py +311 -0
  28. loopcaptain/demo/loops.py +76 -0
  29. loopcaptain/mcp_server/AGENTS.md +23 -0
  30. loopcaptain/mcp_server/__init__.py +0 -0
  31. loopcaptain/mcp_server/server.py +181 -0
  32. loopcaptain/prompt_texts/build-a-loop.md +6 -0
  33. loopcaptain/prompt_texts/watch-my-loops.md +6 -0
  34. loopcaptain/prompts.py +27 -0
  35. loopcaptain/protocol/AGENTS.md +24 -0
  36. loopcaptain/protocol/LOOPS.md +402 -0
  37. loopcaptain/protocol/__init__.py +17 -0
  38. loopcaptain/protocol/doctor.py +79 -0
  39. loopcaptain/protocol/models.py +496 -0
  40. loopcaptain/protocol/signing.py +105 -0
  41. loopcaptain/protocol/state.py +595 -0
  42. loopcaptain/protocol/store.py +275 -0
  43. loopcaptain/protocol/validate.py +199 -0
  44. loopcaptain-0.3.1.dist-info/METADATA +220 -0
  45. loopcaptain-0.3.1.dist-info/RECORD +50 -0
  46. loopcaptain-0.3.1.dist-info/WHEEL +4 -0
  47. loopcaptain-0.3.1.dist-info/entry_points.txt +2 -0
  48. loopcaptain-0.3.1.dist-info/licenses/LICENSE +202 -0
  49. loopcaptain-0.3.1.dist-info/licenses/NOTICE +11 -0
  50. loopcaptain-0.3.1.dist-info/licenses/src/loopcaptain/board/static/fonts/OFL.txt +94 -0
@@ -0,0 +1,249 @@
1
+ """Which loops the board shows: the repositories named in the config, read fresh on every request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from loopcaptain.protocol import store
14
+ from loopcaptain.protocol.models import FOLDER, Manifest, now
15
+ from loopcaptain.protocol.signing import untrusted_ids
16
+ from loopcaptain.protocol.state import LoopState, fold
17
+ from loopcaptain.protocol.store import Problem, StoreError
18
+
19
+ #: Where the config is looked for, first hit wins. The file names this laptop's folders, so it is
20
+ #: never committed (`*.local.yaml` is ignored).
21
+ CONFIG_ENV = "LOOPCAPTAIN_CONFIG"
22
+ CONFIG_NAMES = ("loopcaptain.local.yaml",)
23
+ USER_CONFIG = Path("~/.config/loopcaptain/config.yaml")
24
+ GIT_TIMEOUT_S = 3
25
+ DEMO_FOLDER = "demo"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Config:
30
+ """`roots`: folders whose direct sub-folders are repositories. `repos`: single repositories."""
31
+
32
+ roots: tuple[Path, ...] = ()
33
+ repos: tuple[Path, ...] = ()
34
+ source: Path | None = None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Repo:
39
+ key: str # unique on the board, the folder name unless two repositories share it
40
+ path: Path # of worktrees of one repository, the one whose `.loops` was written last
41
+ others: tuple[Path, ...] = () # the other worktrees of the same repository that have `.loops`
42
+
43
+
44
+ def git_common_dir(path: Path) -> Path:
45
+ """The `.git` folder a checkout shares with its worktrees, read from disk without running git.
46
+
47
+ A worktree's `.git` is a file, `gitdir: <common>/worktrees/<name>`; a main checkout's is the folder
48
+ itself. Anything else (no git, an unreadable file) stands for itself.
49
+ """
50
+ dot_git = path / ".git"
51
+ if dot_git.is_dir():
52
+ return dot_git.resolve()
53
+ try:
54
+ first = dot_git.read_text(encoding="utf-8").splitlines()[0]
55
+ except (OSError, IndexError, UnicodeDecodeError):
56
+ return path.resolve()
57
+ if not first.startswith("gitdir:"):
58
+ return path.resolve()
59
+ gitdir = (path / first.removeprefix("gitdir:").strip()).resolve()
60
+ return gitdir.parent.parent if gitdir.parent.name == "worktrees" else gitdir
61
+
62
+
63
+ def last_written(path: Path) -> int:
64
+ """When anything in a repository's `.loops` was last written, in nanoseconds; 0 if never."""
65
+ folder = path / FOLDER
66
+ stamps = [0]
67
+ for loop_id in store.loop_ids(path):
68
+ for name in ("loop.json", "events.jsonl", "inbox.jsonl"):
69
+ try:
70
+ stamps.append((folder / loop_id / name).lstat().st_mtime_ns)
71
+ except OSError:
72
+ continue
73
+ return max(stamps)
74
+
75
+
76
+ def one_per_repository(paths: list[Path]) -> list[tuple[Path, tuple[Path, ...]]]:
77
+ """Worktrees of one repository become one entry: the last written, and the others beside it."""
78
+ groups: dict[Path, list[Path]] = {}
79
+ for path in paths:
80
+ groups.setdefault(git_common_dir(path), []).append(path)
81
+ grouped = []
82
+ for members in groups.values():
83
+ ordered = sorted(members, key=last_written, reverse=True)
84
+ grouped.append((ordered[0], tuple(ordered[1:])))
85
+ return grouped
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class LoopView:
90
+ """One loop as the board shows it: where it lives, where it stands, and what could not be read."""
91
+
92
+ repo: Repo
93
+ loop_id: str
94
+ state: LoopState | None
95
+ problems: tuple[Problem, ...] = ()
96
+ error: str = ""
97
+
98
+ @property
99
+ def title(self) -> str:
100
+ return (self.state.manifest.title if self.state else "") or self.loop_id
101
+
102
+
103
+ def load_config(cwd: Path | None = None) -> Config:
104
+ base = cwd or Path.cwd()
105
+ candidates = [Path(os.environ[CONFIG_ENV])] if os.environ.get(CONFIG_ENV) else []
106
+ candidates += [base / name for name in CONFIG_NAMES]
107
+ candidates.append(USER_CONFIG.expanduser())
108
+ for path in candidates:
109
+ if path.is_file():
110
+ return parse_config(path)
111
+ # No config yet: show the example loop if `make demo` has written it, so a fresh clone shows something.
112
+ demo = base / DEMO_FOLDER
113
+ return Config(repos=(demo.resolve(),) if demo.is_dir() else ())
114
+
115
+
116
+ def parse_config(path: Path) -> Config:
117
+ try:
118
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
119
+ except (OSError, yaml.YAMLError) as error:
120
+ raise StoreError(f"{path.name}: {error}") from error
121
+ if not isinstance(data, dict):
122
+ raise StoreError(f"{path.name}: expected `roots:` and `repos:` lists")
123
+
124
+ def paths(key: str) -> tuple[Path, ...]:
125
+ value = data.get(key) or []
126
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
127
+ raise StoreError(f"{path.name}: `{key}` must be a list of paths")
128
+ return tuple((path.parent / Path(item).expanduser()).resolve() for item in value)
129
+
130
+ return Config(roots=paths("roots"), repos=paths("repos"), source=path)
131
+
132
+
133
+ def find_repos(config: Config) -> list[Repo]:
134
+ """Every configured repository that has a `.loops` folder, each once, in a stable order."""
135
+ found: list[Path] = []
136
+ for root in config.roots:
137
+ if not root.is_dir():
138
+ continue
139
+ found.extend(child for child in sorted(root.iterdir()) if (child / FOLDER).is_dir())
140
+ found.extend(repo for repo in config.repos if (repo / FOLDER).is_dir())
141
+ grouped = one_per_repository(list(dict.fromkeys(found)))
142
+ names = [path.name for path, _ in grouped]
143
+ repos = []
144
+ for path, others in grouped:
145
+ key = path.name if names.count(path.name) == 1 else f"{path.parent.name}.{path.name}"
146
+ repos.append(Repo(key=safe_key(key), path=path, others=others))
147
+ return repos
148
+
149
+
150
+ def safe_key(name: str) -> str:
151
+ """A repository key is a URL part: anything else in a folder name becomes a dash."""
152
+ return "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in name) or "repo"
153
+
154
+
155
+ def load_loop(repo: Repo, loop_id: str, at: datetime | None = None, process_alive: bool = False) -> LoopView:
156
+ try:
157
+ manifest = store.read_manifest(repo.path, loop_id)
158
+ events, event_problems = store.read_events(repo.path, loop_id)
159
+ inbox, inbox_problems = store.read_inbox(repo.path, loop_id)
160
+ except StoreError as error:
161
+ return LoopView(repo, loop_id, None, error=str(error))
162
+ state = fold(manifest, events, inbox, at or now(), process_alive, untrusted_ids(inbox, loop_id))
163
+ return LoopView(repo, loop_id, state, (*event_problems, *inbox_problems))
164
+
165
+
166
+ def load_all(config: Config) -> list[LoopView]:
167
+ return [load_loop(repo, loop_id) for repo in find_repos(config) for loop_id in store.loop_ids(repo.path)]
168
+
169
+
170
+ def find_repo(config: Config, repo_key: str, loop_id: str) -> Repo | None:
171
+ """The repository behind a URL if it lists this loop, readable or not."""
172
+ return next(
173
+ (
174
+ repo
175
+ for repo in find_repos(config)
176
+ if repo.key == repo_key and loop_id in store.loop_ids(repo.path)
177
+ ),
178
+ None,
179
+ )
180
+
181
+
182
+ def find_loop(config: Config, repo_key: str, loop_id: str) -> tuple[Repo, Manifest] | None:
183
+ """The repository and manifest behind a URL, or None. Only configured repositories are reachable."""
184
+ for repo in find_repos(config):
185
+ if repo.key == repo_key and loop_id in store.loop_ids(repo.path):
186
+ try:
187
+ return repo, store.read_manifest(repo.path, loop_id)
188
+ except StoreError:
189
+ return None
190
+ return None
191
+
192
+
193
+ def git_branch(repo: Path) -> str:
194
+ """The checked-out branch, or "" where git cannot say (no repository, detached, too slow)."""
195
+ try:
196
+ done = subprocess.run(
197
+ ["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "HEAD"],
198
+ capture_output=True,
199
+ text=True,
200
+ timeout=GIT_TIMEOUT_S,
201
+ check=False,
202
+ )
203
+ except (OSError, subprocess.TimeoutExpired):
204
+ return ""
205
+ branch = done.stdout.strip()
206
+ return branch if done.returncode == 0 and branch != "HEAD" else ""
207
+
208
+
209
+ def config_target() -> Path:
210
+ """The config `watch` and `unwatch` edit: the one `LOOPCAPTAIN_CONFIG` names, else the user's."""
211
+ named = os.environ.get(CONFIG_ENV)
212
+ return Path(named).expanduser() if named else USER_CONFIG.expanduser()
213
+
214
+
215
+ def as_written(path: Path) -> str:
216
+ """A path as it goes into the config: absolute, the home folder as `~`."""
217
+ resolved = path.expanduser().resolve()
218
+ try:
219
+ return f"~/{resolved.relative_to(Path.home())}"
220
+ except ValueError:
221
+ return str(resolved)
222
+
223
+
224
+ def edit_config(path: Path, *, add: tuple[str, str] | None = None, remove: str | None = None) -> list[str]:
225
+ """Add `(key, path)` to the config or remove a path from both lists; returns what changed, in words."""
226
+ target = config_target()
227
+ data: dict[str, list[str]] = {"roots": [], "repos": []}
228
+ if target.is_file():
229
+ loaded = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
230
+ if not isinstance(loaded, dict):
231
+ raise StoreError(f"{target}: expected `roots:` and `repos:` lists")
232
+ data.update({key: list(loaded.get(key) or []) for key in ("roots", "repos")})
233
+ changes: list[str] = []
234
+ if add is not None:
235
+ key, entry = add
236
+ if entry in data[key]:
237
+ return [f"{entry} is already under {key}"]
238
+ data[key].append(entry)
239
+ changes.append(f"added {entry} to {key}")
240
+ if remove is not None:
241
+ for key in ("roots", "repos"):
242
+ if remove in data[key]:
243
+ data[key].remove(remove)
244
+ changes.append(f"removed {remove} from {key}")
245
+ if not changes:
246
+ return [f"{remove} is not in {target}"]
247
+ target.parent.mkdir(parents=True, exist_ok=True)
248
+ target.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
249
+ return [f"{target}: {change}" for change in changes]
@@ -0,0 +1,94 @@
1
+ """Which files of a watched repository the board opens, and which it never shows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from pathlib import Path
7
+
8
+ from fastapi import HTTPException
9
+
10
+ #: The largest file the board renders. A round's plan or review is a few kilobytes.
11
+ MAX_FILE_BYTES = 400_000
12
+
13
+
14
+ #: Never shown, though inside the repository: git's own folder and files that hold secrets.
15
+ PRIVATE_PARTS = frozenset(
16
+ {".git", ".ssh", ".aws", ".gnupg", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".docker", ".kube"}
17
+ )
18
+
19
+
20
+ #: Name patterns of files that hold keys or credentials, matched against every part of the path.
21
+ PRIVATE_PATTERNS = (
22
+ ".env*",
23
+ "*.pem",
24
+ "*.key",
25
+ "*.p12",
26
+ "*.pfx",
27
+ "*.keystore",
28
+ "*.jks",
29
+ "*.tfstate",
30
+ "*.tfstate.*",
31
+ ".htpasswd",
32
+ "id_rsa*",
33
+ "id_ed25519*",
34
+ "id_ecdsa*",
35
+ "credentials*.json",
36
+ "service-account*.json",
37
+ "*-sa-key*.json",
38
+ "secrets.*",
39
+ )
40
+
41
+
42
+ #: The board shows a file's content only for these; anything else is named, not opened.
43
+ TEXT_SUFFIXES = frozenset(
44
+ {
45
+ ".md",
46
+ ".markdown",
47
+ ".txt",
48
+ ".rst",
49
+ ".json",
50
+ ".jsonl",
51
+ ".yaml",
52
+ ".yml",
53
+ ".toml",
54
+ ".csv",
55
+ ".tsv",
56
+ ".log",
57
+ ".py",
58
+ ".sh",
59
+ ".js",
60
+ ".ts",
61
+ ".html",
62
+ ".css",
63
+ ".sql",
64
+ ".r",
65
+ ".tex",
66
+ ".bib",
67
+ ".diff",
68
+ ".patch",
69
+ }
70
+ )
71
+
72
+
73
+ def is_private(part: str) -> bool:
74
+ """Whether one part of a path names git's folder or a file that holds keys or credentials."""
75
+ lowered = part.lower()
76
+ return lowered in PRIVATE_PARTS or any(
77
+ fnmatch.fnmatchcase(lowered, pattern) for pattern in PRIVATE_PATTERNS
78
+ )
79
+
80
+
81
+ def inside(repo: Path, relative: str) -> Path:
82
+ """A path inside the repository, or 404: no link on the board may lead out of it."""
83
+ root = repo.resolve()
84
+ try:
85
+ target = (root / relative).resolve()
86
+ found = bool(relative) and target.is_relative_to(root) and target.is_file()
87
+ except (ValueError, OSError): # a null byte, a name too long
88
+ found = False
89
+ if not found:
90
+ raise HTTPException(404, "no such file in this repository")
91
+ parts = target.relative_to(root).parts
92
+ if any(is_private(part) for part in parts):
93
+ raise HTTPException(404, "no such file in this repository")
94
+ return target
@@ -0,0 +1,48 @@
1
+ """How numbers, times and places read on a page: the template filters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from loopcaptain.protocol.models import now
9
+
10
+
11
+ def ago(value: datetime | None, at: datetime | None = None) -> str:
12
+ if value is None:
13
+ return ""
14
+ seconds = int(((at or now()) - value).total_seconds())
15
+ if seconds < 60:
16
+ return "just now"
17
+ for size, unit in ((86400, "d"), (3600, "h"), (60, "min")):
18
+ if seconds >= size:
19
+ return f"{seconds // size} {unit} ago"
20
+ return ""
21
+
22
+
23
+ def usd(value: float) -> str:
24
+ return f"${value:,.2f}" if value else ""
25
+
26
+
27
+ def minutes(value: int | None) -> str:
28
+ if not value:
29
+ return ""
30
+ return f"{value} min" if value < 120 else f"{value / 60:.1f} h"
31
+
32
+
33
+ def tokens(value: int) -> str:
34
+ if not value:
35
+ return ""
36
+ return f"{value / 1_000_000:.1f}M" if value >= 1_000_000 else f"{value / 1000:.0f}k"
37
+
38
+
39
+ def stamp(value: datetime | None) -> str:
40
+ return value.astimezone().strftime("%Y-%m-%d %H:%M") if value else ""
41
+
42
+
43
+ def place(path: Path) -> str:
44
+ """Where a repository is, as the person would type it: the home folder as `~`."""
45
+ try:
46
+ return f"~/{path.resolve().relative_to(Path.home())}"
47
+ except ValueError:
48
+ return str(path)
@@ -0,0 +1,139 @@
1
+ """The picture of one round: the manifest's steps laid out left to right, as numbers a template draws.
2
+
3
+ No drawing library: each step gets a column (one further right than the last step it waits for)
4
+ and a row within it, and every edge is one curve.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from loopcaptain.protocol.models import Manifest
12
+ from loopcaptain.protocol.state import RoundState
13
+
14
+ NODE_W = 148
15
+ NODE_H = 46
16
+ GAP_X = 44
17
+ GAP_Y = 14
18
+ PAD = 14
19
+ #: Room under the nodes for the arrow that leads from the last step back to the first.
20
+ RETURN_H = 34
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Node:
25
+ id: str
26
+ name: str
27
+ kind: str
28
+ x: int
29
+ y: int
30
+ #: pending, running, ok, failed, skipped: the step in the round the picture shows.
31
+ state: str = "pending"
32
+ runs: int = 0
33
+ findings: int = 0
34
+
35
+ @property
36
+ def cx(self) -> int:
37
+ return self.x + NODE_W // 2
38
+
39
+ @property
40
+ def cy(self) -> int:
41
+ return self.y + NODE_H // 2
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Graph:
46
+ nodes: tuple[Node, ...]
47
+ edges: tuple[str, ...] # SVG path data
48
+ back: str # the path from the last column back to the first, "" for a single column
49
+ width: int
50
+ height: int
51
+ node_w: int = NODE_W
52
+ node_h: int = NODE_H
53
+
54
+
55
+ def columns(manifest: Manifest) -> dict[str, int]:
56
+ """Each step's column: 0 for a step that waits for nothing, else one past its latest predecessor.
57
+
58
+ In waves, without recursion: a loop.json with thousands of chained steps must not exhaust the
59
+ stack. A step left over after as many waves as there are steps sits in a cycle and goes to 0.
60
+ """
61
+ before: dict[str, list[str]] = {step.id: [] for step in manifest.steps}
62
+ for source, target in manifest.edges():
63
+ before[target].append(source)
64
+ column: dict[str, int] = {}
65
+ for _ in range(len(before)):
66
+ ready = [
67
+ step_id
68
+ for step_id, sources in before.items()
69
+ if step_id not in column and all(name in column for name in sources)
70
+ ]
71
+ if not ready:
72
+ break
73
+ for step_id in ready:
74
+ column[step_id] = 1 + max((column[name] for name in before[step_id]), default=-1)
75
+ return {step_id: column.get(step_id, 0) for step_id in before}
76
+
77
+
78
+ def step_state(round_: RoundState | None, step_id: str) -> tuple[str, int, int]:
79
+ """How a step stands in a round: its state, how many runs it had, how many open material findings."""
80
+ runs = round_.runs_of(step_id) if round_ else ()
81
+ if not runs:
82
+ return "pending", 0, 0
83
+ findings = sum(run.material_open for run in runs)
84
+ statuses = {run.status for run in runs}
85
+ for state in ("running", "failed", "ok"):
86
+ if state in statuses:
87
+ return state, len(runs), findings
88
+ return "skipped", len(runs), findings
89
+
90
+
91
+ def layout(manifest: Manifest, round_: RoundState | None = None) -> Graph:
92
+ column = columns(manifest)
93
+ by_column: dict[int, list[str]] = {}
94
+ for step in manifest.steps:
95
+ by_column.setdefault(column[step.id], []).append(step.id)
96
+ tallest = max((len(ids) for ids in by_column.values()), default=1)
97
+ inner_h = tallest * NODE_H + (tallest - 1) * GAP_Y
98
+
99
+ nodes: dict[str, Node] = {}
100
+ for col, ids in by_column.items():
101
+ block = len(ids) * NODE_H + (len(ids) - 1) * GAP_Y
102
+ top = PAD + (inner_h - block) // 2
103
+ for row, step_id in enumerate(ids):
104
+ declared = manifest.step(step_id)
105
+ state, runs, findings = step_state(round_, step_id)
106
+ nodes[step_id] = Node(
107
+ id=step_id,
108
+ name=(declared.name if declared else "") or step_id,
109
+ kind=declared.kind if declared else "other",
110
+ x=PAD + col * (NODE_W + GAP_X),
111
+ y=top + row * (NODE_H + GAP_Y),
112
+ state=state,
113
+ runs=runs,
114
+ findings=findings,
115
+ )
116
+
117
+ edges = tuple(curve(nodes[source], nodes[target]) for source, target in manifest.edges())
118
+ last_col = max(by_column, default=0)
119
+ width = 2 * PAD + (last_col + 1) * NODE_W + last_col * GAP_X
120
+ height = 2 * PAD + inner_h
121
+ back = ""
122
+ if last_col > 0:
123
+ floor = height + RETURN_H - 12
124
+ start_x = PAD + last_col * (NODE_W + GAP_X) + NODE_W // 2
125
+ end_x = PAD + NODE_W // 2
126
+ low = PAD + inner_h
127
+ back = (
128
+ f"M {start_x} {low + 4} V {floor - 10} Q {start_x} {floor} {start_x - 10} {floor} "
129
+ f"H {end_x + 10} Q {end_x} {floor} {end_x} {floor - 10} V {low + 8}"
130
+ )
131
+ height += RETURN_H
132
+ return Graph(tuple(nodes.values()), edges, back, width, height)
133
+
134
+
135
+ def curve(source: Node, target: Node) -> str:
136
+ x1, y1 = source.x + NODE_W, source.cy
137
+ x2, y2 = target.x, target.cy
138
+ mid = (x1 + x2) // 2
139
+ return f"M {x1} {y1} C {mid} {y1}, {mid} {y2}, {x2 - 5} {y2}"
@@ -0,0 +1,59 @@
1
+ """The first page: loops grouped by repository, and the filters over them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from loopcaptain.board.discover import LoopView, Repo, git_branch
8
+ from loopcaptain.board.filters import place
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Project:
13
+ """One repository on the first page, with the loops that live in it."""
14
+
15
+ repo: Repo
16
+ place: str
17
+ branch: str
18
+ views: tuple[LoopView, ...]
19
+
20
+ @property
21
+ def waiting(self) -> int:
22
+ return sum(1 for view in self.views if view.state and view.state.question)
23
+
24
+
25
+ def projects_of(views: list[LoopView]) -> list[Project]:
26
+ """The loops grouped by repository, a repository where its first loop stands in `views`."""
27
+ grouped: dict[str, list[LoopView]] = {}
28
+ for view in views:
29
+ grouped.setdefault(view.repo.key, []).append(view)
30
+ return [
31
+ Project(found[0].repo, place(found[0].repo.path), git_branch(found[0].repo.path), tuple(found))
32
+ for found in grouped.values()
33
+ ]
34
+
35
+
36
+ #: Statuses that are over; the overview folds them away unless asked.
37
+ FINISHED = frozenset({"done", "stopped"})
38
+
39
+
40
+ def filter_views(
41
+ views: list[LoopView], text: str, status: str, *, show_finished: bool
42
+ ) -> tuple[list[LoopView], int]:
43
+ """The loops the table shows, and how many finished ones were folded away."""
44
+ needle = text.strip().lower()
45
+
46
+ def status_of(view: LoopView) -> str:
47
+ return view.state.status if view.state else "failed"
48
+
49
+ def matches(view: LoopView) -> bool:
50
+ haystack = f"{view.title} {view.loop_id} {view.repo.key}".lower()
51
+ if view.state:
52
+ haystack += " " + view.state.manifest.brief.goal.lower()
53
+ return (not needle or needle in haystack) and (not status or status_of(view) == status)
54
+
55
+ picked = [view for view in views if matches(view)]
56
+ if show_finished or status in FINISHED:
57
+ return picked, 0
58
+ kept = [view for view in picked if status_of(view) not in FINISHED]
59
+ return kept, len(picked) - len(kept)
@@ -0,0 +1,41 @@
1
+ // Two small things, no library: reload the page when a loop's files change, and copy the protocol.
2
+ (function () {
3
+ "use strict";
4
+ var body = document.body;
5
+ var url = body.getAttribute("data-pulse-url");
6
+ var seen = body.getAttribute("data-pulse");
7
+ var POLL_MS = 4000;
8
+
9
+ function busy() {
10
+ // Never reload under someone who is typing an answer or reading a check's history.
11
+ var typed = Array.prototype.some.call(document.querySelectorAll("textarea[name]"), function (field) {
12
+ return field.value.trim() !== "" || field === document.activeElement;
13
+ });
14
+ return typed || document.querySelector("table.items details[open]") !== null;
15
+ }
16
+
17
+ function poll() {
18
+ if (document.hidden) return;
19
+ fetch(url, { cache: "no-store" })
20
+ .then(function (response) { return response.ok ? response.json() : null; })
21
+ .then(function (data) {
22
+ if (data && data.pulse !== seen && !busy()) window.location.reload();
23
+ })
24
+ .catch(function () { /* the board is restarting; the next poll tries again */ });
25
+ }
26
+
27
+ if (url) window.setInterval(poll, POLL_MS);
28
+
29
+ // Every copy button copies its own source and says so beside itself.
30
+ if (navigator.clipboard) {
31
+ document.querySelectorAll("[data-copy-from]").forEach(function (button) {
32
+ button.addEventListener("click", function () {
33
+ var source = document.getElementById(button.getAttribute("data-copy-from"));
34
+ navigator.clipboard.writeText(source.value).then(function () {
35
+ var done = button.parentNode.querySelector("[data-copied]");
36
+ if (done) done.hidden = false;
37
+ });
38
+ });
39
+ });
40
+ }
41
+ })();