Fix actions skipped commit status indicator (#34507)

Addresses https://github.com/go-gitea/gitea/issues/34500

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
pull/34531/head^2
badhezi 2025-05-28 18:36:21 +03:00 committed by GitHub
parent c6e2093f42
commit 0cec4b84e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 113 additions and 250 deletions

View File

@ -230,18 +230,24 @@ func (status *CommitStatus) HideActionsURL(ctx context.Context) {
// CalcCommitStatus returns commit status state via some status, the commit statues should order by id desc // CalcCommitStatus returns commit status state via some status, the commit statues should order by id desc
func CalcCommitStatus(statuses []*CommitStatus) *CommitStatus { func CalcCommitStatus(statuses []*CommitStatus) *CommitStatus {
// This function is widely used, but it is not quite right.
// Ideally it should return something like "CommitStatusSummary" with properly aggregated state.
// GitHub's behavior: if all statuses are "skipped", GitHub will return "success" as the combined status.
var lastStatus *CommitStatus var lastStatus *CommitStatus
state := api.CommitStatusSuccess state := api.CommitStatusSuccess
for _, status := range statuses { for _, status := range statuses {
if status.State.NoBetterThan(state) { if state == status.State || status.State.HasHigherPriorityThan(state) {
state = status.State state = status.State
lastStatus = status lastStatus = status
} }
} }
if lastStatus == nil { if lastStatus == nil {
if len(statuses) > 0 { if len(statuses) > 0 {
// FIXME: a bad case: Gitea just returns the first commit status, its status is "skipped" in this case.
lastStatus = statuses[0] lastStatus = statuses[0]
} else { } else {
// FIXME: another bad case: if the "statuses" slice is empty, the returned value is an invalid CommitStatus, all its fields are empty.
// Frontend code (tmpl&vue) sometimes depend on the empty fields to skip rendering commit status elements (need to double check in the future)
lastStatus = &CommitStatus{} lastStatus = &CommitStatus{}
} }
} }

View File

@ -59,7 +59,11 @@ func UpdateCommitStatusSummary(ctx context.Context, repoID int64, sha string) er
if err != nil { if err != nil {
return err return err
} }
state := CalcCommitStatus(commitStatuses) // it guarantees that commitStatuses is not empty because this function is always called after a commit status is created
if len(commitStatuses) == 0 {
setting.PanicInDevOrTesting("no commit statuses found for repo %d and sha %s", repoID, sha)
}
state := CalcCommitStatus(commitStatuses) // non-empty commitStatuses is guaranteed
// mysql will return 0 when update a record which state hasn't been changed which behaviour is different from other database, // mysql will return 0 when update a record which state hasn't been changed which behaviour is different from other database,
// so we need to use insert in on duplicate // so we need to use insert in on duplicate
if setting.Database.Type.IsMySQL() { if setting.Database.Type.IsMySQL() {

View File

@ -18,6 +18,8 @@ const (
CommitStatusFailure CommitStatusState = "failure" CommitStatusFailure CommitStatusState = "failure"
// CommitStatusWarning is for when the CommitStatus is Warning // CommitStatusWarning is for when the CommitStatus is Warning
CommitStatusWarning CommitStatusState = "warning" CommitStatusWarning CommitStatusState = "warning"
// CommitStatusSkipped is for when CommitStatus is Skipped
CommitStatusSkipped CommitStatusState = "skipped"
) )
var commitStatusPriorities = map[CommitStatusState]int{ var commitStatusPriorities = map[CommitStatusState]int{
@ -26,25 +28,17 @@ var commitStatusPriorities = map[CommitStatusState]int{
CommitStatusWarning: 2, CommitStatusWarning: 2,
CommitStatusPending: 3, CommitStatusPending: 3,
CommitStatusSuccess: 4, CommitStatusSuccess: 4,
CommitStatusSkipped: 5,
} }
func (css CommitStatusState) String() string { func (css CommitStatusState) String() string {
return string(css) return string(css)
} }
// NoBetterThan returns true if this State is no better than the given State // HasHigherPriorityThan returns true if this state has higher priority than the other
// This function only handles the states defined in CommitStatusPriorities // Undefined states are considered to have the highest priority like CommitStatusError(0)
func (css CommitStatusState) NoBetterThan(css2 CommitStatusState) bool { func (css CommitStatusState) HasHigherPriorityThan(other CommitStatusState) bool {
// NoBetterThan only handles the 5 states above return commitStatusPriorities[css] < commitStatusPriorities[other]
if _, exist := commitStatusPriorities[css]; !exist {
return false
}
if _, exist := commitStatusPriorities[css2]; !exist {
return false
}
return commitStatusPriorities[css] <= commitStatusPriorities[css2]
} }
// IsPending represents if commit status state is pending // IsPending represents if commit status state is pending

View File

@ -10,165 +10,21 @@ import (
) )
func TestNoBetterThan(t *testing.T) { func TestNoBetterThan(t *testing.T) {
type args struct {
css CommitStatusState
css2 CommitStatusState
}
var unExpectedState CommitStatusState
tests := []struct { tests := []struct {
name string s1, s2 CommitStatusState
args args higher bool
want bool
}{ }{
{ {CommitStatusError, CommitStatusFailure, true},
name: "success is no better than success", {CommitStatusFailure, CommitStatusWarning, true},
args: args{ {CommitStatusWarning, CommitStatusPending, true},
css: CommitStatusSuccess, {CommitStatusPending, CommitStatusSuccess, true},
css2: CommitStatusSuccess, {CommitStatusSuccess, CommitStatusSkipped, true},
},
want: true, {CommitStatusError, "unknown-xxx", false},
}, {"unknown-xxx", CommitStatusFailure, true},
{
name: "success is no better than pending",
args: args{
css: CommitStatusSuccess,
css2: CommitStatusPending,
},
want: false,
},
{
name: "success is no better than failure",
args: args{
css: CommitStatusSuccess,
css2: CommitStatusFailure,
},
want: false,
},
{
name: "success is no better than error",
args: args{
css: CommitStatusSuccess,
css2: CommitStatusError,
},
want: false,
},
{
name: "pending is no better than success",
args: args{
css: CommitStatusPending,
css2: CommitStatusSuccess,
},
want: true,
},
{
name: "pending is no better than pending",
args: args{
css: CommitStatusPending,
css2: CommitStatusPending,
},
want: true,
},
{
name: "pending is no better than failure",
args: args{
css: CommitStatusPending,
css2: CommitStatusFailure,
},
want: false,
},
{
name: "pending is no better than error",
args: args{
css: CommitStatusPending,
css2: CommitStatusError,
},
want: false,
},
{
name: "failure is no better than success",
args: args{
css: CommitStatusFailure,
css2: CommitStatusSuccess,
},
want: true,
},
{
name: "failure is no better than pending",
args: args{
css: CommitStatusFailure,
css2: CommitStatusPending,
},
want: true,
},
{
name: "failure is no better than failure",
args: args{
css: CommitStatusFailure,
css2: CommitStatusFailure,
},
want: true,
},
{
name: "failure is no better than error",
args: args{
css: CommitStatusFailure,
css2: CommitStatusError,
},
want: false,
},
{
name: "error is no better than success",
args: args{
css: CommitStatusError,
css2: CommitStatusSuccess,
},
want: true,
},
{
name: "error is no better than pending",
args: args{
css: CommitStatusError,
css2: CommitStatusPending,
},
want: true,
},
{
name: "error is no better than failure",
args: args{
css: CommitStatusError,
css2: CommitStatusFailure,
},
want: true,
},
{
name: "error is no better than error",
args: args{
css: CommitStatusError,
css2: CommitStatusError,
},
want: true,
},
{
name: "unExpectedState is no better than success",
args: args{
css: unExpectedState,
css2: CommitStatusSuccess,
},
want: false,
},
{
name: "unExpectedState is no better than unExpectedState",
args: args{
css: unExpectedState,
css2: unExpectedState,
},
want: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.higher, tt.s1.HasHigherPriorityThan(tt.s2), "s1=%s, s2=%s, expected=%v", tt.s1, tt.s2, tt.higher)
result := tt.args.css.NoBetterThan(tt.args.css2)
assert.Equal(t, tt.want, result)
})
} }
assert.False(t, CommitStatusError.HasHigherPriorityThan(CommitStatusError))
} }

View File

@ -149,12 +149,14 @@ func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) er
func toCommitStatus(status actions_model.Status) api.CommitStatusState { func toCommitStatus(status actions_model.Status) api.CommitStatusState {
switch status { switch status {
case actions_model.StatusSuccess, actions_model.StatusSkipped: case actions_model.StatusSuccess:
return api.CommitStatusSuccess return api.CommitStatusSuccess
case actions_model.StatusFailure, actions_model.StatusCancelled: case actions_model.StatusFailure, actions_model.StatusCancelled:
return api.CommitStatusFailure return api.CommitStatusFailure
case actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusRunning: case actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusRunning:
return api.CommitStatusPending return api.CommitStatusPending
case actions_model.StatusSkipped:
return api.CommitStatusSkipped
default: default:
return api.CommitStatusError return api.CommitStatusError
} }

View File

@ -42,13 +42,14 @@ func ToCombinedStatus(ctx context.Context, statuses []*git_model.CommitStatus, r
SHA: statuses[0].SHA, SHA: statuses[0].SHA,
TotalCount: len(statuses), TotalCount: len(statuses),
Repository: repo, Repository: repo,
URL: "", URL: "", // never set or used?
State: api.CommitStatusSuccess,
} }
retStatus.Statuses = make([]*api.CommitStatus, 0, len(statuses)) retStatus.Statuses = make([]*api.CommitStatus, 0, len(statuses))
for _, status := range statuses { for _, status := range statuses {
retStatus.Statuses = append(retStatus.Statuses, ToCommitStatus(ctx, status)) retStatus.Statuses = append(retStatus.Statuses, ToCommitStatus(ctx, status))
if retStatus.State == "" || status.State.NoBetterThan(retStatus.State) { if status.State.HasHigherPriorityThan(retStatus.State) {
retStatus.State = status.State retStatus.State = status.State
} }
} }
@ -57,9 +58,13 @@ func ToCombinedStatus(ctx context.Context, statuses []*git_model.CommitStatus, r
// > failure if any of the contexts report as error or failure // > failure if any of the contexts report as error or failure
// > pending if there are no statuses or a context is pending // > pending if there are no statuses or a context is pending
// > success if the latest status for all contexts is success // > success if the latest status for all contexts is success
if retStatus.State.IsError() { switch retStatus.State {
retStatus.State = api.CommitStatusFailure case api.CommitStatusSkipped:
retStatus.State = api.CommitStatusSuccess // all skipped means success
case api.CommitStatusPending, api.CommitStatusSuccess:
// use the current state for pending or success
default:
retStatus.State = api.CommitStatusFailure // otherwise, it is a failure
} }
return retStatus return retStatus
} }

View File

@ -46,59 +46,33 @@ func MergeRequiredContextsCommitStatus(commitStatuses []*git_model.CommitStatus,
// If required rule not match any action, then it is pending // If required rule not match any action, then it is pending
if targetStatus == "" { if targetStatus == "" {
if structs.CommitStatusPending.NoBetterThan(returnedStatus) { if structs.CommitStatusPending.HasHigherPriorityThan(returnedStatus) {
returnedStatus = structs.CommitStatusPending returnedStatus = structs.CommitStatusPending
} }
break break
} }
if targetStatus.NoBetterThan(returnedStatus) { if targetStatus.HasHigherPriorityThan(returnedStatus) {
returnedStatus = targetStatus returnedStatus = targetStatus
} }
} }
} }
if matchedCount == 0 && returnedStatus == structs.CommitStatusSuccess { if matchedCount == 0 && returnedStatus == structs.CommitStatusSuccess {
status := git_model.CalcCommitStatus(commitStatuses) if len(commitStatuses) == 0 {
if status != nil { // "no statuses" should mean "pending"
return status.State return structs.CommitStatusPending
} }
return structs.CommitStatusSuccess status := git_model.CalcCommitStatus(commitStatuses)
if status.State == structs.CommitStatusSkipped {
return structs.CommitStatusSuccess // if all statuses are skipped, return success
}
return status.State
} }
return returnedStatus return returnedStatus
} }
// IsCommitStatusContextSuccess returns true if all required status check contexts succeed.
func IsCommitStatusContextSuccess(commitStatuses []*git_model.CommitStatus, requiredContexts []string) bool {
// If no specific context is required, require that last commit status is a success
if len(requiredContexts) == 0 {
status := git_model.CalcCommitStatus(commitStatuses)
if status == nil || status.State != structs.CommitStatusSuccess {
return false
}
return true
}
for _, ctx := range requiredContexts {
var found bool
for _, commitStatus := range commitStatuses {
if commitStatus.Context == ctx {
if commitStatus.State != structs.CommitStatusSuccess {
return false
}
found = true
break
}
}
if !found {
return false
}
}
return true
}
// IsPullCommitStatusPass returns if all required status checks PASS // IsPullCommitStatusPass returns if all required status checks PASS
func IsPullCommitStatusPass(ctx context.Context, pr *issues_model.PullRequest) (bool, error) { func IsPullCommitStatusPass(ctx context.Context, pr *issues_model.PullRequest) (bool, error) {
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch) pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)

