Merge 5576b10604c39c2323521edf36160e4885c84bd8 into 21af8150b7ba315a9f75264ab77813b0b7c697a8

This commit is contained in:
zam. 2025-02-20 10:55:20 +08:00 committed by GitHub
commit f8da60f218
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 517 additions and 2 deletions

View File

@ -435,3 +435,56 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error {
}
type ActionRunIndex db.ResourceIndex
// DeleteActionRunAndChild delete action_task_step, action_task_output, action_task, action_run and action_run_job.
func DeleteActionRunAndChild(ctx context.Context, runIDs, jobIDs, taskIDs []int64) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
_, err = db.GetEngine(ctx).In("task_id", taskIDs).
Delete(ActionTaskStep{})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).In("task_id", taskIDs).
Delete(ActionTaskOutput{})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).In("id", taskIDs).Delete(ActionTask{})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).In("id", jobIDs).Delete(ActionRunJob{})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).In("id", runIDs).Delete(ActionRun{})
if err != nil {
return err
}
return committer.Commit()
}
// GetRunsByIDsAndTriggerUserID -- get all action run by trigger user with selected ids
func GetRunsByIDsAndTriggerUserID(ctx context.Context, ids []int64, triggerUserID int64) ([]*ActionRun, error) {
var runs []*ActionRun
err := db.GetEngine(ctx).Where("trigger_user_id=?", triggerUserID).
In("id", ids).Find(&runs)
if err != nil {
return nil, err
}
if len(runs) < 1 {
return nil, fmt.Errorf("run with ids %d: %w", ids, util.ErrNotExist)
}
return runs, nil
}

View File

@ -184,3 +184,19 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status {
return StatusUnknown // it shouldn't happen
}
}
func GetRunJobsByRunIDs(ctx context.Context, runIDs []int64) ([]*ActionRunJob, error) {
var jobs []*ActionRunJob
if err := db.GetEngine(ctx).In("run_id", runIDs).Find(&jobs); err != nil {
return nil, err
}
return jobs, nil
}
func GetRunTasksByJobIDs(ctx context.Context, jobIDs []int64) ([]*ActionTask, error) {
var tasks []*ActionTask
if err := db.GetEngine(ctx).In("job_id", jobIDs).Find(&tasks); err != nil {
return nil, err
}
return tasks, nil
}

View File

@ -7,6 +7,8 @@ import (
"bytes"
stdCtx "context"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
@ -18,6 +20,7 @@ import (
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
@ -28,6 +31,7 @@ import (
"code.gitea.io/gitea/services/convert"
"github.com/nektos/act/pkg/model"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v3"
)
@ -312,6 +316,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []Workflow) {
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0
ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin)
}
// loadIsRefDeleted loads the IsRefDeleted field for each run in the list.
@ -424,3 +429,94 @@ func decodeNode(node yaml.Node, out any) bool {
}
return true
}
func DeleteRuns(ctx *context.Context) {
rd := ctx.Req.Body
defer rd.Close()
req := DeleteRunsRequest{}
if err := json.NewDecoder(rd).Decode(&req); err != nil {
ctx.ServerError("failed to decode request body into delte runs request", err)
return
}
if len(req.ActionIDs) < 1 {
ctx.ServerError("missing action_run.id for delete action run", nil)
return
}
var (
eg = new(errgroup.Group)
actionRuns []*actions_model.ActionRun
jobIDs, taskIDs []int64
taskLogFileNames []string
)
eg.Go(func() error {
var err error
actionRuns, err = actions_model.GetRunsByIDsAndTriggerUserID(ctx, req.ActionIDs, ctx.Doer.ID)
return err
})
eg.Go(func() error {
actionRunJobs, err := actions_model.GetRunJobsByRunIDs(ctx, req.ActionIDs)
if err != nil {
return err
}
for _, actionRunJob := range actionRunJobs {
jobIDs = append(jobIDs, actionRunJob.ID)
}
actionTasks, err := actions_model.GetRunTasksByJobIDs(ctx, jobIDs)
if err != nil {
return err
}
for _, actionTask := range actionTasks {
taskIDs = append(taskIDs, actionTask.ID)
taskLogFileNames = append(taskLogFileNames, actionTask.LogFilename)
}
return nil
})
err := eg.Wait()
if err != nil {
ctx.ServerError("failed to get action runs and action run jobs", err)
return
}
if len(actionRuns) != len(req.ActionIDs) {
ctx.ServerError("action ids not match with request", nil)
return
}
err = actions_model.DeleteActionRunAndChild(ctx, req.ActionIDs, jobIDs, taskIDs)
if err != nil {
ctx.ServerError("failed to delete action_run", err)
return
}
removeActionTaskLogFilenames(taskLogFileNames)
ctx.Status(http.StatusNoContent)
}
type DeleteRunsRequest struct {
ActionIDs []int64 `json:"actionIds"`
}
func removeActionTaskLogFilenames(taskLogFileNames []string) {
dirNameActionLog := "actions_log"
go func() {
for _, taskLogFileName := range taskLogFileNames {
var fileName string
if filepath.IsAbs(setting.AppDataPath) {
fileName = filepath.Join(setting.AppDataPath, dirNameActionLog, taskLogFileName)
} else {
fileName = filepath.Join(setting.AppWorkPath, setting.AppDataPath, dirNameActionLog, taskLogFileName)
}
if err := os.Remove(fileName); err != nil {
log.Error("failed to remove actions_log file %s: %v", fileName, err)
}
}
}()
}

