From 0c0d1c149356e4b3ac1d47efe02f29304c0d6d95 Mon Sep 17 00:00:00 2001 From: logikonline Date: Sun, 5 Apr 2026 03:39:54 -0400 Subject: [PATCH] feat(mcp): add landing page management tools to MCP server Creates mcp_pages.go with 10 new tools for managing repository landing pages via Claude Code. Supports getting/updating brand, hero, pricing, comparison, features, social proof, SEO, and theme sections. Includes template listing and enable/disable functionality. Integrates with existing pages service and registers tools in handleToolsList and handleToolsCall. --- routers/api/v2/mcp.go | 26 +- routers/api/v2/mcp_pages.go | 665 ++++++++++++++++++++++++++++++++++++ 2 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 routers/api/v2/mcp_pages.go diff --git a/routers/api/v2/mcp.go b/routers/api/v2/mcp.go index b271bb3fdc..373456af06 100644 --- a/routers/api/v2/mcp.go +++ b/routers/api/v2/mcp.go @@ -685,9 +685,10 @@ func handleInitialize(ctx *context_service.APIContext, req *MCPRequest) { } func handleToolsList(ctx *context_service.APIContext, req *MCPRequest) { - allTools := make([]MCPTool, 0, len(mcpTools)+len(mcpAITools)) + allTools := make([]MCPTool, 0, len(mcpTools)+len(mcpAITools)+len(mcpPagesTools)) allTools = append(allTools, mcpTools...) allTools = append(allTools, mcpAITools...) + allTools = append(allTools, mcpPagesTools...) result := MCPToolsListResult{Tools: allTools} sendMCPResult(ctx, req.ID, result) } @@ -759,6 +760,29 @@ func handleToolsCall(ctx *context_service.APIContext, req *MCPRequest) { result, err = toolListIssues(ctx, params.Arguments) case "get_issue": result, err = toolGetIssue(ctx, params.Arguments) + // Landing Pages tools + case "get_landing_config": + result, err = toolGetLandingConfig(ctx, params.Arguments) + case "list_landing_templates": + result, err = toolListLandingTemplates(ctx, params.Arguments) + case "enable_landing_page": + result, err = toolEnableLandingPage(ctx, params.Arguments) + case "update_landing_brand": + result, err = toolUpdateLandingBrand(ctx, params.Arguments) + case "update_landing_hero": + result, err = toolUpdateLandingHero(ctx, params.Arguments) + case "update_landing_pricing": + result, err = toolUpdateLandingPricing(ctx, params.Arguments) + case "update_landing_comparison": + result, err = toolUpdateLandingComparison(ctx, params.Arguments) + case "update_landing_features": + result, err = toolUpdateLandingFeatures(ctx, params.Arguments) + case "update_landing_social_proof": + result, err = toolUpdateLandingSocialProof(ctx, params.Arguments) + case "update_landing_seo": + result, err = toolUpdateLandingSEO(ctx, params.Arguments) + case "update_landing_theme": + result, err = toolUpdateLandingTheme(ctx, params.Arguments) default: sendMCPError(ctx, req.ID, -32602, "Unknown tool", params.Name) return diff --git a/routers/api/v2/mcp_pages.go b/routers/api/v2/mcp_pages.go new file mode 100644 index 0000000000..203b091401 --- /dev/null +++ b/routers/api/v2/mcp_pages.go @@ -0,0 +1,665 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v2 + +import ( + "fmt" + + repo_model "code.gitcaddy.com/server/v3/models/repo" + user_model "code.gitcaddy.com/server/v3/models/user" + "code.gitcaddy.com/server/v3/modules/json" + pages_module "code.gitcaddy.com/server/v3/modules/pages" + "code.gitcaddy.com/server/v3/services/context" + pages_service "code.gitcaddy.com/server/v3/services/pages" +) + +// Landing Pages MCP Tools +var mcpPagesTools = []MCPTool{ + { + Name: "get_landing_config", + Description: "Get the full landing page configuration for a repository. Returns all sections: brand, hero, pricing, comparison, features, social proof, SEO, and more.", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"owner", "repo"}, + "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "Repository owner"}, + "repo": map[string]any{"type": "string", "description": "Repository name"}, + }, + }, + }, + { + Name: "list_landing_templates", + Description: "List available landing page templates with display names. Templates include: open-source-hero, saas-conversion, bold-marketing, developer-tool, and more.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{}, + }, + }, + { + Name: "enable_landing_page", + Description: "Enable or disable the landing page for a repository. Optionally set a template.", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"owner", "repo", "enabled"}, + "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "Repository owner"}, + "repo": map[string]any{"type": "string", "description": "Repository name"}, + "enabled": map[string]any{"type": "boolean", "description": "Enable or disable"}, + "template": map[string]any{"type": "string", "description": "Template name (e.g., 'saas-conversion', 'open-source-hero')"}, + }, + }, + }, + { + Name: "update_landing_brand", + Description: "Update the brand section of a landing page: name, logo URL, tagline, favicon.", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"owner", "repo"}, + "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "Repository owner"}, + "repo": map[string]any{"type": "string", "description": "Repository name"}, + "name": map[string]any{"type": "string", "description": "Brand name"}, + "logo_url": map[string]any{"type": "string", "description": "Logo image URL"}, + "tagline": map[string]any{"type": "string", "description": "Brand tagline"}, + "favicon_url": map[string]any{"type": "string", "description": "Favicon URL"}, + }, + }, + }, + { + Name: "update_landing_hero", + Description: "Update the hero section: headline, subheadline, CTA buttons, hero image or video URL.", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"owner", "repo"}, + "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "Repository owner"}, + "repo": map[string]any{"type": "string", "description": "Repository name"}, + "headline": map[string]any{"type": "string", "description": "Main headline"}, + "subheadline": map[string]any{"type": "string", "description": "Supporting text"}, + "image_url": map[string]any{"type": "string", "description": "Hero image URL"}, + "video_url": map[string]any{"type": "string", "description": "Hero video URL"}, + "primary_cta_label": map[string]any{"type": "string", "description": "Primary CTA button label"}, + "primary_cta_url": map[string]any{"type": "string", "description": "Primary CTA button URL"}, + "secondary_cta_label": map[string]any{"type": "string", "description": "Secondary CTA button label"}, + "secondary_cta_url": map[string]any{"type": "string", "description": "Secondary CTA button URL"}, + }, + }, + }, + { + Name: "update_landing_pricing", + Description: "Update the pricing section with plans. Each plan has a name, price, period, feature list, CTA, and optional 'featured' flag.", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"owner", "repo", "plans"}, + "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "Repository owner"}, + "repo": map[string]any{"type": "string", "description": "Repository name"}, + "headline": map[string]any{"type": "string", "description": "Pricing section headline"}, + "subheadline": map[string]any{"type": "string", "description": "Pricing section subheadline"}, + "plans": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "price": map[string]any{"type": "string"}, + "period": map[string]any{"type": "string"}, + "features": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "cta": map[string]any{"type": "string"}, + "featured": map[string]any{"type": "boolean"}, + }, + }, + "description": "Array of pricing plans", + }, + }, + }, + }, + { + Name: "update_landing_comparison", + Description: "Update the feature comparison matrix. Define columns (products/tiers) and groups of features with per-column values.", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"owner", "repo"}, + "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "Repository owner"}, + "repo": map[string]any{"type": "string", "description": "Repository name"}, + "enabled": map[string]any{"type": "boolean", "description": "Enable comparison section"}, + "headline": map[string]any{"type": "string", "description": "Section headline"}, + "subheadline": map[string]any{"type": "string", "description": "Section subheadline"}, + "columns": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Column headers (e.g., ['Free', 'Pro', 'Enterprise'])", + }, + "groups": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "features": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "values": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + }, + }, + }, + }, + "description": "Feature groups with per-column values", + }, + }, + }, + }, + { + Name: "update_landing_features", + Description: "Update the features section with title, description, and optional icon/image for each feature.", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"owner", "repo", "features"}, + "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "Repository owner"}, + "repo": map[string]any{"type": "string", "description": "Repository name"}, + "features": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "title": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "icon": map[string]any{"type": "string"}, + "image_url": map[string]any{"type": "string"}, + }, + }, + "description": "Array of features", + }, + }, + }, + }, + { + Name: "update_landing_social_proof", + Description: "Update testimonials and client logos for social proof.", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"owner", "repo"}, + "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "Repository owner"}, + "repo": map[string]any{"type": "string", "description": "Repository name"}, + "logos": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Client/partner logo URLs", + }, + "testimonials": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "quote": map[string]any{"type": "string"}, + "author": map[string]any{"type": "string"}, + "role": map[string]any{"type": "string"}, + "avatar": map[string]any{"type": "string"}, + }, + }, + "description": "Testimonials", + }, + }, + }, + }, + { + Name: "update_landing_seo", + Description: "Update SEO metadata: title, description, keywords, Open Graph image, Twitter card settings.", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"owner", "repo"}, + "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "Repository owner"}, + "repo": map[string]any{"type": "string", "description": "Repository name"}, + "title": map[string]any{"type": "string", "description": "SEO title"}, + "description": map[string]any{"type": "string", "description": "SEO description"}, + "keywords": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "SEO keywords"}, + "og_image": map[string]any{"type": "string", "description": "Open Graph image URL"}, + "twitter_card": map[string]any{"type": "string", "description": "Twitter card type (summary, summary_large_image)"}, + "twitter_site": map[string]any{"type": "string", "description": "Twitter @handle"}, + }, + }, + }, + { + Name: "update_landing_theme", + Description: "Update the visual theme: primary color, accent color, light/dark/auto mode.", + InputSchema: map[string]any{ + "type": "object", + "required": []string{"owner", "repo"}, + "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "Repository owner"}, + "repo": map[string]any{"type": "string", "description": "Repository name"}, + "primary_color": map[string]any{"type": "string", "description": "Primary brand color (hex, e.g., '#512BD4')"}, + "accent_color": map[string]any{"type": "string", "description": "Accent color (hex)"}, + "mode": map[string]any{"type": "string", "enum": []string{"light", "dark", "auto"}, "description": "Color mode"}, + }, + }, + }, +} + +// ── Tool Implementations ────────────────────────────────── + +func toolGetLandingConfig(ctx *context.APIContext, args map[string]any) (any, error) { + owner, repo, err := resolveOwnerRepo(args) + if err != nil { + return nil, err + } + + repoObj, err := getRepoByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, err + } + + config, err := pages_service.GetPagesConfig(ctx, repoObj) + if err != nil { + return nil, fmt.Errorf("get pages config: %w", err) + } + if config == nil { + return map[string]any{"enabled": false, "message": "No landing page configured"}, nil + } + + return buildFullResponse(config), nil +} + +func toolListLandingTemplates(ctx *context.APIContext, args map[string]any) (any, error) { + templates := pages_module.ValidTemplates() + displayNames := pages_module.TemplateDisplayNames() + + result := make([]map[string]string, 0, len(templates)) + for _, t := range templates { + result = append(result, map[string]string{ + "id": t, + "name": displayNames[t], + }) + } + return map[string]any{"templates": result}, nil +} + +func toolEnableLandingPage(ctx *context.APIContext, args map[string]any) (any, error) { + owner, repo, err := resolveOwnerRepo(args) + if err != nil { + return nil, err + } + + repoObj, err := getRepoByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, err + } + + enabled, _ := args["enabled"].(bool) + template, _ := args["template"].(string) + + if enabled { + if template == "" { + template = "open-source-hero" + } + if !pages_module.IsValidTemplate(template) { + return nil, fmt.Errorf("invalid template: %s", template) + } + if err := pages_service.EnablePages(ctx, repoObj, template); err != nil { + return nil, fmt.Errorf("enable pages: %w", err) + } + return map[string]any{"enabled": true, "template": template}, nil + } + + if err := pages_service.DisablePages(ctx, repoObj); err != nil { + return nil, fmt.Errorf("disable pages: %w", err) + } + return map[string]any{"enabled": false}, nil +} + +func toolUpdateLandingBrand(ctx *context.APIContext, args map[string]any) (any, error) { + config, repoObj, err := getConfigForUpdate(ctx, args) + if err != nil { + return nil, err + } + + if v, ok := args["name"].(string); ok { + config.Brand.Name = v + } + if v, ok := args["logo_url"].(string); ok { + config.Brand.LogoURL = v + } + if v, ok := args["tagline"].(string); ok { + config.Brand.Tagline = v + } + if v, ok := args["favicon_url"].(string); ok { + config.Brand.FaviconURL = v + } + + return saveAndReturn(ctx, repoObj, config, "brand") +} + +func toolUpdateLandingHero(ctx *context.APIContext, args map[string]any) (any, error) { + config, repoObj, err := getConfigForUpdate(ctx, args) + if err != nil { + return nil, err + } + + if v, ok := args["headline"].(string); ok { + config.Hero.Headline = v + } + if v, ok := args["subheadline"].(string); ok { + config.Hero.Subheadline = v + } + if v, ok := args["image_url"].(string); ok { + config.Hero.ImageURL = v + } + if v, ok := args["video_url"].(string); ok { + config.Hero.VideoURL = v + } + if v, ok := args["primary_cta_label"].(string); ok { + config.Hero.PrimaryCTA.Label = v + } + if v, ok := args["primary_cta_url"].(string); ok { + config.Hero.PrimaryCTA.URL = v + } + if v, ok := args["secondary_cta_label"].(string); ok { + config.Hero.SecondaryCTA.Label = v + } + if v, ok := args["secondary_cta_url"].(string); ok { + config.Hero.SecondaryCTA.URL = v + } + + return saveAndReturn(ctx, repoObj, config, "hero") +} + +func toolUpdateLandingPricing(ctx *context.APIContext, args map[string]any) (any, error) { + config, repoObj, err := getConfigForUpdate(ctx, args) + if err != nil { + return nil, err + } + + if v, ok := args["headline"].(string); ok { + config.Pricing.Headline = v + } + if v, ok := args["subheadline"].(string); ok { + config.Pricing.Subheadline = v + } + if plans, ok := args["plans"].([]any); ok { + config.Pricing.Plans = nil + for _, p := range plans { + if pm, ok := p.(map[string]any); ok { + plan := pages_module.PricingPlanConfig{ + Name: strVal(pm, "name"), + Price: strVal(pm, "price"), + Period: strVal(pm, "period"), + CTA: strVal(pm, "cta"), + Featured: boolVal(pm, "featured"), + } + if features, ok := pm["features"].([]any); ok { + for _, f := range features { + if s, ok := f.(string); ok { + plan.Features = append(plan.Features, s) + } + } + } + config.Pricing.Plans = append(config.Pricing.Plans, plan) + } + } + } + + return saveAndReturn(ctx, repoObj, config, "pricing") +} + +func toolUpdateLandingComparison(ctx *context.APIContext, args map[string]any) (any, error) { + config, repoObj, err := getConfigForUpdate(ctx, args) + if err != nil { + return nil, err + } + + if v, ok := args["enabled"].(bool); ok { + config.Comparison.Enabled = v + } + if v, ok := args["headline"].(string); ok { + config.Comparison.Headline = v + } + if v, ok := args["subheadline"].(string); ok { + config.Comparison.Subheadline = v + } + if cols, ok := args["columns"].([]any); ok { + config.Comparison.Columns = nil + for _, c := range cols { + if s, ok := c.(string); ok { + config.Comparison.Columns = append(config.Comparison.Columns, pages_module.ComparisonColumnConfig{Name: s}) + } else if cm, ok := c.(map[string]any); ok { + config.Comparison.Columns = append(config.Comparison.Columns, pages_module.ComparisonColumnConfig{ + Name: strVal(cm, "name"), + Highlight: boolVal(cm, "highlight"), + }) + } + } + } + if groups, ok := args["groups"].([]any); ok { + config.Comparison.Groups = nil + for _, g := range groups { + if gm, ok := g.(map[string]any); ok { + group := pages_module.ComparisonGroupConfig{Name: strVal(gm, "name")} + if features, ok := gm["features"].([]any); ok { + for _, f := range features { + if fm, ok := f.(map[string]any); ok { + feature := pages_module.ComparisonFeatureConfig{Name: strVal(fm, "name")} + if vals, ok := fm["values"].([]any); ok { + for _, v := range vals { + if s, ok := v.(string); ok { + feature.Values = append(feature.Values, s) + } + } + } + group.Features = append(group.Features, feature) + } + } + } + config.Comparison.Groups = append(config.Comparison.Groups, group) + } + } + } + + return saveAndReturn(ctx, repoObj, config, "comparison") +} + +func toolUpdateLandingFeatures(ctx *context.APIContext, args map[string]any) (any, error) { + config, repoObj, err := getConfigForUpdate(ctx, args) + if err != nil { + return nil, err + } + + if features, ok := args["features"].([]any); ok { + config.Features = nil + for _, f := range features { + if fm, ok := f.(map[string]any); ok { + config.Features = append(config.Features, pages_module.FeatureConfig{ + Title: strVal(fm, "title"), + Description: strVal(fm, "description"), + Icon: strVal(fm, "icon"), + ImageURL: strVal(fm, "image_url"), + }) + } + } + } + + return saveAndReturn(ctx, repoObj, config, "features") +} + +func toolUpdateLandingSocialProof(ctx *context.APIContext, args map[string]any) (any, error) { + config, repoObj, err := getConfigForUpdate(ctx, args) + if err != nil { + return nil, err + } + + if logos, ok := args["logos"].([]any); ok { + config.SocialProof.Logos = nil + for _, l := range logos { + if s, ok := l.(string); ok { + config.SocialProof.Logos = append(config.SocialProof.Logos, s) + } + } + } + if testimonials, ok := args["testimonials"].([]any); ok { + config.SocialProof.Testimonials = nil + for _, t := range testimonials { + if tm, ok := t.(map[string]any); ok { + config.SocialProof.Testimonials = append(config.SocialProof.Testimonials, pages_module.TestimonialConfig{ + Quote: strVal(tm, "quote"), + Author: strVal(tm, "author"), + Role: strVal(tm, "role"), + Avatar: strVal(tm, "avatar"), + }) + } + } + } + + return saveAndReturn(ctx, repoObj, config, "social_proof") +} + +func toolUpdateLandingSEO(ctx *context.APIContext, args map[string]any) (any, error) { + config, repoObj, err := getConfigForUpdate(ctx, args) + if err != nil { + return nil, err + } + + if v, ok := args["title"].(string); ok { + config.SEO.Title = v + } + if v, ok := args["description"].(string); ok { + config.SEO.Description = v + } + if v, ok := args["og_image"].(string); ok { + config.SEO.OGImage = v + } + if v, ok := args["twitter_card"].(string); ok { + config.SEO.TwitterCard = v + } + if v, ok := args["twitter_site"].(string); ok { + config.SEO.TwitterSite = v + } + if keywords, ok := args["keywords"].([]any); ok { + config.SEO.Keywords = nil + for _, k := range keywords { + if s, ok := k.(string); ok { + config.SEO.Keywords = append(config.SEO.Keywords, s) + } + } + } + + return saveAndReturn(ctx, repoObj, config, "seo") +} + +func toolUpdateLandingTheme(ctx *context.APIContext, args map[string]any) (any, error) { + config, repoObj, err := getConfigForUpdate(ctx, args) + if err != nil { + return nil, err + } + + if v, ok := args["primary_color"].(string); ok { + config.Theme.PrimaryColor = v + } + if v, ok := args["accent_color"].(string); ok { + config.Theme.AccentColor = v + } + if v, ok := args["mode"].(string); ok { + config.Theme.Mode = v + } + + return saveAndReturn(ctx, repoObj, config, "theme") +} + +// ── Helpers ────────────────────────────────── + +func getConfigForUpdate(ctx *context.APIContext, args map[string]any) (*pages_module.LandingConfig, *repo_model.Repository, error) { + owner, repo, err := resolveOwnerRepo(args) + if err != nil { + return nil, nil, err + } + + repoObj, err := getRepoByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, nil, err + } + + config, err := pages_service.GetPagesConfig(ctx, repoObj) + if err != nil { + return nil, nil, fmt.Errorf("get config: %w", err) + } + if config == nil { + config = pages_module.DefaultConfig() + } + + return config, repoObj, nil +} + +func saveAndReturn(ctx *context.APIContext, repo *repo_model.Repository, config *pages_module.LandingConfig, section string) (any, error) { + configJSON, _ := json.Marshal(config) + hash := pages_module.HashConfig(configJSON) + + existing, _ := repo_model.GetPagesConfigByRepoID(ctx, repo.ID) + + if existing != nil { + existing.ConfigJSON = string(configJSON) + existing.ConfigHash = hash + existing.Template = repo_model.PagesTemplate(config.Template) + existing.Enabled = config.Enabled + if err := repo_model.UpdatePagesConfig(ctx, existing); err != nil { + return nil, fmt.Errorf("save config: %w", err) + } + } else { + if err := repo_model.CreatePagesConfig(ctx, &repo_model.PagesConfig{ + RepoID: repo.ID, + Enabled: config.Enabled, + Template: repo_model.PagesTemplate(config.Template), + ConfigJSON: string(configJSON), + ConfigHash: hash, + }); err != nil { + return nil, fmt.Errorf("create config: %w", err) + } + } + + return map[string]any{ + "success": true, + "section": section, + "message": fmt.Sprintf("Updated %s section", section), + }, nil +} + +func strVal(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func boolVal(m map[string]any, key string) bool { + if v, ok := m[key].(bool); ok { + return v + } + return false +} + +func resolveOwnerRepo(args map[string]any) (string, string, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + if owner == "" || repo == "" { + return "", "", fmt.Errorf("owner and repo are required") + } + return owner, repo, nil +} + +func getRepoByOwnerAndName(ctx *context.APIContext, owner, repo string) (*repo_model.Repository, error) { + ownerObj, err := user_model.GetUserByName(ctx, owner) + if err != nil { + return nil, fmt.Errorf("owner not found: %s", owner) + } + repoObj, err := repo_model.GetRepositoryByName(ctx, ownerObj.ID, repo) + if err != nil { + return nil, fmt.Errorf("repo not found: %s/%s", owner, repo) + } + return repoObj, nil +}