feat: manage bot accounts from the admin UI, API and CLI (#38966)

Adds first-class bot accounts (`UserTypeBot`): local, password-less
users for automation that authenticate only with access tokens.

1. Admin UI: create bots, filter users by type, manage a bot's access
tokens, convert between user and bot
2. API: `POST /admin/users/{username}/convert-type`, and user objects
gain a GitHub-compatible `type` (`User`, `Organization`, `Bot`)
3. CLI: `gitea admin user change-type`, `--user-type` accepts `User` or
`Bot` case-insensitively
4. Converting keeps the password, 2FA, OAuth2 grants and access tokens,
and since sign-in rejects bots, converting back restores the account.
Only local, non-admin accounts can be converted, and conversions are
audited
5. Session, reverse proxy, SSPI, external source and password reset
sign-in reject non-individual users, so a bot never gets an interactive
session
6. Bots receive no notifications or emails

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: joestump <joe@joestump.net>
Co-authored-by: Joe Stump <joe@stu.mp>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
Joe (Agent) Stump
2026-09-18 12:43:36 +00:00
committed by GitHub
co-authored by Nicolas joestump Joe Stump silverwind Lunny Xiao
parent db7dbd5a6b
commit 3bec08f998
71 changed files with 1246 additions and 330 deletions
+2 -4
View File
@@ -120,8 +120,7 @@ func mailIssueCommentBatch(ctx context.Context, comment *mailComment, users []*u
langMap := make(map[string][]*user_model.User)
for _, user := range users {
if !user.IsActive {
// Exclude deactivated users
if !user.IsMailable() {
continue
}
// At this point we exclude:
@@ -205,8 +204,7 @@ func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer
langMap := make(map[string][]*user_model.User)
for _, user := range recipients {
if !user.IsActive {
// don't send emails to inactive users
if !user.IsMailable() {
continue
}
langMap[user.Language] = append(langMap[user.Language], user)
+5 -3
View File
@@ -38,8 +38,7 @@ func SendRepoTransferNotifyMail(ctx context.Context, doer, newOwner *user_model.
langMap := make(map[string][]*user_model.User)
for _, user := range users {
if !user.IsActive {
// don't send emails to inactive users
if !user.IsMailable() {
continue
}
langMap[user.Language] = append(langMap[user.Language], user)
@@ -54,6 +53,9 @@ func SendRepoTransferNotifyMail(ctx context.Context, doer, newOwner *user_model.
return nil
}
if newOwner.IsTypeBot() {
return nil
}
return sendRepoTransferNotifyMailPerLang(newOwner.Language, newOwner, doer, []*user_model.User{newOwner}, repo)
}
@@ -98,7 +100,7 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U
// SendCollaboratorMail sends mail notification to new collaborator.
func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) {
if setting.MailService == nil || !u.IsActive {
if setting.MailService == nil || !u.IsMailable() {
return
}
locale := translation.NewLocale(u.Language)
+22
View File
@@ -168,6 +168,28 @@ func TestMailMentionsComment(t *testing.T) {
assert.Equal(t, 3, mails)
}
func TestMailsSkipBots(t *testing.T) {
doer, repo, issue, comment := prepareMailerTest(t)
comment.Poster = doer
var recipients []string
defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) {
for _, msg := range msgs {
recipients = append(recipients, msg.To)
}
})()
require.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: 5, Type: user_model.UserTypeBot}, "type"))
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
require.NoError(t, SendIssueAssignedMail(t.Context(), issue, doer, "", comment, []*user_model.User{user, bot}))
require.NoError(t, MailParticipantsComment(t.Context(), comment, activities_model.ActionCommentIssue, issue, []*user_model.User{bot}))
require.NoError(t, SendRepoTransferNotifyMail(t.Context(), doer, bot, repo))
SendCollaboratorMail(bot, doer, repo)
SendRegisterNotifyMail(bot)
assert.Contains(t, recipients, user.Email)
assert.NotContains(t, strings.Join(recipients, " "), bot.Email)
}
func TestComposeIssueMessage(t *testing.T) {
doer, _, issue, _ := prepareMailerTest(t)
+1 -2
View File
@@ -101,8 +101,7 @@ func SendActivateEmailMail(u *user_model.User, email string) {
// SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
func SendRegisterNotifyMail(u *user_model.User) {
if setting.MailService == nil || !u.IsActive {
// No mail service configured OR user is inactive
if setting.MailService == nil || !u.IsMailable() {
return
}
locale := translation.NewLocale(u.Language)