feat: refresh a single repository from its own button (#2)

* feat(refresh): refresh a single repository with POST /refresh?repo=

The endpoint refreshes one configured repository and returns the full state plus the repo name.
Each repository has a 60-second cooldown that is separate from the full-refresh limit.
A running full refresh or a refresh of the same repository returns 409.
An unknown repository returns 404. Cross-site and disabled-config behaviour stay the same.
The collector reuses its fetch path and replaces only that repository.

* feat(ui): add a refresh button beside each repository

The button appears in the project list and in each attention group header.
It stays hidden when the server disables manual refresh.
The button shows a spinner while its repository refreshes and reports refusals in the notice line.
The store swaps in the returned state, so no extra fetch follows.
This commit is contained in:
2026-10-02 23:16:22 +01:00
committed by GitHub
parent 4c2c9d3341
commit 8cbc495dc7
16 changed files with 760 additions and 19 deletions
+58
View File
@@ -219,4 +219,62 @@ describe('tracker store', () => {
expect(store.error).toContain('HTTP 500')
expect(store.notice).toBe('')
})
describe('refreshRepo', () => {
const refreshed = (over = {}) => ({
...state({ repos: [repo({ refreshedAt: '2026-10-02T12:00:00Z', ...over })] }),
repo: 'o/r',
})
it('posts the repo, swaps the state in and announces it without another fetch', async () => {
const calls: string[] = []
mockFetch((url, init) => {
calls.push(`${init?.method ?? 'GET'} ${url}`)
return json(refreshed())
})
const store = useTrackerStore()
const done = store.refreshRepo('o/r')
expect(store.refreshingRepos).toEqual(['o/r'])
await done
expect(calls).toEqual(['POST /refresh?repo=o%2Fr'])
expect(store.refreshingRepos).toEqual([])
expect(store.state?.repos[0].refreshedAt).toBe('2026-10-02T12:00:00Z')
expect(store.announcement).toBe('Refreshed o/r')
expect(store.notice).toBe('')
})
it('ignores a second request for the same repo while one is pending', async () => {
const fetchMock = mockFetch(() => json(refreshed()))
const store = useTrackerStore()
await Promise.all([store.refreshRepo('o/r'), store.refreshRepo('o/r')])
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('shows the wait from Retry-After on 429', async () => {
mockFetch(() => new Response('{}', { status: 429, headers: { 'Retry-After': '42' } }))
const store = useTrackerStore()
await store.refreshRepo('o/r')
expect(store.notice).toBe('o/r was refreshed recently. Try again in 42s')
expect(store.refreshingRepos).toEqual([])
expect(store.error).toBe('')
})
it('says a scan is running on 409', async () => {
mockFetch(() => new Response('{}', { status: 409 }))
const store = useTrackerStore()
await store.refreshRepo('o/r')
expect(store.notice).toContain('already running')
})
it('reports other failures and a repo that came back down', async () => {
mockFetch(() => new Response('{}', { status: 500 }))
const store = useTrackerStore()
await store.refreshRepo('o/r')
expect(store.notice).toContain('HTTP 500')
mockFetch(() => json(refreshed({ up: false, error: 'boom' })))
await store.refreshRepo('o/r')
expect(store.notice).toBe('Could not refresh o/r: boom')
expect(store.announcement).toBe('')
})
})
})
+33 -5
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { fetchState, RescanLimitedError, requestRefresh } from '@/lib/api'
import { fetchState, RescanLimitedError, requestRefresh, requestRepoRefresh } from '@/lib/api'
import {
activeFilterCount,
applyFilters,
@@ -98,6 +98,9 @@ export const useTrackerStore = defineStore('tracker', () => {
const loading = ref(false)
const requesting = ref(false)
const notice = ref('')
/** Screen-reader text for a finished single-repository refresh. */
const announcement = ref('')
const refreshingRepos = ref<string[]>([])
const skewMs = ref(0)
const tick = ref(Date.now())
@@ -143,13 +146,16 @@ export const useTrackerStore = defineStore('tracker', () => {
tick.value = Date.now()
}
function applyState(next: TrackerState): void {
skewMs.value = Date.parse(next.now) - Date.now()
tick.value = Date.now()
state.value = next
}
async function load(): Promise<void> {
loading.value = true
try {
const next = await fetchState()
skewMs.value = Date.parse(next.now) - Date.now()
tick.value = Date.now()
state.value = next
applyState(await fetchState())
error.value = ''
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
@@ -180,11 +186,32 @@ export const useTrackerStore = defineStore('tracker', () => {
}
}
/** Refreshes one repository; the answer carries the new state, so no extra fetch follows. */
async function refreshRepo(fullName: string): Promise<void> {
if (refreshingRepos.value.includes(fullName)) return
refreshingRepos.value = [...refreshingRepos.value, fullName]
notice.value = ''
announcement.value = ''
try {
const next = await requestRepoRefresh(fullName)
applyState(next)
const failed = next.repos.find((r) => r.fullName === fullName)?.error
if (failed) notice.value = `Could not refresh ${fullName}: ${failed}`
else announcement.value = `Refreshed ${fullName}`
} catch (e) {
notice.value = e instanceof Error ? e.message : String(e)
} finally {
refreshingRepos.value = refreshingRepos.value.filter((n) => n !== fullName)
}
}
return {
state,
error,
loading,
notice,
announcement,
refreshingRepos,
scanning,
nowMs,
blocks,
@@ -201,6 +228,7 @@ export const useTrackerStore = defineStore('tracker', () => {
clearFilters,
load,
rescan,
refreshRepo,
advanceClock,
}
})