diff --git a/.mokogitea/CLAUDE.md b/.mokogitea/CLAUDE.md new file mode 100644 index 0000000000..b43f3124aa --- /dev/null +++ b/.mokogitea/CLAUDE.md @@ -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 # Single JS test +GITEA_TEST_E2E_FLAGS='' 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) diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml new file mode 100644 index 0000000000..1a9eeef0ec --- /dev/null +++ b/.mokogitea/workflows/pre-release.yml @@ -0,0 +1,241 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /templates/workflows/universal/pre-release.yml.template +# VERSION: 05.01.00 +# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch + +name: "Universal: Pre-Release" + +on: + pull_request: + types: [closed] + branches: + - dev + pull_request_target: + types: [synchronize, opened, reopened] + branches: + - main + workflow_dispatch: + inputs: + stability: + description: 'Pre-release channel' + required: true + type: choice + options: + - development + - alpha + - beta + - release-candidate + +permissions: + contents: write + +env: + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +jobs: + build: + name: "Build Pre-Release (${{ inputs.stability || 'development' }})" + runs-on: release + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') || + (github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.MOKOGITEA_TOKEN }} + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }} + + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + run: | + # Use pre-installed /opt/moko-platform if available (updated by cron every 6h) + if [ -f “/opt/moko-platform/cli/version_bump.php” ] && [ -f “/opt/moko-platform/vendor/autoload.php” ]; then + echo “Using pre-installed /opt/moko-platform” + echo “MOKO_CLI=/opt/moko-platform/cli” >> “$GITHUB_ENV” + else + echo “Falling back to fresh clone” + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + rm -rf /tmp/moko-platform-api + git clone --depth 1 --branch main --quiet \ + “https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git” \ + /tmp/moko-platform-api + cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet + echo “MOKO_CLI=/tmp/moko-platform-api/cli” >> “$GITHUB_ENV” + fi + + - name: Detect platform + id: platform + run: | + php ${MOKO_CLI}/manifest_read.php --path . --github-output + + - name: Resolve metadata and bump version + id: meta + run: | + # Auto-detect stability: RC for PRs targeting main, else use input or default to development + if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then + STABILITY="release-candidate" + else + STABILITY="${{ inputs.stability || 'development' }}" + fi + + case "$STABILITY" in + development) SUFFIX="-dev"; TAG="development" ;; + alpha) SUFFIX="-alpha"; TAG="alpha" ;; + beta) SUFFIX="-beta"; TAG="beta" ;; + release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;; + esac + + # Bump version via CLI: patch for dev/alpha/beta, minor for RC + case "$STABILITY" in + release-candidate) BUMP="minor" ;; + *) BUMP="patch" ;; + esac + + php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true + + # Set stability suffix and verify consistency + VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01") + VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') + + php ${MOKO_CLI}/version_set_platform.php \ + --path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true + php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true + + # Append suffix for output + if [ -n "$SUFFIX" ]; then + VERSION="${VERSION}${SUFFIX}" + fi + + # Commit version bump + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + git add -A + git diff --cached --quiet || { + git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]" + git push origin HEAD 2>&1 + } + + # Auto-detect element via manifest_element.php + php ${MOKO_CLI}/manifest_element.php \ + --path . --version "$VERSION" --stability "$STABILITY" \ + --repo "${GITEA_REPO}" --github-output + + # Read back element outputs + EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) + ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) + [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') + [ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip" + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" + echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" + echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" + + echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ===" + + - name: Create release + id: release + run: | + TAG="${{ steps.meta.outputs.tag }}" + VERSION="${{ steps.meta.outputs.version }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/release_create.php \ + --path . --version "$VERSION" --tag "$TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --repo "${GITEA_REPO}" --branch dev --prerelease + + - name: Update release notes from CHANGELOG.md + run: | + TAG="${{ steps.meta.outputs.tag }}" + VERSION="${{ steps.meta.outputs.version }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + # Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading) + if [ -f "CHANGELOG.md" ]; then + NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) + [ -z "$NOTES" ] && NOTES="Release ${VERSION}" + else + NOTES="Release ${VERSION}" + fi + + # Update release body via API + RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ + "${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -n "$RELEASE_ID" ]; then + python3 -c " + import json, urllib.request + body = open('/dev/stdin').read() + payload = json.dumps({'body': body}).encode() + req = urllib.request.Request( + '${API_BASE}/releases/${RELEASE_ID}', + data=payload, method='PATCH', + headers={ + 'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}', + 'Content-Type': 'application/json' + }) + urllib.request.urlopen(req) + " <<< "$NOTES" + echo "Release notes updated from CHANGELOG.md" + fi + + - name: Build package and upload + id: package + run: | + VERSION="${{ steps.meta.outputs.version }}" + TAG="${{ steps.meta.outputs.tag }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/release_package.php \ + --path . --version "$VERSION" --tag "$TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --repo "${GITEA_REPO}" --output /tmp || true + + # updates.xml is generated dynamically by MokoGitea license server + # No need to build, commit, or sync updates.xml from workflows + + - name: "Delete lesser pre-release channels (cascade)" + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + php ${MOKO_CLI}/release_cascade.php \ + --stability "${{ steps.meta.outputs.stability }}" \ + --token "${TOKEN}" \ + --api-base "${API_BASE}" + + - name: Summary + if: always() + run: | + VERSION="${{ steps.meta.outputs.version }}" + STABILITY="${{ steps.meta.outputs.stability }}" + ZIP_NAME="${{ steps.meta.outputs.zip_name }}" + SHA256="${{ steps.package.outputs.sha256_zip }}" + echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY + echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY + echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 16770e49fc..0000000000 --- a/AGENTS.md +++ /dev/null @@ -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 ` -- Run single playwright e2e test files with `GITEA_TEST_E2E_FLAGS='' 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3352f10677..cb10171a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog All notable changes to MokoGitea are documented here. Versions follow the format -`v{upstream}-moko.{major}.{minor}` (e.g. `v1.26.1-moko.06.02`). +`v{upstream}-moko.{major}.{minor}` (e.g. `v1.26.1-moko.06.03`). -## [v1.26.1-moko.06] - 2026-06-04 +## [v1.26.1-moko.06.04] - 2026-06-06 * FEATURES * feat(licenses): full commercial license management system diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c2d3..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/models/issues/issue.go b/models/issues/issue.go index 817f899dab..1992008b2f 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -76,6 +76,8 @@ 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:"-"` IsRead bool `xorm:"-"` IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not. PullRequest *PullRequest `xorm:"-"` diff --git a/models/issues/issue_status.go b/models/issues/issue_status.go new file mode 100644 index 0000000000..4f35cbd3f7 --- /dev/null +++ b/models/issues/issue_status.go @@ -0,0 +1,104 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package issues + +import ( + "context" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +func init() { + db.RegisterModel(new(IssueStatusDef)) +} + +// IssueStatusDef defines a custom issue status at the org level. +type IssueStatusDef struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'org_id'"` + Name string `xorm:"NOT NULL"` + Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48" + Description string `xorm:"TEXT"` + ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"` + SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"` + IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"` + UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"` +} + +func (IssueStatusDef) TableName() string { + return "issue_status_def" +} + +// ────────────────────────────────────────────────────────────────────── +// Queries +// ────────────────────────────────────────────────────────────────────── + +// GetIssueStatusDefsByOrg returns active status definitions for an org. +func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDef, error) { + defs := make([]*IssueStatusDef, 0, 10) + return defs, db.GetEngine(ctx). + Where("org_id = ? AND is_active = ?", orgID, true). + OrderBy("sort_order ASC, id ASC"). + Find(&defs) +} + +// GetAllIssueStatusDefsByOrg returns all status definitions (including inactive). +func GetAllIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDef, error) { + defs := make([]*IssueStatusDef, 0, 10) + return defs, db.GetEngine(ctx). + Where("org_id = ?", orgID). + OrderBy("sort_order ASC, id ASC"). + Find(&defs) +} + +// GetIssueStatusDefByID returns a single status definition. +func GetIssueStatusDefByID(ctx context.Context, id int64) (*IssueStatusDef, error) { + def := new(IssueStatusDef) + has, err := db.GetEngine(ctx).ID(id).Get(def) + if err != nil { + return nil, err + } + if !has { + return nil, db.ErrNotExist{Resource: "IssueStatusDef", ID: id} + } + return def, nil +} + +// ────────────────────────────────────────────────────────────────────── +// CRUD +// ────────────────────────────────────────────────────────────────────── + +// CreateIssueStatusDef creates a new status definition. +func CreateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error { + _, err := db.GetEngine(ctx).Insert(def) + return err +} + +// UpdateIssueStatusDef updates a status definition. +func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error { + _, err := db.GetEngine(ctx).ID(def.ID).AllCols().Update(def) + return err +} + +// DeleteIssueStatusDef deletes a status definition and clears references on issues. +func DeleteIssueStatusDef(ctx context.Context, id int64) error { + // Clear status_id on all issues that reference this definition + if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil { + return err + } + _, err := db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef)) + return err +} + +// ────────────────────────────────────────────────────────────────────── +// Issue status helpers +// ────────────────────────────────────────────────────────────────────── + +// SetIssueStatusID updates the status_id on an issue. +func SetIssueStatusID(ctx context.Context, issueID, statusID int64) error { + _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = ? WHERE id = ?", statusID, issueID) + return err +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 0c0a515881..6af0731f31 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -423,6 +423,8 @@ func prepareMigrationTasks() []*migration { newMigration(343, "Add custom field tables for issue custom fields", v1_27.AddCustomFieldTables), newMigration(344, "Add domain_restriction to license_package table", v1_27.AddDomainRestrictionToLicensePackage), newMigration(345, "Migrate custom fields to org-level with scope", v1_27.MigrateCustomFieldsToOrgLevel), + newMigration(346, "Add issue status definitions table", v1_27.AddIssueStatusDefTable), + newMigration(347, "Add repo manifest table", v1_27.AddRepoManifestTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v346.go b/models/migrations/v1_27/v346.go new file mode 100644 index 0000000000..e6583b0aff --- /dev/null +++ b/models/migrations/v1_27/v346.go @@ -0,0 +1,34 @@ +// Copyright 2026 Moko Consulting +// 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)) +} diff --git a/models/migrations/v1_27/v347.go b/models/migrations/v1_27/v347.go new file mode 100644 index 0000000000..20f2e027b3 --- /dev/null +++ b/models/migrations/v1_27/v347.go @@ -0,0 +1,32 @@ +// Copyright 2026 Moko Consulting +// 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)) +} diff --git a/models/repo/repo_manifest.go b/models/repo/repo_manifest.go new file mode 100644 index 0000000000..39b074702b --- /dev/null +++ b/models/repo/repo_manifest.go @@ -0,0 +1,83 @@ +// Copyright 2026 Moko Consulting +// 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 +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index f0fa63ad5c..e19b9802eb 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1004,6 +1004,12 @@ "repo.object_format": "Object Format", "repo.object_format_helper": "Object format of the repository. Cannot be changed later. SHA1 is most compatible.", "repo.readme": "README", + "repo.well_known_file.readme": "Readme", + "repo.well_known_file.license": "License", + "repo.well_known_file.contributing": "Contributing", + "repo.well_known_file.code_of_conduct": "Code of Conduct", + "repo.well_known_file.security": "Security", + "repo.well_known_file.changelog": "Changelog", "repo.readme_helper": "Select a README file template.", "repo.readme_helper_desc": "This is the place where you can write a complete description for your project.", "repo.auto_init": "Initialize Repository (Adds .gitignore, License and README)", @@ -1576,6 +1582,7 @@ "repo.issues.edit": "Edit", "repo.issues.cancel": "Cancel", "repo.issues.save": "Save", + "repo.issues.status": "Status", "repo.issues.label_title": "Name", "repo.issues.label_description": "Description", "repo.issues.label_color": "Color", @@ -2722,6 +2729,25 @@ "repo.settings.support_url": "Support / Product Page URL", "repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.", "repo.settings.custom_fields": "Custom Fields", + "repo.settings.manifest": "Manifest", + "repo.settings.manifest_desc": "Project identity, governance, and build settings from the moko-platform manifest. These are accessible via API for Actions workflows and the moko-platform CLI.", + "repo.settings.manifest_identity": "Identity", + "repo.settings.manifest_name": "Project Name", + "repo.settings.manifest_org": "Organization", + "repo.settings.manifest_description": "Description", + "repo.settings.manifest_version": "Version", + "repo.settings.manifest_license_spdx": "License (SPDX)", + "repo.settings.manifest_license_name": "License Name", + "repo.settings.manifest_governance": "Governance", + "repo.settings.manifest_platform": "Platform", + "repo.settings.manifest_standards_version": "Standards Version", + "repo.settings.manifest_standards_source": "Standards Source", + "repo.settings.manifest_build": "Build", + "repo.settings.manifest_language": "Language", + "repo.settings.manifest_package_type": "Package Type", + "repo.settings.manifest_entry_point": "Entry Point", + "repo.settings.manifest_save": "Save Manifest", + "repo.settings.manifest_saved": "Manifest settings saved.", "repo.settings.metadata": "Metadata", "repo.settings.metadata_saved": "Repository metadata saved.", "repo.settings.metadata_empty": "No metadata fields defined. Org admins can add fields in Organization Settings > Custom Fields.", @@ -2911,6 +2937,21 @@ "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.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.", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2df3e5af6e..b47453b225 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/api/v1/repo/manifest.go b/routers/api/v1/repo/manifest.go new file mode 100644 index 0000000000..ce8c45d6b7 --- /dev/null +++ b/routers/api/v1/repo/manifest.go @@ -0,0 +1,125 @@ +// Copyright 2026 Moko Consulting +// 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, + }) +} diff --git a/routers/web/org/issue_statuses.go b/routers/web/org/issue_statuses.go new file mode 100644 index 0000000000..805c1f9b19 --- /dev/null +++ b/routers/web/org/issue_statuses.go @@ -0,0 +1,112 @@ +// Copyright 2026 Moko Consulting +// 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") +} diff --git a/routers/web/repo/issue_custom_status.go b/routers/web/repo/issue_custom_status.go new file mode 100644 index 0000000000..102d76f37c --- /dev/null +++ b/routers/web/repo/issue_custom_status.go @@ -0,0 +1,59 @@ +// Copyright 2026 Moko Consulting +// 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) +} diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 4803827c4c..e12089d6f2 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -364,6 +364,14 @@ func ViewIssue(ctx *context.Context) { } ctx.Data["CustomFieldValues"] = customFieldValues ctx.Data["CustomFieldOptions"] = fieldOptions + + // Load custom issue status definitions for the sidebar. + issueStatusDefs, isErr := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Repo.Repository.OwnerID) + if isErr != nil { + log.Error("ViewIssue: GetIssueStatusDefsByOrg: %v", isErr) + } + ctx.Data["IssueStatusDefs"] = issueStatusDefs + upload.AddUploadContext(ctx, "comment") if err := issue.LoadAttributes(ctx); err != nil { diff --git a/routers/web/repo/setting/manifest.go b/routers/web/repo/setting/manifest.go new file mode 100644 index 0000000000..f552cd43e8 --- /dev/null +++ b/routers/web/repo/setting/manifest.go @@ -0,0 +1,163 @@ +// Copyright 2026 Moko Consulting +// 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 +} diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index bc8dfafe8e..6abf7538e3 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -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) } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 27a19ed15c..d36cc9ac7b 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -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 diff --git a/routers/web/web.go b/routers/web/web.go index 98b7eb3e9f..f50e1e076f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1067,6 +1067,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/{id}/edit", org.SettingsCustomFieldsEditPost) m.Post("/{id}/delete", org.SettingsCustomFieldsDeletePost) }) + m.Group("/issue-statuses", func() { + m.Get("", org.SettingsIssueStatuses) + m.Post("", org.SettingsIssueStatusesCreatePost) + m.Post("/{id}/edit", org.SettingsIssueStatusesEditPost) + m.Post("/{id}/delete", org.SettingsIssueStatusesDeletePost) + }) }, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true)) }, context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true})) }, reqSignIn) @@ -1193,6 +1199,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Combo("/advanced").Get(repo_setting.AdvancedSettings).Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost) }, repo_setting.SettingsCtxData) m.Combo("/licensing").Get(repo_setting.LicensingSettings).Post(repo_setting.LicensingSettingsPost) + m.Combo("/manifest").Get(repo_setting.ManifestSettings).Post(repo_setting.ManifestSettingsPost) m.Combo("/metadata").Get(repo_setting.Metadata).Post(repo_setting.MetadataPost) m.Group("/collaboration", func() { @@ -1399,6 +1406,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) m.Post("/{id}/custom-fields/{field_id}", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomField) + m.Post("/{id}/custom-status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueCustomStatus) m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues) m.Delete("/unpin/{index}", reqRepoAdmin, repo.IssueUnpin) m.Post("/move_pin", reqRepoAdmin, repo.IssuePinMove) diff --git a/services/repository/manifest_sync.go b/services/repository/manifest_sync.go new file mode 100644 index 0000000000..1b65a71825 --- /dev/null +++ b/services/repository/manifest_sync.go @@ -0,0 +1,98 @@ +// Copyright 2026 Moko Consulting +// 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()) +} diff --git a/services/repository/push.go b/services/repository/push.go index aa598c669f..5519da1409 100644 --- a/services/repository/push.go +++ b/services/repository/push.go @@ -193,6 +193,8 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { if err := DelRepoDivergenceFromCache(ctx, repo.ID); err != nil { log.Error("DelRepoDivergenceFromCache: %v", err) } + // Auto-sync .mokogitea/manifest.xml to database on default branch push + SyncManifestFromCommit(ctx, repo, newCommit) } else { if err := DelDivergenceFromCache(repo.ID, branch); err != nil { log.Error("DelDivergenceFromCache: %v", err) diff --git a/templates/org/settings/issue_statuses.tmpl b/templates/org/settings/issue_statuses.tmpl new file mode 100644 index 0000000000..c11396df0e --- /dev/null +++ b/templates/org/settings/issue_statuses.tmpl @@ -0,0 +1,93 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings issue-statuses")}} +

