Quellcode durchsuchen

db: refactor "action" table to use GORM (#7054)

Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com>
Joe Chen vor 2 Jahren
Ursprung
Commit
083c3ee659
70 geänderte Dateien mit 3312 neuen und 1204 gelöschten Zeilen
  1. 42 2
      .github/workflows/go.yml
  2. 24 0
      docs/dev/database_schema.md
  3. 32 0
      internal/conf/mocks.go
  4. 92 81
      internal/conf/static.go
  5. 2 1
      internal/context/go_get.go
  6. 2 1
      internal/context/repo.go
  7. 6 4
      internal/db/access_tokens.go
  8. 16 6
      internal/db/access_tokens_test.go
  9. 0 766
      internal/db/action.go
  10. 0 41
      internal/db/action_test.go
  11. 962 0
      internal/db/actions.go
  12. 872 0
      internal/db/actions_test.go
  13. 1 1
      internal/db/backup.go
  14. 44 3
      internal/db/backup_test.go
  15. 11 11
      internal/db/comment.go
  16. 5 3
      internal/db/db.go
  17. 14 14
      internal/db/issue.go
  18. 2 2
      internal/db/lfs.go
  19. 0 1
      internal/db/lfs_test.go
  20. 2 2
      internal/db/login_source_files.go
  21. 2 2
      internal/db/login_source_files_test.go
  22. 8 8
      internal/db/login_sources.go
  23. 16 17
      internal/db/login_sources_test.go
  24. 2 0
      internal/db/migrations/migrations.go
  25. 19 0
      internal/db/migrations/v21.go
  26. 82 0
      internal/db/migrations/v21_test.go
  27. 2 2
      internal/db/milestone.go
  28. 19 11
      internal/db/mirror.go
  29. 34 34
      internal/db/mocks_test.go
  30. 1 1
      internal/db/models.go
  31. 1 1
      internal/db/perms.go
  32. 0 1
      internal/db/perms_test.go
  33. 13 10
      internal/db/pull.go
  34. 1 1
      internal/db/release.go
  35. 104 47
      internal/db/repo.go
  36. 82 8
      internal/db/repos.go
  37. 40 12
      internal/db/repos_test.go
  38. 3 0
      internal/db/testdata/backup/Action.golden.json
  39. 2 2
      internal/db/two_factor.go
  40. 25 1
      internal/db/two_factors_test.go
  41. 28 20
      internal/db/update.go
  42. 15 13
      internal/db/user.go
  43. 3 3
      internal/db/user_mail.go
  44. 10 4
      internal/db/users.go
  45. 11 12
      internal/db/users_test.go
  46. 38 0
      internal/db/watches.go
  47. 47 0
      internal/db/watches_test.go
  48. 6 0
      internal/db/webhook.go
  49. 4 1
      internal/db/wiki.go
  50. 4 4
      internal/dbtest/dbtest.go
  51. 3 5
      internal/lazyregexp/lazyre.go
  52. 62 0
      internal/repoutil/repoutil.go
  53. 127 0
      internal/repoutil/repoutil_test.go
  54. 2 2
      internal/route/admin/auths.go
  55. 3 3
      internal/route/admin/users.go
  56. 9 9
      internal/route/api/v1/repo/repo.go
  57. 257 11
      internal/route/lfs/mocks_test.go
  58. 1 1
      internal/route/repo/branch.go
  59. 1 1
      internal/route/repo/repo.go
  60. 2 2
      internal/route/repo/setting.go
  61. 1 1
      internal/route/repo/webhook.go
  62. 2 2
      internal/route/user/auth.go
  63. 10 2
      internal/route/user/home.go
  64. 1 1
      internal/route/user/profile.go
  65. 10 0
      internal/strutil/strutil.go
  66. 40 0
      internal/strutil/strutil_test.go
  67. 2 1
      internal/template/template.go
  68. 13 0
      internal/testutil/testutil.go
  69. 15 0
      internal/testutil/testutil_test.go
  70. 0 9
      internal/tool/tool.go

+ 42 - 2
.github/workflows/go.yml

@@ -56,7 +56,7 @@ jobs:
     strategy:
       matrix:
         go-version: [ 1.16.x, 1.17.x, 1.18.x ]
-        platform: [ ubuntu-latest, macos-latest, windows-latest ]
+        platform: [ ubuntu-latest, macos-latest ]
     runs-on: ${{ matrix.platform }}
     steps:
       - name: Install Go
@@ -89,6 +89,46 @@ jobs:
 
             View the job run at: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
 
+  # Running tests with race detection consumes too much memory on Windows,
+  # see https://github.com/golang/go/issues/46099 for details.
+  test-windows:
+    name: Test
+    strategy:
+      matrix:
+        go-version: [ 1.16.x, 1.17.x, 1.18.x ]
+        platform: [ windows-latest ]
+    runs-on: ${{ matrix.platform }}
+    steps:
+      - name: Install Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: ${{ matrix.go-version }}
+      - name: Checkout code
+        uses: actions/checkout@v2
+      - name: Run tests with coverage
+        run: go test -v -coverprofile=coverage -covermode=atomic ./...
+      - name: Upload coverage report to Codecov
+        uses: codecov/[email protected]
+        with:
+          file: ./coverage
+          flags: unittests
+      - name: Send email on failure
+        uses: dawidd6/action-send-mail@v3
+        if: ${{ failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' }}
+        with:
+          server_address: smtp.mailgun.org
+          server_port: 465
+          username: ${{ secrets.SMTP_USERNAME }}
+          password: ${{ secrets.SMTP_PASSWORD }}
+          subject: GitHub Actions (${{ github.repository }}) job result
+          to: [email protected]
+          from: GitHub Actions (${{ github.repository }})
+          reply_to: [email protected]
+          body: |
+            The job "${{ github.job }}" of ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }} completed with "${{ job.status }}".
+
+            View the job run at: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+
   postgres:
     name: Postgres
     strategy:
@@ -151,7 +191,7 @@ jobs:
           MYSQL_PORT: 3306
 
   sqlite-go:
-    name: SQLite (Go)
+    name: SQLite - Go
     strategy:
       matrix:
         go-version: [ 1.16.x, 1.17.x, 1.18.x ]

+ 24 - 0
docs/dev/database_schema.md

@@ -31,6 +31,30 @@ Indexes:
 	"idx_access_token_user_id" (uid)
 ```
 
+# Table "action"
+
+```
+     FIELD     |     COLUMN     |           POSTGRESQL           |             MYSQL              |            SQLITE3              
+---------------+----------------+--------------------------------+--------------------------------+---------------------------------
+  ID           | id             | BIGSERIAL                      | BIGINT AUTO_INCREMENT          | INTEGER                         
+  UserID       | user_id        | BIGINT                         | BIGINT                         | INTEGER                         
+  OpType       | op_type        | BIGINT                         | BIGINT                         | INTEGER                         
+  ActUserID    | act_user_id    | BIGINT                         | BIGINT                         | INTEGER                         
+  ActUserName  | act_user_name  | TEXT                           | LONGTEXT                       | TEXT                            
+  RepoID       | repo_id        | BIGINT                         | BIGINT                         | INTEGER                         
+  RepoUserName | repo_user_name | TEXT                           | LONGTEXT                       | TEXT                            
+  RepoName     | repo_name      | TEXT                           | LONGTEXT                       | TEXT                            
+  RefName      | ref_name       | TEXT                           | LONGTEXT                       | TEXT                            
+  IsPrivate    | is_private     | BOOLEAN NOT NULL DEFAULT FALSE | BOOLEAN NOT NULL DEFAULT FALSE | NUMERIC NOT NULL DEFAULT FALSE  
+  Content      | content        | TEXT                           | LONGTEXT                       | TEXT                            
+  CreatedUnix  | created_unix   | BIGINT                         | BIGINT                         | INTEGER                         
+
+Primary keys: id
+Indexes: 
+	"idx_action_repo_id" (repo_id)
+	"idx_action_user_id" (user_id)
+```
+
 # Table "lfs_object"
 
 ```

+ 32 - 0
internal/conf/mocks.go

@@ -8,6 +8,14 @@ import (
 	"testing"
 )
 
+func SetMockApp(t *testing.T, opts AppOpts) {
+	before := App
+	App = opts
+	t.Cleanup(func() {
+		App = before
+	})
+}
+
 func SetMockServer(t *testing.T, opts ServerOpts) {
 	before := Server
 	Server = opts
@@ -15,3 +23,27 @@ func SetMockServer(t *testing.T, opts ServerOpts) {
 		Server = before
 	})
 }
+
+func SetMockSSH(t *testing.T, opts SSHOpts) {
+	before := SSH
+	SSH = opts
+	t.Cleanup(func() {
+		SSH = before
+	})
+}
+
+func SetMockRepository(t *testing.T, opts RepositoryOpts) {
+	before := Repository
+	Repository = opts
+	t.Cleanup(func() {
+		Repository = before
+	})
+}
+
+func SetMockUI(t *testing.T, opts UIOpts) {
+	before := UI
+	UI = opts
+	t.Cleanup(func() {
+		UI = before
+	})
+}

+ 92 - 81
internal/conf/static.go

@@ -13,6 +13,9 @@ import (
 )
 
 // ℹ️ README: This file contains static values that should only be set at initialization time.
+//
+// ⚠️ WARNING: After changing any options, do not forget to update template of
+// "/admin/config" page as well.
 
 // HasMinWinSvc is whether the application is built with Windows Service support.
 //
