Compare commits

...

1 Commits

Author SHA1 Message Date
jmiller a5dac03e7f feat: add org-governance MCP tools (issue #738 scope B)
Add 19 tools covering the new org-level governance API endpoints under
/orgs/{org}/...: branch protection rulesets, tag protection rules, and the
push-policy, repo-defaults and email-domain-policy singletons.

Input schemas derived from the CreateOrg*/EditOrg* DTOs in
modules/structs/org_*.go. Follows existing gitea_org_* conventions:
required-vs-optional fields, PATCH bodies built only from provided fields,
and reuse of the existing GiteaClient methods.

Tools added:
- gitea_org_branch_protections_list / _get / _create / _edit / _delete
- gitea_org_tag_protections_list / _get / _create / _edit / _delete
- gitea_org_push_policy_get / _edit / _delete
- gitea_org_repo_defaults_get / _edit / _delete
- gitea_org_email_domain_policy_get / _edit / _delete

Claude-Session: https://claude.ai/code/session_01Wsno14cxE49MstXFs9G5KT
2026-07-05 16:03:34 -05:00
+272
View File
@@ -1788,6 +1788,278 @@ server.tool(
},
);
// ── Organization Branch Protection (org-governance) ──────────────────────
// Shared field schema for org branch protection rulesets (create + edit).
// rule_name is supplied separately (body field on create, path param on edit).
const OrgBranchProtectionFields = {
priority: z.number().optional().describe('Rule priority (lower applies first)'),
enable_push: z.boolean().optional().describe('Allow push to matching branches'),
enable_push_whitelist: z.boolean().optional().describe('Restrict push to whitelisted teams'),
push_whitelist_teams: z.array(z.string()).optional().describe('Teams allowed to push'),
enable_force_push: z.boolean().optional().describe('Allow force push'),
enable_force_push_allowlist: z.boolean().optional().describe('Restrict force push to allowlisted teams'),
force_push_allowlist_teams: z.array(z.string()).optional().describe('Teams allowed to force push'),
enable_delete: z.boolean().optional().describe('Allow branch deletion'),
enable_delete_allowlist: z.boolean().optional().describe('Restrict deletion to allowlisted teams'),
delete_allowlist_teams: z.array(z.string()).optional().describe('Teams allowed to delete branches'),
enable_merge_whitelist: z.boolean().optional().describe('Restrict merges to whitelisted teams'),
merge_whitelist_teams: z.array(z.string()).optional().describe('Teams allowed to merge'),
enable_status_check: z.boolean().optional().describe('Require status checks to pass'),
status_check_contexts: z.array(z.string()).optional().describe('Required status check names'),
required_approvals: z.number().optional().describe('Required PR approvals (0 = none)'),
enable_approvals_whitelist: z.boolean().optional().describe('Restrict approvals to whitelisted teams'),
approvals_whitelist_teams: z.array(z.string()).optional().describe('Teams whose approvals count'),
block_on_rejected_reviews: z.boolean().optional().describe('Block merge on rejected reviews'),
block_on_official_review_requests: z.boolean().optional().describe('Block merge on pending official review requests'),
block_on_outdated_branch: z.boolean().optional().describe('Block merge when branch is behind base'),
dismiss_stale_approvals: z.boolean().optional().describe('Dismiss approvals when new commits are pushed'),
ignore_stale_approvals: z.boolean().optional().describe('Ignore stale approvals when counting'),
require_signed_commits: z.boolean().optional().describe('Require signed commits'),
protected_file_patterns: z.string().optional().describe('Protected file path patterns (glob, semicolon-separated)'),
unprotected_file_patterns: z.string().optional().describe('Unprotected file path patterns (glob, semicolon-separated)'),
block_admin_merge_override: z.boolean().optional().describe('Prevent admins from overriding merge restrictions'),
};
server.tool(
'gitea_org_branch_protections_list',
'List org-level branch protection rulesets (requires org owner)',
{ org: z.string().describe('Organization name'), ...ConnectionParam },
async ({ org, connection }) => formatResponse(await clientFor(connection).get(`/orgs/${org}/branch_protections`)),
);
server.tool(
'gitea_org_branch_protection_get',
'Get a single org-level branch protection ruleset by rule name (requires org owner)',
{
org: z.string().describe('Organization name'),
rule_name: z.string().describe('Rule name / branch pattern (e.g. "main", "release/*")'),
...ConnectionParam,
},
async ({ org, rule_name, connection }) => formatResponse(await clientFor(connection).get(`/orgs/${org}/branch_protections/${rule_name}`)),
);
server.tool(
'gitea_org_branch_protection_create',
'Create an org-level branch protection ruleset applied across the org (requires org owner)',
{
org: z.string().describe('Organization name'),
rule_name: z.string().describe('Rule name / branch pattern (e.g. "main", "release/*")'),
...OrgBranchProtectionFields,
...ConnectionParam,
},
async ({ org, rule_name, connection, ...fields }) => {
const body: Record<string, unknown> = { rule_name };
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) body[k] = v;
}
return formatResponse(await clientFor(connection).post(`/orgs/${org}/branch_protections`, body));
},
);
server.tool(
'gitea_org_branch_protection_edit',
'Edit an org-level branch protection ruleset (only provided fields change; requires org owner)',
{
org: z.string().describe('Organization name'),
rule_name: z.string().describe('Rule name / branch pattern of the ruleset to edit'),
...OrgBranchProtectionFields,
...ConnectionParam,
},
async ({ org, rule_name, connection, ...fields }) => {
const body: Record<string, unknown> = {};
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) body[k] = v;
}
return formatResponse(await clientFor(connection).patch(`/orgs/${org}/branch_protections/${rule_name}`, body));
},
);
server.tool(
'gitea_org_branch_protection_delete',
'Delete an org-level branch protection ruleset by rule name (requires org owner)',
{
org: z.string().describe('Organization name'),
rule_name: z.string().describe('Rule name / branch pattern of the ruleset to delete'),
...ConnectionParam,
},
async ({ org, rule_name, connection }) => formatResponse(await clientFor(connection).delete(`/orgs/${org}/branch_protections/${rule_name}`)),
);
// ── Organization Tag Protection (org-governance) ─────────────────────────
server.tool(
'gitea_org_tag_protections_list',
'List org-level tag protection rules (requires org owner)',
{ org: z.string().describe('Organization name'), ...ConnectionParam },
async ({ org, connection }) => formatResponse(await clientFor(connection).get(`/orgs/${org}/tag_protections`)),
);
server.tool(
'gitea_org_tag_protection_get',
'Get a single org-level tag protection rule by id (requires org owner)',
{
org: z.string().describe('Organization name'),
id: z.number().describe('Tag protection rule ID'),
...ConnectionParam,
},
async ({ org, id, connection }) => formatResponse(await clientFor(connection).get(`/orgs/${org}/tag_protections/${id}`)),
);
server.tool(
'gitea_org_tag_protection_create',
'Create an org-level tag protection rule (requires org owner)',
{
org: z.string().describe('Organization name'),
name_pattern: z.string().describe('Tag name pattern to protect (glob, e.g. "v*")'),
whitelist_teams: z.array(z.string()).optional().describe('Teams allowed to create/delete matching tags'),
...ConnectionParam,
},
async ({ org, name_pattern, whitelist_teams, connection }) => {
const body: Record<string, unknown> = { name_pattern };
if (whitelist_teams !== undefined) body.whitelist_teams = whitelist_teams;
return formatResponse(await clientFor(connection).post(`/orgs/${org}/tag_protections`, body));
},
);
server.tool(
'gitea_org_tag_protection_edit',
'Edit an org-level tag protection rule (only provided fields change; requires org owner)',
{
org: z.string().describe('Organization name'),
id: z.number().describe('Tag protection rule ID'),
name_pattern: z.string().optional().describe('Tag name pattern to protect (glob, e.g. "v*")'),
whitelist_teams: z.array(z.string()).optional().describe('Teams allowed to create/delete matching tags'),
...ConnectionParam,
},
async ({ org, id, name_pattern, whitelist_teams, connection }) => {
const body: Record<string, unknown> = {};
if (name_pattern !== undefined) body.name_pattern = name_pattern;
if (whitelist_teams !== undefined) body.whitelist_teams = whitelist_teams;
return formatResponse(await clientFor(connection).patch(`/orgs/${org}/tag_protections/${id}`, body));
},
);
server.tool(
'gitea_org_tag_protection_delete',
'Delete an org-level tag protection rule by id (requires org owner)',
{
org: z.string().describe('Organization name'),
id: z.number().describe('Tag protection rule ID'),
...ConnectionParam,
},
async ({ org, id, connection }) => formatResponse(await clientFor(connection).delete(`/orgs/${org}/tag_protections/${id}`)),
);
// ── Organization Push Policy (singleton, org-governance) ─────────────────
server.tool(
'gitea_org_push_policy_get',
'Get the organization push policy (requires org owner)',
{ org: z.string().describe('Organization name'), ...ConnectionParam },
async ({ org, connection }) => formatResponse(await clientFor(connection).get(`/orgs/${org}/push_policy`)),
);
server.tool(
'gitea_org_push_policy_edit',
'Edit the organization push policy (only provided fields change; requires org owner)',
{
org: z.string().describe('Organization name'),
branch_name_pattern: z.string().optional().describe('Allowed branch name pattern (glob)'),
tag_name_pattern: z.string().optional().describe('Allowed tag name pattern (glob)'),
require_secret_block: z.boolean().optional().describe('Block pushes containing detected secrets'),
max_file_size: z.number().optional().describe('Maximum allowed pushed file size in bytes (0 = unlimited)'),
blocked_file_patterns: z.string().optional().describe('File path patterns to block on push (glob, semicolon-separated)'),
...ConnectionParam,
},
async ({ org, connection, ...fields }) => {
const body: Record<string, unknown> = {};
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) body[k] = v;
}
return formatResponse(await clientFor(connection).patch(`/orgs/${org}/push_policy`, body));
},
);
server.tool(
'gitea_org_push_policy_delete',
'Delete (reset) the organization push policy (requires org owner)',
{ org: z.string().describe('Organization name'), ...ConnectionParam },
async ({ org, connection }) => formatResponse(await clientFor(connection).delete(`/orgs/${org}/push_policy`)),
);
// ── Organization Repo Defaults (singleton, org-governance) ───────────────
server.tool(
'gitea_org_repo_defaults_get',
'Get the organization default repository settings (requires org owner)',
{ org: z.string().describe('Organization name'), ...ConnectionParam },
async ({ org, connection }) => formatResponse(await clientFor(connection).get(`/orgs/${org}/repo_defaults`)),
);
server.tool(
'gitea_org_repo_defaults_edit',
'Edit the organization default repository settings (only provided fields change; requires org owner)',
{
org: z.string().describe('Organization name'),
force_private: z.boolean().optional().describe('Force all new repos to be private'),
apply_pr_defaults: z.boolean().optional().describe('Apply the PR merge defaults below to new repos'),
allow_merge: z.boolean().optional().describe('Allow merge commits'),
allow_rebase: z.boolean().optional().describe('Allow rebase merging'),
allow_rebase_merge: z.boolean().optional().describe('Allow rebase with merge commit'),
allow_squash: z.boolean().optional().describe('Allow squash merging'),
allow_fast_forward_only: z.boolean().optional().describe('Allow fast-forward-only merging'),
default_merge_style: z.string().optional().describe('Default merge style (merge, rebase, rebase-merge, squash, fast-forward-only)'),
delete_branch_after_merge: z.boolean().optional().describe('Delete head branch after merge by default'),
...ConnectionParam,
},
async ({ org, connection, ...fields }) => {
const body: Record<string, unknown> = {};
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) body[k] = v;
}
return formatResponse(await clientFor(connection).patch(`/orgs/${org}/repo_defaults`, body));
},
);
server.tool(
'gitea_org_repo_defaults_delete',
'Delete (reset) the organization default repository settings (requires org owner)',
{ org: z.string().describe('Organization name'), ...ConnectionParam },
async ({ org, connection }) => formatResponse(await clientFor(connection).delete(`/orgs/${org}/repo_defaults`)),
);
// ── Organization Email Domain Policy (singleton, org-governance) ─────────
server.tool(
'gitea_org_email_domain_policy_get',
'Get the organization email domain policy (requires org owner)',
{ org: z.string().describe('Organization name'), ...ConnectionParam },
async ({ org, connection }) => formatResponse(await clientFor(connection).get(`/orgs/${org}/email_domain_policy`)),
);
server.tool(
'gitea_org_email_domain_policy_edit',
'Edit the organization email domain policy (only provided fields change; requires org owner)',
{
org: z.string().describe('Organization name'),
allowed_domains: z.string().optional().describe('Allowed member email domains (comma-separated, e.g. "example.com,foo.org")'),
...ConnectionParam,
},
async ({ org, allowed_domains, connection }) => {
const body: Record<string, unknown> = {};
if (allowed_domains !== undefined) body.allowed_domains = allowed_domains;
return formatResponse(await clientFor(connection).patch(`/orgs/${org}/email_domain_policy`, body));
},
);
server.tool(
'gitea_org_email_domain_policy_delete',
'Delete (reset) the organization email domain policy (requires org owner)',
{ org: z.string().describe('Organization name'), ...ConnectionParam },
async ({ org, connection }) => formatResponse(await clientFor(connection).delete(`/orgs/${org}/email_domain_policy`)),
);
// ── Start Server ────────────────────────────────────────────────────────
async function main(): Promise<void> {