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
+48
View File
@@ -89,4 +89,52 @@ describe('App', () => {
await flushPromises()
expect(off.find('[data-test=rescan]').exists()).toBe(false)
})
it('refreshes one repo from its button, shows the new state and announces it', async () => {
const first = state({
manualRefresh: true,
repos: [repo({ refreshedAt: '2026-10-02T10:00:00Z' })],
})
const after = { ...first, repo: 'o/r', repos: [repo({ refreshedAt: '2026-10-02T12:00:00Z' })] }
const fetchMock = vi.fn((_url: string, init?: RequestInit) =>
Promise.resolve(
new Response(JSON.stringify(init?.method === 'POST' ? after : first), { status: 200 }),
),
)
vi.stubGlobal('fetch', fetchMock)
const w = mount(App, { global: { plugins: [createPinia()] } })
await flushPromises()
await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith('/refresh?repo=o%2Fr', { method: 'POST' })
expect(w.get('[data-test=announcement]').text()).toBe('Refreshed o/r')
})
it('shows the refusal message when a repo is refreshed too soon', async () => {
const body = state({ manualRefresh: true, repos: [repo()] })
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init?: RequestInit) =>
Promise.resolve(
init?.method === 'POST'
? new Response('{}', { status: 429, headers: { 'Retry-After': '30' } })
: new Response(JSON.stringify(body), { status: 200 }),
),
),
)
const w = mount(App, { global: { plugins: [createPinia()] } })
await flushPromises()
await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click')
await flushPromises()
expect(w.get('[data-test=notice]').text()).toBe('o/r was refreshed recently. Try again in 30s')
expect(
w.get('[data-test=project] [data-test=repo-refresh]').attributes('disabled'),
).toBeUndefined()
})
it('hides the repo buttons when the server disables manual refresh', async () => {
const w = mountWith(state({ manualRefresh: false, repos: [repo()] }))
await flushPromises()
expect(w.find('[data-test=repo-refresh]').exists()).toBe(false)
})
})