+ {{ctx.Locale.Tr "org.settings.issue_statuses"}} +

+
+

{{ctx.Locale.Tr "org.settings.issue_statuses_desc"}}

+ + {{if .IssueStatuses}} + + + + + + + + + + + + {{range .IssueStatuses}} + + + + + + + + {{end}} + +
{{ctx.Locale.Tr "org.settings.issue_status_color"}}{{ctx.Locale.Tr "org.settings.issue_status_name"}}{{ctx.Locale.Tr "org.settings.issue_status_closes_issue"}}{{ctx.Locale.Tr "org.settings.issue_status_sort_order"}}
+ {{if .Color}} + + {{else}} + - + {{end}} + + {{.Name}} + {{if not .IsActive}}{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}{{end}} + {{if .Description}}
{{.Description}}{{end}} +
+ {{if .ClosesIssue}} + {{ctx.Locale.Tr "org.settings.issue_status_closes"}} + {{else}} + - + {{end}} + {{.SortOrder}} +
+ {{$.CsrfTokenHtml}} + +
+
+ {{else}} +
+

{{ctx.Locale.Tr "org.settings.issue_statuses_empty"}}

+
+ {{end}} + +
+ +
{{ctx.Locale.Tr "org.settings.issue_status_add"}}
+
+ {{.CsrfTokenHtml}} +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+

