mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-14 10:57:54 +02:00
Merge ae0b9a3b3e410887e01bd39e5349c329daf91d59 into ce089f498bce32305b2d9e8c6adfd8cb7c82f88f
This commit is contained in:
commit
0b72a065ea
93
models/auth/github_app_credential.go
Normal file
93
models/auth/github_app_credential.go
Normal file
@ -0,0 +1,93 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// GithubAppCredential represents a GitHub App credential for migrations
|
||||
type GithubAppCredential struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
AppID int64 `xorm:"NOT NULL"`
|
||||
InstallationID int64 `xorm:"NOT NULL"`
|
||||
PrivateKeyEncrypted string `xorm:"TEXT NOT NULL"`
|
||||
BaseURL string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'https://api.github.com'"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
// HasRecentActivity returns true if this credential was used recently
|
||||
func (g *GithubAppCredential) HasRecentActivity() bool {
|
||||
// Consider activity within the last 7 days as recent
|
||||
return timeutil.TimeStampNow()-g.UpdatedUnix < 7*24*3600
|
||||
}
|
||||
|
||||
// HasUsed returns true if this credential has been used (updated after creation)
|
||||
func (g *GithubAppCredential) HasUsed() bool {
|
||||
return g.UpdatedUnix > g.CreatedUnix
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(GithubAppCredential))
|
||||
}
|
||||
|
||||
// TableName returns the table name for GithubAppCredential
|
||||
func (g *GithubAppCredential) TableName() string {
|
||||
return "github_app_credential"
|
||||
}
|
||||
|
||||
// CreateGithubAppCredential creates a new GitHub App credential
|
||||
func CreateGithubAppCredential(ctx context.Context, cred *GithubAppCredential) error {
|
||||
_, err := db.GetEngine(ctx).Insert(cred)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetGithubAppCredentialByID gets a GitHub App credential by ID
|
||||
func GetGithubAppCredentialByID(ctx context.Context, id int64) (*GithubAppCredential, error) {
|
||||
cred := &GithubAppCredential{}
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, util.ErrNotExist
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
// GetGithubAppCredentialsByOwnerID gets all GitHub App credentials for an owner
|
||||
func GetGithubAppCredentialsByOwnerID(ctx context.Context, ownerID int64) ([]*GithubAppCredential, error) {
|
||||
creds := make([]*GithubAppCredential, 0, 5)
|
||||
return creds, db.GetEngine(ctx).
|
||||
Where("owner_id = ?", ownerID).
|
||||
Find(&creds)
|
||||
}
|
||||
|
||||
// UpdateGithubAppCredential updates a GitHub App credential
|
||||
func UpdateGithubAppCredential(ctx context.Context, cred *GithubAppCredential) error {
|
||||
_, err := db.GetEngine(ctx).ID(cred.ID).AllCols().Update(cred)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteGithubAppCredential deletes a GitHub App credential
|
||||
func DeleteGithubAppCredential(ctx context.Context, id int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(&GithubAppCredential{})
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckGithubAppCredentialOwnership checks if a user owns a GitHub App credential
|
||||
func CheckGithubAppCredentialOwnership(ctx context.Context, credID, ownerID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).
|
||||
Where(builder.Eq{"id": credID, "owner_id": ownerID}).
|
||||
Exist(&GithubAppCredential{})
|
||||
}
|
||||
@ -409,6 +409,9 @@ func prepareMigrationTasks() []*migration {
|
||||
// Gitea 1.26.0 ends at migration ID number 330 (database version 331)
|
||||
|
||||
newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel),
|
||||
// Gitea 1.27.0 ends at migration ID number 331 (database version 332)
|
||||
|
||||
newMigration(332, "Add GitHub App credential table", v1_27.AddGithubAppCredentialTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
28
models/migrations/v1_27/v332.go
Normal file
28
models/migrations/v1_27/v332.go
Normal file
@ -0,0 +1,28 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddGithubAppCredentialTable(ctx context.Context, x *xorm.Engine) error {
|
||||
type GithubAppCredential struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
AppID int64 `xorm:"NOT NULL"`
|
||||
InstallationID int64 `xorm:"NOT NULL"`
|
||||
PrivateKeyEncrypted string `xorm:"TEXT NOT NULL"`
|
||||
BaseURL string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'https://api.github.com'"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
return x.Sync(new(GithubAppCredential))
|
||||
}
|
||||
228
modules/auth/github_app.go
Normal file
228
modules/auth/github_app.go
Normal file
@ -0,0 +1,228 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/go-github/v84/github"
|
||||
)
|
||||
|
||||
// GitHubAppTokenCache caches installation access tokens
|
||||
type GitHubAppTokenCache struct {
|
||||
mu sync.RWMutex
|
||||
tokens map[string]*cachedToken
|
||||
}
|
||||
|
||||
type cachedToken struct {
|
||||
token string
|
||||
expiresAt time.Time
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var globalTokenCache = &GitHubAppTokenCache{
|
||||
tokens: make(map[string]*cachedToken),
|
||||
}
|
||||
|
||||
// GenerateGitHubAppJWT generates a JWT for GitHub App authentication
|
||||
func GenerateGitHubAppJWT(appID int64, privateKeyPEM string) (string, error) {
|
||||
// Log the private key format for debugging
|
||||
lines := strings.Split(privateKeyPEM, "\n")
|
||||
if len(lines) > 0 {
|
||||
log.Debug("Private key first line: %s", lines[0])
|
||||
if len(lines) > 1 {
|
||||
log.Debug("Private key last line: %s", lines[len(lines)-1])
|
||||
}
|
||||
log.Debug("Private key total lines: %d", len(lines))
|
||||
}
|
||||
|
||||
// Parse the private key
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return "", errors.New("failed to parse PEM block containing the private key")
|
||||
}
|
||||
|
||||
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
// Try PKCS8 format
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
privateKey, ok = key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return "", errors.New("private key is not RSA")
|
||||
}
|
||||
}
|
||||
|
||||
// Create JWT claims
|
||||
now := time.Now()
|
||||
claims := jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), // GitHub requires max 10 minutes
|
||||
Issuer: strconv.FormatInt(appID, 10),
|
||||
}
|
||||
|
||||
// Create token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
|
||||
// Sign token
|
||||
signedToken, err := token.SignedString(privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||
}
|
||||
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
// GetGitHubAppInstallationToken exchanges a JWT for an installation access token
|
||||
func GetGitHubAppInstallationToken(ctx context.Context, appID, installationID int64, privateKeyPEM, baseURL string, httpTransport *http.Transport) (string, *http.Client, error) {
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("%d:%d:%s", appID, installationID, baseURL)
|
||||
|
||||
globalTokenCache.mu.RLock()
|
||||
cached, exists := globalTokenCache.tokens[cacheKey]
|
||||
globalTokenCache.mu.RUnlock()
|
||||
|
||||
if exists && time.Now().Before(cached.expiresAt.Add(-5*time.Minute)) {
|
||||
// Token is still valid (with 5 minute buffer)
|
||||
return cached.token, cached.httpClient, nil
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
jwtToken, err := GenerateGitHubAppJWT(appID, privateKeyPEM)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to generate JWT: %w", err)
|
||||
}
|
||||
log.Debug("Generated JWT for GitHub App %d (token: %s...%s)", appID, jwtToken[:20], jwtToken[len(jwtToken)-20:])
|
||||
|
||||
// Create HTTP client with JWT for authentication
|
||||
jwtHTTPClient := &http.Client{
|
||||
Transport: &jwtTransport{
|
||||
token: jwtToken,
|
||||
transport: httpTransport,
|
||||
},
|
||||
}
|
||||
|
||||
// Create GitHub client
|
||||
var githubClient *github.Client
|
||||
if baseURL == "" || baseURL == "https://api.github.com" {
|
||||
githubClient = github.NewClient(jwtHTTPClient)
|
||||
} else {
|
||||
githubClient, err = github.NewClient(jwtHTTPClient).WithEnterpriseURLs(baseURL, baseURL)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create GitHub client: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get installation access token
|
||||
installationToken, _, err := githubClient.Apps.CreateInstallationToken(
|
||||
ctx,
|
||||
installationID,
|
||||
&github.InstallationTokenOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create installation token: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP client with installation token
|
||||
tokenHTTPClient := &http.Client{
|
||||
Transport: &tokenTransport{
|
||||
token: installationToken.GetToken(),
|
||||
transport: httpTransport,
|
||||
},
|
||||
}
|
||||
|
||||
// Cache the token
|
||||
globalTokenCache.mu.Lock()
|
||||
globalTokenCache.tokens[cacheKey] = &cachedToken{
|
||||
token: installationToken.GetToken(),
|
||||
expiresAt: installationToken.GetExpiresAt().Time,
|
||||
httpClient: tokenHTTPClient,
|
||||
}
|
||||
globalTokenCache.mu.Unlock()
|
||||
|
||||
log.Debug("Generated new GitHub App installation token for app %d, installation %d (expires at %v)",
|
||||
appID, installationID, installationToken.GetExpiresAt().Time)
|
||||
|
||||
return installationToken.GetToken(), tokenHTTPClient, nil
|
||||
}
|
||||
|
||||
// jwtTransport is an http.RoundTripper that adds JWT authentication
|
||||
type jwtTransport struct {
|
||||
token string
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *jwtTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// GitHub requires "Bearer" prefix for JWT authentication
|
||||
req.Header.Set("Authorization", "Bearer "+t.token)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
|
||||
log.Debug("GitHub App JWT request: %s %s", req.Method, req.URL.String())
|
||||
|
||||
transport := t.transport
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
log.Error("GitHub App JWT request failed: %v", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
log.Debug("GitHub App JWT response: %d %s", resp.StatusCode, resp.Status)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// tokenTransport is an http.RoundTripper that adds token authentication
|
||||
type tokenTransport struct {
|
||||
token string
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("Authorization", "token "+t.token)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
|
||||
log.Debug("GitHub App token request: %s %s (token: %s...%s)", req.Method, req.URL.String(), t.token[:20], t.token[len(t.token)-20:])
|
||||
|
||||
transport := t.transport
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
log.Error("GitHub App token request failed: %v", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
log.Debug("GitHub App token response: %d %s", resp.StatusCode, resp.Status)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ClearTokenCache clears the token cache (useful for testing)
|
||||
func ClearTokenCache() {
|
||||
globalTokenCache.mu.Lock()
|
||||
defer globalTokenCache.mu.Unlock()
|
||||
globalTokenCache.tokens = make(map[string]*cachedToken)
|
||||
}
|
||||
@ -17,6 +17,8 @@ type MigrateOptions struct {
|
||||
AuthPasswordEncrypted string `json:"auth_password_encrypted,omitempty"`
|
||||
AuthToken string `json:"-"`
|
||||
AuthTokenEncrypted string `json:"auth_token_encrypted,omitempty"`
|
||||
// GitHub App authentication
|
||||
GitHubAppCredentialID int64 `json:"github_app_credential_id"`
|
||||
// required: true
|
||||
UID int `json:"uid" binding:"Required"`
|
||||
// required: true
|
||||
|
||||
@ -657,6 +657,25 @@
|
||||
"settings.ssh_gpg_keys": "SSH / GPG Keys",
|
||||
"settings.social": "Social Accounts",
|
||||
"settings.applications": "Applications",
|
||||
"settings.github_apps": "GitHub Apps",
|
||||
"settings.manage_github_apps": "Manage GitHub App Credentials",
|
||||
"settings.github_app_desc": "GitHub App credentials can be used for repository migrations with enhanced security and better rate limits.",
|
||||
"settings.github_app_name": "Credential Name",
|
||||
"settings.github_app_id": "App ID / Client ID",
|
||||
"settings.github_app_id_helper": "The GitHub App ID / Client ID from your app settings",
|
||||
"settings.github_app_installation_id": "Installation ID",
|
||||
"settings.github_app_installation_id_helper": "The Installation ID from your app installation",
|
||||
"settings.github_app_private_key": "Private Key (PEM format)",
|
||||
"settings.github_app_private_key_helper": "The private key generated for your GitHub App",
|
||||
"settings.github_app_base_url": "Base URL",
|
||||
"settings.github_app_base_url_helper": "For GitHub Enterprise Server, use your instance API URL (e.g., https://github.example.com/api/v3). Leave empty for GitHub.com.",
|
||||
"settings.add_github_app": "Add GitHub App Credential",
|
||||
"settings.add_github_app_success": "GitHub App credential has been added successfully.",
|
||||
"settings.delete_github_app": "Delete Credential",
|
||||
"settings.delete_github_app_success": "GitHub App credential has been deleted successfully.",
|
||||
"settings.github_app_deletion": "GitHub App Credential Deletion",
|
||||
"settings.github_app_deletion_desc": "Deleting a GitHub App credential will prevent it from being used for future migrations. This action cannot be undone. Continue?",
|
||||
"settings.github_app_state_desc": "This credential has been used recently",
|
||||
"settings.orgs": "Manage Organizations",
|
||||
"settings.repos": "Repositories",
|
||||
"settings.delete": "Delete Account",
|
||||
@ -1107,6 +1126,13 @@
|
||||
"repo.migrate_repo": "Migrate Repository",
|
||||
"repo.migrate.clone_address": "Migrate / Clone From URL",
|
||||
"repo.migrate.clone_address_desc": "The HTTP(S) or Git 'clone' URL of an existing repository",
|
||||
"repo.migrate.auth_method": "Authentication Method",
|
||||
"repo.migrate.auth_method_token": "Personal Access Token",
|
||||
"repo.migrate.auth_method_github_app": "GitHub App",
|
||||
"repo.migrate.github_app_credential": "GitHub App Credential",
|
||||
"repo.migrate.github_app_credential_select": "Select a credential",
|
||||
"repo.migrate.github_app_credential_helper": "Use a GitHub App for authentication with enhanced security and better rate limits.",
|
||||
"repo.migrate.github_app_credential_manage": "Manage credentials",
|
||||
"repo.migrate.github_token_desc": "You can put one or more tokens here, separated by commas, to make migrating faster by circumventing the GitHub API rate limit. WARNING: Abusing this feature may violate the service provider's policy and may lead to getting your account(s) blocked.",
|
||||
"repo.migrate.clone_local_path": "or a local server path",
|
||||
"repo.migrate.permission_denied": "You are not allowed to import local repositories.",
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
admin_model "code.gitea.io/gitea/models/admin"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
@ -68,6 +69,17 @@ func Migrate(ctx *context.Context) {
|
||||
}
|
||||
ctx.Data["ContextUser"] = ctxUser
|
||||
|
||||
// Load GitHub App credentials for GitHub migrations
|
||||
if serviceType == structs.GithubService && ctx.Doer != nil {
|
||||
credentials, err := auth_model.GetGithubAppCredentialsByOwnerID(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
log.Error("Failed to load GitHub App credentials: %v", err)
|
||||
} else {
|
||||
ctx.Data["GitHubAppCredentials"] = credentials
|
||||
log.Debug("Loaded %d GitHub App credentials for user %d", len(credentials), ctx.Doer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, templates.TplName("repo/migrate/"+serviceType.Name()))
|
||||
}
|
||||
|
||||
@ -205,25 +217,26 @@ func MigratePost(ctx *context.Context) {
|
||||
}
|
||||
|
||||
opts := migrations.MigrateOptions{
|
||||
OriginalURL: form.CloneAddr,
|
||||
GitServiceType: form.Service,
|
||||
CloneAddr: remoteAddr,
|
||||
RepoName: form.RepoName,
|
||||
Description: form.Description,
|
||||
Private: form.Private || setting.Repository.ForcePrivate,
|
||||
Mirror: form.Mirror,
|
||||
LFS: form.LFS,
|
||||
LFSEndpoint: form.LFSEndpoint,
|
||||
AuthUsername: form.AuthUsername,
|
||||
AuthPassword: form.AuthPassword,
|
||||
AuthToken: form.AuthToken,
|
||||
Wiki: form.Wiki,
|
||||
Issues: form.Issues,
|
||||
Milestones: form.Milestones,
|
||||
Labels: form.Labels,
|
||||
Comments: form.Issues || form.PullRequests,
|
||||
PullRequests: form.PullRequests,
|
||||
Releases: form.Releases,
|
||||
OriginalURL: form.CloneAddr,
|
||||
GitServiceType: form.Service,
|
||||
CloneAddr: remoteAddr,
|
||||
RepoName: form.RepoName,
|
||||
Description: form.Description,
|
||||
Private: form.Private || setting.Repository.ForcePrivate,
|
||||
Mirror: form.Mirror,
|
||||
LFS: form.LFS,
|
||||
LFSEndpoint: form.LFSEndpoint,
|
||||
AuthUsername: form.AuthUsername,
|
||||
AuthPassword: form.AuthPassword,
|
||||
AuthToken: form.AuthToken,
|
||||
GitHubAppCredentialID: form.GitHubAppCredentialID,
|
||||
Wiki: form.Wiki,
|
||||
Issues: form.Issues,
|
||||
Milestones: form.Milestones,
|
||||
Labels: form.Labels,
|
||||
Comments: form.Issues || form.PullRequests,
|
||||
PullRequests: form.PullRequests,
|
||||
Releases: form.Releases,
|
||||
}
|
||||
if opts.Mirror {
|
||||
opts.Issues = false
|
||||
@ -263,6 +276,16 @@ func setMigrationContextData(ctx *context.Context, serviceType structs.GitServic
|
||||
// Plain git should be first
|
||||
ctx.Data["Services"] = append([]structs.GitServiceType{structs.PlainGitService}, structs.SupportedFullGitService...)
|
||||
ctx.Data["service"] = serviceType
|
||||
|
||||
// Load GitHub App credentials for the current user if viewing GitHub migration
|
||||
if serviceType == structs.GithubService && ctx.Doer != nil {
|
||||
credentials, err := auth_model.GetGithubAppCredentialsByOwnerID(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
log.Error("Failed to load GitHub App credentials: %v", err)
|
||||
} else {
|
||||
ctx.Data["GitHubAppCredentials"] = credentials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func MigrateRetryPost(ctx *context.Context) {
|
||||
|
||||
125
routers/web/user/setting/github_apps.go
Normal file
125
routers/web/user/setting/github_apps.go
Normal file
@ -0,0 +1,125 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/modules/secret"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsGitHubApps templates.TplName = "user/settings/github_apps"
|
||||
)
|
||||
|
||||
// GitHubApps renders the GitHub App credentials management page
|
||||
func GitHubApps(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("settings.github_apps")
|
||||
ctx.Data["PageIsSettingsGitHubApps"] = true
|
||||
|
||||
loadGitHubAppsData(ctx)
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsGitHubApps)
|
||||
}
|
||||
|
||||
// GitHubAppsPost handles adding a new GitHub App credential
|
||||
func GitHubAppsPost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.NewGitHubAppCredentialForm)
|
||||
ctx.Data["Title"] = ctx.Tr("settings.github_apps")
|
||||
ctx.Data["PageIsSettingsGitHubApps"] = true
|
||||
|
||||
if ctx.HasError() {
|
||||
loadGitHubAppsData(ctx)
|
||||
ctx.HTML(http.StatusOK, tplSettingsGitHubApps)
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize and validate the private key
|
||||
privateKey := strings.TrimSpace(form.PrivateKey)
|
||||
|
||||
// Ensure the private key has proper PEM format
|
||||
if !strings.HasPrefix(privateKey, "-----BEGIN") {
|
||||
ctx.Flash.Error(ctx.Tr("settings.github_app_invalid_private_key"))
|
||||
loadGitHubAppsData(ctx)
|
||||
ctx.HTML(http.StatusOK, tplSettingsGitHubApps)
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(privateKey, "-----") {
|
||||
ctx.Flash.Error(ctx.Tr("settings.github_app_invalid_private_key"))
|
||||
loadGitHubAppsData(ctx)
|
||||
ctx.HTML(http.StatusOK, tplSettingsGitHubApps)
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt the private key
|
||||
encryptedKey, err := secret.EncryptSecret(setting.SecretKey, privateKey)
|
||||
if err != nil {
|
||||
ctx.ServerError("EncryptSecret", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set default base URL if empty
|
||||
baseURL := form.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.github.com"
|
||||
}
|
||||
|
||||
cred := &auth_model.GithubAppCredential{
|
||||
OwnerID: ctx.Doer.ID,
|
||||
Name: form.Name,
|
||||
AppID: form.AppID,
|
||||
InstallationID: form.InstallationID,
|
||||
PrivateKeyEncrypted: encryptedKey,
|
||||
BaseURL: baseURL,
|
||||
}
|
||||
|
||||
if err := auth_model.CreateGithubAppCredential(ctx, cred); err != nil {
|
||||
ctx.ServerError("CreateGithubAppCredential", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.add_github_app_success"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/github_apps")
|
||||
}
|
||||
|
||||
// DeleteGitHubApp handles deleting a GitHub App credential
|
||||
func DeleteGitHubApp(ctx *context.Context) {
|
||||
id := ctx.FormInt64("id")
|
||||
|
||||
// Check ownership
|
||||
owned, err := auth_model.CheckGithubAppCredentialOwnership(ctx, id, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("CheckGithubAppCredentialOwnership", err)
|
||||
return
|
||||
}
|
||||
if !owned {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth_model.DeleteGithubAppCredential(ctx, id); err != nil {
|
||||
ctx.Flash.Error("DeleteGithubAppCredential: " + err.Error())
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("settings.delete_github_app_success"))
|
||||
}
|
||||
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/github_apps")
|
||||
}
|
||||
|
||||
func loadGitHubAppsData(ctx *context.Context) {
|
||||
creds, err := auth_model.GetGithubAppCredentialsByOwnerID(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetGithubAppCredentialsByOwnerID", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Credentials"] = creds
|
||||
}
|
||||
@ -670,6 +670,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/delete", user_setting.DeleteApplication)
|
||||
})
|
||||
|
||||
m.Group("/github_apps", func() {
|
||||
m.Combo("").Get(user_setting.GitHubApps).
|
||||
Post(web.Bind(forms.NewGitHubAppCredentialForm{}), user_setting.GitHubAppsPost)
|
||||
m.Post("/delete", user_setting.DeleteGitHubApp)
|
||||
})
|
||||
|
||||
m.Combo("/keys").Get(user_setting.Keys).
|
||||
Post(web.Bind(forms.AddKeyForm{}), user_setting.KeysPost)
|
||||
m.Post("/keys/delete", user_setting.DeleteKey)
|
||||
|
||||
@ -96,3 +96,18 @@ func (f *AdminDashboardForm) Validate(req *http.Request, errs binding.Errors) bi
|
||||
ctx := context.GetValidateContext(req)
|
||||
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
|
||||
}
|
||||
|
||||
// NewGitHubAppCredentialForm form for creating GitHub App credential
|
||||
type NewGitHubAppCredentialForm struct {
|
||||
Name string `binding:"Required;MaxSize(255)" locale:"settings.github_app_name"`
|
||||
AppID int64 `binding:"Required" locale:"settings.github_app_id"`
|
||||
InstallationID int64 `binding:"Required" locale:"settings.github_app_installation_id"`
|
||||
PrivateKey string `binding:"Required" locale:"settings.github_app_private_key"`
|
||||
BaseURL string `binding:"MaxSize(255)" locale:"settings.github_app_base_url"`
|
||||
}
|
||||
|
||||
// Validate validates the fields
|
||||
func (f *NewGitHubAppCredentialForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
|
||||
ctx := context.GetValidateContext(req)
|
||||
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
|
||||
}
|
||||
|
||||
@ -57,11 +57,12 @@ func (f *CreateRepoForm) Validate(req *http.Request, errs binding.Errors) bindin
|
||||
// this is used to interact with web ui
|
||||
type MigrateRepoForm struct {
|
||||
// required: true
|
||||
CloneAddr string `json:"clone_addr" binding:"Required"`
|
||||
Service structs.GitServiceType `json:"service"`
|
||||
AuthUsername string `json:"auth_username"`
|
||||
AuthPassword string `json:"auth_password"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
CloneAddr string `json:"clone_addr" binding:"Required"`
|
||||
Service structs.GitServiceType `json:"service"`
|
||||
AuthUsername string `json:"auth_username"`
|
||||
AuthPassword string `json:"auth_password"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
GitHubAppCredentialID int64 `json:"github_app_credential_id" form:"github_app_credential_id"`
|
||||
// required: true
|
||||
UID int64 `json:"uid" binding:"Required"`
|
||||
// required: true
|
||||
|
||||
@ -14,10 +14,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/modules/auth"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
base "code.gitea.io/gitea/modules/migration"
|
||||
"code.gitea.io/gitea/modules/proxy"
|
||||
"code.gitea.io/gitea/modules/secret"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
|
||||
"github.com/google/go-github/v84/github"
|
||||
@ -52,6 +56,12 @@ func (f *GithubDownloaderV3Factory) New(ctx context.Context, opts base.MigrateOp
|
||||
|
||||
log.Trace("Create github downloader BaseURL: %s %s/%s", baseURL, oldOwner, oldName)
|
||||
|
||||
// Check if GitHub App authentication is requested
|
||||
if opts.GitHubAppCredentialID > 0 {
|
||||
log.Info("Using GitHub App authentication with credential ID: %d", opts.GitHubAppCredentialID)
|
||||
return NewGithubDownloaderV3WithApp(ctx, baseURL, opts.GitHubAppCredentialID, oldOwner, oldName)
|
||||
}
|
||||
|
||||
return NewGithubDownloaderV3(ctx, baseURL, opts.AuthUsername, opts.AuthPassword, opts.AuthToken, oldOwner, oldName), nil
|
||||
}
|
||||
|
||||
@ -118,6 +128,68 @@ func NewGithubDownloaderV3(_ context.Context, baseURL, userName, password, token
|
||||
return &downloader
|
||||
}
|
||||
|
||||
// NewGithubDownloaderV3WithApp creates a github Downloader using GitHub App authentication
|
||||
func NewGithubDownloaderV3WithApp(ctx context.Context, baseURL string, credentialID int64, repoOwner, repoName string) (*GithubDownloaderV3, error) {
|
||||
// Get the GitHub App credential from database
|
||||
cred, err := auth_model.GetGithubAppCredentialByID(ctx, credentialID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get GitHub App credential: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the private key
|
||||
privateKey, err := secret.DecryptSecret(setting.SecretKey, cred.PrivateKeyEncrypted)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
// Determine base URL for API
|
||||
apiBaseURL := cred.BaseURL
|
||||
if apiBaseURL == "" {
|
||||
apiBaseURL = "https://api.github.com"
|
||||
}
|
||||
|
||||
// Get installation access token
|
||||
token, _, err := auth.GetGitHubAppInstallationToken(
|
||||
ctx,
|
||||
cred.AppID,
|
||||
cred.InstallationID,
|
||||
privateKey,
|
||||
apiBaseURL,
|
||||
NewMigrationHTTPTransport(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get GitHub App installation token: %w", err)
|
||||
}
|
||||
|
||||
downloader := &GithubDownloaderV3{
|
||||
baseURL: baseURL,
|
||||
repoOwner: repoOwner,
|
||||
repoName: repoName,
|
||||
maxPerPage: 100,
|
||||
}
|
||||
|
||||
// Use the token directly with go-github's built-in authentication
|
||||
// Create a simple HTTP client with the migration transport
|
||||
httpClient := &http.Client{
|
||||
Transport: NewMigrationHTTPTransport(),
|
||||
}
|
||||
|
||||
// Create GitHub client and set the auth token
|
||||
githubClient := github.NewClient(httpClient).WithAuthToken(token)
|
||||
|
||||
if baseURL != "https://github.com" && baseURL != "https://api.github.com" {
|
||||
githubClient, err = githubClient.WithEnterpriseURLs(baseURL, baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set enterprise URLs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
downloader.clients = append(downloader.clients, githubClient)
|
||||
downloader.rates = append(downloader.rates, nil)
|
||||
|
||||
return downloader, nil
|
||||
}
|
||||
|
||||
// String implements Stringer
|
||||
func (g *GithubDownloaderV3) String() string {
|
||||
return fmt.Sprintf("migration from github server %s %s/%s", g.baseURL, g.repoOwner, g.repoName)
|
||||
|
||||
@ -18,7 +18,20 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="inline field {{if .Err_Auth}}error{{end}}">
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.migrate.auth_method"}}</label>
|
||||
<div class="ui selection dropdown">
|
||||
<input type="hidden" name="auth_method" value="{{.auth_method}}">
|
||||
<div class="default text">{{ctx.Locale.Tr "repo.migrate.auth_method_token"}}</div>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu">
|
||||
<div class="item" data-value="token">{{ctx.Locale.Tr "repo.migrate.auth_method_token"}}</div>
|
||||
<div class="item" data-value="github_app">{{ctx.Locale.Tr "repo.migrate.auth_method_github_app"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auth_token_field" class="inline field {{if .Err_Auth}}error{{end}}">
|
||||
<label for="auth_token">{{ctx.Locale.Tr "access_token"}}</label>
|
||||
<input id="auth_token" name="auth_token" type="password" autocomplete="new-password" value="{{.auth_token}}" {{if not .auth_token}}data-need-clear="true"{{end}}>
|
||||
<a target="_blank" href="https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token">{{svg "octicon-question"}}</a>
|
||||
@ -27,6 +40,19 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="github_app_field" class="inline field {{if .Err_GithubAppCredential}}error{{end}}" style="display: none;">
|
||||
<label for="github_app_credential_id">{{ctx.Locale.Tr "repo.migrate.github_app_credential"}}</label>
|
||||
<select id="github_app_credential_id" name="github_app_credential_id" class="ui selection dropdown">
|
||||
<option value="">{{ctx.Locale.Tr "repo.migrate.github_app_credential_select"}}</option>
|
||||
{{range .GitHubAppCredentials}}
|
||||
<option value="{{.ID}}" {{if eq $.github_app_credential_id .ID}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<span class="help">
|
||||
{{ctx.Locale.Tr "repo.migrate.github_app_credential_helper"}} <a href="{{AppSubUrl}}/user/settings/github_apps">{{ctx.Locale.Tr "repo.migrate.github_app_credential_manage"}}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{template "repo/migrate/options" .}}
|
||||
|
||||
<div class="inline field">
|
||||
|
||||
101
templates/user/settings/github_apps.tmpl
Normal file
101
templates/user/settings/github_apps.tmpl
Normal file
@ -0,0 +1,101 @@
|
||||
{{template "user/settings/layout_head" (dict "pageClass" "user settings github-apps")}}
|
||||
<div class="user-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "settings.manage_github_apps"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="flex-divided-list items-with-main">
|
||||
<div class="item">
|
||||
{{ctx.Locale.Tr "settings.github_app_desc"}}
|
||||
</div>
|
||||
{{range .Credentials}}
|
||||
<div class="item">
|
||||
<div class="item-leading">
|
||||
<span class="{{if .HasRecentActivity}}tw-text-green{{end}}" {{if .HasRecentActivity}}data-tooltip-content="{{ctx.Locale.Tr "settings.github_app_state_desc"}}"{{end}}>
|
||||
{{svg "octicon-key" 32}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-main">
|
||||
<details>
|
||||
<summary><span class="item-title">{{.Name}}</span></summary>
|
||||
<p class="tw-my-1">
|
||||
{{ctx.Locale.Tr "settings.github_app_id"}}: {{.AppID}}
|
||||
</p>
|
||||
<p class="tw-my-1">
|
||||
{{ctx.Locale.Tr "settings.github_app_installation_id"}}: {{.InstallationID}}
|
||||
</p>
|
||||
<p class="tw-my-1">
|
||||
{{ctx.Locale.Tr "settings.github_app_base_url"}}: {{.BaseURL}}
|
||||
</p>
|
||||
</details>
|
||||
<div class="item-body">
|
||||
<i>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}} — {{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="tw-text-green"{{end}}>{{DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}</i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-trailing">
|
||||
<button class="ui red tiny button delete-button" data-modal-id="delete-github-app" data-url="{{$.Link}}/delete" data-id="{{.ID}}">
|
||||
{{svg "octicon-trash"}}
|
||||
{{ctx.Locale.Tr "settings.delete_github_app"}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui bottom attached segment">
|
||||
<details {{if or .name (not .Credentials)}}open{{end}}>
|
||||
<summary><h4 class="ui header tw-inline-block tw-my-2">{{ctx.Locale.Tr "settings.add_github_app"}}</h4></summary>
|
||||
<form class="ui form ignore-dirty" action="{{.Link}}" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="field {{if .Err_Name}}error{{end}}">
|
||||
<label for="name">{{ctx.Locale.Tr "settings.github_app_name"}}</label>
|
||||
<input id="name" name="name" value="{{.name}}" required maxlength="255">
|
||||
</div>
|
||||
<div class="field {{if .Err_AppID}}error{{end}}">
|
||||
<label for="app_id">{{ctx.Locale.Tr "settings.github_app_id"}}</label>
|
||||
<input id="app_id" name="app_id" type="number" value="{{.app_id}}" required>
|
||||
<span class="help">
|
||||
{{ctx.Locale.Tr "settings.github_app_id_helper"}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field {{if .Err_InstallationID}}error{{end}}">
|
||||
<label for="installation_id">{{ctx.Locale.Tr "settings.github_app_installation_id"}}</label>
|
||||
<input id="installation_id" name="installation_id" type="number" value="{{.installation_id}}" required>
|
||||
<span class="help">
|
||||
{{ctx.Locale.Tr "settings.github_app_installation_id_helper"}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field {{if .Err_PrivateKey}}error{{end}}">
|
||||
<label for="private_key">{{ctx.Locale.Tr "settings.github_app_private_key"}}</label>
|
||||
<textarea id="private_key" name="private_key" rows="10" required placeholder="-----BEGIN RSA PRIVATE KEY----- ... -----END RSA PRIVATE KEY-----">{{.private_key}}</textarea>
|
||||
<span class="help">
|
||||
{{ctx.Locale.Tr "settings.github_app_private_key_helper"}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field {{if .Err_BaseURL}}error{{end}}">
|
||||
<label for="base_url">{{ctx.Locale.Tr "settings.github_app_base_url"}}</label>
|
||||
<input id="base_url" name="base_url" value="{{.base_url}}" placeholder="https://api.github.com" maxlength="255">
|
||||
<span class="help">
|
||||
{{ctx.Locale.Tr "settings.github_app_base_url_helper"}}
|
||||
</span>
|
||||
</div>
|
||||
<button class="ui primary button">
|
||||
{{ctx.Locale.Tr "settings.add_github_app"}}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui g-modal-confirm delete modal" id="delete-github-app">
|
||||
<div class="header">
|
||||
{{svg "octicon-trash"}}
|
||||
{{ctx.Locale.Tr "settings.github_app_deletion"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{{ctx.Locale.Tr "settings.github_app_deletion_desc"}}</p>
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm"}}
|
||||
</div>
|
||||
|
||||
{{template "user/settings/layout_footer" .}}
|
||||
@ -28,6 +28,9 @@
|
||||
<a class="{{if .PageIsSettingsApplications}}active {{end}}item" href="{{AppSubUrl}}/user/settings/applications">
|
||||
{{ctx.Locale.Tr "settings.applications"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsGitHubApps}}active {{end}}item" href="{{AppSubUrl}}/user/settings/github_apps">
|
||||
{{ctx.Locale.Tr "settings.github_apps"}}
|
||||
</a>
|
||||
{{if not ($.UserDisabledFeatures.Contains "manage_ssh_keys" "manage_gpg_keys")}}
|
||||
<a class="{{if .PageIsSettingsKeys}}active {{end}}item" href="{{AppSubUrl}}/user/settings/keys">
|
||||
{{ctx.Locale.Tr "settings.ssh_gpg_keys"}}
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
|
||||
export function initRepoMigrationStatusChecker() {
|
||||
const repoMigrating = document.querySelector('#repo_migrating');
|
||||
if (!repoMigrating) return;
|
||||
|
||||
document.querySelector<HTMLButtonElement>('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry);
|
||||
|
||||
const repoLink = repoMigrating.getAttribute('data-migrating-repo-link');
|
||||
|
||||
// returns true if the refresh still needs to be called after a while
|
||||
const refresh = async () => {
|
||||
const res = await GET(`${repoLink}/-/migrate/status`);
|
||||
if (res.status !== 200) return true; // continue to refresh if network error occurs
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// for all status
|
||||
if (data.message) {
|
||||
document.querySelector('#repo_migrating_progress_message')!.textContent = data.message;
|
||||
}
|
||||
|
||||
// TaskStatusFinished
|
||||
if (data.status === 4) {
|
||||
window.location.reload();
|
||||
return false;
|
||||
}
|
||||
|
||||
// TaskStatusFailed
|
||||
if (data.status === 3) {
|
||||
hideElem('#repo_migrating_progress');
|
||||
hideElem('#repo_migrating');
|
||||
showElem('#repo_migrating_retry');
|
||||
showElem('#repo_migrating_failed');
|
||||
showElem('#repo_migrating_failed_image');
|
||||
document.querySelector('#repo_migrating_failed_error')!.textContent = data.message;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // continue to refresh
|
||||
};
|
||||
|
||||
const syncTaskStatus = async () => {
|
||||
let doNextRefresh = true;
|
||||
try {
|
||||
doNextRefresh = await refresh();
|
||||
} finally {
|
||||
if (doNextRefresh) {
|
||||
setTimeout(syncTaskStatus, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
syncTaskStatus(); // no await
|
||||
}
|
||||
|
||||
async function doMigrationRetry(e: Event) {
|
||||
await POST((e.target as HTMLElement).getAttribute('data-migrating-task-retry-url')!);
|
||||
window.location.reload();
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {sanitizeRepoName} from './repo-common.ts';
|
||||
|
||||
const service = document.querySelector<HTMLInputElement>('#service_type');
|
||||
@ -78,3 +79,88 @@ function setLFSSettingsVisibility() {
|
||||
toggleElem(lfsSettings, visible);
|
||||
hideElem(lfsEndpoint);
|
||||
}
|
||||
|
||||
export function initRepoMigrate() {
|
||||
const authMethodDropdown = document.querySelector<HTMLInputElement>('input[name="auth_method"]');
|
||||
if (!authMethodDropdown) return;
|
||||
|
||||
const authTokenField = document.querySelector<HTMLElement>('#auth_token_field');
|
||||
const githubAppField = document.querySelector<HTMLElement>('#github_app_field');
|
||||
|
||||
function updateAuthFields() {
|
||||
const authMethod = authMethodDropdown?.value || 'token';
|
||||
|
||||
if (authMethod === 'github_app') {
|
||||
authTokenField?.style.setProperty('display', 'none');
|
||||
githubAppField?.style.removeProperty('display');
|
||||
} else {
|
||||
authTokenField?.style.removeProperty('display');
|
||||
githubAppField?.style.setProperty('display', 'none');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
updateAuthFields();
|
||||
|
||||
// Listen for changes
|
||||
authMethodDropdown.addEventListener('change', updateAuthFields);
|
||||
}
|
||||
|
||||
export function initRepoMigrationStatusChecker() {
|
||||
const repoMigrating = document.querySelector('#repo_migrating');
|
||||
if (!repoMigrating) return;
|
||||
|
||||
document.querySelector<HTMLButtonElement>('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry);
|
||||
|
||||
const repoLink = repoMigrating.getAttribute('data-migrating-repo-link');
|
||||
|
||||
// returns true if the refresh still needs to be called after a while
|
||||
const refresh = async () => {
|
||||
const res = await GET(`${repoLink}/-/migrate/status`);
|
||||
if (res.status !== 200) return true; // continue to refresh if network error occurs
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// for all status
|
||||
if (data.message) {
|
||||
document.querySelector('#repo_migrating_progress_message')!.textContent = data.message;
|
||||
}
|
||||
|
||||
// TaskStatusFinished
|
||||
if (data.status === 4) {
|
||||
window.location.reload();
|
||||
return false;
|
||||
}
|
||||
|
||||
// TaskStatusFailed
|
||||
if (data.status === 3) {
|
||||
hideElem('#repo_migrating_progress');
|
||||
hideElem('#repo_migrating');
|
||||
showElem('#repo_migrating_retry');
|
||||
showElem('#repo_migrating_failed');
|
||||
showElem('#repo_migrating_failed_image');
|
||||
document.querySelector('#repo_migrating_failed_error')!.textContent = data.message;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // continue to refresh
|
||||
};
|
||||
|
||||
const syncTaskStatus = async () => {
|
||||
let doNextRefresh = true;
|
||||
try {
|
||||
doNextRefresh = await refresh();
|
||||
} finally {
|
||||
if (doNextRefresh) {
|
||||
setTimeout(syncTaskStatus, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
syncTaskStatus(); // no await
|
||||
}
|
||||
|
||||
async function doMigrationRetry(e: Event) {
|
||||
await POST((e.target as HTMLElement).getAttribute('data-migrating-task-retry-url')!);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
|
||||
import {initRepoGraphGit} from './features/repo-graph.ts';
|
||||
import {initHeatmap} from './features/heatmap.ts';
|
||||
import {initImageDiff} from './features/imagediff.ts';
|
||||
import {initRepoMigration} from './features/repo-migration.ts';
|
||||
import {initRepoMigration, initRepoMigrate, initRepoMigrationStatusChecker} from './features/repo-migration.ts';
|
||||
import {initRepoProjectsView} from './features/repo-projects.ts';
|
||||
import {initTableSort} from './features/tablesort.ts';
|
||||
import {initAdminUserListSearchForm} from './features/admin/users.ts';
|
||||
@ -27,7 +27,6 @@ import {initRepoCodeView} from './features/repo-code.ts';
|
||||
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
|
||||
import {initUserSettings} from './features/user-settings.ts';
|
||||
import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts';
|
||||
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
|
||||
import {initRepoDiffView} from './features/repo-diff.ts';
|
||||
import {initOrgTeam} from './features/org-team.ts';
|
||||
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
|
||||
@ -130,6 +129,7 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initRepoIssueList,
|
||||
initRepoIssueFilterItemLabel,
|
||||
initRepoMigration,
|
||||
initRepoMigrate,
|
||||
initRepoMigrationStatusChecker,
|
||||
initRepoProjectsView,
|
||||
initRepoPullRequestReview,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user