View File

@ -14,52 +14,70 @@ import (
) )
func TestMergeRequiredContextsCommitStatus(t *testing.T) { func TestMergeRequiredContextsCommitStatus(t *testing.T) {
testCases := [][]*git_model.CommitStatus{ cases := []struct {
commitStatuses []*git_model.CommitStatus
requiredContexts []string
expected structs.CommitStatusState
}{
{ {
{Context: "Build 1", State: structs.CommitStatusSuccess}, commitStatuses: []*git_model.CommitStatus{},
{Context: "Build 2", State: structs.CommitStatusSuccess}, requiredContexts: []string{},
{Context: "Build 3", State: structs.CommitStatusSuccess}, expected: structs.CommitStatusPending,
}, },
{ {
{Context: "Build 1", State: structs.CommitStatusSuccess}, commitStatuses: []*git_model.CommitStatus{
{Context: "Build 2", State: structs.CommitStatusSuccess}, {Context: "Build xxx", State: structs.CommitStatusSkipped},
{Context: "Build 2t", State: structs.CommitStatusPending}, },
requiredContexts: []string{"Build*"},
expected: structs.CommitStatusSuccess,
}, },
{ {
{Context: "Build 1", State: structs.CommitStatusSuccess}, commitStatuses: []*git_model.CommitStatus{
{Context: "Build 2", State: structs.CommitStatusSuccess}, {Context: "Build 1", State: structs.CommitStatusSkipped},
{Context: "Build 2t", State: structs.CommitStatusFailure}, {Context: "Build 2", State: structs.CommitStatusSuccess},
{Context: "Build 3", State: structs.CommitStatusSuccess},
},
requiredContexts: []string{"Build*"},
expected: structs.CommitStatusSuccess,
}, },
{ {
{Context: "Build 1", State: structs.CommitStatusSuccess}, commitStatuses: []*git_model.CommitStatus{
{Context: "Build 2", State: structs.CommitStatusSuccess}, {Context: "Build 1", State: structs.CommitStatusSuccess},
{Context: "Build 2t", State: structs.CommitStatusSuccess}, {Context: "Build 2", State: structs.CommitStatusSuccess},
{Context: "Build 2t", State: structs.CommitStatusPending},
},
requiredContexts: []string{"Build*", "Build 2t*"},
expected: structs.CommitStatusPending,
}, },
{ {
{Context: "Build 1", State: structs.CommitStatusSuccess}, commitStatuses: []*git_model.CommitStatus{
{Context: "Build 2", State: structs.CommitStatusSuccess}, {Context: "Build 1", State: structs.CommitStatusSuccess},
{Context: "Build 2t", State: structs.CommitStatusSuccess}, {Context: "Build 2", State: structs.CommitStatusSuccess},
{Context: "Build 2t", State: structs.CommitStatusFailure},
},
requiredContexts: []string{"Build*", "Build 2t*"},
expected: structs.CommitStatusFailure,
},
{
commitStatuses: []*git_model.CommitStatus{
{Context: "Build 1", State: structs.CommitStatusSuccess},
{Context: "Build 2", State: structs.CommitStatusSuccess},
{Context: "Build 2t", State: structs.CommitStatusSuccess},
},
requiredContexts: []string{"Build*", "Build 2t*", "Build 3*"},
expected: structs.CommitStatusPending,
},
{
commitStatuses: []*git_model.CommitStatus{
{Context: "Build 1", State: structs.CommitStatusSuccess},
{Context: "Build 2", State: structs.CommitStatusSuccess},
{Context: "Build 2t", State: structs.CommitStatusSuccess},
},
requiredContexts: []string{"Build*", "Build *", "Build 2t*", "Build 1*"},
expected: structs.CommitStatusSuccess,
}, },
} }
testCasesRequiredContexts := [][]string{ for i, c := range cases {
{"Build*"}, assert.Equal(t, c.expected, MergeRequiredContextsCommitStatus(c.commitStatuses, c.requiredContexts), "case %d", i)
{"Build*", "Build 2t*"},
{"Build*", "Build 2t*"},
{"Build*", "Build 2t*", "Build 3*"},
{"Build*", "Build *", "Build 2t*", "Build 1*"},
}
testCasesExpected := []structs.CommitStatusState{
structs.CommitStatusSuccess,
structs.CommitStatusPending,
structs.CommitStatusFailure,
structs.CommitStatusPending,
structs.CommitStatusSuccess,
}
for i, commitStatuses := range testCases {
if MergeRequiredContextsCommitStatus(commitStatuses, testCasesRequiredContexts[i]) != testCasesExpected[i] {
assert.Fail(t, "Test case failed", "Test case %d failed", i+1)
}
} }
} }

