my prefect server setup prefect-metrics.waow.tech
python orchestration
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add tangled.org as hub data source

fetch issues/PRs from PDS via com.atproto.repo.listRecords (no auth),
persist to DuckDB, score with dbt, and serve through the unified
hub_action_items mart alongside github data.

also includes the source-agnostic UI refactor: componentized card table
with filtering, source badges, and multi-source URL helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz 13b966f4 a49b585b

+993 -159
+15
analytics/models/enrichment/int_tangled_items_scored.sql
··· 1 + WITH scored AS ( 2 + SELECT 3 + t.*, 4 + -- recency: decay over 30 days, max 1.0 5 + GREATEST(0.0, 1.0 - DATEDIFF('day', t.created_at::DATE, CURRENT_DATE) / 30.0) AS recency_score, 6 + -- no engagement data on PDS — flat 0.5 7 + 0.5 AS engagement_score, 8 + -- contributor weight (from seed) 9 + COALESCE(kc.weight, 1.0) AS contributor_weight 10 + FROM {{ ref('stg_tangled_items') }} t 11 + LEFT JOIN {{ ref('known_contributors') }} kc ON t.author_handle = kc.login 12 + ) 13 + SELECT *, 14 + ROUND(recency_score * engagement_score * contributor_weight, 4) AS importance_score 15 + FROM scored
-7
analytics/models/marts/github_action_items.sql
··· 1 - SELECT 2 - repo, number, type, title, url, "user", labels, 3 - comments, reactions_total, importance_score, 4 - updated_at 5 - FROM {{ ref('int_github_issues_scored') }} 6 - ORDER BY importance_score DESC 7 - LIMIT 50
+33
analytics/models/marts/hub_action_items.sql
··· 1 + SELECT source, repo, identifier, kind, title, url, 2 + author, labels, importance_score, updated 3 + FROM ( 4 + SELECT 5 + 'github' AS source, 6 + repo, 7 + number::VARCHAR AS identifier, 8 + type AS kind, 9 + title, 10 + url, 11 + "user" AS author, 12 + labels, 13 + importance_score, 14 + updated_at AS updated 15 + FROM {{ ref('int_github_issues_scored') }} 16 + 17 + UNION ALL 18 + 19 + SELECT 20 + 'tangled' AS source, 21 + repo, 22 + SPLIT_PART(at_uri, '/', -1) AS identifier, 23 + kind, 24 + title, 25 + url, 26 + author_handle AS author, 27 + ARRAY[]::VARCHAR[] AS labels, 28 + importance_score, 29 + created_at AS updated 30 + FROM {{ ref('int_tangled_items_scored') }} 31 + ) 32 + ORDER BY importance_score DESC 33 + LIMIT 200
+1
analytics/models/staging/_sources.yml
··· 4 4 schema: main 5 5 tables: 6 6 - name: raw_github_issues 7 + - name: raw_tangled_items
+8
analytics/models/staging/stg_tangled_items.sql
··· 1 + -- dedup by at_uri, keep most recent fetch; exclude comments (context, not action items) 2 + SELECT DISTINCT ON (at_uri) 3 + repo, kind, title, body, url, at_uri, 4 + author_did, author_handle, created_at, 5 + parent_uri, fetched_at 6 + FROM {{ source('raw', 'raw_tangled_items') }} 7 + WHERE kind IN ('issue', 'pr') 8 + ORDER BY at_uri, fetched_at DESC
+1
analytics/seeds/known_contributors.csv
··· 1 1 login,weight 2 2 zzstoatzz,2.0 3 + zzstoatzz.io,2.0
+246 -43
deploy/dashboards/github-intelligence.json
··· 4 4 "version": 1, 5 5 "schemaVersion": 39, 6 6 "refresh": "5m", 7 - "tags": ["prefect", "github"], 7 + "tags": [ 8 + "prefect", 9 + "github" 10 + ], 8 11 "templating": { 9 12 "list": [ 10 13 { ··· 21 24 "type": "stat", 22 25 "title": "tracked", 23 26 "description": "total issues + PRs in raw table", 24 - "gridPos": {"h": 4, "w": 4, "x": 0, "y": 0}, 25 - "datasource": {"type": "motherduck-duckdb-datasource", "uid": "duckdb"}, 26 - "options": {"colorMode": "background", "graphMode": "none"}, 27 - "targets": [{"rawSql": "SELECT count(*)::INT AS value FROM raw_github_issues", "format": "table"}], 28 - "fieldConfig": {"defaults": {"thresholds": {"steps": [{"color": "blue", "value": null}]}}} 27 + "gridPos": { 28 + "h": 4, 29 + "w": 4, 30 + "x": 0, 31 + "y": 0 32 + }, 33 + "datasource": { 34 + "type": "motherduck-duckdb-datasource", 35 + "uid": "duckdb" 36 + }, 37 + "options": { 38 + "colorMode": "background", 39 + "graphMode": "none" 40 + }, 41 + "targets": [ 42 + { 43 + "rawSql": "SELECT count(*)::INT AS value FROM raw_github_issues" 44 + } 45 + ], 46 + "fieldConfig": { 47 + "defaults": { 48 + "thresholds": { 49 + "steps": [ 50 + { 51 + "color": "blue", 52 + "value": null 53 + } 54 + ] 55 + } 56 + } 57 + } 29 58 }, 30 59 { 31 60 "id": 2, 32 61 "type": "stat", 33 62 "title": "open", 34 - "gridPos": {"h": 4, "w": 4, "x": 4, "y": 0}, 35 - "datasource": {"type": "motherduck-duckdb-datasource", "uid": "duckdb"}, 36 - "options": {"colorMode": "background", "graphMode": "none"}, 37 - "targets": [{"rawSql": "SELECT count(*)::INT AS value FROM raw_github_issues WHERE state = 'open'", "format": "table"}], 38 - "fieldConfig": {"defaults": {"thresholds": {"steps": [{"color": "green", "value": null}]}}} 63 + "gridPos": { 64 + "h": 4, 65 + "w": 4, 66 + "x": 4, 67 + "y": 0 68 + }, 69 + "datasource": { 70 + "type": "motherduck-duckdb-datasource", 71 + "uid": "duckdb" 72 + }, 73 + "options": { 74 + "colorMode": "background", 75 + "graphMode": "none" 76 + }, 77 + "targets": [ 78 + { 79 + "rawSql": "SELECT count(*)::INT AS value FROM raw_github_issues WHERE state = 'open'" 80 + } 81 + ], 82 + "fieldConfig": { 83 + "defaults": { 84 + "thresholds": { 85 + "steps": [ 86 + { 87 + "color": "green", 88 + "value": null 89 + } 90 + ] 91 + } 92 + } 93 + } 39 94 }, 40 95 { 41 96 "id": 3, 42 97 "type": "stat", 43 98 "title": "with reactions", 44 - "gridPos": {"h": 4, "w": 4, "x": 8, "y": 0}, 45 - "datasource": {"type": "motherduck-duckdb-datasource", "uid": "duckdb"}, 46 - "options": {"colorMode": "background", "graphMode": "none"}, 47 - "targets": [{"rawSql": "SELECT count(*)::INT AS value FROM raw_github_issues WHERE reactions_total > 0", "format": "table"}], 48 - "fieldConfig": {"defaults": {"thresholds": {"steps": [{"color": "orange", "value": null}]}}} 99 + "gridPos": { 100 + "h": 4, 101 + "w": 4, 102 + "x": 8, 103 + "y": 0 104 + }, 105 + "datasource": { 106 + "type": "motherduck-duckdb-datasource", 107 + "uid": "duckdb" 108 + }, 109 + "options": { 110 + "colorMode": "background", 111 + "graphMode": "none" 112 + }, 113 + "targets": [ 114 + { 115 + "rawSql": "SELECT count(*)::INT AS value FROM raw_github_issues WHERE reactions_total > 0" 116 + } 117 + ], 118 + "fieldConfig": { 119 + "defaults": { 120 + "thresholds": { 121 + "steps": [ 122 + { 123 + "color": "orange", 124 + "value": null 125 + } 126 + ] 127 + } 128 + } 129 + } 49 130 }, 50 131 { 51 132 "id": 4, 52 133 "type": "stat", 53 134 "title": "repos tracked", 54 - "gridPos": {"h": 4, "w": 4, "x": 12, "y": 0}, 55 - "datasource": {"type": "motherduck-duckdb-datasource", "uid": "duckdb"}, 56 - "options": {"colorMode": "background", "graphMode": "none"}, 57 - "targets": [{"rawSql": "SELECT count(DISTINCT repo)::INT AS value FROM raw_github_issues", "format": "table"}], 58 - "fieldConfig": {"defaults": {"thresholds": {"steps": [{"color": "purple", "value": null}]}}} 135 + "gridPos": { 136 + "h": 4, 137 + "w": 4, 138 + "x": 12, 139 + "y": 0 140 + }, 141 + "datasource": { 142 + "type": "motherduck-duckdb-datasource", 143 + "uid": "duckdb" 144 + }, 145 + "options": { 146 + "colorMode": "background", 147 + "graphMode": "none" 148 + }, 149 + "targets": [ 150 + { 151 + "rawSql": "SELECT count(DISTINCT repo)::INT AS value FROM raw_github_issues" 152 + } 153 + ], 154 + "fieldConfig": { 155 + "defaults": { 156 + "thresholds": { 157 + "steps": [ 158 + { 159 + "color": "purple", 160 + "value": null 161 + } 162 + ] 163 + } 164 + } 165 + } 59 166 }, 60 167 { 61 168 "id": 5, 62 169 "type": "table", 63 170 "title": "top action items (open, scored by engagement)", 64 - "description": "scored by dbt: recency × engagement × label multiplier × contributor weight", 65 - "gridPos": {"h": 12, "w": 24, "x": 0, "y": 4}, 66 - "datasource": {"type": "motherduck-duckdb-datasource", "uid": "duckdb"}, 67 - "options": {"sortBy": [{"desc": true, "displayName": "importance_score"}]}, 171 + "description": "scored by dbt: recency \u00d7 engagement \u00d7 label multiplier \u00d7 contributor weight", 172 + "gridPos": { 173 + "h": 12, 174 + "w": 24, 175 + "x": 0, 176 + "y": 4 177 + }, 178 + "datasource": { 179 + "type": "motherduck-duckdb-datasource", 180 + "uid": "duckdb" 181 + }, 182 + "options": { 183 + "sortBy": [ 184 + { 185 + "desc": true, 186 + "displayName": "importance_score" 187 + } 188 + ] 189 + }, 68 190 "targets": [ 69 191 { 70 - "rawSql": "SELECT repo, number, type, title, url, \"user\", labels, comments, reactions_total, importance_score, updated_at FROM github_action_items ORDER BY importance_score DESC", 71 - "format": "table" 192 + "rawSql": "SELECT repo, number, type, title, url, \"user\", array_to_string(labels, ', ') AS labels, comments, reactions_total, importance_score, updated_at FROM github_action_items ORDER BY importance_score DESC" 72 193 } 73 194 ], 74 195 "fieldConfig": { 75 196 "overrides": [ 76 - {"matcher": {"id": "byName", "options": "url"}, "properties": [{"id": "links", "value": [{"title": "open", "url": "${__value.text}"}]}]}, 77 - {"matcher": {"id": "byName", "options": "score"}, "properties": [{"id": "custom.displayMode", "value": "color-background"}, {"id": "thresholds", "mode": "absolute", "steps": [{"color": "blue", "value": null}, {"color": "green", "value": 0.1}, {"color": "orange", "value": 0.3}]}]}, 78 - {"matcher": {"id": "byName", "options": "body"}, "properties": [{"id": "custom.hidden", "value": true}]} 197 + { 198 + "matcher": { 199 + "id": "byName", 200 + "options": "url" 201 + }, 202 + "properties": [ 203 + { 204 + "id": "links", 205 + "value": [ 206 + { 207 + "title": "open", 208 + "url": "${__value.text}" 209 + } 210 + ] 211 + } 212 + ] 213 + }, 214 + { 215 + "matcher": { 216 + "id": "byName", 217 + "options": "importance_score" 218 + }, 219 + "properties": [ 220 + { 221 + "id": "custom.displayMode", 222 + "value": "color-background" 223 + }, 224 + { 225 + "id": "thresholds", 226 + "value": { 227 + "mode": "absolute", 228 + "steps": [ 229 + { 230 + "color": "blue", 231 + "value": null 232 + }, 233 + { 234 + "color": "green", 235 + "value": 0.1 236 + }, 237 + { 238 + "color": "orange", 239 + "value": 0.3 240 + } 241 + ] 242 + } 243 + } 244 + ] 245 + }, 246 + { 247 + "matcher": { 248 + "id": "byName", 249 + "options": "body" 250 + }, 251 + "properties": [ 252 + { 253 + "id": "custom.hidden", 254 + "value": true 255 + } 256 + ] 257 + } 79 258 ] 80 259 } 81 260 }, ··· 83 262 "id": 6, 84 263 "type": "barchart", 85 264 "title": "open issues by repo", 86 - "gridPos": {"h": 8, "w": 12, "x": 0, "y": 16}, 87 - "datasource": {"type": "motherduck-duckdb-datasource", "uid": "duckdb"}, 88 - "options": {"xField": "repo", "orientation": "horizontal"}, 265 + "gridPos": { 266 + "h": 8, 267 + "w": 12, 268 + "x": 0, 269 + "y": 16 270 + }, 271 + "datasource": { 272 + "type": "motherduck-duckdb-datasource", 273 + "uid": "duckdb" 274 + }, 275 + "options": { 276 + "xField": "repo", 277 + "orientation": "horizontal" 278 + }, 89 279 "targets": [ 90 280 { 91 - "rawSql": "SELECT repo, count(*) AS open_count\nFROM raw_github_issues\nWHERE state = 'open'\nGROUP BY repo\nORDER BY open_count DESC\nLIMIT 15", 92 - "format": "table" 281 + "rawSql": "SELECT repo, count(*) AS open_count\nFROM raw_github_issues\nWHERE state = 'open'\nGROUP BY repo\nORDER BY open_count DESC\nLIMIT 15" 93 282 } 94 283 ] 95 284 }, ··· 97 286 "id": 7, 98 287 "type": "piechart", 99 288 "title": "issues vs PRs", 100 - "gridPos": {"h": 8, "w": 6, "x": 12, "y": 16}, 101 - "datasource": {"type": "motherduck-duckdb-datasource", "uid": "duckdb"}, 289 + "gridPos": { 290 + "h": 8, 291 + "w": 6, 292 + "x": 12, 293 + "y": 16 294 + }, 295 + "datasource": { 296 + "type": "motherduck-duckdb-datasource", 297 + "uid": "duckdb" 298 + }, 102 299 "targets": [ 103 300 { 104 - "rawSql": "SELECT type, count(*) AS cnt FROM raw_github_issues WHERE state = 'open' GROUP BY type", 105 - "format": "table" 301 + "rawSql": "SELECT count(*) FILTER (WHERE type = 'Issue')::INT AS \"issues\", count(*) FILTER (WHERE type = 'PullRequest')::INT AS \"PRs\" FROM raw_github_issues WHERE state = 'open'" 106 302 } 107 303 ] 108 304 }, ··· 110 306 "id": 8, 111 307 "type": "piechart", 112 308 "title": "open vs closed", 113 - "gridPos": {"h": 8, "w": 6, "x": 18, "y": 16}, 114 - "datasource": {"type": "motherduck-duckdb-datasource", "uid": "duckdb"}, 309 + "gridPos": { 310 + "h": 8, 311 + "w": 6, 312 + "x": 18, 313 + "y": 16 314 + }, 315 + "datasource": { 316 + "type": "motherduck-duckdb-datasource", 317 + "uid": "duckdb" 318 + }, 115 319 "targets": [ 116 320 { 117 - "rawSql": "SELECT state, count(*) AS cnt FROM raw_github_issues GROUP BY state", 118 - "format": "table" 321 + "rawSql": "SELECT count(*) FILTER (WHERE state = 'open')::INT AS \"open\", count(*) FILTER (WHERE state = 'closed')::INT AS \"closed\" FROM raw_github_issues" 119 322 } 120 323 ] 121 324 }
+1
deploy/hub-deployment.yaml
··· 18 18 containers: 19 19 - name: hub 20 20 image: atcr.io/zzstoatzz.io/hub:latest 21 + imagePullPolicy: Always 21 22 ports: 22 23 - containerPort: 3000 23 24 env:
+68
flows/tangled_items.py
··· 1 + """ 2 + Fetch issues, PRs, and comments from the tangled.org PDS and persist to DuckDB. 3 + 4 + No auth needed — PDS records are public. Low volume, full resync each run. 5 + """ 6 + 7 + import os 8 + 9 + import httpx 10 + from prefect import flow, get_run_logger, task 11 + 12 + from mps.db import write_tangled_items 13 + from mps.tangled import ( 14 + PDS_BASE, 15 + TangledItem, 16 + fetch_items, 17 + fetch_repo_at_uris, 18 + ) 19 + 20 + COLLECTIONS = [ 21 + "sh.tangled.repo.issue", 22 + "sh.tangled.repo.pull", 23 + "sh.tangled.repo.issue.comment", 24 + "sh.tangled.repo.pull.comment", 25 + ] 26 + 27 + 28 + @task 29 + def fetch_all_items() -> list[TangledItem]: 30 + """Fetch issues, PRs, and comments from the PDS.""" 31 + logger = get_run_logger() 32 + with httpx.Client(base_url=PDS_BASE, timeout=30) as client: 33 + repo_uris = fetch_repo_at_uris(client) 34 + logger.info(f"found {len(repo_uris)} target repos on PDS") 35 + 36 + items: list[TangledItem] = [] 37 + for collection in COLLECTIONS: 38 + batch = fetch_items(client, collection, repo_uris) 39 + logger.info(f"{collection}: {len(batch)} records") 40 + items.extend(batch) 41 + 42 + return items 43 + 44 + 45 + @task 46 + def persist_to_duckdb(items: list[TangledItem]) -> int: 47 + db_path = os.environ.get( 48 + "ANALYTICS_DB_PATH", 49 + os.environ.get("PREFECT_LOCAL_STORAGE_PATH", "/tmp") + "/analytics.duckdb", 50 + ) 51 + return write_tangled_items(items, db_path) 52 + 53 + 54 + @flow(name="tangled-items", log_prints=True) 55 + def tangled_items(): 56 + logger = get_run_logger() 57 + 58 + items = fetch_all_items() 59 + if not items: 60 + logger.info("no tangled items found") 61 + return 62 + 63 + total = persist_to_duckdb(items) 64 + logger.info(f"persisted {len(items)} items; {total} total in raw_tangled_items") 65 + 66 + 67 + if __name__ == "__main__": 68 + tangled_items()
+2 -1
justfile
··· 214 214 push-web: build-web 215 215 docker push atcr.io/zzstoatzz.io/hub:latest 216 216 217 - # apply hub k8s manifests 217 + # apply hub k8s manifests and restart the pod to pull the new image 218 218 deploy-web: 219 219 kubectl apply -f deploy/hub-deployment.yaml 220 220 sed "s|HUB_DOMAIN_PLACEHOLDER|hub.waow.tech|g" deploy/hub-ingress.yaml | kubectl apply -f - 221 + kubectl rollout restart deployment/hub -n prefect 221 222 222 223 # build, push, and deploy hub 223 224 web: push-web deploy-web
+12
packages/mps/src/mps/__init__.py
··· 1 1 """shared utilities for my-prefect-server flows.""" 2 + 3 + from mps.github import IssueOrPR, IssueRef, gh_headers 4 + from mps.tangled import TangledItem, fetch_items, fetch_repo_at_uris 5 + 6 + __all__ = [ 7 + "IssueOrPR", 8 + "IssueRef", 9 + "TangledItem", 10 + "fetch_items", 11 + "fetch_repo_at_uris", 12 + "gh_headers", 13 + ]
+33
packages/mps/src/mps/db.py
··· 35 35 count = con.execute("SELECT count(*) FROM raw_github_issues").fetchone()[0] 36 36 con.close() 37 37 return count 38 + 39 + 40 + def write_tangled_items(items: list, db_path: str) -> int: 41 + """Upsert TangledItem objects into raw_tangled_items. Returns total row count.""" 42 + con = duckdb.connect(db_path) 43 + con.execute(""" 44 + CREATE TABLE IF NOT EXISTS raw_tangled_items ( 45 + repo VARCHAR, kind VARCHAR, title VARCHAR, 46 + body VARCHAR, url VARCHAR, at_uri VARCHAR, 47 + author_did VARCHAR, author_handle VARCHAR, 48 + created_at VARCHAR, parent_uri VARCHAR, 49 + fetched_at TIMESTAMP DEFAULT now(), 50 + PRIMARY KEY (at_uri) 51 + ) 52 + """) 53 + rows = [ 54 + ( 55 + item.repo, item.kind, item.title, 56 + item.body, item.url, item.at_uri, 57 + item.author_did, item.author_handle, 58 + item.created_at, item.parent_uri, 59 + datetime.datetime.now(datetime.UTC), 60 + ) 61 + for item in items 62 + ] 63 + if rows: 64 + con.executemany( 65 + "INSERT OR REPLACE INTO raw_tangled_items VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 66 + rows, 67 + ) 68 + count = con.execute("SELECT count(*) FROM raw_tangled_items").fetchone()[0] 69 + con.close() 70 + return count
+127
packages/mps/src/mps/tangled.py
··· 1 + """Tangled.org PDS fetch helpers and models.""" 2 + 3 + import httpx 4 + from pydantic import BaseModel 5 + 6 + PDS_BASE = "https://pds.zzstoatzz.io" 7 + DID = "did:plc:xbtmt2zjwlrfegqvch7fboei" 8 + HANDLE = "zzstoatzz.io" 9 + TARGET_REPOS = ["zat", "zlay", "plyr.fm", "at-me", "pollz", "typeahead"] 10 + 11 + XRPC = f"{PDS_BASE}/xrpc/com.atproto.repo.listRecords" 12 + 13 + 14 + class TangledItem(BaseModel): 15 + """A tangled.org issue, PR, or comment fetched from the PDS.""" 16 + 17 + repo: str 18 + kind: str # "issue" | "pr" | "comment" 19 + title: str | None = None 20 + body: str = "" 21 + url: str 22 + at_uri: str 23 + author_did: str 24 + author_handle: str 25 + created_at: str 26 + parent_uri: str | None = None 27 + 28 + 29 + def _rkey(uri: str) -> str: 30 + """Extract rkey from an at:// URI.""" 31 + return uri.rsplit("/", 1)[-1] 32 + 33 + 34 + def build_tangled_url(repo_name: str, kind: str, rkey: str) -> str: 35 + """Construct a tangled.org web URL.""" 36 + segment = "pulls" if kind == "pr" else "issues" 37 + return f"https://tangled.org/{HANDLE}/{repo_name}/{segment}/{rkey}" 38 + 39 + 40 + def fetch_repo_at_uris(client: httpx.Client) -> dict[str, str]: 41 + """Read sh.tangled.repo records and return {at_uri: repo_name} for target repos.""" 42 + resp = client.get( 43 + XRPC, 44 + params={"repo": DID, "collection": "sh.tangled.repo", "limit": 100}, 45 + ) 46 + resp.raise_for_status() 47 + 48 + lookup: dict[str, str] = {} 49 + for record in resp.json().get("records", []): 50 + name = record.get("value", {}).get("name", "") 51 + if name in TARGET_REPOS: 52 + lookup[record["uri"]] = name 53 + return lookup 54 + 55 + 56 + def fetch_items( 57 + client: httpx.Client, 58 + collection: str, 59 + repo_uris: dict[str, str], 60 + ) -> list[TangledItem]: 61 + """Fetch records of a given collection, filtering to target repos.""" 62 + is_comment = "comment" in collection 63 + is_pr = "pull" in collection 64 + kind = "comment" if is_comment else ("pr" if is_pr else "issue") 65 + 66 + cursor: str | None = None 67 + items: list[TangledItem] = [] 68 + 69 + while True: 70 + params: dict[str, str | int] = { 71 + "repo": DID, 72 + "collection": collection, 73 + "limit": 100, 74 + } 75 + if cursor: 76 + params["cursor"] = cursor 77 + 78 + resp = client.get(XRPC, params=params) 79 + resp.raise_for_status() 80 + data = resp.json() 81 + 82 + for record in data.get("records", []): 83 + uri = record["uri"] 84 + value = record.get("value", {}) 85 + 86 + # resolve repo — comments have a "subject" pointing to the parent 87 + # issue/PR, whose repo we already know 88 + repo_uri = value.get("repo", "") 89 + parent_uri = value.get("subject", "") if is_comment else None 90 + 91 + # for comments, resolve repo from the parent's repo field 92 + # by checking if the parent's repo URI is in our lookup 93 + if is_comment: 94 + repo_name = None 95 + # try to match parent subject to a known issue/PR repo 96 + for known_uri, name in repo_uris.items(): 97 + if parent_uri and known_uri in parent_uri: 98 + repo_name = name 99 + break 100 + if repo_name is None: 101 + continue 102 + else: 103 + repo_name = repo_uris.get(repo_uri) 104 + if repo_name is None: 105 + continue 106 + 107 + rkey = _rkey(uri) 108 + items.append( 109 + TangledItem( 110 + repo=repo_name, 111 + kind=kind, 112 + title=value.get("title"), 113 + body=value.get("body", ""), 114 + url=build_tangled_url(repo_name, kind, rkey), 115 + at_uri=uri, 116 + author_did=DID, 117 + author_handle=HANDLE, 118 + created_at=value.get("createdAt", ""), 119 + parent_uri=parent_uri if is_comment else None, 120 + ) 121 + ) 122 + 123 + cursor = data.get("cursor") 124 + if not cursor: 125 + break 126 + 127 + return items
+7
prefect.yaml
··· 24 24 parameters: 25 25 only_unread: true 26 26 27 + - name: tangled-items 28 + entrypoint: flows/tangled_items.py:tangled_items 29 + work_pool: 30 + name: kubernetes-pool 31 + schedules: 32 + - cron: "0 * * * *" # hourly 33 + 27 34 - name: enrich 28 35 entrypoint: flows/enrich.py:enrich 29 36 work_pool:
+72
web/src/lib/components/CardRow.svelte
··· 1 + <script lang="ts"> 2 + import type { Card } from '$lib/types'; 3 + import SourceBadge from '$lib/components/SourceBadge.svelte'; 4 + import { hashColor, timeAgo, parseInlineCode, authorUrl, originUrl } from '$lib/format'; 5 + 6 + let { card }: { card: Card } = $props(); 7 + 8 + let segments = $derived(parseInlineCode(card.title)); 9 + let author = $derived(authorUrl(card)); 10 + let origin = $derived(originUrl(card)); 11 + </script> 12 + 13 + <tr class="border-b border-gray-800/50 hover:bg-gray-800/60 transition-colors"> 14 + <!-- source --> 15 + <td class="px-4 py-3"> 16 + <SourceBadge kind={card.source} /> 17 + </td> 18 + 19 + <!-- item --> 20 + <td class="px-4 py-3 max-w-xs"> 21 + {#if card.meta.repo} 22 + <a href={origin} target="_blank" rel="noopener noreferrer"> 23 + <span class="inline-block px-2 py-0.5 rounded-full text-xs border {hashColor(String(card.meta.repo))}"> 24 + {card.meta.repo} 25 + </span> 26 + </a> 27 + {/if} 28 + <a 29 + href={card.url} 30 + target="_blank" 31 + rel="noopener noreferrer" 32 + class="text-gray-200 hover:text-white hover:underline ml-2" 33 + > 34 + #{card.meta.number} 35 + {#each segments as seg, i (i)} 36 + {#if seg.code} 37 + <code class="bg-gray-700/60 px-1 rounded text-xs">{seg.code}</code> 38 + {:else} 39 + {seg.text} 40 + {/if} 41 + {/each} 42 + </a> 43 + </td> 44 + 45 + <!-- author --> 46 + <td class="px-4 py-3"> 47 + {#if card.meta.user} 48 + {#if author} 49 + <a href={author} class="text-gray-400 hover:text-gray-200 text-xs" target="_blank" rel="noopener noreferrer"> 50 + @{card.meta.user} 51 + </a> 52 + {:else} 53 + <span class="text-gray-400 text-xs">@{card.meta.user}</span> 54 + {/if} 55 + {/if} 56 + </td> 57 + 58 + <!-- tags --> 59 + <td class="px-4 py-3"> 60 + {#each card.tags as tag (tag)} 61 + <span class="inline-block px-2 py-0.5 rounded-full text-xs border mr-1 {hashColor(tag)}"> 62 + {tag} 63 + </span> 64 + {/each} 65 + </td> 66 + 67 + <!-- relevance --> 68 + <td class="px-4 py-3 text-right"> 69 + <div class="text-gray-300 font-mono text-xs">{card.score.toFixed(1)}</div> 70 + <div class="text-gray-500 text-xs">{timeAgo(card.updated)}</div> 71 + </td> 72 + </tr>
+81
web/src/lib/components/CardTable.svelte
··· 1 + <script lang="ts"> 2 + import type { Card } from '$lib/types'; 3 + import CardRow from '$lib/components/CardRow.svelte'; 4 + 5 + let { cards }: { cards: Card[] } = $props(); 6 + 7 + let sortCol: string = $state('score'); 8 + let sortDir: 'asc' | 'desc' = $state('desc'); 9 + 10 + let sorted: Card[] = $derived.by(() => { 11 + const copy = [...cards]; 12 + const dir = sortDir === 'asc' ? 1 : -1; 13 + return copy.sort((a, b) => { 14 + switch (sortCol) { 15 + case 'source': 16 + return a.source.localeCompare(b.source) * dir; 17 + case 'item': 18 + return a.title.localeCompare(b.title) * dir; 19 + case 'author': 20 + return String(a.meta.user ?? '').localeCompare(String(b.meta.user ?? '')) * dir; 21 + case 'score': 22 + return (a.score - b.score) * dir; 23 + default: 24 + return 0; 25 + } 26 + }); 27 + }); 28 + 29 + function toggle(col: string) { 30 + if (sortCol === col) { 31 + sortDir = sortDir === 'asc' ? 'desc' : 'asc'; 32 + } else { 33 + sortCol = col; 34 + sortDir = 'desc'; 35 + } 36 + } 37 + 38 + function indicator(col: string): string { 39 + if (sortCol !== col) return ''; 40 + return sortDir === 'asc' ? ' ↑' : ' ↓'; 41 + } 42 + </script> 43 + 44 + <div class="bg-gray-900 rounded-lg overflow-hidden"> 45 + <table class="w-full text-sm"> 46 + <thead> 47 + <tr class="border-b border-gray-800"> 48 + <th 49 + class="text-left px-4 py-3 font-normal cursor-pointer {sortCol === 'source' ? 'text-gray-200' : 'text-gray-400'}" 50 + onclick={() => toggle('source')} 51 + > 52 + source{indicator('source')} 53 + </th> 54 + <th 55 + class="text-left px-4 py-3 font-normal cursor-pointer {sortCol === 'item' ? 'text-gray-200' : 'text-gray-400'}" 56 + onclick={() => toggle('item')} 57 + > 58 + item{indicator('item')} 59 + </th> 60 + <th 61 + class="text-left px-4 py-3 font-normal cursor-pointer {sortCol === 'author' ? 'text-gray-200' : 'text-gray-400'}" 62 + onclick={() => toggle('author')} 63 + > 64 + author{indicator('author')} 65 + </th> 66 + <th class="text-left px-4 py-3 text-gray-400 font-normal">tags</th> 67 + <th 68 + class="text-right px-4 py-3 font-normal cursor-pointer {sortCol === 'score' ? 'text-gray-200' : 'text-gray-400'}" 69 + onclick={() => toggle('score')} 70 + > 71 + relevance{indicator('score')} 72 + </th> 73 + </tr> 74 + </thead> 75 + <tbody> 76 + {#each sorted as card (card.id)} 77 + <CardRow {card} /> 78 + {/each} 79 + </tbody> 80 + </table> 81 + </div>
+61
web/src/lib/components/FilterBar.svelte
··· 1 + <script lang="ts"> 2 + let { 3 + search = $bindable(''), 4 + sourceFilter = $bindable(''), 5 + kindFilter = $bindable(''), 6 + tagFilter = $bindable(''), 7 + sources, 8 + kinds, 9 + tags, 10 + count 11 + }: { 12 + search: string; 13 + sourceFilter: string; 14 + kindFilter: string; 15 + tagFilter: string; 16 + sources: string[]; 17 + kinds: string[]; 18 + tags: string[]; 19 + count: number; 20 + } = $props(); 21 + </script> 22 + 23 + <div class="flex flex-row gap-3 items-center flex-wrap mb-6"> 24 + <input 25 + bind:value={search} 26 + placeholder="search..." 27 + class="bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-gray-500 w-64" 28 + /> 29 + 30 + <select 31 + bind:value={sourceFilter} 32 + class="bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-gray-500 w-auto" 33 + > 34 + <option value="">all sources</option> 35 + {#each sources as source (source)} 36 + <option value={source}>{source}</option> 37 + {/each} 38 + </select> 39 + 40 + <select 41 + bind:value={kindFilter} 42 + class="bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-gray-500 w-auto" 43 + > 44 + <option value="">all kinds</option> 45 + {#each kinds as kind (kind)} 46 + <option value={kind}>{kind}</option> 47 + {/each} 48 + </select> 49 + 50 + <select 51 + bind:value={tagFilter} 52 + class="bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-gray-500 w-auto" 53 + > 54 + <option value="">all tags</option> 55 + {#each tags as tag (tag)} 56 + <option value={tag}>{tag}</option> 57 + {/each} 58 + </select> 59 + 60 + <span class="text-xs text-gray-500 ml-auto">{count} items</span> 61 + </div>
+21
web/src/lib/components/SourceBadge.svelte
··· 1 + <script lang="ts"> 2 + let { kind }: { kind: string } = $props(); 3 + 4 + const colorMap: Record<string, string> = { 5 + pr: 'bg-violet-900/60 text-violet-300 border-violet-700/40', 6 + issue: 'bg-emerald-900/60 text-emerald-300 border-emerald-700/40', 7 + thread: 'bg-sky-900/60 text-sky-300 border-sky-700/40', 8 + ticket: 'bg-amber-900/60 text-amber-300 border-amber-700/40', 9 + github: 'bg-gray-800/60 text-gray-300 border-gray-600/40', 10 + slack: 'bg-purple-900/60 text-purple-300 border-purple-700/40', 11 + agent: 'bg-blue-900/60 text-blue-300 border-blue-700/40' 12 + }; 13 + 14 + const fallback = 'bg-gray-900/60 text-gray-300 border-gray-700/40'; 15 + 16 + let colors = $derived(colorMap[kind] ?? fallback); 17 + </script> 18 + 19 + <span class="inline-block px-2 py-0.5 rounded-full text-xs border {colors}"> 20 + {kind.toLowerCase()} 21 + </span>
+17
web/src/lib/components/StatBar.svelte
··· 1 + <script lang="ts"> 2 + interface Entry { 3 + value: number; 4 + label: string; 5 + } 6 + 7 + let { entries }: { entries: Entry[] } = $props(); 8 + </script> 9 + 10 + <div class="grid grid-cols-2 sm:grid-cols-4 gap-4"> 11 + {#each entries as entry (entry.label)} 12 + <div class="bg-gray-800 rounded-lg px-5 py-4"> 13 + <p class="text-3xl font-semibold text-gray-100">{entry.value}</p> 14 + <p class="text-xs text-gray-400 mt-1 uppercase tracking-wider">{entry.label}</p> 15 + </div> 16 + {/each} 17 + </div>
+88
web/src/lib/format.ts
··· 1 + import type { Card } from '$lib/types'; 2 + 3 + const PALETTE = [ 4 + 'bg-red-900/60 text-red-300 border-red-700/40', 5 + 'bg-orange-900/60 text-orange-300 border-orange-700/40', 6 + 'bg-amber-900/60 text-amber-300 border-amber-700/40', 7 + 'bg-emerald-900/60 text-emerald-300 border-emerald-700/40', 8 + 'bg-teal-900/60 text-teal-300 border-teal-700/40', 9 + 'bg-sky-900/60 text-sky-300 border-sky-700/40', 10 + 'bg-violet-900/60 text-violet-300 border-violet-700/40', 11 + 'bg-pink-900/60 text-pink-300 border-pink-700/40' 12 + ]; 13 + 14 + function hash(s: string): number { 15 + let h = 0; 16 + for (let i = 0; i < s.length; i++) { 17 + h = ((h << 5) - h + s.charCodeAt(i)) | 0; 18 + } 19 + return Math.abs(h); 20 + } 21 + 22 + export function hashColor(s: string): string { 23 + return PALETTE[hash(s) % PALETTE.length]; 24 + } 25 + 26 + export function timeAgo(iso: string): string { 27 + const ms = Date.now() - new Date(iso).getTime(); 28 + const sec = Math.floor(ms / 1000); 29 + if (sec < 60) return 'just now'; 30 + const min = Math.floor(sec / 60); 31 + if (min < 60) return `${min}m ago`; 32 + const hr = Math.floor(min / 60); 33 + if (hr < 24) return `${hr}h ago`; 34 + const days = Math.floor(hr / 24); 35 + if (days < 30) return `${days}d ago`; 36 + const months = Math.floor(days / 30); 37 + if (months < 12) return `${months}mo ago`; 38 + return `${Math.floor(months / 12)}y ago`; 39 + } 40 + 41 + export interface TextSegment { 42 + text?: string; 43 + code?: string; 44 + } 45 + 46 + export function parseInlineCode(text: string): TextSegment[] { 47 + const segments: TextSegment[] = []; 48 + const re = /`([^`]+)`/g; 49 + let last = 0; 50 + let match: RegExpExecArray | null; 51 + while ((match = re.exec(text)) !== null) { 52 + if (match.index > last) { 53 + segments.push({ text: text.slice(last, match.index) }); 54 + } 55 + segments.push({ code: match[1] }); 56 + last = re.lastIndex; 57 + } 58 + if (last < text.length) { 59 + segments.push({ text: text.slice(last) }); 60 + } 61 + return segments; 62 + } 63 + 64 + export function authorUrl(card: Card): string | null { 65 + const user = card.meta.user; 66 + if (!user) return null; 67 + switch (card.source) { 68 + case 'github': 69 + return `https://github.com/${user}`; 70 + case 'tangled': 71 + return `https://tangled.org/${user}`; 72 + default: 73 + return null; 74 + } 75 + } 76 + 77 + export function originUrl(card: Card): string | null { 78 + const repo = card.meta.repo; 79 + if (!repo) return null; 80 + switch (card.source) { 81 + case 'github': 82 + return `https://github.com/${repo}`; 83 + case 'tangled': 84 + return `https://tangled.org/zzstoatzz.io/${repo}`; 85 + default: 86 + return null; 87 + } 88 + }
+18
web/src/lib/types.ts
··· 1 + export interface Card { 2 + id: string; 3 + source: string; 4 + kind: string; 5 + title: string; 6 + url: string; 7 + score: number; 8 + updated: string; 9 + tags: string[]; 10 + meta: Record<string, string | number>; 11 + } 12 + 13 + export interface DashboardStats { 14 + tracked: number; 15 + open: number; 16 + with_reactions: number; 17 + repos: number; 18 + }
+3
web/src/routes/+layout.svelte
··· 4 4 </script> 5 5 6 6 <div class="bg-gray-950 text-gray-100 min-h-screen"> 7 + <nav class="px-6 py-4 border-b border-gray-800/50"> 8 + <h1 class="text-xl font-light tracking-widest text-gray-500 lowercase">hub</h1> 9 + </nav> 7 10 {@render children()} 8 11 </div>
+31 -22
web/src/routes/+page.server.ts
··· 1 1 import { query } from '$lib/server/db'; 2 - 3 - interface Stats { 4 - tracked: number; 5 - open: number; 6 - with_reactions: number; 7 - repos: number; 8 - } 2 + import type { Card, DashboardStats } from '$lib/types'; 9 3 10 - interface ActionItem { 4 + interface ActionRow { 5 + source: string; 11 6 repo: string; 12 - number: number; 13 - type: string; 7 + identifier: string; 8 + kind: string; 14 9 title: string; 15 10 url: string; 16 - user: string; 17 - labels: string; 18 - comments: number; 19 - reactions_total: number; 11 + author: string; 12 + labels: string[]; 20 13 importance_score: number; 21 - updated_at: string; 14 + updated: string; 22 15 } 23 16 24 17 export async function load() { 25 - const [stats] = await query<Stats>(` 18 + const [stats] = await query<DashboardStats>(` 26 19 SELECT 27 20 count(*)::INT as tracked, 28 21 count(*) FILTER (WHERE state = 'open')::INT as open, ··· 31 24 FROM raw_github_issues 32 25 `); 33 26 34 - const items = await query<ActionItem>(` 35 - SELECT repo, number, type, title, url, "user", 36 - array_to_string(labels, ', ') AS labels, 37 - comments, reactions_total, importance_score, updated_at 38 - FROM github_action_items 27 + const rows = await query<ActionRow>(` 28 + SELECT source, repo, identifier, kind, title, url, 29 + author, labels, importance_score, updated 30 + FROM hub_action_items 39 31 ORDER BY importance_score DESC 32 + LIMIT 200 40 33 `); 41 34 42 - return { stats, items }; 35 + const cards: Card[] = rows.map((r) => ({ 36 + id: `${r.source}:${r.repo}#${r.identifier}`, 37 + source: r.source, 38 + kind: r.kind, 39 + title: r.title, 40 + url: r.url, 41 + score: r.importance_score, 42 + updated: r.updated, 43 + tags: Array.isArray(r.labels) ? r.labels : [], 44 + meta: { 45 + repo: r.repo, 46 + number: r.identifier, 47 + user: r.author 48 + } 49 + })); 50 + 51 + return { stats, cards }; 43 52 }
+47 -86
web/src/routes/+page.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 + import StatBar from '$lib/components/StatBar.svelte'; 3 + import FilterBar from '$lib/components/FilterBar.svelte'; 4 + import CardTable from '$lib/components/CardTable.svelte'; 5 + 2 6 let { data } = $props(); 3 7 4 8 const stats = $derived(data.stats); 5 - const items = $derived(data.items); 9 + const cards = $derived(data.cards); 10 + 11 + let search = $state(''); 12 + let sourceFilter = $state(''); 13 + let kindFilter = $state(''); 14 + let tagFilter = $state(''); 15 + 16 + const sources = $derived([...new Set(cards.map((c) => c.source))].sort()); 17 + const kinds = $derived([...new Set(cards.map((c) => c.kind))].sort()); 18 + const allTags = $derived([...new Set(cards.flatMap((c) => c.tags))].sort()); 19 + 20 + const filteredCards = $derived.by(() => { 21 + const q = search.toLowerCase(); 22 + return cards.filter((c) => { 23 + if (q && !c.title.toLowerCase().includes(q) && !String(c.meta.repo).toLowerCase().includes(q)) 24 + return false; 25 + if (sourceFilter && c.source !== sourceFilter) return false; 26 + if (kindFilter && c.kind !== kindFilter) return false; 27 + if (tagFilter && !c.tags.includes(tagFilter)) return false; 28 + return true; 29 + }); 30 + }); 6 31 </script> 7 32 8 33 <svelte:head> ··· 10 35 </svelte:head> 11 36 12 37 <div class="px-6 py-8 max-w-screen-xl mx-auto"> 13 - <header class="mb-8"> 14 - <h1 class="text-xl font-light tracking-widest text-gray-500 lowercase">hub</h1> 15 - </header> 38 + <StatBar 39 + entries={[ 40 + { value: stats.tracked, label: 'tracked' }, 41 + { value: stats.open, label: 'open' }, 42 + { value: stats.with_reactions, label: 'with reactions' }, 43 + { value: stats.repos, label: 'repos' } 44 + ]} 45 + /> 16 46 17 - <!-- stat tiles --> 18 - <div class="grid grid-cols-4 gap-4 mb-10"> 19 - <div class="bg-gray-800 rounded-lg px-5 py-4"> 20 - <p class="text-3xl font-semibold text-gray-100">{stats.tracked}</p> 21 - <p class="text-xs text-gray-400 mt-1 uppercase tracking-wider">tracked</p> 22 - </div> 23 - <div class="bg-gray-800 rounded-lg px-5 py-4"> 24 - <p class="text-3xl font-semibold text-gray-100">{stats.open}</p> 25 - <p class="text-xs text-gray-400 mt-1 uppercase tracking-wider">open</p> 26 - </div> 27 - <div class="bg-gray-800 rounded-lg px-5 py-4"> 28 - <p class="text-3xl font-semibold text-gray-100">{stats.with_reactions}</p> 29 - <p class="text-xs text-gray-400 mt-1 uppercase tracking-wider">with reactions</p> 30 - </div> 31 - <div class="bg-gray-800 rounded-lg px-5 py-4"> 32 - <p class="text-3xl font-semibold text-gray-100">{stats.repos}</p> 33 - <p class="text-xs text-gray-400 mt-1 uppercase tracking-wider">repos</p> 34 - </div> 35 - </div> 36 - 37 - <!-- action items table --> 38 - <div class="bg-gray-900 rounded-lg overflow-hidden"> 39 - <table class="w-full text-sm"> 40 - <thead> 41 - <tr class="border-b border-gray-800"> 42 - <th class="text-left px-4 py-3 text-gray-400 font-normal">repo</th> 43 - <th class="text-left px-4 py-3 text-gray-400 font-normal">#</th> 44 - <th class="text-left px-4 py-3 text-gray-400 font-normal">type</th> 45 - <th class="text-left px-4 py-3 text-gray-400 font-normal">title</th> 46 - <th class="text-left px-4 py-3 text-gray-400 font-normal">user</th> 47 - <th class="text-left px-4 py-3 text-gray-400 font-normal">labels</th> 48 - <th class="text-right px-4 py-3 text-gray-400 font-normal">score</th> 49 - <th class="text-right px-4 py-3 text-gray-400 font-normal">updated</th> 50 - </tr> 51 - </thead> 52 - <tbody> 53 - {#each items as item (item.repo + '#' + item.number)} 54 - <tr class="border-b border-gray-800/50 hover:bg-gray-800/60 transition-colors"> 55 - <td class="px-4 py-3 text-gray-400 font-mono text-xs whitespace-nowrap"> 56 - {item.repo} 57 - </td> 58 - <td class="px-4 py-3 text-gray-500 font-mono text-xs whitespace-nowrap"> 59 - {item.number} 60 - </td> 61 - <td class="px-4 py-3 whitespace-nowrap"> 62 - {#if item.type === 'pr'} 63 - <span class="inline-block px-2 py-0.5 rounded-full text-xs bg-violet-900/60 text-violet-300 border border-violet-700/40"> 64 - pr 65 - </span> 66 - {:else} 67 - <span class="inline-block px-2 py-0.5 rounded-full text-xs bg-emerald-900/60 text-emerald-300 border border-emerald-700/40"> 68 - issue 69 - </span> 70 - {/if} 71 - </td> 72 - <td class="px-4 py-3 max-w-xs"> 73 - <a 74 - href={item.url} 75 - target="_blank" 76 - rel="noopener noreferrer" 77 - class="text-gray-200 hover:text-white hover:underline truncate block" 78 - > 79 - {item.title} 80 - </a> 81 - </td> 82 - <td class="px-4 py-3 text-gray-400 text-xs whitespace-nowrap"> 83 - {item.user} 84 - </td> 85 - <td class="px-4 py-3 text-gray-500 text-xs"> 86 - {item.labels} 87 - </td> 88 - <td class="px-4 py-3 text-right text-gray-300 font-mono text-xs whitespace-nowrap"> 89 - {item.importance_score.toFixed(1)} 90 - </td> 91 - <td class="px-4 py-3 text-right text-gray-500 font-mono text-xs whitespace-nowrap"> 92 - {item.updated_at.slice(0, 10)} 93 - </td> 94 - </tr> 95 - {/each} 96 - </tbody> 97 - </table> 47 + <div class="mt-10"> 48 + <FilterBar 49 + bind:search 50 + bind:sourceFilter 51 + bind:kindFilter 52 + bind:tagFilter 53 + {sources} 54 + {kinds} 55 + tags={allTags} 56 + count={filteredCards.length} 57 + /> 58 + <CardTable cards={filteredCards} /> 98 59 </div> 99 60 </div>