View File

@ -1447,6 +1447,8 @@ func registerRoutes(m *web.Router) {
m.Group("/workflows/{workflow_name}", func() {
m.Get("/badge.svg", actions.GetWorkflowBadge)
})
m.Post("/runs/delete", reqRepoActionsWriter, actions.DeleteRuns)
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
// end "/{username}/{reponame}/actions"

View File

@ -25,7 +25,19 @@
</div>
</div>
<div class="twelve wide column content">
<div class="ui secondary filter menu tw-justify-end tw-flex tw-items-center">
<div id="action-filter" class="ui secondary filter menu tw-flex tw-items-center tw-justify-between">
<!-- Checkbox -->
<div class="action-list-toolbar-left">
<input type="checkbox" autocomplete="off" class="action-checkbox-all tw-mr-4 tw-ml-4" title="{{ctx.Locale.Tr "repo.issues.action_check_all"}}">
</div>
<div class="tw-flex tw-items-center">
{{if $.IsRepoAdmin}}
<div id="action-delete" class="ui jump item tw-hidden">
<button class="ui red button action-action" data-action="delete" data-url="{{$.RepoLink}}/actions/runs/delete" data-action-delete-confirm="{{ctx.Locale.Tr "confirm_delete_selected"}}">
{{ctx.Locale.Tr "repo.issues.delete"}}
</button>
</div>
{{end}}
<!-- Actor -->
<div class="ui{{if not .Actors}} disabled{{end}} dropdown jump item">
<span class="text">{{ctx.Locale.Tr "actions.runs.actor"}}</span>
@ -63,6 +75,7 @@
</a>
{{end}}
</div>
</div>
</div>
{{if .AllowDisableOrEnableWorkflow}}

View File

@ -1,4 +1,4 @@
<div class="flex-list run-list">
<div id="action-actions" class="flex-list run-list">
{{if not .Runs}}
<div class="empty-placeholder">
{{svg "octicon-no-entry" 48}}
@ -7,6 +7,7 @@
{{end}}
{{range .Runs}}
<div class="flex-item tw-items-center">
<input type="checkbox" autocomplete="off" class="action-checkbox tw-mr-4 tw-ml-4" data-action-id={{.ID}} aria-label="{{ctx.Locale.Tr "repo.issues.action_check"}} &quot;{{.Title}}&quot;"{{if or (eq .Status 6) (eq .Status 5)}}disabled{{end}}>
<div class="flex-item-leading">
{{template "repo/actions/status" (dict "status" .Status.String)}}
</div>

View File

@ -0,0 +1,235 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/tests"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
)
func TestRepoActionDelete(t *testing.T) {
defer tests.PrepareTestEnv(t)()
testCases := []struct {
treePath string
fileContent string
outcomes map[string]*mockTaskOutcome
expectedTaskNeeds map[string]*runnerv1.TaskNeed // jobID => TaskNeed
}{
{
treePath: ".gitea/workflows/jobs-outputs-with-matrix.yml",
fileContent: `name: jobs-outputs-with-matrix
on:
push:
paths:
- '.gitea/workflows/jobs-outputs-with-matrix.yml'
jobs:
job1:
runs-on: ubuntu-latest
outputs:
output_1: ${{ steps.gen_output.outputs.output_1 }}
output_2: ${{ steps.gen_output.outputs.output_2 }}
output_3: ${{ steps.gen_output.outputs.output_3 }}
strategy:
matrix:
version: [1, 2, 3]
steps:
- name: Generate output
id: gen_output
run: |
version="${{ matrix.version }}"
echo "output_${version}=${version}" >> "$GITHUB_OUTPUT"
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo '${{ toJSON(needs.job1.outputs) }}'
`,
outcomes: map[string]*mockTaskOutcome{
"job1 (1)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "1",
"output_2": "",
"output_3": "",
},
},
"job1 (2)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "",
"output_2": "2",
"output_3": "",
},
},
"job1 (3)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "",
"output_2": "",
"output_3": "3",
},
},
},
expectedTaskNeeds: map[string]*runnerv1.TaskNeed{
"job1": {
Result: runnerv1.Result_RESULT_SUCCESS,
Outputs: map[string]string{
"output_1": "1",
"output_2": "2",
"output_3": "3",
},
},
},
},
{
treePath: ".gitea/workflows/jobs-outputs-with-matrix-failure.yml",
fileContent: `name: jobs-outputs-with-matrix-failure
on:
push:
paths:
- '.gitea/workflows/jobs-outputs-with-matrix-failure.yml'
jobs:
job1:
runs-on: ubuntu-latest
outputs:
output_1: ${{ steps.gen_output.outputs.output_1 }}
output_2: ${{ steps.gen_output.outputs.output_2 }}
output_3: ${{ steps.gen_output.outputs.output_3 }}
strategy:
matrix:
version: [1, 2, 3]
steps:
- name: Generate output
id: gen_output
run: |
version="${{ matrix.version }}"
echo "output_${version}=${version}" >> "$GITHUB_OUTPUT"
job2:
runs-on: ubuntu-latest
if: ${{ always() }}
needs: [job1]
steps:
- run: echo '${{ toJSON(needs.job1.outputs) }}'
`,
outcomes: map[string]*mockTaskOutcome{
"job1 (1)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "1",
"output_2": "",
"output_3": "",
},
},
"job1 (2)": {
result: runnerv1.Result_RESULT_FAILURE,
outputs: map[string]string{
"output_1": "",
"output_2": "",
"output_3": "",
},
},
"job1 (3)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "",
"output_2": "",
"output_3": "3",
},
},
},
expectedTaskNeeds: map[string]*runnerv1.TaskNeed{
"job1": {
Result: runnerv1.Result_RESULT_FAILURE,
Outputs: map[string]string{
"output_1": "1",
"output_2": "",
"output_3": "3",
},
},
},
},
}
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-jobs-outputs-with-matrix", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"})
for _, tc := range testCases {
t.Run(fmt.Sprintf("test %s", tc.treePath), func(t *testing.T) {
opts := getWorkflowCreateFileOptions(user2, apiRepo.DefaultBranch, fmt.Sprintf("create %s", tc.treePath), tc.fileContent)
createWorkflowFile(t, token, user2.Name, apiRepo.Name, tc.treePath, opts)
for i := 0; i < len(tc.outcomes); i++ {
task := runner.fetchTask(t)
jobName := getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, task.Id)
outcome := tc.outcomes[jobName]
assert.NotNil(t, outcome)
runner.execTask(t, task, outcome)
}
task := runner.fetchTask(t)
actualTaskNeeds := task.Needs
assert.Len(t, actualTaskNeeds, len(tc.expectedTaskNeeds))
for jobID, tn := range tc.expectedTaskNeeds {
actualNeed := actualTaskNeeds[jobID]
assert.Equal(t, tn.Result, actualNeed.Result)
assert.Len(t, actualNeed.Outputs, len(tn.Outputs))
for outputKey, outputValue := range tn.Outputs {
assert.Equal(t, outputValue, actualNeed.Outputs[outputKey])
}
}
})
}
httpContext := NewAPITestContext(t, user2.Name, apiRepo.Name, auth_model.AccessTokenScopeWriteRepository)
// check result
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions", httpContext.Username, httpContext.Reponame))
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
var runIDs []int64
list := htmlDoc.doc.Find("#action-actions input.action-checkbox:not(:disabled)")
list.Each(func(i int, s *goquery.Selection) {
idStr, exists := s.Attr("data-action-id")
if exists {
runID, err := strconv.ParseInt(idStr, 10, 64)
assert.NoError(t, err)
runIDs = append(runIDs, runID)
}
})
assert.NotEmpty(t, runIDs)
csrf := GetUserCSRFToken(t, session)
reqDelete := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/%s/%s/actions/runs/delete", httpContext.Username, httpContext.Reponame), map[string]any{
"actionIds": runIDs,
"_csrf": csrf,
}).AddTokenAuth(token).
SetHeader("X-Csrf-Token", csrf)
session.MakeRequest(t, reqDelete, http.StatusNoContent)
// should not found
_, err := actions_model.GetRunsByIDsAndTriggerUserID(context.Background(), runIDs, user2.ID)
assert.EqualError(t, err, fmt.Errorf("run with ids %d: %w", runIDs, util.ErrNotExist).Error())
doAPIDeleteRepository(httpContext)
})
}

