2
0

2 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
7 changed files with 1531 additions and 2 deletions

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,10 +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)+len(mcpPagesTools))
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)
}
@@ -789,6 +790,23 @@ func handleToolsCall(ctx *context_service.APIContext, req *MCPRequest) {
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 ""
}

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

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

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
}