diff --git a/.golangci.yml b/.golangci.yml index d6ce37f49a..99e033dfe5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -104,6 +104,12 @@ issues: - gosec - unparam - staticcheck + - path: services/f3/driver/base.go + linters: + - gosimple + - path: services/f3/driver + linters: + - dupl - path: models/migrations/v linters: - gocyclo diff --git a/cmd/forgejo/actions.go b/cmd/forgejo/actions.go index 4949dfcba5..6d3e70c0d3 100644 --- a/cmd/forgejo/actions.go +++ b/cmd/forgejo/actions.go @@ -35,7 +35,8 @@ func SubcmdActionsGenerateRunnerToken(ctx context.Context) *cli.Command { return &cli.Command{ Name: "generate-runner-token", Usage: "Generate a new token for a runner to use to register with the server", - Action: prepareWorkPathAndCustomConf(ctx, func(cliCtx *cli.Context) error { return RunGenerateActionsRunnerToken(ctx, cliCtx) }), + Before: prepareWorkPathAndCustomConf(ctx), + Action: func(cliCtx *cli.Context) error { return RunGenerateActionsRunnerToken(ctx, cliCtx) }, Flags: []cli.Flag{ &cli.StringFlag{ Name: "scope", @@ -59,7 +60,8 @@ func SubcmdActionsRegister(ctx context.Context) *cli.Command { return &cli.Command{ Name: "register", Usage: "Idempotent registration of a runner using a shared secret", - Action: prepareWorkPathAndCustomConf(ctx, func(cliCtx *cli.Context) error { return RunRegister(ctx, cliCtx) }), + Before: prepareWorkPathAndCustomConf(ctx), + Action: func(cliCtx *cli.Context) error { return RunRegister(ctx, cliCtx) }, Flags: []cli.Flag{ &cli.StringFlag{ Name: "secret", @@ -132,8 +134,8 @@ func validateSecret(secret string) error { } func RunRegister(ctx context.Context, cliCtx *cli.Context) error { + var cancel context.CancelFunc if !ContextGetNoInit(ctx) { - var cancel context.CancelFunc ctx, cancel = installSignals(ctx) defer cancel() @@ -219,25 +221,3 @@ func RunGenerateActionsRunnerToken(ctx context.Context, cliCtx *cli.Context) err } return nil } - -func prepareWorkPathAndCustomConf(ctx context.Context, action cli.ActionFunc) func(cliCtx *cli.Context) error { - return func(cliCtx *cli.Context) error { - if !ContextGetNoInit(ctx) { - var args setting.ArgWorkPathAndCustomConf - // from children to parent, check the global flags - for _, curCtx := range cliCtx.Lineage() { - if curCtx.IsSet("work-path") && args.WorkPath == "" { - args.WorkPath = curCtx.String("work-path") - } - if curCtx.IsSet("custom-path") && args.CustomPath == "" { - args.CustomPath = curCtx.String("custom-path") - } - if curCtx.IsSet("config") && args.CustomConf == "" { - args.CustomConf = curCtx.String("config") - } - } - setting.InitWorkPathAndCommonConfig(os.Getenv, args) - } - return action(cliCtx) - } -} diff --git a/cmd/forgejo/f3.go b/cmd/forgejo/f3.go new file mode 100644 index 0000000000..e3cdae7085 --- /dev/null +++ b/cmd/forgejo/f3.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + + _ "code.gitea.io/gitea/services/f3/driver" // register the driver + + "github.com/urfave/cli/v2" + f3_cmd "lab.forgefriends.org/friendlyforgeformat/gof3/cmd" + f3_types "lab.forgefriends.org/friendlyforgeformat/gof3/config/types" +) + +func F3Logger() *f3_types.Logger { + messenger := func(message string, args ...interface{}) { + log.Info("Message: "+message, args...) + } + return &f3_types.Logger{ + Message: f3_types.LoggerFun(messenger), + Trace: log.Trace, + Debug: log.Debug, + Info: log.Info, + Warn: log.Warn, + Error: log.Error, + Critical: log.Critical, + Fatal: log.Fatal, + } +} + +func CmdF3(ctx context.Context) *cli.Command { + ctx = f3_types.ContextSetLogger(ctx, F3Logger()) + return &cli.Command{ + Name: "f3", + Usage: "F3", + Subcommands: []*cli.Command{ + SubcmdF3Mirror(ctx), + }, + } +} + +func SubcmdF3Mirror(ctx context.Context) *cli.Command { + mirrorCmd := f3_cmd.CreateCmdMirror(ctx) + mirrorCmd.Before = prepareWorkPathAndCustomConf(ctx) + f3Action := mirrorCmd.Action + mirrorCmd.Action = func(c *cli.Context) error { return runMirror(ctx, c, f3Action) } + return mirrorCmd +} + +func runMirror(ctx context.Context, c *cli.Context, action cli.ActionFunc) error { + var cancel context.CancelFunc + if !ContextGetNoInit(ctx) { + ctx, cancel = installSignals(ctx) + defer cancel() + + if err := initDB(ctx); err != nil { + return err + } + + if err := git.InitSimple(ctx); err != nil { + return err + } + if err := models.Init(ctx); err != nil { + return err + } + } + + return action(c) +} diff --git a/cmd/forgejo/forgejo.go b/cmd/forgejo/forgejo.go index affb39157a..5dc2c261c4 100644 --- a/cmd/forgejo/forgejo.go +++ b/cmd/forgejo/forgejo.go @@ -33,9 +33,14 @@ func CmdForgejo(ctx context.Context) *cli.Command { return &cli.Command{ Name: "forgejo-cli", Usage: "Forgejo CLI", - Flags: []cli.Flag{}, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, Subcommands: []*cli.Command{ CmdActions(ctx), + CmdF3(ctx), }, } } @@ -145,3 +150,25 @@ func handleCliResponseExtra(ctx context.Context, extra private.ResponseExtra) er } return cli.Exit(extra.Error, 1) } + +func prepareWorkPathAndCustomConf(ctx context.Context) func(c *cli.Context) error { + return func(c *cli.Context) error { + if !ContextGetNoInit(ctx) { + var args setting.ArgWorkPathAndCustomConf + // from children to parent, check the global flags + for _, curCtx := range c.Lineage() { + if curCtx.IsSet("work-path") && args.WorkPath == "" { + args.WorkPath = curCtx.String("work-path") + } + if curCtx.IsSet("custom-path") && args.CustomPath == "" { + args.CustomPath = curCtx.String("custom-path") + } + if curCtx.IsSet("config") && args.CustomConf == "" { + args.CustomConf = curCtx.String("config") + } + } + setting.InitWorkPathAndCommonConfig(os.Getenv, args) + } + return nil + } +} diff --git a/cmd/main.go b/cmd/main.go index c0afc138ed..4eb511760c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -124,6 +124,7 @@ func NewMainApp(version, versionExtra string) *cli.App { var subCmdsStandalone []*cli.Command = make([]*cli.Command, 0, 10) var subCmdWithConfig []*cli.Command = make([]*cli.Command, 0, 10) + var globalFlags []cli.Flag = make([]cli.Flag, 0, 10) // // If the executable is forgejo-cli, provide a Forgejo specific CLI @@ -131,6 +132,18 @@ func NewMainApp(version, versionExtra string) *cli.App { // if executable == "forgejo-cli" { subCmdsStandalone = append(subCmdsStandalone, forgejo.CmdActions(context.Background())) + subCmdWithConfig = append(subCmdWithConfig, forgejo.CmdF3(context.Background())) + globalFlags = append(globalFlags, []cli.Flag{ + &cli.BoolFlag{ + Name: "quiet", + }, + &cli.BoolFlag{ + Name: "verbose", + }, + &cli.BoolFlag{ + Name: "debug", + }, + }...) } else { // // Otherwise provide a Gitea compatible CLI which includes Forgejo @@ -142,10 +155,10 @@ func NewMainApp(version, versionExtra string) *cli.App { subCmdWithConfig = append(subCmdWithConfig, CmdActions) } - return innerNewMainApp(version, versionExtra, subCmdsStandalone, subCmdWithConfig) + return innerNewMainApp(version, versionExtra, subCmdsStandalone, subCmdWithConfig, globalFlags) } -func innerNewMainApp(version, versionExtra string, subCmdsStandaloneArgs, subCmdWithConfigArgs []*cli.Command) *cli.App { +func innerNewMainApp(version, versionExtra string, subCmdsStandaloneArgs, subCmdWithConfigArgs []*cli.Command, globalFlagsArgs []cli.Flag) *cli.App { app := cli.NewApp() app.Name = "Gitea" app.HelpName = "gitea" @@ -185,6 +198,7 @@ func innerNewMainApp(version, versionExtra string, subCmdsStandaloneArgs, subCmd app.DefaultCommand = CmdWeb.Name globalFlags := appGlobalFlags() + globalFlags = append(globalFlags, globalFlagsArgs...) app.Flags = append(app.Flags, cli.VersionFlag) app.Flags = append(app.Flags, globalFlags...) app.HideHelp = true // use our own help action to show helps (with more information like default config) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index f9c9c331b8..80097517ed 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2389,6 +2389,15 @@ LEVEL = Info ;; If a domain is allowed by ALLOWED_DOMAINS, this option will be ignored. ;ALLOW_LOCALNETWORKS = false +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[F3] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; Enable/Disable Friendly Forge Format (F3) +;ENABLED = false + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;[federation] diff --git a/go.mod b/go.mod index 54a5608670..625448ec33 100644 --- a/go.mod +++ b/go.mod @@ -118,6 +118,7 @@ require ( gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/ini.v1 v1.67.0 gopkg.in/yaml.v3 v3.0.1 + lab.forgefriends.org/friendlyforgeformat/gof3 v1.0.1-0.20230917111322-4e5634530d7b mvdan.cc/xurls/v2 v2.5.0 strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251 xorm.io/builder v0.3.13 @@ -205,6 +206,7 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/google/go-cmp v0.5.9 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.0 // indirect github.com/gorilla/css v1.0.1 // indirect diff --git a/go.sum b/go.sum index 348fc86da4..548258570c 100644 --- a/go.sum +++ b/go.sum @@ -1463,6 +1463,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +lab.forgefriends.org/friendlyforgeformat/gof3 v1.0.1-0.20230917111322-4e5634530d7b h1:ws6llA9HwUA2ofK//7Y/HkV2oaP60M120FFBsHI0G78= +lab.forgefriends.org/friendlyforgeformat/gof3 v1.0.1-0.20230917111322-4e5634530d7b/go.mod h1:TcKaEsgVihjAjw290iDvvirCT0P+DZNpzc0ZgNgy3E4= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= diff --git a/models/auth/source.go b/models/auth/source.go index 1ac3b4c080..b761d671d9 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -34,6 +34,9 @@ const ( SSPI // 7 ) +// This should be in the above list of types but is separated to avoid conflicts with Gitea changes +const F3 Type = 129 + // String returns the string name of the LoginType func (typ Type) String() string { return Names[typ] @@ -52,6 +55,7 @@ var Names = map[Type]string{ PAM: "PAM", OAuth2: "OAuth2", SSPI: "SPNEGO with SSPI", + F3: "F3", } // Config represents login config as far as the db is concerned @@ -180,6 +184,10 @@ func (source *Source) IsSSPI() bool { return source.Type == SSPI } +func (source *Source) IsF3() bool { + return source.Type == F3 +} + // HasTLS returns true of this source supports TLS. func (source *Source) HasTLS() bool { hasTLSer, ok := source.Cfg.(HasTLSer) diff --git a/models/repo/topic.go b/models/repo/topic.go index b71f43bc88..c7eee0f5e5 100644 --- a/models/repo/topic.go +++ b/models/repo/topic.go @@ -230,6 +230,21 @@ func GetRepoTopicByName(ctx context.Context, repoID int64, topicName string) (*T return nil, err } +// GetRepoTopicByID retrieves topic from ID for a repo if it exist +func GetRepoTopicByID(ctx context.Context, repoID, topicID int64) (*Topic, error) { + cond := builder.NewCond() + var topic Topic + cond = cond.And(builder.Eq{"repo_topic.repo_id": repoID}).And(builder.Eq{"topic.id": topicID}) + sess := db.GetEngine(ctx).Table("topic").Where(cond) + sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id") + if has, err := sess.Select("topic.*").Get(&topic); err != nil { + return nil, err + } else if !has { + return nil, ErrTopicNotExist{""} + } + return &topic, nil +} + // AddTopic adds a topic name to a repository (if it does not already have it) func AddTopic(ctx context.Context, repoID int64, topicName string) (*Topic, error) { ctx, committer, err := db.TxContext(ctx) diff --git a/models/user/search.go b/models/user/search.go index 0fa278c257..47ffcdee18 100644 --- a/models/user/search.go +++ b/models/user/search.go @@ -42,7 +42,11 @@ type SearchUserOptions struct { func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session { var cond builder.Cond - cond = builder.Eq{"type": opts.Type} + if opts.Type == UserTypeIndividual { + cond = builder.In("type", UserTypeIndividual, UserTypeF3) + } else { + cond = builder.Eq{"type": opts.Type} + } if opts.IncludeReserved { if opts.Type == UserTypeIndividual { cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or( diff --git a/models/user/user.go b/models/user/user.go index b1c46c14c9..3a64da0ed3 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -55,6 +55,9 @@ const ( UserTypeRemoteUser ) +// It belongs above but is set explicitly here to avoid conflicts +const UserTypeF3 UserType = 128 + const ( // EmailNotificationsEnabled indicates that the user would like to receive all email notifications except your own EmailNotificationsEnabled = "enabled" @@ -220,7 +223,7 @@ func (u *User) GetEmail() string { // GetAllUsers returns a slice of all individual users found in DB. func GetAllUsers(ctx context.Context) ([]*User, error) { users := make([]*User, 0) - return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).Find(&users) + return users, db.GetEngine(ctx).OrderBy("id").In("type", UserTypeIndividual, UserTypeF3).Find(&users) } // IsLocal returns true if user login type is LoginPlain. @@ -421,6 +424,10 @@ func (u *User) IsBot() bool { return u.Type == UserTypeBot } +func (u *User) IsF3() bool { + return u.Type == UserTypeF3 +} + // DisplayName returns full name if it's not empty, // returns username otherwise. func (u *User) DisplayName() string { @@ -973,7 +980,8 @@ func GetUserByName(ctx context.Context, name string) (*User, error) { if len(name) == 0 { return nil, ErrUserNotExist{0, name, 0} } - u := &User{LowerName: strings.ToLower(name), Type: UserTypeIndividual} + // adding Type: UserTypeIndividual is a noop because it is zero and discarded + u := &User{LowerName: strings.ToLower(name)} has, err := db.GetEngine(ctx).Get(u) if err != nil { return nil, err @@ -1009,7 +1017,7 @@ func GetMaileableUsersByIDs(ctx context.Context, ids []int64, isMention bool) ([ if isMention { return ous, db.GetEngine(ctx). In("id", ids). - Where("`type` = ?", UserTypeIndividual). + In("`type`", UserTypeIndividual, UserTypeF3). And("`prohibit_login` = ?", false). And("`is_active` = ?", true). In("`email_notifications_preference`", EmailNotificationsEnabled, EmailNotificationsOnMention, EmailNotificationsAndYourOwn). @@ -1018,7 +1026,7 @@ func GetMaileableUsersByIDs(ctx context.Context, ids []int64, isMention bool) ([ return ous, db.GetEngine(ctx). In("id", ids). - Where("`type` = ?", UserTypeIndividual). + In("`type`", UserTypeIndividual, UserTypeF3). And("`prohibit_login` = ?", false). And("`is_active` = ?", true). In("`email_notifications_preference`", EmailNotificationsEnabled, EmailNotificationsAndYourOwn). diff --git a/modules/setting/f3.go b/modules/setting/f3.go new file mode 100644 index 0000000000..8a66d68fc6 --- /dev/null +++ b/modules/setting/f3.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +package setting + +import ( + "code.gitea.io/gitea/modules/log" +) + +// Friendly Forge Format (F3) settings +var ( + F3 = struct { + Enabled bool + }{ + Enabled: false, + } +) + +func loadF3From(rootCfg ConfigProvider) { + if err := rootCfg.Section("F3").MapTo(&F3); err != nil { + log.Fatal("Failed to map F3 settings: %v", err) + } +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index ebfd3b27be..6f963e2030 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -220,6 +220,7 @@ func LoadSettings() { loadProjectFrom(CfgProvider) loadMimeTypeMapFrom(CfgProvider) loadFederationFrom(CfgProvider) + loadF3From(CfgProvider) } // LoadSettingsForInstall initializes the settings for install diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 9968fbe6a6..6e133abfca 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -33,6 +33,7 @@ import ( source_service "code.gitea.io/gitea/services/auth/source" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/externalaccount" + f3_service "code.gitea.io/gitea/services/f3" "code.gitea.io/gitea/services/forms" user_service "code.gitea.io/gitea/services/user" @@ -1205,9 +1206,21 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model ctx.Redirect(setting.AppSubURL + "/user/two_factor") } -// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful -// login the user func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { + gothUser, err := oAuth2FetchUser(ctx, authSource, request, response) + if err != nil { + return nil, goth.User{}, err + } + + if err := f3_service.MaybePromoteF3User(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil { + return nil, goth.User{}, err + } + + u, err := oAuth2GothUserToUser(ctx, authSource, gothUser) + return u, gothUser, err +} + +func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) { oauth2Source := authSource.Cfg.(*oauth2.Source) // Make sure that the response is not an error response. @@ -1219,10 +1232,10 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ // Delete the goth session err := gothic.Logout(response, request) if err != nil { - return nil, goth.User{}, err + return goth.User{}, err } - return nil, goth.User{}, errCallback{ + return goth.User{}, errCallback{ Code: errorName, Description: errorDescription, } @@ -1235,24 +1248,28 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) } - return nil, goth.User{}, err + return goth.User{}, err } if oauth2Source.RequiredClaimName != "" { claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] if !has { - return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} } if oauth2Source.RequiredClaimValue != "" { groups := claimValueToStringSet(claimInterface) if !groups.Contains(oauth2Source.RequiredClaimValue) { - return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} } } } + return gothUser, nil +} + +func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) { user := &user_model.User{ LoginName: gothUser.UserID, LoginType: auth.OAuth2, @@ -1261,12 +1278,13 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ hasUser, err := user_model.GetUser(ctx, user) if err != nil { - return nil, goth.User{}, err + return nil, err } if hasUser { - return user, gothUser, nil + return user, nil } + log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType) // search in external linked users externalLoginUser := &user_model.ExternalLoginUser{ @@ -1275,13 +1293,13 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ } hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser) if err != nil { - return nil, goth.User{}, err + return nil, err } if hasUser { - user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID) - return user, gothUser, err + user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID) + return user, err } // no user found to login - return nil, gothUser, nil + return nil, nil } diff --git a/services/auth/source/f3/source.go b/services/auth/source/f3/source.go new file mode 100644 index 0000000000..800e4baea3 --- /dev/null +++ b/services/auth/source/f3/source.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright the Forgejo contributors +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/json" +) + +type Source struct { + URL string + MatchingSource string + + // reference to the authSource + authSource *auth.Source +} + +func (source *Source) FromDB(bs []byte) error { + return json.UnmarshalHandleDoubleEncode(bs, &source) +} + +func (source *Source) ToDB() ([]byte, error) { + return json.Marshal(source) +} + +func (source *Source) SetAuthSource(authSource *auth.Source) { + source.authSource = authSource +} + +func init() { + auth.RegisterTypeConfig(auth.F3, &Source{}) +} diff --git a/services/convert/pull_review.go b/services/convert/pull_review.go index 0332606285..4d95f2784a 100644 --- a/services/convert/pull_review.go +++ b/services/convert/pull_review.go @@ -101,7 +101,7 @@ func ToPullReviewCommentList(ctx context.Context, review *issues_model.Review, d Path: comment.TreePath, CommitID: comment.CommitSHA, OrigCommitID: comment.OldRef, - DiffHunk: patch2diff(comment.Patch), + DiffHunk: Patch2diff(comment.Patch), HTMLURL: comment.HTMLURL(ctx), HTMLPullURL: review.Issue.HTMLURL(), } @@ -118,7 +118,7 @@ func ToPullReviewCommentList(ctx context.Context, review *issues_model.Review, d return apiComments, nil } -func patch2diff(patch string) string { +func Patch2diff(patch string) string { split := strings.Split(patch, "\n@@") if len(split) == 2 { return "@@" + split[1] diff --git a/services/f3/driver/asset.go b/services/f3/driver/asset.go new file mode 100644 index 0000000000..9c650be42a --- /dev/null +++ b/services/f3/driver/asset.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + "io" + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/attachment" + + "github.com/google/uuid" + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type Asset struct { + repo_model.Attachment + DownloadFunc func() io.ReadCloser +} + +func AssetConverter(f *repo_model.Attachment) *Asset { + return &Asset{ + Attachment: *f, + } +} + +func (o Asset) GetID() int64 { + return o.ID +} + +func (o Asset) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o *Asset) SetID(id int64) { + o.ID = id +} + +func (o *Asset) SetIDString(id string) { + o.SetID(util.ParseInt(id)) +} + +func (o *Asset) IsNil() bool { + return o.ID == 0 +} + +func (o *Asset) Equals(other *Asset) bool { + return o.Name == other.Name +} + +func (o *Asset) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Asset) ToFormat() *format.ReleaseAsset { + return &format.ReleaseAsset{ + Common: format.NewCommon(o.ID), + Name: o.Name, + Size: int(o.Size), + DownloadCount: int(o.DownloadCount), + Created: o.CreatedUnix.AsTime(), + DownloadURL: o.DownloadURL(), + DownloadFunc: o.DownloadFunc, + } +} + +func (o *Asset) FromFormat(asset *format.ReleaseAsset) { + *o = Asset{ + Attachment: repo_model.Attachment{ + ID: asset.GetID(), + Name: asset.Name, + Size: int64(asset.Size), + DownloadCount: int64(asset.DownloadCount), + CustomDownloadURL: asset.DownloadURL, + CreatedUnix: timeutil.TimeStamp(asset.Created.Unix()), + }, + DownloadFunc: asset.DownloadFunc, + } +} + +type AssetProvider struct { + BaseProvider +} + +func (o *AssetProvider) ToFormat(ctx context.Context, asset *Asset) *format.ReleaseAsset { + httpClient := o.g.GetNewMigrationHTTPClient()() + a := asset.ToFormat() + a.DownloadFunc = func() io.ReadCloser { + o.g.GetLogger().Debug("download from %s", asset.DownloadURL()) + req, err := http.NewRequest("GET", asset.DownloadURL(), nil) + if err != nil { + panic(err) + } + resp, err := httpClient.Do(req) + if err != nil { + panic(fmt.Errorf("while downloading %s %w", asset.DownloadURL(), err)) + } + + // resp.Body is closed by the consumer + return resp.Body + } + return a +} + +func (o *AssetProvider) FromFormat(ctx context.Context, p *format.ReleaseAsset) *Asset { + var asset Asset + asset.FromFormat(p) + return &asset +} + +func (o *AssetProvider) ProcessObject(ctx context.Context, user *User, project *Project, release *Release, asset *Asset) { +} + +func (o *AssetProvider) GetObjects(ctx context.Context, user *User, project *Project, release *Release, page int) []*Asset { + if page > 1 { + return []*Asset{} + } + r, err := repo_model.GetReleaseByID(ctx, release.GetID()) + if err != nil { + panic(err) + } + if err := r.LoadAttributes(ctx); err != nil { + panic(fmt.Errorf("error while listing assets: %v", err)) + } + + return util.ConvertMap[*repo_model.Attachment, *Asset](r.Attachments, AssetConverter) +} + +func (o *AssetProvider) Get(ctx context.Context, user *User, project *Project, release *Release, exemplar *Asset) *Asset { + id := exemplar.GetID() + asset, err := repo_model.GetAttachmentByID(ctx, id) + if repo_model.IsErrAttachmentNotExist(err) { + return &Asset{} + } + if err != nil { + panic(err) + } + return AssetConverter(asset) +} + +func (o *AssetProvider) Put(ctx context.Context, user *User, project *Project, release *Release, asset, existing *Asset) *Asset { + var result *Asset + + if existing == nil || existing.IsNil() { + a := asset.Attachment + a.UploaderID = user.GetID() + a.RepoID = project.GetID() + a.ReleaseID = release.GetID() + a.UUID = uuid.New().String() + + download := asset.DownloadFunc() + defer download.Close() + + insertedAttachment, err := attachment.NewAttachment(ctx, &a, download, asset.Size) + if err != nil { + panic(err) + } + result = AssetConverter(insertedAttachment) + } else { + var u repo_model.Attachment + u.ID = existing.GetID() + cols := make([]string, 0, 10) + + if asset.Name != existing.Name { + u.Name = asset.Name + cols = append(cols, "name") + } + if len(cols) > 0 { + if _, err := db.GetEngine(ctx).ID(existing.ID).Cols(cols...).Update(u); err != nil { + panic(err) + } + } + result = existing + } + return o.Get(ctx, user, project, release, result) +} + +func (o *AssetProvider) Delete(ctx context.Context, user *User, project *Project, release *Release, asset *Asset) *Asset { + a := o.Get(ctx, user, project, release, asset) + if !a.IsNil() { + err := repo_model.DeleteAttachment(ctx, &a.Attachment, true) + if err != nil { + panic(err) + } + } + return a +} diff --git a/services/f3/driver/base.go b/services/f3/driver/base.go new file mode 100644 index 0000000000..eed508827b --- /dev/null +++ b/services/f3/driver/base.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + + "lab.forgefriends.org/friendlyforgeformat/gof3/forges/common" + "lab.forgefriends.org/friendlyforgeformat/gof3/format" +) + +type BaseProvider struct { + g *Forgejo +} + +func (o *BaseProvider) SetForgejo(g *Forgejo) { + o.g = g +} + +type BaseProviderConstraint[Provider any] interface { + *Provider + SetForgejo(*Forgejo) +} + +func NewProvider[T any, TPtr BaseProviderConstraint[T]](g *Forgejo) TPtr { + var p TPtr + p = new(T) + p.SetForgejo(g) + return p +} + +func (o *BaseProvider) GetLocalMatchingRemote(ctx context.Context, format format.Interface, parents ...common.ContainerObjectInterface) (string, bool) { + return "", false +} + +type BaseProviderWithProjectProvider struct { + BaseProvider + project *ProjectProvider +} + +func (o *BaseProviderWithProjectProvider) SetProjectProvider(project *ProjectProvider) { + o.project = project +} + +type BaseProviderWithProjectProviderConstraint[Provider any] interface { + BaseProviderConstraint[Provider] + SetProjectProvider(project *ProjectProvider) +} + +func NewProviderWithProjectProvider[T any, TPtr BaseProviderWithProjectProviderConstraint[T]](g *Forgejo, project *ProjectProvider) TPtr { + p := NewProvider[T, TPtr](g) + p.SetProjectProvider(project) + return p +} diff --git a/services/f3/driver/comment.go b/services/f3/driver/comment.go new file mode 100644 index 0000000000..447da10461 --- /dev/null +++ b/services/f3/driver/comment.go @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + issue_service "code.gitea.io/gitea/services/issue" + + "lab.forgefriends.org/friendlyforgeformat/gof3/forges/common" + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type Comment struct { + issues_model.Comment +} + +func CommentConverter(f *issues_model.Comment) *Comment { + return &Comment{ + Comment: *f, + } +} + +func (o Comment) GetID() int64 { + return o.Comment.ID +} + +func (o Comment) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o *Comment) SetID(id int64) { + o.Comment.ID = id +} + +func (o *Comment) SetIDString(id string) { + o.SetID(util.ParseInt(id)) +} + +func (o *Comment) IsNil() bool { + return o.ID == 0 +} + +func (o *Comment) Equals(other *Comment) bool { + return o.Comment.Content == other.Comment.Content +} + +func (o *Comment) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Comment) ToFormat() *format.Comment { + return &format.Comment{ + Common: format.NewCommon(o.Comment.ID), + IssueIndex: o.Comment.IssueID, + PosterID: format.NewUserReference(o.Comment.Poster.ID), + Content: o.Comment.Content, + Created: o.Comment.CreatedUnix.AsTime(), + Updated: o.Comment.UpdatedUnix.AsTime(), + } +} + +func (o *Comment) FromFormat(comment *format.Comment) { + *o = Comment{ + Comment: issues_model.Comment{ + ID: comment.GetID(), + IssueID: comment.IssueIndex, + Issue: &issues_model.Issue{ + ID: comment.IssueIndex, + }, + PosterID: comment.PosterID.GetID(), + Poster: &user_model.User{ + ID: comment.PosterID.GetID(), + }, + Content: comment.Content, + CreatedUnix: timeutil.TimeStamp(comment.Created.Unix()), + UpdatedUnix: timeutil.TimeStamp(comment.Updated.Unix()), + }, + } +} + +type CommentProvider struct { + BaseProvider +} + +func (o *CommentProvider) ToFormat(ctx context.Context, comment *Comment) *format.Comment { + return comment.ToFormat() +} + +func (o *CommentProvider) FromFormat(ctx context.Context, f *format.Comment) *Comment { + var comment Comment + comment.FromFormat(f) + return &comment +} + +func (o *CommentProvider) GetObjects(ctx context.Context, user *User, project *Project, commentable common.ContainerObjectInterface, page int) []*Comment { + var issue *issues_model.Issue + switch c := commentable.(type) { + case *PullRequest: + issue = c.PullRequest.Issue + case *Issue: + issue = &c.Issue + default: + panic(fmt.Errorf("unexpected type %T", commentable)) + } + comments, err := issues_model.FindComments(ctx, &issues_model.FindCommentsOptions{ + ListOptions: db.ListOptions{Page: page, PageSize: o.g.perPage}, + RepoID: project.GetID(), + IssueID: issue.ID, + Type: issues_model.CommentTypeComment, + }) + if err != nil { + panic(fmt.Errorf("error while listing comment: %v", err)) + } + + return util.ConvertMap[*issues_model.Comment, *Comment](comments, CommentConverter) +} + +func (o *CommentProvider) ProcessObject(ctx context.Context, user *User, project *Project, commentable common.ContainerObjectInterface, comment *Comment) { + if err := comment.LoadIssue(ctx); err != nil { + panic(err) + } + if err := comment.LoadPoster(ctx); err != nil { + panic(err) + } +} + +func (o *CommentProvider) Get(ctx context.Context, user *User, project *Project, commentable common.ContainerObjectInterface, comment *Comment) *Comment { + id := comment.GetID() + c, err := issues_model.GetCommentByID(ctx, id) + if issues_model.IsErrCommentNotExist(err) { + return &Comment{} + } + if err != nil { + panic(err) + } + + co := CommentConverter(c) + o.ProcessObject(ctx, user, project, commentable, co) + return co +} + +func (o *CommentProvider) Put(ctx context.Context, user *User, project *Project, commentable common.ContainerObjectInterface, comment, existing *Comment) *Comment { + var issue *issues_model.Issue + switch c := commentable.(type) { + case *PullRequest: + issue = c.PullRequest.Issue + case *Issue: + issue = &c.Issue + default: + panic(fmt.Errorf("unexpected type %T", commentable)) + } + + var result *Comment + + if existing == nil || existing.IsNil() { + c, err := issue_service.CreateIssueComment(ctx, o.g.GetDoer(), &project.Repository, issue, comment.Content, nil) + if err != nil { + panic(err) + } + result = CommentConverter(c) + } else { + var u issues_model.Comment + u.ID = existing.GetID() + cols := make([]string, 0, 10) + + if comment.Content != existing.Content { + u.Content = comment.Content + cols = append(cols, "content") + } + + if len(cols) > 0 { + if _, err := db.GetEngine(ctx).ID(existing.ID).Cols(cols...).Update(u); err != nil { + panic(err) + } + } + + result = existing + } + + return o.Get(ctx, user, project, commentable, result) +} + +func (o *CommentProvider) Delete(ctx context.Context, user *User, project *Project, commentable common.ContainerObjectInterface, comment *Comment) *Comment { + c := o.Get(ctx, user, project, commentable, comment) + if !c.IsNil() { + err := issues_model.DeleteComment(ctx, &c.Comment) + if err != nil { + panic(err) + } + } + return c +} diff --git a/services/f3/driver/driver.go b/services/f3/driver/driver.go new file mode 100644 index 0000000000..4495bdd821 --- /dev/null +++ b/services/f3/driver/driver.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + + auth_model "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/migrations" + + "github.com/urfave/cli/v2" + config_factory "lab.forgefriends.org/friendlyforgeformat/gof3/config/factory" + f3_types "lab.forgefriends.org/friendlyforgeformat/gof3/config/types" + "lab.forgefriends.org/friendlyforgeformat/gof3/forges/common" + "lab.forgefriends.org/friendlyforgeformat/gof3/forges/driver" + "lab.forgefriends.org/friendlyforgeformat/gof3/format" +) + +var Name = "InternalForgejo" + +func init() { + config_factory.RegisterFactory(Name, f3_types.OptionsFactory{ + Name: Name, + New: func() f3_types.OptionsInterface { return &Options{} }, + Flags: GetFlags, + }, func() common.DriverInterface { return &Forgejo{} }) +} + +type Options struct { + f3_types.Options + + AuthenticationSource int64 + Doer *user_model.User +} + +func getAuthenticationSource(ctx context.Context, authenticationSource string) (*auth_model.Source, error) { + source, err := auth_model.GetSourceByName(ctx, authenticationSource) + if err != nil { + if auth_model.IsErrSourceNotExist(err) { + return nil, nil + } + return nil, err + } + return source, nil +} + +func (o *Options) FromFlags(ctx context.Context, c *cli.Context, prefix string) f3_types.OptionsInterface { + o.Options.FromFlags(ctx, c, prefix) + sourceName := c.String("authentication-source") + if sourceName != "" { + source, err := getAuthenticationSource(ctx, sourceName) + if err != nil { + panic(fmt.Errorf("error retrieving the authentication-source %s %v", sourceName, err)) + } + if source != nil { + o.AuthenticationSource = source.ID + } + } + + doer, err := user_model.GetAdminUser(ctx) + if err != nil { + panic(fmt.Errorf("GetAdminUser %v", err)) + } + o.Doer = doer + + return o +} + +func GetFlags(prefix, category string) []cli.Flag { + flags := make([]cli.Flag, 0, 10) + + flags = append(flags, &cli.StringFlag{ + Name: "authentication-source", + Value: "", + Usage: "The name of the authentication source matching the forge of origin", + }) + + return flags +} + +type Forgejo struct { + perPage int + options *Options +} + +func (o *Forgejo) GetName() string { + return Name +} + +func (o *Forgejo) GetPerPage() int { + return o.perPage +} + +func (o *Forgejo) GetOptions() f3_types.OptionsInterface { + return o.options +} + +func (o *Forgejo) SetOptions(options f3_types.OptionsInterface) { + var ok bool + o.options, ok = options.(*Options) + if !ok { + panic(fmt.Errorf("unexpected type %T", options)) + } +} + +func (o *Forgejo) GetLogger() *f3_types.Logger { + return o.GetOptions().GetLogger() +} + +func (o *Forgejo) Init(options f3_types.OptionsInterface) { + o.SetOptions(options) + o.perPage = setting.ItemsPerPage +} + +func (o *Forgejo) GetDirectory() string { + return o.options.GetDirectory() +} + +func (o *Forgejo) GetDoer() *user_model.User { + return o.options.Doer +} + +func (o *Forgejo) GetAuthenticationSource() int64 { + return o.options.AuthenticationSource +} + +func (o *Forgejo) GetNewMigrationHTTPClient() f3_types.NewMigrationHTTPClientFun { + return migrations.NewMigrationHTTPClient +} + +func (o *Forgejo) SupportGetRepoComments() bool { + return false +} + +func (o *Forgejo) GetProvider(name string, parent common.ProviderInterface) common.ProviderInterface { + var parentImpl any + if parent != nil { + parentImpl = parent.GetImplementation() + } + switch name { + case driver.ProviderUser: + return driver.NewProvider[UserProvider, *UserProvider, User, *User, format.User, *format.User](driver.ProviderUser, NewProvider[UserProvider](o)) + case driver.ProviderProject: + return driver.NewProviderWithParentOne[ProjectProvider, *ProjectProvider, Project, *Project, format.Project, *format.Project, User, *User](driver.ProviderProject, NewProvider[ProjectProvider, *ProjectProvider](o)) + case driver.ProviderMilestone: + return driver.NewProviderWithParentOneTwo[MilestoneProvider, *MilestoneProvider, Milestone, *Milestone, format.Milestone, *format.Milestone, User, *User, Project, *Project](driver.ProviderMilestone, NewProviderWithProjectProvider[MilestoneProvider](o, parentImpl.(*ProjectProvider))) + case driver.ProviderIssue: + return driver.NewProviderWithParentOneTwo[IssueProvider, *IssueProvider, Issue, *Issue, format.Issue, *format.Issue, User, *User, Project, *Project](driver.ProviderIssue, NewProviderWithProjectProvider[IssueProvider](o, parentImpl.(*ProjectProvider))) + case driver.ProviderPullRequest: + return driver.NewProviderWithParentOneTwo[PullRequestProvider, *PullRequestProvider, PullRequest, *PullRequest, format.PullRequest, *format.PullRequest, User, *User, Project, *Project](driver.ProviderPullRequest, NewProviderWithProjectProvider[PullRequestProvider](o, parentImpl.(*ProjectProvider))) + case driver.ProviderReview: + return driver.NewProviderWithParentOneTwoThree[ReviewProvider, *ReviewProvider, Review, *Review, format.Review, *format.Review, User, *User, Project, *Project, PullRequest, *PullRequest](driver.ProviderReview, NewProvider[ReviewProvider](o)) + case driver.ProviderRepository: + return driver.NewProviderWithParentOneTwo[RepositoryProvider, *RepositoryProvider, Repository, *Repository, format.Repository, *format.Repository, User, *User, Project, *Project](driver.ProviderRepository, NewProvider[RepositoryProvider](o)) + case driver.ProviderTopic: + return driver.NewProviderWithParentOneTwo[TopicProvider, *TopicProvider, Topic, *Topic, format.Topic, *format.Topic, User, *User, Project, *Project](driver.ProviderTopic, NewProvider[TopicProvider](o)) + case driver.ProviderLabel: + return driver.NewProviderWithParentOneTwo[LabelProvider, *LabelProvider, Label, *Label, format.Label, *format.Label, User, *User, Project, *Project](driver.ProviderLabel, NewProviderWithProjectProvider[LabelProvider](o, parentImpl.(*ProjectProvider))) + case driver.ProviderRelease: + return driver.NewProviderWithParentOneTwo[ReleaseProvider, *ReleaseProvider, Release, *Release, format.Release, *format.Release, User, *User, Project, *Project](driver.ProviderRelease, NewProvider[ReleaseProvider](o)) + case driver.ProviderAsset: + return driver.NewProviderWithParentOneTwoThree[AssetProvider, *AssetProvider, Asset, *Asset, format.ReleaseAsset, *format.ReleaseAsset, User, *User, Project, *Project, Release, *Release](driver.ProviderAsset, NewProvider[AssetProvider](o)) + case driver.ProviderComment: + return driver.NewProviderWithParentOneTwoThreeInterface[CommentProvider, *CommentProvider, Comment, *Comment, format.Comment, *format.Comment, User, *User, Project, *Project](driver.ProviderComment, NewProvider[CommentProvider](o)) + case driver.ProviderReaction: + return driver.NewProviderWithParentOneTwoRest[ReactionProvider, *ReactionProvider, Reaction, *Reaction, format.Reaction, *format.Reaction, User, *User, Project, *Project](driver.ProviderReaction, NewProvider[ReactionProvider](o)) + default: + panic(fmt.Sprintf("unknown provider name %s", name)) + } +} + +func (o Forgejo) Finish() { +} diff --git a/services/f3/driver/issue.go b/services/f3/driver/issue.go new file mode 100644 index 0000000000..10aaa7196a --- /dev/null +++ b/services/f3/driver/issue.go @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/timeutil" + issue_service "code.gitea.io/gitea/services/issue" + + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type Issue struct { + issues_model.Issue +} + +func IssueConverter(f *issues_model.Issue) *Issue { + return &Issue{ + Issue: *f, + } +} + +func (o Issue) GetID() int64 { + return o.Index +} + +func (o Issue) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o *Issue) SetID(id int64) { + o.Index = id +} + +func (o *Issue) SetIDString(id string) { + o.SetID(util.ParseInt(id)) +} + +func (o *Issue) IsNil() bool { + return o.Index == 0 +} + +func (o *Issue) Equals(other *Issue) bool { + return (o.Title == other.Title) +} + +func (o *Issue) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Issue) ToFormat() *format.Issue { + var milestone string + if o.Milestone != nil { + milestone = o.Milestone.Name + } + + labels := make([]string, 0, len(o.Labels)) + for _, label := range o.Labels { + labels = append(labels, label.Name) + } + + var assignees []string + for i := range o.Assignees { + assignees = append(assignees, o.Assignees[i].Name) + } + + return &format.Issue{ + Common: format.NewCommon(o.Index), + Title: o.Title, + PosterID: format.NewUserReference(o.Poster.ID), + Content: o.Content, + Milestone: milestone, + State: string(o.State()), + Created: o.CreatedUnix.AsTime(), + Updated: o.UpdatedUnix.AsTime(), + Closed: o.ClosedUnix.AsTimePtr(), + IsLocked: o.IsLocked, + Labels: labels, + Assignees: assignees, + } +} + +func (o *Issue) FromFormat(issue *format.Issue) { + labels := make([]*issues_model.Label, 0, len(issue.Labels)) + for _, label := range issue.Labels { + labels = append(labels, &issues_model.Label{Name: label}) + } + + assignees := make([]*user_model.User, 0, len(issue.Assignees)) + for _, a := range issue.Assignees { + assignees = append(assignees, &user_model.User{ + Name: a, + }) + } + + *o = Issue{ + Issue: issues_model.Issue{ + Title: issue.Title, + Index: issue.GetID(), + PosterID: issue.PosterID.GetID(), + Poster: &user_model.User{ + ID: issue.PosterID.GetID(), + }, + Content: issue.Content, + Milestone: &issues_model.Milestone{ + Name: issue.Milestone, + }, + IsClosed: issue.State == "closed", + CreatedUnix: timeutil.TimeStamp(issue.Created.Unix()), + UpdatedUnix: timeutil.TimeStamp(issue.Updated.Unix()), + IsLocked: issue.IsLocked, + Labels: labels, + Assignees: assignees, + }, + } + + if issue.Closed != nil { + o.ClosedUnix = timeutil.TimeStamp(issue.Closed.Unix()) + } +} + +type IssueProvider struct { + BaseProviderWithProjectProvider +} + +func (o *IssueProvider) ToFormat(ctx context.Context, issue *Issue) *format.Issue { + return issue.ToFormat() +} + +func (o *IssueProvider) FromFormat(ctx context.Context, i *format.Issue) *Issue { + var issue Issue + issue.FromFormat(i) + if i.Milestone != "" { + issue.Milestone.ID = o.project.milestones.GetID(issue.Milestone.Name) + } + for _, label := range issue.Labels { + label.ID = o.project.labels.GetID(label.Name) + } + return &issue +} + +func (o *IssueProvider) GetObjects(ctx context.Context, user *User, project *Project, page int) []*Issue { + issues, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + Paginator: &db.ListOptions{Page: page, PageSize: o.g.perPage}, + RepoIDs: []int64{project.GetID()}, + }) + if err != nil { + panic(fmt.Errorf("error while listing issues: %v", err)) + } + + return util.ConvertMap[*issues_model.Issue, *Issue](issues, IssueConverter) +} + +func (o *IssueProvider) ProcessObject(ctx context.Context, user *User, project *Project, issue *Issue) { + if err := (&issue.Issue).LoadAttributes(ctx); err != nil { + panic(true) + } +} + +func (o *IssueProvider) Get(ctx context.Context, user *User, project *Project, exemplar *Issue) *Issue { + id := exemplar.GetID() + issue, err := issues_model.GetIssueByIndex(ctx, project.GetID(), id) + if issues_model.IsErrIssueNotExist(err) { + return &Issue{} + } + if err != nil { + panic(err) + } + i := IssueConverter(issue) + o.ProcessObject(ctx, user, project, i) + return i +} + +func (o *IssueProvider) Put(ctx context.Context, user *User, project *Project, issue, existing *Issue) *Issue { + i := issue.Issue + i.RepoID = project.GetID() + makeLabels := func(issueID int64) []issues_model.IssueLabel { + labels := make([]issues_model.IssueLabel, 0, len(i.Labels)) + for _, label := range i.Labels { + o.g.GetLogger().Trace("%d with label %d", issueID, label.ID) + labels = append(labels, issues_model.IssueLabel{ + IssueID: issueID, + LabelID: label.ID, + }) + } + return labels + } + + var result *Issue + + sess := db.GetEngine(ctx) + if existing == nil || existing.IsNil() { + idx, err := db.GetNextResourceIndex(ctx, "issue_index", project.Repository.ID) + if err != nil { + panic(fmt.Errorf("generate issue index failed: %w", err)) + } + i.Index = idx + + if _, err = sess.NoAutoTime().Insert(&i); err != nil { + panic(err) + } + labels := makeLabels(i.ID) + if len(labels) > 0 { + if _, err := sess.Insert(labels); err != nil { + panic(err) + } + } + result = IssueConverter(&i) + } else { + e := existing.Issue + if issue.GetID() != existing.GetID() { + panic(fmt.Sprintf("issue.GetID() %d != existing.GetID() %d", issue.GetID(), existing.GetID())) + } + var u issues_model.Issue + u.Index = existing.GetID() + u.RepoID = project.GetID() + cols := make([]string, 0, 10) + if i.Title != e.Title { + u.Title = i.Title + cols = append(cols, "name") + } + + if len(cols) > 0 { + if _, err := sess.ID(existing.ID).Cols(cols...).Update(u); err != nil { + panic(err) + } + } + + result = existing + } + + return o.Get(ctx, user, project, result) +} + +func (o *IssueProvider) Delete(ctx context.Context, user *User, project *Project, issue *Issue) *Issue { + m := o.Get(ctx, user, project, issue) + if !m.IsNil() { + repoPath := repo_model.RepoPath(user.Name, project.Name) + gitRepo, err := git.OpenRepository(ctx, repoPath) + if err != nil { + panic(err) + } + defer gitRepo.Close() + if err := issue_service.DeleteIssue(ctx, o.g.GetDoer(), gitRepo, &issue.Issue); err != nil { + panic(err) + } + } + return m +} diff --git a/services/f3/driver/issue_test.go b/services/f3/driver/issue_test.go new file mode 100644 index 0000000000..34b79d0d2c --- /dev/null +++ b/services/f3/driver/issue_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "testing" + + issue_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + + "lab.forgefriends.org/friendlyforgeformat/gof3/forges/tests" + "lab.forgefriends.org/friendlyforgeformat/gof3/format" +) + +func TestF3Driver_IssueFormat(t *testing.T) { + now := timeutil.TimeStampNow() + updated := now.Add(1) + closed := now.Add(2) + issue := Issue{ + Issue: issue_model.Issue{ + Title: "title", + Index: 123, + PosterID: 11111, + Poster: &user_model.User{ + ID: 11111, + }, + Content: "content", + Milestone: &issue_model.Milestone{ + Name: "milestone1", + }, + IsClosed: true, + CreatedUnix: now, + UpdatedUnix: updated, + ClosedUnix: closed, + IsLocked: false, + Labels: []*issue_model.Label{ + { + Name: "label1", + }, + }, + Assignees: []*user_model.User{ + { + Name: "assignee1", + }, + }, + }, + } + tests.ToFromFormat[Issue, format.Issue, *Issue, *format.Issue](t, &issue) +} diff --git a/services/f3/driver/label.go b/services/f3/driver/label.go new file mode 100644 index 0000000000..68d33214b6 --- /dev/null +++ b/services/f3/driver/label.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type Label struct { + issues_model.Label +} + +func LabelConverter(f *issues_model.Label) *Label { + return &Label{ + Label: *f, + } +} + +func (o Label) GetID() int64 { + return o.ID +} + +func (o Label) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o Label) GetName() string { + return o.Name +} + +func (o *Label) SetID(id int64) { + o.ID = id +} + +func (o *Label) SetIDString(id string) { + o.SetID(util.ParseInt(id)) +} + +func (o *Label) IsNil() bool { + return o.ID == 0 +} + +func (o *Label) Equals(other *Label) bool { + return o.Name == other.Name +} + +func (o *Label) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Label) ToFormat() *format.Label { + return &format.Label{ + Common: format.NewCommon(o.ID), + Name: o.Name, + Color: o.Color, + Description: o.Description, + } +} + +func (o *Label) FromFormat(label *format.Label) { + *o = Label{ + Label: issues_model.Label{ + ID: label.GetID(), + Name: label.Name, + Description: label.Description, + Color: label.Color, + }, + } +} + +type LabelProvider struct { + BaseProviderWithProjectProvider +} + +func (o *LabelProvider) ToFormat(ctx context.Context, label *Label) *format.Label { + return label.ToFormat() +} + +func (o *LabelProvider) FromFormat(ctx context.Context, m *format.Label) *Label { + var label Label + label.FromFormat(m) + return &label +} + +func (o *LabelProvider) GetObjects(ctx context.Context, user *User, project *Project, page int) []*Label { + labels, err := issues_model.GetLabelsByRepoID(ctx, project.GetID(), "", db.ListOptions{Page: page, PageSize: o.g.perPage}) + if err != nil { + panic(fmt.Errorf("error while listing labels: %v", err)) + } + + r := util.ConvertMap[*issues_model.Label, *Label](labels, LabelConverter) + if o.project != nil { + o.project.labels = util.NewNameIDMap[*Label](r) + } + return r +} + +func (o *LabelProvider) ProcessObject(ctx context.Context, user *User, project *Project, label *Label) { +} + +func (o *LabelProvider) Get(ctx context.Context, user *User, project *Project, exemplar *Label) *Label { + id := exemplar.GetID() + label, err := issues_model.GetLabelInRepoByID(ctx, project.GetID(), id) + if issues_model.IsErrRepoLabelNotExist(err) { + return &Label{} + } + if err != nil { + panic(err) + } + return LabelConverter(label) +} + +func (o *LabelProvider) Put(ctx context.Context, user *User, project *Project, label, existing *Label) *Label { + l := label.Label + l.RepoID = project.GetID() + + var result *Label + + if existing == nil || existing.IsNil() { + if err := issues_model.NewLabel(ctx, &l); err != nil { + panic(err) + } + result = LabelConverter(&l) + } else { + var u issues_model.Label + u.ID = existing.GetID() + cols := make([]string, 0, 10) + + if l.Name != existing.Name { + u.Name = l.Name + cols = append(cols, "name") + } + + if len(cols) > 0 { + if _, err := db.GetEngine(ctx).ID(existing.ID).Cols(cols...).Update(u); err != nil { + panic(err) + } + } + + result = existing + } + + return o.Get(ctx, user, project, result) +} + +func (o *LabelProvider) Delete(ctx context.Context, user *User, project *Project, label *Label) *Label { + l := o.Get(ctx, user, project, label) + if !l.IsNil() { + if err := issues_model.DeleteLabel(ctx, project.GetID(), l.GetID()); err != nil { + panic(err) + } + } + return l +} diff --git a/services/f3/driver/main_test.go b/services/f3/driver/main_test.go new file mode 100644 index 0000000000..fdc9168aaf --- /dev/null +++ b/services/f3/driver/main_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "fmt" + "strings" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + config_types "lab.forgefriends.org/friendlyforgeformat/gof3/config/types" + f3_tests "lab.forgefriends.org/friendlyforgeformat/gof3/forges/tests" +) + +func TestForgejoDriverMethods(t *testing.T) { + unittest.PrepareTestEnv(t) + TestForgeMethods(t) +} + +func TestForgeMethods(t *testing.T) { + unittest.PrepareTestEnv(t) + + testUsersProviderOptions := f3_tests.TestUsersProviderOptions + testUsersProviderOptions.ModifiedPut = true + + testIssueProviderOptions := f3_tests.TestIssueProviderOptions + testIssueProviderOptions.ModifiedPut = true + + testLabelProviderOptions := f3_tests.TestLabelProviderOptions + testLabelProviderOptions.ModifiedPut = true + + testMilestonesProviderOptions := f3_tests.TestMilestonesProviderOptions + testMilestonesProviderOptions.ModifiedPut = true + + testReleasesProviderOptions := f3_tests.TestReleasesProviderOptions + testReleasesProviderOptions.ModifiedPut = true + + testAssetsProviderOptions := f3_tests.TestAssetsProviderOptions + testAssetsProviderOptions.ModifiedPut = true + + testCommentProviderOptions := f3_tests.TestCommentProviderOptions + testCommentProviderOptions.ModifiedPut = true + + testProjectProviderOptions := f3_tests.TestProjectProviderOptions + testProjectProviderOptions.ModifiedPut = true + + testReviewProviderOptions := f3_tests.TestReviewProviderOptions + testReviewProviderOptions.ModifiedPut = true + + testPullRequestsProviderOptions := f3_tests.TestPullRequestsProviderOptions + testPullRequestsProviderOptions.ModifiedPut = true + + for _, testCase := range []struct { + name string + fun func(f3_tests.ForgeTestInterface, f3_tests.ProviderOptions) + opts f3_tests.ProviderOptions + }{ + {name: "asset", fun: f3_tests.TestAssets, opts: testAssetsProviderOptions}, + {name: "repository", fun: f3_tests.TestRepository, opts: f3_tests.TestRepositoryProviderOptions}, + {name: "comment", fun: f3_tests.TestComment, opts: testCommentProviderOptions}, + {name: "issue", fun: f3_tests.TestIssue, opts: testIssueProviderOptions}, + {name: "label", fun: f3_tests.TestLabel, opts: testLabelProviderOptions}, + {name: "milestone", fun: f3_tests.TestMilestones, opts: testMilestonesProviderOptions}, + {name: "project", fun: f3_tests.TestProject, opts: testProjectProviderOptions}, + {name: "user", fun: f3_tests.TestUsers, opts: testUsersProviderOptions}, + {name: "topic", fun: f3_tests.TestTopic, opts: f3_tests.TestTopicProviderOptions}, + {name: "pull_request", fun: f3_tests.TestPullRequests, opts: testPullRequestsProviderOptions}, + {name: "release", fun: f3_tests.TestReleases, opts: testReleasesProviderOptions}, + {name: "review", fun: f3_tests.TestReview, opts: testReviewProviderOptions}, + } { + t.Run(testCase.name, func(t *testing.T) { + testCase.fun(NewTestForgejo(t), testCase.opts) + }) + } +} + +type forgejoInstance struct { + f3_tests.ForgeInstance +} + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} + +func (o *forgejoInstance) Init(t f3_tests.TestingT) { + g := &Forgejo{} + o.ForgeInstance.Init(t, g) + + doer, err := user_model.GetAdminUser(o.GetCtx()) + if err != nil { + panic(fmt.Errorf("GetAdminUser %v", err)) + } + + options := &Options{ + Options: config_types.Options{ + Configuration: config_types.Configuration{ + Type: strings.ToLower(Name), + }, + Features: config_types.AllFeatures, + }, + Doer: doer, + } + options.SetDefaults() + g.Init(options) +} + +func NewTestForgejo(t f3_tests.TestingT) f3_tests.ForgeTestInterface { + o := forgejoInstance{} + o.Init(t) + return &o +} diff --git a/services/f3/driver/milestone.go b/services/f3/driver/milestone.go new file mode 100644 index 0000000000..f5aea0fae3 --- /dev/null +++ b/services/f3/driver/milestone.go @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type Milestone struct { + issues_model.Milestone +} + +func MilestoneConverter(f *issues_model.Milestone) *Milestone { + return &Milestone{ + Milestone: *f, + } +} + +func (o Milestone) GetID() int64 { + return o.ID +} + +func (o Milestone) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o Milestone) GetName() string { + return o.Name +} + +func (o *Milestone) SetID(id int64) { + o.ID = id +} + +func (o *Milestone) SetIDString(id string) { + o.SetID(util.ParseInt(id)) +} + +func (o *Milestone) IsNil() bool { + return o.ID == 0 +} + +func (o *Milestone) Equals(other *Milestone) bool { + return o.Name == other.Name +} + +func (o *Milestone) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Milestone) ToFormat() *format.Milestone { + milestone := &format.Milestone{ + Common: format.NewCommon(o.ID), + Title: o.Name, + Description: o.Content, + Created: o.CreatedUnix.AsTime(), + Updated: o.UpdatedUnix.AsTimePtr(), + State: string(o.State()), + } + if o.IsClosed { + milestone.Closed = o.ClosedDateUnix.AsTimePtr() + } + if o.DeadlineUnix.Year() < 9999 { + milestone.Deadline = o.DeadlineUnix.AsTimePtr() + } + return milestone +} + +func (o *Milestone) FromFormat(milestone *format.Milestone) { + var deadline timeutil.TimeStamp + if milestone.Deadline != nil { + deadline = timeutil.TimeStamp(milestone.Deadline.Unix()) + } + if deadline == 0 { + deadline = timeutil.TimeStamp(time.Date(9999, 1, 1, 0, 0, 0, 0, setting.DefaultUILocation).Unix()) + } + + var closed timeutil.TimeStamp + if milestone.Closed != nil { + closed = timeutil.TimeStamp(milestone.Closed.Unix()) + } + + if milestone.Created.IsZero() { + if milestone.Updated != nil { + milestone.Created = *milestone.Updated + } else if milestone.Deadline != nil { + milestone.Created = *milestone.Deadline + } else { + milestone.Created = time.Now() + } + } + if milestone.Updated == nil || milestone.Updated.IsZero() { + milestone.Updated = &milestone.Created + } + + *o = Milestone{ + issues_model.Milestone{ + ID: milestone.GetID(), + Name: milestone.Title, + Content: milestone.Description, + IsClosed: milestone.State == "closed", + CreatedUnix: timeutil.TimeStamp(milestone.Created.Unix()), + UpdatedUnix: timeutil.TimeStamp(milestone.Updated.Unix()), + ClosedDateUnix: closed, + DeadlineUnix: deadline, + }, + } +} + +type MilestoneProvider struct { + BaseProviderWithProjectProvider +} + +func (o *MilestoneProvider) ToFormat(ctx context.Context, milestone *Milestone) *format.Milestone { + return milestone.ToFormat() +} + +func (o *MilestoneProvider) FromFormat(ctx context.Context, m *format.Milestone) *Milestone { + var milestone Milestone + milestone.FromFormat(m) + return &milestone +} + +func (o *MilestoneProvider) GetObjects(ctx context.Context, user *User, project *Project, page int) []*Milestone { + milestones, _, err := issues_model.GetMilestones(issues_model.GetMilestonesOption{ + ListOptions: db.ListOptions{Page: page, PageSize: o.g.perPage}, + RepoID: project.GetID(), + State: api.StateAll, + }) + if err != nil { + panic(fmt.Errorf("error while listing milestones: %v", err)) + } + + r := util.ConvertMap[*issues_model.Milestone, *Milestone](([]*issues_model.Milestone)(milestones), MilestoneConverter) + if o.project != nil { + o.project.milestones = util.NewNameIDMap[*Milestone](r) + } + return r +} + +func (o *MilestoneProvider) ProcessObject(ctx context.Context, user *User, project *Project, milestone *Milestone) { +} + +func (o *MilestoneProvider) Get(ctx context.Context, user *User, project *Project, exemplar *Milestone) *Milestone { + id := exemplar.GetID() + milestone, err := issues_model.GetMilestoneByRepoID(ctx, project.GetID(), id) + if issues_model.IsErrMilestoneNotExist(err) { + return &Milestone{} + } + if err != nil { + panic(err) + } + return MilestoneConverter(milestone) +} + +func (o *MilestoneProvider) Put(ctx context.Context, user *User, project *Project, milestone, existing *Milestone) *Milestone { + m := milestone.Milestone + m.RepoID = project.GetID() + + var result *Milestone + + if existing == nil || existing.IsNil() { + if err := issues_model.NewMilestone(ctx, &m); err != nil { + panic(err) + } + result = MilestoneConverter(&m) + } else { + var u issues_model.Milestone + u.ID = existing.GetID() + cols := make([]string, 0, 10) + + if m.Name != existing.Name { + u.Name = m.Name + cols = append(cols, "name") + } + + if len(cols) > 0 { + if _, err := db.GetEngine(ctx).ID(existing.ID).Cols(cols...).Update(u); err != nil { + panic(err) + } + } + + result = existing + } + + return o.Get(ctx, user, project, result) +} + +func (o *MilestoneProvider) Delete(ctx context.Context, user *User, project *Project, milestone *Milestone) *Milestone { + m := o.Get(ctx, user, project, milestone) + if !m.IsNil() { + if err := issues_model.DeleteMilestoneByRepoID(ctx, project.GetID(), m.GetID()); err != nil { + panic(err) + } + } + return m +} diff --git a/services/f3/driver/project.go b/services/f3/driver/project.go new file mode 100644 index 0000000000..5fb163da48 --- /dev/null +++ b/services/f3/driver/project.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + repo_service "code.gitea.io/gitea/services/repository" + + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + f3_util "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type Project struct { + repo_model.Repository +} + +func ProjectConverter(f *repo_model.Repository) *Project { + return &Project{ + Repository: *f, + } +} + +func (o Project) GetID() int64 { + return o.ID +} + +func (o Project) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o *Project) SetID(id int64) { + o.ID = id +} + +func (o *Project) SetIDString(id string) { + o.SetID(f3_util.ParseInt(id)) +} + +func (o *Project) IsNil() bool { + return o.ID == 0 +} + +func (o *Project) Equals(other *Project) bool { + return (o.Name == other.Name) +} + +func (o *Project) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Project) ToFormat() *format.Project { + return &format.Project{ + Common: format.NewCommon(o.ID), + Name: o.Name, + Owner: o.Owner.Name, + IsPrivate: o.IsPrivate, + Description: o.Description, + CloneURL: repo_model.ComposeHTTPSCloneURL(o.Owner.Name, o.Name), + OriginalURL: o.OriginalURL, + DefaultBranch: o.DefaultBranch, + } +} + +func (o *Project) FromFormat(project *format.Project) { + *o = Project{ + Repository: repo_model.Repository{ + ID: project.GetID(), + Name: project.Name, + Owner: &user_model.User{ + Name: project.Owner, + }, + IsPrivate: project.IsPrivate, + Description: project.Description, + OriginalURL: project.OriginalURL, + DefaultBranch: project.DefaultBranch, + }, + } +} + +type ProjectProvider struct { + BaseProvider + milestones f3_util.NameIDMap + labels f3_util.NameIDMap +} + +func (o *ProjectProvider) ToFormat(ctx context.Context, project *Project) *format.Project { + return project.ToFormat() +} + +func (o *ProjectProvider) FromFormat(ctx context.Context, p *format.Project) *Project { + var project Project + project.FromFormat(p) + return &project +} + +func (o *ProjectProvider) GetObjects(ctx context.Context, user *User, page int) []*Project { + repoList, _, err := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{Page: page, PageSize: o.g.perPage}, + Actor: &user.User, + Private: true, + }) + if err != nil { + panic(fmt.Errorf("error while listing projects: %T %v", err, err)) + } + if err := repoList.LoadAttributes(ctx); err != nil { + panic(nil) + } + return f3_util.ConvertMap[*repo_model.Repository, *Project](([]*repo_model.Repository)(repoList), ProjectConverter) +} + +func (o *ProjectProvider) ProcessObject(ctx context.Context, user *User, project *Project) { +} + +func (o *ProjectProvider) Get(ctx context.Context, user *User, exemplar *Project) *Project { + var project *repo_model.Repository + var err error + if exemplar.GetID() > 0 { + project, err = repo_model.GetRepositoryByID(ctx, exemplar.GetID()) + } else if exemplar.Name != "" { + project, err = repo_model.GetRepositoryByName(user.GetID(), exemplar.Name) + } else { + panic("GetID() == 0 and ProjectName == \"\"") + } + if repo_model.IsErrRepoNotExist(err) { + return &Project{} + } + if err != nil { + panic(fmt.Errorf("project %v %w", exemplar, err)) + } + if err := project.LoadOwner(ctx); err != nil { + panic(err) + } + return ProjectConverter(project) +} + +func (o *ProjectProvider) Put(ctx context.Context, user *User, project, existing *Project) *Project { + var result *Project + + if existing == nil || existing.IsNil() { + repo, err := repo_service.CreateRepository(ctx, o.g.GetDoer(), &user.User, repo_service.CreateRepoOptions{ + Name: project.Name, + Description: project.Description, + OriginalURL: project.OriginalURL, + IsPrivate: project.IsPrivate, + }) + if err != nil { + panic(err) + } + result = ProjectConverter(repo) + } else { + var u repo_model.Repository + u.ID = existing.GetID() + cols := make([]string, 0, 10) + + if project.Name != existing.Name { + u.Name = project.Name + u.LowerName = strings.ToLower(u.Name) + cols = append(cols, "name", "lower_name") + } + if len(cols) > 0 { + if _, err := db.GetEngine(ctx).ID(existing.ID).Cols(cols...).Update(u); err != nil { + panic(err) + } + } + result = existing + } + + return o.Get(ctx, user, result) +} + +func (o *ProjectProvider) Delete(ctx context.Context, user *User, project *Project) *Project { + if project.IsNil() { + return project + } + if project.ID > 0 { + project = o.Get(ctx, user, project) + } + if !project.IsNil() { + err := repo_service.DeleteRepository(ctx, o.g.GetDoer(), &project.Repository, true) + if err != nil { + panic(err) + } + } + return project +} diff --git a/services/f3/driver/pull_request.go b/services/f3/driver/pull_request.go new file mode 100644 index 0000000000..a90293e18b --- /dev/null +++ b/services/f3/driver/pull_request.go @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + issue_service "code.gitea.io/gitea/services/issue" + + f3_forgejo "lab.forgefriends.org/friendlyforgeformat/gof3/forges/forgejo" + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type PullRequest struct { + issues_model.PullRequest + FetchFunc func(repository string) string +} + +func PullRequestConverter(f *issues_model.PullRequest) *PullRequest { + return &PullRequest{ + PullRequest: *f, + } +} + +func (o PullRequest) GetID() int64 { + return o.Index +} + +func (o PullRequest) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o *PullRequest) SetID(id int64) { + o.Index = id +} + +func (o *PullRequest) SetIDString(id string) { + o.SetID(util.ParseInt(id)) +} + +func (o *PullRequest) IsNil() bool { + return o.Index == 0 +} + +func (o *PullRequest) Equals(other *PullRequest) bool { + return o.Issue.Title == other.Issue.Title +} + +func (o PullRequest) IsForkPullRequest() bool { + return o.HeadRepoID != o.BaseRepoID +} + +func (o *PullRequest) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *PullRequest) ToFormat() *format.PullRequest { + var milestone string + if o.Issue.Milestone != nil { + milestone = o.Issue.Milestone.Name + } + + labels := make([]string, 0, len(o.Issue.Labels)) + for _, label := range o.Issue.Labels { + labels = append(labels, label.Name) + } + + var mergedTime *time.Time + if o.HasMerged { + mergedTime = o.MergedUnix.AsTimePtr() + } + + getSHA := func(repo *repo_model.Repository, branch string) string { + r, err := git.OpenRepository(context.Background(), repo.RepoPath()) + if err != nil { + panic(err) + } + defer r.Close() + + b, err := r.GetBranch(branch) + if err != nil { + panic(err) + } + + c, err := b.GetCommit() + if err != nil { + panic(err) + } + return c.ID.String() + } + + head := format.PullRequestBranch{ + CloneURL: o.HeadRepo.CloneLink().HTTPS, + Ref: o.HeadBranch, + SHA: getSHA(o.HeadRepo, o.HeadBranch), + RepoName: o.HeadRepo.Name, + OwnerName: o.HeadRepo.OwnerName, + } + + base := format.PullRequestBranch{ + CloneURL: o.BaseRepo.CloneLink().HTTPS, + Ref: o.BaseBranch, + SHA: getSHA(o.BaseRepo, o.BaseBranch), + RepoName: o.BaseRepo.Name, + OwnerName: o.BaseRepo.OwnerName, + } + + return &format.PullRequest{ + Common: format.NewCommon(o.Index), + PosterID: format.NewUserReference(o.Issue.Poster.ID), + Title: o.Issue.Title, + Content: o.Issue.Content, + Milestone: milestone, + State: string(o.Issue.State()), + IsLocked: o.Issue.IsLocked, + Created: o.Issue.CreatedUnix.AsTime(), + Updated: o.Issue.UpdatedUnix.AsTime(), + Closed: o.Issue.ClosedUnix.AsTimePtr(), + Labels: labels, + PatchURL: o.Issue.PatchURL(), + Merged: o.HasMerged, + MergedTime: mergedTime, + MergeCommitSHA: o.MergedCommitID, + Head: head, + Base: base, + } +} + +func (o *PullRequest) FromFormat(pullRequest *format.PullRequest) { + labels := make([]*issues_model.Label, 0, len(pullRequest.Labels)) + for _, label := range pullRequest.Labels { + labels = append(labels, &issues_model.Label{Name: label}) + } + + if pullRequest.Created.IsZero() { + if pullRequest.Closed != nil { + pullRequest.Created = *pullRequest.Closed + } else if pullRequest.MergedTime != nil { + pullRequest.Created = *pullRequest.MergedTime + } else { + pullRequest.Created = time.Now() + } + } + if pullRequest.Updated.IsZero() { + pullRequest.Updated = pullRequest.Created + } + + ctx := context.Background() + base, err := repo_model.GetRepositoryByOwnerAndName(ctx, pullRequest.Base.OwnerName, pullRequest.Base.RepoName) + if err != nil { + panic(err) + } + var head *repo_model.Repository + if pullRequest.Head.RepoName == "" { + head = base + } else { + head, err = repo_model.GetRepositoryByOwnerAndName(ctx, pullRequest.Head.OwnerName, pullRequest.Head.RepoName) + if err != nil { + panic(err) + } + } + + issue := issues_model.Issue{ + RepoID: base.ID, + Repo: base, + Title: pullRequest.Title, + Index: pullRequest.GetID(), + PosterID: pullRequest.PosterID.GetID(), + Poster: &user_model.User{ + ID: pullRequest.PosterID.GetID(), + }, + Content: pullRequest.Content, + IsPull: true, + IsClosed: pullRequest.State == "closed", + IsLocked: pullRequest.IsLocked, + Labels: labels, + CreatedUnix: timeutil.TimeStamp(pullRequest.Created.Unix()), + UpdatedUnix: timeutil.TimeStamp(pullRequest.Updated.Unix()), + } + + pr := issues_model.PullRequest{ + HeadRepoID: head.ID, + HeadRepo: &repo_model.Repository{ + ID: head.ID, + Name: pullRequest.Head.RepoName, + OwnerName: pullRequest.Head.OwnerName, + }, + HeadBranch: pullRequest.Head.Ref, + BaseRepoID: base.ID, + BaseRepo: &repo_model.Repository{ + ID: base.ID, + Name: pullRequest.Base.RepoName, + OwnerName: pullRequest.Base.OwnerName, + }, + BaseBranch: pullRequest.Base.Ref, + MergeBase: pullRequest.Base.SHA, + Index: pullRequest.GetID(), + HasMerged: pullRequest.Merged, + + Issue: &issue, + } + + if pr.Issue.IsClosed && pullRequest.Closed != nil { + pr.Issue.ClosedUnix = timeutil.TimeStamp(pullRequest.Closed.Unix()) + } + if pr.HasMerged && pullRequest.MergedTime != nil { + pr.MergedUnix = timeutil.TimeStamp(pullRequest.MergedTime.Unix()) + pr.MergedCommitID = pullRequest.MergeCommitSHA + } + + *o = PullRequest{ + PullRequest: pr, + FetchFunc: pullRequest.FetchFunc, + } +} + +type PullRequestProvider struct { + BaseProviderWithProjectProvider + prHeadCache f3_forgejo.PrHeadCache +} + +func (o *PullRequestProvider) ToFormat(ctx context.Context, pullRequest *PullRequest) *format.PullRequest { + return pullRequest.ToFormat() +} + +func (o *PullRequestProvider) FromFormat(ctx context.Context, pr *format.PullRequest) *PullRequest { + var pullRequest PullRequest + pullRequest.FromFormat(pr) + return &pullRequest +} + +func (o *PullRequestProvider) Init() *PullRequestProvider { + o.prHeadCache = make(f3_forgejo.PrHeadCache) + return o +} + +func (o *PullRequestProvider) cleanupRemotes(ctx context.Context, repository string) { + for remote := range o.prHeadCache { + util.Command(ctx, "git", "-C", repository, "remote", "rm", remote) + } + o.prHeadCache = make(f3_forgejo.PrHeadCache) +} + +func (o *PullRequestProvider) GetObjects(ctx context.Context, user *User, project *Project, page int) []*PullRequest { + pullRequests, _, err := issues_model.PullRequests(ctx, project.GetID(), &issues_model.PullRequestsOptions{ + ListOptions: db.ListOptions{Page: page, PageSize: o.g.perPage}, + State: string(api.StateAll), + }) + if err != nil { + panic(fmt.Errorf("error while listing pullRequests: %v", err)) + } + + return util.ConvertMap[*issues_model.PullRequest, *PullRequest](pullRequests, PullRequestConverter) +} + +func (o *PullRequestProvider) ProcessObject(ctx context.Context, user *User, project *Project, pr *PullRequest) { + if err := pr.LoadIssue(ctx); err != nil { + panic(err) + } + if err := pr.Issue.LoadRepo(ctx); err != nil { + panic(err) + } + if err := pr.Issue.LoadAttributes(ctx); err != nil { + panic(err) + } + if err := pr.LoadAttributes(ctx); err != nil { + panic(err) + } + if err := pr.LoadBaseRepo(ctx); err != nil { + panic(err) + } + if err := pr.LoadHeadRepo(ctx); err != nil { + panic(err) + } + + pr.FetchFunc = func(repository string) string { + head, messages := f3_forgejo.UpdateGitForPullRequest(ctx, &o.prHeadCache, pr.ToFormat(), repository) + for _, message := range messages { + o.g.GetLogger().Warn(message) + } + o.cleanupRemotes(ctx, repository) + return head + } +} + +func (o *PullRequestProvider) Get(ctx context.Context, user *User, project *Project, pullRequest *PullRequest) *PullRequest { + id := pullRequest.GetID() + pr, err := issues_model.GetPullRequestByIndex(ctx, project.GetID(), id) + if issues_model.IsErrPullRequestNotExist(err) { + return &PullRequest{} + } + if err != nil { + panic(err) + } + p := PullRequestConverter(pr) + o.ProcessObject(ctx, user, project, p) + return p +} + +func (o *PullRequestProvider) Put(ctx context.Context, user *User, project *Project, pullRequest, existing *PullRequest) *PullRequest { + i := pullRequest.PullRequest.Issue + i.RepoID = project.GetID() + labels := make([]int64, 0, len(i.Labels)) + for _, label := range i.Labels { + labels = append(labels, label.ID) + } + + if existing == nil || existing.IsNil() { + if err := issues_model.NewPullRequest(ctx, &project.Repository, i, labels, []string{}, &pullRequest.PullRequest); err != nil { + panic(err) + } + } else { + var u issues_model.Issue + u.Index = i.Index + u.RepoID = project.GetID() + cols := make([]string, 0, 10) + + if i.Title != existing.Issue.Title { + u.Title = i.Title + cols = append(cols, "name") + } + + if len(cols) > 0 { + if _, err := db.GetEngine(ctx).ID(existing.Issue.ID).Cols(cols...).Update(u); err != nil { + panic(err) + } + } + } + + if pullRequest.FetchFunc != nil { + repoPath := repo_model.RepoPath(user.Name, project.Name) + fromHead := pullRequest.FetchFunc(repoPath) + gitRepo, err := git.OpenRepository(ctx, repoPath) + if err != nil { + panic(err) + } + defer gitRepo.Close() + + toHead := fmt.Sprintf("%s%d/head", git.PullPrefix, pullRequest.GetID()) + if err := git.NewCommand(ctx, "update-ref").AddDynamicArguments(toHead, fromHead).Run(&git.RunOpts{Dir: repoPath}); err != nil { + panic(err) + } + } + + return o.Get(ctx, user, project, pullRequest) +} + +func (o *PullRequestProvider) Delete(ctx context.Context, user *User, project *Project, pullRequest *PullRequest) *PullRequest { + p := o.Get(ctx, user, project, pullRequest) + if !p.IsNil() { + repoPath := repo_model.RepoPath(user.Name, project.Name) + gitRepo, err := git.OpenRepository(ctx, repoPath) + if err != nil { + panic(err) + } + defer gitRepo.Close() + if err := issue_service.DeleteIssue(ctx, o.g.GetDoer(), gitRepo, p.PullRequest.Issue); err != nil { + panic(err) + } + } + return p +} diff --git a/services/f3/driver/reaction.go b/services/f3/driver/reaction.go new file mode 100644 index 0000000000..fb7cdb7b1b --- /dev/null +++ b/services/f3/driver/reaction.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + + "lab.forgefriends.org/friendlyforgeformat/gof3/forges/common" + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" + "xorm.io/builder" +) + +type Reaction struct { + issues_model.Reaction +} + +func ReactionConverter(f *issues_model.Reaction) *Reaction { + return &Reaction{ + Reaction: *f, + } +} + +func (o Reaction) GetID() int64 { + return o.ID +} + +func (o Reaction) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o *Reaction) SetID(id int64) { + o.ID = id +} + +func (o *Reaction) SetIDString(id string) { + o.SetID(util.ParseInt(id)) +} + +func (o *Reaction) IsNil() bool { + return o.ID == 0 +} + +func (o *Reaction) Equals(other *Reaction) bool { + return o.UserID == other.UserID && o.Type == other.Type +} + +func (o *Reaction) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Reaction) ToFormat() *format.Reaction { + return &format.Reaction{ + Common: format.NewCommon(o.ID), + UserID: format.NewUserReference(o.User.ID), + Content: o.Type, + } +} + +func (o *Reaction) FromFormat(reaction *format.Reaction) { + *o = Reaction{ + Reaction: issues_model.Reaction{ + ID: reaction.GetID(), + UserID: reaction.UserID.GetID(), + User: &user_model.User{ + ID: reaction.UserID.GetID(), + }, + Type: reaction.Content, + }, + } +} + +type ReactionProvider struct { + BaseProvider +} + +func (o *ReactionProvider) ToFormat(ctx context.Context, reaction *Reaction) *format.Reaction { + return reaction.ToFormat() +} + +func (o *ReactionProvider) FromFormat(ctx context.Context, m *format.Reaction) *Reaction { + var reaction Reaction + reaction.FromFormat(m) + return &reaction +} + +// +// Although it would be possible to use a higher level logic instead of the database, +// as of September 2022 (1.18 dev) +// (i) models/issues/reaction.go imposes a significant overhead +// (ii) is fragile and bugous https://github.com/go-gitea/gitea/issues/20860 +// + +func (o *ReactionProvider) GetObjects(ctx context.Context, user *User, project *Project, parents []common.ContainerObjectInterface, page int) []*Reaction { + cond := builder.NewCond() + switch l := parents[len(parents)-1].(type) { + case *Issue: + cond = cond.And(builder.Eq{"reaction.issue_id": l.ID}) + cond = cond.And(builder.Eq{"reaction.comment_id": 0}) + case *Comment: + cond = cond.And(builder.Eq{"reaction.comment_id": l.ID}) + default: + panic(fmt.Errorf("unexpected type %T", parents[len(parents)-1])) + } + sess := db.GetEngine(ctx).Where(cond) + if page > 0 { + sess = db.SetSessionPagination(sess, &db.ListOptions{Page: page, PageSize: o.g.perPage}) + } + reactions := make([]*issues_model.Reaction, 0, 10) + if err := sess.Find(&reactions); err != nil { + panic(err) + } + _, err := (issues_model.ReactionList)(reactions).LoadUsers(ctx, nil) + if err != nil { + panic(err) + } + return util.ConvertMap[*issues_model.Reaction, *Reaction](reactions, ReactionConverter) +} + +func (o *ReactionProvider) ProcessObject(ctx context.Context, user *User, project *Project, parents []common.ContainerObjectInterface, reaction *Reaction) { +} + +func (o *ReactionProvider) Get(ctx context.Context, user *User, project *Project, parents []common.ContainerObjectInterface, exemplar *Reaction) *Reaction { + reaction := &Reaction{} + has, err := db.GetEngine(ctx).ID(exemplar.GetID()).Get(&reaction.Reaction) + if err != nil { + panic(err) + } else if !has { + return &Reaction{} + } + if _, err := (issues_model.ReactionList{&reaction.Reaction}).LoadUsers(ctx, nil); err != nil { + panic(err) + } + return reaction +} + +func (o *ReactionProvider) Put(ctx context.Context, user *User, project *Project, parents []common.ContainerObjectInterface, reaction, existing *Reaction) *Reaction { + r := &issues_model.Reaction{ + Type: reaction.Type, + UserID: o.g.GetDoer().ID, + } + switch l := parents[len(parents)-1].(type) { + case *Issue: + r.IssueID = l.ID + r.CommentID = 0 + case *Comment: + i, ok := parents[len(parents)-2].(*Issue) + if !ok { + panic(fmt.Errorf("unexpected type %T", parents[len(parents)-2])) + } + r.IssueID = i.ID + r.CommentID = l.ID + default: + panic(fmt.Errorf("unexpected type %T", parents[len(parents)-1])) + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + panic(err) + } + defer committer.Close() + + if _, err := db.GetEngine(ctx).Insert(r); err != nil { + panic(err) + } + + if err := committer.Commit(); err != nil { + panic(err) + } + return ReactionConverter(r) +} + +func (o *ReactionProvider) Delete(ctx context.Context, user *User, project *Project, parents []common.ContainerObjectInterface, reaction *Reaction) *Reaction { + r := o.Get(ctx, user, project, parents, reaction) + if !r.IsNil() { + if _, err := db.GetEngine(ctx).Delete(&reaction.Reaction); err != nil { + panic(err) + } + return reaction + } + return r +} diff --git a/services/f3/driver/release.go b/services/f3/driver/release.go new file mode 100644 index 0000000000..a666794052 --- /dev/null +++ b/services/f3/driver/release.go @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/timeutil" + release_service "code.gitea.io/gitea/services/release" + + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type Release struct { + repo_model.Release +} + +func ReleaseConverter(f *repo_model.Release) *Release { + return &Release{ + Release: *f, + } +} + +func (o Release) GetID() int64 { + return o.ID +} + +func (o Release) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o *Release) SetID(id int64) { + o.ID = id +} + +func (o *Release) SetIDString(id string) { + o.SetID(util.ParseInt(id)) +} + +func (o *Release) IsNil() bool { + return o.ID == 0 +} + +func (o *Release) Equals(other *Release) bool { + return (o.TagName == other.TagName && + o.Title == other.Title) +} + +func (o *Release) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Release) ToFormat() *format.Release { + return &format.Release{ + Common: format.NewCommon(o.ID), + TagName: o.TagName, + TargetCommitish: o.Target, + Name: o.Title, + Body: o.Note, + Draft: o.IsDraft, + Prerelease: o.IsPrerelease, + Created: o.CreatedUnix.AsTime(), + PublisherID: format.NewUserReference(o.Publisher.ID), + } +} + +func (o *Release) FromFormat(release *format.Release) { + if release.Created.IsZero() { + if !release.Published.IsZero() { + release.Created = release.Published + } else { + release.Created = time.Now() + } + } + + *o = Release{ + repo_model.Release{ + ID: release.GetID(), + PublisherID: release.PublisherID.GetID(), + Publisher: &user_model.User{ + ID: release.PublisherID.GetID(), + }, + TagName: release.TagName, + LowerTagName: strings.ToLower(release.TagName), + Target: release.TargetCommitish, + Title: release.Name, + Note: release.Body, + IsDraft: release.Draft, + IsPrerelease: release.Prerelease, + IsTag: false, + CreatedUnix: timeutil.TimeStamp(release.Created.Unix()), + }, + } +} + +type ReleaseProvider struct { + BaseProvider +} + +func (o *ReleaseProvider) ToFormat(ctx context.Context, release *Release) *format.Release { + return release.ToFormat() +} + +func (o *ReleaseProvider) FromFormat(ctx context.Context, i *format.Release) *Release { + var release Release + release.FromFormat(i) + return &release +} + +func (o *ReleaseProvider) GetObjects(ctx context.Context, user *User, project *Project, page int) []*Release { + releases, err := repo_model.GetReleasesByRepoID(ctx, project.GetID(), repo_model.FindReleasesOptions{ + ListOptions: db.ListOptions{Page: page, PageSize: o.g.perPage}, + IncludeDrafts: true, + IncludeTags: false, + }) + if err != nil { + panic(fmt.Errorf("error while listing releases: %v", err)) + } + + return util.ConvertMap[*repo_model.Release, *Release](releases, ReleaseConverter) +} + +func (o *ReleaseProvider) ProcessObject(ctx context.Context, user *User, project *Project, release *Release) { + if err := (&release.Release).LoadAttributes(ctx); err != nil { + panic(err) + } +} + +func (o *ReleaseProvider) Get(ctx context.Context, user *User, project *Project, exemplar *Release) *Release { + id := exemplar.GetID() + release, err := repo_model.GetReleaseByID(ctx, id) + if repo_model.IsErrReleaseNotExist(err) { + return &Release{} + } + if err != nil { + panic(err) + } + r := ReleaseConverter(release) + o.ProcessObject(ctx, user, project, r) + return r +} + +func (o *ReleaseProvider) Put(ctx context.Context, user *User, project *Project, release, existing *Release) *Release { + var result *Release + + if existing == nil || existing.IsNil() { + r := release.Release + r.RepoID = project.GetID() + + repoPath := repo_model.RepoPath(user.Name, project.Name) + gitRepo, err := git.OpenRepository(ctx, repoPath) + if err != nil { + panic(err) + } + defer gitRepo.Close() + + if err := release_service.CreateRelease(gitRepo, &r, nil, ""); err != nil { + panic(err) + } + result = ReleaseConverter(&r) + } else { + var u repo_model.Release + u.ID = existing.GetID() + cols := make([]string, 0, 10) + + if release.Title != existing.Title { + u.Title = release.Title + cols = append(cols, "title") + } + if len(cols) > 0 { + if _, err := db.GetEngine(ctx).ID(existing.ID).Cols(cols...).Update(u); err != nil { + panic(err) + } + } + result = existing + } + return o.Get(ctx, user, project, result) +} + +func (o *ReleaseProvider) Delete(ctx context.Context, user *User, project *Project, release *Release) *Release { + m := o.Get(ctx, user, project, release) + if !m.IsNil() { + if err := release_service.DeleteReleaseByID(ctx, release.GetID(), o.g.GetDoer(), false); err != nil { + panic(err) + } + } + return m +} diff --git a/services/f3/driver/repository.go b/services/f3/driver/repository.go new file mode 100644 index 0000000000..76d992e07d --- /dev/null +++ b/services/f3/driver/repository.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + + repo_model "code.gitea.io/gitea/models/repo" + base "code.gitea.io/gitea/modules/migration" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/services/migrations" + + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type Repository struct { + format.Repository +} + +func (o *Repository) Equals(other *Repository) bool { + return false // it is costly to figure that out, mirroring is as fast +} + +func (o *Repository) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Repository) ToFormat() *format.Repository { + return &o.Repository +} + +func (o *Repository) FromFormat(repository *format.Repository) { + o.Repository = *repository +} + +type RepositoryProvider struct { + BaseProvider +} + +func (o *RepositoryProvider) ToFormat(ctx context.Context, repository *Repository) *format.Repository { + return repository.ToFormat() +} + +func (o *RepositoryProvider) FromFormat(ctx context.Context, p *format.Repository) *Repository { + var repository Repository + repository.FromFormat(p) + return &repository +} + +func (o *RepositoryProvider) GetObjects(ctx context.Context, user *User, project *Project, page int) []*Repository { + if page > 1 { + return make([]*Repository, 0) + } + repositories := make([]*Repository, 0, len(format.RepositoryNames)) + for _, name := range format.RepositoryNames { + repositories = append(repositories, o.Get(ctx, user, project, &Repository{ + Repository: format.Repository{ + Name: name, + }, + })) + } + return repositories +} + +func (o *RepositoryProvider) ProcessObject(ctx context.Context, user *User, project *Project, repository *Repository) { +} + +func (o *RepositoryProvider) Get(ctx context.Context, user *User, project *Project, exemplar *Repository) *Repository { + repoPath := repo_model.RepoPath(user.Name, project.Name) + exemplar.Name + o.g.GetLogger().Debug(repoPath) + return &Repository{ + Repository: format.Repository{ + Name: exemplar.Name, + FetchFunc: func(destination string) { + o.g.GetLogger().Debug("RepositoryProvider:Get: git clone %s %s", repoPath, destination) + util.Command(ctx, "git", "clone", "--mirror", repoPath, destination) + }, + }, + } +} + +func (o *RepositoryProvider) Put(ctx context.Context, user *User, project *Project, repository, existing *Repository) *Repository { + if repository.FetchFunc != nil { + directory, delete := format.RepositoryDefaultDirectory() + defer delete() + repository.FetchFunc(directory) + + _, err := repo_module.MigrateRepositoryGitData(ctx, &user.User, &project.Repository, base.MigrateOptions{ + RepoName: project.Name, + Mirror: false, + MirrorInterval: "", + LFS: false, + LFSEndpoint: "", + CloneAddr: directory, + Wiki: o.g.GetOptions().GetFeatures().Wiki, + Releases: o.g.GetOptions().GetFeatures().Releases, + }, migrations.NewMigrationHTTPTransport()) + if err != nil { + panic(err) + } + } + return o.Get(ctx, user, project, repository) +} + +func (o *RepositoryProvider) Delete(ctx context.Context, user *User, project *Project, repository *Repository) *Repository { + panic("It is not possible to delete a repository") +} diff --git a/services/f3/driver/review.go b/services/f3/driver/review.go new file mode 100644 index 0000000000..e8035070e7 --- /dev/null +++ b/services/f3/driver/review.go @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/convert" + + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type Review struct { + issues_model.Review +} + +func ReviewConverter(f *issues_model.Review) *Review { + return &Review{ + Review: *f, + } +} + +func (o Review) GetID() int64 { + return o.ID +} + +func (o Review) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o *Review) SetID(id int64) { + o.ID = id +} + +func (o *Review) SetIDString(id string) { + o.SetID(util.ParseInt(id)) +} + +func (o *Review) IsNil() bool { + return o.ID == 0 +} + +func (o *Review) Equals(other *Review) bool { + return o.Content == other.Content +} + +func (o *Review) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Review) ToFormat() *format.Review { + comments := make([]*format.ReviewComment, 0, len(o.Comments)) + for _, comment := range o.Comments { + comments = append(comments, &format.ReviewComment{ + Common: format.NewCommon(comment.ID), + // InReplyTo + Content: comment.Content, + TreePath: comment.TreePath, + DiffHunk: convert.Patch2diff(comment.Patch), + Patch: comment.Patch, + Line: int(comment.Line), + CommitID: comment.CommitSHA, + PosterID: format.NewUserReference(comment.PosterID), + CreatedAt: comment.CreatedUnix.AsTime(), + UpdatedAt: comment.UpdatedUnix.AsTime(), + }) + } + + review := format.Review{ + Common: format.NewCommon(o.Review.ID), + IssueIndex: o.IssueID, + Official: o.Review.Official, + CommitID: o.Review.CommitID, + Content: o.Review.Content, + CreatedAt: o.Review.CreatedUnix.AsTime(), + State: format.ReviewStateUnknown, + Comments: comments, + } + + if o.Review.Reviewer != nil { + review.ReviewerID = format.NewUserReference(o.Review.Reviewer.ID) + } + + switch o.Type { + case issues_model.ReviewTypeApprove: + review.State = format.ReviewStateApproved + case issues_model.ReviewTypeReject: + review.State = format.ReviewStateChangesRequested + case issues_model.ReviewTypeComment: + review.State = format.ReviewStateCommented + case issues_model.ReviewTypePending: + review.State = format.ReviewStatePending + case issues_model.ReviewTypeRequest: + review.State = format.ReviewStateRequestReview + } + + return &review +} + +func (o *Review) FromFormat(review *format.Review) { + comments := make([]*issues_model.Comment, 0, len(review.Comments)) + for _, comment := range review.Comments { + comments = append(comments, &issues_model.Comment{ + ID: comment.GetID(), + Type: issues_model.CommentTypeReview, + // InReplyTo + CommitSHA: comment.CommitID, + Line: int64(comment.Line), + TreePath: comment.TreePath, + Content: comment.Content, + Patch: comment.Patch, + PosterID: comment.PosterID.GetID(), + CreatedUnix: timeutil.TimeStamp(comment.CreatedAt.Unix()), + UpdatedUnix: timeutil.TimeStamp(comment.UpdatedAt.Unix()), + }) + } + *o = Review{ + Review: issues_model.Review{ + ID: review.GetID(), + ReviewerID: review.ReviewerID.GetID(), + Reviewer: &user_model.User{ + ID: review.ReviewerID.GetID(), + }, + IssueID: review.IssueIndex, + Official: review.Official, + CommitID: review.CommitID, + Content: review.Content, + CreatedUnix: timeutil.TimeStamp(review.CreatedAt.Unix()), + Comments: comments, + }, + } + + switch review.State { + case format.ReviewStateApproved: + o.Type = issues_model.ReviewTypeApprove + case format.ReviewStateChangesRequested: + o.Type = issues_model.ReviewTypeReject + case format.ReviewStateCommented: + o.Type = issues_model.ReviewTypeComment + case format.ReviewStatePending: + o.Type = issues_model.ReviewTypePending + case format.ReviewStateRequestReview: + o.Type = issues_model.ReviewTypeRequest + } +} + +type ReviewProvider struct { + BaseProvider +} + +func (o *ReviewProvider) ToFormat(ctx context.Context, review *Review) *format.Review { + return review.ToFormat() +} + +func (o *ReviewProvider) FromFormat(ctx context.Context, r *format.Review) *Review { + var review Review + review.FromFormat(r) + return &review +} + +func (o *ReviewProvider) GetObjects(ctx context.Context, user *User, project *Project, pullRequest *PullRequest, page int) []*Review { + reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{ + ListOptions: db.ListOptions{Page: page, PageSize: o.g.perPage}, + IssueID: pullRequest.IssueID, + Type: issues_model.ReviewTypeUnknown, + }) + if err != nil { + panic(fmt.Errorf("error while listing reviews: %v", err)) + } + + return util.ConvertMap[*issues_model.Review, *Review](reviews, ReviewConverter) +} + +func (o *ReviewProvider) ProcessObject(ctx context.Context, user *User, project *Project, pullRequest *PullRequest, review *Review) { + if err := (&review.Review).LoadAttributes(ctx); err != nil { + panic(err) + } +} + +func (o *ReviewProvider) Get(ctx context.Context, user *User, project *Project, pullRequest *PullRequest, exemplar *Review) *Review { + id := exemplar.GetID() + review, err := issues_model.GetReviewByID(ctx, id) + if issues_model.IsErrReviewNotExist(err) { + return &Review{} + } + if err != nil { + panic(err) + } + if err := review.LoadAttributes(ctx); err != nil { + panic(err) + } + return ReviewConverter(review) +} + +func (o *ReviewProvider) Put(ctx context.Context, user *User, project *Project, pullRequest *PullRequest, review, existing *Review) *Review { + var result *Review + + if existing == nil || existing.IsNil() { + r := &review.Review + r.ID = 0 + for _, comment := range r.Comments { + comment.ID = 0 + } + r.IssueID = pullRequest.IssueID + if err := issues_model.InsertReviews(ctx, []*issues_model.Review{r}); err != nil { + panic(err) + } + result = ReviewConverter(r) + } else { + var u issues_model.Review + u.ID = existing.GetID() + cols := make([]string, 0, 10) + + if review.Content != existing.Content { + u.Content = review.Content + cols = append(cols, "content") + } + if len(cols) > 0 { + if _, err := db.GetEngine(ctx).ID(existing.ID).Cols(cols...).Update(u); err != nil { + panic(err) + } + } + result = existing + } + + return o.Get(ctx, user, project, pullRequest, result) +} + +func (o *ReviewProvider) Delete(ctx context.Context, user *User, project *Project, pullRequest *PullRequest, review *Review) *Review { + r := o.Get(ctx, user, project, pullRequest, review) + if !r.IsNil() { + if err := issues_model.DeleteReview(ctx, &r.Review); err != nil { + panic(err) + } + } + return r +} diff --git a/services/f3/driver/topic.go b/services/f3/driver/topic.go new file mode 100644 index 0000000000..519466fe50 --- /dev/null +++ b/services/f3/driver/topic.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type Topic struct { + repo_model.Topic +} + +func TopicConverter(f *repo_model.Topic) *Topic { + return &Topic{ + Topic: *f, + } +} + +func (o Topic) GetID() int64 { + return o.ID +} + +func (o Topic) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o *Topic) SetID(id int64) { + o.ID = id +} + +func (o *Topic) SetIDString(id string) { + o.SetID(util.ParseInt(id)) +} + +func (o *Topic) IsNil() bool { + return o.ID == 0 +} + +func (o *Topic) Equals(other *Topic) bool { + return o.Name == other.Name +} + +func (o *Topic) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *Topic) ToFormat() *format.Topic { + return &format.Topic{ + Common: format.NewCommon(o.ID), + Name: o.Name, + } +} + +func (o *Topic) FromFormat(topic *format.Topic) { + *o = Topic{ + Topic: repo_model.Topic{ + ID: topic.Index.GetID(), + Name: topic.Name, + }, + } +} + +type TopicProvider struct { + BaseProvider +} + +func (o *TopicProvider) ToFormat(ctx context.Context, topic *Topic) *format.Topic { + return topic.ToFormat() +} + +func (o *TopicProvider) FromFormat(ctx context.Context, m *format.Topic) *Topic { + var topic Topic + topic.FromFormat(m) + return &topic +} + +func (o *TopicProvider) GetObjects(ctx context.Context, user *User, project *Project, page int) []*Topic { + topics, _, err := repo_model.FindTopics(ctx, &repo_model.FindTopicOptions{ + ListOptions: db.ListOptions{Page: page, PageSize: o.g.perPage}, + RepoID: project.GetID(), + }) + if err != nil { + panic(err) + } + + return util.ConvertMap[*repo_model.Topic, *Topic](topics, TopicConverter) +} + +func (o *TopicProvider) ProcessObject(ctx context.Context, user *User, project *Project, topic *Topic) { +} + +func (o *TopicProvider) Get(ctx context.Context, user *User, project *Project, exemplar *Topic) *Topic { + id := exemplar.GetID() + topic, err := repo_model.GetRepoTopicByID(ctx, project.GetID(), id) + if repo_model.IsErrTopicNotExist(err) { + return &Topic{} + } + if err != nil { + panic(err) + } + return TopicConverter(topic) +} + +func (o *TopicProvider) Put(ctx context.Context, user *User, project *Project, topic, existing *Topic) *Topic { + t, err := repo_model.AddTopic(ctx, project.GetID(), topic.Name) + if err != nil { + panic(err) + } + return o.Get(ctx, user, project, TopicConverter(t)) +} + +func (o *TopicProvider) Delete(ctx context.Context, user *User, project *Project, topic *Topic) *Topic { + t := o.Get(ctx, user, project, topic) + if !t.IsNil() { + t, err := repo_model.DeleteTopic(ctx, project.GetID(), t.Name) + if err != nil { + panic(err) + } + return TopicConverter(t) + } + return t +} diff --git a/services/f3/driver/user.go b/services/f3/driver/user.go new file mode 100644 index 0000000000..562044e017 --- /dev/null +++ b/services/f3/driver/user.go @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "context" + "fmt" + "strings" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" + user_service "code.gitea.io/gitea/services/user" + + "lab.forgefriends.org/friendlyforgeformat/gof3/forges/common" + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + f3_util "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +type User struct { + user_model.User +} + +func UserConverter(f *user_model.User) *User { + return &User{ + User: *f, + } +} + +func (o User) GetID() int64 { + return o.ID +} + +func (o User) GetIDString() string { + return fmt.Sprintf("%d", o.GetID()) +} + +func (o *User) SetID(id int64) { + o.ID = id +} + +func (o *User) SetIDString(id string) { + o.SetID(f3_util.ParseInt(id)) +} + +func (o *User) IsNil() bool { + return o.ID == 0 +} + +func (o *User) Equals(other *User) bool { + // + // Only compare user data if both are managed by F3 otherwise + // they are equal if they have the same ID. Here is an example: + // + // * mirror from F3 to Forgejo => user jane created and assigned + // ID 213 & IsF3() + // * mirror from F3 to Forgejo => user jane username in F3 is updated + // the username for user ID 213 in Forgejo is also updated + // * user jane sign in with OAuth from the same source as the + // F3 mirror. They are promoted to IsIndividual() + // * mirror from F3 to Forgejo => user jane username in F3 is updated + // the username for user ID 213 in Forgejo is **NOT** updated, it + // no longer is managed by F3 + // + if !o.IsF3() || !other.IsF3() { + return o.ID == other.ID + } + return (o.Name == other.Name && + o.FullName == other.FullName && + o.Email == other.Email) +} + +func (o *User) ToFormatInterface() format.Interface { + return o.ToFormat() +} + +func (o *User) ToFormat() *format.User { + return &format.User{ + Common: format.NewCommon(o.ID), + UserName: o.Name, + Name: o.FullName, + Email: o.Email, + Password: o.Passwd, + } +} + +func (o *User) FromFormat(user *format.User) { + *o = User{ + User: user_model.User{ + Type: user_model.UserTypeF3, + ID: user.Index.GetID(), + Name: user.UserName, + FullName: user.Name, + Email: user.Email, + Passwd: user.Password, + }, + } +} + +type UserProvider struct { + BaseProvider +} + +func getLocalMatchingRemote(ctx context.Context, authenticationSource int64, id string) *user_model.User { + u := &user_model.User{ + LoginName: id, + LoginSource: authenticationSource, + LoginType: auth_model.OAuth2, + Type: user_model.UserTypeIndividual, + } + has, err := db.GetEngine(ctx).Get(u) + if err != nil { + panic(err) + } else if !has { + return nil + } + return u +} + +func (o *UserProvider) GetLocalMatchingRemote(ctx context.Context, format format.Interface, parents ...common.ContainerObjectInterface) (string, bool) { + authenticationSource := o.g.GetAuthenticationSource() + if authenticationSource == 0 { + return "", false + } + user := getLocalMatchingRemote(ctx, authenticationSource, format.GetIDString()) + if user != nil { + o.g.GetLogger().Debug("found existing user %d with a matching authentication source for %s", user.ID, format.GetIDString()) + return fmt.Sprintf("%d", user.ID), true + } + o.g.GetLogger().Debug("no pre-existing local user for %s", format.GetIDString()) + return "", false +} + +func (o *UserProvider) ToFormat(ctx context.Context, user *User) *format.User { + return user.ToFormat() +} + +func (o *UserProvider) FromFormat(ctx context.Context, p *format.User) *User { + var user User + user.FromFormat(p) + return &user +} + +func (o *UserProvider) GetObjects(ctx context.Context, page int) []*User { + sess := db.GetEngine(ctx).In("type", user_model.UserTypeIndividual, user_model.UserTypeF3) + if page != 0 { + sess = db.SetSessionPagination(sess, &db.ListOptions{Page: page, PageSize: o.g.perPage}) + } + sess = sess.Select("`user`.*") + users := make([]*user_model.User, 0, o.g.perPage) + + if err := sess.Find(&users); err != nil { + panic(fmt.Errorf("error while listing users: %v", err)) + } + return f3_util.ConvertMap[*user_model.User, *User](users, UserConverter) +} + +func (o *UserProvider) ProcessObject(ctx context.Context, user *User) { +} + +func GetUserByName(ctx context.Context, name string) (*user_model.User, error) { + if len(name) == 0 { + return nil, user_model.ErrUserNotExist{Name: name} + } + u := &user_model.User{Name: name} + has, err := db.GetEngine(ctx).In("type", user_model.UserTypeIndividual, user_model.UserTypeF3).Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, user_model.ErrUserNotExist{Name: name} + } + return u, nil +} + +func (o *UserProvider) Get(ctx context.Context, exemplar *User) *User { + o.g.GetLogger().Debug("%+v", *exemplar) + var user *user_model.User + var err error + if exemplar.GetID() > 0 { + user, err = user_model.GetUserByID(ctx, exemplar.GetID()) + o.g.GetLogger().Debug("%+v %v", user, err) + } else if exemplar.Name != "" { + user, err = GetUserByName(ctx, exemplar.Name) + } else { + panic("GetID() == 0 and UserName == \"\"") + } + if err != nil { + if user_model.IsErrUserNotExist(err) { + return &User{} + } + panic(fmt.Errorf("user %+v %w", *exemplar, err)) + } + return UserConverter(user) +} + +func (o *UserProvider) Put(ctx context.Context, user, existing *User) *User { + o.g.GetLogger().Trace("begin %+v", *user) + u := &user_model.User{ + ID: user.GetID(), + Type: user_model.UserTypeF3, + } + // + // Get the user, if any + // + var has bool + var err error + if u.ID > 0 { + has, err = db.GetEngine(ctx).Get(u) + if err != nil { + panic(err) + } + } + // + // Set user information + // + u.Name = user.Name + u.LowerName = strings.ToLower(u.Name) + u.FullName = user.FullName + u.Email = user.Email + if !has { + // + // The user does not exist, create it + // + o.g.GetLogger().Trace("creating %+v", *u) + u.ID = 0 + u.Passwd = user.Passwd + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsActive: util.OptionalBoolTrue, + } + err := user_model.CreateUser(ctx, u, overwriteDefault) + if err != nil { + panic(err) + } + } else { + // + // The user already exists, update it + // + o.g.GetLogger().Trace("updating %+v", *u) + if err := user_model.UpdateUserCols(ctx, u, "name", "lower_name", "email", "full_name"); err != nil { + panic(err) + } + } + r := o.Get(ctx, UserConverter(u)) + o.g.GetLogger().Trace("finish %+v", r.User) + return r +} + +func (o *UserProvider) Delete(ctx context.Context, user *User) *User { + u := o.Get(ctx, user) + if !u.IsNil() { + if err := user_service.DeleteUser(ctx, &user.User, true); err != nil { + panic(err) + } + } + return u +} diff --git a/services/f3/driver/user_test.go b/services/f3/driver/user_test.go new file mode 100644 index 0000000000..ca9527d05c --- /dev/null +++ b/services/f3/driver/user_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +package driver + +import ( + "testing" + + user_model "code.gitea.io/gitea/models/user" + + "lab.forgefriends.org/friendlyforgeformat/gof3/forges/tests" + "lab.forgefriends.org/friendlyforgeformat/gof3/format" +) + +func TestF3Driver_UserFormat(t *testing.T) { + user := User{ + User: user_model.User{ + ID: 1234, + Type: user_model.UserTypeF3, + Name: "username", + FullName: "User Name", + Email: "username@example.com", + }, + } + tests.ToFromFormat[User, format.User, *User, *format.User](t, &user) +} diff --git a/services/f3/promote.go b/services/f3/promote.go new file mode 100644 index 0000000000..5a56e83773 --- /dev/null +++ b/services/f3/promote.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright the Forgejo contributors +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + f3_source "code.gitea.io/gitea/services/auth/source/f3" + "code.gitea.io/gitea/services/auth/source/oauth2" +) + +func getUserByLoginName(ctx context.Context, name string) (*user_model.User, error) { + if len(name) == 0 { + return nil, user_model.ErrUserNotExist{Name: name} + } + u := &user_model.User{LoginName: name, LoginType: auth_model.F3, Type: user_model.UserTypeF3} + has, err := db.GetEngine(ctx).Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, user_model.ErrUserNotExist{Name: name} + } + return u, nil +} + +// The user created by F3 has: +// +// Type UserTypeF3 +// LogingType F3 +// LoginName set to the unique identifier of the originating forge +// LoginSource set to the F3 source that can be matched against a OAuth2 source +// +// If the source from which an authentification happens is OAuth2, an existing +// F3 user will be promoted to an OAuth2 user provided: +// +// user.LoginName is the same as goth.UserID (argument loginName) +// user.LoginSource has a MatchingSource equals to the name of the OAuth2 provider +// +// Once promoted, the user will be logged in without further interaction from the +// user and will own all repositories, issues, etc. associated with it. +func MaybePromoteF3User(ctx context.Context, source *auth_model.Source, loginName, email string) error { + user, err := getF3UserToPromote(ctx, source, loginName, email) + if err != nil { + return err + } + if user != nil { + promote := &user_model.User{ + ID: user.ID, + Type: user_model.UserTypeIndividual, + Email: email, + LoginSource: source.ID, + LoginType: source.Type, + } + log.Debug("promote user %v: LoginName %v => %v, LoginSource %v => %v, LoginType %v => %v, Email %v => %v", user.ID, user.LoginName, promote.LoginName, user.LoginSource, promote.LoginSource, user.LoginType, promote.LoginType, user.Email, promote.Email) + return user_model.UpdateUser(ctx, promote, true, "type", "email", "login_source", "login_type") + } + return nil +} + +func getF3UserToPromote(ctx context.Context, source *auth_model.Source, loginName, email string) (*user_model.User, error) { + if !source.IsOAuth2() { + log.Debug("getF3UserToPromote: source %v is not OAuth2", source) + return nil, nil + } + oauth2Source, ok := source.Cfg.(*oauth2.Source) + if !ok { + log.Error("getF3UserToPromote: source claims to be OAuth2 but really is %v", oauth2Source) + return nil, nil + } + + u, err := getUserByLoginName(ctx, loginName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + log.Debug("getF3UserToPromote: no user with LoginType F3 and LoginName '%s'", loginName) + return nil, nil + } + return nil, err + } + + if !u.IsF3() { + log.Debug("getF3UserToPromote: user %v is not a managed by F3", u) + return nil, nil + } + + if u.Email != "" { + log.Debug("getF3UserToPromote: the user email is already set to '%s'", u.Email) + return nil, nil + } + + userSource, err := auth_model.GetSourceByID(u.LoginSource) + if err != nil { + if auth_model.IsErrSourceNotExist(err) { + log.Error("getF3UserToPromote: source id = %v for user %v not found %v", u.LoginSource, u.ID, err) + return nil, nil + } + return nil, err + } + f3Source, ok := userSource.Cfg.(*f3_source.Source) + if !ok { + log.Error("getF3UserToPromote: expected an F3 source but got %T %v", userSource, userSource) + return nil, nil + } + + if oauth2Source.Provider != f3Source.MatchingSource { + log.Debug("getF3UserToPromote: skip OAuth2 source %s because it is different from %s which is the expected match for the F3 source %s", oauth2Source.Provider, f3Source.MatchingSource, f3Source.URL) + return nil, nil + } + + return u, nil +} diff --git a/services/f3/util/util.go b/services/f3/util/util.go new file mode 100644 index 0000000000..c6272cec37 --- /dev/null +++ b/services/f3/util/util.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT + +package util + +import ( + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + base "code.gitea.io/gitea/modules/migration" + "code.gitea.io/gitea/services/f3/driver" + + f3_types "lab.forgefriends.org/friendlyforgeformat/gof3/config/types" + f3_forges "lab.forgefriends.org/friendlyforgeformat/gof3/forges" + "lab.forgefriends.org/friendlyforgeformat/gof3/forges/f3" +) + +func ToF3Logger(messenger base.Messenger) *f3_types.Logger { + if messenger == nil { + messenger = func(message string, args ...interface{}) { + log.Info("Message: "+message, args...) + } + } + return &f3_types.Logger{ + Message: f3_types.LoggerFun(messenger), + Trace: log.Trace, + Debug: log.Debug, + Info: log.Info, + Warn: log.Warn, + Error: log.Error, + Critical: log.Critical, + Fatal: log.Fatal, + } +} + +func ForgejoForgeRoot(features f3_types.Features, doer *user_model.User, authenticationSource int64) *f3_forges.ForgeRoot { + forgeRoot := f3_forges.NewForgeRoot(&driver.Forgejo{}, &driver.Options{ + Options: f3_types.Options{ + Features: features, + Logger: ToF3Logger(nil), + }, + Doer: doer, + AuthenticationSource: authenticationSource, + }) + return forgeRoot +} + +func F3ForgeRoot(features f3_types.Features, directory string) *f3_forges.ForgeRoot { + forgeRoot := f3_forges.NewForgeRoot(&f3.F3{}, &f3.Options{ + Options: f3_types.Options{ + Configuration: f3_types.Configuration{ + Directory: directory, + }, + Features: features, + Logger: ToF3Logger(nil), + }, + Remap: true, + }) + return forgeRoot +} diff --git a/tests/integration/cmd_forgejo_f3_test.go b/tests/integration/cmd_forgejo_f3_test.go new file mode 100644 index 0000000000..ff6721661a --- /dev/null +++ b/tests/integration/cmd_forgejo_f3_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "testing" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/f3/driver" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + f3_forges "lab.forgefriends.org/friendlyforgeformat/gof3/forges" + f3_util "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +func TestF3_CmdMirror_LocalForgejo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + ctx := context.Background() + var userID int64 = 700 + // + // Step 1: create a fixture as an F3 archive + // + userID++ + fixture := f3_forges.NewFixture(t, f3_forges.FixtureF3Factory) + fixture.NewUser(userID) + fixture.NewIssue() + fixture.NewRepository() + + // + // Step 3: mirror the F3 archive to the forge + // + _, err := cmdForgejoCaptureOutput(t, []string{ + "forgejo", "forgejo-cli", "f3", "mirror", + "--from-type=f3", "--from", fixture.ForgeRoot.GetDirectory(), + "--to-type", driver.Name, + }) + assert.NoError(t, err) + user, err := user_model.GetUserByName(ctx, fixture.UserFormat.UserName) + assert.NoError(t, err) + // + // Step 4: mirror the forge to an F3 archive + // + dumpDir := t.TempDir() + _, err = cmdForgejoCaptureOutput(t, []string{ + "forgejo", "forgejo-cli", "f3", "mirror", + "--user", user.Name, "--repository", fixture.ProjectFormat.Name, + "--from-type", driver.Name, + "--to-type=f3", "--to", dumpDir, + }) + assert.NoError(t, err) + + // + // Step 5: verify the F3 archive content + // + files := f3_util.Command(context.Background(), "find", dumpDir) + assert.Contains(t, files, "/user/") + assert.Contains(t, files, "/project/") +} diff --git a/tests/integration/f3_test.go b/tests/integration/f3_test.go new file mode 100644 index 0000000000..b92e022649 --- /dev/null +++ b/tests/integration/f3_test.go @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/f3/util" + "code.gitea.io/gitea/services/migrations" + "code.gitea.io/gitea/tests" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" + f3_types "lab.forgefriends.org/friendlyforgeformat/gof3/config/types" + f3_forges "lab.forgefriends.org/friendlyforgeformat/gof3/forges" + f3_common "lab.forgefriends.org/friendlyforgeformat/gof3/forges/common" + f3_f3 "lab.forgefriends.org/friendlyforgeformat/gof3/forges/f3" + f3_forgejo "lab.forgefriends.org/friendlyforgeformat/gof3/forges/forgejo" + f3_tests "lab.forgefriends.org/friendlyforgeformat/gof3/forges/tests" + "lab.forgefriends.org/friendlyforgeformat/gof3/format" + f3_util "lab.forgefriends.org/friendlyforgeformat/gof3/util" +) + +func TestF3_MirrorAPITOLocal(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + AllowLocalNetworks := setting.Migrations.AllowLocalNetworks + setting.F3.Enabled = true + setting.Migrations.AllowLocalNetworks = true + AppVer := setting.AppVer + // Gitea SDK (go-sdk) need to parse the AppVer from server response, so we must set it to a valid version string. + setting.AppVer = "1.16.0" + defer func() { + setting.Migrations.AllowLocalNetworks = AllowLocalNetworks + setting.AppVer = AppVer + migrations.Init() + }() + assert.NoError(t, migrations.Init()) + + // + // Step 1: create a fixture + // + fixtureNewF3Forge := func(t f3_tests.TestingT, logger *f3_types.Logger, user *format.User, tmpDir string) *f3_forges.ForgeRoot { + root := f3_forges.NewForgeRoot(&f3_f3.F3{}, &f3_f3.Options{ + Options: f3_types.Options{ + Configuration: f3_types.Configuration{ + Directory: tmpDir, + }, + Features: f3_types.AllFeatures, + Logger: util.ToF3Logger(nil), + }, + Remap: true, + }) + return root + } + fixture := f3_forges.NewFixture(t, f3_forges.FixtureForgeFactory{Fun: fixtureNewF3Forge, AdminRequired: false}) + fixture.NewUser(5432) + fixture.NewMilestone() + fixture.NewLabel() + fixture.NewIssue() + fixture.NewTopic() + fixture.NewRepository() + fixture.NewPullRequest() + fixture.NewRelease() + fixture.NewAsset() + fixture.NewIssueComment(nil) + fixture.NewPullRequestComment() + // fixture.NewReview() + fixture.NewIssueReaction() + fixture.NewCommentReaction() + + // + // Step 2: mirror F3 into Forgejo + // + doer, err := user_model.GetAdminUser(context.Background()) + assert.NoError(t, err) + forgejoLocalUpload := util.ForgejoForgeRoot(f3_types.AllFeatures, doer, 0) + upload := forgejoLocalUpload.Forge + options := f3_common.NewMirrorOptionsRecurse() + upload.Mirror(context.Background(), fixture.Forge, options) + + // + // Step 3: mirror Forgejo into F3 + // + logger := util.ToF3Logger(nil) + f3 := f3_forges.FixtureNewF3Forge(t, logger, nil, t.TempDir()) + forgejoLocalDownload := util.ForgejoForgeRoot(f3_types.AllFeatures, doer, 0) + download := forgejoLocalDownload.Forge + downloadUser := download.Users.GetFromFormat(context.Background(), &format.User{UserName: fixture.UserFormat.UserName}) + downloadProject := downloadUser.Projects.GetFromFormat(context.Background(), &format.Project{Name: fixture.ProjectFormat.Name}) + options = f3_common.NewMirrorOptionsRecurse(downloadUser, downloadProject) + f3.Forge.Mirror(context.Background(), download, options) + + // + // Step 4: verify the fixture and F3 are equivalent + // + files := f3_util.Command(context.Background(), "find", f3.GetDirectory()) + assert.Contains(t, files, "/repository/git/hooks") + assert.Contains(t, files, "/label/") + assert.Contains(t, files, "/issue/") + assert.Contains(t, files, "/milestone/") + assert.Contains(t, files, "/topic/") + assert.Contains(t, files, "/pull_request/") + assert.Contains(t, files, "/release/") + assert.Contains(t, files, "/asset/") + assert.Contains(t, files, "/comment/") + // assert.Contains(t, files, "/review/") + assert.Contains(t, files, "/reaction/") + // f3_util.Command(context.Background(), "cp", "-a", f3.GetDirectory(), "abc") + }) +} + +func TestF3_MaybePromoteUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // + // OAuth2 authentication source GitLab + // + gitlabName := "gitlab" + _ = addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + // + // F3 authentication source matching the GitLab authentication source + // + f3Name := "f3" + f3 := createF3AuthSource(t, f3Name, "http://mygitlab.eu", gitlabName) + + // + // Create a user as if it had been previously been created by the F3 + // authentication source. + // + gitlabUserID := "5678" + gitlabEmail := "gitlabuser@example.com" + userBeforeSignIn := &user_model.User{ + Name: "gitlabuser", + Type: user_model.UserTypeF3, + LoginType: auth_model.F3, + LoginSource: f3.ID, + LoginName: gitlabUserID, + } + defer createUser(context.Background(), t, userBeforeSignIn)() + + // + // A request for user information sent to Goth will return a + // goth.User exactly matching the user created above. + // + defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + return goth.User{ + Provider: gitlabName, + UserID: gitlabUserID, + Email: gitlabEmail, + }, nil + })() + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName)) + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/", test.RedirectURL(resp)) + userAfterSignIn := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userBeforeSignIn.ID}) + + // both are about the same user + assert.Equal(t, userAfterSignIn.ID, userBeforeSignIn.ID) + // the login time was updated, proof the login succeeded + assert.Greater(t, userAfterSignIn.LastLoginUnix, userBeforeSignIn.LastLoginUnix) + // the login type was promoted from F3 to OAuth2 + assert.Equal(t, userBeforeSignIn.LoginType, auth_model.F3) + assert.Equal(t, userAfterSignIn.LoginType, auth_model.OAuth2) + // the OAuth2 email was used to set the missing user email + assert.Equal(t, userBeforeSignIn.Email, "") + assert.Equal(t, userAfterSignIn.Email, gitlabEmail) +} + +func TestF3_UserMappingExisting(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + AllowLocalNetworks := setting.Migrations.AllowLocalNetworks + setting.F3.Enabled = true + setting.Migrations.AllowLocalNetworks = true + AppVer := setting.AppVer + // Gitea SDK (go-sdk) need to parse the AppVer from server response, so we must set it to a valid version string. + setting.AppVer = "1.16.0" + defer func() { + setting.Migrations.AllowLocalNetworks = AllowLocalNetworks + setting.AppVer = AppVer + }() + + log.Debug("Step 1: create a fixture in F3") + fixtureNewF3Forge := func(t f3_tests.TestingT, logger *f3_types.Logger, user *format.User, tmpDir string) *f3_forges.ForgeRoot { + root := f3_forges.NewForgeRoot(&f3_f3.F3{}, &f3_f3.Options{ + Options: f3_types.Options{ + Configuration: f3_types.Configuration{ + Directory: tmpDir, + }, + Features: f3_types.AllFeatures, + Logger: util.ToF3Logger(nil), + }, + Remap: true, + }) + return root + } + fixture := f3_forges.NewFixture(t, f3_forges.FixtureForgeFactory{Fun: fixtureNewF3Forge, AdminRequired: false}) + userID := int64(5432) + fixture.NewUser(userID) + // fixture.NewProject() + + log.Debug("Step 2: mirror F3 into Forgejo") + // + // OAuth2 authentication source GitLab + // + gitlabName := "gitlab" + gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + // + // Create a user as if it had been previously been created by the F3 + // authentication source. + // + gitlabUserID := fmt.Sprintf("%d", userID) + gitlabUser := &user_model.User{ + Name: "gitlabuser", + Email: "gitlabuser@example.com", + LoginType: auth_model.OAuth2, + LoginSource: gitlab.ID, + LoginName: gitlabUserID, + } + defer createUser(context.Background(), t, gitlabUser)() + + doer, err := user_model.GetAdminUser(context.Background()) + assert.NoError(t, err) + forgejoLocal := util.ForgejoForgeRoot(f3_types.AllFeatures, doer, gitlab.ID) + options := f3_common.NewMirrorOptionsRecurse() + forgejoLocal.Forge.Mirror(context.Background(), fixture.Forge, options) + + log.Debug("Step 3: mirror Forgejo into F3") + adminUsername := "user1" + logger := util.ToF3Logger(nil) + forgejoAPI := f3_forges.NewForgeRoot(&f3_forgejo.Forgejo{}, &f3_forgejo.Options{ + Options: f3_types.Options{ + Configuration: f3_types.Configuration{ + URL: setting.AppURL, + Directory: t.TempDir(), + }, + Features: f3_types.AllFeatures, + Logger: logger, + }, + AuthToken: getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeAll), + }) + + f3 := f3_forges.FixtureNewF3Forge(t, logger, nil, t.TempDir()) + apiForge := forgejoAPI.Forge + apiUser := apiForge.Users.GetFromFormat(context.Background(), &format.User{UserName: gitlabUser.Name}) + // apiProject := apiUser.Projects.GetFromFormat(context.Background(), &format.Project{Name: fixture.ProjectFormat.Name}) + // options = f3_common.NewMirrorOptionsRecurse(apiUser, apiProject) + options = f3_common.NewMirrorOptionsRecurse(apiUser) + f3.Forge.Mirror(context.Background(), apiForge, options) + + // + // Step 4: verify the fixture and F3 are equivalent + // + files := f3_util.Command(context.Background(), "find", f3.GetDirectory()) + assert.Contains(t, files, fmt.Sprintf("/user/%d", gitlabUser.ID)) + }) +} + +func TestF3_UserMappingNew(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + AllowLocalNetworks := setting.Migrations.AllowLocalNetworks + setting.F3.Enabled = true + setting.Migrations.AllowLocalNetworks = true + AppVer := setting.AppVer + // Gitea SDK (go-sdk) need to parse the AppVer from server response, so we must set it to a valid version string. + setting.AppVer = "1.16.0" + defer func() { + setting.Migrations.AllowLocalNetworks = AllowLocalNetworks + setting.AppVer = AppVer + }() + + log.Debug("Step 1: create a fixture in F3") + fixtureNewF3Forge := func(t f3_tests.TestingT, logger *f3_types.Logger, user *format.User, tmpDir string) *f3_forges.ForgeRoot { + root := f3_forges.NewForgeRoot(&f3_f3.F3{}, &f3_f3.Options{ + Options: f3_types.Options{ + Configuration: f3_types.Configuration{ + Directory: tmpDir, + }, + Features: f3_types.AllFeatures, + Logger: util.ToF3Logger(nil), + }, + Remap: true, + }) + return root + } + fixture := f3_forges.NewFixture(t, f3_forges.FixtureForgeFactory{Fun: fixtureNewF3Forge, AdminRequired: false}) + userID := int64(5432) + fixture.NewUser(userID) + + log.Debug("Step 2: mirror F3 into Forgejo") + doer, err := user_model.GetAdminUser(context.Background()) + assert.NoError(t, err) + forgejoLocalDestination := util.ForgejoForgeRoot(f3_types.AllFeatures, doer, 0) + options := f3_common.NewMirrorOptionsRecurse() + forgejoLocalDestination.Forge.Mirror(context.Background(), fixture.Forge, options) + + log.Debug("Step 3: change the Name of the user in F3 and mirror to Forgejo") + otherusername := "otheruser" + fixture.UserFormat.UserName = otherusername + fixture.Forge.Users.Upsert(context.Background(), fixture.UserFormat) + forgejoLocalDestination.Forge.Mirror(context.Background(), fixture.Forge, options) + + log.Debug("Step 4: mirror Forgejo into F3 using the changed name") + f3 := util.F3ForgeRoot(f3_types.AllFeatures, t.TempDir()) + forgejoLocalOrigin := util.ForgejoForgeRoot(f3_types.AllFeatures, doer, 0) + forgejoLocalOriginUser := forgejoLocalOrigin.Forge.Users.GetFromFormat(context.Background(), &format.User{UserName: otherusername}) + options = f3_common.NewMirrorOptionsRecurse(forgejoLocalOriginUser) + f3.Forge.Mirror(context.Background(), forgejoLocalOrigin.Forge, options) + + // + // verify the fixture and F3 are equivalent + // + files := f3_util.Command(context.Background(), "find", f3.GetDirectory()) + assert.Contains(t, files, fmt.Sprintf("/user/%d", forgejoLocalOriginUser.GetID())) + }) +} diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index b505c9d857..6abed73ab9 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -36,6 +36,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/services/auth/source/f3" user_service "code.gitea.io/gitea/services/user" "code.gitea.io/gitea/tests" @@ -279,6 +280,21 @@ func authSourcePayloadGitLabCustom(name string) map[string]string { return payload } +func createF3AuthSource(t *testing.T, name, url, matchingSource string) *auth.Source { + assert.NoError(t, auth.CreateSource(&auth.Source{ + Type: auth.F3, + Name: name, + IsActive: true, + Cfg: &f3.Source{ + URL: url, + MatchingSource: matchingSource, + }, + })) + source, err := auth.GetSourceByName(context.Background(), name) + assert.NoError(t, err) + return source +} + func createUser(ctx context.Context, t testing.TB, user *user_model.User) func() { user.MustChangePassword = false user.LowerName = strings.ToLower(user.Name)