Merge branch 'forgejo' into upload_with_path_structure
Some checks failed
Integration tests for the release process / release-simulation (push) Has been cancelled

This commit is contained in:
David Rotermund 2025-06-23 17:23:40 +02:00
commit c3b559b79b
82 changed files with 2730 additions and 512 deletions

View file

@ -13,6 +13,13 @@ forgejo.org/models
IsErrSHANotFound
IsErrMergeDivergingFastForwardOnly
forgejo.org/models/activities
GetActivityByID
NewFederatedUserActivity
CreateUserActivity
GetFollowingFeeds
FederatedUserActivity.loadActor
forgejo.org/models/auth
WebAuthnCredentials
@ -54,9 +61,17 @@ forgejo.org/models/user
IsErrExternalLoginUserAlreadyExist
IsErrExternalLoginUserNotExist
NewFederatedUser
NewFederatedUserFollower
IsErrUserSettingIsNotExist
GetUserAllSettings
DeleteUserSetting
GetFederatedUser
GetFederatedUserByUserID
UpdateFederatedUser
GetFollowersForUser
AddFollower
RemoveFollower
IsFollowingAp
forgejo.org/modules/activitypub
NewContext

View file

@ -28,7 +28,7 @@ jobs:
runs-on: docker
container:
image: data.forgejo.org/renovate/renovate:40.57.1
image: data.forgejo.org/renovate/renovate:41.1.4
steps:
- name: Load renovate repo cache

View file

@ -115,6 +115,11 @@ jobs:
run: |
su forgejo -c 'make deps-frontend frontend'
- uses: ./.forgejo/workflows-composite/build-backend
- name: Decide to run all tests
id: run-all
if: contains(github.event.pull_request.labels.*.name, 'run-all-playwright-tests') || contains(github.event.pull_request.title, 'playwright')
run: |
echo "all=1" >> "$GITHUB_OUTPUT"
- name: Get changed files
id: changed-files
uses: https://data.forgejo.org/tj-actions/changed-files@v46
@ -127,6 +132,7 @@ jobs:
USE_REPO_TEST_DIR: 1
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
CHANGED_FILES: ${{steps.changed-files.outputs.all_changed_files}}
RUN_ALL: ${{steps.run-all.all}}
- name: Upload test artifacts on failure
if: failure()
uses: https://data.forgejo.org/forgejo/upload-artifact@v4

View file

@ -47,7 +47,7 @@ GO_LICENSES_PACKAGE ?= github.com/google/go-licenses@v1.6.0 # renovate: datasour
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 # renovate: datasource=go
DEADCODE_PACKAGE ?= golang.org/x/tools/cmd/deadcode@v0.34.0 # renovate: datasource=go
GOMOCK_PACKAGE ?= go.uber.org/mock/mockgen@v0.5.2 # renovate: datasource=go
RENOVATE_NPM_PACKAGE ?= renovate@40.57.1 # renovate: datasource=docker packageName=data.forgejo.org/renovate/renovate
RENOVATE_NPM_PACKAGE ?= renovate@41.1.4 # renovate: datasource=docker packageName=data.forgejo.org/renovate/renovate
# https://github.com/disposable-email-domains/disposable-email-domains/commits/main/
DISPOSABLE_EMAILS_SHA ?= 0c27e671231d27cf66370034d7f6818037416989 # renovate: ...

2
go.mod
View file

