fix(webhook): fire repository event on repository transfer (#40200)

Transferring a repository fired no `repository` webhook. It now sends a
`transferred` action shaped like GitHub's, with the previous owner in
`changes.owner.from.user` or `changes.owner.from.organization`. Like
GitHub, it goes to the new owner's webhooks, plus the repository's own
and system webhooks.

- Chat webhooks render "Repository transferred from <owner>".
- Actions workflows are unaffected, no trigger matches `repository`
events.

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

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Shivansh Garg
2026-11-04 17:25:51 +00:00
committed by GitHub
parent fca64e7330
commit be297b7e05
21 changed files with 84 additions and 34 deletions

View File

@@ -460,6 +460,13 @@ type ChangesFromPayload struct {
From string `json:"from"`
}
type ChangesOwnerPayload struct {
From struct {
User *User `json:"user,omitempty"`
Organization *User `json:"organization,omitempty"`
} `json:"from"`
}
// ChangesPayload represents the payload information of issue change
type ChangesPayload struct {
// Changes made to the title
@@ -470,6 +477,8 @@ type ChangesPayload struct {
Ref *ChangesFromPayload `json:"ref,omitempty"`
// Changes made to the repository name
Name *ChangesFromPayload `json:"name,omitempty"`
// Changes made to the repository owner
Owner *ChangesOwnerPayload `json:"owner,omitempty"`
// Changes made to the labels added
AddedLabels []*Label `json:"added_labels"`
// Changes made to the labels removed
@@ -560,6 +569,8 @@ const (
HookRepoDeleted HookRepoAction = "deleted"
// HookRepoRenamed renamed
HookRepoRenamed HookRepoAction = "renamed"
// HookRepoTransferred transferred
HookRepoTransferred HookRepoAction = "transferred"
)
// RepositoryPayload payload for repository webhooks

View File

@@ -2318,7 +2318,7 @@
"repo.settings.event_branch_delete": "Branch Deletion",
"repo.settings.event_push_desc": "Git push to a repository.",
"repo.settings.event_repository": "Repository",
"repo.settings.event_repository_desc": "Repository created, deleted, or renamed.",
"repo.settings.event_repository_desc": "Repository created, deleted, renamed, or transferred.",
"repo.settings.event_header_issue": "Issue Events",
"repo.settings.event_issues": "Issues",
"repo.settings.event_issues_desc": "Issue opened, closed, reopened, edited or deleted.",

View File

@@ -37,8 +37,8 @@ func (n *auditNotifier) RenameRepository(ctx context.Context, doer *user_model.U
RecordAs(ctx, doer, audit_model.RepositoryName, repo, "previous_name", oldRepoName)
}
func (n *auditNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
RecordAs(ctx, doer, audit_model.RepositoryTransferFinish, repo, "old_owner", oldOwnerName, "new_owner", repo.OwnerName)
func (n *auditNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwner *user_model.User) {
RecordAs(ctx, doer, audit_model.RepositoryTransferFinish, repo, "old_owner", oldOwner.Name, "new_owner", repo.OwnerName)
}
func (n *auditNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) {

View File

@@ -53,7 +53,7 @@ func TestAuditNotifier(t *testing.T) {
audit_model.Event{Action: audit_model.RepositoryCreate, ActorID: doer.ID, Origin: audit_model.OriginUI},
},
{
func() { notifier.TransferRepository(ctx, doer, repo, "previous_owner") },
func() { notifier.TransferRepository(ctx, doer, repo, &user_model.User{Name: "previous_owner"}) },
audit_model.Event{Action: audit_model.RepositoryTransferFinish, ActorID: doer.ID, Message: "Transferred repository owner/repo from previous_owner to owner."},
},
{

View File

@@ -164,7 +164,7 @@ func (a *actionNotifier) RenameRepository(ctx context.Context, doer *user_model.
}
}
func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwner *user_model.User) {
if err := NotifyWatchers(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
@@ -172,7 +172,7 @@ func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_mode
RepoID: repo.ID,
Repo: repo,
IsPrivate: repo.IsPrivate,
Content: path.Join(oldOwnerName, repo.Name),
Content: path.Join(oldOwner.Name, repo.Name),
}); err != nil {
log.Error("NotifyWatchers: %v", err)
}

View File

@@ -27,7 +27,7 @@ type Notifier interface {
DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository)
ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository)
RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string)
TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string)
TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwner *user_model.User)
RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository)
NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User)