@@ -30,67 +33,7 @@ var (
 // CustomConf returns the absolute path of custom configuration file that is used.
 var CustomConf string
 
-// ⚠️ WARNING: After changing the following section, do not forget to update template of
-// "/admin/config" page as well.
 var (
-	// Application settings
-	App struct {
-		// ⚠️ WARNING: Should only be set by the main package (i.e. "gogs.go").
-		Version string `ini:"-"`
-
-		BrandName string
-		RunUser   string
-		RunMode   string
-	}
-
-	// SSH settings
-	SSH struct {
-		Disabled                     bool   `ini:"DISABLE_SSH"`
-		Domain                       string `ini:"SSH_DOMAIN"`
-		Port                         int    `ini:"SSH_PORT"`
-		RootPath                     string `ini:"SSH_ROOT_PATH"`
-		KeygenPath                   string `ini:"SSH_KEYGEN_PATH"`
-		KeyTestPath                  string `ini:"SSH_KEY_TEST_PATH"`
-		MinimumKeySizeCheck          bool
-		MinimumKeySizes              map[string]int `ini:"-"` // Load from [ssh.minimum_key_sizes]
-		RewriteAuthorizedKeysAtStart bool
-
-		StartBuiltinServer bool     `ini:"START_SSH_SERVER"`
-		ListenHost         string   `ini:"SSH_LISTEN_HOST"`
-		ListenPort         int      `ini:"SSH_LISTEN_PORT"`
-		ServerCiphers      []string `ini:"SSH_SERVER_CIPHERS"`
-		ServerMACs         []string `ini:"SSH_SERVER_MACS"`
-	}
-
-	// Repository settings
-	Repository struct {
-		Root                     string
-		ScriptType               string
-		ANSICharset              string `ini:"ANSI_CHARSET"`
-		ForcePrivate             bool
-		MaxCreationLimit         int
-		PreferredLicenses        []string
-		DisableHTTPGit           bool `ini:"DISABLE_HTTP_GIT"`
-		EnableLocalPathMigration bool
-		EnableRawFileRenderMode  bool
-		CommitsFetchConcurrency  int
-
-		// Repository editor settings
-		Editor struct {
-			LineWrapExtensions   []string
-			PreviewableFileModes []string
-		} `ini:"repository.editor"`
-
-		// Repository upload settings
-		Upload struct {
-			Enabled      bool
-			TempPath     string
-			AllowedTypes []string `delim:"|"`
-			FileMaxSize  int64
-			MaxFiles     int
-		} `ini:"repository.upload"`
-	}
-
 	// Security settings
 	Security struct {
 		InstallLock             bool
@@ -295,27 +238,6 @@ var (
 		MaxResponseItems int
 	}
 
-	// UI settings
-	UI struct {
-		ExplorePagingNum   int
-		IssuePagingNum     int
-		FeedMaxCommitNum   int
-		ThemeColorMetaTag  string
-		MaxDisplayFileSize int64
-
-		Admin struct {
-			UserPagingNum   int
-			RepoPagingNum   int
-			NoticePagingNum int
-			OrgPagingNum    int
-		} `ini:"ui.admin"`
-		User struct {
-			RepoPagingNum     int
-			NewsFeedPagingNum int
-			CommitsPagingNum  int
-		} `ini:"ui.user"`
-	}
-
 	// Prometheus settings
 	Prometheus struct {
 		Enabled           bool
@@ -334,6 +256,18 @@ var (
 	HasRobotsTxt bool
 )
 
+type AppOpts struct {
+	// ⚠️ WARNING: Should only be set by the main package (i.e. "gogs.go").
+	Version string `ini:"-"`
+
+	BrandName string
+	RunUser   string
+	RunMode   string
+}
+
+// Application settings
+var App AppOpts
+
 type ServerOpts struct {
 	ExternalURL          string `ini:"EXTERNAL_URL"`
 	Domain               string
@@ -365,6 +299,58 @@ type ServerOpts struct {
 // Server settings
 var Server ServerOpts
 
+type SSHOpts struct {
+	Disabled                     bool   `ini:"DISABLE_SSH"`
+	Domain                       string `ini:"SSH_DOMAIN"`
+	Port                         int    `ini:"SSH_PORT"`
+	RootPath                     string `ini:"SSH_ROOT_PATH"`
+	KeygenPath                   string `ini:"SSH_KEYGEN_PATH"`
+	KeyTestPath                  string `ini:"SSH_KEY_TEST_PATH"`
+	MinimumKeySizeCheck          bool
+	MinimumKeySizes              map[string]int `ini:"-"` // Load from [ssh.minimum_key_sizes]
+	RewriteAuthorizedKeysAtStart bool
+
+	StartBuiltinServer bool     `ini:"START_SSH_SERVER"`
+	ListenHost         string   `ini:"SSH_LISTEN_HOST"`
+	ListenPort         int      `ini:"SSH_LISTEN_PORT"`
+	ServerCiphers      []string `ini:"SSH_SERVER_CIPHERS"`
+	ServerMACs         []string `ini:"SSH_SERVER_MACS"`
+}
+
+// SSH settings
+var SSH SSHOpts
+
+type RepositoryOpts struct {
+	Root                     string
+	ScriptType               string
+	ANSICharset              string `ini:"ANSI_CHARSET"`
+	ForcePrivate             bool
+	MaxCreationLimit         int
+	PreferredLicenses        []string
+	DisableHTTPGit           bool `ini:"DISABLE_HTTP_GIT"`
+	EnableLocalPathMigration bool
+	EnableRawFileRenderMode  bool
+	CommitsFetchConcurrency  int
+
+	// Repository editor settings
+	Editor struct {
+		LineWrapExtensions   []string
+		PreviewableFileModes []string
+	} `ini:"repository.editor"`
+
+	// Repository upload settings
+	Upload struct {
+		Enabled      bool
+		TempPath     string
+		AllowedTypes []string `delim:"|"`
+		FileMaxSize  int64
+		MaxFiles     int
+	} `ini:"repository.upload"`
+}
+
+// Repository settings
+var Repository RepositoryOpts
+
 type DatabaseOpts struct {
 	Type         string
 	Host         string
@@ -389,6 +375,31 @@ type LFSOpts struct {
 // LFS settings
 var LFS LFSOpts
 
+type UIUserOpts struct {
+	RepoPagingNum     int
+	NewsFeedPagingNum int
+	CommitsPagingNum  int
+}
+
+type UIOpts struct {
+	ExplorePagingNum   int
+	IssuePagingNum     int
+	FeedMaxCommitNum   int
+	ThemeColorMetaTag  string
+	MaxDisplayFileSize int64
+
+	Admin struct {
+		UserPagingNum   int
+		RepoPagingNum   int
+		NoticePagingNum int
+		OrgPagingNum    int
+	} `ini:"ui.admin"`
+	User UIUserOpts `ini:"ui.user"`
+}
+
+// UI settings
+var UI UIOpts
+
 type i18nConf struct {
 	Langs     []string          `delim:","`
 	Names     []string          `delim:","`

+ 2 - 1
internal/context/go_get.go

@@ -10,6 +10,7 @@ import (
 
 	"gogs.io/gogs/internal/conf"
 	"gogs.io/gogs/internal/db"
+	"gogs.io/gogs/internal/repoutil"
 )
 
 // ServeGoGet does quick responses for appropriate go-get meta with status OK
@@ -52,7 +53,7 @@ func ServeGoGet() macaron.Handler {
 `,
 			map[string]string{
 				"GoGetImport":    path.Join(conf.Server.URL.Host, conf.Server.Subpath, ownerName, repoName),
-				"CloneLink":      db.ComposeHTTPSCloneURL(ownerName, repoName),
+				"CloneLink":      repoutil.HTTPSCloneURL(ownerName, repoName),
 				"GoDocDirectory": prefix + "{/dir}",
 				"GoDocFile":      prefix + "{/dir}/{file}#L{line}",
 				"InsecureFlag":   insecureFlag,

+ 2 - 1
internal/context/repo.go

@@ -18,6 +18,7 @@ import (
 
 	"gogs.io/gogs/internal/conf"
 	"gogs.io/gogs/internal/db"
+	"gogs.io/gogs/internal/repoutil"
 )
 
 type PullRequest struct {
@@ -43,7 +44,7 @@ type Repository struct {
 	TreePath     string
 	CommitID     string
 	RepoLink     string
-	CloneLink    db.CloneLink
+	CloneLink    repoutil.CloneLink
 	CommitsCount int64
 	Mirror       *db.Mirror
 

+ 6 - 4
internal/db/access_tokens.go

@@ -42,7 +42,7 @@ var AccessTokens AccessTokensStore
 
 // AccessToken is a personal access token.
 type AccessToken struct {
-	ID     int64
+	ID     int64 `gorm:"primarykey"`
 	UserID int64 `xorm:"uid" gorm:"column:uid;index"`
 	Name   string
 	Sha1   string `gorm:"type:VARCHAR(40);unique"`
@@ -67,9 +67,11 @@ func (t *AccessToken) BeforeCreate(tx *gorm.DB) error {
 // AfterFind implements the GORM query hook.
 func (t *AccessToken) AfterFind(tx *gorm.DB) error {
 	t.Created = time.Unix(t.CreatedUnix, 0).Local()
-	t.Updated = time.Unix(t.UpdatedUnix, 0).Local()
-	t.HasUsed = t.Updated.After(t.Created)
-	t.HasRecentActivity = t.Updated.Add(7 * 24 * time.Hour).After(tx.NowFunc())
+	if t.UpdatedUnix > 0 {
+		t.Updated = time.Unix(t.UpdatedUnix, 0).Local()
+		t.HasUsed = t.Updated.After(t.Created)
+		t.HasRecentActivity = t.Updated.Add(7 * 24 * time.Hour).After(tx.NowFunc())
+	}
 	return nil
 }
 

+ 16 - 6
internal/db/access_tokens_test.go

@@ -46,7 +46,6 @@ func TestAccessTokens(t *testing.T) {
 	if testing.Short() {
 		t.Skip()
 	}
-
 	t.Parallel()
 
 	tables := []interface{}{new(AccessToken)}
@@ -95,7 +94,12 @@ func accessTokensCreate(t *testing.T, db *accessTokens) {
 
 	// Try create second access token with same name should fail
 	_, err = db.Create(ctx, token.UserID, token.Name)
-	wantErr := ErrAccessTokenAlreadyExist{args: errutil.Args{"userID": token.UserID, "name": token.Name}}
+	wantErr := ErrAccessTokenAlreadyExist{
+		args: errutil.Args{
+			"userID": token.UserID,
+			"name":   token.Name,
+		},
+	}
 	assert.Equal(t, wantErr, err)
 }
 
@@ -113,8 +117,6 @@ func accessTokensDeleteByID(t *testing.T, db *accessTokens) {
 	// We should be able to get it back
 	_, err = db.GetBySHA1(ctx, token.Sha1)
 	require.NoError(t, err)
-	_, err = db.GetBySHA1(ctx, token.Sha1)
-	require.NoError(t, err)
 
 	// Now delete this token with correct user ID
 	err = db.DeleteByID(ctx, token.UserID, token.ID)
@@ -122,7 +124,11 @@ func accessTokensDeleteByID(t *testing.T, db *accessTokens) {
 
 	// We should get token not found error
 	_, err = db.GetBySHA1(ctx, token.Sha1)
-	wantErr := ErrAccessTokenNotExist{args: errutil.Args{"sha": token.Sha1}}
+	wantErr := ErrAccessTokenNotExist{
+		args: errutil.Args{
+			"sha": token.Sha1,
+		},
+	}
 	assert.Equal(t, wantErr, err)
 }
 
@@ -139,7 +145,11 @@ func accessTokensGetBySHA(t *testing.T, db *accessTokens) {
 
 	// Try to get a non-existent token
 	_, err = db.GetBySHA1(ctx, "bad_sha")
-	wantErr := ErrAccessTokenNotExist{args: errutil.Args{"sha": "bad_sha"}}
+	wantErr := ErrAccessTokenNotExist{
+		args: errutil.Args{
+			"sha": "bad_sha",
+		},
+	}
 	assert.Equal(t, wantErr, err)
 }
 

+ 0 - 766
internal/db/action.go

@@ -1,766 +0,0 @@
-// Copyright 2014 The Gogs Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package db
-
-import (
-	"fmt"
-	"path"
-	"strings"
-	"time"
-	"unicode"
-
-	jsoniter "github.com/json-iterator/go"
-	"github.com/unknwon/com"
-	log "unknwon.dev/clog/v2"
-	"xorm.io/xorm"
-
-	"github.com/gogs/git-module"
-	api "github.com/gogs/go-gogs-client"
-
-	"gogs.io/gogs/internal/conf"
-	"gogs.io/gogs/internal/lazyregexp"
-	"gogs.io/gogs/internal/tool"
-)
-
-type ActionType int
-
-// Note: To maintain backward compatibility only append to the end of list
-const (
-	ACTION_CREATE_REPO         ActionType = iota + 1 // 1
-	ACTION_RENAME_REPO                               // 2
-	ACTION_STAR_REPO                                 // 3
-	ACTION_WATCH_REPO                                // 4
-	ACTION_COMMIT_REPO                               // 5
-	ACTION_CREATE_ISSUE                              // 6
-	ACTION_CREATE_PULL_REQUEST                       // 7
-	ACTION_TRANSFER_REPO                             // 8
-	ACTION_PUSH_TAG                                  // 9
-	ACTION_COMMENT_ISSUE                             // 10
-	ACTION_MERGE_PULL_REQUEST                        // 11
-	ACTION_CLOSE_ISSUE                               // 12
-	ACTION_REOPEN_ISSUE                              // 13
-	ACTION_CLOSE_PULL_REQUEST                        // 14
-	ACTION_REOPEN_PULL_REQUEST                       // 15
-	ACTION_CREATE_BRANCH                             // 16
-	ACTION_DELETE_BRANCH                             // 17
-	ACTION_DELETE_TAG                                // 18
-	ACTION_FORK_REPO                                 // 19
-	ACTION_MIRROR_SYNC_PUSH                          // 20
-	ACTION_MIRROR_SYNC_CREATE                        // 21
-	ACTION_MIRROR_SYNC_DELETE                        // 22
-)
-
-var (
-	// Same as Github. See https://help.github.com/articles/closing-issues-via-commit-messages
-	IssueCloseKeywords  = []string{"close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"}
-	IssueReopenKeywords = []string{"reopen", "reopens", "reopened"}
-
-	IssueCloseKeywordsPat  = lazyregexp.New(assembleKeywordsPattern(IssueCloseKeywords))
-	IssueReopenKeywordsPat = lazyregexp.New(assembleKeywordsPattern(IssueReopenKeywords))
-	issueReferencePattern  = lazyregexp.New(`(?i)(?:)(^| )\S*#\d+`)
-)
-
-func assembleKeywordsPattern(words []string) string {
-	return fmt.Sprintf(`(?i)(?:%s) \S+`, strings.Join(words, "|"))
-}
-
-// Action represents user operation type and other information to repository,
-// it implemented interface base.Actioner so that can be used in template render.
-type Action struct {
-	ID           int64
-	UserID       int64 // Receiver user ID
-	OpType       ActionType
-	ActUserID    int64  // Doer user ID
-	ActUserName  string // Doer user name
-	ActAvatar    string `xorm:"-" json:"-"`
-	RepoID       int64  `xorm:"INDEX"`
-	RepoUserName string
-	RepoName     string
-	RefName      string
-	IsPrivate    bool      `xorm:"NOT NULL DEFAULT false"`
-	Content      string    `xorm:"TEXT"`
-	Created      time.Time `xorm:"-" json:"-"`
-	CreatedUnix  int64
-}
-
-func (a *Action) BeforeInsert() {
-	a.CreatedUnix = time.Now().Unix()
-}
-
-func (a *Action) AfterSet(colName string, _ xorm.Cell) {
-	switch colName {
-	case "created_unix":
-		a.Created = time.Unix(a.CreatedUnix, 0).Local()
-	}
-}
-
-func (a *Action) GetOpType() int {
-	return int(a.OpType)
-}
-
-func (a *Action) GetActUserName() string {
-	return a.ActUserName
-}
-
-func (a *Action) ShortActUserName() string {
-	return tool.EllipsisString(a.ActUserName, 20)
-}
-
-func (a *Action) GetRepoUserName() string {
-	return a.RepoUserName
-}
-
-func (a *Action) ShortRepoUserName() string {
-	return tool.EllipsisString(a.RepoUserName, 20)
-}
-
-func (a *Action) GetRepoName() string {
-	return a.RepoName
-}
-
-func (a *Action) ShortRepoName() string {
-	return tool.EllipsisString(a.RepoName, 33)
-}
-
-func (a *Action) GetRepoPath() string {
-	return path.Join(a.RepoUserName, a.RepoName)
-}
-
-func (a *Action) ShortRepoPath() string {
-	return path.Join(a.ShortRepoUserName(), a.ShortRepoName())
-}
-
-func (a *Action) GetRepoLink() string {
-	if conf.Server.Subpath != "" {
-		return path.Join(conf.Server.Subpath, a.GetRepoPath())
-	}
-	return "/" + a.GetRepoPath()
-}
-
-func (a *Action) GetBranch() string {
-	return a.RefName
-}
-
-func (a *Action) GetContent() string {
-	return a.Content
-}
-
-func (a *Action) GetCreate() time.Time {
-	return a.Created
-}
-
-func (a *Action) GetIssueInfos() []string {
-	return strings.SplitN(a.Content, "|", 2)
-}
-
-func (a *Action) GetIssueTitle() string {
-	index := com.StrTo(a.GetIssueInfos()[0]).MustInt64()
-	issue, err := GetIssueByIndex(a.RepoID, index)
-	if err != nil {
-		log.Error("GetIssueByIndex: %v", err)
-		return "500 when get issue"
-	}
-	return issue.Title
-}
-
-func (a *Action) GetIssueContent() string {
-	index := com.StrTo(a.GetIssueInfos()[0]).MustInt64()
-	issue, err := GetIssueByIndex(a.RepoID, index)
-	if err != nil {
-		log.Error("GetIssueByIndex: %v", err)
-		return "500 when get issue"
-	}
-	return issue.Content
-}
-
-func newRepoAction(e Engine, doer, _ *User, repo *Repository) (err error) {
-	opType := ACTION_CREATE_REPO
-	if repo.IsFork {
-		opType = ACTION_FORK_REPO
-	}
-
-	return notifyWatchers(e, &Action{
-		ActUserID:    doer.ID,
-		ActUserName:  doer.Name,
-		OpType:       opType,
-		RepoID:       repo.ID,
-		RepoUserName: repo.Owner.Name,
-		RepoName:     repo.Name,
-		IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
-	})
-}
-
-// NewRepoAction adds new action for creating repository.
-func NewRepoAction(doer, owner *User, repo *Repository) (err error) {
-	return newRepoAction(x, doer, owner, repo)
-}
-
-func renameRepoAction(e Engine, actUser *User, oldRepoName string, repo *Repository) (err error) {
-	if err = notifyWatchers(e, &Action{
-		ActUserID:    actUser.ID,
-		ActUserName:  actUser.Name,
-		OpType:       ACTION_RENAME_REPO,
-		RepoID:       repo.ID,
-		RepoUserName: repo.Owner.Name,
-		RepoName:     repo.Name,
-		IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
-		Content:      oldRepoName,
-	}); err != nil {
-		return fmt.Errorf("notify watchers: %v", err)
-	}
-
-	log.Trace("action.renameRepoAction: %s/%s", actUser.Name, repo.Name)
-	return nil
-}
-
-// RenameRepoAction adds new action for renaming a repository.
-func RenameRepoAction(actUser *User, oldRepoName string, repo *Repository) error {
-	return renameRepoAction(x, actUser, oldRepoName, repo)
-}
-
-func issueIndexTrimRight(c rune) bool {
-	return !unicode.IsDigit(c)
-}
-
-type PushCommit struct {
-	Sha1           string
-	Message        string
-	AuthorEmail    string
-	AuthorName     string
-	CommitterEmail string
-	CommitterName  string
-	Timestamp      time.Time
-}
-
-type PushCommits struct {
-	Len        int
-	Commits    []*PushCommit
-	CompareURL string
-
-	avatars map[string]string
-}
-
-func NewPushCommits() *PushCommits {
-	return &PushCommits{
-		avatars: make(map[string]string),
-	}
-}
-
-func (pc *PushCommits) ToApiPayloadCommits(repoPath, repoURL string) ([]*api.PayloadCommit, error) {
-	commits := make([]*api.PayloadCommit, len(pc.Commits))
-	for i, commit := range pc.Commits {
-		authorUsername := ""
-		author, err := GetUserByEmail(commit.AuthorEmail)
-		if err == nil {
-			authorUsername = author.Name
-		} else if !IsErrUserNotExist(err) {
-			return nil, fmt.Errorf("get user by email: %v", err)
-		}
-
-		committerUsername := ""
-		committer, err := GetUserByEmail(commit.CommitterEmail)
-		if err == nil {
-			committerUsername = committer.Name
-		} else if !IsErrUserNotExist(err) {
-			return nil, fmt.Errorf("get user by email: %v", err)
-		}
-
-		nameStatus, err := git.ShowNameStatus(repoPath, commit.Sha1)
-		if err != nil {
-			return nil, fmt.Errorf("show name status [commit_sha1: %s]: %v", commit.Sha1, err)
-		}
-
-		commits[i] = &api.PayloadCommit{
-			ID:      commit.Sha1,
-			Message: commit.Message,
-			URL:     fmt.Sprintf("%s/commit/%s", repoURL, commit.Sha1),
-			Author: &api.PayloadUser{
-				Name:     commit.AuthorName,
-				Email:    commit.AuthorEmail,
-				UserName: authorUsername,
-			},
-			Committer: &api.PayloadUser{
-				Name:     commit.CommitterName,
-				Email:    commit.CommitterEmail,
-				UserName: committerUsername,
-			},
-			Added:     nameStatus.Added,
-			Removed:   nameStatus.Removed,
-			Modified:  nameStatus.Modified,
-			Timestamp: commit.Timestamp,
-		}
-	}
-	return commits, nil
-}
-
-// AvatarLink tries to match user in database with e-mail
-// in order to show custom avatar, and falls back to general avatar link.
-func (pcs *PushCommits) AvatarLink(email string) string {
-	_, ok := pcs.avatars[email]
-	if !ok {
-		u, err := GetUserByEmail(email)
-		if err != nil {
-			pcs.avatars[email] = tool.AvatarLink(email)
-			if !IsErrUserNotExist(err) {
-				log.Error("get user by email: %v", err)
-			}
-		} else {
-			pcs.avatars[email] = u.RelAvatarLink()
-		}
-	}
-
-	return pcs.avatars[email]
-}
-
-// UpdateIssuesCommit checks if issues are manipulated by commit message.
-func UpdateIssuesCommit(doer *User, repo *Repository, commits []*PushCommit) error {
-	// Commits are appended in the reverse order.
-	for i := len(commits) - 1; i >= 0; i-- {
-		c := commits[i]
-
-		refMarked := make(map[int64]bool)
-		for _, ref := range issueReferencePattern.FindAllString(c.Message, -1) {
-			ref = strings.TrimSpace(ref)
-			ref = strings.TrimRightFunc(ref, issueIndexTrimRight)
-
-			if ref == "" {
-				continue
-			}
-
-			// Add repo name if missing
-			if ref[0] == '#' {
-				ref = fmt.Sprintf("%s%s", repo.FullName(), ref)
-			} else if !strings.Contains(ref, "/") {
-				// FIXME: We don't support User#ID syntax yet
-				// return ErrNotImplemented
-				continue
-			}
-
-			issue, err := GetIssueByRef(ref)
-			if err != nil {
-				if IsErrIssueNotExist(err) {
-					continue
-				}
-				return err
-			}
-
-			if refMarked[issue.ID] {
-				continue
-			}
-			refMarked[issue.ID] = true
-
-			msgLines := strings.Split(c.Message, "\n")
-			shortMsg := msgLines[0]
-			if len(msgLines) > 2 {
-				shortMsg += "..."
-			}
-			message := fmt.Sprintf(`<a href="%s/commit/%s">%s</a>`, repo.Link(), c.Sha1, shortMsg)
-			if err = CreateRefComment(doer, repo, issue, message, c.Sha1); err != nil {
-				return err
-			}
-		}
-
-		refMarked = make(map[int64]bool)
-		// FIXME: can merge this one and next one to a common function.
-		for _, ref := range IssueCloseKeywordsPat.FindAllString(c.Message, -1) {
-			ref = ref[strings.IndexByte(ref, byte(' '))+1:]
-			ref = strings.TrimRightFunc(ref, issueIndexTrimRight)
-
-			if ref == "" {
-				continue
-			}
-
-			// Add repo name if missing
-			if ref[0] == '#' {
-				ref = fmt.Sprintf("%s%s", repo.FullName(), ref)
-			} else if !strings.Contains(ref, "/") {
-				// FIXME: We don't support User#ID syntax yet
-				continue
-			}
-
-			issue, err := GetIssueByRef(ref)
-			if err != nil {
-				if IsErrIssueNotExist(err) {
-					continue
-				}
-				return err
-			}
-
-			if refMarked[issue.ID] {
-				continue
-			}
-			refMarked[issue.ID] = true
-
-			if issue.RepoID != repo.ID || issue.IsClosed {
-				continue
-			}
-
-			if err = issue.ChangeStatus(doer, repo, true); err != nil {
-				return err
-			}
-		}
-
-		// It is conflict to have close and reopen at same time, so refsMarkd doesn't need to reinit here.
-		for _, ref := range IssueReopenKeywordsPat.FindAllString(c.Message, -1) {
-			ref = ref[strings.IndexByte(ref, byte(' '))+1:]
-			ref = strings.TrimRightFunc(ref, issueIndexTrimRight)
-
-			if ref == "" {
-				continue
-			}
-
-			// Add repo name if missing
-			if ref[0] == '#' {
-				ref = fmt.Sprintf("%s%s", repo.FullName(), ref)
-			} else if !strings.Contains(ref, "/") {
-				// We don't support User#ID syntax yet
-				// return ErrNotImplemented
-				continue
-			}
-
-			issue, err := GetIssueByRef(ref)
-			if err != nil {
-				if IsErrIssueNotExist(err) {
-					continue
-				}
-				return err
-			}
-
-			if refMarked[issue.ID] {
-				continue
-			}
-			refMarked[issue.ID] = true
-
-			if issue.RepoID != repo.ID || !issue.IsClosed {
-				continue
-			}
-
-			if err = issue.ChangeStatus(doer, repo, false); err != nil {
-				return err
-			}
-		}
-	}
-	return nil
-}
-
-type CommitRepoActionOptions struct {
-	PusherName  string
-	RepoOwnerID int64
-	RepoName    string
-	RefFullName string
-	OldCommitID string
-	NewCommitID string
-	Commits     *PushCommits
-}
-
-// CommitRepoAction adds new commit action to the repository, and prepare corresponding webhooks.
-func CommitRepoAction(opts CommitRepoActionOptions) error {
-	pusher, err := GetUserByName(opts.PusherName)
-	if err != nil {
-		return fmt.Errorf("GetUserByName [%s]: %v", opts.PusherName, err)
-	}
-
-	repo, err := GetRepositoryByName(opts.RepoOwnerID, opts.RepoName)
-	if err != nil {
-		return fmt.Errorf("GetRepositoryByName [owner_id: %d, name: %s]: %v", opts.RepoOwnerID, opts.RepoName, err)
-	}
-
-	// Change repository bare status and update last updated time.
-	repo.IsBare = false
-	if err = UpdateRepository(repo, false); err != nil {
-		return fmt.Errorf("UpdateRepository: %v", err)
-	}
-
-	isNewRef := opts.OldCommitID == git.EmptyID
-	isDelRef := opts.NewCommitID == git.EmptyID
-
-	opType := ACTION_COMMIT_REPO
-	// Check if it's tag push or branch.
-	if strings.HasPrefix(opts.RefFullName, git.RefsTags) {
-		opType = ACTION_PUSH_TAG
-	} else {
-		// if not the first commit, set the compare URL.
-		if !isNewRef && !isDelRef {
-			opts.Commits.CompareURL = repo.ComposeCompareURL(opts.OldCommitID, opts.NewCommitID)
-		}
-
-		// Only update issues via commits when internal issue tracker is enabled
-		if repo.EnableIssues && !repo.EnableExternalTracker {
-			if err = UpdateIssuesCommit(pusher, repo, opts.Commits.Commits); err != nil {
-				log.Error("UpdateIssuesCommit: %v", err)
-			}
-		}
-	}
-
-	if len(opts.Commits.Commits) > conf.UI.FeedMaxCommitNum {
-		opts.Commits.Commits = opts.Commits.Commits[:conf.UI.FeedMaxCommitNum]
-	}
-
-	data, err := jsoniter.Marshal(opts.Commits)
-	if err != nil {
-		return fmt.Errorf("Marshal: %v", err)
-	}
-
-	refName := git.RefShortName(opts.RefFullName)
-	action := &Action{
-		ActUserID:    pusher.ID,
-		ActUserName:  pusher.Name,
-		Content:      string(data),
-		RepoID:       repo.ID,
-		RepoUserName: repo.MustOwner().Name,
-		RepoName:     repo.Name,
-		RefName:      refName,
-		IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
-	}
-
-	apiRepo := repo.APIFormat(nil)
-	apiPusher := pusher.APIFormat()
-	switch opType {
-	case ACTION_COMMIT_REPO: // Push
-		if isDelRef {
-			if err = PrepareWebhooks(repo, HOOK_EVENT_DELETE, &api.DeletePayload{
-				Ref:        refName,
-				RefType:    "branch",
-				PusherType: api.PUSHER_TYPE_USER,
-				Repo:       apiRepo,
-				Sender:     apiPusher,
-			}); err != nil {
-				return fmt.Errorf("PrepareWebhooks.(delete branch): %v", err)
-			}
-
-			action.OpType = ACTION_DELETE_BRANCH
-			if err = NotifyWatchers(action); err != nil {
-				return fmt.Errorf("NotifyWatchers.(delete branch): %v", err)
-			}
-
-			// Delete branch doesn't have anything to push or compare
-			return nil
-		}
-
-		compareURL := conf.Server.ExternalURL + opts.Commits.CompareURL
-		if isNewRef {
-			compareURL = ""
-			if err = PrepareWebhooks(repo, HOOK_EVENT_CREATE, &api.CreatePayload{
-				Ref:           refName,
-				RefType:       "branch",
-				DefaultBranch: repo.DefaultBranch,
-				Repo:          apiRepo,
-				Sender:        apiPusher,
-			}); err != nil {
-				return fmt.Errorf("PrepareWebhooks.(new branch): %v", err)
-			}
-
-			action.OpType = ACTION_CREATE_BRANCH
-			if err = NotifyWatchers(action); err != nil {
-				return fmt.Errorf("NotifyWatchers.(new branch): %v", err)
-			}
-		}
-
-		commits, err := opts.Commits.ToApiPayloadCommits(repo.RepoPath(), repo.HTMLURL())
-		if err != nil {
-			return fmt.Errorf("ToApiPayloadCommits: %v", err)
-		}
-
-		if err = PrepareWebhooks(repo, HOOK_EVENT_PUSH, &api.PushPayload{
-			Ref:        opts.RefFullName,
-			Before:     opts.OldCommitID,
-			After:      opts.NewCommitID,
-			CompareURL: compareURL,
-			Commits:    commits,
-			Repo:       apiRepo,
-			Pusher:     apiPusher,
-			Sender:     apiPusher,
-		}); err != nil {
-			return fmt.Errorf("PrepareWebhooks.(new commit): %v", err)
-		}
-
-		action.OpType = ACTION_COMMIT_REPO
-		if err = NotifyWatchers(action); err != nil {
-			return fmt.Errorf("NotifyWatchers.(new commit): %v", err)
-		}
-
-	case ACTION_PUSH_TAG: // Tag
-		if isDelRef {
-			if err = PrepareWebhooks(repo, HOOK_EVENT_DELETE, &api.DeletePayload{
-				Ref:        refName,
-				RefType:    "tag",
-				PusherType: api.PUSHER_TYPE_USER,
-				Repo:       apiRepo,
-				Sender:     apiPusher,
-			}); err != nil {
-				return fmt.Errorf("PrepareWebhooks.(delete tag): %v", err)
-			}
-
-			action.OpType = ACTION_DELETE_TAG
-			if err = NotifyWatchers(action); err != nil {
-				return fmt.Errorf("NotifyWatchers.(delete tag): %v", err)
-			}
-			return nil
-		}
-
-		if err = PrepareWebhooks(repo, HOOK_EVENT_CREATE, &api.CreatePayload{
-			Ref:           refName,
-			RefType:       "tag",
-			Sha:           opts.NewCommitID,
-			DefaultBranch: repo.DefaultBranch,
-			Repo:          apiRepo,
-			Sender:        apiPusher,
-		}); err != nil {
-			return fmt.Errorf("PrepareWebhooks.(new tag): %v", err)
-		}
-
-		action.OpType = ACTION_PUSH_TAG
-		if err = NotifyWatchers(action); err != nil {
-			return fmt.Errorf("NotifyWatchers.(new tag): %v", err)
-		}
-	}
-
-	return nil
-}
-
-func transferRepoAction(e Engine, doer, oldOwner *User, repo *Repository) (err error) {
-	if err = notifyWatchers(e, &Action{
-		ActUserID:    doer.ID,
-		ActUserName:  doer.Name,
-		OpType:       ACTION_TRANSFER_REPO,
-		RepoID:       repo.ID,
-		RepoUserName: repo.Owner.Name,
-		RepoName:     repo.Name,
-		IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
-		Content:      path.Join(oldOwner.Name, repo.Name),
-	}); err != nil {
-		return fmt.Errorf("notifyWatchers: %v", err)
-	}
-
-	// Remove watch for organization.
-	if oldOwner.IsOrganization() {
-		if err = watchRepo(e, oldOwner.ID, repo.ID, false); err != nil {
-			return fmt.Errorf("watchRepo [false]: %v", err)
-		}
-	}
-
-	return nil
-}
-
-// TransferRepoAction adds new action for transferring repository,
-// the Owner field of repository is assumed to be new owner.
-func TransferRepoAction(doer, oldOwner *User, repo *Repository) error {
-	return transferRepoAction(x, doer, oldOwner, repo)
-}
-
-func mergePullRequestAction(e Engine, doer *User, repo *Repository, issue *Issue) error {
-	return notifyWatchers(e, &Action{
-		ActUserID:    doer.ID,
-		ActUserName:  doer.Name,
-		OpType:       ACTION_MERGE_PULL_REQUEST,
-		Content:      fmt.Sprintf("%d|%s", issue.Index, issue.Title),
-		RepoID:       repo.ID,
-		RepoUserName: repo.Owner.Name,
-		RepoName:     repo.Name,
-		IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
-	})
-}
-
-// MergePullRequestAction adds new action for merging pull request.
-func MergePullRequestAction(actUser *User, repo *Repository, pull *Issue) error {
-	return mergePullRequestAction(x, actUser, repo, pull)
-}
-
-func mirrorSyncAction(opType ActionType, repo *Repository, refName string, data []byte) error {
-	return NotifyWatchers(&Action{
-		ActUserID:    repo.OwnerID,
-		ActUserName:  repo.MustOwner().Name,
-		OpType:       opType,
-		Content:      string(data),
-		RepoID:       repo.ID,
-		RepoUserName: repo.MustOwner().Name,
-		RepoName:     repo.Name,
-		RefName:      refName,
-		IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
-	})
-}
-
-type MirrorSyncPushActionOptions struct {
-	RefName     string
-	OldCommitID string
-	NewCommitID string
-	Commits     *PushCommits
-}
-
-// MirrorSyncPushAction adds new action for mirror synchronization of pushed commits.
-func MirrorSyncPushAction(repo *Repository, opts MirrorSyncPushActionOptions) error {
-	if len(opts.Commits.Commits) > conf.UI.FeedMaxCommitNum {
-		opts.Commits.Commits = opts.Commits.Commits[:conf.UI.FeedMaxCommitNum]
-	}
-
-	apiCommits, err := opts.Commits.ToApiPayloadCommits(repo.RepoPath(), repo.HTMLURL())
-	if err != nil {
-		return fmt.Errorf("ToApiPayloadCommits: %v", err)
-	}
-
-	opts.Commits.CompareURL = repo.ComposeCompareURL(opts.OldCommitID, opts.NewCommitID)
-	apiPusher := repo.MustOwner().APIFormat()
-	if err := PrepareWebhooks(repo, HOOK_EVENT_PUSH, &api.PushPayload{
-		Ref:        opts.RefName,
-		Before:     opts.OldCommitID,
-		After:      opts.NewCommitID,
-		CompareURL: conf.Server.ExternalURL + opts.Commits.CompareURL,
-		Commits:    apiCommits,
-		Repo:       repo.APIFormat(nil),
-		Pusher:     apiPusher,
-		Sender:     apiPusher,
-	}); err != nil {
-		return fmt.Errorf("PrepareWebhooks: %v", err)
-	}
-
-	data, err := jsoniter.Marshal(opts.Commits)
-	if err != nil {
-		return err
-	}
-
-	return mirrorSyncAction(ACTION_MIRROR_SYNC_PUSH, repo, opts.RefName, data)
-}
-
-// MirrorSyncCreateAction adds new action for mirror synchronization of new reference.
-func MirrorSyncCreateAction(repo *Repository, refName string) error {
-	return mirrorSyncAction(ACTION_MIRROR_SYNC_CREATE, repo, refName, nil)
-}
-
-// MirrorSyncCreateAction adds new action for mirror synchronization of delete reference.
-func MirrorSyncDeleteAction(repo *Repository, refName string) error {
-	return mirrorSyncAction(ACTION_MIRROR_SYNC_DELETE, repo, refName, nil)
-}
-
-// GetFeeds returns action list of given user in given context.
-// actorID is the user who's requesting, ctxUserID is the user/org that is requested.
-// actorID can be -1 when isProfile is true or to skip the permission check.
-func GetFeeds(ctxUser *User, actorID, afterID int64, isProfile bool) ([]*Action, error) {
-	actions := make([]*Action, 0, conf.UI.User.NewsFeedPagingNum)
-	sess := x.Limit(conf.UI.User.NewsFeedPagingNum).Where("user_id = ?", ctxUser.ID).Desc("id")
-	if afterID > 0 {
-		sess.And("id < ?", afterID)
-	}
-	if isProfile {
-		sess.And("is_private = ?", false).And("act_user_id = ?", ctxUser.ID)
-	} else if actorID != -1 && ctxUser.IsOrganization() {
-		// FIXME: only need to get IDs here, not all fields of repository.
-		repos, _, err := ctxUser.GetUserRepositories(actorID, 1, ctxUser.NumRepos)
-		if err != nil {
-			return nil, fmt.Errorf("GetUserRepositories: %v", err)
-		}
-
-		var repoIDs []int64
-		for _, repo := range repos {
-			repoIDs = append(repoIDs, repo.ID)
-		}
-
-		if len(repoIDs) > 0 {
-			sess.In("repo_id", repoIDs)
-		}
-	}
-
-	err := sess.Find(&actions)
-	return actions, err
-}

+ 0 - 41
internal/db/action_test.go

@@ -1,41 +0,0 @@
-// Copyright 2020 The Gogs Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package db
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func Test_issueReferencePattern(t *testing.T) {
-	tests := []struct {
-		name       string
-		message    string
-		expStrings []string
-	}{
-		{
-			name:       "no match",
-			message:    "Hello world!",
-			expStrings: nil,
-		},
-		{
-			name:       "contains issue numbers",
-			message:    "#123 is fixed, and #456 is WIP",
-			expStrings: []string{"#123", " #456"},
-		},
-		{
-			name:       "contains full issue references",
-			message:    "#123 is fixed, and user/repo#456 is WIP",
-			expStrings: []string{"#123", " user/repo#456"},
-		},
-	}
-	for _, test := range tests {
-		t.Run(test.name, func(t *testing.T) {
-			strs := issueReferencePattern.FindAllString(test.message, -1)
-			assert.Equal(t, test.expStrings, strs)
-		})
-	}
-}

+ 962 - 0
internal/db/actions.go

@@ -0,0 +1,962 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package db
+
+import (
+	"context"
+	"fmt"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+	"unicode"
+
+	"github.com/gogs/git-module"
+	api "github.com/gogs/go-gogs-client"
+	jsoniter "github.com/json-iterator/go"
+	"github.com/pkg/errors"
+	"gorm.io/gorm"
+	log "unknwon.dev/clog/v2"
+
+	"gogs.io/gogs/internal/conf"
+	"gogs.io/gogs/internal/lazyregexp"
+	"gogs.io/gogs/internal/repoutil"
+	"gogs.io/gogs/internal/strutil"
+	"gogs.io/gogs/internal/testutil"
+	"gogs.io/gogs/internal/tool"
+)
+
+// ActionsStore is the persistent interface for actions.
+//
+// NOTE: All methods are sorted in alphabetical order.
+type ActionsStore interface {
+	// CommitRepo creates actions for pushing commits to the repository. An action
+	// with the type ActionDeleteBranch is created if the push deletes a branch; an
+	// action with the type ActionCommitRepo is created for a regular push. If the
+	// regular push also creates a new branch, then another action with type
+	// ActionCreateBranch is created.
+	CommitRepo(ctx context.Context, opts CommitRepoOptions) error
+	// ListByOrganization returns actions of the organization viewable by the actor.
+	// Results are paginated if `afterID` is given.
+	ListByOrganization(ctx context.Context, orgID, actorID, afterID int64) ([]*Action, error)
+	// ListByUser returns actions of the user viewable by the actor. Results are
+	// paginated if `afterID` is given. The `isProfile` indicates whether repository
+	// permissions should be considered.
+	ListByUser(ctx context.Context, userID, actorID, afterID int64, isProfile bool) ([]*Action, error)
+	// MergePullRequest creates an action for merging a pull request.
+	MergePullRequest(ctx context.Context, doer, owner *User, repo *Repository, pull *Issue) error
+	// MirrorSyncCreate creates an action for mirror synchronization of a new
+	// reference.
+	MirrorSyncCreate(ctx context.Context, owner *User, repo *Repository, refName string) error
+	// MirrorSyncDelete creates an action for mirror synchronization of a reference
+	// deletion.
+	MirrorSyncDelete(ctx context.Context, owner *User, repo *Repository, refName string) error
+	// MirrorSyncPush creates an action for mirror synchronization of pushed
+	// commits.
+	MirrorSyncPush(ctx context.Context, opts MirrorSyncPushOptions) error
+	// NewRepo creates an action for creating a new repository. The action type
+	// could be ActionCreateRepo or ActionForkRepo based on whether the repository
+	// is a fork.
+	NewRepo(ctx context.Context, doer, owner *User, repo *Repository) error
+	// PushTag creates an action for pushing tags to the repository. An action with
+	// the type ActionDeleteTag is created if the push deletes a tag. Otherwise, an
+	// action with the type ActionPushTag is created for a regular push.
+	PushTag(ctx context.Context, opts PushTagOptions) error
+	// RenameRepo creates an action for renaming a repository.
+	RenameRepo(ctx context.Context, doer, owner *User, oldRepoName string, repo *Repository) error
+	// TransferRepo creates an action for transferring a repository to a new owner.
+	TransferRepo(ctx context.Context, doer, oldOwner, newOwner *User, repo *Repository) error
+}
+
+var Actions ActionsStore
+
+var _ ActionsStore = (*actions)(nil)
+
+type actions struct {
+	*gorm.DB
+}
+
+// NewActionsStore returns a persistent interface for actions with given
+// database connection.
+func NewActionsStore(db *gorm.DB) ActionsStore {
+	return &actions{DB: db}
+}
+
+func (db *actions) listByOrganization(ctx context.Context, orgID, actorID, afterID int64) *gorm.DB {
+	/*
+		Equivalent SQL for PostgreSQL:
+
+		SELECT * FROM "action"
+		WHERE
+			user_id = @userID
+		AND (@skipAfter OR id < @afterID)
+		AND repo_id IN (
+			SELECT repository.id FROM "repository"
+			JOIN team_repo ON repository.id = team_repo.repo_id
+			WHERE team_repo.team_id IN (
+					SELECT team_id FROM "team_user"
+					WHERE
+						team_user.org_id = @orgID AND uid = @actorID)
+					OR  (repository.is_private = FALSE AND repository.is_unlisted = FALSE)
+			)
+		ORDER BY id DESC
+		LIMIT @limit
+	*/
+	return db.WithContext(ctx).
+		Where("user_id = ?", orgID).
+		Where(db.
+			// Not apply when afterID is not given
+			Where("?", afterID <= 0).
+			Or("id < ?", afterID),
+		).
+		Where("repo_id IN (?)",
+			db.Select("repository.id").
+				Table("repository").
+				Joins("JOIN team_repo ON repository.id = team_repo.repo_id").
+				Where("team_repo.team_id IN (?)",
+					db.Select("team_id").
+						Table("team_user").
+						Where("team_user.org_id = ? AND uid = ?", orgID, actorID),
+				).
+				Or("repository.is_private = ? AND repository.is_unlisted = ?", false, false),
+		).
+		Limit(conf.UI.User.NewsFeedPagingNum).
+		Order("id DESC")
+}
+
+func (db *actions) ListByOrganization(ctx context.Context, orgID, actorID, afterID int64) ([]*Action, error) {
+	actions := make([]*Action, 0, conf.UI.User.NewsFeedPagingNum)
+	return actions, db.listByOrganization(ctx, orgID, actorID, afterID).Find(&actions).Error
+}
+
+func (db *actions) listByUser(ctx context.Context, userID, actorID, afterID int64, isProfile bool) *gorm.DB {
+	/*
+		Equivalent SQL for PostgreSQL:
+
+		SELECT * FROM "action"
+		WHERE
+			user_id = @userID
+		AND (@skipAfter OR id < @afterID)
+		AND (@includePrivate OR (is_private = FALSE AND act_user_id = @actorID))
+		ORDER BY id DESC
+		LIMIT @limit
+	*/
+	return db.WithContext(ctx).
+		Where("user_id = ?", userID).
+		Where(db.
+			// Not apply when afterID is not given
+			Where("?", afterID <= 0).
+			Or("id < ?", afterID),
+		).
+		Where(db.
+			// Not apply when in not profile page or the user is viewing own profile
+			Where("?", !isProfile || actorID == userID).
+			Or("is_private = ? AND act_user_id = ?", false, userID),
+		).
+		Limit(conf.UI.User.NewsFeedPagingNum).
+		Order("id DESC")
+}
+
+func (db *actions) ListByUser(ctx context.Context, userID, actorID, afterID int64, isProfile bool) ([]*Action, error) {
+	actions := make([]*Action, 0, conf.UI.User.NewsFeedPagingNum)
+	return actions, db.listByUser(ctx, userID, actorID, afterID, isProfile).Find(&actions).Error
+}
+
+// notifyWatchers creates rows in action table for watchers who are able to see the action.
+func (db *actions) notifyWatchers(ctx context.Context, act *Action) error {
+	watches, err := NewWatchesStore(db.DB).ListByRepo(ctx, act.RepoID)
+	if err != nil {
+		return errors.Wrap(err, "list watches")
+	}
+
+	// Clone returns a deep copy of the action with UserID assigned
+	clone := func(userID int64) *Action {
+		tmp := *act
+		tmp.UserID = userID
+		return &tmp
+	}
+
+	// Plus one for the actor
+	actions := make([]*Action, 0, len(watches)+1)
+	actions = append(actions, clone(act.ActUserID))
+
+	for _, watch := range watches {
+		if act.ActUserID == watch.UserID {
+			continue
+		}
+		actions = append(actions, clone(watch.UserID))
+	}
+
+	return db.Create(actions).Error
+}
+
+func (db *actions) NewRepo(ctx context.Context, doer, owner *User, repo *Repository) error {
+	opType := ActionCreateRepo
+	if repo.IsFork {
+		opType = ActionForkRepo
+	}
+
+	return db.notifyWatchers(ctx,
+		&Action{
+			ActUserID:    doer.ID,
+			ActUserName:  doer.Name,
+			OpType:       opType,
+			RepoID:       repo.ID,
+			RepoUserName: owner.Name,
+			RepoName:     repo.Name,
+			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
+		},
+	)
+}
+
+func (db *actions) RenameRepo(ctx context.Context, doer, owner *User, oldRepoName string, repo *Repository) error {
+	return db.notifyWatchers(ctx,
+		&Action{
+			ActUserID:    doer.ID,
+			ActUserName:  doer.Name,
+			OpType:       ActionRenameRepo,
+			RepoID:       repo.ID,
+			RepoUserName: owner.Name,
+			RepoName:     repo.Name,
+			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
+			Content:      oldRepoName,
+		},
+	)
+}
+
+func (db *actions) mirrorSyncAction(ctx context.Context, opType ActionType, owner *User, repo *Repository, refName string, content []byte) error {
+	return db.notifyWatchers(ctx,
+		&Action{
+			ActUserID:    owner.ID,
+			ActUserName:  owner.Name,
+			OpType:       opType,
+			Content:      string(content),
+			RepoID:       repo.ID,
+			RepoUserName: owner.Name,
+			RepoName:     repo.Name,
+			RefName:      refName,
+			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
+		},
+	)
+}
+
+type MirrorSyncPushOptions struct {
+	Owner       *User
+	Repo        *Repository
+	RefName     string
+	OldCommitID string
+	NewCommitID string
+	Commits     *PushCommits
+}
+
+func (db *actions) MirrorSyncPush(ctx context.Context, opts MirrorSyncPushOptions) error {
+	if conf.UI.FeedMaxCommitNum > 0 && len(opts.Commits.Commits) > conf.UI.FeedMaxCommitNum {
+		opts.Commits.Commits = opts.Commits.Commits[:conf.UI.FeedMaxCommitNum]
+	}
+
+	apiCommits, err := opts.Commits.APIFormat(ctx,
+		NewUsersStore(db.DB),
+		repoutil.RepositoryPath(opts.Owner.Name, opts.Repo.Name),
+		repoutil.HTMLURL(opts.Owner.Name, opts.Repo.Name),
+	)
+	if err != nil {
+		return errors.Wrap(err, "convert commits to API format")
+	}
+
+	opts.Commits.CompareURL = repoutil.CompareCommitsPath(opts.Owner.Name, opts.Repo.Name, opts.OldCommitID, opts.NewCommitID)
+	apiPusher := opts.Owner.APIFormat()
+	err = PrepareWebhooks(
+		opts.Repo,
+		HOOK_EVENT_PUSH,
+		&api.PushPayload{
+			Ref:        opts.RefName,
+			Before:     opts.OldCommitID,
+			After:      opts.NewCommitID,
+			CompareURL: conf.Server.ExternalURL + opts.Commits.CompareURL,
+			Commits:    apiCommits,
+			Repo:       opts.Repo.APIFormat(opts.Owner),
+			Pusher:     apiPusher,
+			Sender:     apiPusher,
+		},
+	)
+	if err != nil {
+		return errors.Wrap(err, "prepare webhooks")
+	}
+
+	data, err := jsoniter.Marshal(opts.Commits)
+	if err != nil {
+		return errors.Wrap(err, "marshal JSON")
+	}
+
+	return db.mirrorSyncAction(ctx, ActionMirrorSyncPush, opts.Owner, opts.Repo, opts.RefName, data)
+}
+
+func (db *actions) MirrorSyncCreate(ctx context.Context, owner *User, repo *Repository, refName string) error {
+	return db.mirrorSyncAction(ctx, ActionMirrorSyncCreate, owner, repo, refName, nil)
+}
+
+func (db *actions) MirrorSyncDelete(ctx context.Context, owner *User, repo *Repository, refName string) error {
+	return db.mirrorSyncAction(ctx, ActionMirrorSyncDelete, owner, repo, refName, nil)
+}
+
+func (db *actions) MergePullRequest(ctx context.Context, doer, owner *User, repo *Repository, pull *Issue) error {
+	return db.notifyWatchers(ctx,
+		&Action{
+			ActUserID:    doer.ID,
+			ActUserName:  doer.Name,
+			OpType:       ActionMergePullRequest,
+			Content:      fmt.Sprintf("%d|%s", pull.Index, pull.Title),
+			RepoID:       repo.ID,
+			RepoUserName: owner.Name,
+			RepoName:     repo.Name,
+			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
+		},
+	)
+}
+
+func (db *actions) TransferRepo(ctx context.Context, doer, oldOwner, newOwner *User, repo *Repository) error {
+	return db.notifyWatchers(ctx,
+		&Action{
+			ActUserID:    doer.ID,
+			ActUserName:  doer.Name,
+			OpType:       ActionTransferRepo,
+			RepoID:       repo.ID,
+			RepoUserName: newOwner.Name,
+			RepoName:     repo.Name,
+			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
+			Content:      oldOwner.Name + "/" + repo.Name,
+		},
+	)
+}
+
+var (
+	// Same as GitHub, see https://docs.github.com/en/free-pro-team@latest/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue
+	issueCloseKeywords  = []string{"close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"}
+	issueReopenKeywords = []string{"reopen", "reopens", "reopened"}
+
+	issueCloseKeywordsPattern  = lazyregexp.New(assembleKeywordsPattern(issueCloseKeywords))
+	issueReopenKeywordsPattern = lazyregexp.New(assembleKeywordsPattern(issueReopenKeywords))
+	issueReferencePattern      = lazyregexp.New(`(?i)(?:)(^| )\S*#\d+`)
+)
+
+func assembleKeywordsPattern(words []string) string {
+	return fmt.Sprintf(`(?i)(?:%s) \S+`, strings.Join(words, "|"))
+}
+
+// updateCommitReferencesToIssues checks if issues are manipulated by commit message.
+func updateCommitReferencesToIssues(doer *User, repo *Repository, commits []*PushCommit) error {
+	trimRightNonDigits := func(c rune) bool {
+		return !unicode.IsDigit(c)
+	}
+
+	// Commits are appended in the reverse order.
+	for i := len(commits) - 1; i >= 0; i-- {
+		c := commits[i]
+
+		refMarked := make(map[int64]bool)
+		for _, ref := range issueReferencePattern.FindAllString(c.Message, -1) {
+			ref = strings.TrimSpace(ref)
+			ref = strings.TrimRightFunc(ref, trimRightNonDigits)
+
+			if ref == "" {
+				continue
+			}
+
+			// Add repo name if missing
+			if ref[0] == '#' {
+				ref = fmt.Sprintf("%s%s", repo.FullName(), ref)
+			} else if !strings.Contains(ref, "/") {
+				// FIXME: We don't support User#ID syntax yet
+				continue
+			}
+
+			issue, err := GetIssueByRef(ref)
+			if err != nil {
+				if IsErrIssueNotExist(err) {
+					continue
+				}
+				return err
+			}
+
+			if refMarked[issue.ID] {
+				continue
+			}
+			refMarked[issue.ID] = true
+
+			msgLines := strings.Split(c.Message, "\n")
+			shortMsg := msgLines[0]
+			if len(msgLines) > 2 {
+				shortMsg += "..."
+			}
+			message := fmt.Sprintf(`<a href="%s/commit/%s">%s</a>`, repo.Link(), c.Sha1, shortMsg)
+			if err = CreateRefComment(doer, repo, issue, message, c.Sha1); err != nil {
+				return err
+			}
+		}
+
+		refMarked = make(map[int64]bool)
+		// FIXME: Can merge this and the next for loop to a common function.
+		for _, ref := range issueCloseKeywordsPattern.FindAllString(c.Message, -1) {
+			ref = ref[strings.IndexByte(ref, byte(' '))+1:]
+			ref = strings.TrimRightFunc(ref, trimRightNonDigits)
+
+			if ref == "" {
+				continue
+			}
+
+			// Add repo name if missing
+			if ref[0] == '#' {
+				ref = fmt.Sprintf("%s%s", repo.FullName(), ref)
+			} else if !strings.Contains(ref, "/") {
+				// FIXME: We don't support User#ID syntax yet
+				continue
+			}
+
+			issue, err := GetIssueByRef(ref)
+			if err != nil {
+				if IsErrIssueNotExist(err) {
+					continue
+				}
+				return err
+			}
+
+			if refMarked[issue.ID] {
+				continue
+			}
+			refMarked[issue.ID] = true
+
+			if issue.RepoID != repo.ID || issue.IsClosed {
+				continue
+			}
+
+			if err = issue.ChangeStatus(doer, repo, true); err != nil {
+				return err
+			}
+		}
+
+		// It is conflict to have close and reopen at same time, so refsMarkd doesn't need to reinit here.
+		for _, ref := range issueReopenKeywordsPattern.FindAllString(c.Message, -1) {
+			ref = ref[strings.IndexByte(ref, byte(' '))+1:]
+			ref = strings.TrimRightFunc(ref, trimRightNonDigits)
+
+			if ref == "" {
+				continue
+			}
+
+			// Add repo name if missing
+			if ref[0] == '#' {
+				ref = fmt.Sprintf("%s%s", repo.FullName(), ref)
+			} else if !strings.Contains(ref, "/") {
+				// We don't support User#ID syntax yet
+				// return ErrNotImplemented
+				continue
+			}
+
+			issue, err := GetIssueByRef(ref)
+			if err != nil {
+				if IsErrIssueNotExist(err) {
+					continue
+				}
+				return err
+			}
+
+			if refMarked[issue.ID] {
+				continue
+			}
+			refMarked[issue.ID] = true
+
+			if issue.RepoID != repo.ID || !issue.IsClosed {
+				continue
+			}
+
+			if err = issue.ChangeStatus(doer, repo, false); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+type CommitRepoOptions struct {
+	Owner       *User
+	Repo        *Repository
+	PusherName  string
+	RefFullName string
+	OldCommitID string
+	NewCommitID string
+	Commits     *PushCommits
+}
+
+func (db *actions) CommitRepo(ctx context.Context, opts CommitRepoOptions) error {
+	err := NewReposStore(db.DB).Touch(ctx, opts.Repo.ID)
+	if err != nil {
+		return errors.Wrap(err, "touch repository")
+	}
+
+	pusher, err := NewUsersStore(db.DB).GetByUsername(ctx, opts.PusherName)
+	if err != nil {
+		return errors.Wrapf(err, "get pusher [name: %s]", opts.PusherName)
+	}
+
+	isNewRef := opts.OldCommitID == git.EmptyID
+	isDelRef := opts.NewCommitID == git.EmptyID
+
+	// If not the first commit, set the compare URL.
+	if !isNewRef && !isDelRef {
+		opts.Commits.CompareURL = repoutil.CompareCommitsPath(opts.Owner.Name, opts.Repo.Name, opts.OldCommitID, opts.NewCommitID)
+	}
+
+	refName := git.RefShortName(opts.RefFullName)
+	action := &Action{
+		ActUserID:    pusher.ID,
+		ActUserName:  pusher.Name,
+		RepoID:       opts.Repo.ID,
+		RepoUserName: opts.Owner.Name,
+		RepoName:     opts.Repo.Name,
+		RefName:      refName,
+		IsPrivate:    opts.Repo.IsPrivate || opts.Repo.IsUnlisted,
+	}
+
+	apiRepo := opts.Repo.APIFormat(opts.Owner)
+	apiPusher := pusher.APIFormat()
+	if isDelRef {
+		err = PrepareWebhooks(
+			opts.Repo,
+			HOOK_EVENT_DELETE,
+			&api.DeletePayload{
+				Ref:        refName,
+				RefType:    "branch",
+				PusherType: api.PUSHER_TYPE_USER,
+				Repo:       apiRepo,
+				Sender:     apiPusher,
+			},
+		)
+		if err != nil {
+			return errors.Wrap(err, "prepare webhooks for delete branch")
+		}
+
+		action.OpType = ActionDeleteBranch
+		err = db.notifyWatchers(ctx, action)
+		if err != nil {
+			return errors.Wrap(err, "notify watchers")
+		}
+
+		// Delete branch doesn't have anything to push or compare
+		return nil
+	}
+
+	// Only update issues via commits when internal issue tracker is enabled
+	if opts.Repo.EnableIssues && !opts.Repo.EnableExternalTracker {
+		if err = updateCommitReferencesToIssues(pusher, opts.Repo, opts.Commits.Commits); err != nil {
+			log.Error("update commit references to issues: %v", err)
+		}
+	}
+
+	if conf.UI.FeedMaxCommitNum > 0 && len(opts.Commits.Commits) > conf.UI.FeedMaxCommitNum {
+		opts.Commits.Commits = opts.Commits.Commits[:conf.UI.FeedMaxCommitNum]
+	}
+
+	data, err := jsoniter.Marshal(opts.Commits)
+	if err != nil {
+		return errors.Wrap(err, "marshal JSON")
+	}
+	action.Content = string(data)
+
+	var compareURL string
+	if isNewRef {
+		err = PrepareWebhooks(
+			opts.Repo,
+			HOOK_EVENT_CREATE,
+			&api.CreatePayload{
+				Ref:           refName,
+				RefType:       "branch",
+				DefaultBranch: opts.Repo.DefaultBranch,
+				Repo:          apiRepo,
+				Sender:        apiPusher,
+			},
+		)
+		if err != nil {
+			return errors.Wrap(err, "prepare webhooks for new branch")
+		}
+
+		action.OpType = ActionCreateBranch
+		err = db.notifyWatchers(ctx, action)
+		if err != nil {
+			return errors.Wrap(err, "notify watchers")
+		}
+	} else {
+		compareURL = conf.Server.ExternalURL + opts.Commits.CompareURL
+	}
+
+	commits, err := opts.Commits.APIFormat(ctx,
+		NewUsersStore(db.DB),
+		repoutil.RepositoryPath(opts.Owner.Name, opts.Repo.Name),
+		repoutil.HTMLURL(opts.Owner.Name, opts.Repo.Name),
+	)
+	if err != nil {
+		return errors.Wrap(err, "convert commits to API format")
+	}
+
+	err = PrepareWebhooks(
+		opts.Repo,
+		HOOK_EVENT_PUSH,
+		&api.PushPayload{
+			Ref:        opts.RefFullName,
+			Before:     opts.OldCommitID,
+			After:      opts.NewCommitID,
+			CompareURL: compareURL,
+			Commits:    commits,
+			Repo:       apiRepo,
+			Pusher:     apiPusher,
+			Sender:     apiPusher,
+		},
+	)
+	if err != nil {
+		return errors.Wrap(err, "prepare webhooks for new commit")
+	}
+
+	action.OpType = ActionCommitRepo
+	err = db.notifyWatchers(ctx, action)
+	if err != nil {
+		return errors.Wrap(err, "notify watchers")
+	}
+	return nil
+}
+
+type PushTagOptions struct {
+	Owner       *User
+	Repo        *Repository
+	PusherName  string
+	RefFullName string
+	NewCommitID string
+}
+
+func (db *actions) PushTag(ctx context.Context, opts PushTagOptions) error {
+	err := NewReposStore(db.DB).Touch(ctx, opts.Repo.ID)
+	if err != nil {
+		return errors.Wrap(err, "touch repository")
+	}
+
+	pusher, err := NewUsersStore(db.DB).GetByUsername(ctx, opts.PusherName)
+	if err != nil {
+		return errors.Wrapf(err, "get pusher [name: %s]", opts.PusherName)
+	}
+
+	refName := git.RefShortName(opts.RefFullName)
+	action := &Action{
+		ActUserID:    pusher.ID,
+		ActUserName:  pusher.Name,
+		RepoID:       opts.Repo.ID,
+		RepoUserName: opts.Owner.Name,
+		RepoName:     opts.Repo.Name,
+		RefName:      refName,
+		IsPrivate:    opts.Repo.IsPrivate || opts.Repo.IsUnlisted,
+	}
+
+	apiRepo := opts.Repo.APIFormat(opts.Owner)
+	apiPusher := pusher.APIFormat()
+	if opts.NewCommitID == git.EmptyID {
+		err = PrepareWebhooks(
+			opts.Repo,
+			HOOK_EVENT_DELETE,
+			&api.DeletePayload{
+				Ref:        refName,
+				RefType:    "tag",
+				PusherType: api.PUSHER_TYPE_USER,
+				Repo:       apiRepo,
+				Sender:     apiPusher,
+			},
+		)
+		if err != nil {
+			return errors.Wrap(err, "prepare webhooks for delete tag")
+		}
+
+		action.OpType = ActionDeleteTag
+		err = db.notifyWatchers(ctx, action)
+		if err != nil {
+			return errors.Wrap(err, "notify watchers")
+		}
+		return nil
+	}
+
+	err = PrepareWebhooks(
+		opts.Repo,
+		HOOK_EVENT_CREATE,
+		&api.CreatePayload{
+			Ref:           refName,
+			RefType:       "tag",
+			Sha:           opts.NewCommitID,
+			DefaultBranch: opts.Repo.DefaultBranch,
+			Repo:          apiRepo,
+			Sender:        apiPusher,
+		},
+	)
+	if err != nil {
+		return errors.Wrapf(err, "prepare webhooks for new tag")
+	}
+
+	action.OpType = ActionPushTag
+	err = db.notifyWatchers(ctx, action)
+	if err != nil {
+		return errors.Wrap(err, "notify watchers")
+	}
+	return nil
+}
+
+// ActionType is the type of an action.
+type ActionType int
+
+// ⚠️ WARNING: Only append to the end of list to maintain backward compatibility.
+const (
+	ActionCreateRepo        ActionType = iota + 1 // 1
+	ActionRenameRepo                              // 2
+	ActionStarRepo                                // 3
+	ActionWatchRepo                               // 4
+	ActionCommitRepo                              // 5
+	ActionCreateIssue                             // 6
+	ActionCreatePullRequest                       // 7
+	ActionTransferRepo                            // 8
+	ActionPushTag                                 // 9
+	ActionCommentIssue                            // 10
+	ActionMergePullRequest                        // 11
+	ActionCloseIssue                              // 12
+	ActionReopenIssue                             // 13
+	ActionClosePullRequest                        // 14
+	ActionReopenPullRequest                       // 15
+	ActionCreateBranch                            // 16
+	ActionDeleteBranch                            // 17
+	ActionDeleteTag                               // 18
+	ActionForkRepo                                // 19
+	ActionMirrorSyncPush                          // 20
+	ActionMirrorSyncCreate                        // 21
+	ActionMirrorSyncDelete                        // 22
+)
+
+// Action is a user operation to a repository. It implements template.Actioner
+// interface to be able to use it in template rendering.
+type Action struct {
+	ID           int64 `gorm:"primaryKey"`
+	UserID       int64 `gorm:"index"` // Receiver user ID
+	OpType       ActionType
+	ActUserID    int64  // Doer user ID
+	ActUserName  string // Doer user name
+	ActAvatar    string `xorm:"-" gorm:"-" json:"-"`
+	RepoID       int64  `xorm:"INDEX" gorm:"index"`
+	RepoUserName string
+	RepoName     string
+	RefName      string
+	IsPrivate    bool   `xorm:"NOT NULL DEFAULT false" gorm:"not null;default:FALSE"`
+	Content      string `xorm:"TEXT"`
+
+	Created     time.Time `xorm:"-" gorm:"-" json:"-"`
+	CreatedUnix int64
+}
+
+// BeforeCreate implements the GORM create hook.
+func (a *Action) BeforeCreate(tx *gorm.DB) error {
+	if a.CreatedUnix <= 0 {
+		a.CreatedUnix = tx.NowFunc().Unix()
+	}
+	return nil
+}
+
+// AfterFind implements the GORM query hook.
+func (a *Action) AfterFind(_ *gorm.DB) error {
+	a.Created = time.Unix(a.CreatedUnix, 0).Local()
+	return nil
+}
+
+func (a *Action) GetOpType() int {
+	return int(a.OpType)
+}
+
+func (a *Action) GetActUserName() string {
+	return a.ActUserName
+}
+
+func (a *Action) ShortActUserName() string {
+	return strutil.Ellipsis(a.ActUserName, 20)
+}
+
+func (a *Action) GetRepoUserName() string {
+	return a.RepoUserName
+}
+
+func (a *Action) ShortRepoUserName() string {
+	return strutil.Ellipsis(a.RepoUserName, 20)
+}
+
+func (a *Action) GetRepoName() string {
+	return a.RepoName
+}
+
+func (a *Action) ShortRepoName() string {
+	return strutil.Ellipsis(a.RepoName, 33)
+}
+
+func (a *Action) GetRepoPath() string {
+	return path.Join(a.RepoUserName, a.RepoName)
+}
+
+func (a *Action) ShortRepoPath() string {
+	return path.Join(a.ShortRepoUserName(), a.ShortRepoName())
+}
+
+func (a *Action) GetRepoLink() string {
+	if conf.Server.Subpath != "" {
+		return path.Join(conf.Server.Subpath, a.GetRepoPath())
+	}
+	return "/" + a.GetRepoPath()
+}
+
+func (a *Action) GetBranch() string {
+	return a.RefName
+}
+
+func (a *Action) GetContent() string {
+	return a.Content
+}
+
+func (a *Action) GetCreate() time.Time {
+	return a.Created
+}
+
+func (a *Action) GetIssueInfos() []string {
+	return strings.SplitN(a.Content, "|", 2)
+}
+
+func (a *Action) GetIssueTitle() string {
+	index, _ := strconv.ParseInt(a.GetIssueInfos()[0], 10, 64)
+	issue, err := GetIssueByIndex(a.RepoID, index)
+	if err != nil {
+		log.Error("Failed to get issue title [repo_id: %d, index: %d]: %v", a.RepoID, index, err)
+		return "error getting issue"
+	}
+	return issue.Title
+}
+
+func (a *Action) GetIssueContent() string {
+	index, _ := strconv.ParseInt(a.GetIssueInfos()[0], 10, 64)
+	issue, err := GetIssueByIndex(a.RepoID, index)
+	if err != nil {
+		log.Error("Failed to get issue content [repo_id: %d, index: %d]: %v", a.RepoID, index, err)
+		return "error getting issue"
+	}
+	return issue.Content
+}
+
+// PushCommit contains information of a pushed commit.
+type PushCommit struct {
+	Sha1           string
+	Message        string
+	AuthorEmail    string
+	AuthorName     string
+	CommitterEmail string
+	CommitterName  string
+	Timestamp      time.Time
+}
+
+// PushCommits is a list of pushed commits.
+type PushCommits struct {
+	Len        int
+	Commits    []*PushCommit
+	CompareURL string
+
+	avatars map[string]string
+}
+
+// NewPushCommits returns a new PushCommits.
+func NewPushCommits() *PushCommits {
+	return &PushCommits{
+		avatars: make(map[string]string),
+	}
+}
+
+func (pcs *PushCommits) APIFormat(ctx context.Context, usersStore UsersStore, repoPath, repoURL string) ([]*api.PayloadCommit, error) {
+	// NOTE: We cache query results in case there are many commits in a single push.
+	usernameByEmail := make(map[string]string)
+	getUsernameByEmail := func(email string) (string, error) {
+		username, ok := usernameByEmail[email]
+		if ok {
+			return username, nil
+		}
+
+		user, err := usersStore.GetByEmail(ctx, email)
+		if err != nil {
+			if IsErrUserNotExist(err) {
+				usernameByEmail[email] = ""
+				return "", nil
+			}
+			return "", err
+		}
+
+		usernameByEmail[email] = user.Name
+		return user.Name, nil
+	}
+
+	commits := make([]*api.PayloadCommit, len(pcs.Commits))
+	for i, commit := range pcs.Commits {
+		authorUsername, err := getUsernameByEmail(commit.AuthorEmail)
+		if err != nil {
+			return nil, errors.Wrap(err, "get author username")
+		}
+
+		committerUsername, err := getUsernameByEmail(commit.CommitterEmail)
+		if err != nil {
+			return nil, errors.Wrap(err, "get committer username")
+		}
+
+		nameStatus := &git.NameStatus{}
+		if !testutil.InTest {
+			nameStatus, err = git.ShowNameStatus(repoPath, commit.Sha1)
+			if err != nil {
+				return nil, errors.Wrapf(err, "show name status [commit_sha1: %s]", commit.Sha1)
+			}
+		}
+
+		commits[i] = &api.PayloadCommit{
+			ID:      commit.Sha1,
+			Message: commit.Message,
+			URL:     fmt.Sprintf("%s/commit/%s", repoURL, commit.Sha1),
+			Author: &api.PayloadUser{
+				Name:     commit.AuthorName,
+				Email:    commit.AuthorEmail,
+				UserName: authorUsername,
+			},
+			Committer: &api.PayloadUser{
+				Name:     commit.CommitterName,
+				Email:    commit.CommitterEmail,
+				UserName: committerUsername,
+			},
+			Added:     nameStatus.Added,
+			Removed:   nameStatus.Removed,
+			Modified:  nameStatus.Modified,
+			Timestamp: commit.Timestamp,
+		}
+	}
+	return commits, nil
+}
+
+// AvatarLink tries to match user in database with email in order to show custom
+// avatars, and falls back to general avatar link.
+//
+// FIXME: This method does not belong to PushCommits, should be a pure template
+// 	function.
+func (pcs *PushCommits) AvatarLink(email string) string {
+	_, ok := pcs.avatars[email]
+	if !ok {
+		u, err := Users.GetByEmail(context.Background(), email)
+		if err != nil {
+			pcs.avatars[email] = tool.AvatarLink(email)
+			if !IsErrUserNotExist(err) {
+				log.Error("Failed to get user [email: %s]: %v", email, err)
+			}
+		} else {
+			pcs.avatars[email] = u.RelAvatarLink()
+		}
+	}
+
+	return pcs.avatars[email]
+}

+ 872 - 0
internal/db/actions_test.go

@@ -0,0 +1,872 @@
+// Copyright 2022 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package db
+
+import (
+	"context"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/gogs/git-module"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+
+	"gogs.io/gogs/internal/conf"
+	"gogs.io/gogs/internal/dbtest"
+)
+
+func TestIssueReferencePattern(t *testing.T) {
+	tests := []struct {
+		name    string
+		message string
+		want    []string
+	}{
+		{
+			name:    "no match",
+			message: "Hello world!",
+			want:    nil,
+		},
+		{
+			name:    "contains issue numbers",
+			message: "#123 is fixed, and #456 is WIP",
+			want:    []string{"#123", " #456"},
+		},
+		{
+			name:    "contains full issue references",
+			message: "#123 is fixed, and user/repo#456 is WIP",
+			want:    []string{"#123", " user/repo#456"},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got := issueReferencePattern.FindAllString(test.message, -1)
+			assert.Equal(t, test.want, got)
+		})
+	}
+}
+
+func TestAction_BeforeCreate(t *testing.T) {
+	now := time.Now()
+	db := &gorm.DB{
+		Config: &gorm.Config{
+			SkipDefaultTransaction: true,
+			NowFunc: func() time.Time {
+				return now
+			},
+		},
+	}
+
+	t.Run("CreatedUnix has been set", func(t *testing.T) {
+		action := &Action{CreatedUnix: 1}
+		_ = action.BeforeCreate(db)
+		assert.Equal(t, int64(1), action.CreatedUnix)
+	})
+
+	t.Run("CreatedUnix has not been set", func(t *testing.T) {
+		action := &Action{}
+		_ = action.BeforeCreate(db)
+		assert.Equal(t, db.NowFunc().Unix(), action.CreatedUnix)
+	})
+}
+
+func TestActions(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+	t.Parallel()
+
+	tables := []interface{}{new(Action), new(User), new(Repository), new(EmailAddress), new(Watch)}
+	db := &actions{
+		DB: dbtest.NewDB(t, "actions", tables...),
+	}
+
+	for _, tc := range []struct {
+		name string
+		test func(*testing.T, *actions)
+	}{
+		{"CommitRepo", actionsCommitRepo},
+		{"ListByOrganization", actionsListByOrganization},
+		{"ListByUser", actionsListByUser},
+		{"MergePullRequest", actionsMergePullRequest},
+		{"MirrorSyncCreate", actionsMirrorSyncCreate},
+		{"MirrorSyncDelete", actionsMirrorSyncDelete},
+		{"MirrorSyncPush", actionsMirrorSyncPush},
+		{"NewRepo", actionsNewRepo},
+		{"PushTag", actionsPushTag},
+		{"RenameRepo", actionsRenameRepo},
+		{"TransferRepo", actionsTransferRepo},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Cleanup(func() {
+				err := clearTables(t, db.DB, tables...)
+				require.NoError(t, err)
+			})
+			tc.test(t, db)
+		})
+		if t.Failed() {
+			break
+		}
+	}
+}
+
+func actionsCommitRepo(t *testing.T, db *actions) {
+	ctx := context.Background()
+
+	alice, err := NewUsersStore(db.DB).Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	repo, err := NewReposStore(db.DB).Create(ctx,
+		alice.ID,
+		CreateRepoOptions{
+			Name: "example",
+		},
+	)
+	require.NoError(t, err)
+
+	now := time.Unix(1588568886, 0).UTC()
+
+	t.Run("new commit", func(t *testing.T) {
+		t.Cleanup(func() {
+			err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).WithContext(ctx).Delete(new(Action)).Error
+			require.NoError(t, err)
+		})
+
+		err = db.CommitRepo(ctx,
+			CommitRepoOptions{
+				PusherName:  alice.Name,
+				Owner:       alice,
+				Repo:        repo,
+				RefFullName: "refs/heads/main",
+				OldCommitID: "ca82a6dff817ec66f44342007202690a93763949",
+				NewCommitID: "085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7",
+				Commits: CommitsToPushCommits(
+					[]*git.Commit{
+						{
+							ID: git.MustIDFromString("085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7"),
+							Author: &git.Signature{
+								Name:  "alice",
+								Email: "[email protected]",
+								When:  now,
+							},
+							Committer: &git.Signature{
+								Name:  "alice",
+								Email: "[email protected]",
+								When:  now,
+							},
+							Message: "A random commit",
+						},
+					},
+				),
+			},
+		)
+		require.NoError(t, err)
+
+		got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+		require.NoError(t, err)
+		require.Len(t, got, 1)
+		got[0].ID = 0
+
+		want := []*Action{
+			{
+				UserID:       alice.ID,
+				OpType:       ActionCommitRepo,
+				ActUserID:    alice.ID,
+				ActUserName:  alice.Name,
+				RepoID:       repo.ID,
+				RepoUserName: alice.Name,
+				RepoName:     repo.Name,
+				RefName:      "main",
+				IsPrivate:    false,
+				Content:      `{"Len":1,"Commits":[{"Sha1":"085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7","Message":"A random commit","AuthorEmail":"[email protected]","AuthorName":"alice","CommitterEmail":"[email protected]","CommitterName":"alice","Timestamp":"2020-05-04T05:08:06Z"}],"CompareURL":"alice/example/compare/ca82a6dff817ec66f44342007202690a93763949...085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7"}`,
+				CreatedUnix:  db.NowFunc().Unix(),
+			},
+		}
+		want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+		assert.Equal(t, want, got)
+	})
+
+	t.Run("new ref", func(t *testing.T) {
+		t.Cleanup(func() {
+			err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).WithContext(ctx).Delete(new(Action)).Error
+			require.NoError(t, err)
+		})
+
+		err = db.CommitRepo(ctx,
+			CommitRepoOptions{
+				PusherName:  alice.Name,
+				Owner:       alice,
+				Repo:        repo,
+				RefFullName: "refs/heads/main",
+				OldCommitID: git.EmptyID,
+				NewCommitID: "085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7",
+				Commits: CommitsToPushCommits(
+					[]*git.Commit{
+						{
+							ID: git.MustIDFromString("085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7"),
+							Author: &git.Signature{
+								Name:  "alice",
+								Email: "[email protected]",
+								When:  now,
+							},
+							Committer: &git.Signature{
+								Name:  "alice",
+								Email: "[email protected]",
+								When:  now,
+							},
+							Message: "A random commit",
+						},
+					},
+				),
+			},
+		)
+		require.NoError(t, err)
+
+		got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+		require.NoError(t, err)
+		require.Len(t, got, 2)
+		got[0].ID = 0
+		got[1].ID = 0
+
+		want := []*Action{
+			{
+				UserID:       alice.ID,
+				OpType:       ActionCommitRepo,
+				ActUserID:    alice.ID,
+				ActUserName:  alice.Name,
+				RepoID:       repo.ID,
+				RepoUserName: alice.Name,
+				RepoName:     repo.Name,
+				RefName:      "main",
+				IsPrivate:    false,
+				Content:      `{"Len":1,"Commits":[{"Sha1":"085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7","Message":"A random commit","AuthorEmail":"[email protected]","AuthorName":"alice","CommitterEmail":"[email protected]","CommitterName":"alice","Timestamp":"2020-05-04T05:08:06Z"}],"CompareURL":""}`,
+				CreatedUnix:  db.NowFunc().Unix(),
+			},
+			{
+				UserID:       alice.ID,
+				OpType:       ActionCreateBranch,
+				ActUserID:    alice.ID,
+				ActUserName:  alice.Name,
+				RepoID:       repo.ID,
+				RepoUserName: alice.Name,
+				RepoName:     repo.Name,
+				RefName:      "main",
+				IsPrivate:    false,
+				Content:      `{"Len":1,"Commits":[{"Sha1":"085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7","Message":"A random commit","AuthorEmail":"[email protected]","AuthorName":"alice","CommitterEmail":"[email protected]","CommitterName":"alice","Timestamp":"2020-05-04T05:08:06Z"}],"CompareURL":""}`,
+				CreatedUnix:  db.NowFunc().Unix(),
+			},
+		}
+		want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+		want[1].Created = time.Unix(want[1].CreatedUnix, 0)
+		assert.Equal(t, want, got)
+	})
+
+	t.Run("delete ref", func(t *testing.T) {
+		t.Cleanup(func() {
+			err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).WithContext(ctx).Delete(new(Action)).Error
+			require.NoError(t, err)
+		})
+
+		err = db.CommitRepo(ctx,
+			CommitRepoOptions{
+				PusherName:  alice.Name,
+				Owner:       alice,
+				Repo:        repo,
+				RefFullName: "refs/heads/main",
+				OldCommitID: "ca82a6dff817ec66f44342007202690a93763949",
+				NewCommitID: git.EmptyID,
+			},
+		)
+		require.NoError(t, err)
+
+		got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+		require.NoError(t, err)
+		require.Len(t, got, 1)
+		got[0].ID = 0
+
+		want := []*Action{
+			{
+				UserID:       alice.ID,
+				OpType:       ActionDeleteBranch,
+				ActUserID:    alice.ID,
+				ActUserName:  alice.Name,
+				RepoID:       repo.ID,
+				RepoUserName: alice.Name,
+				RepoName:     repo.Name,
+				RefName:      "main",
+				IsPrivate:    false,
+				CreatedUnix:  db.NowFunc().Unix(),
+			},
+		}
+		want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+		assert.Equal(t, want, got)
+	})
+}
+
+func actionsListByOrganization(t *testing.T, db *actions) {
+	if os.Getenv("GOGS_DATABASE_TYPE") != "postgres" {
+		t.Skip("Skipping testing with not using PostgreSQL")
+		return
+	}
+
+	ctx := context.Background()
+
+	conf.SetMockUI(t,
+		conf.UIOpts{
+			User: conf.UIUserOpts{
+				NewsFeedPagingNum: 20,
+			},
+		},
+	)
+
+	tests := []struct {
+		name    string
+		orgID   int64
+		actorID int64
+		afterID int64
+		want    string
+	}{
+		{
+			name:    "no afterID",
+			orgID:   1,
+			actorID: 1,
+			afterID: 0,
+			want:    `SELECT * FROM "action" WHERE user_id = 1 AND (true OR id < 0) AND repo_id IN (SELECT repository.id FROM "repository" JOIN team_repo ON repository.id = team_repo.repo_id WHERE team_repo.team_id IN (SELECT team_id FROM "team_user" WHERE team_user.org_id = 1 AND uid = 1) OR (repository.is_private = false AND repository.is_unlisted = false)) ORDER BY id DESC LIMIT 20`,
+		},
+		{
+			name:    "has afterID",
+			orgID:   1,
+			actorID: 1,
+			afterID: 5,
+			want:    `SELECT * FROM "action" WHERE user_id = 1 AND (false OR id < 5) AND repo_id IN (SELECT repository.id FROM "repository" JOIN team_repo ON repository.id = team_repo.repo_id WHERE team_repo.team_id IN (SELECT team_id FROM "team_user" WHERE team_user.org_id = 1 AND uid = 1) OR (repository.is_private = false AND repository.is_unlisted = false)) ORDER BY id DESC LIMIT 20`,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got := db.DB.ToSQL(func(tx *gorm.DB) *gorm.DB {
+				return NewActionsStore(tx).(*actions).listByOrganization(ctx, test.orgID, test.actorID, test.afterID).Find(new(Action))
+			})
+			assert.Equal(t, test.want, got)
+		})
+	}
+}
+
+func actionsListByUser(t *testing.T, db *actions) {
+	if os.Getenv("GOGS_DATABASE_TYPE") != "postgres" {
+		t.Skip("Skipping testing with not using PostgreSQL")
+		return
+	}
+
+	ctx := context.Background()
+
+	conf.SetMockUI(t,
+		conf.UIOpts{
+			User: conf.UIUserOpts{
+				NewsFeedPagingNum: 20,
+			},
+		},
+	)
+
+	tests := []struct {
+		name      string
+		userID    int64
+		actorID   int64
+		afterID   int64
+		isProfile bool
+		want      string
+	}{
+		{
+			name:      "same user no afterID not in profile",
+			userID:    1,
+			actorID:   1,
+			afterID:   0,
+			isProfile: false,
+			want:      `SELECT * FROM "action" WHERE user_id = 1 AND (true OR id < 0) AND (true OR (is_private = false AND act_user_id = 1)) ORDER BY id DESC LIMIT 20`,
+		},
+		{
+			name:      "same user no afterID in profile",
+			userID:    1,
+			actorID:   1,
+			afterID:   0,
+			isProfile: true,
+			want:      `SELECT * FROM "action" WHERE user_id = 1 AND (true OR id < 0) AND (true OR (is_private = false AND act_user_id = 1)) ORDER BY id DESC LIMIT 20`,
+		},
+		{
+			name:      "same user has afterID not in profile",
+			userID:    1,
+			actorID:   1,
+			afterID:   5,
+			isProfile: false,
+			want:      `SELECT * FROM "action" WHERE user_id = 1 AND (false OR id < 5) AND (true OR (is_private = false AND act_user_id = 1)) ORDER BY id DESC LIMIT 20`,
+		},
+		{
+			name:      "different user no afterID in profile",
+			userID:    1,
+			actorID:   2,
+			afterID:   0,
+			isProfile: true,
+			want:      `SELECT * FROM "action" WHERE user_id = 1 AND (true OR id < 0) AND (false OR (is_private = false AND act_user_id = 1)) ORDER BY id DESC LIMIT 20`,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got := db.DB.ToSQL(func(tx *gorm.DB) *gorm.DB {
+				return NewActionsStore(tx).(*actions).listByUser(ctx, test.userID, test.actorID, test.afterID, test.isProfile).Find(new(Action))
+			})
+			assert.Equal(t, test.want, got)
+		})
+	}
+}
+
+func actionsMergePullRequest(t *testing.T, db *actions) {
+	ctx := context.Background()
+
+	alice, err := NewUsersStore(db.DB).Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	repo, err := NewReposStore(db.DB).Create(ctx,
+		alice.ID,
+		CreateRepoOptions{
+			Name: "example",
+		},
+	)
+	require.NoError(t, err)
+
+	err = db.MergePullRequest(ctx,
+		alice,
+		alice,
+		repo,
+		&Issue{
+			Index: 1,
+			Title: "Fix issue 1",
+		},
+	)
+	require.NoError(t, err)
+
+	got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	got[0].ID = 0
+
+	want := []*Action{
+		{
+			UserID:       alice.ID,
+			OpType:       ActionMergePullRequest,
+			ActUserID:    alice.ID,
+			ActUserName:  alice.Name,
+			RepoID:       repo.ID,
+			RepoUserName: alice.Name,
+			RepoName:     repo.Name,
+			IsPrivate:    false,
+			Content:      `1|Fix issue 1`,
+			CreatedUnix:  db.NowFunc().Unix(),
+		},
+	}
+	want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+	assert.Equal(t, want, got)
+}
+
+func actionsMirrorSyncCreate(t *testing.T, db *actions) {
+	ctx := context.Background()
+
+	alice, err := NewUsersStore(db.DB).Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	repo, err := NewReposStore(db.DB).Create(ctx,
+		alice.ID,
+		CreateRepoOptions{
+			Name: "example",
+		},
+	)
+	require.NoError(t, err)
+
+	err = db.MirrorSyncCreate(ctx,
+		alice,
+		repo,
+		"main",
+	)
+	require.NoError(t, err)
+
+	got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	got[0].ID = 0
+
+	want := []*Action{
+		{
+			UserID:       alice.ID,
+			OpType:       ActionMirrorSyncCreate,
+			ActUserID:    alice.ID,
+			ActUserName:  alice.Name,
+			RepoID:       repo.ID,
+			RepoUserName: alice.Name,
+			RepoName:     repo.Name,
+			RefName:      "main",
+			IsPrivate:    false,
+			CreatedUnix:  db.NowFunc().Unix(),
+		},
+	}
+	want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+	assert.Equal(t, want, got)
+}
+
+func actionsMirrorSyncDelete(t *testing.T, db *actions) {
+	ctx := context.Background()
+
+	alice, err := NewUsersStore(db.DB).Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	repo, err := NewReposStore(db.DB).Create(ctx,
+		alice.ID,
+		CreateRepoOptions{
+			Name: "example",
+		},
+	)
+	require.NoError(t, err)
+
+	err = db.MirrorSyncDelete(ctx,
+		alice,
+		repo,
+		"main",
+	)
+	require.NoError(t, err)
+
+	got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	got[0].ID = 0
+
+	want := []*Action{
+		{
+			UserID:       alice.ID,
+			OpType:       ActionMirrorSyncDelete,
+			ActUserID:    alice.ID,
+			ActUserName:  alice.Name,
+			RepoID:       repo.ID,
+			RepoUserName: alice.Name,
+			RepoName:     repo.Name,
+			RefName:      "main",
+			IsPrivate:    false,
+			CreatedUnix:  db.NowFunc().Unix(),
+		},
+	}
+	want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+	assert.Equal(t, want, got)
+}
+
+func actionsMirrorSyncPush(t *testing.T, db *actions) {
+	ctx := context.Background()
+
+	alice, err := NewUsersStore(db.DB).Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	repo, err := NewReposStore(db.DB).Create(ctx,
+		alice.ID,
+		CreateRepoOptions{
+			Name: "example",
+		},
+	)
+	require.NoError(t, err)
+
+	now := time.Unix(1588568886, 0).UTC()
+	err = db.MirrorSyncPush(ctx,
+		MirrorSyncPushOptions{
+			Owner:       alice,
+			Repo:        repo,
+			RefName:     "main",
+			OldCommitID: "ca82a6dff817ec66f44342007202690a93763949",
+			NewCommitID: "085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7",
+			Commits: CommitsToPushCommits(
+				[]*git.Commit{
+					{
+						ID: git.MustIDFromString("085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7"),
+						Author: &git.Signature{
+							Name:  "alice",
+							Email: "[email protected]",
+							When:  now,
+						},
+						Committer: &git.Signature{
+							Name:  "alice",
+							Email: "[email protected]",
+							When:  now,
+						},
+						Message: "A random commit",
+					},
+				},
+			),
+		},
+	)
+	require.NoError(t, err)
+
+	got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	got[0].ID = 0
+
+	want := []*Action{
+		{
+			UserID:       alice.ID,
+			OpType:       ActionMirrorSyncPush,
+			ActUserID:    alice.ID,
+			ActUserName:  alice.Name,
+			RepoID:       repo.ID,
+			RepoUserName: alice.Name,
+			RepoName:     repo.Name,
+			RefName:      "main",
+			IsPrivate:    false,
+			Content:      `{"Len":1,"Commits":[{"Sha1":"085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7","Message":"A random commit","AuthorEmail":"[email protected]","AuthorName":"alice","CommitterEmail":"[email protected]","CommitterName":"alice","Timestamp":"2020-05-04T05:08:06Z"}],"CompareURL":"alice/example/compare/ca82a6dff817ec66f44342007202690a93763949...085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7"}`,
+			CreatedUnix:  db.NowFunc().Unix(),
+		},
+	}
+	want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+	assert.Equal(t, want, got)
+}
+
+func actionsNewRepo(t *testing.T, db *actions) {
+	ctx := context.Background()
+
+	alice, err := NewUsersStore(db.DB).Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	repo, err := NewReposStore(db.DB).Create(ctx,
+		alice.ID,
+		CreateRepoOptions{
+			Name: "example",
+		},
+	)
+	require.NoError(t, err)
+
+	t.Run("new repo", func(t *testing.T) {
+		t.Cleanup(func() {
+			err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).WithContext(ctx).Delete(new(Action)).Error
+			require.NoError(t, err)
+		})
+
+		err = db.NewRepo(ctx, alice, alice, repo)
+		require.NoError(t, err)
+
+		got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+		require.NoError(t, err)
+		require.Len(t, got, 1)
+		got[0].ID = 0
+
+		want := []*Action{
+			{
+				UserID:       alice.ID,
+				OpType:       ActionCreateRepo,
+				ActUserID:    alice.ID,
+				ActUserName:  alice.Name,
+				RepoID:       repo.ID,
+				RepoUserName: alice.Name,
+				RepoName:     repo.Name,
+				IsPrivate:    false,
+				CreatedUnix:  db.NowFunc().Unix(),
+			},
+		}
+		want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+		assert.Equal(t, want, got)
+	})
+
+	t.Run("fork repo", func(t *testing.T) {
+		t.Cleanup(func() {
+			err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).WithContext(ctx).Delete(new(Action)).Error
+			require.NoError(t, err)
+		})
+
+		repo.IsFork = true
+		err = db.NewRepo(ctx, alice, alice, repo)
+		require.NoError(t, err)
+
+		got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+		require.NoError(t, err)
+		require.Len(t, got, 1)
+		got[0].ID = 0
+
+		want := []*Action{
+			{
+				UserID:       alice.ID,
+				OpType:       ActionForkRepo,
+				ActUserID:    alice.ID,
+				ActUserName:  alice.Name,
+				RepoID:       repo.ID,
+				RepoUserName: alice.Name,
+				RepoName:     repo.Name,
+				IsPrivate:    false,
+				CreatedUnix:  db.NowFunc().Unix(),
+			},
+		}
+		want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+		assert.Equal(t, want, got)
+	})
+}
+
+func actionsPushTag(t *testing.T, db *actions) {
+	ctx := context.Background()
+
+	alice, err := NewUsersStore(db.DB).Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	repo, err := NewReposStore(db.DB).Create(ctx,
+		alice.ID,
+		CreateRepoOptions{
+			Name: "example",
+		},
+	)
+	require.NoError(t, err)
+
+	t.Run("new tag", func(t *testing.T) {
+		t.Cleanup(func() {
+			err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).WithContext(ctx).Delete(new(Action)).Error
+			require.NoError(t, err)
+		})
+
+		err = db.PushTag(ctx,
+			PushTagOptions{
+				Owner:       alice,
+				Repo:        repo,
+				PusherName:  alice.Name,
+				RefFullName: "refs/tags/v1.0.0",
+				NewCommitID: "085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7",
+			},
+		)
+		require.NoError(t, err)
+
+		got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+		require.NoError(t, err)
+		require.Len(t, got, 1)
+		got[0].ID = 0
+
+		want := []*Action{
+			{
+				UserID:       alice.ID,
+				OpType:       ActionPushTag,
+				ActUserID:    alice.ID,
+				ActUserName:  alice.Name,
+				RepoID:       repo.ID,
+				RepoUserName: alice.Name,
+				RepoName:     repo.Name,
+				RefName:      "v1.0.0",
+				IsPrivate:    false,
+				CreatedUnix:  db.NowFunc().Unix(),
+			},
+		}
+		want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+		assert.Equal(t, want, got)
+	})
+
+	t.Run("delete tag", func(t *testing.T) {
+		t.Cleanup(func() {
+			err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).WithContext(ctx).Delete(new(Action)).Error
+			require.NoError(t, err)
+		})
+
+		err = db.PushTag(ctx,
+			PushTagOptions{
+				Owner:       alice,
+				Repo:        repo,
+				PusherName:  alice.Name,
+				RefFullName: "refs/tags/v1.0.0",
+				NewCommitID: git.EmptyID,
+			},
+		)
+		require.NoError(t, err)
+
+		got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+		require.NoError(t, err)
+		require.Len(t, got, 1)
+		got[0].ID = 0
+
+		want := []*Action{
+			{
+				UserID:       alice.ID,
+				OpType:       ActionDeleteTag,
+				ActUserID:    alice.ID,
+				ActUserName:  alice.Name,
+				RepoID:       repo.ID,
+				RepoUserName: alice.Name,
+				RepoName:     repo.Name,
+				RefName:      "v1.0.0",
+				IsPrivate:    false,
+				CreatedUnix:  db.NowFunc().Unix(),
+			},
+		}
+		want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+		assert.Equal(t, want, got)
+	})
+}
+
+func actionsRenameRepo(t *testing.T, db *actions) {
+	ctx := context.Background()
+
+	alice, err := NewUsersStore(db.DB).Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	repo, err := NewReposStore(db.DB).Create(ctx,
+		alice.ID,
+		CreateRepoOptions{
+			Name: "example",
+		},
+	)
+	require.NoError(t, err)
+
+	err = db.RenameRepo(ctx, alice, alice, "oldExample", repo)
+	require.NoError(t, err)
+
+	got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	got[0].ID = 0
+
+	want := []*Action{
+		{
+			UserID:       alice.ID,
+			OpType:       ActionRenameRepo,
+			ActUserID:    alice.ID,
+			ActUserName:  alice.Name,
+			RepoID:       repo.ID,
+			RepoUserName: alice.Name,
+			RepoName:     repo.Name,
+			IsPrivate:    false,
+			Content:      "oldExample",
+			CreatedUnix:  db.NowFunc().Unix(),
+		},
+	}
+	want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+	assert.Equal(t, want, got)
+}
+
+func actionsTransferRepo(t *testing.T, db *actions) {
+	ctx := context.Background()
+
+	alice, err := NewUsersStore(db.DB).Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	bob, err := NewUsersStore(db.DB).Create(ctx, "bob", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	repo, err := NewReposStore(db.DB).Create(ctx,
+		alice.ID,
+		CreateRepoOptions{
+			Name: "example",
+		},
+	)
+	require.NoError(t, err)
+
+	err = db.TransferRepo(ctx, alice, alice, bob, repo)
+	require.NoError(t, err)
+
+	got, err := db.ListByUser(ctx, alice.ID, alice.ID, 0, false)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	got[0].ID = 0
+
+	want := []*Action{
+		{
+			UserID:       alice.ID,
+			OpType:       ActionTransferRepo,
+			ActUserID:    alice.ID,
+			ActUserName:  alice.Name,
+			RepoID:       repo.ID,
+			RepoUserName: bob.Name,
+			RepoName:     repo.Name,
+			IsPrivate:    false,
+			Content:      "alice/example",
+			CreatedUnix:  db.NowFunc().Unix(),
+		},
+	}
+	want[0].Created = time.Unix(want[0].CreatedUnix, 0)
+	assert.Equal(t, want, got)
+}

+ 1 - 1
internal/db/backup.go

@@ -221,7 +221,7 @@ func importTable(ctx context.Context, db *gorm.DB, table interface{}, r io.Reade
 	// PostgreSQL needs manually reset table sequence for auto increment keys
 	if conf.UsePostgreSQL && !skipResetIDSeq[rawTableName] {
 		seqName := rawTableName + "_id_seq"
-		if _, err = x.Context(ctx).Exec(fmt.Sprintf(`SELECT setval('%s', COALESCE((SELECT MAX(id)+1 FROM "%s"), 1), false);`, seqName, rawTableName)); err != nil {
+		if err = db.WithContext(ctx).Exec(fmt.Sprintf(`SELECT setval('%s', COALESCE((SELECT MAX(id)+1 FROM "%s"), 1), false)`, seqName, rawTableName)).Error; err != nil {
 			return errors.Wrapf(err, "reset table %q.%q", rawTableName, seqName)
 		}
 	}

+ 44 - 3
internal/db/backup_test.go

@@ -29,11 +29,10 @@ func TestDumpAndImport(t *testing.T) {
 	if testing.Short() {
 		t.Skip()
 	}
-
 	t.Parallel()
 
-	if len(Tables) != 4 {
-		t.Fatalf("New table has added (want 4 got %d), please add new tests for the table and update this check", len(Tables))
+	if len(Tables) != 5 {
+		t.Fatalf("New table has added (want 5 got %d), please add new tests for the table and update this check", len(Tables))
 	}
 
 	db := dbtest.NewDB(t, "dumpAndImport", Tables...)
@@ -90,6 +89,48 @@ func setupDBToDump(t *testing.T, db *gorm.DB) {
 			CreatedUnix: 1588568886,
 		},
 
+		&Action{
+			ID:           1,
+			UserID:       1,
+			OpType:       ActionCreateBranch,
+			ActUserID:    1,
+			ActUserName:  "alice",
+			RepoID:       1,
+			RepoUserName: "alice",
+			RepoName:     "example",
+			RefName:      "main",
+			IsPrivate:    false,
+			Content:      `{"Len":1,"Commits":[],"CompareURL":""}`,
+			CreatedUnix:  1588568886,
+		},
+		&Action{
+			ID:           2,
+			UserID:       1,
+			OpType:       ActionCommitRepo,
+			ActUserID:    1,
+			ActUserName:  "alice",
+			RepoID:       1,
+			RepoUserName: "alice",
+			RepoName:     "example",
+			RefName:      "main",
+			IsPrivate:    false,
+			Content:      `{"Len":1,"Commits":[],"CompareURL":""}`,
+			CreatedUnix:  1588568886,
+		},
+		&Action{
+			ID:           3,
+			UserID:       1,
+			OpType:       ActionDeleteBranch,
+			ActUserID:    1,
+			ActUserName:  "alice",
+			RepoID:       1,
+			RepoUserName: "alice",
+			RepoName:     "example",
+			RefName:      "main",
+			IsPrivate:    false,
+			CreatedUnix:  1588568886,
+		},
+
 		&LFSObject{
 			RepoID:    1,
 			OID:       "ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f",

+ 11 - 11
internal/db/comment.go

@@ -172,11 +172,11 @@ func (cmt *Comment) mailParticipants(e Engine, opType ActionType, issue *Issue)
 	}
 
 	switch opType {
-	case ACTION_COMMENT_ISSUE:
+	case ActionCommentIssue:
 		issue.Content = cmt.Content
-	case ACTION_CLOSE_ISSUE:
+	case ActionCloseIssue:
 		issue.Content = fmt.Sprintf("Closed #%d", issue.Index)
-	case ACTION_REOPEN_ISSUE:
+	case ActionReopenIssue:
 		issue.Content = fmt.Sprintf("Reopened #%d", issue.Index)
 	}
 	if err = mailIssueCommentToParticipants(issue, cmt.Poster, mentions); err != nil {
@@ -216,7 +216,7 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err
 	// Check comment type.
 	switch opts.Type {
 	case COMMENT_TYPE_COMMENT:
-		act.OpType = ACTION_COMMENT_ISSUE
+		act.OpType = ActionCommentIssue
 
 		if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil {
 			return nil, err
@@ -245,9 +245,9 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err
 		}
 
 	case COMMENT_TYPE_REOPEN:
-		act.OpType = ACTION_REOPEN_ISSUE
+		act.OpType = ActionReopenIssue
 		if opts.Issue.IsPull {
-			act.OpType = ACTION_REOPEN_PULL_REQUEST
+			act.OpType = ActionReopenPullRequest
 		}
 
 		if opts.Issue.IsPull {
@@ -260,9 +260,9 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err
 		}
 
 	case COMMENT_TYPE_CLOSE:
-		act.OpType = ACTION_CLOSE_ISSUE
+		act.OpType = ActionCloseIssue
 		if opts.Issue.IsPull {
-			act.OpType = ACTION_CLOSE_PULL_REQUEST
+			act.OpType = ActionClosePullRequest
 		}
 
 		if opts.Issue.IsPull {
@@ -353,7 +353,7 @@ func CreateIssueComment(doer *User, repo *Repository, issue *Issue, content stri
 		Action:     api.HOOK_ISSUE_COMMENT_CREATED,
 		Issue:      issue.APIFormat(),
 		Comment:    comment.APIFormat(),
-		Repository: repo.APIFormat(nil),
+		Repository: repo.APIFormatLegacy(nil),
 		Sender:     doer.APIFormat(),
 	}); err != nil {
 		log.Error("PrepareWebhooks [comment_id: %d]: %v", comment.ID, err)
@@ -494,7 +494,7 @@ func UpdateComment(doer *User, c *Comment, oldContent string) (err error) {
 				From: oldContent,
 			},
 		},
-		Repository: c.Issue.Repo.APIFormat(nil),
+		Repository: c.Issue.Repo.APIFormatLegacy(nil),
 		Sender:     doer.APIFormat(),
 	}); err != nil {
 		log.Error("PrepareWebhooks [comment_id: %d]: %v", c.ID, err)
@@ -544,7 +544,7 @@ func DeleteCommentByID(doer *User, id int64) error {
 		Action:     api.HOOK_ISSUE_COMMENT_DELETED,
 		Issue:      comment.Issue.APIFormat(),
 		Comment:    comment.APIFormat(),
-		Repository: comment.Issue.Repo.APIFormat(nil),
+		Repository: comment.Issue.Repo.APIFormatLegacy(nil),
 		Sender:     doer.APIFormat(),
 	}); err != nil {
 		log.Error("PrepareWebhooks [comment_id: %d]: %v", comment.ID, err)

+ 5 - 3
internal/db/db.go

@@ -41,7 +41,7 @@ func newLogWriter() (logger.Writer, error) {
 //
 // NOTE: Lines are sorted in alphabetical order, each letter in its own line.
 var Tables = []interface{}{
-	new(Access), new(AccessToken),
+	new(Access), new(AccessToken), new(Action),
 	new(LFSObject), new(LoginSource),
 }
 
@@ -119,12 +119,14 @@ func Init(w logger.Writer) (*gorm.DB, error) {
 
 	// Initialize stores, sorted in alphabetical order.
 	AccessTokens = &accessTokens{DB: db}
+	Actions = NewActionsStore(db)
 	LoginSources = &loginSources{DB: db, files: sourceFiles}
 	LFS = &lfs{DB: db}
 	Perms = &perms{DB: db}
-	Repos = &repos{DB: db}
+	Repos = NewReposStore(db)
 	TwoFactors = &twoFactors{DB: db}
-	Users = &users{DB: db}
+	Users = NewUsersStore(db)
+	Watches = NewWatchesStore(db)
 
 	return db, nil
 }

+ 14 - 14
internal/db/issue.go

@@ -237,7 +237,7 @@ func (issue *Issue) sendLabelUpdatedWebhook(doer *User) {
 			Action:      api.HOOK_ISSUE_LABEL_UPDATED,
 			Index:       issue.Index,
 			PullRequest: issue.PullRequest.APIFormat(),
-			Repository:  issue.Repo.APIFormat(nil),
+			Repository:  issue.Repo.APIFormatLegacy(nil),
 			Sender:      doer.APIFormat(),
 		})
 	} else {
@@ -245,7 +245,7 @@ func (issue *Issue) sendLabelUpdatedWebhook(doer *User) {
 			Action:     api.HOOK_ISSUE_LABEL_UPDATED,
 			Index:      issue.Index,
 			Issue:      issue.APIFormat(),
-			Repository: issue.Repo.APIFormat(nil),
+			Repository: issue.Repo.APIFormatLegacy(nil),
 			Sender:     doer.APIFormat(),
 		})
 	}
@@ -350,7 +350,7 @@ func (issue *Issue) ClearLabels(doer *User) (err error) {
 			Action:      api.HOOK_ISSUE_LABEL_CLEARED,
 			Index:       issue.Index,
 			PullRequest: issue.PullRequest.APIFormat(),
-			Repository:  issue.Repo.APIFormat(nil),
+			Repository:  issue.Repo.APIFormatLegacy(nil),
 			Sender:      doer.APIFormat(),
 		})
 	} else {
@@ -358,7 +358,7 @@ func (issue *Issue) ClearLabels(doer *User) (err error) {
 			Action:     api.HOOK_ISSUE_LABEL_CLEARED,
 			Index:      issue.Index,
 			Issue:      issue.APIFormat(),
-			Repository: issue.Repo.APIFormat(nil),
+			Repository: issue.Repo.APIFormatLegacy(nil),
 			Sender:     doer.APIFormat(),
 		})
 	}
@@ -477,7 +477,7 @@ func (issue *Issue) ChangeStatus(doer *User, repo *Repository, isClosed bool) (e
 		apiPullRequest := &api.PullRequestPayload{
 			Index:       issue.Index,
 			PullRequest: issue.PullRequest.APIFormat(),
-			Repository:  repo.APIFormat(nil),
+			Repository:  repo.APIFormatLegacy(nil),
 			Sender:      doer.APIFormat(),
 		}
 		if isClosed {
@@ -490,7 +490,7 @@ func (issue *Issue) ChangeStatus(doer *User, repo *Repository, isClosed bool) (e
 		apiIssues := &api.IssuesPayload{
 			Index:      issue.Index,
 			Issue:      issue.APIFormat(),
-			Repository: repo.APIFormat(nil),
+			Repository: repo.APIFormatLegacy(nil),
 			Sender:     doer.APIFormat(),
 		}
 		if isClosed {
@@ -525,7 +525,7 @@ func (issue *Issue) ChangeTitle(doer *User, title string) (err error) {
 					From: oldTitle,
 				},
 			},
-			Repository: issue.Repo.APIFormat(nil),
+			Repository: issue.Repo.APIFormatLegacy(nil),
 			Sender:     doer.APIFormat(),
 		})
 	} else {
@@ -538,7 +538,7 @@ func (issue *Issue) ChangeTitle(doer *User, title string) (err error) {
 					From: oldTitle,
 				},
 			},
-			Repository: issue.Repo.APIFormat(nil),
+			Repository: issue.Repo.APIFormatLegacy(nil),
 			Sender:     doer.APIFormat(),
 		})
 	}
