From 5a93de88ddd70f5bb1e26f85eca11070980834b7 Mon Sep 17 00:00:00 2001 From: tuxmainy Date: Thu, 27 Nov 2025 10:35:29 +0000 Subject: [PATCH] add events query parameter to badge url (#5728) Co-authored-by: damage --- docs/docs/20-usage/80-badges.md | 8 ++- server/api/badge.go | 23 +++++++- server/store/datastore/pipeline.go | 5 +- server/store/mocks/mock_Store.go | 30 ++++++---- server/store/store.go | 2 +- web/src/assets/locales/en.json | 3 +- web/src/components/form/Checkbox.vue | 8 ++- web/src/components/form/CheckboxesField.vue | 2 + web/src/views/repo/settings/Badge.vue | 65 +++++++++++++++++++-- 9 files changed, 122 insertions(+), 24 deletions(-) diff --git a/docs/docs/20-usage/80-badges.md b/docs/docs/20-usage/80-badges.md index 1bee62a58..44ea89460 100644 --- a/docs/docs/20-usage/80-badges.md +++ b/docs/docs/20-usage/80-badges.md @@ -15,4 +15,10 @@ The status badge displays the status for the latest build to your default branch +:///api/badges//status.svg?branch= ``` -Please note status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state. +By default status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state. +If you'd like to respect other or further events, you can add the `events` query parameter, otherwise the badge represents only the state of the last push event: + +```diff +-:///api/badges//status.svg ++:///api/badges//status.svg?events=manual,cron +``` diff --git a/server/api/badge.go b/server/api/badge.go index 74378a7b5..ef5a59091 100644 --- a/server/api/badge.go +++ b/server/api/badge.go @@ -21,6 +21,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" @@ -72,7 +73,27 @@ func GetBadge(c *gin.Context) { branch = repo.Branch } - pipeline, err := _store.GetPipelineBadge(repo, branch) + // Events to lookup, multiple separated by comma + var events []model.WebhookEvent + eventsQuery := c.Query("events") + // If none given, fallback to default "push" + if len(eventsQuery) == 0 { + events = []model.WebhookEvent{model.EventPush} + } else { + strEvents := strings.Split(eventsQuery, ",") + events = make([]model.WebhookEvent, len(strEvents)) + for i, strEvent := range strEvents { + event := model.WebhookEvent(strEvent) + if err := event.Validate(); err == nil { + events[i] = event + } else { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + } + } + + pipeline, err := _store.GetPipelineBadge(repo, branch, events) if err != nil { if !errors.Is(err, types.RecordNotExist) { log.Warn().Err(err).Msg("could not get last pipeline for badge") diff --git a/server/store/datastore/pipeline.go b/server/store/datastore/pipeline.go index f2c1530d3..602ccc78e 100644 --- a/server/store/datastore/pipeline.go +++ b/server/store/datastore/pipeline.go @@ -35,11 +35,12 @@ func (s storage) GetPipelineNumber(repo *model.Repo, num int64) (*model.Pipeline ).Get(pipeline)) } -func (s storage) GetPipelineBadge(repo *model.Repo, branch string) (*model.Pipeline, error) { +func (s storage) GetPipelineBadge(repo *model.Repo, branch string, events []model.WebhookEvent) (*model.Pipeline, error) { pipeline := new(model.Pipeline) return pipeline, wrapGet(s.engine. Desc("number"). - Where(builder.Eq{"repo_id": repo.ID, "branch": branch, "event": model.EventPush}). + Where(builder.Eq{"repo_id": repo.ID, "branch": branch}). + Where(builder.In("event", events)). Where(builder.Neq{"status": model.StatusBlocked}). Get(pipeline)) } diff --git a/server/store/mocks/mock_Store.go b/server/store/mocks/mock_Store.go index 31c73f748..856e5e369 100644 --- a/server/store/mocks/mock_Store.go +++ b/server/store/mocks/mock_Store.go @@ -1822,8 +1822,8 @@ func (_c *MockStore_GetPipeline_Call) RunAndReturn(run func(n int64) (*model.Pip } // GetPipelineBadge provides a mock function for the type MockStore -func (_mock *MockStore) GetPipelineBadge(repo *model.Repo, s string) (*model.Pipeline, error) { - ret := _mock.Called(repo, s) +func (_mock *MockStore) GetPipelineBadge(repo *model.Repo, s string, webhookEvents []model.WebhookEvent) (*model.Pipeline, error) { + ret := _mock.Called(repo, s, webhookEvents) if len(ret) == 0 { panic("no return value specified for GetPipelineBadge") @@ -1831,18 +1831,18 @@ func (_mock *MockStore) GetPipelineBadge(repo *model.Repo, s string) (*model.Pip var r0 *model.Pipeline var r1 error - if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Pipeline, error)); ok { - return returnFunc(repo, s) + if returnFunc, ok := ret.Get(0).(func(*model.Repo, string, []model.WebhookEvent) (*model.Pipeline, error)); ok { + return returnFunc(repo, s, webhookEvents) } - if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Pipeline); ok { - r0 = returnFunc(repo, s) + if returnFunc, ok := ret.Get(0).(func(*model.Repo, string, []model.WebhookEvent) *model.Pipeline); ok { + r0 = returnFunc(repo, s, webhookEvents) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Pipeline) } } - if returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok { - r1 = returnFunc(repo, s) + if returnFunc, ok := ret.Get(1).(func(*model.Repo, string, []model.WebhookEvent) error); ok { + r1 = returnFunc(repo, s, webhookEvents) } else { r1 = ret.Error(1) } @@ -1857,11 +1857,12 @@ type MockStore_GetPipelineBadge_Call struct { // GetPipelineBadge is a helper method to define mock.On call // - repo *model.Repo // - s string -func (_e *MockStore_Expecter) GetPipelineBadge(repo interface{}, s interface{}) *MockStore_GetPipelineBadge_Call { - return &MockStore_GetPipelineBadge_Call{Call: _e.mock.On("GetPipelineBadge", repo, s)} +// - webhookEvents []model.WebhookEvent +func (_e *MockStore_Expecter) GetPipelineBadge(repo interface{}, s interface{}, webhookEvents interface{}) *MockStore_GetPipelineBadge_Call { + return &MockStore_GetPipelineBadge_Call{Call: _e.mock.On("GetPipelineBadge", repo, s, webhookEvents)} } -func (_c *MockStore_GetPipelineBadge_Call) Run(run func(repo *model.Repo, s string)) *MockStore_GetPipelineBadge_Call { +func (_c *MockStore_GetPipelineBadge_Call) Run(run func(repo *model.Repo, s string, webhookEvents []model.WebhookEvent)) *MockStore_GetPipelineBadge_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { @@ -1871,9 +1872,14 @@ func (_c *MockStore_GetPipelineBadge_Call) Run(run func(repo *model.Repo, s stri if args[1] != nil { arg1 = args[1].(string) } + var arg2 []model.WebhookEvent + if args[2] != nil { + arg2 = args[2].([]model.WebhookEvent) + } run( arg0, arg1, + arg2, ) }) return _c @@ -1884,7 +1890,7 @@ func (_c *MockStore_GetPipelineBadge_Call) Return(pipeline *model.Pipeline, err return _c } -func (_c *MockStore_GetPipelineBadge_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Pipeline, error)) *MockStore_GetPipelineBadge_Call { +func (_c *MockStore_GetPipelineBadge_Call) RunAndReturn(run func(repo *model.Repo, s string, webhookEvents []model.WebhookEvent) (*model.Pipeline, error)) *MockStore_GetPipelineBadge_Call { _c.Call.Return(run) return _c } diff --git a/server/store/store.go b/server/store/store.go index 68212e3b6..ab034041f 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -71,7 +71,7 @@ type Store interface { // GetPipelineNumber gets a pipeline by number. GetPipelineNumber(*model.Repo, int64) (*model.Pipeline, error) // GetPipelineBadge gets the last relevant pipeline for the badge. - GetPipelineBadge(*model.Repo, string) (*model.Pipeline, error) + GetPipelineBadge(*model.Repo, string, []model.WebhookEvent) (*model.Pipeline, error) // GetPipelineLast gets the last pipeline for the branch. GetPipelineLast(*model.Repo, string) (*model.Pipeline, error) // GetPipelineLastBefore gets the last pipeline before pipeline number N. diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index 358b72741..65e8738d0 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -174,7 +174,8 @@ "type_url": "URL", "type_markdown": "Markdown", "type_html": "HTML", - "branch": "Branch" + "branch": "Branch", + "events": "Events" }, "actions": { "actions": "Actions", diff --git a/web/src/components/form/Checkbox.vue b/web/src/components/form/Checkbox.vue index a6490bc6c..05b5b5d83 100644 --- a/web/src/components/form/Checkbox.vue +++ b/web/src/components/form/Checkbox.vue @@ -3,8 +3,9 @@
@@ -21,6 +22,7 @@ const props = defineProps<{ modelValue: boolean; label: string; description?: string; + disabled?: boolean; }>(); const emit = defineEmits<{ @@ -65,6 +67,10 @@ const id = (Math.random() + 1).toString(36).substring(7); opacity: 0; } +.checkbox:disabled::before { + border-color: var(--wp-text-alt-100); +} + .checkbox:checked::before { opacity: 1; } diff --git a/web/src/components/form/CheckboxesField.vue b/web/src/components/form/CheckboxesField.vue index 775feb572..f80dc11f2 100644 --- a/web/src/components/form/CheckboxesField.vue +++ b/web/src/components/form/CheckboxesField.vue @@ -4,6 +4,7 @@ :key="option.value" :model-value="innerValue.includes(option.value)" :label="option.text" + :disabled="disabled ? disabled(option) : false" :description="option.description" class="mb-2" @update:model-value="clickOption(option)" @@ -19,6 +20,7 @@ import type { CheckboxOption } from './form.types'; const props = defineProps<{ modelValue?: CheckboxOption['value'][]; options?: CheckboxOption[]; + disabled?: (option: CheckboxOption) => boolean; }>(); const emit = defineEmits<{ (event: 'update:modelValue', value: CheckboxOption['value'][]): void; diff --git a/web/src/views/repo/settings/Badge.vue b/web/src/views/repo/settings/Badge.vue index fc83d3772..2a6ff0899 100644 --- a/web/src/views/repo/settings/Badge.vue +++ b/web/src/views/repo/settings/Badge.vue @@ -30,6 +30,15 @@ + + +
@@ -44,7 +53,8 @@ import { useStorage } from '@vueuse/core'; import { computed, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; -import type { SelectOption } from '~/components/form/form.types'; +import CheckboxesField from '~/components/form/CheckboxesField.vue'; +import type { CheckboxOption, SelectOption } from '~/components/form/form.types'; import InputField from '~/components/form/InputField.vue'; import SelectField from '~/components/form/SelectField.vue'; import Settings from '~/components/layout/Settings.vue'; @@ -53,6 +63,7 @@ import useConfig from '~/compositions/useConfig'; import { requiredInject } from '~/compositions/useInjectProvide'; import { usePaginate } from '~/compositions/usePaginate'; import { useWPTitle } from '~/compositions/useWPTitle'; +import { WebhookEvents } from '~/lib/api/types'; const apiClient = useApiClient(); const repo = requiredInject('repo'); @@ -62,6 +73,7 @@ const badgeType = useStorage('woodpecker:last-badge-type', 'markdown'); const defaultBranch = computed(() => repo.value.default_branch); const branches = ref([]); const branch = ref(''); +const events = ref([WebhookEvents.Push]); async function loadBranches() { branches.value = (await usePaginate((page) => apiClient.getRepoBranches(repo.value.id, { page }))) @@ -80,9 +92,22 @@ const baseUrl = `${window.location.protocol}//${window.location.hostname}${ window.location.port ? `:${window.location.port}` : '' }`; const { rootPath } = useConfig(); -const badgeUrl = computed( - () => `${rootPath}/api/badges/${repo.value.id}/status.svg${branch.value !== '' ? `?branch=${branch.value}` : ''}`, -); +const badgeUrl = computed(() => { + const params = []; + + if (branch.value !== '') { + params.push(`branch=${encodeURIComponent(branch.value)}`); + } + + if (events.value.length > 0) { + // dont set events parameters, if only WebhookEvents.Push is selected, as this is the default behaviour + if (events.value.length !== 1 || events.value.at(0) !== WebhookEvents.Push) { + params.push(`events=${encodeURIComponent(events.value.join(','))}`); + } + } + + return `${rootPath}/api/badges/${repo.value.id}/status.svg${params.length > 0 ? `?${params.join('&')}` : ''}`; +}); const repoUrl = computed( () => `${rootPath}/repos/${repo.value.id}${branch.value !== '' ? `/branches/${encodeURIComponent(branch.value)}` : ''}`, @@ -98,7 +123,7 @@ const badgeContent = computed(() => { } if (badgeType.value === 'html') { - return `\n status-badge\n`; + return `\n status-badge\n`; } return ''; @@ -113,5 +138,35 @@ watch(repo, () => { }); const { t } = useI18n(); + +const badgeEventsOptions: CheckboxOption[] = [ + { value: WebhookEvents.Push, text: t('repo.pipeline.event.push'), description: t('default') }, + { value: WebhookEvents.Tag, text: t('repo.pipeline.event.tag') }, + { value: WebhookEvents.Release, text: t('repo.pipeline.event.release') }, + { value: WebhookEvents.PullRequest, text: t('repo.pipeline.event.pr') }, + { value: WebhookEvents.PullRequestClosed, text: t('repo.pipeline.event.pr_closed') }, + { value: WebhookEvents.PullRequestMetadata, text: t('repo.pipeline.event.pr_metadata') }, + { value: WebhookEvents.Deploy, text: t('repo.pipeline.event.deploy') }, + { value: WebhookEvents.Cron, text: t('repo.pipeline.event.cron') }, + { value: WebhookEvents.Manual, text: t('repo.pipeline.event.manual') }, +]; + useWPTitle(computed(() => [t('repo.settings.badge.badge'), repo.value.full_name])); + +function eventsChanged() { + if (events.value.length === 0) { + events.value.push(WebhookEvents.Push); + } +} + +const isDisabled = computed(() => { + return (option: CheckboxOption) => { + if (events.value.length === 1 && events.value[0] === WebhookEvents.Push) { + // disable Push checkbox if only Push is selected because it's the default + return option.value === WebhookEvents.Push; + } else { + return false; + } + }; +});