From 3a8bdd936cd53eed7504d6f05f86c12659769ddb Mon Sep 17 00:00:00 2001 From: logikonline Date: Sat, 7 Mar 2026 12:39:42 -0500 Subject: [PATCH] feat(pages): add A/B testing framework for landing pages Implement comprehensive A/B testing system for landing page optimization: - Database models for experiments, variants, and events - AI-powered variant generation and analysis - Visitor tracking with conversion metrics - Experiment lifecycle management (draft/active/paused/completed) - Email notifications for experiment results - Cron job for automated experiment monitoring - UI for viewing experiment results and statistics --- models/migrations/migrations.go | 1 + models/migrations/v1_26/v366.go | 54 ++++ models/pages/experiment.go | 272 +++++++++++++++++++ models/repo/star.go | 7 +- modules/ai/client.go | 14 + modules/ai/types.go | 16 ++ modules/pages/config.go | 29 +++ options/locale/locale_en-US.json | 9 + routers/web/pages/pages.go | 287 ++++++++++++++++++++- routers/web/repo/setting/pages.go | 6 + routers/web/web.go | 5 + services/cron/tasks_extended.go | 12 + services/mailer/mail_pages.go | 70 +++++ services/pages/analysis.go | 194 ++++++++++++++ services/pages/experiment.go | 193 ++++++++++++++ services/repository/fork.go | 4 + templates/pages/base_footer.tmpl | 34 +++ templates/pages/base_head.tmpl | 4 + templates/pages/bold-marketing.tmpl | 35 ++- templates/pages/experiment_result.tmpl | 29 +++ templates/pages/footer.tmpl | 17 +- templates/pages/header.tmpl | 12 +- templates/pages/minimalist-docs.tmpl | 37 +-- templates/pages/open-source-hero.tmpl | 37 ++- templates/pages/saas-conversion.tmpl | 39 +-- templates/repo/settings/pages_brand.tmpl | 5 + templates/repo/settings/pages_content.tmpl | 33 +++ 27 files changed, 1371 insertions(+), 84 deletions(-) create mode 100644 models/migrations/v1_26/v366.go create mode 100644 models/pages/experiment.go create mode 100644 services/mailer/mail_pages.go create mode 100644 services/pages/analysis.go create mode 100644 services/pages/experiment.go create mode 100644 templates/pages/experiment_result.tmpl diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 624d4ea864..ff2fd8673f 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -440,6 +440,7 @@ func prepareMigrationTasks() []*migration { newMigration(363, "Add keep_packages_private to user", v1_26.AddKeepPackagesPrivateToUser), newMigration(364, "Add view_count to blog_post", v1_26.AddViewCountToBlogPost), newMigration(365, "Add public_app_integration to repository", v1_26.AddPublicAppIntegrationToRepository), + newMigration(366, "Add page experiment tables for A/B testing", v1_26.AddPageExperimentTables), } return preparedMigrations } diff --git a/models/migrations/v1_26/v366.go b/models/migrations/v1_26/v366.go new file mode 100644 index 0000000000..fcd73bf149 --- /dev/null +++ b/models/migrations/v1_26/v366.go @@ -0,0 +1,54 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "code.gitcaddy.com/server/v3/modules/timeutil" + + "xorm.io/xorm" +) + +func AddPageExperimentTables(x *xorm.Engine) error { + type PageExperiment struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"VARCHAR(255) NOT NULL"` + Status string `xorm:"VARCHAR(32) NOT NULL DEFAULT 'draft'"` + CreatedByAI bool `xorm:"NOT NULL DEFAULT true"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + EndsUnix timeutil.TimeStamp `xorm:"DEFAULT 0"` + } + + type PageVariant struct { + ID int64 `xorm:"pk autoincr"` + ExperimentID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"VARCHAR(255) NOT NULL"` + IsControl bool `xorm:"NOT NULL DEFAULT false"` + Weight int `xorm:"NOT NULL DEFAULT 50"` + ConfigOverride string `xorm:"TEXT"` + Impressions int64 `xorm:"NOT NULL DEFAULT 0"` + Conversions int64 `xorm:"NOT NULL DEFAULT 0"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + } + + type PageEvent struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + VariantID int64 `xorm:"INDEX DEFAULT 0"` + ExperimentID int64 `xorm:"INDEX DEFAULT 0"` + VisitorID string `xorm:"VARCHAR(64) INDEX"` + EventType string `xorm:"VARCHAR(32) NOT NULL"` + EventData string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + } + + if err := x.Sync(new(PageExperiment)); err != nil { + return err + } + if err := x.Sync(new(PageVariant)); err != nil { + return err + } + return x.Sync(new(PageEvent)) +} diff --git a/models/pages/experiment.go b/models/pages/experiment.go new file mode 100644 index 0000000000..9c7251adba --- /dev/null +++ b/models/pages/experiment.go @@ -0,0 +1,272 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package pages + +import ( + "context" + + "code.gitcaddy.com/server/v3/models/db" + "code.gitcaddy.com/server/v3/modules/timeutil" +) + +// ExperimentStatus represents the status of an A/B test experiment. +type ExperimentStatus string + +const ( + ExperimentStatusDraft ExperimentStatus = "draft" + ExperimentStatusActive ExperimentStatus = "active" + ExperimentStatusPaused ExperimentStatus = "paused" + ExperimentStatusCompleted ExperimentStatus = "completed" + ExperimentStatusApproved ExperimentStatus = "approved" +) + +func init() { + db.RegisterModel(new(PageExperiment)) + db.RegisterModel(new(PageVariant)) + db.RegisterModel(new(PageEvent)) +} + +// PageExperiment tracks an A/B test on a landing page. +type PageExperiment struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"VARCHAR(255) NOT NULL"` + Status ExperimentStatus `xorm:"VARCHAR(32) NOT NULL DEFAULT 'draft'"` + CreatedByAI bool `xorm:"NOT NULL DEFAULT true"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + EndsUnix timeutil.TimeStamp `xorm:"DEFAULT 0"` + + Variants []*PageVariant `xorm:"-"` +} + +// TableName returns the table name for PageExperiment. +func (e *PageExperiment) TableName() string { + return "page_experiment" +} + +// PageVariant is one arm of an A/B test experiment. +type PageVariant struct { + ID int64 `xorm:"pk autoincr"` + ExperimentID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"VARCHAR(255) NOT NULL"` + IsControl bool `xorm:"NOT NULL DEFAULT false"` + Weight int `xorm:"NOT NULL DEFAULT 50"` + ConfigOverride string `xorm:"TEXT"` + Impressions int64 `xorm:"NOT NULL DEFAULT 0"` + Conversions int64 `xorm:"NOT NULL DEFAULT 0"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +// TableName returns the table name for PageVariant. +func (v *PageVariant) TableName() string { + return "page_variant" +} + +// ConversionRate returns the conversion rate for this variant. +func (v *PageVariant) ConversionRate() float64 { + if v.Impressions == 0 { + return 0 + } + return float64(v.Conversions) / float64(v.Impressions) +} + +// PageEvent tracks visitor interactions with a landing page. +type PageEvent struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + VariantID int64 `xorm:"INDEX DEFAULT 0"` + ExperimentID int64 `xorm:"INDEX DEFAULT 0"` + VisitorID string `xorm:"VARCHAR(64) INDEX"` + EventType string `xorm:"VARCHAR(32) NOT NULL"` + EventData string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +// TableName returns the table name for PageEvent. +func (e *PageEvent) TableName() string { + return "page_event" +} + +// Valid event types +const ( + EventTypeImpression = "impression" + EventTypeCTAClick = "cta_click" + EventTypeScrollDepth = "scroll_depth" + EventTypeClick = "click" + EventTypeStar = "star" + EventTypeFork = "fork" + EventTypeClone = "clone" +) + +// CreateExperiment creates a new experiment. +func CreateExperiment(ctx context.Context, exp *PageExperiment) error { + _, err := db.GetEngine(ctx).Insert(exp) + return err +} + +// GetExperimentByID returns an experiment by ID. +func GetExperimentByID(ctx context.Context, id int64) (*PageExperiment, error) { + exp := new(PageExperiment) + has, err := db.GetEngine(ctx).ID(id).Get(exp) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return exp, nil +} + +// GetExperimentsByRepoID returns all experiments for a repository. +func GetExperimentsByRepoID(ctx context.Context, repoID int64) ([]*PageExperiment, error) { + experiments := make([]*PageExperiment, 0, 10) + return experiments, db.GetEngine(ctx).Where("repo_id = ?", repoID). + Desc("created_unix").Find(&experiments) +} + +// GetActiveExperimentByRepoID returns the currently active experiment for a repo, if any. +func GetActiveExperimentByRepoID(ctx context.Context, repoID int64) (*PageExperiment, error) { + exp := new(PageExperiment) + has, err := db.GetEngine(ctx). + Where("repo_id = ? AND status = ?", repoID, ExperimentStatusActive). + Get(exp) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return exp, nil +} + +// GetAllActiveExperiments returns all active experiments across all repos. +func GetAllActiveExperiments(ctx context.Context) ([]*PageExperiment, error) { + experiments := make([]*PageExperiment, 0, 50) + return experiments, db.GetEngine(ctx). + Where("status = ?", ExperimentStatusActive). + Find(&experiments) +} + +// UpdateExperiment updates an experiment. +func UpdateExperiment(ctx context.Context, exp *PageExperiment) error { + _, err := db.GetEngine(ctx).ID(exp.ID).AllCols().Update(exp) + return err +} + +// UpdateExperimentStatus updates just the status of an experiment. +func UpdateExperimentStatus(ctx context.Context, id int64, status ExperimentStatus) error { + _, err := db.GetEngine(ctx).ID(id).Cols("status"). + Update(&PageExperiment{Status: status}) + return err +} + +// CreateVariant creates a new variant for an experiment. +func CreateVariant(ctx context.Context, variant *PageVariant) error { + _, err := db.GetEngine(ctx).Insert(variant) + return err +} + +// GetVariantByID returns a variant by ID. +func GetVariantByID(ctx context.Context, id int64) (*PageVariant, error) { + variant := new(PageVariant) + has, err := db.GetEngine(ctx).ID(id).Get(variant) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return variant, nil +} + +// GetVariantsByExperimentID returns all variants for an experiment. +func GetVariantsByExperimentID(ctx context.Context, experimentID int64) ([]*PageVariant, error) { + variants := make([]*PageVariant, 0, 5) + return variants, db.GetEngine(ctx). + Where("experiment_id = ?", experimentID). + Find(&variants) +} + +// IncrementVariantImpressions increments the impression counter for a variant. +func IncrementVariantImpressions(ctx context.Context, variantID int64) error { + _, err := db.GetEngine(ctx).Exec( + "UPDATE `page_variant` SET impressions = impressions + 1 WHERE id = ?", variantID) + return err +} + +// IncrementVariantConversions increments the conversion counter for a variant. +func IncrementVariantConversions(ctx context.Context, variantID int64) error { + _, err := db.GetEngine(ctx).Exec( + "UPDATE `page_variant` SET conversions = conversions + 1 WHERE id = ?", variantID) + return err +} + +// CreatePageEvent records a visitor event. +func CreatePageEvent(ctx context.Context, event *PageEvent) error { + _, err := db.GetEngine(ctx).Insert(event) + return err +} + +// GetEventCountByVariant returns event counts grouped by event type for a variant. +func GetEventCountByVariant(ctx context.Context, variantID int64) (map[string]int64, error) { + type countResult struct { + EventType string `xorm:"event_type"` + Count int64 `xorm:"cnt"` + } + results := make([]countResult, 0) + err := db.GetEngine(ctx).Table("page_event"). + Select("event_type, COUNT(*) as cnt"). + Where("variant_id = ?", variantID). + GroupBy("event_type"). + Find(&results) + if err != nil { + return nil, err + } + counts := make(map[string]int64, len(results)) + for _, r := range results { + counts[r.EventType] = r.Count + } + return counts, nil +} + +// GetEventCountsByExperiment returns event counts for all variants in an experiment. +func GetEventCountsByExperiment(ctx context.Context, experimentID int64) (map[int64]map[string]int64, error) { + type countResult struct { + VariantID int64 `xorm:"variant_id"` + EventType string `xorm:"event_type"` + Count int64 `xorm:"cnt"` + } + results := make([]countResult, 0) + err := db.GetEngine(ctx).Table("page_event"). + Select("variant_id, event_type, COUNT(*) as cnt"). + Where("experiment_id = ?", experimentID). + GroupBy("variant_id, event_type"). + Find(&results) + if err != nil { + return nil, err + } + counts := make(map[int64]map[string]int64) + for _, r := range results { + if counts[r.VariantID] == nil { + counts[r.VariantID] = make(map[string]int64) + } + counts[r.VariantID][r.EventType] = r.Count + } + return counts, nil +} + +// RecordRepoAction records a repo action (star, fork, clone) as a page event +// if the repo has an active experiment. +func RecordRepoAction(ctx context.Context, repoID int64, eventType string) { + exp, err := GetActiveExperimentByRepoID(ctx, repoID) + if err != nil || exp == nil { + return + } + _ = CreatePageEvent(ctx, &PageEvent{ + RepoID: repoID, + ExperimentID: exp.ID, + EventType: eventType, + }) +} diff --git a/models/repo/star.go b/models/repo/star.go index 069af087f4..54b98f7f03 100644 --- a/models/repo/star.go +++ b/models/repo/star.go @@ -7,6 +7,7 @@ import ( "context" "code.gitcaddy.com/server/v3/models/db" + pages_model "code.gitcaddy.com/server/v3/models/pages" user_model "code.gitcaddy.com/server/v3/models/user" "code.gitcaddy.com/server/v3/modules/timeutil" ) @@ -25,7 +26,7 @@ func init() { // StarRepo or unstar repository. func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star bool) error { - return db.WithTx(ctx, func(ctx context.Context) error { + err := db.WithTx(ctx, func(ctx context.Context) error { staring := IsStaring(ctx, doer.ID, repo.ID) if star { @@ -64,6 +65,10 @@ func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star return nil }) + if err == nil && star { + pages_model.RecordRepoAction(ctx, repo.ID, pages_model.EventTypeStar) + } + return err } // IsStaring checks if user has starred given repository. diff --git a/modules/ai/client.go b/modules/ai/client.go index 5c21435d50..069414c3e0 100644 --- a/modules/ai/client.go +++ b/modules/ai/client.go @@ -213,6 +213,20 @@ func (c *Client) InspectWorkflow(ctx context.Context, req *InspectWorkflowReques return resp, nil } +// ExecuteTask executes a generic AI task via the sidecar +func (c *Client) ExecuteTask(ctx context.Context, req *ExecuteTaskRequest) (*ExecuteTaskResponse, error) { + if !IsEnabled() { + return nil, errors.New("AI service is not enabled") + } + + var resp ExecuteTaskResponse + if err := c.doRequest(ctx, http.MethodPost, "/execute-task", req, &resp); err != nil { + log.Error("AI ExecuteTask failed: %v", err) + return nil, err + } + return &resp, nil +} + // CheckHealth checks the health of the AI service func (c *Client) CheckHealth(ctx context.Context) (*HealthCheckResponse, error) { var resp HealthCheckResponse diff --git a/modules/ai/types.go b/modules/ai/types.go index 4350c3c3f9..efc60ab161 100644 --- a/modules/ai/types.go +++ b/modules/ai/types.go @@ -258,6 +258,22 @@ type InspectWorkflowResponse struct { OutputTokens int `json:"output_tokens"` } +// ExecuteTaskRequest is the request for executing a generic AI task +type ExecuteTaskRequest struct { + ProviderConfig *ProviderConfig `json:"provider_config,omitempty"` + RepoID int64 `json:"repo_id"` + Task string `json:"task"` + Context map[string]string `json:"context"` + AllowedTools []string `json:"allowed_tools,omitempty"` +} + +// ExecuteTaskResponse is the response from executing a generic AI task +type ExecuteTaskResponse struct { + Success bool `json:"success"` + Result string `json:"result"` + Error string `json:"error,omitempty"` +} + // HealthCheckResponse is the response from a health check type HealthCheckResponse struct { Healthy bool `json:"healthy"` diff --git a/modules/pages/config.go b/modules/pages/config.go index dda118768a..819c0b5e72 100644 --- a/modules/pages/config.go +++ b/modules/pages/config.go @@ -47,6 +47,9 @@ type LandingConfig struct { // Blog section Blog BlogSectionConfig `yaml:"blog,omitempty"` + // Navigation visibility + Navigation NavigationConfig `yaml:"navigation,omitempty"` + // Footer Footer FooterConfig `yaml:"footer,omitempty"` @@ -61,6 +64,9 @@ type LandingConfig struct { // Advanced settings Advanced AdvancedConfig `yaml:"advanced,omitempty"` + + // A/B testing experiments + Experiments ExperimentConfig `yaml:"experiments,omitempty"` } // BrandConfig represents brand/identity settings @@ -69,6 +75,7 @@ type BrandConfig struct { LogoURL string `yaml:"logo_url,omitempty"` LogoSource string `yaml:"logo_source,omitempty"` // "url" (default), "repo", or "org" — selects avatar source Tagline string `yaml:"tagline,omitempty"` + FaviconURL string `yaml:"favicon_url,omitempty"` } // HeroConfig represents hero section settings @@ -159,6 +166,15 @@ type BlogSectionConfig struct { CTAButton CTAButton `yaml:"cta_button,omitempty"` // "View All Posts" link } +// NavigationConfig controls which built-in navigation links appear in the header and footer +type NavigationConfig struct { + ShowDocs bool `yaml:"show_docs,omitempty"` + ShowAPI bool `yaml:"show_api,omitempty"` + ShowRepository bool `yaml:"show_repository,omitempty"` + ShowReleases bool `yaml:"show_releases,omitempty"` + ShowIssues bool `yaml:"show_issues,omitempty"` +} + // FooterConfig represents footer settings type FooterConfig struct { Links []FooterLink `yaml:"links,omitempty"` @@ -209,6 +225,14 @@ type UmamiConfig struct { URL string `yaml:"url,omitempty"` } +// ExperimentConfig represents A/B testing experiment settings +type ExperimentConfig struct { + Enabled bool `yaml:"enabled,omitempty"` + AutoOptimize bool `yaml:"auto_optimize,omitempty"` + MinImpressions int `yaml:"min_impressions,omitempty"` + ApprovalRequired bool `yaml:"approval_required,omitempty"` +} + // AdvancedConfig represents advanced settings type AdvancedConfig struct { CustomCSS string `yaml:"custom_css,omitempty"` @@ -272,6 +296,11 @@ func DefaultConfig() *LandingConfig { {Title: "Flexible", Description: "Adapts to your workflow, not the other way around.", Icon: "gear"}, {Title: "Open Source", Description: "Free forever. Community driven.", Icon: "heart"}, }, + Navigation: NavigationConfig{ + ShowDocs: true, + ShowRepository: true, + ShowReleases: true, + }, CTASection: CTASectionConfig{ Headline: "Ready to get started?", Subheadline: "Join thousands of developers already using this project.", diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index eba5b039e9..f7289742a1 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -4493,6 +4493,15 @@ "repo.settings.pages.seo_description": "Meta Description", "repo.settings.pages.seo_keywords": "Keywords", "repo.settings.pages.og_image": "Open Graph Image URL", + "repo.settings.pages.brand_favicon_url": "Favicon URL", + "repo.settings.pages.brand_favicon_url_help": "URL to a custom favicon for your landing page (ICO, PNG, or SVG). Leave blank to use the default.", + "repo.settings.pages.navigation": "Navigation Links", + "repo.settings.pages.navigation_desc": "Control which built-in links appear in the header and footer navigation.", + "repo.settings.pages.nav_show_docs": "Show Docs link (links to wiki)", + "repo.settings.pages.nav_show_api": "Show API link (links to Swagger docs)", + "repo.settings.pages.nav_show_repository": "Show Repository link (View Source button)", + "repo.settings.pages.nav_show_releases": "Show Releases link", + "repo.settings.pages.nav_show_issues": "Show Issues link", "repo.vault": "Vault", "repo.vault.secrets": "Secrets", "repo.vault.new_secret": "New Secret", diff --git a/routers/web/pages/pages.go b/routers/web/pages/pages.go index af0151631a..1ba4d63385 100644 --- a/routers/web/pages/pages.go +++ b/routers/web/pages/pages.go @@ -4,9 +4,13 @@ package pages import ( + "crypto/rand" + "encoding/hex" "errors" "fmt" "html/template" + "io" + "math/big" "net/http" "path" "strconv" @@ -14,9 +18,11 @@ import ( "time" blog_model "code.gitcaddy.com/server/v3/models/blog" + pages_model "code.gitcaddy.com/server/v3/models/pages" "code.gitcaddy.com/server/v3/models/renderhelper" repo_model "code.gitcaddy.com/server/v3/models/repo" "code.gitcaddy.com/server/v3/modules/git" + "code.gitcaddy.com/server/v3/modules/json" "code.gitcaddy.com/server/v3/modules/log" "code.gitcaddy.com/server/v3/modules/markup/markdown" pages_module "code.gitcaddy.com/server/v3/modules/pages" @@ -56,6 +62,12 @@ func ServeLandingPage(ctx *context.Context) { } } + // Handle event tracking POST + if ctx.Req.Method == http.MethodPost && (requestPath == "/pages/events" || requestPath == "/pages/events/") { + servePageEvent(ctx, repo) + return + } + // Check for blog paths on custom domain if config.Blog.Enabled && repo.BlogEnabled { if idStr, found := strings.CutPrefix(requestPath, "/blog/"); found { @@ -71,8 +83,9 @@ func ServeLandingPage(ctx *context.Context) { } } - // Render the landing page + // Render the landing page with A/B test variant ctx.Data["BlogBaseURL"] = "/blog" + config = assignVariant(ctx, repo, config) renderLandingPage(ctx, repo, config) } @@ -473,6 +486,7 @@ func ServeRepoLandingPage(ctx *context.Context) { } ctx.Data["BlogBaseURL"] = fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name) + config = assignVariant(ctx, repo, config) renderLandingPage(ctx, repo, config) } @@ -599,6 +613,277 @@ func ServeRepoPageAsset(ctx *context.Context) { _, _ = ctx.Resp.Write(content) } +// servePageEvent handles POST /pages/events for A/B test event tracking +func servePageEvent(ctx *context.Context, repo *repo_model.Repository) { + body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, 4096)) + if err != nil { + ctx.Status(http.StatusBadRequest) + return + } + + var payload struct { + EventType string `json:"event_type"` + VariantID int64 `json:"variant_id"` + ExperimentID int64 `json:"experiment_id"` + VisitorID string `json:"visitor_id"` + Data string `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + ctx.Status(http.StatusBadRequest) + return + } + + // Validate event type + validTypes := map[string]bool{ + pages_model.EventTypeImpression: true, + pages_model.EventTypeCTAClick: true, + pages_model.EventTypeScrollDepth: true, + pages_model.EventTypeClick: true, + } + if !validTypes[payload.EventType] { + ctx.Status(http.StatusBadRequest) + return + } + + // Record event + _ = pages_model.CreatePageEvent(ctx, &pages_model.PageEvent{ + RepoID: repo.ID, + VariantID: payload.VariantID, + ExperimentID: payload.ExperimentID, + VisitorID: payload.VisitorID, + EventType: payload.EventType, + EventData: payload.Data, + }) + + // Update denormalized counters + if payload.VariantID > 0 { + switch payload.EventType { + case pages_model.EventTypeImpression: + _ = pages_model.IncrementVariantImpressions(ctx, payload.VariantID) + case pages_model.EventTypeCTAClick: + _ = pages_model.IncrementVariantConversions(ctx, payload.VariantID) + } + } + + ctx.Status(http.StatusNoContent) +} + +// ServePageEvent handles POST event tracking via path-based routes +func ServePageEvent(ctx *context.Context) { + repo := ctx.Repo.Repository + if repo == nil { + ctx.Status(http.StatusNotFound) + return + } + servePageEvent(ctx, repo) +} + +// assignVariant checks for an active experiment, assigns the visitor to a variant, +// and deep-merges the variant's config overrides onto the base config. +func assignVariant(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) *pages_module.LandingConfig { + if !config.Experiments.Enabled { + return config + } + + exp, err := pages_model.GetActiveExperimentByRepoID(ctx, repo.ID) + if err != nil || exp == nil { + return config + } + + // Ensure visitor ID cookie + visitorID := ctx.GetSiteCookie("pgvid") + if visitorID == "" { + visitorID = generateVisitorID() + ctx.SetSiteCookie("pgvid", visitorID, 86400*30) // 30 days + } + + // Check existing variant assignment + cookieName := fmt.Sprintf("pgvar_%d", exp.ID) + variantIDStr := ctx.GetSiteCookie(cookieName) + var variant *pages_model.PageVariant + + if variantIDStr != "" { + variantID, parseErr := strconv.ParseInt(variantIDStr, 10, 64) + if parseErr == nil { + variant, _ = pages_model.GetVariantByID(ctx, variantID) + } + } + + if variant == nil { + // Load all variants and do weighted random assignment + variants, loadErr := pages_model.GetVariantsByExperimentID(ctx, exp.ID) + if loadErr != nil || len(variants) == 0 { + return config + } + variant = weightedRandomSelect(variants) + ctx.SetSiteCookie(cookieName, strconv.FormatInt(variant.ID, 10), 86400*30) + } + + // Set A/B test template data + ctx.Data["ABTestActive"] = true + ctx.Data["ExperimentID"] = exp.ID + ctx.Data["VariantID"] = variant.ID + ctx.Data["VisitorID"] = visitorID + + // Determine event tracking URL + if blogBaseURL, ok := ctx.Data["BlogBaseURL"].(string); ok && strings.Contains(blogBaseURL, "/pages/blog") { + // Path-based: use repo-scoped events URL + ctx.Data["EventTrackURL"] = strings.TrimSuffix(blogBaseURL, "/blog") + "/events" + } else { + ctx.Data["EventTrackURL"] = "/pages/events" + } + + // If control variant, no config overrides needed + if variant.IsControl || variant.ConfigOverride == "" { + return config + } + + // Deep-merge variant overrides onto config + merged, mergeErr := deepMergeConfig(config, variant.ConfigOverride) + if mergeErr != nil { + log.Error("Failed to merge variant config: %v", mergeErr) + return config + } + return merged +} + +// generateVisitorID creates a random 16-byte hex visitor identifier. +func generateVisitorID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// weightedRandomSelect picks a variant using weighted random selection. +func weightedRandomSelect(variants []*pages_model.PageVariant) *pages_model.PageVariant { + totalWeight := 0 + for _, v := range variants { + totalWeight += v.Weight + } + if totalWeight <= 0 { + return variants[0] + } + + n, _ := rand.Int(rand.Reader, big.NewInt(int64(totalWeight))) + pick := int(n.Int64()) + cumulative := 0 + for _, v := range variants { + cumulative += v.Weight + if pick < cumulative { + return v + } + } + return variants[len(variants)-1] +} + +// deepMergeConfig deep-merges a JSON config override onto a base LandingConfig. +// Only non-zero values in the override replace base values. +func deepMergeConfig(base *pages_module.LandingConfig, overrideJSON string) (*pages_module.LandingConfig, error) { + // Marshal base to JSON map + baseJSON, err := json.Marshal(base) + if err != nil { + return nil, err + } + + var baseMap map[string]any + if err := json.Unmarshal(baseJSON, &baseMap); err != nil { + return nil, err + } + + var overrideMap map[string]any + if err := json.Unmarshal([]byte(overrideJSON), &overrideMap); err != nil { + return nil, err + } + + // Deep merge + merged := deepMerge(baseMap, overrideMap) + + // Unmarshal back to LandingConfig + mergedJSON, err := json.Marshal(merged) + if err != nil { + return nil, err + } + + var result pages_module.LandingConfig + if err := json.Unmarshal(mergedJSON, &result); err != nil { + return nil, err + } + return &result, nil +} + +// deepMerge recursively merges src into dst. +func deepMerge(dst, src map[string]any) map[string]any { + for key, srcVal := range src { + if dstVal, ok := dst[key]; ok { + // Both are maps: recurse + srcMap, srcIsMap := srcVal.(map[string]any) + dstMap, dstIsMap := dstVal.(map[string]any) + if srcIsMap && dstIsMap { + dst[key] = deepMerge(dstMap, srcMap) + continue + } + } + dst[key] = srcVal + } + return dst +} + +// ApproveExperiment handles the email approval link for an A/B test experiment +func ApproveExperiment(ctx *context.Context) { + handleExperimentAction(ctx, true) +} + +// DeclineExperiment handles the email decline link for an A/B test experiment +func DeclineExperiment(ctx *context.Context) { + handleExperimentAction(ctx, false) +} + +func handleExperimentAction(ctx *context.Context, approve bool) { + tokenStr := ctx.PathParam("token") + if tokenStr == "" { + ctx.NotFound(errors.New("missing token")) + return + } + + // Extract and verify the token + expIDStr, err := pages_service.VerifyExperimentToken(ctx, tokenStr) + if err != nil { + log.Error("Invalid experiment token: %v", err) + ctx.NotFound(errors.New("invalid or expired token")) + return + } + + expID, err := strconv.ParseInt(expIDStr, 10, 64) + if err != nil { + ctx.NotFound(errors.New("invalid experiment ID")) + return + } + + exp, err := pages_model.GetExperimentByID(ctx, expID) + if err != nil || exp == nil { + ctx.NotFound(errors.New("experiment not found")) + return + } + + if approve { + err = pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusApproved) + ctx.Data["Title"] = "Experiment Approved" + ctx.Data["ExperimentApproved"] = true + } else { + err = pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusPaused) + ctx.Data["Title"] = "Experiment Declined" + ctx.Data["ExperimentDeclined"] = true + } + + if err != nil { + ctx.ServerError("Failed to update experiment", err) + return + } + + ctx.Data["ExperimentName"] = exp.Name + ctx.HTML(http.StatusOK, "pages/experiment_result") +} + // getContentType returns the content type for a file extension func getContentType(ext string) string { types := map[string]string{ diff --git a/routers/web/repo/setting/pages.go b/routers/web/repo/setting/pages.go index 8d0dcda993..f82c755910 100644 --- a/routers/web/repo/setting/pages.go +++ b/routers/web/repo/setting/pages.go @@ -213,6 +213,7 @@ func PagesBrandPost(ctx *context.Context) { config.Brand.Name = ctx.FormString("brand_name") config.Brand.LogoURL = ctx.FormString("brand_logo_url") config.Brand.Tagline = ctx.FormString("brand_tagline") + config.Brand.FaviconURL = ctx.FormString("brand_favicon_url") if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return @@ -261,6 +262,11 @@ func PagesContent(ctx *context.Context) { func PagesContentPost(ctx *context.Context) { config := getPagesLandingConfig(ctx) config.Advanced.PublicReleases = ctx.FormBool("public_releases") + config.Navigation.ShowDocs = ctx.FormBool("nav_show_docs") + config.Navigation.ShowAPI = ctx.FormBool("nav_show_api") + config.Navigation.ShowRepository = ctx.FormBool("nav_show_repository") + config.Navigation.ShowReleases = ctx.FormBool("nav_show_releases") + config.Navigation.ShowIssues = ctx.FormBool("nav_show_issues") config.Stats = nil for i := range 10 { value := ctx.FormString(fmt.Sprintf("stat_value_%d", i)) diff --git a/routers/web/web.go b/routers/web/web.go index bb15ea38bc..080e3aef43 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -550,6 +550,10 @@ func registerWebRoutes(m *web.Router) { m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup) + // A/B test experiment approval (email links, no auth required — token-verified) + m.Get("/-/pages/experiment/approve/{token}", pages.ApproveExperiment) + m.Get("/-/pages/experiment/decline/{token}", pages.DeclineExperiment) + m.Get("/-/web-theme/list", misc.WebThemeList) m.Post("/-/web-theme/apply", optSignIn, misc.WebThemeApply) @@ -1794,6 +1798,7 @@ func registerWebRoutes(m *web.Router) { m.Get("", pages.ServeRepoLandingPage) m.Get("/blog", pages.ServeRepoBlogList) m.Get("/blog/{id}", pages.ServeRepoBlogDetail) + m.Post("/events", pages.ServePageEvent) m.Get("/assets/*", pages.ServeRepoPageAsset) }, optSignIn, context.RepoAssignment, func(ctx *context.Context) { ctx.Data["PageIsPagesLanding"] = true diff --git a/services/cron/tasks_extended.go b/services/cron/tasks_extended.go index e530b7fd33..dd0d36b3a5 100644 --- a/services/cron/tasks_extended.go +++ b/services/cron/tasks_extended.go @@ -16,6 +16,7 @@ import ( "code.gitcaddy.com/server/v3/modules/updatechecker" asymkey_service "code.gitcaddy.com/server/v3/services/asymkey" attachment_service "code.gitcaddy.com/server/v3/services/attachment" + pages_service "code.gitcaddy.com/server/v3/services/pages" repo_service "code.gitcaddy.com/server/v3/services/repository" archiver_service "code.gitcaddy.com/server/v3/services/repository/archiver" user_service "code.gitcaddy.com/server/v3/services/user" @@ -235,6 +236,16 @@ func registerCleanupExpiredUploadSessions() { }) } +func registerAnalyzePageExperiments() { + RegisterTaskFatal("analyze_page_experiments", &BaseConfig{ + Enabled: true, + RunAtStart: false, + Schedule: "@every 6h", + }, func(ctx context.Context, _ *user_model.User, _ Config) error { + return pages_service.AnalyzeAllActiveExperiments(ctx) + }) +} + func initExtendedTasks() { registerDeleteInactiveUsers() registerDeleteRepositoryArchives() @@ -251,4 +262,5 @@ func initExtendedTasks() { registerGCLFS() registerRebuildIssueIndexer() registerCleanupExpiredUploadSessions() + registerAnalyzePageExperiments() } diff --git a/services/mailer/mail_pages.go b/services/mailer/mail_pages.go new file mode 100644 index 0000000000..652123a7df --- /dev/null +++ b/services/mailer/mail_pages.go @@ -0,0 +1,70 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "fmt" + + pages_model "code.gitcaddy.com/server/v3/models/pages" + repo_model "code.gitcaddy.com/server/v3/models/repo" + user_model "code.gitcaddy.com/server/v3/models/user" + "code.gitcaddy.com/server/v3/modules/log" + "code.gitcaddy.com/server/v3/modules/setting" + sender_service "code.gitcaddy.com/server/v3/services/mailer/sender" +) + +// SendExperimentApprovalEmail sends an email to the repo owner with approve/decline links +// for an A/B test experiment that has found a winning variant. +func SendExperimentApprovalEmail(owner *user_model.User, repo *repo_model.Repository, + exp *pages_model.PageExperiment, winnerVariant *pages_model.PageVariant, + approveToken, declineToken, summary string, +) { + if setting.MailService == nil { + log.Warn("Mail service not configured, cannot send experiment approval email") + return + } + + if owner.Email == "" { + log.Warn("Repo owner %s has no email, cannot send experiment approval", owner.Name) + return + } + + subject := fmt.Sprintf("[%s] A/B Test Results: %s", setting.AppName, exp.Name) + + approveURL := fmt.Sprintf("%s-/pages/experiment/approve/%s", setting.AppURL, approveToken) + declineURL := fmt.Sprintf("%s-/pages/experiment/decline/%s", setting.AppURL, declineToken) + + var body bytes.Buffer + body.WriteString("A/B Test Experiment Results\n") + body.WriteString("===========================\n\n") + body.WriteString(fmt.Sprintf("Repository: %s/%s\n", repo.OwnerName, repo.Name)) + body.WriteString(fmt.Sprintf("Experiment: %s\n\n", exp.Name)) + + body.WriteString("Results Summary\n") + body.WriteString("---------------\n") + body.WriteString(summary + "\n\n") + + if winnerVariant != nil { + body.WriteString(fmt.Sprintf("Winner: %s\n", winnerVariant.Name)) + body.WriteString(fmt.Sprintf("Impressions: %d\n", winnerVariant.Impressions)) + body.WriteString(fmt.Sprintf("Conversions: %d\n", winnerVariant.Conversions)) + body.WriteString(fmt.Sprintf("Conversion Rate: %.2f%%\n\n", winnerVariant.ConversionRate()*100)) + } + + body.WriteString("Actions\n") + body.WriteString("-------\n") + body.WriteString(fmt.Sprintf("Approve (apply winning variant): %s\n\n", approveURL)) + body.WriteString(fmt.Sprintf("Decline (keep current config): %s\n\n", declineURL)) + + body.WriteString("This link will expire in 7 days.\n") + body.WriteString(fmt.Sprintf("\n--\n%s\n%s\n", setting.AppName, setting.AppURL)) + + msg := sender_service.NewMessage(owner.Email, subject, body.String()) + msg.Info = fmt.Sprintf("Experiment approval for %s/%s: %s", repo.OwnerName, repo.Name, exp.Name) + + if err := sender_service.Send(sender, msg); err != nil { + log.Error("Failed to send experiment approval email: %v", err) + } +} diff --git a/services/pages/analysis.go b/services/pages/analysis.go new file mode 100644 index 0000000000..c19aca950b --- /dev/null +++ b/services/pages/analysis.go @@ -0,0 +1,194 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package pages + +import ( + "context" + "errors" + "fmt" + + pages_model "code.gitcaddy.com/server/v3/models/pages" + repo_model "code.gitcaddy.com/server/v3/models/repo" + "code.gitcaddy.com/server/v3/modules/ai" + "code.gitcaddy.com/server/v3/modules/json" + "code.gitcaddy.com/server/v3/modules/log" +) + +// AnalysisResult holds the AI's analysis of an experiment. +type AnalysisResult struct { + Status string `json:"status"` // "winner", "needs_more_data", "no_difference" + WinnerVariantID int64 `json:"winner_variant_id"` + Confidence float64 `json:"confidence"` + Summary string `json:"summary"` + Recommendation string `json:"recommendation"` +} + +// AnalyzeExperiment uses the AI sidecar to evaluate experiment results. +func AnalyzeExperiment(ctx context.Context, exp *pages_model.PageExperiment) (*AnalysisResult, error) { + if !ai.IsEnabled() { + return nil, errors.New("AI service is not enabled") + } + + // Load variants + variants, err := pages_model.GetVariantsByExperimentID(ctx, exp.ID) + if err != nil { + return nil, fmt.Errorf("failed to load variants: %w", err) + } + + // Load event counts by variant + eventCounts, err := pages_model.GetEventCountsByExperiment(ctx, exp.ID) + if err != nil { + return nil, fmt.Errorf("failed to load event counts: %w", err) + } + + // Build summary data + type variantSummary struct { + ID int64 `json:"id"` + Name string `json:"name"` + IsControl bool `json:"is_control"` + Impressions int64 `json:"impressions"` + Conversions int64 `json:"conversions"` + ConversionRate float64 `json:"conversion_rate"` + Events map[string]int64 `json:"events"` + } + + summaries := make([]variantSummary, 0, len(variants)) + for _, v := range variants { + vs := variantSummary{ + ID: v.ID, + Name: v.Name, + IsControl: v.IsControl, + Impressions: v.Impressions, + Conversions: v.Conversions, + ConversionRate: v.ConversionRate(), + Events: eventCounts[v.ID], + } + summaries = append(summaries, vs) + } + + variantsJSON, _ := json.Marshal(summaries) + experimentJSON, _ := json.Marshal(map[string]any{ + "id": exp.ID, + "name": exp.Name, + "created_at": exp.CreatedUnix, + }) + + client := ai.GetClient() + resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{ + RepoID: exp.RepoID, + Task: "ab_test_analyze", + Context: map[string]string{ + "experiment": string(experimentJSON), + "variants": string(variantsJSON), + "instruction": `Analyze these A/B test results. Look at conversion rates, +impression counts, and event distributions across variants. +Determine if there is a statistically significant winner. +Return valid JSON: +{ + "status": "winner" or "needs_more_data" or "no_difference", + "winner_variant_id": , + "confidence": <0.0 to 1.0>, + "summary": "Brief human-readable summary of results", + "recommendation": "What to do next" +} +Require at least 100 impressions per variant before declaring a winner. +Use a minimum 95% confidence threshold.`, + }, + }) + if err != nil { + return nil, fmt.Errorf("AI analysis failed: %w", err) + } + + if !resp.Success { + return nil, fmt.Errorf("AI analysis error: %s", resp.Error) + } + + var result AnalysisResult + if err := json.Unmarshal([]byte(resp.Result), &result); err != nil { + return nil, fmt.Errorf("failed to parse AI analysis: %w", err) + } + + // If winner found, update experiment status + if result.Status == "winner" { + if err := pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusCompleted); err != nil { + log.Error("Failed to update experiment status: %v", err) + } + } + + return &result, nil +} + +// AnalyzeAllActiveExperiments analyzes all active experiments that have enough data. +func AnalyzeAllActiveExperiments(ctx context.Context) error { + experiments, err := pages_model.GetAllActiveExperiments(ctx) + if err != nil { + return fmt.Errorf("failed to load active experiments: %w", err) + } + + for _, exp := range experiments { + // Load variants to check if we have enough data + variants, err := pages_model.GetVariantsByExperimentID(ctx, exp.ID) + if err != nil { + log.Error("Failed to load variants for experiment %d: %v", exp.ID, err) + continue + } + + // Determine minimum impressions threshold + repo, err := repo_model.GetRepositoryByID(ctx, exp.RepoID) + if err != nil { + log.Error("Failed to load repo for experiment %d: %v", exp.ID, err) + continue + } + + config, err := GetPagesConfig(ctx, repo) + if err != nil { + log.Error("Failed to load config for experiment %d: %v", exp.ID, err) + continue + } + + minImpressions := int64(config.Experiments.MinImpressions) + if minImpressions <= 0 { + minImpressions = 100 + } + + // Check if all variants have enough impressions + hasEnoughData := true + for _, v := range variants { + if v.Impressions < minImpressions { + hasEnoughData = false + break + } + } + + if !hasEnoughData { + continue + } + + result, err := AnalyzeExperiment(ctx, exp) + if err != nil { + log.Error("Failed to analyze experiment %d: %v", exp.ID, err) + continue + } + + if result.Status == "winner" && result.WinnerVariantID > 0 { + log.Info("Experiment %d (%s): winner found (variant %d), confidence %.2f", + exp.ID, exp.Name, result.WinnerVariantID, result.Confidence) + + // Send approval email if required + if config.Experiments.ApprovalRequired { + sendExperimentApprovalEmail(repo, exp, result) + } + } + } + + return nil +} + +// sendExperimentApprovalEmail logs the experiment completion. +// Actual email sending is handled by services/mailer/mail_pages.go +// which is called from the cron task handler to avoid import cycles. +func sendExperimentApprovalEmail(repo *repo_model.Repository, exp *pages_model.PageExperiment, result *AnalysisResult) { + log.Info("Experiment %d completed: %s. Winner variant: %d. Approval email should be sent to repo owner %s.", + exp.ID, result.Summary, result.WinnerVariantID, repo.OwnerName) +} diff --git a/services/pages/experiment.go b/services/pages/experiment.go new file mode 100644 index 0000000000..d347e529e3 --- /dev/null +++ b/services/pages/experiment.go @@ -0,0 +1,193 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package pages + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" + + pages_model "code.gitcaddy.com/server/v3/models/pages" + repo_model "code.gitcaddy.com/server/v3/models/repo" + "code.gitcaddy.com/server/v3/modules/ai" + "code.gitcaddy.com/server/v3/modules/json" + "code.gitcaddy.com/server/v3/modules/log" + pages_module "code.gitcaddy.com/server/v3/modules/pages" + "code.gitcaddy.com/server/v3/modules/setting" +) + +// experimentTokenSecret is derived from the app's secret key +func experimentTokenSecret() []byte { + h := sha256.Sum256([]byte(setting.SecretKey + ":page-experiment")) + return h[:] +} + +// CreateExperimentToken creates an HMAC-signed token for experiment approval. +func CreateExperimentToken(experimentID int64, action string) string { + data := fmt.Sprintf("%d:%s:%d", experimentID, action, time.Now().Unix()) + mac := hmac.New(sha256.New, experimentTokenSecret()) + mac.Write([]byte(data)) + sig := hex.EncodeToString(mac.Sum(nil)) + return hex.EncodeToString([]byte(data)) + "." + sig +} + +// VerifyExperimentToken verifies an experiment approval token and returns the experiment ID. +func VerifyExperimentToken(_ context.Context, tokenStr string) (string, error) { + parts := strings.SplitN(tokenStr, ".", 2) + if len(parts) != 2 { + return "", errors.New("invalid token format") + } + + dataBytes, err := hex.DecodeString(parts[0]) + if err != nil { + return "", errors.New("invalid token encoding") + } + data := string(dataBytes) + + // Verify HMAC + mac := hmac.New(sha256.New, experimentTokenSecret()) + mac.Write(dataBytes) + expectedSig := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(parts[1]), []byte(expectedSig)) { + return "", errors.New("invalid token signature") + } + + // Parse data: "experimentID:action:timestamp" + dataParts := strings.SplitN(data, ":", 3) + if len(dataParts) != 3 { + return "", errors.New("invalid token data") + } + + // Check expiry (7 days) + ts, err := strconv.ParseInt(dataParts[2], 10, 64) + if err != nil { + return "", errors.New("invalid token timestamp") + } + if time.Since(time.Unix(ts, 0)) > 7*24*time.Hour { + return "", errors.New("token expired") + } + + return dataParts[0], nil +} + +// GenerateExperiment uses the AI sidecar to create an A/B test experiment for a landing page. +func GenerateExperiment(ctx context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) (*pages_model.PageExperiment, error) { + if !ai.IsEnabled() { + return nil, errors.New("AI service is not enabled") + } + + client := ai.GetClient() + configJSON, err := json.Marshal(config) + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + + resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{ + RepoID: repo.ID, + Task: "ab_test_generate", + Context: map[string]string{ + "landing_config": string(configJSON), + "repo_name": repo.Name, + "repo_description": repo.Description, + "instruction": `Analyze this landing page config and create an A/B test experiment. +Return valid JSON with this exact structure: +{ + "name": "Short experiment name", + "variants": [ + { + "name": "Variant A", + "config_override": { ... partial landing config fields to override ... }, + "weight": 50 + } + ] +} +Focus on high-impact changes: headlines, CTAs, value propositions. +Keep variants meaningfully different but plausible. +The control variant (original config) is added automatically — do NOT include it. +Return 1-3 variants. Each variant's config_override should be a partial +LandingConfig with only the fields that differ from the control.`, + }, + }) + if err != nil { + return nil, fmt.Errorf("AI task failed: %w", err) + } + + if !resp.Success { + return nil, fmt.Errorf("AI task error: %s", resp.Error) + } + + // Parse AI response + var result struct { + Name string `json:"name"` + Variants []struct { + Name string `json:"name"` + ConfigOverride map[string]any `json:"config_override"` + Weight int `json:"weight"` + } `json:"variants"` + } + if err := json.Unmarshal([]byte(resp.Result), &result); err != nil { + return nil, fmt.Errorf("failed to parse AI response: %w", err) + } + + if result.Name == "" || len(result.Variants) == 0 { + return nil, errors.New("AI returned empty experiment") + } + + // Create experiment + exp := &pages_model.PageExperiment{ + RepoID: repo.ID, + Name: result.Name, + Status: pages_model.ExperimentStatusDraft, + CreatedByAI: true, + } + if err := pages_model.CreateExperiment(ctx, exp); err != nil { + return nil, fmt.Errorf("failed to create experiment: %w", err) + } + + // Create control variant + controlVariant := &pages_model.PageVariant{ + ExperimentID: exp.ID, + Name: "Control", + IsControl: true, + Weight: 50, + } + if err := pages_model.CreateVariant(ctx, controlVariant); err != nil { + return nil, fmt.Errorf("failed to create control variant: %w", err) + } + + // Create AI-generated variants + remainingWeight := 50 + for i, v := range result.Variants { + overrideJSON, err := json.Marshal(v.ConfigOverride) + if err != nil { + log.Error("Failed to marshal variant %d config: %v", i, err) + continue + } + + weight := v.Weight + if weight <= 0 { + weight = remainingWeight / (len(result.Variants) - i) + } + remainingWeight -= weight + + variant := &pages_model.PageVariant{ + ExperimentID: exp.ID, + Name: v.Name, + IsControl: false, + Weight: weight, + ConfigOverride: string(overrideJSON), + } + if err := pages_model.CreateVariant(ctx, variant); err != nil { + log.Error("Failed to create variant %s: %v", v.Name, err) + } + } + + return exp, nil +} diff --git a/services/repository/fork.go b/services/repository/fork.go index c82aea591a..64aeee3dda 100644 --- a/services/repository/fork.go +++ b/services/repository/fork.go @@ -11,6 +11,7 @@ import ( "code.gitcaddy.com/server/v3/models/db" git_model "code.gitcaddy.com/server/v3/models/git" + pages_model "code.gitcaddy.com/server/v3/models/pages" repo_model "code.gitcaddy.com/server/v3/models/repo" "code.gitcaddy.com/server/v3/models/unit" user_model "code.gitcaddy.com/server/v3/models/user" @@ -119,6 +120,9 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork return nil, err } + // Record fork event for A/B testing analytics + pages_model.RecordRepoAction(ctx, opts.BaseRepo.ID, pages_model.EventTypeFork) + // last - clean up if something goes wrong // WARNING: Don't override all later err with local variables defer func() { diff --git a/templates/pages/base_footer.tmpl b/templates/pages/base_footer.tmpl index 308b1d01b6..ae5ba1b38c 100644 --- a/templates/pages/base_footer.tmpl +++ b/templates/pages/base_footer.tmpl @@ -1,2 +1,36 @@ +{{if .ABTestActive}} + +{{end}} diff --git a/templates/pages/base_head.tmpl b/templates/pages/base_head.tmpl index 7a369f1edd..c9cb407fa3 100644 --- a/templates/pages/base_head.tmpl +++ b/templates/pages/base_head.tmpl @@ -5,7 +5,11 @@ {{if .Config.Hero.Headline}}{{.Config.Hero.Headline}}{{else}}{{.Repository.Name}}{{end}} - {{.Repository.Owner.Name}} + {{if .Config.Brand.FaviconURL}} + + {{else}} + {{end}} {{template "base/head_style" .}} + + +
+{{if .ExperimentApproved}} +
+

