| 80fafdf | | | 1 | import type Database from "better-sqlite3"; |
| 80fafdf | | | 2 | import type { CanopyRunner } from "./canopy-runner.js"; |
| e5b523e | | | 3 | import type { PagesDeployer } from "./pages-deployer.js"; |
| 80fafdf | | | 4 | |
| 80fafdf | | | 5 | /** |
| 80fafdf | | | 6 | * Polls grove-bridge bookmarks to detect pushes. |
| 791afd4 | | | 7 | * Gets the repo list directly from grove-bridge (Mononoke), |
| 791afd4 | | | 8 | * compares bookmark positions to `bookmark_state` table, |
| 80fafdf | | | 9 | * and calls runner.onPush() when a bookmark advances. |
| 80fafdf | | | 10 | */ |
| 80fafdf | | | 11 | export class CanopyPoller { |
| 80fafdf | | | 12 | private interval: ReturnType<typeof setInterval> | null = null; |
| 80fafdf | | | 13 | private polling = false; |
| 80fafdf | | | 14 | |
| 80fafdf | | | 15 | constructor( |
| 80fafdf | | | 16 | private db: Database.Database, |
| 80fafdf | | | 17 | private bridgeUrl: string, |
| 80fafdf | | | 18 | private runner: CanopyRunner, |
| e5b523e | | | 19 | private pagesDeployer: PagesDeployer | null, |
| 80fafdf | | | 20 | private logger: { info: (...args: any[]) => void; error: (...args: any[]) => void } |
| 80fafdf | | | 21 | ) {} |
| 80fafdf | | | 22 | |
| 80fafdf | | | 23 | start(intervalMs: number): void { |
| 80fafdf | | | 24 | this.interval = setInterval(() => this.poll(), intervalMs); |
| 80fafdf | | | 25 | } |
| 80fafdf | | | 26 | |
| 80fafdf | | | 27 | stop(): void { |
| 80fafdf | | | 28 | if (this.interval) { |
| 80fafdf | | | 29 | clearInterval(this.interval); |
| 80fafdf | | | 30 | this.interval = null; |
| 80fafdf | | | 31 | } |
| 80fafdf | | | 32 | } |
| 80fafdf | | | 33 | |
| 80fafdf | | | 34 | private async poll(): Promise<void> { |
| 80fafdf | | | 35 | if (this.polling) return; |
| 80fafdf | | | 36 | this.polling = true; |
| 80fafdf | | | 37 | |
| 80fafdf | | | 38 | try { |
| 791afd4 | | | 39 | const res = await fetch(`${this.bridgeUrl}/repos`); |
| 791afd4 | | | 40 | if (!res.ok) return; |
| 791afd4 | | | 41 | const data = (await res.json()) as any; |
| 791afd4 | | | 42 | const repos: string[] = data.repos ?? []; |
| 80fafdf | | | 43 | |
| 80fafdf | | | 44 | for (const repo of repos) { |
| 791afd4 | | | 45 | await this.pollRepo(repo); |
| 80fafdf | | | 46 | } |
| 80fafdf | | | 47 | } catch (err) { |
| 80fafdf | | | 48 | this.logger.error({ err }, "Canopy poll error"); |
| 80fafdf | | | 49 | } finally { |
| 80fafdf | | | 50 | this.polling = false; |
| 80fafdf | | | 51 | } |
| 80fafdf | | | 52 | } |
| 80fafdf | | | 53 | |
| 80fafdf | | | 54 | private async pollRepo(repoName: string): Promise<void> { |
| 80fafdf | | | 55 | let bookmarks: Array<{ name: string; commit_id: string }>; |
| 80fafdf | | | 56 | try { |
| 80fafdf | | | 57 | const res = await fetch( |
| 80fafdf | | | 58 | `${this.bridgeUrl}/repos/${repoName}/bookmarks` |
| 80fafdf | | | 59 | ); |
| 80fafdf | | | 60 | if (!res.ok) return; |
| 80fafdf | | | 61 | const data = (await res.json()) as any; |
| 80fafdf | | | 62 | bookmarks = data.bookmarks ?? []; |
| 80fafdf | | | 63 | } catch { |
| 80fafdf | | | 64 | return; |
| 80fafdf | | | 65 | } |
| 80fafdf | | | 66 | |
| 80fafdf | | | 67 | const existingStates = this.db |
| 80fafdf | | | 68 | .prepare( |
| 80fafdf | | | 69 | `SELECT bookmark_name, commit_id FROM bookmark_state WHERE repo_name = ?` |
| 80fafdf | | | 70 | ) |
| 80fafdf | | | 71 | .all(repoName) as Array<{ bookmark_name: string; commit_id: string }>; |
| 80fafdf | | | 72 | |
| 80fafdf | | | 73 | const stateMap = new Map( |
| 80fafdf | | | 74 | existingStates.map((s) => [s.bookmark_name, s.commit_id]) |
| 80fafdf | | | 75 | ); |
| 80fafdf | | | 76 | |
| 80fafdf | | | 77 | // If no state exists yet, seed all bookmarks without triggering |
| 80fafdf | | | 78 | const isFirstSeed = existingStates.length === 0; |
| 80fafdf | | | 79 | |
| 80fafdf | | | 80 | const upsert = this.db.prepare( |
| 80fafdf | | | 81 | `INSERT INTO bookmark_state (repo_name, bookmark_name, commit_id, updated_at) |
| 80fafdf | | | 82 | VALUES (?, ?, ?, datetime('now')) |
| 80fafdf | | | 83 | ON CONFLICT(repo_name, bookmark_name) DO UPDATE SET |
| 80fafdf | | | 84 | commit_id = excluded.commit_id, |
| 80fafdf | | | 85 | updated_at = datetime('now')` |
| 80fafdf | | | 86 | ); |
| 80fafdf | | | 87 | |
| 80fafdf | | | 88 | for (const bookmark of bookmarks) { |
| 80fafdf | | | 89 | const oldCommitId = stateMap.get(bookmark.name); |
| 80fafdf | | | 90 | |
| 80fafdf | | | 91 | if (oldCommitId === bookmark.commit_id) continue; |
| 80fafdf | | | 92 | |
| c98936c | | | 93 | if (isFirstSeed) { |
| c98936c | | | 94 | upsert.run(repoName, bookmark.name, bookmark.commit_id); |
| c98936c | | | 95 | continue; |
| c98936c | | | 96 | } |
| 80fafdf | | | 97 | |
| c98936c | | | 98 | try { |
| 80fafdf | | | 99 | this.logger.info( |
| 80fafdf | | | 100 | { repo: repoName, branch: bookmark.name, commit: bookmark.commit_id }, |
| 80fafdf | | | 101 | "Bookmark changed, triggering pipeline" |
| 80fafdf | | | 102 | ); |
| c98936c | | | 103 | await this.runner.onPush({ |
| 80fafdf | | | 104 | repo: repoName, |
| 80fafdf | | | 105 | branch: bookmark.name, |
| 80fafdf | | | 106 | oldCommitId: oldCommitId ?? "", |
| 80fafdf | | | 107 | newCommitId: bookmark.commit_id, |
| 80fafdf | | | 108 | }); |
| e5b523e | | | 109 | // Deploy pages if applicable |
| e5b523e | | | 110 | if (this.pagesDeployer) { |
| e5b523e | | | 111 | const repo = this.db |
| b5baf6d | | | 112 | .prepare( |
| b5baf6d | | | 113 | `SELECT r.default_branch, rwo.owner_name FROM repos r |
| b5baf6d | | | 114 | JOIN repos_with_owner rwo ON rwo.id = r.id |
| b5baf6d | | | 115 | WHERE r.name = ? AND r.pages_enabled = 1` |
| b5baf6d | | | 116 | ) |
| e5b523e | | | 117 | .get(repoName) as any; |
| e5b523e | | | 118 | if (repo && bookmark.name === (repo.default_branch ?? "main")) { |
| b5baf6d | | | 119 | void this.pagesDeployer.deploy(repo.owner_name, repoName, bookmark.name).catch((err) => { |
| e5b523e | | | 120 | this.logger.error( |
| e5b523e | | | 121 | { err, repo: repoName, branch: bookmark.name }, |
| e5b523e | | | 122 | "Pages deployment failed" |
| e5b523e | | | 123 | ); |
| e5b523e | | | 124 | }); |
| e5b523e | | | 125 | } |
| e5b523e | | | 126 | } |
| e5b523e | | | 127 | |
| c98936c | | | 128 | // Only mark bookmark processed after runs are queued successfully. |
| c98936c | | | 129 | upsert.run(repoName, bookmark.name, bookmark.commit_id); |
| c98936c | | | 130 | } catch (err) { |
| c98936c | | | 131 | this.logger.error( |
| c98936c | | | 132 | { err, repo: repoName, branch: bookmark.name, commit: bookmark.commit_id }, |
| c98936c | | | 133 | "Failed to queue pipeline for bookmark change" |
| c98936c | | | 134 | ); |
| 80fafdf | | | 135 | } |
| 80fafdf | | | 136 | } |
| 80fafdf | | | 137 | } |
| 80fafdf | | | 138 | } |