mirror your GitHub repos to tangled.org automatically synchub.to
mirror sync tangled github git
92

Configure Feed

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

fix: reflect pending and failing pushes in dashboard repo health

Daniel Roe (Jul 10, 2026, 1:01 PM +0200) 87b6b726 060a55e6

+82 -18
+13 -6
app/pages/dashboard.vue
··· 183 183 // with a long repo list sees the problems without scrolling. 184 184 const HEALTH_ORDER: Record<string, number> = { 185 185 error: 0, 186 - enrolling: 1, 187 - info: 2, 188 - paused: 3, 189 - ok: 4, 186 + behind: 1, 187 + enrolling: 2, 188 + info: 3, 189 + paused: 4, 190 + ok: 5, 190 191 } 191 192 const sortedRepos = computed<DashboardRepo[]>(() => { 192 193 const list = data.value?.repos ?? [] ··· 361 362 <template v-if="data.summary.enrolling > 0">{{ data.summary.enrolling }} still setting up.</template> 362 363 </span> 363 364 </div> 364 - <div v-else-if="data.summary.enrolling > 0" class="summary" role="status"> 365 + <div v-else-if="data.summary.behind > 0 || data.summary.enrolling > 0" class="summary" role="status"> 365 366 <span aria-hidden="true">◐</span> 366 - <span>{{ data.summary.enrolling }} {{ data.summary.enrolling === 1 ? 'repository is' : 'repositories are' }} still setting up on tangled. This is normal right after installing.</span> 367 + <span> 368 + <template v-if="data.summary.behind > 0">{{ data.summary.behind }} {{ data.summary.behind === 1 ? 'repository is' : 'repositories are' }} catching up. </template> 369 + <template v-if="data.summary.enrolling > 0">{{ data.summary.enrolling }} still setting up on tangled. </template> 370 + This clears on its own. 371 + </span> 367 372 </div> 368 373 <div v-else-if="data.summary.total > 0" class="summary summary--ok" role="status"> 369 374 <span aria-hidden="true">●</span> ··· 849 854 850 855 .badge-ok { border-color: var(--color-ok); color: var(--color-ok); } 851 856 .badge-ok::before { content: "●"; } 857 + .badge-behind, 852 858 .badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); } 859 + .badge-behind::before, 853 860 .badge-enrolling::before { content: "◐"; } 854 861 .badge-info { border-color: var(--color-accent-dim); color: var(--color-accent-dim); } 855 862 .badge-info::before { content: "ℹ"; }
+30 -2
server/api/me/dashboard.get.ts
··· 1 1 import { sql } from 'drizzle-orm' 2 - import { installation, repoMapping, sshKey, userIdentity } from '#server/db/schema' 2 + import { installation, job, repoMapping, sshKey, userIdentity } from '#server/db/schema' 3 3 import { useDb } from '#server/utils/db' 4 4 import { type RepoHealth, repoHealth } from '#server/utils/repo-health' 5 5 import { requireSession } from '#server/utils/server-session' ··· 23 23 export interface DashboardSummary { 24 24 total: number 25 25 ok: number 26 + behind: number 26 27 enrolling: number 27 28 needsAttention: number 28 29 } ··· 50 51 const session = await requireSession(event) 51 52 const db = useDb() 52 53 53 - const [identityRow, installRows, repoRows, sshKeyRows] = await Promise.all([ 54 + const [identityRow, installRows, repoRows, sshKeyRows, jobRows] = await Promise.all([ 54 55 db.select({ did: userIdentity.did, handle: userIdentity.handle }) 55 56 .from(userIdentity) 56 57 .where(sql`${userIdentity.did} = ${session.did}`) ··· 75 76 .from(sshKey) 76 77 .where(sql`${sshKey.installationId} = ${session.installationId} AND ${sshKey.did} = ${session.did}`) 77 78 .limit(1), 79 + // Outstanding sync work per repo. A queued/running ref job means the mirror 80 + // is behind (a push hasn't landed yet); a failed one means it's stuck. The 81 + // `repo_mapping` row alone can't tell us this: `lastError` is only set on 82 + // terminal rejections, and a repo pending its first relayed push looks 83 + // identical to a fully-synced one. Keyed off the job payload's repo id. 84 + db.select({ 85 + // bigint and count(*) both arrive as strings over the wire; coerce below. 86 + githubRepoId: sql<string>`(${job.payload}->>'githubRepoId')::bigint`, 87 + pending: sql<string>`count(*) filter (where ${job.status} in ('queued', 'running'))`, 88 + failing: sql<string>`count(*) filter (where ${job.status} = 'failed')`, 89 + }) 90 + .from(job) 91 + .where(sql` 92 + (${job.payload}->>'installationId')::bigint = ${session.installationId} 93 + AND ${job.kind} in ('github.push', 'github.create', 'github.delete') 94 + AND ${job.status} in ('queued', 'running', 'failed') 95 + `) 96 + .groupBy(sql`(${job.payload}->>'githubRepoId')::bigint`), 78 97 ]) 79 98 80 99 const installRow = installRows[0] ?? null 81 100 const sshKeyRow = sshKeyRows[0] ?? null 101 + 102 + const jobStateByRepo = new Map<number, { pending: number, failing: number }>() 103 + for (const j of jobRows) { 104 + if (j.githubRepoId == null) continue 105 + jobStateByRepo.set(Number(j.githubRepoId), { pending: Number(j.pending), failing: Number(j.failing) }) 106 + } 82 107 83 108 const repos: DashboardRepo[] = repoRows.map(row => { 84 109 // Schema audit prefers no new column for "last push time": `updatedAt` ··· 108 133 lastError: row.lastError, 109 134 disabledAt, 110 135 tangledRepoDid: row.tangledRepoDid, 136 + pendingJobs: jobStateByRepo.get(row.githubRepoId)?.pending ?? 0, 137 + failingJobs: jobStateByRepo.get(row.githubRepoId)?.failing ?? 0, 111 138 }), 112 139 } 113 140 }) ··· 115 142 const summary: DashboardSummary = { 116 143 total: repos.length, 117 144 ok: repos.filter(r => r.health.state === 'ok').length, 145 + behind: repos.filter(r => r.health.state === 'behind').length, 118 146 enrolling: repos.filter(r => r.health.state === 'enrolling').length, 119 147 needsAttention: repos.filter(r => r.health.needsAttention).length, 120 148 }
+20 -10
server/utils/repo-health.ts
··· 10 10 */ 11 11 12 12 export type RepoHealthState = 13 - /** Mirrored on tangled (the knot holds the repo). Nothing to do. */ 13 + /** Mirrored on tangled and no outstanding sync work. Nothing to do. */ 14 14 | 'ok' 15 + /** Mirrored, but a push/ref update is still queued (catching up). */ 16 + | 'behind' 15 17 /** Enrolment hasn't produced a tangled mirror yet. */ 16 18 | 'enrolling' 17 19 /** User paused sync from the dashboard. */ ··· 34 36 lastError: string | null 35 37 disabledAt: string | null 36 38 tangledRepoDid: string | null 39 + /** Count of queued/running ref-sync jobs for this repo. */ 40 + pendingJobs: number 41 + /** Count of failed ref-sync jobs for this repo. */ 42 + failingJobs: number 37 43 } 38 44 39 45 /** ··· 85 91 return { state: 'info', message: input.lastError.slice('info:'.length).trim(), needsAttention: false } 86 92 } 87 93 88 - if (input.status === 'error') { 94 + // A stuck mapping, or a failed ref-sync job for an otherwise-active mapping: 95 + // either way sync won't recover on its own. 96 + if (input.status === 'error' || input.failingJobs > 0) { 89 97 const explained = input.lastError ? explainRepoError(input.lastError) : null 90 98 return { 91 99 state: 'error', 92 - message: explained ?? 'Sync is stuck. Try a resync; if it keeps failing, report it with the details below.', 100 + message: explained ?? 'A recent sync failed and needs attention. Try a resync; if it keeps failing, report it with the details below.', 93 101 needsAttention: true, 94 102 } 95 103 } 96 104 97 - // The knot clones the whole repo from `source` at enrolment, so a mapping 98 - // with a `tangledRepoDid` and `active` status is mirrored regardless of 99 - // whether we've relayed a push since. `refCount` counts only pushes we've 100 - // observed after enrolment, so it is NOT a completeness signal: a repo that 101 - // simply hasn't been pushed to since enrolment still has `refCount === 0` 102 - // while being fully up to date on the knot. 103 105 if (!input.tangledRepoDid || input.status !== 'active') { 104 106 return { state: 'enrolling', message: 'Setting up the mirror on tangled. This can take a minute.', needsAttention: false } 105 107 } 106 108 107 - return { state: 'ok', message: 'Mirrored and syncing.', needsAttention: false } 109 + // Mirrored, but a push hasn't landed on the knot yet. This is the honest 110 + // "not up to date *right now*" signal: the knot cloned the whole repo at 111 + // enrolment, so the mapping is otherwise complete, but an outstanding job 112 + // means a newer commit is still in flight. 113 + if (input.pendingJobs > 0) { 114 + return { state: 'behind', message: 'Catching up: a recent push is still syncing to tangled.', needsAttention: false } 115 + } 116 + 117 + return { state: 'ok', message: 'Mirrored and up to date.', needsAttention: false } 108 118 }
+19
test/unit/repo-health.spec.ts
··· 7 7 lastError: null, 8 8 disabledAt: null, 9 9 tangledRepoDid: 'did:plc:repo', 10 + pendingJobs: 0, 11 + failingJobs: 0, 10 12 ...overrides, 11 13 } 12 14 } ··· 25 27 // "waiting for the first sync". 26 28 const h = repoHealth(input({ tangledRepoDid: 'did:plc:repo' })) 27 29 expect(h.state).toBe('ok') 30 + }) 31 + 32 + it('reports behind when a push is still queued for a mirrored repo', () => { 33 + const h = repoHealth(input({ pendingJobs: 1 })) 34 + expect(h.state).toBe('behind') 35 + expect(h.needsAttention).toBe(false) 36 + }) 37 + 38 + it('flags a failing sync job as an error even when the mapping status is active', () => { 39 + const h = repoHealth(input({ failingJobs: 2 })) 40 + expect(h.state).toBe('error') 41 + expect(h.needsAttention).toBe(true) 42 + }) 43 + 44 + it('prefers the error state over behind when both failing and pending jobs exist', () => { 45 + const h = repoHealth(input({ pendingJobs: 3, failingJobs: 1 })) 46 + expect(h.state).toBe('error') 28 47 }) 29 48 30 49 it('reports enrolling before the tangled repo exists', () => {