{{ctx.Locale.Tr "org.settings.issue_status_closes_issue_help"}}

+
+
+ +
+
+{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 85417f1870..09be57f477 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -31,6 +31,9 @@ {{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "org.settings.custom_fields"}} + + {{svg "octicon-tasklist"}} {{ctx.Locale.Tr "org.settings.issue_statuses"}} + {{if .EnableActions}}
{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}} diff --git a/templates/repo/issue/sidebar/issue_status.tmpl b/templates/repo/issue/sidebar/issue_status.tmpl new file mode 100644 index 0000000000..98abd21458 --- /dev/null +++ b/templates/repo/issue/sidebar/issue_status.tmpl @@ -0,0 +1,33 @@ +{{if .IssueStatusDefs}} +
+
+ {{ctx.Locale.Tr "repo.issues.status"}} + {{$canModify := .HasIssuesOrPullsWritePermission}} + {{if $canModify}} +
+ {{$.CsrfTokenHtml}} + +
+ {{else}} + {{$found := false}} + {{range .IssueStatusDefs}} + {{if eq .ID $.Issue.StatusID}} + {{if .Color}}{{end}} + {{.Name}} + {{$found = true}} + {{end}} + {{end}} + {{if not $found}} + + {{end}} + {{end}} +
+{{end}} diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 9f064ce6ee..e20ef6ddae 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -7,6 +7,8 @@ {{template "repo/issue/sidebar/label_list" $.IssuePageMetaData}} + {{template "repo/issue/sidebar/issue_status" $}} + {{template "repo/issue/sidebar/custom_fields" $}} {{template "repo/issue/sidebar/milestone_list" $.IssuePageMetaData}} diff --git a/templates/repo/settings/manifest.tmpl b/templates/repo/settings/manifest.tmpl new file mode 100644 index 0000000000..d0b0bdc1b4 --- /dev/null +++ b/templates/repo/settings/manifest.tmpl @@ -0,0 +1,88 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings manifest")}} +

