From 2013fb3fab5e23d0088434d835411f26a3fd9905 Mon Sep 17 00:00:00 2001
From: Gergely Nagy <forgejo@gergo.csillger.hu>
Date: Fri, 8 Dec 2023 13:41:48 +0100
Subject: [PATCH] [GITEA] allow viewing the latest Action Run on the web

Similar to how some other parts of the web UI support a `/latest` path
to directly go to the latest of a certain thing, let the Actions web UI
do the same: `/{owner}/{repo}/actions/runs/latest` will redirect to the
latest run, if there's one available.

Fixes gitea#27991.

Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
(cherry picked from commit f67ccef1dd3146b0b942a94e2482b37595180e91)

Code cleanup in the actions.ViewLatest route handler

Based on feedback received after the feature was merged, use
`ctx.NotFound` and `ctx.ServerError`, and drop the use of the
unnecessary `ctx.Written()`.

Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
(cherry picked from commit 74e42da5630f9148faaf6b03bf1ac5724fa86b25)
(cherry picked from commit f7535a1cef96ce0589f37907f88b024cd095d0ac)
(cherry picked from commit 1a90cd37c31a1b9c770d6d79a4663ed8d67845c0)
(cherry picked from commit d86d71340afd372e5b5083d5563c2f5b48d975e6)
(cherry picked from commit 9e5cce1afccebcd6146e5e0d364bfdbb840b5276)
---
 models/actions/run.go                   | 11 +++
 routers/web/repo/actions/view.go        | 14 ++++
 routers/web/web.go                      | 25 ++++---
 tests/integration/actions_route_test.go | 91 +++++++++++++++++++++++++
 4 files changed, 130 insertions(+), 11 deletions(-)
 create mode 100644 tests/integration/actions_route_test.go

diff --git a/models/actions/run.go b/models/actions/run.go
index 4656aa22a2..e84552682b 100644
--- a/models/actions/run.go
+++ b/models/actions/run.go
@@ -308,6 +308,17 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork
 	return commiter.Commit()
 }
 
+func GetLatestRun(ctx context.Context, repoID int64) (*ActionRun, error) {
+	var run ActionRun
+	has, err := db.GetEngine(ctx).Where("repo_id=?", repoID).OrderBy("id DESC").Limit(1).Get(&run)
+	if err != nil {
+		return nil, err
+	} else if !has {
+		return nil, fmt.Errorf("latest run: %w", util.ErrNotExist)
+	}
+	return &run, nil
+}
+
 func GetRunByID(ctx context.Context, id int64) (*ActionRun, error) {
 	var run ActionRun
 	has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run)
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index 1cdae32a32..b6984567e5 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -46,6 +46,20 @@ func View(ctx *context_module.Context) {
 	ctx.HTML(http.StatusOK, tplViewActions)
 }
 
+func ViewLatest(ctx *context_module.Context) {
+	run, err := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID)
+	if err != nil {
+		ctx.NotFound("GetLatestRun", err)
+		return
+	}
+	err = run.LoadAttributes(ctx)
+	if err != nil {
+		ctx.ServerError("LoadAttributes", err)
+		return
+	}
+	ctx.Redirect(run.HTMLURL(), http.StatusTemporaryRedirect)
+}
+
 type ViewRequest struct {
 	LogCursors []struct {
 		Step     int   `json:"step"`
diff --git a/routers/web/web.go b/routers/web/web.go
index 5172d33d03..69540d1d64 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1345,22 +1345,25 @@ func registerRoutes(m *web.Route) {
 			m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile)
 			m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile)
 
-			m.Group("/runs/{run}", func() {
-				m.Combo("").
-					Get(actions.View).
-					Post(web.Bind(actions.ViewRequest{}), actions.ViewPost)
-				m.Group("/jobs/{job}", func() {
+			m.Group("/runs", func() {
+				m.Get("/latest", actions.ViewLatest)
+				m.Group("/{run}", func() {
 					m.Combo("").
 						Get(actions.View).
 						Post(web.Bind(actions.ViewRequest{}), actions.ViewPost)
+					m.Group("/jobs/{job}", func() {
+						m.Combo("").
+							Get(actions.View).
+							Post(web.Bind(actions.ViewRequest{}), actions.ViewPost)
+						m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
+						m.Get("/logs", actions.Logs)
+					})
+					m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
+					m.Post("/approve", reqRepoActionsWriter, actions.Approve)
+					m.Post("/artifacts", actions.ArtifactsView)
+					m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
 					m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
-					m.Get("/logs", actions.Logs)
 				})
-				m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
-				m.Post("/approve", reqRepoActionsWriter, actions.Approve)
-				m.Post("/artifacts", actions.ArtifactsView)
-				m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
-				m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
 			})
 		}, reqRepoActionsReader, actions.MustEnableActions)
 
diff --git a/tests/integration/actions_route_test.go b/tests/integration/actions_route_test.go
new file mode 100644
index 0000000000..b6ebacda8b
--- /dev/null
+++ b/tests/integration/actions_route_test.go
@@ -0,0 +1,91 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	unit_model "code.gitea.io/gitea/models/unit"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/git"
+	repo_service "code.gitea.io/gitea/services/repository"
+	files_service "code.gitea.io/gitea/services/repository/files"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestActionsWebRouteLatestRun(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "actions-latest",
+			Description:   "test /actions/runs/latest",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// enable actions
+		err = repo_model.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{
+			RepoID: repo.ID,
+			Type:   unit_model.TypeActions,
+		}}, nil)
+		assert.NoError(t, err)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation:     "create",
+					TreePath:      ".gitea/workflows/pr.yml",
+					ContentReader: strings.NewReader("name: test\non:\n  push:\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				Name:  user2.Name,
+				Email: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				Name:  user2.Name,
+				Email: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// a run has been created
+		assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
+
+		// Hit the `/actions/runs/latest` route
+		req := NewRequest(t, "GET", fmt.Sprintf("%s/actions/runs/latest", repo.HTMLURL()))
+		resp := MakeRequest(t, req, http.StatusTemporaryRedirect)
+
+		// Verify that it redirects to the run we just created
+		expectedURI := fmt.Sprintf("%s/actions/runs/1", repo.HTMLURL())
+		assert.Equal(t, expectedURI, resp.Header().Get("Location"))
+	})
+}