View File

@ -14,3 +14,6 @@
{{if eq .State "warning"}} {{if eq .State "warning"}}
{{svg "gitea-exclamation" 18 "commit-status icon text yellow"}} {{svg "gitea-exclamation" 18 "commit-status icon text yellow"}}
{{end}} {{end}}
{{if eq .State "skipped"}}
{{svg "octicon-skip" 18 "commit-status icon text grey"}}
{{end}}

View File

@ -6,7 +6,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl, assetUrlPrefix, pageData} = window.config; const {appSubUrl, assetUrlPrefix, pageData} = window.config;
type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning'; type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped';
type CommitStatusMap = { type CommitStatusMap = {
[status in CommitStatus]: { [status in CommitStatus]: {
@ -22,6 +22,7 @@ const commitStatus: CommitStatusMap = {
error: {name: 'gitea-exclamation', color: 'red'}, error: {name: 'gitea-exclamation', color: 'red'},
failure: {name: 'octicon-x', color: 'red'}, failure: {name: 'octicon-x', color: 'red'},
warning: {name: 'gitea-exclamation', color: 'yellow'}, warning: {name: 'gitea-exclamation', color: 'yellow'},
skipped: {name: 'octicon-skip', color: 'grey'},
}; };
export default defineComponent({ export default defineComponent({