Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fabef6a65 | |||
| f26bd3e273 |
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -248,6 +248,26 @@ func Routes() *web.Router {
|
||||
}, reqToken())
|
||||
}, repoAssignment())
|
||||
|
||||
// Organization v2 API - org overview, repos, profile, pinned repos
|
||||
m.Group("/user/orgs", func() {
|
||||
m.Get("", ListUserOrgsV2)
|
||||
}, reqToken())
|
||||
|
||||
m.Group("/orgs/{org}", func() {
|
||||
// Public read endpoints
|
||||
m.Get("/overview", GetOrgOverviewV2)
|
||||
m.Get("/repos", ListOrgReposV2)
|
||||
m.Get("/profile/readme", GetOrgProfileReadmeV2)
|
||||
|
||||
// Write endpoints require authentication + org ownership (checked in handler)
|
||||
m.Group("", func() {
|
||||
m.Patch("", web.Bind(UpdateOrgV2Option{}), UpdateOrgV2)
|
||||
m.Put("/profile/readme", web.Bind(UpdateOrgProfileReadmeOption{}), UpdateOrgProfileReadmeV2)
|
||||
m.Post("/pinned", web.Bind(PinOrgRepoV2Option{}), PinOrgRepoV2)
|
||||
m.Delete("/pinned/{repo}", UnpinOrgRepoV2)
|
||||
}, reqToken())
|
||||
})
|
||||
|
||||
// AI settings API - org-scoped AI settings
|
||||
m.Group("/orgs/{org}/ai", func() {
|
||||
m.Get("/settings", GetOrgAISettingsV2)
|
||||
|
||||
@@ -685,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
720
routers/api/v2/mcp_org.go
Normal file
@@ -0,0 +1,720 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
"code.gitcaddy.com/server/v3/models/organization"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
"code.gitcaddy.com/server/v3/modules/optional"
|
||||
context_service "code.gitcaddy.com/server/v3/services/context"
|
||||
org_service "code.gitcaddy.com/server/v3/services/org"
|
||||
user_service "code.gitcaddy.com/server/v3/services/user"
|
||||
)
|
||||
|
||||
// mcpOrgTools defines MCP tools for organization management.
|
||||
var mcpOrgTools = []MCPTool{
|
||||
{
|
||||
Name: "list_orgs",
|
||||
Description: "List all organizations the authenticated user is a member of. Returns name, full name, description, avatar URL, website, location, group header, and visibility for each org.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_org_overview",
|
||||
Description: "Get a comprehensive overview of an organization including pinned repos, pinned groups, public members, stats (repos/members/teams/stars), profile README content, and recent repository activity.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
},
|
||||
"required": []string{"org"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_org",
|
||||
Description: "Update an organization's basic information. Only provided fields are changed. Requires org owner or site admin.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
"full_name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Display name of the organization",
|
||||
},
|
||||
"email": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Contact email address",
|
||||
},
|
||||
"description": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization description",
|
||||
},
|
||||
"website": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Website URL",
|
||||
},
|
||||
"location": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Physical location",
|
||||
},
|
||||
"group_header": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Group header for organizing this org on the explore page",
|
||||
},
|
||||
},
|
||||
"required": []string{"org"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_org_repos",
|
||||
Description: "List repositories for an organization. Supports grouping by group_header to see how repos are organized. Respects the caller's access permissions.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
"group_by": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Group results by field. Use 'group_header' to see repos grouped by their assigned group.",
|
||||
"enum": []string{"group_header"},
|
||||
},
|
||||
"q": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Search keyword to filter repos by name",
|
||||
},
|
||||
"sort": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Sort order",
|
||||
"enum": []string{"newest", "oldest", "alphabetically", "reversealphabetically", "stars", "forks", "recentupdate"},
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Number of repos to return (max 100, default 50)",
|
||||
},
|
||||
"page": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Page number (1-based)",
|
||||
},
|
||||
},
|
||||
"required": []string{"org"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_org_profile_readme",
|
||||
Description: "Get the raw markdown content of an organization's profile README from its .profile repository.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
},
|
||||
"required": []string{"org"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_org_profile_readme",
|
||||
Description: "Update (or create) the organization's profile README. Creates the .profile repository if it doesn't exist. Requires org owner or site admin.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Markdown content for the README",
|
||||
},
|
||||
"commit_message": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Git commit message (default: 'Update organization profile README')",
|
||||
},
|
||||
},
|
||||
"required": []string{"org", "content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pin_org_repo",
|
||||
Description: "Pin a repository to the organization's overview page. Optionally assign it to a pinned group. Requires org owner or site admin.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository name to pin",
|
||||
},
|
||||
"group_id": map[string]any{
|
||||
"type": "number",
|
||||
"description": "ID of the pinned group to add the repo to (0 or omit for ungrouped)",
|
||||
},
|
||||
"display_order": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Display order within the group (lower = first)",
|
||||
},
|
||||
},
|
||||
"required": []string{"org", "repo"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unpin_org_repo",
|
||||
Description: "Unpin a repository from the organization's overview page. Requires org owner or site admin.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"org": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
},
|
||||
"repo": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Repository name to unpin",
|
||||
},
|
||||
},
|
||||
"required": []string{"org", "repo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// --- Tool Handlers ---
|
||||
|
||||
func toolListOrgs(ctx *context_service.APIContext, _ map[string]any) (any, error) {
|
||||
if ctx.Doer == nil {
|
||||
return nil, errors.New("authentication required")
|
||||
}
|
||||
|
||||
opts := organization.FindOrgOptions{
|
||||
ListOptions: db.ListOptions{PageSize: 50},
|
||||
UserID: ctx.Doer.ID,
|
||||
IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, ctx.Doer),
|
||||
}
|
||||
orgs, _, err := db.FindAndCount[organization.Organization](ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, 0, len(orgs))
|
||||
for _, org := range orgs {
|
||||
entry := map[string]any{
|
||||
"name": org.Name,
|
||||
"full_name": org.FullName,
|
||||
"description": org.Description,
|
||||
"avatar_url": org.AsUser().AvatarLink(ctx),
|
||||
"website": org.Website,
|
||||
"location": org.Location,
|
||||
"group_header": org.GroupHeader,
|
||||
"visibility": org.Visibility.String(),
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"organizations": result,
|
||||
"count": len(result),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolGetOrgOverview(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
if orgName == "" {
|
||||
return nil, errors.New("org is required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
// Stats
|
||||
stats, err := org_service.GetOrgOverviewStats(ctx, org.ID, ctx.Doer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pinned repos
|
||||
pinnedRepos, err := org_service.GetOrgPinnedReposWithDetails(ctx, org.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiPinned := make([]map[string]any, 0, len(pinnedRepos))
|
||||
for _, p := range pinnedRepos {
|
||||
if p.Repo == nil {
|
||||
continue
|
||||
}
|
||||
entry := map[string]any{
|
||||
"id": p.ID,
|
||||
"display_order": p.DisplayOrder,
|
||||
}
|
||||
if repo, ok := p.Repo.(*repo_model.Repository); ok {
|
||||
entry["repo_name"] = repo.Name
|
||||
entry["repo_full_name"] = repo.FullName()
|
||||
entry["description"] = repo.Description
|
||||
entry["is_private"] = repo.IsPrivate
|
||||
}
|
||||
if p.Group != nil {
|
||||
entry["group_id"] = p.GroupID
|
||||
entry["group_name"] = p.Group.Name
|
||||
}
|
||||
apiPinned = append(apiPinned, entry)
|
||||
}
|
||||
|
||||
// Pinned groups
|
||||
pinnedGroups, err := organization.GetOrgPinnedGroups(ctx, org.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiGroups := make([]map[string]any, 0, len(pinnedGroups))
|
||||
for _, g := range pinnedGroups {
|
||||
apiGroups = append(apiGroups, map[string]any{
|
||||
"id": g.ID,
|
||||
"name": g.Name,
|
||||
"display_order": g.DisplayOrder,
|
||||
"collapsed": g.Collapsed,
|
||||
})
|
||||
}
|
||||
|
||||
// Profile readme
|
||||
readme, _ := org_service.GetOrgProfileReadme(ctx, org.ID)
|
||||
|
||||
// Recent activity
|
||||
recentActivity, _ := org_service.GetOrgRecentActivity(ctx, org.ID, ctx.Doer, 10)
|
||||
apiRecent := make([]map[string]any, 0, len(recentActivity))
|
||||
for _, a := range recentActivity {
|
||||
apiRecent = append(apiRecent, map[string]any{
|
||||
"repo_name": a.RepoName,
|
||||
"repo_full_name": a.RepoFullName,
|
||||
"commit_message": a.CommitMessage,
|
||||
"commit_time": a.CommitTime,
|
||||
"is_private": a.IsPrivate,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"organization": map[string]any{
|
||||
"name": org.Name,
|
||||
"full_name": org.FullName,
|
||||
"description": org.Description,
|
||||
"website": org.Website,
|
||||
"location": org.Location,
|
||||
"group_header": org.GroupHeader,
|
||||
"visibility": org.Visibility.String(),
|
||||
},
|
||||
"stats": map[string]any{
|
||||
"total_repos": stats.TotalRepos,
|
||||
"total_members": stats.TotalMembers,
|
||||
"total_teams": stats.TotalTeams,
|
||||
"total_stars": stats.TotalStars,
|
||||
},
|
||||
"pinned_repos": apiPinned,
|
||||
"pinned_groups": apiGroups,
|
||||
"profile_readme": readme,
|
||||
"recent_activity": apiRecent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolUpdateOrg(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
if orgName == "" {
|
||||
return nil, errors.New("org is required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ctx.Doer == nil {
|
||||
return nil, errors.New("authentication required")
|
||||
}
|
||||
if !ctx.Doer.IsAdmin {
|
||||
isOwner, err := org.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, errors.New("organization admin access required")
|
||||
}
|
||||
}
|
||||
|
||||
opts := &user_service.UpdateOptions{}
|
||||
updated := []string{}
|
||||
|
||||
if v, ok := args["full_name"].(string); ok {
|
||||
opts.FullName = optional.Some(v)
|
||||
updated = append(updated, "full_name")
|
||||
}
|
||||
if v, ok := args["description"].(string); ok {
|
||||
opts.Description = optional.Some(v)
|
||||
updated = append(updated, "description")
|
||||
}
|
||||
if v, ok := args["website"].(string); ok {
|
||||
opts.Website = optional.Some(v)
|
||||
updated = append(updated, "website")
|
||||
}
|
||||
if v, ok := args["location"].(string); ok {
|
||||
opts.Location = optional.Some(v)
|
||||
updated = append(updated, "location")
|
||||
}
|
||||
if v, ok := args["group_header"].(string); ok {
|
||||
opts.GroupHeader = optional.Some(v)
|
||||
updated = append(updated, "group_header")
|
||||
}
|
||||
|
||||
if v, ok := args["email"].(string); ok && v != "" {
|
||||
if err := user_service.ReplacePrimaryEmailAddress(ctx, org.AsUser(), v); err != nil {
|
||||
return nil, fmt.Errorf("failed to update email: %v", err)
|
||||
}
|
||||
updated = append(updated, "email")
|
||||
}
|
||||
|
||||
if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"status": "updated",
|
||||
"org": orgName,
|
||||
"fields_updated": updated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolListOrgRepos(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
if orgName == "" {
|
||||
return nil, errors.New("org is required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||
limit = min(int(v), 100)
|
||||
}
|
||||
page := 1
|
||||
if v, ok := args["page"].(float64); ok && v > 0 {
|
||||
page = int(v)
|
||||
}
|
||||
|
||||
groupBy, _ := args["group_by"].(string)
|
||||
keyword, _ := args["q"].(string)
|
||||
sortOrder, _ := args["sort"].(string)
|
||||
|
||||
var orderBy db.SearchOrderBy
|
||||
switch sortOrder {
|
||||
case "newest":
|
||||
orderBy = db.SearchOrderByNewest
|
||||
case "oldest":
|
||||
orderBy = db.SearchOrderByOldest
|
||||
case "reversealphabetically":
|
||||
orderBy = db.SearchOrderByAlphabeticallyReverse
|
||||
case "stars":
|
||||
orderBy = db.SearchOrderByStarsReverse
|
||||
case "forks":
|
||||
orderBy = db.SearchOrderByForksReverse
|
||||
case "recentupdate":
|
||||
orderBy = db.SearchOrderByRecentUpdated
|
||||
default:
|
||||
orderBy = db.SearchOrderByAlphabetically
|
||||
}
|
||||
|
||||
if groupBy == "group_header" {
|
||||
orderBy = db.SearchOrderBy("CASE WHEN group_header = '' OR group_header IS NULL THEN 1 ELSE 0 END, group_header ASC, " + string(orderBy))
|
||||
}
|
||||
|
||||
repos, count, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{PageSize: limit, Page: page},
|
||||
Keyword: keyword,
|
||||
OwnerID: org.ID,
|
||||
OrderBy: orderBy,
|
||||
Private: ctx.Doer != nil,
|
||||
Actor: ctx.Doer,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if groupBy == "group_header" {
|
||||
// Grouped response
|
||||
type repoEntry struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
IsFork bool `json:"is_fork"`
|
||||
IsArchived bool `json:"is_archived"`
|
||||
Stars int `json:"stars"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
grouped := make(map[string][]repoEntry)
|
||||
var headers []string
|
||||
headerSeen := make(map[string]bool)
|
||||
|
||||
for _, repo := range repos {
|
||||
header := repo.GroupHeader
|
||||
if !headerSeen[header] {
|
||||
headerSeen[header] = true
|
||||
headers = append(headers, header)
|
||||
}
|
||||
grouped[header] = append(grouped[header], repoEntry{
|
||||
Name: repo.Name,
|
||||
FullName: repo.FullName(),
|
||||
Description: repo.Description,
|
||||
IsPrivate: repo.IsPrivate,
|
||||
IsFork: repo.IsFork,
|
||||
IsArchived: repo.IsArchived,
|
||||
Stars: repo.NumStars,
|
||||
Language: repoLanguage(repo),
|
||||
})
|
||||
}
|
||||
|
||||
groups := make([]map[string]any, 0, len(headers))
|
||||
for _, h := range headers {
|
||||
displayName := h
|
||||
if displayName == "" {
|
||||
displayName = "(ungrouped)"
|
||||
}
|
||||
groups = append(groups, map[string]any{
|
||||
"group_header": h,
|
||||
"display_name": displayName,
|
||||
"repos": grouped[h],
|
||||
"count": len(grouped[h]),
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"groups": groups,
|
||||
"total": count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Flat response
|
||||
repoList := make([]map[string]any, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
repoList = append(repoList, map[string]any{
|
||||
"name": repo.Name,
|
||||
"full_name": repo.FullName(),
|
||||
"description": repo.Description,
|
||||
"is_private": repo.IsPrivate,
|
||||
"is_fork": repo.IsFork,
|
||||
"is_archived": repo.IsArchived,
|
||||
"stars": repo.NumStars,
|
||||
"language": repoLanguage(repo),
|
||||
"group_header": repo.GroupHeader,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"repos": repoList,
|
||||
"total": count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolGetOrgProfileReadme(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
if orgName == "" {
|
||||
return nil, errors.New("org is required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
readme, err := org_service.GetOrgProfileReadme(ctx, org.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"org": orgName,
|
||||
"content": readme,
|
||||
"has_profile": readme != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolUpdateOrgProfileReadme(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
if orgName == "" {
|
||||
return nil, errors.New("org is required")
|
||||
}
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return nil, errors.New("content is required")
|
||||
}
|
||||
commitMessage, _ := args["commit_message"].(string)
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ctx.Doer == nil {
|
||||
return nil, errors.New("authentication required")
|
||||
}
|
||||
if !ctx.Doer.IsAdmin {
|
||||
isOwner, err := org.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, errors.New("organization admin access required")
|
||||
}
|
||||
}
|
||||
|
||||
if err := org_service.UpdateOrgProfileReadme(ctx, ctx.Doer, org.ID, content, commitMessage); err != nil {
|
||||
return nil, fmt.Errorf("failed to update profile readme: %v", err)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"status": "updated",
|
||||
"org": orgName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolPinOrgRepo(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
repoName, _ := args["repo"].(string)
|
||||
if orgName == "" || repoName == "" {
|
||||
return nil, errors.New("org and repo are required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ctx.Doer == nil {
|
||||
return nil, errors.New("authentication required")
|
||||
}
|
||||
if !ctx.Doer.IsAdmin {
|
||||
isOwner, err := org.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, errors.New("organization admin access required")
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, org.ID, repoName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository not found: %s/%s", orgName, repoName)
|
||||
}
|
||||
|
||||
isPinned, err := organization.IsRepoPinned(ctx, org.ID, repo.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isPinned {
|
||||
return map[string]any{
|
||||
"status": "already_pinned",
|
||||
"org": orgName,
|
||||
"repo": repoName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var groupID int64
|
||||
if v, ok := args["group_id"].(float64); ok {
|
||||
groupID = int64(v)
|
||||
}
|
||||
var displayOrder int
|
||||
if v, ok := args["display_order"].(float64); ok {
|
||||
displayOrder = int(v)
|
||||
}
|
||||
|
||||
pinned := &organization.OrgPinnedRepo{
|
||||
OrgID: org.ID,
|
||||
RepoID: repo.ID,
|
||||
GroupID: groupID,
|
||||
DisplayOrder: displayOrder,
|
||||
}
|
||||
|
||||
if err := organization.CreateOrgPinnedRepo(ctx, pinned); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"status": "pinned",
|
||||
"org": orgName,
|
||||
"repo": repoName,
|
||||
"id": pinned.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolUnpinOrgRepo(ctx *context_service.APIContext, args map[string]any) (any, error) {
|
||||
orgName, _ := args["org"].(string)
|
||||
repoName, _ := args["repo"].(string)
|
||||
if orgName == "" || repoName == "" {
|
||||
return nil, errors.New("org and repo are required")
|
||||
}
|
||||
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("organization not found: %s", orgName)
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ctx.Doer == nil {
|
||||
return nil, errors.New("authentication required")
|
||||
}
|
||||
if !ctx.Doer.IsAdmin {
|
||||
isOwner, err := org.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, errors.New("organization admin access required")
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, org.ID, repoName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository not found: %s/%s", orgName, repoName)
|
||||
}
|
||||
|
||||
if err := organization.DeleteOrgPinnedRepo(ctx, org.ID, repo.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"status": "unpinned",
|
||||
"org": orgName,
|
||||
"repo": repoName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func repoLanguage(repo *repo_model.Repository) string {
|
||||
if repo.PrimaryLanguage != nil {
|
||||
return repo.PrimaryLanguage.Language
|
||||
}
|
||||
return ""
|
||||
}
|
||||
526
routers/api/v2/org_overview.go
Normal file
526
routers/api/v2/org_overview.go
Normal file
@@ -0,0 +1,526 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
"code.gitcaddy.com/server/v3/models/organization"
|
||||
"code.gitcaddy.com/server/v3/models/perm"
|
||||
access_model "code.gitcaddy.com/server/v3/models/perm/access"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
apierrors "code.gitcaddy.com/server/v3/modules/errors"
|
||||
"code.gitcaddy.com/server/v3/modules/optional"
|
||||
api "code.gitcaddy.com/server/v3/modules/structs"
|
||||
"code.gitcaddy.com/server/v3/modules/web"
|
||||
"code.gitcaddy.com/server/v3/services/context"
|
||||
"code.gitcaddy.com/server/v3/services/convert"
|
||||
org_service "code.gitcaddy.com/server/v3/services/org"
|
||||
user_service "code.gitcaddy.com/server/v3/services/user"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// loadOrg looks up the org from the path param and checks it exists.
|
||||
func loadOrg(ctx *context.APIContext) *organization.Organization {
|
||||
orgName := ctx.PathParam("org")
|
||||
org, err := organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
if organization.IsErrOrgNotExist(err) {
|
||||
ctx.APIErrorWithCode(apierrors.OrgNotFound)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return org
|
||||
}
|
||||
|
||||
// requireOrgOwner checks the doer is an org owner or site admin. Returns false if denied.
|
||||
func requireOrgOwner(ctx *context.APIContext, org *organization.Organization) bool {
|
||||
if ctx.Doer.IsAdmin {
|
||||
return true
|
||||
}
|
||||
isOwner, err := org.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return false
|
||||
}
|
||||
if !isOwner {
|
||||
ctx.APIErrorWithCode(apierrors.PermOrgAdminRequired)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --- List user's orgs ---
|
||||
|
||||
// ListUserOrgsV2 returns all organizations the authenticated user belongs to.
|
||||
func ListUserOrgsV2(ctx *context.APIContext) {
|
||||
opts := organization.FindOrgOptions{
|
||||
ListOptions: db.ListOptions{PageSize: 50},
|
||||
UserID: ctx.Doer.ID,
|
||||
IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, ctx.Doer),
|
||||
}
|
||||
orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiOrgs := make([]*api.Organization, len(orgs))
|
||||
for i := range orgs {
|
||||
apiOrgs[i] = convert.ToOrganization(ctx, orgs[i])
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(maxResults)
|
||||
ctx.JSON(http.StatusOK, apiOrgs)
|
||||
}
|
||||
|
||||
// --- Org Overview ---
|
||||
|
||||
// GetOrgOverviewV2 returns the full organization overview including profile readme and recent activity.
|
||||
func GetOrgOverviewV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Pinned repos
|
||||
pinnedRepos, err := org_service.GetOrgPinnedReposWithDetails(ctx, org.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Pinned groups
|
||||
pinnedGroups, err := organization.GetOrgPinnedGroups(ctx, org.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Public members
|
||||
publicMembers, totalMembers, err := organization.GetPublicOrgMembers(ctx, org.ID, 12)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Stats
|
||||
stats, err := org_service.GetOrgOverviewStats(ctx, org.ID, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Profile readme
|
||||
readme, _ := org_service.GetOrgProfileReadme(ctx, org.ID)
|
||||
|
||||
// Recent activity
|
||||
recentActivity, _ := org_service.GetOrgRecentActivity(ctx, org.ID, ctx.Doer, 10)
|
||||
|
||||
// Convert pinned repos
|
||||
apiPinnedRepos := make([]*api.OrgPinnedRepo, 0, len(pinnedRepos))
|
||||
for _, p := range pinnedRepos {
|
||||
if p.Repo == nil {
|
||||
continue
|
||||
}
|
||||
apiPinnedRepos = append(apiPinnedRepos, convertOrgPinnedRepoV2(ctx, p))
|
||||
}
|
||||
|
||||
apiPinnedGroups := make([]*api.OrgPinnedGroup, 0, len(pinnedGroups))
|
||||
for _, g := range pinnedGroups {
|
||||
apiPinnedGroups = append(apiPinnedGroups, convertOrgPinnedGroupV2(g))
|
||||
}
|
||||
|
||||
apiPublicMembers := make([]*api.OrgPublicMember, 0, len(publicMembers))
|
||||
for _, m := range publicMembers {
|
||||
apiPublicMembers = append(apiPublicMembers, &api.OrgPublicMember{
|
||||
User: convert.ToUser(ctx, m.User, ctx.Doer),
|
||||
Role: m.Role,
|
||||
})
|
||||
}
|
||||
|
||||
// Build profile content
|
||||
var profile *api.OrgProfileContent
|
||||
if readme != "" {
|
||||
profile = &api.OrgProfileContent{
|
||||
HasProfile: true,
|
||||
Readme: readme,
|
||||
}
|
||||
}
|
||||
|
||||
// Build recent activity
|
||||
var apiRecentActivity []api.OrgRecentActivity
|
||||
if len(recentActivity) > 0 {
|
||||
apiRecentActivity = make([]api.OrgRecentActivity, 0, len(recentActivity))
|
||||
for _, a := range recentActivity {
|
||||
apiRecentActivity = append(apiRecentActivity, api.OrgRecentActivity{
|
||||
RepoName: a.RepoName,
|
||||
RepoFullName: a.RepoFullName,
|
||||
DefaultBranch: a.DefaultBranch,
|
||||
CommitMessage: a.CommitMessage,
|
||||
CommitTime: a.CommitTime,
|
||||
IsPrivate: a.IsPrivate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
overview := &api.OrgOverviewV2{
|
||||
Organization: convert.ToOrganization(ctx, org),
|
||||
PinnedRepos: apiPinnedRepos,
|
||||
PinnedGroups: apiPinnedGroups,
|
||||
PublicMembers: apiPublicMembers,
|
||||
TotalMembers: totalMembers,
|
||||
Stats: &api.OrgOverviewStats{
|
||||
TotalRepos: stats.TotalRepos,
|
||||
TotalMembers: stats.TotalMembers,
|
||||
TotalTeams: stats.TotalTeams,
|
||||
TotalStars: stats.TotalStars,
|
||||
},
|
||||
Profile: profile,
|
||||
RecentActivity: apiRecentActivity,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, overview)
|
||||
}
|
||||
|
||||
// --- Update Org ---
|
||||
|
||||
// UpdateOrgV2Option represents the fields that can be updated on an org.
|
||||
type UpdateOrgV2Option struct {
|
||||
FullName *string `json:"full_name"`
|
||||
Email *string `json:"email"`
|
||||
Description *string `json:"description"`
|
||||
Website *string `json:"website"`
|
||||
Location *string `json:"location"`
|
||||
GroupHeader *string `json:"group_header"`
|
||||
}
|
||||
|
||||
// UpdateOrgV2 updates an organization's basic information.
|
||||
func UpdateOrgV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
if !requireOrgOwner(ctx, org) {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*UpdateOrgV2Option)
|
||||
|
||||
opts := &user_service.UpdateOptions{}
|
||||
if form.FullName != nil {
|
||||
opts.FullName = optional.Some(*form.FullName)
|
||||
}
|
||||
if form.Description != nil {
|
||||
opts.Description = optional.Some(*form.Description)
|
||||
}
|
||||
if form.Website != nil {
|
||||
opts.Website = optional.Some(*form.Website)
|
||||
}
|
||||
if form.Location != nil {
|
||||
opts.Location = optional.Some(*form.Location)
|
||||
}
|
||||
if form.GroupHeader != nil {
|
||||
opts.GroupHeader = optional.Some(*form.GroupHeader)
|
||||
}
|
||||
|
||||
if form.Email != nil && *form.Email != "" {
|
||||
if err := user_service.ReplacePrimaryEmailAddress(ctx, org.AsUser(), *form.Email); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Reload to get updated fields
|
||||
org, err := organization.GetOrgByName(ctx, org.Name)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToOrganization(ctx, org))
|
||||
}
|
||||
|
||||
// --- List Org Repos ---
|
||||
|
||||
// ListOrgReposV2 lists all repos for an org, with optional group_header grouping.
|
||||
func ListOrgReposV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
|
||||
page := ctx.FormInt("page")
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
limit := ctx.FormInt("limit")
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
groupBy := ctx.FormString("group_by")
|
||||
keyword := ctx.FormTrim("q")
|
||||
sortOrder := ctx.FormString("sort")
|
||||
|
||||
var orderBy db.SearchOrderBy
|
||||
switch sortOrder {
|
||||
case "newest":
|
||||
orderBy = db.SearchOrderByNewest
|
||||
case "oldest":
|
||||
orderBy = db.SearchOrderByOldest
|
||||
case "reversealphabetically":
|
||||
orderBy = db.SearchOrderByAlphabeticallyReverse
|
||||
case "stars":
|
||||
orderBy = db.SearchOrderByStarsReverse
|
||||
case "forks":
|
||||
orderBy = db.SearchOrderByForksReverse
|
||||
case "recentupdate":
|
||||
orderBy = db.SearchOrderByRecentUpdated
|
||||
default:
|
||||
orderBy = db.SearchOrderByAlphabetically
|
||||
}
|
||||
|
||||
// When grouping by group_header, prepend it to the sort
|
||||
if groupBy == "group_header" {
|
||||
orderBy = db.SearchOrderBy("CASE WHEN group_header = '' OR group_header IS NULL THEN 1 ELSE 0 END, group_header ASC, " + string(orderBy))
|
||||
}
|
||||
|
||||
repos, count, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{PageSize: limit, Page: page},
|
||||
Keyword: keyword,
|
||||
OwnerID: org.ID,
|
||||
OrderBy: orderBy,
|
||||
Private: ctx.IsSigned,
|
||||
Actor: ctx.Doer,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiRepos := make([]*api.Repository, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
apiRepos = append(apiRepos, convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeRead}))
|
||||
}
|
||||
|
||||
if groupBy == "group_header" {
|
||||
// Return grouped response
|
||||
type groupedEntry struct {
|
||||
GroupHeader string `json:"group_header"`
|
||||
Repos []*api.Repository `json:"repos"`
|
||||
}
|
||||
grouped := make(map[string][]*api.Repository)
|
||||
var headers []string
|
||||
headerSeen := make(map[string]bool)
|
||||
|
||||
for i, repo := range repos {
|
||||
header := repo.GroupHeader
|
||||
if !headerSeen[header] {
|
||||
headerSeen[header] = true
|
||||
headers = append(headers, header)
|
||||
}
|
||||
grouped[header] = append(grouped[header], apiRepos[i])
|
||||
}
|
||||
|
||||
groups := make([]groupedEntry, 0, len(headers))
|
||||
for _, h := range headers {
|
||||
groups = append(groups, groupedEntry{
|
||||
GroupHeader: h,
|
||||
Repos: grouped[h],
|
||||
})
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"groups": groups,
|
||||
"total": count,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiRepos)
|
||||
}
|
||||
|
||||
// --- Profile Readme ---
|
||||
|
||||
// GetOrgProfileReadmeV2 returns the raw README content from the .profile repo.
|
||||
func GetOrgProfileReadmeV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
|
||||
readme, err := org_service.GetOrgProfileReadme(ctx, org.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"content": readme,
|
||||
"has_profile": readme != "",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateOrgProfileReadmeOption represents a request to update the profile readme.
|
||||
type UpdateOrgProfileReadmeOption struct {
|
||||
Content string `json:"content" binding:"Required"`
|
||||
CommitMessage string `json:"commit_message"`
|
||||
}
|
||||
|
||||
// UpdateOrgProfileReadmeV2 updates the README.md in the .profile repo.
|
||||
func UpdateOrgProfileReadmeV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
if !requireOrgOwner(ctx, org) {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*UpdateOrgProfileReadmeOption)
|
||||
|
||||
if err := org_service.UpdateOrgProfileReadme(ctx, ctx.Doer, org.ID, form.Content, form.CommitMessage); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// --- Pinned Repos ---
|
||||
|
||||
// PinOrgRepoV2Option represents a request to pin a repo.
|
||||
type PinOrgRepoV2Option struct {
|
||||
RepoName string `json:"repo_name" binding:"Required"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
}
|
||||
|
||||
// PinOrgRepoV2 pins a repository to the org overview.
|
||||
func PinOrgRepoV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
if !requireOrgOwner(ctx, org) {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*PinOrgRepoV2Option)
|
||||
|
||||
// Look up the repo
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, org.ID, form.RepoName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, "Repository not found: "+form.RepoName)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already pinned
|
||||
isPinned, err := organization.IsRepoPinned(ctx, org.ID, repo.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if isPinned {
|
||||
ctx.JSON(http.StatusOK, map[string]any{"status": "already_pinned"})
|
||||
return
|
||||
}
|
||||
|
||||
pinned := &organization.OrgPinnedRepo{
|
||||
OrgID: org.ID,
|
||||
RepoID: repo.ID,
|
||||
GroupID: form.GroupID,
|
||||
DisplayOrder: form.DisplayOrder,
|
||||
}
|
||||
|
||||
if err := organization.CreateOrgPinnedRepo(ctx, pinned); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, map[string]any{
|
||||
"id": pinned.ID,
|
||||
"repo_id": repo.ID,
|
||||
"status": "pinned",
|
||||
})
|
||||
}
|
||||
|
||||
// UnpinOrgRepoV2 unpins a repository from the org overview.
|
||||
func UnpinOrgRepoV2(ctx *context.APIContext) {
|
||||
org := loadOrg(ctx)
|
||||
if org == nil {
|
||||
return
|
||||
}
|
||||
if !requireOrgOwner(ctx, org) {
|
||||
return
|
||||
}
|
||||
|
||||
repoName := ctx.PathParam("repo")
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, org.ID, repoName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, "Repository not found: "+repoName)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := organization.DeleteOrgPinnedRepo(ctx, org.ID, repo.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- Converters ---
|
||||
|
||||
func convertOrgPinnedRepoV2(ctx *context.APIContext, p *organization.OrgPinnedRepo) *api.OrgPinnedRepo {
|
||||
result := &api.OrgPinnedRepo{
|
||||
ID: p.ID,
|
||||
RepoID: p.RepoID,
|
||||
GroupID: p.GroupID,
|
||||
DisplayOrder: p.DisplayOrder,
|
||||
}
|
||||
|
||||
if p.Repo != nil {
|
||||
if repo, ok := p.Repo.(*repo_model.Repository); ok {
|
||||
result.Repo = convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeRead})
|
||||
}
|
||||
}
|
||||
|
||||
if p.Group != nil {
|
||||
result.Group = convertOrgPinnedGroupV2(p.Group)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func convertOrgPinnedGroupV2(g *organization.OrgPinnedGroup) *api.OrgPinnedGroup {
|
||||
return &api.OrgPinnedGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
DisplayOrder: g.DisplayOrder,
|
||||
Collapsed: g.Collapsed,
|
||||
}
|
||||
}
|
||||
@@ -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
218
services/org/profile.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
user_model "code.gitcaddy.com/server/v3/models/user"
|
||||
"code.gitcaddy.com/server/v3/modules/git"
|
||||
repo_service "code.gitcaddy.com/server/v3/services/repository"
|
||||
files_service "code.gitcaddy.com/server/v3/services/repository/files"
|
||||
)
|
||||
|
||||
// GetOrgProfileReadme reads the README.md from the org's .profile repository.
|
||||
// Returns empty string if the .profile repo doesn't exist or has no README.
|
||||
func GetOrgProfileReadme(ctx context.Context, orgID int64) (string, error) {
|
||||
profileRepo, err := repo_model.GetRepositoryByName(ctx, orgID, ".profile")
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
if profileRepo.IsEmpty {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
gitRepo, err := git.OpenRepository(ctx, profileRepo.RepoPath())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(profileRepo.DefaultBranch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Try common README filenames
|
||||
readmeFiles := []string{"README.md", "readme.md", "Readme.md", "README", "README.txt"}
|
||||
for _, filename := range readmeFiles {
|
||||
entry, err := commit.GetTreeEntryByPath(filename)
|
||||
if err == nil && !entry.IsDir() {
|
||||
content, err := entry.Blob().GetBlobContent(1024 * 512) // 512KB max
|
||||
if err == nil {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// UpdateOrgProfileReadme updates (or creates) the README.md in the org's .profile repository.
|
||||
// If the .profile repo doesn't exist, it is created first.
|
||||
func UpdateOrgProfileReadme(ctx context.Context, doer *user_model.User, orgID int64, content, commitMessage string) error {
|
||||
// Look up the org as a user (needed for repo operations)
|
||||
orgUser, err := user_model.GetUserByID(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileRepo, err := repo_model.GetRepositoryByName(ctx, orgID, ".profile")
|
||||
if err != nil {
|
||||
if !repo_model.IsErrRepoNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create .profile repo
|
||||
profileRepo, err = repo_service.CreateRepository(ctx, doer, orgUser, repo_service.CreateRepoOptions{
|
||||
Name: ".profile",
|
||||
Description: "Organization profile",
|
||||
AutoInit: true,
|
||||
Readme: "Default",
|
||||
DefaultBranch: "main",
|
||||
IsPrivate: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if commitMessage == "" {
|
||||
commitMessage = "Update organization profile README"
|
||||
}
|
||||
|
||||
// Determine operation: create or update
|
||||
operation := "update"
|
||||
var existingSHA string
|
||||
|
||||
if !profileRepo.IsEmpty {
|
||||
gitRepo, err := git.OpenRepository(ctx, profileRepo.RepoPath())
|
||||
if err == nil {
|
||||
commit, err := gitRepo.GetBranchCommit(profileRepo.DefaultBranch)
|
||||
if err == nil {
|
||||
entry, err := commit.GetTreeEntryByPath("README.md")
|
||||
if err != nil {
|
||||
operation = "create"
|
||||
} else {
|
||||
existingSHA = entry.ID.String()
|
||||
}
|
||||
}
|
||||
gitRepo.Close()
|
||||
}
|
||||
} else {
|
||||
operation = "create"
|
||||
}
|
||||
|
||||
_, err = files_service.ChangeRepoFiles(ctx, profileRepo, doer, &files_service.ChangeRepoFilesOptions{
|
||||
OldBranch: profileRepo.DefaultBranch,
|
||||
NewBranch: profileRepo.DefaultBranch,
|
||||
Message: commitMessage,
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: operation,
|
||||
TreePath: "README.md",
|
||||
ContentReader: strings.NewReader(content),
|
||||
SHA: existingSHA,
|
||||
},
|
||||
},
|
||||
Author: &files_service.IdentityOptions{
|
||||
GitUserName: doer.Name,
|
||||
GitUserEmail: doer.Email,
|
||||
},
|
||||
})
|
||||
|
||||
// If update failed because file doesn't exist, try create
|
||||
if err != nil && operation == "update" {
|
||||
_, err = files_service.ChangeRepoFiles(ctx, profileRepo, doer, &files_service.ChangeRepoFilesOptions{
|
||||
OldBranch: profileRepo.DefaultBranch,
|
||||
NewBranch: profileRepo.DefaultBranch,
|
||||
Message: commitMessage,
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: "README.md",
|
||||
ContentReader: strings.NewReader(content),
|
||||
},
|
||||
},
|
||||
Author: &files_service.IdentityOptions{
|
||||
GitUserName: doer.Name,
|
||||
GitUserEmail: doer.Email,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOrgProfileRepo returns the .profile repository for an org, or nil if it doesn't exist.
|
||||
func GetOrgProfileRepo(ctx context.Context, orgID int64) (*repo_model.Repository, error) {
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, orgID, ".profile")
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// GetOrgRecentActivity returns the N most recently updated repos for an org
|
||||
// with their latest commit info.
|
||||
type RecentRepoActivity struct {
|
||||
RepoName string `json:"repo_name"`
|
||||
RepoFullName string `json:"repo_full_name"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
CommitMessage string `json:"commit_message,omitempty"`
|
||||
CommitTime int64 `json:"commit_time,omitempty"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
}
|
||||
|
||||
func GetOrgRecentActivity(ctx context.Context, orgID int64, actor *user_model.User, limit int) ([]*RecentRepoActivity, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
showPrivate := actor != nil
|
||||
repos, _, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{PageSize: limit, Page: 1},
|
||||
OwnerID: orgID,
|
||||
OrderBy: db.SearchOrderByRecentUpdated,
|
||||
Private: showPrivate,
|
||||
Actor: actor,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*RecentRepoActivity, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
activity := &RecentRepoActivity{
|
||||
RepoName: repo.Name,
|
||||
RepoFullName: repo.FullName(),
|
||||
DefaultBranch: repo.DefaultBranch,
|
||||
IsPrivate: repo.IsPrivate,
|
||||
}
|
||||
|
||||
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||
if err == nil {
|
||||
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||
if err == nil {
|
||||
activity.CommitMessage = commit.Summary()
|
||||
activity.CommitTime = commit.Author.When.Unix()
|
||||
}
|
||||
gitRepo.Close()
|
||||
}
|
||||
|
||||
result = append(result, activity)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user