2
0

12 Commits

Author SHA1 Message Date
4fabef6a65 feat(api): add organization management API v2 and MCP tools
All checks were successful
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 3m59s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 8m32s
Build and Release / Lint (push) Successful in 9m40s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Adds comprehensive v2 API endpoints for organization management:
- GET /api/v2/user/orgs - list user's organizations
- GET /api/v2/orgs/{org}/overview - org overview with pinned repos, members, stats, profile, recent activity
- GET /api/v2/orgs/{org}/repos - list org repositories with grouping support
- GET /api/v2/orgs/{org}/profile/readme - get org profile README
- PATCH /api/v2/orgs/{org} - update org metadata (requires owner/admin)
- PUT /api/v2/orgs/{org}/profile/readme - update profile README
- POST /api/v2/orgs/{org}/pinned - pin repository
- DELETE /api/v2/orgs/{org}/pinned/{repo} - unpin repository

Adds 8 MCP tools for AI assistant access: list_orgs, get_org_overview, update_org, list_org_repos, get_org_profile_readme, update_org_profile_readme, pin_org_repo, unpin_org_repo.

Introduces OrgOverviewV2 and OrgRecentActivity structs. Adds org/profile service for README and pinning operations.
2026-04-19 17:32:06 -04:00
f26bd3e273 fix(explore): prevent group splitting across pages in org explorer
When organization grouping is enabled, modifies sort order to group by group_header first before applying user-selected ordering. This prevents organizations in the same group from being split across pagination boundaries.

Adds CASE expression to sort ungrouped orgs (null/empty group_header) last, then groups alphabetically, then applies the requested orderBy within each group.
2026-04-19 17:16:14 -04:00
c5daac3366 feat(mcp): add stats, value props, and CTA tools for landing pages
All checks were successful
Build and Release / Lint (push) Successful in 5m21s
Build and Release / Create Release (push) Successful in 0s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 4m21s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 4m32s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 12m1s
Build and Release / Build Binary (linux/arm64) (push) Successful in 7m55s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h7m31s
Build and Release / Unit Tests (push) Successful in 14m12s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 12m51s
Implements three new MCP tools for landing page management: update_landing_stats for stat counters, update_landing_value_props for value proposition cards, and update_landing_cta for bottom call-to-action section. Each tool supports structured data with validation and integrates with existing config save flow.
2026-04-05 12:49:19 -04:00
916211004d docs(mcp): add explanation to nolint directive
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 3m21s
Build and Release / Unit Tests (push) Successful in 15m52s
Build and Release / Lint (push) Successful in 16m53s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 4m18s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 4m49s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 8m12s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h7m30s
Build and Release / Build Binary (linux/arm64) (push) Successful in 7m19s
Clarifies why unparam is suppressed on toolListLandingTemplates with inline comment explaining interface requirement.
2026-04-05 09:09:01 -04:00
02fdc1a194 chore(mcp): suppress unparam linter warning for toolListLandingTemplates
Some checks failed
Build and Release / Create Release (push) Has been skipped
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m27s
Build and Release / Unit Tests (push) Successful in 5m15s
Build and Release / Lint (push) Failing after 10m56s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
Adds nolint:unparam directive to toolListLandingTemplates. Function signature must match tool handler interface even though parameters are unused.
2026-04-05 08:45:31 -04:00
1b0bba09b9 style(mcp): use errors.New for static error messages
Some checks failed
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 3m19s
Build and Release / Create Release (push) Successful in 0s
Build and Release / Lint (push) Failing after 11m49s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 12m52s
Replaces fmt.Errorf with errors.New for static error message in resolveOwnerRepo. Marks unused context and args parameters with underscore in toolListLandingTemplates.
2026-04-05 04:03:02 -04:00
0c0d1c1493 feat(mcp): add landing page management tools to MCP server
Some checks failed
Build and Release / Create Release (push) Successful in 1s
Build and Release / Unit Tests (push) Successful in 3m42s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 9m25s
Build and Release / Lint (push) Failing after 10m12s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
Creates mcp_pages.go with 10 new tools for managing repository landing pages via Claude Code. Supports getting/updating brand, hero, pricing, comparison, features, social proof, SEO, and theme sections. Includes template listing and enable/disable functionality. Integrates with existing pages service and registers tools in handleToolsList and handleToolsCall.
2026-04-05 03:39:54 -04:00
9461599b57 fix(api): populate Repo field in release API responses
All checks were successful
Build and Release / Unit Tests (push) Successful in 7m8s
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 7m33s
Build and Release / Lint (push) Successful in 7m50s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 4m31s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 4m39s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h5m34s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 12m17s
Build and Release / Build Binary (linux/arm64) (push) Successful in 33m20s
Set release.Repo before converting to API format in all v2 release endpoints (CheckAppUpdate, ListReleasesV2, GetReleaseV2, GetLatestReleaseV2). Ensures repository information is available in API responses
2026-04-03 23:57:29 -04:00
414560f470 fix(ci): isolate GOMODCACHE per job to prevent cache conflicts
Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 3m1s
Build and Release / Unit Tests (push) Successful in 22m31s
Build and Release / Lint (push) Successful in 24m45s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 5m37s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 5m40s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h7m8s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 13m32s
Build and Release / Build Binary (linux/arm64) (push) Failing after 30m51s
Move GOMODCACHE from global env to job-level env with unique paths per job. Prevents race conditions and cache corruption when jobs run in parallel, especially after the gotextdiff mirror replacement.
2026-03-30 09:47:38 -04:00
b43345986a refactor(pages): remove unused app store fields from advanced settings
Some checks failed
Build and Release / Unit Tests (push) Successful in 6m20s
Build and Release / Create Release (push) Successful in 0s
Build and Release / Lint (push) Successful in 6m31s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Failing after 9h1m42s
Build and Release / Integration Tests (PostgreSQL) (push) Has been cancelled
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been cancelled
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been cancelled
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been cancelled
Build and Release / Build Binary (linux/arm64) (push) Has been cancelled
2026-03-30 09:32:58 -04:00
7fbbd26b20 refactor(ui): consolidate pages navigation into main settings navbar
Some checks failed
Build and Release / Unit Tests (push) Successful in 7m33s
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 7m40s
Build and Release / Lint (push) Successful in 7m57s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Failing after 9h1m36s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 7m8s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m21s
Build and Release / Build Binary (linux/arm64) (push) Successful in 7m37s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 20m50s
Remove redundant pages_nav.tmpl and integrate the advanced pages link directly into the main settings navbar. Reduces template duplication while maintaining the same navigation structure.
2026-03-30 03:23:26 -04:00
b26bf4bfe8 fix(ci): replace deleted gotextdiff dependency with mirror
Some checks failed
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Failing after 1m41s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 3m32s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been cancelled
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been cancelled
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been cancelled
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been cancelled
Build and Release / Build Binary (linux/arm64) (push) Has been cancelled
Build and Release / Lint (push) Has been cancelled
The upstream github.com/hexops/gotextdiff repository was deleted. Replace with internal mirror and add go clean -modcache to all CI dependency installation steps to ensure clean builds with the new module path.
2026-03-30 03:12:19 -04:00
16 changed files with 2382 additions and 77 deletions

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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"`
}

View File

@@ -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)

View File

@@ -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
View 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
View 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
}

View 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,
}
}

View File

@@ -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))
}

View File

@@ -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 {

View File

@@ -1533,10 +1533,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
View 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
}

View File

@@ -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}}>

View File

@@ -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>

View File

@@ -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}}