Compare commits
282 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eadade2f4 | |||
| 2cc4f7c047 | |||
| 74a5fe2b80 | |||
| 50c472991a | |||
| 7dabf844a8 | |||
| 7d03541201 | |||
| d4a2c33c37 | |||
| e59290802a | |||
| 1d857d8205 | |||
| 4f9aeb7b85 | |||
| 1178eaec62 | |||
| dd1454c3cf | |||
| c539bed4d3 | |||
| 135b37edf1 | |||
| 48cf445e79 | |||
| 72708b5a99 | |||
| 948860e8ac | |||
| f7c1904625 | |||
| e3b2df4aac | |||
| 6cd4a19ed6 | |||
| 4d73c6a939 | |||
| df91ed2aac | |||
| 8639d85fe7 | |||
| e7a79d973e | |||
| 6c5394107e | |||
| 0a158e9ec3 | |||
| 48c354fbb4 | |||
| 41c42b968e | |||
| d64dc7cf45 | |||
| 6010841ee7 | |||
| 3857f1339d | |||
| 328ff92c52 | |||
| af7d6d78a8 | |||
| 01ef500793 | |||
| 1438dc7838 | |||
| a6245ff075 | |||
| a4b7b5276c | |||
| 64b3dbe50b | |||
| 22586b7a06 | |||
| 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 | |||
| 33076e4e1b | |||
| a3c6f54ad3 | |||
| cfdb9b4f0a | |||
| 0109a2db12 | |||
| ad4451f23c | |||
| fe6ca172f6 | |||
| bcd207cd51 | |||
| 1913b4c8c2 | |||
| 5ca1c888c0 | |||
| 8e0388c9d8 | |||
| cd4c701cb6 | |||
| b72f88e78b | |||
| 1935889f6b | |||
| 9ebe1b26b1 | |||
| 1322b5e905 | |||
| 4d43553c91 | |||
| 3aec6c2cae | |||
| 558b36da8c | |||
| e42214930a | |||
| 539619be2f | |||
| c124527ca2 | |||
| 8fce854f18 | |||
| 6bd9548b2a | |||
| e1b0c74d24 | |||
| 96661dcb7c | |||
| 5665bc545e | |||
| 6c4a4ca819 | |||
| 15188fc0ea | |||
| df58aacc30 | |||
| 659f3f2537 | |||
| 26376b7d11 | |||
| 6575d3fce2 | |||
| b297ea2204 | |||
| 161ca23836 | |||
| d553c87a9d | |||
| f2ec3d5c02 | |||
| b3acbc9789 | |||
| 178e8fffe2 | |||
| ac1a726c3b | |||
| 2f767e91cb | |||
| 5cb5cec7ef | |||
| 5209dea127 | |||
| 5c22bb04b5 | |||
| 73ec0b52f6 | |||
| 49299c6a32 | |||
| d6d0d5a11f | |||
| c948696488 | |||
| 03b3a10541 | |||
| 81aab5d9ea | |||
| 635a13d277 | |||
| 3a836b69d9 | |||
| 3e1e179bf0 | |||
| 6be3e5c879 | |||
| ed909919f6 | |||
| e339646067 | |||
| a86a9afb1a | |||
| eaf581071d | |||
| 7da7e35d89 | |||
| 5a80b8da33 | |||
| 0b21fe859e | |||
| 4ec0db8658 | |||
| 10e76cf033 | |||
| 45af52611c | |||
| a5bda0f9a6 | |||
| a053126bd9 | |||
| 9fff67ab57 | |||
| 3eb649a1a6 | |||
| f091f4cab3 | |||
| 0de02fdce5 | |||
| 06f8ab3d1a | |||
| 3918e8ef9a | |||
| 480aaa088a | |||
| f0aa2c3034 | |||
| 01d38e13f9 | |||
| 04338fe159 | |||
| 0389410efc | |||
| ddababa6fa | |||
| 3e156e8307 | |||
| 546245c9bb | |||
| 26bb906a96 | |||
| 6509bd1eb7 | |||
| 6ac7c0c774 | |||
| 53c86c9b17 | |||
| 4a687a9438 | |||
| 0187f9814f | |||
| 92ca601aa6 | |||
| e866d16ee6 | |||
| 5a1772b026 | |||
| 969015a87a | |||
| ee20006b15 | |||
| ce38bab2cf | |||
| aba8021344 | |||
| 7cbbfb7505 | |||
| 7f45e98630 | |||
| 74194f4283 | |||
| e4ea1303ea | |||
| 7d6cc3152d | |||
| 42b0ff182c | |||
| 32d5a292c7 | |||
| 33ba1159c3 | |||
| cd45824a0d | |||
| bab1acdfe3 | |||
| e59837b250 | |||
| 5c1e1cc8cc | |||
| 6ea5dd37aa | |||
| 099f30d05b | |||
| cbaf289657 | |||
| 619295f469 | |||
| eced91be74 | |||
| 902e3b5edd | |||
| 95d4259a26 | |||
| 64dc6f28fa | |||
| cea3418894 | |||
| b6671ee1f9 | |||
| a52835b8ee | |||
| c64bafbe80 | |||
| e088589af7 | |||
| c90edc3efc | |||
| dda0f6d4ed | |||
| 21fb789d3c | |||
| 558bf37fce | |||
| 746f1a5a50 | |||
| 9cceb5da0b | |||
| fb5002d317 | |||
| f2482a712c | |||
| 349a326881 | |||
| 7dc598104b | |||
| 402166589b | |||
| 6c9a26ebd3 | |||
| c15582aa64 | |||
| 03dee5af39 | |||
| 2827fa0a4c | |||
| 979d6f5964 | |||
| 396220368f | |||
| 877f39d4f4 | |||
| 0492ea399e | |||
| 53b2d5b754 | |||
| 5db84e3932 | |||
| 02cb4ae1a1 | |||
| 75f05e1c80 | |||
| 23eb52cafb | |||
| 7f2aaa84bd | |||
| 6f16459e13 | |||
| 5cf91a12bc | |||
| c7d8f6066f | |||
| 6b0ec5196a | |||
| 8741096fb4 | |||
| 0ab3b7dbd7 | |||
| 02495327ee | |||
| 6f5c40716d | |||
| 1eff03ab21 | |||
| e3e2cb4543 | |||
| a15139f70b | |||
| abf961dd1e | |||
| b34381e8da | |||
| f9653411a7 | |||
| cd2e8b4d34 | |||
| acf9b4a4da | |||
| 23af404ae4 | |||
| a5b4f24b48 | |||
| f485f14615 | |||
| b2d2a3b622 | |||
| ba9907ba41 | |||
| b1b64a3b4e | |||
| 3cddb46053 | |||
| 0a0cc16528 | |||
| da41d7072f | |||
| cb3817f5bc | |||
| f657f58fbb | |||
| 1709566fa6 | |||
| 95c136d838 | |||
| 48f32ae961 | |||
| dce87fcb5d | |||
| 7004170d64 | |||
| c045c6abfc | |||
| ce35e3a603 | |||
| 3e4cb4d2e5 | |||
| ba361c609f | |||
| 7aaf8dcbb7 | |||
| 128b120ad9 | |||
| 3f817babd3 | |||
| 6290ff07e4 | |||
| c4e51ff55c | |||
| b707c5aff9 | |||
| 2db1f4eaf6 | |||
| 25499fb183 | |||
| 7c15301228 | |||
| e4718f5036 | |||
| 581bfa5f31 | |||
| 8ae663e15e | |||
| 4bc962adbf | |||
| ca841716db | |||
| 117daf51c3 | |||
| a2e0735a26 | |||
| 1a46a8f14f | |||
| b18519e8b9 | |||
| 94649efed0 | |||
| a52ac1bf61 | |||
| 5da4b3b314 | |||
| 75e2a21b89 | |||
| e82fe7d021 | |||
| 24a9bfb30d | |||
| 257908e083 | |||
| 2c3aad51af | |||
| 66a6a2afc1 | |||
| 74935e3bed | |||
| bc95ecf4d5 | |||
| a35fb4695c | |||
| 70c31a4953 | |||
| 6c913abbda | |||
| 878671ebc9 | |||
| c7cfcf894b | |||
| bbe3e570fe | |||
| 26bbe690fd | |||
| bfa9043bc8 | |||
| b1a9b09f5b |
@@ -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.14.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"]
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | joomla: XML manifest, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
@@ -71,20 +71,25 @@ jobs:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
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
|
||||
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 2>&1; 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
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /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
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
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
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
@@ -100,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
@@ -108,7 +113,7 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
@@ -150,25 +155,61 @@ jobs:
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
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
|
||||
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 2>&1; 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
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /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
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
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
|
||||
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
else
|
||||
NOTES="Stable release"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/stable" | 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
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
@@ -180,7 +221,7 @@ jobs:
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/release_mirror.php \
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
@@ -254,7 +295,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/version_reset_dev.php \
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 05.14.00
|
||||
# VERSION: 05.47.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Publish MCP to npm
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.mokogitea/mcp/**'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install and build
|
||||
working-directory: .mokogitea/mcp
|
||||
run: |
|
||||
npm ci
|
||||
npx tsc
|
||||
|
||||
- name: Check version change
|
||||
id: version
|
||||
working-directory: .mokogitea/mcp
|
||||
run: |
|
||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
||||
NPM_VERSION=$(npm view @mokoconsulting/mokogitea-mcp version 2>/dev/null || echo "0.0.0")
|
||||
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
echo "Version unchanged: $LOCAL_VERSION"
|
||||
fi
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.version.outputs.changed == 'true'
|
||||
working-directory: .mokogitea/mcp
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish to Gitea registry
|
||||
if: steps.version.outputs.changed == 'true'
|
||||
working-directory: .mokogitea/mcp
|
||||
run: |
|
||||
npm publish --registry ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/npm/ \
|
||||
--//$(echo "${{ github.server_url }}" | sed 's|https://||')/api/packages/${{ github.repository_owner }}/npm/:_authToken=${{ secrets.GITEA_TOKEN }}
|
||||
@@ -147,6 +147,98 @@ jobs:
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Joomla JEXEC guard check
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
# Skip vendor, node_modules, and index.html stub files
|
||||
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
|
||||
# Check first 10 lines for JEXEC or JPATH guard
|
||||
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
|
||||
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
||||
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "JEXEC guard: OK"
|
||||
|
||||
- name: Joomla directory listing protection
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
MISSING=0
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
while IFS= read -r dir; do
|
||||
if [ ! -f "${dir}/index.html" ]; then
|
||||
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Directory protection: ${MISSING} missing (advisory)"
|
||||
|
||||
- name: Joomla script file and asset checks
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check scriptfile exists if declared
|
||||
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
if [ -n "$SCRIPTFILE" ]; then
|
||||
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
||||
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Require joomla.asset.json and validate it
|
||||
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ASSET_JSON" ]; then
|
||||
echo "::error::joomla.asset.json not found — Joomla asset system is required"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
|
||||
echo "::error::joomla.asset.json is not valid JSON"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
}
|
||||
fi
|
||||
echo "joomla.asset.json: valid"
|
||||
fi
|
||||
|
||||
# Validate all XML files in src/ are well-formed
|
||||
XML_ERRORS=0
|
||||
if command -v php &> /dev/null; then
|
||||
while IFS= read -r -d '' xmlfile; do
|
||||
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
|
||||
XML_ERRORS=$((XML_ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
|
||||
fi
|
||||
if [ "$XML_ERRORS" -gt 0 ]; then
|
||||
echo "::error::${XML_ERRORS} XML file(s) are malformed"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "XML well-formedness: OK"
|
||||
fi
|
||||
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
echo "Joomla asset checks: OK"
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
@@ -164,6 +256,13 @@ jobs:
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
# Block legacy raw/branch update server URLs on MokoGitea
|
||||
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
||||
if [ -n "$RAW_URLS" ]; then
|
||||
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
||||
echo "$RAW_URLS"
|
||||
exit 1
|
||||
fi
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
@@ -196,6 +295,138 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Validate Joomla language files
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
# Require both en-GB and en-US language directories
|
||||
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$LANG_ROOT" ]; then
|
||||
echo "No language/ directory found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$LANG_ROOT/en-GB" ]; then
|
||||
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
if [ ! -d "$LANG_ROOT/en-US" ]; then
|
||||
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Check that en-GB and en-US have matching .ini files
|
||||
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
|
||||
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
|
||||
[ ! -f "$GB_INI" ] && continue
|
||||
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
|
||||
if [ ! -f "$US_INI" ]; then
|
||||
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
|
||||
[ ! -f "$US_INI" ] && continue
|
||||
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
|
||||
if [ ! -f "$GB_INI" ]; then
|
||||
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Find all .ini language files
|
||||
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
|
||||
if [ -z "$INI_FILES" ]; then
|
||||
echo "No .ini language files found"
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
|
||||
|
||||
for FILE in $INI_FILES; do
|
||||
FNAME=$(basename "$FILE")
|
||||
LINENUM=0
|
||||
SEEN_KEYS=""
|
||||
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
LINENUM=$((LINENUM + 1))
|
||||
|
||||
# Skip empty lines and comments
|
||||
[ -z "$line" ] && continue
|
||||
echo "$line" | grep -qE '^\s*;' && continue
|
||||
echo "$line" | grep -qE '^\s*$' && continue
|
||||
|
||||
# Must match KEY="VALUE" format
|
||||
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract key and check for duplicates
|
||||
KEY=$(echo "$line" | sed 's/=.*//')
|
||||
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
SEEN_KEYS="${SEEN_KEYS}
|
||||
${KEY}"
|
||||
done < "$FILE"
|
||||
|
||||
echo " ${FILE}: checked ${LINENUM} lines"
|
||||
done
|
||||
|
||||
# Cross-check en-GB vs en-US key consistency
|
||||
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
|
||||
for GB_FILE in "$GB_DIR"/*.ini; do
|
||||
[ ! -f "$GB_FILE" ] && continue
|
||||
FNAME=$(basename "$GB_FILE")
|
||||
US_FILE="$US_DIR/$FNAME"
|
||||
[ ! -f "$US_FILE" ] && continue
|
||||
|
||||
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
|
||||
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
|
||||
|
||||
# Keys in en-GB but not en-US
|
||||
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_US" ]; then
|
||||
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
|
||||
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
# Keys in en-US but not en-GB
|
||||
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_GB" ]; then
|
||||
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
|
||||
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
{
|
||||
echo "### Language File Validation"
|
||||
echo "| Metric | Count |"
|
||||
echo "|---|---|"
|
||||
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
|
||||
echo "| Errors | ${ERRORS} |"
|
||||
echo "| Warnings | ${WARNINGS} |"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::Language validation failed with ${ERRORS} error(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Language files: OK (${WARNINGS} warning(s))"
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
# 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/cli/manifest_element.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 2>&1; 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
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /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
|
||||
|
||||
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
|
||||
php ${MOKO_CLI}/manifest_licensing.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
|
||||
@@ -11,7 +11,7 @@
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
|
||||
name: "Generic: Repo Health"
|
||||
@@ -24,13 +24,12 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, release, scripts, or repo'
|
||||
description: 'Validation profile: all, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- release
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
@@ -40,10 +39,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Release policy - Repository Variables Only
|
||||
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
@@ -138,101 +133,6 @@ jobs:
|
||||
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
release_config:
|
||||
name: Release configuration
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Guardrails release vars
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
|
||||
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes release validation'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
|
||||
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
|
||||
|
||||
missing=()
|
||||
missing_optional=()
|
||||
|
||||
for k in "${required[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing+=("${k}")
|
||||
done
|
||||
|
||||
for k in "${optional[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing_optional+=("${k}")
|
||||
done
|
||||
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Variable | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
|
||||
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repository variables'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repository variables'
|
||||
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository variables validation result'
|
||||
printf '%s\n' 'Status: OK'
|
||||
printf '%s\n' 'All required repository variables present.'
|
||||
printf '%s\n' ''
|
||||
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
scripts_governance:
|
||||
name: Scripts governance
|
||||
needs: access_check
|
||||
@@ -256,14 +156,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
|
||||
if [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -370,14 +270,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
|
||||
if [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -704,7 +604,7 @@ jobs:
|
||||
printf '%s\n' '| Domain | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
|
||||
printf '%s\n' '| Release variables | OK | Repository variables validation |'
|
||||
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
|
||||
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
|
||||
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
|
||||
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
|
||||
@@ -773,11 +673,10 @@ jobs:
|
||||
report-issues:
|
||||
name: "Report Issues"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [access_check, release_config, scripts_governance, repo_health]
|
||||
needs: [access_check, scripts_governance, repo_health]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.release_config.result == 'failure' ||
|
||||
needs.scripts_governance.result == 'failure' ||
|
||||
(needs.scripts_governance.result == 'failure' ||
|
||||
needs.repo_health.result == 'failure')
|
||||
|
||||
steps:
|
||||
@@ -803,10 +702,6 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Release Configuration" \
|
||||
"${{ needs.release_config.result }}" \
|
||||
"Required repository variables are missing (RS_FTP_PATH_SUFFIX). Check repository settings."
|
||||
|
||||
report_gate "Scripts Governance" \
|
||||
"${{ needs.scripts_governance.result }}" \
|
||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
||||
@@ -814,4 +709,3 @@ jobs:
|
||||
report_gate "Repository Health" \
|
||||
"${{ needs.repo_health.result }}" \
|
||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run 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
|
||||
+111
-480
@@ -1,9 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
This changelog goes through the changes that have been made in each release
|
||||
without substantial changes to our git log; to see the highlights of what has
|
||||
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
## [v1.26.1-moko.06.02.00] - 2026-06-02
|
||||
All notable changes to MokoGitea are documented here. Versions follow the format
|
||||
`v{upstream}-moko.{major}.{minor}` (e.g. `v1.26.1-moko.06.03`).
|
||||
|
||||
## [v1.26.1-moko.06.10] - 2026-06-06
|
||||
|
||||
* FEATURES
|
||||
* feat(issues): first-class Type field with 12 auto-seeded defaults (Bug, Feature, Enhancement, Task, Documentation, Security, Roadmap, Client, Dolibarr, Infrastructure, Joomla, WaaS)
|
||||
* feat(issues): first-class Status field with 13 auto-seeded defaults including 7 Pending states
|
||||
* feat(issues): first-class Priority field with 4 auto-seeded defaults (Critical, High, Medium, Low)
|
||||
* feat(issues): Type/Status/Priority colored badges in issue list view
|
||||
* feat(issues): status dropdown replaces close/reopen button in comment form
|
||||
* feat(security): built-in security scanning platform with secret scanner (15 patterns)
|
||||
* feat(security): Security tab in repo navigation with alerts, scan controls
|
||||
* feat(wiki): hierarchical folder navigation with sidebar tree and breadcrumbs
|
||||
* feat(ui): well-known file tabs (README/LICENSE/CONTRIBUTING/SECURITY/CHANGELOG)
|
||||
* feat(settings): repo manifest settings with REST API and auto-sync on push
|
||||
* feat(mcp): public MCP server published to npm (@mokoconsulting/mokogitea-mcp)
|
||||
* feat(mcp): SSE transport, env var config, Docker support, 120+ tools
|
||||
* feat(mcp): issue dedup on create, type_id/status_id/priority_id params
|
||||
|
||||
* MIGRATIONS
|
||||
* All org labels migrated to first-class Type/Status/Priority fields and deleted
|
||||
* Type custom field (id=9) migrated to type_id and deleted
|
||||
* Status custom field (id=1) deleted (replaced by first-class field)
|
||||
* Priority labels migrated to priority_id
|
||||
* Pending labels migrated to status definitions
|
||||
* Scope labels migrated to type definitions
|
||||
* Manifests populated for all 61 repos via API
|
||||
|
||||
* FIXES
|
||||
* fix(ui): dashboard issue count badges use label spans instead of strong tags
|
||||
* fix(wiki): directory check before raw redirect for folder navigation
|
||||
* fix(wiki): proper display names in sidebar tree (strip dash markers)
|
||||
* fix: replace non-ASCII em dashes with hyphens for hook compatibility
|
||||
* fix: hookify __init__.py for stop hook JSON validation
|
||||
|
||||
* INFRASTRUCTURE
|
||||
* npm: @mokoconsulting/mokogitea-mcp@1.1.0 and @mokoconsulting/mokowaas-mcp@1.0.0
|
||||
* MCP servers consolidated under moko-platform/mcp/servers/
|
||||
* Remote MCP repos renamed to hyphens
|
||||
* Wiki restructured into features/, api/, operations/ folders
|
||||
* Swagger API docs enabled at /api/swagger
|
||||
|
||||
## [v1.26.1-moko.06.04] - 2026-06-06
|
||||
|
||||
* FEATURES
|
||||
* feat(licenses): full commercial license management system
|
||||
@@ -11,40 +51,53 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
* Search keys by customer, domain, key number, email, or payment ref
|
||||
* Download gating (none/prerelease/all modes)
|
||||
* Domain lock grace period (DomainLockHours)
|
||||
* Domain restriction on packages and keys (comma-separated allowed domains)
|
||||
* RepoScope enforcement — packages scoped to specific repos
|
||||
* Configurable license key prefix per organization
|
||||
* Master key auto-generates, sorts first in key list
|
||||
* License package creation at repo level via modal
|
||||
* Key generation modal with licensee name, email, and domain fields
|
||||
* Manual release-to-stream mapping with UI selector
|
||||
* Joomla changelog XML endpoint (/changelog.xml)
|
||||
* SHA256 checksums from sidecar files in Joomla updates.xml
|
||||
* Joomla-standard tag values (dev/alpha/beta/rc/stable)
|
||||
* Double confirmation modals for permanent deletion
|
||||
* Combolist channel picker (replaces checkboxes)
|
||||
* Extension metadata in repo settings (per-repo override)
|
||||
* API: package CRUD, key revoke, key renew, settings GET/PUT
|
||||
* API: purchase webhook with PaymentRef idempotency
|
||||
* API: public validation endpoint (no auth)
|
||||
* Migration v340-v342: all new columns synced
|
||||
* feat(updates): 7 platform update feeds
|
||||
* Joomla XML with downloadkey, SHA256, changelog URL
|
||||
* Migration v340-v344: all new columns synced
|
||||
* feat(updates): Update Server system (renamed from "Licensing")
|
||||
* Joomla XML with SHA256, changelog URL, version from asset filename
|
||||
* Dolibarr JSON with channel filtering
|
||||
* WordPress PUC-compatible JSON (plugin-update-checker)
|
||||
* Composer packages.json
|
||||
* PrestaShop module update XML
|
||||
* Drupal update status XML
|
||||
* WHMCS module update JSON
|
||||
* feat(updates): feed always public — downloads gated separately
|
||||
* feat(updates): stream-name tags supported alongside version tags
|
||||
* feat(updates): version extraction via regex from release titles
|
||||
* feat(updates): infourl defaults to release listing / support URL
|
||||
* feat(updates): downloadkey prefix matches Akeeba pattern (dlid=)
|
||||
* Feed always public — downloads gated separately
|
||||
* Stream-name tags supported alongside version tags
|
||||
* Omit `<client>` for package extension types
|
||||
* `<downloadkey>` only when download_gating is prerelease or all
|
||||
* Version extracted from asset filename (matches actual download)
|
||||
* Joomla tag values verified: dev, alpha, beta, rc, stable
|
||||
* feat(orgs): enterprise sub-org hierarchy with parent-child relationships
|
||||
* feat(repos): three-level visibility — Public (200), Private (403), Hidden (404)
|
||||
* feat(settings): separate licensing settings page (/settings/licensing)
|
||||
* feat(settings): advanced settings on dedicated page (/settings/advanced)
|
||||
* feat(settings): section headers with dividers and icons
|
||||
* feat(ui): icons on all settings navbars (repo, org, user, admin)
|
||||
* feat(settings): Update Server settings page with enable toggle in Advanced Settings
|
||||
* feat(settings): advanced settings on dedicated page with dividing headers
|
||||
* feat(settings): icons on all settings navbars (repo, org, user, admin)
|
||||
* feat(ui): styled 403 Access Denied page with inline login form
|
||||
* feat(ui): open-in-new-tab button on feed URLs
|
||||
* feat(issues): custom fields with inline editing in issue sidebar
|
||||
* feat(issues): pre-fill custom fields from issue template YAML frontmatter (#493)
|
||||
* Templates specify `custom_fields:` map (field name → default value)
|
||||
* New issue sidebar shows org-level fields with template defaults pre-selected
|
||||
* API create issue accepts `custom_fields` map by name
|
||||
* feat(updateserver): resolve extension metadata from org-level custom fields (#492)
|
||||
* Cascading fallback: custom fields → config table → repo-derived defaults
|
||||
* All six generators updated (Joomla, WordPress, Composer, Drupal, PrestaShop, WHMCS)
|
||||
* Repos can be migrated to custom fields gradually
|
||||
* feat(ui): two-in-one Update Server / Licenses tab
|
||||
* No gating: shows "Update Server" tab with feed URLs only
|
||||
* Gated: shows "Licenses" tab with full key management
|
||||
* `<downloadkey>` only appears when downloads are gated
|
||||
* SECURITY
|
||||
* fix(security): ownership guards on all API handlers (cross-org prevention)
|
||||
* fix(security): RepoScope JSON parsing (substring matching bug)
|
||||
@@ -55,48 +108,50 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
* fix(security): licensed private repos allow release viewing for signed-in users
|
||||
* fix(security): anonymous download access respects download_gating setting
|
||||
* FIXES
|
||||
* fix(licenses): expanded delete permissions to org owners + site admins
|
||||
* fix(licenses): explicit xorm column names for UpdateStreamConfig fields
|
||||
* fix(licenses): feed always public when licensing enabled
|
||||
* fix(settings): prevent double-highlight on Advanced Settings nav item
|
||||
* fix(settings): redirect back to /settings/advanced after save
|
||||
* fix(build): remove stale custom field API routes and dead code
|
||||
* fix(build): replace invalid UTF-8 character in API comment
|
||||
* fix(build): permanent fixes for AI migration, feed/file.go, unused imports
|
||||
* fix(updateserver): version extracted from asset filename (not release title)
|
||||
* fix(updateserver): omit `<client>` for package types per Joomla spec
|
||||
* fix(updateserver): `<downloadkey>` only shown when downloads are gated
|
||||
* fix(updateserver): prevent stream name tag from overriding asset-derived version
|
||||
* fix(build): restore build/ directory after accidental deletion
|
||||
* fix(licenses): master key banner removed, master keys sort first in table
|
||||
* fix(issues): issue sidebar loads org-level fields instead of legacy repo-level fields
|
||||
|
||||
## [v1.26.1-moko.05.15.00] - 2026-05-31
|
||||
## [v1.26.1-moko.05] - 2026-05-31
|
||||
|
||||
* BREAKING CHANGES
|
||||
* Deprecated Issue.Ref branch selector UI (#307)
|
||||
* Removed branch/tag selector from issue sidebar and new issue form
|
||||
* Removed ref badge from issue lists
|
||||
* Removed POST /ref web route and UpdateIssueRef handler
|
||||
* DB column and commit-close logic preserved for backward compatibility
|
||||
* API create/edit still accept `ref` field (no-op) for backward compat
|
||||
* FEATURES
|
||||
* feat(ui): add generic combo-multiselect component (#361)
|
||||
* feat(ui): generic combo-multiselect component (#361)
|
||||
* Reusable dropdown with search, checkable items, and selected-items display
|
||||
* Template: `shared/combolist.tmpl` — accepts Items, Name, Title, SelectedValues
|
||||
* Decoupled from issue sidebar — works in any form context
|
||||
* Template: `shared/combolist.tmpl`
|
||||
* feat(updates): extension metadata settings for update feed generation
|
||||
* feat(licenses): platform enforcement, key deletion, expired key cleanup
|
||||
* feat(licenses): store keys in plaintext, show full key with copy button
|
||||
* feat(actions): rebrand actions bot user to mokogitea-actions (#233, #234)
|
||||
* Backward-compatible: recognizes github-actions[bot], gitea-actions[bot]
|
||||
* feat(actions): actions bot user in branch protection whitelist (#233, #234)
|
||||
* WhitelistActionsUser, MergeWhitelistActionsUser, ForcePushAllowlistActionsUser
|
||||
* TECH DEBT
|
||||
* chore: full namespace migration from git.mokoconsulting.tech to code.mokoconsulting.tech (#336, #337, #344)
|
||||
* Go module path, all imports, template URLs, workflow configs (2,276 files)
|
||||
* chore: full namespace migration to code.mokoconsulting.tech (#336, #337, #344)
|
||||
* fix(blame): set HasSourceRenderedToggle for renderable files (#344)
|
||||
* fix(settings): translate team permission strings via data-locale attributes (#344)
|
||||
* fix(settings): translate team permission strings via data-locale (#344)
|
||||
* fix(dropzone): use relative path for non-image attachment markdown links (#344)
|
||||
* fix(templates): add required validation to issue dropdown fields (#350)
|
||||
* refactor(ts): remove redundant `handled` field from MarkdownHandleIndentionResult (#350)
|
||||
* refactor(go): rename HasOrgOrUserVisible to IsOwnerVisibleToDoer (#350)
|
||||
* refactor(go): replace ValuesRepository with maps.Values (Go 1.21+) (#357)
|
||||
* refactor(go): remove CanEnableEditor wrapper, use CanContentChange directly (#357)
|
||||
* fix(ts): parseIssueHref now uses URL pathname and trims appSubUrl (#360)
|
||||
* fix(actions): enforce MaxJobNumPerRun (256) limit when creating jobs (#360)
|
||||
* refactor(go): remove CanEnableEditor wrapper (#357)
|
||||
* fix(ts): parseIssueHref uses URL pathname and trims appSubUrl (#360)
|
||||
* fix(actions): enforce MaxJobNumPerRun (256) limit (#360)
|
||||
* fix(css): use calc(infinity * 1px) for --border-radius-full (#361)
|
||||
* fix(css): remove legacy .center class from 2015, replace with tw-text-center (#361)
|
||||
* chore: remove stale TODO from OAuth2 regenerate secret (already implemented) (#332)
|
||||
* chore: remove stale pull request test stub TODOs (#328)
|
||||
* chore: remove stale GetProjectsMode TODO
|
||||
* chore: remove stale mustNotBeArchived/mustEnableEditor FIXME from API
|
||||
* fix(routes): remove dead legacy /cherry-pick/{sha} route (replaced by /_cherrypick/)
|
||||
* fix(css): remove legacy .center class, replace with tw-text-center (#361)
|
||||
* fix(routes): remove dead legacy /cherry-pick/{sha} route
|
||||
* fix(feed): use full ref name instead of ShortName for file feed revision
|
||||
* BUGFIXES
|
||||
* fix(build): use slices.Collect for maps.Values (Go 1.23+ compat)
|
||||
@@ -104,25 +159,10 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
* fix(licenses): only show licenses tab when licensing is enabled
|
||||
* fix(licenses): show feed URLs based on repo update platform setting
|
||||
* fix(updates): correct dlid prefix and align XML with Joomla standard
|
||||
|
||||
## [v1.26.1-moko.05.06.00] - 2026-05-30
|
||||
|
||||
* FEATURES
|
||||
* feat(actions): rebrand actions bot user to mokogitea-actions (#233, #234)
|
||||
* Name: gitea-actions → mokogitea-actions, FullName: MokoGitea Actions
|
||||
* Email: mokogitea-actions[bot]@mokoconsulting.tech
|
||||
* Backward-compatible: recognizes github-actions[bot], gitea-actions[bot], mokogitea-actions[bot]
|
||||
* feat(actions): add actions bot user to branch protection whitelist (#233, #234)
|
||||
* New toggles: WhitelistActionsUser, MergeWhitelistActionsUser, ForcePushAllowlistActionsUser
|
||||
* Allows CI/CD workflows to push/merge/force-push to protected branches when enabled
|
||||
* DB migration v334 adds the three boolean columns
|
||||
* Exposed in API (create/edit branch protection) and web UI settings
|
||||
* INFRASTRUCTURE
|
||||
* fix(ci): auto-deploy to production on merge to main (#235)
|
||||
* Deploy workflow now triggers on push to main, not just manual dispatch
|
||||
* Version derived from git describe for auto-deploys
|
||||
|
||||
## [v1.26.1-moko.04.00.00] - 2026-05-24
|
||||
## [v1.26.1-moko.04] - 2026-05-24
|
||||
|
||||
* SECURITY
|
||||
* Backport 12 upstream v1.26.2 security fixes:
|
||||
@@ -131,47 +171,38 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
* OAuth PKCE hardening and refresh token replay protection (#142)
|
||||
* Wiki git write and LFS token access enforcement (#143)
|
||||
* Public-only token filtering in API queries (#144)
|
||||
* Reading permission fix (#145)
|
||||
* Artifact signature payload hardening (#146)
|
||||
* AWS credentials encryption (#161)
|
||||
* Mermaid v11.15.0 security update (#162)
|
||||
* Composer package permission check (#164)
|
||||
* BUGFIXES
|
||||
* fix(actions): nil pointer dereference in concurrency during PR creation (#136)
|
||||
* fix(ui): actions runs list broken row layout — CSS class mismatch (#138)
|
||||
* fix: scheduled action panic with null event payload (upstream #37459)
|
||||
* fix: treat email addresses case-insensitively (upstream #37600)
|
||||
* fix(ui): actions runs list broken row layout (#138)
|
||||
* fix: scheduled action panic with null event payload
|
||||
* fix: treat email addresses case-insensitively
|
||||
* fix: .mod lexer panic — removed invalid AMPL mapping
|
||||
* fix: remove unused setting import in action.go
|
||||
* fix: restore Permission field access in context middleware
|
||||
* FEATURES
|
||||
* Joomla-style updates.xml with channel selection (stable/dev/security/rc)
|
||||
* Update checker reads from updates.xml with configurable CHANNEL setting
|
||||
* Admin dashboard shows update banner with channel name and docker pull command
|
||||
* Upstream bug sync workflow — daily automated issue creation from release/v1.26
|
||||
* Joomla-style updates.xml with channel selection
|
||||
* Update checker with configurable CHANNEL setting
|
||||
* Admin dashboard update banner with docker pull command
|
||||
* Upstream bug sync workflow — daily automated issue creation
|
||||
* PR RC release workflow — auto-build RC on PR to main
|
||||
* INFRASTRUCTURE
|
||||
* New 3-part versioning: v{upstream}-moko.{major}.{minor}.{patch}
|
||||
* Branding updates: error pages, home page, settings link to MokoGitea
|
||||
* Branding updates: error pages, home page, settings link
|
||||
* Deploy workflow updated for new version format
|
||||
* PROCESS
|
||||
* Created `type: bug` and `upstream` labels for automated issue tracking
|
||||
* Deduplicated 19 duplicate feature request issues
|
||||
* Closed 24 upstream bug/security issues after backporting
|
||||
|
||||
## [MokoGitea Unreleased]
|
||||
## [v1.26.1-moko.03] - 2026-05-15
|
||||
|
||||
* FEATURES
|
||||
* feat(api): Bulk issue operations — add/remove/replace labels, close/reopen, set milestone, and set assignees across multiple issues in a single request (#21)
|
||||
* `POST /api/v1/repos/{owner}/{repo}/issues/bulk/labels`
|
||||
* `POST /api/v1/repos/{owner}/{repo}/issues/bulk/state`
|
||||
* `POST /api/v1/repos/{owner}/{repo}/issues/bulk/milestone`
|
||||
* `POST /api/v1/repos/{owner}/{repo}/issues/bulk/assignees`
|
||||
* Partial-failure support: returns per-issue success/failure map
|
||||
* feat(api): Bulk issue operations — add/remove/replace labels, close/reopen, set milestone, assignees (#21)
|
||||
* INFRASTRUCTURE
|
||||
* Grafana: Standardized kiosk header across all 14 playlist dashboards — each now shows dashboard name, kiosk link, terminal/exit/switch instructions
|
||||
* Grafana: Standardized kiosk header across all 14 playlist dashboards
|
||||
* PROCESS
|
||||
* Reopened 9 closed issues lacking documented testing proof (#3, #5, #38, #41, #70, #74, #75, #76, #78)
|
||||
* Reopened 9 closed issues lacking documented testing proof
|
||||
* Created `pending: testing` label for features awaiting verification
|
||||
* Established policy: issues must not be closed without documented testing proof
|
||||
|
||||
@@ -196,403 +227,3 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
* Fix org team assignee/reviewer lookups for team member permissions (#37365) #37391
|
||||
* Fix repo init README EOL (#37388) #37399
|
||||
* Fix: dump with default zip type produces uncompressed zip (#37401)#37402
|
||||
|
||||
## [1.26.0](https://github.com/go-gitea/gitea/releases/tag/v1.26.0) - 2026-04-17
|
||||
|
||||
* BREAKING
|
||||
* Correct swagger annotations for enums, status codes, and notification state (#37030)
|
||||
* Remove GET API registration-token (#36801)
|
||||
* Support Actions `concurrency` syntax (#32751)
|
||||
* Make PUBLIC_URL_DETECTION default to "auto" (#36955)
|
||||
* SECURITY
|
||||
* Bound PageSize in `ListUnadoptedRepositories` (#36884)
|
||||
* FEATURES
|
||||
* Support Actions `concurrency` syntax (#32751)
|
||||
* Add terraform state registry (#36710)
|
||||
* Instance-wide (global) info banner and maintenance mode (#36571)
|
||||
* Support rendering OpenAPI spec (#36449)
|
||||
* Add keyboard shortcuts for repository file and code search (#36416)
|
||||
* Add support for archive-upload rpc (#36391)
|
||||
* Add ability to download subpath archive (#36371)
|
||||
* Add workflow dependencies visualization (#26062) (#36248) & Restyle Workflow Graph (#36912)
|
||||
* Automatic generation of release notes (#35977)
|
||||
* Add "Go to file", "Delete Directory" to repo file list page (#35911)
|
||||
* Introduce "config edit-ini" sub command to help maintaining INI config file (#35735)
|
||||
* Add button to re-run failed jobs in Actions (#36924)
|
||||
* Support actions and reusable workflows from private repos (#32562)
|
||||
* Add summary to action runs view (#36883)
|
||||
* Add user badges (#36752)
|
||||
* Add configurable permissions for Actions automatic tokens (#36173)
|
||||
* Add per-runner "Disable/Pause" (#36776)
|
||||
* Feature non-zipped actions artifacts (action v7 / nodejs / npm v6.2.0) (#36786)
|
||||
* PERFORMANCE
|
||||
* WorkflowDispatch API optionally return runid (#36706)
|
||||
* Add render cache for SVG icons (#36863)
|
||||
* Load `mentionValues` asynchronously (#36739)
|
||||
* Lazy-load some Vue components, fix heatmap chunk loading on every page (#36719)
|
||||
* Load heatmap data asynchronously (#36622)
|
||||
* Use prev/next pagination for user profile activities page to speed up (#36642)
|
||||
* Refactor cat-file batch operations and support `--batch-command` approach (#35775)
|
||||
* Use merge tree to detect conflicts when possible (#36400)
|
||||
* ENHANCEMENTS
|
||||
* Implement logout redirection for reverse proxy auth setups (#36085) (#37171)
|
||||
* Adds option to force update new branch in contents routes (#35592)
|
||||
* Add viewer controller for mermaid (zoom, drag) (#36557)
|
||||
* Add code editor setting dropdowns (#36534)
|
||||
* Add `elk` layout support to mermaid (#36486)
|
||||
* Add resolve/unresolve review comment API endpoints (#36441)
|
||||
* Allow configuring default PR base branch (fixes #36412) (#36425)
|
||||
* Add support for RPM Errata (updateinfo.xml) (#37125)
|
||||
* Require additional user confirmation for making repo private (#36959)
|
||||
* Add `actions.WORKFLOW_DIRS` setting (#36619)
|
||||
* Avoid opening new tab when downloading actions logs (#36740)
|
||||
* Implements OIDC RP-Initiated Logout (#36724)
|
||||
* Show workflow link (#37070)
|
||||
* Desaturate dark theme background colors (#37056)
|
||||
* Refactor "org teams" page and help new users to "add member" to an org (#37051)
|
||||
* Add webhook name field to improve webhook identification (#37025) (#37040)
|
||||
* Make task list checkboxes clickable in the preview tab (#37010)
|
||||
* Improve severity labels in Actions logs and tweak colors (#36993)
|
||||
* Linkify URLs in Actions workflow logs (#36986)
|
||||
* Allow text selection on checkbox labels (#36970)
|
||||
* Support dark/light theme images in markdown (#36922)
|
||||
* Enable native dark mode for swagger-ui (#36899)
|
||||
* Rework checkbox styling, remove `input` border hover effect (#36870)
|
||||
* Refactor storage content-type handling of ServeDirectURL (#36804)
|
||||
* Use "Enable Gravatar" but not "Disable" (#36771)
|
||||
* Use case-insensitive matching for Git error "Not a valid object name" (#36728)
|
||||
* Add "Copy Source" to markup comment menu (#36726)
|
||||
* Change image transparency grid to CSS (#36711)
|
||||
* Add "Run" prefix for unnamed action steps (#36624)
|
||||
* Persist actions log time display settings in `localStorage` (#36623)
|
||||
* Use first commit title for multi-commit PRs and fix auto-focus title field (#36606)
|
||||
* Improve BuildCaseInsensitiveLike with lowercase (#36598)
|
||||
* Improve diff highlighting (#36583)
|
||||
* Exclude cancelled runs from failure-only email notifications (#36569)
|
||||
* Use full-file highlighting for diff sections (#36561)
|
||||
* Color command/error logs in Actions log (#36538)
|
||||
* Add paging headers (#36521)
|
||||
* Improve timeline entries for WIP prefix changes in pull requests (#36518)
|
||||
* Add FOLDER_ICON_THEME configuration option (#36496)
|
||||
* Normalize guessed languages for code highlighting (#36450)
|
||||
* Add chunked transfer encoding support for LFS uploads (#36380)
|
||||
* Indicate when only optional checks failed (#36367)
|
||||
* Add 'allow_maintainer_edit' API option for creating a pull request (#36283)
|
||||
* Support closing keywords with URL references (#36221)
|
||||
* Improve diff file headers (#36215)
|
||||
* Fix and enhance comment editor monospace toggle (#36181)
|
||||
* Add git.DIFF_RENAME_SIMILARITY_THRESHOLD option (#36164)
|
||||
* Add matching pair insertion to markdown textarea (#36121)
|
||||
* Add sorting/filtering to admin user search API endpoint (#36112)
|
||||
* Allow action user have read permission in public repo like other user (#36095)
|
||||
* Disable matchBrackets in monaco (#36089)
|
||||
* Use GitHub-style commit message for squash merge (#35987)
|
||||
* Make composer registry support tar.gz and tar.bz2 and fix bugs (#35958)
|
||||
* Add GITEA_PR_INDEX env variable to githooks (#35938)
|
||||
* Add proper error message if session provider can not be created (#35520)
|
||||
* Add button to copy file name in PR files (#35509)
|
||||
* Move `X_FRAME_OPTIONS` setting from `cors` to `security` section (#30256)
|
||||
* Add placeholder content for empty content page (#37114)
|
||||
* Add `DEFAULT_DELETE_BRANCH_AFTER_MERGE` setting (#36917)
|
||||
* Redirect to the only OAuth2 provider when no other login methods and fix various problems (#36901)
|
||||
* Add admin badge to navbar avatar (#36790)
|
||||
* Add `never` option to `PUBLIC_URL_DETECTION` configuration (#36785)
|
||||
* Add background and run count to actions list page (#36707)
|
||||
* Add icon to buttons "Close with Comment", "Close Pull Request", "Close Issue" (#36654)
|
||||
* Add support for in_progress event in workflow_run webhook (#36979)
|
||||
* Report commit status for pull_request_review events (#36589)
|
||||
* Render merged pull request title as such in dashboard feed (#36479)
|
||||
* Feature to be able to filter project boards by milestones (#36321)
|
||||
* Use user id in noreply emails (#36550)
|
||||
* Enable pagination on GiteaDownloader.getIssueReactions() (#36549)
|
||||
* Remove striped tables in UI (#36509)
|
||||
* Improve control char rendering and escape button styling (#37094)
|
||||
* Support legacy run/job index-based URLs and refactor migration 326 (#37008)
|
||||
* Add date to "No Contributions" tooltip (#36190)
|
||||
* Show edit page confirmation dialog on tree view file change (#36130)
|
||||
* Mention proc-receive in text for dashboard.resync_all_hooks func (#35991)
|
||||
* Reuse selectable style for wiki (#35990)
|
||||
* Support blue yellow colorblind theme (#35910)
|
||||
* Support selecting theme on the footer (#35741)
|
||||
* Improve online runner check (#35722)
|
||||
* Add quick approve button on PR page (#35678)
|
||||
* Enable commenting on expanded lines in PR diffs (#35662)
|
||||
* Print PR-Title into tooltip for actions (#35579)
|
||||
* Use explicit, stronger defaults for newly generated repo signing keys for Debian (#36236)
|
||||
* Improve the compare page (#36261)
|
||||
* Unify repo names in system notices (#36491)
|
||||
* Move package settings to package instead of being tied to version (#37026)
|
||||
* Add Actions API rerun endpoints for runs and jobs (#36768)
|
||||
* Add branch_count to repository API (#35351) (#36743)
|
||||
* Add created_by filter to SearchIssues (#36670)
|
||||
* Allow admins to rename non-local users (#35970)
|
||||
* Support updating branch via API (#35951)
|
||||
* Add an option to automatically verify SSH keys from LDAP (#35927)
|
||||
* Make "update file" API can create a new file when SHA is not set (#35738)
|
||||
* Update issue.go with labels documentation (labels content, not ids) (#35522)
|
||||
* Expose content_version for optimistic locking on issue and PR edits (#37035)
|
||||
* Pass ServeHeaderOptions by value instead of pointer, fine tune httplib tests (#36982)
|
||||
* BUGFIXES
|
||||
* Frontend iframe renderer framework: 3D models, OpenAPI (#37233) (#37273)
|
||||
* Fix CODEOWNERS absolute path matching. (#37244) (#37264)
|
||||
* Swift registry metadata: preserve more JSON fields and accept empty metadata (#37254) (#37261)
|
||||
* Fix user ssh key exporting and tests (#37256) (#37258)
|
||||
* Fix team member avatar size and add tooltip (#37253)
|
||||
* Fix commit title rendering in action run and blame (#37243) (#37251)
|
||||
* Fix corrupted JSON caused by goccy library (#37214) (#37220)
|
||||
* Add test for "fetch redirect", add CSS value validation for external render (#37207) (#37216)
|
||||
* Fix incorrect concurrency check (#37205) (#37215)
|
||||
* Fix handle missing base branch in PR commits API (#37193) (#37203)
|
||||
* Fix encoding for Matrix Webhooks (#37190) (#37201)
|
||||
* Fix handle fork-only commits in compare API (#37185) (#37199)
|
||||
* Indicate form field readonly via background, fix RunUser config (#37175, #37180) (#37178)
|
||||
* Report structurally invalid workflows to users (#37116) (#37164)
|
||||
* Fix API not persisting pull request unit config when has_pull_requests is not set (#36718)
|
||||
* Rename CSS variables and improve colorblind themes (#36353)
|
||||
* Hide `add-matcher` and `remove-matcher` from actions job logs (#36520)
|
||||
* Prevent navigation keys from triggering actions during IME composition (#36540)
|
||||
* Fix vertical alignment of `.commit-sign-badge` children (#36570)
|
||||
* Fix duplicate startup warnings in admin panel (#36641)
|
||||
* Fix CODEOWNERS review request attribution using comment metadata (#36348)
|
||||
* Fix HTML tags appearing in wiki table of contents (#36284)
|
||||
* Fix various bugs (#37096)
|
||||
* Fix various legacy problems (#37092)
|
||||
* Fix RPM Registry 404 when package name contains 'package' (#37087)
|
||||
* Merge some standalone Vite entries into index.js (#37085)
|
||||
* Fix various problems (#37077)
|
||||
* Fix issue label deletion with Actions tokens (#37013)
|
||||
* Hide delete branch or tag buttons in mirror or archived repositories. (#37006)
|
||||
* Fix org contact email not clearable once set (#36975)
|
||||
* Fix a bug when forking a repository in an organization (#36950)
|
||||
* Preserve sort order of exclusive labels from template repo (#36931)
|
||||
* Make container registry support Apple Container (basic auth) (#36920)
|
||||
* Fix the wrong push commits in the pull request when force push (#36914)
|
||||
* Add class "list-header-filters" to the div for projects (#36889)
|
||||
* Fix dbfs error handling (#36844)
|
||||
* Fix incorrect viewed files counter if reverted change was viewed (#36819)
|
||||
* Refactor avatar package, support default avatar fallback (#36788)
|
||||
* Fix README symlink resolution in subdirectories like .github (#36775)
|
||||
* Fix CSS stacking context issue in actions log (#36749)
|
||||
* Add gpg signing for merge rebase and update by rebase (#36701)
|
||||
* Delete non-exist branch should return 404 (#36694)
|
||||
* Fix `TestActionsCollaborativeOwner` (#36657)
|
||||
* Fix multi-arch Docker build SIGILL by splitting frontend stage (#36646)
|
||||
* Fix linguist-detectable attribute being ignored for configuration files (#36640)
|
||||
* Fix state desync in ComboMarkdownEditor (#36625)
|
||||
* Unify DEFAULT_SHOW_FULL_NAME output in templates and dropdown (#36597)
|
||||
* Pull Request Pusher should be the author of the merge (#36581)
|
||||
* Fix various version parsing problems (#36553)
|
||||
* Fix highlight diff result (#36539)
|
||||
* Fix mirror sync parser and fix mirror messages (#36504)
|
||||
* Fix bug when list pull request commits (#36485)
|
||||
* Fix various bugs (#36446)
|
||||
* Fix issue filter menu layout (#36426)
|
||||
* Restrict branch naming when new change matches with protection rules (#36405)
|
||||
* Fix link/origin referrer and login redirect (#36279)
|
||||
* Generate IDs for HTML headings without id attribute (#36233)
|
||||
* Use a migration test instead of a wrong test which populated the meta test repositories and fix a migration bug (#36160)
|
||||
* Fix issue close timeline icon (#36138)
|
||||
* Fix diff blob excerpt expansion (#35922)
|
||||
* Fix external render (#35727)
|
||||
* Fix review request webhook bug (#35339) (#35723)
|
||||
* Fix shutdown waitgroup panic (#35676)
|
||||
* Cleanup ActionRun creation (#35624)
|
||||
* Fix possible bug when migrating issues/pull requests (#33487)
|
||||
* Various fixes (#36697)
|
||||
* Apply notify/register mail flags during install load (#37120)
|
||||
* Repair duration display for bad stopped timestamps (#37121)
|
||||
* Fix(upgrade.sh): use HTTPS for GPG key import and restore SELinux context after upgrade (#36930)
|
||||
* Fix various trivial problems (#36921)
|
||||
* Fix various trivial problems (#36953)
|
||||
* Fix NuGet package upload error handling (#37074)
|
||||
* Fix CodeQL code scanning alerts (#36858)
|
||||
* Refactor issue sidebar and fix various problems (#37045)
|
||||
* Fix various problems (#37029)
|
||||
* Fix relative-time RangeError (#37021)
|
||||
* Fix chroma lexer mapping (#36629)
|
||||
* Fix typos and grammar in English locale (#36751)
|
||||
* Fix milestone/project text overflow in issue sidebar (#36741)
|
||||
* Fix `no-content` message not rendering after comment edit (#36733)
|
||||
* Fix theme loading in development (#36605)
|
||||
* Fix workflow run jobs API returning null steps (#36603)
|
||||
* Fix timeline event layout overflow with long content (#36595)
|
||||
* Fix minor UI issues in runner edit page (#36590)
|
||||
* Fix incorrect vendored detections (#36508)
|
||||
* Fix editorconfig not respected in PR Conversation view (#36492)
|
||||
* Don't create self-references in merged PRs (#36490)
|
||||
* Fix potential incorrect runID in run status update (#36437)
|
||||
* Fix file-tree ui error when adding files to repo without commits (#36312)
|
||||
* Improve image captcha contrast for dark mode (#36265)
|
||||
* Fix panic in blame view when a file has only a single commit (#36230)
|
||||
* Fix spelling error in migrate-storage cmd utility (#36226)
|
||||
* Fix code highlighting on blame page (#36157)
|
||||
* Fix nilnil in onedev downloader (#36154)
|
||||
* Fix actions lint (#36029)
|
||||
* Fix oauth2 session gob register (#36017)
|
||||
* Fix Arch repo pacman.conf snippet (#35825)
|
||||
* Fix a number of `strictNullChecks`-related issues (#35795)
|
||||
* Fix URLJoin, markup render link reoslving, sign-in/up/linkaccount page common data (#36861)
|
||||
* Hide delete directory button for mirror or archive repository and disable the menu item if user have no permission (#36384)
|
||||
* Update message severity colors, fix navbar double border (#37019)
|
||||
* Inline and lazy-load EasyMDE CSS, fix border colors (#36714)
|
||||
* Closed milestones with no issues now show as 100% completed (#36220)
|
||||
* Add test for ExtendCommentTreePathLength migration and fix bugs (#35791)
|
||||
* Only turn links to current instance into hash links (#36237)
|
||||
* Fix typos in code comments: doesnt, dont, wont (#36890)
|
||||
* REFACTOR
|
||||
* Clean up and improve non-gitea js error filter (#37148) (#37155)
|
||||
* Always show owner/repo name in compare page dropdowns (#37172) (#37200)
|
||||
* Remove dead CSS rules (#37173) (#37177)
|
||||
* Replace Monaco with CodeMirror (#36764)
|
||||
* Replace CSRF cookie with `CrossOriginProtection` (#36183)
|
||||
* Replace index with id in actions routes (#36842)
|
||||
* Remove unnecessary function parameter (#35765)
|
||||
* Move jobparser from act repository to Gitea (#36699)
|
||||
* Refactor compare router param parse (#36105)
|
||||
* Optimize 'refreshAccesses' to perform update without removing then adding (#35702)
|
||||
* Clean up checkbox cursor styles (#37016)
|
||||
* Remove undocumented support of signing key in the repository git configuration file (#36143)
|
||||
* Switch `cmd/` to use constructor functions. (#36962)
|
||||
* Use `relative-time` to render absolute dates (#36238)
|
||||
* Some refactors about GetMergeBase (#36186)
|
||||
* Some small refactors (#36163)
|
||||
* Use gitRepo as parameter instead of repopath when invoking sign functions (#36162)
|
||||
* Move blame to gitrepo (#36161)
|
||||
* Move some functions to gitrepo package to reduce RepoPath reference directly (#36126)
|
||||
* Use gitrepo's clone and push when possible (#36093)
|
||||
* Remove mermaid margin workaround (#35732)
|
||||
* Move some functions to gitrepo package (#35543)
|
||||
* Move GetDiverging functions to gitrepo (#35524)
|
||||
* Use global lock instead of status pool for cron lock (#35507)
|
||||
* Use explicit mux instead of DefaultServeMux (#36276)
|
||||
* Use gitrepo's push function (#36245)
|
||||
* Pass request context to generateAdditionalHeadersForIssue (#36274)
|
||||
* Move assign project when creating pull request to the same database transaction (#36244)
|
||||
* Move catfile batch to a sub package of git module (#36232)
|
||||
* Use gitrepo.Repository instead of wikipath (#35398)
|
||||
* Use experimental go json v2 library (#35392)
|
||||
* Refactor template render (#36438)
|
||||
* Refactor GetRepoRawDiffForFile to avoid unnecessary pipe or goroutine (#36434)
|
||||
* Refactor text utility classes to Tailwind CSS (#36703)
|
||||
* Refactor git command stdio pipe (#36422)
|
||||
* Refactor git command context & pipeline (#36406)
|
||||
* Refactor git command stdio pipe (#36393)
|
||||
* Remove unused functions (#36672)
|
||||
* Refactor Actions Token Access (#35688)
|
||||
* Move commit related functions to gitrepo package (#35600)
|
||||
* Move archive function to repo_model and gitrepo (#35514)
|
||||
* Move some functions to gitrepo package (#35503)
|
||||
* Use git model to detect whether branch exist instead of gitrepo method (#35459)
|
||||
* Some refactor for repo path (#36251)
|
||||
* Extract helper functions from SearchIssues (#36158)
|
||||
* Refactor merge conan and container auth preserve actions taskID (#36560)
|
||||
* Refactor Nuget Auth to reuse Basic Auth Token Validation (#36558)
|
||||
* Refactor ActionsTaskID (#36503)
|
||||
* Refactor auth middleware (#36848)
|
||||
* Refactor code render and render control chars (#37078)
|
||||
* Clean up AppURL, remove legacy origin-url webcomponent (#37090)
|
||||
* Remove `util.URLJoin` and replace all callers with direct path concatenation (#36867)
|
||||
* Replace legacy tw-flex utility classes with flex-text-block/inline (#36778)
|
||||
* Mark unused&immature activitypub as "not implemented" (#36789)
|
||||
* TESTING
|
||||
* Add e2e tests for server push events (#36879)
|
||||
* Rework e2e tests (#36634)
|
||||
* Add e2e reaction test, improve accessibility, enable parallel testing (#37081)
|
||||
* Increase e2e test timeouts on CI to fix flaky tests (#37053)
|
||||
* BUILD
|
||||
* Upgrade go-git to v5.18.0 (#37269)
|
||||
* Replace rollup-plugin-license with rolldown-license-plugin (#37130) (#37158)
|
||||
* Bump min go version to 1.26.2 (#37139) (#37143)
|
||||
* Convert locale files from ini to json format (#35489)
|
||||
* Bump golangci-lint to 2.7.2, enable modernize stringsbuilder (#36180)
|
||||
* Port away from `flake-utils` (#35675)
|
||||
* Remove nolint (#36252)
|
||||
* Update the Unlicense copy to latest version (#36636)
|
||||
* Update to go 1.26.0 and golangci-lint 2.9.0 (#36588)
|
||||
* Replace `google/go-licenses` with custom generation (#36575)
|
||||
* Update go dependencies (#36548)
|
||||
* Bump appleboy/git-push-action from 1.0.0 to 1.2.0 (#36306)
|
||||
* Remove fomantic form module (#36222)
|
||||
* Bump setup-node to v6, re-enable cache (#36207)
|
||||
* Bump crowdin/github-action from 1 to 2 (#36204)
|
||||
* Revert "Bump alpine to 3.23 (#36185)" (#36202)
|
||||
* Update chroma to v2.21.1 (#36201)
|
||||
* Bump astral-sh/setup-uv from 6 to 7 (#36198)
|
||||
* Bump docker/build-push-action from 5 to 6 (#36197)
|
||||
* Bump aws-actions/configure-aws-credentials from 4 to 5 (#36196)
|
||||
* Bump dev-hanz-ops/install-gh-cli-action from 0.1.0 to 0.2.1 (#36195)
|
||||
* Add JSON linting (#36192)
|
||||
* Enable dependabot for actions (#36191)
|
||||
* Bump alpine to 3.23 (#36185)
|
||||
* Update chroma to v2.21.0 (#36171)
|
||||
* Update JS deps and eslint enhancements (#36147)
|
||||
* Update JS deps (#36091)
|
||||
* update golangci-lint to v2.7.0 (#36079)
|
||||
* Update JS deps, fix deprecations (#36040)
|
||||
* Update JS deps (#35978)
|
||||
* Add toolchain directive to go.mod (#35901)
|
||||
* Move `gitea-vet` to use `go tool` (#35878)
|
||||
* Update to go 1.25.4 (#35877)
|
||||
* Enable TypeScript `strictNullChecks` (#35843)
|
||||
* Enable `vue/require-typed-ref` eslint rule (#35764)
|
||||
* Update JS dependencies (#35759)
|
||||
* Move `codeformat` folder to tools (#35758)
|
||||
* Update dependencies (#35733)
|
||||
* Bump happy-dom from 20.0.0 to 20.0.2 (#35677)
|
||||
* Bump setup-go to v6 (#35660)
|
||||
* Update JS deps, misc tweaks (#35643)
|
||||
* Bump happy-dom from 19.0.2 to 20.0.0 (#35625)
|
||||
* Use bundled version of spectral (#35573)
|
||||
* Update JS and PY deps (#35565)
|
||||
* Bump github.com/wneessen/go-mail from 0.6.2 to 0.7.1 (#35557)
|
||||
* Migrate from webpack to vite (#37002)
|
||||
* Update JS dependencies and misc tweaks (#37064)
|
||||
* Update to eslint 10 (#36925)
|
||||
* Optimize Docker build with dependency layer caching (#36864)
|
||||
* Update JS deps (#36850)
|
||||
* Update tool dependencies and fix new lint issues (#36702)
|
||||
* Remove redundant linter rules (#36658)
|
||||
* Move Fomantic dropdown CSS to custom module (#36530)
|
||||
* Remove and forbid `@ts-expect-error` (#36513)
|
||||
* Refactor git command stderr handling (#36402)
|
||||
* Enable gocheckcompilerdirectives linter (#36156)
|
||||
* Replace `lint-go-gopls` with additional `govet` linters (#36028)
|
||||
* Update golangci-lint to v2.6.0 (#35801)
|
||||
* Misc tool tweaks (#35734)
|
||||
* Add cache to container build (#35697)
|
||||
* Upgrade vite (#37126)
|
||||
* Update `setup-uv` to v8.0.0 (#37101)
|
||||
* Upgrade `go-git` to v5.17.2 and related dependencies (#37060)
|
||||
* Raise minimum Node.js version to 22.18.0 (#37058)
|
||||
* Upgrade `golang.org/x/image` to v0.38.0 (#37054)
|
||||
* Update minimum go version to 1.26.1, golangci-lint to 2.11.2, fix test style (#36876)
|
||||
* Enable eslint concurrency (#36878)
|
||||
* Vendor relative-time-element as local web component (#36853)
|
||||
* Update material-icon-theme v5.32.0 (#36832)
|
||||
* Update Go dependencies (#36781)
|
||||
* Upgrade minimatch (#36760)
|
||||
* Remove i18n backport tool at the moment because of translation format changed (#36643)
|
||||
* Update emoji data for Unicode 16 (#36596)
|
||||
* Update JS dependencies, adjust webpack config, misc fixes (#36431)
|
||||
* Update material-icon-theme to v5.31.0 (#36427)
|
||||
* Update JS and PY deps (#36383)
|
||||
* Bump alpine to 3.23, add platforms to `docker-dryrun` (#36379)
|
||||
* Update JS deps (#36354)
|
||||
* Update goldmark to v1.7.16 (#36343)
|
||||
* Update chroma to v2.22.0 (#36342)
|
||||
* DOCS
|
||||
* Update AI Contribution Policy (#37022)
|
||||
* Update AGENTS.md with additional guidelines (#37018)
|
||||
* Add missing cron tasks to example ini (#37012)
|
||||
* Add AI Contribution Policy to CONTRIBUTING.md (#36651)
|
||||
* Minor punctuation improvement in CONTRIBUTING.md (#36291)
|
||||
* Add documentation for markdown anchor post-processing (#36443)
|
||||
* MISC
|
||||
* Correct spelling (#36783)
|
||||
* Update Nix flake (#37110)
|
||||
* Update Nix flake (#37024)
|
||||
* Add valid github scopes (#36977)
|
||||
* Update Nix flake (#36943)
|
||||
* Update Nix flake (#36902)
|
||||
* Update Nix flake (#36857)
|
||||
* Update Nix flake (#36787)
|
||||
|
||||
+160
-58
@@ -1,5 +1,5 @@
|
||||
// Copyright 2026 Moko Consulting. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package issues
|
||||
|
||||
@@ -10,87 +10,185 @@ import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
// CustomFieldDefinition defines a custom field for a repository's issues
|
||||
type CustomFieldDefinition struct {
|
||||
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL" json:"repo_id"`
|
||||
Name string `xorm:"NOT NULL" json:"name"`
|
||||
FieldType string `xorm:"NOT NULL" json:"field_type"` // text, number, date, dropdown, checkbox
|
||||
Description string `json:"description"`
|
||||
Required bool `xorm:"NOT NULL DEFAULT false" json:"required"`
|
||||
Position int `xorm:"NOT NULL DEFAULT 0" json:"position"`
|
||||
Options string `xorm:"TEXT" json:"options"` // JSON array for dropdown options
|
||||
DefaultVal string `json:"default_value"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created" json:"created_at"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated" json:"updated_at"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(CustomFieldDefinition))
|
||||
db.RegisterModel(new(CustomFieldDef))
|
||||
db.RegisterModel(new(CustomFieldValue))
|
||||
}
|
||||
|
||||
// CustomFieldValue stores the value of a custom field for a specific issue
|
||||
// CustomFieldType represents the data type of a custom field.
|
||||
type CustomFieldType string
|
||||
|
||||
const (
|
||||
CustomFieldTypeText CustomFieldType = "text"
|
||||
CustomFieldTypeNumber CustomFieldType = "number"
|
||||
CustomFieldTypeDate CustomFieldType = "date"
|
||||
CustomFieldTypeDropdown CustomFieldType = "dropdown"
|
||||
CustomFieldTypeCheckbox CustomFieldType = "checkbox"
|
||||
CustomFieldTypeURL CustomFieldType = "url"
|
||||
)
|
||||
|
||||
// CustomFieldScope determines where the field appears.
|
||||
type CustomFieldScope string
|
||||
|
||||
const (
|
||||
CustomFieldScopeIssue CustomFieldScope = "issue" // appears in issue sidebar
|
||||
CustomFieldScopeRepo CustomFieldScope = "repo" // appears in repo settings metadata
|
||||
)
|
||||
|
||||
// CustomFieldDef defines a custom field at the org level.
|
||||
// owner_id = org ID, scope = issue or repo.
|
||||
// repo_id is kept for backward compat but 0 for org-level definitions.
|
||||
type CustomFieldDef struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'owner_id'"` // org that owns this field
|
||||
RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'repo_id'"` // 0 = org-level (inherited by all repos)
|
||||
Scope CustomFieldScope `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'scope'"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
FieldType CustomFieldType `xorm:"VARCHAR(20) NOT NULL 'field_type'"`
|
||||
Description string `xorm:"TEXT"`
|
||||
Options string `xorm:"TEXT"` // JSON array for dropdown options
|
||||
Required bool `xorm:"NOT NULL DEFAULT false"`
|
||||
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 (CustomFieldDef) TableName() string {
|
||||
return "custom_field_def"
|
||||
}
|
||||
|
||||
// CustomFieldValue stores a custom field value for an entity (issue or repo).
|
||||
type CustomFieldValue struct {
|
||||
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||
IssueID int64 `xorm:"INDEX NOT NULL" json:"issue_id"`
|
||||
FieldID int64 `xorm:"INDEX NOT NULL" json:"field_id"`
|
||||
Value string `xorm:"TEXT" json:"value"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created" json:"created_at"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated" json:"updated_at"`
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
EntityID int64 `xorm:"INDEX NOT NULL 'entity_id'"` // issue ID or repo ID
|
||||
EntityType string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'entity_type'"` // "issue" or "repo"
|
||||
FieldID int64 `xorm:"INDEX NOT NULL 'field_id'"`
|
||||
Value string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
// GetCustomFieldsByRepoID returns all custom field definitions for a repo
|
||||
func GetCustomFieldsByRepoID(ctx context.Context, repoID int64) ([]*CustomFieldDefinition, error) {
|
||||
fields := make([]*CustomFieldDefinition, 0)
|
||||
return fields, db.GetEngine(ctx).Where("repo_id = ?", repoID).OrderBy("position ASC").Find(&fields)
|
||||
func (CustomFieldValue) TableName() string {
|
||||
return "custom_field_value"
|
||||
}
|
||||
|
||||
// GetCustomFieldByID returns a custom field definition by ID
|
||||
func GetCustomFieldByID(ctx context.Context, id int64) (*CustomFieldDefinition, error) {
|
||||
field := &CustomFieldDefinition{ID: id}
|
||||
has, err := db.GetEngine(ctx).Get(field)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Queries for org-level field definitions
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldsByOwner returns all active field definitions for an org with a given scope.
|
||||
func GetCustomFieldsByOwner(ctx context.Context, ownerID int64, scope CustomFieldScope) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("owner_id = ? AND scope = ? AND is_active = ?", ownerID, scope, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// GetAllCustomFieldsByOwner returns all field definitions for an org (including inactive).
|
||||
func GetAllCustomFieldsByOwner(ctx context.Context, ownerID int64) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("owner_id = ?", ownerID).
|
||||
OrderBy("scope ASC, sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// GetCustomFieldsByOwnerAndScope returns all fields for an org filtered by scope.
|
||||
func GetCustomFieldsByOwnerAndScope(ctx context.Context, ownerID int64, scope CustomFieldScope) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("owner_id = ? AND scope = ?", ownerID, scope).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Backward-compatible queries (load by repo's owner)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldsByRepo returns active issue-scoped fields for a repo's org.
|
||||
// This is the main query used by the issue sidebar.
|
||||
func GetCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomFieldDef, error) {
|
||||
// First try org-level fields (owner_id != 0, repo_id = 0)
|
||||
// Fall back to legacy repo-level fields (repo_id = repoID)
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("((owner_id != 0 AND repo_id = 0) OR repo_id = ?) AND scope = ? AND is_active = ?",
|
||||
repoID, CustomFieldScopeIssue, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// GetAllCustomFieldsByRepo returns all field definitions for a repo (for settings page).
|
||||
func GetAllCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("repo_id = ?", repoID).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Field definition CRUD
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldDefByID returns a single field definition.
|
||||
func GetCustomFieldDefByID(ctx context.Context, id int64) (*CustomFieldDef, error) {
|
||||
field := new(CustomFieldDef)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil
|
||||
return nil, db.ErrNotExist{Resource: "CustomFieldDef", ID: id}
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// CreateCustomField creates a new custom field definition
|
||||
func CreateCustomField(ctx context.Context, field *CustomFieldDefinition) error {
|
||||
// CreateCustomFieldDef creates a new custom field definition.
|
||||
func CreateCustomFieldDef(ctx context.Context, field *CustomFieldDef) error {
|
||||
_, err := db.GetEngine(ctx).Insert(field)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCustomField updates a custom field definition
|
||||
func UpdateCustomField(ctx context.Context, field *CustomFieldDefinition) error {
|
||||
// UpdateCustomFieldDef updates a custom field definition.
|
||||
func UpdateCustomFieldDef(ctx context.Context, field *CustomFieldDef) error {
|
||||
_, err := db.GetEngine(ctx).ID(field.ID).AllCols().Update(field)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCustomField deletes a custom field and all its values
|
||||
func DeleteCustomField(ctx context.Context, id int64) error {
|
||||
sess := db.GetEngine(ctx)
|
||||
if _, err := sess.Where("field_id = ?", id).Delete(&CustomFieldValue{}); err != nil {
|
||||
// DeleteCustomFieldDef deletes a field definition and all its values.
|
||||
func DeleteCustomFieldDef(ctx context.Context, id int64) error {
|
||||
if _, err := db.GetEngine(ctx).Where("field_id = ?", id).Delete(new(CustomFieldValue)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := sess.ID(id).Delete(&CustomFieldDefinition{})
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(CustomFieldDef))
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCustomFieldValues returns all custom field values for an issue
|
||||
func GetCustomFieldValues(ctx context.Context, issueID int64) ([]*CustomFieldValue, error) {
|
||||
values := make([]*CustomFieldValue, 0)
|
||||
return values, db.GetEngine(ctx).Where("issue_id = ?", issueID).Find(&values)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Field values — generic entity-based (works for issues and repos)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetCustomFieldValuesMap returns field_id -> value for an entity.
|
||||
func GetCustomFieldValuesMap(ctx context.Context, entityID int64) (map[int64]string, error) {
|
||||
values := make([]*CustomFieldValue, 0, 10)
|
||||
if err := db.GetEngine(ctx).Where("entity_id = ?", entityID).Find(&values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[int64]string, len(values))
|
||||
for _, v := range values {
|
||||
result[v.FieldID] = v.Value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SetCustomFieldValue sets or updates a custom field value for an issue
|
||||
func SetCustomFieldValue(ctx context.Context, issueID, fieldID int64, value string) error {
|
||||
existing := &CustomFieldValue{}
|
||||
has, err := db.GetEngine(ctx).Where("issue_id = ? AND field_id = ?", issueID, fieldID).Get(existing)
|
||||
// SetCustomFieldValue creates or updates a single custom field value.
|
||||
func SetCustomFieldValue(ctx context.Context, entityID, fieldID int64, value string) error {
|
||||
existing := new(CustomFieldValue)
|
||||
has, err := db.GetEngine(ctx).Where("entity_id = ? AND field_id = ?", entityID, fieldID).Get(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -100,15 +198,19 @@ func SetCustomFieldValue(ctx context.Context, issueID, fieldID int64, value stri
|
||||
return err
|
||||
}
|
||||
_, err = db.GetEngine(ctx).Insert(&CustomFieldValue{
|
||||
IssueID: issueID,
|
||||
FieldID: fieldID,
|
||||
Value: value,
|
||||
EntityID: entityID,
|
||||
FieldID: fieldID,
|
||||
Value: value,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCustomFieldValue deletes a specific custom field value
|
||||
func DeleteCustomFieldValue(ctx context.Context, issueID, fieldID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("issue_id = ? AND field_id = ?", issueID, fieldID).Delete(&CustomFieldValue{})
|
||||
return err
|
||||
// SetCustomFieldValues sets multiple custom field values for an entity.
|
||||
func SetCustomFieldValues(ctx context.Context, entityID int64, values map[int64]string) error {
|
||||
for fieldID, value := range values {
|
||||
if err := SetCustomFieldValue(ctx, entityID, fieldID, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ 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:"-"`
|
||||
TypeID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'type_id'"`
|
||||
TypeDef *IssueTypeDef `xorm:"-"`
|
||||
IsRead bool `xorm:"-"`
|
||||
IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not.
|
||||
PullRequest *PullRequest `xorm:"-"`
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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.
|
||||
// If none exist, seeds the org with default priorities automatically.
|
||||
func GetIssuePriorityDefsByOrg(ctx context.Context, orgID int64) ([]*IssuePriorityDef, error) {
|
||||
defs := make([]*IssuePriorityDef, 0, 10)
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("org_id = ? AND is_active = ?", orgID, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&defs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(defs) == 0 && orgID > 0 {
|
||||
if err := seedDefaultIssuePriorities(ctx, orgID); err != nil {
|
||||
return defs, nil // non-fatal
|
||||
}
|
||||
return GetIssuePriorityDefsByOrg(ctx, orgID)
|
||||
}
|
||||
return defs, nil
|
||||
}
|
||||
|
||||
// seedDefaultIssuePriorities creates the standard priority presets for an org.
|
||||
func seedDefaultIssuePriorities(ctx context.Context, orgID int64) error {
|
||||
defaults := []*IssuePriorityDef{
|
||||
{OrgID: orgID, Name: "Critical", Color: "#dc2626", Description: "Requires immediate attention", SortOrder: 1, IsActive: true},
|
||||
{OrgID: orgID, Name: "High", Color: "#f97316", Description: "Should be addressed soon", SortOrder: 2, IsActive: true},
|
||||
{OrgID: orgID, Name: "Medium", Color: "#eab308", Description: "Normal priority", SortOrder: 3, IsDefault: true, IsActive: true},
|
||||
{OrgID: orgID, Name: "Low", Color: "#2563eb", Description: "Can wait", SortOrder: 4, IsActive: true},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -49,8 +49,9 @@ type IssuesOptions struct { //nolint:revive // export stutter
|
||||
UpdatedAfterUnix int64
|
||||
UpdatedBeforeUnix int64
|
||||
// prioritize issues from this repo
|
||||
PriorityRepoID int64
|
||||
IsArchived optional.Option[bool]
|
||||
PriorityRepoID int64
|
||||
IsArchived optional.Option[bool]
|
||||
CustomFieldFilters map[int64]string // field_id → required value (AND semantics)
|
||||
Owner *user_model.User // issues permission scope, it could be an organization or a user
|
||||
Team *organization.Team // issues permission scope
|
||||
Doer *user_model.User // issues permission scope
|
||||
@@ -211,6 +212,20 @@ func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
// do not need to apply any condition
|
||||
}
|
||||
|
||||
func applyCustomFieldCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
if len(opts.CustomFieldFilters) == 0 {
|
||||
return
|
||||
}
|
||||
// Each filtered field adds a subquery: the issue must have a matching
|
||||
// custom_field_value row for every specified field (AND semantics).
|
||||
for fieldID, value := range opts.CustomFieldFilters {
|
||||
subQuery := builder.Select("entity_id").From("custom_field_value").Where(
|
||||
builder.Eq{"field_id": fieldID, "value": value, "entity_type": "issue"},
|
||||
)
|
||||
sess.And(builder.In("issue.id", subQuery))
|
||||
}
|
||||
}
|
||||
|
||||
func applyRepoConditions(sess *xorm.Session, opts *IssuesOptions) {
|
||||
if len(opts.RepoIDs) == 1 {
|
||||
opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoIDs[0]}
|
||||
@@ -278,6 +293,7 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
|
||||
}
|
||||
|
||||
applyLabelsCondition(sess, opts)
|
||||
applyCustomFieldCondition(sess, opts)
|
||||
|
||||
if opts.Owner != nil {
|
||||
sess.And(repo_model.UserOwnedRepoCond(opts.Owner.ID))
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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.
|
||||
// If none exist, seeds the org with default statuses automatically.
|
||||
func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDef, error) {
|
||||
defs := make([]*IssueStatusDef, 0, 10)
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("org_id = ? AND is_active = ?", orgID, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&defs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(defs) == 0 && orgID > 0 {
|
||||
if err := seedDefaultIssueStatuses(ctx, orgID); err != nil {
|
||||
return defs, nil // non-fatal
|
||||
}
|
||||
return GetIssueStatusDefsByOrg(ctx, orgID)
|
||||
}
|
||||
return defs, nil
|
||||
}
|
||||
|
||||
// seedDefaultIssueStatuses creates the standard status presets for an org.
|
||||
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
|
||||
defaults := []*IssueStatusDef{
|
||||
{OrgID: orgID, Name: "In Progress", Color: "#2563eb", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
|
||||
{OrgID: orgID, Name: "Needs Info", Color: "#f59e0b", Description: "Waiting for more information", SortOrder: 2, IsActive: true},
|
||||
{OrgID: orgID, Name: "Blocked", Color: "#dc2626", Description: "Cannot proceed due to dependency", SortOrder: 3, IsActive: true},
|
||||
{OrgID: orgID, Name: "Resolved", Color: "#16a34a", Description: "Fix implemented and verified", ClosesIssue: true, SortOrder: 4, IsActive: true},
|
||||
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
|
||||
{OrgID: orgID, Name: "Duplicate", Color: "#8b5cf6", Description: "Already tracked elsewhere", ClosesIssue: true, SortOrder: 6, IsActive: true},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// 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(IssueTypeDef))
|
||||
}
|
||||
|
||||
// IssueTypeDef defines a custom issue type at the org level.
|
||||
type IssueTypeDef 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 (IssueTypeDef) TableName() string {
|
||||
return "issue_type_def"
|
||||
}
|
||||
|
||||
// GetIssueTypeDefsByOrg returns active type definitions for an org.
|
||||
// Auto-seeds defaults if none exist.
|
||||
func GetIssueTypeDefsByOrg(ctx context.Context, orgID int64) ([]*IssueTypeDef, error) {
|
||||
defs := make([]*IssueTypeDef, 0, 10)
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("org_id = ? AND is_active = ?", orgID, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&defs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(defs) == 0 && orgID > 0 {
|
||||
if err := seedDefaultIssueTypes(ctx, orgID); err != nil {
|
||||
return defs, nil
|
||||
}
|
||||
return GetIssueTypeDefsByOrg(ctx, orgID)
|
||||
}
|
||||
return defs, nil
|
||||
}
|
||||
|
||||
// GetAllIssueTypeDefsByOrg returns all type definitions (including inactive).
|
||||
func GetAllIssueTypeDefsByOrg(ctx context.Context, orgID int64) ([]*IssueTypeDef, error) {
|
||||
defs := make([]*IssueTypeDef, 0, 10)
|
||||
return defs, db.GetEngine(ctx).
|
||||
Where("org_id = ?", orgID).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&defs)
|
||||
}
|
||||
|
||||
// GetIssueTypeDefByID returns a single type definition.
|
||||
func GetIssueTypeDefByID(ctx context.Context, id int64) (*IssueTypeDef, error) {
|
||||
def := new(IssueTypeDef)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(def)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, db.ErrNotExist{Resource: "IssueTypeDef", ID: id}
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
func CreateIssueTypeDef(ctx context.Context, def *IssueTypeDef) error {
|
||||
_, err := db.GetEngine(ctx).Insert(def)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateIssueTypeDef(ctx context.Context, def *IssueTypeDef) error {
|
||||
_, err := db.GetEngine(ctx).ID(def.ID).AllCols().Update(def)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteIssueTypeDef(ctx context.Context, id int64) error {
|
||||
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET type_id = 0 WHERE type_id = ?", id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssueTypeDef))
|
||||
return err
|
||||
}
|
||||
|
||||
func SetIssueTypeID(ctx context.Context, issueID, typeID int64) error {
|
||||
_, err := db.GetEngine(ctx).Exec("UPDATE issue SET type_id = ? WHERE id = ?", typeID, issueID)
|
||||
return err
|
||||
}
|
||||
|
||||
func seedDefaultIssueTypes(ctx context.Context, orgID int64) error {
|
||||
defaults := []*IssueTypeDef{
|
||||
{OrgID: orgID, Name: "Bug", Color: "#dc2626", SortOrder: 1, IsActive: true},
|
||||
{OrgID: orgID, Name: "Feature", Color: "#2563eb", SortOrder: 2, IsDefault: true, IsActive: true},
|
||||
{OrgID: orgID, Name: "Enhancement", Color: "#16a34a", SortOrder: 3, IsActive: true},
|
||||
{OrgID: orgID, Name: "Task", Color: "#6b7280", SortOrder: 4, IsActive: true},
|
||||
{OrgID: orgID, Name: "Documentation", Color: "#8b5cf6", SortOrder: 5, IsActive: true},
|
||||
{OrgID: orgID, Name: "Security", Color: "#e11d48", SortOrder: 6, IsActive: true},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
if _, err := db.GetEngine(ctx).Insert(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -130,10 +130,11 @@ func GetLicenseKeyByID(ctx context.Context, id int64) (*LicenseKey, error) {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ListLicenseKeys returns all keys for the given owner.
|
||||
// ListLicenseKeys returns all keys for the given owner, master keys first.
|
||||
func ListLicenseKeys(ctx context.Context, ownerID int64) ([]*LicenseKey, error) {
|
||||
keys := make([]*LicenseKey, 0, 20)
|
||||
return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&keys)
|
||||
return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).
|
||||
OrderBy("is_internal DESC, created_unix DESC").Find(&keys)
|
||||
}
|
||||
|
||||
// SearchLicenseKeys searches keys for an owner by key prefix/raw, licensee, email, or domain.
|
||||
|
||||
@@ -32,6 +32,10 @@ type LicensePackage struct {
|
||||
// AllowedChannels defines which update streams keys from this package
|
||||
// can access. JSON array, e.g. ["stable","rc"]. Empty = all channels.
|
||||
AllowedChannels string `xorm:"TEXT"`
|
||||
// DomainRestriction is a comma-separated list of allowed domains.
|
||||
// Keys generated from this package inherit this unless overridden.
|
||||
// Empty = no restriction.
|
||||
DomainRestriction string `xorm:"TEXT"`
|
||||
IsActive bool `xorm:"NOT NULL DEFAULT true"`
|
||||
IsArchived bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||
|
||||
@@ -420,6 +420,14 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(340, "Sync license system columns (key_raw, payment_ref, heartbeat, archive, metadata)", v1_27.SyncLicenseSystemColumns),
|
||||
newMigration(341, "Add parent_org_id to user table for enterprise sub-org hierarchy", v1_27.AddParentOrgIDToUser),
|
||||
newMigration(342, "Add is_hidden to repository for three-level visibility", v1_27.AddIsHiddenToRepository),
|
||||
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),
|
||||
newMigration(349, "Add security scanning tables", v1_27.AddSecurityScanningTables),
|
||||
newMigration(350, "Add issue type definitions table", v1_27.AddIssueTypeDefTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright 2026 Moko Consulting. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_25
|
||||
|
||||
import "xorm.io/xorm"
|
||||
|
||||
func AddCustomFieldTables(x *xorm.Engine) error {
|
||||
type CustomFieldDefinition struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
FieldType string `xorm:"NOT NULL"` // text, number, date, dropdown, checkbox
|
||||
Description string
|
||||
Required bool `xorm:"NOT NULL DEFAULT false"`
|
||||
Position int `xorm:"NOT NULL DEFAULT 0"`
|
||||
Options string `xorm:"TEXT"` // JSON array for dropdown options
|
||||
DefaultVal string
|
||||
CreatedUnix int64 `xorm:"INDEX created"`
|
||||
UpdatedUnix int64 `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
type CustomFieldValue struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
IssueID int64 `xorm:"INDEX NOT NULL"`
|
||||
FieldID int64 `xorm:"INDEX NOT NULL"`
|
||||
Value string `xorm:"TEXT"`
|
||||
CreatedUnix int64 `xorm:"INDEX created"`
|
||||
UpdatedUnix int64 `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
return x.Sync(new(CustomFieldDefinition), new(CustomFieldValue))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type customFieldDef343 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL 'repo_id'"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
FieldType string `xorm:"VARCHAR(20) NOT NULL 'field_type'"`
|
||||
Description string `xorm:"TEXT"`
|
||||
Options string `xorm:"TEXT"`
|
||||
Required bool `xorm:"NOT NULL DEFAULT false"`
|
||||
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 (customFieldDef343) TableName() string {
|
||||
return "custom_field_def"
|
||||
}
|
||||
|
||||
type customFieldValue343 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
IssueID int64 `xorm:"INDEX NOT NULL 'issue_id'"`
|
||||
FieldID int64 `xorm:"INDEX NOT NULL 'field_id'"`
|
||||
Value string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
func (customFieldValue343) TableName() string {
|
||||
return "custom_field_value"
|
||||
}
|
||||
|
||||
// AddCustomFieldTables creates the custom_field_def and custom_field_value tables.
|
||||
func AddCustomFieldTables(x *xorm.Engine) error {
|
||||
return x.Sync(new(customFieldDef343), new(customFieldValue343))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddDomainRestrictionToLicensePackage(x *xorm.Engine) error {
|
||||
type LicensePackage struct {
|
||||
DomainRestriction string `xorm:"TEXT"`
|
||||
}
|
||||
return x.Sync(new(LicensePackage))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// MigrateCustomFieldsToOrgLevel adds owner_id, scope to custom_field_def
|
||||
// and renames issue_id to entity_id + adds entity_type in custom_field_value.
|
||||
func MigrateCustomFieldsToOrgLevel(x *xorm.Engine) error {
|
||||
// Add new columns to custom_field_def
|
||||
type CustomFieldDef struct {
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'owner_id'"`
|
||||
Scope string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'scope'"`
|
||||
}
|
||||
if err := x.Sync(new(CustomFieldDef)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add entity_type and entity_id to custom_field_value
|
||||
type CustomFieldValue struct {
|
||||
EntityID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'entity_id'"`
|
||||
EntityType string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'entity_type'"`
|
||||
}
|
||||
if err := x.Sync(new(CustomFieldValue)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Migrate existing data: copy issue_id to entity_id where entity_id is 0
|
||||
if _, err := x.Exec("UPDATE custom_field_value SET entity_id = issue_id WHERE entity_id = 0 AND issue_id != 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set issue_id default to 0 so new inserts don't require it
|
||||
_, err := x.Exec("ALTER TABLE custom_field_value MODIFY COLUMN issue_id bigint NOT NULL DEFAULT 0")
|
||||
return err
|
||||
}
|
||||
@@ -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,49 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddSecurityScanningTables creates security_alert and security_scanner_config tables.
|
||||
func AddSecurityScanningTables(x *xorm.Engine) error {
|
||||
type SecurityAlert struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL 'repo_id'"`
|
||||
Scanner string `xorm:"VARCHAR(20) NOT NULL 'scanner'"`
|
||||
Severity string `xorm:"VARCHAR(10) NOT NULL 'severity'"`
|
||||
Status string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'active' 'status'"`
|
||||
RuleID string `xorm:"VARCHAR(100) NOT NULL 'rule_id'"`
|
||||
Title string `xorm:"TEXT NOT NULL 'title'"`
|
||||
Description string `xorm:"TEXT 'description'"`
|
||||
FilePath string `xorm:"TEXT 'file_path'"`
|
||||
LineNumber int `xorm:"'line_number'"`
|
||||
CommitSHA string `xorm:"VARCHAR(64) 'commit_sha'"`
|
||||
Fingerprint string `xorm:"VARCHAR(64) INDEX 'fingerprint'"`
|
||||
Metadata string `xorm:"TEXT 'metadata'"`
|
||||
ResolvedBy int64 `xorm:"'resolved_by'"`
|
||||
CreatedUnix int64 `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix int64 `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
if err := x.Sync(new(SecurityAlert)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type SecurityScannerConfig struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE INDEX NOT NULL 'repo_id'"`
|
||||
Enabled bool `xorm:"NOT NULL DEFAULT true 'enabled'"`
|
||||
BlockOnPush bool `xorm:"NOT NULL DEFAULT false 'block_on_push'"`
|
||||
SecretScanner bool `xorm:"NOT NULL DEFAULT true 'secret_scanner'"`
|
||||
DependScanner bool `xorm:"NOT NULL DEFAULT true 'depend_scanner'"`
|
||||
CodeScanner bool `xorm:"NOT NULL DEFAULT false 'code_scanner'"`
|
||||
ConfigScanner bool `xorm:"NOT NULL DEFAULT false 'config_scanner'"`
|
||||
LicenseScanner bool `xorm:"NOT NULL DEFAULT false 'license_scanner'"`
|
||||
CustomPatterns string `xorm:"TEXT 'custom_patterns'"`
|
||||
CreatedUnix int64 `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix int64 `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
return x.Sync(new(SecurityScannerConfig))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import "xorm.io/xorm"
|
||||
|
||||
// AddIssueTypeDefTable creates the issue_type_def table and adds type_id to issues.
|
||||
func AddIssueTypeDefTable(x *xorm.Engine) error {
|
||||
type IssueTypeDef 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(IssueTypeDef)); err != nil {
|
||||
return err
|
||||
}
|
||||
type Issue struct {
|
||||
TypeID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'type_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
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(SecurityAlert))
|
||||
db.RegisterModel(new(SecurityScannerConfig))
|
||||
}
|
||||
|
||||
// AlertSeverity represents the severity level of a security finding.
|
||||
type AlertSeverity string
|
||||
|
||||
const (
|
||||
SeverityCritical AlertSeverity = "critical"
|
||||
SeverityHigh AlertSeverity = "high"
|
||||
SeverityMedium AlertSeverity = "medium"
|
||||
SeverityLow AlertSeverity = "low"
|
||||
SeverityInfo AlertSeverity = "info"
|
||||
)
|
||||
|
||||
// AlertStatus represents the lifecycle state of an alert.
|
||||
type AlertStatus string
|
||||
|
||||
const (
|
||||
AlertStatusActive AlertStatus = "active"
|
||||
AlertStatusResolved AlertStatus = "resolved"
|
||||
AlertStatusDismissed AlertStatus = "dismissed"
|
||||
)
|
||||
|
||||
// ScannerType identifies which scanner produced a finding.
|
||||
type ScannerType string
|
||||
|
||||
const (
|
||||
ScannerSecret ScannerType = "secret"
|
||||
ScannerDependency ScannerType = "dependency"
|
||||
ScannerCode ScannerType = "code"
|
||||
ScannerConfig ScannerType = "config"
|
||||
ScannerLicense ScannerType = "license"
|
||||
)
|
||||
|
||||
// SecurityAlert stores a single security finding for a repository.
|
||||
type SecurityAlert struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL 'repo_id'"`
|
||||
Scanner ScannerType `xorm:"VARCHAR(20) NOT NULL 'scanner'"`
|
||||
Severity AlertSeverity `xorm:"VARCHAR(10) NOT NULL 'severity'"`
|
||||
Status AlertStatus `xorm:"VARCHAR(10) NOT NULL DEFAULT 'active' 'status'"`
|
||||
RuleID string `xorm:"VARCHAR(100) NOT NULL 'rule_id'"` // e.g. "aws-access-key", "cve-2024-1234"
|
||||
Title string `xorm:"TEXT NOT NULL 'title'"`
|
||||
Description string `xorm:"TEXT 'description'"`
|
||||
FilePath string `xorm:"TEXT 'file_path'"`
|
||||
LineNumber int `xorm:"'line_number'"`
|
||||
CommitSHA string `xorm:"VARCHAR(64) 'commit_sha'"`
|
||||
Fingerprint string `xorm:"VARCHAR(64) INDEX 'fingerprint'"` // dedup key: hash of rule+file+content
|
||||
Metadata string `xorm:"TEXT 'metadata'"` // JSON extra data
|
||||
ResolvedBy int64 `xorm:"'resolved_by'"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
func (SecurityAlert) TableName() string {
|
||||
return "security_alert"
|
||||
}
|
||||
|
||||
// SecurityScannerConfig stores per-repo scanner settings.
|
||||
type SecurityScannerConfig struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE INDEX NOT NULL 'repo_id'"`
|
||||
Enabled bool `xorm:"NOT NULL DEFAULT true 'enabled'"`
|
||||
BlockOnPush bool `xorm:"NOT NULL DEFAULT false 'block_on_push'"` // reject push if secrets found
|
||||
SecretScanner bool `xorm:"NOT NULL DEFAULT true 'secret_scanner'"`
|
||||
DependScanner bool `xorm:"NOT NULL DEFAULT true 'depend_scanner'"`
|
||||
CodeScanner bool `xorm:"NOT NULL DEFAULT false 'code_scanner'"`
|
||||
ConfigScanner bool `xorm:"NOT NULL DEFAULT false 'config_scanner'"`
|
||||
LicenseScanner bool `xorm:"NOT NULL DEFAULT false 'license_scanner'"`
|
||||
CustomPatterns string `xorm:"TEXT 'custom_patterns'"` // JSON array of custom regex patterns
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
|
||||
}
|
||||
|
||||
func (SecurityScannerConfig) TableName() string {
|
||||
return "security_scanner_config"
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Alert queries
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetActiveAlerts returns all active alerts for a repo.
|
||||
func GetActiveAlerts(ctx context.Context, repoID int64) ([]*SecurityAlert, error) {
|
||||
alerts := make([]*SecurityAlert, 0, 20)
|
||||
return alerts, db.GetEngine(ctx).
|
||||
Where("repo_id = ? AND status = ?", repoID, AlertStatusActive).
|
||||
OrderBy("severity ASC, created_unix DESC").
|
||||
Find(&alerts)
|
||||
}
|
||||
|
||||
// GetAllAlerts returns all alerts for a repo (including resolved/dismissed).
|
||||
func GetAllAlerts(ctx context.Context, repoID int64) ([]*SecurityAlert, error) {
|
||||
alerts := make([]*SecurityAlert, 0, 50)
|
||||
return alerts, db.GetEngine(ctx).
|
||||
Where("repo_id = ?", repoID).
|
||||
OrderBy("status ASC, severity ASC, created_unix DESC").
|
||||
Find(&alerts)
|
||||
}
|
||||
|
||||
// GetAlertByID returns a single alert.
|
||||
func GetAlertByID(ctx context.Context, id int64) (*SecurityAlert, error) {
|
||||
alert := new(SecurityAlert)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(alert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, db.ErrNotExist{Resource: "SecurityAlert", ID: id}
|
||||
}
|
||||
return alert, nil
|
||||
}
|
||||
|
||||
// GetAlertCountsByRepo returns count of active alerts grouped by severity.
|
||||
func GetAlertCountsByRepo(ctx context.Context, repoID int64) (map[AlertSeverity]int64, error) {
|
||||
type result struct {
|
||||
Severity AlertSeverity `xorm:"severity"`
|
||||
Count int64 `xorm:"count"`
|
||||
}
|
||||
var results []result
|
||||
err := db.GetEngine(ctx).
|
||||
Table("security_alert").
|
||||
Select("severity, COUNT(*) as count").
|
||||
Where("repo_id = ? AND status = ?", repoID, AlertStatusActive).
|
||||
GroupBy("severity").
|
||||
Find(&results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[AlertSeverity]int64)
|
||||
for _, r := range results {
|
||||
counts[r.Severity] = r.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdateAlert creates a new alert or updates if fingerprint exists.
|
||||
func CreateOrUpdateAlert(ctx context.Context, alert *SecurityAlert) error {
|
||||
if alert.Fingerprint != "" {
|
||||
existing := new(SecurityAlert)
|
||||
has, err := db.GetEngine(ctx).
|
||||
Where("repo_id = ? AND fingerprint = ?", alert.RepoID, alert.Fingerprint).
|
||||
Get(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
// Update existing - refresh commit SHA and keep active
|
||||
existing.CommitSHA = alert.CommitSHA
|
||||
existing.LineNumber = alert.LineNumber
|
||||
existing.Status = AlertStatusActive
|
||||
_, err = db.GetEngine(ctx).ID(existing.ID).
|
||||
Cols("commit_sha", "line_number", "status").Update(existing)
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := db.GetEngine(ctx).Insert(alert)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateAlertStatus changes the status of an alert.
|
||||
func UpdateAlertStatus(ctx context.Context, id int64, status AlertStatus, resolvedBy int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(id).
|
||||
Cols("status", "resolved_by").
|
||||
Update(&SecurityAlert{Status: status, ResolvedBy: resolvedBy})
|
||||
return err
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Scanner config queries
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetScannerConfig returns the scanner config for a repo, or defaults.
|
||||
func GetScannerConfig(ctx context.Context, repoID int64) (*SecurityScannerConfig, error) {
|
||||
cfg := new(SecurityScannerConfig)
|
||||
has, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Get(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return &SecurityScannerConfig{
|
||||
RepoID: repoID,
|
||||
Enabled: true,
|
||||
SecretScanner: true,
|
||||
DependScanner: true,
|
||||
}, nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveScannerConfig creates or updates scanner config.
|
||||
func SaveScannerConfig(ctx context.Context, cfg *SecurityScannerConfig) error {
|
||||
existing := new(SecurityScannerConfig)
|
||||
has, err := db.GetEngine(ctx).Where("repo_id = ?", cfg.RepoID).Get(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
cfg.ID = existing.ID
|
||||
_, err = db.GetEngine(ctx).ID(cfg.ID).AllCols().Update(cfg)
|
||||
return err
|
||||
}
|
||||
_, err = db.GetEngine(ctx).Insert(cfg)
|
||||
return err
|
||||
}
|
||||
@@ -82,6 +82,8 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
|
||||
Doer: nil,
|
||||
}
|
||||
|
||||
opts.CustomFieldFilters = options.CustomFieldFilters
|
||||
|
||||
if len(options.MilestoneIDs) == 1 && options.MilestoneIDs[0] == 0 {
|
||||
opts.MilestoneIDs = []int64{db.NoConditionID}
|
||||
} else {
|
||||
|
||||
@@ -79,6 +79,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
|
||||
}
|
||||
|
||||
searchOpt.Paginator = opts.Paginator
|
||||
searchOpt.CustomFieldFilters = opts.CustomFieldFilters
|
||||
|
||||
switch opts.SortType {
|
||||
case "", "latest":
|
||||
|
||||
@@ -114,6 +114,8 @@ type SearchOptions struct {
|
||||
Paginator *db.ListOptions
|
||||
|
||||
SortBy SortBy // sort by field
|
||||
|
||||
CustomFieldFilters map[int64]string // field_id → required value (AND semantics, DB-only)
|
||||
}
|
||||
|
||||
// Copy returns a copy of the options.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// Copyright 2026 Moko Consulting. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
import "time"
|
||||
|
||||
// CustomFieldDefinition represents a custom field definition for a repository
|
||||
// swagger:model
|
||||
type CustomFieldDefinition struct {
|
||||
ID int64 `json:"id"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Name string `json:"name"`
|
||||
FieldType string `json:"field_type"`
|
||||
Description string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
Position int `json:"position"`
|
||||
Options string `json:"options"`
|
||||
DefaultValue string `json:"default_value"`
|
||||
// swagger:strfmt date-time
|
||||
Created time.Time `json:"created_at"`
|
||||
// swagger:strfmt date-time
|
||||
Updated time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateCustomFieldOption options for creating a custom field
|
||||
// swagger:model
|
||||
type CreateCustomFieldOption struct {
|
||||
// required: true
|
||||
Name string `json:"name" binding:"Required"`
|
||||
// required: true
|
||||
FieldType string `json:"field_type" binding:"Required"` // text, number, date, dropdown, checkbox
|
||||
Description string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
Position int `json:"position"`
|
||||
Options string `json:"options"` // JSON array for dropdown
|
||||
DefaultValue string `json:"default_value"`
|
||||
}
|
||||
|
||||
// EditCustomFieldOption options for editing a custom field
|
||||
// swagger:model
|
||||
type EditCustomFieldOption struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Required *bool `json:"required"`
|
||||
Position *int `json:"position"`
|
||||
Options *string `json:"options"`
|
||||
DefaultValue *string `json:"default_value"`
|
||||
}
|
||||
|
||||
// CustomFieldValue represents a custom field value for an issue
|
||||
// swagger:model
|
||||
type CustomFieldValue struct {
|
||||
ID int64 `json:"id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
FieldID int64 `json:"field_id"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// SetCustomFieldValueOption options for setting a custom field value
|
||||
// swagger:model
|
||||
type SetCustomFieldValueOption struct {
|
||||
// required: true
|
||||
Value string `json:"value" binding:"Required"`
|
||||
}
|
||||
@@ -104,6 +104,8 @@ type CreateIssueOption struct {
|
||||
// list of project ids
|
||||
Projects []int64 `json:"projects"`
|
||||
Closed bool `json:"closed"`
|
||||
// custom field values keyed by field name
|
||||
CustomFields map[string]string `json:"custom_fields,omitempty"`
|
||||
}
|
||||
|
||||
// EditIssueOption options for editing an issue
|
||||
@@ -190,15 +192,16 @@ const (
|
||||
// IssueTemplate represents an issue template for a repository
|
||||
// swagger:model
|
||||
type IssueTemplate struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Title string `json:"title" yaml:"title"`
|
||||
About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible
|
||||
Labels IssueTemplateStringSlice `json:"labels" yaml:"labels"`
|
||||
Assignees IssueTemplateStringSlice `json:"assignees" yaml:"assignees"`
|
||||
Ref string `json:"ref" yaml:"ref"`
|
||||
Content string `json:"content" yaml:"-"`
|
||||
Fields []*IssueFormField `json:"body" yaml:"body"`
|
||||
FileName string `json:"file_name" yaml:"-"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Title string `json:"title" yaml:"title"`
|
||||
About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible
|
||||
Labels IssueTemplateStringSlice `json:"labels" yaml:"labels"`
|
||||
Assignees IssueTemplateStringSlice `json:"assignees" yaml:"assignees"`
|
||||
Ref string `json:"ref" yaml:"ref"`
|
||||
Content string `json:"content" yaml:"-"`
|
||||
Fields []*IssueFormField `json:"body" yaml:"body"`
|
||||
FileName string `json:"file_name" yaml:"-"`
|
||||
CustomFields map[string]string `json:"custom_fields,omitempty" yaml:"custom_fields"`
|
||||
}
|
||||
|
||||
type IssueTemplateStringSlice []string
|
||||
|
||||
@@ -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)",
|
||||
@@ -1412,6 +1418,7 @@
|
||||
"repo.issues.new.open_projects": "Open Projects",
|
||||
"repo.issues.new.closed_projects": "Closed Projects",
|
||||
"repo.issues.new.no_items": "No items",
|
||||
"repo.issues.custom_fields": "Custom Fields",
|
||||
"repo.issues.new.milestone": "Milestone",
|
||||
"repo.issues.new.no_milestone": "No Milestone",
|
||||
"repo.issues.new.clear_milestone": "Clear milestone",
|
||||
@@ -1575,6 +1582,9 @@
|
||||
"repo.issues.edit": "Edit",
|
||||
"repo.issues.cancel": "Cancel",
|
||||
"repo.issues.save": "Save",
|
||||
"repo.issues.status": "Status",
|
||||
"repo.issues.priority": "Priority",
|
||||
"repo.issues.type": "Type",
|
||||
"repo.issues.label_title": "Name",
|
||||
"repo.issues.label_description": "Description",
|
||||
"repo.issues.label_color": "Color",
|
||||
@@ -1960,6 +1970,7 @@
|
||||
"repo.signing.wont_sign.approved": "The merge will not be signed as the PR is not approved.",
|
||||
"repo.ext_wiki": "Access to External Wiki",
|
||||
"repo.ext_wiki.desc": "Link to an external wiki.",
|
||||
"repo.security": "Security",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.wiki.welcome": "Welcome to the Wiki.",
|
||||
"repo.wiki.welcome_desc": "The wiki lets you write and share documentation with collaborators.",
|
||||
@@ -1983,6 +1994,7 @@
|
||||
"repo.wiki.page_already_exists": "A wiki page with the same name already exists.",
|
||||
"repo.wiki.reserved_page": "The wiki page name \"%s\" is reserved.",
|
||||
"repo.wiki.pages": "Pages",
|
||||
"repo.wiki.folder_empty": "This folder is empty.",
|
||||
"repo.wiki.last_updated": "Last updated %s",
|
||||
"repo.wiki.page_name_desc": "Enter a name for this Wiki page. Some special names are: 'Home', '_Sidebar' and '_Footer'.",
|
||||
"repo.wiki.original_git_entry_tooltip": "View original Git file instead of using friendly link.",
|
||||
@@ -2149,15 +2161,15 @@
|
||||
"repo.settings.unit_visibility_private": "Private (follow repo visibility)",
|
||||
"repo.settings.unit_visibility_public": "Public (anyone can read)",
|
||||
"repo.settings.unit_visibility_releases_help": "Controls whether the releases page is visible to anonymous visitors.",
|
||||
"repo.settings.licensing_section": "Licensing & Updates",
|
||||
"repo.settings.licensing_section_desc": "Manage commercial license keys and gated update feeds for this repository. When enabled, the Licenses tab appears and release tags must follow update stream naming.",
|
||||
"repo.settings.licensing_section": "Update Server",
|
||||
"repo.settings.licensing_section_desc": "Manage update feeds and optional license key gating for this repository. When enabled, the Licenses tab appears and release tags are served via update feeds.",
|
||||
"repo.settings.update_platform": "Update Feed Format",
|
||||
"repo.settings.update_platform_both": "Both (Joomla + Dolibarr)",
|
||||
"repo.settings.update_platform_help": "Choose which update feed format to generate. All formats support license key validation.",
|
||||
"repo.settings.require_update_key": "Require license key for update feeds",
|
||||
"repo.settings.require_update_key_help": "When enabled, update feeds return empty results unless a valid license key is provided. Joomla clients will see a Download Key field in Update Sites.",
|
||||
"repo.settings.enable_licensing": "Enable licensing for this repository",
|
||||
"repo.settings.enable_licensing_help": "Show the Licenses tab and enable license key management for this repository.",
|
||||
"repo.settings.enable_licensing": "Enable Update Server for this repository",
|
||||
"repo.settings.enable_licensing_help": "Serve update feeds from releases and show the Licenses tab for optional key management.",
|
||||
"repo.settings.packages_desc": "Enable Repository Packages Registry",
|
||||
"repo.settings.projects_desc": "Enable Projects",
|
||||
"repo.settings.projects_mode_desc": "Projects Mode (which kinds of projects to show)",
|
||||
@@ -2660,12 +2672,16 @@
|
||||
"repo.licenses.key_revoked": "License key revoked.",
|
||||
"repo.licenses.master_key_created": "Master License Key Created",
|
||||
"repo.licenses.master_key_created_copy": "This is your organization master key with unlimited access to all update channels. Copy it now — it will not be shown again.",
|
||||
"repo.licenses.regenerate_master_key": "Regenerate",
|
||||
"repo.licenses.regenerate_master_key_help": "Deactivates the current master key and generates a new one. The new key will be shown once.",
|
||||
"repo.licenses.master_key_regenerated": "Master key regenerated. Copy the new key below — it will not be shown again.",
|
||||
"repo.licenses.update_feeds": "Update Feed URLs",
|
||||
"repo.licenses.edit_key": "Edit License Key",
|
||||
"repo.licenses.licensee_name": "Licensee Name",
|
||||
"repo.licenses.licensee_email": "Licensee Email",
|
||||
"repo.licenses.domain_restriction": "Domain Restriction",
|
||||
"repo.licenses.domain_restriction_help": "Comma-separated list of allowed domains. Leave empty for no restriction.",
|
||||
"repo.licenses.domain_restriction_help": "Comma-separated list of allowed domains. Leave empty to inherit from the package default.",
|
||||
"repo.licenses.domain_restriction_package_help": "Default domain restriction for keys generated from this package. Comma-separated. Keys can override this.",
|
||||
"repo.licenses.use_package_default": "use package default",
|
||||
"repo.licenses.expires_at": "Expires At",
|
||||
"repo.licenses.expires_at_help": "Leave empty for no expiry (lifetime).",
|
||||
@@ -2716,6 +2732,66 @@
|
||||
"repo.settings.download_gating": "Download Gating",
|
||||
"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.security": "Security",
|
||||
"repo.settings.security_desc": "Security scanning detects secrets, vulnerabilities, and code issues across the repository.",
|
||||
"repo.settings.security_scanners": "Scanners",
|
||||
"repo.settings.security_enabled": "Enable security scanning",
|
||||
"repo.settings.security_secret_scanner": "Secret Scanner - API keys, tokens, passwords, private keys",
|
||||
"repo.settings.security_depend_scanner": "Dependency Scanner - CVEs in dependencies (coming soon)",
|
||||
"repo.settings.security_code_scanner": "Code Scanner - SQL injection, XSS, command injection (coming soon)",
|
||||
"repo.settings.security_config_scanner": "Config Scanner - Insecure settings, debug modes (coming soon)",
|
||||
"repo.settings.security_license_scanner": "License Scanner - License compliance (coming soon)",
|
||||
"repo.settings.security_block_on_push": "Block pushes with critical findings",
|
||||
"repo.settings.security_block_on_push_help": "Reject pushes to the default branch if critical secrets are detected.",
|
||||
"repo.settings.security_save": "Save Settings",
|
||||
"repo.settings.security_saved": "Security settings saved.",
|
||||
"repo.settings.security_alerts": "Security Alerts",
|
||||
"repo.settings.security_scan_now": "Scan Now",
|
||||
"repo.settings.security_scan_complete": "Security scan complete.",
|
||||
"repo.settings.security_severity": "Severity",
|
||||
"repo.settings.security_scanner_type": "Scanner",
|
||||
"repo.settings.security_finding": "Finding",
|
||||
"repo.settings.security_file": "File",
|
||||
"repo.settings.security_status": "Status",
|
||||
"repo.settings.security_no_alerts": "No security alerts found. Run a scan or push to the default branch to check.",
|
||||
"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.",
|
||||
"repo.settings.custom_field_new": "New Field",
|
||||
"repo.settings.custom_field_create": "Create Field",
|
||||
"repo.settings.custom_field_name": "Field Name",
|
||||
"repo.settings.custom_field_type": "Type",
|
||||
"repo.settings.custom_field_description": "Description",
|
||||
"repo.settings.custom_field_options": "Options (JSON)",
|
||||
"repo.settings.custom_field_options_help": "JSON array for dropdown fields. e.g. [\"Low\",\"Medium\",\"High\"]",
|
||||
"repo.settings.custom_field_required": "Required",
|
||||
"repo.settings.custom_field_sort_order": "Sort Order",
|
||||
"repo.settings.custom_field_created": "Custom field created.",
|
||||
"repo.settings.custom_field_updated": "Custom field updated.",
|
||||
"repo.settings.custom_field_deleted": "Custom field deleted.",
|
||||
"repo.settings.custom_field_confirm_delete": "Delete this custom field? All values stored for this field will be lost.",
|
||||
"repo.settings.custom_fields_none": "No Custom Fields",
|
||||
"repo.settings.custom_fields_none_desc": "Define custom fields to add structured metadata to issues.",
|
||||
"repo.settings.features": "Features",
|
||||
"repo.settings.features_units": "Units",
|
||||
"repo.settings.change_visibility": "Change Visibility",
|
||||
@@ -2874,11 +2950,65 @@
|
||||
"org.form.create_org_not_allowed": "You are not allowed to create an organization.",
|
||||
"org.settings": "Settings",
|
||||
"org.settings.options": "Organization",
|
||||
"org.settings.update_streams": "Licensing & Update Streams",
|
||||
"org.settings.licensing": "Licensing",
|
||||
"org.settings.licensing_desc": "Control commercial license key management and gated update feeds across all repositories in this organization.",
|
||||
"org.settings.enable_licensing": "Enable licensing for this organization",
|
||||
"org.settings.enable_licensing_help": "Show the Licenses page in the org menu and enable license key management. Individual repos can also enable licensing independently.",
|
||||
"org.settings.custom_fields": "Custom Fields",
|
||||
"org.settings.custom_fields_desc": "Define custom fields that appear across all repositories in this organization. Issue fields show in issue sidebars. Repo fields show in repo settings metadata.",
|
||||
"org.settings.custom_fields_empty": "No custom fields defined yet.",
|
||||
"org.settings.custom_field_add": "Add Custom Field",
|
||||
"org.settings.custom_field_name": "Field Name",
|
||||
"org.settings.custom_field_scope": "Scope",
|
||||
"org.settings.custom_field_type": "Type",
|
||||
"org.settings.custom_field_options": "Options (JSON)",
|
||||
"org.settings.custom_field_options_help": "For dropdown fields, enter options as a JSON array.",
|
||||
"org.settings.custom_field_description": "Description",
|
||||
"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.issue_types": "Issue Types",
|
||||
"org.settings.issue_types_desc": "Define issue types for all repositories in this organization.",
|
||||
"org.settings.issue_types_empty": "No custom issue types defined yet.",
|
||||
"org.settings.issue_type_add": "Add Type",
|
||||
"org.settings.issue_type_name": "Type Name",
|
||||
"org.settings.issue_type_color": "Color",
|
||||
"org.settings.issue_type_description": "Description",
|
||||
"org.settings.issue_type_default": "Default",
|
||||
"org.settings.issue_type_sort_order": "Sort Order",
|
||||
"org.settings.issue_type_created": "Issue type created.",
|
||||
"org.settings.issue_type_updated": "Issue type updated.",
|
||||
"org.settings.issue_type_deleted": "Issue type 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.",
|
||||
"org.settings.enable_licensing": "Enable Update Server for this organization",
|
||||
"org.settings.enable_licensing_help": "Show the Licenses page in the org menu and serve update feeds. Individual repos can also enable this independently.",
|
||||
"org.settings.require_key": "Require license key for all update feeds",
|
||||
"org.settings.require_key_help": "Update feeds return empty results unless a valid key is provided. Joomla clients will see a Download Key field. Individual repos can override this.",
|
||||
"org.settings.feed_visibility": "Update Feed Visibility",
|
||||
|
||||
+14
-12
@@ -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)
|
||||
@@ -1655,21 +1658,15 @@ func Routes() *web.Router {
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
m.Group("/custom-fields", func() {
|
||||
m.Combo("").Get(repo.ListCustomFields).
|
||||
Post(reqToken(), reqRepoWriter(unit.TypeIssues), bind(api.CreateCustomFieldOption{}), repo.CreateCustomField)
|
||||
m.Group("/{fieldId}", func() {
|
||||
m.Combo("").Get(repo.GetCustomField).
|
||||
Patch(reqToken(), reqRepoWriter(unit.TypeIssues), bind(api.EditCustomFieldOption{}), repo.EditCustomField).
|
||||
Delete(reqToken(), reqRepoWriter(unit.TypeIssues), repo.DeleteCustomField)
|
||||
})
|
||||
// Repo metadata (repo-scoped custom fields)
|
||||
m.Group("/metadata", func() {
|
||||
m.Get("", repo.GetRepoMetadata)
|
||||
m.Put("", reqToken(), reqRepoWriter(unit.TypeCode), repo.SetRepoMetadata)
|
||||
})
|
||||
// Issue custom fields
|
||||
m.Group("/issues/{index}/custom-fields", func() {
|
||||
m.Get("", repo.GetIssueCustomFields)
|
||||
m.Group("/{fieldId}", func() {
|
||||
m.Put("", reqToken(), reqRepoWriter(unit.TypeIssues), bind(api.SetCustomFieldValueOption{}), repo.SetIssueCustomField)
|
||||
m.Delete("", reqToken(), reqRepoWriter(unit.TypeIssues), repo.DeleteIssueCustomField)
|
||||
})
|
||||
m.Put("", reqToken(), reqRepoWriter(unit.TypeIssues), repo.SetIssueCustomFields)
|
||||
})
|
||||
}, repoAssignment(), checkTokenPublicOnly())
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
|
||||
@@ -1771,6 +1768,11 @@ func Routes() *web.Router {
|
||||
m.Delete("", org.UnblockUser)
|
||||
})
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
m.Group("/custom-fields", func() {
|
||||
m.Get("", org.ListOrgCustomFields)
|
||||
m.Post("", reqToken(), reqOrgOwnership(), org.CreateOrgCustomField)
|
||||
m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField)
|
||||
})
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||
m.Group("/teams/{teamid}", func() {
|
||||
m.Combo("").Get(reqToken(), org.GetTeam).
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
type apiCustomFieldDef struct {
|
||||
ID int64 `json:"id"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
FieldType string `json:"field_type"`
|
||||
Description string `json:"description"`
|
||||
Options any `json:"options"`
|
||||
Required bool `json:"required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func toAPIFieldDef(f *issues_model.CustomFieldDef) apiCustomFieldDef {
|
||||
var opts any
|
||||
if f.Options != "" {
|
||||
var parsed []string
|
||||
if json.Unmarshal([]byte(f.Options), &parsed) == nil {
|
||||
opts = parsed
|
||||
} else {
|
||||
opts = f.Options
|
||||
}
|
||||
}
|
||||
return apiCustomFieldDef{
|
||||
ID: f.ID,
|
||||
OwnerID: f.OwnerID,
|
||||
Scope: string(f.Scope),
|
||||
Name: f.Name,
|
||||
FieldType: string(f.FieldType),
|
||||
Description: f.Description,
|
||||
Options: opts,
|
||||
Required: f.Required,
|
||||
SortOrder: f.SortOrder,
|
||||
IsActive: f.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
// ListOrgCustomFields returns all custom field definitions for an org.
|
||||
func ListOrgCustomFields(ctx *context.APIContext) {
|
||||
fields, err := issues_model.GetAllCustomFieldsByOwner(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
result := make([]apiCustomFieldDef, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
result = append(result, toAPIFieldDef(f))
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// CreateOrgCustomField creates a new custom field definition.
|
||||
func CreateOrgCustomField(ctx *context.APIContext) {
|
||||
var req struct {
|
||||
Scope string `json:"scope" binding:"Required"`
|
||||
Name string `json:"name" binding:"Required"`
|
||||
FieldType string `json:"field_type" binding:"Required"`
|
||||
Description string `json:"description"`
|
||||
Options []string `json:"options"`
|
||||
Required bool `json:"required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
if err := ctx.Req.ParseForm(); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Scope == "" {
|
||||
ctx.APIError(http.StatusBadRequest, "name and scope are required")
|
||||
return
|
||||
}
|
||||
|
||||
scope := issues_model.CustomFieldScope(req.Scope)
|
||||
if scope != issues_model.CustomFieldScopeIssue && scope != issues_model.CustomFieldScopeRepo {
|
||||
ctx.APIError(http.StatusBadRequest, "scope must be 'issue' or 'repo'")
|
||||
return
|
||||
}
|
||||
|
||||
var optionsJSON string
|
||||
if len(req.Options) > 0 {
|
||||
data, _ := json.Marshal(req.Options)
|
||||
optionsJSON = string(data)
|
||||
}
|
||||
|
||||
field := &issues_model.CustomFieldDef{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
RepoID: 0,
|
||||
Scope: scope,
|
||||
Name: req.Name,
|
||||
FieldType: issues_model.CustomFieldType(req.FieldType),
|
||||
Description: req.Description,
|
||||
Options: optionsJSON,
|
||||
Required: req.Required,
|
||||
SortOrder: req.SortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := issues_model.CreateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, toAPIFieldDef(field))
|
||||
}
|
||||
|
||||
// DeleteOrgCustomField deletes a custom field definition.
|
||||
func DeleteOrgCustomField(ctx *context.APIContext) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if field.OwnerID != ctx.Org.Organization.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if err := issues_model.DeleteCustomFieldDef(ctx, id); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
// Copyright 2026 Moko Consulting. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
func fieldToAPI(f *issues_model.CustomFieldDefinition) *api.CustomFieldDefinition {
|
||||
return &api.CustomFieldDefinition{
|
||||
ID: f.ID,
|
||||
RepoID: f.RepoID,
|
||||
Name: f.Name,
|
||||
FieldType: f.FieldType,
|
||||
Description: f.Description,
|
||||
Required: f.Required,
|
||||
Position: f.Position,
|
||||
Options: f.Options,
|
||||
DefaultValue: f.DefaultVal,
|
||||
Created: f.CreatedUnix.AsTime(),
|
||||
Updated: f.UpdatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListCustomFields lists custom field definitions for a repository
|
||||
func ListCustomFields(ctx *context.APIContext) {
|
||||
fields, err := issues_model.GetCustomFieldsByRepoID(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
result := make([]*api.CustomFieldDefinition, len(fields))
|
||||
for i, f := range fields {
|
||||
result[i] = fieldToAPI(f)
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetCustomField gets a custom field definition by ID
|
||||
func GetCustomField(ctx *context.APIContext) {
|
||||
field, err := issues_model.GetCustomFieldByID(ctx, ctx.PathParamInt64("fieldId"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if field == nil || field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, fieldToAPI(field))
|
||||
}
|
||||
|
||||
// CreateCustomField creates a new custom field definition
|
||||
func CreateCustomField(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.CreateCustomFieldOption)
|
||||
|
||||
validTypes := map[string]bool{"text": true, "number": true, "date": true, "dropdown": true, "checkbox": true}
|
||||
if !validTypes[form.FieldType] {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "field_type must be: text, number, date, dropdown, or checkbox")
|
||||
return
|
||||
}
|
||||
|
||||
field := &issues_model.CustomFieldDefinition{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Name: form.Name,
|
||||
FieldType: form.FieldType,
|
||||
Description: form.Description,
|
||||
Required: form.Required,
|
||||
Position: form.Position,
|
||||
Options: form.Options,
|
||||
DefaultVal: form.DefaultValue,
|
||||
}
|
||||
if err := issues_model.CreateCustomField(ctx, field); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusCreated, fieldToAPI(field))
|
||||
}
|
||||
|
||||
// EditCustomField updates a custom field definition
|
||||
func EditCustomField(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.EditCustomFieldOption)
|
||||
field, err := issues_model.GetCustomFieldByID(ctx, ctx.PathParamInt64("fieldId"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if field == nil || field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if form.Name != nil {
|
||||
field.Name = *form.Name
|
||||
}
|
||||
if form.Description != nil {
|
||||
field.Description = *form.Description
|
||||
}
|
||||
if form.Required != nil {
|
||||
field.Required = *form.Required
|
||||
}
|
||||
if form.Position != nil {
|
||||
field.Position = *form.Position
|
||||
}
|
||||
if form.Options != nil {
|
||||
field.Options = *form.Options
|
||||
}
|
||||
if form.DefaultValue != nil {
|
||||
field.DefaultVal = *form.DefaultValue
|
||||
}
|
||||
|
||||
if err := issues_model.UpdateCustomField(ctx, field); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, fieldToAPI(field))
|
||||
}
|
||||
|
||||
// DeleteCustomField deletes a custom field and all its values
|
||||
func DeleteCustomField(ctx *context.APIContext) {
|
||||
field, err := issues_model.GetCustomFieldByID(ctx, ctx.PathParamInt64("fieldId"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if field == nil || field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if err := issues_model.DeleteCustomField(ctx, field.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetIssueCustomFields gets all custom field values for an issue
|
||||
func GetIssueCustomFields(ctx *context.APIContext) {
|
||||
values, err := issues_model.GetCustomFieldValues(ctx, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
result := make([]*api.CustomFieldValue, len(values))
|
||||
for i, v := range values {
|
||||
result[i] = &api.CustomFieldValue{
|
||||
ID: v.ID,
|
||||
IssueID: v.IssueID,
|
||||
FieldID: v.FieldID,
|
||||
Value: v.Value,
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SetIssueCustomField sets a custom field value on an issue
|
||||
func SetIssueCustomField(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.SetCustomFieldValueOption)
|
||||
issueID := ctx.PathParamInt64("index")
|
||||
fieldID := ctx.PathParamInt64("fieldId")
|
||||
|
||||
// Verify field belongs to this repo
|
||||
field, err := issues_model.GetCustomFieldByID(ctx, fieldID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if field == nil || field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.SetCustomFieldValue(ctx, issueID, fieldID, form.Value); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteIssueCustomField removes a custom field value from an issue
|
||||
func DeleteIssueCustomField(ctx *context.APIContext) {
|
||||
issueID := ctx.PathParamInt64("index")
|
||||
fieldID := ctx.PathParamInt64("fieldId")
|
||||
|
||||
if err := issues_model.DeleteCustomFieldValue(ctx, issueID, fieldID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// GetRepoMetadata returns all repo-scoped custom field values.
|
||||
func GetRepoMetadata(ctx *context.APIContext) {
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
values, err := issues_model.GetCustomFieldValuesMap(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
result[f.Name] = values[f.ID]
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SetRepoMetadata sets repo-scoped custom field values.
|
||||
func SetRepoMetadata(ctx *context.APIContext) {
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
var req map[string]string
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build name->ID map
|
||||
nameToID := make(map[string]int64, len(fields))
|
||||
for _, f := range fields {
|
||||
nameToID[f.Name] = f.ID
|
||||
}
|
||||
|
||||
for name, value := range req {
|
||||
if fieldID, ok := nameToID[name]; ok {
|
||||
if err := issues_model.SetCustomFieldValue(ctx, repoID, fieldID, value); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetIssueCustomFields returns custom field values for an issue.
|
||||
func GetIssueCustomFields(ctx *context.APIContext) {
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
values, err := issues_model.GetCustomFieldValuesMap(ctx, issue.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
result[f.Name] = values[f.ID]
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SetIssueCustomFields sets custom field values for an issue.
|
||||
func SetIssueCustomFields(ctx *context.APIContext) {
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
var req map[string]string
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
nameToID := make(map[string]int64, len(fields))
|
||||
for _, f := range fields {
|
||||
nameToID[f.Name] = f.ID
|
||||
}
|
||||
|
||||
for name, value := range req {
|
||||
if fieldID, ok := nameToID[name]; ok {
|
||||
if err := issues_model.SetCustomFieldValue(ctx, issue.ID, fieldID, value); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -289,6 +289,12 @@ func SearchIssues(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if cfFilters, cfErr := parseAPICustomFieldFilters(ctx); cfErr != nil {
|
||||
return
|
||||
} else if len(cfFilters) > 0 {
|
||||
searchOpt.CustomFieldFilters = cfFilters
|
||||
}
|
||||
|
||||
ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -517,6 +523,12 @@ func ListIssues(ctx *context.APIContext) {
|
||||
searchOpt.MentionID = optional.Some(mentionedByID)
|
||||
}
|
||||
|
||||
if cfFilters, cfErr := parseAPICustomFieldFilters(ctx); cfErr != nil {
|
||||
return
|
||||
} else if len(cfFilters) > 0 {
|
||||
searchOpt.CustomFieldFilters = cfFilters
|
||||
}
|
||||
|
||||
ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -553,6 +565,25 @@ func getUserIDForFilter(ctx *context.APIContext, queryName string) int64 {
|
||||
return user.ID
|
||||
}
|
||||
|
||||
// parseAPICustomFieldFilters extracts cf_{fieldID}=value query parameters.
|
||||
// Returns an error (and writes a 400 response) if a field ID is non-numeric or non-positive.
|
||||
func parseAPICustomFieldFilters(ctx *context.APIContext) (map[int64]string, error) {
|
||||
filters := make(map[int64]string)
|
||||
for key, values := range ctx.Req.URL.Query() {
|
||||
after, ok := strings.CutPrefix(key, "cf_")
|
||||
if !ok || len(values) == 0 || values[0] == "" {
|
||||
continue
|
||||
}
|
||||
fieldID, err := strconv.ParseInt(after, 10, 64)
|
||||
if err != nil || fieldID <= 0 {
|
||||
ctx.APIError(http.StatusBadRequest, fmt.Sprintf("invalid custom field filter: cf_%s must use a positive numeric field ID", after))
|
||||
return nil, fmt.Errorf("invalid cf_ param")
|
||||
}
|
||||
filters[fieldID] = values[0]
|
||||
}
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
// GetIssue get an issue of a repository
|
||||
func GetIssue(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/issues/{index} issue issueGetIssue
|
||||
@@ -702,6 +733,29 @@ func CreateIssue(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save custom field values if provided (resolve field names to IDs).
|
||||
if len(form.CustomFields) > 0 {
|
||||
defs, defErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue)
|
||||
if defErr != nil {
|
||||
ctx.APIErrorInternal(defErr)
|
||||
return
|
||||
}
|
||||
if len(defs) > 0 {
|
||||
vals := make(map[int64]string)
|
||||
for _, def := range defs {
|
||||
if v, ok := form.CustomFields[def.Name]; ok {
|
||||
vals[def.ID] = v
|
||||
}
|
||||
}
|
||||
if len(vals) > 0 {
|
||||
if setErr := issues_model.SetCustomFieldValues(ctx, issue.ID, vals); setErr != nil {
|
||||
ctx.APIErrorInternal(setErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if form.Closed {
|
||||
if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
|
||||
if issues_model.IsErrDependenciesLeft(err) {
|
||||
|
||||
@@ -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,120 @@
|
||||
// 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 tplOrgCustomFields templates.TplName = "org/settings/custom_fields"
|
||||
|
||||
// SettingsCustomFields shows the org-level custom fields management page.
|
||||
func SettingsCustomFields(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("org.settings.custom_fields")
|
||||
ctx.Data["PageIsOrgSettings"] = true
|
||||
ctx.Data["PageIsSettingsCustomFields"] = true
|
||||
|
||||
fields, err := issues_model.GetAllCustomFieldsByOwner(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllCustomFieldsByOwner", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["CustomFields"] = fields
|
||||
|
||||
ctx.HTML(http.StatusOK, tplOrgCustomFields)
|
||||
}
|
||||
|
||||
// SettingsCustomFieldsCreatePost creates a new org-level custom field.
|
||||
func SettingsCustomFieldsCreatePost(ctx *context.Context) {
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
scope := issues_model.CustomFieldScope(ctx.FormString("scope"))
|
||||
if scope != issues_model.CustomFieldScopeIssue && scope != issues_model.CustomFieldScopeRepo {
|
||||
scope = issues_model.CustomFieldScopeIssue
|
||||
}
|
||||
|
||||
field := &issues_model.CustomFieldDef{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
RepoID: 0, // org-level
|
||||
Scope: scope,
|
||||
Name: ctx.FormString("name"),
|
||||
FieldType: issues_model.CustomFieldType(ctx.FormString("field_type")),
|
||||
Description: ctx.FormString("description"),
|
||||
Options: ctx.FormString("options"),
|
||||
Required: ctx.FormString("required") == "on",
|
||||
SortOrder: sortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if field.Name == "" {
|
||||
ctx.Flash.Error("Field name is required")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.CreateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("CreateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.custom_field_created"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// SettingsCustomFieldsEditPost updates an org-level custom field.
|
||||
func SettingsCustomFieldsEditPost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
if field.OwnerID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
field.Name = ctx.FormString("name")
|
||||
field.FieldType = issues_model.CustomFieldType(ctx.FormString("field_type"))
|
||||
field.Description = ctx.FormString("description")
|
||||
field.Options = ctx.FormString("options")
|
||||
field.Required = ctx.FormString("required") == "on"
|
||||
field.IsActive = ctx.FormString("is_active") == "on"
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
field.SortOrder = sortOrder
|
||||
|
||||
if err := issues_model.UpdateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("UpdateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.custom_field_updated"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// SettingsCustomFieldsDeletePost deletes an org-level custom field.
|
||||
func SettingsCustomFieldsDeletePost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
if field.OwnerID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.DeleteCustomFieldDef(ctx, id); err != nil {
|
||||
ctx.ServerError("DeleteCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.custom_field_deleted"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/custom-fields")
|
||||
}
|
||||
@@ -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,98 @@
|
||||
// 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 tplOrgIssueTypes templates.TplName = "org/settings/issue_types"
|
||||
|
||||
func SettingsIssueTypes(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("org.settings.issue_types")
|
||||
ctx.Data["PageIsOrgSettings"] = true
|
||||
ctx.Data["PageIsSettingsIssueTypes"] = true
|
||||
|
||||
defs, err := issues_model.GetAllIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllIssueTypeDefsByOrg", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["IssueTypes"] = defs
|
||||
ctx.HTML(http.StatusOK, tplOrgIssueTypes)
|
||||
}
|
||||
|
||||
func SettingsIssueTypesCreatePost(ctx *context.Context) {
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
def := &issues_model.IssueTypeDef{
|
||||
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("Type name is required")
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-types")
|
||||
return
|
||||
}
|
||||
if err := issues_model.CreateIssueTypeDef(ctx, def); err != nil {
|
||||
ctx.ServerError("CreateIssueTypeDef", err)
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.issue_type_created"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-types")
|
||||
}
|
||||
|
||||
func SettingsIssueTypesEditPost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
def, err := issues_model.GetIssueTypeDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueTypeDefByID", 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.UpdateIssueTypeDef(ctx, def); err != nil {
|
||||
ctx.ServerError("UpdateIssueTypeDef", err)
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.issue_type_updated"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-types")
|
||||
}
|
||||
|
||||
func SettingsIssueTypesDeletePost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
def, err := issues_model.GetIssueTypeDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueTypeDefByID", err)
|
||||
return
|
||||
}
|
||||
if def.OrgID != ctx.Org.Organization.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
if err := issues_model.DeleteIssueTypeDef(ctx, id); err != nil {
|
||||
ctx.ServerError("DeleteIssueTypeDef", err)
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("org.settings.issue_type_deleted"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-types")
|
||||
}
|
||||
@@ -630,7 +630,7 @@ func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Contex
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData)
|
||||
_, templateErrs, _ := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData)
|
||||
if len(templateErrs) > 0 {
|
||||
ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ func CheckDownloadGating(ctx *context.Context, tagName string) bool {
|
||||
return true // no download gating configured
|
||||
}
|
||||
|
||||
// Signed-in users with repo access bypass download gating.
|
||||
// The gate is for anonymous/external clients (Joomla update checker).
|
||||
if ctx.IsSigned && ctx.Repo.Permission.HasAnyUnitAccess() {
|
||||
return true
|
||||
}
|
||||
|
||||
// For prerelease-only gating, check if this is a prerelease tag.
|
||||
if gating == "prerelease" && tagName != "" {
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
@@ -184,6 +184,45 @@ func NewComment(ctx *context.Context) {
|
||||
}
|
||||
} // end if: handle close or reopen
|
||||
|
||||
// Handle custom status from the status dropdown (replaces close button for issues with org statuses).
|
||||
if statusIDStr := ctx.Req.FormValue("status_id"); statusIDStr != "" && statusIDStr != "" {
|
||||
if statusIDStr == "reopen" {
|
||||
// Reopen via dropdown
|
||||
if issue.IsClosed {
|
||||
if err := issue_service.ReopenIssue(ctx, issue, ctx.Doer, ""); err != nil {
|
||||
log.Error("ReopenIssue via status dropdown: %v", err)
|
||||
}
|
||||
if err := issues_model.SetIssueStatusID(ctx, issue.ID, 0); err != nil {
|
||||
log.Error("SetIssueStatusID: %v", err)
|
||||
}
|
||||
}
|
||||
} else if statusIDStr == "close" {
|
||||
// Plain close via dropdown
|
||||
if !issue.IsClosed {
|
||||
if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
|
||||
log.Error("CloseIssue via status dropdown: %v", err)
|
||||
}
|
||||
}
|
||||
} else if statusID, err := strconv.ParseInt(statusIDStr, 10, 64); err == nil && statusID > 0 {
|
||||
// Custom status selected
|
||||
statusDef, err := issues_model.GetIssueStatusDefByID(ctx, statusID)
|
||||
if err == nil && statusDef.OrgID == ctx.Repo.Repository.OwnerID {
|
||||
if err := issues_model.SetIssueStatusID(ctx, issue.ID, statusID); err != nil {
|
||||
log.Error("SetIssueStatusID: %v", err)
|
||||
}
|
||||
if statusDef.ClosesIssue && !issue.IsClosed {
|
||||
if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
|
||||
log.Error("CloseIssue via custom status: %v", err)
|
||||
}
|
||||
} else if !statusDef.ClosesIssue && issue.IsClosed {
|
||||
if err := issue_service.ReopenIssue(ctx, issue, ctx.Doer, ""); err != nil {
|
||||
log.Error("ReopenIssue via custom status: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSONRedirect(redirect)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// UpdateIssueCustomField handles POST to set a custom field value on an issue.
|
||||
func UpdateIssueCustomField(ctx *context.Context) {
|
||||
issueID := ctx.PathParamInt64("id")
|
||||
fieldID := ctx.PathParamInt64("field_id")
|
||||
value := ctx.FormString("value")
|
||||
|
||||
// Look up issue to get the index for redirect.
|
||||
issue, err := issues_model.GetIssueByID(ctx, issueID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.SetCustomFieldValue(ctx, issueID, fieldID, value); err != nil {
|
||||
ctx.ServerError("SetCustomFieldValue", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// UpdateIssueCustomType handles POST to set a custom type on an issue.
|
||||
func UpdateIssueCustomType(ctx *context.Context) {
|
||||
issueID := ctx.PathParamInt64("id")
|
||||
typeID := ctx.FormInt64("type_id")
|
||||
|
||||
issue, err := issues_model.GetIssueByID(ctx, issueID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if typeID > 0 {
|
||||
typeDef, err := issues_model.GetIssueTypeDefByID(ctx, typeID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueTypeDefByID", err)
|
||||
return
|
||||
}
|
||||
if typeDef.OrgID != ctx.Repo.Repository.OwnerID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := issues_model.SetIssueTypeID(ctx, issueID, typeID); err != nil {
|
||||
ctx.ServerError("SetIssueTypeID", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index), http.StatusSeeOther)
|
||||
}
|
||||
@@ -5,8 +5,11 @@ package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -521,20 +524,60 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectI
|
||||
|
||||
prepareIssueFilterExclusiveOrderScopes(ctx, preparedLabelFilter.AllLabels)
|
||||
|
||||
// Parse custom field filters from query params (cf_{fieldID}={value}).
|
||||
customFieldFilters := parseCustomFieldQueryParams(ctx.Req.URL.Query())
|
||||
|
||||
// Load custom field definitions for the filter UI.
|
||||
// If this fails, clear filters so users don't get invisible filtering.
|
||||
customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, repo.OwnerID, issues_model.CustomFieldScopeIssue)
|
||||
if cfErr != nil {
|
||||
log.Error("prepareIssueFilterAndList: GetCustomFieldsByOwner: %v", cfErr)
|
||||
customFieldFilters = make(map[int64]string)
|
||||
}
|
||||
ctx.Data["CustomFieldDefs"] = customFieldDefs
|
||||
ctx.Data["CustomFieldFilters"] = customFieldFilters
|
||||
|
||||
// Load first-class field definitions for issue list badges
|
||||
issueStatusDefs, _ := issues_model.GetIssueStatusDefsByOrg(ctx, repo.OwnerID)
|
||||
ctx.Data["IssueStatusDefs"] = issueStatusDefs
|
||||
issuePriorityDefs, _ := issues_model.GetIssuePriorityDefsByOrg(ctx, repo.OwnerID)
|
||||
ctx.Data["IssuePriorityDefs"] = issuePriorityDefs
|
||||
issueTypeDefs, _ := issues_model.GetIssueTypeDefsByOrg(ctx, repo.OwnerID)
|
||||
ctx.Data["IssueTypeDefs"] = issueTypeDefs
|
||||
// Build a query string fragment for cf_ params so they survive pagination/sort changes.
|
||||
cfQuery := make(url.Values)
|
||||
for fieldID, value := range customFieldFilters {
|
||||
cfQuery.Set(fmt.Sprintf("cf_%d", fieldID), value)
|
||||
}
|
||||
ctx.Data["CustomFieldQueryString"] = cfQuery.Encode()
|
||||
fieldOptions := make(map[int64][]string)
|
||||
for _, f := range customFieldDefs {
|
||||
if f.Options != "" {
|
||||
var opts []string
|
||||
if err := json.Unmarshal([]byte(f.Options), &opts); err != nil {
|
||||
log.Error("prepareIssueFilterAndList: invalid options JSON for field %d (%s): %v", f.ID, f.Name, err)
|
||||
} else {
|
||||
fieldOptions[f.ID] = opts
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["CustomFieldOptions"] = fieldOptions
|
||||
|
||||
var keywordMatchedIssueIDs []int64
|
||||
var issueStats *issues_model.IssueStats
|
||||
statsOpts := &issues_model.IssuesOptions{
|
||||
RepoIDs: []int64{repo.ID},
|
||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||
MilestoneIDs: mileIDs,
|
||||
ProjectIDs: projectIDs,
|
||||
AssigneeID: assigneeID,
|
||||
MentionedID: mentionedID,
|
||||
PosterID: posterUserID,
|
||||
ReviewRequestedID: reviewRequestedID,
|
||||
ReviewedID: reviewedID,
|
||||
IsPull: isPullOption,
|
||||
IssueIDs: nil,
|
||||
RepoIDs: []int64{repo.ID},
|
||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||
MilestoneIDs: mileIDs,
|
||||
ProjectIDs: projectIDs,
|
||||
AssigneeID: assigneeID,
|
||||
MentionedID: mentionedID,
|
||||
PosterID: posterUserID,
|
||||
ReviewRequestedID: reviewRequestedID,
|
||||
ReviewedID: reviewedID,
|
||||
IsPull: isPullOption,
|
||||
IssueIDs: nil,
|
||||
CustomFieldFilters: customFieldFilters,
|
||||
}
|
||||
|
||||
if keyword != "" {
|
||||
@@ -611,9 +654,10 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectI
|
||||
ProjectIDs: projectIDs,
|
||||
IsClosed: isShowClosed,
|
||||
IsPull: isPullOption,
|
||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||
SortType: sortType,
|
||||
IssueIDs: keywordMatchedIssueIDs,
|
||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||
SortType: sortType,
|
||||
IssueIDs: keywordMatchedIssueIDs,
|
||||
CustomFieldFilters: customFieldFilters,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("DBIndexer.Search", err)
|
||||
@@ -771,3 +815,17 @@ func Issues(ctx *context.Context) {
|
||||
|
||||
ctx.HTML(http.StatusOK, tplIssues)
|
||||
}
|
||||
|
||||
// parseCustomFieldQueryParams extracts cf_{fieldID}=value query parameters.
|
||||
// Non-numeric or non-positive field IDs are silently skipped.
|
||||
func parseCustomFieldQueryParams(query url.Values) map[int64]string {
|
||||
filters := make(map[int64]string)
|
||||
for key, values := range query {
|
||||
if after, ok := strings.CutPrefix(key, "cf_"); ok && len(values) > 0 && values[0] != "" {
|
||||
if fieldID, err := strconv.ParseInt(after, 10, 64); err == nil && fieldID > 0 {
|
||||
filters[fieldID] = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -36,10 +37,11 @@ import (
|
||||
)
|
||||
|
||||
// Tries to load and set an issue template. The first return value indicates if a template was loaded.
|
||||
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error) {
|
||||
// The third return value contains the template's custom_fields map (field name → default value).
|
||||
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error, map[string]string) {
|
||||
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
templateCandidates := make([]string, 0, 1+len(possibleFiles))
|
||||
@@ -84,9 +86,9 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
|
||||
}
|
||||
metaData.AssigneesData.SelectedAssigneeIDs = strings.Join(selectedAssigneeIDStrings, ",")
|
||||
|
||||
return true, templateErrs
|
||||
return true, templateErrs, template.CustomFields
|
||||
}
|
||||
return false, templateErrs
|
||||
return false, templateErrs, nil
|
||||
}
|
||||
|
||||
// NewIssue render creating issue page
|
||||
@@ -128,7 +130,7 @@ func NewIssue(ctx *context.Context) {
|
||||
ctx.Data["Tags"] = tags
|
||||
|
||||
ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
|
||||
templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData)
|
||||
templateLoaded, errs, templateCustomFields := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData)
|
||||
maps.Copy(ret.TemplateErrors, errs)
|
||||
if ctx.Written() {
|
||||
return
|
||||
@@ -140,6 +142,35 @@ func NewIssue(ctx *context.Context) {
|
||||
|
||||
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypeIssues)
|
||||
|
||||
// Load org-level issue-scoped custom fields for the new issue sidebar.
|
||||
customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue)
|
||||
if cfErr != nil {
|
||||
log.Error("NewIssue: GetCustomFieldsByOwner: %v", cfErr)
|
||||
}
|
||||
ctx.Data["CustomFieldDefs"] = customFieldDefs
|
||||
customFieldValues := make(map[int64]string)
|
||||
fieldOptions := make(map[int64][]string)
|
||||
if len(customFieldDefs) > 0 {
|
||||
// Resolve template custom_fields (name → value) to field IDs.
|
||||
if len(templateCustomFields) > 0 {
|
||||
for _, def := range customFieldDefs {
|
||||
if val, ok := templateCustomFields[def.Name]; ok {
|
||||
customFieldValues[def.ID] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range customFieldDefs {
|
||||
if f.Options != "" {
|
||||
var opts []string
|
||||
if err := json.Unmarshal([]byte(f.Options), &opts); err == nil {
|
||||
fieldOptions[f.ID] = opts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["CustomFieldValues"] = customFieldValues
|
||||
ctx.Data["CustomFieldOptions"] = fieldOptions
|
||||
|
||||
if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
|
||||
// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
|
||||
ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
|
||||
@@ -377,6 +408,9 @@ func NewIssuePost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save custom field values submitted from the new issue form.
|
||||
saveCustomFieldsFromForm(ctx, repo.OwnerID, issue.ID)
|
||||
|
||||
log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
|
||||
if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 {
|
||||
// When issue is in multiple projects, redirect to first project from form order.
|
||||
@@ -392,3 +426,29 @@ func NewIssuePost(ctx *context.Context) {
|
||||
}
|
||||
ctx.JSONRedirect(issue.Link())
|
||||
}
|
||||
|
||||
// saveCustomFieldsFromForm reads custom field values from the form
|
||||
// (submitted as "custom-field-{fieldID}") and persists them for the issue.
|
||||
func saveCustomFieldsFromForm(ctx *context.Context, ownerID, issueID int64) {
|
||||
defs, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
|
||||
if err != nil {
|
||||
log.Error("saveCustomFieldsFromForm: GetCustomFieldsByOwner: %v", err)
|
||||
return
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
return
|
||||
}
|
||||
vals := make(map[int64]string)
|
||||
for _, def := range defs {
|
||||
v := ctx.Req.FormValue(fmt.Sprintf("custom-field-%d", def.ID))
|
||||
if v != "" {
|
||||
vals[def.ID] = v
|
||||
}
|
||||
}
|
||||
if len(vals) > 0 {
|
||||
if err := issues_model.SetCustomFieldValues(ctx, issueID, vals); err != nil {
|
||||
log.Error("saveCustomFieldsFromForm: %v", err)
|
||||
ctx.Flash.Error("Failed to save custom field values")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
@@ -337,6 +338,54 @@ func ViewIssue(ctx *context.Context) {
|
||||
|
||||
ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanRead(unit.TypeProjects)
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
|
||||
// Load custom fields for the issue sidebar (org-level issue-scoped fields).
|
||||
customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue)
|
||||
if cfErr != nil {
|
||||
log.Error("ViewIssue: GetCustomFieldsByOwner: %v", cfErr)
|
||||
}
|
||||
ctx.Data["CustomFieldDefs"] = customFieldDefs
|
||||
customFieldValues := make(map[int64]string)
|
||||
fieldOptions := make(map[int64][]string)
|
||||
if len(customFieldDefs) > 0 {
|
||||
var cvErr error
|
||||
customFieldValues, cvErr = issues_model.GetCustomFieldValuesMap(ctx, issue.ID)
|
||||
if cvErr != nil {
|
||||
log.Error("ViewIssue: GetCustomFieldValuesMap: %v", cvErr)
|
||||
}
|
||||
for _, f := range customFieldDefs {
|
||||
if f.Options != "" {
|
||||
var opts []string
|
||||
if err := json.Unmarshal([]byte(f.Options), &opts); err == nil {
|
||||
fieldOptions[f.ID] = opts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
// Load custom issue type definitions for the sidebar.
|
||||
issueTypeDefs, itErr := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Repo.Repository.OwnerID)
|
||||
if itErr != nil {
|
||||
log.Error("ViewIssue: GetIssueTypeDefsByOrg: %v", itErr)
|
||||
}
|
||||
ctx.Data["IssueTypeDefs"] = issueTypeDefs
|
||||
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
if err := issue.LoadAttributes(ctx); err != nil {
|
||||
|
||||
@@ -90,6 +90,10 @@ func Licenses(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Always load the master key for display (prefix + status).
|
||||
masterKey, _ := licenses.GetMasterKey(ctx, ownerID)
|
||||
ctx.Data["MasterKey"] = masterKey
|
||||
|
||||
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListLicensePackages", err)
|
||||
@@ -183,15 +187,16 @@ func LicensesCreatePackage(ctx *context.Context) {
|
||||
}
|
||||
|
||||
pkg := &licenses.LicensePackage{
|
||||
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||
Name: name,
|
||||
Description: ctx.FormString("description"),
|
||||
DurationDays: durationDays,
|
||||
MaxSites: maxSites,
|
||||
DomainLockHours: domainLockHours,
|
||||
AllowedChannels: allowedChannels,
|
||||
RepoScope: repoScope,
|
||||
IsActive: true,
|
||||
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||
Name: name,
|
||||
Description: ctx.FormString("description"),
|
||||
DurationDays: durationDays,
|
||||
MaxSites: maxSites,
|
||||
DomainLockHours: domainLockHours,
|
||||
AllowedChannels: allowedChannels,
|
||||
DomainRestriction: strings.TrimSpace(ctx.FormString("domain_restriction")),
|
||||
RepoScope: repoScope,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := licenses.CreateLicensePackage(ctx, pkg); err != nil {
|
||||
@@ -203,9 +208,62 @@ func LicensesCreatePackage(ctx *context.Context) {
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
}
|
||||
|
||||
// LicensesRegenerateMasterKey handles POST to regenerate the master key.
|
||||
func LicensesRegenerateMasterKey(ctx *context.Context) {
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
|
||||
// Deactivate the old master key.
|
||||
oldKey, _ := licenses.GetMasterKey(ctx, ownerID)
|
||||
if oldKey != nil {
|
||||
oldKey.IsActive = false
|
||||
_ = licenses.UpdateLicenseKey(ctx, oldKey)
|
||||
}
|
||||
|
||||
// Find the master package.
|
||||
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListLicensePackages", err)
|
||||
return
|
||||
}
|
||||
var masterPkg *licenses.LicensePackage
|
||||
for _, pkg := range pkgs {
|
||||
if pkg.Name == licenses.MasterPackageName {
|
||||
masterPkg = pkg
|
||||
break
|
||||
}
|
||||
}
|
||||
if masterPkg == nil {
|
||||
ctx.Flash.Error("Master package not found")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new master key.
|
||||
newKey := &licenses.LicenseKey{
|
||||
PackageID: masterPkg.ID,
|
||||
OwnerID: ownerID,
|
||||
IsInternal: true,
|
||||
IsActive: true,
|
||||
}
|
||||
rawKey, err := licenses.CreateLicenseKey(ctx, newKey)
|
||||
if err != nil {
|
||||
ctx.ServerError("CreateLicenseKey", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.licenses.master_key_regenerated"))
|
||||
ctx.Data["NewMasterKey"] = rawKey
|
||||
Licenses(ctx)
|
||||
}
|
||||
|
||||
// LicensesGenerateKey handles POST to generate a new key from a package.
|
||||
func LicensesGenerateKey(ctx *context.Context) {
|
||||
packageID, _ := strconv.ParseInt(ctx.FormString("package_id"), 10, 64)
|
||||
// Accept package_id from form body or query string (modal sets it via form action URL).
|
||||
pkgIDStr := ctx.FormString("package_id")
|
||||
if pkgIDStr == "" {
|
||||
pkgIDStr = ctx.Req.URL.Query().Get("package_id")
|
||||
}
|
||||
packageID, _ := strconv.ParseInt(pkgIDStr, 10, 64)
|
||||
if packageID == 0 {
|
||||
ctx.Flash.Error("Invalid package")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
|
||||
@@ -218,10 +276,19 @@ func LicensesGenerateKey(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Domain restriction: use form input, fall back to package default.
|
||||
domainRestriction := strings.TrimSpace(ctx.FormString("domain_restriction"))
|
||||
if domainRestriction == "" && pkg.DomainRestriction != "" {
|
||||
domainRestriction = pkg.DomainRestriction
|
||||
}
|
||||
|
||||
key := &licenses.LicenseKey{
|
||||
PackageID: packageID,
|
||||
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||
IsActive: true,
|
||||
PackageID: packageID,
|
||||
OwnerID: ctx.Repo.Repository.OwnerID,
|
||||
IsActive: true,
|
||||
DomainRestriction: domainRestriction,
|
||||
LicenseeName: strings.TrimSpace(ctx.FormString("licensee_name")),
|
||||
LicenseeEmail: strings.TrimSpace(ctx.FormString("licensee_email")),
|
||||
}
|
||||
|
||||
// Auto-calculate expiry from package duration.
|
||||
@@ -448,6 +515,7 @@ func LicensesEditPackagePost(ctx *context.Context) {
|
||||
pkg.AllowedChannels = ""
|
||||
}
|
||||
|
||||
pkg.DomainRestriction = strings.TrimSpace(ctx.FormString("domain_restriction"))
|
||||
pkg.IsActive = ctx.FormString("is_active") == "on"
|
||||
|
||||
if err := licenses.UpdateLicensePackage(ctx, pkg); err != nil {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
|
||||
)
|
||||
|
||||
const tplRepoSecurity templates.TplName = "repo/security"
|
||||
|
||||
// Security renders the repo-level security tab showing alerts and scan controls.
|
||||
func Security(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.security")
|
||||
ctx.Data["PageIsSecurity"] = true
|
||||
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
cfg, err := security_model.GetScannerConfig(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetScannerConfig", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["ScannerConfig"] = cfg
|
||||
|
||||
alerts, err := security_model.GetAllAlerts(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllAlerts", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["SecurityAlerts"] = alerts
|
||||
|
||||
counts, err := security_model.GetAlertCountsByRepo(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAlertCountsByRepo", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["AlertCounts"] = counts
|
||||
|
||||
ctx.HTML(http.StatusOK, tplRepoSecurity)
|
||||
}
|
||||
|
||||
// SecurityScanNow triggers an immediate scan from the security tab.
|
||||
func SecurityScanNow(ctx *context.Context) {
|
||||
commit := ctx.Repo.Commit
|
||||
if commit == nil {
|
||||
ctx.Flash.Error("No commits found")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/security")
|
||||
return
|
||||
}
|
||||
|
||||
security_service.ScanOnPush(ctx, ctx.Repo.Repository, commit)
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.security_scan_complete"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/security")
|
||||
}
|
||||
|
||||
// SecurityAlertUpdate changes alert status from the security tab.
|
||||
func SecurityAlertUpdateTab(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
status := security_model.AlertStatus(ctx.FormString("status"))
|
||||
|
||||
if status != security_model.AlertStatusResolved && status != security_model.AlertStatusDismissed {
|
||||
status = security_model.AlertStatusDismissed
|
||||
}
|
||||
|
||||
alert, err := security_model.GetAlertByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAlertByID", err)
|
||||
return
|
||||
}
|
||||
if alert.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := security_model.UpdateAlertStatus(ctx, id, status, ctx.Doer.ID); err != nil {
|
||||
ctx.ServerError("UpdateAlertStatus", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success("Alert updated")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/security")
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const tplSettingsAdvanced templates.TplName = "repo/settings/advanced"
|
||||
// AdvancedSettings displays the advanced (feature units) settings page.
|
||||
func AdvancedSettings(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.advanced_settings")
|
||||
ctx.Data["PageIsSettingsOptions"] = false
|
||||
ctx.Data["PageIsSettingsAdvanced"] = true
|
||||
ctx.HTML(http.StatusOK, tplSettingsAdvanced)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
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 tplSettingsCustomFields templates.TplName = "repo/settings/custom_fields"
|
||||
|
||||
// CustomFields displays the custom fields settings page.
|
||||
func CustomFields(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.custom_fields")
|
||||
ctx.Data["PageIsSettingsCustomFields"] = true
|
||||
|
||||
fields, err := issues_model.GetAllCustomFieldsByRepo(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllCustomFieldsByRepo", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["CustomFields"] = fields
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsCustomFields)
|
||||
}
|
||||
|
||||
// CustomFieldsCreatePost creates a new custom field definition.
|
||||
func CustomFieldsCreatePost(ctx *context.Context) {
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
|
||||
field := &issues_model.CustomFieldDef{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Name: ctx.FormString("name"),
|
||||
FieldType: issues_model.CustomFieldType(ctx.FormString("field_type")),
|
||||
Description: ctx.FormString("description"),
|
||||
Options: ctx.FormString("options"),
|
||||
Required: ctx.FormString("required") == "on",
|
||||
SortOrder: sortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if field.Name == "" {
|
||||
ctx.Flash.Error("Field name is required")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.CreateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("CreateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.custom_field_created"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// CustomFieldsEditPost updates a custom field definition.
|
||||
func CustomFieldsEditPost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
field.Name = ctx.FormString("name")
|
||||
field.FieldType = issues_model.CustomFieldType(ctx.FormString("field_type"))
|
||||
field.Description = ctx.FormString("description")
|
||||
field.Options = ctx.FormString("options")
|
||||
field.Required = ctx.FormString("required") == "on"
|
||||
field.IsActive = ctx.FormString("is_active") == "on"
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
field.SortOrder = sortOrder
|
||||
|
||||
if err := issues_model.UpdateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("UpdateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.custom_field_updated"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// CustomFieldsDeletePost deletes a custom field definition.
|
||||
func CustomFieldsDeletePost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
if field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.DeleteCustomFieldDef(ctx, id); err != nil {
|
||||
ctx.ServerError("DeleteCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.custom_field_deleted"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplSettingsMetadata templates.TplName = "repo/settings/metadata"
|
||||
|
||||
// Metadata displays the repo metadata page (repo-scoped custom field values).
|
||||
func Metadata(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.metadata")
|
||||
ctx.Data["PageIsSettingsMetadata"] = true
|
||||
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
fields, _ := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
ctx.Data["CustomFieldDefs"] = fields
|
||||
|
||||
values := make(map[int64]string)
|
||||
fieldOptions := make(map[int64][]string)
|
||||
if len(fields) > 0 {
|
||||
values, _ = issues_model.GetCustomFieldValuesMap(ctx, repoID)
|
||||
for _, f := range fields {
|
||||
if f.Options != "" {
|
||||
var opts []string
|
||||
if err := json.Unmarshal([]byte(f.Options), &opts); err == nil {
|
||||
fieldOptions[f.ID] = opts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Data["CustomFieldValues"] = values
|
||||
ctx.Data["CustomFieldOptions"] = fieldOptions
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsMetadata)
|
||||
}
|
||||
|
||||
// MetadataPost saves repo-scoped custom field values.
|
||||
func MetadataPost(ctx *context.Context) {
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
ownerID := ctx.Repo.Repository.OwnerID
|
||||
|
||||
fields, _ := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
|
||||
for _, f := range fields {
|
||||
val := ctx.Req.FormValue(fmt.Sprintf("field_%d", f.ID))
|
||||
if err := issues_model.SetCustomFieldValue(ctx, repoID, f.ID, val); err != nil {
|
||||
ctx.ServerError("SetCustomFieldValue", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.metadata_saved"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/metadata")
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
|
||||
)
|
||||
|
||||
const tplSettingsSecurity templates.TplName = "repo/settings/security"
|
||||
|
||||
// SecuritySettings displays the repo security scanning settings and alerts.
|
||||
func SecuritySettings(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.security")
|
||||
ctx.Data["PageIsSettingsSecurity"] = true
|
||||
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
cfg, err := security_model.GetScannerConfig(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetScannerConfig", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["ScannerConfig"] = cfg
|
||||
|
||||
alerts, err := security_model.GetAllAlerts(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllAlerts", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["SecurityAlerts"] = alerts
|
||||
|
||||
counts, err := security_model.GetAlertCountsByRepo(ctx, repoID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAlertCountsByRepo", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["AlertCounts"] = counts
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsSecurity)
|
||||
}
|
||||
|
||||
// SecuritySettingsPost saves security scanner configuration.
|
||||
func SecuritySettingsPost(ctx *context.Context) {
|
||||
cfg := &security_model.SecurityScannerConfig{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Enabled: ctx.FormString("enabled") == "on",
|
||||
BlockOnPush: ctx.FormString("block_on_push") == "on",
|
||||
SecretScanner: ctx.FormString("secret_scanner") == "on",
|
||||
DependScanner: ctx.FormString("depend_scanner") == "on",
|
||||
CodeScanner: ctx.FormString("code_scanner") == "on",
|
||||
ConfigScanner: ctx.FormString("config_scanner") == "on",
|
||||
LicenseScanner: ctx.FormString("license_scanner") == "on",
|
||||
}
|
||||
|
||||
if err := security_model.SaveScannerConfig(ctx, cfg); err != nil {
|
||||
ctx.ServerError("SaveScannerConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.security_saved"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/security")
|
||||
}
|
||||
|
||||
// SecurityScanNow triggers an immediate scan of the repository.
|
||||
func SecurityScanNow(ctx *context.Context) {
|
||||
commit := ctx.Repo.Commit
|
||||
if commit == nil {
|
||||
ctx.Flash.Error("No commits found in repository")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/security")
|
||||
return
|
||||
}
|
||||
|
||||
security_service.ScanOnPush(ctx, ctx.Repo.Repository, commit)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.security_scan_complete"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/security")
|
||||
}
|
||||
|
||||
// SecurityAlertUpdate changes the status of a security alert.
|
||||
func SecurityAlertUpdate(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
status := security_model.AlertStatus(ctx.FormString("status"))
|
||||
|
||||
if status != security_model.AlertStatusResolved && status != security_model.AlertStatusDismissed {
|
||||
status = security_model.AlertStatusDismissed
|
||||
}
|
||||
|
||||
alert, err := security_model.GetAlertByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAlertByID", err)
|
||||
return
|
||||
}
|
||||
if alert.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := security_model.UpdateAlertStatus(ctx, id, status, ctx.Doer.ID); err != nil {
|
||||
ctx.ServerError("UpdateAlertStatus", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success("Alert updated")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/security")
|
||||
}
|
||||
@@ -703,7 +703,7 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
|
||||
log.Trace("Repository advanced settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/advanced")
|
||||
}
|
||||
|
||||
func handleSettingsPostSigning(ctx *context.Context) {
|
||||
|
||||
@@ -94,7 +94,9 @@ func ServeUpdatesXML(ctx *context.Context) {
|
||||
}
|
||||
|
||||
repoCfg, _ := licenses.GetRepoConfig(ctx, ctx.Repo.Repository.ID)
|
||||
requireKey := repoCfg != nil && repoCfg.RequireKey
|
||||
// Show <downloadkey> only when downloads are gated (prerelease or all).
|
||||
// No gating = no license keys needed = no downloadkey element.
|
||||
requireKey := repoCfg != nil && repoCfg.DownloadGating != "" && repoCfg.DownloadGating != "none"
|
||||
|
||||
xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, requireKey, stripDownloads, allowedChannels...)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,6 +77,20 @@ type PageMeta struct {
|
||||
UpdatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// WikiTreeNode represents a node in the wiki folder tree for sidebar navigation.
|
||||
type WikiTreeNode struct {
|
||||
Name string
|
||||
SubURL string
|
||||
IsDir bool
|
||||
Children []*WikiTreeNode
|
||||
}
|
||||
|
||||
// WikiBreadcrumb represents a breadcrumb segment.
|
||||
type WikiBreadcrumb struct {
|
||||
Name string
|
||||
SubURL string
|
||||
}
|
||||
|
||||
// findEntryForFile finds the tree entry for a target filepath.
|
||||
func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) {
|
||||
entry, err := commit.GetTreeEntryByPath(target)
|
||||
@@ -232,6 +246,43 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
|
||||
isSideBar := pageName == "_Sidebar"
|
||||
isFooter := pageName == "_Footer"
|
||||
|
||||
// Build breadcrumbs for the current path
|
||||
breadcrumbs := buildWikiBreadcrumbs(pageName)
|
||||
ctx.Data["WikiBreadcrumbs"] = breadcrumbs
|
||||
|
||||
// Build folder tree for sidebar navigation
|
||||
wikiTree := buildWikiTree(commit)
|
||||
ctx.Data["WikiTree"] = wikiTree
|
||||
|
||||
// Check if path is a directory first (before file lookup)
|
||||
dirEntry, _ := commit.GetTreeEntryByPath(string(pageName))
|
||||
if dirEntry != nil && dirEntry.IsDir() {
|
||||
// Path is a directory - try index files or show folder listing
|
||||
var entry *git.TreeEntry
|
||||
foundIndex := false
|
||||
for _, indexName := range []string{"README", "Home", "index"} {
|
||||
indexPath := wiki_service.WebPath(string(pageName) + "/" + indexName)
|
||||
idxEntry, _, idxNoEntry, _ := wikiEntryByName(ctx, commit, indexPath)
|
||||
if !idxNoEntry && idxEntry != nil {
|
||||
pageName = indexPath
|
||||
entry = idxEntry
|
||||
_, displayName = wiki_service.WebPathToUserTitle(pageName)
|
||||
ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName)
|
||||
ctx.Data["Title"] = displayName
|
||||
foundIndex = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundIndex {
|
||||
ctx.Data["IsWikiFolder"] = true
|
||||
ctx.Data["WikiFolderPath"] = string(pageName)
|
||||
folderEntries := listWikiFolderEntries(commit, string(pageName))
|
||||
ctx.Data["WikiFolderEntries"] = folderEntries
|
||||
return wikiGitRepo, nil
|
||||
}
|
||||
_ = entry // will be used below via pageName lookup
|
||||
}
|
||||
|
||||
// lookup filename in wiki - get gitTree entry , real filename
|
||||
entry, pageFilename, noEntry, isRaw := wikiEntryByName(ctx, commit, pageName)
|
||||
if noEntry {
|
||||
@@ -479,6 +530,14 @@ func Wiki(ctx *context.Context) {
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
// Folder listing - no entry but IsWikiFolder flag is set
|
||||
if ctx.Data["IsWikiFolder"] != nil {
|
||||
if wikiGitRepo != nil {
|
||||
defer wikiGitRepo.Close()
|
||||
}
|
||||
ctx.HTML(http.StatusOK, tplWikiView)
|
||||
return
|
||||
}
|
||||
if entry == nil {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.wiki")
|
||||
ctx.HTML(http.StatusOK, tplWikiStart)
|
||||
@@ -752,3 +811,134 @@ func DeleteWikiPagePost(ctx *context.Context) {
|
||||
|
||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/wiki/")
|
||||
}
|
||||
|
||||
// buildWikiBreadcrumbs creates breadcrumb segments from a wiki path.
|
||||
func buildWikiBreadcrumbs(pageName wiki_service.WebPath) []WikiBreadcrumb {
|
||||
parts := strings.Split(string(pageName), "/")
|
||||
crumbs := make([]WikiBreadcrumb, 0, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
subURL := strings.Join(parts[:i+1], "/")
|
||||
crumbs = append(crumbs, WikiBreadcrumb{
|
||||
Name: part,
|
||||
SubURL: subURL,
|
||||
})
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
|
||||
// buildWikiTree builds a hierarchical folder tree from the wiki git repo.
|
||||
func buildWikiTree(commit *git.Commit) []*WikiTreeNode {
|
||||
if commit == nil {
|
||||
return nil
|
||||
}
|
||||
entries, err := commit.ListEntries()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
root := make(map[string]*WikiTreeNode)
|
||||
var topLevel []*WikiTreeNode
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
node := &WikiTreeNode{
|
||||
Name: name,
|
||||
SubURL: name,
|
||||
IsDir: true,
|
||||
}
|
||||
// List children of this directory
|
||||
subTree := entry.Tree()
|
||||
if subTree != nil {
|
||||
children, _ := subTree.ListEntries()
|
||||
for _, child := range children {
|
||||
childName := child.Name()
|
||||
if child.IsDir() {
|
||||
node.Children = append(node.Children, &WikiTreeNode{
|
||||
Name: childName,
|
||||
SubURL: name + "/" + childName,
|
||||
IsDir: true,
|
||||
})
|
||||
} else if strings.HasSuffix(childName, ".md") {
|
||||
wpChild, err := wiki_service.GitPathToWebPath(childName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, childDisplay := wiki_service.WebPathToUserTitle(wpChild)
|
||||
if childDisplay == "_Sidebar" || childDisplay == "_Footer" {
|
||||
continue
|
||||
}
|
||||
node.Children = append(node.Children, &WikiTreeNode{
|
||||
Name: childDisplay,
|
||||
SubURL: name + "/" + string(wpChild),
|
||||
IsDir: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
root[name] = node
|
||||
topLevel = append(topLevel, node)
|
||||
} else if strings.HasSuffix(name, ".md") {
|
||||
wpName, err := wiki_service.GitPathToWebPath(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, displayName := wiki_service.WebPathToUserTitle(wpName)
|
||||
if displayName == "_Sidebar" || displayName == "_Footer" {
|
||||
continue
|
||||
}
|
||||
node := &WikiTreeNode{
|
||||
Name: displayName,
|
||||
SubURL: string(wpName),
|
||||
IsDir: false,
|
||||
}
|
||||
topLevel = append(topLevel, node)
|
||||
}
|
||||
}
|
||||
return topLevel
|
||||
}
|
||||
|
||||
// listWikiFolderEntries lists the pages and subfolders in a wiki directory.
|
||||
func listWikiFolderEntries(commit *git.Commit, treePath string) []PageMeta {
|
||||
if commit == nil {
|
||||
return nil
|
||||
}
|
||||
tree, err := commit.SubTree(treePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
entries, err := tree.ListEntries()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pages []PageMeta
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
pages = append(pages, PageMeta{
|
||||
Name: name + "/",
|
||||
SubURL: treePath + "/" + name,
|
||||
GitEntryName: name,
|
||||
})
|
||||
} else if strings.HasSuffix(name, ".md") {
|
||||
wpName, err := wiki_service.GitPathToWebPath(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, displayName := wiki_service.WebPathToUserTitle(wpName)
|
||||
if displayName == "_Sidebar" || displayName == "_Footer" {
|
||||
continue
|
||||
}
|
||||
pages = append(pages, PageMeta{
|
||||
Name: displayName,
|
||||
SubURL: treePath + "/" + string(wpName),
|
||||
GitEntryName: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
@@ -1061,6 +1061,30 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Get("", org.SettingsUpdateStreams)
|
||||
m.Post("", org.SettingsUpdateStreamsPost)
|
||||
})
|
||||
m.Group("/custom-fields", func() {
|
||||
m.Get("", org.SettingsCustomFields)
|
||||
m.Post("", org.SettingsCustomFieldsCreatePost)
|
||||
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)
|
||||
})
|
||||
m.Group("/issue-types", func() {
|
||||
m.Get("", org.SettingsIssueTypes)
|
||||
m.Post("", org.SettingsIssueTypesCreatePost)
|
||||
m.Post("/{id}/edit", org.SettingsIssueTypesEditPost)
|
||||
m.Post("/{id}/delete", org.SettingsIssueTypesDeletePost)
|
||||
})
|
||||
}, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true))
|
||||
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true}))
|
||||
}, reqSignIn)
|
||||
@@ -1187,6 +1211,13 @@ 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("/security", func() {
|
||||
m.Combo("").Get(repo_setting.SecuritySettings).Post(repo_setting.SecuritySettingsPost)
|
||||
m.Post("/scan", repo_setting.SecurityScanNow)
|
||||
m.Post("/alert/{id}", repo_setting.SecurityAlertUpdate)
|
||||
})
|
||||
|
||||
m.Group("/collaboration", func() {
|
||||
m.Combo("").Get(repo_setting.Collaboration).Post(repo_setting.CollaborationPost)
|
||||
@@ -1391,6 +1422,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/projects/column", reqRepoIssuesOrPullsWriter, reqRepoProjectsWriter, repo.UpdateIssueProjectColumn)
|
||||
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("/{id}/custom-type", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomType)
|
||||
m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues)
|
||||
m.Delete("/unpin/{index}", reqRepoAdmin, repo.IssueUnpin)
|
||||
m.Post("/move_pin", reqRepoAdmin, repo.IssuePinMove)
|
||||
@@ -1543,6 +1578,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/packages/{id}/archive", repo.LicensesArchivePackage)
|
||||
m.Post("/packages/{id}/unarchive", repo.LicensesUnarchivePackage)
|
||||
m.Post("/keys/generate", repo.LicensesGenerateKey)
|
||||
m.Post("/master-key/regenerate", repo.LicensesRegenerateMasterKey)
|
||||
m.Get("/keys/{id}/edit", repo.LicensesEditKey)
|
||||
m.Post("/keys/{id}/edit", repo.LicensesEditKeyPost)
|
||||
m.Post("/keys/{id}/revoke", repo.LicensesRevokeKey)
|
||||
@@ -1648,6 +1684,13 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
})
|
||||
// end "/{username}/{reponame}/wiki"
|
||||
|
||||
m.Group("/{username}/{reponame}/security", func() {
|
||||
m.Get("", repo.Security)
|
||||
m.Post("/scan", reqRepoAdmin, repo.SecurityScanNow)
|
||||
m.Post("/alert/{id}", reqRepoAdmin, repo.SecurityAlertUpdateTab)
|
||||
}, reqSignIn, context.RepoAssignment, reqRepoAdmin)
|
||||
// end "/{username}/{reponame}/security"
|
||||
|
||||
m.Group("/{username}/{reponame}/activity", func() {
|
||||
// activity has its own permission checks
|
||||
m.Get("", repo.Activity)
|
||||
|
||||
@@ -16,8 +16,11 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/httplib"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/optional"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth/source/oauth2"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
@@ -166,6 +169,18 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
|
||||
ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
|
||||
ctx.Data["Title"] = "Page Not Found"
|
||||
ctx.Data["ErrorMsg"] = "" // FIXME: the template never renders this message, need to fix in the future (and show safe messages to end users)
|
||||
ctx.Data["CurrentURL"] = ctx.Req.URL.RequestURI()
|
||||
|
||||
// Load OAuth2 providers for the login form on error pages
|
||||
if !ctx.IsSigned {
|
||||
oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
|
||||
if err != nil {
|
||||
log.Error("NotFound: GetOAuth2Providers: %v", err)
|
||||
}
|
||||
ctx.Data["OAuth2Providers"] = oauth2Providers
|
||||
ctx.Data["EnableSSPI"] = auth_model.IsSSPIEnabled(ctx)
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusNotFound, "status/404")
|
||||
}
|
||||
|
||||
@@ -187,6 +202,17 @@ func (ctx *Context) Forbidden() {
|
||||
ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
|
||||
ctx.Data["Title"] = "Access Denied"
|
||||
ctx.Data["CurrentURL"] = ctx.Req.URL.RequestURI()
|
||||
|
||||
// Load OAuth2 providers for the login form on the 403 page
|
||||
if !ctx.IsSigned {
|
||||
oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
|
||||
if err != nil {
|
||||
log.Error("Forbidden: GetOAuth2Providers: %v", err)
|
||||
}
|
||||
ctx.Data["OAuth2Providers"] = oauth2Providers
|
||||
ctx.Data["EnableSSPI"] = auth_model.IsSSPIEnabled(ctx)
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusForbidden, "status/403")
|
||||
}
|
||||
|
||||
|
||||
@@ -663,6 +663,8 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
|
||||
ctx.Data["NumLicensePackages"] = numLicensePackages
|
||||
ctx.Data["EnableLicenses"] = licensingEnabled || numLicensePackages > 0
|
||||
ctx.Data["LicensingEnabled"] = licensingEnabled
|
||||
downloadGated := repoUpdateCfg != nil && repoUpdateCfg.DownloadGating != "" && repoUpdateCfg.DownloadGating != "none"
|
||||
ctx.Data["DownloadGated"] = downloadGated
|
||||
|
||||
// Determine release page access based on feed visibility mode.
|
||||
feedVis := "public"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
issue_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/issue"
|
||||
notify_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/notify"
|
||||
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
||||
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
|
||||
)
|
||||
|
||||
// pushQueue represents a queue to handle update pull request tests
|
||||
@@ -193,6 +194,10 @@ 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)
|
||||
// Run security scanners on default branch push
|
||||
security_service.ScanOnPush(ctx, repo, newCommit)
|
||||
} else {
|
||||
if err := DelDivergenceFromCache(repo.ID, branch); err != nil {
|
||||
log.Error("DelDivergenceFromCache: %v", err)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
)
|
||||
|
||||
// ScanOnPush runs enabled scanners against a commit pushed to the default branch.
|
||||
// Called from services/repository/push.go on default branch pushes.
|
||||
func ScanOnPush(ctx context.Context, repo *repo_model.Repository, commit *git.Commit) {
|
||||
if commit == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := security_model.GetScannerConfig(ctx, repo.ID)
|
||||
if err != nil {
|
||||
log.Error("SecurityScan: GetScannerConfig for %s: %v", repo.FullName(), err)
|
||||
return
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
var scanners []Scanner
|
||||
if cfg.SecretScanner {
|
||||
scanners = append(scanners, NewSecretScanner())
|
||||
}
|
||||
// Future scanners added here:
|
||||
// if cfg.DependScanner { scanners = append(scanners, NewDependencyScanner()) }
|
||||
// if cfg.CodeScanner { scanners = append(scanners, NewCodeScanner()) }
|
||||
|
||||
if len(scanners) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
totalFindings := 0
|
||||
for _, s := range scanners {
|
||||
findings, err := s.ScanTree(commit)
|
||||
if err != nil {
|
||||
log.Error("SecurityScan: %s scanner for %s: %v", s.Type(), repo.FullName(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range findings {
|
||||
alert := &security_model.SecurityAlert{
|
||||
RepoID: repo.ID,
|
||||
Scanner: f.Scanner,
|
||||
Severity: f.Severity,
|
||||
RuleID: f.RuleID,
|
||||
Title: f.Title,
|
||||
Description: f.Description,
|
||||
FilePath: f.FilePath,
|
||||
LineNumber: f.LineNumber,
|
||||
CommitSHA: f.CommitSHA,
|
||||
Fingerprint: f.Fingerprint,
|
||||
Metadata: f.Metadata,
|
||||
}
|
||||
if err := security_model.CreateOrUpdateAlert(ctx, alert); err != nil {
|
||||
log.Error("SecurityScan: CreateOrUpdateAlert: %v", err)
|
||||
}
|
||||
totalFindings++
|
||||
}
|
||||
}
|
||||
|
||||
if totalFindings > 0 {
|
||||
log.Warn("SecurityScan: %d findings in %s", totalFindings, repo.FullName())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||
)
|
||||
|
||||
// Finding represents a single security issue found by a scanner.
|
||||
type Finding struct {
|
||||
Scanner security_model.ScannerType
|
||||
Severity security_model.AlertSeverity
|
||||
RuleID string
|
||||
Title string
|
||||
Description string
|
||||
FilePath string
|
||||
LineNumber int
|
||||
CommitSHA string
|
||||
Fingerprint string // unique identifier for dedup
|
||||
Metadata string // JSON extra data
|
||||
}
|
||||
|
||||
// Scanner is the interface all security scanner modules implement.
|
||||
type Scanner interface {
|
||||
// Type returns the scanner type identifier.
|
||||
Type() security_model.ScannerType
|
||||
|
||||
// ScanCommit scans a single commit and returns findings.
|
||||
ScanCommit(commit *git.Commit) ([]Finding, error)
|
||||
|
||||
// ScanTree scans the full repository tree and returns findings.
|
||||
ScanTree(commit *git.Commit) ([]Finding, error)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
)
|
||||
|
||||
// SecretRule defines a pattern to match against file contents.
|
||||
type SecretRule struct {
|
||||
ID string
|
||||
Title string
|
||||
Pattern *regexp.Regexp
|
||||
Severity security_model.AlertSeverity
|
||||
Description string
|
||||
}
|
||||
|
||||
// DefaultSecretRules contains the built-in secret detection patterns.
|
||||
var DefaultSecretRules = []SecretRule{
|
||||
// AWS
|
||||
{ID: "aws-access-key", Title: "AWS Access Key ID", Severity: security_model.SeverityCritical,
|
||||
Pattern: regexp.MustCompile(`AKIA[0-9A-Z]{16}`), Description: "AWS access key ID detected"},
|
||||
{ID: "aws-secret-key", Title: "AWS Secret Access Key", Severity: security_model.SeverityCritical,
|
||||
Pattern: regexp.MustCompile(`(?i)aws_secret_access_key\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}`), Description: "AWS secret access key detected"},
|
||||
|
||||
// Generic tokens/keys
|
||||
{ID: "private-key", Title: "Private Key", Severity: security_model.SeverityCritical,
|
||||
Pattern: regexp.MustCompile(`-----BEGIN (RSA|EC|OPENSSH|DSA|PGP) PRIVATE KEY-----`), Description: "Private key file detected"},
|
||||
{ID: "generic-api-key", Title: "Generic API Key", Severity: security_model.SeverityHigh,
|
||||
Pattern: regexp.MustCompile(`(?i)(api[_-]?key|apikey)\s*[=:]\s*['"]?[A-Za-z0-9_\-]{20,}`), Description: "API key assignment detected"},
|
||||
{ID: "generic-secret", Title: "Generic Secret", Severity: security_model.SeverityHigh,
|
||||
Pattern: regexp.MustCompile(`(?i)(secret|password|passwd|pwd)\s*[=:]\s*['"][^'"]{8,}['"]`), Description: "Hardcoded secret or password detected"},
|
||||
{ID: "generic-token", Title: "Generic Token", Severity: security_model.SeverityHigh,
|
||||
Pattern: regexp.MustCompile(`(?i)(token|auth_token|access_token)\s*[=:]\s*['"]?[A-Za-z0-9_\-.]{20,}`), Description: "Token assignment detected"},
|
||||
|
||||
// GitHub/Gitea
|
||||
{ID: "github-pat", Title: "GitHub Personal Access Token", Severity: security_model.SeverityCritical,
|
||||
Pattern: regexp.MustCompile(`ghp_[A-Za-z0-9]{36}`), Description: "GitHub personal access token detected"},
|
||||
{ID: "github-oauth", Title: "GitHub OAuth Token", Severity: security_model.SeverityCritical,
|
||||
Pattern: regexp.MustCompile(`gho_[A-Za-z0-9]{36}`), Description: "GitHub OAuth token detected"},
|
||||
|
||||
// Stripe
|
||||
{ID: "stripe-secret", Title: "Stripe Secret Key", Severity: security_model.SeverityCritical,
|
||||
Pattern: regexp.MustCompile(`sk_live_[A-Za-z0-9]{24,}`), Description: "Stripe live secret key detected"},
|
||||
{ID: "stripe-publishable", Title: "Stripe Publishable Key", Severity: security_model.SeverityLow,
|
||||
Pattern: regexp.MustCompile(`pk_live_[A-Za-z0-9]{24,}`), Description: "Stripe live publishable key detected (usually safe but flagged)"},
|
||||
|
||||
// JWT
|
||||
{ID: "jwt-token", Title: "JWT Token", Severity: security_model.SeverityMedium,
|
||||
Pattern: regexp.MustCompile(`eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), Description: "JWT token detected"},
|
||||
|
||||
// Connection strings
|
||||
{ID: "connection-string", Title: "Connection String with Password", Severity: security_model.SeverityCritical,
|
||||
Pattern: regexp.MustCompile(`(?i)(mysql|postgres|postgresql|mongodb|redis|amqp|smtp)://[^:]+:[^@]+@[^\s]+`), Description: "Database/service connection string with embedded password"},
|
||||
|
||||
// Google
|
||||
{ID: "google-api-key", Title: "Google API Key", Severity: security_model.SeverityHigh,
|
||||
Pattern: regexp.MustCompile(`AIza[0-9A-Za-z_-]{35}`), Description: "Google API key detected"},
|
||||
|
||||
// Slack
|
||||
{ID: "slack-webhook", Title: "Slack Webhook URL", Severity: security_model.SeverityMedium,
|
||||
Pattern: regexp.MustCompile(`https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+`), Description: "Slack webhook URL detected"},
|
||||
|
||||
// SendGrid
|
||||
{ID: "sendgrid-api-key", Title: "SendGrid API Key", Severity: security_model.SeverityHigh,
|
||||
Pattern: regexp.MustCompile(`SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}`), Description: "SendGrid API key detected"},
|
||||
|
||||
// PayPal
|
||||
{ID: "paypal-client-secret", Title: "PayPal Client Secret", Severity: security_model.SeverityCritical,
|
||||
Pattern: regexp.MustCompile(`(?i)paypal.*secret\s*[=:]\s*['"]?[A-Za-z0-9_-]{20,}`), Description: "PayPal client secret detected"},
|
||||
}
|
||||
|
||||
// Files to skip during scanning.
|
||||
var skipExtensions = map[string]bool{
|
||||
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true,
|
||||
".svg": true, ".woff": true, ".woff2": true, ".ttf": true, ".eot": true,
|
||||
".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".7z": true,
|
||||
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true,
|
||||
".exe": true, ".dll": true, ".so": true, ".dylib": true, ".o": true,
|
||||
".min.js": true, ".min.css": true,
|
||||
}
|
||||
|
||||
var skipPaths = []string{
|
||||
"vendor/", "node_modules/", ".git/", "dist/", "build/",
|
||||
"go.sum", "package-lock.json", "composer.lock", "yarn.lock",
|
||||
}
|
||||
|
||||
// SecretScanner implements the Scanner interface for secret detection.
|
||||
type SecretScanner struct {
|
||||
Rules []SecretRule
|
||||
}
|
||||
|
||||
// NewSecretScanner creates a scanner with default rules.
|
||||
func NewSecretScanner() *SecretScanner {
|
||||
return &SecretScanner{Rules: DefaultSecretRules}
|
||||
}
|
||||
|
||||
func (s *SecretScanner) Type() security_model.ScannerType {
|
||||
return security_model.ScannerSecret
|
||||
}
|
||||
|
||||
func (s *SecretScanner) ScanCommit(commit *git.Commit) ([]Finding, error) {
|
||||
// For push-time scanning, we scan the diff of the commit
|
||||
return s.ScanTree(commit)
|
||||
}
|
||||
|
||||
func (s *SecretScanner) ScanTree(commit *git.Commit) ([]Finding, error) {
|
||||
if commit == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entries, err := commit.ListEntriesRecursiveFast()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListEntriesRecursiveFast: %w", err)
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
for _, entry := range entries {
|
||||
if !entry.IsRegular() {
|
||||
continue
|
||||
}
|
||||
|
||||
path := entry.Name()
|
||||
if shouldSkipFile(path) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip large files (> 1MB)
|
||||
if entry.Blob().Size() > 1024*1024 {
|
||||
continue
|
||||
}
|
||||
|
||||
reader, err := entry.Blob().DataAsync()
|
||||
if err != nil {
|
||||
log.Trace("SecretScanner: skip %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
fileFindings := s.scanReader(reader, path, commit.ID.String())
|
||||
reader.Close()
|
||||
findings = append(findings, fileFindings...)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (s *SecretScanner) scanReader(r io.Reader, filePath, commitSHA string) []Finding {
|
||||
var findings []Finding
|
||||
scanner := bufio.NewScanner(r)
|
||||
lineNum := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
|
||||
for _, rule := range s.Rules {
|
||||
if rule.Pattern.MatchString(line) {
|
||||
fingerprint := fmt.Sprintf("%x", sha256.Sum256([]byte(rule.ID+":"+filePath+":"+line)))
|
||||
findings = append(findings, Finding{
|
||||
Scanner: security_model.ScannerSecret,
|
||||
Severity: rule.Severity,
|
||||
RuleID: rule.ID,
|
||||
Title: rule.Title,
|
||||
Description: rule.Description,
|
||||
FilePath: filePath,
|
||||
LineNumber: lineNum,
|
||||
CommitSHA: commitSHA,
|
||||
Fingerprint: fingerprint[:32],
|
||||
})
|
||||
break // one finding per line per file
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func shouldSkipFile(path string) bool {
|
||||
lower := strings.ToLower(path)
|
||||
|
||||
for _, skip := range skipPaths {
|
||||
if strings.HasPrefix(lower, skip) || strings.Contains(lower, "/"+skip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for ext := range skipExtensions {
|
||||
if strings.HasSuffix(lower, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -68,13 +68,15 @@ func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, lice
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
|
||||
// Composer package name: vendor/package
|
||||
// Composer package name: vendor/package (override with resolved extension name if set)
|
||||
packageName := fmt.Sprintf("%s/%s", strings.ToLower(repo.Owner.Name), strings.ToLower(repo.Name))
|
||||
if cfg != nil && cfg.ExtensionName != "" {
|
||||
packageName = cfg.ExtensionName
|
||||
if meta.Element != strings.ToLower(repo.Name) {
|
||||
packageName = meta.Element
|
||||
}
|
||||
|
||||
description := meta.Description
|
||||
maintainer := repo.Owner.Name
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
if cfg != nil && cfg.Maintainer != "" {
|
||||
@@ -84,14 +86,9 @@ func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, lice
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
|
||||
description := ""
|
||||
if cfg != nil && cfg.Description != "" {
|
||||
description = cfg.Description
|
||||
}
|
||||
|
||||
phpMin := ""
|
||||
if cfg != nil && cfg.PHPMinimum != "" {
|
||||
phpMin = ">=" + cfg.PHPMinimum
|
||||
if meta.PHPMinimum != "" {
|
||||
phpMin = ">=" + meta.PHPMinimum
|
||||
}
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
@@ -66,16 +66,9 @@ func GenerateDrupalXML(ctx context.Context, repo *repo_model.Repository, allowed
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
shortName := strings.ToLower(repo.Name)
|
||||
title := repo.Name
|
||||
if cfg != nil {
|
||||
if cfg.ExtensionName != "" {
|
||||
shortName = cfg.ExtensionName
|
||||
}
|
||||
if cfg.DisplayName != "" {
|
||||
title = cfg.DisplayName
|
||||
}
|
||||
}
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
shortName := meta.Element
|
||||
title := meta.DisplayName
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
|
||||
+161
-43
@@ -13,8 +13,10 @@ import (
|
||||
"time"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
)
|
||||
@@ -37,7 +39,7 @@ type xmlUpdate struct {
|
||||
TargetPlatform xmlTargetPlat `xml:"targetplatform"`
|
||||
SHA256 string `xml:"sha256,omitempty"`
|
||||
SHA512 string `xml:"sha512,omitempty"`
|
||||
Client string `xml:"client"`
|
||||
Client string `xml:"client,omitempty"`
|
||||
PHPMinimum string `xml:"php_minimum,omitempty"`
|
||||
Description string `xml:"description,omitempty"`
|
||||
CreationDate string `xml:"creationDate,omitempty"`
|
||||
@@ -120,7 +122,10 @@ func isStreamName(s string, streams []licenses.StreamDef) bool {
|
||||
}
|
||||
|
||||
// joomlaTagName maps internal stream names to Joomla-standard tag values.
|
||||
// Joomla recognizes: dev, alpha, beta, rc, stable.
|
||||
// Joomla's Update.php maps tags via STABILITY_ + strtoupper(tag) constants.
|
||||
// Valid values: dev (0), alpha (1), beta (2), rc (3), stable (4).
|
||||
// Using full names like "development" or "release-candidate" would silently
|
||||
// fall back to STABILITY_STABLE, breaking pre-release channel filtering.
|
||||
func joomlaTagName(channel string) string {
|
||||
switch channel {
|
||||
case ChannelDevelopment:
|
||||
@@ -157,9 +162,121 @@ func NormalizeChannel(ch string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// extensionMetadata holds resolved metadata for feed generation.
|
||||
// Fields are resolved with priority: custom field → config table → default.
|
||||
type extensionMetadata struct {
|
||||
Element string
|
||||
DisplayName string
|
||||
ExtType string
|
||||
TargetVersion string
|
||||
PHPMinimum string
|
||||
Description string
|
||||
SupportURL string
|
||||
DownloadGating string
|
||||
KeyPrefix string
|
||||
}
|
||||
|
||||
// resolveExtensionMetadata loads extension metadata with cascading fallback:
|
||||
// org-level repo-scoped custom fields → update_stream_config → repo-derived defaults.
|
||||
func resolveExtensionMetadata(ctx context.Context, repo *repo_model.Repository, cfg *licenses.UpdateStreamConfig) extensionMetadata {
|
||||
m := extensionMetadata{
|
||||
Element: strings.ToLower(repo.Name),
|
||||
DisplayName: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
|
||||
ExtType: "component",
|
||||
TargetVersion: "(5|6)\\..*",
|
||||
}
|
||||
|
||||
// Apply config table values.
|
||||
if cfg != nil {
|
||||
if cfg.ExtensionName != "" {
|
||||
m.Element = cfg.ExtensionName
|
||||
}
|
||||
if cfg.DisplayName != "" {
|
||||
m.DisplayName = cfg.DisplayName
|
||||
}
|
||||
if cfg.ExtensionType != "" {
|
||||
m.ExtType = cfg.ExtensionType
|
||||
}
|
||||
if cfg.TargetVersion != "" {
|
||||
m.TargetVersion = cfg.TargetVersion
|
||||
}
|
||||
if cfg.PHPMinimum != "" {
|
||||
m.PHPMinimum = cfg.PHPMinimum
|
||||
}
|
||||
if cfg.Description != "" {
|
||||
m.Description = cfg.Description
|
||||
}
|
||||
if cfg.SupportURL != "" {
|
||||
m.SupportURL = cfg.SupportURL
|
||||
}
|
||||
if cfg.DownloadGating != "" {
|
||||
m.DownloadGating = cfg.DownloadGating
|
||||
}
|
||||
if cfg.KeyPrefix != "" {
|
||||
m.KeyPrefix = cfg.KeyPrefix
|
||||
}
|
||||
}
|
||||
|
||||
// Override with custom field values (highest priority).
|
||||
fields, err := issues_model.GetCustomFieldsByOwner(ctx, repo.OwnerID, issues_model.CustomFieldScopeRepo)
|
||||
if err != nil {
|
||||
log.Error("resolveExtensionMetadata: GetCustomFieldsByOwner for repo %d: %v", repo.ID, err)
|
||||
return m
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return m
|
||||
}
|
||||
values, err := issues_model.GetCustomFieldValuesMap(ctx, repo.ID)
|
||||
if err != nil {
|
||||
log.Error("resolveExtensionMetadata: GetCustomFieldValuesMap for repo %d: %v", repo.ID, err)
|
||||
return m
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return m
|
||||
}
|
||||
|
||||
// Build name → value map from field definitions + values.
|
||||
named := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
if v, ok := values[f.ID]; ok && v != "" {
|
||||
named[f.Name] = v
|
||||
}
|
||||
}
|
||||
|
||||
if v := named["Extension Name"]; v != "" {
|
||||
m.Element = v
|
||||
}
|
||||
if v := named["Display Name"]; v != "" {
|
||||
m.DisplayName = v
|
||||
}
|
||||
if v := named["Extension Type"]; v != "" {
|
||||
m.ExtType = v
|
||||
}
|
||||
if v := named["Target Version"]; v != "" {
|
||||
m.TargetVersion = v
|
||||
}
|
||||
if v := named["PHP Minimum"]; v != "" {
|
||||
m.PHPMinimum = v
|
||||
}
|
||||
if v := named["Support URL"]; v != "" {
|
||||
m.SupportURL = v
|
||||
}
|
||||
if v := named["Description"]; v != "" {
|
||||
m.Description = v
|
||||
}
|
||||
if v := named["Download Gating"]; v != "" {
|
||||
m.DownloadGating = v
|
||||
}
|
||||
if v := named["Key Prefix"]; v != "" {
|
||||
m.KeyPrefix = v
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases.
|
||||
// It returns the raw XML bytes. Extension metadata is read from the update stream config;
|
||||
// falls back to repo name/owner when not configured.
|
||||
// It returns the raw XML bytes. Extension metadata is resolved from custom fields first,
|
||||
// then the update stream config, then repo-derived defaults.
|
||||
// allowedChannels optionally restricts output to specific channels (nil = all).
|
||||
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey, stripDownloads bool, allowedChannels ...string) ([]byte, error) {
|
||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||
@@ -182,42 +299,26 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
|
||||
}
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
// Load extension metadata from config (falls back to repo-derived values).
|
||||
// Load extension metadata with cascading fallback:
|
||||
// custom fields → config table → repo-derived defaults.
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
|
||||
element := strings.ToLower(repo.Name)
|
||||
if cfg != nil && cfg.ExtensionName != "" {
|
||||
element = cfg.ExtensionName
|
||||
element := meta.Element
|
||||
displayName := meta.DisplayName
|
||||
extType := meta.ExtType
|
||||
targetVersion := meta.TargetVersion
|
||||
phpMinimum := meta.PHPMinimum
|
||||
feedDescription := meta.Description
|
||||
|
||||
// Maintainer and URL always come from the org profile.
|
||||
maintainer := repo.Owner.FullName
|
||||
if maintainer == "" {
|
||||
maintainer = repo.Owner.Name
|
||||
}
|
||||
displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name)
|
||||
if cfg != nil && cfg.DisplayName != "" {
|
||||
displayName = cfg.DisplayName
|
||||
}
|
||||
extType := "component"
|
||||
if cfg != nil && cfg.ExtensionType != "" {
|
||||
extType = cfg.ExtensionType
|
||||
}
|
||||
maintainer := repo.Owner.Name
|
||||
if cfg != nil && cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
if cfg != nil && cfg.SupportURL != "" {
|
||||
maintainerURL = cfg.SupportURL
|
||||
} else if cfg != nil && cfg.MaintainerURL != "" {
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
targetVersion := "(5|6)\\..*"
|
||||
if cfg != nil && cfg.TargetVersion != "" {
|
||||
targetVersion = cfg.TargetVersion
|
||||
}
|
||||
phpMinimum := ""
|
||||
if cfg != nil && cfg.PHPMinimum != "" {
|
||||
phpMinimum = cfg.PHPMinimum
|
||||
}
|
||||
feedDescription := ""
|
||||
if cfg != nil && cfg.Description != "" {
|
||||
feedDescription = cfg.Description
|
||||
maintainerURL := repo.Owner.Website
|
||||
if maintainerURL == "" {
|
||||
maintainerURL = fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
}
|
||||
|
||||
// Resolve effective streams (repo override → org default → Joomla default).
|
||||
@@ -286,9 +387,16 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
|
||||
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
|
||||
}
|
||||
|
||||
version := extractVersion(rel.TagName)
|
||||
// If the tag is a stream name (not a version), try the release title instead.
|
||||
if version == "" || isStreamName(rel.TagName, streams) {
|
||||
// Extract version: prefer asset filename (matches actual download),
|
||||
// then tag name, then release title. Only fall through when empty.
|
||||
version := ""
|
||||
if zipName != "" {
|
||||
version = extractVersion(zipName)
|
||||
}
|
||||
if version == "" {
|
||||
version = extractVersion(rel.TagName)
|
||||
}
|
||||
if version == "" {
|
||||
version = extractVersion(rel.Title)
|
||||
}
|
||||
// Last resort: use the tag name as-is.
|
||||
@@ -308,9 +416,19 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
|
||||
desc = fmt.Sprintf("%s %s build.", displayName, ch)
|
||||
}
|
||||
|
||||
// Info URL: use support_url (product page), fall back to releases page.
|
||||
infoURL := fmt.Sprintf("%s/releases", repoLink)
|
||||
if cfg != nil && cfg.InfoURL != "" {
|
||||
infoURL = cfg.InfoURL
|
||||
if meta.SupportURL != "" {
|
||||
infoURL = meta.SupportURL
|
||||
}
|
||||
|
||||
// Joomla <client> element: packages use client_id=0 in #__extensions,
|
||||
// so we must output <client>0</client> for Joomla to match the update
|
||||
// to the installed extension. Other types default to "site" (client_id=0)
|
||||
// or "administrator" (client_id=1).
|
||||
client := "site"
|
||||
if extType == "package" {
|
||||
client = "0"
|
||||
}
|
||||
|
||||
u := xmlUpdate{
|
||||
@@ -318,7 +436,7 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
|
||||
Description: desc,
|
||||
Element: element,
|
||||
Type: extType,
|
||||
Client: "site",
|
||||
Client: client,
|
||||
Version: version,
|
||||
CreationDate: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
|
||||
InfoURL: xmlInfoURL{
|
||||
|
||||
@@ -55,23 +55,13 @@ func GeneratePrestaShopXML(ctx context.Context, repo *repo_model.Repository, all
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
moduleName := strings.ToLower(repo.Name)
|
||||
displayName := repo.Name
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
moduleName := meta.Element
|
||||
displayName := meta.DisplayName
|
||||
description := meta.Description
|
||||
maintainer := repo.Owner.Name
|
||||
description := ""
|
||||
if cfg != nil {
|
||||
if cfg.ExtensionName != "" {
|
||||
moduleName = cfg.ExtensionName
|
||||
}
|
||||
if cfg.DisplayName != "" {
|
||||
displayName = cfg.DisplayName
|
||||
}
|
||||
if cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
if cfg.Description != "" {
|
||||
description = cfg.Description
|
||||
}
|
||||
if cfg != nil && cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
@@ -50,23 +50,18 @@ func GenerateWHMCSJSON(ctx context.Context, repo *repo_model.Repository, license
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
displayName := repo.Name
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
displayName := meta.DisplayName
|
||||
description := meta.Description
|
||||
maintainer := repo.Owner.Name
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
description := ""
|
||||
if cfg != nil {
|
||||
if cfg.DisplayName != "" {
|
||||
displayName = cfg.DisplayName
|
||||
}
|
||||
if cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
if cfg.MaintainerURL != "" {
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
if cfg.Description != "" {
|
||||
description = cfg.Description
|
||||
}
|
||||
}
|
||||
|
||||
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
|
||||
|
||||
@@ -57,36 +57,30 @@ func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, lic
|
||||
baseURL := strings.TrimSuffix(setting.AppURL, "/")
|
||||
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
|
||||
|
||||
// Load extension metadata.
|
||||
// Load extension metadata with cascading fallback:
|
||||
// custom fields → config table → repo-derived defaults.
|
||||
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
|
||||
meta := resolveExtensionMetadata(ctx, repo, cfg)
|
||||
|
||||
slug := strings.ToLower(repo.Name)
|
||||
displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name)
|
||||
slug := meta.Element
|
||||
displayName := meta.DisplayName
|
||||
requiresPHP := meta.PHPMinimum
|
||||
homepage := repoLink
|
||||
if meta.SupportURL != "" {
|
||||
homepage = meta.SupportURL
|
||||
}
|
||||
maintainer := repo.Owner.Name
|
||||
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
|
||||
homepage := repoLink
|
||||
requiresPHP := ""
|
||||
if cfg != nil {
|
||||
if cfg.ExtensionName != "" {
|
||||
slug = cfg.ExtensionName
|
||||
}
|
||||
if cfg.DisplayName != "" {
|
||||
displayName = cfg.DisplayName
|
||||
}
|
||||
if cfg.Maintainer != "" {
|
||||
maintainer = cfg.Maintainer
|
||||
}
|
||||
if cfg.MaintainerURL != "" {
|
||||
maintainerURL = cfg.MaintainerURL
|
||||
}
|
||||
if cfg.SupportURL != "" {
|
||||
homepage = cfg.SupportURL
|
||||
} else if cfg.InfoURL != "" {
|
||||
if homepage == repoLink && cfg.InfoURL != "" {
|
||||
homepage = cfg.InfoURL
|
||||
}
|
||||
if cfg.PHPMinimum != "" {
|
||||
requiresPHP = cfg.PHPMinimum
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve streams and find the latest stable release.
|
||||
|
||||
+27
-11
@@ -144,21 +144,23 @@ func WebPathToURLPath(s WebPath) string {
|
||||
|
||||
func WebPathFromRequest(s string) WebPath {
|
||||
s = util.PathJoinRelX(s)
|
||||
// The old wiki code's behavior is always using %2F, instead of subdirectory.
|
||||
s = strings.ReplaceAll(s, "/", "%2F")
|
||||
// MokoGitea: support real subdirectories for hierarchical wiki navigation.
|
||||
// Slashes are preserved as path separators, not escaped to %2F.
|
||||
return WebPath(s)
|
||||
}
|
||||
|
||||
var multiHyphenRe = regexp.MustCompile(`-{2,}`)
|
||||
var nonSlugRe = regexp.MustCompile(`[^a-zA-Z0-9+.\-]`)
|
||||
var nonSlugReWithSlash = regexp.MustCompile(`[^a-zA-Z0-9+.\-/]`)
|
||||
|
||||
// sanitizeWikiTitle converts a user-provided title into a clean, URL-friendly slug.
|
||||
// Spaces and special characters become hyphens, consecutive hyphens collapse to one.
|
||||
// Preserves: letters, digits, hyphens, plus signs (+), and dots (.)
|
||||
// Preserves: letters, digits, hyphens, plus signs (+), dots (.), and slashes (/).
|
||||
func sanitizeWikiTitle(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
title = strings.ReplaceAll(title, " ", "-")
|
||||
title = nonSlugRe.ReplaceAllString(title, "-")
|
||||
// Preserve slashes as directory separators
|
||||
title = nonSlugReWithSlash.ReplaceAllString(title, "-")
|
||||
title = multiHyphenRe.ReplaceAllString(title, "-")
|
||||
title = strings.NewReplacer("-+-", "-", "+-", "-", "-+", "-").Replace(title) // clean stray plus signs
|
||||
title = strings.Trim(title, "-+.")
|
||||
@@ -166,14 +168,28 @@ func sanitizeWikiTitle(title string) string {
|
||||
}
|
||||
|
||||
func UserTitleToWebPath(base, title string) WebPath {
|
||||
// TODO: no support for subdirectory, because the old wiki code's behavior is always using %2F, instead of subdirectory.
|
||||
// So we do not add the support for writing slashes in title at the moment.
|
||||
title = sanitizeWikiTitle(title)
|
||||
title = util.PathJoinRelX(base, escapeSegToWeb(title, false))
|
||||
if title == "" || title == "." {
|
||||
title = "unnamed"
|
||||
// MokoGitea: support subdirectories - slashes in title create folder structure.
|
||||
// Split on /, sanitize each segment, rejoin.
|
||||
parts := strings.Split(title, "/")
|
||||
sanitized := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
p = sanitizeWikiTitle(p)
|
||||
if p != "" {
|
||||
sanitized = append(sanitized, escapeSegToWeb(p, false))
|
||||
}
|
||||
}
|
||||
return WebPath(title)
|
||||
result := strings.Join(sanitized, "/")
|
||||
if base != "" {
|
||||
result = util.PathJoinRelX(base, result)
|
||||
}
|
||||
if result == "" || result == "." {
|
||||
result = "unnamed"
|
||||
}
|
||||
return WebPath(result)
|
||||
}
|
||||
|
||||
// ToWikiPageMetaData converts meta information to a WikiPageMetaData
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings custom-fields")}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "org.settings.custom_fields"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p class="text grey">{{ctx.Locale.Tr "org.settings.custom_fields_desc"}}</p>
|
||||
|
||||
{{if .CustomFields}}
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_name"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_scope"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_type"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.settings.custom_field_options"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .CustomFields}}
|
||||
<tr>
|
||||
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
|
||||
<td>{{if eq .Scope "issue"}}{{svg "octicon-issue-opened" 14}} Issue{{else}}{{svg "octicon-repo" 14}} Repo{{end}}</td>
|
||||
<td><code>{{.FieldType}}</code></td>
|
||||
<td>{{if .Options}}<code class="tw-text-xs">{{.Options}}</code>{{else}}<span class="text grey">-</span>{{end}}</td>
|
||||
<td class="tw-text-right">
|
||||
<form method="post" action="{{$.OrgLink}}/settings/custom-fields/{{.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.custom_fields_empty"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h5>{{ctx.Locale.Tr "org.settings.custom_field_add"}}</h5>
|
||||
<form class="ui form" method="post" action="{{.OrgLink}}/settings/custom-fields">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="three fields">
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_name"}}</label>
|
||||
<input name="name" required placeholder="e.g. Status, Platform, Priority">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_scope"}}</label>
|
||||
<select name="scope" class="ui dropdown">
|
||||
<option value="issue">{{svg "octicon-issue-opened" 14}} Issue (sidebar)</option>
|
||||
<option value="repo">{{svg "octicon-repo" 14}} Repo (metadata)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_type"}}</label>
|
||||
<select name="field_type" class="ui dropdown">
|
||||
<option value="dropdown">Dropdown</option>
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="url">URL</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_options"}}</label>
|
||||
<input name="options" placeholder='["Option 1","Option 2","Option 3"]'>
|
||||
<p class="help">{{ctx.Locale.Tr "org.settings.custom_field_options_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.settings.custom_field_description"}}</label>
|
||||
<input name="description" placeholder="Help text shown to users">
|
||||
</div>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "org.settings.custom_field_add"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{template "org/settings/layout_footer" .}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user