@@ -567,7 +567,7 @@ func (issue *Issue) ChangeContent(doer *User, content string) (err error) {
 					From: oldContent,
 				},
 			},
-			Repository: issue.Repo.APIFormat(nil),
+			Repository: issue.Repo.APIFormatLegacy(nil),
 			Sender:     doer.APIFormat(),
 		})
 	} else {
@@ -580,7 +580,7 @@ func (issue *Issue) ChangeContent(doer *User, content string) (err error) {
 					From: oldContent,
 				},
 			},
-			Repository: issue.Repo.APIFormat(nil),
+			Repository: issue.Repo.APIFormatLegacy(nil),
 			Sender:     doer.APIFormat(),
 		})
 	}
@@ -610,7 +610,7 @@ func (issue *Issue) ChangeAssignee(doer *User, assigneeID int64) (err error) {
 		apiPullRequest := &api.PullRequestPayload{
 			Index:       issue.Index,
 			PullRequest: issue.PullRequest.APIFormat(),
-			Repository:  issue.Repo.APIFormat(nil),
+			Repository:  issue.Repo.APIFormatLegacy(nil),
 			Sender:      doer.APIFormat(),
 		}
 		if isRemoveAssignee {
@@ -623,7 +623,7 @@ func (issue *Issue) ChangeAssignee(doer *User, assigneeID int64) (err error) {
 		apiIssues := &api.IssuesPayload{
 			Index:      issue.Index,
 			Issue:      issue.APIFormat(),
-			Repository: issue.Repo.APIFormat(nil),
+			Repository: issue.Repo.APIFormatLegacy(nil),
 			Sender:     doer.APIFormat(),
 		}
 		if isRemoveAssignee {
@@ -763,7 +763,7 @@ func NewIssue(repo *Repository, issue *Issue, labelIDs []int64, uuids []string)
 	if err = NotifyWatchers(&Action{
 		ActUserID:    issue.Poster.ID,
 		ActUserName:  issue.Poster.Name,
-		OpType:       ACTION_CREATE_ISSUE,
+		OpType:       ActionCreateIssue,
 		Content:      fmt.Sprintf("%d|%s", issue.Index, issue.Title),
 		RepoID:       repo.ID,
 		RepoUserName: repo.Owner.Name,
@@ -780,7 +780,7 @@ func NewIssue(repo *Repository, issue *Issue, labelIDs []int64, uuids []string)
 		Action:     api.HOOK_ISSUE_OPENED,
 		Index:      issue.Index,
 		Issue:      issue.APIFormat(),
-		Repository: repo.APIFormat(nil),
+		Repository: repo.APIFormatLegacy(nil),
 		Sender:     issue.Poster.APIFormat(),
 	}); err != nil {
 		log.Error("PrepareWebhooks: %v", err)

+ 2 - 2
internal/db/lfs.go

@@ -33,8 +33,8 @@ var LFS LFSStore
 
 // LFSObject is the relation between an LFS object and a repository.
 type LFSObject struct {
-	RepoID    int64           `gorm:"primary_key;auto_increment:false"`
-	OID       lfsutil.OID     `gorm:"primary_key;column:oid"`
+	RepoID    int64           `gorm:"primaryKey;auto_increment:false"`
+	OID       lfsutil.OID     `gorm:"primaryKey;column:oid"`
 	Size      int64           `gorm:"not null"`
 	Storage   lfsutil.Storage `gorm:"not null"`
 	CreatedAt time.Time       `gorm:"not null"`

+ 0 - 1
internal/db/lfs_test.go

@@ -21,7 +21,6 @@ func TestLFS(t *testing.T) {
 	if testing.Short() {
 		t.Skip()
 	}
-
 	t.Parallel()
 
 	tables := []interface{}{new(LFSObject)}

+ 2 - 2
internal/db/login_source_files.go

@@ -33,7 +33,7 @@ type loginSourceFilesStore interface {
 	// Len returns number of login sources.
 	Len() int
 	// List returns a list of login sources filtered by options.
-	List(opts ListLoginSourceOpts) []*LoginSource
+	List(opts ListLoginSourceOptions) []*LoginSource
 	// Update updates in-memory copy of the authentication source.
 	Update(source *LoginSource)
 }
@@ -85,7 +85,7 @@ func (s *loginSourceFiles) Len() int {
 	return len(s.sources)
 }
 
-func (s *loginSourceFiles) List(opts ListLoginSourceOpts) []*LoginSource {
+func (s *loginSourceFiles) List(opts ListLoginSourceOptions) []*LoginSource {
 	s.RLock()
 	defer s.RUnlock()
 

+ 2 - 2
internal/db/login_source_files_test.go

@@ -53,12 +53,12 @@ func TestLoginSourceFiles_List(t *testing.T) {
 	}
 
 	t.Run("list all sources", func(t *testing.T) {
-		sources := store.List(ListLoginSourceOpts{})
+		sources := store.List(ListLoginSourceOptions{})
 		assert.Equal(t, 2, len(sources), "number of sources")
 	})
 
 	t.Run("list only activated sources", func(t *testing.T) {
-		sources := store.List(ListLoginSourceOpts{OnlyActivated: true})
+		sources := store.List(ListLoginSourceOptions{OnlyActivated: true})
 		assert.Equal(t, 1, len(sources), "number of sources")
 		assert.Equal(t, int64(101), sources[0].ID)
 	})

+ 8 - 8
internal/db/login_sources.go

@@ -28,7 +28,7 @@ import (
 type LoginSourcesStore interface {
 	// Create creates a new login source and persist to database. It returns
 	// ErrLoginSourceAlreadyExist when a login source with same name already exists.
-	Create(ctx context.Context, opts CreateLoginSourceOpts) (*LoginSource, error)
+	Create(ctx context.Context, opts CreateLoginSourceOptions) (*LoginSource, error)
 	// Count returns the total number of login sources.
 	Count(ctx context.Context) int64
 	// DeleteByID deletes a login source by given ID. It returns ErrLoginSourceInUse
@@ -38,7 +38,7 @@ type LoginSourcesStore interface {
 	// ErrLoginSourceNotExist when not found.
 	GetByID(ctx context.Context, id int64) (*LoginSource, error)
 	// List returns a list of login sources filtered by options.
-	List(ctx context.Context, opts ListLoginSourceOpts) ([]*LoginSource, error)
+	List(ctx context.Context, opts ListLoginSourceOptions) ([]*LoginSource, error)
 	// ResetNonDefault clears default flag for all the other login sources.
 	ResetNonDefault(ctx context.Context, source *LoginSource) error
 	// Save persists all values of given login source to database or local file. The
@@ -50,7 +50,7 @@ var LoginSources LoginSourcesStore
 
 // LoginSource represents an external way for authorizing users.
 type LoginSource struct {
-	ID        int64
+	ID        int64 `gorm:"primaryKey"`
 	Type      auth.Type
 	Name      string        `xorm:"UNIQUE" gorm:"UNIQUE"`
 	IsActived bool          `xorm:"NOT NULL DEFAULT false" gorm:"NOT NULL"`
@@ -189,7 +189,7 @@ type loginSources struct {
 	files loginSourceFilesStore
 }
 
-type CreateLoginSourceOpts struct {
+type CreateLoginSourceOptions struct {
 	Type      auth.Type
 	Name      string
 	Activated bool
@@ -210,7 +210,7 @@ func (err ErrLoginSourceAlreadyExist) Error() string {
 	return fmt.Sprintf("login source already exists: %v", err.args)
 }
 
-func (db *loginSources) Create(ctx context.Context, opts CreateLoginSourceOpts) (*LoginSource, error) {
+func (db *loginSources) Create(ctx context.Context, opts CreateLoginSourceOptions) (*LoginSource, error) {
 	err := db.WithContext(ctx).Where("name = ?", opts.Name).First(new(LoginSource)).Error
 	if err == nil {
 		return nil, ErrLoginSourceAlreadyExist{args: errutil.Args{"name": opts.Name}}
@@ -274,12 +274,12 @@ func (db *loginSources) GetByID(ctx context.Context, id int64) (*LoginSource, er
 	return source, nil
 }
 
-type ListLoginSourceOpts struct {
+type ListLoginSourceOptions struct {
 	// Whether to only include activated login sources.
 	OnlyActivated bool
 }
 
-func (db *loginSources) List(ctx context.Context, opts ListLoginSourceOpts) ([]*LoginSource, error) {
+func (db *loginSources) List(ctx context.Context, opts ListLoginSourceOptions) ([]*LoginSource, error) {
 	var sources []*LoginSource
 	query := db.WithContext(ctx).Order("id ASC")
 	if opts.OnlyActivated {
@@ -303,7 +303,7 @@ func (db *loginSources) ResetNonDefault(ctx context.Context, dflt *LoginSource)
 		return err
 	}
 
-	for _, source := range db.files.List(ListLoginSourceOpts{}) {
+	for _, source := range db.files.List(ListLoginSourceOptions{}) {
 		if source.File != nil && source.ID != dflt.ID {
 			source.File.SetGeneral("is_default", "false")
 			if err = source.File.Save(); err != nil {

+ 16 - 17
internal/db/login_sources_test.go

@@ -81,7 +81,6 @@ func Test_loginSources(t *testing.T) {
 	if testing.Short() {
 		t.Skip()
 	}
-
 	t.Parallel()
 
 	tables := []interface{}{new(LoginSource), new(User)}
@@ -119,7 +118,7 @@ func loginSourcesCreate(t *testing.T, db *loginSources) {
 
 	// Create first login source with name "GitHub"
 	source, err := db.Create(ctx,
-		CreateLoginSourceOpts{
+		CreateLoginSourceOptions{
 			Type:      auth.GitHub,
 			Name:      "GitHub",
 			Activated: true,
@@ -138,7 +137,7 @@ func loginSourcesCreate(t *testing.T, db *loginSources) {
 	assert.Equal(t, db.NowFunc().Format(time.RFC3339), source.Updated.UTC().Format(time.RFC3339))
 
 	// Try create second login source with same name should fail
-	_, err = db.Create(ctx, CreateLoginSourceOpts{Name: source.Name})
+	_, err = db.Create(ctx, CreateLoginSourceOptions{Name: source.Name})
 	wantErr := ErrLoginSourceAlreadyExist{args: errutil.Args{"name": source.Name}}
 	assert.Equal(t, wantErr, err)
 }
@@ -148,7 +147,7 @@ func loginSourcesCount(t *testing.T, db *loginSources) {
 
 	// Create two login sources, one in database and one as source file.
 	_, err := db.Create(ctx,
-		CreateLoginSourceOpts{
+		CreateLoginSourceOptions{
 			Type:      auth.GitHub,
 			Name:      "GitHub",
 			Activated: true,
@@ -172,7 +171,7 @@ func loginSourcesDeleteByID(t *testing.T, db *loginSources) {
 
 	t.Run("delete but in used", func(t *testing.T) {
 		source, err := db.Create(ctx,
-			CreateLoginSourceOpts{
+			CreateLoginSourceOptions{
 				Type:      auth.GitHub,
 				Name:      "GitHub",
 				Activated: true,
@@ -186,7 +185,7 @@ func loginSourcesDeleteByID(t *testing.T, db *loginSources) {
 
 		// Create a user that uses this login source
 		_, err = (&users{DB: db.DB}).Create(ctx, "alice", "",
-			CreateUserOpts{
+			CreateUserOptions{
 				LoginSource: source.ID,
 			},
 		)
@@ -206,7 +205,7 @@ func loginSourcesDeleteByID(t *testing.T, db *loginSources) {
 
 	// Create a login source with name "GitHub2"
 	source, err := db.Create(ctx,
-		CreateLoginSourceOpts{
+		CreateLoginSourceOptions{
 			Type:      auth.GitHub,
 			Name:      "GitHub2",
 			Activated: true,
@@ -254,7 +253,7 @@ func loginSourcesGetByID(t *testing.T, db *loginSources) {
 
 	// Create a login source with name "GitHub"
 	source, err := db.Create(ctx,
-		CreateLoginSourceOpts{
+		CreateLoginSourceOptions{
 			Type:      auth.GitHub,
 			Name:      "GitHub",
 			Activated: true,
@@ -278,7 +277,7 @@ func loginSourcesList(t *testing.T, db *loginSources) {
 	ctx := context.Background()
 
 	mock := NewMockLoginSourceFilesStore()
-	mock.ListFunc.SetDefaultHook(func(opts ListLoginSourceOpts) []*LoginSource {
+	mock.ListFunc.SetDefaultHook(func(opts ListLoginSourceOptions) []*LoginSource {
 		if opts.OnlyActivated {
 			return []*LoginSource{
 				{ID: 1},
@@ -293,7 +292,7 @@ func loginSourcesList(t *testing.T, db *loginSources) {
 
 	// Create two login sources in database, one activated and the other one not
 	_, err := db.Create(ctx,
-		CreateLoginSourceOpts{
+		CreateLoginSourceOptions{
 			Type: auth.PAM,
 			Name: "PAM",
 			Config: &pam.Config{
@@ -303,7 +302,7 @@ func loginSourcesList(t *testing.T, db *loginSources) {
 	)
 	require.NoError(t, err)
 	_, err = db.Create(ctx,
-		CreateLoginSourceOpts{
+		CreateLoginSourceOptions{
 			Type:      auth.GitHub,
 			Name:      "GitHub",
 			Activated: true,
@@ -315,12 +314,12 @@ func loginSourcesList(t *testing.T, db *loginSources) {
 	require.NoError(t, err)
 
 	// List all login sources
-	sources, err := db.List(ctx, ListLoginSourceOpts{})
+	sources, err := db.List(ctx, ListLoginSourceOptions{})
 	require.NoError(t, err)
 	assert.Equal(t, 4, len(sources), "number of sources")
 
 	// Only list activated login sources
-	sources, err = db.List(ctx, ListLoginSourceOpts{OnlyActivated: true})
+	sources, err = db.List(ctx, ListLoginSourceOptions{OnlyActivated: true})
 	require.NoError(t, err)
 	assert.Equal(t, 2, len(sources), "number of sources")
 }
@@ -329,7 +328,7 @@ func loginSourcesResetNonDefault(t *testing.T, db *loginSources) {
 	ctx := context.Background()
 
 	mock := NewMockLoginSourceFilesStore()
-	mock.ListFunc.SetDefaultHook(func(opts ListLoginSourceOpts) []*LoginSource {
+	mock.ListFunc.SetDefaultHook(func(opts ListLoginSourceOptions) []*LoginSource {
 		mockFile := NewMockLoginSourceFileStore()
 		mockFile.SetGeneralFunc.SetDefaultHook(func(name, value string) {
 			assert.Equal(t, "is_default", name)
@@ -345,7 +344,7 @@ func loginSourcesResetNonDefault(t *testing.T, db *loginSources) {
 
 	// Create two login sources both have default on
 	source1, err := db.Create(ctx,
-		CreateLoginSourceOpts{
+		CreateLoginSourceOptions{
 			Type:    auth.PAM,
 			Name:    "PAM",
 			Default: true,
@@ -356,7 +355,7 @@ func loginSourcesResetNonDefault(t *testing.T, db *loginSources) {
 	)
 	require.NoError(t, err)
 	source2, err := db.Create(ctx,
-		CreateLoginSourceOpts{
+		CreateLoginSourceOptions{
 			Type:      auth.GitHub,
 			Name:      "GitHub",
 			Activated: true,
@@ -388,7 +387,7 @@ func loginSourcesSave(t *testing.T, db *loginSources) {
 	t.Run("save to database", func(t *testing.T) {
 		// Create a login source with name "GitHub"
 		source, err := db.Create(ctx,
-			CreateLoginSourceOpts{
+			CreateLoginSourceOptions{
 				Type:      auth.GitHub,
 				Name:      "GitHub",
 				Activated: true,

+ 2 - 0
internal/db/migrations/migrations.go

@@ -54,6 +54,8 @@ var migrations = []Migration{
 
 	// v19 -> v20:v0.13.0
 	NewMigration("migrate access tokens to store SHA56", migrateAccessTokenToSHA256),
+	// v20 -> v21:v0.13.0
+	NewMigration("add index to action.user_id", addIndexToActionUserID),
 }
 
 // Migrate migrates the database schema and/or data to the current version.

+ 19 - 0
internal/db/migrations/v21.go

@@ -0,0 +1,19 @@
+// Copyright 2022 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package migrations
+
+import (
+	"gorm.io/gorm"
+)
+
+func addIndexToActionUserID(db *gorm.DB) error {
+	type action struct {
+		UserID string `gorm:"index"`
+	}
+	if db.Migrator().HasIndex(&action{}, "UserID") {
+		return nil
+	}
+	return db.Migrator().CreateIndex(&action{}, "UserID")
+}

+ 82 - 0
internal/db/migrations/v21_test.go

@@ -0,0 +1,82 @@
+// Copyright 2022 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package migrations
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"gogs.io/gogs/internal/dbtest"
+)
+
+type actionPreV21 struct {
+	ID           int64 `gorm:"primaryKey"`
+	UserID       int64
+	OpType       int
+	ActUserID    int64
+	ActUserName  string
+	RepoID       int64 `gorm:"index"`
+	RepoUserName string
+	RepoName     string
+	RefName      string
+	IsPrivate    bool `gorm:"not null;default:FALSE"`
+	Content      string
+	CreatedUnix  int64
+}
+
+func (*actionPreV21) TableName() string {
+	return "action"
+}
+
+type actionV21 struct {
+	ID           int64 `gorm:"primaryKey"`
+	UserID       int64 `gorm:"index"`
+	OpType       int
+	ActUserID    int64
+	ActUserName  string
+	RepoID       int64 `gorm:"index"`
+	RepoUserName string
+	RepoName     string
+	RefName      string
+	IsPrivate    bool `gorm:"not null;default:FALSE"`
+	Content      string
+	CreatedUnix  int64
+}
+
+func (*actionV21) TableName() string {
+	return "action"
+}
+
+func TestAddIndexToActionUserID(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+	t.Parallel()
+
+	db := dbtest.NewDB(t, "addIndexToActionUserID", new(actionPreV21))
+	err := db.Create(
+		&actionPreV21{
+			ID:           1,
+			UserID:       1,
+			OpType:       1,
+			ActUserID:    1,
+			ActUserName:  "alice",
+			RepoID:       1,
+			RepoUserName: "alice",
+			RepoName:     "example",
+			RefName:      "main",
+			IsPrivate:    false,
+			CreatedUnix:  db.NowFunc().Unix(),
+		},
+	).Error
+	require.NoError(t, err)
+	assert.False(t, db.Migrator().HasIndex(&actionV21{}, "UserID"))
+
+	err = addIndexToActionUserID(db)
+	require.NoError(t, err)
+	assert.True(t, db.Migrator().HasIndex(&actionV21{}, "UserID"))
+}

+ 2 - 2
internal/db/milestone.go

@@ -363,7 +363,7 @@ func ChangeMilestoneAssign(doer *User, issue *Issue, oldMilestoneID int64) (err
 			Action:      hookAction,
 			Index:       issue.Index,
 			PullRequest: issue.PullRequest.APIFormat(),
-			Repository:  issue.Repo.APIFormat(nil),
+			Repository:  issue.Repo.APIFormatLegacy(nil),
 			Sender:      doer.APIFormat(),
 		})
 	} else {
@@ -371,7 +371,7 @@ func ChangeMilestoneAssign(doer *User, issue *Issue, oldMilestoneID int64) (err
 			Action:     hookAction,
 			Index:      issue.Index,
 			Issue:      issue.APIFormat(),
-			Repository: issue.Repo.APIFormat(nil),
+			Repository: issue.Repo.APIFormatLegacy(nil),
 			Sender:     doer.APIFormat(),
 		})
 	}

+ 19 - 11
internal/db/mirror.go

@@ -5,6 +5,7 @@
 package db
 
 import (
+	"context"
 	"fmt"
 	"net/url"
 	"strings"
@@ -314,6 +315,8 @@ func MirrorUpdate() {
 // SyncMirrors checks and syncs mirrors.
 // TODO: sync more mirrors at same time.
 func SyncMirrors() {
+	ctx := context.Background()
+
 	// Start listening on new sync requests.
 	for repoID := range MirrorQueue.Queue() {
 		log.Trace("SyncMirrors [repo_id: %s]", repoID)
@@ -358,8 +361,8 @@ func SyncMirrors() {
 
 			// Delete reference
 			if result.newCommitID == gitShortEmptyID {
-				if err = MirrorSyncDeleteAction(m.Repo, result.refName); err != nil {
-					log.Error("MirrorSyncDeleteAction [repo_id: %d]: %v", m.RepoID, err)
+				if err = Actions.MirrorSyncDelete(ctx, m.Repo.MustOwner(), m.Repo, result.refName); err != nil {
+					log.Error("Failed to create action for mirror sync delete [repo_id: %d]: %v", m.RepoID, err)
 				}
 				continue
 			}
@@ -367,8 +370,8 @@ func SyncMirrors() {
 			// New reference
 			isNewRef := false
 			if result.oldCommitID == gitShortEmptyID {
-				if err = MirrorSyncCreateAction(m.Repo, result.refName); err != nil {
-					log.Error("MirrorSyncCreateAction [repo_id: %d]: %v", m.RepoID, err)
+				if err = Actions.MirrorSyncCreate(ctx, m.Repo.MustOwner(), m.Repo, result.refName); err != nil {
+					log.Error("Failed to create action for mirror sync create [repo_id: %d]: %v", m.RepoID, err)
 					continue
 				}
 				isNewRef = true
@@ -416,13 +419,18 @@ func SyncMirrors() {
 				newCommitID = refNewCommit.ID.String()
 			}
 
-			if err = MirrorSyncPushAction(m.Repo, MirrorSyncPushActionOptions{
-				RefName:     result.refName,
-				OldCommitID: oldCommitID,
-				NewCommitID: newCommitID,
-				Commits:     CommitsToPushCommits(commits),
-			}); err != nil {
-				log.Error("MirrorSyncPushAction [repo_id: %d]: %v", m.RepoID, err)
+			err = Actions.MirrorSyncPush(ctx,
+				MirrorSyncPushOptions{
+					Owner:       m.Repo.MustOwner(),
+					Repo:        m.Repo,
+					RefName:     result.refName,
+					OldCommitID: oldCommitID,
+					NewCommitID: newCommitID,
+					Commits:     CommitsToPushCommits(commits),
+				},
+			)
+			if err != nil {
+				log.Error("Failed to create action for mirror sync push [repo_id: %d]: %v", m.RepoID, err)
 				continue
 			}
 		}

+ 34 - 34
internal/db/mocks_test.go

@@ -51,7 +51,7 @@ func NewMockLoginSourcesStore() *MockLoginSourcesStore {
 			},
 		},
 		CreateFunc: &LoginSourcesStoreCreateFunc{
-			defaultHook: func(context.Context, CreateLoginSourceOpts) (r0 *LoginSource, r1 error) {
+			defaultHook: func(context.Context, CreateLoginSourceOptions) (r0 *LoginSource, r1 error) {
 				return
 			},
 		},
@@ -66,7 +66,7 @@ func NewMockLoginSourcesStore() *MockLoginSourcesStore {
 			},
 		},
 		ListFunc: &LoginSourcesStoreListFunc{
-			defaultHook: func(context.Context, ListLoginSourceOpts) (r0 []*LoginSource, r1 error) {
+			defaultHook: func(context.Context, ListLoginSourceOptions) (r0 []*LoginSource, r1 error) {
 				return
 			},
 		},
@@ -94,7 +94,7 @@ func NewStrictMockLoginSourcesStore() *MockLoginSourcesStore {
 			},
 		},
 		CreateFunc: &LoginSourcesStoreCreateFunc{
-			defaultHook: func(context.Context, CreateLoginSourceOpts) (*LoginSource, error) {
+			defaultHook: func(context.Context, CreateLoginSourceOptions) (*LoginSource, error) {
 				panic("unexpected invocation of MockLoginSourcesStore.Create")
 			},
 		},
@@ -109,7 +109,7 @@ func NewStrictMockLoginSourcesStore() *MockLoginSourcesStore {
 			},
 		},
 		ListFunc: &LoginSourcesStoreListFunc{
-			defaultHook: func(context.Context, ListLoginSourceOpts) ([]*LoginSource, error) {
+			defaultHook: func(context.Context, ListLoginSourceOptions) ([]*LoginSource, error) {
 				panic("unexpected invocation of MockLoginSourcesStore.List")
 			},
 		},
@@ -260,15 +260,15 @@ func (c LoginSourcesStoreCountFuncCall) Results() []interface{} {
 // LoginSourcesStoreCreateFunc describes the behavior when the Create method
 // of the parent MockLoginSourcesStore instance is invoked.
 type LoginSourcesStoreCreateFunc struct {
-	defaultHook func(context.Context, CreateLoginSourceOpts) (*LoginSource, error)
-	hooks       []func(context.Context, CreateLoginSourceOpts) (*LoginSource, error)
+	defaultHook func(context.Context, CreateLoginSourceOptions) (*LoginSource, error)
+	hooks       []func(context.Context, CreateLoginSourceOptions) (*LoginSource, error)
 	history     []LoginSourcesStoreCreateFuncCall
 	mutex       sync.Mutex
 }
 
 // Create delegates to the next hook function in the queue and stores the
 // parameter and result values of this invocation.
-func (m *MockLoginSourcesStore) Create(v0 context.Context, v1 CreateLoginSourceOpts) (*LoginSource, error) {
+func (m *MockLoginSourcesStore) Create(v0 context.Context, v1 CreateLoginSourceOptions) (*LoginSource, error) {
 	r0, r1 := m.CreateFunc.nextHook()(v0, v1)
 	m.CreateFunc.appendCall(LoginSourcesStoreCreateFuncCall{v0, v1, r0, r1})
 	return r0, r1
@@ -277,7 +277,7 @@ func (m *MockLoginSourcesStore) Create(v0 context.Context, v1 CreateLoginSourceO
 // SetDefaultHook sets function that is called when the Create method of the
 // parent MockLoginSourcesStore instance is invoked and the hook queue is
 // empty.
-func (f *LoginSourcesStoreCreateFunc) SetDefaultHook(hook func(context.Context, CreateLoginSourceOpts) (*LoginSource, error)) {
+func (f *LoginSourcesStoreCreateFunc) SetDefaultHook(hook func(context.Context, CreateLoginSourceOptions) (*LoginSource, error)) {
 	f.defaultHook = hook
 }
 
@@ -285,7 +285,7 @@ func (f *LoginSourcesStoreCreateFunc) SetDefaultHook(hook func(context.Context,
 // Create method of the parent MockLoginSourcesStore instance invokes the
 // hook at the front of the queue and discards it. After the queue is empty,
 // the default hook function is invoked for any future action.
-func (f *LoginSourcesStoreCreateFunc) PushHook(hook func(context.Context, CreateLoginSourceOpts) (*LoginSource, error)) {
+func (f *LoginSourcesStoreCreateFunc) PushHook(hook func(context.Context, CreateLoginSourceOptions) (*LoginSource, error)) {
 	f.mutex.Lock()
 	f.hooks = append(f.hooks, hook)
 	f.mutex.Unlock()
@@ -294,19 +294,19 @@ func (f *LoginSourcesStoreCreateFunc) PushHook(hook func(context.Context, Create
 // SetDefaultReturn calls SetDefaultHook with a function that returns the
 // given values.
 func (f *LoginSourcesStoreCreateFunc) SetDefaultReturn(r0 *LoginSource, r1 error) {
-	f.SetDefaultHook(func(context.Context, CreateLoginSourceOpts) (*LoginSource, error) {
+	f.SetDefaultHook(func(context.Context, CreateLoginSourceOptions) (*LoginSource, error) {
 		return r0, r1
 	})
 }
 
 // PushReturn calls PushHook with a function that returns the given values.
 func (f *LoginSourcesStoreCreateFunc) PushReturn(r0 *LoginSource, r1 error) {
-	f.PushHook(func(context.Context, CreateLoginSourceOpts) (*LoginSource, error) {
+	f.PushHook(func(context.Context, CreateLoginSourceOptions) (*LoginSource, error) {
 		return r0, r1
 	})
 }
 
-func (f *LoginSourcesStoreCreateFunc) nextHook() func(context.Context, CreateLoginSourceOpts) (*LoginSource, error) {
+func (f *LoginSourcesStoreCreateFunc) nextHook() func(context.Context, CreateLoginSourceOptions) (*LoginSource, error) {
 	f.mutex.Lock()
 	defer f.mutex.Unlock()
 
@@ -344,7 +344,7 @@ type LoginSourcesStoreCreateFuncCall struct {
 	Arg0 context.Context
 	// Arg1 is the value of the 2nd argument passed to this method
 	// invocation.
-	Arg1 CreateLoginSourceOpts
+	Arg1 CreateLoginSourceOptions
 	// Result0 is the value of the 1st result returned from this method
 	// invocation.
 	Result0 *LoginSource
@@ -582,15 +582,15 @@ func (c LoginSourcesStoreGetByIDFuncCall) Results() []interface{} {
 // LoginSourcesStoreListFunc describes the behavior when the List method of
 // the parent MockLoginSourcesStore instance is invoked.
 type LoginSourcesStoreListFunc struct {
-	defaultHook func(context.Context, ListLoginSourceOpts) ([]*LoginSource, error)
-	hooks       []func(context.Context, ListLoginSourceOpts) ([]*LoginSource, error)
+	defaultHook func(context.Context, ListLoginSourceOptions) ([]*LoginSource, error)
+	hooks       []func(context.Context, ListLoginSourceOptions) ([]*LoginSource, error)
 	history     []LoginSourcesStoreListFuncCall
 	mutex       sync.Mutex
 }
 
 // List delegates to the next hook function in the queue and stores the
 // parameter and result values of this invocation.
-func (m *MockLoginSourcesStore) List(v0 context.Context, v1 ListLoginSourceOpts) ([]*LoginSource, error) {
+func (m *MockLoginSourcesStore) List(v0 context.Context, v1 ListLoginSourceOptions) ([]*LoginSource, error) {
 	r0, r1 := m.ListFunc.nextHook()(v0, v1)
 	m.ListFunc.appendCall(LoginSourcesStoreListFuncCall{v0, v1, r0, r1})
 	return r0, r1
@@ -599,7 +599,7 @@ func (m *MockLoginSourcesStore) List(v0 context.Context, v1 ListLoginSourceOpts)
 // SetDefaultHook sets function that is called when the List method of the
 // parent MockLoginSourcesStore instance is invoked and the hook queue is
 // empty.
-func (f *LoginSourcesStoreListFunc) SetDefaultHook(hook func(context.Context, ListLoginSourceOpts) ([]*LoginSource, error)) {
+func (f *LoginSourcesStoreListFunc) SetDefaultHook(hook func(context.Context, ListLoginSourceOptions) ([]*LoginSource, error)) {
 	f.defaultHook = hook
 }
 
@@ -607,7 +607,7 @@ func (f *LoginSourcesStoreListFunc) SetDefaultHook(hook func(context.Context, Li
 // List method of the parent MockLoginSourcesStore instance invokes the hook
 // at the front of the queue and discards it. After the queue is empty, the
 // default hook function is invoked for any future action.
-func (f *LoginSourcesStoreListFunc) PushHook(hook func(context.Context, ListLoginSourceOpts) ([]*LoginSource, error)) {
+func (f *LoginSourcesStoreListFunc) PushHook(hook func(context.Context, ListLoginSourceOptions) ([]*LoginSource, error)) {
 	f.mutex.Lock()
 	f.hooks = append(f.hooks, hook)
 	f.mutex.Unlock()
@@ -616,19 +616,19 @@ func (f *LoginSourcesStoreListFunc) PushHook(hook func(context.Context, ListLogi
 // SetDefaultReturn calls SetDefaultHook with a function that returns the
 // given values.
 func (f *LoginSourcesStoreListFunc) SetDefaultReturn(r0 []*LoginSource, r1 error) {
-	f.SetDefaultHook(func(context.Context, ListLoginSourceOpts) ([]*LoginSource, error) {
+	f.SetDefaultHook(func(context.Context, ListLoginSourceOptions) ([]*LoginSource, error) {
 		return r0, r1
 	})
 }
 
 // PushReturn calls PushHook with a function that returns the given values.
 func (f *LoginSourcesStoreListFunc) PushReturn(r0 []*LoginSource, r1 error) {
-	f.PushHook(func(context.Context, ListLoginSourceOpts) ([]*LoginSource, error) {
+	f.PushHook(func(context.Context, ListLoginSourceOptions) ([]*LoginSource, error) {
 		return r0, r1
 	})
 }
 
-func (f *LoginSourcesStoreListFunc) nextHook() func(context.Context, ListLoginSourceOpts) ([]*LoginSource, error) {
+func (f *LoginSourcesStoreListFunc) nextHook() func(context.Context, ListLoginSourceOptions) ([]*LoginSource, error) {
 	f.mutex.Lock()
 	defer f.mutex.Unlock()
 
@@ -666,7 +666,7 @@ type LoginSourcesStoreListFuncCall struct {
 	Arg0 context.Context
 	// Arg1 is the value of the 2nd argument passed to this method
 	// invocation.
-	Arg1 ListLoginSourceOpts
+	Arg1 ListLoginSourceOptions
 	// Result0 is the value of the 1st result returned from this method
 	// invocation.
 	Result0 []*LoginSource
@@ -1328,7 +1328,7 @@ func NewMockLoginSourceFilesStore() *MockLoginSourceFilesStore {
 			},
 		},
 		ListFunc: &LoginSourceFilesStoreListFunc{
-			defaultHook: func(ListLoginSourceOpts) (r0 []*LoginSource) {
+			defaultHook: func(ListLoginSourceOptions) (r0 []*LoginSource) {
 				return
 			},
 		},
@@ -1356,7 +1356,7 @@ func NewStrictMockLoginSourceFilesStore() *MockLoginSourceFilesStore {
 			},
 		},
 		ListFunc: &LoginSourceFilesStoreListFunc{
-			defaultHook: func(ListLoginSourceOpts) []*LoginSource {
+			defaultHook: func(ListLoginSourceOptions) []*LoginSource {
 				panic("unexpected invocation of MockLoginSourceFilesStore.List")
 			},
 		},
@@ -1374,7 +1374,7 @@ func NewStrictMockLoginSourceFilesStore() *MockLoginSourceFilesStore {
 type surrogateMockLoginSourceFilesStore interface {
 	GetByID(int64) (*LoginSource, error)
 	Len() int
-	List(ListLoginSourceOpts) []*LoginSource
+	List(ListLoginSourceOptions) []*LoginSource
 	Update(*LoginSource)
 }
 
@@ -1605,15 +1605,15 @@ func (c LoginSourceFilesStoreLenFuncCall) Results() []interface{} {
 // LoginSourceFilesStoreListFunc describes the behavior when the List method
 // of the parent MockLoginSourceFilesStore instance is invoked.
 type LoginSourceFilesStoreListFunc struct {
-	defaultHook func(ListLoginSourceOpts) []*LoginSource
-	hooks       []func(ListLoginSourceOpts) []*LoginSource
+	defaultHook func(ListLoginSourceOptions) []*LoginSource
+	hooks       []func(ListLoginSourceOptions) []*LoginSource
 	history     []LoginSourceFilesStoreListFuncCall
 	mutex       sync.Mutex
 }
 
 // List delegates to the next hook function in the queue and stores the
 // parameter and result values of this invocation.
-func (m *MockLoginSourceFilesStore) List(v0 ListLoginSourceOpts) []*LoginSource {
+func (m *MockLoginSourceFilesStore) List(v0 ListLoginSourceOptions) []*LoginSource {
 	r0 := m.ListFunc.nextHook()(v0)
 	m.ListFunc.appendCall(LoginSourceFilesStoreListFuncCall{v0, r0})
 	return r0
@@ -1622,7 +1622,7 @@ func (m *MockLoginSourceFilesStore) List(v0 ListLoginSourceOpts) []*LoginSource
 // SetDefaultHook sets function that is called when the List method of the
 // parent MockLoginSourceFilesStore instance is invoked and the hook queue
 // is empty.
-func (f *LoginSourceFilesStoreListFunc) SetDefaultHook(hook func(ListLoginSourceOpts) []*LoginSource) {
+func (f *LoginSourceFilesStoreListFunc) SetDefaultHook(hook func(ListLoginSourceOptions) []*LoginSource) {
 	f.defaultHook = hook
 }
 
@@ -1630,7 +1630,7 @@ func (f *LoginSourceFilesStoreListFunc) SetDefaultHook(hook func(ListLoginSource
 // List method of the parent MockLoginSourceFilesStore instance invokes the
 // hook at the front of the queue and discards it. After the queue is empty,
 // the default hook function is invoked for any future action.
-func (f *LoginSourceFilesStoreListFunc) PushHook(hook func(ListLoginSourceOpts) []*LoginSource) {
+func (f *LoginSourceFilesStoreListFunc) PushHook(hook func(ListLoginSourceOptions) []*LoginSource) {
 	f.mutex.Lock()
 	f.hooks = append(f.hooks, hook)
 	f.mutex.Unlock()
@@ -1639,19 +1639,19 @@ func (f *LoginSourceFilesStoreListFunc) PushHook(hook func(ListLoginSourceOpts)
 // SetDefaultReturn calls SetDefaultHook with a function that returns the
 // given values.
 func (f *LoginSourceFilesStoreListFunc) SetDefaultReturn(r0 []*LoginSource) {
-	f.SetDefaultHook(func(ListLoginSourceOpts) []*LoginSource {
+	f.SetDefaultHook(func(ListLoginSourceOptions) []*LoginSource {
 		return r0
 	})
 }
 
 // PushReturn calls PushHook with a function that returns the given values.
 func (f *LoginSourceFilesStoreListFunc) PushReturn(r0 []*LoginSource) {
-	f.PushHook(func(ListLoginSourceOpts) []*LoginSource {
+	f.PushHook(func(ListLoginSourceOptions) []*LoginSource {
 		return r0
 	})
 }
 
-func (f *LoginSourceFilesStoreListFunc) nextHook() func(ListLoginSourceOpts) []*LoginSource {
+func (f *LoginSourceFilesStoreListFunc) nextHook() func(ListLoginSourceOptions) []*LoginSource {
 	f.mutex.Lock()
 	defer f.mutex.Unlock()
 
@@ -1686,7 +1686,7 @@ func (f *LoginSourceFilesStoreListFunc) History() []LoginSourceFilesStoreListFun
 type LoginSourceFilesStoreListFuncCall struct {
 	// Arg0 is the value of the 1st argument passed to this method
 	// invocation.
-	Arg0 ListLoginSourceOpts
+	Arg0 ListLoginSourceOptions
 	// Result0 is the value of the 1st result returned from this method
 	// invocation.
 	Result0 []*LoginSource

+ 1 - 1
internal/db/models.go

@@ -52,7 +52,7 @@ func init() {
 	legacyTables = append(legacyTables,
 		new(User), new(PublicKey), new(TwoFactor), new(TwoFactorRecoveryCode),
 		new(Repository), new(DeployKey), new(Collaboration), new(Upload),
-		new(Watch), new(Star), new(Follow), new(Action),
+		new(Watch), new(Star), new(Follow),
 		new(Issue), new(PullRequest), new(Comment), new(Attachment), new(IssueUser),
 		new(Label), new(IssueLabel), new(Milestone),
 		new(Mirror), new(Release), new(Webhook), new(HookTask),

+ 1 - 1
internal/db/perms.go

@@ -32,7 +32,7 @@ var Perms PermsStore
 // In case of an organization repository, the members of the owners team are in
 // this table.
 type Access struct {
-	ID     int64
+	ID     int64      `gorm:"primaryKey"`
 	UserID int64      `xorm:"UNIQUE(s)" gorm:"uniqueIndex:access_user_repo_unique;not null"`
 	RepoID int64      `xorm:"UNIQUE(s)" gorm:"uniqueIndex:access_user_repo_unique;not null"`
 	Mode   AccessMode `gorm:"not null"`

+ 0 - 1
internal/db/perms_test.go

@@ -18,7 +18,6 @@ func TestPerms(t *testing.T) {
 	if testing.Short() {
 		t.Skip()
 	}
-
 	t.Parallel()
 
 	tables := []interface{}{new(Access)}

+ 13 - 10
internal/db/pull.go

@@ -5,6 +5,7 @@
 package db
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -138,7 +139,7 @@ func (pr *PullRequest) APIFormat() *api.PullRequest {
 			Name: "deleted",
 		}
 	} else {
-		apiHeadRepo = pr.HeadRepo.APIFormat(nil)
+		apiHeadRepo = pr.HeadRepo.APIFormatLegacy(nil)
 	}
 
 	apiIssue := pr.Issue.APIFormat()
@@ -156,7 +157,7 @@ func (pr *PullRequest) APIFormat() *api.PullRequest {
 		HeadBranch: pr.HeadBranch,
 		HeadRepo:   apiHeadRepo,
 		BaseBranch: pr.BaseBranch,
-		BaseRepo:   pr.BaseRepo.APIFormat(nil),
+		BaseRepo:   pr.BaseRepo.APIFormatLegacy(nil),
 		HTMLURL:    pr.Issue.HTMLURL(),
 		HasMerged:  pr.HasMerged,
 	}
@@ -195,6 +196,8 @@ const (
 // Merge merges pull request to base repository.
 // FIXME: add repoWorkingPull make sure two merges does not happen at same time.
 func (pr *PullRequest) Merge(doer *User, baseGitRepo *git.Repository, mergeStyle MergeStyle, commitDescription string) (err error) {
+	ctx := context.TODO()
+
 	defer func() {
 		go HookQueue.Add(pr.BaseRepo.ID)
 		go AddTestPullRequestTask(doer, pr.BaseRepo.ID, pr.BaseBranch, false)
@@ -334,8 +337,8 @@ func (pr *PullRequest) Merge(doer *User, baseGitRepo *git.Repository, mergeStyle
 		return fmt.Errorf("Commit: %v", err)
 	}
 
-	if err = MergePullRequestAction(doer, pr.Issue.Repo, pr.Issue); err != nil {
-		log.Error("MergePullRequestAction [%d]: %v", pr.ID, err)
+	if err = Actions.MergePullRequest(ctx, doer, pr.Issue.Repo.Owner, pr.Issue.Repo, pr.Issue); err != nil {
+		log.Error("Failed to create action for merge pull request, pull_request_id: %d, error: %v", pr.ID, err)
 	}
 
 	// Reload pull request information.
@@ -347,7 +350,7 @@ func (pr *PullRequest) Merge(doer *User, baseGitRepo *git.Repository, mergeStyle
 		Action:      api.HOOK_ISSUE_CLOSED,
 		Index:       pr.Index,
 		PullRequest: pr.APIFormat(),
-		Repository:  pr.Issue.Repo.APIFormat(nil),
+		Repository:  pr.Issue.Repo.APIFormatLegacy(nil),
 		Sender:      doer.APIFormat(),
 	}); err != nil {
 		log.Error("PrepareWebhooks: %v", err)
@@ -372,7 +375,7 @@ func (pr *PullRequest) Merge(doer *User, baseGitRepo *git.Repository, mergeStyle
 		commits = append([]*git.Commit{mergeCommit}, commits...)
 	}
 
-	pcs, err := CommitsToPushCommits(commits).ToApiPayloadCommits(pr.BaseRepo.RepoPath(), pr.BaseRepo.HTMLURL())
+	pcs, err := CommitsToPushCommits(commits).APIFormat(ctx, Users, pr.BaseRepo.RepoPath(), pr.BaseRepo.HTMLURL())
 	if err != nil {
 		log.Error("Failed to convert to API payload commits: %v", err)
 		return nil
@@ -384,7 +387,7 @@ func (pr *PullRequest) Merge(doer *User, baseGitRepo *git.Repository, mergeStyle
 		After:      mergeCommit.ID.String(),
 		CompareURL: conf.Server.ExternalURL + pr.BaseRepo.ComposeCompareURL(pr.MergeBase, pr.MergedCommitID),
 		Commits:    pcs,
-		Repo:       pr.BaseRepo.APIFormat(nil),
+		Repo:       pr.BaseRepo.APIFormatLegacy(nil),
 		Pusher:     pr.HeadRepo.MustOwner().APIFormat(),
 		Sender:     doer.APIFormat(),
 	}
@@ -487,7 +490,7 @@ func NewPullRequest(repo *Repository, pull *Issue, labelIDs []int64, uuids []str
 	if err = NotifyWatchers(&Action{
 		ActUserID:    pull.Poster.ID,
 		ActUserName:  pull.Poster.Name,
-		OpType:       ACTION_CREATE_PULL_REQUEST,
+		OpType:       ActionCreatePullRequest,
 		Content:      fmt.Sprintf("%d|%s", pull.Index, pull.Title),
 		RepoID:       repo.ID,
 		RepoUserName: repo.Owner.Name,
@@ -506,7 +509,7 @@ func NewPullRequest(repo *Repository, pull *Issue, labelIDs []int64, uuids []str
 		Action:      api.HOOK_ISSUE_OPENED,
 		Index:       pull.Index,
 		PullRequest: pr.APIFormat(),
-		Repository:  repo.APIFormat(nil),
+		Repository:  repo.APIFormatLegacy(nil),
 		Sender:      pull.Poster.APIFormat(),
 	}); err != nil {
 		log.Error("PrepareWebhooks: %v", err)
@@ -798,7 +801,7 @@ func AddTestPullRequestTask(doer *User, repoID int64, branch string, isSync bool
 					Action:      api.HOOK_ISSUE_SYNCHRONIZED,
 					Index:       pr.Issue.Index,
 					PullRequest: pr.Issue.PullRequest.APIFormat(),
-					Repository:  pr.Issue.Repo.APIFormat(nil),
+					Repository:  pr.Issue.Repo.APIFormatLegacy(nil),
 					Sender:      doer.APIFormat(),
 				}); err != nil {
 					log.Error("PrepareWebhooks [pull_id: %v]: %v", pr.ID, err)

+ 1 - 1
internal/db/release.go

@@ -153,7 +153,7 @@ func (r *Release) preparePublishWebhooks() {
 	if err := PrepareWebhooks(r.Repo, HOOK_EVENT_RELEASE, &api.ReleasePayload{
 		Action:     api.HOOK_RELEASE_PUBLISHED,
 		Release:    r.APIFormat(),
-		Repository: r.Repo.APIFormat(nil),
+		Repository: r.Repo.APIFormatLegacy(nil),
 		Sender:     r.Publisher.APIFormat(),
 	}); err != nil {
 		log.Error("PrepareWebhooks: %v", err)

+ 104 - 47
internal/db/repo.go

@@ -21,6 +21,7 @@ import (
 	"time"
 
 	"github.com/nfnt/resize"
+	"github.com/pkg/errors"
 	"github.com/unknwon/cae/zip"
 	"github.com/unknwon/com"
 	"gopkg.in/ini.v1"
@@ -33,11 +34,12 @@ import (
 	embedConf "gogs.io/gogs/conf"
 	"gogs.io/gogs/internal/avatar"
 	"gogs.io/gogs/internal/conf"
-	"gogs.io/gogs/internal/db/errors"
+	dberrors "gogs.io/gogs/internal/db/errors"
 	"gogs.io/gogs/internal/errutil"
 	"gogs.io/gogs/internal/markup"
 	"gogs.io/gogs/internal/osutil"
 	"gogs.io/gogs/internal/process"
+	"gogs.io/gogs/internal/repoutil"
 	"gogs.io/gogs/internal/semverutil"
 	"gogs.io/gogs/internal/sync"
 )
@@ -150,15 +152,15 @@ func NewRepoContext() {
 
 // Repository contains information of a repository.
 type Repository struct {
-	ID              int64
-	OwnerID         int64  `xorm:"UNIQUE(s)" gorm:"UNIQUE_INDEX:s"`
+	ID              int64  `gorm:"primaryKey"`
+	OwnerID         int64  `xorm:"UNIQUE(s)" gorm:"uniqueIndex:repo_owner_name_unique"`
 	Owner           *User  `xorm:"-" gorm:"-" json:"-"`
-	LowerName       string `xorm:"UNIQUE(s) INDEX NOT NULL" gorm:"UNIQUE_INDEX:s"`
-	Name            string `xorm:"INDEX NOT NULL" gorm:"NOT NULL"`
-	Description     string `xorm:"VARCHAR(512)" gorm:"TYPE:VARCHAR(512)"`
+	LowerName       string `xorm:"UNIQUE(s) INDEX NOT NULL" gorm:"uniqueIndex:repo_owner_name_unique;index;not null"`
+	Name            string `xorm:"INDEX NOT NULL" gorm:"index;not null"`
+	Description     string `xorm:"VARCHAR(512)" gorm:"type:VARCHAR(512)"`
 	Website         string
 	DefaultBranch   string
-	Size            int64 `xorm:"NOT NULL DEFAULT 0" gorm:"NOT NULL;DEFAULT:0"`
+	Size            int64 `xorm:"NOT NULL DEFAULT 0" gorm:"not null;default:0"`
 	UseCustomAvatar bool
 
 	// Counters
@@ -171,37 +173,37 @@ type Repository struct {
 	NumPulls            int
 	NumClosedPulls      int
 	NumOpenPulls        int `xorm:"-" gorm:"-" json:"-"`
-	NumMilestones       int `xorm:"NOT NULL DEFAULT 0" gorm:"NOT NULL;DEFAULT:0"`
-	NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0" gorm:"NOT NULL;DEFAULT:0"`
+	NumMilestones       int `xorm:"NOT NULL DEFAULT 0" gorm:"not null;default:0"`
+	NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0" gorm:"not null;default:0"`
 	NumOpenMilestones   int `xorm:"-" gorm:"-" json:"-"`
 	NumTags             int `xorm:"-" gorm:"-" json:"-"`
 
 	IsPrivate bool
 	// TODO: When migrate to GORM, make sure to do a loose migration with `HasColumn` and `AddColumn`,
 	// see docs in https://gorm.io/docs/migration.html.
-	IsUnlisted bool `xorm:"NOT NULL DEFAULT false"`
+	IsUnlisted bool `xorm:"NOT NULL DEFAULT false" gorm:"not null;default:FALSE"`
 	IsBare     bool
 
 	IsMirror bool
 	*Mirror  `xorm:"-" gorm:"-" json:"-"`
 
 	// Advanced settings
-	EnableWiki            bool `xorm:"NOT NULL DEFAULT true" gorm:"NOT NULL;DEFAULT:TRUE"`
+	EnableWiki            bool `xorm:"NOT NULL DEFAULT true" gorm:"not null;default:TRUE"`
 	AllowPublicWiki       bool
 	EnableExternalWiki    bool
 	ExternalWikiURL       string
-	EnableIssues          bool `xorm:"NOT NULL DEFAULT true" gorm:"NOT NULL;DEFAULT:TRUE"`
+	EnableIssues          bool `xorm:"NOT NULL DEFAULT true" gorm:"not null;default:TRUE"`
 	AllowPublicIssues     bool
 	EnableExternalTracker bool
 	ExternalTrackerURL    string
 	ExternalTrackerFormat string
 	ExternalTrackerStyle  string
 	ExternalMetas         map[string]string `xorm:"-" gorm:"-" json:"-"`
-	EnablePulls           bool              `xorm:"NOT NULL DEFAULT true" gorm:"NOT NULL;DEFAULT:TRUE"`
-	PullsIgnoreWhitespace bool              `xorm:"NOT NULL DEFAULT false" gorm:"NOT NULL;DEFAULT:FALSE"`
-	PullsAllowRebase      bool              `xorm:"NOT NULL DEFAULT false" gorm:"NOT NULL;DEFAULT:FALSE"`
+	EnablePulls           bool              `xorm:"NOT NULL DEFAULT true" gorm:"not null;default:TRUE"`
+	PullsIgnoreWhitespace bool              `xorm:"NOT NULL DEFAULT false" gorm:"not null;default:FALSE"`
+	PullsAllowRebase      bool              `xorm:"NOT NULL DEFAULT false" gorm:"not null;default:FALSE"`
 
-	IsFork   bool `xorm:"NOT NULL DEFAULT false" gorm:"NOT NULL;DEFAULT:FALSE"`
+	IsFork   bool `xorm:"NOT NULL DEFAULT false" gorm:"not null;default:FALSE"`
 	ForkID   int64
 	BaseRepo *Repository `xorm:"-" gorm:"-" json:"-"`
 
@@ -290,6 +292,7 @@ func (repo *Repository) FullName() string {
 	return repo.MustOwner().Name + "/" + repo.Name
 }
 
+// Deprecated: Use repoutil.HTMLURL instead.
 func (repo *Repository) HTMLURL() string {
 	return conf.Server.ExternalURL + repo.FullName()
 }
@@ -356,7 +359,9 @@ func (repo *Repository) DeleteAvatar() error {
 // This method assumes following fields have been assigned with valid values:
 // Required - BaseRepo (if fork)
 // Arguments that are allowed to be nil: permission
-func (repo *Repository) APIFormat(permission *api.Permission, user ...*User) *api.Repository {
+//
+// Deprecated: Use APIFormat instead.
+func (repo *Repository) APIFormatLegacy(permission *api.Permission, user ...*User) *api.Repository {
 	cloneLink := repo.CloneLink()
 	apiRepo := &api.Repository{
 		ID:            repo.ID,
@@ -390,7 +395,7 @@ func (repo *Repository) APIFormat(permission *api.Permission, user ...*User) *ap
 			p.Admin = user[0].IsAdminOfRepo(repo)
 			p.Push = user[0].IsWriterOfRepo(repo)
 		}
-		apiRepo.Parent = repo.BaseRepo.APIFormat(p)
+		apiRepo.Parent = repo.BaseRepo.APIFormatLegacy(p)
 	}
 	return apiRepo
 }
@@ -537,6 +542,7 @@ func (repo *Repository) repoPath(e Engine) string {
 	return RepoPath(repo.mustOwner(e).Name, repo.Name)
 }
 
+// Deprecated: Use repoutil.RepositoryPath instead.
 func (repo *Repository) RepoPath() string {
 	return repo.repoPath(x)
 }
@@ -553,6 +559,7 @@ func (repo *Repository) Link() string {
 	return conf.Server.Subpath + "/" + repo.FullName()
 }
 
+// Deprecated: Use repoutil.ComparePath instead.
 func (repo *Repository) ComposeCompareURL(oldCommitID, newCommitID string) string {
 	return fmt.Sprintf("%s/%s/compare/%s...%s", repo.MustOwner().Name, repo.Name, oldCommitID, newCommitID)
 }
@@ -694,37 +701,28 @@ func IsRepositoryExist(u *User, repoName string) (bool, error) {
 	return isRepositoryExist(x, u, repoName)
 }
 
-// CloneLink represents different types of clone URLs of repository.
-type CloneLink struct {
-	SSH   string
-	HTTPS string
-	Git   string
-}
-
-// ComposeHTTPSCloneURL returns HTTPS clone URL based on given owner and repository name.
-func ComposeHTTPSCloneURL(owner, repo string) string {
-	return fmt.Sprintf("%s%s/%s.git", conf.Server.ExternalURL, owner, repo)
-}
-
-func (repo *Repository) cloneLink(isWiki bool) *CloneLink {
+// Deprecated: Use repoutil.NewCloneLink instead.
+func (repo *Repository) cloneLink(isWiki bool) *repoutil.CloneLink {
 	repoName := repo.Name
 	if isWiki {
 		repoName += ".wiki"
 	}
 
 	repo.Owner = repo.MustOwner()
-	cl := new(CloneLink)
+	cl := new(repoutil.CloneLink)
 	if conf.SSH.Port != 22 {
 		cl.SSH = fmt.Sprintf("ssh://%s@%s:%d/%s/%s.git", conf.App.RunUser, conf.SSH.Domain, conf.SSH.Port, repo.Owner.Name, repoName)
 	} else {
 		cl.SSH = fmt.Sprintf("%s@%s:%s/%s.git", conf.App.RunUser, conf.SSH.Domain, repo.Owner.Name, repoName)
 	}
-	cl.HTTPS = ComposeHTTPSCloneURL(repo.Owner.Name, repoName)
+	cl.HTTPS = repoutil.HTTPSCloneURL(repo.Owner.Name, repoName)
 	return cl
 }
 
 // CloneLink returns clone URLs of repository.
-func (repo *Repository) CloneLink() (cl *CloneLink) {
+//
+// Deprecated: Use repoutil.NewCloneLink instead.
+func (repo *Repository) CloneLink() (cl *repoutil.CloneLink) {
 	return repo.cloneLink(false)
 }
 
@@ -758,7 +756,7 @@ func wikiRemoteURL(remote string) string {
 
 // MigrateRepository migrates a existing repository from other project hosting.
 func MigrateRepository(doer, owner *User, opts MigrateRepoOptions) (*Repository, error) {
-	repo, err := CreateRepository(doer, owner, CreateRepoOptions{
+	repo, err := CreateRepository(doer, owner, CreateRepoOptionsLegacy{
 		Name:        opts.Name,
 		Description: opts.Description,
 		IsPrivate:   opts.IsPrivate,
@@ -930,7 +928,7 @@ func initRepoCommit(tmpPath string, sig *git.Signature) (err error) {
 	return nil
 }
 
-type CreateRepoOptions struct {
+type CreateRepoOptionsLegacy struct {
 	Name        string
 	Description string
 	Gitignores  string
@@ -953,7 +951,7 @@ func getRepoInitFile(tp, name string) ([]byte, error) {
 	return embedConf.Files.ReadFile(relPath)
 }
 
-func prepareRepoCommit(repo *Repository, tmpDir, repoPath string, opts CreateRepoOptions) error {
+func prepareRepoCommit(repo *Repository, tmpDir, repoPath string, opts CreateRepoOptionsLegacy) error {
 	// Clone to temporary path and do the init commit.
 	_, stderr, err := process.Exec(
 		fmt.Sprintf("initRepository(git clone): %s", repoPath), "git", "clone", repoPath, tmpDir)
@@ -1016,7 +1014,7 @@ func prepareRepoCommit(repo *Repository, tmpDir, repoPath string, opts CreateRep
 }
 
 // initRepository performs initial commit with chosen setup files on behave of doer.
-func initRepository(e Engine, repoPath string, doer *User, repo *Repository, opts CreateRepoOptions) (err error) {
+func initRepository(e Engine, repoPath string, doer *User, repo *Repository, opts CreateRepoOptionsLegacy) (err error) {
 	// Somehow the directory could exist.
 	if com.IsExist(repoPath) {
 		return fmt.Errorf("initRepository: path already exists: %s", repoPath)
@@ -1116,7 +1114,29 @@ func createRepository(e *xorm.Session, doer, owner *User, repo *Repository) (err
 
 	if err = watchRepo(e, owner.ID, repo.ID, true); err != nil {
 		return fmt.Errorf("watchRepo: %v", err)
-	} else if err = newRepoAction(e, doer, owner, repo); err != nil {
+	}
+
+	// FIXME: This is identical to Actions.NewRepo but we are not yet able to wrap
+	// transaction with different ORM objects, should delete this once migrated to
+	// GORM for this part of logic.
+	newRepoAction := func(e Engine, doer *User, repo *Repository) (err error) {
+		opType := ActionCreateRepo
+		if repo.IsFork {
+			opType = ActionForkRepo
+		}
+
+		return notifyWatchers(e, &Action{
+			ActUserID:    doer.ID,
+			ActUserName:  doer.Name,
+			OpType:       opType,
+			RepoID:       repo.ID,
+			RepoUserName: repo.Owner.Name,
+			RepoName:     repo.Name,
+			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
+			CreatedUnix:  time.Now().Unix(),
+		})
+	}
+	if err = newRepoAction(e, doer, repo); err != nil {
 		return fmt.Errorf("newRepoAction: %v", err)
 	}
 
@@ -1137,7 +1157,7 @@ func (err ErrReachLimitOfRepo) Error() string {
 }
 
 // CreateRepository creates a repository for given user or organization.
-func CreateRepository(doer, owner *User, opts CreateRepoOptions) (_ *Repository, err error) {
+func CreateRepository(doer, owner *User, opts CreateRepoOptionsLegacy) (_ *Repository, err error) {
 	if !owner.CanCreateRepo() {
 		return nil, ErrReachLimitOfRepo{Limit: owner.RepoCreationNum()}
 	}
@@ -1265,6 +1285,8 @@ func FilterRepositoryWithIssues(repoIDs []int64) ([]int64, error) {
 }
 
 // RepoPath returns repository path by given user and repository name.
+//
+// Deprecated: Use repoutil.RepositoryPath instead.
 func RepoPath(userName, repoName string) string {
 	return filepath.Join(UserPath(userName), strings.ToLower(repoName)+".git")
 }
@@ -1361,9 +1383,34 @@ func TransferOwnership(doer *User, newOwnerName string, repo *Repository) error
 		return fmt.Errorf("decrease old owner repository count: %v", err)
 	}
 
+	// Remove watch for organization.
+	if owner.IsOrganization() {
+		if err = watchRepo(sess, owner.ID, repo.ID, false); err != nil {
+			return errors.Wrap(err, "unwatch repository for the organization owner")
+		}
+	}
+
 	if err = watchRepo(sess, newOwner.ID, repo.ID, true); err != nil {
 		return fmt.Errorf("watchRepo: %v", err)
-	} else if err = transferRepoAction(sess, doer, owner, repo); err != nil {
+	}
+
+	// FIXME: This is identical to Actions.TransferRepo but we are not yet able to
+	// wrap transaction with different ORM objects, should delete this once migrated
+	// to GORM for this part of logic.
+	transferRepoAction := func(e Engine, doer, oldOwner *User, repo *Repository) error {
+		return notifyWatchers(e, &Action{
+			ActUserID:    doer.ID,
+			ActUserName:  doer.Name,
+			OpType:       ActionTransferRepo,
+			RepoID:       repo.ID,
+			RepoUserName: repo.Owner.Name,
+			RepoName:     repo.Name,
+			IsPrivate:    repo.IsPrivate || repo.IsUnlisted,
+			Content:      path.Join(oldOwner.Name, repo.Name),
+			CreatedUnix:  time.Now().Unix(),
+		})
+	}
+	if err = transferRepoAction(sess, doer, owner, repo); err != nil {
 		return fmt.Errorf("transferRepoAction: %v", err)
 	}
 
@@ -1651,7 +1698,7 @@ func DeleteRepository(ownerID, repoID int64) error {
 func GetRepositoryByRef(ref string) (*Repository, error) {
 	n := strings.IndexByte(ref, byte('/'))
 	if n < 2 {
-		return nil, errors.InvalidRepoReference{Ref: ref}
+		return nil, dberrors.InvalidRepoReference{Ref: ref}
 	}
 
 	userName, repoName := ref[:n], ref[n+1:]
@@ -2235,9 +2282,9 @@ func (repos MirrorRepositoryList) LoadAttributes() error {
 
 // Watch is connection request for receiving repository notification.
 type Watch struct {
-	ID     int64
-	UserID int64 `xorm:"UNIQUE(watch)"`
-	RepoID int64 `xorm:"UNIQUE(watch)"`
+	ID     int64 `gorm:"primaryKey"`
+	UserID int64 `xorm:"UNIQUE(watch)" gorm:"uniqueIndex:watch_user_repo_unique;not null"`
+	RepoID int64 `xorm:"UNIQUE(watch)" gorm:"uniqueIndex:watch_user_repo_unique;not null"`
 }
 
 func isWatching(e Engine, userID, repoID int64) bool {
@@ -2276,12 +2323,15 @@ func WatchRepo(userID, repoID int64, watch bool) (err error) {
 	return watchRepo(x, userID, repoID, watch)
 }
 
+// Deprecated: Use Repos.ListByRepo instead.
 func getWatchers(e Engine, repoID int64) ([]*Watch, error) {
 	watches := make([]*Watch, 0, 10)
 	return watches, e.Find(&watches, &Watch{RepoID: repoID})
 }
 
 // GetWatchers returns all watchers of given repository.
+//
+// Deprecated: Use Repos.ListByRepo instead.
 func GetWatchers(repoID int64) ([]*Watch, error) {
 	return getWatchers(x, repoID)
 }
@@ -2298,7 +2348,12 @@ func (repo *Repository) GetWatchers(page int) ([]*User, error) {
 	return users, sess.Find(&users)
 }
 
+// Deprecated: Use Actions.notifyWatchers instead.
 func notifyWatchers(e Engine, act *Action) error {
+	if act.CreatedUnix <= 0 {
+		act.CreatedUnix = time.Now().Unix()
+	}
+
 	// Add feeds for user self and all watchers.
 	watchers, err := getWatchers(e, act.RepoID)
 	if err != nil {
@@ -2329,6 +2384,8 @@ func notifyWatchers(e Engine, act *Action) error {
 }
 
 // NotifyWatchers creates batch of actions for every watcher.
+//
+// Deprecated: Use Actions.notifyWatchers instead.
 func NotifyWatchers(act *Action) error {
 	return notifyWatchers(x, act)
 }
@@ -2469,8 +2526,8 @@ func ForkRepository(doer, owner *User, baseRepo *Repository, name, desc string)
 		log.Error("UpdateSize [repo_id: %d]: %v", repo.ID, err)
 	}
 	if err = PrepareWebhooks(baseRepo, HOOK_EVENT_FORK, &api.ForkPayload{
-		Forkee: repo.APIFormat(nil),
-		Repo:   baseRepo.APIFormat(nil),
+		Forkee: repo.APIFormatLegacy(nil),
+		Repo:   baseRepo.APIFormatLegacy(nil),
 		Sender: doer.APIFormat(),
 	}); err != nil {
 		log.Error("PrepareWebhooks [repo_id: %d]: %v", baseRepo.ID, err)

+ 82 - 8
internal/db/repos.go

@@ -10,18 +10,28 @@ import (
 	"strings"
 	"time"
 
+	api "github.com/gogs/go-gogs-client"
 	"gorm.io/gorm"
 
 	"gogs.io/gogs/internal/errutil"
+	"gogs.io/gogs/internal/repoutil"
 )
 
 // ReposStore is the persistent interface for repositories.
 //
 // NOTE: All methods are sorted in alphabetical order.
 type ReposStore interface {
+	// Create creates a new repository record in the database. It returns
+	// ErrNameNotAllowed when the repository name is not allowed, or
+	// ErrRepoAlreadyExist when a repository with same name already exists for the
+	// owner.
+	Create(ctx context.Context, ownerID int64, opts CreateRepoOptions) (*Repository, error)
 	// GetByName returns the repository with given owner and name. It returns
 	// ErrRepoNotExist when not found.
 	GetByName(ctx context.Context, ownerID int64, name string) (*Repository, error)
+	// Touch updates the updated time to the current time and removes the bare state
+	// of the given repository.
+	Touch(ctx context.Context, id int64) error
 }
 
 var Repos ReposStore
@@ -47,12 +57,58 @@ func (r *Repository) AfterFind(_ *gorm.DB) error {
 	return nil
 }
 
+type RepositoryAPIFormatOptions struct {
+	Permission *api.Permission
+	Parent     *api.Repository
+}
+
+// APIFormat returns the API format of a repository.
+func (r *Repository) APIFormat(owner *User, opts ...RepositoryAPIFormatOptions) *api.Repository {
+	var opt RepositoryAPIFormatOptions
+	if len(opts) > 0 {
+		opt = opts[0]
+	}
+
+	cloneLink := repoutil.NewCloneLink(owner.Name, r.Name, false)
+	return &api.Repository{
+		ID:            r.ID,
+		Owner:         owner.APIFormat(),
+		Name:          r.Name,
+		FullName:      owner.Name + "/" + r.Name,
+		Description:   r.Description,
+		Private:       r.IsPrivate,
+		Fork:          r.IsFork,
+		Parent:        opt.Parent,
+		Empty:         r.IsBare,
+		Mirror:        r.IsMirror,
+		Size:          r.Size,
+		HTMLURL:       repoutil.HTMLURL(owner.Name, r.Name),
+		SSHURL:        cloneLink.SSH,
+		CloneURL:      cloneLink.HTTPS,
+		Website:       r.Website,
+		Stars:         r.NumStars,
+		Forks:         r.NumForks,
+		Watchers:      r.NumWatches,
+		OpenIssues:    r.NumOpenIssues,
+		DefaultBranch: r.DefaultBranch,
+		Created:       r.Created,
+		Updated:       r.Updated,
+		Permissions:   opt.Permission,
+	}
+}
+
 var _ ReposStore = (*repos)(nil)
 
 type repos struct {
 	*gorm.DB
 }
 
+// NewReposStore returns a persistent interface for repositories with given
+// database connection.
+func NewReposStore(db *gorm.DB) ReposStore {
+	return &repos{DB: db}
+}
+
 type ErrRepoAlreadyExist struct {
 	args errutil.Args
 }
@@ -66,7 +122,7 @@ func (err ErrRepoAlreadyExist) Error() string {
 	return fmt.Sprintf("repository already exists: %v", err.args)
 }
 
-type createRepoOpts struct {
+type CreateRepoOptions struct {
 	Name          string
 	Description   string
 	DefaultBranch string
@@ -79,10 +135,7 @@ type createRepoOpts struct {
 	ForkID        int64
 }
 
-// create creates a new repository record in the database. Fields of "repo" will be updated
-// in place upon insertion. It returns ErrNameNotAllowed when the repository name is not allowed,
-// or ErrRepoAlreadyExist when a repository with same name already exists for the owner.
-func (db *repos) create(ctx context.Context, ownerID int64, opts createRepoOpts) (*Repository, error) {
+func (db *repos) Create(ctx context.Context, ownerID int64, opts CreateRepoOptions) (*Repository, error) {
 	err := isRepoNameAllowed(opts.Name)
 	if err != nil {
 		return nil, err
@@ -90,7 +143,12 @@ func (db *repos) create(ctx context.Context, ownerID int64, opts createRepoOpts)
 
 	_, err = db.GetByName(ctx, ownerID, opts.Name)
 	if err == nil {
-		return nil, ErrRepoAlreadyExist{args: errutil.Args{"ownerID": ownerID, "name": opts.Name}}
+		return nil, ErrRepoAlreadyExist{
+			args: errutil.Args{
+				"ownerID": ownerID,
+				"name":    opts.Name,
+			},
+		}
 	} else if !IsErrRepoNotExist(err) {
 		return nil, err
 	}
@@ -115,7 +173,7 @@ func (db *repos) create(ctx context.Context, ownerID int64, opts createRepoOpts)
 var _ errutil.NotFound = (*ErrRepoNotExist)(nil)
 
 type ErrRepoNotExist struct {
-	args map[string]interface{}
+	args errutil.Args
 }
 
 func IsErrRepoNotExist(err error) bool {
@@ -139,9 +197,25 @@ func (db *repos) GetByName(ctx context.Context, ownerID int64, name string) (*Re
 		Error
 	if err != nil {
 		if err == gorm.ErrRecordNotFound {
-			return nil, ErrRepoNotExist{args: map[string]interface{}{"ownerID": ownerID, "name": name}}
+			return nil, ErrRepoNotExist{
+				args: errutil.Args{
+					"ownerID": ownerID,
+					"name":    name,
+				},
+			}
 		}
 		return nil, err
 	}
 	return repo, nil
 }
+
+func (db *repos) Touch(ctx context.Context, id int64) error {
+	return db.WithContext(ctx).
+		Model(new(Repository)).
+		Where("id = ?", id).
+		Updates(map[string]interface{}{
+			"is_bare":      false,
+			"updated_unix": db.NowFunc().Unix(),
+		}).
+		Error
+}

+ 40 - 12
internal/db/repos_test.go

@@ -20,7 +20,6 @@ func TestRepos(t *testing.T) {
 	if testing.Short() {
 		t.Skip()
 	}
-
 	t.Parallel()
 
 	tables := []interface{}{new(Repository)}
@@ -32,8 +31,9 @@ func TestRepos(t *testing.T) {
 		name string
 		test func(*testing.T, *repos)
 	}{
-		{"create", reposCreate},
+		{"Create", reposCreate},
 		{"GetByName", reposGetByName},
+		{"Touch", reposTouch},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			t.Cleanup(func() {
@@ -52,9 +52,9 @@ func reposCreate(t *testing.T, db *repos) {
 	ctx := context.Background()
 
 	t.Run("name not allowed", func(t *testing.T) {
-		_, err := db.create(ctx,
+		_, err := db.Create(ctx,
 			1,
-			createRepoOpts{
+			CreateRepoOptions{
 				Name: "my.git",
 			},
 		)
@@ -63,15 +63,15 @@ func reposCreate(t *testing.T, db *repos) {
 	})
 
 	t.Run("already exists", func(t *testing.T) {
-		_, err := db.create(ctx, 2,
-			createRepoOpts{
+		_, err := db.Create(ctx, 2,
+			CreateRepoOptions{
 				Name: "repo1",
 			},
 		)
 		require.NoError(t, err)
 
-		_, err = db.create(ctx, 2,
-			createRepoOpts{
+		_, err = db.Create(ctx, 2,
+			CreateRepoOptions{
 				Name: "repo1",
 			},
 		)
@@ -79,8 +79,8 @@ func reposCreate(t *testing.T, db *repos) {
 		assert.Equal(t, wantErr, err)
 	})
 
-	repo, err := db.create(ctx, 3,
-		createRepoOpts{
+	repo, err := db.Create(ctx, 3,
+		CreateRepoOptions{
 			Name: "repo2",
 		},
 	)
@@ -94,8 +94,8 @@ func reposCreate(t *testing.T, db *repos) {
 func reposGetByName(t *testing.T, db *repos) {
 	ctx := context.Background()
 
-	repo, err := db.create(ctx, 1,
-		createRepoOpts{
+	repo, err := db.Create(ctx, 1,
+		CreateRepoOptions{
 			Name: "repo1",
 		},
 	)
@@ -108,3 +108,31 @@ func reposGetByName(t *testing.T, db *repos) {
 	wantErr := ErrRepoNotExist{args: errutil.Args{"ownerID": int64(1), "name": "bad_name"}}
 	assert.Equal(t, wantErr, err)
 }
+
+func reposTouch(t *testing.T, db *repos) {
+	ctx := context.Background()
+
+	repo, err := db.Create(ctx, 1,
+		CreateRepoOptions{
+			Name: "repo1",
+		},
+	)
+	require.NoError(t, err)
+
+	err = db.WithContext(ctx).Model(new(Repository)).Where("id = ?", repo.ID).Update("is_bare", true).Error
+	require.NoError(t, err)
+
+	// Make sure it is bare
+	got, err := db.GetByName(ctx, repo.OwnerID, repo.Name)
+	require.NoError(t, err)
+	assert.True(t, got.IsBare)
+
+	// Touch it
+	err = db.Touch(ctx, repo.ID)
+	require.NoError(t, err)
+
+	// It should not be bare anymore
+	got, err = db.GetByName(ctx, repo.OwnerID, repo.Name)
+	require.NoError(t, err)
+	assert.False(t, got.IsBare)
+}

+ 3 - 0
internal/db/testdata/backup/Action.golden.json

@@ -0,0 +1,3 @@
+{"ID":1,"UserID":1,"OpType":16,"ActUserID":1,"ActUserName":"alice","RepoID":1,"RepoUserName":"alice","RepoName":"example","RefName":"main","IsPrivate":false,"Content":"{\"Len\":1,\"Commits\":[],\"CompareURL\":\"\"}","CreatedUnix":1588568886}
+{"ID":2,"UserID":1,"OpType":5,"ActUserID":1,"ActUserName":"alice","RepoID":1,"RepoUserName":"alice","RepoName":"example","RefName":"main","IsPrivate":false,"Content":"{\"Len\":1,\"Commits\":[],\"CompareURL\":\"\"}","CreatedUnix":1588568886}
+{"ID":3,"UserID":1,"OpType":17,"ActUserID":1,"ActUserName":"alice","RepoID":1,"RepoUserName":"alice","RepoName":"example","RefName":"main","IsPrivate":false,"Content":"","CreatedUnix":1588568886}

+ 2 - 2
internal/db/two_factor.go

@@ -18,8 +18,8 @@ import (
 
 // TwoFactor is a 2FA token of a user.
 type TwoFactor struct {
-	ID          int64
-	UserID      int64 `xorm:"UNIQUE" gorm:"UNIQUE"`
+	ID          int64 `gorm:"primaryKey"`
+	UserID      int64 `xorm:"UNIQUE" gorm:"unique"`
 	Secret      string
 	Created     time.Time `xorm:"-" gorm:"-" json:"-"`
 	CreatedUnix int64

+ 25 - 1
internal/db/two_factors_test.go

@@ -11,16 +11,40 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
 
 	"gogs.io/gogs/internal/dbtest"
 	"gogs.io/gogs/internal/errutil"
 )
 
+func TestTwoFactor_BeforeCreate(t *testing.T) {
+	now := time.Now()
+	db := &gorm.DB{
+		Config: &gorm.Config{
+			SkipDefaultTransaction: true,
+			NowFunc: func() time.Time {
+				return now
+			},
+		},
+	}
+
+	t.Run("CreatedUnix has been set", func(t *testing.T) {
+		tf := &TwoFactor{CreatedUnix: 1}
+		_ = tf.BeforeCreate(db)
+		assert.Equal(t, int64(1), tf.CreatedUnix)
+	})
+
+	t.Run("CreatedUnix has not been set", func(t *testing.T) {
+		tf := &TwoFactor{}
+		_ = tf.BeforeCreate(db)
+		assert.Equal(t, db.NowFunc().Unix(), tf.CreatedUnix)
+	})
+}
+
 func TestTwoFactors(t *testing.T) {
 	if testing.Short() {
 		t.Skip()
 	}
-
 	t.Parallel()
 
 	tables := []interface{}{new(TwoFactor), new(TwoFactorRecoveryCode)}

+ 28 - 20
internal/db/update.go

@@ -5,11 +5,13 @@
 package db
 
 import (
+	"context"
 	"fmt"
 	"os/exec"
 	"strings"
 
 	"github.com/gogs/git-module"
+	"github.com/pkg/errors"
 )
 
 // CommitToPushCommit transforms a git.Commit to PushCommit type.
@@ -50,6 +52,8 @@ type PushUpdateOptions struct {
 // PushUpdate must be called for any push actions in order to
 // generates necessary push action history feeds.
 func PushUpdate(opts PushUpdateOptions) (err error) {
+	ctx := context.TODO()
+
 	isNewRef := strings.HasPrefix(opts.OldCommitID, git.EmptyID)
 	isDelRef := strings.HasPrefix(opts.NewCommitID, git.EmptyID)
 	if isNewRef && isDelRef {
@@ -85,16 +89,17 @@ func PushUpdate(opts PushUpdateOptions) (err error) {
 
 	// Push tags
 	if strings.HasPrefix(opts.FullRefspec, git.RefsTags) {
-		if err := CommitRepoAction(CommitRepoActionOptions{
-			PusherName:  opts.PusherName,
-			RepoOwnerID: owner.ID,
-			RepoName:    repo.Name,
-			RefFullName: opts.FullRefspec,
-			OldCommitID: opts.OldCommitID,
-			NewCommitID: opts.NewCommitID,
-			Commits:     &PushCommits{},
-		}); err != nil {
-			return fmt.Errorf("CommitRepoAction.(tag): %v", err)
+		err := Actions.PushTag(ctx,
+			PushTagOptions{
+				Owner:       owner,
+				Repo:        repo,
+				PusherName:  opts.PusherName,
+				RefFullName: opts.FullRefspec,
+				NewCommitID: opts.NewCommitID,
+			},
+		)
+		if err != nil {
+			return errors.Wrap(err, "create action for push tag")
 		}
 		return nil
 	}
@@ -122,16 +127,19 @@ func PushUpdate(opts PushUpdateOptions) (err error) {
 		}
 	}
 
-	if err := CommitRepoAction(CommitRepoActionOptions{
-		PusherName:  opts.PusherName,
-		RepoOwnerID: owner.ID,
-		RepoName:    repo.Name,
-		RefFullName: opts.FullRefspec,
-		OldCommitID: opts.OldCommitID,
-		NewCommitID: opts.NewCommitID,
-		Commits:     CommitsToPushCommits(commits),
-	}); err != nil {
-		return fmt.Errorf("CommitRepoAction.(branch): %v", err)
+	err = Actions.CommitRepo(ctx,
+		CommitRepoOptions{
+			Owner:       owner,
+			Repo:        repo,
+			PusherName:  opts.PusherName,
+			RefFullName: opts.FullRefspec,
+			OldCommitID: opts.OldCommitID,
+			NewCommitID: opts.NewCommitID,
+			Commits:     CommitsToPushCommits(commits),
+		},
+	)
+	if err != nil {
+		return errors.Wrap(err, "create action for commit push")
 	}
 	return nil
 }

+ 15 - 13
internal/db/user.go

@@ -49,14 +49,14 @@ const (
 
 // User represents the object of individual and member of organization.
 type User struct {
-	ID        int64
-	LowerName string `xorm:"UNIQUE NOT NULL" gorm:"UNIQUE"`
-	Name      string `xorm:"UNIQUE NOT NULL" gorm:"NOT NULL"`
+	ID        int64  `gorm:"primaryKey"`
+	LowerName string `xorm:"UNIQUE NOT NULL" gorm:"unique;not null"`
+	Name      string `xorm:"UNIQUE NOT NULL" gorm:"not null"`
 	FullName  string
 	// Email is the primary email address (to be used for communication)
-	Email       string `xorm:"NOT NULL" gorm:"NOT NULL"`
-	Passwd      string `xorm:"NOT NULL" gorm:"NOT NULL"`
-	LoginSource int64  `xorm:"NOT NULL DEFAULT 0" gorm:"NOT NULL;DEFAULT:0"`
+	Email       string `xorm:"NOT NULL" gorm:"not null"`
+	Passwd      string `xorm:"NOT NULL" gorm:"not null"`
+	LoginSource int64  `xorm:"NOT NULL DEFAULT 0" gorm:"not null;default:0"`
 	LoginName   string
 	Type        UserType
 	OwnedOrgs   []*User       `xorm:"-" gorm:"-" json:"-"`
@@ -64,8 +64,8 @@ type User struct {
 	Repos       []*Repository `xorm:"-" gorm:"-" json:"-"`
 	Location    string
 	Website     string
-	Rands       string `xorm:"VARCHAR(10)" gorm:"TYPE:VARCHAR(10)"`
-	Salt        string `xorm:"VARCHAR(10)" gorm:"TYPE:VARCHAR(10)"`
+	Rands       string `xorm:"VARCHAR(10)" gorm:"type:VARCHAR(10)"`
+	Salt        string `xorm:"VARCHAR(10)" gorm:"type:VARCHAR(10)"`
 
 	Created     time.Time `xorm:"-" gorm:"-" json:"-"`
 	CreatedUnix int64
@@ -75,7 +75,7 @@ type User struct {
 	// Remember visibility choice for convenience, true for private
 	LastRepoVisibility bool
 	// Maximum repository creation limit, -1 means use global default
-	MaxRepoCreation int `xorm:"NOT NULL DEFAULT -1" gorm:"NOT NULL;DEFAULT:-1"`
+	MaxRepoCreation int `xorm:"NOT NULL DEFAULT -1" gorm:"not null;default:-1"`
 
 	// Permissions
 	IsActive         bool // Activate primary email
@@ -85,13 +85,13 @@ type User struct {
 	ProhibitLogin    bool
 
 	// Avatar
-	Avatar          string `xorm:"VARCHAR(2048) NOT NULL" gorm:"TYPE:VARCHAR(2048);NOT NULL"`
-	AvatarEmail     string `xorm:"NOT NULL" gorm:"NOT NULL"`
+	Avatar          string `xorm:"VARCHAR(2048) NOT NULL" gorm:"type:VARCHAR(2048);not null"`
+	AvatarEmail     string `xorm:"NOT NULL" gorm:"not null"`
 	UseCustomAvatar bool
 
 	// Counters
 	NumFollowers int
-	NumFollowing int `xorm:"NOT NULL DEFAULT 0" gorm:"NOT NULL;DEFAULT:0"`
+	NumFollowing int `xorm:"NOT NULL DEFAULT 0" gorm:"not null;default:0"`
 	NumStars     int
 	NumRepos     int
 
@@ -466,7 +466,7 @@ func (u *User) DisplayName() string {
 }
 
 func (u *User) ShortName(length int) string {
-	return tool.EllipsisString(u.Name, length)
+	return strutil.Ellipsis(u.Name, length)
 }
 
 // IsMailable checks if a user is eligible
@@ -908,6 +908,8 @@ func DeleteInactivateUsers() (err error) {
 }
 
 // UserPath returns the path absolute path of user repositories.
+//
+// Deprecated: Use repoutil.UserPath instead.
 func UserPath(username string) string {
 	return filepath.Join(conf.Repository.Root, strings.ToLower(username))
 }

+ 3 - 3
internal/db/user_mail.go

@@ -16,9 +16,9 @@ import (
 // primary email address, but is not obligatory.
 type EmailAddress struct {
 	ID          int64
-	UID         int64  `xorm:"INDEX NOT NULL" gorm:"INDEX"`
-	Email       string `xorm:"UNIQUE NOT NULL" gorm:"UNIQUE"`
-	IsActivated bool   `gorm:"NOT NULL;DEFAULT:FALSE"`
+	UID         int64  `xorm:"INDEX NOT NULL" gorm:"index;not null"`
+	Email       string `xorm:"UNIQUE NOT NULL" gorm:"unique;not null"`
+	IsActivated bool   `gorm:"not null;default:FALSE"`
 	IsPrimary   bool   `xorm:"-" gorm:"-" json:"-"`
 }
 

+ 10 - 4
internal/db/users.go

@@ -38,7 +38,7 @@ type UsersStore interface {
 	// Create creates a new user and persists to database. It returns
 	// ErrUserAlreadyExist when a user with same name already exists, or
 	// ErrEmailAlreadyUsed if the email has been used by another user.
-	Create(ctx context.Context, username, email string, opts CreateUserOpts) (*User, error)
+	Create(ctx context.Context, username, email string, opts CreateUserOptions) (*User, error)
 	// GetByEmail returns the user (not organization) with given email. It ignores
 	// records with unverified emails and returns ErrUserNotExist when not found.
 	GetByEmail(ctx context.Context, email string) (*User, error)
@@ -74,6 +74,12 @@ type users struct {
 	*gorm.DB
 }
 
+// NewUsersStore returns a persistent interface for users with given database
+// connection.
+func NewUsersStore(db *gorm.DB) UsersStore {
+	return &users{DB: db}
+}
+
 type ErrLoginSourceMismatch struct {
 	args errutil.Args
 }
@@ -154,7 +160,7 @@ func (db *users) Authenticate(ctx context.Context, login, password string, login
 	}
 
 	return db.Create(ctx, extAccount.Name, extAccount.Email,
-		CreateUserOpts{
+		CreateUserOptions{
 			FullName:    extAccount.FullName,
 			LoginSource: authSourceID,
 			LoginName:   extAccount.Login,
@@ -166,7 +172,7 @@ func (db *users) Authenticate(ctx context.Context, login, password string, login
 	)
 }
 
-type CreateUserOpts struct {
+type CreateUserOptions struct {
 	FullName    string
 	Password    string
 	LoginSource int64
@@ -211,7 +217,7 @@ func (err ErrEmailAlreadyUsed) Error() string {
 	return fmt.Sprintf("email has been used: %v", err.args)
 }
 
-func (db *users) Create(ctx context.Context, username, email string, opts CreateUserOpts) (*User, error) {
+func (db *users) Create(ctx context.Context, username, email string, opts CreateUserOptions) (*User, error) {
 	err := isUsernameAllowed(username)
 	if err != nil {
 		return nil, err

+ 11 - 12
internal/db/users_test.go

@@ -22,7 +22,6 @@ func TestUsers(t *testing.T) {
 	if testing.Short() {
 		t.Skip()
 	}
-
 	t.Parallel()
 
 	tables := []interface{}{new(User), new(EmailAddress)}
@@ -58,7 +57,7 @@ func usersAuthenticate(t *testing.T, db *users) {
 
 	password := "pa$$word"
 	alice, err := db.Create(ctx, "alice", "[email protected]",
-		CreateUserOpts{
+		CreateUserOptions{
 			Password: password,
 		},
 	)
@@ -109,7 +108,7 @@ func usersAuthenticate(t *testing.T, db *users) {
 		setMockLoginSourcesStore(t, mockLoginSources)
 
 		bob, err := db.Create(ctx, "bob", "[email protected]",
-			CreateUserOpts{
+			CreateUserOptions{
 				Password:    password,
 				LoginSource: 1,
 			},
@@ -154,26 +153,26 @@ func usersCreate(t *testing.T, db *users) {
 	ctx := context.Background()
 
 	alice, err := db.Create(ctx, "alice", "[email protected]",
-		CreateUserOpts{
+		CreateUserOptions{
 			Activated: true,
 		},
 	)
 	require.NoError(t, err)
 
 	t.Run("name not allowed", func(t *testing.T) {
-		_, err := db.Create(ctx, "-", "", CreateUserOpts{})
+		_, err := db.Create(ctx, "-", "", CreateUserOptions{})
 		wantErr := ErrNameNotAllowed{args: errutil.Args{"reason": "reserved", "name": "-"}}
 		assert.Equal(t, wantErr, err)
 	})
 
 	t.Run("name already exists", func(t *testing.T) {
-		_, err := db.Create(ctx, alice.Name, "", CreateUserOpts{})
+		_, err := db.Create(ctx, alice.Name, "", CreateUserOptions{})
 		wantErr := ErrUserAlreadyExist{args: errutil.Args{"name": alice.Name}}
 		assert.Equal(t, wantErr, err)
 	})
 
 	t.Run("email already exists", func(t *testing.T) {
-		_, err := db.Create(ctx, "bob", alice.Email, CreateUserOpts{})
+		_, err := db.Create(ctx, "bob", alice.Email, CreateUserOptions{})
 		wantErr := ErrEmailAlreadyUsed{args: errutil.Args{"email": alice.Email}}
 		assert.Equal(t, wantErr, err)
 	})
@@ -195,7 +194,7 @@ func usersGetByEmail(t *testing.T, db *users) {
 
 	t.Run("ignore organization", func(t *testing.T) {
 		// TODO: Use Orgs.Create to replace SQL hack when the method is available.
-		org, err := db.Create(ctx, "gogs", "[email protected]", CreateUserOpts{})
+		org, err := db.Create(ctx, "gogs", "[email protected]", CreateUserOptions{})
 		require.NoError(t, err)
 
 		err = db.Model(&User{}).Where("id", org.ID).UpdateColumn("type", UserOrganization).Error
@@ -207,7 +206,7 @@ func usersGetByEmail(t *testing.T, db *users) {
 	})
 
 	t.Run("by primary email", func(t *testing.T) {
-		alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOpts{})
+		alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
 		require.NoError(t, err)
 
 		_, err = db.GetByEmail(ctx, alice.Email)
@@ -225,7 +224,7 @@ func usersGetByEmail(t *testing.T, db *users) {
 	})
 
 	t.Run("by secondary email", func(t *testing.T) {
-		bob, err := db.Create(ctx, "bob", "[email protected]", CreateUserOpts{})
+		bob, err := db.Create(ctx, "bob", "[email protected]", CreateUserOptions{})
 		require.NoError(t, err)
 
 		// TODO: Use UserEmails.Create to replace SQL hack when the method is available.
@@ -250,7 +249,7 @@ func usersGetByEmail(t *testing.T, db *users) {
 func usersGetByID(t *testing.T, db *users) {
 	ctx := context.Background()
 
-	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOpts{})
+	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
 	require.NoError(t, err)
 
 	user, err := db.GetByID(ctx, alice.ID)
@@ -265,7 +264,7 @@ func usersGetByID(t *testing.T, db *users) {
 func usersGetByUsername(t *testing.T, db *users) {
 	ctx := context.Background()
 
-	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOpts{})
+	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
 	require.NoError(t, err)
 
 	user, err := db.GetByUsername(ctx, alice.Name)

+ 38 - 0
internal/db/watches.go

@@ -0,0 +1,38 @@
+// Copyright 2020 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package db
+
+import (
+	"context"
+
+	"gorm.io/gorm"
+)
+
+// WatchesStore is the persistent interface for watches.
+//
+// NOTE: All methods are sorted in alphabetical order.
+type WatchesStore interface {
+	// ListByRepo returns all watches of the given repository.
+	ListByRepo(ctx context.Context, repoID int64) ([]*Watch, error)
+}
+
+var Watches WatchesStore
+
+var _ WatchesStore = (*watches)(nil)
+
+type watches struct {
+	*gorm.DB
+}
+
+// NewWatchesStore returns a persistent interface for watches with given
+// database connection.
+func NewWatchesStore(db *gorm.DB) WatchesStore {
+	return &watches{DB: db}
+}
+
+func (db *watches) ListByRepo(ctx context.Context, repoID int64) ([]*Watch, error) {
+	var watches []*Watch
+	return watches, db.WithContext(ctx).Where("repo_id = ?", repoID).Find(&watches).Error
+}

+ 47 - 0
internal/db/watches_test.go

@@ -0,0 +1,47 @@
+// Copyright 2022 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package db
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"gogs.io/gogs/internal/dbtest"
+)
+
+func TestWatches(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+	t.Parallel()
+
+	tables := []interface{}{new(Watch)}
+	db := &watches{
+		DB: dbtest.NewDB(t, "watches", tables...),
+	}
+
+	for _, tc := range []struct {
+		name string
+		test func(*testing.T, *watches)
+	}{
+		{"ListByRepo", watchesListByRepo},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Cleanup(func() {
+				err := clearTables(t, db.DB, tables...)
+				require.NoError(t, err)
+			})
+			tc.test(t, db)
+		})
+		if t.Failed() {
+			break
+		}
+	}
+}
+
+func watchesListByRepo(_ *testing.T, _ *watches) {
+	// TODO: Add tests once WatchRepo is migrated to GORM.
+}

+ 6 - 0
internal/db/webhook.go

@@ -26,6 +26,7 @@ import (
 	"gogs.io/gogs/internal/httplib"
 	"gogs.io/gogs/internal/netutil"
 	"gogs.io/gogs/internal/sync"
+	"gogs.io/gogs/internal/testutil"
 )
 
 var HookQueue = sync.NewUniqueQueue(1000)
@@ -676,6 +677,11 @@ func prepareWebhooks(e Engine, repo *Repository, event HookEventType, p api.Payl
 
 // PrepareWebhooks adds all active webhooks to task queue.
 func PrepareWebhooks(repo *Repository, event HookEventType, p api.Payloader) error {
+	// NOTE: To prevent too many cascading changes in a single refactoring PR, we
+	// choose to ignore this function in tests.
+	if x == nil && testutil.InTest {
+		return nil
+	}
 	return prepareWebhooks(x, repo, event, p)
 }
 

+ 4 - 1
internal/db/wiki.go

@@ -18,6 +18,7 @@ import (
 	"github.com/gogs/git-module"
 
 	"gogs.io/gogs/internal/conf"
+	"gogs.io/gogs/internal/repoutil"
 	"gogs.io/gogs/internal/sync"
 )
 
@@ -37,7 +38,9 @@ func ToWikiPageName(urlString string) string {
 }
 
 // WikiCloneLink returns clone URLs of repository wiki.
-func (repo *Repository) WikiCloneLink() (cl *CloneLink) {
+//
+// Deprecated: Use repoutil.NewCloneLink instead.
+func (repo *Repository) WikiCloneLink() (cl *repoutil.CloneLink) {
 	return repo.cloneLink(true)
 }
 

+ 4 - 4
internal/dbtest/dbtest.go

@@ -55,8 +55,8 @@ func NewDB(t *testing.T, suite string, tables ...interface{}) *gorm.DB {
 
 		dbOpts.Name = dbName
 
-		cleanup = func(db *gorm.DB) {
-			db.Exec(fmt.Sprintf("DROP DATABASE `%s`", dbName))
+		cleanup = func(_ *gorm.DB) {
+			_, _ = sqlDB.Exec(fmt.Sprintf("DROP DATABASE `%s`", dbName))
 			_ = sqlDB.Close()
 		}
 	case "postgres":
@@ -86,8 +86,8 @@ func NewDB(t *testing.T, suite string, tables ...interface{}) *gorm.DB {
 
 		dbOpts.Name = dbName
 
-		cleanup = func(db *gorm.DB) {
-			db.Exec(fmt.Sprintf(`DROP DATABASE %q`, dbName))
+		cleanup = func(_ *gorm.DB) {
+			_, _ = sqlDB.Exec(fmt.Sprintf(`DROP DATABASE %q`, dbName))
 			_ = sqlDB.Close()
 		}
 	case "sqlite":

+ 3 - 5
internal/lazyregexp/lazyre.go

@@ -7,10 +7,10 @@
 package lazyregexp
 
 import (
-	"os"
 	"regexp"
-	"strings"
 	"sync"
+
+	"gogs.io/gogs/internal/testutil"
 )
 
 // Regexp is a wrapper around regexp.Regexp, where the underlying regexp will be
@@ -99,14 +99,12 @@ func (r *Regexp) ReplaceAll(src, repl []byte) []byte {
 	return r.Regexp().ReplaceAll(src, repl)
 }
 
-var inTest = len(os.Args) > 0 && strings.HasSuffix(strings.TrimSuffix(os.Args[0], ".exe"), ".test")
-
 // New creates a new lazy regexp, delaying the compiling work until it is first
 // needed. If the code is being run as part of tests, the regexp compiling will
 // happen immediately.
 func New(str string) *Regexp {
 	lr := &Regexp{str: str}
-	if inTest {
+	if testutil.InTest {
 		// In tests, always compile the regexps early.
 		lr.Regexp()
 	}

+ 62 - 0
internal/repoutil/repoutil.go

@@ -0,0 +1,62 @@
+// Copyright 2022 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package repoutil
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"gogs.io/gogs/internal/conf"
+)
+
+// CloneLink represents different types of clone URLs of repository.
+type CloneLink struct {
+	SSH   string
+	HTTPS string
+}
+
+// NewCloneLink returns clone URLs using given owner and repository name.
+func NewCloneLink(owner, repo string, isWiki bool) *CloneLink {
+	if isWiki {
+		repo += ".wiki"
+	}
+
+	cl := new(CloneLink)
+	if conf.SSH.Port != 22 {
+		cl.SSH = fmt.Sprintf("ssh://%s@%s:%d/%s/%s.git", conf.App.RunUser, conf.SSH.Domain, conf.SSH.Port, owner, repo)
+	} else {
+		cl.SSH = fmt.Sprintf("%s@%s:%s/%s.git", conf.App.RunUser, conf.SSH.Domain, owner, repo)
+	}
+	cl.HTTPS = HTTPSCloneURL(owner, repo)
+	return cl
+}
+
+// HTTPSCloneURL returns HTTPS clone URL using given owner and repository name.
+func HTTPSCloneURL(owner, repo string) string {
+	return fmt.Sprintf("%s%s/%s.git", conf.Server.ExternalURL, owner, repo)
+}
+
+// HTMLURL returns HTML URL using given owner and repository name.
+func HTMLURL(owner, repo string) string {
+	return conf.Server.ExternalURL + owner + "/" + repo
+}
+
+// CompareCommitsPath returns the comparison path using given owner, repository,
+// and commit IDs.
+func CompareCommitsPath(owner, repo, oldCommitID, newCommitID string) string {
+	return fmt.Sprintf("%s/%s/compare/%s...%s", owner, repo, oldCommitID, newCommitID)
+}
+
+// UserPath returns the absolute path for storing user repositories.
+func UserPath(user string) string {
+	return filepath.Join(conf.Repository.Root, strings.ToLower(user))
+}
+
+// RepositoryPath returns the absolute path using given user and repository
+// name.
+func RepositoryPath(owner, repo string) string {
+	return filepath.Join(UserPath(owner), strings.ToLower(repo)+".git")
+}

+ 127 - 0
internal/repoutil/repoutil_test.go

@@ -0,0 +1,127 @@
+// Copyright 2022 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package repoutil
+
+import (
+	"runtime"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"gogs.io/gogs/internal/conf"
+)
+
+func TestNewCloneLink(t *testing.T) {
+	conf.SetMockApp(t,
+		conf.AppOpts{
+			RunUser: "git",
+		},
+	)
+	conf.SetMockServer(t,
+		conf.ServerOpts{
+			ExternalURL: "https://example.com/",
+		},
+	)
+
+	t.Run("regular SSH port", func(t *testing.T) {
+		conf.SetMockSSH(t,
+			conf.SSHOpts{
+				Domain: "example.com",
+				Port:   22,
+			},
+		)
+
+		got := NewCloneLink("alice", "example", false)
+		want := &CloneLink{
+			SSH:   "[email protected]:alice/example.git",
+			HTTPS: "https://example.com/alice/example.git",
+		}
+		assert.Equal(t, want, got)
+	})
+
+	t.Run("irregular SSH port", func(t *testing.T) {
+		conf.SetMockSSH(t,
+			conf.SSHOpts{
+				Domain: "example.com",
+				Port:   2222,
+			},
+		)
+
+		got := NewCloneLink("alice", "example", false)
+		want := &CloneLink{
+			SSH:   "ssh://[email protected]:2222/alice/example.git",
+			HTTPS: "https://example.com/alice/example.git",
+		}
+		assert.Equal(t, want, got)
+	})
+
+	t.Run("wiki", func(t *testing.T) {
+		conf.SetMockSSH(t,
+			conf.SSHOpts{
+				Domain: "example.com",
+				Port:   22,
+			},
+		)
+
+		got := NewCloneLink("alice", "example", true)
+		want := &CloneLink{
+			SSH:   "[email protected]:alice/example.wiki.git",
+			HTTPS: "https://example.com/alice/example.wiki.git",
+		}
+		assert.Equal(t, want, got)
+	})
+}
+
+func TestHTMLURL(t *testing.T) {
+	conf.SetMockServer(t,
+		conf.ServerOpts{
+			ExternalURL: "https://example.com/",
+		},
+	)
+
+	got := HTMLURL("alice", "example")
+	want := "https://example.com/alice/example"
+	assert.Equal(t, want, got)
+}
+
+func TestCompareCommitsPath(t *testing.T) {
+	got := CompareCommitsPath("alice", "example", "old", "new")
+	want := "alice/example/compare/old...new"
+	assert.Equal(t, want, got)
+}
+
+func TestUserPath(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("Skipping testing on Windows")
+		return
+	}
+
+	conf.SetMockRepository(t,
+		conf.RepositoryOpts{
+			Root: "/home/git/gogs-repositories",
+		},
+	)
+
+	got := UserPath("alice")
+	want := "/home/git/gogs-repositories/alice"
+	assert.Equal(t, want, got)
+}
+
+func TestRepositoryPath(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("Skipping testing on Windows")
+		return
+	}
+
+	conf.SetMockRepository(t,
+		conf.RepositoryOpts{
+			Root: "/home/git/gogs-repositories",
+		},
+	)
+
+	got := RepositoryPath("alice", "example")
+	want := "/home/git/gogs-repositories/alice/example.git"
+	assert.Equal(t, want, got)
+}

+ 2 - 2
internal/route/admin/auths.go

@@ -35,7 +35,7 @@ func Authentications(c *context.Context) {
 	c.PageIs("AdminAuthentications")
 
 	var err error
-	c.Data["Sources"], err = db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOpts{})
+	c.Data["Sources"], err = db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOptions{})
 	if err != nil {
 		c.Error(err, "list login sources")
 		return
@@ -160,7 +160,7 @@ func NewAuthSourcePost(c *context.Context, f form.Authentication) {
 	}
 
 	source, err := db.LoginSources.Create(c.Req.Context(),
-		db.CreateLoginSourceOpts{
+		db.CreateLoginSourceOptions{
 			Type:      auth.Type(f.Type),
 			Name:      f.Name,
 			Activated: f.IsActive,

+ 3 - 3
internal/route/admin/users.go

@@ -46,7 +46,7 @@ func NewUser(c *context.Context) {
 
 	c.Data["login_type"] = "0-0"
 
-	sources, err := db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOpts{})
+	sources, err := db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOptions{})
 	if err != nil {
 		c.Error(err, "list login sources")
 		return
@@ -62,7 +62,7 @@ func NewUserPost(c *context.Context, f form.AdminCrateUser) {
 	c.Data["PageIsAdmin"] = true
 	c.Data["PageIsAdminUsers"] = true
 
-	sources, err := db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOpts{})
+	sources, err := db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOptions{})
 	if err != nil {
 		c.Error(err, "list login sources")
 		return
@@ -136,7 +136,7 @@ func prepareUserInfo(c *context.Context) *db.User {
 		c.Data["LoginSource"] = &db.LoginSource{}
 	}
 
-	sources, err := db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOpts{})
+	sources, err := db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOptions{})
 	if err != nil {
 		c.Error(err, "list login sources")
 		return nil

+ 9 - 9
internal/route/api/v1/repo/repo.go

@@ -66,7 +66,7 @@ func Search(c *context.APIContext) {
 
 	results := make([]*api.Repository, len(repos))
 	for i := range repos {
-		results[i] = repos[i].APIFormat(nil)
+		results[i] = repos[i].APIFormatLegacy(nil)
 	}
 
 	c.SetLinkHeader(int(count), opts.PageSize)
@@ -110,7 +110,7 @@ func listUserRepositories(c *context.APIContext, username string) {
 	if c.User.ID != user.ID {
 		repos := make([]*api.Repository, len(ownRepos))
 		for i := range ownRepos {
-			repos[i] = ownRepos[i].APIFormat(&api.Permission{Admin: true, Push: true, Pull: true})
+			repos[i] = ownRepos[i].APIFormatLegacy(&api.Permission{Admin: true, Push: true, Pull: true})
 		}
 		c.JSONSuccess(&repos)
 		return
@@ -125,12 +125,12 @@ func listUserRepositories(c *context.APIContext, username string) {
 	numOwnRepos := len(ownRepos)
 	repos := make([]*api.Repository, numOwnRepos+len(accessibleRepos))
 	for i := range ownRepos {
-		repos[i] = ownRepos[i].APIFormat(&api.Permission{Admin: true, Push: true, Pull: true})
+		repos[i] = ownRepos[i].APIFormatLegacy(&api.Permission{Admin: true, Push: true, Pull: true})
 	}
 
 	i := numOwnRepos
 	for repo, access := range accessibleRepos {
-		repos[i] = repo.APIFormat(&api.Permission{
+		repos[i] = repo.APIFormatLegacy(&api.Permission{
 			Admin: access >= db.AccessModeAdmin,
 			Push:  access >= db.AccessModeWrite,
 			Pull:  true,
@@ -154,7 +154,7 @@ func ListOrgRepositories(c *context.APIContext) {
 }
 
 func CreateUserRepo(c *context.APIContext, owner *db.User, opt api.CreateRepoOption) {
-	repo, err := db.CreateRepository(c.User, owner, db.CreateRepoOptions{
+	repo, err := db.CreateRepository(c.User, owner, db.CreateRepoOptionsLegacy{
 		Name:        opt.Name,
 		Description: opt.Description,
 		Gitignores:  opt.Gitignores,
@@ -178,7 +178,7 @@ func CreateUserRepo(c *context.APIContext, owner *db.User, opt api.CreateRepoOpt
 		return
 	}
 
-	c.JSON(201, repo.APIFormat(&api.Permission{Admin: true, Push: true, Pull: true}))
+	c.JSON(201, repo.APIFormatLegacy(&api.Permission{Admin: true, Push: true, Pull: true}))
 }
 
 func Create(c *context.APIContext, opt api.CreateRepoOption) {
@@ -282,7 +282,7 @@ func Migrate(c *context.APIContext, f form.MigrateRepo) {
 	}
 
 	log.Trace("Repository migrated: %s/%s", ctxUser.Name, f.RepoName)
-	c.JSON(201, repo.APIFormat(&api.Permission{Admin: true, Push: true, Pull: true}))
+	c.JSON(201, repo.APIFormatLegacy(&api.Permission{Admin: true, Push: true, Pull: true}))
 }
 
 // FIXME: inject in the handler chain
@@ -312,7 +312,7 @@ func Get(c *context.APIContext) {
 		return
 	}
 
-	c.JSONSuccess(repo.APIFormat(&api.Permission{
+	c.JSONSuccess(repo.APIFormatLegacy(&api.Permission{
 		Admin: c.Repo.IsAdmin(),
 		Push:  c.Repo.IsWriter(),
 		Pull:  true,
@@ -352,7 +352,7 @@ func ListForks(c *context.APIContext) {
 			c.Error(err, "get owner")
 			return
 		}
-		apiForks[i] = forks[i].APIFormat(&api.Permission{
+		apiForks[i] = forks[i].APIFormatLegacy(&api.Permission{
 			Admin: c.User.IsAdminOfRepo(forks[i]),
 			Push:  c.User.IsWriterOfRepo(forks[i]),
 			Pull:  true,

+ 257 - 11
internal/route/lfs/mocks_test.go

@@ -1492,20 +1492,36 @@ func (c PermsStoreSetRepoPermsFuncCall) Results() []interface{} {
 // MockReposStore is a mock implementation of the ReposStore interface (from
 // the package gogs.io/gogs/internal/db) used for unit testing.
 type MockReposStore struct {
+	// CreateFunc is an instance of a mock function object controlling the
+	// behavior of the method Create.
+	CreateFunc *ReposStoreCreateFunc
 	// GetByNameFunc is an instance of a mock function object controlling
 	// the behavior of the method GetByName.
 	GetByNameFunc *ReposStoreGetByNameFunc
+	// TouchFunc is an instance of a mock function object controlling the
+	// behavior of the method Touch.
+	TouchFunc *ReposStoreTouchFunc
 }
 
 // NewMockReposStore creates a new mock of the ReposStore interface. All
 // methods return zero values for all results, unless overwritten.
 func NewMockReposStore() *MockReposStore {
 	return &MockReposStore{
+		CreateFunc: &ReposStoreCreateFunc{
+			defaultHook: func(context.Context, int64, db.CreateRepoOptions) (r0 *db.Repository, r1 error) {
+				return
+			},
+		},
 		GetByNameFunc: &ReposStoreGetByNameFunc{
 			defaultHook: func(context.Context, int64, string) (r0 *db.Repository, r1 error) {
 				return
 			},
 		},
+		TouchFunc: &ReposStoreTouchFunc{
+			defaultHook: func(context.Context, int64) (r0 error) {
+				return
+			},
+		},
 	}
 }
 
@@ -1513,11 +1529,21 @@ func NewMockReposStore() *MockReposStore {
 // All methods panic on invocation, unless overwritten.
 func NewStrictMockReposStore() *MockReposStore {
 	return &MockReposStore{
+		CreateFunc: &ReposStoreCreateFunc{
+			defaultHook: func(context.Context, int64, db.CreateRepoOptions) (*db.Repository, error) {
+				panic("unexpected invocation of MockReposStore.Create")
+			},
+		},
 		GetByNameFunc: &ReposStoreGetByNameFunc{
 			defaultHook: func(context.Context, int64, string) (*db.Repository, error) {
 				panic("unexpected invocation of MockReposStore.GetByName")
 			},
 		},
+		TouchFunc: &ReposStoreTouchFunc{
+			defaultHook: func(context.Context, int64) error {
+				panic("unexpected invocation of MockReposStore.Touch")
+			},
+		},
 	}
 }
 
@@ -1525,12 +1551,128 @@ func NewStrictMockReposStore() *MockReposStore {
 // All methods delegate to the given implementation, unless overwritten.
 func NewMockReposStoreFrom(i db.ReposStore) *MockReposStore {
 	return &MockReposStore{
+		CreateFunc: &ReposStoreCreateFunc{
+			defaultHook: i.Create,
+		},
 		GetByNameFunc: &ReposStoreGetByNameFunc{
 			defaultHook: i.GetByName,
 		},
+		TouchFunc: &ReposStoreTouchFunc{
+			defaultHook: i.Touch,
+		},
 	}
 }
 
+// ReposStoreCreateFunc describes the behavior when the Create method of the
+// parent MockReposStore instance is invoked.
+type ReposStoreCreateFunc struct {
+	defaultHook func(context.Context, int64, db.CreateRepoOptions) (*db.Repository, error)
+	hooks       []func(context.Context, int64, db.CreateRepoOptions) (*db.Repository, error)
+	history     []ReposStoreCreateFuncCall
+	mutex       sync.Mutex
+}
+
+// Create delegates to the next hook function in the queue and stores the
+// parameter and result values of this invocation.
+func (m *MockReposStore) Create(v0 context.Context, v1 int64, v2 db.CreateRepoOptions) (*db.Repository, error) {
+	r0, r1 := m.CreateFunc.nextHook()(v0, v1, v2)
+	m.CreateFunc.appendCall(ReposStoreCreateFuncCall{v0, v1, v2, r0, r1})
+	return r0, r1
+}
+
+// SetDefaultHook sets function that is called when the Create method of the
+// parent MockReposStore instance is invoked and the hook queue is empty.
+func (f *ReposStoreCreateFunc) SetDefaultHook(hook func(context.Context, int64, db.CreateRepoOptions) (*db.Repository, error)) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// Create method of the parent MockReposStore instance invokes the hook at
+// the front of the queue and discards it. After the queue is empty, the
+// default hook function is invoked for any future action.
+func (f *ReposStoreCreateFunc) PushHook(hook func(context.Context, int64, db.CreateRepoOptions) (*db.Repository, error)) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *ReposStoreCreateFunc) SetDefaultReturn(r0 *db.Repository, r1 error) {
+	f.SetDefaultHook(func(context.Context, int64, db.CreateRepoOptions) (*db.Repository, error) {
+		return r0, r1
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *ReposStoreCreateFunc) PushReturn(r0 *db.Repository, r1 error) {
+	f.PushHook(func(context.Context, int64, db.CreateRepoOptions) (*db.Repository, error) {
+		return r0, r1
+	})
+}
+
+func (f *ReposStoreCreateFunc) nextHook() func(context.Context, int64, db.CreateRepoOptions) (*db.Repository, error) {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *ReposStoreCreateFunc) appendCall(r0 ReposStoreCreateFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of ReposStoreCreateFuncCall objects describing
+// the invocations of this function.
+func (f *ReposStoreCreateFunc) History() []ReposStoreCreateFuncCall {
+	f.mutex.Lock()
+	history := make([]ReposStoreCreateFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// ReposStoreCreateFuncCall is an object that describes an invocation of
+// method Create on an instance of MockReposStore.
+type ReposStoreCreateFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Arg2 is the value of the 3rd argument passed to this method
+	// invocation.
+	Arg2 db.CreateRepoOptions
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 *db.Repository
+	// Result1 is the value of the 2nd result returned from this method
+	// invocation.
+	Result1 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c ReposStoreCreateFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c ReposStoreCreateFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0, c.Result1}
+}
+
 // ReposStoreGetByNameFunc describes the behavior when the GetByName method
 // of the parent MockReposStore instance is invoked.
 type ReposStoreGetByNameFunc struct {
@@ -1642,6 +1784,110 @@ func (c ReposStoreGetByNameFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0, c.Result1}
 }
 
+// ReposStoreTouchFunc describes the behavior when the Touch method of the
+// parent MockReposStore instance is invoked.
+type ReposStoreTouchFunc struct {
+	defaultHook func(context.Context, int64) error
+	hooks       []func(context.Context, int64) error
+	history     []ReposStoreTouchFuncCall
+	mutex       sync.Mutex
+}
+
+// Touch delegates to the next hook function in the queue and stores the
+// parameter and result values of this invocation.
+func (m *MockReposStore) Touch(v0 context.Context, v1 int64) error {
+	r0 := m.TouchFunc.nextHook()(v0, v1)
+	m.TouchFunc.appendCall(ReposStoreTouchFuncCall{v0, v1, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the Touch method of the
+// parent MockReposStore instance is invoked and the hook queue is empty.
+func (f *ReposStoreTouchFunc) SetDefaultHook(hook func(context.Context, int64) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// Touch method of the parent MockReposStore instance invokes the hook at
+// the front of the queue and discards it. After the queue is empty, the
+// default hook function is invoked for any future action.
+func (f *ReposStoreTouchFunc) PushHook(hook func(context.Context, int64) error) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *ReposStoreTouchFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *ReposStoreTouchFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64) error {
+		return r0
+	})
+}
+
+func (f *ReposStoreTouchFunc) nextHook() func(context.Context, int64) error {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *ReposStoreTouchFunc) appendCall(r0 ReposStoreTouchFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of ReposStoreTouchFuncCall objects describing
+// the invocations of this function.
+func (f *ReposStoreTouchFunc) History() []ReposStoreTouchFuncCall {
+	f.mutex.Lock()
+	history := make([]ReposStoreTouchFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// ReposStoreTouchFuncCall is an object that describes an invocation of
+// method Touch on an instance of MockReposStore.
+type ReposStoreTouchFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c ReposStoreTouchFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c ReposStoreTouchFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // MockTwoFactorsStore is a mock implementation of the TwoFactorsStore
 // interface (from the package gogs.io/gogs/internal/db) used for unit
 // testing.
@@ -2074,7 +2320,7 @@ func NewMockUsersStore() *MockUsersStore {
 			},
 		},
 		CreateFunc: &UsersStoreCreateFunc{
-			defaultHook: func(context.Context, string, string, db.CreateUserOpts) (r0 *db.User, r1 error) {
+			defaultHook: func(context.Context, string, string, db.CreateUserOptions) (r0 *db.User, r1 error) {
 				return
 			},
 		},
@@ -2106,7 +2352,7 @@ func NewStrictMockUsersStore() *MockUsersStore {
 			},
 		},
 		CreateFunc: &UsersStoreCreateFunc{
-			defaultHook: func(context.Context, string, string, db.CreateUserOpts) (*db.User, error) {
+			defaultHook: func(context.Context, string, string, db.CreateUserOptions) (*db.User, error) {
 				panic("unexpected invocation of MockUsersStore.Create")
 			},
 		},
@@ -2267,15 +2513,15 @@ func (c UsersStoreAuthenticateFuncCall) Results() []interface{} {
 // UsersStoreCreateFunc describes the behavior when the Create method of the
 // parent MockUsersStore instance is invoked.
 type UsersStoreCreateFunc struct {
-	defaultHook func(context.Context, string, string, db.CreateUserOpts) (*db.User, error)
-	hooks       []func(context.Context, string, string, db.CreateUserOpts) (*db.User, error)
+	defaultHook func(context.Context, string, string, db.CreateUserOptions) (*db.User, error)
+	hooks       []func(context.Context, string, string, db.CreateUserOptions) (*db.User, error)
 	history     []UsersStoreCreateFuncCall
 	mutex       sync.Mutex
 }
 
 // Create delegates to the next hook function in the queue and stores the
 // parameter and result values of this invocation.
-func (m *MockUsersStore) Create(v0 context.Context, v1 string, v2 string, v3 db.CreateUserOpts) (*db.User, error) {
+func (m *MockUsersStore) Create(v0 context.Context, v1 string, v2 string, v3 db.CreateUserOptions) (*db.User, error) {
 	r0, r1 := m.CreateFunc.nextHook()(v0, v1, v2, v3)
 	m.CreateFunc.appendCall(UsersStoreCreateFuncCall{v0, v1, v2, v3, r0, r1})
 	return r0, r1
@@ -2283,7 +2529,7 @@ func (m *MockUsersStore) Create(v0 context.Context, v1 string, v2 string, v3 db.
 
 // SetDefaultHook sets function that is called when the Create method of the
 // parent MockUsersStore instance is invoked and the hook queue is empty.
-func (f *UsersStoreCreateFunc) SetDefaultHook(hook func(context.Context, string, string, db.CreateUserOpts) (*db.User, error)) {
+func (f *UsersStoreCreateFunc) SetDefaultHook(hook func(context.Context, string, string, db.CreateUserOptions) (*db.User, error)) {
 	f.defaultHook = hook
 }
 
@@ -2291,7 +2537,7 @@ func (f *UsersStoreCreateFunc) SetDefaultHook(hook func(context.Context, string,
 // Create method of the parent MockUsersStore instance invokes the hook at
 // the front of the queue and discards it. After the queue is empty, the
 // default hook function is invoked for any future action.
-func (f *UsersStoreCreateFunc) PushHook(hook func(context.Context, string, string, db.CreateUserOpts) (*db.User, error)) {
+func (f *UsersStoreCreateFunc) PushHook(hook func(context.Context, string, string, db.CreateUserOptions) (*db.User, error)) {
 	f.mutex.Lock()
 	f.hooks = append(f.hooks, hook)
 	f.mutex.Unlock()
@@ -2300,19 +2546,19 @@ func (f *UsersStoreCreateFunc) PushHook(hook func(context.Context, string, strin
 // SetDefaultReturn calls SetDefaultHook with a function that returns the
 // given values.
 func (f *UsersStoreCreateFunc) SetDefaultReturn(r0 *db.User, r1 error) {
-	f.SetDefaultHook(func(context.Context, string, string, db.CreateUserOpts) (*db.User, error) {
+	f.SetDefaultHook(func(context.Context, string, string, db.CreateUserOptions) (*db.User, error) {
 		return r0, r1
 	})
 }
 
 // PushReturn calls PushHook with a function that returns the given values.
 func (f *UsersStoreCreateFunc) PushReturn(r0 *db.User, r1 error) {
-	f.PushHook(func(context.Context, string, string, db.CreateUserOpts) (*db.User, error) {
+	f.PushHook(func(context.Context, string, string, db.CreateUserOptions) (*db.User, error) {
 		return r0, r1
 	})
 }
 
-func (f *UsersStoreCreateFunc) nextHook() func(context.Context, string, string, db.CreateUserOpts) (*db.User, error) {
+func (f *UsersStoreCreateFunc) nextHook() func(context.Context, string, string, db.CreateUserOptions) (*db.User, error) {
 	f.mutex.Lock()
 	defer f.mutex.Unlock()
 
@@ -2356,7 +2602,7 @@ type UsersStoreCreateFuncCall struct {
 	Arg2 string
 	// Arg3 is the value of the 4th argument passed to this method
 	// invocation.
-	Arg3 db.CreateUserOpts
+	Arg3 db.CreateUserOptions
 	// Result0 is the value of the 1st result returned from this method
 	// invocation.
 	Result0 *db.User

+ 1 - 1
internal/route/repo/branch.go

@@ -146,7 +146,7 @@ func DeleteBranchPost(c *context.Context) {
 		Ref:        branchName,
 		RefType:    "branch",
 		PusherType: api.PUSHER_TYPE_USER,
-		Repo:       c.Repo.Repository.APIFormat(nil),
+		Repo:       c.Repo.Repository.APIFormatLegacy(nil),
 		Sender:     c.User.APIFormat(),
 	}); err != nil {
 		log.Error("Failed to prepare webhooks for %q: %v", db.HOOK_EVENT_DELETE, err)

+ 1 - 1
internal/route/repo/repo.go

@@ -119,7 +119,7 @@ func CreatePost(c *context.Context, f form.CreateRepo) {
 		return
 	}
 
-	repo, err := db.CreateRepository(c.User, ctxUser, db.CreateRepoOptions{
+	repo, err := db.CreateRepository(c.User, ctxUser, db.CreateRepoOptionsLegacy{
 		Name:        f.RepoName,
 		Description: f.Description,
 		Gitignores:  f.Gitignores,

+ 2 - 2
internal/route/repo/setting.go

@@ -100,8 +100,8 @@ func SettingsPost(c *context.Context, f form.RepoSetting) {
 		log.Trace("Repository basic settings updated: %s/%s", c.Repo.Owner.Name, repo.Name)
 
 		if isNameChanged {
-			if err := db.RenameRepoAction(c.User, oldRepoName, repo); err != nil {
-				log.Error("RenameRepoAction: %v", err)
+			if err := db.Actions.RenameRepo(c.Req.Context(), c.User, repo.MustOwner(), oldRepoName, repo); err != nil {
+				log.Error("create rename repository action: %v", err)
 			}
 		}
 

+ 1 - 1
internal/route/repo/webhook.go

@@ -536,7 +536,7 @@ func TestWebhook(c *context.Context) {
 				Modified: nameStatus.Modified,
 			},
 		},
-		Repo:   c.Repo.Repository.APIFormat(nil),
+		Repo:   c.Repo.Repository.APIFormatLegacy(nil),
 		Pusher: apiUser,
 		Sender: apiUser,
 	}

+ 2 - 2
internal/route/user/auth.go

@@ -102,7 +102,7 @@ func Login(c *context.Context) {
 	}
 
 	// Display normal login page
-	loginSources, err := db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOpts{OnlyActivated: true})
+	loginSources, err := db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOptions{OnlyActivated: true})
 	if err != nil {
 		c.Error(err, "list activated login sources")
 		return
@@ -149,7 +149,7 @@ func afterLogin(c *context.Context, u *db.User, remember bool) {
 func LoginPost(c *context.Context, f form.SignIn) {
 	c.Title("sign_in")
 
-	loginSources, err := db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOpts{OnlyActivated: true})
+	loginSources, err := db.LoginSources.List(c.Req.Context(), db.ListLoginSourceOptions{OnlyActivated: true})
 	if err != nil {
 		c.Error(err, "list activated login sources")
 		return

+ 10 - 2
internal/route/user/home.go

@@ -53,9 +53,17 @@ func getDashboardContextUser(c *context.Context) *db.User {
 // The user could be organization so it is not always the logged in user,
 // which is why we have to explicitly pass the context user ID.
 func retrieveFeeds(c *context.Context, ctxUser *db.User, userID int64, isProfile bool) {
-	actions, err := db.GetFeeds(ctxUser, userID, c.QueryInt64("after_id"), isProfile)
+	afterID := c.QueryInt64("after_id")
+
+	var err error
+	var actions []*db.Action
+	if ctxUser.IsOrganization() {
+		actions, err = db.Actions.ListByOrganization(c.Req.Context(), ctxUser.ID, userID, afterID)
+	} else {
+		actions, err = db.Actions.ListByUser(c.Req.Context(), ctxUser.ID, userID, afterID, isProfile)
+	}
 	if err != nil {
-		c.Error(err, "get feeds")
+		c.Error(err, "list actions")
 		return
 	}
 

+ 1 - 1
internal/route/user/profile.go

@@ -54,7 +54,7 @@ func Profile(c *context.Context, puser *context.ParamsUser) {
 	c.Data["TabName"] = tab
 	switch tab {
 	case "activity":
-		retrieveFeeds(c, puser.User, -1, true)
+		retrieveFeeds(c, puser.User, c.UserID(), true)
 		if c.Written() {
 			return
 		}

+ 10 - 0
internal/strutil/strutil.go

@@ -44,3 +44,13 @@ func RandomChars(n int) (string, error) {
 
 	return string(buffer), nil
 }
+
+// Ellipsis returns a truncated string and appends "..." to the end of the
+// string if the string length is larger than the threshold. Otherwise, the
+// original string is returned.
+func Ellipsis(str string, threshold int) string {
+	if len(str) <= threshold || threshold < 0 {
+		return str
+	}
+	return str[:threshold] + "..."
+}

+ 40 - 0
internal/strutil/strutil_test.go

@@ -55,3 +55,43 @@ func TestRandomChars(t *testing.T) {
 		cache[chars] = true
 	}
 }
+
+func TestEllipsis(t *testing.T) {
+	tests := []struct {
+		name      string
+		str       string
+		threshold int
+		want      string
+	}{
+		{
+			name:      "empty string and zero threshold",
+			str:       "",
+			threshold: 0,
+			want:      "",
+		},
+		{
+			name:      "smaller length than threshold",
+			str:       "ab",
+			threshold: 3,
+			want:      "ab",
+		},
+		{
+			name:      "same length as threshold",
+			str:       "abc",
+			threshold: 3,
+			want:      "abc",
+		},
+		{
+			name:      "greater length than threshold",
+			str:       "ab",
+			threshold: 1,
+			want:      "a...",
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got := Ellipsis(test.str, test.threshold)
+			assert.Equal(t, test.want, got)
+		})
+	}
+}

+ 2 - 1
internal/template/template.go

@@ -27,6 +27,7 @@ import (
 	"gogs.io/gogs/internal/db"
 	"gogs.io/gogs/internal/gitutil"
 	"gogs.io/gogs/internal/markup"
+	"gogs.io/gogs/internal/strutil"
 	"gogs.io/gogs/internal/tool"
 )
 
@@ -106,7 +107,7 @@ func FuncMap() []template.FuncMap {
 				return str[start:end]
 			},
 			"Join":                  strings.Join,
-			"EllipsisString":        tool.EllipsisString,
+			"EllipsisString":        strutil.Ellipsis,
 			"DiffFileTypeToStr":     DiffFileTypeToStr,
 			"DiffLineTypeToStr":     DiffLineTypeToStr,
 			"Sha1":                  Sha1,

+ 13 - 0
internal/testutil/testutil.go

@@ -0,0 +1,13 @@
+// Copyright 2022 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package testutil
+
+import (
+	"os"
+	"strings"
+)
+
+// InTest is ture if the current binary looks like a test artifact.
+var InTest = len(os.Args) > 0 && strings.HasSuffix(strings.TrimSuffix(os.Args[0], ".exe"), ".test")

+ 15 - 0
internal/testutil/testutil_test.go

@@ -0,0 +1,15 @@
+// Copyright 2022 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package testutil
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestInTest(t *testing.T) {
+	assert.True(t, InTest)
+}

+ 0 - 9
internal/tool/tool.go

@@ -358,15 +358,6 @@ func Subtract(left, right interface{}) interface{} {
 	}
 }
 
-// EllipsisString returns a truncated short string,
-// it appends '...' in the end of the length of string is too large.
-func EllipsisString(str string, length int) string {
-	if len(str) < length {
-		return str
-	}
-	return str[:length-3] + "..."
-}
-
 // TruncateString returns a truncated string with given limit,
 // it returns input string if length is not reached limit.
 func TruncateString(str string, limit int) string {