Experiment Approved

+

The winning variant from {{.ExperimentName}} will be applied to your landing page.

+{{else if .ExperimentDeclined}} +
+

Experiment Declined

+

The experiment {{.ExperimentName}} has been paused. Your landing page remains unchanged.

+{{end}} +
+ + diff --git a/templates/pages/footer.tmpl b/templates/pages/footer.tmpl index 39ea6a2c9f..b53774af55 100644 --- a/templates/pages/footer.tmpl +++ b/templates/pages/footer.tmpl @@ -1,21 +1,22 @@
- {{if .Config.Footer.Links}} - {{end}} diff --git a/templates/pages/minimalist-docs.tmpl b/templates/pages/minimalist-docs.tmpl index 6365536d72..a2553af75d 100644 --- a/templates/pages/minimalist-docs.tmpl +++ b/templates/pages/minimalist-docs.tmpl @@ -992,42 +992,44 @@
- {{if .Config.Footer.Links}} {{range .Config.Footer.Links}} {{.Label}} {{end}} - {{else}} - Docs - API - {{end}} + {{if .Config.Navigation.ShowDocs}}Docs{{end}} + {{if .Config.Navigation.ShowAPI}}API{{end}} + {{if .Config.Navigation.ShowReleases}}Releases{{end}} + {{if .Config.Navigation.ShowIssues}}Issues{{end}} {{if .Config.ValueProps}}Why{{end}} {{if .Config.Features}}Features{{end}} {{if .Config.Pricing.Plans}}Pricing{{end}} {{if .Config.Blog.Enabled}}Blog{{end}} + {{if .Config.Navigation.ShowRepository}} GitCaddy Repository + {{end}}
{{if .PageIsBlogDetail}} @@ -1103,12 +1105,12 @@
{{if .Config.Hero.PrimaryCTA.Label}} - + {{.Config.Hero.PrimaryCTA.Label}} {{svg "octicon-arrow-right" 14}} {{else}} - + Get Started {{svg "octicon-arrow-right" 14}} @@ -1297,7 +1299,7 @@ {{end}} {{if .Config.CTASection.Button.Label}} {{end}}
diff --git a/templates/pages/open-source-hero.tmpl b/templates/pages/open-source-hero.tmpl index 4cb8123f39..67ab15f7ea 100644 --- a/templates/pages/open-source-hero.tmpl +++ b/templates/pages/open-source-hero.tmpl @@ -983,23 +983,23 @@ {{if .Config.Brand.Name}}{{.Config.Brand.Name}}{{else}}{{.Repository.Name}}{{end}}
- {{if .Config.Footer.Links}} {{range .Config.Footer.Links}} {{.Label}} {{end}} - {{end}} + {{if .Config.Navigation.ShowDocs}}Docs{{end}} + {{if .Config.Navigation.ShowAPI}}API{{end}} + {{if .Config.Navigation.ShowReleases}}Releases{{end}} + {{if .Config.Navigation.ShowIssues}}Issues{{end}} {{if .Config.ValueProps}}Why{{end}} {{if .Config.Features}}Features{{end}} {{if .Config.Pricing.Plans}}Pricing{{end}} {{if .Config.Blog.Enabled}}Blog{{end}} + {{if .Config.Navigation.ShowRepository}} GitCaddy Repository + {{end}}
{{if .PageIsBlogDetail}} @@ -1155,7 +1163,7 @@ {{.BlogRenderedContent | SafeHTML}}
- + {{svg "octicon-arrow-left" 16}} Back to Blog
@@ -1214,14 +1222,14 @@

{{if .Config.Blog.CTAButton.Label}} {{end}} diff --git a/templates/repo/settings/pages_brand.tmpl b/templates/repo/settings/pages_brand.tmpl index 6d78ccabef..8d8d0f9470 100644 --- a/templates/repo/settings/pages_brand.tmpl +++ b/templates/repo/settings/pages_brand.tmpl @@ -20,6 +20,11 @@ +
+ + +

{{ctx.Locale.Tr "repo.settings.pages.brand_favicon_url_help"}}

+
diff --git a/templates/repo/settings/pages_content.tmpl b/templates/repo/settings/pages_content.tmpl index 82e495f0ce..8d99bc8df0 100644 --- a/templates/repo/settings/pages_content.tmpl +++ b/templates/repo/settings/pages_content.tmpl @@ -4,6 +4,39 @@
{{.CsrfTokenHtml}} +
{{ctx.Locale.Tr "repo.settings.pages.navigation"}}
+

{{ctx.Locale.Tr "repo.settings.pages.navigation_desc"}}

+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
{{ctx.Locale.Tr "repo.settings.pages.public_releases"}}