Compare commits

..
4 Commits
Author SHA1 Message Date
d2bd1589fe fix(util): reject invalid characters between time-estimate units (#38416)
### What / why

`TimeEstimateParse` (used by the issue time-estimate form) only checked
that the first token starts at the beginning of the string and the last
token ends at its end, but never checked the gaps between consecutive
tokens. Non-whitespace garbage embedded between two valid units was
silently dropped and the string accepted with a wrong value instead of
being reported as invalid.

Examples that were wrongly accepted before this change:

- `1h 2x 3m` → 3780s (parsed as 1h3m)
- `1h_2m`    → 3720s
- `1h,1m`    → 3660s

All three now return an "invalid time string" error, while valid inputs
such as `1h 1m 1s` and `1h1m1s` keep working.

### How

Reject any non-whitespace content between two matched units.

---------

Signed-off-by: TowyTowy <towy@airreps.link>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-12 13:38:34 +00:00
b998c3c1aa feat(actions): implement adaptive auto-refresh for workflow runs list (#38329)
### Description
This PR implements an optimized, adaptive client-side auto-refresh
mechanism for the Gitea Actions workflow runs list page. It allows users
to see workflow progress updates dynamically without having to reload
the page.

Fixes https://github.com/go-gitea/gitea/issues/37457
---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-12 12:14:37 +00:00
65c5a5ff7b fix(turnstile): route CAPTCHA verification through the configured proxy (#38412)
Fixes #38217

## Problem

Turnstile CAPTCHA verification uses `http.DefaultClient`, so the request
to `challenges.cloudflare.com` bypasses Gitea's configured HTTP proxy —
unlike other outbound HTTP clients such as the update checker
(`modules/updatechecker/update_checker.go`) and migrations. In
deployments where egress is only permitted through the configured proxy,
verification fails.

## Fix

Build the client with `proxy.Proxy()` as the transport proxy, mirroring
the update checker:

```go
func httpClient() *http.Client {
	return &http.Client{
		Transport: &http.Transport{
			Proxy: proxy.Proxy(),
		},
	}
}
```

The client is built per call (rather than a package-level var) because
`proxy.Proxy()` reads `setting.Proxy` when invoked; building it at
request time ensures it reflects the loaded settings. When no proxy is
configured, behavior is unchanged (`proxy.Proxy()` returns a no-op /
`http.ProxyFromEnvironment`).

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-12 11:33:14 +00:00
aba0eb1749 fix: represent a deleted assignee team as a Ghost team (#38413)
Fixes #35472.

`Comment.LoadAssigneeUserAndTeam` already has a Ghost user fallback
for a deleted assignee user, but the parallel branch for a deleted
assignee team just swallowed the not-found error and left
`AssigneeTeam` as `nil`. This is inconsistent (the reporter's example
shows `assignee` becoming a Ghost user while `assignee_team` becomes
`null`), and it's also a latent nil pointer bug: other code that
assumes `AssigneeTeam` is set once this function returns without
error will panic.

Added `organization.NewGhostTeam()` / `Team.IsGhost()`, mirroring the
existing `user_model.NewGhostUser()` / `User.IsGhost()` pattern, and
used it in the same fallback branch.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-12 12:54:59 +02:00
18 changed files with 494 additions and 138 deletions
+6
View File
@@ -277,6 +277,12 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er
return &run, nil
}
func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) ([]*ActionRun, error) {
var runs []*ActionRun
err := db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).Find(&runs)
return runs, err
}
func GetRunByRepoAndIndex(ctx context.Context, repoID, runIndex int64) (*ActionRun, error) {
run, has, err := db.Get[ActionRun](ctx, builder.Eq{"repo_id": repoID, "`index`": runIndex})
if err != nil {
+18 -30
View File
@@ -27,6 +27,7 @@ import (
"gitea.dev/modules/markup"
"gitea.dev/modules/optional"
"gitea.dev/modules/references"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/translation"
@@ -643,36 +644,18 @@ func UpdateCommentAttachments(ctx context.Context, c *Comment, uuids []string) e
}
// LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) error {
var err error
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) (err error) {
if c.AssigneeID > 0 && c.Assignee == nil {
c.Assignee, err = user_model.GetUserByID(ctx, c.AssigneeID)
_, c.Assignee, err = user_model.GetPossibleUserByID(ctx, c.AssigneeID)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
return err
}
c.Assignee = user_model.NewGhostUser()
}
} else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
if err = c.LoadIssue(ctx); err != nil {
return err
}
if err = c.Issue.LoadRepo(ctx); err != nil {
}
if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
_, c.AssigneeTeam, err = organization.GetPossibleTeamByID(ctx, c.AssigneeTeamID)
if err != nil {
return err
}
if err = c.Issue.Repo.LoadOwner(ctx); err != nil {
return err
}
if c.Issue.Repo.Owner.IsOrganization() {
c.AssigneeTeam, err = organization.GetTeamByID(ctx, c.AssigneeTeamID)
if err != nil && !organization.IsErrTeamNotExist(err) {
return err
}
}
}
return nil
}
@@ -795,8 +778,7 @@ func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML {
}
func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML {
if c.AssigneeID > 0 {
// it guarantees LoadAssigneeUserAndTeam has been called, and c.Assignee is Ghost user but not nil if the user doesn't exist
if c.Assignee != nil {
if c.RemovedAssignee {
if c.PosterID == c.AssigneeID {
return locale.Tr("repo.issues.review.remove_review_request_self", createdStr)
@@ -805,14 +787,20 @@ func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdSt
}
return locale.Tr("repo.issues.review.add_review_request", c.Assignee.GetDisplayName(), createdStr)
}
teamName := "Ghost Team"
if c.AssigneeTeam != nil {
teamName = c.AssigneeTeam.Name
if c.RemovedAssignee {
return locale.Tr("repo.issues.review.remove_review_request", c.AssigneeTeam.Name, createdStr)
}
return locale.Tr("repo.issues.review.add_review_request", c.AssigneeTeam.Name, createdStr)
}
// impossible fallback
assigneePrompt := fmt.Sprintf("(AssigneeID=%d, AssigneeTeamID=%d)", c.AssigneeID, c.AssigneeTeam.ID)
setting.PanicInDevOrTesting("unknown timeline pull request review event comment: id=%d, %s", c.ID, assigneePrompt)
if c.RemovedAssignee {
return locale.Tr("repo.issues.review.remove_review_request", teamName, createdStr)
return locale.Tr("repo.issues.review.remove_review_request", assigneePrompt, createdStr)
}
return locale.Tr("repo.issues.review.add_review_request", teamName, createdStr)
return locale.Tr("repo.issues.review.add_review_request", assigneePrompt, createdStr)
}
// CreateComment creates comment with context
+13
View File
@@ -45,6 +45,19 @@ func TestCreateComment(t *testing.T) {
unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
}
func TestLoadAssigneeUserAndTeam_DeletedTeamBecomesGhostTeam(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 15})
comment := &issues_model.Comment{
Type: issues_model.CommentTypeAssignees,
IssueID: issue.ID,
AssigneeTeamID: 999999, // non-existing team ID
}
assert.NoError(t, comment.LoadAssigneeUserAndTeam(t.Context()))
assert.NotNil(t, comment.AssigneeTeam)
assert.EqualValues(t, -1, comment.AssigneeTeam.ID)
}
func Test_UpdateCommentAttachment(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
+21
View File
@@ -6,6 +6,7 @@ package organization
import (
"context"
"errors"
"fmt"
"strings"
@@ -92,6 +93,15 @@ func (t *Team) IsPublic() bool { return t.Visibility.IsPublic() }
func (t *Team) IsLimited() bool { return t.Visibility.IsLimited() }
func (t *Team) IsPrivate() bool { return t.Visibility.IsPrivate() }
const (
ghostTeamID = -1
ghostTeamName = "(deleted team)"
)
func newGhostTeam() *Team {
return &Team{ID: ghostTeamID, Name: ghostTeamName, LowerName: ghostTeamName}
}
// CanNonMemberReadMeta reports whether a non-member, non-owner doer may read
// the team's metadata, based on the team's visibility tier and the parent org's
// visibility. Privileged callers (site admins, org owners, team members) are
@@ -270,6 +280,17 @@ func GetTeamByID(ctx context.Context, teamID int64) (*Team, error) {
return t, nil
}
func GetPossibleTeamByID(ctx context.Context, teamID int64) (int64, *Team, error) {
t, err := GetTeamByID(ctx, teamID)
if errors.Is(err, util.ErrNotExist) {
t = newGhostTeam()
return t.ID, t, nil
} else if err != nil {
return 0, nil, err
}
return t.ID, t, nil
}
// IncrTeamRepoNum increases the number of repos for the given team by 1
func IncrTeamRepoNum(ctx context.Context, teamID int64) error {
_, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team))
+12 -1
View File
@@ -12,9 +12,20 @@ import (
"strings"
"gitea.dev/modules/json"
"gitea.dev/modules/proxy"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
// httpClient returns an HTTP client that honors Gitea's proxy configuration.
var httpClient = util.OnceValue[*http.Client]{
Func: func() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = proxy.Proxy()
return &http.Client{Transport: transport}
},
}
// Response is the structure of JSON returned from API
type Response struct {
Success bool `json:"success"`
@@ -40,7 +51,7 @@ func Verify(ctx context.Context, response string) (bool, error) {
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Value().Do(req)
if err != nil {
return false, fmt.Errorf("Failed to send CAPTCHA response: %w", err)
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package turnstile
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTTPClientHonorsProxy(t *testing.T) {
proxyURL, err := url.Parse("http://proxy.example.com:3128")
require.NoError(t, err)
defer test.MockVariableValue(&setting.Proxy.Enabled, true)()
defer test.MockVariableValue(&setting.Proxy.ProxyURL, proxyURL.String())()
defer test.MockVariableValue(&setting.Proxy.ProxyURLFixed, proxyURL)()
defer test.MockVariableValue(&setting.Proxy.ProxyHosts, []string{"**"})()
httpClient.Reset()
transport, ok := httpClient.Value().Transport.(*http.Transport)
require.True(t, ok)
require.NotNil(t, transport.Proxy)
// The Turnstile verification request must be routed through the configured proxy.
req := httptest.NewRequest(http.MethodPost, "https://any.example.com", nil)
got, err := transport.Proxy(req)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, proxyURL.String(), got.String())
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
import (
"sync"
"sync/atomic"
)
type onceValueResult[T any] struct {
value T
panic any
}
// OnceValue is similar to Golang's "sync.OnceValue", but can be reset.
type OnceValue[T any] struct {
Func func() T
mu sync.Mutex
res atomic.Pointer[onceValueResult[T]]
}
func (o *OnceValue[T]) Value() T {
res := o.res.Load()
if res == nil {
o.mu.Lock()
defer o.mu.Unlock()
res = o.res.Load()
if res == nil {
res = &onceValueResult[T]{}
defer func() {
res.panic = recover()
o.res.Store(res)
if res.panic != nil {
panic(res.panic)
}
}()
res.value = o.Func()
}
}
if res.panic != nil {
panic(res.panic)
}
return res.value
}
func (o *OnceValue[T]) Reset() {
o.mu.Lock()
defer o.mu.Unlock()
o.res.Store(nil)
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestOnceValue(t *testing.T) {
t.Run("RepeatCall", func(t *testing.T) {
callCount := 0
o := OnceValue[int]{Func: func() int {
callCount++
return 42
}}
assert.Equal(t, 42, o.Value())
assert.Equal(t, 42, o.Value())
assert.Equal(t, 1, callCount)
o.Reset()
assert.Equal(t, 42, o.Value())
assert.Equal(t, 2, callCount)
assert.Equal(t, 42, o.Value())
assert.Equal(t, 2, callCount)
})
t.Run("Panic", func(t *testing.T) {
callCount := 0
doPanic := true
o := OnceValue[int]{Func: func() int {
callCount++
if doPanic {
panic("some error")
}
return 42
}}
assert.PanicsWithValue(t, "some error", func() { o.Value() })
assert.PanicsWithValue(t, "some error", func() { o.Value() })
assert.Equal(t, 1, callCount)
doPanic = false
o.Reset()
assert.Equal(t, 42, o.Value())
assert.Equal(t, 2, callCount)
assert.Equal(t, 42, o.Value())
assert.Equal(t, 2, callCount)
})
}
+7
View File
@@ -40,6 +40,7 @@ var timeStrGlobalVars = sync.OnceValue(func() *timeStrGlobalVarsType {
})
func TimeEstimateParse(timeStr string) (int64, error) {
timeStr = strings.TrimSpace(timeStr)
if timeStr == "" {
return 0, nil
}
@@ -51,7 +52,13 @@ func TimeEstimateParse(timeStr string) (int64, error) {
if matches[0][0] != 0 || matches[len(matches)-1][1] != len(timeStr) {
return 0, fmt.Errorf("invalid time string: %s", timeStr)
}
prevEnd := 0
for _, match := range matches {
// only whitespace may separate two units, otherwise the string contains invalid content like "1h x 2m"
if strings.TrimSpace(timeStr[prevEnd:match[0]]) != "" {
return 0, fmt.Errorf("invalid time string: %s", timeStr)
}
prevEnd = match[1]
amount, err := strconv.ParseInt(timeStr[match[2]:match[3]], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid time string: %v", err)
+3
View File
@@ -22,6 +22,9 @@ func TestTimeStr(t *testing.T) {
{"1s", 1, false},
{"1h 1m 1s", 3600 + 60 + 1, false},
{"1d1x", 0, true},
{"1h 2x 3m", 0, true},
{"1h_2m", 0, true},
{"1h,1m", 0, true},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
+180 -84
View File
@@ -8,6 +8,7 @@ import (
stdCtx "context"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"slices"
@@ -19,6 +20,7 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/actions"
"gitea.dev/modules/base"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
@@ -42,9 +44,9 @@ const (
)
type WorkflowInfo struct {
Entry git.TreeEntry
ErrMsg string
Workflow *act_model.Workflow
EntryName string
ErrMsg string
Workflow *act_model.Workflow
}
type workflowBadge struct {
@@ -58,7 +60,7 @@ func (w WorkflowInfo) DisplayName() string {
if w.Workflow != nil && w.Workflow.Name != "" {
return w.Workflow.Name
}
return w.Entry.Name()
return w.EntryName
}
// MustEnableActions check if actions are enabled in settings
@@ -99,9 +101,22 @@ func List(ctx *context.Context) {
if ctx.Written() {
return
}
curWorkflowRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
ctx.Data["CurWorkflowRepoID"] = curWorkflowRepoID
scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, curWorkflowRepoID)
data := prepareWorkflowList(ctx, curWorkflowID)
if data.partialRefresh {
// TODO: at the moment, the "partial refresh" depends on some heavy git&db operations (especially finding & parsing the workflow files)
// If it becomes a real problem in the future, maybe some "caches" can be used to optimize.
if !data.preparePartialRefreshRuns(ctx) {
return
}
if !data.prepareCommon(ctx, workflows) {
return
}
ctx.HTML(http.StatusOK, "repo/actions/runs_list")
return
}
scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, data.scopedWorkflowSourceRepoID)
if ctx.Written() {
return
}
@@ -109,16 +124,22 @@ func List(ctx *context.Context) {
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, curWorkflowRepoID)
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, data.scopedWorkflowSourceRepoID)
if ctx.Written() {
return
}
prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0)
if ctx.Written() {
if !data.prepareFullPageRuns(ctx, otherWorkflows) {
return
}
if !data.prepareCommon(ctx, workflows) {
return
}
if !data.prepareFullPageTemplate(ctx) {
return
}
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(data.ActionRuns) > 0 || len(scopedNames) > 0
ctx.HTML(http.StatusOK, tplListActions)
}
@@ -127,7 +148,7 @@ func List(ctx *context.Context) {
func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, scopedNames container.Set[string], curWorkflowID string) []string {
listed := make(container.Set[string], len(workflows))
for _, w := range workflows {
listed.Add(w.Entry.Name())
listed.Add(w.EntryName)
}
var other []string
@@ -193,7 +214,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
workflows = make([]WorkflowInfo, 0, len(entries))
for _, entry := range entries {
workflow := WorkflowInfo{Entry: *entry}
workflow := WorkflowInfo{EntryName: entry.Name()}
content, err := actions.GetContentFromEntry(entry)
if err != nil {
ctx.ServerError("GetContentFromEntry", err)
@@ -404,7 +425,7 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf
curWorkflow = loadScopedWorkflowModel(ctx, repo, curWorkflowRepoID, curWorkflowID)
} else {
for _, workflowInfo := range workflowInfos {
if workflowInfo.Entry.Name() == curWorkflowID {
if workflowInfo.EntryName == curWorkflowID {
if workflowInfo.Workflow == nil {
log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID)
return
@@ -452,71 +473,66 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf
ctx.Data["Tags"] = tags
}
func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows bool) {
actorID := ctx.FormInt64("actor")
status := ctx.FormInt("status")
workflowID := ctx.FormString("workflow")
scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
branch := ctx.FormString("branch")
page := ctx.FormInt("page")
if page <= 0 {
page = 1
}
// if status or actor query param is not given to frontend href, (href="/<repoLink>/actions")
// they will be 0 by default, which indicates get all status or actors
ctx.Data["CurActor"] = actorID
ctx.Data["CurStatus"] = status
ctx.Data["CurBranch"] = branch
if actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "" {
ctx.Data["IsFiltered"] = true
}
func (data *actionRunListData) prepareFullPageRuns(ctx *context.Context, otherWorkflows []string) bool {
opts := actions_model.FindRunOptions{
ListOptions: db.ListOptions{
Page: page,
Page: max(ctx.FormInt("page"), 1),
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
},
RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflowID,
WorkflowRepoID: scopedWorkflowSourceRepoID,
TriggerUserID: actorID,
WorkflowID: data.workflowID,
WorkflowRepoID: data.scopedWorkflowSourceRepoID,
TriggerUserID: data.actorID,
}
// Constrain scoped vs repo-level only for a listed workflow, whose link carries scoped_workflow_source_repo_id.
if workflowID != "" && !slices.Contains(otherWorkflows, workflowID) {
opts.IsScopedRun = optional.Some(scopedWorkflowSourceRepoID > 0)
if data.workflowID != "" && !slices.Contains(otherWorkflows, data.workflowID) {
opts.IsScopedRun = optional.Some(data.scopedWorkflowSourceRepoID > 0)
}
// if status is not StatusUnknown, it means user has selected a status filter
if actions_model.Status(status) != actions_model.StatusUnknown {
opts.Status = []actions_model.Status{actions_model.Status(status)}
if data.status != actions_model.StatusUnknown {
opts.Status = []actions_model.Status{data.status}
}
if branch != "" {
opts.Ref = string(git.RefNameFromBranch(branch))
if data.branch != "" {
opts.Ref = string(git.RefNameFromBranch(data.branch))
}
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil {
ctx.ServerError("FindAndCount", err)
return
return false
}
data.pager = context.NewPagination(total, opts.PageSize, opts.Page, 5)
data.pager.AddParamFromRequest(ctx.Req)
data.ActionRuns = runs
return true
}
for _, run := range runs {
run.Repo = ctx.Repo.Repository
}
type actionRunListData struct {
actorID int64
status actions_model.Status
workflowID string
workflowDisplayName string
scopedWorkflowSourceRepoID int64
branch string
refreshRunIDs []int64
partialRefresh bool
pager *context.Pagination
if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil {
ctx.ServerError("LoadTriggerUser", err)
return
}
if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, runs); err != nil {
log.Error("LoadIsRefDeleted", err)
}
// the following fields are used by the template
RefreshLink template.URL
RefreshIntervalMs int64
ActionRuns []*actions_model.ActionRun
IsFiltered bool
WorkflowNames map[string]string
RunErrors map[int64]string
CurWorkflow string
CanWriteRepoUnitActions bool
}
func (data *actionRunListData) processActionRuns(ctx *context.Context) bool {
// Check for each run if there is at least one online runner that can run its jobs
runErrors := make(map[int64]string)
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
RepoID: ctx.Repo.Repository.ID,
IsOnline: optional.Some(true),
@@ -524,28 +540,30 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
})
if err != nil {
ctx.ServerError("FindRunners", err)
return
return false
}
for _, run := range runs {
data.RunErrors = make(map[int64]string)
for _, run := range data.ActionRuns {
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) {
continue
}
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
return
return false
}
for _, job := range jobs {
if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) {
continue
}
if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
break
}
if job.CallUses != "" {
if _, err := actions_service.ResolveUses(ctx, job.CallUses); err != nil {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
break
}
}
@@ -558,54 +576,132 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
}
}
if !hasOnlineRunner {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
break
}
}
}
}
ctx.Data["RunErrors"] = runErrors
return true
}
ctx.Data["Runs"] = runs
func (data *actionRunListData) fillRefreshMeta(ctx *context.Context) bool {
hasActiveRuns := false
var actionRunIDs []int64
for _, run := range data.ActionRuns {
if !hasActiveRuns && run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) {
hasActiveRuns = true
}
actionRunIDs = append(actionRunIDs, run.ID)
}
data.RefreshIntervalMs = util.Iif[int64](hasActiveRuns, 3*1000, 12*1000)
if !setting.IsProd {
data.RefreshIntervalMs = util.Iif[int64](hasActiveRuns, 1000, 2*1000) // faster in dev mode to make debug easier
}
if len(data.ActionRuns) == 0 {
data.RefreshIntervalMs = 0
}
// Only refresh the items on the current page, do not keep loading "new" items.
// Otherwise, when end users are browsing the list page, they will just keep seeing "new" items coming in
// and "old" items disappearing, they won't be able to focus on an item that they are interested in, which is a bad user experience.
data.RefreshLink = templates.QueryBuild(setting.AppSubURL+ctx.Req.RequestURI, "run_ids", strings.Join(base.Int64sToStrings(actionRunIDs), ","))
return true
}
workflowNames := make(map[string]string, len(workflows))
workflowDisplayName := workflowID
func (data *actionRunListData) fillWorkflowNames(workflows []WorkflowInfo) bool {
data.WorkflowNames = make(map[string]string, len(workflows))
data.workflowDisplayName = data.workflowID
for _, wf := range workflows {
displayName := wf.DisplayName()
workflowNames[wf.Entry.Name()] = displayName
if wf.Entry.Name() == workflowID {
workflowDisplayName = displayName
data.WorkflowNames[wf.EntryName] = displayName
if wf.EntryName == data.workflowID {
data.workflowDisplayName = displayName
}
}
ctx.Data["WorkflowNames"] = workflowNames
// A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs),
// so don't offer the "create status badge" entry for it.
if scopedWorkflowSourceRepoID == 0 {
prepareWorkflowBadgeTemplate(ctx, workflowID, workflowDisplayName)
return true
}
func prepareWorkflowList(ctx *context.Context, curWorkflowID string) *actionRunListData {
data := &actionRunListData{}
data.actorID = ctx.FormInt64("actor")
data.status = actions_model.Status(ctx.FormInt("status"))
data.workflowID = ctx.FormString("workflow")
data.scopedWorkflowSourceRepoID = ctx.FormInt64("scoped_workflow_source_repo_id")
data.branch = ctx.FormString("branch")
data.refreshRunIDs = ctx.FormStringInt64s("run_ids")
data.partialRefresh = ctx.Req.Form.Has("run_ids")
data.IsFiltered = data.actorID > 0 || data.status != actions_model.StatusUnknown || data.branch != ""
data.CurWorkflow = curWorkflowID
data.CanWriteRepoUnitActions = ctx.Repo.Permission.CanWrite(unit.TypeActions)
return data
}
func (data *actionRunListData) preparePartialRefreshRuns(ctx *context.Context) bool {
runs, err := actions_model.GetRunsByRepoAndID(ctx, ctx.Repo.Repository.ID, data.refreshRunIDs)
if err != nil {
ctx.ServerError("GetRunsByRepoAndID", err)
return false
}
data.ActionRuns = runs
return true
}
func (data *actionRunListData) prepareCommon(ctx *context.Context, workflows []WorkflowInfo) bool {
for _, run := range data.ActionRuns {
run.Repo = ctx.Repo.Repository
}
if err := actions_model.RunList(data.ActionRuns).LoadTriggerUser(ctx); err != nil {
ctx.ServerError("LoadTriggerUser", err)
return false
}
if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, data.ActionRuns); err != nil {
log.Error("LoadIsRefDeleted", err)
}
if !data.processActionRuns(ctx) {
return false
}
if !data.fillRefreshMeta(ctx) {
return false
}
if !data.fillWorkflowNames(workflows) {
return false
}
ctx.Data["ActionRunListData"] = data
return true
}
func (data *actionRunListData) prepareFullPageTemplate(ctx *context.Context) bool {
// A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs),
// so don't offer the "create status badge" entry for it.
if data.scopedWorkflowSourceRepoID == 0 {
prepareWorkflowBadgeTemplate(ctx, data.workflowID, data.workflowDisplayName)
}
// fill template data
actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetActors", err)
return
return false
}
ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors)
ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale)
runBranches, err := actions_model.GetRunBranches(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetRunBranches", err)
return
return false
}
ctx.Data["RunBranches"] = runBranches
pager := context.NewPagination(total, opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0 || hasScopedWorkflows
ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions)
ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale)
ctx.Data["CurActor"] = data.actorID
ctx.Data["CurStatus"] = int(data.status) // don't make template use its "String" method, otherwise the query parameter would be wrong
ctx.Data["CurBranch"] = data.branch
ctx.Data["CurWorkflowRepoID"] = data.scopedWorkflowSourceRepoID
ctx.Data["Page"] = data.pager
return true
}
func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) {
+4 -2
View File
@@ -1,7 +1,9 @@
{{$paginationParams := .Page.GetParams}}
{{$page := or $.Page ctx.RootData.Page}}
{{$paginationLink := $.Link}}
{{if eq $paginationLink nil}}{{$paginationLink = ctx.RootData.Link}}{{end}}
{{if eq $paginationLink AppSubUrl}}{{$paginationLink = print $paginationLink "/"}}{{end}}
{{with .Page.Paginater}}
{{$paginationParams := $page.GetParams}}
{{with $page.Paginater}}
{{if or (eq .TotalPages -1) (gt .TotalPages 1)}}
{{$showFirstLast := gt .TotalPages 1}}
<div class="center page buttons">
+4 -3
View File
@@ -14,14 +14,14 @@
<div class="ui fluid vertical menu">
<a class="item {{if not $.CurWorkflow}}active{{end}}" href="?actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">{{ctx.Locale.Tr "actions.runs.all_workflows"}}</a>
{{range .workflows}}
<a class="item flex-text-block {{if and (eq .Entry.Name $.CurWorkflow) (not $.CurWorkflowScopedRepoID)}}active{{end}}" href="?workflow={{.Entry.Name}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
<a class="item flex-text-block {{if and (eq .EntryName $.CurWorkflow) (not $.CurWorkflowScopedRepoID)}}active{{end}}" href="?workflow={{.EntryName}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
<span class="gt-ellipsis" data-tooltip-content="{{.DisplayName}}">{{.DisplayName}}</span>
{{if .ErrMsg}}
<span class="flex-text-inline tw-shrink-0" data-tooltip-content="{{.ErrMsg}}">{{svg "octicon-alert" 16 "tw-text-red"}}</span>
{{end}}
{{if $.ActionsConfig.IsWorkflowDisabled .Entry.Name}}
{{if $.ActionsConfig.IsWorkflowDisabled .EntryName}}
<div class="ui red label tw-shrink-0">{{ctx.Locale.Tr "disabled"}}</div>
{{end}}
</a>
@@ -153,7 +153,8 @@
{{end}}
<div class="ui attached segment">
{{template "repo/actions/runs_list" .}}
{{template "repo/actions/runs_list" dict "ActionRunListData" $.ActionRunListData}}
{{template "base/paginate" dict "Page" $.Page}}
</div>
</div>
</div>
+16 -13
View File
@@ -1,12 +1,16 @@
<div class="flex-divided-list items-with-main run-list">
{{if not .Runs}}
{{$data := .ActionRunListData}}
<div class="flex-divided-list items-with-main run-list" data-global-init="initActionRunsList"
data-action-runs-refresh-link="{{$data.RefreshLink}}"
data-action-runs-refresh-interval="{{$data.RefreshIntervalMs}}"
>
{{if not $data.ActionRuns}}
<div class="empty-placeholder">
{{svg "octicon-no-entry" 48}}
<h2>{{if $.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}</h2>
<h2>{{if $data.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}</h2>
</div>
{{end}}
{{range $run := .Runs}}
<div class="item tw-items-center">
{{range $run := $data.ActionRuns}}
<div id="action-run-item-{{$run.ID}}" class="item tw-items-center" data-status="{{$run.Status.String}}">
<div class="item-leading">
<span data-tooltip-content="{{ctx.Locale.Tr (printf "actions.status.%s" $run.Status.String)}}">
{{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}}
@@ -15,22 +19,22 @@
<div class="item-main">
<div class="item-title">
{{$title := or $run.Title (ctx.Locale.Tr "actions.runs.empty_commit_message")}}
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $title $run.Link $.Repository}}
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $title $run.Link $run.Repo}}
</div>
<div class="item-body">
{{$workflowName := index $.WorkflowNames $run.WorkflowID}}
<span><b>{{if not $.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}</b>:</span>
{{$workflowName := index $data.WorkflowNames $run.WorkflowID}}
<span><b>{{if not $data.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}</b>:</span>
{{- if $run.ScheduleID -}}
{{ctx.Locale.Tr "actions.runs.scheduled"}}
{{- else -}}
{{ctx.Locale.Tr "actions.runs.commit"}}
<a href="{{$.RepoLink}}/commit/{{$run.CommitSHA}}">{{ShortSha $run.CommitSHA}}</a>
<a href="{{$run.Repo.Link}}/commit/{{$run.CommitSHA}}">{{ShortSha $run.CommitSHA}}</a>
{{ctx.Locale.Tr "actions.runs.pushed_by"}}
<a href="{{$run.TriggerUser.HomeLink}}">{{$run.TriggerUser.GetDisplayName}}</a>
{{- end -}}
{{$errMsg := index $.RunErrors $run.ID}}
{{$errMsg := index $data.RunErrors $run.ID}}
{{if $errMsg}}
<span class="flex-text-inline" data-tooltip-content="{{$errMsg}}">
{{svg "octicon-alert" 16 "tw-text-red"}}
@@ -52,12 +56,12 @@
{{svg "octicon-kebab-horizontal"}}
<div class="menu flex-items-menu">
<a class="item" href="{{$run.Link}}/workflow">{{svg "octicon-play"}}{{ctx.Locale.Tr "actions.runs.view_workflow_file"}}</a>
{{if and $.CanWriteRepoUnitActions (not $run.Status.IsDone)}}
{{if and $data.CanWriteRepoUnitActions (not $run.Status.IsDone)}}
<a class="item link-action" data-url="{{$run.Link}}/cancel">
{{svg "octicon-x"}}{{ctx.Locale.Tr "actions.runs.cancel"}}
</a>
{{end}}
{{if and $.CanWriteRepoUnitActions $run.Status.IsDone}}
{{if and $data.CanWriteRepoUnitActions $run.Status.IsDone}}
<a class="item link-action"
data-url="{{$run.Link}}/delete"
data-modal-confirm="{{ctx.Locale.Tr "actions.runs.delete.description"}}"
@@ -71,4 +75,3 @@
</div>
{{end}}
</div>
{{template "base/paginate" .}}
@@ -37,7 +37,7 @@
</div>
{{end}}
{{range .workflows}}
{{if and .ErrMsg (eq .Entry.Name $.CurWorkflow)}}
{{if and .ErrMsg (eq .EntryName $.CurWorkflow)}}
<div class="ui field">
<div>{{svg "octicon-alert" 16 "tw-text-red"}} {{.ErrMsg}}</div>
</div>
+29
View File
@@ -2,6 +2,9 @@ import {createApp} from 'vue';
import RepoActionView from '../components/RepoActionView.vue';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {html} from '../utils/html.ts';
import {GET} from '../modules/fetch.ts';
import {activePageTimerRefresh, createElementFromHTML, protectMorphElements, recoverMorphElements} from '../utils/dom.ts';
import {Idiomorph} from 'idiomorph';
export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void {
const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!);
@@ -27,6 +30,7 @@ function initWorkflowBadgeForm(form: HTMLElement): void {
export function initRepositoryActions() {
registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm);
initRepositoryActionsView();
registerGlobalInitFunc('initActionRunsList', initActionRunsList);
}
function initRepositoryActionsView() {
@@ -103,3 +107,28 @@ function initRepositoryActionsView() {
});
view.mount(el);
}
function initActionRunsList(el: HTMLElement) {
activePageTimerRefresh({
interval: () => Number(el.getAttribute('data-action-runs-refresh-interval')),
async callback() {
const resp = await GET(el.getAttribute('data-action-runs-refresh-link')!);
if (!resp.ok || resp.status !== 200) return;
const newEl = createElementFromHTML(await resp.text());
for (const attr of newEl.attributes) el.setAttribute(attr.name, attr.value);
for (const newItem of newEl.querySelectorAll(':scope > .item')) {
const oldItem = el.querySelector(`#${newItem.id}`);
if (!oldItem) continue;
// If the end user is operating the row, then don't refresh its content.
// Otherwise, there will be more edge cases and inconsistencies, e.g.: dropdown still shows old items but the icon has changed.
if (oldItem.querySelector('.ui.dropdown.active')) continue;
const protectedElems = protectMorphElements(newItem);
Idiomorph.morph(oldItem, newItem, {morphStyle: 'outerHTML'});
recoverMorphElements(el.querySelector(`#${newItem.id}`)!, protectedElems);
}
},
});
}
+19 -4
View File
@@ -1,8 +1,7 @@
import {
createElementFromAttrs,
createElementFromHTML,
queryElemChildren,
querySingleVisibleElem,
createElementFromAttrs, createElementFromHTML,
queryElemChildren, querySingleVisibleElem,
protectMorphElements, recoverMorphElements,
toggleElem,
} from './dom.ts';
@@ -54,3 +53,19 @@ test('toggleElem', () => {
toggleElem(el.children, true);
expect(el.outerHTML).toEqual('<div><div class="">a</div><div class="">b</div></div>');
});
test('protectMorphElements', () => {
const el = createElementFromHTML('<div><span data-morph-protect="">foo</span></div>');
const protectedElems = protectMorphElements(el);
const span = el.querySelector('span')!;
const spanMorphProtectId = span.getAttribute('data-morph-protect');
expect(spanMorphProtectId).toBeTruthy();
expect(el.outerHTML).toEqual(`<div><span data-morph-protect="${spanMorphProtectId}">foo</span></div>`);
span.textContent = 'bar';
span.classList.add('new-class');
expect(el.outerHTML).toEqual(`<div><span data-morph-protect="${spanMorphProtectId}" class="new-class">bar</span></div>`);
recoverMorphElements(el, protectedElems);
expect(el.outerHTML).toEqual('<div><span data-morph-protect="">foo</span></div>');
});
+23
View File
@@ -411,3 +411,26 @@ export function activePageTimerRefresh(opts: ActivePageTimerOptions): ActivePage
timer.start();
return timer;
}
type ProtectedMorphElements = Record<string, string>;
export function protectMorphElements(el: Element): ProtectedMorphElements {
// Some components like Dropdown manage their internal states and styles.
// If morph changes any style or layout, then the Dropdown will stop working.
// So, protect such fragile components ahead, then morph, then recover.
const ret: ProtectedMorphElements = {};
for (const it of el.querySelectorAll('.ui.dropdown, [data-morph-protect]')) {
const id = generateElemId();
ret[id] = it.outerHTML;
it.setAttribute('data-morph-protect', id);
}
return ret;
}
export function recoverMorphElements(el: Element, protectedElems: ProtectedMorphElements) {
for (const [id, html] of Object.entries(protectedElems)) {
const it = el.querySelector(`[data-morph-protect="${CSS.escape(id)}"]`);
if (!it) continue;
it.outerHTML = html;
}
}