add events query parameter to badge url (#5728)

Co-authored-by: damage <damage@devloop.de>
This commit is contained in:
tuxmainy
2025-11-27 10:35:29 +00:00
committed by GitHub
parent 88020fe1ca
commit 5a93de88dd
9 changed files with 122 additions and 24 deletions

View File

@@ -15,4 +15,10 @@ The status badge displays the status for the latest build to your default branch
+<scheme>://<hostname>/api/badges/<repo-id>/status.svg?branch=<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
-<scheme>://<hostname>/api/badges/<repo-id>/status.svg
+<scheme>://<hostname>/api/badges/<repo-id>/status.svg?events=manual,cron
```

View File

@@ -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")

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -174,7 +174,8 @@
"type_url": "URL",
"type_markdown": "Markdown",
"type_html": "HTML",
"branch": "Branch"
"branch": "Branch",
"events": "Events"
},
"actions": {
"actions": "Actions",

View File

@@ -3,8 +3,9 @@
<input
:id="`checkbox-${id}`"
type="checkbox"
class="checkbox border-wp-control-neutral-200 checked:border-wp-control-ok-200 checked:bg-wp-control-ok-200 focus-visible:border-wp-control-neutral-300 checked:focus-visible:border-wp-control-ok-300 relative h-5 w-5 shrink-0 cursor-pointer rounded-md border transition-colors duration-150"
class="checkbox border-wp-control-neutral-200 disabled:border-wp-control-neutral-200 disabled:bg-wp-control-neutral-300 checked:border-wp-control-ok-200 checked:bg-wp-control-ok-200 focus-visible:border-wp-control-neutral-300 checked:focus-visible:border-wp-control-ok-300 relative h-5 w-5 shrink-0 cursor-pointer rounded-md border transition-colors duration-150"
:checked="innerValue"
:disabled="disabled || false"
@click="innerValue = !innerValue"
/>
<div class="ml-4 flex flex-col">
@@ -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;
}

View File

@@ -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;

View File

@@ -30,6 +30,15 @@
<InputField v-slot="{ id }" :label="$t('repo.settings.badge.branch')">
<SelectField :id="id" v-model="branch" :options="branches" required />
</InputField>
<InputField v-slot="{ id }" :label="$t('repo.settings.badge.events')">
<CheckboxesField
:id="id"
v-model="events"
:options="badgeEventsOptions"
:disabled="isDisabled"
@update:model-value="eventsChanged"
/>
</InputField>
<div v-if="badgeContent" class="flex flex-col space-y-4">
<div>
@@ -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<SelectOption[]>([]);
const branch = ref<string>('');
const events = ref<string[]>([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 `<a href="${baseUrl}${repoUrl.value}" target="_blank">\n <img src="${baseUrl}${badgeUrl.value}" alt="status-badge" />\n</a>`;
return `<a href="${baseUrl}${repoUrl.value}" target="_blank">\n <img src="${baseUrl}${badgeUrl.value.replace('&', '&amp;')}" alt="status-badge" />\n</a>`;
}
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;
}
};
});
</script>