code.gitea.io/gitea@v1.22.3/routers/web/repo/actions/view.go (about)

     1  // Copyright 2022 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package actions
     5  
     6  import (
     7  	"archive/zip"
     8  	"compress/gzip"
     9  	"context"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"net/http"
    14  	"net/url"
    15  	"strconv"
    16  	"strings"
    17  	"time"
    18  
    19  	actions_model "code.gitea.io/gitea/models/actions"
    20  	"code.gitea.io/gitea/models/db"
    21  	repo_model "code.gitea.io/gitea/models/repo"
    22  	"code.gitea.io/gitea/models/unit"
    23  	"code.gitea.io/gitea/modules/actions"
    24  	"code.gitea.io/gitea/modules/base"
    25  	"code.gitea.io/gitea/modules/setting"
    26  	"code.gitea.io/gitea/modules/storage"
    27  	"code.gitea.io/gitea/modules/timeutil"
    28  	"code.gitea.io/gitea/modules/util"
    29  	"code.gitea.io/gitea/modules/web"
    30  	actions_service "code.gitea.io/gitea/services/actions"
    31  	context_module "code.gitea.io/gitea/services/context"
    32  
    33  	"xorm.io/builder"
    34  )
    35  
    36  func View(ctx *context_module.Context) {
    37  	ctx.Data["PageIsActions"] = true
    38  	runIndex := ctx.ParamsInt64("run")
    39  	jobIndex := ctx.ParamsInt64("job")
    40  	ctx.Data["RunIndex"] = runIndex
    41  	ctx.Data["JobIndex"] = jobIndex
    42  	ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
    43  
    44  	if getRunJobs(ctx, runIndex, jobIndex); ctx.Written() {
    45  		return
    46  	}
    47  
    48  	ctx.HTML(http.StatusOK, tplViewActions)
    49  }
    50  
    51  type ViewRequest struct {
    52  	LogCursors []struct {
    53  		Step     int   `json:"step"`
    54  		Cursor   int64 `json:"cursor"`
    55  		Expanded bool  `json:"expanded"`
    56  	} `json:"logCursors"`
    57  }
    58  
    59  type ViewResponse struct {
    60  	State struct {
    61  		Run struct {
    62  			Link              string     `json:"link"`
    63  			Title             string     `json:"title"`
    64  			Status            string     `json:"status"`
    65  			CanCancel         bool       `json:"canCancel"`
    66  			CanApprove        bool       `json:"canApprove"` // the run needs an approval and the doer has permission to approve
    67  			CanRerun          bool       `json:"canRerun"`
    68  			CanDeleteArtifact bool       `json:"canDeleteArtifact"`
    69  			Done              bool       `json:"done"`
    70  			WorkflowID        string     `json:"workflowID"`
    71  			WorkflowLink      string     `json:"workflowLink"`
    72  			IsSchedule        bool       `json:"isSchedule"`
    73  			Jobs              []*ViewJob `json:"jobs"`
    74  			Commit            ViewCommit `json:"commit"`
    75  		} `json:"run"`
    76  		CurrentJob struct {
    77  			Title  string         `json:"title"`
    78  			Detail string         `json:"detail"`
    79  			Steps  []*ViewJobStep `json:"steps"`
    80  		} `json:"currentJob"`
    81  	} `json:"state"`
    82  	Logs struct {
    83  		StepsLog []*ViewStepLog `json:"stepsLog"`
    84  	} `json:"logs"`
    85  }
    86  
    87  type ViewJob struct {
    88  	ID       int64  `json:"id"`
    89  	Name     string `json:"name"`
    90  	Status   string `json:"status"`
    91  	CanRerun bool   `json:"canRerun"`
    92  	Duration string `json:"duration"`
    93  }
    94  
    95  type ViewCommit struct {
    96  	ShortSha string     `json:"shortSHA"`
    97  	Link     string     `json:"link"`
    98  	Pusher   ViewUser   `json:"pusher"`
    99  	Branch   ViewBranch `json:"branch"`
   100  }
   101  
   102  type ViewUser struct {
   103  	DisplayName string `json:"displayName"`
   104  	Link        string `json:"link"`
   105  }
   106  
   107  type ViewBranch struct {
   108  	Name string `json:"name"`
   109  	Link string `json:"link"`
   110  }
   111  
   112  type ViewJobStep struct {
   113  	Summary  string `json:"summary"`
   114  	Duration string `json:"duration"`
   115  	Status   string `json:"status"`
   116  }
   117  
   118  type ViewStepLog struct {
   119  	Step    int                `json:"step"`
   120  	Cursor  int64              `json:"cursor"`
   121  	Lines   []*ViewStepLogLine `json:"lines"`
   122  	Started int64              `json:"started"`
   123  }
   124  
   125  type ViewStepLogLine struct {
   126  	Index     int64   `json:"index"`
   127  	Message   string  `json:"message"`
   128  	Timestamp float64 `json:"timestamp"`
   129  }
   130  
   131  func ViewPost(ctx *context_module.Context) {
   132  	req := web.GetForm(ctx).(*ViewRequest)
   133  	runIndex := ctx.ParamsInt64("run")
   134  	jobIndex := ctx.ParamsInt64("job")
   135  
   136  	current, jobs := getRunJobs(ctx, runIndex, jobIndex)
   137  	if ctx.Written() {
   138  		return
   139  	}
   140  	run := current.Run
   141  	if err := run.LoadAttributes(ctx); err != nil {
   142  		ctx.Error(http.StatusInternalServerError, err.Error())
   143  		return
   144  	}
   145  
   146  	resp := &ViewResponse{}
   147  
   148  	resp.State.Run.Title = run.Title
   149  	resp.State.Run.Link = run.Link()
   150  	resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
   151  	resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
   152  	resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
   153  	resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
   154  	resp.State.Run.Done = run.Status.IsDone()
   155  	resp.State.Run.WorkflowID = run.WorkflowID
   156  	resp.State.Run.WorkflowLink = run.WorkflowLink()
   157  	resp.State.Run.IsSchedule = run.IsSchedule()
   158  	resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json
   159  	resp.State.Run.Status = run.Status.String()
   160  	for _, v := range jobs {
   161  		resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{
   162  			ID:       v.ID,
   163  			Name:     v.Name,
   164  			Status:   v.Status.String(),
   165  			CanRerun: v.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions),
   166  			Duration: v.Duration().String(),
   167  		})
   168  	}
   169  
   170  	pusher := ViewUser{
   171  		DisplayName: run.TriggerUser.GetDisplayName(),
   172  		Link:        run.TriggerUser.HomeLink(),
   173  	}
   174  	branch := ViewBranch{
   175  		Name: run.PrettyRef(),
   176  		Link: run.RefLink(),
   177  	}
   178  	resp.State.Run.Commit = ViewCommit{
   179  		ShortSha: base.ShortSha(run.CommitSHA),
   180  		Link:     fmt.Sprintf("%s/commit/%s", run.Repo.Link(), run.CommitSHA),
   181  		Pusher:   pusher,
   182  		Branch:   branch,
   183  	}
   184  
   185  	var task *actions_model.ActionTask
   186  	if current.TaskID > 0 {
   187  		var err error
   188  		task, err = actions_model.GetTaskByID(ctx, current.TaskID)
   189  		if err != nil {
   190  			ctx.Error(http.StatusInternalServerError, err.Error())
   191  			return
   192  		}
   193  		task.Job = current
   194  		if err := task.LoadAttributes(ctx); err != nil {
   195  			ctx.Error(http.StatusInternalServerError, err.Error())
   196  			return
   197  		}
   198  	}
   199  
   200  	resp.State.CurrentJob.Title = current.Name
   201  	resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale)
   202  	if run.NeedApproval {
   203  		resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc")
   204  	}
   205  	resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json
   206  	resp.Logs.StepsLog = make([]*ViewStepLog, 0)          // marshal to '[]' instead fo 'null' in json
   207  	if task != nil {
   208  		steps := actions.FullSteps(task)
   209  
   210  		for _, v := range steps {
   211  			resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &ViewJobStep{
   212  				Summary:  v.Name,
   213  				Duration: v.Duration().String(),
   214  				Status:   v.Status.String(),
   215  			})
   216  		}
   217  
   218  		for _, cursor := range req.LogCursors {
   219  			if !cursor.Expanded {
   220  				continue
   221  			}
   222  
   223  			step := steps[cursor.Step]
   224  
   225  			logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead fo 'null' in json
   226  
   227  			index := step.LogIndex + cursor.Cursor
   228  			validCursor := cursor.Cursor >= 0 &&
   229  				// !(cursor.Cursor < step.LogLength) when the frontend tries to fetch next line before it's ready.
   230  				// So return the same cursor and empty lines to let the frontend retry.
   231  				cursor.Cursor < step.LogLength &&
   232  				// !(index < task.LogIndexes[index]) when task data is older than step data.
   233  				// It can be fixed by making sure write/read tasks and steps in the same transaction,
   234  				// but it's easier to just treat it as fetching the next line before it's ready.
   235  				index < int64(len(task.LogIndexes))
   236  
   237  			if validCursor {
   238  				length := step.LogLength - cursor.Cursor
   239  				offset := task.LogIndexes[index]
   240  				var err error
   241  				logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length)
   242  				if err != nil {
   243  					ctx.Error(http.StatusInternalServerError, err.Error())
   244  					return
   245  				}
   246  
   247  				for i, row := range logRows {
   248  					logLines = append(logLines, &ViewStepLogLine{
   249  						Index:     cursor.Cursor + int64(i) + 1, // start at 1
   250  						Message:   row.Content,
   251  						Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second),
   252  					})
   253  				}
   254  			}
   255  
   256  			resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{
   257  				Step:    cursor.Step,
   258  				Cursor:  cursor.Cursor + int64(len(logLines)),
   259  				Lines:   logLines,
   260  				Started: int64(step.Started),
   261  			})
   262  		}
   263  	}
   264  
   265  	ctx.JSON(http.StatusOK, resp)
   266  }
   267  
   268  // Rerun will rerun jobs in the given run
   269  // If jobIndexStr is a blank string, it means rerun all jobs
   270  func Rerun(ctx *context_module.Context) {
   271  	runIndex := ctx.ParamsInt64("run")
   272  	jobIndexStr := ctx.Params("job")
   273  	var jobIndex int64
   274  	if jobIndexStr != "" {
   275  		jobIndex, _ = strconv.ParseInt(jobIndexStr, 10, 64)
   276  	}
   277  
   278  	run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
   279  	if err != nil {
   280  		ctx.Error(http.StatusInternalServerError, err.Error())
   281  		return
   282  	}
   283  
   284  	// can not rerun job when workflow is disabled
   285  	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
   286  	cfg := cfgUnit.ActionsConfig()
   287  	if cfg.IsWorkflowDisabled(run.WorkflowID) {
   288  		ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled"))
   289  		return
   290  	}
   291  
   292  	// reset run's start and stop time when it is done
   293  	if run.Status.IsDone() {
   294  		run.PreviousDuration = run.Duration()
   295  		run.Started = 0
   296  		run.Stopped = 0
   297  		if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil {
   298  			ctx.Error(http.StatusInternalServerError, err.Error())
   299  			return
   300  		}
   301  	}
   302  
   303  	job, jobs := getRunJobs(ctx, runIndex, jobIndex)
   304  	if ctx.Written() {
   305  		return
   306  	}
   307  
   308  	if jobIndexStr == "" { // rerun all jobs
   309  		for _, j := range jobs {
   310  			// if the job has needs, it should be set to "blocked" status to wait for other jobs
   311  			shouldBlock := len(j.Needs) > 0
   312  			if err := rerunJob(ctx, j, shouldBlock); err != nil {
   313  				ctx.Error(http.StatusInternalServerError, err.Error())
   314  				return
   315  			}
   316  		}
   317  		ctx.JSON(http.StatusOK, struct{}{})
   318  		return
   319  	}
   320  
   321  	rerunJobs := actions_service.GetAllRerunJobs(job, jobs)
   322  
   323  	for _, j := range rerunJobs {
   324  		// jobs other than the specified one should be set to "blocked" status
   325  		shouldBlock := j.JobID != job.JobID
   326  		if err := rerunJob(ctx, j, shouldBlock); err != nil {
   327  			ctx.Error(http.StatusInternalServerError, err.Error())
   328  			return
   329  		}
   330  	}
   331  
   332  	ctx.JSON(http.StatusOK, struct{}{})
   333  }
   334  
   335  func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shouldBlock bool) error {
   336  	status := job.Status
   337  	if !status.IsDone() {
   338  		return nil
   339  	}
   340  
   341  	job.TaskID = 0
   342  	job.Status = actions_model.StatusWaiting
   343  	if shouldBlock {
   344  		job.Status = actions_model.StatusBlocked
   345  	}
   346  	job.Started = 0
   347  	job.Stopped = 0
   348  
   349  	if err := db.WithTx(ctx, func(ctx context.Context) error {
   350  		_, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, "task_id", "status", "started", "stopped")
   351  		return err
   352  	}); err != nil {
   353  		return err
   354  	}
   355  
   356  	actions_service.CreateCommitStatus(ctx, job)
   357  	return nil
   358  }
   359  
   360  func Logs(ctx *context_module.Context) {
   361  	runIndex := ctx.ParamsInt64("run")
   362  	jobIndex := ctx.ParamsInt64("job")
   363  
   364  	job, _ := getRunJobs(ctx, runIndex, jobIndex)
   365  	if ctx.Written() {
   366  		return
   367  	}
   368  	if job.TaskID == 0 {
   369  		ctx.Error(http.StatusNotFound, "job is not started")
   370  		return
   371  	}
   372  
   373  	err := job.LoadRun(ctx)
   374  	if err != nil {
   375  		ctx.Error(http.StatusInternalServerError, err.Error())
   376  		return
   377  	}
   378  
   379  	task, err := actions_model.GetTaskByID(ctx, job.TaskID)
   380  	if err != nil {
   381  		ctx.Error(http.StatusInternalServerError, err.Error())
   382  		return
   383  	}
   384  	if task.LogExpired {
   385  		ctx.Error(http.StatusNotFound, "logs have been cleaned up")
   386  		return
   387  	}
   388  
   389  	reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
   390  	if err != nil {
   391  		ctx.Error(http.StatusInternalServerError, err.Error())
   392  		return
   393  	}
   394  	defer reader.Close()
   395  
   396  	workflowName := job.Run.WorkflowID
   397  	if p := strings.Index(workflowName, "."); p > 0 {
   398  		workflowName = workflowName[0:p]
   399  	}
   400  	ctx.ServeContent(reader, &context_module.ServeHeaderOptions{
   401  		Filename:           fmt.Sprintf("%v-%v-%v.log", workflowName, job.Name, task.ID),
   402  		ContentLength:      &task.LogSize,
   403  		ContentType:        "text/plain",
   404  		ContentTypeCharset: "utf-8",
   405  		Disposition:        "attachment",
   406  	})
   407  }
   408  
   409  func Cancel(ctx *context_module.Context) {
   410  	runIndex := ctx.ParamsInt64("run")
   411  
   412  	_, jobs := getRunJobs(ctx, runIndex, -1)
   413  	if ctx.Written() {
   414  		return
   415  	}
   416  
   417  	if err := db.WithTx(ctx, func(ctx context.Context) error {
   418  		for _, job := range jobs {
   419  			status := job.Status
   420  			if status.IsDone() {
   421  				continue
   422  			}
   423  			if job.TaskID == 0 {
   424  				job.Status = actions_model.StatusCancelled
   425  				job.Stopped = timeutil.TimeStampNow()
   426  				n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
   427  				if err != nil {
   428  					return err
   429  				}
   430  				if n == 0 {
   431  					return fmt.Errorf("job has changed, try again")
   432  				}
   433  				continue
   434  			}
   435  			if err := actions_model.StopTask(ctx, job.TaskID, actions_model.StatusCancelled); err != nil {
   436  				return err
   437  			}
   438  		}
   439  		return nil
   440  	}); err != nil {
   441  		ctx.Error(http.StatusInternalServerError, err.Error())
   442  		return
   443  	}
   444  
   445  	actions_service.CreateCommitStatus(ctx, jobs...)
   446  
   447  	ctx.JSON(http.StatusOK, struct{}{})
   448  }
   449  
   450  func Approve(ctx *context_module.Context) {
   451  	runIndex := ctx.ParamsInt64("run")
   452  
   453  	current, jobs := getRunJobs(ctx, runIndex, -1)
   454  	if ctx.Written() {
   455  		return
   456  	}
   457  	run := current.Run
   458  	doer := ctx.Doer
   459  
   460  	if err := db.WithTx(ctx, func(ctx context.Context) error {
   461  		run.NeedApproval = false
   462  		run.ApprovedBy = doer.ID
   463  		if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
   464  			return err
   465  		}
   466  		for _, job := range jobs {
   467  			if len(job.Needs) == 0 && job.Status.IsBlocked() {
   468  				job.Status = actions_model.StatusWaiting
   469  				_, err := actions_model.UpdateRunJob(ctx, job, nil, "status")
   470  				if err != nil {
   471  					return err
   472  				}
   473  			}
   474  		}
   475  		return nil
   476  	}); err != nil {
   477  		ctx.Error(http.StatusInternalServerError, err.Error())
   478  		return
   479  	}
   480  
   481  	actions_service.CreateCommitStatus(ctx, jobs...)
   482  
   483  	ctx.JSON(http.StatusOK, struct{}{})
   484  }
   485  
   486  // getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs.
   487  // Any error will be written to the ctx.
   488  // It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0.
   489  func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob) {
   490  	run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
   491  	if err != nil {
   492  		if errors.Is(err, util.ErrNotExist) {
   493  			ctx.Error(http.StatusNotFound, err.Error())
   494  			return nil, nil
   495  		}
   496  		ctx.Error(http.StatusInternalServerError, err.Error())
   497  		return nil, nil
   498  	}
   499  	run.Repo = ctx.Repo.Repository
   500  
   501  	jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
   502  	if err != nil {
   503  		ctx.Error(http.StatusInternalServerError, err.Error())
   504  		return nil, nil
   505  	}
   506  	if len(jobs) == 0 {
   507  		ctx.Error(http.StatusNotFound)
   508  		return nil, nil
   509  	}
   510  
   511  	for _, v := range jobs {
   512  		v.Run = run
   513  	}
   514  
   515  	if jobIndex >= 0 && jobIndex < int64(len(jobs)) {
   516  		return jobs[jobIndex], jobs
   517  	}
   518  	return jobs[0], jobs
   519  }
   520  
   521  type ArtifactsViewResponse struct {
   522  	Artifacts []*ArtifactsViewItem `json:"artifacts"`
   523  }
   524  
   525  type ArtifactsViewItem struct {
   526  	Name   string `json:"name"`
   527  	Size   int64  `json:"size"`
   528  	Status string `json:"status"`
   529  }
   530  
   531  func ArtifactsView(ctx *context_module.Context) {
   532  	runIndex := ctx.ParamsInt64("run")
   533  	run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
   534  	if err != nil {
   535  		if errors.Is(err, util.ErrNotExist) {
   536  			ctx.Error(http.StatusNotFound, err.Error())
   537  			return
   538  		}
   539  		ctx.Error(http.StatusInternalServerError, err.Error())
   540  		return
   541  	}
   542  	artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID)
   543  	if err != nil {
   544  		ctx.Error(http.StatusInternalServerError, err.Error())
   545  		return
   546  	}
   547  	artifactsResponse := ArtifactsViewResponse{
   548  		Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)),
   549  	}
   550  	for _, art := range artifacts {
   551  		status := "completed"
   552  		if art.Status == actions_model.ArtifactStatusExpired {
   553  			status = "expired"
   554  		}
   555  		artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{
   556  			Name:   art.ArtifactName,
   557  			Size:   art.FileSize,
   558  			Status: status,
   559  		})
   560  	}
   561  	ctx.JSON(http.StatusOK, artifactsResponse)
   562  }
   563  
   564  func ArtifactsDeleteView(ctx *context_module.Context) {
   565  	if !ctx.Repo.CanWrite(unit.TypeActions) {
   566  		ctx.Error(http.StatusForbidden, "no permission")
   567  		return
   568  	}
   569  
   570  	runIndex := ctx.ParamsInt64("run")
   571  	artifactName := ctx.Params("artifact_name")
   572  
   573  	run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
   574  	if err != nil {
   575  		ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool {
   576  			return errors.Is(err, util.ErrNotExist)
   577  		}, err)
   578  		return
   579  	}
   580  	if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
   581  		ctx.Error(http.StatusInternalServerError, err.Error())
   582  		return
   583  	}
   584  	ctx.JSON(http.StatusOK, struct{}{})
   585  }
   586  
   587  func ArtifactsDownloadView(ctx *context_module.Context) {
   588  	runIndex := ctx.ParamsInt64("run")
   589  	artifactName := ctx.Params("artifact_name")
   590  
   591  	run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
   592  	if err != nil {
   593  		if errors.Is(err, util.ErrNotExist) {
   594  			ctx.Error(http.StatusNotFound, err.Error())
   595  			return
   596  		}
   597  		ctx.Error(http.StatusInternalServerError, err.Error())
   598  		return
   599  	}
   600  
   601  	artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
   602  		RunID:        run.ID,
   603  		ArtifactName: artifactName,
   604  	})
   605  	if err != nil {
   606  		ctx.Error(http.StatusInternalServerError, err.Error())
   607  		return
   608  	}
   609  	if len(artifacts) == 0 {
   610  		ctx.Error(http.StatusNotFound, "artifact not found")
   611  		return
   612  	}
   613  
   614  	// if artifacts status is not uploaded-confirmed, treat it as not found
   615  	for _, art := range artifacts {
   616  		if art.Status != int64(actions_model.ArtifactStatusUploadConfirmed) {
   617  			ctx.Error(http.StatusNotFound, "artifact not found")
   618  			return
   619  		}
   620  	}
   621  
   622  	ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName))
   623  
   624  	// Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend
   625  	// The v4 backend enshures ContentEncoding is set to "application/zip", which is not the case for the old backend
   626  	if len(artifacts) == 1 && artifacts[0].ArtifactName+".zip" == artifacts[0].ArtifactPath && artifacts[0].ContentEncoding == "application/zip" {
   627  		art := artifacts[0]
   628  		if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect {
   629  			u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath)
   630  			if u != nil && err == nil {
   631  				ctx.Redirect(u.String())
   632  				return
   633  			}
   634  		}
   635  		f, err := storage.ActionsArtifacts.Open(art.StoragePath)
   636  		if err != nil {
   637  			ctx.Error(http.StatusInternalServerError, err.Error())
   638  			return
   639  		}
   640  		_, _ = io.Copy(ctx.Resp, f)
   641  		return
   642  	}
   643  
   644  	// Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend
   645  	// Those need to be zipped for download
   646  	writer := zip.NewWriter(ctx.Resp)
   647  	defer writer.Close()
   648  	for _, art := range artifacts {
   649  		f, err := storage.ActionsArtifacts.Open(art.StoragePath)
   650  		if err != nil {
   651  			ctx.Error(http.StatusInternalServerError, err.Error())
   652  			return
   653  		}
   654  
   655  		var r io.ReadCloser
   656  		if art.ContentEncoding == "gzip" {
   657  			r, err = gzip.NewReader(f)
   658  			if err != nil {
   659  				ctx.Error(http.StatusInternalServerError, err.Error())
   660  				return
   661  			}
   662  		} else {
   663  			r = f
   664  		}
   665  		defer r.Close()
   666  
   667  		w, err := writer.Create(art.ArtifactPath)
   668  		if err != nil {
   669  			ctx.Error(http.StatusInternalServerError, err.Error())
   670  			return
   671  		}
   672  		if _, err := io.Copy(w, r); err != nil {
   673  			ctx.Error(http.StatusInternalServerError, err.Error())
   674  			return
   675  		}
   676  	}
   677  }
   678  
   679  func DisableWorkflowFile(ctx *context_module.Context) {
   680  	disableOrEnableWorkflowFile(ctx, false)
   681  }
   682  
   683  func EnableWorkflowFile(ctx *context_module.Context) {
   684  	disableOrEnableWorkflowFile(ctx, true)
   685  }
   686  
   687  func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) {
   688  	workflow := ctx.FormString("workflow")
   689  	if len(workflow) == 0 {
   690  		ctx.ServerError("workflow", nil)
   691  		return
   692  	}
   693  
   694  	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
   695  	cfg := cfgUnit.ActionsConfig()
   696  
   697  	if isEnable {
   698  		cfg.EnableWorkflow(workflow)
   699  	} else {
   700  		cfg.DisableWorkflow(workflow)
   701  	}
   702  
   703  	if err := repo_model.UpdateRepoUnit(ctx, cfgUnit); err != nil {
   704  		ctx.ServerError("UpdateRepoUnit", err)
   705  		return
   706  	}
   707  
   708  	if isEnable {
   709  		ctx.Flash.Success(ctx.Tr("actions.workflow.enable_success", workflow))
   710  	} else {
   711  		ctx.Flash.Success(ctx.Tr("actions.workflow.disable_success", workflow))
   712  	}
   713  
   714  	redirectURL := fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s", ctx.Repo.RepoLink, url.QueryEscape(workflow),
   715  		url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status")))
   716  	ctx.JSONRedirect(redirectURL)
   717  }