mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-15 01:07:40 +00:00
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:
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user