View File

@ -0,0 +1,97 @@
import {queryElems, toggleElem} from '../utils/dom.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {showErrorToast} from '../modules/toast.ts';
import {POST} from '../modules/fetch.ts';
function initRepoActionListCheckboxes() {
const actionListSelectAll = document.querySelector<HTMLInputElement>('.action-checkbox-all');
if (!actionListSelectAll) return; // logged out state
const issueCheckboxes = document.querySelectorAll<HTMLInputElement>('.action-checkbox:not([disabled])');
const actionDelete = document.querySelector('#action-delete');
const syncIssueSelectionState = () => {
const enabledCheckboxes = Array.from(issueCheckboxes).filter((el) => !el.disabled);
const checkedCheckboxes = enabledCheckboxes.filter((el) => el.checked);
const anyChecked = Boolean(checkedCheckboxes.length);
const allChecked = anyChecked && checkedCheckboxes.length === enabledCheckboxes.length;
if (allChecked) {
actionListSelectAll.checked = true;
actionListSelectAll.indeterminate = false;
} else if (anyChecked) {
actionListSelectAll.checked = false;
actionListSelectAll.indeterminate = true;
} else {
actionListSelectAll.checked = false;
actionListSelectAll.indeterminate = false;
}
if (actionDelete) {
toggleElem('#action-delete', anyChecked);
}
};
for (const el of issueCheckboxes) {
el.addEventListener('change', syncIssueSelectionState);
}
actionListSelectAll.addEventListener('change', () => {
for (const el of issueCheckboxes) {
if (!el.disabled) {
el.checked = actionListSelectAll.checked;
}
}
syncIssueSelectionState();
});
queryElems(document, '.action-action', (el) => el.addEventListener('click',
async (e: MouseEvent) => {
e.preventDefault();
const action = el.getAttribute('data-action');
const url = el.getAttribute('data-url');
const actionIDList: number[] = [];
const radix = 10;
for (const el of document.querySelectorAll<HTMLInputElement>('.action-checkbox:checked:not([disabled])')) {
const id = el.getAttribute('data-action-id');
if (id) {
actionIDList.push(parseInt(id, radix));
}
}
if (actionIDList.length < 1) return;
// for delete
if (action === 'delete') {
const confirmText = el.getAttribute('data-action-delete-confirm');
if (!await confirmModal({content: confirmText, confirmButtonColor: 'red'})) {
return;
}
}
try {
await deleteActions(url, actionIDList);
window.location.reload();
} catch (err) {
showErrorToast(err.responseJSON?.error ?? err.message);
}
},
));
}
async function deleteActions(url: string, actionIds: number[]) {
try {
const response = await POST(url, {
data: {
actionIds,
},
});
if (!response.ok) {
throw new Error('failed to delete actions');
}
} catch (error) {
console.error(error);
}
}
export function initRepoActionList() {
if (document.querySelector('.page-content.repository.actions')) {
initRepoActionListCheckboxes();
}
}

View File

@ -85,6 +85,7 @@ import {
initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm,
} from './features/common-form.ts';
import {initRepoActionList} from './features/repo-action-list.ts';
initGiteaFomantic();
initDirAuto();
@ -213,5 +214,6 @@ onDomReady(() => {
initColorPickers,
initOAuth2SettingsDisableCheckbox,
initRepoActionList,
]);
});