+ {{ctx.Locale.Tr "repo.settings.manifest"}} +

+
+

{{ctx.Locale.Tr "repo.settings.manifest_desc"}}

+ +
+ {{.CsrfTokenHtml}} + +
{{ctx.Locale.Tr "repo.settings.manifest_identity"}}
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
{{ctx.Locale.Tr "repo.settings.manifest_governance"}}
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
{{ctx.Locale.Tr "repo.settings.manifest_build"}}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+{{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index 0d7ac89133..071fb3b5fb 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -12,6 +12,9 @@ {{svg "octicon-broadcast"}} {{ctx.Locale.Tr "repo.settings.licensing_section"}} {{end}} + + {{svg "octicon-file-code"}} {{ctx.Locale.Tr "repo.settings.manifest"}} + {{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "repo.settings.metadata"}} diff --git a/templates/repo/view_content.tmpl b/templates/repo/view_content.tmpl index b2286162ba..31f258be5e 100644 --- a/templates/repo/view_content.tmpl +++ b/templates/repo/view_content.tmpl @@ -130,6 +130,21 @@ {{template "repo/code/upstream_diverging_info" .}} {{end}} {{template "repo/view_list" .}} + {{if .WellKnownFileTabs}} + + {{end}} {{if and .ReadmeExist (or .RenderAsMarkup .IsPlainText)}} {{template "repo/view_file" .}} {{end}} diff --git a/wiki/custom-fields.md b/wiki/custom-fields.md new file mode 100644 index 0000000000..2df4d869c8 --- /dev/null +++ b/wiki/custom-fields.md @@ -0,0 +1,136 @@ +# Custom Fields + +Custom fields allow organizations to define structured metadata that appears in issue sidebars and repository settings across all repos in the organization. + +## Overview + +Custom fields are defined at the **organization level** in Org Settings > Custom Fields. Each field has a scope: + +- **Issue scope** — appears in the issue sidebar for inline editing +- **Repo scope** — appears in Repository Settings > Metadata for repo-level values + +## Field Types + +| Type | Description | Example | +|------|-------------|---------| +| `text` | Free-form text input | "Affected Component" | +| `number` | Numeric input | "Story Points" | +| `date` | Date picker | "Due Date" | +| `dropdown` | Select from predefined options | "Priority: Low/Medium/High/Critical" | +| `checkbox` | Boolean toggle | "Requires QA" | +| `url` | URL input | "Design Link" | + +## Org Settings + +Navigate to **Organization Settings > Custom Fields** to manage field definitions. + +Each field has: + +| Field | Description | +|-------|-------------| +| Name | Display name | +| Scope | `issue` (sidebar) or `repo` (metadata) | +| Type | One of: text, number, date, dropdown, checkbox, url | +| Options | JSON array for dropdown options (e.g., `["Low","Medium","High"]`) | +| Description | Help text (shown as tooltip) | +| Sort Order | Controls display order | +| Is Active | Inactive fields are hidden from new forms but preserved on existing entities | + +## Issue Sidebar + +Issue-scoped fields appear in the sidebar between labels and milestones. Dropdown fields auto-submit on change. Text/number/date fields display their current value. + +Each field renders as an inline form posting to: +``` +POST /{owner}/{repo}/issues/{issue_id}/custom-fields/{field_id} +``` + +## Repository Metadata + +Repo-scoped fields appear on the **Repository Settings > Metadata** page. All fields for the org are shown with their current values for the repository. Values are saved via form POST. + +## Issue Template Integration + +Custom fields can be pre-filled from issue template YAML frontmatter: + +```yaml +name: Bug Report +about: Report a bug +custom_fields: + Priority: High + Affected Component: Backend +``` + +When a new issue is created from this template, the sidebar shows the custom fields with the specified defaults pre-selected. + +## API + +### Issue-Level Custom Fields + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/repos/{owner}/{repo}/issues/{index}/custom-fields` | Get field values for an issue | +| PUT | `/api/v1/repos/{owner}/{repo}/issues/{index}/custom-fields` | Set field values (name-value map) | + +### Repo-Level Metadata + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/repos/{owner}/{repo}/metadata` | Get repo metadata field values | +| PUT | `/api/v1/repos/{owner}/{repo}/metadata` | Set repo metadata field values | + +### Org-Level Definitions + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/orgs/{org}/custom-fields` | List all field definitions | +| POST | `/api/v1/orgs/{org}/custom-fields` | Create a field definition | +| DELETE | `/api/v1/orgs/{org}/custom-fields/{id}` | Delete a field definition | + +## Database + +### Tables + +**`custom_field_def`** — field definitions (org-level) + +| Column | Type | Description | +|--------|------|-------------| +| id | bigint | Primary key | +| owner_id | bigint | Org ID (0 = legacy repo-level) | +| repo_id | bigint | 0 for org-level definitions | +| scope | varchar(10) | `issue` or `repo` | +| name | varchar | Field name | +| field_type | varchar(20) | text, number, date, dropdown, checkbox, url | +| description | text | Help text | +| options | text | JSON array for dropdown options | +| required | bool | Whether the field is required | +| sort_order | int | Display order | +| is_active | bool | Visibility flag | + +**`custom_field_value`** — field values (per entity) + +| Column | Type | Description | +|--------|------|-------------| +| id | bigint | Primary key | +| entity_id | bigint | Issue ID or Repo ID | +| entity_type | varchar(10) | `issue` or `repo` | +| field_id | bigint | FK to custom_field_def | +| value | text | The stored value | + +### Cascade on Delete + +When a field definition is deleted, all associated values in `custom_field_value` are also deleted. + +## Relationship to Other Systems + +| System | Relationship | +|--------|-------------| +| Update Server | Repo-scoped custom fields with specific names (Extension Name, Display Name, etc.) are read by the update feed generators as the highest-priority metadata source. | +| Manifest Settings | Manifest fields follow the moko-platform schema and are separate from custom fields. Custom fields are user-defined; manifest fields are standardized. | +| Issue Statuses | Custom statuses are a separate feature with their own dedicated table and UI, not implemented as custom fields. | + +--- + +| Revision | Date | Author | Description | +|---|---|---|---| +| 1.0 | 2026-06-06 | Jonathan Miller (@jmiller) | Initial version | diff --git a/wiki/custom-issue-statuses.md b/wiki/custom-issue-statuses.md new file mode 100644 index 0000000000..8077e4d880 --- /dev/null +++ b/wiki/custom-issue-statuses.md @@ -0,0 +1,107 @@ +# Custom Issue Statuses + +Custom issue statuses extend Gitea's binary Open/Closed model with org-defined workflow states. Each status has a name, color, and an optional "closes issue" flag that triggers automatic close/reopen when selected. + +## Overview + +Statuses are defined at the **organization level** and appear in the issue sidebar for all repositories under that organization. This is the same pattern as org-level labels and custom fields. + +### Key Concepts + +- **Status definitions** are managed in Org Settings > Issue Statuses +- **Status selection** appears as a dropdown in the issue sidebar +- **Auto close/reopen** — selecting a status with `closes_issue = true` automatically closes the issue; switching to a non-closing status reopens it +- **Status is supplemental** — the existing Open/Closed binary state is preserved; statuses add granularity on top + +## Org Settings + +Navigate to **Organization Settings > Issue Statuses** to manage status definitions. + +Each status has: + +| Field | Description | +|-------|-------------| +| Name | Display name (e.g., "In Progress", "Won't Fix", "Blocked") | +| Color | Hex color for visual distinction (e.g., `#2563eb`) | +| Description | Help text shown to users | +| Closes Issue | When checked, selecting this status automatically closes the issue | +| Sort Order | Controls display order in dropdowns (ascending) | +| Is Active | Inactive statuses are hidden from dropdowns but preserved on existing issues | + +### Example Statuses + +| Status | Color | Closes Issue | Use Case | +|--------|-------|:------------:|----------| +| In Progress | Blue | No | Work is actively being done | +| Needs Info | Yellow | No | Waiting for more information from reporter | +| Blocked | Red | No | Cannot proceed due to external dependency | +| Won't Fix | Gray | Yes | Decided not to address this issue | +| Duplicate | Purple | Yes | Already tracked in another issue | +| Resolved | Green | Yes | Fix has been implemented and verified | + +## Issue Sidebar + +When an organization has custom statuses defined, a **Status** dropdown appears in the issue sidebar between labels and custom fields. The dropdown: + +- Shows all active status definitions for the repo's organization +- Auto-submits on change (no save button needed) +- Displays a colored left border on each option +- Shows a power symbol on statuses that close the issue +- Selecting "—" (empty) clears the status + +### Auto Close/Reopen Behavior + +| Current State | Selected Status | Result | +|:---:|---|---| +| Open | Status with `closes_issue = true` | Issue is closed automatically | +| Closed | Status with `closes_issue = false` | Issue is reopened automatically | +| Open | Status with `closes_issue = false` | Status set, issue stays open | +| Closed | Status with `closes_issue = true` | Status set, issue stays closed | + +All close/reopen actions go through the standard Gitea service layer, so webhooks, notifications, and timeline events fire normally. + +## Database + +### Tables + +**`issue_status_def`** (migration v346) — org-level status definitions + +| Column | Type | Description | +|--------|------|-------------| +| id | bigint | Primary key | +| org_id | bigint | Organization ID | +| name | varchar | Status name | +| color | varchar(7) | Hex color | +| description | text | Help text | +| closes_issue | bool | Auto-close flag | +| sort_order | int | Display order | +| is_active | bool | Visibility flag | + +**`issue`** table — added `status_id` column (bigint, default 0) + +### Cascade on Delete + +When a status definition is deleted, all issues referencing it have their `status_id` set to 0 (cleared). Issues are not closed or reopened during deletion. + +## Routes + +### Web Routes (Org Settings) + +| Method | Path | Handler | +|--------|------|---------| +| GET | `/org/{org}/settings/issue-statuses` | `SettingsIssueStatuses` | +| POST | `/org/{org}/settings/issue-statuses` | `SettingsIssueStatusesCreatePost` | +| POST | `/org/{org}/settings/issue-statuses/{id}/edit` | `SettingsIssueStatusesEditPost` | +| POST | `/org/{org}/settings/issue-statuses/{id}/delete` | `SettingsIssueStatusesDeletePost` | + +### Web Routes (Issue Sidebar) + +| Method | Path | Handler | +|--------|------|---------| +| POST | `/{owner}/{repo}/issues/{id}/custom-status` | `UpdateIssueCustomStatus` | + +--- + +| Revision | Date | Author | Description | +|---|---|---|---| +| 1.0 | 2026-06-06 | Jonathan Miller (@jmiller) | Initial version | diff --git a/wiki/home.md b/wiki/home.md new file mode 100644 index 0000000000..89d80177e5 --- /dev/null +++ b/wiki/home.md @@ -0,0 +1,50 @@ +# MokoGitea + +Moko Consulting's custom fork of [Gitea](https://gitea.com), extending the self-hosted Git service with commercial licensing, update feeds, custom issue workflows, and org-level management features. + +| Field | Value | +|-----|-----| +| **Language** | Go | +| **License** | MIT | +| **Upstream** | Gitea 1.26.1 | +| **Version** | v1.26.1-moko.06.04.00 | +| **Platform** | [Gitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea) | + +--- + +## Features + +- **Commercial License System** — Package-based license keys with download gating, domain restriction, key expiry, and payment webhook API +- **Update Server** — Built-in update feeds for Joomla, WordPress, Dolibarr, Composer, Drupal, PrestaShop, and WHMCS +- **Custom Issue Statuses** — Org-defined workflow states (In Progress, Blocked, Won't Fix) with auto close/reopen +- **Custom Fields** — Org-level field definitions for issues (sidebar) and repos (metadata) with dropdown, text, number, date, checkbox, and URL types +- **Manifest Settings** — Per-repo identity/governance/build metadata with REST API for CI/CD integration +- **Well-Known File Tabs** — README/LICENSE/CONTRIBUTING/SECURITY/CHANGELOG tabs on repo home page +- **Org-Level Branch Protection** — Organization-scoped rulesets that cascade to all repos. Supports glob patterns. Full CRUD API +- **Enterprise Sub-Orgs** — Parent-child organization hierarchy +- **Three-Level Visibility** — Public (200), Private (403), Hidden (404) for repositories +- **Configurable Help/Support URLs** — Replace hardcoded docs.gitea.com links via HELP_URL and SUPPORT_URL in app.ini +- **Project Board API** — REST API endpoints for managing project boards, columns, and cards +- **Custom branding** — Moko Consulting visual identity (logos, colors, footer) + +## Pages + +| Page | Description | +|---|---| +| [Branding](Branding) | Custom branding and visual identity details | +| [Custom Fields](Custom-Fields) | Org-level custom fields for issues and repos | +| [Custom Issue Statuses](Custom-Issue-Statuses) | Org-defined workflow states with auto close/reopen | +| [Deployment](Deployment) | Production deployment guide | +| [Manifest Settings](Manifest-Settings) | Per-repo manifest settings and REST API | +| [Org Branch Protection API](Org-Branch-Protection-API) | Org-level branch protection rulesets and API reference | +| [Project API](Project-API) | Custom API endpoint reference for project boards | +| [Roadmap](Roadmap) | Development roadmap and planned features | + +--- + +| Revision | Date | Author | Description | +|---|---|---|---| +| 4.0 | 2026-06-06 | Jonathan Miller (@jmiller) | Add manifest settings, custom statuses, custom fields, well-known tabs, update version to v1.26.1-moko.06.04.00 | +| 3.0 | 2026-05-12 | Jonathan Miller (@jmiller) | Add org branch protection, help URLs, version convention | +| 2.0 | 2026-05-10 | Jonathan Miller (@jmiller) | Rewrite with detailed features and fork documentation | +| 1.0 | 2026-05-09 | Jonathan Miller (@jmiller) | Initial version | diff --git a/wiki/manifest-settings.md b/wiki/manifest-settings.md new file mode 100644 index 0000000000..393c16f01f --- /dev/null +++ b/wiki/manifest-settings.md @@ -0,0 +1,111 @@ +# Manifest Settings + +The manifest settings feature provides a centralized way to store and manage project identity, governance, and build metadata for each repository. Settings are stored in the database and exposed via both a web UI and REST API. + +## Overview + +Each repository can have a manifest that describes: + +- **Identity** — project name, organization, description, version, and license +- **Governance** — platform type, moko-platform standards version, and standards source URL +- **Build** — language, package type, and entry point + +These settings replace the legacy `.mokogitea/manifest.xml` file-based approach. + +## Repo Settings Page + +Navigate to **Repository Settings > Manifest** to view and edit manifest fields. + +| Section | Fields | +|---------|--------| +| Identity | Name, Org, Description, Version, License SPDX, License Name | +| Governance | Platform, Standards Version, Standards Source | +| Build | Language, Package Type, Entry Point | + +### Auto-Migration from manifest.xml + +On first visit to the Manifest settings page, if no manifest exists in the database but a `.mokogitea/manifest.xml` file exists in the repository, the system will: + +1. Parse the XML and extract all fields +2. Store them in the database +3. Display a flash message indicating migration was successful +4. The manifest.xml file can then be manually deleted from the repository + +If a field already has a value in the database (e.g., from org-level custom fields), the existing value is preserved and the manifest.xml value is skipped. + +## REST API + +The manifest API allows Actions workflows and the moko-platform CLI to read and write manifest settings programmatically. + +### Get Manifest + +``` +GET /api/v1/repos/{owner}/{repo}/manifest +Authorization: token {access_token} +``` + +Returns the current manifest settings. If no manifest has been saved, returns defaults derived from repository metadata (name, owner, description). + +**Response:** +```json +{ + "name": "MokoGitea", + "org": "MokoConsulting", + "description": "Moko fork of Gitea", + "version": "06.04.00", + "license_spdx": "GPL-3.0-or-later", + "license_name": "GNU General Public License v3", + "platform": "go", + "standards_version": "05.00.00", + "standards_source": "https://code.mokoconsulting.tech/MokoConsulting/moko-platform", + "language": "Go", + "package_type": "application", + "entry_point": "./" +} +``` + +### Update Manifest + +``` +PUT /api/v1/repos/{owner}/{repo}/manifest +Authorization: token {access_token} +Content-Type: application/json +``` + +Requires repo admin permission. Accepts the same JSON structure as the GET response. Creates or updates the manifest. + +### Usage in Actions Workflows + +```yaml +steps: + - name: Read manifest version + run: | + VERSION=$(curl -s "$GITEA_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/manifest" \ + -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" | jq -r '.version') + echo "Current version: $VERSION" + + - name: Bump version + run: | + curl -s -X PUT "$GITEA_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/manifest" \ + -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "{\"version\": \"$NEW_VERSION\"}" +``` + +## Database + +Manifest settings are stored in the `repo_manifest` table (migration v347). One row per repository, keyed by `repo_id`. + +## Relationship to Other Systems + +| System | Relationship | +|--------|-------------| +| Update Server | The update server generators read from both manifest settings and update_stream_config. Manifest provides identity metadata; update_stream_config provides feed-specific settings. | +| Custom Fields | Repo-scoped custom fields (org settings) are separate from manifest fields. Custom fields are user-defined; manifest fields follow the moko-platform schema. | +| moko-platform CLI | The CLI reads manifest settings via the API for version bumping, build decisions, and cross-repo syncing (see issue #505). | + +--- + +| Revision | Date | Author | Description | +|---|---|---|---| +| 1.0 | 2026-06-06 | Jonathan Miller (@jmiller) | Initial version |