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 authored PRs fetch, JSON API routes, and briefing visual improvements

- gh-notifications flow now also fetches open issues/PRs authored by
zzstoatzz via the search API, merged and deduped with notifications
- extract shared loaders; add /api/cards.json, /api/briefing.json,
/api/stats.json endpoints for machine consumption
- briefing cards: repo names as colored pills linking to repo,
separate #number links, stronger background tints (/20 → /40)

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

+203 -68
+44 -2
flows/gh_notifications.py
··· 119 119 120 120 121 121 @task 122 + def fetch_authored_items(token: str, username: str = "zzstoatzz") -> list[IssueRef]: 123 + """Fetch open issues/PRs authored by the user via the search API.""" 124 + logger = get_run_logger() 125 + with httpx.Client(headers=gh_headers(token)) as client: 126 + resp = client.get( 127 + f"{GITHUB_API}/search/issues", 128 + params={"q": f"author:{username} is:open", "per_page": 50, "sort": "updated"}, 129 + ) 130 + resp.raise_for_status() 131 + 132 + refs: list[IssueRef] = [] 133 + for item in resp.json().get("items", []): 134 + html_url = item.get("html_url", "") 135 + is_pr = "/pull/" in html_url 136 + # extract repo from html_url: https://github.com/{owner}/{repo}/issues/{n} 137 + parts = html_url.split("/") 138 + try: 139 + repo = f"{parts[3]}/{parts[4]}" 140 + number = int(parts[-1]) 141 + except (IndexError, ValueError): 142 + continue 143 + refs.append(IssueRef( 144 + repo=repo, 145 + number=number, 146 + subject_type="PullRequest" if is_pr else "Issue", 147 + )) 148 + 149 + logger.info(f"fetched {len(refs)} authored items for {username}") 150 + return refs 151 + 152 + 153 + @task 122 154 def persist_to_duckdb(items: list[IssueOrPR]) -> int: 123 155 db_path = os.environ.get( 124 156 "ANALYTICS_DB_PATH", ··· 136 168 logger = get_run_logger() 137 169 138 170 token = load_token() 139 - refs = fetch_notifications(token, only_unread=only_unread) 171 + notif_refs = fetch_notifications(token, only_unread=only_unread) 172 + authored_refs = fetch_authored_items(token) 173 + 174 + # merge and dedupe by (repo, number) 175 + seen: set[tuple[str, int]] = set() 176 + refs: list[IssueRef] = [] 177 + for ref in notif_refs + authored_refs: 178 + key = (ref.repo, ref.number) 179 + if key not in seen: 180 + seen.add(key) 181 + refs.append(ref) 140 182 141 183 if not refs: 142 - logger.info("no notifications") 184 + logger.info("no items") 143 185 return [] 144 186 145 187 futures = fetch_issue_or_pr.map(refs, unmapped(token))
+6 -6
web/src/lib/briefing-styles.ts
··· 5 5 * border-red-500 border-amber-500 border-emerald-500 border-sky-500 border-violet-500 6 6 * text-red-400 text-amber-400 text-emerald-400 text-sky-400 text-violet-400 7 7 * text-red-300 text-amber-300 text-emerald-300 text-sky-300 text-violet-300 8 - * bg-red-950/20 bg-amber-950/20 bg-emerald-950/20 bg-sky-950/20 bg-violet-950/20 8 + * bg-red-950/40 bg-amber-950/40 bg-emerald-950/40 bg-sky-950/40 bg-violet-950/40 9 9 * border-l-2 border-l-3 border-l-4 10 10 * sm:col-span-2 11 11 */ ··· 25 25 headerText: 'text-red-400', 26 26 summaryText: 'text-red-300/70', 27 27 highlightBar: 'border-red-500', 28 - bgTint: 'bg-red-950/20' 28 + bgTint: 'bg-red-950/40' 29 29 }, 30 30 amber: { 31 31 border: 'border-amber-500', 32 32 headerText: 'text-amber-400', 33 33 summaryText: 'text-amber-300/70', 34 34 highlightBar: 'border-amber-500', 35 - bgTint: 'bg-amber-950/20' 35 + bgTint: 'bg-amber-950/40' 36 36 }, 37 37 emerald: { 38 38 border: 'border-emerald-500', 39 39 headerText: 'text-emerald-400', 40 40 summaryText: 'text-emerald-300/70', 41 41 highlightBar: 'border-emerald-500', 42 - bgTint: 'bg-emerald-950/20' 42 + bgTint: 'bg-emerald-950/40' 43 43 }, 44 44 sky: { 45 45 border: 'border-sky-500', 46 46 headerText: 'text-sky-400', 47 47 summaryText: 'text-sky-300/70', 48 48 highlightBar: 'border-sky-500', 49 - bgTint: 'bg-sky-950/20' 49 + bgTint: 'bg-sky-950/40' 50 50 }, 51 51 violet: { 52 52 border: 'border-violet-500', 53 53 headerText: 'text-violet-400', 54 54 summaryText: 'text-violet-300/70', 55 55 highlightBar: 'border-violet-500', 56 - bgTint: 'bg-violet-950/20' 56 + bgTint: 'bg-violet-950/40' 57 57 } 58 58 }; 59 59
+70 -10
web/src/lib/components/Briefing.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Briefing, BriefingItem } from '$lib/server/briefing'; 3 3 import type { Card } from '$lib/types'; 4 - import { timeAgo } from '$lib/format'; 4 + import { hashColor, timeAgo } from '$lib/format'; 5 5 import { ACCENT_STYLES, ICON_PATHS, PRIORITY_LAYOUT } from '$lib/briefing-styles'; 6 6 7 7 let { briefing, cards }: { briefing: Briefing | null; cards: Card[] } = $props(); ··· 12 12 return cardMap.get(item.item_id)?.url ?? null; 13 13 } 14 14 15 - /** "github:prefecthq/prefect#1234" -> "prefect#1234" */ 16 - function shortLabel(item_id: string): string { 17 - const after = item_id.includes(':') ? item_id.split(':')[1] : item_id; 18 - const match = after.match(/([^/]+)(#\d+)$/); 19 - return match ? `${match[1]}${match[2]}` : after; 15 + interface ParsedItemId { 16 + source: string; 17 + repo: string; 18 + shortRepo: string; 19 + number: string; 20 + } 21 + 22 + /** "github:prefecthq/prefect#1234" -> { source, repo, shortRepo, number } */ 23 + function parseItemId(item_id: string): ParsedItemId | null { 24 + const colonIdx = item_id.indexOf(':'); 25 + if (colonIdx === -1) return null; 26 + 27 + const source = item_id.slice(0, colonIdx); 28 + const rest = item_id.slice(colonIdx + 1); 29 + 30 + const match = rest.match(/^(.+?)#(\d+)$/); 31 + if (!match) return null; 32 + 33 + const repo = match[1]; 34 + const parts = repo.split('/'); 35 + const shortRepo = parts[parts.length - 1]; 36 + 37 + return { source, repo, shortRepo, number: match[2] }; 38 + } 39 + 40 + /** build repo URL based on source */ 41 + function repoUrl(parsed: ParsedItemId): string { 42 + switch (parsed.source) { 43 + case 'github': 44 + return `https://github.com/${parsed.repo}`; 45 + case 'tangled': 46 + return `https://tangled.org/zzstoatzz.io/${parsed.repo}`; 47 + default: 48 + return '#'; 49 + } 20 50 } 21 51 </script> 22 52 ··· 55 85 <ul class="space-y-1.5"> 56 86 {#each section.items as item (item.item_id)} 57 87 {@const url = urlFor(item)} 88 + {@const parsed = parseItemId(item.item_id)} 58 89 {@const highlighted = item.highlight ?? false} 59 90 <li 60 - class="text-sm flex gap-2 {highlighted 91 + class="text-sm flex items-center gap-2 {highlighted 61 92 ? `border-l-2 ${accent.highlightBar} pl-2 text-gray-100` 62 93 : 'text-gray-300'}" 63 94 > 64 - <span class="font-mono text-xs opacity-60 shrink-0 pt-0.5"> 65 - {shortLabel(item.item_id)} 66 - </span> 95 + {#if parsed} 96 + <a 97 + href={repoUrl(parsed)} 98 + target="_blank" 99 + rel="noopener noreferrer" 100 + class="shrink-0" 101 + > 102 + <span 103 + class="inline-block px-2 py-0.5 rounded-full text-xs border {hashColor(parsed.repo)}" 104 + > 105 + {parsed.shortRepo} 106 + </span> 107 + </a> 108 + {#if url} 109 + <a 110 + href={url} 111 + target="_blank" 112 + rel="noopener noreferrer" 113 + class="font-mono text-xs opacity-60 shrink-0" 114 + > 115 + #{parsed.number} 116 + </a> 117 + {:else} 118 + <span class="font-mono text-xs opacity-60 shrink-0"> 119 + #{parsed.number} 120 + </span> 121 + {/if} 122 + {:else} 123 + <span class="font-mono text-xs opacity-60 shrink-0"> 124 + {item.item_id} 125 + </span> 126 + {/if} 67 127 {#if url} 68 128 <a 69 129 href={url}
+57
web/src/lib/server/loaders.ts
··· 1 + import { query } from '$lib/server/db'; 2 + import { loadBriefing } from '$lib/server/briefing'; 3 + import type { Card, DashboardStats } from '$lib/types'; 4 + 5 + export type { Card, DashboardStats }; 6 + export { loadBriefing }; 7 + 8 + interface ActionRow { 9 + source: string; 10 + repo: string; 11 + identifier: string; 12 + kind: string; 13 + title: string; 14 + url: string; 15 + author: string; 16 + labels: string[]; 17 + importance_score: number; 18 + updated: string; 19 + } 20 + 21 + export async function loadCards(): Promise<Card[]> { 22 + const rows = await query<ActionRow>(` 23 + SELECT source, repo, identifier, kind, title, url, 24 + author, labels, importance_score, updated 25 + FROM hub_action_items 26 + ORDER BY importance_score DESC 27 + LIMIT 200 28 + `); 29 + 30 + return rows.map((r) => ({ 31 + id: `${r.source}:${r.repo}#${r.identifier}`, 32 + source: r.source, 33 + kind: r.kind, 34 + title: r.title, 35 + url: r.url, 36 + score: r.importance_score, 37 + updated: r.updated, 38 + tags: Array.isArray(r.labels) ? r.labels : [], 39 + meta: { 40 + repo: r.repo, 41 + number: r.identifier, 42 + user: r.author 43 + } 44 + })); 45 + } 46 + 47 + export async function loadStats(): Promise<DashboardStats> { 48 + const [stats] = await query<DashboardStats>(` 49 + SELECT 50 + count(*)::INT as tracked, 51 + count(*) FILTER (WHERE state = 'open')::INT as open, 52 + count(*) FILTER (WHERE reactions_total > 0)::INT as with_reactions, 53 + count(DISTINCT repo)::INT as repos 54 + FROM raw_github_issues 55 + `); 56 + return stats; 57 + }
+6 -50
web/src/routes/+page.server.ts
··· 1 - import { query } from '$lib/server/db'; 2 - import { loadBriefing } from '$lib/server/briefing'; 3 - import type { Card, DashboardStats } from '$lib/types'; 4 - 5 - interface ActionRow { 6 - source: string; 7 - repo: string; 8 - identifier: string; 9 - kind: string; 10 - title: string; 11 - url: string; 12 - author: string; 13 - labels: string[]; 14 - importance_score: number; 15 - updated: string; 16 - } 1 + import { loadCards, loadStats, loadBriefing } from '$lib/server/loaders'; 17 2 18 3 export async function load() { 19 - const [stats] = await query<DashboardStats>(` 20 - SELECT 21 - count(*)::INT as tracked, 22 - count(*) FILTER (WHERE state = 'open')::INT as open, 23 - count(*) FILTER (WHERE reactions_total > 0)::INT as with_reactions, 24 - count(DISTINCT repo)::INT as repos 25 - FROM raw_github_issues 26 - `); 27 - 28 - const rows = await query<ActionRow>(` 29 - SELECT source, repo, identifier, kind, title, url, 30 - author, labels, importance_score, updated 31 - FROM hub_action_items 32 - ORDER BY importance_score DESC 33 - LIMIT 200 34 - `); 35 - 36 - const cards: Card[] = rows.map((r) => ({ 37 - id: `${r.source}:${r.repo}#${r.identifier}`, 38 - source: r.source, 39 - kind: r.kind, 40 - title: r.title, 41 - url: r.url, 42 - score: r.importance_score, 43 - updated: r.updated, 44 - tags: Array.isArray(r.labels) ? r.labels : [], 45 - meta: { 46 - repo: r.repo, 47 - number: r.identifier, 48 - user: r.author 49 - } 50 - })); 51 - 52 - const briefing = await loadBriefing(); 4 + const [stats, cards, briefing] = await Promise.all([ 5 + loadStats(), 6 + loadCards(), 7 + loadBriefing() 8 + ]); 53 9 54 10 return { stats, cards, briefing }; 55 11 }
+8
web/src/routes/api/briefing.json/+server.ts
··· 1 + import { json, error } from '@sveltejs/kit'; 2 + import { loadBriefing } from '$lib/server/loaders'; 3 + 4 + export async function GET() { 5 + const briefing = await loadBriefing(); 6 + if (!briefing) throw error(404, 'no briefing available'); 7 + return json(briefing); 8 + }
+6
web/src/routes/api/cards.json/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import { loadCards } from '$lib/server/loaders'; 3 + 4 + export async function GET() { 5 + return json(await loadCards()); 6 + }
+6
web/src/routes/api/stats.json/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import { loadStats } from '$lib/server/loaders'; 3 + 4 + export async function GET() { 5 + return json(await loadStats()); 6 + }