Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2f4367dbe | |||
| 7b4e85a473 | |||
| d8904e2846 | |||
| 4fabef6a65 | |||
| f26bd3e273 | |||
| c5daac3366 | |||
| 916211004d | |||
| 02fdc1a194 | |||
| 1b0bba09b9 | |||
| 0c0d1c1493 | |||
| 9461599b57 | |||
| 414560f470 | |||
| b43345986a | |||
| 7fbbd26b20 | |||
| b26bf4bfe8 |
@@ -24,6 +24,8 @@ jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: linux-latest
|
||||
env:
|
||||
GOMODCACHE: /tmp/gomod-${{ github.run_id }}-lint
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -51,7 +53,8 @@ jobs:
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: make deps-frontend deps-backend
|
||||
run: |
|
||||
make deps-frontend deps-backend
|
||||
|
||||
- name: Run Go linter
|
||||
run: make lint-go
|
||||
@@ -64,6 +67,8 @@ jobs:
|
||||
test-unit:
|
||||
name: Unit Tests
|
||||
runs-on: linux-latest
|
||||
env:
|
||||
GOMODCACHE: /tmp/gomod-${{ github.run_id }}-unit
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -83,7 +88,8 @@ jobs:
|
||||
cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod download
|
||||
run: |
|
||||
go mod download
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
@@ -99,6 +105,8 @@ jobs:
|
||||
test-pgsql:
|
||||
name: Integration Tests (PostgreSQL)
|
||||
runs-on: linux-latest
|
||||
env:
|
||||
GOMODCACHE: /tmp/gomod-${{ github.run_id }}-pgsql
|
||||
services:
|
||||
pgsql:
|
||||
image: postgres:15
|
||||
@@ -140,7 +148,8 @@ jobs:
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: make deps-frontend deps-backend
|
||||
run: |
|
||||
make deps-frontend deps-backend
|
||||
|
||||
- name: Build frontend
|
||||
run: make frontend
|
||||
@@ -378,7 +387,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies (Unix)
|
||||
if: matrix.goos != 'windows'
|
||||
run: make deps-frontend deps-backend
|
||||
run: |
|
||||
make deps-frontend deps-backend
|
||||
|
||||
- name: Install dependencies (Windows)
|
||||
if: matrix.goos == 'windows'
|
||||
|
||||
3
go.mod
3
go.mod
@@ -326,6 +326,9 @@ replace github.com/go-ini/ini => github.com/go-ini/ini v1.66.6
|
||||
// Use GitCaddy fork with capability support
|
||||
replace code.gitea.io/actions-proto-go => git.marketally.com/gitcaddy/actions-proto-go v0.5.8
|
||||
|
||||
// Mirror of deleted github.com/hexops/gotextdiff
|
||||
replace github.com/hexops/gotextdiff => git.marketally.com/mirrors/gotextdiff v1.0.3
|
||||
|
||||
// Vault plugin - use local directory for development
|
||||
replace git.marketally.com/gitcaddy/gitcaddy-vault => ../gitcaddy-vault
|
||||
|
||||
|
||||
4
go.sum
4
go.sum
@@ -31,6 +31,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
git.marketally.com/gitcaddy/actions-proto-go v0.5.8 h1:MBipeHvY6A0jcobvziUtzgatZTrV4fs/HE1rPQxREN4=
|
||||
git.marketally.com/gitcaddy/actions-proto-go v0.5.8/go.mod h1:RPu21UoRD3zSAujoZR6LJwuVNa2uFRBveadslczCRfQ=
|
||||
git.marketally.com/mirrors/gotextdiff v1.0.3 h1:Mxf+YurdCHT4y1GNiZCTDWYtVXSxhlLUeG7g7i9Za70=
|
||||
git.marketally.com/mirrors/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
gitea.com/gitea/act v0.261.7-0.20251003180512-ac6e4b751763 h1:ohdxegvslDEllZmRNDqpKun6L4Oq81jNdEDtGgHEV2c=
|
||||
gitea.com/gitea/act v0.261.7-0.20251003180512-ac6e4b751763/go.mod h1:Pg5C9kQY1CEA3QjthjhlrqOC/QOT5NyWNjOjRHw23Ok=
|
||||
gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:BAFmdZpRW7zMQZQDClaCWobRj9uL1MR3MzpCVJvc5s4=
|
||||
@@ -490,8 +492,6 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
|
||||
@@ -191,3 +191,25 @@ type OrgProfileContent struct {
|
||||
Readme string `json:"readme,omitempty"`
|
||||
HasCSS bool `json:"has_css"`
|
||||
}
|
||||
|
||||
// OrgRecentActivity represents a recently updated repo in the org overview
|
||||
type OrgRecentActivity struct {
|
||||
RepoName string `json:"repo_name"`
|
||||
RepoFullName string `json:"repo_full_name"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
CommitMessage string `json:"commit_message,omitempty"`
|
||||
CommitTime int64 `json:"commit_time,omitempty"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
}
|
||||
|
||||
// OrgOverviewV2 represents the enhanced organization overview for v2 API
|
||||
type OrgOverviewV2 struct {
|
||||
Organization *Organization `json:"organization"`
|
||||
PinnedRepos []*OrgPinnedRepo `json:"pinned_repos"`
|
||||
PinnedGroups []*OrgPinnedGroup `json:"pinned_groups"`
|
||||
PublicMembers []*OrgPublicMember `json:"public_members"`
|
||||
TotalMembers int64 `json:"total_members"`
|
||||
Stats *OrgOverviewStats `json:"stats"`
|
||||
Profile *OrgProfileContent `json:"profile,omitempty"`
|
||||
RecentActivity []OrgRecentActivity `json:"recent_activity,omitempty"`
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4545,6 +4545,12 @@
|
||||
"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.settings.pages.section_labels": "Section Labels",
|
||||
"repo.settings.pages.section_labels_desc": "Customize the headings shown on your landing page for each section",
|
||||
"repo.settings.pages.label_value_props": "Value Props Heading",
|
||||
"repo.settings.pages.label_value_props_help": "Heading for the value propositions section (e.g., \"Why choose us\")",
|
||||
"repo.settings.pages.label_features": "Features Heading",
|
||||
"repo.settings.pages.label_features_help": "Heading for the features section (e.g., \"Capabilities\")",
|
||||
"repo.settings.pages.blog_section": "Blog Section",
|
||||
"repo.settings.pages.blog_enabled_desc": "Show recent blog posts on the landing page",
|
||||
"repo.settings.pages.blog_headline": "Blog Headline",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1954,6 +1954,12 @@
|
||||
"repo.settings.pages.nav_show_repository": "Tampilkan tautan repositori (tombol Lihat kode sumber)",
|
||||
"repo.settings.pages.nav_show_releases": "Tampilkan tautan rilis",
|
||||
"repo.settings.pages.nav_show_issues": "Tampilkan tautan isu",
|
||||
"repo.settings.pages.section_labels": "Label bagian",
|
||||
"repo.settings.pages.section_labels_desc": "Sesuaikan judul yang ditampilkan di halaman arahan untuk setiap bagian",
|
||||
"repo.settings.pages.label_value_props": "Judul proposisi nilai",
|
||||
"repo.settings.pages.label_value_props_help": "Judul untuk bagian proposisi nilai (mis., \"Mengapa memilih kami\")",
|
||||
"repo.settings.pages.label_features": "Judul fitur",
|
||||
"repo.settings.pages.label_features_help": "Judul untuk bagian fitur (mis., \"Kemampuan\")",
|
||||
"repo.settings.pages.blog_section": "Bagian blog",
|
||||
"repo.settings.pages.blog_enabled_desc": "Tampilkan postingan blog terbaru di halaman arahan",
|
||||
"repo.settings.pages.blog_headline": "Judul blog",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2884,6 +2884,12 @@
|
||||
"repo.settings.pages.nav_show_repository": "Repository-link tonen (Broncode bekijken-knop)",
|
||||
"repo.settings.pages.nav_show_releases": "Releases-link tonen",
|
||||
"repo.settings.pages.nav_show_issues": "Issues-link tonen",
|
||||
"repo.settings.pages.section_labels": "Sectielabels",
|
||||
"repo.settings.pages.section_labels_desc": "Pas de koppen aan die op uw landingspagina worden weergegeven voor elke sectie",
|
||||
"repo.settings.pages.label_value_props": "Kop waardeproposities",
|
||||
"repo.settings.pages.label_value_props_help": "Kop voor de sectie waardeproposities (bijv., \"Waarom voor ons kiezen\")",
|
||||
"repo.settings.pages.label_features": "Kop functies",
|
||||
"repo.settings.pages.label_features_help": "Kop voor de sectie functies (bijv., \"Mogelijkheden\")",
|
||||
"repo.settings.pages.blog_section": "Blogsectie",
|
||||
"repo.settings.pages.blog_enabled_desc": "Toon recente blogberichten op de landingspagina",
|
||||
"repo.settings.pages.blog_headline": "Blogtitel",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -248,6 +248,26 @@ func Routes() *web.Router {
|
||||
}, reqToken())
|
||||
}, repoAssignment())
|
||||
|
||||
// Organization v2 API - org overview, repos, profile, pinned repos
|
||||
m.Group("/user/orgs", func() {
|
||||
m.Get("", ListUserOrgsV2)
|
||||
}, reqToken())
|
||||
|
||||
m.Group("/orgs/{org}", func() {
|
||||
// Public read endpoints
|
||||
m.Get("/overview", GetOrgOverviewV2)
|
||||
m.Get("/repos", ListOrgReposV2)
|
||||
m.Get("/profile/readme", GetOrgProfileReadmeV2)
|
||||
|
||||
// Write endpoints require authentication + org ownership (checked in handler)
|
||||
m.Group("", func() {
|
||||
m.Patch("", web.Bind(UpdateOrgV2Option{}), UpdateOrgV2)
|
||||
m.Put("/profile/readme", web.Bind(UpdateOrgProfileReadmeOption{}), UpdateOrgProfileReadmeV2)
|
||||
m.Post("/pinned", web.Bind(PinOrgRepoV2Option{}), PinOrgRepoV2)
|
||||
m.Delete("/pinned/{repo}", UnpinOrgRepoV2)
|
||||
}, reqToken())
|
||||
})
|
||||
|
||||
// AI settings API - org-scoped AI settings
|
||||
m.Group("/orgs/{org}/ai", func() {
|
||||
m.Get("/settings", GetOrgAISettingsV2)
|
||||
|
||||
@@ -685,9 +685,11 @@ 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)+len(mcpOrgTools))
|
||||
allTools = append(allTools, mcpTools...)
|
||||
allTools = append(allTools, mcpAITools...)
|
||||
allTools = append(allTools, mcpPagesTools...)
|
||||
allTools = append(allTools, mcpOrgTools...)
|
||||
result := MCPToolsListResult{Tools: allTools}
|
||||
sendMCPResult(ctx, req.ID, result)
|
||||
}
|
||||
@@ -759,6 +761,52 @@ 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)
|
||||
case "update_landing_stats":
|
||||
result, err = toolUpdateLandingStats(ctx, params.Arguments)
|
||||
case "update_landing_value_props":
|
||||
result, err = toolUpdateLandingValueProps(ctx, params.Arguments)
|
||||
case "update_landing_cta":
|
||||
result, err = toolUpdateLandingCTA(ctx, params.Arguments)
|
||||
// Organization tools
|
||||
case "list_orgs":
|
||||
result, err = toolListOrgs(ctx, params.Arguments)
|
||||
case "get_org_overview":
|
||||
result, err = toolGetOrgOverview(ctx, params.Arguments)
|
||||
case "update_org":
|
||||
result, err = toolUpdateOrg(ctx, params.Arguments)
|
||||
case "list_org_repos":
|
||||
result, err = toolListOrgRepos(ctx, params.Arguments)
|
||||
case "get_org_profile_readme":
|
||||
result, err = toolGetOrgProfileReadme(ctx, params.Arguments)
|
||||
case "update_org_profile_readme":
|
||||
result, err = toolUpdateOrgProfileReadme(ctx, params.Arguments)
|
||||
case "pin_org_repo":
|
||||
result, err = toolPinOrgRepo(ctx, params.Arguments)
|
||||
case "unpin_org_repo":
|
||||
result, err = toolUnpinOrgRepo(ctx, params.Arguments)
|
||||
default:
|
||||
sendMCPError(ctx, req.ID, -32602, "Unknown tool", params.Name)
|
||||
return
|
||||
|
||||
720
routers/api/v2/mcp_org.go
Normal file
720
routers/api/v2/mcp_org.go
Normal file
@@ -0,0 +1,720 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
"code.gitcaddy.com/server/v3/models/organization"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
"code.gitcaddy.com/server/v3/modules/optional"
|
||||
context_service "code.gitcaddy.com/server/v3/services/context"
|
||||
org_service "code.gitcaddy.com/server/v3/services/org"
|
||||
user_service "code.gitcaddy.com/server/v3/services/user"
|
||||
)
|
||||
|
||||
// mcpOrgTools defines MCP tools for organization management.
|
||||
var mcpOrgTools = []MCPTool{
|
||||
{
|
||||
Name: "list_orgs",
|
||||
Description: "List all organizations the authenticated user is a member of. Returns name, full name, description, avatar URL, website, location, group header, and visibility for each org.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_org_overview",
|
||||
Description: "Get a comprehensive overview of an organization including pinned repos, pinned groups, public members, stats (repos/members/teams/stars), profile README content, and recent repository activity.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
},
|
||||
"required": []string{"org"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_org",
|
||||
Description: "Update an organization's basic information. Only provided fields are changed. Requires org owner or site admin.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
"full_name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Display name of the organization",
|
||||
},
|
||||
"email": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Contact email address",
|
||||
},
|
||||
"description": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization description",
|
||||
},
|
||||
"website": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Website URL",
|
||||
},
|
||||
"location": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Physical location",
|
||||
},
|
||||
"group_header": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Group header for organizing this org on the explore page",
|
||||
},
|
||||
},
|
||||
"required": []string{"org"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_org_repos",
|
||||
Description: "List repositories for an organization. Supports grouping by group_header to see how repos are organized. Respects the caller's access permissions.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
"group_by": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Group results by field. Use 'group_header' to see repos grouped by their assigned group.",
|
||||
"enum": []string{"group_header"},
|
||||
},
|
||||
"q": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Search keyword to filter repos by name",
|
||||
},
|
||||
"sort": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Sort order",
|
||||
"enum": []string{"newest", "oldest", "alphabetically", "reversealphabetically", "stars", "forks", "recentupdate"},
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Number of repos to return (max 100, default 50)",
|
||||
},
|
||||
"page": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Page number (1-based)",
|
||||
},
|
||||
},
|
||||
"required": []string{"org"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_org_profile_readme",
|
||||
Description: "Get the raw markdown content of an organization's profile README from its .profile repository.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
},
|
||||
"required": []string{"org"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_org_profile_readme",
|
||||
Description: "Update (or create) the organization's profile README. Creates the .profile repository if it doesn't exist. Requires org owner or site admin.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Markdown content for the README",
|
||||
},
|
||||
"commit_message": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Git commit message (default: 'Update organization profile README')",
|
||||
},
|
||||
},
|
||||
"required": []string{"org", "content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pin_org_repo",
|
||||
Description: "Pin a repository to the organization's overview page. Optionally assign it to a pinned group. Requires org owner or site admin.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository name to pin",
|
||||
},
|
||||
"group_id": map[string]any{
|
||||
"type": "number",
|
||||
"description": "ID of the pinned group to add the repo to (0 or omit for ungrouped)",
|
||||
},
|
||||
"display_order": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Display order within the group (lower = first)",
|
||||
},
|
||||
},
|
||||
"required": []string{"org", "repo"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unpin_org_repo",
|
||||
Description: "Unpin a repository from the organization's overview page. Requires org owner or site admin.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository name to unpin",
|
||||
},
|
||||
},
|
||||
"required": []string{"org", "repo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// --- Tool Handlers ---
|
||||
|
||||
func toolListOrgs(ctx *context_service.APIContext, _ map[string]any) (any, error) {
|
||||
if ctx.Doer == nil {
|
||||
return nil, errors.New("authentication required")
|
||||
}
|
||||
|
||||
opts := organization.FindOrgOptions{
|
||||
ListOptions: db.ListOptions{PageSize: 50},
|
||||
UserID: ctx.Doer.ID,
|
||||
IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, ctx.Doer),
|
||||
}
|
||||
orgs, _, err := db.FindAndCount[organization.Organization](ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, 0, len(orgs))
|
||||
for _, org := range orgs {
|
||||
entry := map[string]any{
|
||||
"name": org.Name,
|
||||
"full_name": org.FullName,
|
||||
"description": org.Description,
|
||||
"avatar_url": org.AsUser().AvatarLink(ctx),
|
||||
"website": org.Website,
|
||||
"location": org.Location,
|
||||
"group_header": org.GroupHeader,
|
||||
"visibility": org.Visibility.String(),
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"organizations": result,
|
||||
"count": len(result),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolGetOrgOverview(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
if orgName == "" {
|
||||
return nil, errors.New("org is required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
// Stats
|
||||
stats, err := org_service.GetOrgOverviewStats(ctx, org.ID, ctx.Doer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pinned repos
|
||||
pinnedRepos, err := org_service.GetOrgPinnedReposWithDetails(ctx, org.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiPinned := make([]map[string]any, 0, len(pinnedRepos))
|
||||
for _, p := range pinnedRepos {
|
||||
if p.Repo == nil {
|
||||
continue
|
||||
}
|
||||
entry := map[string]any{
|
||||
"id": p.ID,
|
||||
"display_order": p.DisplayOrder,
|
||||
}
|
||||
if repo, ok := p.Repo.(*repo_model.Repository); ok {
|
||||
entry["repo_name"] = repo.Name
|
||||
entry["repo_full_name"] = repo.FullName()
|
||||
entry["description"] = repo.Description
|
||||
entry["is_private"] = repo.IsPrivate
|
||||
}
|
||||
if p.Group != nil {
|
||||
entry["group_id"] = p.GroupID
|
||||
entry["group_name"] = p.Group.Name
|
||||
}
|
||||
apiPinned = append(apiPinned, entry)
|
||||
}
|
||||
|
||||
// Pinned groups
|
||||
pinnedGroups, err := organization.GetOrgPinnedGroups(ctx, org.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiGroups := make([]map[string]any, 0, len(pinnedGroups))
|
||||
for _, g := range pinnedGroups {
|
||||
apiGroups = append(apiGroups, map[string]any{
|
||||
"id": g.ID,
|
||||
"name": g.Name,
|
||||
"display_order": g.DisplayOrder,
|
||||
"collapsed": g.Collapsed,
|
||||
})
|
||||
}
|
||||
|
||||
// Profile readme
|
||||
readme, _ := org_service.GetOrgProfileReadme(ctx, org.ID)
|
||||
|
||||
// Recent activity
|
||||
recentActivity, _ := org_service.GetOrgRecentActivity(ctx, org.ID, ctx.Doer, 10)
|
||||
apiRecent := make([]map[string]any, 0, len(recentActivity))
|
||||
for _, a := range recentActivity {
|
||||
apiRecent = append(apiRecent, map[string]any{
|
||||
"repo_name": a.RepoName,
|
||||
"repo_full_name": a.RepoFullName,
|
||||
"commit_message": a.CommitMessage,
|
||||
"commit_time": a.CommitTime,
|
||||
"is_private": a.IsPrivate,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"organization": map[string]any{
|
||||
"name": org.Name,
|
||||
"full_name": org.FullName,
|
||||
"description": org.Description,
|
||||
"website": org.Website,
|
||||
"location": org.Location,
|
||||
"group_header": org.GroupHeader,
|
||||
"visibility": org.Visibility.String(),
|
||||
},
|
||||
"stats": map[string]any{
|
||||
"total_repos": stats.TotalRepos,
|
||||
"total_members": stats.TotalMembers,
|
||||
"total_teams": stats.TotalTeams,
|
||||
"total_stars": stats.TotalStars,
|
||||
},
|
||||
"pinned_repos": apiPinned,
|
||||
"pinned_groups": apiGroups,
|
||||
"profile_readme": readme,
|
||||
"recent_activity": apiRecent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolUpdateOrg(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
if orgName == "" {
|
||||
return nil, errors.New("org is required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ctx.Doer == nil {
|
||||
return nil, errors.New("authentication required")
|
||||
}
|
||||
if !ctx.Doer.IsAdmin {
|
||||
isOwner, err := org.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, errors.New("organization admin access required")
|
||||
}
|
||||
}
|
||||
|
||||
opts := &user_service.UpdateOptions{}
|
||||
updated := []string{}
|
||||
|
||||
if v, ok := args["full_name"].(string); ok {
|
||||
opts.FullName = optional.Some(v)
|
||||
updated = append(updated, "full_name")
|
||||
}
|
||||
if v, ok := args["description"].(string); ok {
|
||||
opts.Description = optional.Some(v)
|
||||
updated = append(updated, "description")
|
||||
}
|
||||
if v, ok := args["website"].(string); ok {
|
||||
opts.Website = optional.Some(v)
|
||||
updated = append(updated, "website")
|
||||
}
|
||||
if v, ok := args["location"].(string); ok {
|
||||
opts.Location = optional.Some(v)
|
||||
updated = append(updated, "location")
|
||||
}
|
||||
if v, ok := args["group_header"].(string); ok {
|
||||
opts.GroupHeader = optional.Some(v)
|
||||
updated = append(updated, "group_header")
|
||||
}
|
||||
|
||||
if v, ok := args["email"].(string); ok && v != "" {
|
||||
if err := user_service.ReplacePrimaryEmailAddress(ctx, org.AsUser(), v); err != nil {
|
||||
return nil, fmt.Errorf("failed to update email: %v", err)
|
||||
}
|
||||
updated = append(updated, "email")
|
||||
}
|
||||
|
||||
if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"status": "updated",
|
||||
"org": orgName,
|
||||
"fields_updated": updated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolListOrgRepos(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
if orgName == "" {
|
||||
return nil, errors.New("org is required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||
limit = min(int(v), 100)
|
||||
}
|
||||
page := 1
|
||||
if v, ok := args["page"].(float64); ok && v > 0 {
|
||||
page = int(v)
|
||||
}
|
||||
|
||||
groupBy, _ := args["group_by"].(string)
|
||||
keyword, _ := args["q"].(string)
|
||||
sortOrder, _ := args["sort"].(string)
|
||||
|
||||
var orderBy db.SearchOrderBy
|
||||
switch sortOrder {
|
||||
case "newest":
|
||||
orderBy = db.SearchOrderByNewest
|
||||
case "oldest":
|
||||
orderBy = db.SearchOrderByOldest
|
||||
case "reversealphabetically":
|
||||
orderBy = db.SearchOrderByAlphabeticallyReverse
|
||||
case "stars":
|
||||
orderBy = db.SearchOrderByStarsReverse
|
||||
case "forks":
|
||||
orderBy = db.SearchOrderByForksReverse
|
||||
case "recentupdate":
|
||||
orderBy = db.SearchOrderByRecentUpdated
|
||||
default:
|
||||
orderBy = db.SearchOrderByAlphabetically
|
||||
}
|
||||
|
||||
if groupBy == "group_header" {
|
||||
orderBy = db.SearchOrderBy("CASE WHEN group_header = '' OR group_header IS NULL THEN 1 ELSE 0 END, group_header ASC, " + string(orderBy))
|
||||
}
|
||||
|
||||
repos, count, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{PageSize: limit, Page: page},
|
||||
Keyword: keyword,
|
||||
OwnerID: org.ID,
|
||||
OrderBy: orderBy,
|
||||
Private: ctx.Doer != nil,
|
||||
Actor: ctx.Doer,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if groupBy == "group_header" {
|
||||
// Grouped response
|
||||
type repoEntry struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
IsFork bool `json:"is_fork"`
|
||||
IsArchived bool `json:"is_archived"`
|
||||
Stars int `json:"stars"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
grouped := make(map[string][]repoEntry)
|
||||
var headers []string
|
||||
headerSeen := make(map[string]bool)
|
||||
|
||||
for _, repo := range repos {
|
||||
header := repo.GroupHeader
|
||||
if !headerSeen[header] {
|
||||
headerSeen[header] = true
|
||||
headers = append(headers, header)
|
||||
}
|
||||
grouped[header] = append(grouped[header], repoEntry{
|
||||
Name: repo.Name,
|
||||
FullName: repo.FullName(),
|
||||
Description: repo.Description,
|
||||
IsPrivate: repo.IsPrivate,
|
||||
IsFork: repo.IsFork,
|
||||
IsArchived: repo.IsArchived,
|
||||
Stars: repo.NumStars,
|
||||
Language: repoLanguage(repo),
|
||||
})
|
||||
}
|
||||
|
||||
groups := make([]map[string]any, 0, len(headers))
|
||||
for _, h := range headers {
|
||||
displayName := h
|
||||
if displayName == "" {
|
||||
displayName = "(ungrouped)"
|
||||
}
|
||||
groups = append(groups, map[string]any{
|
||||
"group_header": h,
|
||||
"display_name": displayName,
|
||||
"repos": grouped[h],
|
||||
"count": len(grouped[h]),
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"groups": groups,
|
||||
"total": count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Flat response
|
||||
repoList := make([]map[string]any, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
repoList = append(repoList, map[string]any{
|
||||
"name": repo.Name,
|
||||
"full_name": repo.FullName(),
|
||||
"description": repo.Description,
|
||||
"is_private": repo.IsPrivate,
|
||||
"is_fork": repo.IsFork,
|
||||
"is_archived": repo.IsArchived,
|
||||
"stars": repo.NumStars,
|
||||
"language": repoLanguage(repo),
|
||||
"group_header": repo.GroupHeader,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"repos": repoList,
|
||||
"total": count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolGetOrgProfileReadme(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
if orgName == "" {
|
||||
return nil, errors.New("org is required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
readme, err := org_service.GetOrgProfileReadme(ctx, org.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"org": orgName,
|
||||
"content": readme,
|
||||
"has_profile": readme != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolUpdateOrgProfileReadme(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
if orgName == "" {
|
||||
return nil, errors.New("org is required")
|
||||
}
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return nil, errors.New("content is required")
|
||||
}
|
||||
commitMessage, _ := args["commit_message"].(string)
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ctx.Doer == nil {
|
||||
return nil, errors.New("authentication required")
|
||||
}
|
||||
if !ctx.Doer.IsAdmin {
|
||||
isOwner, err := org.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, errors.New("organization admin access required")
|
||||
}
|
||||
}
|
||||
|
||||
if err := org_service.UpdateOrgProfileReadme(ctx, ctx.Doer, org.ID, content, commitMessage); err != nil {
|
||||
return nil, fmt.Errorf("failed to update profile readme: %v", err)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"status": "updated",
|
||||
"org": orgName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolPinOrgRepo(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
repoName, _ := args["repo"].(string)
|
||||
if orgName == "" || repoName == "" {
|
||||
return nil, errors.New("org and repo are required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ctx.Doer == nil {
|
||||
return nil, errors.New("authentication required")
|
||||
}
|
||||
if !ctx.Doer.IsAdmin {
|
||||
isOwner, err := org.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, errors.New("organization admin access required")
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, org.ID, repoName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository not found: %s/%s", orgName, repoName)
|
||||
}
|
||||
|
||||
isPinned, err := organization.IsRepoPinned(ctx, org.ID, repo.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isPinned {
|
||||
return map[string]any{
|
||||
"status": "already_pinned",
|
||||
"org": orgName,
|
||||
"repo": repoName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var groupID int64
|
||||
if v, ok := args["group_id"].(float64); ok {
|
||||
groupID = int64(v)
|
||||
}
|
||||
var displayOrder int
|
||||
if v, ok := args["display_order"].(float64); ok {
|
||||
displayOrder = int(v)
|
||||
}
|
||||
|
||||
pinned := &organization.OrgPinnedRepo{
|
||||
OrgID: org.ID,
|
||||
RepoID: repo.ID,
|
||||
GroupID: groupID,
|
||||
DisplayOrder: displayOrder,
|
||||
}
|
||||
|
||||
if err := organization.CreateOrgPinnedRepo(ctx, pinned); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"status": "pinned",
|
||||
"org": orgName,
|
||||
"repo": repoName,
|
||||
"id": pinned.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolUnpinOrgRepo(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
repoName, _ := args["repo"].(string)
|
||||
if orgName == "" || repoName == "" {
|
||||
return nil, errors.New("org and repo are required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ctx.Doer == nil {
|
||||
return nil, errors.New("authentication required")
|
||||
}
|
||||
if !ctx.Doer.IsAdmin {
|
||||
isOwner, err := org.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, errors.New("organization admin access required")
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, org.ID, repoName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository not found: %s/%s", orgName, repoName)
|
||||
}
|
||||
|
||||
if err := organization.DeleteOrgPinnedRepo(ctx, org.ID, repo.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"status": "unpinned",
|
||||
"org": orgName,
|
||||
"repo": repoName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func repoLanguage(repo *repo_model.Repository) string {
|
||||
if repo.PrimaryLanguage != nil {
|
||||
return repo.PrimaryLanguage.Language
|
||||
}
|
||||
return ""
|
||||
}
|
||||
794
routers/api/v2/mcp_pages.go
Normal file
794
routers/api/v2/mcp_pages.go
Normal file
@@ -0,0 +1,794 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_landing_stats",
|
||||
Description: "Update the stats counters displayed on the landing page. Each stat has a value and label.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"owner", "repo", "stats"},
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{"type": "string", "description": "Repository owner"},
|
||||
"repo": map[string]any{"type": "string", "description": "Repository name"},
|
||||
"stats": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"value": map[string]any{"type": "string"},
|
||||
"label": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
"description": "Array of stat counters (e.g., [{value: '15+', label: 'Tools'}])",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_landing_value_props",
|
||||
Description: "Update the value propositions section. Each value prop has a title, description, and icon.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"owner", "repo", "value_props"},
|
||||
"properties": map[string]any{
|
||||
"owner": map[string]any{"type": "string", "description": "Repository owner"},
|
||||
"repo": map[string]any{"type": "string", "description": "Repository name"},
|
||||
"value_props": 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"},
|
||||
},
|
||||
},
|
||||
"description": "Array of value propositions",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_landing_cta",
|
||||
Description: "Update the call-to-action section at the bottom of the page with headline, subheadline, and button.",
|
||||
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": "CTA headline"},
|
||||
"subheadline": map[string]any{"type": "string", "description": "CTA subheadline"},
|
||||
"button_label": map[string]any{"type": "string", "description": "Button text"},
|
||||
"button_url": map[string]any{"type": "string", "description": "Button URL"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── 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(_ *context.APIContext, _ map[string]any) (any, error) { //nolint:unparam // signature must match tool handler type
|
||||
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")
|
||||
}
|
||||
|
||||
func toolUpdateLandingStats(ctx *context.APIContext, args map[string]any) (any, error) {
|
||||
config, repoObj, err := getConfigForUpdate(ctx, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if stats, ok := args["stats"].([]any); ok {
|
||||
config.Stats = nil
|
||||
for _, s := range stats {
|
||||
if sm, ok := s.(map[string]any); ok {
|
||||
config.Stats = append(config.Stats, pages_module.StatConfig{
|
||||
Value: strVal(sm, "value"),
|
||||
Label: strVal(sm, "label"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return saveAndReturn(ctx, repoObj, config, "stats")
|
||||
}
|
||||
|
||||
func toolUpdateLandingValueProps(ctx *context.APIContext, args map[string]any) (any, error) {
|
||||
config, repoObj, err := getConfigForUpdate(ctx, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if vps, ok := args["value_props"].([]any); ok {
|
||||
config.ValueProps = nil
|
||||
for _, v := range vps {
|
||||
if vm, ok := v.(map[string]any); ok {
|
||||
config.ValueProps = append(config.ValueProps, pages_module.ValuePropConfig{
|
||||
Title: strVal(vm, "title"),
|
||||
Description: strVal(vm, "description"),
|
||||
Icon: strVal(vm, "icon"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return saveAndReturn(ctx, repoObj, config, "value_props")
|
||||
}
|
||||
|
||||
func toolUpdateLandingCTA(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.CTASection.Headline = v
|
||||
}
|
||||
if v, ok := args["subheadline"].(string); ok {
|
||||
config.CTASection.Subheadline = v
|
||||
}
|
||||
if v, ok := args["button_label"].(string); ok {
|
||||
config.CTASection.Button.Label = v
|
||||
}
|
||||
if v, ok := args["button_url"].(string); ok {
|
||||
config.CTASection.Button.URL = v
|
||||
}
|
||||
|
||||
return saveAndReturn(ctx, repoObj, config, "cta_section")
|
||||
}
|
||||
|
||||
// ── 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 "", "", errors.New("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
|
||||
}
|
||||
526
routers/api/v2/org_overview.go
Normal file
526
routers/api/v2/org_overview.go
Normal file
@@ -0,0 +1,526 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
"code.gitcaddy.com/server/v3/models/organization"
|
||||
"code.gitcaddy.com/server/v3/models/perm"
|
||||
access_model "code.gitcaddy.com/server/v3/models/perm/access"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
apierrors "code.gitcaddy.com/server/v3/modules/errors"
|
||||
"code.gitcaddy.com/server/v3/modules/optional"
|
||||
api "code.gitcaddy.com/server/v3/modules/structs"
|
||||
"code.gitcaddy.com/server/v3/modules/web"
|
||||
"code.gitcaddy.com/server/v3/services/context"
|
||||
"code.gitcaddy.com/server/v3/services/convert"
|
||||
org_service "code.gitcaddy.com/server/v3/services/org"
|
||||
user_service "code.gitcaddy.com/server/v3/services/user"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// loadOrg looks up the org from the path param and checks it exists.
|
||||
func loadOrg(ctx *context.APIContext) *organization.Organization {
|
||||
orgName := ctx.PathParam("org")
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
if organization.IsErrOrgNotExist(err) {
|
||||
ctx.APIErrorWithCode(apierrors.OrgNotFound)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return org
|
||||
}
|
||||
|
||||
// requireOrgOwner checks the doer is an org owner or site admin. Returns false if denied.
|
||||
func requireOrgOwner(ctx *context.APIContext, org *organization.Organization) bool {
|
||||
if ctx.Doer.IsAdmin {
|
||||
return true
|
||||
}
|
||||
isOwner, err := org.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return false
|
||||
}
|
||||
if !isOwner {
|
||||
ctx.APIErrorWithCode(apierrors.PermOrgAdminRequired)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --- List user's orgs ---
|
||||
|
||||
// ListUserOrgsV2 returns all organizations the authenticated user belongs to.
|
||||
func ListUserOrgsV2(ctx *context.APIContext) {
|
||||
opts := organization.FindOrgOptions{
|
||||
ListOptions: db.ListOptions{PageSize: 50},
|
||||
UserID: ctx.Doer.ID,
|
||||
IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, ctx.Doer),
|
||||
}
|
||||
orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiOrgs := make([]*api.Organization, len(orgs))
|
||||
for i := range orgs {
|
||||
apiOrgs[i] = convert.ToOrganization(ctx, orgs[i])
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(maxResults)
|
||||
ctx.JSON(http.StatusOK, apiOrgs)
|
||||
}
|
||||
|
||||
// --- Org Overview ---
|
||||
|
||||
// GetOrgOverviewV2 returns the full organization overview including profile readme and recent activity.
|
||||
func GetOrgOverviewV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Pinned repos
|
||||
pinnedRepos, err := org_service.GetOrgPinnedReposWithDetails(ctx, org.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Pinned groups
|
||||
pinnedGroups, err := organization.GetOrgPinnedGroups(ctx, org.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Public members
|
||||
publicMembers, totalMembers, err := organization.GetPublicOrgMembers(ctx, org.ID, 12)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Stats
|
||||
stats, err := org_service.GetOrgOverviewStats(ctx, org.ID, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Profile readme
|
||||
readme, _ := org_service.GetOrgProfileReadme(ctx, org.ID)
|
||||
|
||||
// Recent activity
|
||||
recentActivity, _ := org_service.GetOrgRecentActivity(ctx, org.ID, ctx.Doer, 10)
|
||||
|
||||
// Convert pinned repos
|
||||
apiPinnedRepos := make([]*api.OrgPinnedRepo, 0, len(pinnedRepos))
|
||||
for _, p := range pinnedRepos {
|
||||
if p.Repo == nil {
|
||||
continue
|
||||
}
|
||||
apiPinnedRepos = append(apiPinnedRepos, convertOrgPinnedRepoV2(ctx, p))
|
||||
}
|
||||
|
||||
apiPinnedGroups := make([]*api.OrgPinnedGroup, 0, len(pinnedGroups))
|
||||
for _, g := range pinnedGroups {
|
||||
apiPinnedGroups = append(apiPinnedGroups, convertOrgPinnedGroupV2(g))
|
||||
}
|
||||
|
||||
apiPublicMembers := make([]*api.OrgPublicMember, 0, len(publicMembers))
|
||||
for _, m := range publicMembers {
|
||||
apiPublicMembers = append(apiPublicMembers, &api.OrgPublicMember{
|
||||
User: convert.ToUser(ctx, m.User, ctx.Doer),
|
||||
Role: m.Role,
|
||||
})
|
||||
}
|
||||
|
||||
// Build profile content
|
||||
var profile *api.OrgProfileContent
|
||||
if readme != "" {
|
||||
profile = &api.OrgProfileContent{
|
||||
HasProfile: true,
|
||||
Readme: readme,
|
||||
}
|
||||
}
|
||||
|
||||
// Build recent activity
|
||||
var apiRecentActivity []api.OrgRecentActivity
|
||||
if len(recentActivity) > 0 {
|
||||
apiRecentActivity = make([]api.OrgRecentActivity, 0, len(recentActivity))
|
||||
for _, a := range recentActivity {
|
||||
apiRecentActivity = append(apiRecentActivity, api.OrgRecentActivity{
|
||||
RepoName: a.RepoName,
|
||||
RepoFullName: a.RepoFullName,
|
||||
DefaultBranch: a.DefaultBranch,
|
||||
CommitMessage: a.CommitMessage,
|
||||
CommitTime: a.CommitTime,
|
||||
IsPrivate: a.IsPrivate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
overview := &api.OrgOverviewV2{
|
||||
Organization: convert.ToOrganization(ctx, org),
|
||||
PinnedRepos: apiPinnedRepos,
|
||||
PinnedGroups: apiPinnedGroups,
|
||||
PublicMembers: apiPublicMembers,
|
||||
TotalMembers: totalMembers,
|
||||
Stats: &api.OrgOverviewStats{
|
||||
TotalRepos: stats.TotalRepos,
|
||||
TotalMembers: stats.TotalMembers,
|
||||
TotalTeams: stats.TotalTeams,
|
||||
TotalStars: stats.TotalStars,
|
||||
},
|
||||
Profile: profile,
|
||||
RecentActivity: apiRecentActivity,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, overview)
|
||||
}
|
||||
|
||||
// --- Update Org ---
|
||||
|
||||
// UpdateOrgV2Option represents the fields that can be updated on an org.
|
||||
type UpdateOrgV2Option struct {
|
||||
FullName *string `json:"full_name"`
|
||||
Email *string `json:"email"`
|
||||
Description *string `json:"description"`
|
||||
Website *string `json:"website"`
|
||||
Location *string `json:"location"`
|
||||
GroupHeader *string `json:"group_header"`
|
||||
}
|
||||
|
||||
// UpdateOrgV2 updates an organization's basic information.
|
||||
func UpdateOrgV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
if !requireOrgOwner(ctx, org) {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*UpdateOrgV2Option)
|
||||
|
||||
opts := &user_service.UpdateOptions{}
|
||||
if form.FullName != nil {
|
||||
opts.FullName = optional.Some(*form.FullName)
|
||||
}
|
||||
if form.Description != nil {
|
||||
opts.Description = optional.Some(*form.Description)
|
||||
}
|
||||
if form.Website != nil {
|
||||
opts.Website = optional.Some(*form.Website)
|
||||
}
|
||||
if form.Location != nil {
|
||||
opts.Location = optional.Some(*form.Location)
|
||||
}
|
||||
if form.GroupHeader != nil {
|
||||
opts.GroupHeader = optional.Some(*form.GroupHeader)
|
||||
}
|
||||
|
||||
if form.Email != nil && *form.Email != "" {
|
||||
if err := user_service.ReplacePrimaryEmailAddress(ctx, org.AsUser(), *form.Email); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Reload to get updated fields
|
||||
org, err := organization.GetOrgByName(ctx, org.Name)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToOrganization(ctx, org))
|
||||
}
|
||||
|
||||
// --- List Org Repos ---
|
||||
|
||||
// ListOrgReposV2 lists all repos for an org, with optional group_header grouping.
|
||||
func ListOrgReposV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
|
||||
page := ctx.FormInt("page")
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
limit := ctx.FormInt("limit")
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
groupBy := ctx.FormString("group_by")
|
||||
keyword := ctx.FormTrim("q")
|
||||
sortOrder := ctx.FormString("sort")
|
||||
|
||||
var orderBy db.SearchOrderBy
|
||||
switch sortOrder {
|
||||
case "newest":
|
||||
orderBy = db.SearchOrderByNewest
|
||||
case "oldest":
|
||||
orderBy = db.SearchOrderByOldest
|
||||
case "reversealphabetically":
|
||||
orderBy = db.SearchOrderByAlphabeticallyReverse
|
||||
case "stars":
|
||||
orderBy = db.SearchOrderByStarsReverse
|
||||
case "forks":
|
||||
orderBy = db.SearchOrderByForksReverse
|
||||
case "recentupdate":
|
||||
orderBy = db.SearchOrderByRecentUpdated
|
||||
default:
|
||||
orderBy = db.SearchOrderByAlphabetically
|
||||
}
|
||||
|
||||
// When grouping by group_header, prepend it to the sort
|
||||
if groupBy == "group_header" {
|
||||
orderBy = db.SearchOrderBy("CASE WHEN group_header = '' OR group_header IS NULL THEN 1 ELSE 0 END, group_header ASC, " + string(orderBy))
|
||||
}
|
||||
|
||||
repos, count, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{PageSize: limit, Page: page},
|
||||
Keyword: keyword,
|
||||
OwnerID: org.ID,
|
||||
OrderBy: orderBy,
|
||||
Private: ctx.IsSigned,
|
||||
Actor: ctx.Doer,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiRepos := make([]*api.Repository, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
apiRepos = append(apiRepos, convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeRead}))
|
||||
}
|
||||
|
||||
if groupBy == "group_header" {
|
||||
// Return grouped response
|
||||
type groupedEntry struct {
|
||||
GroupHeader string `json:"group_header"`
|
||||
Repos []*api.Repository `json:"repos"`
|
||||
}
|
||||
grouped := make(map[string][]*api.Repository)
|
||||
var headers []string
|
||||
headerSeen := make(map[string]bool)
|
||||
|
||||
for i, repo := range repos {
|
||||
header := repo.GroupHeader
|
||||
if !headerSeen[header] {
|
||||
headerSeen[header] = true
|
||||
headers = append(headers, header)
|
||||
}
|
||||
grouped[header] = append(grouped[header], apiRepos[i])
|
||||
}
|
||||
|
||||
groups := make([]groupedEntry, 0, len(headers))
|
||||
for _, h := range headers {
|
||||
groups = append(groups, groupedEntry{
|
||||
GroupHeader: h,
|
||||
Repos: grouped[h],
|
||||
})
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"groups": groups,
|
||||
"total": count,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiRepos)
|
||||
}
|
||||
|
||||
// --- Profile Readme ---
|
||||
|
||||
// GetOrgProfileReadmeV2 returns the raw README content from the .profile repo.
|
||||
func GetOrgProfileReadmeV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
|
||||
readme, err := org_service.GetOrgProfileReadme(ctx, org.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"content": readme,
|
||||
"has_profile": readme != "",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateOrgProfileReadmeOption represents a request to update the profile readme.
|
||||
type UpdateOrgProfileReadmeOption struct {
|
||||
Content string `json:"content" binding:"Required"`
|
||||
CommitMessage string `json:"commit_message"`
|
||||
}
|
||||
|
||||
// UpdateOrgProfileReadmeV2 updates the README.md in the .profile repo.
|
||||
func UpdateOrgProfileReadmeV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
if !requireOrgOwner(ctx, org) {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*UpdateOrgProfileReadmeOption)
|
||||
|
||||
if err := org_service.UpdateOrgProfileReadme(ctx, ctx.Doer, org.ID, form.Content, form.CommitMessage); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// --- Pinned Repos ---
|
||||
|
||||
// PinOrgRepoV2Option represents a request to pin a repo.
|
||||
type PinOrgRepoV2Option struct {
|
||||
RepoName string `json:"repo_name" binding:"Required"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
}
|
||||
|
||||
// PinOrgRepoV2 pins a repository to the org overview.
|
||||
func PinOrgRepoV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
if !requireOrgOwner(ctx, org) {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*PinOrgRepoV2Option)
|
||||
|
||||
// Look up the repo
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, org.ID, form.RepoName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, "Repository not found: "+form.RepoName)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already pinned
|
||||
isPinned, err := organization.IsRepoPinned(ctx, org.ID, repo.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if isPinned {
|
||||
ctx.JSON(http.StatusOK, map[string]any{"status": "already_pinned"})
|
||||
return
|
||||
}
|
||||
|
||||
pinned := &organization.OrgPinnedRepo{
|
||||
OrgID: org.ID,
|
||||
RepoID: repo.ID,
|
||||
GroupID: form.GroupID,
|
||||
DisplayOrder: form.DisplayOrder,
|
||||
}
|
||||
|
||||
if err := organization.CreateOrgPinnedRepo(ctx, pinned); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, map[string]any{
|
||||
"id": pinned.ID,
|
||||
"repo_id": repo.ID,
|
||||
"status": "pinned",
|
||||
})
|
||||
}
|
||||
|
||||
// UnpinOrgRepoV2 unpins a repository from the org overview.
|
||||
func UnpinOrgRepoV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
if !requireOrgOwner(ctx, org) {
|
||||
return
|
||||
}
|
||||
|
||||
repoName := ctx.PathParam("repo")
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, org.ID, repoName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, "Repository not found: "+repoName)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := organization.DeleteOrgPinnedRepo(ctx, org.ID, repo.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- Converters ---
|
||||
|
||||
func convertOrgPinnedRepoV2(ctx *context.APIContext, p *organization.OrgPinnedRepo) *api.OrgPinnedRepo {
|
||||
result := &api.OrgPinnedRepo{
|
||||
ID: p.ID,
|
||||
RepoID: p.RepoID,
|
||||
GroupID: p.GroupID,
|
||||
DisplayOrder: p.DisplayOrder,
|
||||
}
|
||||
|
||||
if p.Repo != nil {
|
||||
if repo, ok := p.Repo.(*repo_model.Repository); ok {
|
||||
result.Repo = convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeRead})
|
||||
}
|
||||
}
|
||||
|
||||
if p.Group != nil {
|
||||
result.Group = convertOrgPinnedGroupV2(p.Group)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func convertOrgPinnedGroupV2(g *organization.OrgPinnedGroup) *api.OrgPinnedGroup {
|
||||
return &api.OrgPinnedGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
DisplayOrder: g.DisplayOrder,
|
||||
Collapsed: g.Collapsed,
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,7 @@ func CheckAppUpdate(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
latestRelease.Repo = repo
|
||||
|
||||
// Find the appropriate asset for this platform/arch
|
||||
downloadURL, platformInfo := findUpdateAsset(latestRelease, platform, arch)
|
||||
@@ -346,6 +347,7 @@ func ListReleasesV2(ctx *context.APIContext) {
|
||||
// Convert to API format
|
||||
apiReleases := make([]*api.Release, 0, len(releases))
|
||||
for _, release := range releases {
|
||||
release.Repo = repo
|
||||
apiReleases = append(apiReleases, convertToAPIRelease(repo, release))
|
||||
}
|
||||
|
||||
@@ -388,6 +390,7 @@ func GetReleaseV2(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
release.Repo = repo
|
||||
ctx.JSON(http.StatusOK, convertToAPIRelease(repo, release))
|
||||
}
|
||||
|
||||
@@ -436,6 +439,7 @@ func GetLatestReleaseV2(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
release.Repo = repo
|
||||
ctx.JSON(http.StatusOK, convertToAPIRelease(repo, release))
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,12 @@ func RenderUserSearch(ctx *context.Context, opts user_model.SearchUserOptions, t
|
||||
}
|
||||
|
||||
opts.Keyword = ctx.FormTrim("q")
|
||||
opts.OrderBy = orderBy
|
||||
// When grouping is enabled, order by group first so groups aren't split across pages
|
||||
if ctx.Data["PageIsExploreOrganizations"] == true && ctx.Data["ShowGrouping"] == true {
|
||||
opts.OrderBy = "CASE WHEN `user`.group_header = '' OR `user`.group_header IS NULL THEN 1 ELSE 0 END, `user`.group_header ASC, " + orderBy
|
||||
} else {
|
||||
opts.OrderBy = orderBy
|
||||
}
|
||||
if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) {
|
||||
users, count, err = user_model.SearchUsers(ctx, opts)
|
||||
if err != nil {
|
||||
|
||||
@@ -479,6 +479,12 @@ func PagesContentPost(ctx *context.Context) {
|
||||
config.Advanced.HideMobileReleases = ctx.FormBool("hide_mobile_releases")
|
||||
config.Advanced.GooglePlayID = strings.TrimSpace(ctx.FormString("google_play_id"))
|
||||
config.Advanced.AppStoreID = strings.TrimSpace(ctx.FormString("app_store_id"))
|
||||
if v := ctx.FormString("label_value_props"); v != "" {
|
||||
config.Navigation.LabelValueProps = v
|
||||
}
|
||||
if v := ctx.FormString("label_features"); v != "" {
|
||||
config.Navigation.LabelFeatures = v
|
||||
}
|
||||
config.Navigation.ShowDocs = ctx.FormBool("nav_show_docs")
|
||||
config.Navigation.ShowAPI = ctx.FormBool("nav_show_api")
|
||||
config.Navigation.ShowRepository = ctx.FormBool("nav_show_repository")
|
||||
@@ -1533,10 +1539,6 @@ func PagesAdvancedPost(ctx *context.Context) {
|
||||
// Parse remaining fields
|
||||
config.Advanced.CustomCSS = ctx.FormString("custom_css")
|
||||
config.Advanced.CustomHead = ctx.FormString("custom_head")
|
||||
config.Advanced.GooglePlayID = ctx.FormString("google_play_id")
|
||||
config.Advanced.AppStoreID = ctx.FormString("app_store_id")
|
||||
config.Advanced.PublicReleases = ctx.FormBool("public_releases")
|
||||
config.Advanced.HideMobileReleases = ctx.FormBool("hide_mobile_releases")
|
||||
|
||||
if err := savePagesLandingConfig(ctx, config); err != nil {
|
||||
ctx.ServerError("SavePagesConfig", err)
|
||||
|
||||
218
services/org/profile.go
Normal file
218
services/org/profile.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
user_model "code.gitcaddy.com/server/v3/models/user"
|
||||
"code.gitcaddy.com/server/v3/modules/git"
|
||||
repo_service "code.gitcaddy.com/server/v3/services/repository"
|
||||
files_service "code.gitcaddy.com/server/v3/services/repository/files"
|
||||
)
|
||||
|
||||
// GetOrgProfileReadme reads the README.md from the org's .profile repository.
|
||||
// Returns empty string if the .profile repo doesn't exist or has no README.
|
||||
func GetOrgProfileReadme(ctx context.Context, orgID int64) (string, error) {
|
||||
profileRepo, err := repo_model.GetRepositoryByName(ctx, orgID, ".profile")
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
if profileRepo.IsEmpty {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
gitRepo, err := git.OpenRepository(ctx, profileRepo.RepoPath())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(profileRepo.DefaultBranch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Try common README filenames
|
||||
readmeFiles := []string{"README.md", "readme.md", "Readme.md", "README", "README.txt"}
|
||||
for _, filename := range readmeFiles {
|
||||
entry, err := commit.GetTreeEntryByPath(filename)
|
||||
if err == nil && !entry.IsDir() {
|
||||
content, err := entry.Blob().GetBlobContent(1024 * 512) // 512KB max
|
||||
if err == nil {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// UpdateOrgProfileReadme updates (or creates) the README.md in the org's .profile repository.
|
||||
// If the .profile repo doesn't exist, it is created first.
|
||||
func UpdateOrgProfileReadme(ctx context.Context, doer *user_model.User, orgID int64, content, commitMessage string) error {
|
||||
// Look up the org as a user (needed for repo operations)
|
||||
orgUser, err := user_model.GetUserByID(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileRepo, err := repo_model.GetRepositoryByName(ctx, orgID, ".profile")
|
||||
if err != nil {
|
||||
if !repo_model.IsErrRepoNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create .profile repo
|
||||
profileRepo, err = repo_service.CreateRepository(ctx, doer, orgUser, repo_service.CreateRepoOptions{
|
||||
Name: ".profile",
|
||||
Description: "Organization profile",
|
||||
AutoInit: true,
|
||||
Readme: "Default",
|
||||
DefaultBranch: "main",
|
||||
IsPrivate: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if commitMessage == "" {
|
||||
commitMessage = "Update organization profile README"
|
||||
}
|
||||
|
||||
// Determine operation: create or update
|
||||
operation := "update"
|
||||
var existingSHA string
|
||||
|
||||
if !profileRepo.IsEmpty {
|
||||
gitRepo, err := git.OpenRepository(ctx, profileRepo.RepoPath())
|
||||
if err == nil {
|
||||
commit, err := gitRepo.GetBranchCommit(profileRepo.DefaultBranch)
|
||||
if err == nil {
|
||||
entry, err := commit.GetTreeEntryByPath("README.md")
|
||||
if err != nil {
|
||||
operation = "create"
|
||||
} else {
|
||||
existingSHA = entry.ID.String()
|
||||
}
|
||||
}
|
||||
gitRepo.Close()
|
||||
}
|
||||
} else {
|
||||
operation = "create"
|
||||
}
|
||||
|
||||
_, err = files_service.ChangeRepoFiles(ctx, profileRepo, doer, &files_service.ChangeRepoFilesOptions{
|
||||
OldBranch: profileRepo.DefaultBranch,
|
||||
NewBranch: profileRepo.DefaultBranch,
|
||||
Message: commitMessage,
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: operation,
|
||||
TreePath: "README.md",
|
||||
ContentReader: strings.NewReader(content),
|
||||
SHA: existingSHA,
|
||||
},
|
||||
},
|
||||
Author: &files_service.IdentityOptions{
|
||||
GitUserName: doer.Name,
|
||||
GitUserEmail: doer.Email,
|
||||
},
|
||||
})
|
||||
|
||||
// If update failed because file doesn't exist, try create
|
||||
if err != nil && operation == "update" {
|
||||
_, err = files_service.ChangeRepoFiles(ctx, profileRepo, doer, &files_service.ChangeRepoFilesOptions{
|
||||
OldBranch: profileRepo.DefaultBranch,
|
||||
NewBranch: profileRepo.DefaultBranch,
|
||||
Message: commitMessage,
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: "README.md",
|
||||
ContentReader: strings.NewReader(content),
|
||||
},
|
||||
},
|
||||
Author: &files_service.IdentityOptions{
|
||||
GitUserName: doer.Name,
|
||||
GitUserEmail: doer.Email,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOrgProfileRepo returns the .profile repository for an org, or nil if it doesn't exist.
|
||||
func GetOrgProfileRepo(ctx context.Context, orgID int64) (*repo_model.Repository, error) {
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, orgID, ".profile")
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// GetOrgRecentActivity returns the N most recently updated repos for an org
|
||||
// with their latest commit info.
|
||||
type RecentRepoActivity struct {
|
||||
RepoName string `json:"repo_name"`
|
||||
RepoFullName string `json:"repo_full_name"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
CommitMessage string `json:"commit_message,omitempty"`
|
||||
CommitTime int64 `json:"commit_time,omitempty"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
}
|
||||
|
||||
func GetOrgRecentActivity(ctx context.Context, orgID int64, actor *user_model.User, limit int) ([]*RecentRepoActivity, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
showPrivate := actor != nil
|
||||
repos, _, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{PageSize: limit, Page: 1},
|
||||
OwnerID: orgID,
|
||||
OrderBy: db.SearchOrderByRecentUpdated,
|
||||
Private: showPrivate,
|
||||
Actor: actor,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*RecentRepoActivity, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
activity := &RecentRepoActivity{
|
||||
RepoName: repo.Name,
|
||||
RepoFullName: repo.FullName(),
|
||||
DefaultBranch: repo.DefaultBranch,
|
||||
IsPrivate: repo.IsPrivate,
|
||||
}
|
||||
|
||||
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||
if err == nil {
|
||||
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||
if err == nil {
|
||||
activity.CommitMessage = commit.Summary()
|
||||
activity.CommitTime = commit.Author.When.Unix()
|
||||
}
|
||||
gitRepo.Close()
|
||||
}
|
||||
|
||||
result = append(result, activity)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<details class="item toggleable-item" {{if or .PageIsSettingsPagesGeneral .PageIsSettingsPagesBrand .PageIsSettingsPagesHero .PageIsSettingsPagesContent .PageIsSettingsPagesComparison .PageIsSettingsPagesSocial .PageIsSettingsPagesPricing .PageIsSettingsPagesFooter .PageIsSettingsPagesTheme .PageIsSettingsPagesLanguages}}open{{end}}>
|
||||
<details class="item toggleable-item" {{if or .PageIsSettingsPagesGeneral .PageIsSettingsPagesBrand .PageIsSettingsPagesHero .PageIsSettingsPagesContent .PageIsSettingsPagesComparison .PageIsSettingsPagesSocial .PageIsSettingsPagesPricing .PageIsSettingsPagesFooter .PageIsSettingsPagesTheme .PageIsSettingsPagesLanguages .PageIsSettingsPagesAdvanced}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "repo.settings.pages"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsSettingsPagesGeneral}}active {{end}}item" href="{{.RepoLink}}/settings/pages">
|
||||
@@ -86,6 +86,9 @@
|
||||
<a class="{{if .PageIsSettingsPagesLanguages}}active {{end}}item" href="{{.RepoLink}}/settings/pages/languages">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.languages"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesAdvanced}}active {{end}}item" href="{{.RepoLink}}/settings/pages/advanced">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.advanced"}}
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsActionsSettingsGeneral}}open{{end}}>
|
||||
|
||||
@@ -57,33 +57,6 @@
|
||||
<textarea name="custom_head" rows="4" placeholder="<meta ...>">{{.Config.Advanced.CustomHead}}</textarea>
|
||||
</div>
|
||||
|
||||
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.app_stores"}}</h5>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>Google Play ID</label>
|
||||
<input name="google_play_id" value="{{.Config.Advanced.GooglePlayID}}" placeholder="com.example.app">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>App Store ID</label>
|
||||
<input name="app_store_id" value="{{.Config.Advanced.AppStoreID}}" placeholder="123456789">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inline fields">
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="public_releases" {{if .Config.Advanced.PublicReleases}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.public_releases"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="hide_mobile_releases" {{if .Config.Advanced.HideMobileReleases}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.hide_mobile_releases"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
</div>
|
||||
<div class="ui horizontal divider">{{ctx.Locale.Tr "repo.settings.pages.brand_or"}}</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.brand_logo_url"}}</label>
|
||||
<input name="brand_logo_url" value="{{.Config.Brand.LogoURL}}" placeholder="https://example.com/logo.svg">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.brand_logo_url_help"}}</p>
|
||||
</div>
|
||||
@@ -68,6 +69,7 @@
|
||||
</div>
|
||||
<div class="ui horizontal divider">{{ctx.Locale.Tr "repo.settings.pages.brand_or"}}</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.brand_favicon_url"}}</label>
|
||||
<input name="brand_favicon_url" value="{{.Config.Brand.FaviconURL}}" placeholder="https://example.com/favicon.ico">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.brand_favicon_url_help"}}</p>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.section_labels"}}</h5>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.section_labels_desc"}}</p>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.label_value_props"}}</label>
|
||||
<input name="label_value_props" value="{{.Config.Navigation.LabelValueProps}}" placeholder="Why choose us">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.label_value_props_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.label_features"}}</label>
|
||||
<input name="label_features" value="{{.Config.Navigation.LabelFeatures}}" placeholder="Capabilities">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.label_features_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.public_releases"}}</h5>
|
||||
<div class="inline field">
|
||||
<div class="ui toggle checkbox">
|
||||
|
||||
@@ -35,9 +35,9 @@
|
||||
</div>
|
||||
<div class="ui horizontal divider">{{ctx.Locale.Tr "repo.settings.pages.hero_or"}}</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.image_url"}}</label>
|
||||
<input name="image_url" value="{{.Config.Hero.ImageURL}}" placeholder="https://example.com/hero.png">
|
||||
</div>
|
||||
<br>
|
||||
{{end}}
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
{{if .PagesEnabled}}
|
||||
<div class="ui secondary pointing menu tw-mb-4">
|
||||
<a class="{{if .PageIsSettingsPagesGeneral}}active {{end}}item" href="{{.RepoLink}}/settings/pages">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.general"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesBrand}}active {{end}}item" href="{{.RepoLink}}/settings/pages/brand">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.brand"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesHero}}active {{end}}item" href="{{.RepoLink}}/settings/pages/hero">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.hero"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesContent}}active {{end}}item" href="{{.RepoLink}}/settings/pages/content">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.content"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesComparison}}active {{end}}item" href="{{.RepoLink}}/settings/pages/comparison">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.comparison"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesSocial}}active {{end}}item" href="{{.RepoLink}}/settings/pages/social">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.social"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesPricing}}active {{end}}item" href="{{.RepoLink}}/settings/pages/pricing">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.pricing"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesFooter}}active {{end}}item" href="{{.RepoLink}}/settings/pages/footer">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.footer"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesTheme}}active {{end}}item" href="{{.RepoLink}}/settings/pages/theme">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.theme"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesLanguages}}active {{end}}item" href="{{.RepoLink}}/settings/pages/languages">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.languages"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesAdvanced}}active {{end}}item" href="{{.RepoLink}}/settings/pages/advanced">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.advanced"}}
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user