vylth-annotator 0.0.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.
- vylth_annotator/.env.example +9 -0
- vylth_annotator/__init__.py +0 -0
- vylth_annotator/auth.py +56 -0
- vylth_annotator/cli.py +252 -0
- vylth_annotator/config.py +38 -0
- vylth_annotator/db.py +31 -0
- vylth_annotator/fanout.py +78 -0
- vylth_annotator/main.py +233 -0
- vylth_annotator/migrations/0001_initial.sql +38 -0
- vylth_annotator/models.py +79 -0
- vylth_annotator/schemas.py +61 -0
- vylth_annotator/sink.py +161 -0
- vylth_annotator/skill/SKILL.md +98 -0
- vylth_annotator/static/w.js +125 -0
- vylth_annotator/templates/dashboard.html +175 -0
- vylth_annotator-0.0.1.dist-info/METADATA +122 -0
- vylth_annotator-0.0.1.dist-info/RECORD +20 -0
- vylth_annotator-0.0.1.dist-info/WHEEL +4 -0
- vylth_annotator-0.0.1.dist-info/entry_points.txt +2 -0
- vylth_annotator-0.0.1.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
vylth_annotator/auth.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Token auth: X-Annot-Token header → project_id.
|
|
3
|
+
|
|
4
|
+
Tokens are stored hashed (SHA-256). The widget supplies the raw token; we hash
|
|
5
|
+
+ compare. No JWTs, no rotation logic in v0 — single shared token per project.
|
|
6
|
+
|
|
7
|
+
Local mode (CLI): a single project + token is auto-provisioned on first request,
|
|
8
|
+
so the user can `annotator run` and immediately point a widget at localhost
|
|
9
|
+
without any setup.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
|
|
13
|
+
from fastapi import Depends, Header, HTTPException, status
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
|
|
17
|
+
from .config import settings
|
|
18
|
+
from .db import get_session
|
|
19
|
+
from .models import Project
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hash_token(raw: str) -> str:
|
|
23
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _ensure_local_project(session: AsyncSession) -> Project:
|
|
27
|
+
result = await session.execute(select(Project).where(Project.id == settings.local_project))
|
|
28
|
+
proj = result.scalar_one_or_none()
|
|
29
|
+
if proj is None:
|
|
30
|
+
proj = Project(
|
|
31
|
+
id=settings.local_project,
|
|
32
|
+
name="Local",
|
|
33
|
+
token_hash=hash_token(settings.local_token),
|
|
34
|
+
destinations=[],
|
|
35
|
+
)
|
|
36
|
+
session.add(proj)
|
|
37
|
+
await session.commit()
|
|
38
|
+
await session.refresh(proj)
|
|
39
|
+
return proj
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def project_from_token(
|
|
43
|
+
x_annot_token: str | None = Header(None, alias="X-Annot-Token"),
|
|
44
|
+
session: AsyncSession = Depends(get_session),
|
|
45
|
+
) -> Project:
|
|
46
|
+
if settings.local_mode:
|
|
47
|
+
return await _ensure_local_project(session)
|
|
48
|
+
|
|
49
|
+
if not x_annot_token:
|
|
50
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing X-Annot-Token")
|
|
51
|
+
token_hash = hash_token(x_annot_token)
|
|
52
|
+
result = await session.execute(select(Project).where(Project.token_hash == token_hash))
|
|
53
|
+
project = result.scalar_one_or_none()
|
|
54
|
+
if project is None:
|
|
55
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token")
|
|
56
|
+
return project
|
vylth_annotator/cli.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`annotator` CLI entry point.
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
run Start the API + dashboard locally (SQLite, zero config).
|
|
6
|
+
list Show open feedback (your project, scoped by token).
|
|
7
|
+
pull Download feedback envelopes + images to ./.annot/<project>/.
|
|
8
|
+
resolve Mark a feedback item resolved.
|
|
9
|
+
init Print the <script> snippet to embed into your dev site.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import webbrowser
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from .config import settings
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _set_local_mode() -> None:
|
|
27
|
+
"""Force local mode for this process (the CLI default)."""
|
|
28
|
+
os.environ.setdefault("ANNOTATOR_LOCAL_MODE", "true")
|
|
29
|
+
settings.local_mode = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _api_base(host: str | None = None, port: int | None = None) -> str:
|
|
33
|
+
h = host or settings.host or "127.0.0.1"
|
|
34
|
+
p = port or settings.port
|
|
35
|
+
return f"http://{h}:{p}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.group(help="Annotator — drop-in design-feedback widget. Self-hostable on localhost.")
|
|
39
|
+
@click.version_option(package_name="vylth-annotator")
|
|
40
|
+
def cli() -> None:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cli.command(help="Run the annotator API + dashboard locally on http://localhost:8092.")
|
|
45
|
+
@click.option("--host", default=None, help="Bind host (default 127.0.0.1).")
|
|
46
|
+
@click.option("--port", default=None, type=int, help="Bind port (default 8092).")
|
|
47
|
+
@click.option("--open/--no-open", "open_browser", default=True, help="Open dashboard in browser.")
|
|
48
|
+
@click.option("--db", default=None, help="Override SQLite path or sqlalchemy URL.")
|
|
49
|
+
@click.option("--sink", "sink_dir", default=".annot",
|
|
50
|
+
help="Write PNG + Markdown per annotation under this dir (default ./.annot). Pass '' to disable.")
|
|
51
|
+
def run(host: Optional[str], port: Optional[int], open_browser: bool, db: Optional[str], sink_dir: str) -> None:
|
|
52
|
+
import uvicorn
|
|
53
|
+
|
|
54
|
+
_set_local_mode()
|
|
55
|
+
if db:
|
|
56
|
+
os.environ["ANNOTATOR_DATABASE_URL"] = (
|
|
57
|
+
db if "://" in db else f"sqlite+aiosqlite:///{Path(db).resolve()}"
|
|
58
|
+
)
|
|
59
|
+
if host:
|
|
60
|
+
os.environ["ANNOTATOR_HOST"] = host
|
|
61
|
+
if port:
|
|
62
|
+
os.environ["ANNOTATOR_PORT"] = str(port)
|
|
63
|
+
|
|
64
|
+
abs_sink = str(Path(sink_dir).resolve()) if sink_dir else ""
|
|
65
|
+
os.environ["ANNOTATOR_SINK_DIR"] = abs_sink
|
|
66
|
+
|
|
67
|
+
# Re-import settings so env overrides take effect.
|
|
68
|
+
from .config import settings as fresh
|
|
69
|
+
fresh.local_mode = True
|
|
70
|
+
fresh.sink_dir = abs_sink
|
|
71
|
+
|
|
72
|
+
base = _api_base(host=host or fresh.host, port=port or fresh.port)
|
|
73
|
+
click.echo(f"\n annotator → {base}")
|
|
74
|
+
if abs_sink:
|
|
75
|
+
click.echo(f" sink → {abs_sink}/<project>/ (png + md per annotation)")
|
|
76
|
+
click.echo(f"\n embed this in your dev site:")
|
|
77
|
+
click.echo(f" <script src=\"{base}/w.js\" data-project=\"local\" data-token=\"local\" data-webhook=\"{base}/v1/feedback\"></script>\n")
|
|
78
|
+
|
|
79
|
+
if open_browser:
|
|
80
|
+
try:
|
|
81
|
+
webbrowser.open(base)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
uvicorn.run(
|
|
86
|
+
"vylth_annotator.main:app",
|
|
87
|
+
host=host or fresh.host,
|
|
88
|
+
port=port or fresh.port,
|
|
89
|
+
log_level="info",
|
|
90
|
+
reload=False,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@cli.command("list", help="List open feedback for your token's project.")
|
|
95
|
+
@click.option("--api", default=None, help=f"API base URL (default {_api_base()}).")
|
|
96
|
+
@click.option("--token", default=None, help="Token (default $ANNOTATOR_TOKEN or 'local').")
|
|
97
|
+
@click.option("--status", "status_filter", default="open", help="Filter by status.")
|
|
98
|
+
@click.option("--limit", default=20, type=int)
|
|
99
|
+
def list_cmd(api: Optional[str], token: Optional[str], status_filter: str, limit: int) -> None:
|
|
100
|
+
base = api or _api_base()
|
|
101
|
+
tok = token or os.environ.get("ANNOTATOR_TOKEN") or "local"
|
|
102
|
+
r = httpx.get(f"{base}/v1/feedback", params={"status": status_filter, "limit": limit}, headers={"X-Annot-Token": tok}, timeout=10)
|
|
103
|
+
if r.status_code != 200:
|
|
104
|
+
click.echo(f"error: {r.status_code} {r.text}", err=True)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
items = r.json().get("items", [])
|
|
107
|
+
if not items:
|
|
108
|
+
click.echo("no open feedback")
|
|
109
|
+
return
|
|
110
|
+
for fb in items:
|
|
111
|
+
click.echo(f" {fb['id'][:8]} {fb['pathname']:<32} {fb['comment'][:80]}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@cli.command(help="Download feedback (envelope JSON + image) to ./.annot/<project>/.")
|
|
115
|
+
@click.option("--api", default=None)
|
|
116
|
+
@click.option("--token", default=None)
|
|
117
|
+
@click.option("--out", default=".annot", help="Output directory.")
|
|
118
|
+
def pull(api: Optional[str], token: Optional[str], out: str) -> None:
|
|
119
|
+
base = api or _api_base()
|
|
120
|
+
tok = token or os.environ.get("ANNOTATOR_TOKEN") or "local"
|
|
121
|
+
headers = {"X-Annot-Token": tok}
|
|
122
|
+
r = httpx.get(f"{base}/v1/feedback", params={"status": "open", "limit": 100}, headers=headers, timeout=10)
|
|
123
|
+
r.raise_for_status()
|
|
124
|
+
items = r.json().get("items", [])
|
|
125
|
+
|
|
126
|
+
if not items:
|
|
127
|
+
click.echo("no open feedback to pull")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
out_dir = Path(out)
|
|
131
|
+
for fb in items:
|
|
132
|
+
proj_dir = out_dir / fb["project_id"]
|
|
133
|
+
proj_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
slug = fb["id"][:8]
|
|
135
|
+
env_path = proj_dir / f"{slug}.json"
|
|
136
|
+
img_path = proj_dir / f"{slug}.png"
|
|
137
|
+
env_path.write_text(json.dumps(fb, indent=2, default=str))
|
|
138
|
+
img = httpx.get(f"{base}/v1/feedback/{fb['id']}/image", headers=headers, timeout=15)
|
|
139
|
+
if img.status_code == 200:
|
|
140
|
+
img_path.write_bytes(img.content)
|
|
141
|
+
click.echo(f" pulled {slug} → {env_path}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@cli.command(help="Mark a feedback item resolved.")
|
|
145
|
+
@click.argument("fb_id")
|
|
146
|
+
@click.option("--api", default=None)
|
|
147
|
+
@click.option("--token", default=None)
|
|
148
|
+
def resolve(fb_id: str, api: Optional[str], token: Optional[str]) -> None:
|
|
149
|
+
base = api or _api_base()
|
|
150
|
+
tok = token or os.environ.get("ANNOTATOR_TOKEN") or "local"
|
|
151
|
+
r = httpx.post(f"{base}/v1/feedback/{fb_id}/resolve", headers={"X-Annot-Token": tok}, timeout=10)
|
|
152
|
+
if r.status_code != 200:
|
|
153
|
+
click.echo(f"error: {r.status_code} {r.text}", err=True)
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
click.echo(f"resolved {fb_id}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@cli.command(help="Print the <script> snippet to embed in your dev site.")
|
|
159
|
+
@click.option("--host", default="localhost")
|
|
160
|
+
@click.option("--port", default=8092, type=int)
|
|
161
|
+
@click.option("--project", default="local")
|
|
162
|
+
@click.option("--token", default="local")
|
|
163
|
+
def init(host: str, port: int, project: str, token: str) -> None:
|
|
164
|
+
base = f"http://{host}:{port}"
|
|
165
|
+
snippet = (
|
|
166
|
+
f'<script src="{base}/w.js"\n'
|
|
167
|
+
f' data-project="{project}"\n'
|
|
168
|
+
f' data-token="{token}"\n'
|
|
169
|
+
f' data-webhook="{base}/v1/feedback"></script>'
|
|
170
|
+
)
|
|
171
|
+
click.echo(snippet)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@cli.group(help="Install the annotator skill so agents (Claude Code, Codex, Cursor, …) read .annot/ automatically.")
|
|
175
|
+
def skill() -> None:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _skill_text() -> str:
|
|
180
|
+
"""Read the bundled SKILL.md."""
|
|
181
|
+
from importlib.resources import files
|
|
182
|
+
return (files("vylth_annotator") / "skill" / "SKILL.md").read_text(encoding="utf-8")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@skill.command("install", help="Drop SKILL.md into the right place for the detected agent(s). Defaults to all that apply.")
|
|
186
|
+
@click.option("--target", type=click.Choice(["auto", "claude", "codex", "cursor", "all"]), default="auto",
|
|
187
|
+
help="Which agent flavor(s) to install for.")
|
|
188
|
+
@click.option("--cwd", default=".", help="Project root (default current dir).")
|
|
189
|
+
def skill_install(target: str, cwd: str) -> None:
|
|
190
|
+
root = Path(cwd).resolve()
|
|
191
|
+
text = _skill_text()
|
|
192
|
+
written: list[Path] = []
|
|
193
|
+
|
|
194
|
+
targets: list[str]
|
|
195
|
+
if target == "auto":
|
|
196
|
+
targets = []
|
|
197
|
+
if (root / ".claude").exists() or (root / "CLAUDE.md").exists(): targets.append("claude")
|
|
198
|
+
if (root / "AGENTS.md").exists() or any(root.glob("*.codex*")): targets.append("codex")
|
|
199
|
+
if (root / ".cursor").exists() or (root / ".cursorrules").exists(): targets.append("cursor")
|
|
200
|
+
if not targets:
|
|
201
|
+
targets = ["claude", "codex"] # safe defaults
|
|
202
|
+
elif target == "all":
|
|
203
|
+
targets = ["claude", "codex", "cursor"]
|
|
204
|
+
else:
|
|
205
|
+
targets = [target]
|
|
206
|
+
|
|
207
|
+
for t in targets:
|
|
208
|
+
if t == "claude":
|
|
209
|
+
dst = root / ".claude" / "skills" / "annotator" / "SKILL.md"
|
|
210
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
dst.write_text(text, encoding="utf-8")
|
|
212
|
+
written.append(dst)
|
|
213
|
+
elif t == "codex":
|
|
214
|
+
# AGENTS.md convention — append a section if the file already has unrelated content.
|
|
215
|
+
dst = root / "AGENTS.md"
|
|
216
|
+
section = "\n\n## annotator\n\n" + text
|
|
217
|
+
if dst.exists():
|
|
218
|
+
existing = dst.read_text(encoding="utf-8")
|
|
219
|
+
if "## annotator" in existing:
|
|
220
|
+
# rewrite the annotator section
|
|
221
|
+
head, _, _ = existing.partition("## annotator")
|
|
222
|
+
dst.write_text(head.rstrip() + section, encoding="utf-8")
|
|
223
|
+
else:
|
|
224
|
+
dst.write_text(existing.rstrip() + section, encoding="utf-8")
|
|
225
|
+
else:
|
|
226
|
+
dst.write_text("# AGENTS\n" + section, encoding="utf-8")
|
|
227
|
+
written.append(dst)
|
|
228
|
+
elif t == "cursor":
|
|
229
|
+
dst = root / ".cursor" / "rules" / "annotator.md"
|
|
230
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
dst.write_text(text, encoding="utf-8")
|
|
232
|
+
written.append(dst)
|
|
233
|
+
|
|
234
|
+
click.echo("Installed:")
|
|
235
|
+
for p in written:
|
|
236
|
+
click.echo(f" {p.relative_to(root)}")
|
|
237
|
+
click.echo()
|
|
238
|
+
click.echo("Now any time you submit an annotation, the agent will see new files appear in .annot/")
|
|
239
|
+
click.echo("and know how to read + resolve them. Run `annotator run` to start collecting.")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@skill.command("show", help="Print the SKILL.md content to stdout (for piping into custom locations).")
|
|
243
|
+
def skill_show() -> None:
|
|
244
|
+
click.echo(_skill_text())
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main() -> None: # entry point for pyproject.toml
|
|
248
|
+
cli()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _default_db_url() -> str:
|
|
7
|
+
"""SQLite under the user's data dir — zero-config local mode."""
|
|
8
|
+
home = Path.home() / ".vylth-annotator"
|
|
9
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
return f"sqlite+aiosqlite:///{home / 'annotator.db'}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Settings(BaseSettings):
|
|
14
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="ANNOTATOR_")
|
|
15
|
+
|
|
16
|
+
database_url: str = _default_db_url()
|
|
17
|
+
host: str = "127.0.0.1"
|
|
18
|
+
port: int = 8092
|
|
19
|
+
allowed_origins: str = "*"
|
|
20
|
+
|
|
21
|
+
# Local mode: when true, accept any token (single-user laptop scenario).
|
|
22
|
+
# The CLI sets this. Hosted deployments leave it false.
|
|
23
|
+
local_mode: bool = False
|
|
24
|
+
local_token: str = "local"
|
|
25
|
+
local_project: str = "local"
|
|
26
|
+
|
|
27
|
+
# Filesystem sink: when set, every accepted feedback writes a PNG + .md
|
|
28
|
+
# pair under <sink_dir>/<project_id>/. Agents (Claude Code, Codex, Cursor)
|
|
29
|
+
# read these directly — no API access required.
|
|
30
|
+
sink_dir: str = ""
|
|
31
|
+
|
|
32
|
+
r2_account_id: str = ""
|
|
33
|
+
r2_access_key: str = ""
|
|
34
|
+
r2_secret_key: str = ""
|
|
35
|
+
r2_bucket: str = "annotator"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
settings = Settings()
|
vylth_annotator/db.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
2
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
3
|
+
|
|
4
|
+
from .config import settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _engine_kwargs(url: str) -> dict:
|
|
8
|
+
if url.startswith("sqlite"):
|
|
9
|
+
# SQLite doesn't use pool_pre_ping; future=True is the default in 2.x.
|
|
10
|
+
return {}
|
|
11
|
+
return {"pool_pre_ping": True}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
engine = create_async_engine(settings.database_url, **_engine_kwargs(settings.database_url))
|
|
15
|
+
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Base(DeclarativeBase):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def get_session() -> AsyncSession:
|
|
23
|
+
async with SessionLocal() as session:
|
|
24
|
+
yield session
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def init_db() -> None:
|
|
28
|
+
"""Create tables if missing — used by `annotator run` for zero-config local mode."""
|
|
29
|
+
from . import models # noqa: F401 ensure mappers are registered
|
|
30
|
+
async with engine.begin() as conn:
|
|
31
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Webhook fanout — fire envelope copies to each project's configured destinations.
|
|
3
|
+
|
|
4
|
+
Destinations are stored per-project in `annotator_projects.destinations` as
|
|
5
|
+
`[{type, url, format}, ...]`. Supported types: slack, discord, linear, http.
|
|
6
|
+
Failures are logged but don't fail the original POST.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def dispatch(destinations: list[dict[str, Any]], envelope: dict[str, Any]) -> None:
|
|
20
|
+
if not destinations:
|
|
21
|
+
return
|
|
22
|
+
async with httpx.AsyncClient(timeout=8.0) as client:
|
|
23
|
+
for dest in destinations:
|
|
24
|
+
kind = dest.get("type")
|
|
25
|
+
url = dest.get("url")
|
|
26
|
+
if not url:
|
|
27
|
+
continue
|
|
28
|
+
try:
|
|
29
|
+
if kind == "slack":
|
|
30
|
+
await _slack(client, url, envelope)
|
|
31
|
+
elif kind == "discord":
|
|
32
|
+
await _discord(client, url, envelope)
|
|
33
|
+
elif kind == "linear":
|
|
34
|
+
await _linear(client, url, envelope, dest)
|
|
35
|
+
else:
|
|
36
|
+
await client.post(url, json=envelope)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.warning("fanout failed: %s → %s: %s", kind, url, e)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _summary(envelope: dict[str, Any]) -> str:
|
|
42
|
+
comment = envelope.get("comment", "")[:300]
|
|
43
|
+
href = envelope.get("url", {}).get("href", "")
|
|
44
|
+
rects = len(envelope.get("rects", []))
|
|
45
|
+
errors = len(envelope.get("errors", []))
|
|
46
|
+
net_errs = len(envelope.get("network", []))
|
|
47
|
+
return f"{comment}\n\n→ {href}\n{rects} rect(s) · {errors} JS errors · {net_errs} network errors"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _slack(client: httpx.AsyncClient, url: str, envelope: dict[str, Any]) -> None:
|
|
51
|
+
await client.post(url, json={"text": f"*New annotation*\n{_summary(envelope)}"})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _discord(client: httpx.AsyncClient, url: str, envelope: dict[str, Any]) -> None:
|
|
55
|
+
await client.post(url, json={"content": f"**New annotation**\n{_summary(envelope)}"})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _linear(client: httpx.AsyncClient, url: str, envelope: dict[str, Any], dest: dict[str, Any]) -> None:
|
|
59
|
+
api_key = dest.get("api_key")
|
|
60
|
+
team_id = dest.get("team_id")
|
|
61
|
+
if not (api_key and team_id):
|
|
62
|
+
return
|
|
63
|
+
title = (envelope.get("comment") or "annotation").splitlines()[0][:80]
|
|
64
|
+
body = (
|
|
65
|
+
f"{envelope.get('comment','')}\n\n"
|
|
66
|
+
f"**URL:** {envelope.get('url',{}).get('href','')}\n\n"
|
|
67
|
+
f"```json\n{json.dumps({k: envelope[k] for k in ('viewport','env','errors','network','perf') if k in envelope}, indent=2)}\n```"
|
|
68
|
+
)
|
|
69
|
+
query = """
|
|
70
|
+
mutation IssueCreate($input: IssueCreateInput!) {
|
|
71
|
+
issueCreate(input: $input) { success issue { id identifier } }
|
|
72
|
+
}
|
|
73
|
+
"""
|
|
74
|
+
await client.post(
|
|
75
|
+
"https://api.linear.app/graphql",
|
|
76
|
+
headers={"Authorization": api_key, "Content-Type": "application/json"},
|
|
77
|
+
json={"query": query, "variables": {"input": {"title": title, "description": body, "teamId": team_id}}},
|
|
78
|
+
)
|