@ -41,7 +41,7 @@ require (
github.com/gliderlabs/ssh v0.3.8
github.com/go-ap/activitypub v0.0.0-20231114162308-e219254dc5c9
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/chi/v5 v5.2.2
github.com/go-chi/cors v1.2.1
github.com/go-co-op/gocron v1.37.0
github.com/go-enry/go-enry/v2 v2.9.2

4
go.sum
View file

@ -213,8 +213,8 @@ github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5La
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=

View file

@ -55,6 +55,7 @@ type ActionRun struct {
PreviousDuration time.Duration
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
NotifyEmail bool
}
func init() {

View file

@ -442,6 +442,12 @@ func (a *Action) GetIssueContent(ctx context.Context) string {
return a.Issue.Content
}
func GetActivityByID(ctx context.Context, id int64) (*Action, error) {
var act Action
_, err := db.GetEngine(ctx).ID(id).Get(&act)
return &act, err
}
// GetFeedsOptions options for retrieving feeds
type GetFeedsOptions struct {
db.ListOptions
@ -595,13 +601,14 @@ func DeleteOldActions(ctx context.Context, olderThan time.Duration) (err error)
}
// NotifyWatchers creates batch of actions for every watcher.
func NotifyWatchers(ctx context.Context, actions ...*Action) error {
func NotifyWatchers(ctx context.Context, actions ...*Action) ([]Action, error) {
var watchers []*repo_model.Watch
var repo *repo_model.Repository
var err error
var permCode []bool
var permIssue []bool
var permPR []bool
var out []Action
e := db.GetEngine(ctx)
@ -612,14 +619,14 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
// Add feeds for user self and all watchers.
watchers, err = repo_model.GetWatchers(ctx, act.RepoID)
if err != nil {
return fmt.Errorf("get watchers: %w", err)
return nil, fmt.Errorf("get watchers: %w", err)
}
// Be aware that optimizing this correctly into the `GetWatchers` SQL
// query is for most cases less performant than doing this.
blockedDoerUserIDs, err := user_model.ListBlockedByUsersID(ctx, act.ActUserID)
if err != nil {
return fmt.Errorf("user_model.ListBlockedByUsersID: %w", err)
return nil, fmt.Errorf("user_model.ListBlockedByUsersID: %w", err)
}
if len(blockedDoerUserIDs) > 0 {
@ -634,8 +641,9 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
// Add feed for actioner.
act.UserID = act.ActUserID
if _, err = e.Insert(act); err != nil {
return fmt.Errorf("insert new actioner: %w", err)
return nil, fmt.Errorf("insert new actioner: %w", err)
}
out = append(out, *act)
if repoChanged {
act.loadRepo(ctx)
@ -643,7 +651,7 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
// check repo owner exist.
if err := act.Repo.LoadOwner(ctx); err != nil {
return fmt.Errorf("can't get repo owner: %w", err)
return nil, fmt.Errorf("can't get repo owner: %w", err)
}
} else if act.Repo == nil {
act.Repo = repo
@ -654,7 +662,7 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
act.ID = 0
act.UserID = act.Repo.Owner.ID
if err = db.Insert(ctx, act); err != nil {
return fmt.Errorf("insert new actioner: %w", err)
return nil, fmt.Errorf("insert new actioner: %w", err)
}
}
@ -707,26 +715,29 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
}
if err = db.Insert(ctx, act); err != nil {
return fmt.Errorf("insert new action: %w", err)
return nil, fmt.Errorf("insert new action: %w", err)
}
}
}
return nil
return out, nil
}
// NotifyWatchersActions creates batch of actions for every watcher.
func NotifyWatchersActions(ctx context.Context, acts []*Action) error {
func NotifyWatchersActions(ctx context.Context, acts []*Action) ([]Action, error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
return nil, err
}
defer committer.Close()
var out []Action
for _, act := range acts {
if err := NotifyWatchers(ctx, act); err != nil {
return err
as, err := NotifyWatchers(ctx, act)
if err != nil {
return nil, err
}
out = append(out, as...)
}
return committer.Commit()
return out, committer.Commit()
}
// DeleteIssueActions delete all actions related with issueID

View file

@ -197,7 +197,8 @@ func TestNotifyWatchers(t *testing.T) {
RepoID: 1,
OpType: activities_model.ActionStarRepo,
}
require.NoError(t, activities_model.NotifyWatchers(db.DefaultContext, action))
_, err := activities_model.NotifyWatchers(db.DefaultContext, action)
require.NoError(t, err)
// One watchers are inactive, thus action is only created for user 8, 1, 4, 11
unittest.AssertExistsAndLoadBean(t, &activities_model.Action{

View file

@ -0,0 +1,106 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activities
import (
"context"
"fmt"
"forgejo.org/models/db"
user_model "forgejo.org/models/user"
"forgejo.org/modules/json"
"forgejo.org/modules/log"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/validation"
ap "github.com/go-ap/activitypub"
)
type FederatedUserActivity struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL"`
ActorID int64
ActorURI string
Actor *user_model.User `xorm:"-"` // transient
NoteContent string `xorm:"TEXT"`
NoteURL string `xorm:"VARCHAR(255)"`
OriginalNote string `xorm:"TEXT"`
Created timeutil.TimeStamp `xorm:"created"`
}
func init() {
db.RegisterModel(new(FederatedUserActivity))
}
func NewFederatedUserActivity(userID, actorID int64, actorURI, noteContent, noteURL string, originalNote ap.Activity) (FederatedUserActivity, error) {
jsonString, err := json.Marshal(originalNote)
if err != nil {
return FederatedUserActivity{}, err
}
result := FederatedUserActivity{
UserID: userID,
ActorID: actorID,
ActorURI: actorURI,
NoteContent: noteContent,
NoteURL: noteURL,
OriginalNote: string(jsonString),
}
if valid, err := validation.IsValid(result); !valid {
return FederatedUserActivity{}, err
}
return result, nil
}
func (federatedUserActivity FederatedUserActivity) Validate() []string {
var result []string
result = append(result, validation.ValidateNotEmpty(federatedUserActivity.UserID, "UserID")...)
result = append(result, validation.ValidateNotEmpty(federatedUserActivity.ActorID, "ActorID")...)
result = append(result, validation.ValidateNotEmpty(federatedUserActivity.ActorURI, "ActorURI")...)
result = append(result, validation.ValidateNotEmpty(federatedUserActivity.NoteContent, "NoteContent")...)
result = append(result, validation.ValidateNotEmpty(federatedUserActivity.NoteURL, "NoteURL")...)
result = append(result, validation.ValidateNotEmpty(federatedUserActivity.OriginalNote, "OriginalNote")...)
return result
}
func CreateUserActivity(ctx context.Context, federatedUserActivity *FederatedUserActivity) error {
if valid, err := validation.IsValid(federatedUserActivity); !valid {
return err
}
_, err := db.GetEngine(ctx).Insert(federatedUserActivity)
return err
}
type GetFollowingFeedsOptions struct {
db.ListOptions
}
func GetFollowingFeeds(ctx context.Context, actorID int64, opts GetFollowingFeedsOptions) ([]*FederatedUserActivity, int64, error) {
log.Debug("user_id = %s", actorID)
sess := db.GetEngine(ctx).Where("user_id = ?", actorID)
opts.SetDefaultValues()
sess = db.SetSessionPagination(sess, &opts)
actions := make([]*FederatedUserActivity, 0, opts.PageSize)
count, err := sess.FindAndCount(&actions)
if err != nil {
return nil, 0, fmt.Errorf("FindAndCount: %w", err)
}
for _, act := range actions {
if err := act.loadActor(ctx); err != nil {
return nil, 0, err
}
}
return actions, count, err
}
func (federatedUserActivity *FederatedUserActivity) loadActor(ctx context.Context) error {
log.Debug("for activity %s", federatedUserActivity)
actorUser, _, err := user_model.GetFederatedUserByUserID(ctx, federatedUserActivity.ActorID)
if err != nil {
return err
}
federatedUserActivity.Actor = actorUser
return nil
}

View file

@ -0,0 +1,24 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activities
import (
"testing"
"forgejo.org/modules/validation"
)
func Test_FederatedUserActivityValidation(t *testing.T) {
sut := FederatedUserActivity{}
sut.UserID = 13
sut.ActorID = 33
sut.ActorURI = "33"
sut.NoteContent = "Any content!"
sut.NoteURL = "https://example.org/note/17"
sut.OriginalNote = "federatedUserActivityNote-17"
if res, _ := validation.IsValid(sut); !res {
t.Errorf("sut expected to be valid: %v\n", sut.Validate())
}
}

View file

@ -153,3 +153,304 @@
issue_id: 19 # in repo_id 58
content: '{"is_force_push":true,"commit_ids":["1978192d98bb1b65e11c2cf37da854fbf94bffd6", "9b93963cf6de4dc33f915bb67f192d099c301f43"]}'
created_unix: 1749734240
-
id: 2000
type: 8 # milestone
poster_id: 1
issue_id: 1 # in repo_id 1
milestone_id: 1
old_milestone_id: 0
created_unix: 946684820
-
id: 2001
type: 8 # milestone
poster_id: 1
issue_id: 1 # in repo_id 1
milestone_id: 2
old_milestone_id: 1
created_unix: 946684920
-
id: 2002
type: 8 # milestone
poster_id: 1
issue_id: 1 # in repo_id 1
milestone_id: 0
old_milestone_id: 2
created_unix: 946685020
-
id: 2003
type: 8 # milestone
poster_id: 1
issue_id: 1 # in repo_id 1
milestone_id: 10 # not exsting milestone
old_milestone_id: 0
created_unix: 946685080
-
id: 2010
type: 30 # project
poster_id: 1
issue_id: 1 # in repo_id 1
project_id: 1
old_project_id: 0
created_unix: 946685120
-
id: 2011
type: 30 # project
poster_id: 1
issue_id: 1 # in repo_id 1
project_id: 2
old_project_id: 1
created_unix: 946685220
-
id: 2012
type: 30 # project
poster_id: 1
issue_id: 1 # in repo_id 1
project_id: 0
old_project_id: 2
created_unix: 946685320
-
id: 2013
type: 30 # project
poster_id: 1
issue_id: 1 # in repo_id 1
project_id: 10 # not existing project
old_project_id: 0
created_unix: 946685420
-
id: 2020
type: 7 # label
poster_id: 1
issue_id: 1 # in repo_id 1
label_id: 1
content: 1 # add label
created_unix: 946685520
-
id: 2021
type: 7 # label
poster_id: 1
issue_id: 1
label_id: 2
content: 1 # add label
created_unix: 946685620
-
id: 2022
type: 7 # label
poster_id: 2
issue_id: 1 # in repo_id 1
label_id: 1
content: 0 # remove label
created_unix: 946685720
-
id: 2023
type: 7 # label
poster_id: 1
issue_id: 1 # in repo_id 1
label_id: 1
content: 1 # add label
created_unix: 946685720
-
id: 2024
type: 7 # label
poster_id: 1
issue_id: 1 # in repo_id 1
label_id: 2
content: 0 # remove label
created_unix: 946685720
-
id: 2025
type: 7 # label
poster_id: 2
issue_id: 1 # in repo_id 1
label_id: 2
content: 1 # add label
created_unix: 946685820
-
id: 2026
type: 7 # label
poster_id: 1
issue_id: 1 # in repo_id 1
label_id: 1
content: 0 # remove label
created_unix: 946685920
-
id: 2027
type: 7 # label
poster_id: 1
issue_id: 1 # in repo_id 1
label_id: 2
content: 0 # remove label
created_unix: 946685920
- id: 2040
type: 9 # assignee
poster_id: 1
issue_id: 1 # in repo_id 1
assignee_id: 1 # self
removed_assignee: false # add assignee
created_unix: 946688020
- id: 2041
type: 9 # assignee
poster_id: 2
issue_id: 1 # in repo_id 1
assignee_id: 1
removed_assignee: true # remove assignee
created_unix: 946688120
- id: 2042
type: 9 # assignee
poster_id: 1
issue_id: 1 # in repo_id 1
assignee_id: 2
removed_assignee: false # add assignee
created_unix: 946688220
- id: 2043
type: 9 # assignee
poster_id: 2
issue_id: 1 # in repo_id 1
assignee_id: 2 # self
removed_assignee: true # remove assignee
created_unix: 946688320
- id: 2050
type: 23 # lock
poster_id: 1
issue_id: 1 # in repo_id 1
created_unix: 946688420
- id: 2051
type: 24 # unlock
poster_id: 1
issue_id: 1 # in repo_id 1
created_unix: 946688520
- id: 2052
type: 23 # lock
poster_id: 1
issue_id: 1 # in repo_id 1
content: "Too heated"
created_unix: 946688620
- id: 2053
type: 24 # unlock
poster_id: 1
issue_id: 1 # in repo_id 1
created_unix: 946688720
- id: 2060
type: 36 # pin
poster_id: 1
issue_id: 1 # in repo_id 1
created_unix: 946688820
- id: 2061
type: 37 # unpin
poster_id: 1
issue_id: 1 # in repo_id 1
created_unix: 946688920
- id: 2070
type: 2 # close
poster_id: 1
issue_id: 1 # in repo_id 1
created_unix: 946689020
- id: 2071
type: 1 # reopen
poster_id: 2
issue_id: 1 # in repo_id 1
created_unix: 946689220
- id: 2072
type: 2 # close
poster_id: 1
issue_id: 2 # pull in repo_id 1
created_unix: 946689320
- id: 2073
type: 1 # reopen
poster_id: 2
issue_id: 2 # pull in repo_id 1
created_unix: 946689420
- id: 2080
type: 3 # issue reference
poster_id: 1
issue_id: 1 # issue in repo_id 1
ref_repo_id: 1
ref_issue_id: 5 # issue in repo_id 1
created_unix: 946689500
- id: 2081
type: 3 # issue reference
poster_id: 1
issue_id: 1 # issue in repo_id 1
ref_repo_id: 1
ref_issue_id: 2 # pull in repo_id 1
created_unix: 946689600
- id: 2082
type: 3 # issue reference
poster_id: 1
issue_id: 1 # issue in repo_id 1
ref_repo_id: 32
ref_issue_id: 16 # issue in repo_id 32
created_unix: 946689700
- id: 2083
type: 3 # issue reference
poster_id: 1
issue_id: 1 # issue in repo_id 1
ref_repo_id: 10
ref_issue_id: 8 # pull in repo_id 10
created_unix: 946689800
- id: 2090
type: 6 # pull reference
poster_id: 1
issue_id: 2 # pull in repo_id 1
ref_repo_id: 1
ref_issue_id: 1 # issue in repo_id 1
created_unix: 946689900
- id: 2091
type: 6 # pull reference
poster_id: 1
issue_id: 2 # pull in repo_id 1
ref_repo_id: 1
ref_issue_id: 2 # pull in repo_id 1
created_unix: 946690000
- id: 2092
type: 6 # pull reference
poster_id: 1
issue_id: 2 # pull in repo_id 1
ref_repo_id: 32
ref_issue_id: 16 # issue in repo_id 32
created_unix: 946690050
- id: 2093
type: 6 # pull reference
poster_id: 1
issue_id: 2 # pull in repo_id 1
ref_repo_id: 10
ref_issue_id: 8 # pull in repo_id 10
created_unix: 946690100

View file

@ -6,6 +6,7 @@ package forgefed
import (
"database/sql"
"fmt"
"net/url"
"strings"
"time"
@ -17,9 +18,9 @@ import (
// swagger:model
type FederationHost struct {
ID int64 `xorm:"pk autoincr"`
HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
HostFqdn string `xorm:"host_fqdn UNIQUE(federation_host) INDEX VARCHAR(255) NOT NULL"`
HostPort uint16 `xorm:" UNIQUE(federation_host) INDEX NOT NULL DEFAULT 443"`
NodeInfo NodeInfo `xorm:"extends NOT NULL"`
HostPort uint16 `xorm:"NOT NULL DEFAULT 443"`
HostSchema string `xorm:"NOT NULL DEFAULT 'https'"`
LatestActivity time.Time `xorm:"NOT NULL"`
KeyID sql.NullString `xorm:"key_id UNIQUE"`
@ -42,6 +43,13 @@ func NewFederationHost(hostFqdn string, nodeInfo NodeInfo, port uint16, schema s
return result, nil
}
func (host FederationHost) AsURL() url.URL {
return url.URL{
Scheme: host.HostSchema,
Host: fmt.Sprintf("%v:%v", host.HostFqdn, host.HostPort),
}
}
// Validate collects error strings in a slice and returns this
func (host FederationHost) Validate() []string {
var result []string

View file

@ -17,12 +17,14 @@ type (
)
const (
ForgejoSourceType SoftwareNameType = "forgejo"
GiteaSourceType SoftwareNameType = "gitea"
ForgejoSourceType SoftwareNameType = "forgejo"
GiteaSourceType SoftwareNameType = "gitea"
MastodonSourceType SoftwareNameType = "mastodon"
GoToSocialSourceType SoftwareNameType = "gotosocial"
)
var KnownSourceTypes = []any{
ForgejoSourceType, GiteaSourceType,
ForgejoSourceType, GiteaSourceType, MastodonSourceType, GoToSocialSourceType,
}
// ------------------------------------------------ NodeInfoWellKnown ------------------------------------------------

View file

@ -103,6 +103,12 @@ var migrations = []*Migration{
NewMigration("Normalize repository.topics to empty slice instead of null", SetTopicsAsEmptySlice),
// v31 -> v32
NewMigration("Migrate maven package name concatenation", ChangeMavenArtifactConcatenation),
// v32 -> v33
NewMigration("Add federated user activity tables, update the `federated_user` table & add indexes", FederatedUserActivityMigration),
// v33 -> v34
NewMigration("Add `notify-email` column to `action_run` table", AddNotifyEmailToActionRun),
// v34 -> v35
NewMigration("Add index to `stopped` column in `action_run` table", AddIndexToActionRunStopped),
}
// GetCurrentDBVersion returns the current Forgejo database version.

View file

@ -0,0 +1,126 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forgejo_migrations //nolint:revive
import (
"fmt"
"forgejo.org/modules/log"
"forgejo.org/modules/timeutil"
"xorm.io/xorm"
)
func dropOldFederationHostIndexes(x *xorm.Engine) {
// drop unique index on HostFqdn
type FederationHost struct {
ID int64 `xorm:"pk autoincr"`
HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
}
err := x.DropIndexes(FederationHost{})
if err != nil {
log.Warn("migration[33]: There was an issue: %v", err)
return
}
}
func addFederatedUserActivityTables(x *xorm.Engine) {
type FederatedUserActivity struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL INDEX user_id"`
ActorID int64
ActorURI string
NoteContent string `xorm:"TEXT"`
NoteURL string `xorm:"VARCHAR(255)"`
OriginalNote string `xorm:"TEXT"`
Created timeutil.TimeStamp `xorm:"created"`
}
// add unique index on HostFqdn+HostPort
type FederationHost struct {
ID int64 `xorm:"pk autoincr"`
HostFqdn string `xorm:"host_fqdn UNIQUE(federation_host) INDEX VARCHAR(255) NOT NULL"`
HostPort uint16 `xorm:"UNIQUE(federation_host) INDEX NOT NULL DEFAULT 443"`
}
type FederatedUserFollower struct {
ID int64 `xorm:"pk autoincr"`
FollowedUserID int64 `xorm:"NOT NULL unique(fuf_rel)"`
FollowingUserID int64 `xorm:"NOT NULL unique(fuf_rel)"`
}
// Add InboxPath to FederatedUser & add index fo UserID
type FederatedUser struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL INDEX user_id"`
InboxPath string
}
var err error
err = x.Sync(&FederationHost{})
if err != nil {
log.Warn("migration[33]: There was an issue: %v", err)
return
}
err = x.Sync(&FederatedUserActivity{})
if err != nil {
log.Warn("migration[33]: There was an issue: %v", err)
return
}
err = x.Sync(&FederatedUserFollower{})
if err != nil {
log.Warn("migration[33]: There was an issue: %v", err)
return
}
err = x.Sync(&FederatedUser{})
if err != nil {
log.Warn("migration[33]: There was an issue: %v", err)
return
}
// Migrate
sessMigration := x.NewSession()
defer sessMigration.Close()
if err := sessMigration.Begin(); err != nil {
log.Warn("migration[33]: There was an issue: %v", err)
return
}
federatedUsers := make([]*FederatedUser, 0)
err = sessMigration.OrderBy("id").Find(&federatedUsers)
if err != nil {
log.Warn("migration[33]: There was an issue: %v", err)
return
}
for _, federatedUser := range federatedUsers {
if federatedUser.InboxPath != "" {
log.Info("migration[33]: This user was already migrated: %v", federatedUser)
} else {
// Migrate User.InboxPath
sql := "UPDATE `federated_user` SET `inbox_path` = ? WHERE `id` = ?"
if _, err := sessMigration.Exec(sql, fmt.Sprintf("/api/v1/activitypub/user-id/%v/inbox", federatedUser.UserID), federatedUser.ID); err != nil {
log.Warn("migration[33]: There was an issue: %v", err)
return
}
}
}
err = sessMigration.Commit()
if err != nil {
log.Warn("migration[33]: There was an issue: %v", err)
return
}
}
func FederatedUserActivityMigration(x *xorm.Engine) error {
dropOldFederationHostIndexes(x)
addFederatedUserActivityTables(x)
return nil
}

View file

@ -0,0 +1,46 @@
// Copyright 2025 The Forgejo Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations //nolint:revive
import (
"testing"
"time"
migration_tests "forgejo.org/models/migrations/test"
"forgejo.org/modules/log"
ft "forgejo.org/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_FederatedUserActivityMigration(t *testing.T) {
lc, cl := ft.NewLogChecker(log.DEFAULT, log.WARN)
lc.Filter("migration[33]")
defer cl()
// intentionally conflicting definition
type FederatedUser struct {
ID int64 `xorm:"pk autoincr"`
UserID string
}
// Prepare TestEnv
x, deferable := migration_tests.PrepareTestEnv(t, 0,
new(FederatedUser),
)
sessTest := x.NewSession()
sessTest.Insert(FederatedUser{UserID: "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" +
"1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" +
"1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"})
sessTest.Commit()
defer deferable()
if x == nil || t.Failed() {
return
}
require.NoError(t, FederatedUserActivityMigration(x))
logFiltered, _ := lc.Check(5 * time.Second)
assert.NotEmpty(t, logFiltered)
}

View file

@ -0,0 +1,14 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations //nolint:revive
import "xorm.io/xorm"
func AddNotifyEmailToActionRun(x *xorm.Engine) error {
type ActionRun struct {
ID int64
NotifyEmail bool
}
return x.Sync(new(ActionRun))
}

View file

@ -0,0 +1,19 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations //nolint:revive
import (
"forgejo.org/modules/timeutil"
"xorm.io/xorm"
)
func AddIndexToActionRunStopped(x *xorm.Engine) error {
type ActionRun struct {
ID int64
Stopped timeutil.TimeStamp `xorm:"index"`
}
return x.Sync(&ActionRun{})
}

View file

@ -5,6 +5,7 @@
package issues
import (
"bufio"
"context"
"errors"
"fmt"
@ -923,31 +924,30 @@ func MergeBlockedByOutdatedBranch(protectBranch *git_model.ProtectedBranch, pr *
return protectBranch.BlockOnOutdatedBranch && pr.CommitsBehind > 0
}
// GetCodeOwnersFromContent returns the code owners configuration
// Return empty slice if files missing
// GetCodeOwnersFromReader returns the code owners configuration
// Return warning messages on parsing errors
// We're trying to do the best we can when parsing a file.
// Invalid lines are skipped. Non-existent users and teams too.
func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRule, []string) {
if len(data) == 0 {
return nil, nil
}
func GetCodeOwnersFromReader(ctx context.Context, rc io.ReadCloser, truncated bool) ([]*CodeOwnerRule, []string) {
defer rc.Close()
scanner := bufio.NewScanner(rc)
rules := make([]*CodeOwnerRule, 0)
lines := strings.Split(data, "\n")
warnings := make([]string, 0)
var rules []*CodeOwnerRule
var warnings []string
line := 0
for scanner.Scan() {
line++
for i, line := range lines {
tokens := TokenizeCodeOwnersLine(line)
tokens := TokenizeCodeOwnersLine(scanner.Text())
if len(tokens) == 0 {
continue
} else if len(tokens) < 2 {
warnings = append(warnings, fmt.Sprintf("Line: %d: incorrect format", i+1))
warnings = append(warnings, fmt.Sprintf("Line: %d: incorrect format", line))
continue
}
rule, wr := ParseCodeOwnersLine(ctx, tokens)
for _, w := range wr {
warnings = append(warnings, fmt.Sprintf("Line: %d: %s", i+1, w))
warnings = append(warnings, fmt.Sprintf("Line: %d: %s", line, w))
}
if rule == nil {
continue
@ -955,6 +955,12 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
rules = append(rules, rule)
}
if err := scanner.Err(); err != nil {
warnings = append(warnings, err.Error())
}
if truncated {
warnings = append(warnings, fmt.Sprintf("File too big: truncated while on line %d", line))
}
return rules, warnings
}

View file

@ -11,19 +11,21 @@ import (
type FederatedUser struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL"`
UserID int64 `xorm:"NOT NULL INDEX user_id"`
ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
KeyID sql.NullString `xorm:"key_id UNIQUE"`
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
NormalizedOriginalURL string // This field is just to keep original information. Pls. do not use for search or as ID!
InboxPath string
NormalizedOriginalURL string // This field is just to keep original information. Pls. do not use for search or as ID!
}
func NewFederatedUser(userID int64, externalID string, federationHostID int64, normalizedOriginalURL string) (FederatedUser, error) {
func NewFederatedUser(userID int64, externalID string, federationHostID int64, inboxPath, normalizedOriginalURL string) (FederatedUser, error) {
result := FederatedUser{
UserID: userID,
ExternalID: externalID,
FederationHostID: federationHostID,
InboxPath: inboxPath,
NormalizedOriginalURL: normalizedOriginalURL,
}
if valid, err := validation.IsValid(result); !valid {
@ -32,10 +34,11 @@ func NewFederatedUser(userID int64, externalID string, federationHostID int64, n
return result, nil
}
func (user FederatedUser) Validate() []string {
func (federatedUser FederatedUser) Validate() []string {
var result []string
result = append(result, validation.ValidateNotEmpty(user.UserID, "UserID")...)
result = append(result, validation.ValidateNotEmpty(user.ExternalID, "ExternalID")...)
result = append(result, validation.ValidateNotEmpty(user.FederationHostID, "FederationHostID")...)
result = append(result, validation.ValidateNotEmpty(federatedUser.UserID, "UserID")...)
result = append(result, validation.ValidateNotEmpty(federatedUser.ExternalID, "ExternalID")...)
result = append(result, validation.ValidateNotEmpty(federatedUser.FederationHostID, "FederationHostID")...)
result = append(result, validation.ValidateNotEmpty(federatedUser.InboxPath, "InboxPath")...)
return result
}

View file

@ -0,0 +1,30 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import "forgejo.org/modules/validation"
type FederatedUserFollower struct {
ID int64 `xorm:"pk autoincr"`
FollowedUserID int64 `xorm:"NOT NULL unique(fuf_rel)"`
FollowingUserID int64 `xorm:"NOT NULL unique(fuf_rel)"`
}
func NewFederatedUserFollower(followedUserID, federatedUserID int64) (FederatedUserFollower, error) {
result := FederatedUserFollower{
FollowedUserID: followedUserID,
FollowingUserID: federatedUserID,
}
if valid, err := validation.IsValid(result); !valid {
return FederatedUserFollower{}, err
}
return result, nil
}
func (user FederatedUserFollower) Validate() []string {
var result []string
result = append(result, validation.ValidateNotEmpty(user.FollowedUserID, "FollowedUserID")...)
result = append(result, validation.ValidateNotEmpty(user.FollowingUserID, "FollowingUserID")...)
return result
}

View file

@ -0,0 +1,27 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"testing"
"forgejo.org/modules/validation"
"github.com/stretchr/testify/assert"
)
func Test_FederatedUserFollowerValidation(t *testing.T) {
sut := FederatedUserFollower{
FollowedUserID: 12,
FollowingUserID: 1,
}
res, err := validation.IsValid(sut)
assert.Truef(t, res, "sut should be valid but was %q", err)
sut = FederatedUserFollower{
FollowedUserID: 1,
}
res, _ = validation.IsValid(sut)
assert.False(t, res, "sut should be invalid")
}

View file

@ -14,6 +14,7 @@ func Test_FederatedUserValidation(t *testing.T) {
UserID: 12,
ExternalID: "12",
FederationHostID: 1,
InboxPath: "/api/v1/activitypub/user-id/12/inbox",
}
if res, err := validation.IsValid(sut); !res {
t.Errorf("sut should be valid but was %q", err)
@ -22,6 +23,7 @@ func Test_FederatedUserValidation(t *testing.T) {
sut = FederatedUser{
ExternalID: "12",
FederationHostID: 1,
InboxPath: "/api/v1/activitypub/user-id/12/inbox",
}
if res, _ := validation.IsValid(sut); res {
t.Error("sut should be invalid")

View file

@ -11,6 +11,7 @@ import (
)
// Follow represents relations of user and their followers.
// TODO: We should unify Activity-pub-following and classical following (see models/user/user_repository.go#IsFollowingAp)
type Follow struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(follow)"`

View file

@ -1,4 +1,4 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// Copyright 2024, 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
@ -8,12 +8,14 @@ import (
"fmt"
"forgejo.org/models/db"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/validation"
)
func init() {
db.RegisterModel(new(FederatedUser))
db.RegisterModel(new(FederatedUserFollower))
}
func CreateFederatedUser(ctx context.Context, user *User, federatedUser *FederatedUser) error {
@ -30,7 +32,12 @@ func CreateFederatedUser(ctx context.Context, user *User, federatedUser *Federat
if err != nil {
return err
}
defer committer.Close()
defer func() {
err := committer.Close()
if err != nil {
log.Error("Error closing committer: %v", err)
}
}()
if err := CreateUser(ctx, user, &overwrite); err != nil {
return err
@ -50,6 +57,14 @@ func CreateFederatedUser(ctx context.Context, user *User, federatedUser *Federat
return committer.Commit()
}
func (federatedUser *FederatedUser) UpdateFederatedUser(ctx context.Context) error {
if _, err := validation.IsValid(federatedUser); err != nil {
return err
}
_, err := db.GetEngine(ctx).ID(federatedUser.ID).Cols("inbox_path").Update(federatedUser)
return err
}
func FindFederatedUser(ctx context.Context, externalID string, federationHostID int64) (*User, *FederatedUser, error) {
federatedUser := new(FederatedUser)
user := new(User)
@ -75,6 +90,41 @@ func FindFederatedUser(ctx context.Context, externalID string, federationHostID
return user, federatedUser, nil
}
func GetFederatedUser(ctx context.Context, externalID string, federationHostID int64) (*User, *FederatedUser, error) {
user, federatedUser, err := FindFederatedUser(ctx, externalID, federationHostID)
if err != nil {
return nil, nil, err
} else if federatedUser == nil {
return nil, nil, fmt.Errorf("FederatedUser for externalId = %v and federationHostId = %v does not exist", externalID, federationHostID)
}
return user, federatedUser, nil
}
func GetFederatedUserByUserID(ctx context.Context, userID int64) (*User, *FederatedUser, error) {
federatedUser := new(FederatedUser)
user := new(User)
has, err := db.GetEngine(ctx).Where("user_id=?", userID).Get(federatedUser)
if err != nil {
return nil, nil, err
} else if !has {
return nil, nil, fmt.Errorf("Federated user %v does not exist", federatedUser.UserID)
}
has, err = db.GetEngine(ctx).ID(federatedUser.UserID).Get(user)
if err != nil {
return nil, nil, err
} else if !has {
return nil, nil, fmt.Errorf("User %v for federated user is missing", federatedUser.UserID)
}
if res, err := validation.IsValid(*user); !res {
return nil, nil, err
}
if res, err := validation.IsValid(*federatedUser); !res {
return nil, nil, err
}
return user, federatedUser, nil
}
func FindFederatedUserByKeyID(ctx context.Context, keyID string) (*User, *FederatedUser, error) {
federatedUser := new(FederatedUser)
user := new(User)
@ -101,7 +151,85 @@ func FindFederatedUserByKeyID(ctx context.Context, keyID string) (*User, *Federa
return user, federatedUser, nil
}
func UpdateFederatedUser(ctx context.Context, federatedUser *FederatedUser) error {
if res, err := validation.IsValid(federatedUser); !res {
return err
}
_, err := db.GetEngine(ctx).ID(federatedUser.ID).Update(federatedUser)
return err
}
func DeleteFederatedUser(ctx context.Context, userID int64) error {
_, err := db.GetEngine(ctx).Delete(&FederatedUser{UserID: userID})
return err
}
func GetFollowersForUser(ctx context.Context, user *User) ([]*FederatedUserFollower, error) {
if res, err := validation.IsValid(user); !res {
return nil, err
}
followers := make([]*FederatedUserFollower, 0, 8)
err := db.GetEngine(ctx).
Where("followed_user_id = ?", user.ID).
Find(&followers)
if err != nil {
return nil, err
}
for _, element := range followers {
if res, err := validation.IsValid(*element); !res {
return nil, err
}
}
return followers, nil
}
func AddFollower(ctx context.Context, followedUser *User, followingUser *FederatedUser) (*FederatedUserFollower, error) {
if res, err := validation.IsValid(followedUser); !res {
return nil, err
}
if res, err := validation.IsValid(followingUser); !res {
return nil, err
}
federatedUserFollower, err := NewFederatedUserFollower(followedUser.ID, followingUser.UserID)
if err != nil {
return nil, err
}
_, err = db.GetEngine(ctx).Insert(&federatedUserFollower)
if err != nil {
return nil, err
}
return &federatedUserFollower, err
}
func RemoveFollower(ctx context.Context, followedUser *User, followingUser *FederatedUser) error {
if res, err := validation.IsValid(followedUser); !res {
return err
}
if res, err := validation.IsValid(followingUser); !res {
return err
}
_, err := db.GetEngine(ctx).Delete(&FederatedUserFollower{
FollowedUserID: followedUser.ID,
FollowingUserID: followingUser.UserID,
})
return err
}
// TODO: We should unify Activity-pub-following and classical following (see models/user/follow.go)
func IsFollowingAp(ctx context.Context, followedUser *User, followingUser *FederatedUser) (bool, error) {
if res, err := validation.IsValid(followedUser); !res {
return false, err
}
if res, err := validation.IsValid(followingUser); !res {
return false, err
}
return db.GetEngine(ctx).Get(&FederatedUserFollower{
FollowedUserID: followedUser.ID,
FollowingUserID: followingUser.UserID,
})
}

View file

@ -12,6 +12,13 @@ import (
"forgejo.org/modules/structs"
)
// IsSystem returns true if the user has a fixed
// negative ID, is never stored in the database and
// is generated on the fly when needed.
func (u *User) IsSystem() bool {
return u.IsGhost() || u.IsActions()
}
const (
GhostUserID = -1
GhostUserName = "Ghost"

View file

@ -148,7 +148,7 @@ func TestAPActorID_APActorID(t *testing.T) {
assert.Equal(t, expected, url)
}
func TestAPActorKeyID(t *testing.T) {
func TestKeyID(t *testing.T) {
user := user_model.User{ID: 1}
url := user.APActorKeyID()
expected := "https://try.gitea.io/api/v1/activitypub/user-id/1#main-key"

View file

@ -12,7 +12,6 @@ import (
"forgejo.org/modules/log"
"forgejo.org/modules/typesniffer"
"forgejo.org/modules/util"
)
// Blob represents a Git object.
@ -25,42 +24,25 @@ type Blob struct {
repo *Repository
}
// DataAsync gets a ReadCloser for the contents of a blob without reading it all.
// Calling the Close function on the result will discard all unread output.
func (b *Blob) DataAsync() (io.ReadCloser, error) {
func (b *Blob) newReader() (*bufio.Reader, int64, func(), error) {
wr, rd, cancel, err := b.repo.CatFileBatch(b.repo.Ctx)
if err != nil {
return nil, err
return nil, 0, nil, err
}
_, err = wr.Write([]byte(b.ID.String() + "\n"))
if err != nil {
cancel()
return nil, err
return nil, 0, nil, err
}
_, _, size, err := ReadBatchLine(rd)
if err != nil {
cancel()
return nil, err
return nil, 0, nil, err
}
b.gotSize = true
b.size = size
if size < 4096 {
bs, err := io.ReadAll(io.LimitReader(rd, size))
defer cancel()
if err != nil {
return nil, err
}
_, err = rd.Discard(1)
return io.NopCloser(bytes.NewReader(bs)), err
}
return &blobReader{
rd: rd,
n: size,
cancel: cancel,
}, nil
return rd, size, cancel, err
}
// Size returns the uncompressed size of the blob
@ -91,10 +73,36 @@ func (b *Blob) Size() int64 {
return b.size
}
// DataAsync gets a ReadCloser for the contents of a blob without reading it all.
// Calling the Close function on the result will discard all unread output.
func (b *Blob) DataAsync() (io.ReadCloser, error) {
rd, size, cancel, err := b.newReader()
if err != nil {
return nil, err
}
if size < 4096 {
bs, err := io.ReadAll(io.LimitReader(rd, size))
defer cancel()
if err != nil {
return nil, err
}
_, err = rd.Discard(1)
return io.NopCloser(bytes.NewReader(bs)), err
}
return &blobReader{
rd: rd,
n: size,
cancel: cancel,
}, nil
}
type blobReader struct {
rd *bufio.Reader
n int64
cancel func()
rd *bufio.Reader
n int64 // number of bytes to read
additionalDiscard int64 // additional number of bytes to discard
cancel func()
}
func (b *blobReader) Read(p []byte) (n int, err error) {
@ -117,7 +125,8 @@ func (b *blobReader) Close() error {
defer b.cancel()
if err := DiscardFull(b.rd, b.n+1); err != nil {
// discard the unread bytes, the truncated bytes and the trailing newline
if err := DiscardFull(b.rd, b.n+b.additionalDiscard+1); err != nil {
return err
}
@ -131,17 +140,35 @@ func (b *Blob) Name() string {
return b.name
}
// GetBlobContent Gets the limited content of the blob as raw text
// NewTruncatedReader return a blob-reader which silently truncates when the limit is reached (io.EOF will be returned)
func (b *Blob) NewTruncatedReader(limit int64) (rc io.ReadCloser, fullSize int64, err error) {
r, fullSize, cancel, err := b.newReader()
if err != nil {
return nil, fullSize, err
}
limit = min(limit, fullSize)
return &blobReader{
rd: r,
n: limit,
additionalDiscard: fullSize - limit,
cancel: cancel,
}, fullSize, nil
}
// GetBlobContent Gets the truncated content of the blob as raw text
func (b *Blob) GetBlobContent(limit int64) (string, error) {
if limit <= 0 {
return "", nil
}
dataRc, err := b.DataAsync()
rc, fullSize, err := b.NewTruncatedReader(limit)
if err != nil {
return "", err
}
defer dataRc.Close()
buf, err := util.ReadWithLimit(dataRc, int(limit))
defer rc.Close()
buf := make([]byte, min(fullSize, limit))
_, err = io.ReadFull(rc, buf)
return string(buf), err
}

View file

@ -35,6 +35,106 @@ func TestBlob_Data(t *testing.T) {
assert.Equal(t, output, string(data))
}
func TestBlob(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
require.NoError(t, err)
defer repo.Close()
testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375")
require.NoError(t, err)
t.Run("GetBlobContent", func(t *testing.T) {
r, err := testBlob.GetBlobContent(100)
require.NoError(t, err)
require.Equal(t, "file2\n", r)
r, err = testBlob.GetBlobContent(-1)
require.NoError(t, err)
require.Empty(t, r)
r, err = testBlob.GetBlobContent(4)
require.NoError(t, err)
require.Equal(t, "file", r)
r, err = testBlob.GetBlobContent(6)
require.NoError(t, err)
require.Equal(t, "file2\n", r)
})
t.Run("NewTruncatedReader", func(t *testing.T) {
// read fewer than available
rc, size, err := testBlob.NewTruncatedReader(100)
require.NoError(t, err)
require.Equal(t, int64(6), size)
buf := make([]byte, 1)
n, err := rc.Read(buf)
require.NoError(t, err)
require.Equal(t, 1, n)
require.Equal(t, "f", string(buf))
n, err = rc.Read(buf)
require.NoError(t, err)
require.Equal(t, 1, n)
require.Equal(t, "i", string(buf))
require.NoError(t, rc.Close())
// read more than available
rc, size, err = testBlob.NewTruncatedReader(100)
require.NoError(t, err)
require.Equal(t, int64(6), size)
buf = make([]byte, 100)
n, err = rc.Read(buf)
require.NoError(t, err)
require.Equal(t, 6, n)
require.Equal(t, "file2\n", string(buf[:n]))
n, err = rc.Read(buf)
require.Error(t, err)
require.Equal(t, io.EOF, err)
require.Equal(t, 0, n)
require.NoError(t, rc.Close())
// read more than truncated
rc, size, err = testBlob.NewTruncatedReader(4)
require.NoError(t, err)
require.Equal(t, int64(6), size)
buf = make([]byte, 10)
n, err = rc.Read(buf)
require.NoError(t, err)
require.Equal(t, 4, n)
require.Equal(t, "file", string(buf[:n]))
n, err = rc.Read(buf)
require.Error(t, err)
require.Equal(t, io.EOF, err)
require.Equal(t, 0, n)
require.NoError(t, rc.Close())
})
t.Run("NonExisting", func(t *testing.T) {
nonExistingBlob, err := repo.GetBlob("00003ff740f9380390d5c9ddef4af18690000000")
require.NoError(t, err)
r, err := nonExistingBlob.GetBlobContent(100)
require.Error(t, err)
require.IsType(t, ErrNotExist{}, err)
require.Empty(t, r)
rc, size, err := nonExistingBlob.NewTruncatedReader(100)
require.Error(t, err)
require.IsType(t, ErrNotExist{}, err)
require.Empty(t, rc)
require.Empty(t, size)
})
}
func Benchmark_Blob_Data(b *testing.B) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
repo, err := openRepositoryWithDefaultContext(bareRepo1Path)

View file

@ -267,8 +267,13 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
// RenderString renders Markdown string to HTML with all specific handling stuff and return string
func RenderString(ctx *markup.RenderContext, content string) (template.HTML, error) {
return RenderReader(ctx, strings.NewReader(content))
}
// RenderReader renders Markdown io.Reader to HTML with all specific handling stuff and return string
func RenderReader(ctx *markup.RenderContext, input io.Reader) (template.HTML, error) {
var buf strings.Builder
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
if err := Render(ctx, input, &buf); err != nil {
return "", err
}
return template.HTML(buf.String()), nil

View file

@ -23,6 +23,11 @@ var wellKnownMimeTypesLower = map[string]string{
".wasm": "application/wasm",
".webp": "image/webp",
".xml": "text/xml; charset=utf-8",
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".obj": "model/obj",
".stl": "model/stl",
".3mf": "model/3mf",
// well, there are some types missing from the builtin list
".txt": "text/plain; charset=utf-8",

View file

@ -78,3 +78,9 @@ type ActionRun struct {
// the url of this action run
HTMLURL string `json:"html_url"`
}
// ListActionRunResponse return a list of ActionRun
type ListActionRunResponse struct {
Entries []*ActionRun `json:"workflow_runs"`
TotalCount int64 `json:"total_count"`
}

View file

@ -32,23 +32,3 @@ type ActionTaskResponse struct {
Entries []*ActionTask `json:"workflow_runs"`
TotalCount int64 `json:"total_count"`
}
// ActionRun represents an ActionRun
type RepoActionRun struct {
ID int64 `json:"id"`
Name string `json:"name"`
RunNumber int64 `json:"run_number"`
Event string `json:"event"`
Status string `json:"status"`
HeadBranch string `json:"head_branch"`
HeadSHA string `json:"head_sha"`
WorkflowID string `json:"workflow_id"`
URL string `json:"url"`
TriggeringActor *User `json:"triggering_actor"`
}
// ListActionRunResponse return a list of ActionRun
type ListRepoActionRunResponse struct {
Entries []*RepoActionRun `json:"workflow_runs"`
TotalCount int64 `json:"total_count"`
}

View file

@ -24,6 +24,16 @@ const (
AvifMimeType = "image/avif"
// ApplicationOctetStream MIME type of binary files.
ApplicationOctetStream = "application/octet-stream"
// GLTFMimeType MIME type of GLTF files.
GLTFMimeType = "model/gltf+json"
// GLBMimeType MIME type of GLB files.
GLBMimeType = "model/gltf-binary"
// OBJMimeType MIME type of OBJ files.
OBJMimeType = "model/obj"
// STLMimeType MIME type of STL files.
STLMimeType = "model/stl"
// 3MFMimeType MIME type of 3MF files.
ThreeMFMimeType = "model/3mf"
)
var (
@ -67,6 +77,36 @@ func (ct SniffedType) IsAudio() bool {
return strings.Contains(ct.contentType, "audio/")
}
// Is3DModel detects if data is a 3D format
func (ct SniffedType) Is3DModel() bool {
return strings.Contains(ct.contentType, "model/")
}
// IsGLTFFile detects if data is an SVG image format
func (ct SniffedType) IsGLTF() bool {
return strings.Contains(ct.contentType, GLTFMimeType)
}
// IsGLBFile detects if data is an GLB image format
func (ct SniffedType) IsGLB() bool {
return strings.Contains(ct.contentType, GLBMimeType)
}
// IsOBJFile detects if data is an OBJ image format
func (ct SniffedType) IsOBJ() bool {
return strings.Contains(ct.contentType, OBJMimeType)
}
// IsSTLTextFile detects if data is an STL text format
func (ct SniffedType) IsSTL() bool {
return strings.Contains(ct.contentType, STLMimeType)
}
// Is3MFFile detects if data is an 3MF image format
func (ct SniffedType) Is3MF() bool {
return strings.Contains(ct.contentType, ThreeMFMimeType)
}
// IsRepresentableAsText returns true if file content can be represented as
// plain text or is empty.
func (ct SniffedType) IsRepresentableAsText() bool {
@ -75,7 +115,7 @@ func (ct SniffedType) IsRepresentableAsText() bool {
// IsBrowsableBinaryType returns whether a non-text type can be displayed in a browser
func (ct SniffedType) IsBrowsableBinaryType() bool {
return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio()
return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio() || ct.Is3DModel()
}
// GetMimeType returns the mime type
@ -135,6 +175,13 @@ func DetectContentType(data []byte) SniffedType {
ct = "audio/ogg" // for most cases, it is used as an audio container
}
}
// GLTF is unsupported by http.DetectContentType
// hexdump -n 4 -C glTF.glb
if bytes.HasPrefix(data, []byte("glTF")) {
ct = GLBMimeType
}
return SniffedType{ct}
}

View file

@ -117,6 +117,14 @@ func TestIsAudio(t *testing.T) {
assert.True(t, DetectContentType([]byte("ID3Toy\n====\t* hi 🌞, ..."+"🌛"[0:2])).IsText()) // test ID3 tag with incomplete UTF8 char
}
func TestIsGLB(t *testing.T) {
glb, _ := hex.DecodeString("676c5446")
assert.True(t, DetectContentType(glb).IsGLB())
assert.True(t, DetectContentType(glb).Is3DModel())
assert.False(t, DetectContentType([]byte("plain text")).IsGLB())
assert.False(t, DetectContentType([]byte("plain text")).Is3DModel())
}
func TestDetectContentTypeFromReader(t *testing.T) {
mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl")
st, err := DetectContentTypeFromReader(bytes.NewReader(mp3))
@ -145,3 +153,15 @@ func TestDetectContentTypeAvif(t *testing.T) {
assert.True(t, st.IsImage())
}
func TestDetectContentTypeModelGLB(t *testing.T) {
glb, err := hex.DecodeString("676c5446")
require.NoError(t, err)
st, err := DetectContentTypeFromReader(bytes.NewReader(glb))
require.NoError(t, err)
// print st for debugging
assert.Equal(t, "model/gltf-binary", st.GetMimeType())
assert.True(t, st.IsGLB())
}

View file

@ -4,7 +4,6 @@
package util
import (
"bytes"
"errors"
"io"
)
@ -20,42 +19,6 @@ func ReadAtMost(r io.Reader, buf []byte) (n int, err error) {
return n, err
}
// ReadWithLimit reads at most "limit" bytes from r into buf.
// If EOF or ErrUnexpectedEOF occurs while reading, err will be nil.
func ReadWithLimit(r io.Reader, n int) (buf []byte, err error) {
return readWithLimit(r, 1024, n)
}
func readWithLimit(r io.Reader, batch, limit int) ([]byte, error) {
if limit <= batch {
buf := make([]byte, limit)
n, err := ReadAtMost(r, buf)
if err != nil {
return nil, err
}
return buf[:n], nil
}
res := bytes.NewBuffer(make([]byte, 0, batch))
bufFix := make([]byte, batch)
eof := false
for res.Len() < limit && !eof {
bufTmp := bufFix
if res.Len()+batch > limit {
bufTmp = bufFix[:limit-res.Len()]
}
n, err := io.ReadFull(r, bufTmp)
if err == io.EOF || err == io.ErrUnexpectedEOF {
eof = true
} else if err != nil {
return nil, err
}
if _, err = res.Write(bufTmp[:n]); err != nil {
return nil, err
}
}
return res.Bytes(), nil
}
// ErrNotEmpty is an error reported when there is a non-empty reader
var ErrNotEmpty = errors.New("not-empty")

View file

@ -1,67 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
import (
"bytes"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type readerWithError struct {
buf *bytes.Buffer
}
func (r *readerWithError) Read(p []byte) (n int, err error) {
if r.buf.Len() < 2 {
return 0, errors.New("test error")
}
return r.buf.Read(p)
}
func TestReadWithLimit(t *testing.T) {
bs := []byte("0123456789abcdef")
// normal test
buf, err := readWithLimit(bytes.NewBuffer(bs), 5, 2)
require.NoError(t, err)
assert.Equal(t, []byte("01"), buf)
buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 5)
require.NoError(t, err)
assert.Equal(t, []byte("01234"), buf)
buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 6)
require.NoError(t, err)
assert.Equal(t, []byte("012345"), buf)
buf, err = readWithLimit(bytes.NewBuffer(bs), 5, len(bs))
require.NoError(t, err)
assert.Equal(t, []byte("0123456789abcdef"), buf)
buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 100)
require.NoError(t, err)
assert.Equal(t, []byte("0123456789abcdef"), buf)
// test with error
buf, err = readWithLimit(&readerWithError{bytes.NewBuffer(bs)}, 5, 10)
require.NoError(t, err)
assert.Equal(t, []byte("0123456789"), buf)
buf, err = readWithLimit(&readerWithError{bytes.NewBuffer(bs)}, 5, 100)
require.ErrorContains(t, err, "test error")
assert.Empty(t, buf)
// test public function
buf, err = ReadWithLimit(bytes.NewBuffer(bs), 2)
require.NoError(t, err)
assert.Equal(t, []byte("01"), buf)
buf, err = ReadWithLimit(bytes.NewBuffer(bs), 9999999)
require.NoError(t, err)
assert.Equal(t, []byte("0123456789abcdef"), buf)
}

View file

@ -768,8 +768,8 @@ update_profile_success = Your profile has been updated.
change_username = Your username has been changed.
change_username_prompt = Note: Changing your username also changes your account URL.
change_username_redirect_prompt = The old username will redirect until someone claims it.
change_username_redirect_prompt.with_cooldown.one = The old username will be available to everyone after a cooldown period of %[1]d day, you can still reclaim the old username during the cooldown period.
change_username_redirect_prompt.with_cooldown.few = The old username will be available to everyone after a cooldown period of %[1]d days, you can still reclaim the old username during the cooldown period.
change_username_redirect_prompt.with_cooldown.one = The old username will be available to everyone after a cooldown period of %[1]d day. You can still reclaim the old username during the cooldown period.
change_username_redirect_prompt.with_cooldown.few = The old username will be available to everyone after a cooldown period of %[1]d days. You can still reclaim the old username during the cooldown period.
continue = Continue
cancel = Cancel
language = Language
@ -1694,15 +1694,13 @@ issues.close_comment_issue = Close with comment
issues.reopen_issue = Reopen
issues.reopen_comment_issue = Reopen with comment
issues.create_comment = Comment
issues.closed_at = `closed this issue <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.reopened_at = `reopened this issue <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.commit_ref_at = `referenced this issue from a commit <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.ref_issue_from = `<a href="%[3]s">referenced this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.ref_pull_from = `<a href="%[3]s">referenced this pull request %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.ref_closing_from = `<a href="%[3]s">referenced this issue from a pull request %[4]s that will close it</a>, <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.ref_reopening_from = `<a href="%[3]s">referenced this issue from a pull request %[4]s that will reopen it</a>, <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.ref_closed_from = `<a href="%[3]s">closed this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.ref_reopened_from = `<a href="%[3]s">reopened this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.closed_at = `closed this issue %s`
issues.reopened_at = `reopened this issue %s`
issues.commit_ref_at = `referenced this issue from a commit %s`
issues.ref_issue_from = `<a href="%[2]s">referenced this issue %[3]s</a> %[1]s`
issues.ref_pull_from = `<a href="%[2]s">referenced this pull request %[3]s</a> %[1]s`
issues.ref_closing_from = `<a href="%[2]s">referenced this issue from a pull request %[3]s that will close it</a>, %[1]s`
issues.ref_reopening_from = `<a href="%[2]s">referenced this issue from a pull request %[3]s that will reopen it</a>, %[1]s`
issues.ref_from = `from %[1]s`
issues.author = Author
issues.author.tooltip.issue = This user is the author of this issue.
@ -2014,9 +2012,9 @@ pulls.update_branch_success = Branch update was successful
pulls.update_not_allowed = You are not allowed to update branch
pulls.outdated_with_base_branch = This branch is out-of-date with the base branch
pulls.close = Close pull request
pulls.closed_at = `closed this pull request <a id="%[1]s" href="#%[1]s">%[2]s</a>`
pulls.reopened_at = `reopened this pull request <a id="%[1]s" href="#%[1]s">%[2]s</a>`
pulls.commit_ref_at = `referenced this pull request from a commit <a id="%[1]s" href="#%[1]s">%[2]s</a>`
pulls.closed_at = `closed this pull request %s`
pulls.reopened_at = `reopened this pull request %s`
pulls.commit_ref_at = `referenced this pull request from a commit %s`
pulls.cmd_instruction_hint = View command line instructions
pulls.cmd_instruction_checkout_title = Checkout
pulls.cmd_instruction_checkout_desc = From your project repository, check out a new branch and test the changes.
@ -2933,8 +2931,8 @@ settings.update_settings = Update settings
settings.update_setting_success = Organization settings have been updated.
settings.change_orgname_prompt = Note: Changing the organization name will also change your organization's URL and free the old name.
settings.change_orgname_redirect_prompt = The old name will redirect until it is claimed.
settings.change_orgname_redirect_prompt.with_cooldown.one = The old organization name will be available to everyone after a cooldown period of %[1]d day, you can still reclaim the old name during the cooldown period.
settings.change_orgname_redirect_prompt.with_cooldown.few = The old organization name will be available to everyone after a cooldown period of %[1]d days, you can still reclaim the old name during the cooldown period.
settings.change_orgname_redirect_prompt.with_cooldown.one = The old organization name will be available to everyone after a cooldown period of %[1]d day. You can still reclaim the old name during the cooldown period.
settings.change_orgname_redirect_prompt.with_cooldown.few = The old organization name will be available to everyone after a cooldown period of %[1]d days. You can still reclaim the old name during the cooldown period.
settings.update_avatar_success = The organization's avatar has been updated.
settings.delete = Delete organization
settings.delete_account = Delete this organization

140
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@github/markdown-toolbar-element": "2.2.3",
"@github/quote-selection": "2.1.0",
"@github/text-expander-element": "2.8.0",
"@google/model-viewer": "4.1.0",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "19.14.0",
"ansi_up": "6.0.5",
@ -62,7 +63,7 @@
"devDependencies": {
"@axe-core/playwright": "4.10.2",
"@eslint-community/eslint-plugin-eslint-comments": "4.5.0",
"@playwright/test": "1.53.0",
"@playwright/test": "1.52.0",
"@stoplight/spectral-cli": "6.15.0",
"@stylistic/eslint-plugin": "4.4.1",
"@stylistic/stylelint-plugin": "3.1.2",
@ -1222,6 +1223,22 @@
"dom-input-range": "^1.2.0"
}
},
"node_modules/@google/model-viewer": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@google/model-viewer/-/model-viewer-4.1.0.tgz",
"integrity": "sha512-7WB/jS6wfBfRl/tWhsUUvDMKFE1KlKME97coDLlZQfvJD0nCwjhES1lJ+k7wnmf7T3zMvCfn9mIjM/mgZapuig==",
"license": "Apache-2.0",
"dependencies": {
"@monogrid/gainmap-js": "^3.1.0",
"lit": "^3.2.1"
},
"engines": {
"node": ">=6.0.0"
},
"peerDependencies": {
"three": "^0.172.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2004,6 +2021,21 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.0.tgz",
"integrity": "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0"
}
},
"node_modules/@mcaptcha/core-glue": {
"version": "0.1.0-alpha-5",
"resolved": "https://registry.npmjs.org/@mcaptcha/core-glue/-/core-glue-0.1.0-alpha-5.tgz",
@ -2064,6 +2096,18 @@
"langium": "3.3.1"
}
},
"node_modules/@monogrid/gainmap-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz",
"integrity": "sha512-Obb0/gEd/HReTlg8ttaYk+0m62gQJmCblMOjHSMHRrBP2zdfKMHLCRbh/6ex9fSUJMKdjjIEiohwkbGD3wj2Nw==",
"license": "MIT",
"dependencies": {
"promise-worker-transferable": "^1.0.4"
},
"peerDependencies": {
"three": ">= 0.159.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz",
@ -2143,13 +2187,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
"integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
"integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.53.0"
"playwright": "1.52.0"
},
"bin": {
"playwright": "cli.js"
@ -3493,8 +3537,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "2.0.11",
@ -8768,6 +8811,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@ -9307,6 +9356,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"license": "MIT"
},
"node_modules/is-proto-prop": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-proto-prop/-/is-proto-prop-3.0.1.tgz",
@ -10023,6 +10078,15 @@
"npm": ">=8"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@ -10051,6 +10115,37 @@
"uc.micro": "^2.0.0"
}
},
"node_modules/lit": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.0.tgz",
"integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-element": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.0.tgz",
"integrity": "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0",
"@lit/reactive-element": "^2.1.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-html": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.0.tgz",
"integrity": "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
@ -11859,13 +11954,13 @@
}
},
"node_modules/playwright": {
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
"integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.53.0"
"playwright-core": "1.52.0"
},
"bin": {
"playwright": "cli.js"
@ -11878,9 +11973,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
"integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -12363,6 +12458,16 @@
"dev": true,
"license": "Unlicense"
},
"node_modules/promise-worker-transferable": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
"license": "Apache-2.0",
"dependencies": {
"is-promise": "^2.1.0",
"lie": "^3.0.2"
}
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
@ -14425,6 +14530,13 @@
"node": ">=0.8"
}
},
"node_modules/three": {
"version": "0.172.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz",
"integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==",
"license": "MIT",
"peer": true
},
"node_modules/throttle-debounce": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.0.tgz",

View file

@ -11,6 +11,7 @@
"@github/markdown-toolbar-element": "2.2.3",
"@github/quote-selection": "2.1.0",
"@github/text-expander-element": "2.8.0",
"@google/model-viewer": "4.1.0",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "19.14.0",
"ansi_up": "6.0.5",
@ -61,7 +62,7 @@
"devDependencies": {
"@axe-core/playwright": "4.10.2",
"@eslint-community/eslint-plugin-eslint-comments": "4.5.0",
"@playwright/test": "1.53.0",
"@playwright/test": "1.52.0",
"@stoplight/spectral-cli": "6.15.0",
"@stylistic/eslint-plugin": "4.4.1",
"@stylistic/stylelint-plugin": "3.1.2",
@ -78,8 +79,8 @@
"eslint-plugin-playwright": "2.2.0",
"eslint-plugin-regexp": "2.9.0",
"eslint-plugin-sonarjs": "3.0.2",
"eslint-plugin-unicorn": "59.0.1",
"eslint-plugin-toml": "0.12.0",
"eslint-plugin-unicorn": "59.0.1",
"eslint-plugin-vitest-globals": "1.5.0",
"eslint-plugin-vue": "10.2.0",
"eslint-plugin-vue-scoped-css": "2.10.0",

View file

@ -748,7 +748,7 @@ func ListActionRuns(ctx *context.APIContext) {
// type: string
// responses:
// "200":
// "$ref": "#/responses/RepoActionRunList"
// "$ref": "#/responses/ActionRunList"
// "400":
// "$ref": "#/responses/error"
// "403":
@ -779,16 +779,16 @@ func ListActionRuns(ctx *context.APIContext) {
return
}
res := new(api.ListRepoActionRunResponse)
res := new(api.ListActionRunResponse)
res.TotalCount = total
res.Entries = make([]*api.RepoActionRun, len(runs))
res.Entries = make([]*api.ActionRun, len(runs))
for i, r := range runs {
cr, err := convert.ToRepoActionRun(ctx, r)
if err != nil {
ctx.Error(http.StatusInternalServerError, "ToActionRun", err)
if err := r.LoadAttributes(ctx); err != nil {
ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
return
}
cr := convert.ToActionRun(ctx, r, ctx.Doer)
res.Entries[i] = cr
}
@ -821,7 +821,7 @@ func GetActionRun(ctx *context.APIContext) {
// required: true
// responses:
// "200":
// "$ref": "#/responses/RepoActionRun"
// "$ref": "#/responses/ActionRun"
// "400":
// "$ref": "#/responses/error"
// "403":
@ -839,16 +839,17 @@ func GetActionRun(ctx *context.APIContext) {
return
}
// Action runs lives in its own table, therefore we check that the
// run with the requested ID is owned by the repository
if ctx.Repo.Repository.ID != run.RepoID {
ctx.Error(http.StatusNotFound, "GetRunById", util.ErrNotExist)
return
}
res, err := convert.ToRepoActionRun(ctx, run)
if err != nil {
ctx.Error(http.StatusInternalServerError, "ToRepoActionRun", err)
if err := run.LoadAttributes(ctx); err != nil {
ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
return
}
ctx.JSON(http.StatusOK, res)
ctx.JSON(http.StatusOK, convert.ToActionRun(ctx, run, ctx.Doer))
}

View file

@ -463,16 +463,16 @@ type swaggerSyncForkInfo struct {
Body []api.SyncForkInfo `json:"body"`
}
// RepoActionRunList
// swagger:response RepoActionRunList
type swaggerRepoActionRunList struct {
// ActionRunList
// swagger:response ActionRunList
type swaggerActionRunList struct {
// in:body
Body api.ListRepoActionRunResponse `json:"body"`
Body api.ListActionRunResponse `json:"body"`
}
// RepoActionRun
// swagger:response RepoActionRun
type swaggerRepoActionRun struct {
// ActionRun
// swagger:response ActionRun
type swaggerActionRun struct {
// in:body
Body api.RepoActionRun `json:"body"`
Body api.ActionRun `json:"body"`
}

View file

@ -175,10 +175,12 @@ func prepareOrgProfileReadme(ctx *context.Context, profileGitRepo *git.Repositor
return
}
if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
log.Error("failed to GetBlobContent: %v", err)
if rc, _, err := profileReadme.NewTruncatedReader(setting.UI.MaxDisplayFileSize); err != nil {
log.Error("failed to NewTruncatedReader: %v", err)
} else {
if profileContent, err := markdown.RenderString(&markup.RenderContext{
defer rc.Close()
if profileContent, err := markdown.RenderReader(&markup.RenderContext{
Ctx: ctx,
GitRepo: profileGitRepo,
Links: markup.Links{
@ -188,7 +190,7 @@ func prepareOrgProfileReadme(ctx *context.Context, profileGitRepo *git.Repositor
BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
},
Metas: map[string]string{"mode": "document"},
}, bytes); err != nil {
}, rc); err != nil {
log.Error("failed to RenderString: %v", err)
} else {
ctx.Data["ProfileReadme"] = profileContent

View file

@ -1308,7 +1308,7 @@ func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *use
}
// Special user that can't have associated contributions and permissions in the repo.
if poster.IsGhost() || poster.IsActions() || poster.IsAPServerActor() {
if poster.IsSystem() || poster.IsAPServerActor() {
return roleDescriptor, nil
}

View file

@ -342,6 +342,20 @@ func LFSFileGet(ctx *context.Context) {
ctx.Data["IsVideoFile"] = true
case st.IsAudio():
ctx.Data["IsAudioFile"] = true
case st.Is3DModel():
ctx.Data["Is3DModelFile"] = true
switch {
case st.IsGLB():
ctx.Data["IsGLBFile"] = true
case st.IsSTL():
ctx.Data["IsSTLFile"] = true
case st.IsGLTF():
ctx.Data["IsGLTFFile"] = true
case st.IsOBJ():
ctx.Data["IsOBJFile"] = true
case st.Is3MF():
ctx.Data["Is3MFFile"] = true
}
case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()):
ctx.Data["IsImageFile"] = true
}

View file

@ -153,11 +153,9 @@ func UnitsPost(ctx *context.Context) {
})
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki)
} else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() {
var wikiPermissions repo_model.UnitAccessMode
wikiPermissions := repo_model.UnitAccessModeUnset
if form.GloballyWriteableWiki {
wikiPermissions = repo_model.UnitAccessModeWrite
} else {
wikiPermissions = repo_model.UnitAccessModeRead
}
units = append(units, repo_model.RepoUnit{
RepoID: repo.ID,

View file

@ -439,8 +439,8 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error())
}
} else if slices.Contains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) {
if data, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize); err == nil {
_, warnings := issue_model.GetCodeOwnersFromContent(ctx, data)
if rc, size, err := blob.NewTruncatedReader(setting.UI.MaxDisplayFileSize); err == nil {
_, warnings := issue_model.GetCodeOwnersFromReader(ctx, rc, size > setting.UI.MaxDisplayFileSize)
if len(warnings) > 0 {
ctx.Data["FileWarning"] = strings.Join(warnings, "\n")
}
@ -624,6 +624,20 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["IsVideoFile"] = true
case fInfo.st.IsAudio():
ctx.Data["IsAudioFile"] = true
case fInfo.st.Is3DModel():
ctx.Data["Is3DModelFile"] = true
switch {
case fInfo.st.IsGLB():
ctx.Data["IsGLBFile"] = true
case fInfo.st.IsSTL():
ctx.Data["IsSTLFile"] = true
case fInfo.st.IsGLTF():
ctx.Data["IsGLTFFile"] = true
case fInfo.st.IsOBJ():
ctx.Data["IsOBJFile"] = true
case fInfo.st.Is3MF():
ctx.Data["Is3MFFile"] = true
}
case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()):
ctx.Data["IsImageFile"] = true
ctx.Data["CanCopyContent"] = true

View file

@ -264,10 +264,12 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
total = int(count)
case "overview":
if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
log.Error("failed to GetBlobContent: %v", err)
if rc, _, err := profileReadme.NewTruncatedReader(setting.UI.MaxDisplayFileSize); err != nil {
log.Error("failed to NewTruncatedReader: %v", err)
} else {
if profileContent, err := markdown.RenderString(&markup.RenderContext{
defer rc.Close()
if profileContent, err := markdown.RenderReader(&markup.RenderContext{
Ctx: ctx,
GitRepo: profileGitRepo,
Links: markup.Links{
@ -280,7 +282,7 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
},
Metas: map[string]string{"mode": "document"},
}, bytes); err != nil {
}, rc); err != nil {
log.Error("failed to RenderString: %v", err)
} else {
ctx.Data["ProfileReadme"] = profileContent

View file

@ -66,6 +66,9 @@ func ProfilePost(ctx *context.Context) {
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
ctx.Data["CooldownPeriod"] = setting.Service.UsernameCooldownPeriod
ctx.Data["CommonPronouns"] = commonPronouns
ctx.Data["MaxAvatarFileSize"] = setting.Avatar.MaxFileSize
ctx.Data["MaxAvatarWidth"] = setting.Avatar.MaxWidth
ctx.Data["MaxAvatarHeight"] = setting.Avatar.MaxHeight
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSettingsProfile)

View file

@ -345,6 +345,14 @@ func handleWorkflows(
Status: actions_model.StatusWaiting,
}
if workflow, err := model.ReadWorkflow(bytes.NewReader(dwf.Content)); err == nil {
notifications, err := workflow.Notifications()
if err != nil {
log.Error("Notifications: %w", err)
}
run.NotifyEmail = notifications
}
need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer)
if err != nil {
log.Error("check if need approval for repo %d with user %d: %v", input.Repo.ID, input.Doer.ID, err)

View file

@ -4,6 +4,7 @@
package actions
import (
"bytes"
"context"
"errors"
"fmt"
@ -18,6 +19,7 @@ import (
webhook_module "forgejo.org/modules/webhook"
"github.com/nektos/act/pkg/jobparser"
act_model "github.com/nektos/act/pkg/model"
"xorm.io/builder"
)
@ -140,6 +142,16 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
return err
}
workflow, err := act_model.ReadWorkflow(bytes.NewReader(cron.Content))
if err != nil {
return err
}
notifications, err := workflow.Notifications()
if err != nil {
return err
}
run.NotifyEmail = notifications
// Parse the workflow specification from the cron schedule
workflows, err := jobparser.Parse(cron.Content, jobparser.WithVars(vars))
if err != nil {

View file

@ -0,0 +1,121 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package actions
import (
"testing"
actions_model "forgejo.org/models/actions"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
webhook_module "forgejo.org/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateScheduleTask(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: 2})
assertConstant := func(t *testing.T, cron *actions_model.ActionSchedule, run *actions_model.ActionRun) {
t.Helper()
assert.Equal(t, cron.Title, run.Title)
assert.Equal(t, cron.RepoID, run.RepoID)
assert.Equal(t, cron.OwnerID, run.OwnerID)
assert.Equal(t, cron.WorkflowID, run.WorkflowID)
assert.Equal(t, cron.TriggerUserID, run.TriggerUserID)
assert.Equal(t, cron.Ref, run.Ref)
assert.Equal(t, cron.CommitSHA, run.CommitSHA)
assert.Equal(t, cron.Event, run.Event)
assert.Equal(t, cron.EventPayload, run.EventPayload)
assert.Equal(t, cron.ID, run.ScheduleID)
assert.Equal(t, actions_model.StatusWaiting, run.Status)
}
assertMutable := func(t *testing.T, expected, run *actions_model.ActionRun) {
t.Helper()
assert.Equal(t, expected.NotifyEmail, run.NotifyEmail)
}
testCases := []struct {
name string
cron actions_model.ActionSchedule
want []actions_model.ActionRun
}{
{
name: "simple",
cron: actions_model.ActionSchedule{
Title: "scheduletitle1",
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: "some.yml",
TriggerUserID: repo.OwnerID,
Ref: "branch",
CommitSHA: "fakeSHA",
Event: webhook_module.HookEventSchedule,
EventPayload: "fakepayload",
Content: []byte(
`
name: test
on: push
jobs:
job2:
runs-on: ubuntu-latest
steps:
- run: true
`),
},
want: []actions_model.ActionRun{
{
Title: "scheduletitle1",
NotifyEmail: false,
},
},
},
{
name: "enable-email-notifications is true",
cron: actions_model.ActionSchedule{
Title: "scheduletitle2",
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: "some.yml",
TriggerUserID: repo.OwnerID,
Ref: "branch",
CommitSHA: "fakeSHA",
Event: webhook_module.HookEventSchedule,
EventPayload: "fakepayload",
Content: []byte(
`
name: test
enable-email-notifications: true
on: push
jobs:
job2:
runs-on: ubuntu-latest
steps:
- run: true
`),
},
want: []actions_model.ActionRun{
{
Title: "scheduletitle2",
NotifyEmail: true,
},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
require.NoError(t, CreateScheduleTask(t.Context(), &testCase.cron))
require.Equal(t, len(testCase.want), unittest.GetCount(t, actions_model.ActionRun{RepoID: repo.ID}))
for _, expected := range testCase.want {
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{Title: expected.Title})
assertConstant(t, &testCase.cron, run)
assertMutable(t, &expected, run)
}
unittest.AssertSuccessfulDelete(t, actions_model.ActionRun{RepoID: repo.ID})
})
}
}

View file

@ -111,6 +111,11 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette
return nil, nil, err
}
notifications, err := wf.Notifications()
if err != nil {
return nil, nil, err
}
run := &actions_model.ActionRun{
Title: title,
RepoID: repo.ID,
@ -125,6 +130,7 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette
EventPayload: string(p),
TriggerEvent: string(webhook.HookEventWorkflowDispatch),
Status: actions_model.StatusWaiting,
NotifyEmail: notifications,
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)

View file

@ -8,22 +8,17 @@ import (
actions_model "forgejo.org/models/actions"
access_model "forgejo.org/models/perm/access"
user_model "forgejo.org/models/user"
api "forgejo.org/modules/structs"
)
// ToActionRun convert actions_model.User to api.ActionRun
// the run needs all attributes loaded
func ToActionRun(ctx context.Context, run *actions_model.ActionRun) *api.ActionRun {
func ToActionRun(ctx context.Context, run *actions_model.ActionRun, doer *user_model.User) *api.ActionRun {
if run == nil {
return nil
}
// The doer is the one whose perspective is used to view this ActionRun.
// In the best case we use the user that created the webhook.
// Unfortunately we don't know who that was.
// So instead we use the repo owner, who is able to create webhooks and allow others to do so by making them repo admins.
// This is pretty close to perfect.
doer := run.Repo.Owner
permissionInRepo, _ := access_model.GetUserRepoPermission(ctx, run.Repo, doer)
return &api.ActionRun{

View file

@ -222,29 +222,6 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action
}, nil
}
// ToRepoActionRun convert a actions_model.ActionRun to an api.RepoActionRun
func ToRepoActionRun(ctx context.Context, r *actions_model.ActionRun) (*api.RepoActionRun, error) {
if err := r.LoadAttributes(ctx); err != nil {
return nil, err
}
url := strings.TrimSuffix(setting.AppURL, "/") + r.Link()
actor := ToUser(ctx, r.TriggerUser, nil)
return &api.RepoActionRun{
ID: r.ID,
Name: r.Title,
HeadBranch: r.PrettyRef(),
HeadSHA: r.CommitSHA,
RunNumber: r.Index,
Event: r.TriggerEvent,
Status: r.Status.String(),
WorkflowID: r.WorkflowID,
URL: url,
TriggeringActor: actor,
}, nil
}
// ToVerification convert a git.Commit.Signature to an api.PayloadCommitVerification
func ToVerification(ctx context.Context, c *git.Commit) *api.PayloadCommitVerification {
verif := asymkey_model.ParseCommitWithSignature(ctx, c)

View file

@ -211,6 +211,11 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI
return nil, nil, err
}
inbox, err := url.ParseRequestURI(person.Inbox.GetLink().String())
if err != nil {
return nil, nil, err
}
newUser := user.User{
LowerName: strings.ToLower(name),
Name: name,
@ -227,6 +232,7 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI
federatedUser := user.FederatedUser{
ExternalID: personID.ID,
FederationHostID: federationHostID,
InboxPath: inbox.Path,
NormalizedOriginalURL: personID.AsURI(),
}

View file

@ -39,6 +39,24 @@ func NewNotifier() notify_service.Notifier {
return &actionNotifier{}
}
func notifyAll(ctx context.Context, action *activities_model.Action) error {
_, err := activities_model.NotifyWatchers(ctx, action)
if err != nil {
return err
}
return err
// return federation_service.NotifyActivityPubFollowers(ctx, out)
}
func notifyAllActions(ctx context.Context, acts []*activities_model.Action) error {
_, err := activities_model.NotifyWatchersActions(ctx, acts)
if err != nil {
return err
}
return nil
// return federation_service.NotifyActivityPubFollowers(ctx, out)
}
func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
if err := issue.LoadPoster(ctx); err != nil {
log.Error("issue.LoadPoster: %v", err)
@ -50,7 +68,7 @@ func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue
}
repo := issue.Repo
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: issue.Poster.ID,
ActUser: issue.Poster,
OpType: activities_model.ActionCreateIssue,
@ -91,7 +109,7 @@ func (a *actionNotifier) IssueChangeStatus(ctx context.Context, doer *user_model
}
// Notify watchers for whatever action comes in, ignore if no action type.
if err := activities_model.NotifyWatchers(ctx, act); err != nil {
if err := notifyAll(ctx, act); err != nil {
log.Error("NotifyWatchers: %v", err)
}
}
@ -127,7 +145,7 @@ func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_mode
}
// Notify watchers for whatever action comes in, ignore if no action type.
if err := activities_model.NotifyWatchers(ctx, act); err != nil {
if err := notifyAll(ctx, act); err != nil {
log.Error("NotifyWatchers: %v", err)
}
}
@ -146,7 +164,7 @@ func (a *actionNotifier) NewPullRequest(ctx context.Context, pull *issues_model.
return
}
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: pull.Issue.Poster.ID,
ActUser: pull.Issue.Poster,
OpType: activities_model.ActionCreatePullRequest,
@ -160,7 +178,7 @@ func (a *actionNotifier) NewPullRequest(ctx context.Context, pull *issues_model.
}
func (a *actionNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) {
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionRenameRepo,
@ -174,7 +192,7 @@ func (a *actionNotifier) RenameRepository(ctx context.Context, doer *user_model.
}
func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionTransferRepo,
@ -188,7 +206,7 @@ func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_mode
}
func (a *actionNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionCreateRepo,
@ -201,7 +219,7 @@ func (a *actionNotifier) CreateRepository(ctx context.Context, doer, u *user_mod
}
func (a *actionNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionCreateRepo,
@ -266,13 +284,13 @@ func (a *actionNotifier) PullRequestReview(ctx context.Context, pr *issues_model
actions = append(actions, action)
}
if err := activities_model.NotifyWatchersActions(ctx, actions); err != nil {
if err := notifyAllActions(ctx, actions); err != nil {
log.Error("notify watchers '%d/%d': %v", review.Reviewer.ID, review.Issue.RepoID, err)
}
}
func (*actionNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionMergePullRequest,
@ -286,7 +304,7 @@ func (*actionNotifier) MergePullRequest(ctx context.Context, doer *user_model.Us
}
func (*actionNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionAutoMergePullRequest,
@ -304,7 +322,7 @@ func (*actionNotifier) NotifyPullRevieweDismiss(ctx context.Context, doer *user_
if len(review.OriginalAuthor) > 0 {
reviewerName = review.OriginalAuthor
}
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionPullReviewDismissed,
@ -342,7 +360,7 @@ func (a *actionNotifier) PushCommits(ctx context.Context, pusher *user_model.Use
opType = activities_model.ActionDeleteBranch
}
if err = activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err = notifyAll(ctx, &activities_model.Action{
ActUserID: pusher.ID,
ActUser: pusher,
OpType: opType,
@ -362,7 +380,7 @@ func (a *actionNotifier) CreateRef(ctx context.Context, doer *user_model.User, r
// has sent same action in `PushCommits`, so skip it.
return
}
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: opType,
@ -381,7 +399,7 @@ func (a *actionNotifier) DeleteRef(ctx context.Context, doer *user_model.User, r
// has sent same action in `PushCommits`, so skip it.
return
}
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: opType,
@ -405,7 +423,7 @@ func (a *actionNotifier) SyncPushCommits(ctx context.Context, pusher *user_model
return
}
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: repo.OwnerID,
ActUser: repo.MustOwner(ctx),
OpType: activities_model.ActionMirrorSyncPush,
@ -420,7 +438,7 @@ func (a *actionNotifier) SyncPushCommits(ctx context.Context, pusher *user_model
}
func (a *actionNotifier) SyncCreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) {
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: repo.OwnerID,
ActUser: repo.MustOwner(ctx),
OpType: activities_model.ActionMirrorSyncCreate,
@ -434,7 +452,7 @@ func (a *actionNotifier) SyncCreateRef(ctx context.Context, doer *user_model.Use
}
func (a *actionNotifier) SyncDeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) {
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: repo.OwnerID,
ActUser: repo.MustOwner(ctx),
OpType: activities_model.ActionMirrorSyncDelete,
@ -452,7 +470,7 @@ func (a *actionNotifier) NewRelease(ctx context.Context, rel *repo_model.Release
log.Error("LoadAttributes: %v", err)
return
}
if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
if err := notifyAll(ctx, &activities_model.Action{
ActUserID: rel.PublisherID,
ActUser: rel.Publisher,
OpType: activities_model.ActionPublishRelease,

View file

@ -43,8 +43,6 @@ type ReviewRequestNotifier struct {
}
func PullRequestCodeOwnersReview(ctx context.Context, issue *issues_model.Issue, pr *issues_model.PullRequest) ([]*ReviewRequestNotifier, error) {
files := []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
if pr.IsWorkInProgress(ctx) {
return nil, nil
}
@ -72,18 +70,17 @@ func PullRequestCodeOwnersReview(ctx context.Context, issue *issues_model.Issue,
return nil, err
}
var data string
for _, file := range files {
var rules []*issues_model.CodeOwnerRule
for _, file := range []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"} {
if blob, err := commit.GetBlobByPath(file); err == nil {
data, err = blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
rc, size, err := blob.NewTruncatedReader(setting.UI.MaxDisplayFileSize)
if err == nil {
rules, _ = issues_model.GetCodeOwnersFromReader(ctx, rc, size > setting.UI.MaxDisplayFileSize)
break
}
}
}
rules, _ := issues_model.GetCodeOwnersFromContent(ctx, data)
// get the mergebase
mergeBase, err := getMergeBase(repo, pr, git.BranchPrefix+pr.BaseBranch, pr.GetGitRefName())
if err != nil {

View file

@ -23,19 +23,24 @@ func MailActionRun(run *actions_model.ActionRun, priorStatus actions_model.Statu
return nil
}
if run.TriggerUser.Email != "" && run.TriggerUser.EmailNotificationsPreference != user_model.EmailNotificationsDisabled {
if err := sendMailActionRun(run.TriggerUser, run, priorStatus, lastRun); err != nil {
return err
}
if !run.NotifyEmail {
return nil
}
if run.Repo.Owner.Email != "" && run.Repo.Owner.Email != run.TriggerUser.Email && run.Repo.Owner.EmailNotificationsPreference != user_model.EmailNotificationsDisabled {
if err := sendMailActionRun(run.Repo.Owner, run, priorStatus, lastRun); err != nil {
return err
}
user := run.TriggerUser
// this happens e.g. when this is a scheduled run
if user.IsSystem() {
user = run.Repo.Owner
}
if user.IsSystem() || user.Email == "" {
return nil
}
return nil
if user.EmailNotificationsPreference == user_model.EmailNotificationsDisabled {
return nil
}
return sendMailActionRun(user, run, priorStatus, lastRun)
}
func sendMailActionRun(to *user_model.User, run *actions_model.ActionRun, priorStatus actions_model.Status, lastRun *actions_model.ActionRun) error {

View file

@ -4,42 +4,53 @@
package mailer
import (
"slices"
"testing"
actions_model "forgejo.org/models/actions"
"forgejo.org/models/db"
organization_model "forgejo.org/models/organization"
repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
notify_service "forgejo.org/services/notify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func getActionsNowDoneTestUsers(t *testing.T) []*user_model.User {
func getActionsNowDoneTestUser(t *testing.T, name, email, notifications string) *user_model.User {
t.Helper()
newTriggerUser := new(user_model.User)
newTriggerUser.Name = "new_trigger_user"
newTriggerUser.Language = "en_US"
newTriggerUser.IsAdmin = false
newTriggerUser.Email = "new_trigger_user@example.com"
newTriggerUser.LastLoginUnix = 1693648327
newTriggerUser.CreatedUnix = 1693648027
newTriggerUser.EmailNotificationsPreference = user_model.EmailNotificationsEnabled
require.NoError(t, user_model.CreateUser(db.DefaultContext, newTriggerUser))
user := new(user_model.User)
user.Name = name
user.Language = "en_US"
user.IsAdmin = false
user.Email = email
user.LastLoginUnix = 1693648327
user.CreatedUnix = 1693648027
opts := user_model.CreateUserOverwriteOptions{
AllowCreateOrganization: optional.Some(true),
EmailNotificationsPreference: &notifications,
}
require.NoError(t, user_model.AdminCreateUser(db.DefaultContext, user, &opts))
return user
}
newOwner := new(user_model.User)
newOwner.Name = "new_owner"
newOwner.Language = "en_US"
newOwner.IsAdmin = false
newOwner.Email = "new_owner@example.com"
newOwner.LastLoginUnix = 1693648329
newOwner.CreatedUnix = 1693648029
newOwner.EmailNotificationsPreference = user_model.EmailNotificationsEnabled
require.NoError(t, user_model.CreateUser(db.DefaultContext, newOwner))
return []*user_model.User{newTriggerUser, newOwner}
func getActionsNowDoneTestOrg(t *testing.T, name, email string, owner *user_model.User) *user_model.User {
t.Helper()
org := new(organization_model.Organization)
org.Name = name
org.Language = "en_US"
org.IsAdmin = false
// contact email for the organization, for display purposes but otherwise not used as of v12
org.Email = email
org.LastLoginUnix = 1693648327
org.CreatedUnix = 1693648027
org.Email = email
require.NoError(t, organization_model.CreateOrganization(db.DefaultContext, org, owner))
return (*user_model.User)(org)
}
func assertTranslatedLocaleMailActionsNowDone(t *testing.T, msgBody string) {
@ -49,98 +60,181 @@ func assertTranslatedLocaleMailActionsNowDone(t *testing.T, msgBody string) {
func TestActionRunNowDoneNotificationMail(t *testing.T) {
ctx := t.Context()
users := getActionsNowDoneTestUsers(t)
defer CleanUpUsers(ctx, users)
triggerUser := users[0]
ownerUser := users[1]
defer test.MockVariableValue(&setting.Admin.DisableRegularOrgCreation, false)()
actionsUser := user_model.NewActionsUser()
require.NotEmpty(t, actionsUser.Email)
repo := repo_model.Repository{
Name: "some repo",
Description: "rockets are cool",
Owner: ownerUser,
OwnerID: ownerUser.ID,
}
// Do some funky stuff with the action run's ids:
// The run with the larger ID finished first.
// This is odd but something that must work.
run1 := &actions_model.ActionRun{ID: 2, Repo: &repo, RepoID: repo.ID, Title: "some workflow", TriggerUser: triggerUser, TriggerUserID: triggerUser.ID, Status: actions_model.StatusFailure, Stopped: 1745821796, TriggerEvent: "workflow_dispatch"}
run2 := &actions_model.ActionRun{ID: 1, Repo: &repo, RepoID: repo.ID, Title: "some workflow", TriggerUser: triggerUser, TriggerUserID: triggerUser.ID, Status: actions_model.StatusSuccess, Stopped: 1745822796, TriggerEvent: "push"}
run1 := &actions_model.ActionRun{ID: 2, Repo: &repo, RepoID: repo.ID, Title: "some workflow", Status: actions_model.StatusFailure, Stopped: 1745821796, TriggerEvent: "workflow_dispatch"}
run2 := &actions_model.ActionRun{ID: 1, Repo: &repo, RepoID: repo.ID, Title: "some workflow", Status: actions_model.StatusSuccess, Stopped: 1745822796, TriggerEvent: "push"}
assignUsers := func(triggerUser, owner *user_model.User) {
for _, run := range []*actions_model.ActionRun{run1, run2} {
run.TriggerUser = triggerUser
run.TriggerUserID = triggerUser.ID
run.NotifyEmail = true
}
repo.Owner = owner
repo.OwnerID = owner.ID
}
notify_service.RegisterNotifier(NewNotifier())
orgOwner := getActionsNowDoneTestUser(t, "org_owner", "org_owner@example.com", "disabled")
defer CleanUpUsers(ctx, []*user_model.User{orgOwner})
t.Run("DontSendNotificationEmailOnFirstActionSuccess", func(t *testing.T) {
user := getActionsNowDoneTestUser(t, "new_user", "new_user@example.com", "enabled")
defer CleanUpUsers(ctx, []*user_model.User{user})
assignUsers(user, user)
defer MockMailSettings(func(msgs ...*Message) {
assert.Fail(t, "no mail should be sent")
})()
notify_service.ActionRunNowDone(ctx, run2, actions_model.StatusRunning, nil)
})
t.Run("SendNotificationEmailOnActionRunFailed", func(t *testing.T) {
mailSentToOwner := false
mailSentToTriggerUser := false
t.Run("WorkflowEnableEmailNotificationIsFalse", func(t *testing.T) {
user := getActionsNowDoneTestUser(t, "new_user1", "new_user1@example.com", "enabled")
defer CleanUpUsers(ctx, []*user_model.User{user})
assignUsers(user, user)
defer MockMailSettings(func(msgs ...*Message) {
assert.LessOrEqual(t, len(msgs), 2)
for _, msg := range msgs {
switch msg.To {
case triggerUser.EmailTo():
assert.False(t, mailSentToTriggerUser, "sent mail twice")
mailSentToTriggerUser = true
case ownerUser.EmailTo():
assert.False(t, mailSentToOwner, "sent mail twice")
mailSentToOwner = true
default:
assert.Fail(t, "sent mail to unknown sender", msg.To)
}
assert.Contains(t, msg.Body, triggerUser.HTMLURL())
assert.Contains(t, msg.Body, triggerUser.Name)
// what happened
assert.Contains(t, msg.Body, "failed")
// new status of run
assert.Contains(t, msg.Body, "failure")
// prior status of this run
assert.Contains(t, msg.Body, "waiting")
assertTranslatedLocaleMailActionsNowDone(t, msg.Body)
}
assert.Fail(t, "no mail should be sent")
})()
notify_service.ActionRunNowDone(ctx, run1, actions_model.StatusWaiting, nil)
assert.True(t, mailSentToOwner)
assert.True(t, mailSentToTriggerUser)
run2.NotifyEmail = false
notify_service.ActionRunNowDone(ctx, run2, actions_model.StatusRunning, nil)
})
t.Run("SendNotificationEmailOnActionRunRecovered", func(t *testing.T) {
mailSentToOwner := false
mailSentToTriggerUser := false
defer MockMailSettings(func(msgs ...*Message) {
assert.LessOrEqual(t, len(msgs), 2)
for _, msg := range msgs {
switch msg.To {
case triggerUser.EmailTo():
assert.False(t, mailSentToTriggerUser, "sent mail twice")
mailSentToTriggerUser = true
case ownerUser.EmailTo():
assert.False(t, mailSentToOwner, "sent mail twice")
mailSentToOwner = true
default:
assert.Fail(t, "sent mail to unknown sender", msg.To)
}
assert.Contains(t, msg.Body, triggerUser.HTMLURL())
assert.Contains(t, msg.Body, triggerUser.Name)
// what happened
assert.Contains(t, msg.Body, "recovered")
// old status of run
assert.Contains(t, msg.Body, "failure")
// new status of run
assert.Contains(t, msg.Body, "success")
// prior status of this run
assert.Contains(t, msg.Body, "running")
assertTranslatedLocaleMailActionsNowDone(t, msg.Body)
}
})()
assert.NotNil(t, setting.MailService)
for _, testCase := range []struct {
name string
triggerUser *user_model.User
owner *user_model.User
expected string
expectMail bool
}{
{
// if the action is assigned a trigger user in a repository
// owned by a regular user, the mail is sent to the trigger user
name: "RegularTriggerUser",
triggerUser: getActionsNowDoneTestUser(t, "new_trigger_user0", "new_trigger_user0@example.com", user_model.EmailNotificationsEnabled),
owner: getActionsNowDoneTestUser(t, "new_owner0", "new_owner0@example.com", user_model.EmailNotificationsEnabled),
expected: "trigger",
expectMail: true,
},
{
// if the action is assigned to a system user (e.g. ActionsUser)
// in a repository owned by a regular user, the mail is sent to
// the user that owns the repository
name: "SystemTriggerUserAndRegularOwner",
triggerUser: actionsUser,
owner: getActionsNowDoneTestUser(t, "new_owner1", "new_owner1@example.com", user_model.EmailNotificationsEnabled),
expected: "owner",
expectMail: true,
},
{
// if the action is assigned a trigger user with disabled notifications in a repository
// owned by a regular user, no mail is sent
name: "RegularTriggerUserNotificationsDisabled",
triggerUser: getActionsNowDoneTestUser(t, "new_trigger_user2", "new_trigger_user2@example.com", user_model.EmailNotificationsDisabled),
owner: getActionsNowDoneTestUser(t, "new_owner2", "new_owner2@example.com", user_model.EmailNotificationsEnabled),
expectMail: false,
},
{
// if the action is assigned to a system user (e.g. ActionsUser)
// owned by a regular user with disabled notifications, no mail is sent
name: "SystemTriggerUserAndRegularOwnerNotificationsDisabled",
triggerUser: actionsUser,
owner: getActionsNowDoneTestUser(t, "new_owner3", "new_owner3@example.com", user_model.EmailNotificationsDisabled),
expectMail: false,
},
{
// if the action is assigned to a system user (e.g. ActionsUser)
// in a repository owned by an organization with an email contact, the mail is sent to
// this email contact
name: "SystemTriggerUserAndOrgOwner",
triggerUser: actionsUser,
owner: getActionsNowDoneTestOrg(t, "new_org1", "new_org_owner0@example.com", orgOwner),
expected: "owner",
expectMail: true,
},
{
// if the action is assigned to a system user (e.g. ActionsUser)
// in a repository owned by an organization without an email contact, no mail is sent
name: "SystemTriggerUserAndNoMailOrgOwner",
triggerUser: actionsUser,
owner: getActionsNowDoneTestOrg(t, "new_org2", "", orgOwner),
expectMail: false,
},
} {
t.Run(testCase.name, func(t *testing.T) {
assignUsers(testCase.triggerUser, testCase.owner)
defer CleanUpUsers(ctx, slices.DeleteFunc([]*user_model.User{testCase.triggerUser, testCase.owner}, func(user *user_model.User) bool {
return user.IsSystem()
}))
notify_service.ActionRunNowDone(ctx, run2, actions_model.StatusRunning, run1)
assert.True(t, mailSentToOwner)
assert.True(t, mailSentToTriggerUser)
})
t.Run("SendNotificationEmailOnActionRunFailed", func(t *testing.T) {
mailSent := false
defer MockMailSettings(func(msgs ...*Message) {
assert.Len(t, msgs, 1)
msg := msgs[0]
assert.False(t, mailSent, "sent mail twice")
expectedEmail := testCase.triggerUser.Email
if testCase.expected == "owner" { // otherwise "trigger"
expectedEmail = testCase.owner.Email
}
require.Contains(t, msg.To, expectedEmail, "sent mail to unknown sender")
mailSent = true
assert.Contains(t, msg.Body, testCase.triggerUser.HTMLURL())
assert.Contains(t, msg.Body, testCase.triggerUser.Name)
// what happened
assert.Contains(t, msg.Body, "failed")
// new status of run
assert.Contains(t, msg.Body, "failure")
// prior status of this run
assert.Contains(t, msg.Body, "waiting")
assertTranslatedLocaleMailActionsNowDone(t, msg.Body)
})()
require.NotNil(t, setting.MailService)
notify_service.ActionRunNowDone(ctx, run1, actions_model.StatusWaiting, nil)
assert.Equal(t, testCase.expectMail, mailSent)
})
t.Run("SendNotificationEmailOnActionRunRecovered", func(t *testing.T) {
mailSent := false
defer MockMailSettings(func(msgs ...*Message) {
assert.Len(t, msgs, 1)
msg := msgs[0]
assert.False(t, mailSent, "sent mail twice")
expectedEmail := testCase.triggerUser.Email
if testCase.expected == "owner" { // otherwise "trigger"
expectedEmail = testCase.owner.Email
}
require.Contains(t, msg.To, expectedEmail, "sent mail to unknown sender")
mailSent = true
assert.Contains(t, msg.Body, testCase.triggerUser.HTMLURL())
assert.Contains(t, msg.Body, testCase.triggerUser.Name)
// what happened
assert.Contains(t, msg.Body, "recovered")
// old status of run
assert.Contains(t, msg.Body, "failure")
// new status of run
assert.Contains(t, msg.Body, "success")
// prior status of this run
assert.Contains(t, msg.Body, "running")
})()
require.NotNil(t, setting.MailService)
notify_service.ActionRunNowDone(ctx, run2, actions_model.StatusRunning, run1)
assert.Equal(t, testCase.expectMail, mailSent)
})
})
}
}

View file

@ -8,6 +8,7 @@ import (
"testing"
"forgejo.org/models/db"
organization_model "forgejo.org/models/organization"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/setting"
@ -51,6 +52,11 @@ func MockMailSettings(send func(msgs ...*Message)) func() {
func CleanUpUsers(ctx context.Context, users []*user_model.User) {
for _, u := range users {
db.DeleteByID[user_model.User](ctx, u.ID)
if u.IsOrganization() {
organization_model.DeleteOrganization(ctx, (*organization_model.Organization)(u))
} else {
db.DeleteByID[user_model.User](ctx, u.ID)
db.DeleteByBean(ctx, &user_model.EmailAddress{UID: u.ID})
}
}
}

View file

@ -894,9 +894,16 @@ func (m *webhookNotifier) ActionRunNowDone(ctx context.Context, run *actions_mod
Owner: run.TriggerUser,
}
// The doer is the one whose perspective is used to view this ActionRun.
// In the best case we use the user that created the webhook.
// Unfortunately we don't know who that was.
// So instead we use the repo owner, who is able to create webhooks and allow others to do so by making them repo admins.
// This is pretty close to perfect.
doer := run.Repo.Owner
payload := &api.ActionPayload{
Run: convert.ToActionRun(ctx, run),
LastRun: convert.ToActionRun(ctx, lastRun),
Run: convert.ToActionRun(ctx, run, doer),
LastRun: convert.ToActionRun(ctx, lastRun, doer),
PriorStatus: priorStatus.String(),
}

View file

@ -1,7 +1,7 @@
{{template "base/alert"}}
{{range .Issue.Comments}}
{{if call $.ShouldShowCommentType .Type}}
{{$createdStr:= DateUtils.TimeSince .CreatedUnix}}
{{$createdStr := HTMLFormat `<a id="%s" href="#%s">%s</a>` .EventTag .HashTag (DateUtils.TimeSince .CreatedUnix)}}
<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF,
5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 8 = MILESTONE_CHANGE,
@ -87,9 +87,9 @@
<span class="text grey muted-links">
{{template "repo/issue/view_content/comments_authorlink" dict "ctxData" $ "comment" .}}
{{if .Issue.IsPull}}
{{ctx.Locale.Tr "repo.pulls.reopened_at" .EventTag $createdStr}}
{{ctx.Locale.Tr "repo.pulls.reopened_at" $createdStr}}
{{else}}
{{ctx.Locale.Tr "repo.issues.reopened_at" .EventTag $createdStr}}
{{ctx.Locale.Tr "repo.issues.reopened_at" $createdStr}}
{{end}}
</span>
</div>
@ -102,9 +102,9 @@
<span class="text grey muted-links">
{{template "repo/issue/view_content/comments_authorlink" dict "ctxData" $ "comment" .}}
{{if .Issue.IsPull}}
{{ctx.Locale.Tr "repo.pulls.closed_at" .EventTag $createdStr}}
{{ctx.Locale.Tr "repo.pulls.closed_at" $createdStr}}
{{else}}
{{ctx.Locale.Tr "repo.issues.closed_at" .EventTag $createdStr}}
{{ctx.Locale.Tr "repo.issues.closed_at" $createdStr}}
{{end}}
</span>
</div>
@ -137,14 +137,13 @@
{{else if eq .RefAction 2}}
{{$refTr = "repo.issues.ref_reopening_from"}}
{{end}}
{{$createdStr:= DateUtils.TimeSince .CreatedUnix}}
<div class="timeline-item event" id="{{.HashTag}}">
<span class="badge">{{svg "octicon-bookmark"}}</span>
{{template "shared/user/avatarlink" dict "user" .Poster}}
{{if eq .RefAction 3}}<del>{{end}}
<span class="text grey muted-links">
{{template "shared/user/authorlink" .Poster}}
{{ctx.Locale.Tr $refTr .EventTag $createdStr (.RefCommentLink ctx) $refFrom}}
{{ctx.Locale.Tr $refTr $createdStr (.RefCommentLink ctx) $refFrom}}
</span>
{{if eq .RefAction 3}}</del>{{end}}
@ -159,9 +158,9 @@
<span class="text grey muted-links">
{{template "shared/user/authorlink" .Poster}}
{{if .Issue.IsPull}}
{{ctx.Locale.Tr "repo.pulls.commit_ref_at" .EventTag $createdStr}}
{{ctx.Locale.Tr "repo.pulls.commit_ref_at" $createdStr}}
{{else}}
{{ctx.Locale.Tr "repo.issues.commit_ref_at" .EventTag $createdStr}}
{{ctx.Locale.Tr "repo.issues.commit_ref_at" $createdStr}}
{{end}}
</span>
<div class="detail flex-text-block">

View file

@ -32,6 +32,12 @@
</audio>
{{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
{{else if .Is3DModelFile}}
{{if .IsGLBFile}}
<model-viewer src="{{$.RawFileLink}}" ar shadow-intensity="2" camera-controls touch-action="pan-y"></model-viewer>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}!</a>
{{end}}
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}}

View file

@ -116,6 +116,12 @@
</audio>
{{else if .IsPDFFile}}
<div class="pdf-content is-loading" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
{{else if .Is3DModelFile}}
{{if .IsGLBFile}}
<model-viewer src="{{$.RawFileLink}}" ar shadow-intensity="2" camera-controls touch-action="pan-y"></model-viewer>
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}}
{{else}}
<a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
{{end}}

View file

@ -4985,7 +4985,7 @@
],
"responses": {
"200": {
"$ref": "#/responses/RepoActionRunList"
"$ref": "#/responses/ActionRunList"
},
"400": {
"$ref": "#/responses/error"
@ -5032,7 +5032,7 @@
],
"responses": {
"200": {
"$ref": "#/responses/RepoActionRun"
"$ref": "#/responses/ActionRun"
},
"400": {
"$ref": "#/responses/error"
@ -21120,6 +21120,129 @@
},
"x-go-package": "forgejo.org/modules/structs"
},
"ActionRun": {
"description": "ActionRun represents an action run",
"type": "object",
"properties": {
"ScheduleID": {
"description": "the cron id for the schedule trigger",
"type": "integer",
"format": "int64"
},
"approved_by": {
"description": "who approved this action run",
"type": "integer",
"format": "int64",
"x-go-name": "ApprovedBy"
},
"commit_sha": {
"description": "the commit sha the action run ran on",
"type": "string",
"x-go-name": "CommitSHA"
},
"created": {
"description": "when the action run was created",
"type": "string",
"format": "date-time",
"x-go-name": "Created"
},
"duration": {
"$ref": "#/definitions/Duration"
},
"event": {
"description": "the webhook event that causes the workflow to run",
"type": "string",
"x-go-name": "Event"
},
"event_payload": {
"description": "the payload of the webhook event that causes the workflow to run",
"type": "string",
"x-go-name": "EventPayload"
},
"html_url": {
"description": "the url of this action run",
"type": "string",
"x-go-name": "HTMLURL"
},
"id": {
"description": "the action run id",
"type": "integer",
"format": "int64",
"x-go-name": "ID"
},
"index_in_repo": {
"description": "a unique number for each run of a repository",
"type": "integer",
"format": "int64",
"x-go-name": "Index"
},
"is_fork_pull_request": {
"description": "If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow.",
"type": "boolean",
"x-go-name": "IsForkPullRequest"
},
"is_ref_deleted": {
"description": "has the commit/tag/… the action run ran on been deleted",
"type": "boolean",
"x-go-name": "IsRefDeleted"
},
"need_approval": {
"description": "may need approval if it's a fork pull request",
"type": "boolean",
"x-go-name": "NeedApproval"
},
"prettyref": {
"description": "the commit/tag/… the action run ran on",
"type": "string",
"x-go-name": "PrettyRef"
},
"repository": {
"$ref": "#/definitions/Repository"
},
"started": {
"description": "when the action run was started",
"type": "string",
"format": "date-time",
"x-go-name": "Started"
},
"status": {
"description": "the current status of this run",
"type": "string",
"x-go-name": "Status"
},
"stopped": {
"description": "when the action run was stopped",
"type": "string",
"format": "date-time",
"x-go-name": "Stopped"
},
"title": {
"description": "the action run's title",
"type": "string",
"x-go-name": "Title"
},
"trigger_event": {
"description": "the trigger event defined in the `on` configuration of the triggered workflow",
"type": "string",
"x-go-name": "TriggerEvent"
},
"trigger_user": {
"$ref": "#/definitions/User"
},
"updated": {
"description": "when the action run was last updated",
"type": "string",
"format": "date-time",
"x-go-name": "Updated"
},
"workflow_id": {
"description": "the name of workflow file",
"type": "string",
"x-go-name": "WorkflowID"
}
},
"x-go-package": "forgejo.org/modules/structs"
},
"ActionRunJob": {
"description": "ActionRunJob represents a job of a run",
"type": "object",
@ -23610,6 +23733,12 @@
},
"x-go-package": "forgejo.org/modules/structs"
},
"Duration": {
"description": "A Duration represents the elapsed time between two instants\nas an int64 nanosecond count. The representation limits the\nlargest representable duration to approximately 290 years.",
"type": "integer",
"format": "int64",
"x-go-package": "time"
},
"EditAttachmentOptions": {
"description": "EditAttachmentOptions options for editing attachments",
"type": "object",
@ -25576,7 +25705,7 @@
},
"x-go-package": "forgejo.org/modules/structs"
},
"ListRepoActionRunResponse": {
"ListActionRunResponse": {
"description": "ListActionRunResponse return a list of ActionRun",
"type": "object",
"properties": {
@ -25588,7 +25717,7 @@
"workflow_runs": {
"type": "array",
"items": {
"$ref": "#/definitions/RepoActionRun"
"$ref": "#/definitions/ActionRun"
},
"x-go-name": "Entries"
}
@ -27353,54 +27482,6 @@
},
"x-go-package": "forgejo.org/modules/structs"
},
"RepoActionRun": {
"description": "ActionRun represents an ActionRun",
"type": "object",
"properties": {
"event": {
"type": "string",
"x-go-name": "Event"
},
"head_branch": {
"type": "string",
"x-go-name": "HeadBranch"
},
"head_sha": {
"type": "string",
"x-go-name": "HeadSHA"
},
"id": {
"type": "integer",
"format": "int64",
"x-go-name": "ID"
},
"name": {
"type": "string",
"x-go-name": "Name"
},
"run_number": {
"type": "integer",
"format": "int64",
"x-go-name": "RunNumber"
},
"status": {
"type": "string",
"x-go-name": "Status"
},
"triggering_actor": {
"$ref": "#/definitions/User"
},
"url": {
"type": "string",
"x-go-name": "URL"
},
"workflow_id": {
"type": "string",
"x-go-name": "WorkflowID"
}
},
"x-go-package": "forgejo.org/modules/structs"
},
"RepoCollaboratorPermission": {
"description": "RepoCollaboratorPermission to get repository permission for a collaborator",
"type": "object",
@ -28847,6 +28928,18 @@
}
}
},
"ActionRun": {
"description": "ActionRun",
"schema": {
"$ref": "#/definitions/ActionRun"
}
},
"ActionRunList": {
"description": "ActionRunList",
"schema": {
"$ref": "#/definitions/ListActionRunResponse"
}
},
"ActionVariable": {
"description": "ActionVariable",
"schema": {
@ -29616,18 +29709,6 @@
}
}
},
"RepoActionRun": {
"description": "RepoActionRun",
"schema": {
"$ref": "#/definitions/RepoActionRun"
}
},
"RepoActionRunList": {
"description": "RepoActionRunList",
"schema": {
"$ref": "#/definitions/ListRepoActionRunResponse"
}
},
"RepoCollaboratorPermission": {
"description": "RepoCollaboratorPermission",
"schema": {

View file

@ -16,10 +16,15 @@ import (
var (
changesetFiles []string
changesetAvailable bool
globalFullRun bool
globalFullRun = false
)
func initChangedFiles() {
_, globalFullRun = os.LookupEnv("RUN_ALL")
if globalFullRun {
log.Info("Full run of all tests requested via RUN_ALL environment.")
return
}
var changes string
changes, changesetAvailable = os.LookupEnv("CHANGED_FILES")
// the output of the Action seems to actually contain \n and not a newline literal
@ -44,7 +49,7 @@ func initChangedFiles() {
for _, expr := range globalPatterns {
fullRunPatterns = append(fullRunPatterns, glob.MustCompile(expr, '.', '/'))
}
globalFullRun = false
for _, changedFile := range changesetFiles {
for _, pattern := range fullRunPatterns {
if pattern.Match(changedFile) {

View file

@ -0,0 +1,88 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"fmt"
"net/url"
"testing"
actions_model "forgejo.org/models/actions"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/setting"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
)
func TestActionNotifications(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
testCases := []struct {
name string
treePath string
fileContent string
notifyEmail bool
}{
{
name: "enabled",
treePath: ".forgejo/workflows/enabled.yml",
fileContent: `name: enabled
on:
push:
enable-email-notifications: true
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
`,
notifyEmail: true,
},
{
name: "disabled",
treePath: ".forgejo/workflows/disabled.yml",
fileContent: `name: disabled
on:
push:
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
`,
notifyEmail: false,
},
}
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)
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
apiRepo := createActionsTestRepo(t, token, testCase.name, false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"})
opts := getWorkflowCreateFileOptions(user2, apiRepo.DefaultBranch, fmt.Sprintf("create %s", testCase.treePath), testCase.fileContent)
createWorkflowFile(t, token, user2.Name, apiRepo.Name, testCase.treePath, opts)
task := runner.fetchTask(t)
actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask.JobID})
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: actionRunJob.RunID})
assert.Equal(t, testCase.notifyEmail, actionRun.NotifyEmail)
httpContext := NewAPITestContext(t, user2.Name, apiRepo.Name, auth_model.AccessTokenScopeWriteRepository)
doAPIDeleteRepository(httpContext)(t)
})
}
})
}

View file

@ -44,30 +44,35 @@ func (m *mockNotifier) ActionRunNowDone(ctx context.Context, run *actions_model.
assert.Equal(m.t, actions_model.StatusSuccess, run.Status)
assert.Equal(m.t, actions_model.StatusRunning, priorStatus)
assert.Nil(m.t, lastRun)
assert.True(m.t, run.NotifyEmail)
case 1:
assert.Equal(m.t, m.runID, run.ID)
assert.Equal(m.t, actions_model.StatusFailure, run.Status)
assert.Equal(m.t, actions_model.StatusRunning, priorStatus)
assert.Equal(m.t, m.lastRunID, lastRun.ID)
assert.Equal(m.t, actions_model.StatusSuccess, lastRun.Status)
assert.True(m.t, run.NotifyEmail)
case 2:
assert.Equal(m.t, m.runID, run.ID)
assert.Equal(m.t, actions_model.StatusCancelled, run.Status)
assert.Equal(m.t, actions_model.StatusRunning, priorStatus)
assert.Equal(m.t, m.lastRunID, lastRun.ID)
assert.Equal(m.t, actions_model.StatusFailure, lastRun.Status)
assert.True(m.t, run.NotifyEmail)
case 3:
assert.Equal(m.t, m.runID, run.ID)
assert.Equal(m.t, actions_model.StatusSuccess, run.Status)
assert.Equal(m.t, actions_model.StatusRunning, priorStatus)
assert.Equal(m.t, m.lastRunID, lastRun.ID)
assert.Equal(m.t, actions_model.StatusCancelled, lastRun.Status)
assert.True(m.t, run.NotifyEmail)
case 4:
assert.Equal(m.t, m.runID, run.ID)
assert.Equal(m.t, actions_model.StatusSuccess, run.Status)
assert.Equal(m.t, actions_model.StatusRunning, priorStatus)
assert.Equal(m.t, m.lastRunID, lastRun.ID)
assert.Equal(m.t, actions_model.StatusSuccess, lastRun.Status)
assert.True(m.t, run.NotifyEmail)
default:
assert.Fail(m.t, "too many notifications")
}
@ -101,6 +106,7 @@ func TestActionNowDoneNotification(t *testing.T) {
TreePath: ".forgejo/workflows/dispatch.yml",
ContentReader: strings.NewReader(
"name: test\n" +
"enable-email-notifications: true\n" +
"on: [workflow_dispatch]\n" +
"jobs:\n" +
" test:\n" +

View file

@ -169,7 +169,7 @@ func TestAPIGetListActionRun(t *testing.T) {
req.AddTokenAuth(token)
res := MakeRequest(t, req, http.StatusOK)
apiRuns := new(api.ListRepoActionRunResponse)
apiRuns := new(api.ListActionRunResponse)
DecodeJSON(t, res, apiRuns)
assert.Equal(t, int64(len(tt.expectedIDs)), apiRuns.TotalCount)
@ -231,13 +231,13 @@ func TestAPIGetActionRun(t *testing.T) {
}
dbRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: tt.runID})
apiRun := new(api.RepoActionRun)
apiRun := new(api.ActionRun)
DecodeJSON(t, res, apiRun)
assert.Equal(t, dbRun.Index, apiRun.RunNumber)
assert.Equal(t, dbRun.Index, apiRun.Index)
assert.Equal(t, dbRun.Status.String(), apiRun.Status)
assert.Equal(t, dbRun.CommitSHA, apiRun.HeadSHA)
assert.Equal(t, dbRun.TriggerUserID, apiRun.TriggeringActor.ID)
assert.Equal(t, dbRun.CommitSHA, apiRun.CommitSHA)
assert.Equal(t, dbRun.TriggerUserID, apiRun.TriggerUser.ID)
})
}
}

View file

@ -0,0 +1,375 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"net/http"
"strings"
"testing"
"forgejo.org/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
)
func testIssueCommentChangeEvent(t *testing.T, htmlDoc *HTMLDoc, commentID, badgeOcticon, avatarTitle, avatarLink string, texts, links []string) {
// Check badge octicon
badge := htmlDoc.Find("#issuecomment-" + commentID + " .badge svg." + badgeOcticon)
assert.Equal(t, 1, badge.Length())
// Check avatar title
avatarImg := htmlDoc.Find("#issuecomment-" + commentID + " img.avatar")
if len(avatarTitle) == 0 {
assert.Zero(t, avatarImg.Length())
} else {
assert.Equal(t, 1, avatarImg.Length())
title, exists := avatarImg.Attr("title")
assert.True(t, exists)
assert.Equal(t, avatarTitle, title)
}
// Check avatar link
avatarA := htmlDoc.Find("#issuecomment-" + commentID + " a.avatar")
if len(avatarLink) == 0 {
assert.Zero(t, avatarA.Length())
} else {
assert.Equal(t, 1, avatarA.Length())
href, exists := avatarA.Attr("href")
assert.True(t, exists)
assert.Equal(t, avatarLink, href)
}
event := htmlDoc.Find("#issuecomment-" + commentID + " .text")
// Check text content
for _, text := range texts {
assert.Contains(t, strings.Join(strings.Fields(event.Text()), " "), text)
}
var ids []string
var hrefs []string
event.Find("a").Each(func(i int, s *goquery.Selection) {
if id, exists := s.Attr("id"); exists {
ids = append(ids, id)
}
if href, exists := s.Attr("href"); exists {
hrefs = append(hrefs, href)
}
})
// Check anchors (id)
assert.Equal(t, []string{"event-" + commentID}, ids)
// Check links (href)
issueCommentLink := "#issuecomment-" + commentID
found := false
for _, link := range links {
if link == issueCommentLink {
found = true
break
}
}
if !found {
links = append(links, issueCommentLink)
}
assert.Equal(t, links, hrefs)
}
func TestIssueCommentChangeMilestone(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2/repo1/issues/1")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Add milestone
testIssueCommentChangeEvent(t, htmlDoc, "2000",
"octicon-milestone", "User One", "/user1",
[]string{"user1 added this to the milestone1 milestone"},
[]string{"/user1"})
// []string{"/user1", "/user2/repo1/milestone/1"})
// Modify milestone
testIssueCommentChangeEvent(t, htmlDoc, "2001",
"octicon-milestone", "User One", "/user1",
[]string{"user1 modified the milestone from milestone1 to milestone2"},
[]string{"/user1"})
// []string{"/user1", "/user2/repo1/milestone/1", "/user2/repo1/milestone/2"})
// Remove milestone
testIssueCommentChangeEvent(t, htmlDoc, "2002",
"octicon-milestone", "User One", "/user1",
[]string{"user1 removed this from the milestone2 milestone"},
[]string{"/user1"})
// []string{"/user1", "/user2/repo1/milestone/2"})
// Deleted milestone
testIssueCommentChangeEvent(t, htmlDoc, "2003",
"octicon-milestone", "User One", "/user1",
[]string{"user1 added this to the (deleted) milestone"},
[]string{"/user1"})
}
func TestIssueCommentChangeProject(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2/repo1/issues/1")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Add project
testIssueCommentChangeEvent(t, htmlDoc, "2010",
"octicon-project", "User One", "/user1",
[]string{"user1 added this to the First project project"},
[]string{"/user1"})
// []string{"/user1", "/user2/repo1/projects/1"})
// Modify project
testIssueCommentChangeEvent(t, htmlDoc, "2011",
"octicon-project", "User One", "/user1",
[]string{"user1 modified the project from First project to second project"},
[]string{"/user1"})
// []string{"/user1", "/user2/repo1/projects/1", "/user2/repo1/projects/2"})
// Remove project
testIssueCommentChangeEvent(t, htmlDoc, "2012",
"octicon-project", "User One", "/user1",
[]string{"user1 removed this from the second project project"},
[]string{"/user1"})
// []string{"/user1", "/user2/repo1/projects/2"})
// Deleted project
testIssueCommentChangeEvent(t, htmlDoc, "2013",
"octicon-project", "User One", "/user1",
[]string{"user1 added this to the (deleted) project"},
[]string{"/user1"})
}
func TestIssueCommentChangeLabel(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2/repo1/issues/1")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Add multiple labels
testIssueCommentChangeEvent(t, htmlDoc, "2020",
"octicon-tag", "User One", "/user1",
[]string{"user1 added the label1 label2 labels "},
[]string{"/user1", "/user2/repo1/issues?labels=1", "/user2/repo1/issues?labels=2"})
assert.Empty(t, htmlDoc.Find("#issuecomment-2021 .text").Text())
// Remove single label
testIssueCommentChangeEvent(t, htmlDoc, "2022",
"octicon-tag", "< U<se>r Tw<o > ><", "/user2",
[]string{"user2 removed the label1 label "},
[]string{"/user2", "/user2/repo1/issues?labels=1"})
// Modify labels (add and remove)
testIssueCommentChangeEvent(t, htmlDoc, "2023",
"octicon-tag", "User One", "/user1",
[]string{"user1 added label1 and removed label2 labels "},
[]string{"/user1", "/user2/repo1/issues?labels=1", "/user2/repo1/issues?labels=2"})
assert.Empty(t, htmlDoc.Find("#issuecomment-2024 .text").Text())
// Add single label
testIssueCommentChangeEvent(t, htmlDoc, "2025",
"octicon-tag", "< U<se>r Tw<o > ><", "/user2",
[]string{"user2 added the label2 label "},
[]string{"/user2", "/user2/repo1/issues?labels=2"})
// Remove multiple labels
testIssueCommentChangeEvent(t, htmlDoc, "2026",
"octicon-tag", "User One", "/user1",
[]string{"user1 removed the label1 label2 labels "},
[]string{"/user1", "/user2/repo1/issues?labels=1", "/user2/repo1/issues?labels=2"})
assert.Empty(t, htmlDoc.Find("#issuecomment-2027 .text").Text())
}
func TestIssueCommentChangeAssignee(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2/repo1/issues/1")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Self-assign
testIssueCommentChangeEvent(t, htmlDoc, "2040",
"octicon-person", "User One", "/user1",
[]string{"user1 self-assigned this"},
[]string{"/user1"})
// Remove other
testIssueCommentChangeEvent(t, htmlDoc, "2041",
"octicon-person", "User One", "/user1",
[]string{"user1 was unassigned by user2"},
[]string{"/user1"})
// []string{"/user1", "/user2"})
// Add other
testIssueCommentChangeEvent(t, htmlDoc, "2042",
"octicon-person", "< U<se>r Tw<o > ><", "/user2",
[]string{"user2 was assigned by user1"},
[]string{"/user2"})
// []string{"/user2", "/user1"})
// Self-remove
testIssueCommentChangeEvent(t, htmlDoc, "2043",
"octicon-person", "< U<se>r Tw<o > ><", "/user2",
[]string{"user2 removed their assignment"},
[]string{"/user2"})
}
func TestIssueCommentChangeLock(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2/repo1/issues/1")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Lock without reason
testIssueCommentChangeEvent(t, htmlDoc, "2050",
"octicon-lock", "User One", "/user1",
[]string{"user1 locked and limited conversation to collaborators"},
[]string{"/user1"})
// Unlock
testIssueCommentChangeEvent(t, htmlDoc, "2051",
"octicon-key", "User One", "/user1",
[]string{"user1 unlocked this conversation"},
[]string{"/user1"})
// Lock with reason
testIssueCommentChangeEvent(t, htmlDoc, "2052",
"octicon-lock", "User One", "/user1",
[]string{"user1 locked as Too heated and limited conversation to collaborators"},
[]string{"/user1"})
// Unlock
testIssueCommentChangeEvent(t, htmlDoc, "2053",
"octicon-key", "User One", "/user1",
[]string{"user1 unlocked this conversation"},
[]string{"/user1"})
}
func TestIssueCommentChangePin(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2/repo1/issues/1")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Pin
testIssueCommentChangeEvent(t, htmlDoc, "2060",
"octicon-pin", "User One", "/user1",
[]string{"user1 pinned this"},
[]string{"/user1"})
// Unpin
testIssueCommentChangeEvent(t, htmlDoc, "2061",
"octicon-pin", "User One", "/user1",
[]string{"user1 unpinned this"},
[]string{"/user1"})
}
func TestIssueCommentChangeOpen(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2/repo1/issues/1")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Close issue
testIssueCommentChangeEvent(t, htmlDoc, "2070",
"octicon-circle-slash", "User One", "/user1",
[]string{"user1 closed this issue"},
[]string{"/user1"})
// Reopen issue
testIssueCommentChangeEvent(t, htmlDoc, "2071",
"octicon-dot-fill", "< U<se>r Tw<o > ><", "/user2",
[]string{"user2 reopened this issue"},
[]string{"/user2"})
req = NewRequest(t, "GET", "/user2/repo1/pulls/2")
resp = MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
// Close pull request
testIssueCommentChangeEvent(t, htmlDoc, "2072",
"octicon-circle-slash", "User One", "/user1",
[]string{"user1 closed this pull request"},
[]string{"/user1"})
// Reopen pull request
testIssueCommentChangeEvent(t, htmlDoc, "2073",
"octicon-dot-fill", "< U<se>r Tw<o > ><", "/user2",
[]string{"user2 reopened this pull request"},
[]string{"/user2"})
}
func TestIssueCommentChangeIssueReference(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2/repo1/issues/1")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Issue reference from issue
testIssueCommentChangeEvent(t, htmlDoc, "2080",
"octicon-bookmark", "User One", "/user1",
[]string{"user1 referenced this issue ", "issue5 #4"},
[]string{"/user1", "/user2/repo1/issues/4", "#issuecomment-2080", "/user2/repo1/issues/4"})
// Issue reference from pull
testIssueCommentChangeEvent(t, htmlDoc, "2081",
"octicon-bookmark", "User One", "/user1",
[]string{"user1 referenced this issue ", "issue2 #2"},
[]string{"/user1", "/user2/repo1/pulls/2", "#issuecomment-2081", "/user2/repo1/pulls/2"})
// Issue reference from issue in different repo
testIssueCommentChangeEvent(t, htmlDoc, "2082",
"octicon-bookmark", "User One", "/user1",
[]string{"user1 referenced this issue from org3/repo21", "just a normal issue #1"},
[]string{"/user1", "/org3/repo21/issues/1", "#issuecomment-2082", "/org3/repo21/issues/1"})
// Issue reference from pull in different repo
testIssueCommentChangeEvent(t, htmlDoc, "2083",
"octicon-bookmark", "User One", "/user1",
[]string{"user1 referenced this issue from user12/repo10 ", "pr2 #1"},
[]string{"/user1", "/user12/repo10/pulls/1", "#issuecomment-2083", "/user12/repo10/pulls/1"})
}
func TestIssueCommentChangePullReference(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "GET", "/user2/repo1/pulls/2")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Pull reference from issue
testIssueCommentChangeEvent(t, htmlDoc, "2090",
"octicon-bookmark", "User One", "/user1",
[]string{"user1 referenced this pull request ", "issue1 #1"},
[]string{"/user1", "/user2/repo1/issues/1", "#issuecomment-2090", "/user2/repo1/issues/1"})
// Pull reference from pull
testIssueCommentChangeEvent(t, htmlDoc, "2091",
"octicon-bookmark", "User One", "/user1",
[]string{"user1 referenced this pull request ", "issue2 #2"},
[]string{"/user1", "/user2/repo1/pulls/2", "#issuecomment-2091", "/user2/repo1/pulls/2"})
// Pull reference from issue in different repo
testIssueCommentChangeEvent(t, htmlDoc, "2092",
"octicon-bookmark", "User One", "/user1",
[]string{"user1 referenced this pull request from org3/repo21", "just a normal issue #1"},
[]string{"/user1", "/org3/repo21/issues/1", "#issuecomment-2092", "/org3/repo21/issues/1"})
// Pull reference from pull in different repo
testIssueCommentChangeEvent(t, htmlDoc, "2093",
"octicon-bookmark", "User One", "/user1",
[]string{"user1 referenced this pull request from user12/repo10 ", "pr2 #1"},
[]string{"/user1", "/user12/repo10/pulls/1", "#issuecomment-2093", "/user12/repo10/pulls/1"})
}

View file

@ -0,0 +1,108 @@
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"net/url"
"strings"
"testing"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
files_service "forgejo.org/services/repository/files"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
)
func TestOrgProfile(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
checkReadme := func(t *testing.T, title, readmeFilename string, expectedCount int) {
t.Run(title, func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Prepare the test repository
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
var ops []*files_service.ChangeRepoFile
op := "create"
if readmeFilename != "README.md" {
ops = append(ops, &files_service.ChangeRepoFile{
Operation: "delete",
TreePath: "README.md",
})
} else {
op = "update"
}
if readmeFilename != "" {
ops = append(ops, &files_service.ChangeRepoFile{
Operation: op,
TreePath: readmeFilename,
ContentReader: strings.NewReader("# Hi!\n"),
})
}
_, _, f := tests.CreateDeclarativeRepo(t, org3, ".profile", nil, nil, ops)
defer f()
// Perform the test
req := NewRequest(t, "GET", "/org3")
resp := MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
readmeCount := doc.Find("#readme_profile").Length()
assert.Equal(t, expectedCount, readmeCount)
})
}
checkReadme(t, "No readme", "", 0)
checkReadme(t, "README.md", "README.md", 1)
checkReadme(t, "readme.md", "readme.md", 1)
checkReadme(t, "ReadMe.mD", "ReadMe.mD", 1)
checkReadme(t, "readme.org does not render", "README.org", 0)
t.Run("readme-size", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Prepare the test repository
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
_, _, f := tests.CreateDeclarativeRepo(t, org3, ".profile", nil, nil, []*files_service.ChangeRepoFile{
{
Operation: "update",
TreePath: "README.md",
ContentReader: strings.NewReader(`## Lorem ipsum
dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
## Ut enim ad minim veniam
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum`),
},
})
defer f()
t.Run("full", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.UI.MaxDisplayFileSize, 500)()
req := NewRequest(t, "GET", "/org3")
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "Ut enim ad minim veniam")
assert.Contains(t, resp.Body.String(), "mollit anim id est laborum")
})
t.Run("truncated", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.UI.MaxDisplayFileSize, 146)()
req := NewRequest(t, "GET", "/org3")
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "Ut enim ad minim")
assert.NotContains(t, resp.Body.String(), "veniam")
})
})
})
}

View file

@ -6,6 +6,8 @@ package integration
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
auth_model "forgejo.org/models/auth"
@ -113,3 +115,104 @@ func TestWikiTOC(t *testing.T) {
assert.Equal(t, "Helpdesk", htmlDoc.Find(".wiki-content-toc a").Text())
})
}
func canEditWiki(t *testing.T, username, url string, canEdit bool) {
t.Helper()
// t.Parallel()
req := NewRequest(t, "GET", url)
var resp *httptest.ResponseRecorder
if username != "" {
session := loginUser(t, username)
resp = session.MakeRequest(t, req, http.StatusOK)
} else {
resp = MakeRequest(t, req, http.StatusOK)
}
doc := NewHTMLParser(t, resp.Body)
res := doc.Find(`a[href^="` + url + `"]`).Map(func(_ int, el *goquery.Selection) string {
return el.AttrOr("href", "")
})
found := false
for _, href := range res {
if strings.HasSuffix(href, "?action=_new") {
if !canEdit {
t.Errorf("unexpected edit link: %s", href)
}
found = true
break
}
}
if canEdit {
assert.True(t, found, "could not find ?action=_new link among %v", res)
}
}
func TestWikiPermissions(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("default settings", func(t *testing.T) {
t.Run("anonymous", func(t *testing.T) {
canEditWiki(t, "", "/user5/repo4/wiki", false)
})
t.Run("owner", func(t *testing.T) {
canEditWiki(t, "user5", "/user5/repo4/wiki", true)
})
t.Run("collaborator", func(t *testing.T) {
canEditWiki(t, "user4", "/user5/repo4/wiki", true)
canEditWiki(t, "user29", "/user5/repo4/wiki", true)
})
t.Run("other user", func(t *testing.T) {
canEditWiki(t, "user2", "/user5/repo4/wiki", false)
})
})
t.Run("saved unchanged settings", func(t *testing.T) {
session := loginUser(t, "user5")
csrf := GetCSRF(t, session, "/user5/repo4/settings/units")
req := NewRequestWithValues(t, "POST", "/user5/repo4/settings/units", map[string]string{
"_csrf": csrf,
"enable_wiki": "on",
})
session.MakeRequest(t, req, http.StatusSeeOther)
t.Run("anonymous", func(t *testing.T) {
canEditWiki(t, "", "/user5/repo4/wiki", false)
})
t.Run("owner", func(t *testing.T) {
canEditWiki(t, "user5", "/user5/repo4/wiki", true)
})
t.Run("collaborator", func(t *testing.T) {
canEditWiki(t, "user4", "/user5/repo4/wiki", true)
canEditWiki(t, "user29", "/user5/repo4/wiki", true)
})
t.Run("other user", func(t *testing.T) {
canEditWiki(t, "user2", "/user5/repo4/wiki", false)
})
})
t.Run("globally writable", func(t *testing.T) {
session := loginUser(t, "user5")
csrf := GetCSRF(t, session, "/user5/repo4/settings/units")
req := NewRequestWithValues(t, "POST", "/user5/repo4/settings/units", map[string]string{
"_csrf": csrf,
"enable_wiki": "on",
"globally_writeable_wiki": "on",
})
session.MakeRequest(t, req, http.StatusSeeOther)
t.Run("anonymous", func(t *testing.T) {
canEditWiki(t, "", "/user5/repo4/wiki", false)
})
t.Run("owner", func(t *testing.T) {
canEditWiki(t, "user5", "/user5/repo4/wiki", true)
})
t.Run("collaborator", func(t *testing.T) {
canEditWiki(t, "user4", "/user5/repo4/wiki", true)
canEditWiki(t, "user29", "/user5/repo4/wiki", true)
})
t.Run("other user", func(t *testing.T) {
canEditWiki(t, "user2", "/user5/repo4/wiki", true)
})
})
}

View file

@ -11,6 +11,8 @@ import (
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
files_service "forgejo.org/services/repository/files"
"forgejo.org/tests"
@ -63,5 +65,44 @@ func TestUserProfile(t *testing.T) {
checkReadme(t, "readme.md", "readme.md", 1)
checkReadme(t, "ReadMe.mD", "ReadMe.mD", 1)
checkReadme(t, "readme.org does not render", "README.org", 0)
t.Run("readme-size", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Prepare the test repository
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
_, _, f := tests.CreateDeclarativeRepo(t, user2, ".profile", nil, nil, []*files_service.ChangeRepoFile{
{
Operation: "update",
TreePath: "README.md",
ContentReader: strings.NewReader(`## Lorem ipsum
dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
## Ut enim ad minim veniam
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum`),
},
})
defer f()
t.Run("full", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.UI.MaxDisplayFileSize, 500)()
req := NewRequest(t, "GET", "/user2")
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "Ut enim ad minim veniam")
assert.Contains(t, resp.Body.String(), "mollit anim id est laborum")
})
t.Run("truncated", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.UI.MaxDisplayFileSize, 146)()
req := NewRequest(t, "GET", "/user2")
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "Ut enim ad minim")
assert.NotContains(t, resp.Body.String(), "veniam")
})
})
})
}

View file

@ -143,7 +143,7 @@ func TestUserRedirect(t *testing.T) {
defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 8)()
defer tests.PrintCurrentTest(t)()
assert.Contains(t, getPrompt(t), "The old username will be available to everyone after a cooldown period of 8 days, you can still reclaim the old username during the cooldown period.")
assert.Contains(t, getPrompt(t), "The old username will be available to everyone after a cooldown period of 8 days. You can still reclaim the old username during the cooldown period.")
})
})
@ -167,7 +167,7 @@ func TestUserRedirect(t *testing.T) {
defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 8)()
defer tests.PrintCurrentTest(t)()
assert.Contains(t, getPrompt(t), "The old organization name will be available to everyone after a cooldown period of 8 days, you can still reclaim the old name during the cooldown period.")
assert.Contains(t, getPrompt(t), "The old organization name will be available to everyone after a cooldown period of 8 days. You can still reclaim the old name during the cooldown period.")
})
})
}

14
tests/testdata/data/viewer/README.md vendored Normal file
View file

@ -0,0 +1,14 @@
# GLTF 3D Model Viewer
⚠️ Currently supports `.glb` files only.
3D models with the `.glb` format are rendered in repository file view.
## 🔨 How to Test
1) Create a new repository or use an existing one.
2) Upload a `.glb` file such as [Unicode❤♻Test.glb](./Unicode❤♻Test.glb) (CC0 1.0 Universal).
3) View the file in the repository.
- Similar to image files, the 3D model should be rendered in a viewer.
- Use mouse clicks to turn and zoom.

Binary file not shown.

View file

@ -415,6 +415,11 @@ td .commit-summary {
max-width: 600px !important;
}
model-viewer {
width: 100%;
height: 100vh;
}
.pdf-content {
width: 100%;
height: 100vh;

View file

@ -121,12 +121,12 @@ class ComboMarkdownEditor {
// Prevent special keyboard handling if currently a text expander popup is open
if (this.textarea.hasAttribute('aria-expanded')) return;
const noModifiers = !e.shiftKey && !e.ctrlKey && !e.altKey;
const noModifiers = !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
if (e.key === 'Escape') {
// Explicitly lose focus and reenable tab navigation.
e.target.blur();
this.tabEnabled = false;
} else if (e.key === 'Tab' && this.tabEnabled && !e.altKey && !e.ctrlKey) {
} else if (e.key === 'Tab' && this.tabEnabled && !e.altKey && !e.ctrlKey && !e.metaKey) {
if (this.indentSelection(e.shiftKey, true)) {
this.options?.onContentChanged?.(this, e);
e.preventDefault();

View file

@ -23,6 +23,7 @@ import {initStopwatch} from './features/stopwatch.js';
import {initFindFileInRepo} from './features/repo-findfile.js';
import {initCommentContent, initMarkupContent} from './markup/content.js';
import {initPdfViewer} from './render/pdf.js';
import {initGltfViewer} from './render/gltf.js';
import {initUserAuthOauth2, initUserAuth} from './features/user-auth.js';
import {
@ -187,6 +188,7 @@ onDomReady(() => {
initUserAuth();
initRepoDiffView();
initPdfViewer();
initGltfViewer();
initScopedAccessTokenCategories();
initColorPickers();
});

View file

@ -0,0 +1,6 @@
export async function initGltfViewer() {
const els = document.querySelectorAll('model-viewer');
if (!els.length) return;
await import(/* webpackChunkName: "@google/model-viewer" */'@google/model-viewer');
}