View File

@@ -383,11 +383,11 @@ func MigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo
}
// TransferRepository notifies create repository to notifiers
func TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
func TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwner *user_model.User) {
defer recoverPanic()
for _, notifier := range notifiers {
notifier.TransferRepository(ctx, doer, repo, oldOwnerName)
notifier.TransferRepository(ctx, doer, repo, oldOwner)
}
}

View File

@@ -187,7 +187,7 @@ func (*NullNotifier) RenameRepository(ctx context.Context, doer *user_model.User
}
// TransferRepository places a place holder function
func (*NullNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
func (*NullNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwner *user_model.User) {
}
// SyncPushCommits places a place holder function

View File

@@ -56,7 +56,10 @@ func AcceptTransferOwnership(ctx context.Context, repo *repo_model.Repository, d
return err
}
oldOwnerName := repo.OwnerName
if err := repo.LoadOwner(ctx); err != nil {
return err
}
oldOwner := repo.Owner
if err := db.WithTx(ctx, func(ctx context.Context) error {
if err := repoTransfer.LoadAttributes(ctx); err != nil {
@@ -71,9 +74,6 @@ func AcceptTransferOwnership(ctx context.Context, repo *repo_model.Repository, d
return util.ErrPermissionDenied
}
if err := repo.LoadOwner(ctx); err != nil {
return err
}
for _, team := range repoTransfer.Teams {
if repoTransfer.Recipient.ID != team.OrgID {
return fmt.Errorf("team %d does not belong to organization", team.ID)
@@ -86,7 +86,7 @@ func AcceptTransferOwnership(ctx context.Context, repo *repo_model.Repository, d
}
releaser()
notify_service.TransferRepository(ctx, doer, repo, oldOwnerName)
notify_service.TransferRepository(ctx, doer, repo, oldOwner)
return nil
}
@@ -451,7 +451,7 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use
}
var isDirectTransfer bool
oldOwnerName := repo.OwnerName
oldOwner := repo.Owner
if err := db.WithTx(ctx, func(ctx context.Context) error {
// Admin is always allowed to transfer || user transfer repo back to his account,
@@ -500,7 +500,7 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use
}
if isDirectTransfer {
notify_service.TransferRepository(ctx, doer, repo, oldOwnerName)
notify_service.TransferRepository(ctx, doer, repo, oldOwner)
} else {
// notify users who are able to accept / reject transfer
notify_service.RepoPendingTransfer(ctx, doer, newOwner, repo)

View File

@@ -152,8 +152,8 @@ func (dc dingtalkConvertor) Repository(p *api.RepositoryPayload) (DingtalkPayloa
Content: title,
},
}, nil
case api.HookRepoRenamed:
title := fmt.Sprintf("[%s] Repository renamed from %s", p.Repository.FullName, getRepoRenamedFrom(p))
case api.HookRepoRenamed, api.HookRepoTransferred:
title := fmt.Sprintf("[%s] Repository %s from %s", p.Repository.FullName, p.Action, getRepoChangedFrom(p))
return createDingtalkPayload(title, title, "view repository", p.Repository.HTMLURL), nil
}

View File

@@ -241,8 +241,8 @@ func (d discordConvertor) Repository(p *api.RepositoryPayload) (DiscordPayload,
case api.HookRepoDeleted:
title = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
color = redColor
case api.HookRepoRenamed:
title = fmt.Sprintf("[%s] Repository renamed from %s", p.Repository.FullName, getRepoRenamedFrom(p))
case api.HookRepoRenamed, api.HookRepoTransferred:
title = fmt.Sprintf("[%s] Repository %s from %s", p.Repository.FullName, p.Action, getRepoChangedFrom(p))
url = p.Repository.HTMLURL
color = greenColor
}

View File

@@ -148,8 +148,8 @@ func (fc feishuConvertor) Repository(p *api.RepositoryPayload) (FeishuPayload, e
case api.HookRepoDeleted:
text = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
return newFeishuTextPayload(text), nil
case api.HookRepoRenamed:
text = fmt.Sprintf("[%s] Repository renamed from %s", p.Repository.FullName, getRepoRenamedFrom(p))
case api.HookRepoRenamed, api.HookRepoTransferred:
text = fmt.Sprintf("[%s] Repository %s from %s", p.Repository.FullName, p.Action, getRepoChangedFrom(p))
return newFeishuTextPayload(text), nil
}

View File

@@ -328,7 +328,13 @@ func getIssueCommentPayloadInfo(p *api.IssueCommentPayload, linkFormatter linkFo
return text, issueTitle, color
}
func getRepoRenamedFrom(p *api.RepositoryPayload) string {
func getRepoChangedFrom(p *api.RepositoryPayload) string {
if p.Action == api.HookRepoTransferred {
if org := p.Changes.Owner.From.Organization; org != nil {
return org.UserName
}
return p.Changes.Owner.From.User.UserName
}
if p.Changes == nil || p.Changes.Name == nil {
return ""
}

View File

@@ -227,8 +227,8 @@ func (m matrixConvertor) Repository(p *api.RepositoryPayload) (MatrixPayload, er
text = fmt.Sprintf("[%s] Repository created by %s", repoLink, senderLink)
case api.HookRepoDeleted:
text = fmt.Sprintf("[%s] Repository deleted by %s", repoLink, senderLink)
case api.HookRepoRenamed:
text = fmt.Sprintf("[%s] Repository renamed from %s by %s", repoLink, getRepoRenamedFrom(p), senderLink)
case api.HookRepoRenamed, api.HookRepoTransferred:
text = fmt.Sprintf("[%s] Repository %s from %s by %s", repoLink, p.Action, getRepoChangedFrom(p), senderLink)
}
return m.newPayload(text)
}

View File

@@ -259,8 +259,8 @@ func (m msteamsConvertor) Repository(p *api.RepositoryPayload) (MSTeamsPayload,
case api.HookRepoDeleted:
title = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
color = yellowColor
case api.HookRepoRenamed:
title = fmt.Sprintf("[%s] Repository renamed from %s", p.Repository.FullName, getRepoRenamedFrom(p))
case api.HookRepoRenamed, api.HookRepoTransferred:
title = fmt.Sprintf("[%s] Repository %s from %s", p.Repository.FullName, p.Action, getRepoChangedFrom(p))
url = p.Repository.HTMLURL
color = greenColor
}

View File

@@ -146,6 +146,24 @@ func (m *webhookNotifier) RenameRepository(ctx context.Context, doer *user_model
}
}
func (m *webhookNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwner *user_model.User) {
changes := &api.ChangesPayload{Owner: &api.ChangesOwnerPayload{}}
if oldOwner.IsOrganization() {
changes.Owner.From.Organization = convert.ToUser(ctx, oldOwner, nil)
} else {
changes.Owner.From.User = convert.ToUser(ctx, oldOwner, nil)
}
if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventRepository, &api.RepositoryPayload{
Action: api.HookRepoTransferred,
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
Organization: convert.ToUser(ctx, repo.MustOwner(ctx), nil),
Sender: convert.ToUser(ctx, doer, nil),
Changes: changes,
}); err != nil {
log.Error("PrepareWebhooks [repo_id: %d]: %v", repo.ID, err)
}
}
func (m *webhookNotifier) MigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
// Add to hook queue for created repo after session commit.
if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventRepository, &api.RepositoryPayload{

View File

@@ -302,8 +302,8 @@ func (s slackConvertor) Repository(p *api.RepositoryPayload) (SlackPayload, erro
text = fmt.Sprintf("[%s] Repository created by %s", repoLink, senderLink)
case api.HookRepoDeleted:
text = fmt.Sprintf("[%s] Repository deleted by %s", repoLink, senderLink)
case api.HookRepoRenamed:
text = fmt.Sprintf("[%s] Repository renamed from %s by %s", repoLink, getRepoRenamedFrom(p), senderLink)
case api.HookRepoRenamed, api.HookRepoTransferred:
text = fmt.Sprintf("[%s] Repository %s from %s by %s", repoLink, p.Action, getRepoChangedFrom(p), senderLink)
}
return s.createPayload(text, nil), nil

View File

@@ -115,6 +115,13 @@ func TestSlackPayload(t *testing.T) {
pl, err = sc.Repository(p)
require.NoError(t, err)
assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Repository renamed from old-repo by <https://try.gitea.io/user1|user1>", pl.Text)
p.Action = api.HookRepoTransferred
p.Changes = &api.ChangesPayload{Owner: &api.ChangesOwnerPayload{}}
p.Changes.Owner.From.Organization = &api.User{UserName: "old-org"}
pl, err = sc.Repository(p)
require.NoError(t, err)
assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Repository transferred from old-org by <https://try.gitea.io/user1|user1>", pl.Text)
})
t.Run("Wiki", func(t *testing.T) {

View File

@@ -154,8 +154,8 @@ func (t telegramConvertor) Repository(p *api.RepositoryPayload) (TelegramPayload
case api.HookRepoDeleted:
title = fmt.Sprintf("[%s] Repository deleted", html.EscapeString(p.Repository.FullName))
return createTelegramPayloadHTML(title), nil
case api.HookRepoRenamed:
title = fmt.Sprintf("[%s] Repository renamed from %s", htmlLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName), html.EscapeString(getRepoRenamedFrom(p)))
case api.HookRepoRenamed, api.HookRepoTransferred:
title = fmt.Sprintf("[%s] Repository %s from %s", htmlLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName), p.Action, html.EscapeString(getRepoChangedFrom(p)))
return createTelegramPayloadHTML(title), nil
}
return TelegramPayload{}, nil

View File

@@ -150,8 +150,8 @@ func (wc wechatworkConvertor) Repository(p *api.RepositoryPayload) (WechatworkPa
case api.HookRepoDeleted:
title = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName)
return newWechatworkMarkdownPayload(title), nil
case api.HookRepoRenamed:
title = fmt.Sprintf("[%s] Repository renamed from %s", p.Repository.FullName, getRepoRenamedFrom(p))
case api.HookRepoRenamed, api.HookRepoTransferred:
title = fmt.Sprintf("[%s] Repository %s from %s", p.Repository.FullName, p.Action, getRepoChangedFrom(p))
return newWechatworkMarkdownPayload(title), nil
}

View File

@@ -821,6 +821,14 @@ func Test_WebhookRepository(t *testing.T) {
assert.Equal(t, "org3/"+newName, payloads[1].Repository.FullName)
require.NotNil(t, payloads[1].Changes.Name)
assert.Equal(t, "repo_new", payloads[1].Changes.Name.From)
req = NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/transfer", &api.TransferRepoOption{NewOwner: "org3"}).
AddTokenAuth(getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository))
MakeRequest(t, req, http.StatusAccepted)
require.Len(t, payloads, 3)
assert.Equal(t, api.HookRepoTransferred, payloads[2].Action)
assert.Equal(t, "user2", payloads[2].Changes.Owner.From.User.UserName)
})
}