mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-06 23:49:06 +00:00
Merge branch 'main' into renovate/go-dependencies
This commit is contained in:
8
.github/actions/docker-dryrun/action.yml
vendored
8
.github/actions/docker-dryrun/action.yml
vendored
@@ -9,10 +9,10 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Build regular image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ inputs.platform }}
|
||||
@@ -20,7 +20,7 @@ runs:
|
||||
file: Dockerfile
|
||||
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful
|
||||
- name: Build rootless image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ inputs.platform }}
|
||||
|
||||
113
.github/workflows/agent-scan.yml
vendored
Normal file
113
.github/workflows/agent-scan.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
name: AgentScan
|
||||
|
||||
on:
|
||||
# jobs only use pinned actions and never checkout code
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened, reopened, synchronize, edited]
|
||||
|
||||
concurrency:
|
||||
group: agent-scan-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
agentscan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: AgentScan
|
||||
id: agentscan
|
||||
uses: MatteoGabriele/agentscan-action@0a0c88109b5153dff2805f969f5060441efb7b65
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
skip-members: "dependabot[bot],renovate[bot], giteabot (backports)"
|
||||
agent-scan-comment: false
|
||||
|
||||
- name: Handle flagged PR
|
||||
if: contains(fromJSON('["automation","mixed"]'), steps.agentscan.outputs.classification) || steps.agentscan.outputs.community-flagged == 'true'
|
||||
env:
|
||||
CLASSIFICATION: ${{ steps.agentscan.outputs.classification }}
|
||||
COMMUNITY_FLAGGED: ${{ steps.agentscan.outputs.community-flagged }}
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
|
||||
with:
|
||||
script: |
|
||||
const core = require('@actions/core');
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const classification = process.env.CLASSIFICATION;
|
||||
const communityFlagged = process.env.COMMUNITY_FLAGGED === 'true';
|
||||
const shouldClose = classification === 'automation' || communityFlagged;
|
||||
|
||||
const issue = context.payload.pull_request;
|
||||
const labels = issue.labels?.map(l => l.name) || [];
|
||||
|
||||
if (!labels.includes('possible bot')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['possible bot'],
|
||||
});
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const alreadyCommented = comments.some(c => c.user.type === 'Bot' && c.body.includes('AI Contribution Policy'));
|
||||
|
||||
if (!alreadyCommented) {
|
||||
const closingNote = shouldClose
|
||||
? "We're closing this for now as the account looks automated. If we got that wrong, please just reopen the PR and we'll take another look."
|
||||
: 'If this was flagged in error, we apologise! 😳 Just let us know. 🙏';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: [
|
||||
"We've flagged this pull request as potentially AI-assisted.",
|
||||
'',
|
||||
'Gitea welcomes the thoughtful use of AI tools, but contributors must use them responsibly and clearly disclose any assistance. Please follow the AI Contribution Policy in `CONTRIBUTING.md` and update this PR accordingly:',
|
||||
'',
|
||||
'Maintainers may close PRs that do not disclose AI assistance, appear to be low-quality AI-generated content, or where the contributor cannot explain the changes.',
|
||||
'',
|
||||
'See: https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md#ai-contribution-policy',
|
||||
'',
|
||||
closingNote,
|
||||
].join('\n'),
|
||||
});
|
||||
} else {
|
||||
core.info('Possible-bot comment already exists - skipping comment.');
|
||||
}
|
||||
|
||||
if (shouldClose && issue.state === 'open' && !alreadyCommented) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
title: '🚨 unwelcome pr from bot 🚨',
|
||||
});
|
||||
}
|
||||
|
||||
const actionTaken = [
|
||||
'Added `possible bot` label',
|
||||
alreadyCommented ? null : 'posted policy comment',
|
||||
shouldClose && !alreadyCommented ? 'closed PR' : null,
|
||||
].filter(Boolean).join(', ');
|
||||
|
||||
core.summary
|
||||
.addHeading('AgentScan: Possible Bot Flag', 2)
|
||||
.addTable([
|
||||
[{ data: 'Property', header: true }, { data: 'Value', header: true }],
|
||||
['Pull Request', `#${prNumber}`],
|
||||
['Classification', classification],
|
||||
['Community flagged', String(communityFlagged)],
|
||||
['Action', actionTaken || 'No action (already handled)'],
|
||||
])
|
||||
.write();
|
||||
4
.github/workflows/cache-seeder.yml
vendored
4
.github/workflows/cache-seeder.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
gobuild:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- run: make deps-backend deps-tools
|
||||
- run: TAGS="bindata" make backend
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
include:
|
||||
- { tags: "bindata", target: "lint-backend" }
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
with:
|
||||
lint-cache: "true"
|
||||
|
||||
2
.github/workflows/cron-licenses.yml
vendored
2
.github/workflows/cron-licenses.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
4
.github/workflows/cron-renovate.yml
vendored
4
.github/workflows/cron-renovate.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
if: github.repository == 'go-gitea/gitea' # prevent running on forks
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: renovatebot/github-action@693b9ef15eec82123529a37c782242f091365961 # v46.1.14
|
||||
with:
|
||||
renovate-version: ${{ env.RENOVATE_VERSION }}
|
||||
@@ -28,5 +28,5 @@ jobs:
|
||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||
env:
|
||||
RENOVATE_BINARY_SOURCE: install # auto-install go/node toolchains needed by post-upgrade tasks.
|
||||
RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS: '["^make (tidy|svg nolyfill)$"]'
|
||||
RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS: '["^make (tidy|svg)$"]'
|
||||
RENOVATE_REPOSITORIES: '["go-gitea/gitea"]'
|
||||
|
||||
2
.github/workflows/cron-translations.yml
vendored
2
.github/workflows/cron-translations.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
with:
|
||||
upload_sources: true
|
||||
|
||||
2
.github/workflows/files-changed.yml
vendored
2
.github/workflows/files-changed.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
e2e: ${{ steps.changes.outputs.e2e }}
|
||||
shell: ${{ steps.changes.outputs.shell }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
|
||||
12
.github/workflows/pull-compliance.yml
vendored
12
.github/workflows/pull-compliance.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
with:
|
||||
lint-cache: "true"
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
with:
|
||||
cache: "false"
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- run: make lint-spell
|
||||
|
||||
- if: needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.yaml == 'true' || needs.files-changed.outputs.actions == 'true'
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
python-version: 3.14
|
||||
- if: needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.yaml == 'true'
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- run: make deps-backend deps-tools
|
||||
- run: make --always-make checks-backend # ensure the "go-licenses" make target runs
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/node-setup
|
||||
- run: make deps-frontend
|
||||
- run: make lint-frontend
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- run: make deps-backend generate-go
|
||||
# no frontend build here as backend should be able to build, even without any frontend files
|
||||
|
||||
14
.github/workflows/pull-db-tests.yml
vendored
14
.github/workflows/pull-db-tests.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
ports:
|
||||
- "9000:9000"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- uses: ./.github/actions/pgsql-shard
|
||||
with:
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
ports:
|
||||
- "9000:9000"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- uses: ./.github/actions/pgsql-shard
|
||||
with:
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- run: make deps-backend
|
||||
- run: make backend
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
ports:
|
||||
- "7700:7700"
|
||||
redis:
|
||||
image: redis:latest@sha256:48e78eb9d1e1adcfb10184b2cc3c7fc5ed21e5a3be08875f239257d194bab8c9
|
||||
image: redis:latest@sha256:e74c9b933d78e2829583d88f92793f4524752a15ac59c8baff2dd5ed000b7432
|
||||
options: >- # wait until redis has started
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
ports:
|
||||
- 10000:10000
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- name: Add hosts to /etc/hosts
|
||||
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 minio devstoreaccount1.azurite.local mysql elasticsearch meilisearch smtpimap" | sudo tee -a /etc/hosts'
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
- "587:587"
|
||||
- "993:993"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- name: Add hosts to /etc/hosts
|
||||
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch smtpimap" | sudo tee -a /etc/hosts'
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
ports:
|
||||
- 10000:10000
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- name: Add hosts to /etc/hosts
|
||||
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mssql devstoreaccount1.azurite.local" | sudo tee -a /etc/hosts'
|
||||
|
||||
6
.github/workflows/pull-docker-dryrun.yml
vendored
6
.github/workflows/pull-docker-dryrun.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/docker-dryrun
|
||||
with:
|
||||
platform: linux/amd64
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/docker-dryrun
|
||||
with:
|
||||
platform: linux/arm64
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/docker-dryrun
|
||||
with:
|
||||
platform: linux/riscv64
|
||||
|
||||
2
.github/workflows/pull-e2e-tests.yml
vendored
2
.github/workflows/pull-e2e-tests.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- uses: ./.github/actions/node-setup
|
||||
- run: make deps-frontend
|
||||
|
||||
2
.github/workflows/pull-labeler.yml
vendored
2
.github/workflows/pull-labeler.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Base-branch checkout only: pull_request_target runs with elevated token; never run PR-head code here.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install snapcraft
|
||||
run: sudo snap install snapcraft --classic
|
||||
|
||||
22
.github/workflows/release-nightly.yml
vendored
22
.github/workflows/release-nightly.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
echo "Cleaned name is ${REF_NAME}"
|
||||
echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT"
|
||||
- name: configure aws
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -75,12 +75,12 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Get cleaned branch name
|
||||
id: clean_name
|
||||
env:
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
run: |
|
||||
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//')
|
||||
echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
id: meta
|
||||
with:
|
||||
images: |-
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
type=raw,value=${{ steps.clean_name.outputs.branch }}
|
||||
annotations: |
|
||||
org.opencontainers.image.authors="maintainers@gitea.io"
|
||||
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
id: meta_rootless
|
||||
with:
|
||||
images: |-
|
||||
@@ -112,18 +112,18 @@ jobs:
|
||||
annotations: |
|
||||
org.opencontainers.image.authors="maintainers@gitea.io"
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GHCR using PAT
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: build regular docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful
|
||||
cache-to: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful,mode=max
|
||||
- name: build rootless docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
|
||||
22
.github/workflows/release-tag-rc.yml
vendored
22
.github/workflows/release-tag-rc.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
echo "Cleaned name is ${REF_NAME}"
|
||||
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
- name: configure aws
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -86,13 +86,13 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
id: meta
|
||||
with:
|
||||
images: |-
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
annotations: |
|
||||
org.opencontainers.image.authors="maintainers@gitea.io"
|
||||
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
id: meta_rootless
|
||||
with:
|
||||
images: |-
|
||||
@@ -121,18 +121,18 @@ jobs:
|
||||
annotations: |
|
||||
org.opencontainers.image.authors="maintainers@gitea.io"
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GHCR using PAT
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: build regular container image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
- name: build rootless container image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
|
||||
22
.github/workflows/release-tag-version.yml
vendored
22
.github/workflows/release-tag-version.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
echo "Cleaned name is ${REF_NAME}"
|
||||
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
- name: configure aws
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -89,13 +89,13 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
id: meta
|
||||
with:
|
||||
images: |-
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
annotations: |
|
||||
org.opencontainers.image.authors="maintainers@gitea.io"
|
||||
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
id: meta_rootless
|
||||
with:
|
||||
images: |-
|
||||
@@ -133,18 +133,18 @@ jobs:
|
||||
annotations: |
|
||||
org.opencontainers.image.authors="maintainers@gitea.io"
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GHCR using PAT
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: build regular container image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
- name: build rootless container image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
|
||||
@@ -64,4 +64,4 @@ metiftikci <metiftikci@hotmail.com> (@metiftikci)
|
||||
Christopher Homberger <christopher.homberger@web.de> (@ChristopherHX)
|
||||
Tobias Balle-Petersen <tobiasbp@gmail.com> (@tobiasbp)
|
||||
TheFox <thefox0x7@gmail.com> (@TheFox0x7)
|
||||
Nicolas <bircni@icloud.com> (@bircni)
|
||||
Nicolas <bircni@icloud.com> (@bircni)
|
||||
|
||||
14
Makefile
14
Makefile
@@ -11,12 +11,12 @@ COMMA := ,
|
||||
|
||||
XGO_VERSION := go-1.26.x
|
||||
|
||||
AIR_PACKAGE ?= github.com/air-verse/air@v1.65.2 # renovate: datasource=go
|
||||
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3.6.1 # renovate: datasource=go
|
||||
AIR_PACKAGE ?= github.com/air-verse/air@v1.65.3 # renovate: datasource=go
|
||||
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3.7.0 # renovate: datasource=go
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
|
||||
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
|
||||
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.33.2 # renovate: datasource=go
|
||||
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.34.0 # renovate: datasource=go
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
|
||||
ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 # renovate: datasource=go
|
||||
@@ -608,14 +608,6 @@ update-js: node_modules ## update js dependencies
|
||||
rm -rf node_modules pnpm-lock.yaml
|
||||
pnpm install
|
||||
@touch node_modules
|
||||
$(MAKE) --no-print-directory nolyfill
|
||||
|
||||
.PHONY: nolyfill
|
||||
nolyfill: node_modules ## apply nolyfill overrides to package.json and relock
|
||||
pnpm exec nolyfill install
|
||||
node tools/migrate-nolyfills.ts
|
||||
pnpm install
|
||||
@touch node_modules
|
||||
|
||||
.PHONY: update-py
|
||||
update-py: node_modules ## update py dependencies
|
||||
|
||||
9
assets/go-licenses.json
generated
9
assets/go-licenses.json
generated
File diff suppressed because one or more lines are too long
@@ -6,10 +6,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.dev/modules/generate"
|
||||
"gitea.dev/modules/ssh"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/urfave/cli/v3"
|
||||
@@ -21,6 +23,7 @@ func newGenerateCommand() *cli.Command {
|
||||
Usage: "Generate Gitea's secrets/keys/tokens",
|
||||
Commands: []*cli.Command{
|
||||
newGenerateSecretCommand(),
|
||||
newGenerateSSHCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -37,6 +40,17 @@ func newGenerateSecretCommand() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func newGenerateSSHCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "ssh",
|
||||
Usage: "Generate ssh keys",
|
||||
Commands: []*cli.Command{
|
||||
newGenerateSSHKeyCommand(),
|
||||
newGenerateSSHHostKeysCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newGenerateInternalTokenCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "INTERNAL_TOKEN",
|
||||
@@ -62,6 +76,30 @@ func newGenerateSecretKeyCommand() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func newGenerateSSHKeyCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "key",
|
||||
Usage: "Generate a new ssh key",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{Name: "bits", Aliases: []string{"b"}, Usage: "Number of bits in the key, ignored when key is ed25519"},
|
||||
&cli.StringFlag{Name: "type", Aliases: []string{"t"}, Value: "ed25519", Usage: "Specifies the type of key to create."},
|
||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "Specifies the path or base directory for the key file", Required: true},
|
||||
},
|
||||
Action: runGenerateKeyPair,
|
||||
}
|
||||
}
|
||||
|
||||
func newGenerateSSHHostKeysCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "host-keys",
|
||||
Usage: "Generate host keys of all default key types (rsa, ecdsa, and ed25519) if they do not already exist.",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "dir", Aliases: []string{"d"}, Usage: "Specifies the base directory for the key files", Required: true},
|
||||
},
|
||||
Action: runGenerateHostKey,
|
||||
}
|
||||
}
|
||||
|
||||
func runGenerateInternalToken(_ context.Context, c *cli.Command) error {
|
||||
internalToken, err := generate.NewInternalToken()
|
||||
if err != nil {
|
||||
@@ -103,3 +141,41 @@ func runGenerateSecretKey(_ context.Context, c *cli.Command) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGenerateHostKey(_ context.Context, c *cli.Command) error {
|
||||
file := c.String("dir")
|
||||
info, err := os.Stat(file)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err = os.MkdirAll(file, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else if !info.IsDir() {
|
||||
return errors.New("file already exists and is not a directory")
|
||||
}
|
||||
fmt.Fprintf(c.Writer, "Generating host keys in %s\n", file)
|
||||
_, err = ssh.InitDefaultHostKeys(file)
|
||||
return err
|
||||
}
|
||||
|
||||
func runGenerateKeyPair(_ context.Context, c *cli.Command) error {
|
||||
file := c.String("file")
|
||||
keyType := c.String("type")
|
||||
|
||||
fmt.Fprintf(c.Writer, "Generating public/private %s key pair.\n", keyType)
|
||||
|
||||
// Check if file exists to prevent overwriting
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
if !confirm(c.Reader, c.Writer, "%s already exists.\nOverwrite (y/n)? ", file) {
|
||||
fmt.Println("Aborting")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
bits := c.Int("bits")
|
||||
err := ssh.GenKeyPair(file, generate.SSHKeyType(keyType), bits)
|
||||
if err == nil {
|
||||
fmt.Printf("Your SSH key has been saved in %s\n", file)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -38,22 +38,15 @@ func argsSet(c *cli.Command, args ...string) error {
|
||||
}
|
||||
|
||||
// confirm waits for user input which confirms an action
|
||||
func confirm() (bool, error) {
|
||||
func confirm(stdin io.Reader, stdout io.Writer, msg string, args ...any) bool {
|
||||
var response string
|
||||
|
||||
_, err := fmt.Scanln(&response)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(stdout, msg, args...)
|
||||
_, _ = fmt.Fscanln(stdin, &response)
|
||||
switch strings.ToLower(response) {
|
||||
case "y", "yes":
|
||||
return true, nil
|
||||
case "n", "no":
|
||||
return false, nil
|
||||
default:
|
||||
return false, errors.New(response + " isn't a correct confirmation string")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func initDB(ctx context.Context) error {
|
||||
|
||||
@@ -22,14 +22,10 @@ func runSendMail(ctx context.Context, c *cli.Command) error {
|
||||
|
||||
if !confirmSkipped {
|
||||
if len(body) == 0 {
|
||||
fmt.Print("warning: Content is empty")
|
||||
fmt.Println("warning: Content is empty")
|
||||
}
|
||||
|
||||
fmt.Print("Proceed with sending email? [Y/n] ")
|
||||
isConfirmed, err := confirm()
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !isConfirmed {
|
||||
if !confirm(c.Reader, c.Writer, "Proceed with sending email? [Y/n] ") {
|
||||
fmt.Println("The mail was not sent")
|
||||
return nil
|
||||
}
|
||||
|
||||
23
cmd/serv.go
23
cmd/serv.go
@@ -113,23 +113,25 @@ func handleCliResponseExtra(extra private.ResponseExtra) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAccessMode(verb, lfsVerb string) perm.AccessMode {
|
||||
// getAccessMode maps an SSH git/LFS verb to the access mode it requires, with
|
||||
// ok=false for an unrecognised verb. Callers MUST reject the request when ok is
|
||||
// false: AccessModeNone would otherwise pass the `userMode < mode` permission
|
||||
// check in routers/private/serv.go and grant access.
|
||||
func getAccessMode(verb, lfsVerb string) (mode perm.AccessMode, ok bool) {
|
||||
switch verb {
|
||||
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
|
||||
return perm.AccessModeRead
|
||||
return perm.AccessModeRead, true
|
||||
case git.CmdVerbReceivePack:
|
||||
return perm.AccessModeWrite
|
||||
return perm.AccessModeWrite, true
|
||||
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
|
||||
switch lfsVerb {
|
||||
case git.CmdSubVerbLfsUpload:
|
||||
return perm.AccessModeWrite
|
||||
return perm.AccessModeWrite, true
|
||||
case git.CmdSubVerbLfsDownload:
|
||||
return perm.AccessModeRead
|
||||
return perm.AccessModeRead, true
|
||||
}
|
||||
}
|
||||
// should be unreachable
|
||||
setting.PanicInDevOrTesting("unknown verb: %s %s", verb, lfsVerb)
|
||||
return perm.AccessModeNone
|
||||
return perm.AccessModeNone, false
|
||||
}
|
||||
|
||||
func runServ(ctx context.Context, c *cli.Command) error {
|
||||
@@ -247,7 +249,10 @@ func runServ(ctx context.Context, c *cli.Command) error {
|
||||
}
|
||||
}
|
||||
|
||||
requestedMode := getAccessMode(verb, lfsVerb)
|
||||
requestedMode, ok := getAccessMode(verb, lfsVerb)
|
||||
if !ok {
|
||||
return fail(ctx, "Unknown git command", "Unknown git command %s %s", verb, lfsVerb)
|
||||
}
|
||||
|
||||
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
|
||||
if extra.HasError() {
|
||||
|
||||
56
cmd/serv_test.go
Normal file
56
cmd/serv_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/modules/git"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetAccessMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
verb, lfsVerb string
|
||||
expected perm.AccessMode
|
||||
}{
|
||||
{git.CmdVerbUploadPack, "", perm.AccessModeRead},
|
||||
{git.CmdVerbUploadArchive, "", perm.AccessModeRead},
|
||||
{git.CmdVerbReceivePack, "", perm.AccessModeWrite},
|
||||
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
|
||||
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
|
||||
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
|
||||
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
|
||||
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, tc.expected, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAccessModeUnknownVerb locks in the invariant that getAccessMode reports
|
||||
// ok=false for unrecognised verbs and LFS sub-verbs, so runServ rejects them. An
|
||||
// unknown verb has no valid access mode; if it were treated as AccessModeNone (0)
|
||||
// it would pass the `userMode < mode` permission check in routers/private/serv.go
|
||||
// and hand out valid LFS JWTs for any private repository.
|
||||
func TestGetAccessModeUnknownVerb(t *testing.T) {
|
||||
cases := []struct{ verb, lfsVerb string }{
|
||||
{git.CmdVerbLfsAuthenticate, ""},
|
||||
{git.CmdVerbLfsAuthenticate, "badverb"},
|
||||
{git.CmdVerbLfsTransfer, "badverb"},
|
||||
{"git-unknown-verb", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
|
||||
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, perm.AccessModeNone, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,36 @@
|
||||
|
||||
This document describes maintainer expectations, project governance, and the detailed pull request review workflow (labels, merge queue, commit message format for mergers). For what contributors should do when opening and updating a PR, see [CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Community governance and review process](#community-governance-and-review-process)
|
||||
- [Table of contents](#table-of-contents)
|
||||
- [Code review](#code-review)
|
||||
- [Milestone](#milestone)
|
||||
- [Labels](#labels)
|
||||
- [Reviewing PRs](#reviewing-prs)
|
||||
- [For reviewers](#for-reviewers)
|
||||
- [Getting PRs merged](#getting-prs-merged)
|
||||
- [Final call](#final-call)
|
||||
- [Commit messages](#commit-messages)
|
||||
- [PR Co-authors](#pr-co-authors)
|
||||
- [PRs targeting `main`](#prs-targeting-main)
|
||||
- [Backport PRs](#backport-prs)
|
||||
- [Contribution Roles](#contribution-roles)
|
||||
- [Maintainers](#maintainers)
|
||||
- [Review expectations](#review-expectations)
|
||||
- [Becoming a maintainer](#becoming-a-maintainer)
|
||||
- [Stepping down, advisors, and inactivity](#stepping-down-advisors-and-inactivity)
|
||||
- [Account security](#account-security)
|
||||
- [Mergers](#mergers)
|
||||
- [Becoming a merger](#becoming-a-merger)
|
||||
- [Technical Oversight Committee (TOC)](#technical-oversight-committee-toc)
|
||||
- [TOC election process](#toc-election-process)
|
||||
- [Current TOC members](#current-toc-members)
|
||||
- [Previous TOC/owners members](#previous-tocowners-members)
|
||||
- [Governance Compensation](#governance-compensation)
|
||||
- [Roadmap](#roadmap)
|
||||
|
||||
## Code review
|
||||
|
||||
### Milestone
|
||||
@@ -92,7 +122,9 @@ $PR_TITLE ($INITIAL_PR_INDEX) ($BACKPORT_PR_INDEX)
|
||||
$REWRITTEN_PR_SUMMARY
|
||||
```
|
||||
|
||||
## Maintainers
|
||||
## Contribution Roles
|
||||
|
||||
### Maintainers
|
||||
|
||||
We list [maintainers](../MAINTAINERS) so every PR gets proper review.
|
||||
|
||||
@@ -121,12 +153,25 @@ For security, maintainers should enable 2FA and sign commits with GPG when possi
|
||||
|
||||
Any account with write access (including bots and TOC members) **must** use [2FA](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication).
|
||||
|
||||
## Technical Oversight Committee (TOC)
|
||||
### Mergers
|
||||
|
||||
Mergers are the maintainers who carry out the final merge of approved PRs. Their responsibilities, described throughout this guide, are:
|
||||
|
||||
- Merging PRs from the [merge queue](#getting-prs-merged) in order, once a PR has `lgtm/done`, no open discussions, and no merge conflicts.
|
||||
- Rewriting the PR title and summary so the squash [commit message](#commit-messages) is clear, removing false-positive co-authors while keeping every true co-author.
|
||||
- Assigning the correct labels (including `type/…`) needed for changelog and backport decisions.
|
||||
- Agreeing, together with the owners, on when a release is ready (see [release management](release-management.md)).
|
||||
|
||||
#### Becoming a merger
|
||||
|
||||
A merger should already be a Gitea maintainer. To apply, use the [Discord](https://discord.gg/Gitea) `#maintainers` channel. Mergers teams may also invite contributors.
|
||||
|
||||
### Technical Oversight Committee (TOC)
|
||||
|
||||
At the start of 2023, the `Owners` team was dissolved. Instead, the governance charter proposed a technical oversight committee (TOC) which expands the ownership team of the Gitea project from three elected positions to six positions. Three positions are elected as it has been over the past years, and the other three consist of appointed members from the Gitea company.
|
||||
https://blog.gitea.com/quarterly-23q1/
|
||||
|
||||
### TOC election process
|
||||
#### TOC election process
|
||||
|
||||
Any maintainer is eligible to be part of the community TOC if they are not associated with the Gitea company.
|
||||
A maintainer can either nominate themselves, or can be nominated by other maintainers to be a candidate for the TOC election.
|
||||
@@ -142,7 +187,7 @@ If an elected member that accepts the seat does not have 2FA configured yet, the
|
||||
|
||||
### Current TOC members
|
||||
|
||||
- 2024-01-01 ~ 2024-12-31
|
||||
- 2025-01-01 ~ 2026-06-14
|
||||
- Company
|
||||
- [Jason Song](https://gitea.com/wolfogre) <i@wolfogre.com>
|
||||
- [Lunny Xiao](https://gitea.com/lunny) <xiaolunwen@gmail.com>
|
||||
@@ -150,7 +195,7 @@ If an elected member that accepts the seat does not have 2FA configured yet, the
|
||||
- Community
|
||||
- [6543](https://gitea.com/6543) <6543@obermui.de>
|
||||
- [delvh](https://gitea.com/delvh) <dev.lh@web.de>
|
||||
- [John Olheiser](https://gitea.com/jolheiser) <john.olheiser@gmail.com>
|
||||
- [lafriks](https://gitea.com/lafriks) <lauris@nix.lv>
|
||||
|
||||
### Previous TOC/owners members
|
||||
|
||||
@@ -163,7 +208,7 @@ Here's the history of the owners and the time they served:
|
||||
- [Matti Ranta](https://gitea.com/techknowlogick) - [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023
|
||||
- [Andrew Thornton](https://gitea.com/zeripath) - [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023
|
||||
- [6543](https://gitea.com/6543) - 2023
|
||||
- [John Olheiser](https://gitea.com/jolheiser) - 2023
|
||||
- [John Olheiser](https://gitea.com/jolheiser) - 2023, 2024
|
||||
- [Jason Song](https://gitea.com/wolfogre) - 2023
|
||||
|
||||
## Governance Compensation
|
||||
@@ -176,18 +221,6 @@ These funds will come from community sources like the OpenCollective rather than
|
||||
Only non-company members are eligible for this compensation, and if a member of the community TOC takes the responsibility of release manager, they would only be compensated for their TOC duties.
|
||||
Gitea Ltd employees are not eligible to receive any funds from the OpenCollective unless it is reimbursement for a purchase made for the Gitea project itself.
|
||||
|
||||
## TOC & Working groups
|
||||
|
||||
With Gitea covering many projects outside of the main repository, several groups will be created to help focus on specific areas instead of requiring maintainers to be a jack-of-all-trades. Maintainers are of course more than welcome to be part of multiple groups should they wish to contribute in multiple places.
|
||||
|
||||
The currently proposed groups are:
|
||||
|
||||
- **Core Group**: maintain the primary Gitea repository
|
||||
- **Integration Group**: maintain the Gitea ecosystem's related tools, including go-sdk/tea/changelog/bots etc.
|
||||
- **Documentation Group**: maintain related documents and repositories
|
||||
- **Translation Group**: coordinate with translators and maintain translations
|
||||
- **Security Group**: managed by TOC directly, members are decided by TOC, maintains security patches/responsible for security items
|
||||
|
||||
## Roadmap
|
||||
|
||||
Each year a roadmap will be discussed with the entire Gitea maintainers team, and feedback will be solicited from various stakeholders.
|
||||
|
||||
5
go.mod
5
go.mod
@@ -22,7 +22,7 @@ require (
|
||||
github.com/Necoro/html2text v0.0.0-20250804200300-7bf1ce1c7347
|
||||
github.com/ProtonMail/go-crypto v1.4.1
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.3
|
||||
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.10.0
|
||||
github.com/alecthomas/chroma/v2 v2.25.0
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.17
|
||||
github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.14
|
||||
@@ -57,7 +57,7 @@ require (
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f
|
||||
github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/go-github/v87 v87.0.0
|
||||
github.com/google/go-github/v88 v88.0.0
|
||||
github.com/google/licenseclassifier/v2 v2.0.0
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -193,7 +193,6 @@ require (
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
|
||||
10
go.sum
10
go.sum
@@ -71,8 +71,8 @@ github.com/RoaringBitmap/roaring/v2 v2.16.0 h1:Kys1UNf49d5W8Tq3bpuAhIr/Z8/yPB+59
|
||||
github.com/RoaringBitmap/roaring/v2 v2.16.0/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
|
||||
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.3 h1:ikrUPushSxbpr9mmDhSgz1KyUTChjwkDrxY/jzv2OTQ=
|
||||
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.3/go.mod h1:bnXbvnI9Mfqdj4L3Y9aCsB1A/ztuYLNRzgVKsJFgR/U=
|
||||
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.10.0 h1:LvK7+C6qgz8BPnmn7xGekf8vTkcqTvyBBHaLYIMxx0g=
|
||||
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:I28hc9eaiqKCoOB+9Wh/P1IOScfm0xCgyWC6DAo0lmo=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
@@ -365,8 +365,6 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
|
||||
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
@@ -378,8 +376,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github/v87 v87.0.0 h1:9Ck3dcOxWJyfsN8tzdah4YvmqB/7ZsstMglv/PkOsl0=
|
||||
github.com/google/go-github/v87 v87.0.0/go.mod h1:hGUoT5pwm/ck5uLL+wroSVQfg8mpe+buxllCcGV4VaM=
|
||||
github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M=
|
||||
github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -671,18 +672,18 @@ func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error)
|
||||
func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0)
|
||||
|
||||
if c, err := cancelOneJob(ctx, caller); err != nil {
|
||||
return cancelledJobs, err
|
||||
} else if c != nil {
|
||||
cancelledJobs = append(cancelledJobs, c)
|
||||
}
|
||||
|
||||
attemptJobs, err := GetRunJobsByRunAndAttemptID(ctx, caller.RunID, caller.RunAttemptID)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
|
||||
for _, c := range CollectAllDescendantJobs(caller, attemptJobs) {
|
||||
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
|
||||
// so each child must reach its final state before its parent caller is re-aggregated.
|
||||
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
|
||||
descendants := CollectAllDescendantJobs(caller, attemptJobs)
|
||||
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
|
||||
|
||||
for _, c := range descendants {
|
||||
cancelled, err := cancelOneJob(ctx, c)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
@@ -691,5 +692,11 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionR
|
||||
cancelledJobs = append(cancelledJobs, cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
if c, err := cancelOneJob(ctx, caller); err != nil {
|
||||
return cancelledJobs, err
|
||||
} else if c != nil {
|
||||
cancelledJobs = append(cancelledJobs, c)
|
||||
}
|
||||
return cancelledJobs, nil
|
||||
}
|
||||
|
||||
207
models/actions/run_job_summary.go
Normal file
207
models/actions/run_job_summary.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// JobSummaryCapability is the runner-declare capability string for job summaries.
|
||||
JobSummaryCapability = "job-summary"
|
||||
|
||||
// JobSummaryContentTypeMarkdown is the only accepted content type for job summaries.
|
||||
JobSummaryContentTypeMarkdown = "text/markdown"
|
||||
|
||||
// MaxJobSummarySize is the maximum accepted per-step summary payload size in bytes.
|
||||
MaxJobSummarySize = 1024 * 1024 // 1 MiB
|
||||
|
||||
// MaxJobSummaryAggregateSize is the maximum aggregate size of all step summaries within
|
||||
// a single job attempt. Matches GitHub's documented per-job summary cap of 1 MiB.
|
||||
MaxJobSummaryAggregateSize = 1024 * 1024 // 1 MiB
|
||||
)
|
||||
|
||||
// RunnerCapabilities returns the value advertised in the X-Gitea-Actions-Capabilities header.
|
||||
// When more capabilities are added, return them comma-separated so runners can split on ", ".
|
||||
func RunnerCapabilities() string {
|
||||
return JobSummaryCapability
|
||||
}
|
||||
|
||||
type ActionRunJobSummary struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
|
||||
RepoID int64 `xorm:"UNIQUE(summary_key)"`
|
||||
RunID int64 `xorm:"UNIQUE(summary_key)"`
|
||||
RunAttemptID int64 `xorm:"UNIQUE(summary_key) NOT NULL DEFAULT 0"`
|
||||
JobID int64 `xorm:"UNIQUE(summary_key)"`
|
||||
StepIndex int64 `xorm:"UNIQUE(summary_key)"`
|
||||
|
||||
Content string `xorm:"LONGTEXT"`
|
||||
ContentType string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'text/markdown'"`
|
||||
// ContentSize is the byte length of Content. Stored explicitly because LENGTH()
|
||||
// counts characters (not bytes) on PostgreSQL, SQLite and MSSQL, which would let
|
||||
// multibyte UTF-8 content bypass the aggregate cap.
|
||||
ContentSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(ActionRunJobSummary))
|
||||
}
|
||||
|
||||
func GetActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64) (*ActionRunJobSummary, error) {
|
||||
var s ActionRunJobSummary
|
||||
has, err := db.GetEngine(ctx).
|
||||
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index=?", repoID, runID, runAttemptID, jobID, stepIndex).
|
||||
Get(&s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, util.ErrNotExist
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// ErrJobSummaryAggregateExceeded is returned when a step summary upload would push the
|
||||
// aggregate size of summaries for a single job attempt over MaxJobSummaryAggregateSize.
|
||||
var ErrJobSummaryAggregateExceeded = util.NewInvalidArgumentErrorf("job summary aggregate size exceeded")
|
||||
|
||||
func UpsertActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64, contentType string, content []byte) error {
|
||||
if runID <= 0 || jobID <= 0 || repoID <= 0 || stepIndex < 0 {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
if len(content) == 0 {
|
||||
// Treat empty summaries as no-op; runner may create SUMMARY.md but never write to it.
|
||||
return nil
|
||||
}
|
||||
if len(content) > MaxJobSummarySize {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
if contentType != JobSummaryContentTypeMarkdown {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// The aggregate check is best-effort: a tx wouldn't actually serialize concurrent
|
||||
// step uploads (no row-level lock on the parent job), so wrapping these two
|
||||
// statements only adds round-trip cost without changing the race semantics.
|
||||
// The current step is excluded because the upsert below replaces its size with len(content).
|
||||
otherSize, err := sumOtherJobSummarySizes(ctx, repoID, runID, runAttemptID, jobID, stepIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if otherSize+int64(len(content)) > MaxJobSummaryAggregateSize {
|
||||
return ErrJobSummaryAggregateExceeded
|
||||
}
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
return upsertActionRunJobSummary(ctx, &ActionRunJobSummary{
|
||||
RepoID: repoID,
|
||||
RunID: runID,
|
||||
RunAttemptID: runAttemptID,
|
||||
JobID: jobID,
|
||||
StepIndex: stepIndex,
|
||||
Content: string(content),
|
||||
ContentSize: int64(len(content)),
|
||||
ContentType: contentType,
|
||||
Created: now,
|
||||
Updated: now,
|
||||
})
|
||||
}
|
||||
|
||||
// sumOtherJobSummarySizes returns the total stored size of all step summaries for a job
|
||||
// except excludeStepIndex, computed in the database to avoid loading every row.
|
||||
func sumOtherJobSummarySizes(ctx context.Context, repoID, runID, runAttemptID, jobID, excludeStepIndex int64) (int64, error) {
|
||||
return db.GetEngine(ctx).
|
||||
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index<>?", repoID, runID, runAttemptID, jobID, excludeStepIndex).
|
||||
SumInt(new(ActionRunJobSummary), "content_size")
|
||||
}
|
||||
|
||||
// DeleteActionRunJobSummary removes the stored summary for a specific step. Used when
|
||||
// a runner PUTs an empty body to clear a previously-uploaded step summary.
|
||||
func DeleteActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64) error {
|
||||
_, err := db.GetEngine(ctx).
|
||||
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index=?", repoID, runID, runAttemptID, jobID, stepIndex).
|
||||
Delete(new(ActionRunJobSummary))
|
||||
return err
|
||||
}
|
||||
|
||||
func upsertActionRunJobSummary(ctx context.Context, summary *ActionRunJobSummary) error {
|
||||
engine := db.GetEngine(ctx)
|
||||
columns := "`repo_id`, `run_id`, `run_attempt_id`, `job_id`, `step_index`, `content`, `content_type`, `content_size`, `created`, `updated`"
|
||||
values := []any{
|
||||
summary.RepoID,
|
||||
summary.RunID,
|
||||
summary.RunAttemptID,
|
||||
summary.JobID,
|
||||
summary.StepIndex,
|
||||
summary.Content,
|
||||
summary.ContentType,
|
||||
summary.ContentSize,
|
||||
summary.Created,
|
||||
summary.Updated,
|
||||
}
|
||||
|
||||
if setting.Database.Type.IsPostgreSQL() || setting.Database.Type.IsSQLite3() {
|
||||
args := append([]any{"INSERT INTO `action_run_job_summary` (" + columns + ") VALUES (?,?,?,?,?,?,?,?,?,?) " +
|
||||
"ON CONFLICT (`repo_id`, `run_id`, `run_attempt_id`, `job_id`, `step_index`) DO UPDATE SET " +
|
||||
"`content` = excluded.`content`, `content_type` = excluded.`content_type`, `content_size` = excluded.`content_size`, `updated` = excluded.`updated`"}, values...)
|
||||
_, err := engine.Exec(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.Database.Type.IsMySQL() {
|
||||
args := append([]any{
|
||||
"INSERT INTO `action_run_job_summary` (" + columns + ") VALUES (?,?,?,?,?,?,?,?,?,?) " +
|
||||
"ON DUPLICATE KEY UPDATE `content` = VALUES(`content`), `content_type` = VALUES(`content_type`), `content_size` = VALUES(`content_size`), `updated` = VALUES(`updated`)",
|
||||
}, values...)
|
||||
_, err := engine.Exec(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.Database.Type.IsMSSQL() {
|
||||
_, err := engine.Exec(`
|
||||
MERGE INTO action_run_job_summary WITH (HOLDLOCK) AS target
|
||||
USING (SELECT ? AS repo_id, ? AS run_id, ? AS run_attempt_id, ? AS job_id, ? AS step_index) AS source
|
||||
ON target.repo_id = source.repo_id
|
||||
AND target.run_id = source.run_id
|
||||
AND target.run_attempt_id = source.run_attempt_id
|
||||
AND target.job_id = source.job_id
|
||||
AND target.step_index = source.step_index
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET content = ?, content_type = ?, content_size = ?, updated = ?
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (repo_id, run_id, run_attempt_id, job_id, step_index, content, content_type, content_size, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
`,
|
||||
summary.RepoID, summary.RunID, summary.RunAttemptID, summary.JobID, summary.StepIndex,
|
||||
summary.Content, summary.ContentType, summary.ContentSize, summary.Updated,
|
||||
summary.RepoID, summary.RunID, summary.RunAttemptID, summary.JobID, summary.StepIndex, summary.Content, summary.ContentType, summary.ContentSize, summary.Created, summary.Updated)
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// ListActionRunJobSummaries lists the stored summaries for a run attempt, ordered by job
|
||||
// then step. A positive jobID scopes the lookup to that single job, used by the job view to
|
||||
// avoid rendering every job's summary on each poll; jobID<=0 returns all jobs in the attempt.
|
||||
func ListActionRunJobSummaries(ctx context.Context, repoID, runID, runAttemptID, jobID int64) ([]*ActionRunJobSummary, error) {
|
||||
sess := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=?", repoID, runID, runAttemptID)
|
||||
if jobID > 0 {
|
||||
sess = sess.And("job_id=?", jobID)
|
||||
}
|
||||
var summaries []*ActionRunJobSummary
|
||||
if err := sess.OrderBy("job_id ASC, step_index ASC").Find(&summaries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
@@ -131,3 +131,69 @@ func TestGetPriorAttemptChildrenByParent(t *testing.T) {
|
||||
assertAttempt1Children(t, out)
|
||||
})
|
||||
}
|
||||
|
||||
// A reusable caller subtree with a Blocked descendant (e.g. a nested caller stuck on an invalid `uses:`) must aggregate to Cancelled, when the run is cancelled.
|
||||
func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancel-nested-caller",
|
||||
RepoID: 4,
|
||||
Index: 9701,
|
||||
OwnerID: 1,
|
||||
WorkflowID: "caller.yaml",
|
||||
TriggerUserID: 1,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
EventPayload: "{}",
|
||||
Status: StatusBlocked,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, run))
|
||||
|
||||
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusBlocked}
|
||||
require.NoError(t, db.Insert(ctx, attempt))
|
||||
run.LatestAttemptID = attempt.ID
|
||||
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
|
||||
|
||||
newJob := func(name string, attemptJobID, parentID int64, callUses string) *ActionRunJob {
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: attempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: name,
|
||||
JobID: name,
|
||||
Attempt: 1,
|
||||
Status: StatusBlocked,
|
||||
AttemptJobID: attemptJobID,
|
||||
IsReusableCaller: true,
|
||||
CallUses: callUses,
|
||||
ParentJobID: parentID,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, job))
|
||||
return job
|
||||
}
|
||||
|
||||
// outer: a valid top-level caller that expanded; inner: a nested caller stuck Blocked (invalid uses, never expands).
|
||||
outer := newJob("outer", 1, 0, "./.gitea/workflows/lib.yml")
|
||||
inner := newJob("inner", 2, outer.ID, "https://other.example.com/o/r/.gitea/workflows/ci.yml@v1")
|
||||
|
||||
// Cancel all jobs of the attempt, ordered by id (parent before child).
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, run.ID, attempt.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = CancelJobs(ctx, jobs)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, j := range []*ActionRunJob{outer, inner} {
|
||||
got := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: j.ID})
|
||||
assert.Equal(t, StatusCancelled, got.Status, "job %q should be cancelled", j.JobID)
|
||||
}
|
||||
gotAttempt := unittest.AssertExistsAndLoadBean(t, &ActionRunAttempt{ID: attempt.ID})
|
||||
assert.Equal(t, StatusCancelled, gotAttempt.Status, "attempt must aggregate to Cancelled")
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ type FindRunOptions struct {
|
||||
Ref string // the commit/tag/… that caused this workflow
|
||||
TriggerUserID int64
|
||||
TriggerEvent webhook_module.HookEventType
|
||||
Approved bool // not util.OptionalBool, it works only when it's true
|
||||
Status []Status
|
||||
ConcurrencyGroup string
|
||||
CommitSHA string
|
||||
@@ -81,9 +80,6 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
if opts.TriggerUserID > 0 {
|
||||
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
|
||||
}
|
||||
if opts.Approved {
|
||||
cond = cond.And(builder.Gt{"`action_run`.approved_by": 0})
|
||||
}
|
||||
if len(opts.Status) > 0 {
|
||||
cond = cond.And(builder.In("`action_run`.status", opts.Status))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"hash"
|
||||
|
||||
"gitea.dev/models/gituser"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
@@ -32,8 +33,8 @@ type CommitVerification struct {
|
||||
|
||||
// SignCommit represents a commit with validation of signature.
|
||||
type SignCommit struct {
|
||||
Verification *CommitVerification
|
||||
*user_model.UserCommit
|
||||
Verification *CommitVerification
|
||||
*gituser.UserCommit // TODO: need to use a explicit field name, avoid anonymous field
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
@@ -14,61 +16,34 @@ import (
|
||||
"xorm.io/xorm/dialects"
|
||||
)
|
||||
|
||||
var registerOnce sync.Once
|
||||
type postgresSchemaDriver struct{}
|
||||
|
||||
func registerPostgresSchemaDriver() {
|
||||
registerOnce.Do(func() {
|
||||
sql.Register(sqlDriverPostgresSchema, &postgresSchemaDriver{})
|
||||
dialects.RegisterDriver(sqlDriverPostgresSchema, dialects.QueryDriver("postgres"))
|
||||
})
|
||||
}
|
||||
var registerPostgresSchemaDriver = sync.OnceFunc(func() {
|
||||
sql.Register(sqlDriverPostgresSchema, &postgresSchemaDriver{})
|
||||
dialects.RegisterDriver(sqlDriverPostgresSchema, dialects.QueryDriver("postgres"))
|
||||
})
|
||||
|
||||
type postgresSchemaDriver struct {
|
||||
pq.Driver
|
||||
}
|
||||
|
||||
// Open opens a new connection to the database. name is a connection string.
|
||||
// This function opens the postgres connection in the default manner but immediately
|
||||
// runs set_config to set the search_path appropriately
|
||||
func (d *postgresSchemaDriver) Open(name string) (driver.Conn, error) {
|
||||
conn, err := d.Driver.Open(name)
|
||||
// Open opens the postgres connection in the default manner with default schema support.
|
||||
// It immediately runs "set_config" to set the search_path appropriately.
|
||||
func (*postgresSchemaDriver) Open(connStr string) (driver.Conn, error) {
|
||||
conn, err := pq.Driver{}.Open(connStr)
|
||||
if err != nil {
|
||||
return conn, err
|
||||
return nil, err
|
||||
}
|
||||
schemaValue, _ := driver.String.ConvertValue(setting.Database.Schema)
|
||||
|
||||
// golangci lint is incorrect here - there is no benefit to using driver.ExecerContext here
|
||||
// and in any case pq does not implement it
|
||||
if execer, ok := conn.(driver.Execer); ok { //nolint:staticcheck // see above
|
||||
_, err := execer.Exec(`SELECT set_config(
|
||||
connExec, ok := conn.(driver.ExecerContext)
|
||||
if !ok {
|
||||
return nil, errors.New("postgres driver does not implement ExecerContext interface")
|
||||
}
|
||||
_, err = connExec.ExecContext(context.Background(), `SELECT set_config(
|
||||
'search_path',
|
||||
$1 || ',' || current_setting('search_path'),
|
||||
false)`, []driver.Value{schemaValue})
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
stmt, err := conn.Prepare(`SELECT set_config(
|
||||
'search_path',
|
||||
$1 || ',' || current_setting('search_path'),
|
||||
false)`)
|
||||
false)`,
|
||||
[]driver.NamedValue{{Ordinal: 1, Value: setting.Database.Schema}},
|
||||
)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
// driver.String.ConvertValue will never return err for string
|
||||
|
||||
// golangci lint is incorrect here - there is no benefit to using stmt.ExecWithContext here
|
||||
_, err = stmt.Exec([]driver.Value{schemaValue}) //nolint:staticcheck // see above
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
44
models/gituser/avatar_stack.go
Normal file
44
models/gituser/avatar_stack.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gituser
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// AvatarStackData is the view-model for the AvatarStack render helpers. Participants[0] is
|
||||
// the primary participant (commit author), painted on top; the rest follow.
|
||||
type AvatarStackData struct {
|
||||
Participants []*CommitParticipant
|
||||
SearchByEmailLink string
|
||||
}
|
||||
|
||||
func BuildAvatarStackData(ctx context.Context, allParticipants []*git.CommitIdentity, emailUserMap *user.EmailUserMap) *AvatarStackData {
|
||||
if emailUserMap == nil {
|
||||
emails := make([]string, len(allParticipants))
|
||||
for i, sig := range allParticipants {
|
||||
emails[i] = sig.Email
|
||||
}
|
||||
var err error
|
||||
emailUserMap, err = user.GetUsersByEmails(ctx, emails)
|
||||
if err != nil {
|
||||
log.Error("GetUsersByEmails failed: %v", err)
|
||||
}
|
||||
}
|
||||
ret := &AvatarStackData{
|
||||
Participants: make([]*CommitParticipant, 0, len(allParticipants)),
|
||||
}
|
||||
for _, p := range allParticipants {
|
||||
var giteaUser *user.User
|
||||
if emailUserMap != nil {
|
||||
giteaUser = emailUserMap.GetByEmail(p.Email)
|
||||
}
|
||||
ret.Participants = append(ret.Participants, &CommitParticipant{GiteaUser: giteaUser, GitIdentity: p})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
64
models/gituser/gituser.go
Normal file
64
models/gituser/gituser.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gituser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
)
|
||||
|
||||
// CommitParticipant is one participant of a commit (its author or a co-author):
|
||||
// a git identity, optionally matched to a Gitea user.
|
||||
type CommitParticipant struct {
|
||||
GitIdentity *git.CommitIdentity // git identity (name/email), never nil
|
||||
GiteaUser *user.User // matched Gitea user, nil if unmatched
|
||||
}
|
||||
|
||||
// UserCommit represents a commit with matched of database "author" user.
|
||||
type UserCommit struct {
|
||||
GitCommit *git.Commit
|
||||
AuthorUser *user.User
|
||||
AvatarStackData *AvatarStackData
|
||||
}
|
||||
|
||||
func RepoCommitSearchByEmailLink(repoLink string, ref git.RefName) string {
|
||||
if curRefWebLinkPath := ref.RefWebLinkPath(); curRefWebLinkPath != "" {
|
||||
return repoLink + "/commits/" + curRefWebLinkPath + "/search?q=" + url.QueryEscape("author:") + "{email}"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetUserCommitsByGitCommits checks if authors' e-mails of commits are corresponding to users.
|
||||
func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, repoLink string, currentRef git.RefName) ([]*UserCommit, error) {
|
||||
userCommits := make([]*UserCommit, 0, len(gitCommits))
|
||||
emailSet := make(container.Set[string])
|
||||
for _, c := range gitCommits {
|
||||
emailSet.Add(c.Author.Email)
|
||||
emailSet.Add(c.Committer.Email)
|
||||
for _, p := range c.AllParticipantIdentities() {
|
||||
emailSet.Add(p.Email)
|
||||
}
|
||||
}
|
||||
|
||||
emailUserMap, err := user.GetUsersByEmails(ctx, emailSet.Values())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
searchByEmailLink := RepoCommitSearchByEmailLink(repoLink, currentRef)
|
||||
for _, c := range gitCommits {
|
||||
uc := &UserCommit{
|
||||
AuthorUser: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"?
|
||||
GitCommit: c,
|
||||
AvatarStackData: BuildAvatarStackData(ctx, c.AllParticipantIdentities(), emailUserMap),
|
||||
}
|
||||
uc.AvatarStackData.SearchByEmailLink = searchByEmailLink
|
||||
userCommits = append(userCommits, uc)
|
||||
}
|
||||
return userCommits, nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
@@ -860,6 +861,11 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
|
||||
return rules, warnings
|
||||
}
|
||||
|
||||
// codeOwnerMatchTimeout bounds a single pattern match so a crafted pattern
|
||||
// cannot stall via catastrophic backtracking. See also the aggregate budget
|
||||
// enforced by the caller across the whole rules×files match loop.
|
||||
const codeOwnerMatchTimeout = 150 * time.Millisecond
|
||||
|
||||
type CodeOwnerRule struct {
|
||||
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
||||
Negative bool
|
||||
@@ -888,6 +894,8 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule,
|
||||
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
||||
return nil, warnings
|
||||
}
|
||||
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
|
||||
rule.Rule.MatchTimeout = codeOwnerMatchTimeout
|
||||
|
||||
for _, user := range tokens[1:] {
|
||||
user = strings.TrimPrefix(user, "@")
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package issues_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
@@ -39,6 +41,7 @@ func TestPullRequest(t *testing.T) {
|
||||
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
||||
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
||||
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
||||
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
|
||||
t.Run("GetApprovers", testGetApprovers)
|
||||
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
||||
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
||||
@@ -376,6 +379,22 @@ func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testCodeOwnerPatternMatchTimeout ensures user-supplied CODEOWNERS patterns
|
||||
// cannot stall pull request processing through catastrophic regex backtracking:
|
||||
// each compiled rule must enforce a bounded match time.
|
||||
func testCodeOwnerPatternMatchTimeout(t *testing.T) {
|
||||
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), "(a+)+ @user5\n")
|
||||
require.Len(t, rules, 1)
|
||||
|
||||
maliciousInput := strings.Repeat("a", 30) + "X"
|
||||
start := time.Now()
|
||||
_, err := rules[0].Rule.MatchString(maliciousInput)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Error(t, err, "expected MatchTimeout error on pathological input")
|
||||
assert.Less(t, elapsed, time.Second, "match timeout did not bound regex evaluation; took %s", elapsed)
|
||||
}
|
||||
|
||||
func testGetApprovers(t *testing.T) {
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
||||
// Official reviews are already deduplicated. Allow unofficial reviews
|
||||
|
||||
@@ -413,6 +413,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(333, "Add bypass allowlist to branch protection", v1_27.AddBranchProtectionBypassAllowlist),
|
||||
newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner),
|
||||
newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob),
|
||||
newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
30
models/migrations/v1_27/v336.go
Normal file
30
models/migrations/v1_27/v336.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
func AddActionRunJobSummaryTable(x db.EngineMigration) error {
|
||||
type ActionRunJobSummary struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
|
||||
RepoID int64 `xorm:"UNIQUE(summary_key)"`
|
||||
RunID int64 `xorm:"UNIQUE(summary_key)"`
|
||||
RunAttemptID int64 `xorm:"UNIQUE(summary_key) NOT NULL DEFAULT 0"`
|
||||
JobID int64 `xorm:"UNIQUE(summary_key)"`
|
||||
StepIndex int64 `xorm:"UNIQUE(summary_key)"`
|
||||
|
||||
Content string `xorm:"LONGTEXT"`
|
||||
ContentType string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'text/markdown'"`
|
||||
ContentSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
return x.Sync(new(ActionRunJobSummary))
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/git"
|
||||
giturl "gitea.dev/modules/git/url"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
@@ -641,12 +642,7 @@ func (repo *Repository) CanContentChange() bool {
|
||||
|
||||
// DescriptionHTML does special handles to description and return HTML string.
|
||||
func (repo *Repository) DescriptionHTML(ctx context.Context) template.HTML {
|
||||
desc, err := markup.PostProcessDescriptionHTML(markup.NewRenderContext(ctx), repo.Description)
|
||||
if err != nil {
|
||||
log.Error("Failed to render description for %s (ID: %d): %v", repo.Name, repo.ID, err)
|
||||
return template.HTML(markup.SanitizeDescription(repo.Description))
|
||||
}
|
||||
return template.HTML(markup.SanitizeDescription(desc))
|
||||
return markup.PostProcessDescriptionHTML(markup.NewRenderContext(ctx), htmlutil.EscapeString(repo.Description))
|
||||
}
|
||||
|
||||
// CloneLink represents different types of clone URLs of repository.
|
||||
|
||||
@@ -47,7 +47,7 @@ func OrderBy(orderBy string) any {
|
||||
}
|
||||
|
||||
func whereOrderConditions(e db.Engine, conditions []any) db.Engine {
|
||||
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic
|
||||
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic. FIXME: some tables do not have "id" column
|
||||
for _, condition := range conditions {
|
||||
switch cond := condition.(type) {
|
||||
case *testCond:
|
||||
|
||||
@@ -1148,14 +1148,7 @@ func GetUsersBySource(ctx context.Context, s *auth.Source) ([]*User, error) {
|
||||
return users, err
|
||||
}
|
||||
|
||||
// UserCommit represents a commit with validation of user.
|
||||
type UserCommit struct { //revive:disable-line:exported
|
||||
User *User
|
||||
*git.Commit
|
||||
}
|
||||
|
||||
// ValidateCommitWithEmail check if author's e-mail of commit is corresponding to a user.
|
||||
func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User {
|
||||
func GetUserByGitAuthor(ctx context.Context, c *git.Commit) *User {
|
||||
if c.Author == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1166,33 +1159,6 @@ func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User {
|
||||
return u
|
||||
}
|
||||
|
||||
// ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users.
|
||||
func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([]*UserCommit, error) {
|
||||
var (
|
||||
newCommits = make([]*UserCommit, 0, len(oldCommits))
|
||||
emailSet = make(container.Set[string])
|
||||
)
|
||||
for _, c := range oldCommits {
|
||||
if c.Author != nil {
|
||||
emailSet.Add(c.Author.Email)
|
||||
}
|
||||
}
|
||||
|
||||
emailUserMap, err := GetUsersByEmails(ctx, emailSet.Values())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, c := range oldCommits {
|
||||
user := emailUserMap.GetByEmail(c.Author.Email) // FIXME: why ValidateCommitsWithEmails uses "Author", but ParseCommitsWithSignature uses "Committer"?
|
||||
newCommits = append(newCommits, &UserCommit{
|
||||
User: user,
|
||||
Commit: c,
|
||||
})
|
||||
}
|
||||
return newCommits, nil
|
||||
}
|
||||
|
||||
type EmailUserMap struct {
|
||||
m map[string]*User
|
||||
}
|
||||
@@ -1203,7 +1169,7 @@ func (eum *EmailUserMap) GetByEmail(email string) *User {
|
||||
|
||||
func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, error) {
|
||||
if len(emails) == 0 {
|
||||
return nil, nil //nolint:nilnil // return nil when there are no emails to look up
|
||||
return &EmailUserMap{}, nil
|
||||
}
|
||||
|
||||
needCheckEmails := make(container.Set[string])
|
||||
|
||||
12
modules/consts/asymkey.go
Normal file
12
modules/consts/asymkey.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consts
|
||||
|
||||
const (
|
||||
AsymKeyMinBitsRsa = 3071 // 3072-1 to tolerate the leading zero
|
||||
AsymKeyMinBitsEC = 256
|
||||
|
||||
AsymKeyDefaultBitsRsa = 4096 // ssh-keygen command defaults to 3072
|
||||
AsymKeyDefaultBitsEcdsa = 256
|
||||
)
|
||||
@@ -5,15 +5,23 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/consts"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// NewInternalToken generate a new value intended to be used by INTERNAL_TOKEN.
|
||||
@@ -67,3 +75,75 @@ func NewJwtSecretWithBase64() ([]byte, string) {
|
||||
func NewSecretKey() (string, error) {
|
||||
return util.CryptoRandomString(64), nil
|
||||
}
|
||||
|
||||
type SSHKeyType string
|
||||
|
||||
const (
|
||||
SSHKeyRSA SSHKeyType = "rsa"
|
||||
SSHKeyECDSA SSHKeyType = "ecdsa"
|
||||
SSHKeyED25519 SSHKeyType = "ed25519"
|
||||
)
|
||||
|
||||
func NewSSHKey(keyType SSHKeyType, bits int) (ssh.PublicKey, *pem.Block, error) {
|
||||
pub, priv, err := commonKeyGen(keyType, bits)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pemPriv, err := ssh.MarshalPrivateKey(priv, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sshPub, err := ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return sshPub, pemPriv, nil
|
||||
}
|
||||
|
||||
// commonKeyGen is an abstraction over rsa, ecdsa, and ed25519 generating functions
|
||||
func commonKeyGen(keyType SSHKeyType, bits int) (crypto.PublicKey, crypto.PrivateKey, error) {
|
||||
switch keyType {
|
||||
case SSHKeyRSA:
|
||||
bits = util.IfZero(bits, consts.AsymKeyDefaultBitsRsa)
|
||||
if bits < consts.AsymKeyMinBitsRsa {
|
||||
return nil, nil, util.NewInvalidArgumentErrorf("invalid rsa bits: %d", bits)
|
||||
}
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, bits)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &privateKey.PublicKey, privateKey, nil
|
||||
case SSHKeyED25519:
|
||||
return ed25519.GenerateKey(rand.Reader)
|
||||
case SSHKeyECDSA:
|
||||
bits = util.IfZero(bits, consts.AsymKeyDefaultBitsEcdsa)
|
||||
if bits < consts.AsymKeyMinBitsEC {
|
||||
return nil, nil, util.NewInvalidArgumentErrorf("invalid elliptic-curve bits: %d", bits)
|
||||
}
|
||||
curve, err := getEllipticCurve(bits)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
privateKey, err := ecdsa.GenerateKey(curve, rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &privateKey.PublicKey, privateKey, nil
|
||||
default:
|
||||
return nil, nil, util.NewInvalidArgumentErrorf("unknown key type: %s", keyType)
|
||||
}
|
||||
}
|
||||
|
||||
func getEllipticCurve(bits int) (elliptic.Curve, error) {
|
||||
switch bits {
|
||||
case 256:
|
||||
return elliptic.P256(), nil
|
||||
case 384:
|
||||
return elliptic.P384(), nil
|
||||
case 521:
|
||||
return elliptic.P521(), nil
|
||||
default:
|
||||
return nil, util.NewInvalidArgumentErrorf("unsupported elliptic-curve bits: %d", bits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,10 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type CommitMessage struct {
|
||||
MessageRaw string
|
||||
messageUTF8 *string
|
||||
messageTitle *string
|
||||
messageBody *string
|
||||
}
|
||||
|
||||
// Commit represents a git commit.
|
||||
type Commit struct {
|
||||
Tree // FIXME: bad design, this field can be nil if the commit is from "last commit cache"
|
||||
@@ -44,30 +36,6 @@ type CommitSignature struct {
|
||||
Payload string
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageUTF8() string {
|
||||
if c.messageUTF8 == nil {
|
||||
bs := charset.ToUTF8(util.UnsafeStringToBytes(c.MessageRaw), charset.ConvertOpts{ErrorReplacement: []byte{'?'}})
|
||||
c.messageUTF8 = new(util.UnsafeBytesToString(bs))
|
||||
}
|
||||
return *c.messageUTF8
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageTitle() string {
|
||||
if c.messageTitle == nil {
|
||||
s, _, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n")
|
||||
c.messageTitle = new(strings.TrimSpace(s))
|
||||
}
|
||||
return *c.messageTitle
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageBody() string {
|
||||
if c.messageBody == nil {
|
||||
_, s, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n")
|
||||
c.messageBody = new(strings.TrimSpace(s))
|
||||
}
|
||||
return *c.messageBody
|
||||
}
|
||||
|
||||
// ParentID returns oid of n-th parent (0-based index).
|
||||
// It returns nil if no such parent exists.
|
||||
func (c *Commit) ParentID(n int) (ObjectID, error) {
|
||||
|
||||
131
modules/git/commit_message.go
Normal file
131
modules/git/commit_message.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// CoAuthoredByTrailer is the canonical token for the `Co-authored-by:` git trailer.
|
||||
const CoAuthoredByTrailer = "Co-authored-by"
|
||||
|
||||
type CommitIdentity struct {
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
// CommitMessageTrailerValues keys are all in lower-case
|
||||
type CommitMessageTrailerValues map[string][]string
|
||||
|
||||
type CommitMessage struct {
|
||||
MessageRaw string
|
||||
messageUTF8 *string
|
||||
messageTitle *string
|
||||
messageBody *string
|
||||
|
||||
trailerValues CommitMessageTrailerValues
|
||||
|
||||
allParticipants []*CommitIdentity
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageUTF8() string {
|
||||
if c.messageUTF8 == nil {
|
||||
bs := charset.ToUTF8(util.UnsafeStringToBytes(c.MessageRaw), charset.ConvertOpts{ErrorReplacement: []byte{'?'}})
|
||||
c.messageUTF8 = new(util.UnsafeBytesToString(bs))
|
||||
}
|
||||
return *c.messageUTF8
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageTitle() string {
|
||||
if c.messageTitle == nil {
|
||||
s, _, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n")
|
||||
c.messageTitle = new(strings.TrimSpace(s))
|
||||
}
|
||||
return *c.messageTitle
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageBody() string {
|
||||
if c.messageBody == nil {
|
||||
_, s, _ := strings.Cut(strings.TrimSpace(c.MessageUTF8()), "\n")
|
||||
c.messageBody = new(strings.TrimSpace(s))
|
||||
}
|
||||
return *c.messageBody
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues {
|
||||
if c.trailerValues == nil {
|
||||
_, _, trailer := CommitMessageSplitTrailer(c.MessageUTF8())
|
||||
c.trailerValues = CommitMessageParseTrailer(trailer)
|
||||
}
|
||||
return c.trailerValues
|
||||
}
|
||||
|
||||
var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp {
|
||||
// the sep is either something like "\n---\n" or "\n\n" in the body, or at the start of the body like "---\n"
|
||||
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n|\n-{3,}\n|\n\n)(?P<trailer>(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*)$`)
|
||||
})
|
||||
|
||||
func CommitMessageSplitTrailer(s string) (content, sep, trailer string) {
|
||||
s = util.NormalizeStringEOL(s)
|
||||
re := commitMessageTrailerSplit()
|
||||
v := re.FindStringSubmatch(s)
|
||||
if v == nil {
|
||||
return s, "", ""
|
||||
}
|
||||
return v[re.SubexpIndex("content")], v[re.SubexpIndex("sep")], v[re.SubexpIndex("trailer")]
|
||||
}
|
||||
|
||||
func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
||||
ret := CommitMessageTrailerValues{}
|
||||
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
kLower := strings.ToLower(k)
|
||||
ret[kLower] = append(ret[kLower], v)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// AllParticipantIdentities returns all the participants in the commit, the first one is the commit's author
|
||||
func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
|
||||
if c.allParticipants != nil {
|
||||
return c.allParticipants
|
||||
}
|
||||
|
||||
exclude := container.Set[string]{}
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: c.Author.Name, Email: c.Author.Email})
|
||||
exclude.Add(strings.ToLower(c.Author.Email))
|
||||
|
||||
addParticipant := func(name, email string) {
|
||||
if name == "" && email == "" {
|
||||
return
|
||||
}
|
||||
emailLower := strings.ToLower(email)
|
||||
if emailLower != "" && exclude.Contains(emailLower) {
|
||||
return
|
||||
}
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email})
|
||||
exclude.Add(emailLower)
|
||||
}
|
||||
addParticipant(c.Committer.Name, c.Committer.Email)
|
||||
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
|
||||
addr, err := mail.ParseAddress(coAuthorValue)
|
||||
if err == nil {
|
||||
addParticipant(addr.Name, addr.Address)
|
||||
} else {
|
||||
addParticipant(coAuthorValue, "")
|
||||
}
|
||||
}
|
||||
return c.allParticipants
|
||||
}
|
||||
80
modules/git/commit_message_test.go
Normal file
80
modules/git/commit_message_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCommitMessageSanitizesInvalidUTF8(t *testing.T) {
|
||||
commit := &Commit{
|
||||
CommitMessage: CommitMessage{MessageRaw: "title \xff\n\n\n\nbody \xff\n\n\n"},
|
||||
}
|
||||
assert.Equal(t, "title ÿ", commit.MessageTitle())
|
||||
assert.Equal(t, "body ÿ", commit.MessageBody())
|
||||
assert.Equal(t, "title ÿ\n\n\n\nbody ÿ\n\n\n", commit.MessageUTF8())
|
||||
}
|
||||
|
||||
func TestCommitMessageTrailer(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg, body, sep, trailer string
|
||||
}{
|
||||
{"", "", "", ""},
|
||||
{"a", "a", "", ""},
|
||||
{"a\n\nk", "a\n\nk", "", ""},
|
||||
{"a\n\nk:v", "a", "\n\n", "k:v"},
|
||||
{"a\n--\nk:v", "a\n--\nk:v", "", ""},
|
||||
{"a\n---\nk:v", "a", "\n---\n", "k:v"},
|
||||
|
||||
{"k: v", "", "", "k: v"},
|
||||
{"\nk:v", "", "\n", "k:v"},
|
||||
{"\n\nk:v", "", "\n\n", "k:v"},
|
||||
|
||||
{"---\nk:v", "", "---\n", "k:v"},
|
||||
{"\n---\nk:v", "", "\n---\n", "k:v"},
|
||||
{"a:b\n---\nk:v", "a:b", "\n---\n", "k:v"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
body, sep, trailer := CommitMessageSplitTrailer(c.msg)
|
||||
assert.Equal(t, c.body, body, "input=%q", c.msg)
|
||||
assert.Equal(t, c.sep, sep, "input=%q", c.msg)
|
||||
assert.Equal(t, c.trailer, trailer, "input=%q", c.msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitMessageAllParticipantIdentities(t *testing.T) {
|
||||
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
|
||||
idt := func(n, e string) *CommitIdentity { return &CommitIdentity{Name: n, Email: e} }
|
||||
cases := []struct {
|
||||
commit *Commit
|
||||
participant []*CommitIdentity
|
||||
}{
|
||||
{
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com"), idt("c", "c@m.com"), idt("", "x@m.com")},
|
||||
},
|
||||
{
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com")},
|
||||
},
|
||||
{
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("", ""),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com"), idt("Full Name", "X@M.com")},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.participant, c.commit.AllParticipantIdentities())
|
||||
}
|
||||
}
|
||||
@@ -159,15 +159,6 @@ ISO-8859-1`, commitFromReader.Signature.Payload)
|
||||
assert.Equal(t, commitFromReader, commitFromReader2)
|
||||
}
|
||||
|
||||
func TestCommitMessageSanitizesInvalidUTF8(t *testing.T) {
|
||||
commit := &Commit{
|
||||
CommitMessage: CommitMessage{MessageRaw: "title \xff\n\n\n\nbody \xff\n\n\n"},
|
||||
}
|
||||
assert.Equal(t, "title ÿ", commit.MessageTitle())
|
||||
assert.Equal(t, "body ÿ", commit.MessageBody())
|
||||
assert.Equal(t, "title ÿ\n\n\n\nbody ÿ\n\n\n", commit.MessageUTF8())
|
||||
}
|
||||
|
||||
func TestHasPreviousCommit(t *testing.T) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
|
||||
|
||||
@@ -46,10 +46,8 @@ func syncGitConfig(ctx context.Context) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
if DefaultFeatures().CheckVersionAtLeast("2.10") {
|
||||
if err := configSet(ctx, "receive.advertisePushOptions", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := configSet(ctx, "receive.advertisePushOptions", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if DefaultFeatures().CheckVersionAtLeast("2.18") {
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
const RequiredVersion = "2.6.0" // the minimum Git version required
|
||||
const RequiredVersion = "2.13.0" // the minimum Git version required
|
||||
|
||||
type Features struct {
|
||||
gitVersion *version.Version
|
||||
@@ -173,13 +173,6 @@ func InitFull() (err error) {
|
||||
if err = InitSimple(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.LFS.StartServer {
|
||||
if !DefaultFeatures().CheckVersionAtLeast("2.1.2") {
|
||||
return errors.New("LFS server support requires Git >= 2.1.2")
|
||||
}
|
||||
}
|
||||
|
||||
return syncGitConfig(context.Background())
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type RunStdError interface {
|
||||
@@ -41,32 +43,14 @@ func (r *runStdError) Stderr() string {
|
||||
}
|
||||
|
||||
func ErrorAsStderr(err error) (string, bool) {
|
||||
var runErr RunStdError
|
||||
if errors.As(err, &runErr) {
|
||||
if runErr, ok := errors.AsType[RunStdError](err); ok {
|
||||
return runErr.Stderr(), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func StderrHasPrefix(err error, prefix string) bool {
|
||||
stderr, ok := ErrorAsStderr(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(stderr, prefix)
|
||||
}
|
||||
|
||||
func StderrContains(err error, sub string) bool {
|
||||
stderr, ok := ErrorAsStderr(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(stderr, sub)
|
||||
}
|
||||
|
||||
func IsErrorExitCode(err error, code int) bool {
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
if exitError, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
return exitError.ExitCode() == code
|
||||
}
|
||||
return false
|
||||
@@ -85,11 +69,41 @@ func IsErrorCanceledOrKilled(err error) bool {
|
||||
return errors.Is(err, context.Canceled) || IsErrorSignalKilled(err)
|
||||
}
|
||||
|
||||
func IsStdErrorNotValidObjectName(err error) bool {
|
||||
type StderrPrefix string
|
||||
|
||||
type StderrSubStr string
|
||||
|
||||
const (
|
||||
StderrNotValidObjectName StderrPrefix = "fatal: not a valid object name"
|
||||
StderrNotTreeObject StderrPrefix = "fatal: not a tree object"
|
||||
StderrPathSpec StderrPrefix = "fatal: pathspec"
|
||||
StderrBadRevision StderrPrefix = "fatal: bad revision"
|
||||
|
||||
StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128
|
||||
StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2
|
||||
|
||||
// fatal: ambiguous argument 'origin': unknown revision or path not in the working tree.
|
||||
StderrUnknownRevisionOrPath StderrSubStr = "unknown revision or path not in the working tree"
|
||||
)
|
||||
|
||||
func IsStderr[T StderrPrefix | StderrSubStr](err error, check T) bool {
|
||||
stderr, ok := ErrorAsStderr(err)
|
||||
// Git is lowercasing the "fatal: Not a valid object name" error message
|
||||
// ref: https://lore.kernel.org/git/pull.2052.git.1771836302101.gitgitgadget@gmail.com
|
||||
return ok && strings.Contains(strings.ToLower(stderr), "fatal: not a valid object name")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
checkLen := len(check)
|
||||
if len(stderr) < checkLen {
|
||||
return false
|
||||
}
|
||||
switch any(check).(type) {
|
||||
case StderrPrefix:
|
||||
// Git is lowercasing the "fatal: Not a valid object name" error message
|
||||
// ref: https://lore.kernel.org/git/pull.2052.git.1771836302101.gitgitgadget@gmail.com
|
||||
return util.AsciiEqualFold(stderr[:checkLen], string(check))
|
||||
case StderrSubStr:
|
||||
return strings.Contains(stderr, string(check))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type pipelineError struct {
|
||||
|
||||
@@ -15,13 +15,7 @@ import (
|
||||
|
||||
// GetRemoteAddress returns remote url of git repository in the repoPath with special remote name
|
||||
func GetRemoteAddress(ctx context.Context, repoPath, remoteName string) (string, error) {
|
||||
var cmd *gitcmd.Command
|
||||
if DefaultFeatures().CheckVersionAtLeast("2.7") {
|
||||
cmd = gitcmd.NewCommand("remote", "get-url").AddDynamicArguments(remoteName)
|
||||
} else {
|
||||
cmd = gitcmd.NewCommand("config", "--get").AddDynamicArguments("remote." + remoteName + ".url")
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand("remote", "get-url").AddDynamicArguments(remoteName)
|
||||
result, _, err := cmd.WithDir(repoPath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -72,11 +66,7 @@ func (err *ErrInvalidCloneAddr) Unwrap() error {
|
||||
|
||||
// IsRemoteNotExistError checks the prefix of the error message to see whether a remote does not exist.
|
||||
func IsRemoteNotExistError(err error) bool {
|
||||
// see: https://github.com/go-gitea/gitea/issues/32889#issuecomment-2571848216
|
||||
// Should not add space in the end, sometimes git will add a `:`
|
||||
prefix1 := "fatal: No such remote" // git < 2.30, exit status 128
|
||||
prefix2 := "error: No such remote" // git >= 2.30. exit status 2
|
||||
return gitcmd.StderrHasPrefix(err, prefix1) || gitcmd.StderrHasPrefix(err, prefix2)
|
||||
return gitcmd.IsStderr(err, gitcmd.StderrNoSuchRemote1) || gitcmd.IsStderr(err, gitcmd.StderrNoSuchRemote2)
|
||||
}
|
||||
|
||||
// ParseRemoteAddr checks if given remote address is valid,
|
||||
|
||||
@@ -7,7 +7,6 @@ package git
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -25,9 +24,9 @@ func (repo *Repository) GetTagCommitID(name string) (string, error) {
|
||||
return repo.GetRefCommitID(TagPrefix + name)
|
||||
}
|
||||
|
||||
// GetCommit returns commit object of by ID string.
|
||||
func (repo *Repository) GetCommit(commitID string) (*Commit, error) {
|
||||
id, err := repo.ConvertToGitID(commitID)
|
||||
// GetCommit returns a commit object of by the git ref.
|
||||
func (repo *Repository) GetCommit(ref string) (*Commit, error) {
|
||||
id, err := repo.ConvertToGitID(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -222,17 +221,20 @@ type CommitsByFileAndRangeOptions struct {
|
||||
Page int
|
||||
Since string
|
||||
Until string
|
||||
|
||||
// when using FollowRename, there is no quick way to know the total count, so use hasMore to indicate if there are more commits to load
|
||||
FollowRename bool
|
||||
}
|
||||
|
||||
// CommitsByFileAndRange return the commits according revision file and the page
|
||||
func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) ([]*Commit, error) {
|
||||
gitCmd := gitcmd.NewCommand("rev-list").
|
||||
AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize).
|
||||
func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) (commits []*Commit, hasMore bool, _ error) {
|
||||
limit := setting.Git.CommitsRangeSize
|
||||
gitCmd := gitcmd.NewCommand("--no-pager", "log").
|
||||
AddArguments("--pretty=tformat:%H").
|
||||
AddOptionFormat("--max-count=%d", limit+1).
|
||||
AddOptionFormat("--skip=%d", (opts.Page-1)*setting.Git.CommitsRangeSize)
|
||||
gitCmd.AddDynamicArguments(opts.Revision)
|
||||
|
||||
if opts.Not != "" {
|
||||
gitCmd.AddOptionValues("--not", opts.Not)
|
||||
if opts.FollowRename {
|
||||
gitCmd.AddArguments("--follow")
|
||||
}
|
||||
if opts.Since != "" {
|
||||
gitCmd.AddOptionFormat("--since=%s", opts.Since)
|
||||
@@ -240,9 +242,12 @@ func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions)
|
||||
if opts.Until != "" {
|
||||
gitCmd.AddOptionFormat("--until=%s", opts.Until)
|
||||
}
|
||||
gitCmd.AddDynamicArguments(opts.Revision)
|
||||
if opts.Not != "" {
|
||||
gitCmd.AddOptionValues("--not", opts.Not)
|
||||
}
|
||||
gitCmd.AddDashesAndList(opts.File)
|
||||
|
||||
var commits []*Commit
|
||||
stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe()
|
||||
defer stdoutReaderClose()
|
||||
err := gitCmd.WithDir(repo.Path).
|
||||
@@ -274,7 +279,12 @@ func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions)
|
||||
}
|
||||
}).
|
||||
RunWithStderr(repo.Ctx)
|
||||
return commits, err
|
||||
|
||||
hasMore = len(commits) > limit
|
||||
if hasMore {
|
||||
commits = commits[:limit]
|
||||
}
|
||||
return commits, hasMore, err
|
||||
}
|
||||
|
||||
// FilesCountBetween return the number of files changed between two commits
|
||||
@@ -398,7 +408,7 @@ func (repo *Repository) commitsBefore(id ObjectID, limit int) ([]*Commit, error)
|
||||
|
||||
commits := make([]*Commit, 0, len(formattedLog))
|
||||
for _, commit := range formattedLog {
|
||||
branches, err := repo.getBranches(os.Environ(), commit.ID.String(), 2)
|
||||
branches, err := repo.getBranches(nil, commit.ID.String(), 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -422,46 +432,17 @@ func (repo *Repository) getCommitsBeforeLimit(id ObjectID, num int) ([]*Commit,
|
||||
}
|
||||
|
||||
func (repo *Repository) getBranches(env []string, commitID string, limit int) ([]string, error) {
|
||||
if DefaultFeatures().CheckVersionAtLeast("2.7.0") {
|
||||
stdout, _, err := gitcmd.NewCommand("for-each-ref", "--format=%(refname:strip=2)").
|
||||
AddOptionFormat("--count=%d", limit).
|
||||
AddOptionValues("--contains", commitID, BranchPrefix).
|
||||
WithDir(repo.Path).
|
||||
WithEnv(env).
|
||||
RunStdString(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branches := strings.Fields(stdout)
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
stdout, _, err := gitcmd.NewCommand("branch").
|
||||
stdout, _, err := gitcmd.NewCommand("for-each-ref", "--format=%(refname:strip=2)").
|
||||
AddOptionFormat("--count=%d", limit).
|
||||
AddOptionValues("--contains", commitID).
|
||||
WithDir(repo.Path).
|
||||
AddArguments(BranchPrefix).
|
||||
WithEnv(env).
|
||||
WithDir(repo.Path).
|
||||
RunStdString(repo.Ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := strings.Split(stdout, "\n")
|
||||
|
||||
var maxNum int
|
||||
if len(refs) > limit {
|
||||
maxNum = limit
|
||||
} else {
|
||||
maxNum = len(refs) - 1
|
||||
}
|
||||
|
||||
branches := make([]string, maxNum)
|
||||
for i, ref := range refs[:maxNum] {
|
||||
parts := strings.Fields(ref)
|
||||
|
||||
branches[i] = parts[len(parts)-1]
|
||||
}
|
||||
return branches, nil
|
||||
return strings.Fields(stdout), nil
|
||||
}
|
||||
|
||||
// GetCommitsFromIDs get commits from commit IDs
|
||||
@@ -504,16 +485,17 @@ func (repo *Repository) GetCommitBranchStart(env []string, branch, endCommitID s
|
||||
|
||||
parts := bytes.SplitSeq(bytes.TrimSpace(stdout), []byte{'\n'})
|
||||
|
||||
// check the commits one by one until we find a commit contained by another branch
|
||||
// check the commits one by one until we find a commit contained by another branch,
|
||||
// and we think this commit is the divergence point
|
||||
for commitID := range parts {
|
||||
branches, err := repo.getBranches(env, string(commitID), 2)
|
||||
for part := range parts {
|
||||
commitID := string(part)
|
||||
branches, err := repo.getBranches(env, commitID, 2)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, b := range branches {
|
||||
if b != branch {
|
||||
return string(commitID), nil
|
||||
return commitID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,16 +109,16 @@ func (repo *Repository) getCommitWithBatch(batch CatFileBatch, id ObjectID) (*Co
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertToGitID returns a GitHash object from a potential ID string
|
||||
func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) {
|
||||
// ConvertToGitID returns a git object ID from the git ref, it doesn't guarantee the returned ID really exists
|
||||
func (repo *Repository) ConvertToGitID(ref string) (ObjectID, error) {
|
||||
objectFormat, err := repo.GetObjectFormat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(commitID) == objectFormat.FullLength() && objectFormat.IsValid(commitID) {
|
||||
ID, err := NewIDFromString(commitID)
|
||||
if len(ref) == objectFormat.FullLength() && objectFormat.IsValid(ref) {
|
||||
id, err := NewIDFromString(ref)
|
||||
if err == nil {
|
||||
return ID, nil
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,10 +127,10 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
info, err := batch.QueryInfo(commitID)
|
||||
info, err := batch.QueryInfo(ref)
|
||||
if err != nil {
|
||||
if IsErrNotExist(err) {
|
||||
return nil, ErrNotExist{commitID, ""}
|
||||
return nil, ErrNotExist{ref, ""}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
@@ -36,7 +37,7 @@ func TestRepository_GetCommitBranches(t *testing.T) {
|
||||
for _, testCase := range testCases {
|
||||
commit, err := bareRepo1.GetCommit(testCase.CommitID)
|
||||
assert.NoError(t, err)
|
||||
branches, err := bareRepo1.getBranches(os.Environ(), commit.ID.String(), 2)
|
||||
branches, err := bareRepo1.getBranches(nil, commit.ID.String(), 2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testCase.ExpectedBranches, branches)
|
||||
}
|
||||
@@ -140,11 +141,52 @@ func TestCommitsByFileAndRange(t *testing.T) {
|
||||
defer bareRepo1.Close()
|
||||
|
||||
// "foo" has 3 commits in "master" branch
|
||||
commits, err := bareRepo1.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "foo", Page: 1})
|
||||
commits, hasMore, err := bareRepo1.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "foo", Page: 1})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, hasMore)
|
||||
assert.Len(t, commits, 2)
|
||||
|
||||
commits, err = bareRepo1.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "foo", Page: 2})
|
||||
commits, hasMore, err = bareRepo1.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "foo", Page: 2})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, commits, 1)
|
||||
assert.False(t, hasMore)
|
||||
|
||||
repoFollowRenameDir := filepath.Join(t.TempDir(), "repo.git")
|
||||
require.NoError(t, gitcmd.NewCommand("init").AddDynamicArguments(repoFollowRenameDir).Run(t.Context()))
|
||||
_, _, runErr := gitcmd.NewCommand("fast-import").WithDir(repoFollowRenameDir).WithStdinBytes([]byte(strings.TrimSpace(`
|
||||
blob
|
||||
mark :1
|
||||
data 0
|
||||
|
||||
reset refs/heads/master
|
||||
commit refs/heads/master
|
||||
mark :2
|
||||
author Chi-Iroh <user@example.com> 1778660718 +0200
|
||||
committer Chi-Iroh <user@example.com> 1778660718 +0200
|
||||
data 10
|
||||
Add a.txt
|
||||
M 100644 :1 a.txt
|
||||
|
||||
commit refs/heads/master
|
||||
mark :3
|
||||
author Chi-Iroh <user@example.com> 1778660741 +0200
|
||||
committer Chi-Iroh <user@example.com> 1778660741 +0200
|
||||
data 22
|
||||
Rename a.txt to b.txt
|
||||
from :2
|
||||
D a.txt
|
||||
M 100644 :1 b.txt
|
||||
`))).RunStdString(t.Context())
|
||||
require.NoError(t, runErr)
|
||||
|
||||
repoFollowRename, err := OpenRepository(t.Context(), repoFollowRenameDir)
|
||||
require.NoError(t, err)
|
||||
defer repoFollowRename.Close()
|
||||
|
||||
commits, _, err = repoFollowRename.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "b.txt", Page: 1})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, commits, 1)
|
||||
commits, _, err = repoFollowRename.CommitsByFileAndRange(CommitsByFileAndRangeOptions{Revision: "master", File: "b.txt", Page: 1, FollowRename: true})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, commits, 2)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ package git
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
)
|
||||
@@ -65,7 +64,7 @@ func (t *Tree) ListEntries() (Entries, error) {
|
||||
|
||||
stdout, _, runErr := gitcmd.NewCommand("ls-tree", "-l").AddDynamicArguments(t.ID.String()).WithDir(t.repo.Path).RunStdBytes(t.repo.Ctx)
|
||||
if runErr != nil {
|
||||
if gitcmd.IsStdErrorNotValidObjectName(runErr) || strings.Contains(runErr.Error(), "fatal: not a tree object") {
|
||||
if gitcmd.IsStderr(runErr, gitcmd.StderrNotValidObjectName) || gitcmd.IsStderr(runErr, gitcmd.StderrNotTreeObject) {
|
||||
return nil, ErrNotExist{
|
||||
ID: t.ID.String(),
|
||||
}
|
||||
|
||||
@@ -15,9 +15,8 @@ import (
|
||||
)
|
||||
|
||||
type CacheControlOptions struct {
|
||||
IsPublic bool
|
||||
MaxAge time.Duration
|
||||
NoTransform bool
|
||||
IsPublic bool
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
// SetCacheControlInHeader sets suitable cache-control headers in the response
|
||||
@@ -38,25 +37,19 @@ func SetCacheControlInHeader(h http.Header, opts *CacheControlOptions) {
|
||||
directives = append(directives, "max-age=0", publicPrivate, "must-revalidate")
|
||||
h.Set("X-Gitea-Debug", fmt.Sprintf("RUN_MODE=%v, MaxAge=%s", setting.RunMode, opts.MaxAge))
|
||||
}
|
||||
|
||||
if opts.NoTransform {
|
||||
directives = append(directives, "no-transform")
|
||||
}
|
||||
h.Set("Cache-Control", strings.Join(directives, ", "))
|
||||
}
|
||||
|
||||
func CacheControlForPublicStatic() *CacheControlOptions {
|
||||
return &CacheControlOptions{
|
||||
IsPublic: true,
|
||||
MaxAge: setting.StaticCacheTime,
|
||||
NoTransform: true,
|
||||
IsPublic: true,
|
||||
MaxAge: setting.StaticCacheTime,
|
||||
}
|
||||
}
|
||||
|
||||
func CacheControlForPrivateStatic() *CacheControlOptions {
|
||||
return &CacheControlOptions{
|
||||
MaxAge: setting.StaticCacheTime,
|
||||
NoTransform: true,
|
||||
MaxAge: setting.StaticCacheTime,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestHandleGenericETagCache(t *testing.T) {
|
||||
matchedEtag := `"matched-etag"`
|
||||
lastModifiedTime := new(time.Date(2021, time.January, 2, 15, 4, 5, 0, time.FixedZone("test-zone", 8*3600)))
|
||||
lastModified := lastModifiedTime.UTC().Format(http.TimeFormat)
|
||||
cacheControl := "max-age=0, private, must-revalidate, no-transform"
|
||||
cacheControl := "max-age=0, private, must-revalidate"
|
||||
type testCase struct {
|
||||
name string
|
||||
reqHeaders map[string]string
|
||||
|
||||
@@ -94,9 +94,8 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) {
|
||||
}
|
||||
|
||||
httpcache.SetCacheControlInHeader(header, &httpcache.CacheControlOptions{
|
||||
IsPublic: opts.CacheIsPublic,
|
||||
MaxAge: opts.CacheDuration,
|
||||
NoTransform: true,
|
||||
IsPublic: opts.CacheIsPublic,
|
||||
MaxAge: opts.CacheDuration,
|
||||
})
|
||||
|
||||
if !opts.LastModified.IsZero() {
|
||||
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/common"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
@@ -151,8 +153,7 @@ func PostProcessDefault(ctx *RenderContext, input io.Reader, output io.Writer) e
|
||||
}
|
||||
|
||||
// PostProcessCommitMessage will use the same logic as PostProcess, but will disable the shortLinkProcessor.
|
||||
// FIXME: this function and its family have a very strange design: it takes HTML as input and output, processes the "escaped" content.
|
||||
func PostProcessCommitMessage(ctx *RenderContext, content template.HTML) (template.HTML, error) {
|
||||
func PostProcessCommitMessage(ctx *RenderContext, content template.HTML) template.HTML {
|
||||
procs := []processor{
|
||||
fullIssuePatternProcessor,
|
||||
comparePatternProcessor,
|
||||
@@ -166,8 +167,7 @@ func PostProcessCommitMessage(ctx *RenderContext, content template.HTML) (templa
|
||||
emojiProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
}
|
||||
s, err := postProcessString(ctx, procs, string(content))
|
||||
return template.HTML(s), err
|
||||
return postProcessHTML(ctx, procs, content)
|
||||
}
|
||||
|
||||
var emojiProcessors = []processor{
|
||||
@@ -189,7 +189,7 @@ func isBareURLSubject(content string) bool {
|
||||
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
|
||||
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
||||
// emailAddressProcessor, and wraps the whole subject in defaultLink.
|
||||
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
|
||||
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink string, content template.HTML) template.HTML {
|
||||
procs := []processor{
|
||||
fullIssuePatternProcessor,
|
||||
comparePatternProcessor,
|
||||
@@ -207,7 +207,7 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
|
||||
// plain text inside defaultLink. Partial URLs inside larger text still become
|
||||
// their own links (nested anchors aren't legal HTML, so the outer defaultLink
|
||||
// naturally breaks on that span, same as on GitHub).
|
||||
if !isBareURLSubject(content) {
|
||||
if !isBareURLSubject(string(content)) {
|
||||
procs = append(procs, linkProcessor)
|
||||
}
|
||||
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
||||
@@ -215,27 +215,28 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
|
||||
node.Type = html.ElementNode
|
||||
node.Data = "a"
|
||||
node.DataAtom = atom.A
|
||||
node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted"}}
|
||||
node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted title-full-link"}}
|
||||
node.FirstChild, node.LastChild = ch, ch
|
||||
})
|
||||
return postProcessString(ctx, procs, content)
|
||||
rendered := postProcessHTML(ctx, procs, content)
|
||||
return htmlutil.HTMLFormat(`<span class="title-full-link-hover">%s</span>`, rendered)
|
||||
}
|
||||
|
||||
// PostProcessIssueTitle to process title on individual issue/pull page
|
||||
func PostProcessIssueTitle(ctx *RenderContext, title string) (string, error) {
|
||||
return postProcessString(ctx, []processor{
|
||||
func PostProcessIssueTitle(ctx *RenderContext, titleHTML template.HTML) template.HTML {
|
||||
return postProcessHTML(ctx, []processor{
|
||||
issueIndexPatternProcessor,
|
||||
commitCrossReferencePatternProcessor,
|
||||
hashCurrentPatternProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
}, title)
|
||||
}, titleHTML)
|
||||
}
|
||||
|
||||
// PostProcessDescriptionHTML will use similar logic as PostProcess, but will
|
||||
// use a single special linkProcessor.
|
||||
func PostProcessDescriptionHTML(ctx *RenderContext, content string) (string, error) {
|
||||
return postProcessString(ctx, []processor{
|
||||
func PostProcessDescriptionHTML(ctx *RenderContext, content template.HTML) template.HTML {
|
||||
return postProcessHTML(ctx, []processor{
|
||||
descriptionLinkProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
@@ -243,17 +244,18 @@ func PostProcessDescriptionHTML(ctx *RenderContext, content string) (string, err
|
||||
}
|
||||
|
||||
// PostProcessEmoji for when we want to just process emoji and shortcodes
|
||||
// in various places it isn't already run through the normal markdown processor
|
||||
func PostProcessEmoji(ctx *RenderContext, content string) (string, error) {
|
||||
return postProcessString(ctx, emojiProcessors, content)
|
||||
// in various places it isn't already run through the normal Markdown processor
|
||||
func PostProcessEmoji(ctx *RenderContext, content template.HTML) template.HTML {
|
||||
return postProcessHTML(ctx, emojiProcessors, content)
|
||||
}
|
||||
|
||||
func postProcessString(ctx *RenderContext, procs []processor, content string) (string, error) {
|
||||
func postProcessHTML(ctx *RenderContext, procs []processor, content template.HTML) template.HTML {
|
||||
var buf strings.Builder
|
||||
if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil {
|
||||
return "", err
|
||||
if err := postProcess(ctx, procs, strings.NewReader(string(content)), &buf); err != nil {
|
||||
log.Warn("postProcessHTML err: %v, input: %s", err, util.TruncateRunes(string(content), 200))
|
||||
return content
|
||||
}
|
||||
return buf.String(), nil
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]string, out io.Writer) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package markup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -260,9 +261,8 @@ func TestRender_PostProcessIssueTitle(t *testing.T) {
|
||||
"repo": "someRepo",
|
||||
"style": IssueNameStyleNumeric,
|
||||
}
|
||||
actual, err := PostProcessIssueTitle(NewTestRenderContext(metas), "#1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "#1", actual)
|
||||
actual := PostProcessIssueTitle(NewTestRenderContext(metas), "#1")
|
||||
assert.Equal(t, template.HTML("#1"), actual)
|
||||
}
|
||||
|
||||
func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) {
|
||||
|
||||
@@ -95,13 +95,13 @@ type Icon struct {
|
||||
// ColorPreview is an inline for a color preview
|
||||
type ColorPreview struct {
|
||||
ast.BaseInline
|
||||
Color []byte
|
||||
Color string
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump.
|
||||
func (n *ColorPreview) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["Color"] = string(n.Color)
|
||||
m["Color"] = n.Color
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func (n *ColorPreview) Kind() ast.NodeKind {
|
||||
}
|
||||
|
||||
// NewColorPreview returns a new Span node.
|
||||
func NewColorPreview(color []byte) *ColorPreview {
|
||||
func NewColorPreview(color string) *ColorPreview {
|
||||
return &ColorPreview{
|
||||
BaseInline: ast.BaseInline{},
|
||||
Color: color,
|
||||
@@ -170,3 +170,14 @@ func (n *RawHTML) Kind() ast.NodeKind {
|
||||
func NewRawHTML(rawHTML template.HTML) *RawHTML {
|
||||
return &RawHTML{rawHTML: rawHTML}
|
||||
}
|
||||
|
||||
func childSingleText(node ast.Node, source []byte) (string, bool) {
|
||||
if node.FirstChild() == nil || node.FirstChild() != node.LastChild() {
|
||||
return "", false
|
||||
}
|
||||
c, ok := node.FirstChild().(*ast.Text)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return string(c.Segment.Value(source)), true
|
||||
}
|
||||
|
||||
@@ -46,7 +46,10 @@ func (g *ASTTransformer) extractBlockquoteAttentionEmphasis(firstParagraph ast.N
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
val1 := string(node1.Text(reader.Source())) //nolint:staticcheck // Text is deprecated
|
||||
val1, ok := childSingleText(node1, reader.Source())
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
attentionType := strings.ToLower(val1)
|
||||
if g.attentionTypes.Contains(attentionType) {
|
||||
return attentionType, []ast.Node{node1}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Nod
|
||||
r.Writer.RawWrite(w, value)
|
||||
}
|
||||
case *ColorPreview:
|
||||
_ = r.renderInternal.FormatWithSafeAttrs(w, `<span class="color-preview" style="background-color: %s"></span>`, string(v.Color))
|
||||
_ = r.renderInternal.FormatWithSafeAttrs(w, `<span class="color-preview" style="background-color: %s"></span>`, v.Color)
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
@@ -68,8 +68,11 @@ func cssColorHandler(value string) bool {
|
||||
}
|
||||
|
||||
func (g *ASTTransformer) transformCodeSpan(_ *markup.RenderContext, v *ast.CodeSpan, reader text.Reader) {
|
||||
colorContent := v.Text(reader.Source()) //nolint:staticcheck // Text is deprecated
|
||||
if cssColorHandler(string(colorContent)) {
|
||||
colorContent, ok := childSingleText(v, reader.Source())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if cssColorHandler(colorContent) {
|
||||
v.AppendChild(v, NewColorPreview(colorContent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,38 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
|
||||
|
||||
policy.AllowAttrs("loading").OnElements("img")
|
||||
|
||||
// MathML Core (https://www.w3.org/TR/mathml-core/)
|
||||
mathMLElements := []string{
|
||||
"math",
|
||||
// token elements
|
||||
"mi", "mn", "mo", "mtext", "mspace", "ms",
|
||||
// layout elements
|
||||
"mrow", "mfrac", "msqrt", "mroot", "mstyle", "merror", "mpadded", "mphantom",
|
||||
// scripting elements
|
||||
"msub", "msup", "msubsup", "munder", "mover", "munderover", "mmultiscripts", "mprescripts", "none",
|
||||
// tabular elements
|
||||
"mtable", "mtr", "mtd",
|
||||
// semantic annotations
|
||||
"semantics", "annotation", "annotation-xml",
|
||||
}
|
||||
policy.AllowAttrs("display", "alttext").OnElements("math")
|
||||
policy.AllowAttrs(
|
||||
// global presentation attributes
|
||||
"dir", "displaystyle", "mathbackground", "mathcolor", "mathsize", "mathvariant", "scriptlevel",
|
||||
// operator attributes
|
||||
"accent", "accentunder", "fence", "form", "largeop", "lspace", "maxsize", "minsize", "movablelimits", "rspace", "separator", "stretchy", "symmetric",
|
||||
// space and padding attributes
|
||||
"depth", "height", "voffset", "width",
|
||||
// fraction attribute
|
||||
"linethickness",
|
||||
// table attributes
|
||||
"columnalign", "columnlines", "columnspacing", "frame", "framespacing", "rowalign", "rowlines", "rowspacing",
|
||||
// cell attributes
|
||||
"columnspan",
|
||||
// annotation attribute
|
||||
"encoding",
|
||||
).OnElements(mathMLElements...)
|
||||
|
||||
// Allow generally safe attributes (reference: https://github.com/jch/html-pipeline)
|
||||
generalSafeAttrs := []string{
|
||||
"abbr", "accept", "accept-charset",
|
||||
|
||||
@@ -61,6 +61,9 @@ func TestSanitizer(t *testing.T) {
|
||||
// picture
|
||||
`<picture><source media="a"><source media="b"><img alt="c" src="d"></picture>`, `<picture><source media="a"><source media="b"><img alt="c" src="d"></picture>`,
|
||||
|
||||
// MathML
|
||||
`<math display="display" class="foo"><mi mathcolor="c" class="bar"></mi></math>`, `<math display="display"><mi mathcolor="c"></mi></math>`,
|
||||
|
||||
// Disallow dangerous url schemes
|
||||
`<a href="javascript:alert('xss')">bad</a>`, `bad`,
|
||||
`<a href="vbscript:no">bad</a>`, `bad`,
|
||||
@@ -72,6 +75,6 @@ func TestSanitizer(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := 0; i < len(testCases); i += 2 {
|
||||
assert.Equal(t, testCases[i+1], string(Sanitize(testCases[i])))
|
||||
assert.Equal(t, testCases[i+1], string(Sanitize(testCases[i])), "input: %s", testCases[i])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,11 @@ type Paginator struct {
|
||||
total int // total rows count, -1 means unknown
|
||||
totalPages int // total pages count, -1 means unknown
|
||||
current int // current page number
|
||||
curRows int // current page rows count
|
||||
|
||||
pagingNum int // how many rows in one page
|
||||
numPages int // how many pages to show on the UI
|
||||
|
||||
hasNext *bool // used for total=-1 ("unlimited paging")
|
||||
}
|
||||
|
||||
// New initialize a new pagination calculation and returns a Paginator as result.
|
||||
@@ -60,15 +61,13 @@ func New(total, pagingNum, current, numPages int) *Paginator {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Paginator) SetCurRows(rows int) {
|
||||
func (p *Paginator) SetUnlimitedPaging(curRows int, hasNext bool) {
|
||||
// For "unlimited paging", we need to know the rows of current page to determine if there is a next page.
|
||||
// There is still an edge case: when curRows==pagingNum, then the "next page" will be an empty page.
|
||||
// Ideally we should query one more row to determine if there is really a next page, but it's impossible in current framework.
|
||||
p.curRows = rows
|
||||
if p.total == -1 && p.current == 1 && !p.HasNext() {
|
||||
p.hasNext = &hasNext
|
||||
if p.total == -1 && p.current == 1 && !hasNext {
|
||||
// if there is only one page for the "unlimited paging", set total rows/pages count
|
||||
// then the tmpl could decide to hide the nav bar.
|
||||
p.total = rows
|
||||
p.total = curRows
|
||||
p.totalPages = util.Iif(p.total == 0, 0, 1)
|
||||
}
|
||||
}
|
||||
@@ -92,8 +91,8 @@ func (p *Paginator) Previous() int {
|
||||
|
||||
// HasNext returns true if there is a next page relative to current page.
|
||||
func (p *Paginator) HasNext() bool {
|
||||
if p.total == -1 {
|
||||
return p.curRows >= p.pagingNum
|
||||
if p.hasNext != nil {
|
||||
return *p.hasNext
|
||||
}
|
||||
return p.current*p.pagingNum < p.total
|
||||
}
|
||||
|
||||
@@ -9,19 +9,15 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/avatars"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/cachegroup"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
)
|
||||
|
||||
// PushCommit represents a commit in a push operation.
|
||||
// This struct is marshaled as JSON (see ActionContent2Commits)
|
||||
type PushCommit struct {
|
||||
Sha1 string
|
||||
Message string
|
||||
@@ -33,6 +29,7 @@ type PushCommit struct {
|
||||
}
|
||||
|
||||
// PushCommits represents list of commits in a push operation.
|
||||
// This struct is marshaled as JSON (see ActionContent2Commits)
|
||||
type PushCommits struct {
|
||||
Commits []*PushCommit
|
||||
HeadCommit *PushCommit
|
||||
@@ -128,26 +125,6 @@ func (pc *PushCommits) ToAPIPayloadCommits(ctx context.Context, repo *repo_model
|
||||
return commits, headCommit, nil
|
||||
}
|
||||
|
||||
// AvatarLink tries to match user in database with e-mail
|
||||
// in order to show custom avatar, and falls back to general avatar link.
|
||||
func (pc *PushCommits) AvatarLink(ctx context.Context, email string) string {
|
||||
size := avatars.DefaultAvatarPixelSize * setting.Avatar.RenderedSizeFactor
|
||||
|
||||
v, _ := cache.GetWithContextCache(ctx, cachegroup.EmailAvatarLink, email, func(ctx context.Context, email string) (string, error) {
|
||||
u, err := user_model.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
log.Error("GetUserByEmail: %v", err)
|
||||
return "", err
|
||||
}
|
||||
return avatars.GenerateEmailAvatarFastLink(ctx, email, size), nil
|
||||
}
|
||||
return u.AvatarLinkWithSize(ctx, size), nil
|
||||
})
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// CommitToPushCommit transforms a git.Commit to PushCommit type.
|
||||
func CommitToPushCommit(commit *git.Commit) *PushCommit {
|
||||
return &PushCommit{
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -99,38 +97,6 @@ func TestPushCommits_ToAPIPayloadCommits(t *testing.T) {
|
||||
assert.Equal(t, []string{"readme.md"}, headCommit.Modified)
|
||||
}
|
||||
|
||||
func TestPushCommits_AvatarLink(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
pushCommits := NewPushCommits()
|
||||
pushCommits.Commits = []*PushCommit{
|
||||
{
|
||||
Sha1: "abcdef1",
|
||||
CommitterEmail: "user2@example.com",
|
||||
CommitterName: "User Two",
|
||||
AuthorEmail: "user4@example.com",
|
||||
AuthorName: "User Four",
|
||||
Message: "message1",
|
||||
},
|
||||
{
|
||||
Sha1: "abcdef2",
|
||||
CommitterEmail: "user2@example.com",
|
||||
CommitterName: "User Two",
|
||||
AuthorEmail: "user2@example.com",
|
||||
AuthorName: "User Two",
|
||||
Message: "message2",
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
"/avatars/ab53a2911ddf9b4817ac01ddcd3d975f?size="+strconv.Itoa(28*setting.Avatar.RenderedSizeFactor),
|
||||
pushCommits.AvatarLink(t.Context(), "user2@example.com"))
|
||||
|
||||
assert.Equal(t,
|
||||
"/assets/img/avatar_default.png",
|
||||
pushCommits.AvatarLink(t.Context(), "nonexistent@example.com"))
|
||||
}
|
||||
|
||||
func TestCommitToPushCommit(t *testing.T) {
|
||||
now := time.Now()
|
||||
sig := &git.Signature{
|
||||
|
||||
@@ -78,11 +78,16 @@ func isZeroOrEmpty(v any) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
var SkipDatabaseConfig bool
|
||||
|
||||
func (opt *Option[T]) ValueRevision(ctx context.Context) (v T, rev int, has bool) {
|
||||
dg := GetDynGetter()
|
||||
if dg == nil {
|
||||
// this is an edge case: the database is not initialized but the system setting is going to be used
|
||||
// it should panic to avoid inconsistent config values (from config / system setting) and fix the code
|
||||
if SkipDatabaseConfig {
|
||||
return opt.DefaultValue(), 0, false
|
||||
}
|
||||
panic("no config dyn value getter")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/consts"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
@@ -52,8 +53,8 @@ var SSH = struct {
|
||||
Domain: "",
|
||||
Port: 22,
|
||||
MinimumKeySizeCheck: true,
|
||||
MinimumKeySizes: map[string]int{"ed25519": 256, "ed25519-sk": 256, "ecdsa": 256, "ecdsa-sk": 256, "rsa": 3071},
|
||||
ServerHostKeys: []string{"ssh/gitea.rsa", "ssh/gogs.rsa"},
|
||||
MinimumKeySizes: map[string]int{"ed25519": consts.AsymKeyMinBitsEC, "ed25519-sk": consts.AsymKeyMinBitsEC, "ecdsa": consts.AsymKeyMinBitsEC, "ecdsa-sk": consts.AsymKeyMinBitsEC, "rsa": consts.AsymKeyMinBitsRsa},
|
||||
ServerHostKeys: []string{"ssh/gitea.rsa", "ssh/gitea.ed25519", "ssh/gitea.ecdsa", "ssh/gogs.rsa"},
|
||||
AuthorizedKeysCommandTemplate: "{{.AppPath}} --config={{.CustomConf}} serv key-{{.Key.ID}}",
|
||||
PerWriteTimeout: PerWriteTimeout,
|
||||
PerWritePerKbTimeout: PerWritePerKbTimeout,
|
||||
|
||||
@@ -6,9 +6,6 @@ package ssh
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -23,11 +20,11 @@ import (
|
||||
"syscall"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/modules/generate"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
@@ -59,7 +56,7 @@ func getExitStatusFromError(err error) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
@@ -322,7 +319,7 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
}
|
||||
|
||||
// sshConnectionFailed logs a failed connection
|
||||
// - this mainly exists to give a nice function name in logging
|
||||
// - this mainly exists to give a nice function name in logging
|
||||
func sshConnectionFailed(conn net.Conn, err error) {
|
||||
// Log the underlying error with a specific message
|
||||
log.Warn("Failed connection from %s with error: %v", conn.RemoteAddr(), err)
|
||||
@@ -351,40 +348,37 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) {
|
||||
},
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(setting.SSH.ServerHostKeys))
|
||||
hostKeyFiles := make([]string, 0, len(setting.SSH.ServerHostKeys))
|
||||
for _, key := range setting.SSH.ServerHostKeys {
|
||||
isExist, err := util.IsExist(key)
|
||||
_, err := os.Stat(key)
|
||||
if err != nil {
|
||||
log.Fatal("Unable to check if %s exists. Error: %v", setting.SSH.ServerHostKeys, err)
|
||||
}
|
||||
if isExist {
|
||||
keys = append(keys, key)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Fatal("Unable to check if %s exists. Error: %v", setting.SSH.ServerHostKeys, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
hostKeyFiles = append(hostKeyFiles, key)
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
filePath := filepath.Dir(setting.SSH.ServerHostKeys[0])
|
||||
|
||||
if err := os.MkdirAll(filePath, os.ModePerm); err != nil {
|
||||
log.Error("Failed to create dir %s: %v", filePath, err)
|
||||
if len(hostKeyFiles) == 0 {
|
||||
hostKeyDir := filepath.Dir(setting.SSH.ServerHostKeys[0])
|
||||
err := os.MkdirAll(hostKeyDir, os.ModePerm)
|
||||
if err != nil {
|
||||
log.Error("Failed to create dir %s: %v", hostKeyDir, err)
|
||||
}
|
||||
|
||||
err := GenKeyPair(setting.SSH.ServerHostKeys[0])
|
||||
hostKeyFiles, err = InitDefaultHostKeys(hostKeyDir)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to generate private key: %v", err)
|
||||
}
|
||||
log.Trace("New private key is generated: %s", setting.SSH.ServerHostKeys[0])
|
||||
keys = append(keys, setting.SSH.ServerHostKeys[0])
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
log.Info("Adding SSH host key: %s", key)
|
||||
err := srv.SetOption(ssh.HostKeyFile(key))
|
||||
for _, keyFile := range hostKeyFiles {
|
||||
log.Info("Adding SSH host key: %s", keyFile)
|
||||
err := srv.SetOption(ssh.HostKeyFile(keyFile))
|
||||
if err != nil {
|
||||
log.Error("Failed to set Host Key. %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Service: Built-in SSH server", process.SystemProcessType, true)
|
||||
defer finished()
|
||||
@@ -395,43 +389,44 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) {
|
||||
// GenKeyPair make a pair of public and private keys for SSH access.
|
||||
// Public key is encoded in the format for inclusion in an OpenSSH authorized_keys file.
|
||||
// Private Key generated is PEM encoded
|
||||
func GenKeyPair(keyPath string) error {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
func GenKeyPair(keyPath string, keyType generate.SSHKeyType, bits int) error {
|
||||
publicKey, privateKeyPEM, err := generate.NewSSHKey(keyType, bits)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
|
||||
f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err = f.Close(); err != nil {
|
||||
log.Error("Close: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := pem.Encode(f, privateKeyPEM); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// generate public key
|
||||
pub, err := gossh.NewPublicKey(&privateKey.PublicKey)
|
||||
public := gossh.MarshalAuthorizedKey(publicKey)
|
||||
privateKeyBuf := &bytes.Buffer{}
|
||||
err = pem.Encode(privateKeyBuf, privateKeyPEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
public := gossh.MarshalAuthorizedKey(pub)
|
||||
p, err := os.OpenFile(keyPath+".pub", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
err = os.WriteFile(keyPath, privateKeyBuf.Bytes(), 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err = p.Close(); err != nil {
|
||||
log.Error("Close: %v", err)
|
||||
}
|
||||
}()
|
||||
_, err = p.Write(public)
|
||||
return err
|
||||
|
||||
return os.WriteFile(keyPath+".pub", public, 0o644)
|
||||
}
|
||||
|
||||
// InitDefaultHostKeys mirrors how ssh-keygen -A operates
|
||||
// it runs checks if public and private keys are already defined and creates new ones if not present
|
||||
// key naming does not follow the OpenSSH convention due to existing settings being gitea.{KeyType} so generation follows gitea convention
|
||||
func InitDefaultHostKeys(path string) (keyFiles []string, _ error) {
|
||||
var errs []error
|
||||
keyTypes := []generate.SSHKeyType{generate.SSHKeyRSA, generate.SSHKeyECDSA, generate.SSHKeyED25519}
|
||||
for _, keyType := range keyTypes {
|
||||
keyPath := filepath.Join(path, "gitea."+string(keyType))
|
||||
_, errStatPriv := os.Stat(keyPath)
|
||||
if errStatPriv != nil {
|
||||
err := GenKeyPair(keyPath, keyType, 0)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
keyFiles = append(keyFiles, keyPath)
|
||||
}
|
||||
return keyFiles, errors.Join(errs...)
|
||||
}
|
||||
|
||||
123
modules/ssh/ssh_test.go
Normal file
123
modules/ssh/ssh_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/generate"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestGenKeyPair(t *testing.T) {
|
||||
testCases := []struct {
|
||||
keyType generate.SSHKeyType
|
||||
expectedType any
|
||||
}{
|
||||
{
|
||||
keyType: generate.SSHKeyRSA,
|
||||
expectedType: &rsa.PrivateKey{},
|
||||
},
|
||||
{
|
||||
keyType: generate.SSHKeyED25519,
|
||||
expectedType: &ed25519.PrivateKey{},
|
||||
},
|
||||
{
|
||||
keyType: generate.SSHKeyECDSA,
|
||||
expectedType: &ecdsa.PrivateKey{},
|
||||
},
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
for _, tc := range testCases {
|
||||
name := "gitea." + string(tc.keyType)
|
||||
fn := filepath.Join(tmpDir, name)
|
||||
t.Run("Generate "+name, func(t *testing.T) {
|
||||
require.NoError(t, GenKeyPair(fn, tc.keyType, 0))
|
||||
|
||||
bytes, err := os.ReadFile(fn)
|
||||
require.NoError(t, err)
|
||||
|
||||
privateKey, err := gossh.ParseRawPrivateKey(bytes)
|
||||
require.NoError(t, err)
|
||||
assert.IsType(t, tc.expectedType, privateKey)
|
||||
})
|
||||
}
|
||||
t.Run("Generate unknown key type", func(t *testing.T) {
|
||||
err := GenKeyPair(t.TempDir()+"gitea.badkey", "badkey", 0)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInitKeys(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
keyTypes := []string{"rsa", "ecdsa", "ed25519"}
|
||||
for _, keyType := range keyTypes {
|
||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||
assert.NoFileExists(t, privKeyPath)
|
||||
assert.NoFileExists(t, pubKeyPath)
|
||||
}
|
||||
|
||||
// Test basic creation
|
||||
keyFiles, err := InitDefaultHostKeys(tempDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, keyFiles, len(keyTypes))
|
||||
|
||||
metadata := map[string]os.FileInfo{}
|
||||
for _, keyType := range keyTypes {
|
||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||
info, err := os.Stat(privKeyPath)
|
||||
require.NoError(t, err)
|
||||
metadata[privKeyPath] = info
|
||||
|
||||
info, err = os.Stat(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
metadata[pubKeyPath] = info
|
||||
}
|
||||
|
||||
// Test recreation on missing private key and noop for missing pub key
|
||||
require.NoError(t, os.Remove(filepath.Join(tempDir, "gitea.ecdsa.pub")))
|
||||
require.NoError(t, os.Remove(filepath.Join(tempDir, "gitea.ed25519")))
|
||||
|
||||
keyFiles, err = InitDefaultHostKeys(tempDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, keyFiles, len(keyTypes))
|
||||
|
||||
for _, keyType := range keyTypes {
|
||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||
|
||||
infoPriv, err := os.Stat(privKeyPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
switch keyType {
|
||||
case "rsa":
|
||||
// No modification to RSA key
|
||||
infoPub, err := os.Stat(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, metadata[privKeyPath], infoPriv)
|
||||
assert.Equal(t, metadata[pubKeyPath], infoPub)
|
||||
case "ecdsa":
|
||||
// ECDSA public key should be missing, private unchanged
|
||||
assert.Equal(t, metadata[privKeyPath], infoPriv)
|
||||
assert.NoFileExists(t, pubKeyPath)
|
||||
case "ed25519":
|
||||
// ed25519 private key was removed, so both keys regenerated
|
||||
infoPub, err := os.Stat(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, metadata[privKeyPath], infoPriv)
|
||||
assert.NotEqual(t, metadata[pubKeyPath], infoPub)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
"math"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
user_model "gitea.dev/models/gituser"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/renderhelper"
|
||||
"gitea.dev/models/repo"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/svg"
|
||||
@@ -32,67 +34,43 @@ import (
|
||||
)
|
||||
|
||||
type RenderUtils struct {
|
||||
ctx reqctx.RequestContext
|
||||
ctx reqctx.RequestContext
|
||||
avatarUtils *AvatarUtils
|
||||
}
|
||||
|
||||
func NewRenderUtils(ctx reqctx.RequestContext) *RenderUtils {
|
||||
return &RenderUtils{ctx: ctx}
|
||||
return &RenderUtils{ctx: ctx, avatarUtils: NewAvatarUtils(ctx)}
|
||||
}
|
||||
|
||||
// RenderCommitMessage renders commit message with XSS-safe and special links.
|
||||
// RenderCommitMessage renders commit message title (only title)
|
||||
func (ut *RenderUtils) RenderCommitMessage(msg string, repo *repo.Repository) template.HTML {
|
||||
cleanMsg := template.HTML(template.HTMLEscapeString(msg))
|
||||
// we can safely assume that it will not return any error, since there shouldn't be any special HTML.
|
||||
// "repo" can be nil when rendering commit messages for deleted repositories in a user's dashboard feed.
|
||||
fullMessage, err := markup.PostProcessCommitMessage(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), cleanMsg)
|
||||
if err != nil {
|
||||
log.Error("PostProcessCommitMessage: %v", err)
|
||||
return ""
|
||||
}
|
||||
msgLines := strings.Split(strings.TrimSpace(string(fullMessage)), "\n")
|
||||
if len(msgLines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return renderCodeBlock(template.HTML(msgLines[0]))
|
||||
msgLine := strings.TrimSpace(msg)
|
||||
msgLine, _, _ = strings.Cut(msgLine, "\n")
|
||||
msgLine = strings.TrimSpace(msgLine)
|
||||
rendered := markup.PostProcessCommitMessage(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), htmlutil.EscapeString(msgLine))
|
||||
return renderCodeBlock(rendered)
|
||||
}
|
||||
|
||||
// RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to
|
||||
// the provided default url, handling for special links without email to links.
|
||||
func (ut *RenderUtils) RenderCommitMessageLinkSubject(msg, urlDefault string, repo *repo.Repository) template.HTML {
|
||||
msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace)
|
||||
lineEnd := strings.IndexByte(msgLine, '\n')
|
||||
if lineEnd > 0 {
|
||||
msgLine = msgLine[:lineEnd]
|
||||
}
|
||||
msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace)
|
||||
if len(msgLine) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// we can safely assume that it will not return any error, since there shouldn't be any special HTML.
|
||||
renderedMessage, err := markup.PostProcessCommitMessageSubject(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), urlDefault, template.HTMLEscapeString(msgLine))
|
||||
if err != nil {
|
||||
log.Error("PostProcessCommitMessageSubject: %v", err)
|
||||
return ""
|
||||
}
|
||||
return renderCodeBlock(template.HTML(renderedMessage))
|
||||
msgLine := strings.TrimSpace(msg)
|
||||
msgLine, _, _ = strings.Cut(msgLine, "\n")
|
||||
msgLine = strings.TrimSpace(msgLine)
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
|
||||
rendered := markup.PostProcessCommitMessageSubject(rctx, urlDefault, htmlutil.EscapeString(msgLine))
|
||||
return renderCodeBlock(rendered)
|
||||
}
|
||||
|
||||
// RenderCommitBody extracts the body of a commit message without its title.
|
||||
func (ut *RenderUtils) RenderCommitBody(msg string, repo *repo.Repository) template.HTML {
|
||||
_, body, _ := strings.Cut(strings.TrimSpace(msg), "\n")
|
||||
body = strings.TrimFunc(body, unicode.IsSpace)
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
|
||||
htmlContent := template.HTML(template.HTMLEscapeString(body))
|
||||
renderedMessage, err := markup.PostProcessCommitMessage(rctx, htmlContent)
|
||||
if err != nil {
|
||||
log.Error("PostProcessCommitMessage: %v", err)
|
||||
return ""
|
||||
}
|
||||
renderedMessage := markup.PostProcessCommitMessage(rctx, htmlutil.EscapeString(body))
|
||||
return renderedMessage
|
||||
}
|
||||
|
||||
@@ -108,25 +86,15 @@ func renderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
|
||||
// RenderIssueTitle renders issue/pull title with defined post processors
|
||||
func (ut *RenderUtils) RenderIssueTitle(text string, repo *repo.Repository) template.HTML {
|
||||
// wrap "`…`" in <code> before post-processing so code-span content stays literal, like comment bodies
|
||||
htmlWithCode := renderCodeBlock(template.HTML(template.HTMLEscapeString(text)))
|
||||
renderedText, err := markup.PostProcessIssueTitle(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), string(htmlWithCode))
|
||||
if err != nil {
|
||||
log.Error("PostProcessIssueTitle: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(renderedText)
|
||||
htmlWithCode := renderCodeBlock(htmlutil.EscapeString(text))
|
||||
return markup.PostProcessIssueTitle(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), htmlWithCode)
|
||||
}
|
||||
|
||||
// RenderIssueSimpleTitle only renders with emoji and inline code block
|
||||
func (ut *RenderUtils) RenderIssueSimpleTitle(text string) template.HTML {
|
||||
// see RenderIssueTitle: wrap code spans before processing emoji
|
||||
htmlWithCode := renderCodeBlock(template.HTML(template.HTMLEscapeString(text)))
|
||||
renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), string(htmlWithCode))
|
||||
if err != nil {
|
||||
log.Error("RenderIssueSimpleTitle: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(renderedText)
|
||||
htmlWithCode := renderCodeBlock(htmlutil.EscapeString(text))
|
||||
return markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), htmlWithCode)
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
|
||||
@@ -202,12 +170,7 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
|
||||
|
||||
// RenderEmoji renders html text with emoji post processors
|
||||
func (ut *RenderUtils) RenderEmoji(text string) template.HTML {
|
||||
renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text))
|
||||
if err != nil {
|
||||
log.Error("RenderEmoji: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(renderedText)
|
||||
return markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), htmlutil.EscapeString(text))
|
||||
}
|
||||
|
||||
// reactionToEmoji renders emoji for use in reactions
|
||||
@@ -332,3 +295,134 @@ func (ut *RenderUtils) RenderUnicodeEscapeToggleTd(combined, escapeStatus *chars
|
||||
}
|
||||
return `<td class="lines-escape">` + ut.RenderUnicodeEscapeToggleButton(escapeStatus) + `</td>`
|
||||
}
|
||||
|
||||
func renderAvatarStackViewEmailLink(data *user_model.AvatarStackData, email string) template.URL {
|
||||
if data.SearchByEmailLink != "" && email != "" {
|
||||
return template.URL(strings.ReplaceAll(data.SearchByEmailLink, "{email}", url.QueryEscape(email)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) participantHref(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.URL {
|
||||
if href := renderAvatarStackViewEmailLink(data, participant.GitIdentity.Email); href != "" {
|
||||
return href
|
||||
}
|
||||
if participant.GiteaUser != nil {
|
||||
return template.URL(participant.GiteaUser.HomeLink())
|
||||
} else if participant.GitIdentity.Email != "" {
|
||||
return template.URL("mailto:" + participant.GitIdentity.Email)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) participantAvatar(participant *user_model.CommitParticipant) template.HTML {
|
||||
if participant.GiteaUser != nil {
|
||||
return ut.avatarUtils.Avatar(participant.GiteaUser, 20)
|
||||
}
|
||||
return ut.avatarUtils.AvatarByEmail(participant.GitIdentity.Email, participant.GitIdentity.Name, 20)
|
||||
}
|
||||
|
||||
func participantName(participant *user_model.CommitParticipant) string {
|
||||
if participant.GiteaUser != nil {
|
||||
return participant.GiteaUser.GetDisplayName()
|
||||
}
|
||||
return participant.GitIdentity.Name
|
||||
}
|
||||
|
||||
const renderAvatarStackMaxVisible = 10
|
||||
|
||||
// AvatarStack renders overlapping avatars for the stack participants. It emits children in reverse
|
||||
// so CSS `flex-direction: row-reverse` places the primary (Participants[0]) leftmost and last-painted (on top).
|
||||
func (ut *RenderUtils) AvatarStack(data *user_model.AvatarStackData) template.HTML {
|
||||
visible := data.Participants
|
||||
overflow := len(visible) - renderAvatarStackMaxVisible
|
||||
if overflow > 0 {
|
||||
visible = visible[:renderAvatarStackMaxVisible]
|
||||
}
|
||||
|
||||
var b htmlutil.HTMLBuilder
|
||||
b.WriteHTML(`<span class="avatar-stack">`)
|
||||
if overflow > 0 {
|
||||
b.WriteFormat(`<span class="avatar-stack-overflow-chip tw-text-xs" aria-label="+%d more">+%d</span>`, overflow, overflow)
|
||||
}
|
||||
|
||||
// FIXME: such "backward" breaks a11y like screen readers
|
||||
for _, participant := range slices.Backward(visible) {
|
||||
ut.writeAvatarStackItem(&b, data, participant)
|
||||
}
|
||||
b.WriteHTML(`</span>`)
|
||||
return b.HTMLString()
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) writeAvatarStackItem(b *htmlutil.HTMLBuilder, data *user_model.AvatarStackData, participant *user_model.CommitParticipant) {
|
||||
avatar := ut.participantAvatar(participant)
|
||||
if href := ut.participantHref(data, participant); href != "" {
|
||||
b.WriteFormat(`<a href="%s">%s</a>`, href, avatar)
|
||||
} else {
|
||||
b.WriteFormat(`<span>%s</span>`, avatar)
|
||||
}
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) AvatarStackPushCommit(pushCommit *repository.PushCommit) template.HTML {
|
||||
fakeGitCommit := git.Commit{
|
||||
CommitMessage: git.CommitMessage{MessageRaw: pushCommit.Message},
|
||||
Author: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail},
|
||||
// there is no way to know the real committer, but the field can't be nil
|
||||
Committer: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail},
|
||||
}
|
||||
data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllParticipantIdentities(), nil)
|
||||
return ut.AvatarStack(data)
|
||||
}
|
||||
|
||||
// AvatarStackWithNames renders the avatar stack plus a label: `name` / `a and b` / `N people` (opens popup).
|
||||
func (ut *RenderUtils) AvatarStackWithNames(data *user_model.AvatarStackData) template.HTML {
|
||||
locale := ut.ctx.Value(translation.ContextKey).(translation.Locale)
|
||||
participants := data.Participants
|
||||
|
||||
var b htmlutil.HTMLBuilder
|
||||
b.WriteHTML(`<span class="avatar-stack-names">`)
|
||||
b.WriteHTML(ut.AvatarStack(data))
|
||||
|
||||
switch len(participants) {
|
||||
case 1:
|
||||
b.WriteHTML(ut.participantNameLink(data, participants[0]))
|
||||
case 2:
|
||||
b.WriteHTML(ut.participantNameLink(data, participants[0]))
|
||||
b.WriteFormat(`<span>%s</span>`, locale.Tr("repo.commits.avatar_stack_and"))
|
||||
b.WriteHTML(ut.participantNameLink(data, participants[1]))
|
||||
default:
|
||||
b.WriteFormat(`<button type="button" class="avatar-stack-popup-trigger" data-global-init="initAvatarStackPopup">%s</button>`,
|
||||
locale.Tr("repo.commits.avatar_stack_people", len(participants)))
|
||||
b.WriteHTML(`<div class="tippy-target"><div class="avatar-stack-popup">`)
|
||||
for _, participant := range participants {
|
||||
b.WriteHTML(ut.participantPopupRow(data, participant))
|
||||
}
|
||||
b.WriteHTML(`</div></div>`)
|
||||
}
|
||||
|
||||
b.WriteHTML(`</span>`)
|
||||
return b.HTMLString()
|
||||
}
|
||||
|
||||
// participantNameLink prefers (in order): commits-by-author search, `GetShortDisplayNameLinkHTML` (keeps alt-name tooltip), `mailto:`, bare name.
|
||||
func (ut *RenderUtils) participantNameLink(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.HTML {
|
||||
if href := renderAvatarStackViewEmailLink(data, participant.GitIdentity.Email); href != "" {
|
||||
return htmlutil.HTMLFormat(`<a class="muted" href="%s">%s</a>`, href, participantName(participant))
|
||||
}
|
||||
if participant.GiteaUser != nil {
|
||||
return participant.GiteaUser.GetShortDisplayNameLinkHTML()
|
||||
}
|
||||
if participant.GitIdentity.Email != "" {
|
||||
return htmlutil.HTMLFormat(`<a class="muted" href="mailto:%s">%s</a>`, participant.GitIdentity.Email, participant.GitIdentity.Name)
|
||||
}
|
||||
return template.HTML(template.HTMLEscapeString(participant.GitIdentity.Name))
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) participantPopupRow(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.HTML {
|
||||
avatar := ut.participantAvatar(participant)
|
||||
name := participantName(participant)
|
||||
if href := ut.participantHref(data, participant); href != "" {
|
||||
return htmlutil.HTMLFormat(`<a class="silenced flex-text-block" href="%s">%s<span>%s</span></a>`, href, avatar, name)
|
||||
}
|
||||
return htmlutil.HTMLFormat(`<span class="flex-text-block">%s<span>%s</span></span>`, avatar, name)
|
||||
}
|
||||
|
||||
@@ -7,15 +7,19 @@ import (
|
||||
"context"
|
||||
"html/template"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/gituser"
|
||||
"gitea.dev/models/issues"
|
||||
"gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/setting/config"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/translation"
|
||||
|
||||
@@ -131,24 +135,24 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
||||
})
|
||||
|
||||
t.Run("RenderCommitMessage", func(t *testing.T) {
|
||||
expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a> `
|
||||
expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a>`
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessage(testInput(), mockRepo))
|
||||
})
|
||||
|
||||
t.Run("RenderCommitMessageLinkSubject", func(t *testing.T) {
|
||||
expected := `<a href="https://example.com/link" class="muted">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a>`
|
||||
expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a></span>`
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
|
||||
})
|
||||
|
||||
t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) {
|
||||
// a bare URL in the subject must not hijack the default link
|
||||
expected := `<a href="https://example.com/link" class="muted">https://example.com/file.bin</a>`
|
||||
expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">https://example.com/file.bin</a></span>`
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo))
|
||||
})
|
||||
|
||||
t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) {
|
||||
// a URL embedded in larger subject text still becomes its own link
|
||||
expected := `<a href="https://example.com/link" class="muted">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted"> here</a>`
|
||||
expected := `<span class="title-full-link-hover"><a href="https://example.com/link" class="muted title-full-link">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted title-full-link"> here</a></span>`
|
||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo))
|
||||
})
|
||||
|
||||
@@ -298,3 +302,52 @@ func TestUserMention(t *testing.T) {
|
||||
rendered := newTestRenderUtils(t).MarkdownToHtml("@no-such-user @mention-user @mention-user")
|
||||
assert.Equal(t, `<p>@no-such-user <a href="/mention-user" rel="nofollow">@mention-user</a> <a href="/mention-user" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered)))
|
||||
}
|
||||
|
||||
func TestAvatarStack(t *testing.T) {
|
||||
defer test.MockVariableValue(&config.SkipDatabaseConfig, true)()
|
||||
|
||||
ut := newTestRenderUtils(t)
|
||||
mkCo := func(name, email string) *git.CommitIdentity {
|
||||
return &git.CommitIdentity{Name: name, Email: email}
|
||||
}
|
||||
authorSig := mkCo("Alice", "alice@example.com")
|
||||
mkData := func(co ...*git.CommitIdentity) *gituser.AvatarStackData {
|
||||
all := append([]*git.CommitIdentity{authorSig}, co...)
|
||||
return gituser.BuildAvatarStackData(t.Context(), all, &user_model.EmailUserMap{})
|
||||
}
|
||||
|
||||
t.Run("lone author renders bare name, no label", func(t *testing.T) {
|
||||
got := string(ut.AvatarStackWithNames(mkData()))
|
||||
assert.Contains(t, got, `<span class="avatar-stack-names">`)
|
||||
assert.Contains(t, got, "Alice")
|
||||
assert.NotContains(t, got, "avatar_stack_and")
|
||||
assert.NotContains(t, got, "avatar_stack_people")
|
||||
})
|
||||
|
||||
t.Run("two participants use and label", func(t *testing.T) {
|
||||
got := string(ut.AvatarStackWithNames(mkData(mkCo("Bob", "bob@example.com"))))
|
||||
assert.Contains(t, got, "repo.commits.avatar_stack_and")
|
||||
assert.Contains(t, got, "Bob")
|
||||
assert.NotContains(t, got, "avatar_stack_people")
|
||||
assert.Contains(t, got, `<span class="avatar-stack">`)
|
||||
})
|
||||
|
||||
t.Run("three participants switch to N people label with tippy popup", func(t *testing.T) {
|
||||
got := string(ut.AvatarStackWithNames(mkData(mkCo("Bob", "bob@example.com"), mkCo("Carol", "carol@example.com"))))
|
||||
assert.Contains(t, got, "repo.commits.avatar_stack_people:3")
|
||||
assert.NotContains(t, got, "repo.commits.avatar_stack_and")
|
||||
assert.Contains(t, got, `data-global-init="initAvatarStackPopup"`)
|
||||
assert.Contains(t, got, `<div class="tippy-target">`)
|
||||
assert.Contains(t, got, `class="avatar-stack-popup"`)
|
||||
})
|
||||
|
||||
t.Run("overflow chip renders beyond 10 participants", func(t *testing.T) {
|
||||
cos := make([]*git.CommitIdentity, 0, renderAvatarStackMaxVisible+1)
|
||||
for i := range renderAvatarStackMaxVisible + 1 {
|
||||
cos = append(cos, mkCo("X", strconv.Itoa(i)+"@example.com"))
|
||||
}
|
||||
got := ut.AvatarStack(gituser.BuildAvatarStackData(t.Context(), cos, &user_model.EmailUserMap{}))
|
||||
assert.Contains(t, got, `class="avatar-stack-overflow-chip`)
|
||||
assert.Contains(t, got, "+1")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (r *Reader) Close() error {
|
||||
type SeekableWriter struct {
|
||||
buf []byte
|
||||
n int
|
||||
w seekable.Writer
|
||||
w *seekable.Writer
|
||||
}
|
||||
|
||||
var _ io.WriteCloser = (*SeekableWriter)(nil)
|
||||
@@ -114,7 +114,7 @@ func (w *SeekableWriter) Close() error {
|
||||
}
|
||||
|
||||
type SeekableReader struct {
|
||||
r seekable.Reader
|
||||
r *seekable.Reader
|
||||
c func() error
|
||||
}
|
||||
|
||||
|
||||
@@ -1321,6 +1321,7 @@
|
||||
"repo.editor.fork_branch_exists": "Branch \"%s\" already exists in your fork. Please choose a new branch name.",
|
||||
"repo.commits.desc": "Browse source code change history.",
|
||||
"repo.commits.commits": "Commits",
|
||||
"repo.commits.history_enable_follow_renames": "Include renames",
|
||||
"repo.commits.no_commits": "No commits in common. \"%s\" and \"%s\" have entirely different histories.",
|
||||
"repo.commits.nothing_to_compare": "There are no differences to show.",
|
||||
"repo.commits.search.tooltip": "You can prefix keywords with \"author:\", \"committer:\", \"after:\", or \"before:\", e.g. \"revert author:Alice before:2019-01-13\".",
|
||||
@@ -2204,10 +2205,10 @@
|
||||
"repo.settings.trust_model.collaborator.desc": "Valid signatures by collaborators of this repository will be marked \"trusted\", whether they match the committer or not. Otherwise, valid signatures will be marked \"untrusted\" if the signature matches the committer and \"unmatched\" if not.",
|
||||
"repo.settings.trust_model.committer": "Committer",
|
||||
"repo.settings.trust_model.committer.long": "Committer: Trust signatures that match committers. This matches GitHub's behavior and will force commits signed by Gitea to have Gitea as the committer.",
|
||||
"repo.settings.trust_model.committer.desc": "Valid signatures will only be marked \"trusted\" if they match the committer, otherwise they will be marked \"unmatched\". This forces Gitea to be the committer on signed commits, with the actual committer marked as Co-authored-by: and Co-committed-by: trailer in the commit. The default Gitea key must match a user in the database.",
|
||||
"repo.settings.trust_model.committer.desc": "Valid signatures will only be marked \"trusted\" if they match the committer, otherwise they will be marked \"unmatched\". This forces Gitea to be the committer on signed commits, with the actual committer marked as a Co-authored-by: trailer in the commit. The default Gitea key must match a user in the database.",
|
||||
"repo.settings.trust_model.collaboratorcommitter": "Collaborator+Committer",
|
||||
"repo.settings.trust_model.collaboratorcommitter.long": "Collaborator+Committer: Trust signatures by collaborators which match the committer",
|
||||
"repo.settings.trust_model.collaboratorcommitter.desc": "Valid signatures by collaborators of this repository will be marked \"trusted\" if they match the committer. Otherwise, valid signatures will be marked \"untrusted\" if the signature matches the committer and \"unmatched\" otherwise. This will force Gitea to be marked as the committer on signed commits, with the actual committer marked as Co-Authored-By: and Co-Committed-By: trailer in the commit. The default Gitea key must match a user in the database.",
|
||||
"repo.settings.trust_model.collaboratorcommitter.desc": "Valid signatures by collaborators of this repository will be marked \"trusted\" if they match the committer. Otherwise, valid signatures will be marked \"untrusted\" if the signature matches the committer and \"unmatched\" otherwise. This will force Gitea to be marked as the committer on signed commits, with the actual committer marked as a Co-Authored-By: trailer in the commit. The default Gitea key must match a user in the database.",
|
||||
"repo.settings.wiki_delete": "Delete Wiki Data",
|
||||
"repo.settings.wiki_delete_desc": "Deleting repository wiki data is permanent and cannot be undone.",
|
||||
"repo.settings.wiki_delete_notices_1": "- This will permanently delete and disable the repository wiki for %s.",
|
||||
@@ -2598,6 +2599,9 @@
|
||||
"repo.diff.review.reject": "Request changes",
|
||||
"repo.diff.review.self_approve": "Pull request authors can't approve their own pull request",
|
||||
"repo.diff.committed_by": "committed by",
|
||||
"repo.diff.coauthored_by": "co-authored by",
|
||||
"repo.commits.avatar_stack_and": "and",
|
||||
"repo.commits.avatar_stack_people": "%d people",
|
||||
"repo.diff.protected": "Protected",
|
||||
"repo.diff.image.side_by_side": "Side by Side",
|
||||
"repo.diff.image.swipe": "Swipe",
|
||||
@@ -3773,6 +3777,7 @@
|
||||
"actions.runs.no_matching_online_runner_helper": "No matching online runner with label: %s",
|
||||
"actions.runs.no_job_without_needs": "The workflow must contain at least one job without dependencies.",
|
||||
"actions.runs.no_job": "The workflow must contain at least one job",
|
||||
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",
|
||||
"actions.runs.actor": "Actor",
|
||||
"actions.runs.status": "Status",
|
||||
"actions.runs.actors_no_select": "All actors",
|
||||
@@ -3793,13 +3798,17 @@
|
||||
"actions.runs.view_workflow_file": "View workflow file",
|
||||
"actions.runs.summary": "Summary",
|
||||
"actions.runs.all_jobs": "All jobs",
|
||||
"actions.runs.job_summaries": "Job summaries",
|
||||
"actions.runs.expand_caller_jobs": "Show jobs of this reusable workflow caller",
|
||||
"actions.runs.collapse_caller_jobs": "Hide jobs of this reusable workflow caller",
|
||||
"actions.runs.attempt": "Attempt",
|
||||
"actions.runs.latest": "Latest",
|
||||
"actions.runs.latest_attempt": "Latest attempt",
|
||||
"actions.runs.triggered_via": "Triggered via %s",
|
||||
"actions.runs.total_duration": "Total duration:",
|
||||
"actions.runs.rerun_triggered": "Re-run triggered",
|
||||
"actions.runs.back_to_pull_request": "Back to pull request",
|
||||
"actions.runs.back_to_workflow": "Back to workflow",
|
||||
"actions.runs.total_duration": "Total duration",
|
||||
"actions.runs.workflow_dependencies": "Workflow Dependencies",
|
||||
"actions.runs.graph_jobs_count_1": "%d job",
|
||||
"actions.runs.graph_jobs_count_n": "%d jobs",
|
||||
|
||||
@@ -1321,6 +1321,7 @@
|
||||
"repo.editor.fork_branch_exists": "Tá brainse \"%s\" ann cheana féin i do fhorc. Roghnaigh ainm brainse nua le do thoil.",
|
||||
"repo.commits.desc": "Brabhsáil stair athraithe cód foinse.",
|
||||
"repo.commits.commits": "Tiomáintí",
|
||||
"repo.commits.history_enable_follow_renames": "Cuir athainmneacha san áireamh",
|
||||
"repo.commits.no_commits": "Níl aon ghealltanas i gcoiteann. Tá stair iomlán difriúil ag \"%s\" agus \"%s\".",
|
||||
"repo.commits.nothing_to_compare": "Níl aon difríochtaí le taispeáint.",
|
||||
"repo.commits.search.tooltip": "Is féidir eochairfhocail a réamhfhostú le “údar:”, “committer:”, “after:”, nó “before:”, e.g. \"fill an t-údar:Alice roimh: 2019-01-13\".",
|
||||
|
||||
45
package.json
45
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.2.1",
|
||||
"packageManager": "pnpm@11.5.1",
|
||||
"engines": {
|
||||
"node": ">= 22.18.0",
|
||||
"pnpm": ">= 11.0.0"
|
||||
@@ -10,7 +10,7 @@
|
||||
"@citation-js/plugin-bibtex": "0.7.21",
|
||||
"@citation-js/plugin-csl": "0.7.22",
|
||||
"@citation-js/plugin-software-formats": "0.6.2",
|
||||
"@codemirror/autocomplete": "6.20.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@mcaptcha/vanilla-glue": "0.1.0-rc2",
|
||||
"@mermaid-js/layout-elk": "0.2.1",
|
||||
"@primer/octicons": "19.26.0",
|
||||
"@primer/octicons": "19.28.0",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@replit/codemirror-lang-nix": "6.0.1",
|
||||
"@replit/codemirror-lang-svelte": "6.0.0",
|
||||
@@ -45,19 +45,19 @@
|
||||
"colord": "2.9.3",
|
||||
"compare-versions": "6.1.1",
|
||||
"cropperjs": "1.6.2",
|
||||
"dayjs": "1.11.20",
|
||||
"dayjs": "1.11.21",
|
||||
"easymde": "2.21.0",
|
||||
"esbuild": "0.28.0",
|
||||
"idiomorph": "0.7.4",
|
||||
"jquery": "4.0.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"katex": "0.16.47",
|
||||
"js-yaml": "4.2.0",
|
||||
"katex": "0.17.0",
|
||||
"mermaid": "11.15.0",
|
||||
"online-3d-viewer": "0.18.0",
|
||||
"pdfobject": "2.3.1",
|
||||
"perfect-debounce": "2.1.0",
|
||||
"postcss": "8.5.15",
|
||||
"rolldown-license-plugin": "3.0.7",
|
||||
"rolldown-license-plugin": "3.0.9",
|
||||
"sortablejs": "1.15.7",
|
||||
"swagger-ui-dist": "5.32.6",
|
||||
"tailwindcss": "3.4.19",
|
||||
@@ -67,15 +67,15 @@
|
||||
"tributejs": "5.1.3",
|
||||
"uint8-to-base64": "0.2.1",
|
||||
"vanilla-colorful": "0.7.2",
|
||||
"vite": "8.0.13",
|
||||
"vite": "8.0.16",
|
||||
"vite-string-plugin": "2.0.4",
|
||||
"vue": "3.5.34",
|
||||
"vue": "3.5.35",
|
||||
"vue-bar-graph": "2.2.0",
|
||||
"vue-chartjs": "5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.7.1",
|
||||
"@eslint/json": "1.2.0",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.7.2",
|
||||
"@eslint/json": "2.0.0",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@stylistic/stylelint-plugin": "5.2.0",
|
||||
@@ -89,11 +89,11 @@
|
||||
"@types/swagger-ui-dist": "3.30.6",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/toastify-js": "1.12.4",
|
||||
"@typescript-eslint/parser": "8.59.4",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vitest/eslint-plugin": "1.6.17",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"@vitest/eslint-plugin": "1.6.19",
|
||||
"eslint": "10.4.1",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-array-func": "5.1.1",
|
||||
"eslint-plugin-de-morgan": "2.1.2",
|
||||
"eslint-plugin-github": "6.0.0",
|
||||
@@ -102,15 +102,14 @@
|
||||
"eslint-plugin-regexp": "3.1.0",
|
||||
"eslint-plugin-sonarjs": "4.0.3",
|
||||
"eslint-plugin-unicorn": "64.0.0",
|
||||
"eslint-plugin-vue": "10.9.1",
|
||||
"eslint-plugin-vue-scoped-css": "3.1.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"eslint-plugin-vue-scoped-css": "3.1.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"globals": "17.6.0",
|
||||
"happy-dom": "20.9.0",
|
||||
"jiti": "2.7.0",
|
||||
"markdownlint-cli": "0.48.0",
|
||||
"material-icon-theme": "5.34.0",
|
||||
"nolyfill": "1.0.44",
|
||||
"material-icon-theme": "5.35.0",
|
||||
"postcss-html": "1.8.1",
|
||||
"spectral-cli-bundle": "1.0.8",
|
||||
"stylelint": "17.12.0",
|
||||
@@ -120,9 +119,9 @@
|
||||
"stylelint-value-no-unknown-custom-properties": "6.1.1",
|
||||
"svgo": "4.0.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.4",
|
||||
"updates": "17.16.13",
|
||||
"vitest": "4.1.7",
|
||||
"vue-tsc": "3.3.1"
|
||||
"typescript-eslint": "8.60.1",
|
||||
"updates": "17.17.3",
|
||||
"vitest": "4.1.8",
|
||||
"vue-tsc": "3.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
3639
pnpm-lock.yaml
generated
3639
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
packages: [ . ] # workaround for https://github.com/SukkaW/nolyfill/issues/119
|
||||
savePrefix: ''
|
||||
dedupePeerDependents: false
|
||||
updateNotifier: false
|
||||
@@ -8,28 +7,6 @@ peerDependencyRules:
|
||||
allowedVersions:
|
||||
eslint-plugin-github>eslint: '>=9'
|
||||
|
||||
overrides:
|
||||
array-includes: npm:@nolyfill/array-includes@^1
|
||||
array.prototype.findlastindex: npm:@nolyfill/array.prototype.findlastindex@^1
|
||||
array.prototype.flat: npm:@nolyfill/array.prototype.flat@^1
|
||||
array.prototype.flatmap: npm:@nolyfill/array.prototype.flatmap@^1
|
||||
es-aggregate-error: npm:@nolyfill/es-aggregate-error@^1
|
||||
hasown: npm:@nolyfill/hasown@^1
|
||||
is-core-module: npm:@nolyfill/is-core-module@^1
|
||||
object.assign: npm:@nolyfill/object.assign@^1
|
||||
object.fromentries: npm:@nolyfill/object.fromentries@^1
|
||||
object.groupby: npm:@nolyfill/object.groupby@^1
|
||||
object.values: npm:@nolyfill/object.values@^1
|
||||
safe-buffer: npm:@nolyfill/safe-buffer@^1
|
||||
safe-regex-test: npm:@nolyfill/safe-regex-test@^1
|
||||
safer-buffer: npm:@nolyfill/safer-buffer@^1
|
||||
string.prototype.includes: npm:@nolyfill/string.prototype.includes@^1
|
||||
string.prototype.trimend: npm:@nolyfill/string.prototype.trimend@^1
|
||||
object-keys: npm:@nolyfill/object-keys@^1
|
||||
object.entries: npm:@nolyfill/object.entries@^1
|
||||
abab: npm:@nolyfill/abab@^1
|
||||
es-set-tostringtag: npm:@nolyfill/es-set-tostringtag@^1
|
||||
|
||||
allowBuilds:
|
||||
'@scarf/scarf': false
|
||||
core-js: false
|
||||
|
||||
1
public/assets/img/svg/octicon-flag.svg
generated
Normal file
1
public/assets/img/svg/octicon-flag.svg
generated
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" class="svg octicon-flag" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M15 10.21V2.22c0-.08-.02-.15-.05-.23 0-.02 0-.04-.01-.06-.02-.05-.06-.09-.09-.13s-.04-.08-.07-.11c-.02-.02-.04-.02-.06-.04a.8.8 0 0 0-.19-.12c-2.78-1.11-4.86-.25-6.69.5-1.73.72-3.24 1.33-5.34.57v-.63c0-.41-.34-.75-.75-.75S1 1.56 1 1.97v12.5c0 .41.34.75.75.75s.75-.34.75-.75V4.18c.66.19 1.28.27 1.87.27 1.54 0 2.85-.54 4.05-1.04 1.66-.69 3.12-1.27 5.09-.65v6.43c-2.28-.56-4.06.17-5.66.84-1.25.52-2.41 1-3.79.92-.38-.03-.77.29-.8.7s.29.77.7.8c.14 0 .29.01.42.01 1.52 0 2.8-.53 4.04-1.04 1.79-.74 3.34-1.38 5.56-.5.06.02.12.03.19.03.03 0 .06.02.09.02.23 0 .45-.12.59-.31.04-.05.08-.09.1-.15.02-.05.02-.1.03-.15 0-.04.03-.08.03-.13z"/></svg>
|
||||
|
After Width: | Height: | Size: 805 B |
2
public/assets/img/svg/octicon-vscode.svg
generated
2
public/assets/img/svg/octicon-vscode.svg
generated
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 16 16" class="svg octicon-vscode" width="16" height="16"><path d="M10.863 13.919a.8.8 0 0 1-.644.025.8.8 0 0 1-.279-.183L4.816 9.063l-2.232 1.703a.54.54 0 0 1-.691-.031l-.716-.655a.546.546 0 0 1 0-.805L3.112 7.5 1.177 5.725a.546.546 0 0 1 0-.805l.716-.655a.54.54 0 0 1 .691-.031l2.232 1.703L9.94 1.239a.805.805 0 0 1 .923-.159l2.677 1.295c.281.136.46.422.46.736V8h-3.248V4.534L6.864 7.5l3.888 2.966V8H14v3.889c0 .314-.179.6-.46.736z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg octicon-vscode" width="16" height="16" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M11.098 1.013a.87.87 0 0 1 .524.073l2.883 1.394a.88.88 0 0 1 .495.793v9.454a.88.88 0 0 1-.495.792l-2.883 1.393a.86.86 0 0 1-.994-.17l-5.519-5.06-2.403 1.835a.58.58 0 0 1-.744-.034l-.772-.705a.59.59 0 0 1 0-.867L3.274 8 1.19 6.088a.59.59 0 0 1 0-.866l.772-.706a.58.58 0 0 1 .744-.034l2.403 1.834 5.519-5.058a.87.87 0 0 1 .47-.245M7.315 8l4.187 3.193V11H11.5V6h.002V4.806z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 513 B After Width: | Height: | Size: 577 B |
@@ -95,7 +95,7 @@
|
||||
"matchManagers": ["npm"],
|
||||
"postUpdateOptions": ["pnpmDedupe"],
|
||||
"postUpgradeTasks": {
|
||||
"commands": ["make svg nolyfill"],
|
||||
"commands": ["make svg"],
|
||||
"fileFilters": ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "public/assets/img/svg/**"],
|
||||
"executionMode": "branch",
|
||||
},
|
||||
|
||||
@@ -121,6 +121,9 @@ func ArtifactsRoutes(prefix string) *web.Router {
|
||||
m.Get("/{artifact_id}/download", r.downloadArtifact)
|
||||
})
|
||||
|
||||
// Job summary upload endpoint (GITHUB_STEP_SUMMARY).
|
||||
m.Put(jobSummaryRouteBase, uploadJobSummary)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
104
routers/api/actions/job_summary.go
Normal file
104
routers/api/actions/job_summary.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
const jobSummaryRouteBase = "/_apis/pipelines/workflows/{run_id}/jobs/{job_id}/steps/{step_index}/summary"
|
||||
|
||||
func uploadJobSummary(ctx *ArtifactContext) {
|
||||
task, _, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
jobID := ctx.PathParamInt64("job_id")
|
||||
if jobID <= 0 || task.Job.ID != jobID {
|
||||
ctx.HTTPError(http.StatusBadRequest, "job_id mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
stepIndex, err := strconv.ParseInt(ctx.PathParam("step_index"), 10, 64)
|
||||
if err != nil || stepIndex < 0 {
|
||||
ctx.HTTPError(http.StatusBadRequest, "invalid step_index")
|
||||
return
|
||||
}
|
||||
steps, err := actions_model.GetTaskStepsByTaskID(ctx, task.ID)
|
||||
if err != nil {
|
||||
log.Error("Error getting task steps: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error getting task steps")
|
||||
return
|
||||
}
|
||||
if !slices.ContainsFunc(steps, func(s *actions_model.ActionTaskStep) bool { return s.Index == stepIndex }) {
|
||||
ctx.HTTPError(http.StatusBadRequest, "step_index mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
contentType, ok := normalizeJobSummaryContentType(ctx.Req.Header.Get("Content-Type"))
|
||||
if !ok {
|
||||
ctx.HTTPError(http.StatusBadRequest, "invalid summary content type")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, actions_model.MaxJobSummarySize+1))
|
||||
if err != nil {
|
||||
log.Error("Error reading job summary request body: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "read request body")
|
||||
return
|
||||
}
|
||||
message := "success"
|
||||
if len(body) == 0 {
|
||||
// PUT with an empty body clears any previously-stored summary for this step.
|
||||
if err := actions_model.DeleteActionRunJobSummary(ctx, task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, stepIndex); err != nil {
|
||||
log.Error("Error deleting job summary: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error deleting job summary")
|
||||
return
|
||||
}
|
||||
message = "cleared"
|
||||
} else if err := actions_model.UpsertActionRunJobSummary(ctx, task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, stepIndex, contentType, body); err != nil {
|
||||
if errors.Is(err, actions_model.ErrJobSummaryAggregateExceeded) {
|
||||
ctx.HTTPError(http.StatusBadRequest, "job summary aggregate size exceeded")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.HTTPError(http.StatusBadRequest, "invalid summary")
|
||||
return
|
||||
}
|
||||
log.Error("Error upsert job summary: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error upsert job summary")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"message": message,
|
||||
"sizeBytes": len(body),
|
||||
"runAttempt": task.Job.RunAttemptID,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeJobSummaryContentType(contentType string) (string, bool) {
|
||||
if contentType == "" || contentType == "application/octet-stream" {
|
||||
return actions_model.JobSummaryContentTypeMarkdown, true
|
||||
}
|
||||
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if mediaType != actions_model.JobSummaryContentTypeMarkdown {
|
||||
return "", false
|
||||
}
|
||||
return actions_model.JobSummaryContentTypeMarkdown, true
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func (s *Service) Declare(
|
||||
return nil, status.Errorf(codes.Internal, "update runner: %v", err)
|
||||
}
|
||||
|
||||
return connect.NewResponse(&runnerv1.DeclareResponse{
|
||||
resp := connect.NewResponse(&runnerv1.DeclareResponse{
|
||||
Runner: &runnerv1.Runner{
|
||||
Id: runner.ID,
|
||||
Uuid: runner.UUID,
|
||||
@@ -170,7 +170,11 @@ func (s *Service) Declare(
|
||||
Version: runner.Version,
|
||||
Labels: runner.AgentLabels,
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
// Capabilities are communicated via headers to avoid a hard dependency on a proto bump.
|
||||
// Older runners ignore unknown headers; newer runners can use this for feature negotiation.
|
||||
resp.Header().Set("X-Gitea-Actions-Capabilities", actions_model.RunnerCapabilities())
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// FetchTask assigns a task to the runner
|
||||
|
||||
@@ -67,7 +67,7 @@ func CreateOrg(ctx *context.APIContext) {
|
||||
db.IsErrNameReserved(err) ||
|
||||
db.IsErrNameCharsNotAllowed(err) ||
|
||||
db.IsErrNamePatternNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func parseAuthSource(ctx *context.APIContext, u *user_model.User, sourceID int64
|
||||
source, err := auth.GetSourceByID(ctx, sourceID)
|
||||
if err != nil {
|
||||
if auth.IsErrSourceNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -97,14 +97,12 @@ func CreateUser(ctx *context.APIContext) {
|
||||
|
||||
if u.LoginType == auth.Plain {
|
||||
if len(form.Password) < setting.MinPasswordLength {
|
||||
err := errors.New("PasswordIsRequired")
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, "PasswordIsRequired")
|
||||
return
|
||||
}
|
||||
|
||||
if !password.IsComplexEnough(form.Password) {
|
||||
err := errors.New("PasswordComplexity")
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, "PasswordComplexity")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,7 +110,7 @@ func CreateUser(ctx *context.APIContext) {
|
||||
if password.IsErrIsPwnedRequest(err) {
|
||||
log.Error(err.Error())
|
||||
}
|
||||
ctx.APIError(http.StatusBadRequest, errors.New("PasswordPwned"))
|
||||
ctx.APIError(http.StatusBadRequest, "PasswordPwned")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -143,7 +141,7 @@ func CreateUser(ctx *context.APIContext) {
|
||||
user_model.IsErrEmailCharIsNotSupported(err) ||
|
||||
user_model.IsErrEmailInvalid(err) ||
|
||||
db.IsErrNamePatternNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -204,11 +202,11 @@ func EditUser(ctx *context.APIContext) {
|
||||
if err := user_service.UpdateAuth(ctx, ctx.ContextUser, authOpts); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, password.ErrMinLength):
|
||||
ctx.APIError(http.StatusBadRequest, fmt.Errorf("password must be at least %d characters", setting.MinPasswordLength))
|
||||
ctx.APIError(http.StatusBadRequest, fmt.Sprintf("password must be at least %d characters", setting.MinPasswordLength))
|
||||
case errors.Is(err, password.ErrComplexity):
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, password.ErrIsPwned), password.IsErrIsPwnedRequest(err):
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -222,9 +220,9 @@ func EditUser(ctx *context.APIContext) {
|
||||
if !user_model.IsEmailDomainAllowed(*form.Email) {
|
||||
err = fmt.Errorf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", *form.Email)
|
||||
}
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
case user_model.IsErrEmailAlreadyUsed(err):
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -249,7 +247,7 @@ func EditUser(ctx *context.APIContext) {
|
||||
|
||||
if err := user_service.UpdateUser(ctx, ctx.ContextUser, opts); err != nil {
|
||||
if user_model.IsErrDeleteLastAdminUser(err) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -289,13 +287,13 @@ func DeleteUser(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
if ctx.ContextUser.IsOrganization() {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "target is an organization but not user")
|
||||
return
|
||||
}
|
||||
|
||||
// admin should not delete themself
|
||||
if ctx.ContextUser.ID == ctx.Doer.ID {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, errors.New("you cannot delete yourself"))
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "you cannot delete yourself")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -304,7 +302,7 @@ func DeleteUser(ctx *context.APIContext) {
|
||||
org_model.IsErrUserHasOrgs(err) ||
|
||||
packages_model.IsErrUserOwnPackages(err) ||
|
||||
user_model.IsErrDeleteLastAdminUser(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -471,13 +469,11 @@ func SearchUsers(ctx *context.APIContext) {
|
||||
|
||||
var visible []api.VisibleType
|
||||
visibilityParam := ctx.FormString("visibility")
|
||||
if len(visibilityParam) > 0 {
|
||||
if visibility, ok := api.VisibilityModes[visibilityParam]; ok {
|
||||
visible = []api.VisibleType{visibility}
|
||||
} else {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("Invalid visibility: \"%s\"", visibilityParam))
|
||||
return
|
||||
}
|
||||
if visibility, ok := api.VisibilityModes[visibilityParam]; ok {
|
||||
visible = []api.VisibleType{visibility}
|
||||
} else if visibilityParam != "" {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "invalid visibility")
|
||||
return
|
||||
}
|
||||
|
||||
searchOpts := user_model.SearchUserOptions{
|
||||
@@ -551,7 +547,7 @@ func RenameUser(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
if ctx.ContextUser.IsOrganization() {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "target is an organization but not user")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -560,7 +556,7 @@ func RenameUser(ctx *context.APIContext) {
|
||||
// Check if username has been changed
|
||||
if err := user_service.RenameUser(ctx, ctx.ContextUser, newName, ctx.Doer); err != nil {
|
||||
if user_model.IsErrUserAlreadyExist(err) || db.IsErrNameReserved(err) || db.IsErrNamePatternNotAllowed(err) || db.IsErrNameCharsNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func repoAssignment() func(ctx *context.APIContext) {
|
||||
if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil {
|
||||
context.RedirectToUser(ctx.Base, ctx.Doer, userName, redirectUserID)
|
||||
} else if user_model.IsErrUserRedirectNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -626,7 +626,7 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) {
|
||||
if err == nil {
|
||||
context.RedirectToUser(ctx.Base, ctx.Doer, ctx.PathParam("org"), redirectUserID)
|
||||
} else if user_model.IsErrUserRedirectNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetOrgByName", err)
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -734,14 +734,14 @@ func mustEnableWiki(ctx *context.APIContext) {
|
||||
// FIXME: for consistency, maybe most mustNotBeArchived checks should be replaced with mustEnableEditor
|
||||
func mustNotBeArchived(ctx *context.APIContext) {
|
||||
if ctx.Repo.Repository.IsArchived {
|
||||
ctx.APIError(http.StatusLocked, fmt.Errorf("%s is archived", ctx.Repo.Repository.FullName()))
|
||||
ctx.APIError(http.StatusLocked, "repo is archived")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func mustEnableEditor(ctx *context.APIContext) {
|
||||
if !ctx.Repo.Repository.CanEnableEditor() {
|
||||
ctx.APIError(http.StatusLocked, fmt.Errorf("%s is not allowed to edit", ctx.Repo.Repository.FullName()))
|
||||
ctx.APIError(http.StatusLocked, "repo is not allowed to edit")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -862,12 +862,12 @@ func individualPermsChecker(ctx *context.APIContext) {
|
||||
switch ctx.ContextUser.Visibility {
|
||||
case api.VisibleTypePrivate:
|
||||
if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin) {
|
||||
ctx.APIErrorNotFound("Visit Project", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
case api.VisibleTypeLimited:
|
||||
if ctx.Doer == nil {
|
||||
ctx.APIErrorNotFound("Visit Project", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func NewAvailable(ctx *context.APIContext) {
|
||||
Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func NewAvailable(ctx *context.APIContext) {
|
||||
func getFindNotificationOptions(ctx *context.APIContext) *activities_model.FindNotificationOptions {
|
||||
before, since, err := context.GetQueryBeforeSince(ctx.Base)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
return nil
|
||||
}
|
||||
opts := &activities_model.FindNotificationOptions{
|
||||
|
||||
@@ -183,7 +183,7 @@ func ReadRepoNotifications(ctx *context.APIContext) {
|
||||
if len(qLastRead) > 0 {
|
||||
tmpLastRead, err := time.Parse(time.RFC3339, qLastRead)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !tmpLastRead.IsZero() {
|
||||
|
||||
@@ -104,14 +104,14 @@ func getThread(ctx *context.APIContext) *activities_model.Notification {
|
||||
n, err := activities_model.GetNotificationByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
ctx.APIError(http.StatusNotFound, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if n.UserID != ctx.Doer.ID && !ctx.Doer.IsAdmin {
|
||||
ctx.APIError(http.StatusForbidden, fmt.Errorf("only user itself and admin are allowed to read/change this thread %d", n.ID))
|
||||
ctx.APIError(http.StatusForbidden, fmt.Sprintf("only user itself and admin are allowed to read/change this thread %d", n.ID))
|
||||
return nil
|
||||
}
|
||||
return n
|
||||
|
||||
@@ -134,7 +134,7 @@ func ReadNotifications(ctx *context.APIContext) {
|
||||
if len(qLastRead) > 0 {
|
||||
tmpLastRead, err := time.Parse(time.RFC3339, qLastRead)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !tmpLastRead.IsZero() {
|
||||
|
||||
@@ -111,9 +111,9 @@ func (Action) CreateOrUpdateSecret(ctx *context.APIContext) {
|
||||
_, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
ctx.APIError(http.StatusNotFound, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -158,9 +158,9 @@ func (Action) DeleteSecret(ctx *context.APIContext) {
|
||||
err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
ctx.APIError(http.StatusNotFound, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -277,7 +277,7 @@ func (Action) GetVariable(ctx *context.APIContext) {
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
ctx.APIError(http.StatusNotFound, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -327,9 +327,9 @@ func (Action) DeleteVariable(ctx *context.APIContext) {
|
||||
|
||||
if err := actions_service.DeleteVariableByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("variablename")); err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
ctx.APIError(http.StatusNotFound, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -387,13 +387,13 @@ func (Action) CreateVariable(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
if v != nil && v.ID > 0 {
|
||||
ctx.APIError(http.StatusConflict, util.NewAlreadyExistErrorf("variable name %s already exists", variableName))
|
||||
ctx.APIError(http.StatusConflict, "variable name already exists")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := actions_service.CreateVariable(ctx, ownerID, 0, variableName, opt.Value, opt.Description); err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -445,7 +445,7 @@ func (Action) UpdateVariable(ctx *context.APIContext) {
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
ctx.APIError(http.StatusNotFound, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -462,7 +462,7 @@ func (Action) UpdateVariable(ctx *context.APIContext) {
|
||||
|
||||
if _, err := actions_service.UpdateVariableNameData(ctx, v); err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func UpdateAvatar(ctx *context.APIContext) {
|
||||
|
||||
content, err := base64.StdEncoding.DecodeString(form.Image)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
ctx.APIError(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ func CreateLabel(ctx *context.APIContext) {
|
||||
form.Color = strings.Trim(form.Color, " ")
|
||||
color, err := label.NormalizeColor(form.Color)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
form.Color = color
|
||||
@@ -209,7 +209,7 @@ func EditLabel(ctx *context.APIContext) {
|
||||
if form.Color != nil {
|
||||
color, err := label.NormalizeColor(*form.Color)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
l.Color = color
|
||||
|
||||
@@ -221,7 +221,7 @@ func checkCanChangeOrgUserStatus(ctx *context.APIContext, targetUser *user_model
|
||||
// allow org owners to change status of members
|
||||
isOwner, err := ctx.Org.Organization.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusInternalServerError, err)
|
||||
ctx.APIErrorInternal(err)
|
||||
} else if !isOwner {
|
||||
ctx.APIError(http.StatusForbidden, "Cannot change member visibility")
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func GetUserOrgsPermissions(ctx *context.APIContext) {
|
||||
op := api.OrganizationPermissions{}
|
||||
|
||||
if !organization.HasOrgOrUserVisible(ctx, o, ctx.Doer) {
|
||||
ctx.APIErrorNotFound("HasOrgOrUserVisible", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ func Create(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/validationError"
|
||||
form := web.GetForm(ctx).(*api.CreateOrgOption)
|
||||
if !ctx.Doer.CanCreateOrganization() {
|
||||
ctx.APIError(http.StatusForbidden, nil)
|
||||
ctx.APIError(http.StatusForbidden, "not allowed to create org")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ func Create(ctx *context.APIContext) {
|
||||
db.IsErrNameReserved(err) ||
|
||||
db.IsErrNameCharsNotAllowed(err) ||
|
||||
db.IsErrNamePatternNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -312,7 +312,7 @@ func Get(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
||||
ctx.APIErrorNotFound("HasOrgOrUserVisible", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ func Rename(ctx *context.APIContext) {
|
||||
orgUser := ctx.Org.Organization.AsUser()
|
||||
if err := user_service.RenameUser(ctx, orgUser, form.NewName, ctx.Doer); err != nil {
|
||||
if user_model.IsErrUserAlreadyExist(err) || db.IsErrNameReserved(err) || db.IsErrNamePatternNotAllowed(err) || db.IsErrNameCharsNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -394,7 +394,7 @@ func Edit(ctx *context.APIContext) {
|
||||
|
||||
if err := org.UpdateOrgEmailAddress(ctx, ctx.Org.Organization, form.Email); err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
|
||||
@@ -236,7 +236,7 @@ func CreateTeam(ctx *context.APIContext) {
|
||||
|
||||
if err := org_service.NewTeam(ctx, team); err != nil {
|
||||
if organization.IsErrTeamAlreadyExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -490,7 +490,7 @@ func AddTeamMember(ctx *context.APIContext) {
|
||||
}
|
||||
if err := org_service.AddTeamMember(ctx, ctx.Org.Team, u); err != nil {
|
||||
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
ctx.APIError(http.StatusForbidden, err)
|
||||
ctx.APIError(http.StatusForbidden, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user