Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eccb0de243 | |||
| f80c99db3c | |||
| d32074581d | |||
| 65432aaec6 | |||
| 01da6a48b1 | |||
| 3e86c5181e | |||
| 1ffe31e360 | |||
| e2e80de6fa | |||
| 759a8f590c | |||
| 55c2f81c58 | |||
| b3ee5cc18a | |||
| 4e1a90c4e4 | |||
| 7532b9ff55 | |||
| dd6e114c70 | |||
| 1f6af9dd0a | |||
| 2d9ca59599 | |||
| 7e615516eb | |||
| 34fe0c5934 | |||
| 3aaa7c0843 | |||
| c568e199ed | |||
| 37ae3c5ec5 | |||
| b9937fabd9 | |||
| 9313ae2731 | |||
| 2b5a4dd11c | |||
| 413f7160b3 | |||
| 685d2211a8 | |||
| e80781fa6e | |||
| cfdb9b4f0a | |||
| 0109a2db12 | |||
| ad4451f23c | |||
| fe6ca172f6 |
@@ -0,0 +1,42 @@
|
||||
# MokoGitea
|
||||
|
||||
Fork of Gitea -- self-hosted Git service at git.mokoconsulting.tech. Go backend + TypeScript frontend.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Language** | Go 1.26+ / TypeScript |
|
||||
| **Module** | `code.mokoconsulting.tech/MokoConsulting/MokoGitea` |
|
||||
| **Branch** | develop on `dev`, merge to `main` (protected) |
|
||||
| **Wiki** | [MokoGitea Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make help # List all available targets
|
||||
make fmt # Format .go files
|
||||
make lint-go # Lint Go code
|
||||
make lint-js # Lint TypeScript
|
||||
make tidy # After go.mod changes
|
||||
make build # Build binary
|
||||
|
||||
# Testing
|
||||
go test -run '^TestName$' ./modulepath/ # Single Go test
|
||||
pnpm exec vitest <path-filter> # Single JS test
|
||||
GITEA_TEST_E2E_FLAGS='<filepath>' make test-e2e # Single Playwright test
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Add current year copyright header on new `.go` files
|
||||
- No trailing whitespace in edited files
|
||||
- Conventional Commits for commit messages and PR titles
|
||||
- Never force-push, amend, or squash unless asked -- use new commits
|
||||
- Preserve existing code comments
|
||||
- TypeScript: use `!` (non-null assertion) not `?.`/`??` when value is known to exist
|
||||
- CSS: prefer `flex-*` helpers over per-child `tw-ml-*`/`tw-mr-*` margins
|
||||
- Add `Co-Authored-By` lines to all commits
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Attribution**: `Authored-by: Moko Consulting`
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
@@ -3,8 +3,8 @@
|
||||
<identity>
|
||||
<name>MokoGitea</name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Moko fork of Gitea — adding project board REST API endpoints and custom enhancements</description>
|
||||
<version>05.46.00</version>
|
||||
<description>Moko fork of Gitea -- adding project board REST API endpoints and custom enhancements</description>
|
||||
<version>05.47.00</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
DEFGROUP: gitea-api-mcp.Documentation
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/gitea-api-mcp
|
||||
-->
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Renamed** package from `@mokoconsulting/gitea-api-mcp` to `@mokoconsulting/mokogitea-api-mcp` to distinguish Moko's forked Gitea MCP from upstream
|
||||
- **Renamed** McpServer name and bin entry to `mokogitea-api-mcp`
|
||||
|
||||
|
||||
## [0.0] - 2026-05-07
|
||||
|
||||
### Added
|
||||
|
||||
#### User / Auth (3 tools)
|
||||
- `gitea_me` -- Get the authenticated user info
|
||||
- `gitea_user_orgs` -- List organizations the authenticated user belongs to
|
||||
- `gitea_user_repos` -- List repositories owned by the authenticated user
|
||||
|
||||
#### Repositories (8 tools)
|
||||
- `gitea_repo_get` -- Get repository details
|
||||
- `gitea_repo_create` -- Create a new repository
|
||||
- `gitea_repo_delete` -- Delete a repository
|
||||
- `gitea_repo_edit` -- Edit repository settings
|
||||
- `gitea_repo_fork` -- Fork a repository
|
||||
- `gitea_repo_search` -- Search repositories
|
||||
- `gitea_org_repos` -- List repositories in an organization
|
||||
- `gitea_list_connections` -- List configured Gitea connections
|
||||
|
||||
#### File Contents (5 tools)
|
||||
- `gitea_file_get` -- Get file contents from a repository
|
||||
- `gitea_dir_get` -- Get directory contents (file listing) from a repository
|
||||
- `gitea_file_create_or_update` -- Create or update a file in a repository
|
||||
- `gitea_file_delete` -- Delete a file from a repository
|
||||
- `gitea_tree_get` -- Get the git tree for a repository (recursive file listing)
|
||||
|
||||
#### Branches (4 tools)
|
||||
- `gitea_branches_list` -- List branches in a repository
|
||||
- `gitea_branch_get` -- Get a specific branch
|
||||
- `gitea_branch_create` -- Create a new branch
|
||||
- `gitea_branch_delete` -- Delete a branch
|
||||
|
||||
#### Commits (2 tools)
|
||||
- `gitea_commits_list` -- List commits in a repository
|
||||
- `gitea_commit_get` -- Get a specific commit
|
||||
|
||||
#### Issues (7 tools)
|
||||
- `gitea_issues_list` -- List issues in a repository
|
||||
- `gitea_issue_get` -- Get a single issue by number
|
||||
- `gitea_issue_create` -- Create a new issue
|
||||
- `gitea_issue_update` -- Update an issue
|
||||
- `gitea_issue_comments_list` -- List comments on an issue
|
||||
- `gitea_issue_comment_create` -- Add a comment to an issue
|
||||
- `gitea_issue_search` -- Search issues across all repositories
|
||||
|
||||
#### Labels (2 tools)
|
||||
- `gitea_labels_list` -- List labels in a repository
|
||||
- `gitea_label_create` -- Create a label
|
||||
|
||||
#### Milestones (2 tools)
|
||||
- `gitea_milestones_list` -- List milestones in a repository
|
||||
- `gitea_milestone_create` -- Create a milestone
|
||||
|
||||
#### Pull Requests (6 tools)
|
||||
- `gitea_pulls_list` -- List pull requests
|
||||
- `gitea_pull_get` -- Get a single pull request
|
||||
- `gitea_pull_create` -- Create a pull request
|
||||
- `gitea_pull_merge` -- Merge a pull request
|
||||
- `gitea_pull_files` -- List files changed in a pull request
|
||||
- `gitea_pull_review_create` -- Create a pull request review
|
||||
|
||||
#### Releases (5 tools)
|
||||
- `gitea_releases_list` -- List releases
|
||||
- `gitea_release_get` -- Get a single release by ID
|
||||
- `gitea_release_latest` -- Get the latest release
|
||||
- `gitea_release_create` -- Create a new release
|
||||
- `gitea_release_delete` -- Delete a release
|
||||
|
||||
#### Tags (3 tools)
|
||||
- `gitea_tags_list` -- List tags
|
||||
- `gitea_tag_create` -- Create a tag
|
||||
- `gitea_tag_delete` -- Delete a tag
|
||||
|
||||
#### Actions (2 tools)
|
||||
- `gitea_actions_runs_list` -- List workflow runs for a repository
|
||||
- `gitea_actions_run_get` -- Get a specific workflow run
|
||||
|
||||
#### Organizations (3 tools)
|
||||
- `gitea_org_get` -- Get organization details
|
||||
- `gitea_org_teams_list` -- List teams in an organization
|
||||
- `gitea_org_members_list` -- List members of an organization
|
||||
|
||||
#### Users (2 tools)
|
||||
- `gitea_user_get` -- Get a user profile
|
||||
- `gitea_users_search` -- Search users
|
||||
|
||||
#### Webhooks (2 tools)
|
||||
- `gitea_webhooks_list` -- List webhooks for a repository
|
||||
- `gitea_webhook_create` -- Create a webhook
|
||||
|
||||
#### Wiki (2 tools)
|
||||
- `gitea_wiki_pages_list` -- List wiki pages
|
||||
- `gitea_wiki_page_get` -- Get a wiki page
|
||||
|
||||
#### Notifications (2 tools)
|
||||
- `gitea_notifications_list` -- List notifications for the authenticated user
|
||||
- `gitea_notifications_read` -- Mark all notifications as read
|
||||
|
||||
#### Generic (2 tools)
|
||||
- `gitea_api_request` -- Make a raw API request to any Gitea v1 endpoint
|
||||
- `gitea_list_connections` -- List configured Gitea connections
|
||||
|
||||
### Infrastructure
|
||||
- Multi-connection config support via `~/.gitea-api-mcp.json`
|
||||
- Token-based authentication (Gitea native `Authorization: token` header)
|
||||
- Built on `node:https` / `node:http` (zero HTTP dependencies)
|
||||
- MCP SDK v1.12.x with stdio transport
|
||||
|
||||
[0.0.1]: https://git.mokoconsulting.tech/MokoConsulting/gitea-api-mcp/releases/tag/v0.0.1
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --production=false
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
RUN npx tsc && npm prune --production
|
||||
|
||||
EXPOSE 3100
|
||||
|
||||
ENV PORT=3100
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# SSE mode by default for Docker deployments
|
||||
CMD ["node", "dist/sse.js"]
|
||||
@@ -0,0 +1,116 @@
|
||||
# MokoGitea MCP Server
|
||||
|
||||
A comprehensive [Model Context Protocol](https://modelcontextprotocol.io) server for [Gitea](https://gitea.com) and [MokoGitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea). 120+ tools for repos, issues, PRs, projects, releases, custom fields, statuses, priorities, and manifests.
|
||||
|
||||
Works with any Gitea instance. MokoGitea-specific features degrade gracefully on vanilla Gitea.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### npx (no install)
|
||||
|
||||
```bash
|
||||
GITEA_URL=https://gitea.example.com GITEA_TOKEN=your_token npx @mokoconsulting/mokogitea-mcp
|
||||
```
|
||||
|
||||
### Claude Code
|
||||
|
||||
Add to `.claude.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mokogitea": {
|
||||
"command": "npx",
|
||||
"args": ["@mokoconsulting/mokogitea-mcp"],
|
||||
"env": {
|
||||
"GITEA_URL": "https://gitea.example.com",
|
||||
"GITEA_TOKEN": "your_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Docker (SSE mode)
|
||||
|
||||
```bash
|
||||
docker run -p 3100:3100 \
|
||||
-e GITEA_URL=https://gitea.example.com \
|
||||
-e GITEA_TOKEN=your_token \
|
||||
mokoconsulting/mokogitea-mcp
|
||||
```
|
||||
|
||||
Connect MCP client to `http://localhost:3100/sse`.
|
||||
|
||||
### Multi-instance config
|
||||
|
||||
Create `~/.mcp_mokogitea.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"defaultConnection": "production",
|
||||
"connections": {
|
||||
"production": { "baseUrl": "https://gitea.example.com", "token": "your_token" },
|
||||
"dev": { "baseUrl": "https://dev.gitea.example.com", "token": "dev_token" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `GITEA_URL` + `GITEA_TOKEN` env vars | Single instance, quick setup |
|
||||
| `~/.mcp_mokogitea.json` config file | Multiple instances |
|
||||
| `GITEA_API_MCP_CONFIG` env var | Custom config path |
|
||||
| `GITEA_INSECURE=true` | Skip TLS verification |
|
||||
|
||||
## Tools (120+)
|
||||
|
||||
### Repositories
|
||||
`gitea_repo_create` `gitea_repo_get` `gitea_repo_edit` `gitea_repo_delete` `gitea_repo_search` `gitea_repo_fork` `gitea_repo_generate` `gitea_repo_languages` `gitea_repo_contributors` `gitea_repo_topics` `gitea_repo_topics_set`
|
||||
|
||||
### Issues
|
||||
`gitea_issue_create` (dedup by title) `gitea_issue_get` `gitea_issue_update` `gitea_issues_list` `gitea_issue_search` `gitea_issue_comment_create` `gitea_issue_comments_list` `gitea_issue_labels_set` `gitea_issue_bulk_set_status`
|
||||
|
||||
### Pull Requests
|
||||
`gitea_pull_create` `gitea_pull_get` `gitea_pulls_list` `gitea_pull_merge` `gitea_pull_files` `gitea_pull_review_create`
|
||||
|
||||
### Branches and Tags
|
||||
`gitea_branches_list` `gitea_branch_create` `gitea_branch_delete` `gitea_branch_get` `gitea_tags_list` `gitea_tag_create` `gitea_tag_delete`
|
||||
|
||||
### Releases
|
||||
`gitea_releases_list` `gitea_release_create` `gitea_release_get` `gitea_release_latest` `gitea_release_delete` `gitea_release_asset_upload` `gitea_release_asset_delete`
|
||||
|
||||
### Files and Trees
|
||||
`gitea_file_get` `gitea_file_create_or_update` `gitea_file_delete` `gitea_dir_get` `gitea_tree_get` `gitea_bulk_file_push`
|
||||
|
||||
### Projects
|
||||
`gitea_project_list` `gitea_project_create` `gitea_project_get` `gitea_project_update` `gitea_project_delete` `gitea_project_overview` `gitea_project_columns_list` `gitea_project_column_create` `gitea_project_column_delete` `gitea_project_cards_list` `gitea_project_card_add` `gitea_project_card_move` `gitea_project_card_remove`
|
||||
|
||||
### Organizations
|
||||
`gitea_org_get` `gitea_org_repos` `gitea_org_members_list` `gitea_org_teams_list` `gitea_org_labels_list` `gitea_org_label_create`
|
||||
|
||||
### Wiki
|
||||
`gitea_wiki_pages_list` `gitea_wiki_page_get`
|
||||
|
||||
### MokoGitea Extensions
|
||||
`gitea_manifest_get` `gitea_manifest_update` `gitea_org_custom_fields_list` `gitea_org_custom_field_create` `gitea_org_custom_field_delete` `gitea_issue_custom_fields_get` `gitea_issue_custom_fields_set` `gitea_org_issue_statuses_list` `gitea_issue_set_status` `gitea_org_issue_priorities_list` `gitea_issue_set_priority`
|
||||
|
||||
### Admin and Other
|
||||
`gitea_me` `gitea_users_search` `gitea_user_get` `gitea_notifications_list` `gitea_notifications_read` `gitea_commits_list` `gitea_commit_get` `gitea_compare` `gitea_webhooks_list` `gitea_webhook_create` `gitea_admin_users_list` `gitea_admin_orgs_list` `gitea_admin_cron_list` `gitea_admin_cron_run` `gitea_list_connections`
|
||||
|
||||
## SSE Server
|
||||
|
||||
For hosted deployments:
|
||||
|
||||
```
|
||||
GET / Server info
|
||||
GET /sse SSE connection endpoint
|
||||
POST /message Tool call messages
|
||||
GET /health Health check
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0-or-later - [Moko Consulting](https://mokoconsulting.tech)
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"defaultConnection": "moko",
|
||||
"connections": {
|
||||
"moko": {
|
||||
"baseUrl": "https://git.mokoconsulting.tech",
|
||||
"token": "your-gitea-access-token"
|
||||
},
|
||||
"github-mirror": {
|
||||
"baseUrl": "https://gitea.example.com",
|
||||
"token": "your-other-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1198
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@mokoconsulting/mokogitea-mcp",
|
||||
"version": "1.1.0",
|
||||
"description": "MCP server for Gitea and MokoGitea - 120+ tools for repos, issues, PRs, projects, releases, custom fields, statuses, priorities, and manifests",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"mokogitea-mcp": "dist/index.js",
|
||||
"mokogitea-mcp-sse": "dist/sse.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js",
|
||||
"start:sse": "node dist/sse.js",
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"gitea",
|
||||
"mokogitea",
|
||||
"model-context-protocol",
|
||||
"claude",
|
||||
"ai",
|
||||
"git",
|
||||
"self-hosted",
|
||||
"api",
|
||||
"devops"
|
||||
],
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Moko Consulting <hello@mokoconsulting.tech>",
|
||||
"homepage": "https://git.mokoconsulting.tech/MokoConsulting/mcp_mokogitea_api",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mokoconsulting.tech/MokoConsulting/mcp_mokogitea_api.git"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"config.example.json",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# mcp_mokogitea_api PowerShell Profile
|
||||
# Source this with: . ./profile.ps1
|
||||
|
||||
$env:MCP_ROOT = $PSScriptRoot
|
||||
$env:TEMP = 'A:\temp'
|
||||
$env:TMP = 'A:\temp'
|
||||
|
||||
function mcp { Set-Location $PSScriptRoot }
|
||||
function mcp-src { Set-Location (Join-Path $PSScriptRoot 'src') }
|
||||
function mcp-build { Set-Location $PSScriptRoot; npm run build }
|
||||
function mcp-dev { Set-Location $PSScriptRoot; npm run dev }
|
||||
|
||||
Write-Host "mcp_mokogitea_api profile loaded" -ForegroundColor Cyan
|
||||
Write-Host " Commands: mcp-build, mcp-dev" -ForegroundColor DarkGray
|
||||
Write-Host " Navigate: mcp, mcp-src" -ForegroundColor DarkGray
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* BRIEF: Interactive setup — prompts for Gitea connection details
|
||||
*/
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const CONFIG_PATH = resolve(homedir(), '.gitea-api-mcp.json');
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
async function prompt(q, d) { const a = await rl.question(`${q}${d ? ` [${d}]` : ''}: `); return a.trim() || d || ''; }
|
||||
async function promptRequired(q) { let a = ''; while (!a) { a = (await rl.question(`${q}: `)).trim(); if (!a) console.log(' Required.'); } return a; }
|
||||
|
||||
async function main() {
|
||||
console.log('\n=== gitea-api-mcp Setup ===\n');
|
||||
let existing = null;
|
||||
try { existing = JSON.parse(await readFile(CONFIG_PATH, 'utf-8')); console.log(`Existing: ${Object.keys(existing.connections).join(', ')}\n`); } catch {}
|
||||
|
||||
const name = await prompt('Connection name', 'moko');
|
||||
const baseUrl = await promptRequired('Gitea URL (e.g. https://git.mokoconsulting.tech)');
|
||||
const token = await promptRequired('Access token (Settings > Applications > Generate Token)');
|
||||
const insecure = (await prompt('Skip TLS verification? (y/N)', 'N')).toLowerCase() === 'y';
|
||||
|
||||
const conn = { baseUrl: baseUrl.replace(/\/+$/, ''), token };
|
||||
if (insecure) conn.insecure = true;
|
||||
|
||||
const config = existing ?? { defaultConnection: name, connections: {} };
|
||||
config.connections[name] = conn;
|
||||
if (!existing) config.defaultConnection = name;
|
||||
else if ((await prompt(`Set "${name}" as default? (y/N)`, 'N')).toLowerCase() === 'y') config.defaultConnection = name;
|
||||
|
||||
await writeFile(CONFIG_PATH, JSON.stringify(config, null, '\t') + '\n', 'utf-8');
|
||||
console.log(`\nConfig written to ${CONFIG_PATH}\n`);
|
||||
rl.close();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e.message); rl.close(); process.exit(1); });
|
||||
@@ -0,0 +1,120 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: gitea-api-mcp.Client
|
||||
* INGROUP: gitea-api-mcp
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/gitea-api-mcp
|
||||
* PATH: /src/client.ts
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: HTTP client for Gitea REST API v1
|
||||
*/
|
||||
|
||||
import * as https from 'node:https';
|
||||
import * as http from 'node:http';
|
||||
import type { GiteaConnection, ApiResponse } from './types.js';
|
||||
|
||||
const API_PREFIX = '/api/v1';
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
export class GiteaClient {
|
||||
private readonly base_url: string;
|
||||
private readonly headers: Record<string, string>;
|
||||
private readonly insecure: boolean;
|
||||
|
||||
constructor(conn: GiteaConnection) {
|
||||
this.base_url = conn.baseUrl.replace(/\/+$/, '') + API_PREFIX;
|
||||
this.headers = {
|
||||
'Authorization': `token ${conn.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
this.insecure = conn.insecure ?? false;
|
||||
}
|
||||
|
||||
async get(endpoint: string, params?: Record<string, string>): Promise<ApiResponse> {
|
||||
return this.request(this.buildUrl(endpoint, params), 'GET');
|
||||
}
|
||||
|
||||
async post(endpoint: string, body?: unknown): Promise<ApiResponse> {
|
||||
return this.request(this.buildUrl(endpoint), 'POST', body);
|
||||
}
|
||||
|
||||
async patch(endpoint: string, body: unknown): Promise<ApiResponse> {
|
||||
return this.request(this.buildUrl(endpoint), 'PATCH', body);
|
||||
}
|
||||
|
||||
async put(endpoint: string, body: unknown): Promise<ApiResponse> {
|
||||
return this.request(this.buildUrl(endpoint), 'PUT', body);
|
||||
}
|
||||
|
||||
async delete(endpoint: string): Promise<ApiResponse> {
|
||||
return this.request(this.buildUrl(endpoint), 'DELETE');
|
||||
}
|
||||
|
||||
private buildUrl(endpoint: string, params?: Record<string, string>): string {
|
||||
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
const url = new URL(`${this.base_url}${path}`);
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private request(url: string, method: string, body?: unknown): Promise<ApiResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const is_https = parsed.protocol === 'https:';
|
||||
const transport = is_https ? https : http;
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (is_https ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method,
|
||||
headers: { ...this.headers },
|
||||
timeout: TIMEOUT_MS,
|
||||
};
|
||||
|
||||
if (this.insecure && is_https) {
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
const payload = body !== undefined ? JSON.stringify(body) : undefined;
|
||||
if (payload) {
|
||||
(options.headers as Record<string, string>)['Content-Length'] = Buffer.byteLength(payload).toString();
|
||||
}
|
||||
|
||||
const req = transport.request(options, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
data = raw;
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, data });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => reject(err));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timed out'));
|
||||
});
|
||||
|
||||
if (payload) {
|
||||
req.write(payload);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { GiteaConfig, GiteaConnection } from './types.js';
|
||||
|
||||
const CONFIG_FILENAME = '.mcp_mokogitea.json';
|
||||
|
||||
export async function loadConfig(): Promise<GiteaConfig> {
|
||||
// Priority 1: Environment variables (zero-config single instance)
|
||||
if (process.env.GITEA_URL && process.env.GITEA_TOKEN) {
|
||||
const conn: GiteaConnection = {
|
||||
baseUrl: process.env.GITEA_URL,
|
||||
token: process.env.GITEA_TOKEN,
|
||||
insecure: process.env.GITEA_INSECURE === 'true',
|
||||
};
|
||||
return {
|
||||
connections: { default: conn },
|
||||
defaultConnection: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: Config file
|
||||
const config_path = process.env.GITEA_API_MCP_CONFIG
|
||||
? resolve(process.env.GITEA_API_MCP_CONFIG)
|
||||
: resolve(homedir(), CONFIG_FILENAME);
|
||||
|
||||
try {
|
||||
const raw = await readFile(config_path, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Partial<GiteaConfig>;
|
||||
|
||||
if (!parsed.connections || Object.keys(parsed.connections).length === 0) {
|
||||
throw new Error('No connections defined in config');
|
||||
}
|
||||
|
||||
return {
|
||||
connections: parsed.connections,
|
||||
defaultConnection: parsed.defaultConnection ?? Object.keys(parsed.connections)[0],
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Failed to load config from ${config_path}: ${message}\n` +
|
||||
`Option 1: Set GITEA_URL and GITEA_TOKEN environment variables\n` +
|
||||
`Option 2: Create ${config_path} - see config.example.json for format`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getConnection(config: GiteaConfig, name?: string): GiteaConnection {
|
||||
const key = name ?? config.defaultConnection;
|
||||
const conn = config.connections[key];
|
||||
if (!conn) {
|
||||
throw new Error(
|
||||
`Connection "${key}" not found. Available: ${Object.keys(config.connections).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Creates a configured MCP server instance for use by both stdio and SSE transports.
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { GiteaConfig } from './types.js';
|
||||
|
||||
// Import index.ts to register all tools on its exported `server` singleton,
|
||||
// then re-export a factory that initializes config and returns the server.
|
||||
import { server, initConfig } from './index.js';
|
||||
|
||||
export function createMcpServer(cfg: GiteaConfig): McpServer {
|
||||
initConfig(cfg);
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// SSE transport entry point for MokoGitea MCP server.
|
||||
// Run with: node dist/sse.js
|
||||
// Or: GITEA_URL=https://gitea.example.com GITEA_TOKEN=xxx node dist/sse.js
|
||||
//
|
||||
// Listens on PORT (default 3100) and serves SSE at /sse with POST at /message.
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { createMcpServer } from './server.js';
|
||||
import { loadConfig } from './config.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '3100', 10);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = await loadConfig();
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
const httpServer = createServer(async (req, res) => {
|
||||
// CORS headers for browser clients
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok', tools: 120 }));
|
||||
return;
|
||||
}
|
||||
|
||||
// SSE endpoint - client connects here
|
||||
if (req.url === '/sse' && req.method === 'GET') {
|
||||
const transport = new SSEServerTransport('/message', res);
|
||||
const sessionId = transport.sessionId;
|
||||
transports.set(sessionId, transport);
|
||||
|
||||
const server = createMcpServer(config);
|
||||
await server.connect(transport);
|
||||
|
||||
req.on('close', () => {
|
||||
transports.delete(sessionId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Message endpoint - client sends tool calls here
|
||||
if (req.url?.startsWith('/message') && req.method === 'POST') {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
if (!sessionId || !transports.has(sessionId)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid or missing sessionId' }));
|
||||
return;
|
||||
}
|
||||
const transport = transports.get(sessionId)!;
|
||||
await transport.handlePostMessage(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Root - info page
|
||||
if (req.url === '/' || req.url === '') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
name: '@mokoconsulting/mokogitea-mcp',
|
||||
version: '1.1.0',
|
||||
description: 'MCP server for Gitea and MokoGitea - 120+ tools',
|
||||
endpoints: {
|
||||
sse: '/sse',
|
||||
message: '/message',
|
||||
health: '/health',
|
||||
},
|
||||
docs: 'https://git.mokoconsulting.tech/MokoConsulting/mcp_mokogitea_api',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
process.stderr.write(`MokoGitea MCP SSE server listening on port ${PORT}\n`);
|
||||
process.stderr.write(` SSE: http://localhost:${PORT}/sse\n`);
|
||||
process.stderr.write(` Health: http://localhost:${PORT}/health\n`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`Fatal: ${err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: gitea-api-mcp.Types
|
||||
* INGROUP: gitea-api-mcp
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/gitea-api-mcp
|
||||
* PATH: /src/types.ts
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: TypeScript type definitions for Gitea API MCP server
|
||||
*/
|
||||
|
||||
export interface GiteaConnection {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
/** Skip TLS certificate verification (self-signed certs) */
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubBackupConfig {
|
||||
token: string;
|
||||
org: string;
|
||||
}
|
||||
|
||||
export interface GiteaConfig {
|
||||
connections: Record<string, GiteaConnection>;
|
||||
defaultConnection: string;
|
||||
github?: GitHubBackupConfig;
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 05.46.00
|
||||
# VERSION: 05.47.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- dev
|
||||
pull_request_target:
|
||||
types: [synchronize, opened, reopened]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
|
||||
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
|
||||
if [ -f “/opt/moko-platform/cli/version_bump.php” ] && [ -f “/opt/moko-platform/vendor/autoload.php” ]; then
|
||||
echo “Using pre-installed /opt/moko-platform”
|
||||
echo “MOKO_CLI=/opt/moko-platform/cli” >> “$GITHUB_ENV”
|
||||
else
|
||||
echo “Falling back to fresh clone”
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
“https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git” \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo “MOKO_CLI=/tmp/moko-platform-api/cli” >> “$GITHUB_ENV”
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
# Auto-detect stability: RC for PRs targeting main, else use input or default to development
|
||||
if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
|
||||
STABILITY="release-candidate"
|
||||
else
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
fi
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
|
||||
case "$STABILITY" in
|
||||
release-candidate) BUMP="minor" ;;
|
||||
*) BUMP="patch" ;;
|
||||
esac
|
||||
|
||||
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
|
||||
|
||||
# Set stability suffix and verify consistency
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Append suffix for output
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element via manifest_element.php
|
||||
php ${MOKO_CLI}/manifest_element.php \
|
||||
--path . --version "$VERSION" --stability "$STABILITY" \
|
||||
--repo "${GITEA_REPO}" --github-output
|
||||
|
||||
# Read back element outputs
|
||||
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
else
|
||||
NOTES="Release ${VERSION}"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
# updates.xml is generated dynamically by MokoGitea license server
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
php ${MOKO_CLI}/release_cascade.php \
|
||||
--stability "${{ steps.meta.outputs.stability }}" \
|
||||
--token "${TOKEN}" \
|
||||
--api-base "${API_BASE}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,16 +0,0 @@
|
||||
- Use `make help` to find available development targets
|
||||
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them
|
||||
- Run `make lint-js` to lint `.ts` files
|
||||
- Run `make tidy` after any `go.mod` changes
|
||||
- Run single go tests with `go test -run '^TestName$' ./modulepath/`
|
||||
- Run single js test files with `pnpm exec vitest <path-filter>`
|
||||
- Run single playwright e2e test files with `GITEA_TEST_E2E_FLAGS='<filepath>' make test-e2e`
|
||||
- Add the current year into the copyright header of new `.go` files
|
||||
- Ensure no trailing whitespace in edited files
|
||||
- Use Conventional Commits format for commit messages and PR titles (e.g. `type(scope): subject`)
|
||||
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
||||
- Preserve existing code comments, do not remove or rewrite comments that are still relevant
|
||||
- In TypeScript, use `!` (non-null assertion) instead of `?.`/`??` when a value is known to always exist
|
||||
- For CSS layout, prefer `flex-*` helpers over per-child `tw-ml-*` / `tw-mr-*` margins; fall back to `tw-*` utilities when specificity requires `!important`
|
||||
- Include authorship attribution in issue and pull request comments
|
||||
- Add `Co-Authored-By` lines to all commits, indicating name and model used
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to MokoGitea are documented here. Versions follow the format
|
||||
`v{upstream}-moko.{major}.{minor}` (e.g. `v1.26.1-moko.06.02`).
|
||||
`v{upstream}-moko.{major}.{minor}` (e.g. `v1.26.1-moko.06.03`).
|
||||
|
||||
## [v1.26.1-moko.06] - 2026-06-04
|
||||
## [v1.26.1-moko.06.04] - 2026-06-06
|
||||
|
||||
* FEATURES
|
||||
* feat(licenses): full commercial license management system
|
||||
|
||||
@@ -76,6 +76,10 @@ type Issue struct {
|
||||
Assignee *user_model.User `xorm:"-"`
|
||||
isAssigneeLoaded bool `xorm:"-"`
|
||||
IsClosed bool `xorm:"INDEX"`
|
||||
StatusID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'status_id'"`
|
||||
Status *IssueStatusDef `xorm:"-"`
|
||||
PriorityID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'priority_id'"`
|
||||
PriorityDef *IssuePriorityDef `xorm:"-"`
|
||||
IsRead bool `xorm:"-"`
|
||||
IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not.
|
||||
PullRequest *PullRequest `xorm:"-"`
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package issues
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(IssuePriorityDef))
|
||||
}
|
||||
|
||||
// IssuePriorityDef defines a custom issue priority at the org level.
|
||||
type IssuePriorityDef struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'org_id'"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
Color string `xorm:"VARCHAR(7)"`
|
||||
Description string `xorm:"TEXT"`
|
||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||
IsDefault bool `xorm:"NOT NULL DEFAULT false 'is_default'"`
|
||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
func (IssuePriorityDef) TableName() string {
|
||||
return "issue_priority_def"
|
||||
}
|
||||
|
||||
// GetIssuePriorityDefsByOrg returns active priority definitions for an org.
|
||||
func GetIssuePriorityDefsByOrg(ctx context.Context, orgID int64) ([]*IssuePriorityDef, error) {
|
||||
defs := make([]*IssuePriorityDef, 0, 10)
|
||||
return defs, db.GetEngine(ctx).
|
||||
Where("org_id = ? AND is_active = ?", orgID, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&defs)
|
||||
}
|
||||
|
||||
// GetAllIssuePriorityDefsByOrg returns all priority definitions (including inactive).
|
||||
func GetAllIssuePriorityDefsByOrg(ctx context.Context, orgID int64) ([]*IssuePriorityDef, error) {
|
||||
defs := make([]*IssuePriorityDef, 0, 10)
|
||||
return defs, db.GetEngine(ctx).
|
||||
Where("org_id = ?", orgID).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&defs)
|
||||
}
|
||||
|
||||
// GetIssuePriorityDefByID returns a single priority definition.
|
||||
func GetIssuePriorityDefByID(ctx context.Context, id int64) (*IssuePriorityDef, error) {
|
||||
def := new(IssuePriorityDef)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(def)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, db.ErrNotExist{Resource: "IssuePriorityDef", ID: id}
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
// CreateIssuePriorityDef creates a new priority definition.
|
||||
func CreateIssuePriorityDef(ctx context.Context, def *IssuePriorityDef) error {
|
||||
_, err := db.GetEngine(ctx).Insert(def)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateIssuePriorityDef updates a priority definition.
|
||||
func UpdateIssuePriorityDef(ctx context.Context, def *IssuePriorityDef) error {
|
||||
_, err := db.GetEngine(ctx).ID(def.ID).AllCols().Update(def)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteIssuePriorityDef deletes a priority definition and clears references on issues.
|
||||
func DeleteIssuePriorityDef(ctx context.Context, id int64) error {
|
||||
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET priority_id = 0 WHERE priority_id = ?", id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssuePriorityDef))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetIssuePriorityID updates the priority_id on an issue.
|
||||
func SetIssuePriorityID(ctx context.Context, issueID, priorityID int64) error {
|
||||
_, err := db.GetEngine(ctx).Exec("UPDATE issue SET priority_id = ? WHERE id = ?", priorityID, issueID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package issues
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(IssueStatusDef))
|
||||
}
|
||||
|
||||
// IssueStatusDef defines a custom issue status at the org level.
|
||||
type IssueStatusDef struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'org_id'"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
|
||||
Description string `xorm:"TEXT"`
|
||||
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
func (IssueStatusDef) TableName() string {
|
||||
return "issue_status_def"
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Queries
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetIssueStatusDefsByOrg returns active status definitions for an org.
|
||||
func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDef, error) {
|
||||
defs := make([]*IssueStatusDef, 0, 10)
|
||||
return defs, db.GetEngine(ctx).
|
||||
Where("org_id = ? AND is_active = ?", orgID, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&defs)
|
||||
}
|
||||
|
||||
// GetAllIssueStatusDefsByOrg returns all status definitions (including inactive).
|
||||
func GetAllIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDef, error) {
|
||||
defs := make([]*IssueStatusDef, 0, 10)
|
||||
return defs, db.GetEngine(ctx).
|
||||
Where("org_id = ?", orgID).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&defs)
|
||||
}
|
||||
|
||||
// GetIssueStatusDefByID returns a single status definition.
|
||||
func GetIssueStatusDefByID(ctx context.Context, id int64) (*IssueStatusDef, error) {
|
||||
def := new(IssueStatusDef)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(def)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, db.ErrNotExist{Resource: "IssueStatusDef", ID: id}
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// CRUD
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// CreateIssueStatusDef creates a new status definition.
|
||||
func CreateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
|
||||
_, err := db.GetEngine(ctx).Insert(def)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateIssueStatusDef updates a status definition.
|
||||
func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
|
||||
_, err := db.GetEngine(ctx).ID(def.ID).AllCols().Update(def)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteIssueStatusDef deletes a status definition and clears references on issues.
|
||||
func DeleteIssueStatusDef(ctx context.Context, id int64) error {
|
||||
// Clear status_id on all issues that reference this definition
|
||||
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
|
||||
return err
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Issue status helpers
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// SetIssueStatusID updates the status_id on an issue.
|
||||
func SetIssueStatusID(ctx context.Context, issueID, statusID int64) error {
|
||||
_, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = ? WHERE id = ?", statusID, issueID)
|
||||
return err
|
||||
}
|
||||
@@ -423,6 +423,9 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(343, "Add custom field tables for issue custom fields", v1_27.AddCustomFieldTables),
|
||||
newMigration(344, "Add domain_restriction to license_package table", v1_27.AddDomainRestrictionToLicensePackage),
|
||||
newMigration(345, "Migrate custom fields to org-level with scope", v1_27.MigrateCustomFieldsToOrgLevel),
|
||||
newMigration(346, "Add issue status definitions table", v1_27.AddIssueStatusDefTable),
|
||||
newMigration(347, "Add repo manifest table", v1_27.AddRepoManifestTable),
|
||||
newMigration(348, "Add issue priority definitions table", v1_27.AddIssuePriorityDefTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddIssueStatusDefTable creates the issue_status_def table and adds
|
||||
// status_id to the issue table.
|
||||
func AddIssueStatusDefTable(x *xorm.Engine) error {
|
||||
type IssueStatusDef struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'org_id'"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
Color string `xorm:"VARCHAR(7)"`
|
||||
Description string `xorm:"TEXT"`
|
||||
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
|
||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||
CreatedUnix int64 `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix int64 `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
if err := x.Sync(new(IssueStatusDef)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add status_id column to issue table
|
||||
type Issue struct {
|
||||
StatusID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'status_id'"`
|
||||
}
|
||||
return x.Sync(new(Issue))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddRepoManifestTable creates the repo_manifest table for storing
|
||||
// moko-platform manifest settings per repository.
|
||||
func AddRepoManifestTable(x *xorm.Engine) error {
|
||||
type RepoManifest struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE INDEX NOT NULL 'repo_id'"`
|
||||
Name string `xorm:"TEXT 'name'"`
|
||||
Org string `xorm:"TEXT 'org'"`
|
||||
Description string `xorm:"TEXT 'description'"`
|
||||
Version string `xorm:"TEXT 'version'"`
|
||||
LicenseSPDX string `xorm:"VARCHAR(50) 'license_spdx'"`
|
||||
LicenseName string `xorm:"TEXT 'license_name'"`
|
||||
Platform string `xorm:"VARCHAR(50) 'platform'"`
|
||||
StandardsVersion string `xorm:"VARCHAR(20) 'standards_version'"`
|
||||
StandardsSource string `xorm:"TEXT 'standards_source'"`
|
||||
Language string `xorm:"VARCHAR(50) 'language'"`
|
||||
PackageType string `xorm:"VARCHAR(50) 'package_type'"`
|
||||
EntryPoint string `xorm:"TEXT 'entry_point'"`
|
||||
CreatedUnix int64 `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix int64 `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
return x.Sync(new(RepoManifest))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddIssuePriorityDefTable creates the issue_priority_def table and adds
|
||||
// priority_id to the issue table.
|
||||
func AddIssuePriorityDefTable(x *xorm.Engine) error {
|
||||
type IssuePriorityDef struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'org_id'"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
Color string `xorm:"VARCHAR(7)"`
|
||||
Description string `xorm:"TEXT"`
|
||||
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
|
||||
IsDefault bool `xorm:"NOT NULL DEFAULT false 'is_default'"`
|
||||
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
|
||||
CreatedUnix int64 `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix int64 `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
if err := x.Sync(new(IssuePriorityDef)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
PriorityID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'priority_id'"`
|
||||
}
|
||||
return x.Sync(new(Issue))
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(RepoManifest))
|
||||
}
|
||||
|
||||
// RepoManifest stores moko-platform manifest settings for a repository.
|
||||
// These fields correspond to the .mokogitea/manifest.xml schema and are
|
||||
// exposed via API for use by Actions workflows and the moko-platform CLI.
|
||||
type RepoManifest struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE INDEX NOT NULL 'repo_id'"`
|
||||
|
||||
// identity section
|
||||
Name string `xorm:"TEXT 'name'"` // project name
|
||||
Org string `xorm:"TEXT 'org'"` // organization name
|
||||
Description string `xorm:"TEXT 'description'"` // project description
|
||||
Version string `xorm:"TEXT 'version'"` // current version string
|
||||
LicenseSPDX string `xorm:"VARCHAR(50) 'license_spdx'"` // SPDX identifier, e.g. "GPL-3.0-or-later"
|
||||
LicenseName string `xorm:"TEXT 'license_name'"` // human-readable license name
|
||||
|
||||
// governance section
|
||||
Platform string `xorm:"VARCHAR(50) 'platform'"` // go, php, node, python, etc.
|
||||
StandardsVersion string `xorm:"VARCHAR(20) 'standards_version'"` // moko-platform standards version
|
||||
StandardsSource string `xorm:"TEXT 'standards_source'"` // URL to standards repo
|
||||
|
||||
// build section
|
||||
Language string `xorm:"VARCHAR(50) 'language'"` // Go, PHP, TypeScript, etc.
|
||||
PackageType string `xorm:"VARCHAR(50) 'package_type'"` // application, library, plugin, module, component, package
|
||||
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
func (RepoManifest) TableName() string {
|
||||
return "repo_manifest"
|
||||
}
|
||||
|
||||
// GetRepoManifest returns the manifest for a repo, or nil if none exists.
|
||||
func GetRepoManifest(ctx context.Context, repoID int64) (*RepoManifest, error) {
|
||||
m := new(RepoManifest)
|
||||
has, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Get(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdateRepoManifest upserts a repo manifest.
|
||||
func CreateOrUpdateRepoManifest(ctx context.Context, m *RepoManifest) error {
|
||||
existing := new(RepoManifest)
|
||||
has, err := db.GetEngine(ctx).Where("repo_id = ?", m.RepoID).Get(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
m.ID = existing.ID
|
||||
_, err = db.GetEngine(ctx).ID(m.ID).AllCols().Update(m)
|
||||
return err
|
||||
}
|
||||
_, err = db.GetEngine(ctx).Insert(m)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRepoManifest deletes the manifest for a repo.
|
||||
func DeleteRepoManifest(ctx context.Context, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(RepoManifest))
|
||||
return err
|
||||
}
|
||||
@@ -1004,6 +1004,12 @@
|
||||
"repo.object_format": "Object Format",
|
||||
"repo.object_format_helper": "Object format of the repository. Cannot be changed later. SHA1 is most compatible.",
|
||||
"repo.readme": "README",
|
||||
"repo.well_known_file.readme": "Readme",
|
||||
"repo.well_known_file.license": "License",
|
||||
"repo.well_known_file.contributing": "Contributing",
|
||||
"repo.well_known_file.code_of_conduct": "Code of Conduct",
|
||||
"repo.well_known_file.security": "Security",
|
||||
"repo.well_known_file.changelog": "Changelog",
|
||||
"repo.readme_helper": "Select a README file template.",
|
||||
"repo.readme_helper_desc": "This is the place where you can write a complete description for your project.",
|
||||
"repo.auto_init": "Initialize Repository (Adds .gitignore, License and README)",
|
||||
@@ -1576,6 +1582,8 @@
|
||||
"repo.issues.edit": "Edit",
|
||||
"repo.issues.cancel": "Cancel",
|
||||
"repo.issues.save": "Save",
|
||||
"repo.issues.status": "Status",
|
||||
"repo.issues.priority": "Priority",
|
||||
"repo.issues.label_title": "Name",
|
||||
"repo.issues.label_description": "Description",
|
||||
"repo.issues.label_color": "Color",
|
||||
@@ -2722,6 +2730,25 @@
|
||||
"repo.settings.support_url": "Support / Product Page URL",
|
||||
"repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.",
|
||||
"repo.settings.custom_fields": "Custom Fields",
|
||||
"repo.settings.manifest": "Manifest",
|
||||
"repo.settings.manifest_desc": "Project identity, governance, and build settings from the moko-platform manifest. These are accessible via API for Actions workflows and the moko-platform CLI.",
|
||||
"repo.settings.manifest_identity": "Identity",
|
||||
"repo.settings.manifest_name": "Project Name",
|
||||
"repo.settings.manifest_org": "Organization",
|
||||
"repo.settings.manifest_description": "Description",
|
||||
"repo.settings.manifest_version": "Version",
|
||||
"repo.settings.manifest_license_spdx": "License (SPDX)",
|
||||
"repo.settings.manifest_license_name": "License Name",
|
||||
"repo.settings.manifest_governance": "Governance",
|
||||
"repo.settings.manifest_platform": "Platform",
|
||||
"repo.settings.manifest_standards_version": "Standards Version",
|
||||
"repo.settings.manifest_standards_source": "Standards Source",
|
||||
"repo.settings.manifest_build": "Build",
|
||||
"repo.settings.manifest_language": "Language",
|
||||
"repo.settings.manifest_package_type": "Package Type",
|
||||
"repo.settings.manifest_entry_point": "Entry Point",
|
||||
"repo.settings.manifest_save": "Save Manifest",
|
||||
"repo.settings.manifest_saved": "Manifest settings saved.",
|
||||
"repo.settings.metadata": "Metadata",
|
||||
"repo.settings.metadata_saved": "Repository metadata saved.",
|
||||
"repo.settings.metadata_empty": "No metadata fields defined. Org admins can add fields in Organization Settings > Custom Fields.",
|
||||
@@ -2911,6 +2938,35 @@
|
||||
"org.settings.custom_field_created": "Custom field created.",
|
||||
"org.settings.custom_field_updated": "Custom field updated.",
|
||||
"org.settings.custom_field_deleted": "Custom field deleted.",
|
||||
"org.settings.issue_statuses": "Issue Statuses",
|
||||
"org.settings.issue_statuses_desc": "Define custom issue statuses for all repositories in this organization. Statuses appear in the issue sidebar and can automatically close or reopen issues.",
|
||||
"org.settings.issue_statuses_empty": "No custom issue statuses defined yet.",
|
||||
"org.settings.issue_status_add": "Add Status",
|
||||
"org.settings.issue_status_name": "Status Name",
|
||||
"org.settings.issue_status_color": "Color",
|
||||
"org.settings.issue_status_description": "Description",
|
||||
"org.settings.issue_status_closes_issue": "Closes issue",
|
||||
"org.settings.issue_status_closes_issue_help": "When this status is selected, the issue will be automatically closed.",
|
||||
"org.settings.issue_status_closes": "Closes",
|
||||
"org.settings.issue_status_sort_order": "Sort Order",
|
||||
"org.settings.issue_status_inactive": "Inactive",
|
||||
"org.settings.issue_status_created": "Issue status created.",
|
||||
"org.settings.issue_status_updated": "Issue status updated.",
|
||||
"org.settings.issue_status_deleted": "Issue status deleted.",
|
||||
"org.settings.issue_priorities": "Issue Priorities",
|
||||
"org.settings.issue_priorities_desc": "Define priority levels for all repositories in this organization. Priorities appear in the issue sidebar.",
|
||||
"org.settings.issue_priorities_empty": "No custom issue priorities defined yet.",
|
||||
"org.settings.issue_priority_add": "Add Priority",
|
||||
"org.settings.issue_priority_name": "Priority Name",
|
||||
"org.settings.issue_priority_color": "Color",
|
||||
"org.settings.issue_priority_description": "Description",
|
||||
"org.settings.issue_priority_default": "Default",
|
||||
"org.settings.issue_priority_default_help": "Auto-assigned to new issues.",
|
||||
"org.settings.issue_priority_sort_order": "Sort Order",
|
||||
"org.settings.issue_priority_inactive": "Inactive",
|
||||
"org.settings.issue_priority_created": "Issue priority created.",
|
||||
"org.settings.issue_priority_updated": "Issue priority updated.",
|
||||
"org.settings.issue_priority_deleted": "Issue priority deleted.",
|
||||
"org.settings.update_streams": "Update Server",
|
||||
"org.settings.licensing": "Update Server",
|
||||
"org.settings.licensing_desc": "Manage update feeds and optional license key gating across all repositories in this organization.",
|
||||
|
||||
@@ -1479,6 +1479,9 @@ func Routes() *web.Router {
|
||||
Delete(reqToken(), repo.DeleteTopic)
|
||||
}, reqAdmin())
|
||||
}, reqAnyRepoReader())
|
||||
m.Combo("/manifest", reqRepoReader(unit.TypeCode)).
|
||||
Get(repo.GetRepoManifest).
|
||||
Put(reqToken(), reqAdmin(), repo.UpdateRepoManifest)
|
||||
// MokoGitea badge engine
|
||||
m.Get("/badge/{type}.svg", repo.GetRepoBadge)
|
||||
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// apiManifest is the JSON representation of a repo manifest.
|
||||
type apiManifest struct {
|
||||
Name string `json:"name"`
|
||||
Org string `json:"org"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
LicenseSPDX string `json:"license_spdx"`
|
||||
LicenseName string `json:"license_name"`
|
||||
Platform string `json:"platform"`
|
||||
StandardsVersion string `json:"standards_version"`
|
||||
StandardsSource string `json:"standards_source"`
|
||||
Language string `json:"language"`
|
||||
PackageType string `json:"package_type"`
|
||||
EntryPoint string `json:"entry_point"`
|
||||
}
|
||||
|
||||
// GetRepoManifest returns the manifest settings for a repository.
|
||||
func GetRepoManifest(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/manifest repository repoGetManifest
|
||||
// ---
|
||||
// summary: Get repo manifest settings
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Manifest"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
m, err := repo_model.GetRepoManifest(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
// Return defaults from repo metadata.
|
||||
ctx.JSON(http.StatusOK, &apiManifest{
|
||||
Name: ctx.Repo.Repository.Name,
|
||||
Org: ctx.Repo.Repository.OwnerName,
|
||||
Description: ctx.Repo.Repository.Description,
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, &apiManifest{
|
||||
Name: m.Name,
|
||||
Org: m.Org,
|
||||
Description: m.Description,
|
||||
Version: m.Version,
|
||||
LicenseSPDX: m.LicenseSPDX,
|
||||
LicenseName: m.LicenseName,
|
||||
Platform: m.Platform,
|
||||
StandardsVersion: m.StandardsVersion,
|
||||
StandardsSource: m.StandardsSource,
|
||||
Language: m.Language,
|
||||
PackageType: m.PackageType,
|
||||
EntryPoint: m.EntryPoint,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRepoManifest updates the manifest settings for a repository.
|
||||
func UpdateRepoManifest(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /repos/{owner}/{repo}/manifest repository repoUpdateManifest
|
||||
// ---
|
||||
// summary: Update repo manifest settings
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Manifest"
|
||||
var req apiManifest
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
m := &repo_model.RepoManifest{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Name: req.Name,
|
||||
Org: req.Org,
|
||||
Description: req.Description,
|
||||
Version: req.Version,
|
||||
LicenseSPDX: req.LicenseSPDX,
|
||||
LicenseName: req.LicenseName,
|
||||
Platform: req.Platform,
|
||||
StandardsVersion: req.StandardsVersion,
|
||||
StandardsSource: req.StandardsSource,
|
||||
Language: req.Language,
|
||||
PackageType: req.PackageType,
|
||||
EntryPoint: req.EntryPoint,
|
||||
}
|
||||
|
||||
if err := repo_model.CreateOrUpdateRepoManifest(ctx, m); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, &apiManifest{
|
||||
Name: m.Name,
|
||||
Org: m.Org,
|
||||
Description: m.Description,
|
||||
Version: m.Version,
|
||||
LicenseSPDX: m.LicenseSPDX,
|
||||
LicenseName: m.LicenseName,
|
||||
Platform: m.Platform,
|
||||
StandardsVersion: m.StandardsVersion,
|
||||
StandardsSource: m.StandardsSource,
|
||||
Language: m.Language,
|
||||
PackageType: m.PackageType,
|
||||
EntryPoint: m.EntryPoint,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplOrgIssuePriorities templates.TplName = "org/settings/issue_priorities"
|
||||
|
||||
// SettingsIssuePriorities shows the org-level issue priorities management page.
|
||||
func SettingsIssuePriorities(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("org.settings.issue_priorities")
|
||||
ctx.Data["PageIsOrgSettings"] = true
|
||||
ctx.Data["PageIsSettingsIssuePriorities"] = true
|
||||
|
||||
defs, err := issues_model.GetAllIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllIssuePriorityDefsByOrg", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["IssuePriorities"] = defs
|
||||
|
||||
ctx.HTML(http.StatusOK, tplOrgIssuePriorities)
|
||||
}
|
||||
|
||||
// SettingsIssuePrioritiesCreatePost creates a new org-level issue priority.
|
||||
func SettingsIssuePrioritiesCreatePost(ctx *context.Context) {
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
|
||||
def := &issues_model.IssuePriorityDef{
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
Name: ctx.FormString("name"),
|
||||
Color: ctx.FormString("color"),
|
||||
Description: ctx.FormString("description"),
|
||||
SortOrder: sortOrder,
|
||||
IsDefault: ctx.FormString("is_default") == "on",
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if def.Name == "" {
|
||||
ctx.Flash.Error("Priority name is required")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-priorities")
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.CreateIssuePriorityDef(ctx, def); err != nil {
|
||||
ctx.ServerError("CreateIssuePriorityDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.issue_priority_created"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-priorities")
|
||||
}
|
||||
|
||||
// SettingsIssuePrioritiesEditPost updates an org-level issue priority.
|
||||
func SettingsIssuePrioritiesEditPost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
def, err := issues_model.GetIssuePriorityDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssuePriorityDefByID", err)
|
||||
return
|
||||
}
|
||||
if def.OrgID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
def.Name = ctx.FormString("name")
|
||||
def.Color = ctx.FormString("color")
|
||||
def.Description = ctx.FormString("description")
|
||||
def.IsDefault = ctx.FormString("is_default") == "on"
|
||||
def.IsActive = ctx.FormString("is_active") == "on"
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
def.SortOrder = sortOrder
|
||||
|
||||
if err := issues_model.UpdateIssuePriorityDef(ctx, def); err != nil {
|
||||
ctx.ServerError("UpdateIssuePriorityDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.issue_priority_updated"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-priorities")
|
||||
}
|
||||
|
||||
// SettingsIssuePrioritiesDeletePost deletes an org-level issue priority.
|
||||
func SettingsIssuePrioritiesDeletePost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
def, err := issues_model.GetIssuePriorityDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssuePriorityDefByID", err)
|
||||
return
|
||||
}
|
||||
if def.OrgID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.DeleteIssuePriorityDef(ctx, id); err != nil {
|
||||
ctx.ServerError("DeleteIssuePriorityDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.issue_priority_deleted"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-priorities")
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplOrgIssueStatuses templates.TplName = "org/settings/issue_statuses"
|
||||
|
||||
// SettingsIssueStatuses shows the org-level issue statuses management page.
|
||||
func SettingsIssueStatuses(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("org.settings.issue_statuses")
|
||||
ctx.Data["PageIsOrgSettings"] = true
|
||||
ctx.Data["PageIsSettingsIssueStatuses"] = true
|
||||
|
||||
defs, err := issues_model.GetAllIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllIssueStatusDefsByOrg", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["IssueStatuses"] = defs
|
||||
|
||||
ctx.HTML(http.StatusOK, tplOrgIssueStatuses)
|
||||
}
|
||||
|
||||
// SettingsIssueStatusesCreatePost creates a new org-level issue status.
|
||||
func SettingsIssueStatusesCreatePost(ctx *context.Context) {
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
|
||||
def := &issues_model.IssueStatusDef{
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
Name: ctx.FormString("name"),
|
||||
Color: ctx.FormString("color"),
|
||||
Description: ctx.FormString("description"),
|
||||
ClosesIssue: ctx.FormString("closes_issue") == "on",
|
||||
SortOrder: sortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if def.Name == "" {
|
||||
ctx.Flash.Error("Status name is required")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.CreateIssueStatusDef(ctx, def); err != nil {
|
||||
ctx.ServerError("CreateIssueStatusDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.issue_status_created"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||
}
|
||||
|
||||
// SettingsIssueStatusesEditPost updates an org-level issue status.
|
||||
func SettingsIssueStatusesEditPost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
def, err := issues_model.GetIssueStatusDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueStatusDefByID", err)
|
||||
return
|
||||
}
|
||||
if def.OrgID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
def.Name = ctx.FormString("name")
|
||||
def.Color = ctx.FormString("color")
|
||||
def.Description = ctx.FormString("description")
|
||||
def.ClosesIssue = ctx.FormString("closes_issue") == "on"
|
||||
def.IsActive = ctx.FormString("is_active") == "on"
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
def.SortOrder = sortOrder
|
||||
|
||||
if err := issues_model.UpdateIssueStatusDef(ctx, def); err != nil {
|
||||
ctx.ServerError("UpdateIssueStatusDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.issue_status_updated"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||
}
|
||||
|
||||
// SettingsIssueStatusesDeletePost deletes an org-level issue status.
|
||||
func SettingsIssueStatusesDeletePost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
def, err := issues_model.GetIssueStatusDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueStatusDefByID", err)
|
||||
return
|
||||
}
|
||||
if def.OrgID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
|
||||
ctx.ServerError("DeleteIssueStatusDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.issue_status_deleted"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// UpdateIssueCustomPriority handles POST to set a custom priority on an issue.
|
||||
func UpdateIssueCustomPriority(ctx *context.Context) {
|
||||
issueID := ctx.PathParamInt64("id")
|
||||
priorityID := ctx.FormInt64("priority_id")
|
||||
|
||||
issue, err := issues_model.GetIssueByID(ctx, issueID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the priority belongs to this repo's org.
|
||||
if priorityID > 0 {
|
||||
priorityDef, err := issues_model.GetIssuePriorityDefByID(ctx, priorityID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssuePriorityDefByID", err)
|
||||
return
|
||||
}
|
||||
if priorityDef.OrgID != ctx.Repo.Repository.OwnerID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := issues_model.SetIssuePriorityID(ctx, issueID, priorityID); err != nil {
|
||||
ctx.ServerError("SetIssuePriorityID", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
issue_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/issue"
|
||||
)
|
||||
|
||||
// UpdateIssueCustomStatus handles POST to set a custom status on an issue.
|
||||
// If the chosen status has ClosesIssue=true, the issue is automatically closed.
|
||||
// If the chosen status has ClosesIssue=false and the issue is closed, it is reopened.
|
||||
func UpdateIssueCustomStatus(ctx *context.Context) {
|
||||
issueID := ctx.PathParamInt64("id")
|
||||
statusID := ctx.FormInt64("status_id")
|
||||
|
||||
issue, err := issues_model.GetIssueByID(ctx, issueID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the status belongs to this repo's org (or is being cleared).
|
||||
if statusID > 0 {
|
||||
statusDef, err := issues_model.GetIssueStatusDefByID(ctx, statusID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueStatusDefByID", err)
|
||||
return
|
||||
}
|
||||
if statusDef.OrgID != ctx.Repo.Repository.OwnerID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle automatic close/reopen based on the status definition.
|
||||
if statusDef.ClosesIssue && !issue.IsClosed {
|
||||
if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
|
||||
log.Error("UpdateIssueCustomStatus: CloseIssue: %v", err)
|
||||
}
|
||||
} else if !statusDef.ClosesIssue && issue.IsClosed {
|
||||
if err := issue_service.ReopenIssue(ctx, issue, ctx.Doer, ""); err != nil {
|
||||
log.Error("UpdateIssueCustomStatus: ReopenIssue: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := issues_model.SetIssueStatusID(ctx, issueID, statusID); err != nil {
|
||||
ctx.ServerError("SetIssueStatusID", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther)
|
||||
}
|
||||
@@ -364,6 +364,21 @@ func ViewIssue(ctx *context.Context) {
|
||||
}
|
||||
ctx.Data["CustomFieldValues"] = customFieldValues
|
||||
ctx.Data["CustomFieldOptions"] = fieldOptions
|
||||
|
||||
// Load custom issue status definitions for the sidebar.
|
||||
issueStatusDefs, isErr := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Repo.Repository.OwnerID)
|
||||
if isErr != nil {
|
||||
log.Error("ViewIssue: GetIssueStatusDefsByOrg: %v", isErr)
|
||||
}
|
||||
ctx.Data["IssueStatusDefs"] = issueStatusDefs
|
||||
|
||||
// Load custom issue priority definitions for the sidebar.
|
||||
issuePriorityDefs, ipErr := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Repo.Repository.OwnerID)
|
||||
if ipErr != nil {
|
||||
log.Error("ViewIssue: GetIssuePriorityDefsByOrg: %v", ipErr)
|
||||
}
|
||||
ctx.Data["IssuePriorityDefs"] = issuePriorityDefs
|
||||
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
if err := issue.LoadAttributes(ctx); err != nil {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplSettingsManifest templates.TplName = "repo/settings/manifest"
|
||||
|
||||
// manifestXML mirrors the .mokogitea/manifest.xml schema for XML parsing.
|
||||
type manifestXML struct {
|
||||
XMLName xml.Name `xml:"moko-platform"`
|
||||
Identity manifestIdentity `xml:"identity"`
|
||||
Governance manifestGovernance `xml:"governance"`
|
||||
Build manifestBuild `xml:"build"`
|
||||
}
|
||||
|
||||
type manifestIdentity struct {
|
||||
Name string `xml:"name"`
|
||||
Org string `xml:"org"`
|
||||
Description string `xml:"description"`
|
||||
Version string `xml:"version"`
|
||||
License manifestLicense `xml:"license"`
|
||||
}
|
||||
|
||||
type manifestLicense struct {
|
||||
SPDX string `xml:"spdx,attr"`
|
||||
Name string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type manifestGovernance struct {
|
||||
Platform string `xml:"platform"`
|
||||
StandardsVersion string `xml:"standards-version"`
|
||||
StandardsSource string `xml:"standards-source"`
|
||||
}
|
||||
|
||||
type manifestBuild struct {
|
||||
Language string `xml:"language"`
|
||||
PackageType string `xml:"package-type"`
|
||||
EntryPoint string `xml:"entry-point"`
|
||||
}
|
||||
|
||||
// ManifestSettings displays the repo manifest settings page.
|
||||
// On first visit, if no manifest exists in DB but .mokogitea/manifest.xml
|
||||
// exists in the repo, it auto-migrates the XML values into the database.
|
||||
func ManifestSettings(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.manifest")
|
||||
ctx.Data["PageIsSettingsManifest"] = true
|
||||
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
manifest, err := repo_model.GetRepoManifest(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRepoManifest", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-detect and migrate .mokogitea/manifest.xml if no DB record exists.
|
||||
if manifest == nil {
|
||||
manifest = tryMigrateManifestXML(ctx)
|
||||
}
|
||||
|
||||
if manifest == nil {
|
||||
// No manifest found — provide empty defaults from repo metadata.
|
||||
manifest = &repo_model.RepoManifest{
|
||||
RepoID: repoID,
|
||||
Name: ctx.Repo.Repository.Name,
|
||||
Org: ctx.Repo.Repository.OwnerName,
|
||||
Description: ctx.Repo.Repository.Description,
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Manifest"] = manifest
|
||||
ctx.HTML(http.StatusOK, tplSettingsManifest)
|
||||
}
|
||||
|
||||
// ManifestSettingsPost saves manifest settings from the form.
|
||||
func ManifestSettingsPost(ctx *context.Context) {
|
||||
manifest := &repo_model.RepoManifest{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Name: ctx.FormString("name"),
|
||||
Org: ctx.FormString("org"),
|
||||
Description: ctx.FormString("description"),
|
||||
Version: ctx.FormString("version"),
|
||||
LicenseSPDX: ctx.FormString("license_spdx"),
|
||||
LicenseName: ctx.FormString("license_name"),
|
||||
Platform: ctx.FormString("platform"),
|
||||
StandardsVersion: ctx.FormString("standards_version"),
|
||||
StandardsSource: ctx.FormString("standards_source"),
|
||||
Language: ctx.FormString("language"),
|
||||
PackageType: ctx.FormString("package_type"),
|
||||
EntryPoint: ctx.FormString("entry_point"),
|
||||
}
|
||||
|
||||
if err := repo_model.CreateOrUpdateRepoManifest(ctx, manifest); err != nil {
|
||||
ctx.ServerError("CreateOrUpdateRepoManifest", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.manifest_saved"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/manifest")
|
||||
}
|
||||
|
||||
// tryMigrateManifestXML reads .mokogitea/manifest.xml from the repo,
|
||||
// parses it, and stores the values in the DB. Returns nil if no file found.
|
||||
func tryMigrateManifestXML(ctx *context.Context) *repo_model.RepoManifest {
|
||||
if ctx.Repo.GitRepo == nil || ctx.Repo.Commit == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(".mokogitea/manifest.xml")
|
||||
if err != nil || entry == nil {
|
||||
return nil // no manifest.xml found — not an error
|
||||
}
|
||||
|
||||
reader, err := entry.Blob().DataAsync()
|
||||
if err != nil {
|
||||
log.Error("ManifestMigrate: read blob: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
var mxml manifestXML
|
||||
if err := xml.NewDecoder(reader).Decode(&mxml); err != nil {
|
||||
log.Error("ManifestMigrate: parse XML: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
manifest := &repo_model.RepoManifest{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Name: mxml.Identity.Name,
|
||||
Org: mxml.Identity.Org,
|
||||
Description: mxml.Identity.Description,
|
||||
Version: mxml.Identity.Version,
|
||||
LicenseSPDX: mxml.Identity.License.SPDX,
|
||||
LicenseName: mxml.Identity.License.Name,
|
||||
Platform: mxml.Governance.Platform,
|
||||
StandardsVersion: mxml.Governance.StandardsVersion,
|
||||
StandardsSource: mxml.Governance.StandardsSource,
|
||||
Language: mxml.Build.Language,
|
||||
PackageType: mxml.Build.PackageType,
|
||||
EntryPoint: mxml.Build.EntryPoint,
|
||||
}
|
||||
|
||||
if err := repo_model.CreateOrUpdateRepoManifest(ctx, manifest); err != nil {
|
||||
log.Error("ManifestMigrate: save to DB: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info("ManifestMigrate: migrated .mokogitea/manifest.xml for repo %s/%s",
|
||||
ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name)
|
||||
|
||||
ctx.Flash.Info(fmt.Sprintf("Manifest settings imported from .mokogitea/manifest.xml. You can now delete the file from the repository."))
|
||||
return manifest
|
||||
}
|
||||
@@ -151,6 +151,51 @@ func prepareToRenderDirectory(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Well-known file tabs — only at root
|
||||
activeTab := ctx.FormString("tab")
|
||||
if ctx.Repo.TreePath == "" {
|
||||
wellKnownTabs := findWellKnownFiles(entries)
|
||||
|
||||
// Determine which tab is active
|
||||
hasReadme := readmeFile != nil
|
||||
if hasReadme || len(wellKnownTabs) > 0 {
|
||||
// Only show tabs if there are at least 2 items (README + at least one well-known file)
|
||||
if hasReadme && len(wellKnownTabs) > 0 {
|
||||
readmeTab := WellKnownFileTab{
|
||||
TabKey: "readme",
|
||||
Label: "repo.well_known_file.readme",
|
||||
FileName: "",
|
||||
Active: activeTab == "" || activeTab == "readme",
|
||||
}
|
||||
if readmeFile != nil {
|
||||
readmeTab.FileName = readmeFile.Name()
|
||||
}
|
||||
|
||||
// Set active state for well-known tabs
|
||||
for i := range wellKnownTabs {
|
||||
wellKnownTabs[i].Active = wellKnownTabs[i].TabKey == activeTab
|
||||
}
|
||||
|
||||
allTabs := append([]WellKnownFileTab{readmeTab}, wellKnownTabs...)
|
||||
ctx.Data["WellKnownFileTabs"] = allTabs
|
||||
}
|
||||
}
|
||||
|
||||
// If a non-readme tab is selected, render that file instead
|
||||
if activeTab != "" && activeTab != "readme" {
|
||||
for _, tab := range wellKnownTabs {
|
||||
if tab.TabKey == activeTab {
|
||||
entry := findFileEntryByName(entries, tab.FileName)
|
||||
if entry != nil {
|
||||
prepareToRenderReadmeFile(ctx, "", entry)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// If the requested tab was not found, fall through to render readme
|
||||
}
|
||||
}
|
||||
|
||||
prepareToRenderReadmeFile(ctx, subfolder, readmeFile)
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,63 @@ func localizedExtensions(ext, languageCode string) (localizedExts []string) {
|
||||
return []string{lowerLangCode + ext, ext}
|
||||
}
|
||||
|
||||
// WellKnownFileTab represents a tab for a well-known root file (LICENSE, CONTRIBUTING, etc.)
|
||||
type WellKnownFileTab struct {
|
||||
TabKey string // query parameter value, e.g. "license"
|
||||
Label string // locale key suffix, e.g. "repo.well_known_file.license"
|
||||
FileName string // actual file name found in repo, e.g. "LICENSE.md"
|
||||
Active bool // whether this tab is currently selected
|
||||
}
|
||||
|
||||
// wellKnownFilePatterns maps tab keys to the base file names to search for (case-insensitive).
|
||||
// Order defines the tab display order.
|
||||
var wellKnownFilePatterns = []struct {
|
||||
TabKey string
|
||||
BaseName string // matched case-insensitively against file names (without extension)
|
||||
}{
|
||||
{"license", "LICENSE"},
|
||||
{"contributing", "CONTRIBUTING"},
|
||||
{"code_of_conduct", "CODE_OF_CONDUCT"},
|
||||
{"security", "SECURITY"},
|
||||
{"changelog", "CHANGELOG"},
|
||||
}
|
||||
|
||||
// findWellKnownFiles scans root directory entries for well-known markdown/text files.
|
||||
func findWellKnownFiles(entries []*git.TreeEntry) []WellKnownFileTab {
|
||||
var tabs []WellKnownFileTab
|
||||
for _, pattern := range wellKnownFilePatterns {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || entry.IsSubModule() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
baseName := name
|
||||
if idx := strings.LastIndex(name, "."); idx >= 0 {
|
||||
baseName = name[:idx]
|
||||
}
|
||||
if strings.EqualFold(baseName, pattern.BaseName) {
|
||||
tabs = append(tabs, WellKnownFileTab{
|
||||
TabKey: pattern.TabKey,
|
||||
Label: "repo.well_known_file." + pattern.TabKey,
|
||||
FileName: name,
|
||||
})
|
||||
break // take the first match for this pattern
|
||||
}
|
||||
}
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
|
||||
// findFileEntryByName finds a tree entry by exact file name.
|
||||
func findFileEntryByName(entries []*git.TreeEntry, fileName string) *git.TreeEntry {
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == fileName {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) {
|
||||
if readmeFile == nil {
|
||||
return
|
||||
|
||||
@@ -1067,6 +1067,18 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/{id}/edit", org.SettingsCustomFieldsEditPost)
|
||||
m.Post("/{id}/delete", org.SettingsCustomFieldsDeletePost)
|
||||
})
|
||||
m.Group("/issue-statuses", func() {
|
||||
m.Get("", org.SettingsIssueStatuses)
|
||||
m.Post("", org.SettingsIssueStatusesCreatePost)
|
||||
m.Post("/{id}/edit", org.SettingsIssueStatusesEditPost)
|
||||
m.Post("/{id}/delete", org.SettingsIssueStatusesDeletePost)
|
||||
})
|
||||
m.Group("/issue-priorities", func() {
|
||||
m.Get("", org.SettingsIssuePriorities)
|
||||
m.Post("", org.SettingsIssuePrioritiesCreatePost)
|
||||
m.Post("/{id}/edit", org.SettingsIssuePrioritiesEditPost)
|
||||
m.Post("/{id}/delete", org.SettingsIssuePrioritiesDeletePost)
|
||||
})
|
||||
}, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true))
|
||||
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true}))
|
||||
}, reqSignIn)
|
||||
@@ -1193,6 +1205,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Combo("/advanced").Get(repo_setting.AdvancedSettings).Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost)
|
||||
}, repo_setting.SettingsCtxData)
|
||||
m.Combo("/licensing").Get(repo_setting.LicensingSettings).Post(repo_setting.LicensingSettingsPost)
|
||||
m.Combo("/manifest").Get(repo_setting.ManifestSettings).Post(repo_setting.ManifestSettingsPost)
|
||||
m.Combo("/metadata").Get(repo_setting.Metadata).Post(repo_setting.MetadataPost)
|
||||
|
||||
m.Group("/collaboration", func() {
|
||||
@@ -1399,6 +1412,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee)
|
||||
m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
|
||||
m.Post("/{id}/custom-fields/{field_id}", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomField)
|
||||
m.Post("/{id}/custom-status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomStatus)
|
||||
m.Post("/{id}/custom-priority", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomPriority)
|
||||
m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues)
|
||||
m.Delete("/unpin/{index}", reqRepoAdmin, repo.IssueUnpin)
|
||||
m.Post("/move_pin", reqRepoAdmin, repo.IssuePinMove)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
)
|
||||
|
||||
// manifestXML mirrors the .mokogitea/manifest.xml schema for XML parsing.
|
||||
type manifestXML struct {
|
||||
XMLName xml.Name `xml:"moko-platform"`
|
||||
Identity manifestIdentity `xml:"identity"`
|
||||
Governance manifestGovernance `xml:"governance"`
|
||||
Build manifestBuild `xml:"build"`
|
||||
}
|
||||
|
||||
type manifestIdentity struct {
|
||||
Name string `xml:"name"`
|
||||
Org string `xml:"org"`
|
||||
Description string `xml:"description"`
|
||||
Version string `xml:"version"`
|
||||
License manifestLicense `xml:"license"`
|
||||
}
|
||||
|
||||
type manifestLicense struct {
|
||||
SPDX string `xml:"spdx,attr"`
|
||||
Name string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type manifestGovernance struct {
|
||||
Platform string `xml:"platform"`
|
||||
StandardsVersion string `xml:"standards-version"`
|
||||
StandardsSource string `xml:"standards-source"`
|
||||
}
|
||||
|
||||
type manifestBuild struct {
|
||||
Language string `xml:"language"`
|
||||
PackageType string `xml:"package-type"`
|
||||
EntryPoint string `xml:"entry-point"`
|
||||
}
|
||||
|
||||
// SyncManifestFromCommit reads .mokogitea/manifest.xml from the given commit
|
||||
// and upserts the values into the repo_manifest database table.
|
||||
// This is called on push to the default branch to keep the database in sync
|
||||
// with the XML file. If no manifest.xml exists, this is a no-op.
|
||||
func SyncManifestFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit) {
|
||||
if commit == nil {
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := commit.GetTreeEntryByPath(".mokogitea/manifest.xml")
|
||||
if err != nil || entry == nil {
|
||||
return // no manifest.xml — not an error
|
||||
}
|
||||
|
||||
reader, err := entry.Blob().DataAsync()
|
||||
if err != nil {
|
||||
log.Error("SyncManifest: read blob for %s: %v", repo.FullName(), err)
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
var mxml manifestXML
|
||||
decoder := xml.NewDecoder(reader)
|
||||
if err := decoder.Decode(&mxml); err != nil {
|
||||
log.Error("SyncManifest: parse XML for %s: %v", repo.FullName(), err)
|
||||
return
|
||||
}
|
||||
|
||||
manifest := &repo_model.RepoManifest{
|
||||
RepoID: repo.ID,
|
||||
Name: mxml.Identity.Name,
|
||||
Org: mxml.Identity.Org,
|
||||
Description: mxml.Identity.Description,
|
||||
Version: mxml.Identity.Version,
|
||||
LicenseSPDX: mxml.Identity.License.SPDX,
|
||||
LicenseName: mxml.Identity.License.Name,
|
||||
Platform: mxml.Governance.Platform,
|
||||
StandardsVersion: mxml.Governance.StandardsVersion,
|
||||
StandardsSource: mxml.Governance.StandardsSource,
|
||||
Language: mxml.Build.Language,
|
||||
PackageType: mxml.Build.PackageType,
|
||||
EntryPoint: mxml.Build.EntryPoint,
|
||||
}
|
||||
|
||||
if err := repo_model.CreateOrUpdateRepoManifest(ctx, manifest); err != nil {
|
||||
log.Error("SyncManifest: save for %s: %v", repo.FullName(), err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("SyncManifest: synced .mokogitea/manifest.xml for %s", repo.FullName())
|
||||
}
|
||||
@@ -193,6 +193,8 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
|
||||
if err := DelRepoDivergenceFromCache(ctx, repo.ID); err != nil {
|
||||
log.Error("DelRepoDivergenceFromCache: %v", err)
|
||||
}
|
||||
// Auto-sync .mokogitea/manifest.xml to database on default branch push
|
||||
SyncManifestFromCommit(ctx, repo, newCommit)
|
||||
} else {
|
||||
if err := DelDivergenceFromCache(repo.ID, branch); err != nil {
|
||||
log.Error("DelDivergenceFromCache: %v", err)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings issue-priorities")}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "org.settings.issue_priorities"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p class="text grey">{{ctx.Locale.Tr "org.settings.issue_priorities_desc"}}</p>
|
||||
|
||||
{{if .IssuePriorities}}
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "org.settings.issue_priority_color"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.issue_priority_name"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.issue_priority_default"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.issue_priority_sort_order"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .IssuePriorities}}
|
||||
<tr {{if not .IsActive}}class="tw-opacity-50"{{end}}>
|
||||
<td>
|
||||
{{if .Color}}
|
||||
<span class="tw-inline-block tw-w-4 tw-h-4 tw-rounded" style="background-color: {{.Color}}"></span>
|
||||
{{else}}
|
||||
<span class="text grey">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{.Name}}</strong>
|
||||
{{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_priority_inactive"}}</span>{{end}}
|
||||
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .IsDefault}}
|
||||
<span class="ui mini blue label">{{ctx.Locale.Tr "org.settings.issue_priority_default"}}</span>
|
||||
{{else}}
|
||||
<span class="text grey">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>{{.SortOrder}}</td>
|
||||
<td class="tw-text-right">
|
||||
<form method="post" action="{{$.OrgLink}}/settings/issue-priorities/{{.ID}}/delete" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-placeholder">
|
||||
<p>{{ctx.Locale.Tr "org.settings.issue_priorities_empty"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h5>{{ctx.Locale.Tr "org.settings.issue_priority_add"}}</h5>
|
||||
<form class="ui form" method="post" action="{{.OrgLink}}/settings/issue-priorities">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="three fields">
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.issue_priority_name"}}</label>
|
||||
<input name="name" required placeholder="e.g. Critical, High, Medium, Low">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.issue_priority_color"}}</label>
|
||||
<input name="color" type="color" value="#f59e0b">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.issue_priority_sort_order"}}</label>
|
||||
<input name="sort_order" type="number" value="0" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.issue_priority_description"}}</label>
|
||||
<input name="description" placeholder="Help text shown to users">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox tw-mt-4">
|
||||
<input name="is_default" type="checkbox">
|
||||
<label>{{ctx.Locale.Tr "org.settings.issue_priority_default"}}</label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.issue_priority_default_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "org.settings.issue_priority_add"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{template "org/settings/layout_footer" .}}
|
||||
@@ -0,0 +1,93 @@
|
||||
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings issue-statuses")}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "org.settings.issue_statuses"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p class="text grey">{{ctx.Locale.Tr "org.settings.issue_statuses_desc"}}</p>
|
||||
|
||||
{{if .IssueStatuses}}
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "org.settings.issue_status_color"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.issue_status_name"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.issue_status_closes_issue"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.issue_status_sort_order"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .IssueStatuses}}
|
||||
<tr {{if not .IsActive}}class="tw-opacity-50"{{end}}>
|
||||
<td>
|
||||
{{if .Color}}
|
||||
<span class="tw-inline-block tw-w-4 tw-h-4 tw-rounded" style="background-color: {{.Color}}"></span>
|
||||
{{else}}
|
||||
<span class="text grey">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{.Name}}</strong>
|
||||
{{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}}
|
||||
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .ClosesIssue}}
|
||||
<span class="ui mini purple label">{{ctx.Locale.Tr "org.settings.issue_status_closes"}}</span>
|
||||
{{else}}
|
||||
<span class="text grey">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>{{.SortOrder}}</td>
|
||||
<td class="tw-text-right">
|
||||
<form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-placeholder">
|
||||
<p>{{ctx.Locale.Tr "org.settings.issue_statuses_empty"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h5>{{ctx.Locale.Tr "org.settings.issue_status_add"}}</h5>
|
||||
<form class="ui form" method="post" action="{{.OrgLink}}/settings/issue-statuses">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="three fields">
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.issue_status_name"}}</label>
|
||||
<input name="name" required placeholder="e.g. In Progress, Won't Fix, Blocked">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.issue_status_color"}}</label>
|
||||
<input name="color" type="color" value="#0075ff">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.issue_status_sort_order"}}</label>
|
||||
<input name="sort_order" type="number" value="0" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.issue_status_description"}}</label>
|
||||
<input name="description" placeholder="Help text shown to users">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox tw-mt-4">
|
||||
<input name="closes_issue" type="checkbox">
|
||||
<label>{{ctx.Locale.Tr "org.settings.issue_status_closes_issue"}}</label>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.issue_status_closes_issue_help"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "org.settings.issue_status_add"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{template "org/settings/layout_footer" .}}
|
||||
@@ -31,6 +31,12 @@
|
||||
<a class="{{if .PageIsSettingsCustomFields}}active {{end}}item" href="{{.OrgLink}}/settings/custom-fields">
|
||||
{{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "org.settings.custom_fields"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsIssueStatuses}}active {{end}}item" href="{{.OrgLink}}/settings/issue-statuses">
|
||||
{{svg "octicon-tasklist"}} {{ctx.Locale.Tr "org.settings.issue_statuses"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsIssuePriorities}}active {{end}}item" href="{{.OrgLink}}/settings/issue-priorities">
|
||||
{{svg "octicon-flame"}} {{ctx.Locale.Tr "org.settings.issue_priorities"}}
|
||||
</a>
|
||||
{{if .EnableActions}}
|
||||
<details class="item toggleable-item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
|
||||
<summary>{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{{if .IssuePriorityDefs}}
|
||||
<div class="divider"></div>
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-gap-2">
|
||||
<span class="text grey tw-text-sm">{{ctx.Locale.Tr "repo.issues.priority"}}</span>
|
||||
{{$canModify := .HasIssuesOrPullsWritePermission}}
|
||||
{{if $canModify}}
|
||||
<form method="post" action="{{.RepoLink}}/issues/{{.Issue.ID}}/custom-priority" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<select name="priority_id" class="ui compact mini dropdown tw-max-w-48" onchange="this.form.submit()">
|
||||
<option value="0">-</option>
|
||||
{{range .IssuePriorityDefs}}
|
||||
<option value="{{.ID}}" {{if eq .ID $.Issue.PriorityID}}selected{{end}}
|
||||
{{if .Color}}style="border-left: 3px solid {{.Color}}"{{end}}>
|
||||
{{.Name}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</form>
|
||||
{{else}}
|
||||
{{$found := false}}
|
||||
{{range .IssuePriorityDefs}}
|
||||
{{if eq .ID $.Issue.PriorityID}}
|
||||
{{if .Color}}<span class="tw-inline-block tw-w-3 tw-h-3 tw-rounded" style="background-color: {{.Color}}"></span>{{end}}
|
||||
<span class="tw-text-sm">{{.Name}}</span>
|
||||
{{$found = true}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if not $found}}
|
||||
<span class="tw-text-sm text grey">-</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{if .IssueStatusDefs}}
|
||||
<div class="divider"></div>
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-gap-2">
|
||||
<span class="text grey tw-text-sm">{{ctx.Locale.Tr "repo.issues.status"}}</span>
|
||||
{{$canModify := .HasIssuesOrPullsWritePermission}}
|
||||
{{if $canModify}}
|
||||
<form method="post" action="{{.RepoLink}}/issues/{{.Issue.ID}}/custom-status" class="tw-inline">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<select name="status_id" class="ui compact mini dropdown tw-max-w-48" onchange="this.form.submit()">
|
||||
<option value="0">—</option>
|
||||
{{range .IssueStatusDefs}}
|
||||
<option value="{{.ID}}" {{if eq .ID $.Issue.StatusID}}selected{{end}}
|
||||
{{if .Color}}style="border-left: 3px solid {{.Color}}"{{end}}>
|
||||
{{.Name}}{{if .ClosesIssue}} ⏻{{end}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</form>
|
||||
{{else}}
|
||||
{{$found := false}}
|
||||
{{range .IssueStatusDefs}}
|
||||
{{if eq .ID $.Issue.StatusID}}
|
||||
{{if .Color}}<span class="tw-inline-block tw-w-3 tw-h-3 tw-rounded" style="background-color: {{.Color}}"></span>{{end}}
|
||||
<span class="tw-text-sm">{{.Name}}</span>
|
||||
{{$found = true}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if not $found}}
|
||||
<span class="tw-text-sm text grey">—</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
{{template "repo/issue/sidebar/label_list" $.IssuePageMetaData}}
|
||||
|
||||
{{template "repo/issue/sidebar/issue_status" $}}
|
||||
|
||||
{{template "repo/issue/sidebar/issue_priority" $}}
|
||||
|
||||
{{template "repo/issue/sidebar/custom_fields" $}}
|
||||
|
||||
{{template "repo/issue/sidebar/milestone_list" $.IssuePageMetaData}}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings manifest")}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.manifest"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p class="text grey">{{ctx.Locale.Tr "repo.settings.manifest_desc"}}</p>
|
||||
|
||||
<form class="ui form" method="post" action="{{.RepoLink}}/settings/manifest">
|
||||
{{.CsrfTokenHtml}}
|
||||
|
||||
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.manifest_identity"}}</h5>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_name"}}</label>
|
||||
<input name="name" value="{{.Manifest.Name}}" placeholder="Project name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_org"}}</label>
|
||||
<input name="org" value="{{.Manifest.Org}}" placeholder="Organization">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_description"}}</label>
|
||||
<input name="description" value="{{.Manifest.Description}}" placeholder="Project description">
|
||||
</div>
|
||||
<div class="three fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_version"}}</label>
|
||||
<input name="version" value="{{.Manifest.Version}}" placeholder="e.g. 06.00.00">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_license_spdx"}}</label>
|
||||
<input name="license_spdx" value="{{.Manifest.LicenseSPDX}}" placeholder="e.g. GPL-3.0-or-later">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_license_name"}}</label>
|
||||
<input name="license_name" value="{{.Manifest.LicenseName}}" placeholder="e.g. GNU General Public License v3">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.manifest_governance"}}</h5>
|
||||
<div class="three fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_platform"}}</label>
|
||||
<select name="platform" class="ui dropdown">
|
||||
<option value="">—</option>
|
||||
{{$platform := .Manifest.Platform}}
|
||||
{{range $val := StringUtils.Split "go,php,node,python,ruby,java,dotnet,rust" ","}}
|
||||
<option value="{{$val}}" {{if eq $val $platform}}selected{{end}}>{{$val}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_standards_version"}}</label>
|
||||
<input name="standards_version" value="{{.Manifest.StandardsVersion}}" placeholder="e.g. 05.00.00">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_standards_source"}}</label>
|
||||
<input name="standards_source" value="{{.Manifest.StandardsSource}}" placeholder="URL to standards repo">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.manifest_build"}}</h5>
|
||||
<div class="three fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_language"}}</label>
|
||||
<input name="language" value="{{.Manifest.Language}}" placeholder="e.g. Go, PHP, TypeScript">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_package_type"}}</label>
|
||||
<select name="package_type" class="ui dropdown">
|
||||
<option value="">—</option>
|
||||
{{$pkgType := .Manifest.PackageType}}
|
||||
{{range $val := StringUtils.Split "application,library,plugin,module,component,package,template" ","}}
|
||||
<option value="{{$val}}" {{if eq $val $pkgType}}selected{{end}}>{{$val}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.manifest_entry_point"}}</label>
|
||||
<input name="entry_point" value="{{.Manifest.EntryPoint}}" placeholder="e.g. ./ or src/index.ts">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.settings.manifest_save"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
@@ -12,6 +12,9 @@
|
||||
{{svg "octicon-broadcast"}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsManifest}}active {{end}}item" href="{{.RepoLink}}/settings/manifest">
|
||||
{{svg "octicon-file-code"}} {{ctx.Locale.Tr "repo.settings.manifest"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsMetadata}}active {{end}}item" href="{{.RepoLink}}/settings/metadata">
|
||||
{{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "repo.settings.metadata"}}
|
||||
</a>
|
||||
|
||||
@@ -130,6 +130,21 @@
|
||||
{{template "repo/code/upstream_diverging_info" .}}
|
||||
{{end}}
|
||||
{{template "repo/view_list" .}}
|
||||
{{if .WellKnownFileTabs}}
|
||||
<div class="ui top attached tabular menu well-known-file-tabs">
|
||||
{{range .WellKnownFileTabs}}
|
||||
<a class="{{if .Active}}active {{end}}item" href="?tab={{.TabKey}}">
|
||||
{{if eq .TabKey "readme"}}{{svg "octicon-book" 16 "tw-mr-1"}}{{end -}}
|
||||
{{if eq .TabKey "license"}}{{svg "octicon-law" 16 "tw-mr-1"}}{{end -}}
|
||||
{{if eq .TabKey "contributing"}}{{svg "octicon-heart" 16 "tw-mr-1"}}{{end -}}
|
||||
{{if eq .TabKey "code_of_conduct"}}{{svg "octicon-people" 16 "tw-mr-1"}}{{end -}}
|
||||
{{if eq .TabKey "security"}}{{svg "octicon-shield" 16 "tw-mr-1"}}{{end -}}
|
||||
{{if eq .TabKey "changelog"}}{{svg "octicon-history" 16 "tw-mr-1"}}{{end -}}
|
||||
{{ctx.Locale.Tr .Label}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if and .ReadmeExist (or .RenderAsMarkup .IsPlainText)}}
|
||||
{{template "repo/view_file" .}}
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Custom Fields
|
||||
|
||||
Custom fields allow organizations to define structured metadata that appears in issue sidebars and repository settings across all repos in the organization.
|
||||
|
||||
## Overview
|
||||
|
||||
Custom fields are defined at the **organization level** in Org Settings > Custom Fields. Each field has a scope:
|
||||
|
||||
- **Issue scope** — appears in the issue sidebar for inline editing
|
||||
- **Repo scope** — appears in Repository Settings > Metadata for repo-level values
|
||||
|
||||
## Field Types
|
||||
|
||||
| Type | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `text` | Free-form text input | "Affected Component" |
|
||||
| `number` | Numeric input | "Story Points" |
|
||||
| `date` | Date picker | "Due Date" |
|
||||
| `dropdown` | Select from predefined options | "Priority: Low/Medium/High/Critical" |
|
||||
| `checkbox` | Boolean toggle | "Requires QA" |
|
||||
| `url` | URL input | "Design Link" |
|
||||
|
||||
## Org Settings
|
||||
|
||||
Navigate to **Organization Settings > Custom Fields** to manage field definitions.
|
||||
|
||||
Each field has:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Name | Display name |
|
||||
| Scope | `issue` (sidebar) or `repo` (metadata) |
|
||||
| Type | One of: text, number, date, dropdown, checkbox, url |
|
||||
| Options | JSON array for dropdown options (e.g., `["Low","Medium","High"]`) |
|
||||
| Description | Help text (shown as tooltip) |
|
||||
| Sort Order | Controls display order |
|
||||
| Is Active | Inactive fields are hidden from new forms but preserved on existing entities |
|
||||
|
||||
## Issue Sidebar
|
||||
|
||||
Issue-scoped fields appear in the sidebar between labels and milestones. Dropdown fields auto-submit on change. Text/number/date fields display their current value.
|
||||
|
||||
Each field renders as an inline form posting to:
|
||||
```
|
||||
POST /{owner}/{repo}/issues/{issue_id}/custom-fields/{field_id}
|
||||
```
|
||||
|
||||
## Repository Metadata
|
||||
|
||||
Repo-scoped fields appear on the **Repository Settings > Metadata** page. All fields for the org are shown with their current values for the repository. Values are saved via form POST.
|
||||
|
||||
## Issue Template Integration
|
||||
|
||||
Custom fields can be pre-filled from issue template YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
name: Bug Report
|
||||
about: Report a bug
|
||||
custom_fields:
|
||||
Priority: High
|
||||
Affected Component: Backend
|
||||
```
|
||||
|
||||
When a new issue is created from this template, the sidebar shows the custom fields with the specified defaults pre-selected.
|
||||
|
||||
## API
|
||||
|
||||
### Issue-Level Custom Fields
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/repos/{owner}/{repo}/issues/{index}/custom-fields` | Get field values for an issue |
|
||||
| PUT | `/api/v1/repos/{owner}/{repo}/issues/{index}/custom-fields` | Set field values (name-value map) |
|
||||
|
||||
### Repo-Level Metadata
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/repos/{owner}/{repo}/metadata` | Get repo metadata field values |
|
||||
| PUT | `/api/v1/repos/{owner}/{repo}/metadata` | Set repo metadata field values |
|
||||
|
||||
### Org-Level Definitions
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/orgs/{org}/custom-fields` | List all field definitions |
|
||||
| POST | `/api/v1/orgs/{org}/custom-fields` | Create a field definition |
|
||||
| DELETE | `/api/v1/orgs/{org}/custom-fields/{id}` | Delete a field definition |
|
||||
|
||||
## Database
|
||||
|
||||
### Tables
|
||||
|
||||
**`custom_field_def`** — field definitions (org-level)
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | bigint | Primary key |
|
||||
| owner_id | bigint | Org ID (0 = legacy repo-level) |
|
||||
| repo_id | bigint | 0 for org-level definitions |
|
||||
| scope | varchar(10) | `issue` or `repo` |
|
||||
| name | varchar | Field name |
|
||||
| field_type | varchar(20) | text, number, date, dropdown, checkbox, url |
|
||||
| description | text | Help text |
|
||||
| options | text | JSON array for dropdown options |
|
||||
| required | bool | Whether the field is required |
|
||||
| sort_order | int | Display order |
|
||||
| is_active | bool | Visibility flag |
|
||||
|
||||
**`custom_field_value`** — field values (per entity)
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | bigint | Primary key |
|
||||
| entity_id | bigint | Issue ID or Repo ID |
|
||||
| entity_type | varchar(10) | `issue` or `repo` |
|
||||
| field_id | bigint | FK to custom_field_def |
|
||||
| value | text | The stored value |
|
||||
|
||||
### Cascade on Delete
|
||||
|
||||
When a field definition is deleted, all associated values in `custom_field_value` are also deleted.
|
||||
|
||||
## Relationship to Other Systems
|
||||
|
||||
| System | Relationship |
|
||||
|--------|-------------|
|
||||
| Update Server | Repo-scoped custom fields with specific names (Extension Name, Display Name, etc.) are read by the update feed generators as the highest-priority metadata source. |
|
||||
| Manifest Settings | Manifest fields follow the moko-platform schema and are separate from custom fields. Custom fields are user-defined; manifest fields are standardized. |
|
||||
| Issue Statuses | Custom statuses are a separate feature with their own dedicated table and UI, not implemented as custom fields. |
|
||||
|
||||
---
|
||||
|
||||
| Revision | Date | Author | Description |
|
||||
|---|---|---|---|
|
||||
| 1.0 | 2026-06-06 | Jonathan Miller (@jmiller) | Initial version |
|
||||
@@ -0,0 +1,107 @@
|
||||
# Custom Issue Statuses
|
||||
|
||||
Custom issue statuses extend Gitea's binary Open/Closed model with org-defined workflow states. Each status has a name, color, and an optional "closes issue" flag that triggers automatic close/reopen when selected.
|
||||
|
||||
## Overview
|
||||
|
||||
Statuses are defined at the **organization level** and appear in the issue sidebar for all repositories under that organization. This is the same pattern as org-level labels and custom fields.
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Status definitions** are managed in Org Settings > Issue Statuses
|
||||
- **Status selection** appears as a dropdown in the issue sidebar
|
||||
- **Auto close/reopen** — selecting a status with `closes_issue = true` automatically closes the issue; switching to a non-closing status reopens it
|
||||
- **Status is supplemental** — the existing Open/Closed binary state is preserved; statuses add granularity on top
|
||||
|
||||
## Org Settings
|
||||
|
||||
Navigate to **Organization Settings > Issue Statuses** to manage status definitions.
|
||||
|
||||
Each status has:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Name | Display name (e.g., "In Progress", "Won't Fix", "Blocked") |
|
||||
| Color | Hex color for visual distinction (e.g., `#2563eb`) |
|
||||
| Description | Help text shown to users |
|
||||
| Closes Issue | When checked, selecting this status automatically closes the issue |
|
||||
| Sort Order | Controls display order in dropdowns (ascending) |
|
||||
| Is Active | Inactive statuses are hidden from dropdowns but preserved on existing issues |
|
||||
|
||||
### Example Statuses
|
||||
|
||||
| Status | Color | Closes Issue | Use Case |
|
||||
|--------|-------|:------------:|----------|
|
||||
| In Progress | Blue | No | Work is actively being done |
|
||||
| Needs Info | Yellow | No | Waiting for more information from reporter |
|
||||
| Blocked | Red | No | Cannot proceed due to external dependency |
|
||||
| Won't Fix | Gray | Yes | Decided not to address this issue |
|
||||
| Duplicate | Purple | Yes | Already tracked in another issue |
|
||||
| Resolved | Green | Yes | Fix has been implemented and verified |
|
||||
|
||||
## Issue Sidebar
|
||||
|
||||
When an organization has custom statuses defined, a **Status** dropdown appears in the issue sidebar between labels and custom fields. The dropdown:
|
||||
|
||||
- Shows all active status definitions for the repo's organization
|
||||
- Auto-submits on change (no save button needed)
|
||||
- Displays a colored left border on each option
|
||||
- Shows a power symbol on statuses that close the issue
|
||||
- Selecting "—" (empty) clears the status
|
||||
|
||||
### Auto Close/Reopen Behavior
|
||||
|
||||
| Current State | Selected Status | Result |
|
||||
|:---:|---|---|
|
||||
| Open | Status with `closes_issue = true` | Issue is closed automatically |
|
||||
| Closed | Status with `closes_issue = false` | Issue is reopened automatically |
|
||||
| Open | Status with `closes_issue = false` | Status set, issue stays open |
|
||||
| Closed | Status with `closes_issue = true` | Status set, issue stays closed |
|
||||
|
||||
All close/reopen actions go through the standard Gitea service layer, so webhooks, notifications, and timeline events fire normally.
|
||||
|
||||
## Database
|
||||
|
||||
### Tables
|
||||
|
||||
**`issue_status_def`** (migration v346) — org-level status definitions
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | bigint | Primary key |
|
||||
| org_id | bigint | Organization ID |
|
||||
| name | varchar | Status name |
|
||||
| color | varchar(7) | Hex color |
|
||||
| description | text | Help text |
|
||||
| closes_issue | bool | Auto-close flag |
|
||||
| sort_order | int | Display order |
|
||||
| is_active | bool | Visibility flag |
|
||||
|
||||
**`issue`** table — added `status_id` column (bigint, default 0)
|
||||
|
||||
### Cascade on Delete
|
||||
|
||||
When a status definition is deleted, all issues referencing it have their `status_id` set to 0 (cleared). Issues are not closed or reopened during deletion.
|
||||
|
||||
## Routes
|
||||
|
||||
### Web Routes (Org Settings)
|
||||
|
||||
| Method | Path | Handler |
|
||||
|--------|------|---------|
|
||||
| GET | `/org/{org}/settings/issue-statuses` | `SettingsIssueStatuses` |
|
||||
| POST | `/org/{org}/settings/issue-statuses` | `SettingsIssueStatusesCreatePost` |
|
||||
| POST | `/org/{org}/settings/issue-statuses/{id}/edit` | `SettingsIssueStatusesEditPost` |
|
||||
| POST | `/org/{org}/settings/issue-statuses/{id}/delete` | `SettingsIssueStatusesDeletePost` |
|
||||
|
||||
### Web Routes (Issue Sidebar)
|
||||
|
||||
| Method | Path | Handler |
|
||||
|--------|------|---------|
|
||||
| POST | `/{owner}/{repo}/issues/{id}/custom-status` | `UpdateIssueCustomStatus` |
|
||||
|
||||
---
|
||||
|
||||
| Revision | Date | Author | Description |
|
||||
|---|---|---|---|
|
||||
| 1.0 | 2026-06-06 | Jonathan Miller (@jmiller) | Initial version |
|
||||
@@ -0,0 +1,50 @@
|
||||
# MokoGitea
|
||||
|
||||
Moko Consulting's custom fork of [Gitea](https://gitea.com), extending the self-hosted Git service with commercial licensing, update feeds, custom issue workflows, and org-level management features.
|
||||
|
||||
| Field | Value |
|
||||
|-----|-----|
|
||||
| **Language** | Go |
|
||||
| **License** | MIT |
|
||||
| **Upstream** | Gitea 1.26.1 |
|
||||
| **Version** | v1.26.1-moko.06.04.00 |
|
||||
| **Platform** | [Gitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea) |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Commercial License System** — Package-based license keys with download gating, domain restriction, key expiry, and payment webhook API
|
||||
- **Update Server** — Built-in update feeds for Joomla, WordPress, Dolibarr, Composer, Drupal, PrestaShop, and WHMCS
|
||||
- **Custom Issue Statuses** — Org-defined workflow states (In Progress, Blocked, Won't Fix) with auto close/reopen
|
||||
- **Custom Fields** — Org-level field definitions for issues (sidebar) and repos (metadata) with dropdown, text, number, date, checkbox, and URL types
|
||||
- **Manifest Settings** — Per-repo identity/governance/build metadata with REST API for CI/CD integration
|
||||
- **Well-Known File Tabs** — README/LICENSE/CONTRIBUTING/SECURITY/CHANGELOG tabs on repo home page
|
||||
- **Org-Level Branch Protection** — Organization-scoped rulesets that cascade to all repos. Supports glob patterns. Full CRUD API
|
||||
- **Enterprise Sub-Orgs** — Parent-child organization hierarchy
|
||||
- **Three-Level Visibility** — Public (200), Private (403), Hidden (404) for repositories
|
||||
- **Configurable Help/Support URLs** — Replace hardcoded docs.gitea.com links via HELP_URL and SUPPORT_URL in app.ini
|
||||
- **Project Board API** — REST API endpoints for managing project boards, columns, and cards
|
||||
- **Custom branding** — Moko Consulting visual identity (logos, colors, footer)
|
||||
|
||||
## Pages
|
||||
|
||||
| Page | Description |
|
||||
|---|---|
|
||||
| [Branding](Branding) | Custom branding and visual identity details |
|
||||
| [Custom Fields](Custom-Fields) | Org-level custom fields for issues and repos |
|
||||
| [Custom Issue Statuses](Custom-Issue-Statuses) | Org-defined workflow states with auto close/reopen |
|
||||
| [Deployment](Deployment) | Production deployment guide |
|
||||
| [Manifest Settings](Manifest-Settings) | Per-repo manifest settings and REST API |
|
||||
| [Org Branch Protection API](Org-Branch-Protection-API) | Org-level branch protection rulesets and API reference |
|
||||
| [Project API](Project-API) | Custom API endpoint reference for project boards |
|
||||
| [Roadmap](Roadmap) | Development roadmap and planned features |
|
||||
|
||||
---
|
||||
|
||||
| Revision | Date | Author | Description |
|
||||
|---|---|---|---|
|
||||
| 4.0 | 2026-06-06 | Jonathan Miller (@jmiller) | Add manifest settings, custom statuses, custom fields, well-known tabs, update version to v1.26.1-moko.06.04.00 |
|
||||
| 3.0 | 2026-05-12 | Jonathan Miller (@jmiller) | Add org branch protection, help URLs, version convention |
|
||||
| 2.0 | 2026-05-10 | Jonathan Miller (@jmiller) | Rewrite with detailed features and fork documentation |
|
||||
| 1.0 | 2026-05-09 | Jonathan Miller (@jmiller) | Initial version |
|
||||
@@ -0,0 +1,111 @@
|
||||
# Manifest Settings
|
||||
|
||||
The manifest settings feature provides a centralized way to store and manage project identity, governance, and build metadata for each repository. Settings are stored in the database and exposed via both a web UI and REST API.
|
||||
|
||||
## Overview
|
||||
|
||||
Each repository can have a manifest that describes:
|
||||
|
||||
- **Identity** — project name, organization, description, version, and license
|
||||
- **Governance** — platform type, moko-platform standards version, and standards source URL
|
||||
- **Build** — language, package type, and entry point
|
||||
|
||||
These settings replace the legacy `.mokogitea/manifest.xml` file-based approach.
|
||||
|
||||
## Repo Settings Page
|
||||
|
||||
Navigate to **Repository Settings > Manifest** to view and edit manifest fields.
|
||||
|
||||
| Section | Fields |
|
||||
|---------|--------|
|
||||
| Identity | Name, Org, Description, Version, License SPDX, License Name |
|
||||
| Governance | Platform, Standards Version, Standards Source |
|
||||
| Build | Language, Package Type, Entry Point |
|
||||
|
||||
### Auto-Migration from manifest.xml
|
||||
|
||||
On first visit to the Manifest settings page, if no manifest exists in the database but a `.mokogitea/manifest.xml` file exists in the repository, the system will:
|
||||
|
||||
1. Parse the XML and extract all fields
|
||||
2. Store them in the database
|
||||
3. Display a flash message indicating migration was successful
|
||||
4. The manifest.xml file can then be manually deleted from the repository
|
||||
|
||||
If a field already has a value in the database (e.g., from org-level custom fields), the existing value is preserved and the manifest.xml value is skipped.
|
||||
|
||||
## REST API
|
||||
|
||||
The manifest API allows Actions workflows and the moko-platform CLI to read and write manifest settings programmatically.
|
||||
|
||||
### Get Manifest
|
||||
|
||||
```
|
||||
GET /api/v1/repos/{owner}/{repo}/manifest
|
||||
Authorization: token {access_token}
|
||||
```
|
||||
|
||||
Returns the current manifest settings. If no manifest has been saved, returns defaults derived from repository metadata (name, owner, description).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"name": "MokoGitea",
|
||||
"org": "MokoConsulting",
|
||||
"description": "Moko fork of Gitea",
|
||||
"version": "06.04.00",
|
||||
"license_spdx": "GPL-3.0-or-later",
|
||||
"license_name": "GNU General Public License v3",
|
||||
"platform": "go",
|
||||
"standards_version": "05.00.00",
|
||||
"standards_source": "https://code.mokoconsulting.tech/MokoConsulting/moko-platform",
|
||||
"language": "Go",
|
||||
"package_type": "application",
|
||||
"entry_point": "./"
|
||||
}
|
||||
```
|
||||
|
||||
### Update Manifest
|
||||
|
||||
```
|
||||
PUT /api/v1/repos/{owner}/{repo}/manifest
|
||||
Authorization: token {access_token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Requires repo admin permission. Accepts the same JSON structure as the GET response. Creates or updates the manifest.
|
||||
|
||||
### Usage in Actions Workflows
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: Read manifest version
|
||||
run: |
|
||||
VERSION=$(curl -s "$GITEA_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/manifest" \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" | jq -r '.version')
|
||||
echo "Current version: $VERSION"
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
curl -s -X PUT "$GITEA_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/manifest" \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"version\": \"$NEW_VERSION\"}"
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
Manifest settings are stored in the `repo_manifest` table (migration v347). One row per repository, keyed by `repo_id`.
|
||||
|
||||
## Relationship to Other Systems
|
||||
|
||||
| System | Relationship |
|
||||
|--------|-------------|
|
||||
| Update Server | The update server generators read from both manifest settings and update_stream_config. Manifest provides identity metadata; update_stream_config provides feed-specific settings. |
|
||||
| Custom Fields | Repo-scoped custom fields (org settings) are separate from manifest fields. Custom fields are user-defined; manifest fields follow the moko-platform schema. |
|
||||
| moko-platform CLI | The CLI reads manifest settings via the API for version bumping, build decisions, and cross-repo syncing (see issue #505). |
|
||||
|
||||
---
|
||||
|
||||
| Revision | Date | Author | Description |
|
||||
|---|---|---|---|
|
||||
| 1.0 | 2026-06-06 | Jonathan Miller (@jmiller) | Initial version |
|
||||
Reference in New Issue
Block a user