From decc4b0103c3eafcd584e5309f361fddac19e86d Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Thu, 1 Oct 2026 15:39:15 +0200 Subject: [PATCH 1/5] feat: list each waiting pull request once The waiting to be merged list reads the new open actions endpoint and shows one row per pull request, led by the worst result of the pipelines feeding it. Home shows the 5 most recent, the dashboard and the reports page page through every one matching their filter. --- src/components/PullRequestList.spec.js | 269 ++++++++++++++ src/components/PullRequestList.vue | 470 +++++++++++++++++++++++++ src/components/TodayQueue.spec.js | 42 ++- src/components/TodayQueue.vue | 139 +++----- src/components/pipeline/reports.vue | 109 ------ src/components/scm/_summary.vue | 25 +- src/composables/filter.js | 31 ++ src/composables/filter.spec.js | 23 ++ src/composables/url.js | 16 + src/views/Dashboard.vue | 32 ++ src/views/pipeline/ReportsView.vue | 30 ++ 11 files changed, 948 insertions(+), 238 deletions(-) create mode 100644 src/components/PullRequestList.spec.js create mode 100644 src/components/PullRequestList.vue create mode 100644 src/composables/url.js diff --git a/src/components/PullRequestList.spec.js b/src/components/PullRequestList.spec.js new file mode 100644 index 000000000..64c510991 --- /dev/null +++ b/src/components/PullRequestList.spec.js @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount, RouterLinkStub } from '@vue/test-utils' +import { VPagination } from 'vuetify/components' + +const api = vi.hoisted(() => ({ apiFetch: vi.fn() })) + +vi.mock('@/composables/api', async (importOriginal) => ({ + ...(await importOriginal()), + apiFetch: api.apiFetch, +})) + +function action(n, overrides = {}) { + return { + url: `https://github.com/updatecli/udash/pull/${n}`, + title: `Bump dependency ${n}`, + repository: 'https://github.com/updatecli/udash.git', + branch: 'main', + updated_at: '2026-09-22T11:57:00Z', + pipelines: [{ id: `p${n}`, name: `Pipeline ${n}`, result: '✔', updated_at: '2026-09-22T11:57:00Z' }], + ...overrides, + } +} + +function answer(response) { + api.apiFetch.mockImplementation(async () => { + if (response instanceof Error) throw response + return response + }) +} + +async function mountList(props = {}) { + vi.resetModules() + const { default: PullRequestList } = await import('@/components/PullRequestList.vue') + const wrapper = mount(PullRequestList, { props, global: { stubs: { RouterLink: RouterLinkStub } } }) + await flushPromises() + return wrapper +} + +function lastBody() { + return api.apiFetch.mock.calls.at(-1)[1].body +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setInterval', 'Date'] }) + vi.setSystemTime(new Date('2026-09-22T12:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + api.apiFetch.mockReset() +}) + +describe('PullRequestList', () => { + it('asks for the open pull requests matching the filter', async () => { + answer({ data: [], total_count: 0 }) + await mountList({ + limit: 10, + filter: { + startTime: '2026-09-01T00:00:00Z', + endTime: '2026-09-02T00:00:00Z', + labels: { team: 'infra' }, + results: ['✗'], + openAction: true, + }, + }) + + expect(api.apiFetch).toHaveBeenCalledWith('/pipeline/actions/search', { + method: 'POST', + body: { + start_time: '2026-09-01T00:00:00Z', + end_time: '2026-09-02T00:00:00Z', + labels: { team: 'infra' }, + results: ['✗'], + limit: 10, + page: 1, + }, + }) + }) + + it('narrows the pull requests to the repository branch the filter picked', async () => { + answer({ data: [], total_count: 0 }) + await mountList({ filter: { scmid: 'scm-1', results: ['✗'] } }) + + expect(lastBody()).toEqual({ results: ['✗'], scmid: 'scm-1', limit: 5, page: 1 }) + }) + + it('lists a pull request with its repository, age and pipeline', async () => { + answer({ data: [action(7)], total_count: 1 }) + const wrapper = await mountList() + + const item = wrapper.get('.pull-requests__item') + const link = item.get('a[target="_blank"]') + expect(link.attributes('href')).toBe('https://github.com/updatecli/udash/pull/7') + expect(link.attributes('rel')).toBe('noopener noreferrer') + expect(item.text()).toContain('Bump dependency 7') + expect(item.text()).toContain('updatecli/udash · main') + expect(item.text()).toContain('3 minutes ago') + expect(item.findComponent(RouterLinkStub).props('to')).toBe('/pipeline/reports/p7') + expect(item.find('button').exists()).toBe(false) + expect(wrapper.text()).toContain('1 pull request') + }) + + it('leads with the worst result and tallies the pipelines of a pull request fed by several', async () => { + answer({ + data: [action(7, { + pipelines: [ + { id: 'a', name: 'First manifest', result: '✔' }, + { id: 'b', name: 'Second manifest', result: '✗' }, + { id: 'c', name: 'Third manifest', result: '✔' }, + ], + })], + total_count: 1, + }) + const wrapper = await mountList() + + expect(wrapper.get('.pull-requests__icon').classes()).toContain('text-error') + + const toggle = wrapper.get('.pull-requests__toggle') + expect(toggle.text()).toContain('✗ 1') + expect(toggle.text()).toContain('✔ 2') + expect(toggle.text()).toContain('3 pipelines') + expect(toggle.attributes('aria-label')).toBe('3 pipelines feeding Bump dependency 7: 1 failed, 2 success') + expect(toggle.attributes('aria-expanded')).toBe('false') + + // The list it controls always exists, hidden until expanded. + const list = wrapper.get(`#${toggle.attributes('aria-controls')}`) + expect(list.attributes('style')).toContain('display: none') + + await toggle.trigger('click') + + expect(toggle.attributes('aria-expanded')).toBe('true') + expect(list.attributes('style') || '').not.toContain('display: none') + // The failing pipeline comes first, and each result is spelled out for screen readers. + const links = wrapper.findAllComponents(RouterLinkStub) + expect(links.map((link) => link.props('to'))).toEqual(['/pipeline/reports/b', '/pipeline/reports/a', '/pipeline/reports/c']) + expect(list.findAll('li')[0].text()).toContain('Failed:') + }) + + it('dims the pipelines the result filter left out', async () => { + answer({ + data: [action(7, { + pipelines: [ + { id: 'a', name: 'Changed', result: '⚠' }, + { id: 'b', name: 'Failing', result: '✗' }, + ], + })], + total_count: 1, + }) + const wrapper = await mountList({ filter: { results: ['✗'] } }) + + const items = wrapper.findAll('.pull-requests__pipelines li') + expect(items[0].classes()).not.toContain('pull-requests__pipeline--unmatched') + expect(items[1].classes()).toContain('pull-requests__pipeline--unmatched') + }) + + it('falls back to the link, as an identifier, when the pull request has no title', async () => { + answer({ data: [action(7, { title: '', url: 'https://gitlab.com/group/project/-/merge_requests/7' })], total_count: 1 }) + const wrapper = await mountList() + + const title = wrapper.get('.pull-requests__title') + expect(title.text()).toContain('gitlab.com/group/project/-/merge_requests/7') + expect(title.classes()).toContain('text-mono') + expect(title.text()).toContain('opens the merge request in a new tab') + }) + + it('drops a link that is not http or https', async () => { + answer({ data: [action(7, { url: 'javascript:alert(1)' }), action(8)], total_count: 2 }) + const wrapper = await mountList() + + const hrefs = wrapper.findAll('a[target="_blank"]').map((link) => link.attributes('href')) + expect(hrefs).toEqual(['https://github.com/updatecli/udash/pull/8']) + }) + + it('pages through every pull request', async () => { + answer({ data: [action(1), action(2)], total_count: 30 }) + const wrapper = await mountList({ paginated: true, limit: 10 }) + + expect(lastBody()).toMatchObject({ limit: 10, page: 1 }) + const pagination = wrapper.getComponent(VPagination) + expect(pagination.props('length')).toBe(3) + + pagination.vm.$emit('update:modelValue', 2) + await flushPromises() + expect(lastBody()).toMatchObject({ limit: 10, page: 2 }) + expect(wrapper.get('[aria-live="polite"]').text()).toBe('Page 2 of 3, pull requests 11 to 12 of 30') + }) + + it('shows only the first page when it is not paginated', async () => { + answer({ data: [action(1)], total_count: 30 }) + const wrapper = await mountList({ limit: 5 }) + + expect(lastBody()).toMatchObject({ limit: 5, page: 1 }) + expect(wrapper.findComponent(VPagination).exists()).toBe(false) + }) + + it('does not offer pages when everything fits on one', async () => { + answer({ data: [action(1)], total_count: 10 }) + const wrapper = await mountList({ paginated: true, limit: 10 }) + + expect(wrapper.findComponent(VPagination).exists()).toBe(false) + }) + + it('goes back to the first page when the filter changes', async () => { + answer({ data: [action(1)], total_count: 30 }) + const wrapper = await mountList({ paginated: true, limit: 10, filter: {} }) + + wrapper.getComponent(VPagination).vm.$emit('update:modelValue', 3) + await flushPromises() + api.apiFetch.mockClear() + + await wrapper.setProps({ filter: { results: ['⚠'] } }) + await flushPromises() + + expect(api.apiFetch).toHaveBeenCalledOnce() + expect(lastBody()).toEqual({ results: ['⚠'], limit: 10, page: 1 }) + }) + + it('moves back to the last page when a refresh leaves the current one past it', async () => { + answer({ data: [action(1)], total_count: 30 }) + const wrapper = await mountList({ paginated: true, limit: 10 }) + wrapper.getComponent(VPagination).vm.$emit('update:modelValue', 3) + await flushPromises() + + // Pull requests got merged: 12 are left, so page 3 of 10 no longer exists. + api.apiFetch.mockImplementation(async (path, { body }) => ( + body.page === 3 ? { data: [], total_count: 12 } : { data: [action(11), action(12)], total_count: 12 } + )) + await vi.advanceTimersByTimeAsync(60 * 1000) + await flushPromises() + + expect(lastBody()).toMatchObject({ page: 2 }) + expect(wrapper.text()).toContain('Bump dependency 12') + }) + + it('qualifies the count when a filter narrows it', async () => { + answer({ data: [action(1)], total_count: 3 }) + const wrapper = await mountList({ countQualifier: 'in this period' }) + + expect(wrapper.get('.pull-requests__count').text()).toBe('3 pull requests in this period') + }) + + it('links the count when it is given somewhere to go', async () => { + answer({ data: [action(1)], total_count: 12 }) + const wrapper = await mountList({ seeAllLink: '/scm/dashboard' }) + + const link = wrapper.findAllComponents(RouterLinkStub).find((stub) => stub.props('to') === '/scm/dashboard') + expect(link.text()).toBe('12 pull requests · see all') + }) + + it('says so when no pull request is waiting', async () => { + answer({ data: [], total_count: 0 }) + const wrapper = await mountList() + + expect(wrapper.text()).toContain('No pull request is waiting.') + }) + + it('shows the error and retries', async () => { + answer(Object.assign(new Error('boom'), { status: 503 })) + const wrapper = await mountList() + + expect(wrapper.get('[role="alert"]').text()).toContain('The Udash API is unavailable right now.') + + answer({ data: [action(1)], total_count: 1 }) + await wrapper.get('[role="alert"] button').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Bump dependency 1') + }) +}) diff --git a/src/components/PullRequestList.vue b/src/components/PullRequestList.vue new file mode 100644 index 000000000..8f2ef278d --- /dev/null +++ b/src/components/PullRequestList.vue @@ -0,0 +1,470 @@ + + + + + diff --git a/src/components/TodayQueue.spec.js b/src/components/TodayQueue.spec.js index f892ea049..03a5b7f0f 100644 --- a/src/components/TodayQueue.spec.js +++ b/src/components/TodayQueue.spec.js @@ -23,10 +23,10 @@ function report(id, overrides = {}) { } } -// Answers the failing queue and the waiting queue from what each request asks for. +// Answers the failing queue and the waiting queue from the endpoint each one asks. function answer({ failing, waiting }) { - api.apiFetch.mockImplementation(async (path, { body }) => { - const result = body.open_action ? waiting : failing + api.apiFetch.mockImplementation(async (path) => { + const result = path === '/pipeline/actions/search' ? waiting : failing if (result instanceof Error) { throw result } @@ -57,14 +57,14 @@ afterEach(() => { }) describe('TodayQueue', () => { - it('asks for the latest failing reports and the latest ones with an open pull request', async () => { + it('asks for the latest failing reports and the open pull requests', async () => { answer({ failing: { data: [], total_count: 0 }, waiting: { data: [], total_count: 0 } }) await mountQueue() const bodies = api.apiFetch.mock.calls.map(([path, { method, body }]) => ({ path, method, body })) expect(bodies).toEqual([ { path: '/pipeline/reports/search', method: 'POST', body: { limit: 5, page: 1, latest: true, results: ['✗'] } }, - { path: '/pipeline/reports/search', method: 'POST', body: { limit: 5, page: 1, latest: true, open_action: true } }, + { path: '/pipeline/actions/search', method: 'POST', body: { limit: 5, page: 1 } }, ]) }) @@ -92,18 +92,32 @@ describe('TodayQueue', () => { }) }) - it('offers the pull request of a waiting pipeline', async () => { - const waiting = report('w', { - Result: '✔', - Report: { Targets: {}, Actions: { a1: { actionUrl: 'https://github.com/updatecli/udash/pull/7' } } }, - }) + it('lists each pull request once, linked to the dashboard', async () => { + const waiting = { + url: 'https://github.com/updatecli/udash/pull/7', + title: 'Bump udash', + repository: 'https://github.com/updatecli/udash.git', + branch: 'main', + updated_at: '2026-09-22T11:57:00Z', + pipelines: [ + { id: 'p1', name: 'First manifest', result: '✔', updated_at: '2026-09-22T11:57:00Z' }, + { id: 'p2', name: 'Second manifest', result: '⚠', updated_at: '2026-09-22T11:50:00Z' }, + ], + } answer({ failing: { data: [], total_count: 0 }, waiting: { data: [waiting], total_count: 1 } }) const wrapper = await mountQueue() - const button = column(wrapper, 'waiting').get('a[target="_blank"]') - expect(button.attributes('href')).toBe('https://github.com/updatecli/udash/pull/7') - expect(button.attributes('aria-label')).toBe('Open the pull request for Bump w (opens in a new tab)') - expect(column(wrapper, 'waiting').text()).toContain('1 pull request · see all') + const items = column(wrapper, 'waiting').findAll('.pull-requests__item') + expect(items).toHaveLength(1) + expect(items[0].get('a[target="_blank"]').attributes('href')).toBe('https://github.com/updatecli/udash/pull/7') + expect(items[0].text()).toContain('2 pipelines') + + const link = column(wrapper, 'waiting').findComponent(RouterLinkStub) + expect(link.text()).toBe('1 pull request · see all') + expect(decodeFilterState(link.props('to').query.filter)).toEqual({ + dateRange: [0, 53], + selectedOpenAction: 'open', + }) }) it('says so when a queue is empty', async () => { diff --git a/src/components/TodayQueue.vue b/src/components/TodayQueue.vue index 36edaf2d6..8fb216fbf 100644 --- a/src/components/TodayQueue.vue +++ b/src/components/TodayQueue.vue @@ -1,46 +1,40 @@ diff --git a/src/views/pipeline/ReportsView.vue b/src/views/pipeline/ReportsView.vue index 1a19931a5..e4dbe8db6 100644 --- a/src/views/pipeline/ReportsView.vue +++ b/src/views/pipeline/ReportsView.vue @@ -53,6 +53,28 @@ /> + + + + + + +

+ Pull requests are hidden while the filter keeps only pipelines without one. +

+ + From 4b33252c9e45494fe5aabda91f102fe401dfad7c Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Fri, 2 Oct 2026 17:05:29 +0200 Subject: [PATCH 2/5] fix: keep the pager on the page shown when another fails to load A page the reader asks for only becomes the current one once it arrived. When it fails, the pager stays on the rows shown and says the page could not be loaded, until the reader asks for a page again or changes the filter; a background refresh no longer clears it. A refresh that lands while a page is pending loads that page too. Pull requests whose link is not http(s) are now listed as plain text instead of being dropped, so the list agrees with the count. --- src/components/PullRequestList.spec.js | 82 +++++++++++++++++++++++++- src/components/PullRequestList.vue | 70 ++++++++++++++++------ 2 files changed, 132 insertions(+), 20 deletions(-) diff --git a/src/components/PullRequestList.spec.js b/src/components/PullRequestList.spec.js index 64c510991..edbdfca3f 100644 --- a/src/components/PullRequestList.spec.js +++ b/src/components/PullRequestList.spec.js @@ -163,12 +163,55 @@ describe('PullRequestList', () => { expect(title.text()).toContain('opens the merge request in a new tab') }) - it('drops a link that is not http or https', async () => { - answer({ data: [action(7, { url: 'javascript:alert(1)' }), action(8)], total_count: 2 }) + it('lists a pull request whose link is not http or https as plain text', async () => { + answer({ data: [action(7, { url: 'javascript:alert(1)', title: 'Unsafe' }), action(8)], total_count: 2 }) const wrapper = await mountList() + // Both are counted and listed, only the safe one is a link. + expect(wrapper.findAll('.pull-requests__item')).toHaveLength(2) const hrefs = wrapper.findAll('a[target="_blank"]').map((link) => link.attributes('href')) expect(hrefs).toEqual(['https://github.com/updatecli/udash/pull/8']) + const unsafe = wrapper.findAll('.pull-requests__title')[0] + expect(unsafe.element.tagName).toBe('SPAN') + expect(unsafe.text()).toBe('Unsafe') + }) + + it('keeps the pager on the rows shown when the page asked for fails to load', async () => { + answer({ data: [action(1)], total_count: 30 }) + const wrapper = await mountList({ paginated: true, limit: 10 }) + + api.apiFetch.mockImplementation(async () => { throw Object.assign(new Error('boom'), { status: 503 }) }) + wrapper.getComponent(VPagination).vm.$emit('update:modelValue', 3) + await flushPromises() + + expect(wrapper.getComponent(VPagination).props('modelValue')).toBe(1) + expect(wrapper.text()).toContain('Bump dependency 1') + expect(wrapper.get('.pull-requests__page-error').text()).toBe('Page 3 could not be loaded. The Udash API is unavailable right now. Try again in a moment.') + + // A later refresh still reads the page on screen, not the one that failed, and keeps + // saying the page asked for could not be loaded. + answer({ data: [action(1)], total_count: 30 }) + await vi.advanceTimersByTimeAsync(60 * 1000) + await flushPromises() + expect(lastBody()).toMatchObject({ page: 1 }) + expect(wrapper.find('.pull-requests__page-error').exists()).toBe(true) + + // Asking for a page again replaces it. + answer({ data: [action(11)], total_count: 30 }) + wrapper.getComponent(VPagination).vm.$emit('update:modelValue', 2) + await flushPromises() + expect(wrapper.find('.pull-requests__page-error').exists()).toBe(false) + }) + + it('drops the count of the previous filter while the new one loads', async () => { + answer({ data: [action(1)], total_count: 12 }) + const wrapper = await mountList({ filter: {}, countQualifier: 'in this period' }) + expect(wrapper.get('.pull-requests__count').text()).toBe('12 pull requests in this period') + + api.apiFetch.mockImplementation(() => new Promise(() => {})) + await wrapper.setProps({ filter: { results: ['✗'] } }) + + expect(wrapper.find('.pull-requests__count').exists()).toBe(false) }) it('pages through every pull request', async () => { @@ -232,6 +275,41 @@ describe('PullRequestList', () => { expect(wrapper.text()).toContain('Bump dependency 12') }) + it('keeps the page asked for when a refresh comes before it arrived', async () => { + answer({ data: [action(1)], total_count: 30 }) + const wrapper = await mountList({ paginated: true, limit: 10 }) + + api.apiFetch.mockImplementation(() => new Promise(() => {})) + wrapper.getComponent(VPagination).vm.$emit('update:modelValue', 3) + await flushPromises() + + answer({ data: [action(21)], total_count: 30 }) + await vi.advanceTimersByTimeAsync(60 * 1000) + await flushPromises() + + expect(lastBody()).toMatchObject({ page: 3 }) + expect(wrapper.getComponent(VPagination).props('modelValue')).toBe(3) + expect(wrapper.text()).toContain('Bump dependency 21') + expect(wrapper.get('[aria-live="polite"]').text()).toBe('Page 3 of 3, pull requests 21 to 21 of 30') + }) + + it('says nothing when a refresh fails to move back to the last page', async () => { + answer({ data: [action(1)], total_count: 30 }) + const wrapper = await mountList({ paginated: true, limit: 10 }) + wrapper.getComponent(VPagination).vm.$emit('update:modelValue', 3) + await flushPromises() + + api.apiFetch.mockImplementation(async (path, { body }) => { + if (body.page === 3) return { data: [], total_count: 12 } + throw Object.assign(new Error('boom'), { status: 503 }) + }) + await vi.advanceTimersByTimeAsync(60 * 1000) + await flushPromises() + + expect(lastBody()).toMatchObject({ page: 2 }) + expect(wrapper.find('.pull-requests__page-error').exists()).toBe(false) + }) + it('qualifies the count when a filter narrows it', async () => { answer({ data: [action(1)], total_count: 3 }) const wrapper = await mountList({ countQualifier: 'in this period' }) diff --git a/src/components/PullRequestList.vue b/src/components/PullRequestList.vue index 8f2ef278d..1d2efd42a 100644 --- a/src/components/PullRequestList.vue +++ b/src/components/PullRequestList.vue @@ -30,7 +30,7 @@

    -
  • +
  • (opens the {{ row.noun }} in a new tab) + + {{ row.title }}
    {{ row.repository }} {{ toRelativeTime(row.updatedAt, now) }} @@ -58,10 +66,10 @@ v-if="row.pipelines.length > 1" type="button" class="pull-requests__toggle text-body-small" - :aria-expanded="String(!!expanded[row.url])" + :aria-expanded="String(!!expanded[row.key])" :aria-controls="pipelinesId(index)" :aria-label="`${row.pipelines.length} pipelines feeding ${row.title}: ${row.tallyText}`" - @click="expanded[row.url] = !expanded[row.url]" + @click="expanded[row.key] = !expanded[row.key]" >
      +

      + {{ pageError }} +

      +

      {{ announcement }}

      @@ -164,6 +176,7 @@ const error = ref(null) const loading = ref(false) const page = ref(1) const announcement = ref('') +const pageError = ref('') const expanded = reactive({}) const pageCount = computed(() => Math.ceil(total.value / props.limit)) @@ -174,6 +187,9 @@ const countLabel = computed(() => [ ].filter(Boolean).join(' ')) let generation = 0 +// The page the reader asked for, until its request settled. A refresh meanwhile loads +// it too, so it does not drop the page the reader is waiting on. +let requested = null function severity(result) { const rank = SEVERITY.indexOf(result) @@ -205,9 +221,11 @@ function toRow(action) { .map((result) => ({ result, count: counts[result], textClass: TALLY_CLASSES[result] })) return { + key: action.url, + // url is empty when the link is not http(s), which the row then shows as plain text. url, hasTitle: Boolean(action.title), - title: action.title || url.replace(/^https?:\/\//, ''), + title: action.title || String(action.url || '').replace(/^https?:\/\//i, ''), noun: extractGitURLInfo(url)?.provider === 'gitlab' ? 'merge request' : 'pull request', repository: shortRepository(action.repository), branch: action.branch || '', @@ -219,10 +237,15 @@ function toRow(action) { } } -// load fetches the current page. announce says whether to tell screen readers what -// the page now holds, which only a change asked for by the reader warrants. -async function load({ announce = false } = {}) { +// load fetches a page, the one the reader asked for or else the current one. The page +// only becomes the current one once it arrived, so a page that fails to load leaves the +// pager on the rows shown. announce says whether the reader asked for that page, which +// warrants telling screen readers what it holds, or that it could not be loaded. +async function load({ target = requested ?? page.value, announce = requested !== null } = {}) { error.value = null + // Only a page the reader asks for replaces the message: a background refresh, even one + // that succeeds, still leaves the pager short of the page that could not be loaded. + if (announce) pageError.value = '' loading.value = true // A slow response must not overwrite the one from a later refresh. const current = ++generation @@ -234,21 +257,21 @@ async function load({ announce = false } = {}) { ...filterRequestBody(props.filter), ...(props.filter?.scmid ? { scmid: props.filter.scmid } : {}), limit: props.limit, - page: page.value, + page: target, }, }) if (current !== generation) return total.value = data.total_count || 0 - // A refresh can leave the current page past the last one, once pull requests got merged. - if ((data.data || []).length === 0 && total.value > 0 && page.value > pageCount.value) { - page.value = pageCount.value - load() + // A refresh can leave the page past the last one, once pull requests got merged. + if ((data.data || []).length === 0 && total.value > 0 && target > pageCount.value) { + load({ target: pageCount.value, announce }) return } - // A link that is not http(s) is not offered at all. - rows.value = (data.data || []).map(toRow).filter((row) => row.url) + requested = null + page.value = target + rows.value = (data.data || []).map(toRow) markUpdated() if (announce) { @@ -258,9 +281,13 @@ async function load({ announce = false } = {}) { } } catch (loadError) { if (current !== generation) return - // A background refresh that fails keeps what is already on screen. + requested = null + // A background refresh that fails keeps what is already on screen. A page the reader + // asked for says it could not be loaded, next to the pager still on the rows shown. if (rows.value === null) { error.value = describeLoadError(loadError, 'the pull requests') + } else if (announce) { + pageError.value = `Page ${target} could not be loaded. ${describeLoadError(loadError, 'it')}` } } finally { if (current === generation) loading.value = false @@ -269,13 +296,16 @@ async function load({ announce = false } = {}) { // goTo moves to a page the reader asked for, so what it now holds is announced. function goTo(nextPage) { - page.value = nextPage - load({ announce: true }) + requested = nextPage + load() } watch(() => props.filter, () => { rows.value = null + total.value = 0 page.value = 1 + requested = null + pageError.value = '' load() }, { deep: true }) @@ -463,6 +493,10 @@ a.pull-requests__count:focus-visible { margin-top: 8px; } +.pull-requests__page-error { + margin: 4px 0 0; +} + .pull-requests__pagination :deep(.v-pagination__list) { justify-content: flex-start; } From ef12574438f35d67c444205863230be3e81de670 Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Fri, 2 Oct 2026 17:07:37 +0200 Subject: [PATCH 3/5] chore: limit to 5 pr at a time Signed-off-by: Olivier Vernin --- src/views/Dashboard.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index 5e76f5c40..128f2d2ef 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -35,7 +35,7 @@ From c89680666b204c439ceeac60fbf1d60bc99d4589 Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Fri, 2 Oct 2026 21:57:08 +0200 Subject: [PATCH 4/5] chore: move comment inside if Signed-off-by: Olivier Vernin --- src/components/pipeline/activityChart.vue | 24 ++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/pipeline/activityChart.vue b/src/components/pipeline/activityChart.vue index e94427c6e..dd6a5c20c 100644 --- a/src/components/pipeline/activityChart.vue +++ b/src/components/pipeline/activityChart.vue @@ -1,18 +1,18 @@