mirror of
https://github.com/go-gitea/gitea.git
synced 2026-07-25 22:48:10 +02:00
Merge branch 'main' into punit/ISSUE-36170
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -16,7 +16,7 @@ EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-che
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+5
-12
@@ -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 {
|
||||
|
||||
+2
-6
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+2
-36
@@ -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])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+48
-53
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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,8 +10,10 @@ import (
|
||||
"math"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
user_model "gitea.dev/models/gituser"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/renderhelper"
|
||||
"gitea.dev/models/repo"
|
||||
@@ -22,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"
|
||||
@@ -31,11 +34,12 @@ 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 title (only title)
|
||||
@@ -291,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"
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2205,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.",
|
||||
@@ -2599,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",
|
||||
@@ -3774,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",
|
||||
@@ -3794,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",
|
||||
|
||||
+3
-3
@@ -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",
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.7.2",
|
||||
"@eslint/json": "1.2.0",
|
||||
"@eslint/json": "2.0.0",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@stylistic/stylelint-plugin": "5.2.0",
|
||||
@@ -102,7 +102,7 @@
|
||||
"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": "10.9.2",
|
||||
"eslint-plugin-vue-scoped-css": "3.1.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"globals": "17.6.0",
|
||||
|
||||
Generated
+54
-89
@@ -21,8 +21,8 @@ importers:
|
||||
specifier: 0.6.2
|
||||
version: 0.6.2
|
||||
'@codemirror/autocomplete':
|
||||
specifier: 6.20.2
|
||||
version: 6.20.2
|
||||
specifier: 6.20.3
|
||||
version: 6.20.3
|
||||
'@codemirror/commands':
|
||||
specifier: 6.10.3
|
||||
version: 6.10.3
|
||||
@@ -82,13 +82,13 @@ importers:
|
||||
version: 6.5.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)
|
||||
'@replit/codemirror-lang-nix':
|
||||
specifier: 6.0.1
|
||||
version: 6.0.1(@codemirror/autocomplete@6.20.2)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.10)
|
||||
version: 6.0.1(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.10)
|
||||
'@replit/codemirror-lang-svelte':
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0(@codemirror/autocomplete@6.20.2)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.10)
|
||||
version: 6.0.0(@codemirror/autocomplete@6.20.3)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.10)
|
||||
'@replit/codemirror-vscode-keymap':
|
||||
specifier: 6.0.2
|
||||
version: 6.0.2(@codemirror/autocomplete@6.20.2)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)
|
||||
version: 6.0.2(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)
|
||||
'@resvg/resvg-wasm':
|
||||
specifier: 2.6.2
|
||||
version: 2.6.2
|
||||
@@ -211,8 +211,8 @@ importers:
|
||||
specifier: 4.7.2
|
||||
version: 4.7.2(eslint@10.4.1(jiti@2.7.0))
|
||||
'@eslint/json':
|
||||
specifier: 1.2.0
|
||||
version: 1.2.0
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
'@playwright/test':
|
||||
specifier: 1.60.0
|
||||
version: 1.60.0
|
||||
@@ -289,8 +289,8 @@ importers:
|
||||
specifier: 64.0.0
|
||||
version: 64.0.0(eslint@10.4.1(jiti@2.7.0))
|
||||
eslint-plugin-vue:
|
||||
specifier: 10.9.1
|
||||
version: 10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.4.1(jiti@2.7.0)))(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.1(jiti@2.7.0)))
|
||||
specifier: 10.9.2
|
||||
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@10.4.1(jiti@2.7.0)))(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.1(jiti@2.7.0)))
|
||||
eslint-plugin-vue-scoped-css:
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1(eslint@10.4.1(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.1(jiti@2.7.0)))
|
||||
@@ -446,8 +446,8 @@ packages:
|
||||
resolution: {integrity: sha512-3XQOO3u4WXY/7AWZyQ+9SuBzS8bYTlJ+NF1uCgrZO64g36nK5iIc5YV9cBl2TL2QhHF6S36nvAsXsj5fX9FeHw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@codemirror/autocomplete@6.20.2':
|
||||
resolution: {integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==}
|
||||
'@codemirror/autocomplete@6.20.3':
|
||||
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
|
||||
|
||||
'@codemirror/commands@6.10.3':
|
||||
resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==}
|
||||
@@ -797,18 +797,14 @@ packages:
|
||||
resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/json@1.2.0':
|
||||
resolution: {integrity: sha512-CEFEyNgvzu8zn5QwVYDg3FaG+ZKUeUsNYitFpMYJAqoAlnw68EQgNbUfheSmexZr4n0wZPrAkPLuvsLaXO6wRw==}
|
||||
'@eslint/json@2.0.0':
|
||||
resolution: {integrity: sha512-P32ZJMIopNWQd1SFhd0tgjfA/hgzUuVSqHmMi2273QaLWHWimXq6V+qL4DNKnjGzO/aNECtYW+rEJ/pWB6uP+w==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@eslint/object-schema@3.0.5':
|
||||
resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@eslint/plugin-kit@0.6.1':
|
||||
resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@eslint/plugin-kit@0.7.2':
|
||||
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
@@ -2699,8 +2695,8 @@ packages:
|
||||
postcss-styl:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-vue@10.9.1:
|
||||
resolution: {integrity: sha512-cHB0Tf4Duvzwecwd/AqWzZvF/QszE13BhjVUpVXWCy9AeMR5GjkAjP3i85vqgLgOuTmkHR1OJ5oMeqLHtuw8zg==}
|
||||
eslint-plugin-vue@10.9.2:
|
||||
resolution: {integrity: sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0
|
||||
@@ -5053,7 +5049,7 @@ snapshots:
|
||||
'@citation-js/date': 0.5.1
|
||||
'@citation-js/name': 0.4.2
|
||||
|
||||
'@codemirror/autocomplete@6.20.2':
|
||||
'@codemirror/autocomplete@6.20.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
@@ -5083,7 +5079,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
@@ -5091,7 +5087,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-go@6.0.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
@@ -5099,7 +5095,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-html@6.4.11':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/lang-javascript': 6.2.5
|
||||
'@codemirror/language': 6.12.3
|
||||
@@ -5116,7 +5112,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-javascript@6.2.5':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/lint': 6.9.6
|
||||
'@codemirror/state': 6.6.0
|
||||
@@ -5126,7 +5122,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-jinja@6.0.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
@@ -5150,7 +5146,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-liquid@6.3.2':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
@@ -5161,7 +5157,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-markdown@6.5.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
@@ -5179,7 +5175,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-python@6.2.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
@@ -5200,7 +5196,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-sql@6.10.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
@@ -5225,7 +5221,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-xml@6.1.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
@@ -5234,7 +5230,7 @@ snapshots:
|
||||
|
||||
'@codemirror/lang-yaml@6.1.3':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
@@ -5485,20 +5481,15 @@ snapshots:
|
||||
|
||||
'@eslint/js@9.39.4': {}
|
||||
|
||||
'@eslint/json@1.2.0':
|
||||
'@eslint/json@2.0.0':
|
||||
dependencies:
|
||||
'@eslint/core': 1.2.1
|
||||
'@eslint/plugin-kit': 0.6.1
|
||||
'@eslint/plugin-kit': 0.7.2
|
||||
'@humanwhocodes/momoa': 3.3.10
|
||||
natural-compare: 1.4.0
|
||||
|
||||
'@eslint/object-schema@3.0.5': {}
|
||||
|
||||
'@eslint/plugin-kit@0.6.1':
|
||||
dependencies:
|
||||
'@eslint/core': 1.2.1
|
||||
levn: 0.4.1
|
||||
|
||||
'@eslint/plugin-kit@0.7.2':
|
||||
dependencies:
|
||||
'@eslint/core': 1.2.1
|
||||
@@ -5754,9 +5745,9 @@ snapshots:
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
|
||||
'@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.20.2)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.10)':
|
||||
'@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.10)':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
@@ -5764,9 +5755,9 @@ snapshots:
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.20.2)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.10)':
|
||||
'@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.20.3)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.10)':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/lang-javascript': 6.2.5
|
||||
@@ -5778,9 +5769,9 @@ snapshots:
|
||||
'@lezer/javascript': 1.5.4
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@replit/codemirror-vscode-keymap@6.0.2(@codemirror/autocomplete@6.20.2)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)':
|
||||
'@replit/codemirror-vscode-keymap@6.0.2(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.2
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/commands': 6.10.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/lint': 6.9.6
|
||||
@@ -6134,6 +6125,22 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)
|
||||
'@typescript-eslint/scope-manager': 8.60.1
|
||||
'@typescript-eslint/type-utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
eslint: 10.4.1(jiti@2.7.0)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -7452,17 +7459,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
|
||||
eslint: 10.4.1(jiti@2.7.0)
|
||||
eslint-import-resolver-node: 0.3.10
|
||||
eslint-import-resolver-typescript: 4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.4.1(jiti@2.7.0))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
@@ -7473,7 +7469,6 @@ snapshots:
|
||||
eslint-import-resolver-typescript: 4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.4.1(jiti@2.7.0))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
eslint-plugin-array-func@5.1.1(eslint@10.4.1(jiti@2.7.0)):
|
||||
dependencies:
|
||||
@@ -7508,7 +7503,7 @@ snapshots:
|
||||
'@eslint/eslintrc': 3.3.5
|
||||
'@eslint/js': 9.39.4
|
||||
'@github/browserslist-config': 1.0.0
|
||||
'@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
|
||||
aria-query: 5.3.2
|
||||
eslint: 10.4.1(jiti@2.7.0)
|
||||
@@ -7517,7 +7512,7 @@ snapshots:
|
||||
eslint-plugin-eslint-comments: 3.2.0(eslint@10.4.1(jiti@2.7.0))
|
||||
eslint-plugin-filenames: 1.3.2(eslint@10.4.1(jiti@2.7.0))
|
||||
eslint-plugin-i18n-text: 1.0.1(eslint@10.4.1(jiti@2.7.0))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@10.4.1(jiti@2.7.0))
|
||||
eslint-plugin-no-only-tests: 3.4.0
|
||||
eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.4.1(jiti@2.7.0)))(eslint@10.4.1(jiti@2.7.0))(prettier@3.8.3)
|
||||
@@ -7557,35 +7552,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
array.prototype.findlastindex: 1.2.6
|
||||
array.prototype.flat: 1.3.3
|
||||
array.prototype.flatmap: 1.3.3
|
||||
debug: 3.2.7
|
||||
doctrine: 2.1.0
|
||||
eslint: 10.4.1(jiti@2.7.0)
|
||||
eslint-import-resolver-node: 0.3.10
|
||||
eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0))
|
||||
hasown: 2.0.4
|
||||
is-core-module: 2.16.2
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.5
|
||||
object.fromentries: 2.0.8
|
||||
object.groupby: 1.0.3
|
||||
object.values: 1.2.1
|
||||
semver: 6.3.1
|
||||
string.prototype.trimend: 1.0.9
|
||||
tsconfig-paths: 3.15.0
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
@@ -7614,7 +7580,6 @@ snapshots:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
eslint-plugin-jsx-a11y@6.10.2(eslint@10.4.1(jiti@2.7.0)):
|
||||
dependencies:
|
||||
@@ -7708,7 +7673,7 @@ snapshots:
|
||||
postcss-selector-parser: 7.1.1
|
||||
vue-eslint-parser: 10.4.0(eslint@10.4.1(jiti@2.7.0))
|
||||
|
||||
eslint-plugin-vue@10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.4.1(jiti@2.7.0)))(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.1(jiti@2.7.0))):
|
||||
eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@10.4.1(jiti@2.7.0)))(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.1(jiti@2.7.0))):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0))
|
||||
eslint: 10.4.1(jiti@2.7.0)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1164,11 +1164,8 @@ func ActionsEnableWorkflow(ctx *context.APIContext) {
|
||||
func getCurrentRepoActionRunByID(ctx *context.APIContext) *actions_model.ActionRun {
|
||||
runID := ctx.PathParamInt64("run")
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return nil
|
||||
} else if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
run.Repo = ctx.Repo.Repository
|
||||
@@ -1198,11 +1195,8 @@ func getCurrentRepoActionRunAttemptByNumber(ctx *context.APIContext) (*actions_m
|
||||
|
||||
attemptNum := ctx.PathParamInt64("attempt")
|
||||
attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, nil
|
||||
}
|
||||
return run, attempt
|
||||
@@ -1454,7 +1448,7 @@ func RerunWorkflowJob(ctx *context.APIContext) {
|
||||
jobID := ctx.PathParamInt64("job_id")
|
||||
jobIdx := slices.IndexFunc(jobs, func(job *actions_model.ActionRunJob) bool { return job.ID == jobID })
|
||||
if jobIdx == -1 {
|
||||
ctx.APIErrorNotFound(util.NewNotExistErrorf("workflow job with id %d", jobID))
|
||||
ctx.APIErrorNotFound("workflow job not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1566,11 +1560,7 @@ func ListWorkflowRunJobs(ctx *context.APIContext) {
|
||||
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
// runID is used as an additional filter next to repoID to ensure that we only list jobs for the specified repoID and runID.
|
||||
@@ -1674,7 +1664,7 @@ func GetWorkflowJob(ctx *context.APIContext) {
|
||||
}
|
||||
|
||||
if !has || job.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound(util.ErrNotExist)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/common"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
@@ -45,11 +42,7 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) {
|
||||
jobID := ctx.PathParamInt64("job_id")
|
||||
curJob, err := actions_model.GetRunJobByRepoAndID(ctx, ctx.Repo.Repository.ID, jobID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
if err = curJob.LoadRepo(ctx); err != nil {
|
||||
@@ -59,10 +52,6 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) {
|
||||
|
||||
err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func GetBranch(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if !exist {
|
||||
ctx.APIErrorNotFound(err)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func DeleteBranch(ctx *context.APIContext) {
|
||||
if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName); err != nil {
|
||||
switch {
|
||||
case git.IsErrBranchNotExist(err):
|
||||
ctx.APIErrorNotFound(err)
|
||||
ctx.APIErrorNotFound()
|
||||
case errors.Is(err, repo_service.ErrBranchIsDefault):
|
||||
ctx.APIError(http.StatusForbidden, "can not delete default or pull request target branch")
|
||||
case errors.Is(err, git_model.ErrBranchIsProtected):
|
||||
@@ -446,7 +446,7 @@ func UpdateBranch(ctx *context.APIContext) {
|
||||
if err := repo_service.UpdateBranch(ctx, repo, ctx.Repo.GitRepo, ctx.Doer, branchName, opt.NewCommitID, opt.OldCommitID, opt.Force); err != nil {
|
||||
switch {
|
||||
case git_model.IsErrBranchNotExist(err):
|
||||
ctx.APIErrorNotFound(err)
|
||||
ctx.APIErrorNotFound()
|
||||
case errors.Is(err, util.ErrInvalidArgument):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
case git.IsErrPushRejected(err):
|
||||
|
||||
@@ -258,7 +258,7 @@ func GetAllCommits(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if commitsCountTotal == 0 {
|
||||
ctx.APIErrorNotFound("FileCommitsCount", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEn
|
||||
}
|
||||
|
||||
if entry.IsDir() || entry.IsSubModule() {
|
||||
ctx.APIErrorNotFound("getBlobForEntry", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
@@ -301,18 +301,14 @@ func GetEditorconfig(ctx *context.APIContext) {
|
||||
|
||||
ec, _, err := ctx.Repo.GetEditorconfig(ctx.Repo.Commit)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
fileName := ctx.PathParam("filename")
|
||||
def, err := ec.GetDefinitionForFilename(fileName)
|
||||
if def == nil {
|
||||
ctx.APIErrorNotFound(err)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound(err.Error())
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, def)
|
||||
@@ -699,10 +695,8 @@ func DeleteFile(ctx *context.APIContext) {
|
||||
func resolveRefCommit(ctx *context.APIContext, ref string, minCommitIDLen ...int) *utils.RefCommit {
|
||||
ref = util.IfZero(ref, ctx.Repo.Repository.DefaultBranch)
|
||||
refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ref, minCommitIDLen...)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
}
|
||||
return refCommit
|
||||
}
|
||||
@@ -828,11 +822,8 @@ func getRepoContents(ctx *context.APIContext, opts files_service.GetContentsOrLi
|
||||
}
|
||||
ret, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, refCommit, opts)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetContentsOrList", err)
|
||||
return nil
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
return &ret
|
||||
}
|
||||
|
||||
@@ -540,16 +540,10 @@ func getUserIDForFilter(ctx *context.APIContext, queryName string) int64 {
|
||||
}
|
||||
|
||||
user, err := user_model.GetUserByName(ctx, userName)
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return 0
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return 0
|
||||
}
|
||||
|
||||
return user.ID
|
||||
}
|
||||
|
||||
@@ -969,11 +963,7 @@ func DeleteIssue(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -447,11 +447,7 @@ func GetIssueComment(ctx *context.APIContext) {
|
||||
|
||||
comment, err := issues_model.GetCommentWithRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -572,11 +568,7 @@ func EditIssueCommentDeprecated(ctx *context.APIContext) {
|
||||
func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) {
|
||||
comment, err := issues_model.GetCommentWithRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -681,11 +673,7 @@ func DeleteIssueCommentDeprecated(ctx *context.APIContext) {
|
||||
func deleteIssueComment(ctx *context.APIContext) {
|
||||
comment, err := issues_model.GetCommentWithRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,7 @@ func GetIssueDependencies(ctx *context.APIContext) {
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound("IsErrIssueNotExist", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -487,11 +483,7 @@ func RemoveIssueBlocking(ctx *context.APIContext) {
|
||||
func getParamsIssue(ctx *context.APIContext) *issues_model.Issue {
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound("IsErrIssueNotExist", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
issue.Repo = ctx.Repo.Repository
|
||||
@@ -508,11 +500,7 @@ func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Is
|
||||
var err error
|
||||
repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, form.Owner, form.Name)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.APIErrorNotFound("IsErrRepoNotExist", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
@@ -521,11 +509,7 @@ func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Is
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, repo.ID, form.Index)
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound("IsErrIssueNotExist", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
issue.Repo = repo
|
||||
|
||||
@@ -53,11 +53,7 @@ func LockIssue(ctx *context.APIContext) {
|
||||
reason := web.GetForm(ctx).(*api.LockIssueOption).Reason
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,11 +116,7 @@ func UnlockIssue(ctx *context.APIContext) {
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -53,11 +53,7 @@ func GetIssueCommentReactions(ctx *context.APIContext) {
|
||||
|
||||
comment, err := issues_model.GetCommentByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -190,11 +186,7 @@ func DeleteIssueCommentReaction(ctx *context.APIContext) {
|
||||
func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOption, isCreateType bool) {
|
||||
comment, err := issues_model.GetCommentByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -71,16 +71,12 @@ func ListTrackedTimes(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) {
|
||||
ctx.APIErrorNotFound("Timetracker is disabled")
|
||||
ctx.APIErrorNotFound("timetracker is disabled")
|
||||
return
|
||||
}
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -182,11 +178,7 @@ func AddTime(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.AddTimeOption)
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -265,11 +257,7 @@ func ResetIssueTime(ctx *context.APIContext) {
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -338,11 +326,7 @@ func DeleteTime(ctx *context.APIContext) {
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -357,11 +341,7 @@ func DeleteTime(ctx *context.APIContext) {
|
||||
|
||||
time, err := issues_model.GetTrackedTimeByID(ctx, issue.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
if time.Deleted {
|
||||
@@ -423,11 +403,7 @@ func ListTrackedTimesByUser(ctx *context.APIContext) {
|
||||
}
|
||||
user, err := user_model.GetUserByName(ctx, ctx.PathParam("timetrackingusername"))
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
|
||||
@@ -68,11 +68,7 @@ func getNote(ctx *context.APIContext, identifier string) {
|
||||
|
||||
commitID, err := ctx.Repo.GitRepo.ConvertToGitID(identifier)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -927,11 +927,7 @@ func MergePullRequest(ctx *context.APIContext) {
|
||||
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,7 @@ func ListPullReviews(ctx *context.APIContext) {
|
||||
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -389,11 +385,7 @@ func updatePullReviewCommentResolve(ctx *context.APIContext, isResolve bool) {
|
||||
func getPullReviewCommentToResolve(ctx *context.APIContext) *issues_model.Comment {
|
||||
comment, err := issues_model.GetCommentWithRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetCommentByID", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -510,11 +502,7 @@ func CreatePullReview(ctx *context.APIContext) {
|
||||
opts := web.GetForm(ctx).(*api.CreatePullReviewOptions)
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -737,33 +725,25 @@ func preparePullReviewType(ctx *context.APIContext, pr *issues_model.PullRequest
|
||||
func prepareSingleReview(ctx *context.APIContext) (*issues_model.Review, *issues_model.PullRequest, bool) {
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, nil, true
|
||||
}
|
||||
|
||||
review, err := issues_model.GetReviewByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrReviewNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetReviewByID", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, nil, true
|
||||
}
|
||||
|
||||
// validate the review is for the given PR
|
||||
if review.IssueID != pr.IssueID {
|
||||
ctx.APIErrorNotFound("ReviewNotInPR")
|
||||
ctx.APIErrorNotFound()
|
||||
return nil, nil, true
|
||||
}
|
||||
|
||||
// make sure that the user has access to this review if it is pending
|
||||
if review.Type == issues_model.ReviewTypePending && review.ReviewerID != ctx.Doer.ID && !ctx.Doer.IsAdmin {
|
||||
ctx.APIErrorNotFound("GetReviewByID")
|
||||
ctx.APIErrorNotFound()
|
||||
return nil, nil, true
|
||||
}
|
||||
|
||||
@@ -870,7 +850,7 @@ func parseReviewersByNames(ctx *context.APIContext, reviewerNames, teamReviewerN
|
||||
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIErrorNotFound("UserNotExist", fmt.Sprintf("User '%s' not exist", r))
|
||||
ctx.APIErrorNotFound("user doesn't exist: " + r)
|
||||
return nil, nil
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -886,7 +866,7 @@ func parseReviewersByNames(ctx *context.APIContext, reviewerNames, teamReviewerN
|
||||
teamReviewer, err = organization.GetTeam(ctx, ctx.Repo.Owner.ID, t)
|
||||
if err != nil {
|
||||
if organization.IsErrTeamNotExist(err) {
|
||||
ctx.APIErrorNotFound("TeamNotExist", fmt.Sprintf("Team '%s' not exist", t))
|
||||
ctx.APIErrorNotFound("team doesn't exist: " + t)
|
||||
return nil, nil
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -902,11 +882,7 @@ func parseReviewersByNames(ctx *context.APIContext, reviewerNames, teamReviewerN
|
||||
func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions, isAdd bool) {
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
|
||||
|
||||
// Check if attachments are enabled
|
||||
if !setting.Attachment.Enabled {
|
||||
ctx.APIErrorNotFound("Attachment is not enabled")
|
||||
ctx.APIErrorNotFound("attachment is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -245,11 +245,7 @@ func DeleteWikiPage(ctx *context.APIContext) {
|
||||
wikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("pageName"))
|
||||
|
||||
if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName); err != nil {
|
||||
if err.Error() == "file does not exist" {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -474,21 +470,13 @@ func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error)
|
||||
func findWikiRepoCommit(ctx *context.APIContext) (*git.Repository, *git.Commit) {
|
||||
wikiRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo())
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) || err.Error() == "no such file or directory" {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
commit, err := wikiRepo.GetBranchCommit(ctx.Repo.Repository.DefaultWikiBranch)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return wikiRepo, nil
|
||||
}
|
||||
return wikiRepo, commit
|
||||
|
||||
@@ -45,7 +45,7 @@ func ListBlocks(ctx *context.APIContext, blocker *user_model.User) {
|
||||
func CheckUserBlock(ctx *context.APIContext, blocker *user_model.User) {
|
||||
blockee, err := user_model.GetUserByName(ctx, ctx.PathParam("username"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func CheckUserBlock(ctx *context.APIContext, blocker *user_model.User) {
|
||||
func BlockUser(ctx *context.APIContext, blocker *user_model.User) {
|
||||
blockee, err := user_model.GetUserByName(ctx, ctx.PathParam("username"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func BlockUser(ctx *context.APIContext, blocker *user_model.User) {
|
||||
func UnblockUser(ctx *context.APIContext, doer, blocker *user_model.User) {
|
||||
blockee, err := user_model.GetUserByName(ctx, ctx.PathParam("username"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -77,11 +77,7 @@ func getRunnerByID(ctx *context.APIContext, ownerID, repoID, runnerID int64) (*a
|
||||
|
||||
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound("Runner not found")
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -135,7 +134,7 @@ func GetGPGKey(ctx *context.APIContext) {
|
||||
// CreateUserGPGKey creates new GPG key to given user by ID.
|
||||
func CreateUserGPGKey(ctx *context.APIContext, form api.CreateGPGKeyOption, uid int64) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
|
||||
ctx.APIErrorNotFound("Not Found", errors.New("gpg keys setting is not allowed to be visited"))
|
||||
ctx.APIErrorNotFound("gpg keys setting is not allowed to be changed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -276,7 +275,7 @@ func DeleteGPGKey(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
|
||||
ctx.APIErrorNotFound("Not Found", errors.New("gpg keys setting is not allowed to be visited"))
|
||||
ctx.APIErrorNotFound("gpg keys setting is not allowed to be changed")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func GetUserByPathParam(ctx *context.APIContext, name string) *user_model.User {
|
||||
if redirectUserID, err2 := user_model.LookupUserRedirect(ctx, username); err2 == nil {
|
||||
context.RedirectToUser(ctx.Base, ctx.Doer, username, redirectUserID)
|
||||
} else {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
|
||||
@@ -6,7 +6,6 @@ package user
|
||||
|
||||
import (
|
||||
std_ctx "context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
@@ -201,7 +200,7 @@ func GetPublicKey(ctx *context.APIContext) {
|
||||
// CreateUserPublicKey creates new public key to given user by ID.
|
||||
func CreateUserPublicKey(ctx *context.APIContext, form api.CreateKeyOption, uid int64) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
|
||||
ctx.APIErrorNotFound("Not Found", errors.New("ssh keys setting is not allowed to be visited"))
|
||||
ctx.APIErrorNotFound("ssh keys setting is not allowed to be changed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -271,7 +270,7 @@ func DeletePublicKey(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
|
||||
ctx.APIErrorNotFound("Not Found", errors.New("ssh keys setting is not allowed to be visited"))
|
||||
ctx.APIErrorNotFound("ssh keys setting is not allowed to be changed")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ func GetInfo(ctx *context.APIContext) {
|
||||
|
||||
if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
|
||||
// fake ErrUserNotExist error message to not leak information about existence
|
||||
ctx.APIErrorNotFound("GetUserByName", user_model.ErrUserNotExist{Name: ctx.PathParam("username")})
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, convert.ToUser(ctx, ctx.ContextUser, ctx.Doer))
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/gituser"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/badge"
|
||||
"gitea.dev/modules/charset"
|
||||
@@ -61,8 +62,8 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
|
||||
mockUser := mockUsers[0]
|
||||
commits = append(commits, &asymkey.SignCommit{
|
||||
Verification: &asymkey.CommitVerification{},
|
||||
UserCommit: &user_model.UserCommit{
|
||||
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
UserCommit: &gituser.UserCommit{
|
||||
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
},
|
||||
})
|
||||
commits = append(commits, &asymkey.SignCommit{
|
||||
@@ -73,9 +74,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
|
||||
SigningKey: &asymkey.GPGKey{KeyID: "12345678"},
|
||||
TrustStatus: "trusted",
|
||||
},
|
||||
UserCommit: &user_model.UserCommit{
|
||||
User: mockUser,
|
||||
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
UserCommit: &gituser.UserCommit{
|
||||
AuthorUser: mockUser,
|
||||
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
},
|
||||
})
|
||||
commits = append(commits, &asymkey.SignCommit{
|
||||
@@ -86,9 +87,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
|
||||
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
|
||||
TrustStatus: "untrusted",
|
||||
},
|
||||
UserCommit: &user_model.UserCommit{
|
||||
User: mockUser,
|
||||
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
UserCommit: &gituser.UserCommit{
|
||||
AuthorUser: mockUser,
|
||||
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
},
|
||||
})
|
||||
commits = append(commits, &asymkey.SignCommit{
|
||||
@@ -99,9 +100,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
|
||||
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
|
||||
TrustStatus: "other(unmatch)",
|
||||
},
|
||||
UserCommit: &user_model.UserCommit{
|
||||
User: mockUser,
|
||||
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
UserCommit: &gituser.UserCommit{
|
||||
AuthorUser: mockUser,
|
||||
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
},
|
||||
})
|
||||
commits = append(commits, &asymkey.SignCommit{
|
||||
@@ -110,9 +111,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
|
||||
Reason: "gpg.error",
|
||||
SigningEmail: "test@example.com",
|
||||
},
|
||||
UserCommit: &user_model.UserCommit{
|
||||
User: mockUser,
|
||||
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
UserCommit: &gituser.UserCommit{
|
||||
AuthorUser: mockUser,
|
||||
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -159,6 +160,59 @@ func prepareMockDataBadgeActionsSvg(ctx *context.Context) {
|
||||
ctx.Data["SelectedStyle"] = selectedStyle
|
||||
}
|
||||
|
||||
func prepareMockDataAvatarStack(ctx *context.Context) {
|
||||
/*
|
||||
mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 3}})
|
||||
if len(mockUsers) == 0 {
|
||||
return
|
||||
}
|
||||
u0 := mockUsers[0]
|
||||
u1, u2 := u0, u0
|
||||
if len(mockUsers) >= 2 {
|
||||
u1 = mockUsers[1]
|
||||
}
|
||||
if len(mockUsers) >= 3 {
|
||||
u2 = mockUsers[2]
|
||||
}
|
||||
|
||||
authorSig := func(u *user_model.User) *git.Signature {
|
||||
return &git.Signature{Name: u.Name, Email: u.Email}
|
||||
}
|
||||
coLinked := func(u *user_model.User) *gituser.CommitParticipant {
|
||||
return &gituser.CommitParticipant{GiteaUser: u, GitIdentity: authorSig(u)}
|
||||
}
|
||||
coUnlinked := func(name, email string) *gituser.CommitParticipant {
|
||||
return &gituser.CommitParticipant{GitIdentity: &git.Signature{Name: name, Email: email}}
|
||||
}
|
||||
nUnlinked := func(n int) []*gituser.CommitParticipant {
|
||||
out := make([]*gituser.CommitParticipant, n)
|
||||
for i := range out {
|
||||
out[i] = coUnlinked(fmt.Sprintf("Contributor %d", i+1), fmt.Sprintf("contrib%d@example.com", i+1))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type scenario struct {
|
||||
Label string
|
||||
Data *gituser.AvatarStackData
|
||||
}
|
||||
mk := gituser.BuildAvatarStackData()
|
||||
extSig := &git.Signature{Name: "External Contributor", Email: "external@example.com"}
|
||||
ctx.Data["AvatarStackScenarios"] = []scenario{
|
||||
{Label: "linked author, no co-authors", Data: mk(u0, authorSig(u0), nil)},
|
||||
{Label: "unlinked author, no co-authors", Data: mk(nil, extSig, nil)},
|
||||
{Label: "1 linked co-author", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coLinked(u1)})},
|
||||
{Label: "1 unlinked co-author", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coUnlinked("Bob Smith", "bob@example.com")})},
|
||||
{Label: "2 co-authors (3 people), u1 author", Data: mk(u1, authorSig(u1), []*gituser.CommitParticipant{coLinked(u0), coUnlinked("Bob Smith", "bob@example.com")})},
|
||||
{Label: "3 co-authors mixed (4 people)", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coLinked(u1), coLinked(u2), coUnlinked("Bob Smith", "bob@example.com")})},
|
||||
{Label: "9 co-authors (max visible, no overflow), u2 author", Data: mk(u2, authorSig(u2), nUnlinked(9))},
|
||||
{Label: "10 co-authors (overflow +1)", Data: mk(u0, authorSig(u0), nUnlinked(10))},
|
||||
{Label: "15 co-authors (overflow +6), unlinked author", Data: mk(nil, extSig, nUnlinked(15))},
|
||||
{Label: "30 co-authors (overflow +21)", Data: mk(u0, authorSig(u0), nUnlinked(30))},
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
func prepareMockDataRelativeTime(ctx *context.Context) {
|
||||
now := time.Now()
|
||||
ctx.Data["TimeNow"] = now
|
||||
@@ -196,6 +250,8 @@ func prepareMockData(ctx *context.Context) {
|
||||
prepareMockDataToastAndMessage(ctx)
|
||||
case "/devtest/unicode-escape":
|
||||
prepareMockDataUnicodeEscape(ctx)
|
||||
case "/devtest/avatar-stack":
|
||||
prepareMockDataAvatarStack(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
@@ -87,29 +88,40 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
||||
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
|
||||
resp.State.Run.CanDeleteArtifact = true
|
||||
resp.State.Run.WorkflowID = "workflow-id"
|
||||
resp.State.Run.WorkflowLink = "./workflow-link"
|
||||
resp.State.Run.WorkflowID = "workflow-id.yml"
|
||||
resp.State.Run.TriggerEvent = "push"
|
||||
renderUtils := templates.NewRenderUtils(ctx)
|
||||
user2, _ := user_model.GetUserByID(ctx, 2)
|
||||
if user2 == nil {
|
||||
user2 = &user_model.User{Name: "user2"}
|
||||
}
|
||||
user3, _ := user_model.GetUserByID(ctx, 3)
|
||||
if user3 == nil {
|
||||
user3 = &user_model.User{Name: "user3"}
|
||||
}
|
||||
resp.State.Run.Commit = actions.ViewCommit{
|
||||
ShortSha: "ccccdddd",
|
||||
Link: "./commit-link",
|
||||
Pusher: actions.ViewUser{
|
||||
DisplayName: "pusher user",
|
||||
Link: "./pusher-link",
|
||||
DisplayName: user2.GetDisplayName(),
|
||||
Link: user2.HomeLink(),
|
||||
AvatarLink: user2.AvatarLinkWithSize(ctx, 16),
|
||||
},
|
||||
Branch: actions.ViewBranch{
|
||||
Name: "commit-branch",
|
||||
Name: "user2:commit-branch",
|
||||
Link: "./branch-link",
|
||||
IsDeleted: false,
|
||||
},
|
||||
}
|
||||
resp.State.Run.PullRequest = &actions.ViewPullRequest{
|
||||
Index: "#37658",
|
||||
Link: "./pull/37658",
|
||||
}
|
||||
now := time.Now()
|
||||
currentAttemptNum := int64(1)
|
||||
if attemptID > 0 {
|
||||
currentAttemptNum = attemptID
|
||||
}
|
||||
user2 := &user_model.User{Name: "user2"}
|
||||
user3 := &user_model.User{Name: "user3"}
|
||||
attempts := []*actions_model.ActionRunAttempt{{
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
@@ -168,15 +180,16 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
resp.State.Run.Attempts = append(resp.State.Run.Attempts, &actions.ViewRunAttempt{
|
||||
Attempt: attempt.Attempt,
|
||||
Status: attempt.Status.String(),
|
||||
Done: attempt.Status.IsDone(),
|
||||
Link: link,
|
||||
Current: current,
|
||||
Latest: attempt.Attempt == latestAttempt.Attempt,
|
||||
TriggeredAt: attempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: attempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: attempt.TriggerUser.HomeLink(),
|
||||
Attempt: attempt.Attempt,
|
||||
Status: attempt.Status.String(),
|
||||
Done: attempt.Status.IsDone(),
|
||||
Link: link,
|
||||
Current: current,
|
||||
Latest: attempt.Attempt == latestAttempt.Attempt,
|
||||
TriggeredAt: attempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: attempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: attempt.TriggerUser.HomeLink(),
|
||||
TriggerUserAvatar: attempt.TriggerUser.AvatarLinkWithSize(ctx, 16),
|
||||
})
|
||||
}
|
||||
isLatestAttempt := currentAttemptNum == latestAttempt.Attempt
|
||||
@@ -185,6 +198,20 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
resp.State.Run.CanRerun = runID == 30 && isLatestAttempt
|
||||
resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt
|
||||
|
||||
// Mock job summaries so the devtest page can preview the Summary panel rendering.
|
||||
resp.State.Run.JobSummaries = []*actions.ViewJobSummary{
|
||||
{
|
||||
JobID: runID * 10,
|
||||
JobName: "job 100 (testsubname)",
|
||||
SummaryHTML: renderUtils.MarkdownToHtml("### Devtest job summary\n\n- Markdown rendering\n- Links: [example](https://example.com)\n\n```sh\necho hello\n```\n"),
|
||||
},
|
||||
{
|
||||
JobID: runID*10 + 2,
|
||||
JobName: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
|
||||
SummaryHTML: renderUtils.MarkdownToHtml("### Another summary\n\nThis demonstrates multiple job summaries in one run.\n\n- Item A\n- Item B\n"),
|
||||
},
|
||||
}
|
||||
|
||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||
Name: "artifact-a",
|
||||
Size: 100 * 1024,
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
actions_service "gitea.dev/services/actions"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/convert"
|
||||
|
||||
@@ -208,12 +209,20 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
|
||||
if !hasJobWithoutNeeds && len(j.Needs()) == 0 {
|
||||
hasJobWithoutNeeds = true
|
||||
}
|
||||
if j.Uses != "" {
|
||||
if _, err := actions_service.ResolveUses(ctx, j.Uses); err != nil {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasJobWithoutNeeds {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs")
|
||||
}
|
||||
if emptyJobsNumber == len(wf.Jobs) {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
|
||||
if workflow.ErrMsg == "" {
|
||||
if !hasJobWithoutNeeds {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs")
|
||||
}
|
||||
if emptyJobsNumber == len(wf.Jobs) {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
|
||||
}
|
||||
}
|
||||
workflows = append(workflows, workflow)
|
||||
}
|
||||
@@ -352,7 +361,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
|
||||
return
|
||||
}
|
||||
for _, run := range runs {
|
||||
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning) {
|
||||
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) {
|
||||
continue
|
||||
}
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
|
||||
@@ -361,23 +370,31 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
|
||||
return
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsWaiting() {
|
||||
if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) {
|
||||
continue
|
||||
}
|
||||
if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil {
|
||||
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
|
||||
break
|
||||
}
|
||||
hasOnlineRunner := false
|
||||
for _, runner := range runners {
|
||||
if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
|
||||
hasOnlineRunner = true
|
||||
if job.CallUses != "" {
|
||||
if _, err := actions_service.ResolveUses(ctx, job.CallUses); err != nil {
|
||||
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOnlineRunner {
|
||||
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
|
||||
break
|
||||
if job.Status.IsWaiting() {
|
||||
hasOnlineRunner := false
|
||||
for _, runner := range runners {
|
||||
if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
|
||||
hasOnlineRunner = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOnlineRunner {
|
||||
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,18 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/storage"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -306,10 +310,13 @@ type ViewResponse struct {
|
||||
Attempts []*ViewRunAttempt `json:"attempts"`
|
||||
Jobs []*ViewJob `json:"jobs"`
|
||||
Commit ViewCommit `json:"commit"`
|
||||
PullRequest *ViewPullRequest `json:"pullRequest,omitempty"`
|
||||
// Summary view: run duration and trigger time/event
|
||||
Duration string `json:"duration"`
|
||||
TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
|
||||
TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule
|
||||
|
||||
JobSummaries []*ViewJobSummary `json:"jobSummaries,omitempty"`
|
||||
} `json:"run"`
|
||||
CurrentJob struct {
|
||||
Title string `json:"title"`
|
||||
@@ -339,16 +346,28 @@ type ViewJob struct {
|
||||
CallUses string `json:"callUses,omitempty"`
|
||||
}
|
||||
|
||||
type ViewJobSummary struct {
|
||||
JobID int64 `json:"jobId"`
|
||||
JobName string `json:"jobName"`
|
||||
SummaryHTML template.HTML `json:"summaryHTML"`
|
||||
}
|
||||
|
||||
type ViewRunAttempt struct {
|
||||
Attempt int64 `json:"attempt"`
|
||||
Status string `json:"status"`
|
||||
Done bool `json:"done"`
|
||||
Link string `json:"link"`
|
||||
Current bool `json:"current"`
|
||||
Latest bool `json:"latest"`
|
||||
TriggeredAt int64 `json:"triggeredAt"`
|
||||
TriggerUserName string `json:"triggerUserName"`
|
||||
TriggerUserLink string `json:"triggerUserLink"`
|
||||
Attempt int64 `json:"attempt"`
|
||||
Status string `json:"status"`
|
||||
Done bool `json:"done"`
|
||||
Link string `json:"link"`
|
||||
Current bool `json:"current"`
|
||||
Latest bool `json:"latest"`
|
||||
TriggeredAt int64 `json:"triggeredAt"`
|
||||
TriggerUserName string `json:"triggerUserName"`
|
||||
TriggerUserLink string `json:"triggerUserLink"`
|
||||
TriggerUserAvatar string `json:"triggerUserAvatar"`
|
||||
}
|
||||
|
||||
type ViewPullRequest struct {
|
||||
Index string `json:"index"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
type ViewCommit struct {
|
||||
@@ -361,6 +380,7 @@ type ViewCommit struct {
|
||||
type ViewUser struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Link string `json:"link"`
|
||||
AvatarLink string `json:"avatarLink,omitempty"`
|
||||
}
|
||||
|
||||
type ViewBranch struct {
|
||||
@@ -388,6 +408,132 @@ type ViewStepLogLine struct {
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
func viewPullRequestFromRun(ctx context.Context, run *actions_model.ActionRun, prPayload *api.PullRequestPayload) *ViewPullRequest {
|
||||
if run.Repo == nil {
|
||||
return nil
|
||||
}
|
||||
refName := git.RefName(run.Ref)
|
||||
if refName.IsPull() {
|
||||
return &ViewPullRequest{
|
||||
Index: "#" + refName.ShortName(),
|
||||
Link: run.RefLink(),
|
||||
}
|
||||
}
|
||||
if prPayload != nil && prPayload.Index > 0 {
|
||||
return &ViewPullRequest{
|
||||
Index: fmt.Sprintf("#%d", prPayload.Index),
|
||||
Link: fmt.Sprintf("%s/pulls/%d", run.Repo.Link(), prPayload.Index),
|
||||
}
|
||||
}
|
||||
// Push-triggered run: surface an open PR whose head matches this branch so
|
||||
// users coming from a PR's check details can navigate back to it.
|
||||
if refName.IsBranch() {
|
||||
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, run.RepoID, refName.ShortName())
|
||||
if err != nil {
|
||||
log.Error("GetUnmergedPullRequestsByHeadInfo: %v", err)
|
||||
} else if len(prs) == 1 {
|
||||
pr := prs[0]
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
log.Error("LoadBaseRepo: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &ViewPullRequest{
|
||||
Index: fmt.Sprintf("#%d", pr.Index),
|
||||
Link: fmt.Sprintf("%s/pulls/%d", pr.BaseRepo.Link(), pr.Index),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewSummaryBranchFromRun(ctx context.Context, run *actions_model.ActionRun, prPayload *api.PullRequestPayload) ViewBranch {
|
||||
refName := git.RefName(run.Ref)
|
||||
if prPayload != nil && prPayload.PullRequest != nil && prPayload.PullRequest.Head != nil {
|
||||
head := prPayload.PullRequest.Head
|
||||
name := head.Name
|
||||
if name == "" {
|
||||
name = git.RefName(head.Ref).ShortName()
|
||||
}
|
||||
if head.Repository != nil && run.Repo != nil && head.RepoID > 0 && head.RepoID != run.Repo.ID {
|
||||
ownerName := ""
|
||||
if head.Repository.Owner != nil {
|
||||
ownerName = head.Repository.Owner.UserName
|
||||
} else if head.Repository.FullName != "" {
|
||||
ownerName, _, _ = strings.Cut(head.Repository.FullName, "/")
|
||||
}
|
||||
if ownerName != "" && !strings.Contains(name, ":") {
|
||||
name = ownerName + ":" + name
|
||||
}
|
||||
}
|
||||
link := ""
|
||||
if head.Repository != nil && head.Ref != "" {
|
||||
repoLink := head.Repository.Link
|
||||
if repoLink == "" {
|
||||
repoLink = head.Repository.HTMLURL
|
||||
}
|
||||
if repoLink != "" {
|
||||
link = repoLink + "/src/" + git.RefName(head.Ref).RefWebLinkPath()
|
||||
}
|
||||
}
|
||||
return ViewBranch{Name: name, Link: link}
|
||||
}
|
||||
|
||||
branch := ViewBranch{
|
||||
Name: run.PrettyRef(),
|
||||
Link: run.RefLink(),
|
||||
}
|
||||
if refName.IsBranch() {
|
||||
b, err := git_model.GetBranch(ctx, run.RepoID, refName.ShortName())
|
||||
if err != nil && !git_model.IsErrBranchNotExist(err) {
|
||||
log.Error("GetBranch: %v", err)
|
||||
} else if git_model.IsErrBranchNotExist(err) || (b != nil && b.IsDeleted) {
|
||||
branch.IsDeleted = true
|
||||
}
|
||||
}
|
||||
return branch
|
||||
}
|
||||
|
||||
// actionsSummaryRefCacheTTL bounds how long the resolved PR/branch summary is
|
||||
// cached. ViewPost is polled every second, but this metadata is stable for a
|
||||
// run, so a short TTL collapses the repeated DB lookups while staying fresh
|
||||
// enough for the navigation links.
|
||||
const actionsSummaryRefCacheTTL = 10 // seconds
|
||||
|
||||
type viewSummaryRefInfo struct {
|
||||
PullRequest *ViewPullRequest `json:"pullRequest"`
|
||||
Branch ViewBranch `json:"branch"`
|
||||
}
|
||||
|
||||
// getViewSummaryRefInfo resolves the run's pull request and head branch summary,
|
||||
// caching the result briefly so the per-second poll does not hit the database on
|
||||
// every request (GetUnmergedPullRequestsByHeadInfo / GetBranch).
|
||||
func getViewSummaryRefInfo(ctx context.Context, run *actions_model.ActionRun) viewSummaryRefInfo {
|
||||
compute := func() viewSummaryRefInfo {
|
||||
// parse the event payload once and share it between both resolvers
|
||||
prPayload, _ := run.GetPullRequestEventPayload() // nil unless this is a pull request event
|
||||
return viewSummaryRefInfo{
|
||||
PullRequest: viewPullRequestFromRun(ctx, run, prPayload),
|
||||
Branch: viewSummaryBranchFromRun(ctx, run, prPayload),
|
||||
}
|
||||
}
|
||||
c := cache.GetCache()
|
||||
if c == nil {
|
||||
return compute()
|
||||
}
|
||||
cacheKey := fmt.Sprintf("actions_run_summary_ref:%d", run.ID)
|
||||
if cached, ok := c.Get(cacheKey); ok && cached != "" {
|
||||
var info viewSummaryRefInfo
|
||||
if err := json.Unmarshal([]byte(cached), &info); err == nil {
|
||||
return info
|
||||
}
|
||||
}
|
||||
info := compute()
|
||||
if data, err := json.Marshal(info); err == nil {
|
||||
_ = c.Put(cacheKey, string(data), actionsSummaryRefCacheTTL)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func ViewPost(ctx *context_module.Context) {
|
||||
run, attempt, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
@@ -482,50 +628,71 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
}
|
||||
for _, runAttempt := range attempts {
|
||||
resp.State.Run.Attempts = append(resp.State.Run.Attempts, &ViewRunAttempt{
|
||||
Attempt: runAttempt.Attempt,
|
||||
Status: runAttempt.Status.String(),
|
||||
Done: runAttempt.Status.IsDone(),
|
||||
Link: getRunViewLink(run, runAttempt),
|
||||
Current: runAttempt.ID == attempt.ID,
|
||||
Latest: runAttempt.ID == run.LatestAttemptID,
|
||||
TriggeredAt: runAttempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: runAttempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: runAttempt.TriggerUser.HomeLink(),
|
||||
Attempt: runAttempt.Attempt,
|
||||
Status: runAttempt.Status.String(),
|
||||
Done: runAttempt.Status.IsDone(),
|
||||
Link: getRunViewLink(run, runAttempt),
|
||||
Current: runAttempt.ID == attempt.ID,
|
||||
Latest: runAttempt.ID == run.LatestAttemptID,
|
||||
TriggeredAt: runAttempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: runAttempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: runAttempt.TriggerUser.HomeLink(),
|
||||
TriggerUserAvatar: runAttempt.TriggerUser.AvatarLinkWithSize(ctx, 16),
|
||||
})
|
||||
}
|
||||
|
||||
pusher := ViewUser{
|
||||
DisplayName: run.TriggerUser.GetDisplayName(),
|
||||
Link: run.TriggerUser.HomeLink(),
|
||||
}
|
||||
branch := ViewBranch{
|
||||
Name: run.PrettyRef(),
|
||||
Link: run.RefLink(),
|
||||
}
|
||||
refName := git.RefName(run.Ref)
|
||||
if refName.IsBranch() {
|
||||
b, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, refName.ShortName())
|
||||
if err != nil && !git_model.IsErrBranchNotExist(err) {
|
||||
log.Error("GetBranch: %v", err)
|
||||
} else if git_model.IsErrBranchNotExist(err) || (b != nil && b.IsDeleted) {
|
||||
branch.IsDeleted = true
|
||||
}
|
||||
AvatarLink: run.TriggerUser.AvatarLinkWithSize(ctx, 16),
|
||||
}
|
||||
|
||||
refInfo := getViewSummaryRefInfo(ctx, run)
|
||||
resp.State.Run.Commit = ViewCommit{
|
||||
ShortSha: base.ShortSha(run.CommitSHA),
|
||||
Link: fmt.Sprintf("%s/commit/%s", run.Repo.Link(), run.CommitSHA),
|
||||
Pusher: pusher,
|
||||
Branch: branch,
|
||||
Branch: refInfo.Branch,
|
||||
}
|
||||
resp.State.Run.PullRequest = refInfo.PullRequest
|
||||
resp.State.Run.TriggerEvent = run.TriggerEvent
|
||||
|
||||
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts all share run_attempt_id=0,
|
||||
// so passing 0 here scopes to this run's legacy artifacts only.
|
||||
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all
|
||||
// share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only.
|
||||
var runAttemptID int64
|
||||
if attempt != nil {
|
||||
runAttemptID = attempt.ID
|
||||
}
|
||||
|
||||
// Each step's markdown is rendered independently so an unclosed construct
|
||||
// in one step can't bleed into the next.
|
||||
// On a single-job view only that job's summaries are needed; the run view shows all.
|
||||
// Scoping server-side avoids rendering every job's markdown on each 1s poll.
|
||||
summaries, err := actions_model.ListActionRunJobSummaries(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID, ctx.PathParamInt64("job"))
|
||||
if err != nil {
|
||||
ctx.ServerError("ListActionRunJobSummaries", err)
|
||||
return
|
||||
}
|
||||
if len(summaries) > 0 {
|
||||
jobNameByID := make(map[int64]string, len(jobs))
|
||||
for _, j := range jobs {
|
||||
jobNameByID[j.ID] = j.Name
|
||||
}
|
||||
renderUtils := templates.NewRenderUtils(ctx)
|
||||
var current *ViewJobSummary
|
||||
for _, s := range summaries {
|
||||
if s.ContentType != actions_model.JobSummaryContentTypeMarkdown {
|
||||
log.Warn("Skip unsupported job summary content type %q for run %d job %d step %d", s.ContentType, s.RunID, s.JobID, s.StepIndex)
|
||||
continue
|
||||
}
|
||||
if current == nil || current.JobID != s.JobID {
|
||||
current = &ViewJobSummary{JobID: s.JobID, JobName: jobNameByID[s.JobID]}
|
||||
resp.State.Run.JobSummaries = append(resp.State.Run.JobSummaries, current)
|
||||
}
|
||||
current.SummaryHTML += renderUtils.MarkdownToHtml(s.Content)
|
||||
}
|
||||
}
|
||||
|
||||
arts, err := actions_model.ListUploadedArtifactsMetaByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListUploadedArtifactsMetaByRunAttempt", err)
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/translation"
|
||||
|
||||
@@ -14,6 +16,66 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestViewPullRequestFromRun(t *testing.T) {
|
||||
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
|
||||
|
||||
t.Run("pull ref", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/123/head"}
|
||||
assert.Equal(t, &ViewPullRequest{Index: "#123", Link: "/owner/repo/pulls/123"}, viewPullRequestFromRun(t.Context(), run, nil))
|
||||
})
|
||||
|
||||
t.Run("pull request event payload", func(t *testing.T) {
|
||||
// a non-pull ref forces the payload branch instead of the ref branch
|
||||
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/heads/feature"}
|
||||
payload := &api.PullRequestPayload{Index: 42}
|
||||
assert.Equal(t, &ViewPullRequest{Index: "#42", Link: "/owner/repo/pulls/42"}, viewPullRequestFromRun(t.Context(), run, payload))
|
||||
})
|
||||
|
||||
t.Run("nil repo", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{Ref: "refs/pull/1/head"}
|
||||
assert.Nil(t, viewPullRequestFromRun(t.Context(), run, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestViewSummaryBranchFromRun(t *testing.T) {
|
||||
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
|
||||
|
||||
t.Run("pull request event same repo", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/7/head"}
|
||||
payload := &api.PullRequestPayload{
|
||||
PullRequest: &api.PullRequest{Head: &api.PRBranchInfo{
|
||||
Name: "feature",
|
||||
Ref: "refs/heads/feature",
|
||||
RepoID: 1,
|
||||
Repository: &api.Repository{Link: "/owner/repo"},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, ViewBranch{Name: "feature", Link: "/owner/repo/src/branch/feature"}, viewSummaryBranchFromRun(t.Context(), run, payload))
|
||||
})
|
||||
|
||||
t.Run("pull request event from fork prefixes owner", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/7/head"}
|
||||
payload := &api.PullRequestPayload{
|
||||
PullRequest: &api.PullRequest{Head: &api.PRBranchInfo{
|
||||
Name: "feature",
|
||||
Ref: "refs/heads/feature",
|
||||
RepoID: 2,
|
||||
Repository: &api.Repository{
|
||||
Link: "/forkowner/repo",
|
||||
Owner: &api.User{UserName: "forkowner"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, ViewBranch{Name: "forkowner:feature", Link: "/forkowner/repo/src/branch/feature"}, viewSummaryBranchFromRun(t.Context(), run, payload))
|
||||
})
|
||||
|
||||
t.Run("push to tag does not query branch", func(t *testing.T) {
|
||||
// a tag ref is not a branch, so no GetBranch DB lookup happens
|
||||
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/tags/v1.0.0"}
|
||||
assert.Equal(t, ViewBranch{Name: "v1.0.0", Link: "/owner/repo/src/tag/v1.0.0"}, viewSummaryBranchFromRun(t.Context(), run, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertToViewModel(t *testing.T) {
|
||||
task := &actions_model.ActionTask{
|
||||
Status: actions_model.StatusSuccess,
|
||||
|
||||
+15
-20
@@ -12,8 +12,8 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"gitea.dev/models/gituser"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/languagestats"
|
||||
@@ -29,13 +29,14 @@ import (
|
||||
type blameRow struct {
|
||||
RowNumber int
|
||||
|
||||
Avatar template.HTML
|
||||
PreviousSha string
|
||||
PreviousShaURL string
|
||||
CommitURL string
|
||||
CommitMessage string
|
||||
CommitSince template.HTML
|
||||
|
||||
AvatarStackData *gituser.AvatarStackData
|
||||
|
||||
Code template.HTML
|
||||
EscapeStatus *charset.EscapeStatus
|
||||
}
|
||||
@@ -174,9 +175,9 @@ func fillBlameResult(br *gitrepo.BlameReader, r *blameResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) map[string]*user_model.UserCommit {
|
||||
func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) map[string]*gituser.UserCommit {
|
||||
// store commit data by SHA to look up avatar info etc
|
||||
commitNames := make(map[string]*user_model.UserCommit)
|
||||
commitNames := make(map[string]*gituser.UserCommit)
|
||||
// and as blameParts can reference the same commits multiple
|
||||
// times, we cache the lookup work locally
|
||||
commits := make([]*git.Commit, 0, len(blameParts))
|
||||
@@ -209,33 +210,28 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma
|
||||
}
|
||||
|
||||
// populate commit email addresses to later look up avatars.
|
||||
validatedCommits, err := user_model.ValidateCommitsWithEmails(ctx, commits)
|
||||
userCommits, err := gituser.GetUserCommitsByGitCommits(ctx, commits, ctx.Repo.RepoLink, ctx.Repo.RefFullName)
|
||||
if err != nil {
|
||||
ctx.ServerError("ValidateCommitsWithEmails", err)
|
||||
ctx.ServerError("GetUserCommitsByGitCommits", err)
|
||||
return nil
|
||||
}
|
||||
for _, c := range validatedCommits {
|
||||
commitNames[c.ID.String()] = c
|
||||
for _, c := range userCommits {
|
||||
commitNames[c.GitCommit.ID.String()] = c
|
||||
}
|
||||
|
||||
return commitNames
|
||||
}
|
||||
|
||||
func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) {
|
||||
if commit.User != nil {
|
||||
br.Avatar = avatarUtils.Avatar(commit.User, 18)
|
||||
} else {
|
||||
br.Avatar = avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18)
|
||||
}
|
||||
|
||||
func renderBlameFillFirstBlameRow(ctx *context.Context, repoLink string, part *gitrepo.BlamePart, commit *gituser.UserCommit, br *blameRow) {
|
||||
br.AvatarStackData = gituser.BuildAvatarStackData(ctx, commit.GitCommit.AllParticipantIdentities(), nil)
|
||||
br.PreviousSha = part.PreviousSha
|
||||
br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath))
|
||||
br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha))
|
||||
br.CommitMessage = commit.MessageUTF8()
|
||||
br.CommitSince = templates.TimeSince(commit.Author.When)
|
||||
br.CommitMessage = commit.GitCommit.MessageUTF8()
|
||||
br.CommitSince = templates.TimeSince(commit.GitCommit.Author.When)
|
||||
}
|
||||
|
||||
func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*user_model.UserCommit) {
|
||||
func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*gituser.UserCommit) {
|
||||
language, err := languagestats.GetFileLanguage(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath)
|
||||
if err != nil {
|
||||
log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err)
|
||||
@@ -243,7 +239,6 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
rows := make([]*blameRow, 0)
|
||||
avatarUtils := templates.NewAvatarUtils(ctx)
|
||||
rowNumber := 0 // will be 1-based
|
||||
for _, part := range blameParts {
|
||||
for partLineIdx, line := range part.Lines {
|
||||
@@ -258,7 +253,7 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa
|
||||
}
|
||||
|
||||
if partLineIdx == 0 {
|
||||
renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, avatarUtils, part, commitNames[part.Sha], br)
|
||||
renderBlameFillFirstBlameRow(ctx, ctx.Repo.RepoLink, part, commitNames[part.Sha], br)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/gituser"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/renderhelper"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
@@ -49,7 +50,7 @@ func RefCommits(ctx *context.Context) {
|
||||
switch {
|
||||
case len(ctx.Repo.TreePath) == 0:
|
||||
Commits(ctx)
|
||||
case ctx.Repo.TreePath == "search":
|
||||
case ctx.Repo.TreePath == "search": // FIXME: legacy dirty design, it conflicts with the FileHistory
|
||||
SearchCommits(ctx)
|
||||
default:
|
||||
FileHistory(ctx)
|
||||
@@ -396,7 +397,8 @@ func Diff(ctx *context.Context) {
|
||||
|
||||
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
|
||||
ctx.Data["Verification"] = verification
|
||||
ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit)
|
||||
ctx.Data["Author"] = user_model.GetUserByGitAuthor(ctx, commit)
|
||||
ctx.Data["CommitOtherParticipants"] = gituser.BuildAvatarStackData(ctx, commit.AllParticipantIdentities(), nil).Participants[1:]
|
||||
ctx.Data["Parents"] = parents
|
||||
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
|
||||
|
||||
@@ -411,7 +413,7 @@ func Diff(ctx *context.Context) {
|
||||
err = git.GetNote(ctx, ctx.Repo.GitRepo, commitID, note)
|
||||
if err == nil {
|
||||
ctx.Data["NoteCommit"] = note.Commit
|
||||
ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit)
|
||||
ctx.Data["NoteAuthor"] = user_model.GetUserByGitAuthor(ctx, note.Commit)
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefSubURL: "commit/" + util.PathEscapeSegments(commitID)})
|
||||
htmlMessage := template.HTML(template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{}))))
|
||||
ctx.Data["NoteRendered"] = markup.PostProcessCommitMessage(rctx, htmlMessage)
|
||||
@@ -461,7 +463,7 @@ func RawDiff(ctx *context.Context) {
|
||||
}
|
||||
|
||||
func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_model.SignCommitWithStatuses, error) {
|
||||
commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository)
|
||||
commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository, ctx.Repo.RefFullName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -391,14 +391,14 @@ func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*g
|
||||
if useFirstCommitAsTitle {
|
||||
// the "commits" are from "ShowPrettyFormatLogToList", which is ordered from newest to oldest, here take the oldest one
|
||||
c := commits[len(commits)-1]
|
||||
title = c.UserCommit.MessageTitle()
|
||||
title = c.UserCommit.GitCommit.MessageTitle()
|
||||
} else {
|
||||
title = autoTitleFromBranchName(ci.HeadRef.ShortName())
|
||||
}
|
||||
|
||||
if len(commits) == 1 {
|
||||
c := commits[0]
|
||||
content = c.MessageBody()
|
||||
content = c.GitCommit.MessageBody()
|
||||
}
|
||||
|
||||
var titleTrailer string
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/gituser"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/setting"
|
||||
git_service "gitea.dev/services/git"
|
||||
@@ -52,8 +52,8 @@ func TestNewPullRequestTitleContent(t *testing.T) {
|
||||
mockCommit := func(msg string) *git_model.SignCommitWithStatuses {
|
||||
return &git_model.SignCommitWithStatuses{
|
||||
SignCommit: &asymkey_model.SignCommit{
|
||||
UserCommit: &user_model.UserCommit{
|
||||
Commit: &git.Commit{
|
||||
UserCommit: &gituser.UserCommit{
|
||||
GitCommit: &git.Commit{
|
||||
CommitMessage: git.CommitMessage{MessageRaw: msg},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/gituser"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
unit_model "gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
@@ -132,8 +133,11 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool {
|
||||
ctx.ServerError("CalculateTrustStatus", err)
|
||||
return false
|
||||
}
|
||||
|
||||
avatarStackData := gituser.BuildAvatarStackData(ctx, latestCommit.AllParticipantIdentities(), nil)
|
||||
avatarStackData.SearchByEmailLink = gituser.RepoCommitSearchByEmailLink(ctx.Repo.RepoLink, ctx.Repo.RefFullName)
|
||||
ctx.Data["LatestCommitAvatarStackData"] = avatarStackData
|
||||
ctx.Data["LatestCommitVerification"] = verification
|
||||
ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit)
|
||||
|
||||
statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll)
|
||||
if err != nil {
|
||||
|
||||
@@ -361,7 +361,7 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry)
|
||||
ctx.ServerError("CommitsByFileAndRange", err)
|
||||
return nil, nil
|
||||
}
|
||||
ctx.Data["Commits"], err = git_service.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository)
|
||||
ctx.Data["Commits"], err = git_service.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository, "") // no current ref sub path for wiki commit list
|
||||
if err != nil {
|
||||
ctx.ServerError("ConvertFromGitCommit", err)
|
||||
return nil, nil
|
||||
|
||||
@@ -232,6 +232,10 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
|
||||
RepoID: repoID,
|
||||
RunID: run.ID,
|
||||
})
|
||||
recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJobSummary{
|
||||
RepoID: repoID,
|
||||
RunID: run.ID,
|
||||
})
|
||||
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// TODO: Deleting task records could break current ephemeral runner implementation. This is a temporary workaround suggested by ChristopherHX.
|
||||
|
||||
@@ -399,6 +399,24 @@ func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_mo
|
||||
}
|
||||
|
||||
func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
||||
canWrite := func(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
||||
perm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return perm.CanWrite(unit_model.TypeActions), nil
|
||||
}
|
||||
return ifNeedApprovalWith(ctx, run, repo, user, canWrite, issues_model.HasMergedPullRequestInRepo)
|
||||
}
|
||||
|
||||
func ifNeedApprovalWith(
|
||||
ctx context.Context,
|
||||
run *actions_model.ActionRun,
|
||||
repo *repo_model.Repository,
|
||||
user *user_model.User,
|
||||
canWriteActions func(context.Context, *repo_model.Repository, *user_model.User) (bool, error),
|
||||
hasMergedPR func(context.Context, int64, int64) (bool, error),
|
||||
) (bool, error) {
|
||||
// 1. don't need approval if it's not a fork PR
|
||||
// 2. don't need approval if the event is `pull_request_target` since the workflow will run in the context of base branch
|
||||
// see https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
|
||||
@@ -413,27 +431,24 @@ func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *rep
|
||||
}
|
||||
|
||||
// don't need approval if the user can write
|
||||
if perm, err := access_model.GetDoerRepoPermission(ctx, repo, user); err != nil {
|
||||
if ok, err := canWriteActions(ctx, repo, user); err != nil {
|
||||
return false, fmt.Errorf("GetDoerRepoPermission: %w", err)
|
||||
} else if perm.CanWrite(unit_model.TypeActions) {
|
||||
} else if ok {
|
||||
log.Trace("do not need approval because user %d can write", user.ID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// don't need approval if the user has been approved before
|
||||
if count, err := db.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
|
||||
RepoID: repo.ID,
|
||||
TriggerUserID: user.ID,
|
||||
Approved: true,
|
||||
}); err != nil {
|
||||
return false, fmt.Errorf("CountRuns: %w", err)
|
||||
} else if count > 0 {
|
||||
log.Trace("do not need approval because user %d has been approved before", user.ID)
|
||||
// trust the user only after a merged PR — matching GitHub Actions. Approving one
|
||||
// fork PR's run must not implicitly trust later fork PRs that replace the workflow.
|
||||
if merged, err := hasMergedPR(ctx, repo.ID, user.ID); err != nil {
|
||||
return false, fmt.Errorf("HasMergedPullRequestInRepo: %w", err)
|
||||
} else if merged {
|
||||
log.Trace("do not need approval because user %d has a merged pull request in repo %d", user.ID, repo.ID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// otherwise, need approval
|
||||
log.Trace("need approval because it's the first time user %d triggered actions", user.ID)
|
||||
log.Trace("need approval because user %d has no merged pull request in repo %d", user.ID, repo.ID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIfNeedApproval(t *testing.T) {
|
||||
alwaysWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
neverWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
hasMerged := func(_ context.Context, _, _ int64) (bool, error) { return true, nil }
|
||||
noMerged := func(_ context.Context, _, _ int64) (bool, error) { return false, nil }
|
||||
errPerm := errors.New("perm error")
|
||||
errMerge := errors.New("merge error")
|
||||
|
||||
forkRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequest}
|
||||
nonForkRun := &actions_model.ActionRun{IsForkPullRequest: false, TriggerEvent: actions_module.GithubEventPullRequest}
|
||||
prTargetRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequestTarget}
|
||||
|
||||
repo := &repo_model.Repository{ID: 1}
|
||||
normalUser := &user_model.User{ID: 10}
|
||||
restrictedUser := &user_model.User{ID: 11, IsRestricted: true}
|
||||
|
||||
t.Run("not a fork PR never needs approval", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), nonForkRun, repo, normalUser, alwaysWrite, hasMerged)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, need)
|
||||
})
|
||||
|
||||
t.Run("pull_request_target never needs approval even when fork", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), prTargetRun, repo, normalUser, alwaysWrite, hasMerged)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, need)
|
||||
})
|
||||
|
||||
t.Run("restricted user always needs approval", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, alwaysWrite, hasMerged)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, need)
|
||||
})
|
||||
|
||||
t.Run("fork PR with write permission does not need approval", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, alwaysWrite, noMerged)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, need)
|
||||
})
|
||||
|
||||
t.Run("fork PR with merged PR but no write permission does not need approval", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, hasMerged)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, need)
|
||||
})
|
||||
|
||||
t.Run("fork PR with no write and no merged PR needs approval", func(t *testing.T) {
|
||||
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, noMerged)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, need)
|
||||
})
|
||||
|
||||
t.Run("canWriteActions error is propagated", func(t *testing.T) {
|
||||
failWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||
return false, errPerm
|
||||
}
|
||||
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, failWrite, noMerged)
|
||||
require.ErrorIs(t, err, errPerm)
|
||||
})
|
||||
|
||||
t.Run("hasMergedPR error is propagated", func(t *testing.T) {
|
||||
failMerge := func(_ context.Context, _, _ int64) (bool, error) { return false, errMerge }
|
||||
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, failMerge)
|
||||
require.ErrorIs(t, err, errMerge)
|
||||
})
|
||||
|
||||
t.Run("restricted user skips permission check entirely", func(t *testing.T) {
|
||||
// The perm and merge functions must not be called for a restricted user.
|
||||
called := false
|
||||
trackWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||
called = true
|
||||
return true, nil
|
||||
}
|
||||
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, trackWrite, noMerged)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, need)
|
||||
assert.False(t, called, "permission check must not run for restricted user")
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
@@ -15,7 +16,9 @@ import (
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/convert"
|
||||
@@ -149,10 +152,10 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
return fmt.Errorf("parse caller job %d: %w", caller.ID, err)
|
||||
}
|
||||
|
||||
// 3. Load called-workflow source.
|
||||
ref, err := jobparser.ParseUses(parsedJob.Uses)
|
||||
// 3. Resolve `uses` and load called-workflow source.
|
||||
ref, err := ResolveUses(ctx, parsedJob.Uses)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse uses %q: %w", parsedJob.Uses, err)
|
||||
return fmt.Errorf("resolve uses %q: %w", parsedJob.Uses, err)
|
||||
}
|
||||
content, contentSourceRepoID, contentSourceCommitSHA, err := loadReusableWorkflowSource(ctx, run, caller, ref)
|
||||
if err != nil {
|
||||
@@ -340,3 +343,20 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveUses normalizes and parses a reusable workflow `uses:` value.
|
||||
// It first rewrites an absolute URL pointing to this instance into the cross-repo form (rejecting external URLs),
|
||||
// then validates the syntax via jobparser.ParseUses.
|
||||
func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) {
|
||||
// Rewrite a local-instance URL to the equivalent cross-repo form "owner/repo/.gitea/workflows/file.yml@ref".
|
||||
if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") {
|
||||
// ParseGiteaSiteURL returns nil for URLs that do not belong to this instance.
|
||||
gsu := httplib.ParseGiteaSiteURL(ctx, uses)
|
||||
if gsu == nil {
|
||||
return nil, fmt.Errorf("unsupported reusable workflow URL %q: an absolute URL must point to this Gitea instance (%s)", uses, setting.AppURL)
|
||||
}
|
||||
// RoutePath is the instance-relative path (AppSubURL already stripped), e.g. "/owner/repo/.gitea/workflows/file.yml@ref".
|
||||
uses = strings.TrimPrefix(gsu.RoutePath, "/")
|
||||
}
|
||||
return jobparser.ParseUses(uses)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -132,3 +135,44 @@ func buildCallerChain(t *testing.T, callerUses ...string) []*actions_model.Actio
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
func TestResolveUses(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/sub/")()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
|
||||
ctx := t.Context()
|
||||
|
||||
t.Run("LocalForms", func(t *testing.T) {
|
||||
// Same-repo and cross-repo forms are not URLs and are parsed as-is.
|
||||
ref, err := ResolveUses(ctx, "./.gitea/workflows/build.yml")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"}, *ref)
|
||||
|
||||
ref, err = ResolveUses(ctx, "owner/repo/.gitea/workflows/build.yml@v1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/build.yml", Ref: "v1"}, *ref)
|
||||
})
|
||||
|
||||
t.Run("LocalInstanceURL", func(t *testing.T) {
|
||||
// An absolute URL on this instance (incl. AppSubURL) resolves to the equivalent cross-repo ref.
|
||||
ref, err := ResolveUses(ctx, "https://gitea.example.com/sub/owner/repo/.gitea/workflows/ci.yml@refs/heads/main")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/ci.yml", Ref: "refs/heads/main"}, *ref)
|
||||
})
|
||||
|
||||
t.Run("InvalidSyntax", func(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"owner/.gitea/workflows/foo.yml", // missing repo segment
|
||||
"owner/repo/.gitea/workflows/foo.yml", // missing @ref
|
||||
"https://gitea.example.com/sub/repo/.gitea/workflows/ci.yml@refs/heads/main", // local absolute URL but missing owner
|
||||
"not a valid uses at all",
|
||||
} {
|
||||
_, err := ResolveUses(ctx, in)
|
||||
require.Error(t, err, "in = %s", in)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ForeignURL", func(t *testing.T) {
|
||||
_, err := ResolveUses(ctx, "https://other.gitea-example.com/owner/repo/.gitea/workflows/ci.yaml@v1")
|
||||
assert.ErrorContains(t, err, "must point to this Gitea instance")
|
||||
})
|
||||
}
|
||||
|
||||
+4
-20
@@ -138,26 +138,10 @@ func (ctx *APIContext) apiErrorInternal(skip int, err error) {
|
||||
}
|
||||
|
||||
// APIErrorNotFound handles 404s for APIContext
|
||||
// String will replace message, errors will be added to a slice
|
||||
func (ctx *APIContext) APIErrorNotFound(objs ...any) {
|
||||
var message string
|
||||
var errs []string
|
||||
for _, obj := range objs {
|
||||
// Ignore nil
|
||||
if obj == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err, ok := obj.(error); ok {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
message = obj.(string)
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusNotFound, map[string]any{
|
||||
"message": util.IfZero(message, "not found"), // do not use locale in API
|
||||
"url": setting.API.SwaggerURL,
|
||||
"errors": errs,
|
||||
func (ctx *APIContext) APIErrorNotFound(msg ...string) {
|
||||
ctx.JSON(http.StatusNotFound, APIError{
|
||||
Message: util.OptionalArg(msg, "not found"),
|
||||
URL: setting.API.SwaggerURL,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+10
-9
@@ -9,6 +9,7 @@ import (
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/gituser"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
@@ -17,14 +18,14 @@ import (
|
||||
)
|
||||
|
||||
// ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys.
|
||||
func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository, oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType) ([]*asymkey_model.SignCommit, error) {
|
||||
func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository, oldCommits []*gituser.UserCommit, repoTrustModel repo_model.TrustModelType) ([]*asymkey_model.SignCommit, error) {
|
||||
newCommits := make([]*asymkey_model.SignCommit, 0, len(oldCommits))
|
||||
keyMap := map[string]bool{}
|
||||
|
||||
emails := make(container.Set[string])
|
||||
for _, c := range oldCommits {
|
||||
if c.Committer != nil {
|
||||
emails.Add(c.Committer.Email)
|
||||
if c.GitCommit.Committer != nil {
|
||||
emails.Add(c.GitCommit.Committer.Email)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +35,10 @@ func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository,
|
||||
}
|
||||
|
||||
for _, c := range oldCommits {
|
||||
committerUser := emailUsers.GetByEmail(c.Committer.Email) // FIXME: why ValidateCommitsWithEmails uses "Author", but ParseCommitsWithSignature uses "Committer"?
|
||||
committerUser := emailUsers.GetByEmail(c.GitCommit.Committer.Email) // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"?
|
||||
signCommit := &asymkey_model.SignCommit{
|
||||
UserCommit: c,
|
||||
Verification: asymkey_service.ParseCommitWithSignatureCommitter(ctx, c.Commit, committerUser),
|
||||
Verification: asymkey_service.ParseCommitWithSignatureCommitter(ctx, c.GitCommit, committerUser),
|
||||
}
|
||||
|
||||
isOwnerMemberCollaborator := func(user *user_model.User) (bool, error) {
|
||||
@@ -52,15 +53,15 @@ func ParseCommitsWithSignature(ctx context.Context, repo *repo_model.Repository,
|
||||
}
|
||||
|
||||
// ConvertFromGitCommit converts git commits into SignCommitWithStatuses
|
||||
func ConvertFromGitCommit(ctx context.Context, commits []*git.Commit, repo *repo_model.Repository) ([]*git_model.SignCommitWithStatuses, error) {
|
||||
validatedCommits, err := user_model.ValidateCommitsWithEmails(ctx, commits)
|
||||
func ConvertFromGitCommit(ctx context.Context, commits []*git.Commit, repo *repo_model.Repository, currentRef git.RefName) ([]*git_model.SignCommitWithStatuses, error) {
|
||||
userCommits, err := gituser.GetUserCommitsByGitCommits(ctx, commits, repo.Link(), currentRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signedCommits, err := ParseCommitsWithSignature(
|
||||
ctx,
|
||||
repo,
|
||||
validatedCommits,
|
||||
userCommits,
|
||||
repo.GetTrustModel(),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -77,7 +78,7 @@ func ParseCommitsWithStatus(ctx context.Context, oldCommits []*asymkey_model.Sig
|
||||
commit := &git_model.SignCommitWithStatuses{
|
||||
SignCommit: c,
|
||||
}
|
||||
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commit.ID.String(), db.ListOptionsAll)
|
||||
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commit.GitCommit.ID.String(), db.ListOptionsAll)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ func LoadCommentPushCommits(ctx context.Context, c *issues_model.Comment) error
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
c.Commits, err = git_service.ConvertFromGitCommit(ctx, gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo)
|
||||
c.Commits, err = git_service.ConvertFromGitCommit(ctx, gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo, "") // no current ref sub path for PR commit list
|
||||
if err != nil {
|
||||
log.Debug("ConvertFromGitCommit: %v", err) // no need to show 500 error to end user when the commit does not exist
|
||||
} else {
|
||||
|
||||
@@ -65,9 +65,7 @@ func doMergeStyleSquash(ctx *mergeContext, message string) error {
|
||||
}
|
||||
|
||||
if setting.Repository.PullRequest.AddCoCommitterTrailers && ctx.committer.String() != sig.String() {
|
||||
// add trailer
|
||||
message = AddCommitMessageTailer(message, "Co-authored-by", sig.String())
|
||||
message = AddCommitMessageTailer(message, "Co-committed-by", sig.String()) // FIXME: this one should be removed, it is not really used or widely used
|
||||
message = AddCommitMessageTailer(message, git.CoAuthoredByTrailer, sig.String())
|
||||
}
|
||||
cmdCommit := gitcmd.NewCommand("commit").
|
||||
AddOptionFormat("--author='%s <%s>'", sig.Name, sig.Email).
|
||||
|
||||
@@ -917,7 +917,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
||||
}
|
||||
|
||||
for _, author := range authors {
|
||||
stringBuilder.WriteString("Co-authored-by: ")
|
||||
stringBuilder.WriteString(git.CoAuthoredByTrailer + ": ")
|
||||
stringBuilder.WriteString(author)
|
||||
stringBuilder.WriteRune('\n')
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams
|
||||
&actions_model.ActionScheduleSpec{RepoID: repoID},
|
||||
&actions_model.ActionSchedule{RepoID: repoID},
|
||||
&actions_model.ActionArtifact{RepoID: repoID},
|
||||
&actions_model.ActionRunJobSummary{RepoID: repoID},
|
||||
&actions_model.ActionRunnerToken{RepoID: repoID},
|
||||
&issues_model.IssuePin{RepoID: repoID},
|
||||
); err != nil {
|
||||
|
||||
@@ -300,12 +300,7 @@ func (t *TemporaryUploadRepository) CommitTree(ctx context.Context, opts *Commit
|
||||
cmdCommitTree.AddOptionFormat("-S%s", key.KeyID)
|
||||
if t.repo.GetTrustModel() == repo_model.CommitterTrustModel || t.repo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel {
|
||||
if committerSig.Name != authorSig.Name || committerSig.Email != authorSig.Email {
|
||||
// Add trailers
|
||||
_, _ = messageBytes.WriteString("\n")
|
||||
_, _ = messageBytes.WriteString("Co-authored-by: ")
|
||||
_, _ = messageBytes.WriteString(committerSig.String())
|
||||
_, _ = messageBytes.WriteString("\n")
|
||||
_, _ = messageBytes.WriteString("Co-committed-by: ")
|
||||
_, _ = messageBytes.WriteString("\n" + git.CoAuthoredByTrailer + ": ")
|
||||
_, _ = messageBytes.WriteString(committerSig.String())
|
||||
_, _ = messageBytes.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/gituser"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
@@ -93,9 +95,7 @@ func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error
|
||||
// before finally retrieving the latest status
|
||||
func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_model.Repository, gitRepo *git.Repository) error {
|
||||
var err error
|
||||
var ok bool
|
||||
|
||||
emails := map[string]*user_model.User{}
|
||||
emailSet := make(container.Set[string])
|
||||
keyMap := map[string]bool{}
|
||||
|
||||
for _, c := range graph.Commits {
|
||||
@@ -106,14 +106,26 @@ func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetCommit: %s Error: %w", c.Rev, err)
|
||||
}
|
||||
|
||||
if c.Commit.Author != nil {
|
||||
email := c.Commit.Author.Email
|
||||
if c.User, ok = emails[email]; !ok {
|
||||
c.User, _ = user_model.GetUserByEmail(ctx, email)
|
||||
emails[email] = c.User
|
||||
}
|
||||
emailSet.Add(c.Commit.Author.Email)
|
||||
}
|
||||
for _, sig := range c.Commit.AllParticipantIdentities() {
|
||||
emailSet.Add(sig.Email)
|
||||
}
|
||||
}
|
||||
|
||||
emailUserMap, err := user_model.GetUsersByEmails(ctx, emailSet.Values())
|
||||
if err != nil {
|
||||
log.Error("GetUsersByEmails: %v", err)
|
||||
}
|
||||
|
||||
for _, c := range graph.Commits {
|
||||
if c.Commit == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
c.User = emailUserMap.GetByEmail(c.Commit.Author.Email)
|
||||
c.AvatarStackData = gituser.BuildAvatarStackData(ctx, c.Commit.AllParticipantIdentities(), emailUserMap)
|
||||
|
||||
c.Verification = asymkey_service.ParseCommitWithSignature(ctx, c.Commit)
|
||||
|
||||
@@ -246,18 +258,19 @@ func newRefsFromRefNames(refNames []byte) []git.Reference {
|
||||
|
||||
// Commit represents a commit at coordinate X, Y with the data
|
||||
type Commit struct {
|
||||
Commit *git.Commit
|
||||
User *user_model.User
|
||||
Verification *asymkey_model.CommitVerification
|
||||
Status *git_model.CommitStatus
|
||||
Flow int64
|
||||
Row int
|
||||
Column int
|
||||
Refs []git.Reference
|
||||
Rev string
|
||||
Date time.Time
|
||||
ShortRev string
|
||||
Subject string
|
||||
Commit *git.Commit
|
||||
User *user_model.User // author
|
||||
AvatarStackData *gituser.AvatarStackData
|
||||
Verification *asymkey_model.CommitVerification
|
||||
Status *git_model.CommitStatus
|
||||
Flow int64
|
||||
Row int
|
||||
Column int
|
||||
Refs []git.Reference
|
||||
Rev string
|
||||
Date time.Time // author date from "%ad"
|
||||
ShortRev string
|
||||
Subject string
|
||||
}
|
||||
|
||||
// OnlyRelation returns whether this a relation only commit
|
||||
|
||||
@@ -7,7 +7,6 @@ package wiki
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
@@ -21,6 +20,7 @@ import (
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
)
|
||||
@@ -304,7 +304,7 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return os.ErrNotExist
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// FIXME: The wiki doesn't have lfs support at present - if this changes need to check attributes here
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest ui container">
|
||||
<div>
|
||||
<h1>Avatar Stack</h1>
|
||||
<table class="ui basic table">
|
||||
<thead><tr><th>Scenario</th><th>Rendered</th></tr></thead>
|
||||
<tbody>
|
||||
{{range $s := .AvatarStackScenarios}}
|
||||
<tr>
|
||||
<td>{{$s.Label}}</td>
|
||||
<td>{{ctx.RenderUtils.AvatarStackWithNames $s.Data}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{template "devtest/devtest-footer"}}
|
||||
@@ -4,7 +4,7 @@
|
||||
<h1>Commit Sign Badges</h1>
|
||||
{{range $commit := .MockCommits}}
|
||||
<div class="flex-text-block tw-my-2">
|
||||
{{template "repo/commit_sign_badge" dict "Commit" $commit "CommitBaseLink" "/devtest/commit" "CommitSignVerification" $commit.Verification}}
|
||||
{{template "repo/commit_sign_badge" dict "Commit" $commit.GitCommit "CommitBaseLink" "/devtest/commit" "CommitSignVerification" $commit.Verification}}
|
||||
{{template "repo/commit_sign_badge" dict "CommitSignVerification" $commit.Verification}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -15,9 +15,14 @@
|
||||
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"
|
||||
data-locale-summary="{{ctx.Locale.Tr "actions.runs.summary"}}"
|
||||
data-locale-all-jobs="{{ctx.Locale.Tr "actions.runs.all_jobs"}}"
|
||||
data-locale-job-summaries="{{ctx.Locale.Tr "actions.runs.job_summaries"}}"
|
||||
data-locale-expand-caller-jobs="{{ctx.Locale.Tr "actions.runs.expand_caller_jobs"}}"
|
||||
data-locale-collapse-caller-jobs="{{ctx.Locale.Tr "actions.runs.collapse_caller_jobs"}}"
|
||||
data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}"
|
||||
data-locale-rerun-triggered="{{ctx.Locale.Tr "actions.runs.rerun_triggered"}}"
|
||||
data-locale-back-to-pull-request="{{ctx.Locale.Tr "actions.runs.back_to_pull_request"}}"
|
||||
data-locale-back-to-workflow="{{ctx.Locale.Tr "actions.runs.back_to_workflow"}}"
|
||||
data-locale-status-label="{{ctx.Locale.Tr "actions.runs.status"}}"
|
||||
data-locale-total-duration="{{ctx.Locale.Tr "actions.runs.total_duration"}}"
|
||||
data-locale-run-details="{{ctx.Locale.Tr "actions.runs.run_details"}}"
|
||||
data-locale-workflow-file="{{ctx.Locale.Tr "actions.runs.workflow_file"}}"
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="blame-info">
|
||||
<div class="blame-data">
|
||||
<div class="blame-avatar">
|
||||
{{$row.Avatar}}
|
||||
{{if $row.AvatarStackData}}{{ctx.RenderUtils.AvatarStack $row.AvatarStackData}}{{end}}
|
||||
</div>
|
||||
<div class="blame-message muted-links" title="{{$row.CommitMessage}}">
|
||||
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $row.CommitMessage $row.CommitURL $.Repository}}
|
||||
|
||||
@@ -154,6 +154,29 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .CommitOtherParticipants}}
|
||||
<div class="flex-text-inline">
|
||||
<span class="tw-text-text-light">{{ctx.Locale.Tr "repo.diff.coauthored_by"}}</span>
|
||||
{{range $participant := .CommitOtherParticipants}}
|
||||
{{$user := $participant.GiteaUser}}
|
||||
{{$gitIdentity := $participant.GitIdentity}}
|
||||
{{if $user}}
|
||||
{{ctx.AvatarUtils.Avatar $user 20}}
|
||||
<a class="muted" href="{{$user.HomeLink}}"><strong>{{$user.GetDisplayName}}</strong></a>
|
||||
{{else}}
|
||||
{{$gitName := $gitIdentity.Name}}
|
||||
{{$gitEmail := $gitIdentity.Email}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitName 20}}
|
||||
{{if $gitEmail}}
|
||||
<a class="muted" href="mailto:{{$gitEmail}}"><strong>{{$gitName}}</strong></a>
|
||||
{{else}}
|
||||
<strong>{{$gitName}}</strong>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Verification}}
|
||||
{{template "repo/commit_sign_badge" dict "CommitSignVerification" .Verification}}
|
||||
{{end}}
|
||||
|
||||
@@ -64,10 +64,10 @@ so this template should be kept as small as possible, DO NOT put large component
|
||||
{{- if $verified -}}
|
||||
{{- if and $signingUser $signingUser.ID -}}
|
||||
<span data-tooltip-content="{{$msgReason}}">{{svg "gitea-lock"}}</span>
|
||||
<span data-tooltip-content="{{$msgSigningKey}}">{{ctx.AvatarUtils.Avatar $signingUser 16}}</span>
|
||||
<span data-tooltip-content="{{$msgSigningKey}}">{{ctx.AvatarUtils.Avatar $signingUser 20}}</span>
|
||||
{{- else -}}
|
||||
<span data-tooltip-content="{{$msgReason}}">{{svg "gitea-lock-cog"}}</span>
|
||||
<span data-tooltip-content="{{$msgSigningKey}}">{{ctx.AvatarUtils.AvatarByEmail $signingEmail "" 16}}</span>
|
||||
<span data-tooltip-content="{{$msgSigningKey}}">{{ctx.AvatarUtils.AvatarByEmail $signingEmail "" 20}}</span>
|
||||
{{- end -}}
|
||||
{{- else -}}
|
||||
<span data-tooltip-content="{{$msgReason}}">{{svg "gitea-unlock"}}</span>
|
||||
|
||||
@@ -2,27 +2,21 @@
|
||||
<table class="ui very basic table unstackable" id="commits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="three wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
|
||||
<th class="four wide">{{ctx.Locale.Tr "repo.commits.author"}}</th>
|
||||
<th class="two wide sha">{{StringUtils.ToUpper $.Repository.ObjectFormatName}}</th>
|
||||
<th class="eight wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
|
||||
<th class="seven wide message">{{ctx.Locale.Tr "repo.commits.message"}}</th>
|
||||
<th class="two wide tw-text-right">{{ctx.Locale.Tr "repo.commits.date"}}</th>
|
||||
<th class="one wide"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="commit-list">
|
||||
{{$commitRepoLink := $.RepoLink}}{{if $.CommitRepoLink}}{{$commitRepoLink = $.CommitRepoLink}}{{end}}
|
||||
{{range $commit := .Commits}}
|
||||
{{range $commit := $.Commits}}
|
||||
{{$gitCommit := $commit.GitCommit}}
|
||||
{{$commitID := $gitCommit.ID.String}}
|
||||
<tr>
|
||||
<td class="author">
|
||||
<span class="author-wrapper">
|
||||
{{- if .User -}}
|
||||
{{- ctx.AvatarUtils.Avatar .User 20 "tw-mr-2" -}}
|
||||
{{- .User.GetShortDisplayNameLinkHTML -}}
|
||||
{{- else -}}
|
||||
{{- ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20 "tw-mr-2" -}}
|
||||
{{- .Author.Name -}}
|
||||
{{- end -}}
|
||||
</span>
|
||||
{{ctx.RenderUtils.AvatarStackWithNames $commit.AvatarStackData}}
|
||||
</td>
|
||||
<td class="sha">
|
||||
{{$commitBaseLink := ""}}
|
||||
@@ -33,52 +27,48 @@
|
||||
{{else}}
|
||||
{{$commitBaseLink = printf "%s/commit" $commitRepoLink}}
|
||||
{{end}}
|
||||
{{template "repo/commit_sign_badge" dict "Commit" . "CommitBaseLink" $commitBaseLink "CommitSignVerification" .Verification}}
|
||||
{{template "repo/commit_sign_badge" dict "Commit" $gitCommit "CommitBaseLink" $commitBaseLink "CommitSignVerification" .Verification}}
|
||||
</td>
|
||||
<td class="message">
|
||||
<span class="message-wrapper">
|
||||
{{if $.PageIsWiki}}
|
||||
<span class="commit-summary {{if gt $commit.ParentCount 1}} grey text{{end}}" title="{{$commit.MessageTitle}}">
|
||||
{{$commit.MessageTitle | ctx.RenderUtils.RenderEmoji}}
|
||||
<span class="commit-summary {{if gt $gitCommit.ParentCount 1}} grey text{{end}}" title="{{$gitCommit.MessageTitle}}">
|
||||
{{$gitCommit.MessageTitle | ctx.RenderUtils.RenderEmoji}}
|
||||
</span>
|
||||
{{else}}
|
||||
{{$commitLink:= printf "%s/commit/%s" $commitRepoLink (PathEscape $commit.ID.String)}}
|
||||
<span class="commit-summary {{if gt $commit.ParentCount 1}} grey text{{end}}" title="{{$commit.MessageTitle}}">
|
||||
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.MessageUTF8 $commitLink $.Repository}}
|
||||
{{$commitLink:= printf "%s/commit/%s" $commitRepoLink (PathEscape $commitID)}}
|
||||
<span class="commit-summary {{if gt $gitCommit.ParentCount 1}} grey text{{end}}" title="{{$gitCommit.MessageTitle}}">
|
||||
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $gitCommit.MessageUTF8 $commitLink $.Repository}}
|
||||
</span>
|
||||
{{end}}
|
||||
</span>
|
||||
{{if $commit.MessageBody}}
|
||||
{{if $gitCommit.MessageBody}}
|
||||
<button class="ui button ellipsis-button" aria-expanded="false" data-global-click="onRepoEllipsisButtonClick">...</button>
|
||||
{{end}}
|
||||
{{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}}
|
||||
{{if $commit.MessageBody}}
|
||||
<pre class="commit-body tw-hidden">{{ctx.RenderUtils.RenderCommitBody $commit.MessageUTF8 $.Repository}}</pre>
|
||||
{{if $gitCommit.MessageBody}}
|
||||
<pre class="commit-body tw-hidden">{{ctx.RenderUtils.RenderCommitBody $gitCommit.MessageUTF8 $.Repository}}</pre>
|
||||
{{end}}
|
||||
{{if $.CommitsTagsMap}}
|
||||
{{range (index $.CommitsTagsMap .ID.String)}}
|
||||
{{range (index $.CommitsTagsMap $commitID)}}
|
||||
{{- template "repo/tag/name" dict "AdditionalClasses" "tw-py-0" "RepoLink" $.Repository.Link "TagName" .TagName "IsRelease" (not .IsTag) -}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
{{if .Committer}}
|
||||
<td class="tw-text-right">{{DateUtils.TimeSince .Committer.When}}</td>
|
||||
{{else}}
|
||||
<td class="tw-text-right">{{DateUtils.TimeSince .Author.When}}</td>
|
||||
{{end}}
|
||||
<td class="tw-text-right">{{DateUtils.TimeSince $gitCommit.Committer.When}}</td>
|
||||
<td class="tw-text-right tw-py-0">
|
||||
<button class="btn interact-bg tw-p-2 copy-commit-id" data-tooltip-content="{{ctx.Locale.Tr "copy_hash"}}" data-clipboard-text="{{.ID}}">{{svg "octicon-copy"}}</button>
|
||||
<button class="btn interact-bg tw-p-2 copy-commit-id" data-tooltip-content="{{ctx.Locale.Tr "copy_hash"}}" data-clipboard-text="{{$commitID}}">{{svg "octicon-copy"}}</button>
|
||||
{{/* at the moment, wiki doesn't support these "view" links like "view at history point" */}}
|
||||
{{if not $.PageIsWiki}}
|
||||
{{/* view single file diff */}}
|
||||
{{if $.FileTreePath}}
|
||||
<a class="btn interact-bg tw-p-2 view-single-diff" data-tooltip-content="{{ctx.Locale.Tr "repo.commits.view_file_diff"}}"
|
||||
href="{{$commitRepoLink}}/commit/{{.ID.String}}?files={{$.FileTreePath}}"
|
||||
href="{{$commitRepoLink}}/commit/{{$commitID}}?files={{$.FileTreePath}}"
|
||||
>{{svg "octicon-file-diff"}}</a>
|
||||
{{end}}
|
||||
|
||||
{{/* view at history point */}}
|
||||
{{$viewCommitLink := printf "%s/src/commit/%s" $commitRepoLink (PathEscape .ID.String)}}
|
||||
{{$viewCommitLink := printf "%s/src/commit/%s" $commitRepoLink (PathEscape $commitID)}}
|
||||
{{if $.FileTreePath}}{{$viewCommitLink = printf "%s/%s" $viewCommitLink (PathEscapeSegments $.FileTreePath)}}{{end}}
|
||||
<a class="btn interact-bg tw-p-2 view-commit-path" data-tooltip-content="{{ctx.Locale.Tr "repo.commits.view_path"}}" href="{{$viewCommitLink}}">{{svg "octicon-file-code"}}</a>
|
||||
{{end}}
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
{{$index := 0}}
|
||||
<div class="timeline-item commits-list">
|
||||
{{range $commit := .comment.Commits}}
|
||||
{{range $commit := $.comment.Commits}}
|
||||
{{$gitCommit := $commit.GitCommit}}
|
||||
{{$tag := printf "%s-%d" $.comment.HashTag $index}}
|
||||
{{$index = Eval $index "+" 1}}
|
||||
<div class="flex-text-block" id="{{$tag}}">{{/*singular-commit*/}}
|
||||
<span class="badge badge-commit">{{svg "octicon-git-commit"}}</span>
|
||||
{{if .User}}
|
||||
<a class="avatar" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}}</a>
|
||||
{{else}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20}}
|
||||
{{end}}
|
||||
{{ctx.RenderUtils.AvatarStack $commit.AvatarStackData}}
|
||||
|
||||
{{$commitBaseLink := printf "%s/commit" $.comment.Issue.PullRequest.BaseRepo.Link}}
|
||||
{{$commitLink:= printf "%s/%s" $commitBaseLink (PathEscape .ID.String)}}
|
||||
{{$commitLink:= printf "%s/%s" $commitBaseLink (PathEscape $gitCommit.ID.String)}}
|
||||
|
||||
<span class="tw-flex-1 tw-font-mono gt-ellipsis" title="{{$commit.MessageTitle}}">
|
||||
{{- ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.MessageUTF8 $commitLink $.comment.Issue.PullRequest.BaseRepo -}}
|
||||
<span class="tw-flex-1 tw-font-mono gt-ellipsis" title="{{$gitCommit.MessageTitle}}">
|
||||
{{- ctx.RenderUtils.RenderCommitMessageLinkSubject $gitCommit.MessageUTF8 $commitLink $.comment.Issue.PullRequest.BaseRepo -}}
|
||||
</span>
|
||||
|
||||
{{if $commit.MessageBody}}
|
||||
{{if $gitCommit.MessageBody}}
|
||||
<button class="ui button ellipsis-button show-panel toggle" data-panel="[data-singular-commit-body-for='{{$tag}}']">...</button>
|
||||
{{end}}
|
||||
|
||||
<span class="flex-text-block">
|
||||
{{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}}
|
||||
{{template "repo/commit_sign_badge" dict "Commit" . "CommitBaseLink" $commitBaseLink "CommitSignVerification" .Verification}}
|
||||
{{template "repo/commit_statuses" dict "Status" $commit.Status "Statuses" $commit.Statuses}}
|
||||
{{template "repo/commit_sign_badge" dict "Commit" $gitCommit "CommitBaseLink" $commitBaseLink "CommitSignVerification" $commit.Verification}}
|
||||
</span>
|
||||
</div>
|
||||
{{if $commit.MessageBody}}
|
||||
{{if $gitCommit.MessageBody}}
|
||||
<pre class="commit-body tw-ml-[33px] tw-hidden" data-singular-commit-body-for="{{$tag}}">
|
||||
{{- ctx.RenderUtils.RenderCommitBody $commit.MessageUTF8 $.comment.Issue.PullRequest.BaseRepo -}}
|
||||
{{- ctx.RenderUtils.RenderCommitBody $gitCommit.MessageUTF8 $.comment.Issue.PullRequest.BaseRepo -}}
|
||||
</pre>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -41,14 +41,7 @@
|
||||
</span>
|
||||
|
||||
<span class="flex-text-inline tw-text-12">
|
||||
{{if $commit.User}}
|
||||
{{ctx.AvatarUtils.Avatar $commit.User 18}}
|
||||
{{$commit.User.GetShortDisplayNameLinkHTML}}
|
||||
{{else}}
|
||||
{{$gitUserName := $commit.Commit.Author.Name}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail $commit.Commit.Author.Email $gitUserName 18}}
|
||||
{{$gitUserName}}
|
||||
{{end}}
|
||||
{{ctx.RenderUtils.AvatarStackWithNames $commit.AvatarStackData}}
|
||||
</span>
|
||||
|
||||
<span class="time flex-text-inline">{{DateUtils.FullTime $commit.Date}}</span>
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
{{if not .LatestCommit}}
|
||||
…
|
||||
{{else}}
|
||||
<span class="author-wrapper">
|
||||
{{- if .LatestCommitUser -}}
|
||||
{{- ctx.AvatarUtils.Avatar .LatestCommitUser 20 "tw-mr-2" -}}
|
||||
<strong>{{.LatestCommitUser.GetShortDisplayNameLinkHTML}}</strong>
|
||||
{{- else if .LatestCommit.Author -}}
|
||||
{{- ctx.AvatarUtils.AvatarByEmail .LatestCommit.Author.Email .LatestCommit.Author.Name 20 "tw-mr-2" -}}
|
||||
<strong>{{.LatestCommit.Author.Name}}</strong>
|
||||
{{- end -}}
|
||||
</span>
|
||||
{{ctx.RenderUtils.AvatarStackWithNames .LatestCommitAvatarStackData}}
|
||||
|
||||
{{template "repo/commit_sign_badge" dict "Commit" .LatestCommit "CommitBaseLink" (print .RepoLink "/commit") "CommitSignVerification" .LatestCommitVerification}}
|
||||
|
||||
|
||||
Generated
+27
-27
@@ -22995,7 +22995,7 @@
|
||||
"x-go-name": "SHA"
|
||||
},
|
||||
"state": {
|
||||
"description": "State is the overall combined status state\npending CommitStatusPending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"description": "State is the overall combined status state\npending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
@@ -23005,7 +23005,7 @@
|
||||
"warning",
|
||||
"skipped"
|
||||
],
|
||||
"x-go-enum-desc": "pending CommitStatusPending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"x-go-enum-desc": "pending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"x-go-name": "State"
|
||||
},
|
||||
"statuses": {
|
||||
@@ -23260,7 +23260,7 @@
|
||||
"x-go-name": "ID"
|
||||
},
|
||||
"status": {
|
||||
"description": "State represents the status state (pending, success, error, failure)\npending CommitStatusPending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"description": "State represents the status state (pending, success, error, failure)\npending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
@@ -23270,7 +23270,7 @@
|
||||
"warning",
|
||||
"skipped"
|
||||
],
|
||||
"x-go-enum-desc": "pending CommitStatusPending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"x-go-enum-desc": "pending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"x-go-name": "State"
|
||||
},
|
||||
"target_url": {
|
||||
@@ -24313,7 +24313,7 @@
|
||||
"REQUEST_CHANGES",
|
||||
"REQUEST_REVIEW"
|
||||
],
|
||||
"x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user",
|
||||
"x-go-enum-desc": "APPROVED ReviewStateApproved pr is approved\nPENDING ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview review is requested from user",
|
||||
"x-go-name": "Event"
|
||||
}
|
||||
},
|
||||
@@ -24492,7 +24492,7 @@
|
||||
"x-go-name": "Description"
|
||||
},
|
||||
"state": {
|
||||
"description": "State represents the status state to set (pending, success, error, failure)\npending CommitStatusPending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"description": "State represents the status state to set (pending, success, error, failure)\npending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
@@ -24502,7 +24502,7 @@
|
||||
"warning",
|
||||
"skipped"
|
||||
],
|
||||
"x-go-enum-desc": "pending CommitStatusPending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"x-go-enum-desc": "pending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped is for when CommitStatus is Skipped",
|
||||
"x-go-name": "State"
|
||||
},
|
||||
"target_url": {
|
||||
@@ -26811,7 +26811,7 @@
|
||||
"open",
|
||||
"closed"
|
||||
],
|
||||
"x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed",
|
||||
"x-go-enum-desc": "open StateOpen pr is opened\nclosed StateClosed pr is closed",
|
||||
"x-go-name": "State"
|
||||
},
|
||||
"time_estimate": {
|
||||
@@ -27169,19 +27169,19 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Context": {
|
||||
"description": "URL path for rendering issue, media and file links\nExpected format: /subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}\n\nin: body",
|
||||
"description": "URL path for rendering issue, media and file links\nExpected format: /subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}",
|
||||
"type": "string"
|
||||
},
|
||||
"Mode": {
|
||||
"description": "Mode to render (markdown, comment, wiki, file)\n\nin: body",
|
||||
"description": "Mode to render (markdown, comment, wiki, file)",
|
||||
"type": "string"
|
||||
},
|
||||
"Text": {
|
||||
"description": "Text markdown to render\n\nin: body",
|
||||
"description": "Text markdown to render",
|
||||
"type": "string"
|
||||
},
|
||||
"Wiki": {
|
||||
"description": "Is it a wiki page? (use mode=wiki instead)\n\nDeprecated: true\nin: body",
|
||||
"description": "Is it a wiki page? (use mode=wiki instead)\n\nDeprecated: true",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -27192,23 +27192,23 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Context": {
|
||||
"description": "URL path for rendering issue, media and file links\nExpected format: /subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}\n\nin: body",
|
||||
"description": "URL path for rendering issue, media and file links\nExpected format: /subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}",
|
||||
"type": "string"
|
||||
},
|
||||
"FilePath": {
|
||||
"description": "File path for detecting extension in file mode\n\nin: body",
|
||||
"description": "File path for detecting extension in file mode",
|
||||
"type": "string"
|
||||
},
|
||||
"Mode": {
|
||||
"description": "Mode to render (markdown, comment, wiki, file)\n\nin: body",
|
||||
"description": "Mode to render (markdown, comment, wiki, file)",
|
||||
"type": "string"
|
||||
},
|
||||
"Text": {
|
||||
"description": "Text markup to render\n\nin: body",
|
||||
"description": "Text markup to render",
|
||||
"type": "string"
|
||||
},
|
||||
"Wiki": {
|
||||
"description": "Is it a wiki page? (use mode=wiki instead)\n\nDeprecated: true\nin: body",
|
||||
"description": "Is it a wiki page? (use mode=wiki instead)\n\nDeprecated: true",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -27445,13 +27445,13 @@
|
||||
"x-go-name": "OpenIssues"
|
||||
},
|
||||
"state": {
|
||||
"description": "State indicates if the milestone is open or closed\nopen StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed",
|
||||
"description": "State indicates if the milestone is open or closed\nopen StateOpen pr is opened\nclosed StateClosed pr is closed",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"closed"
|
||||
],
|
||||
"x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed",
|
||||
"x-go-enum-desc": "open StateOpen pr is opened\nclosed StateClosed pr is closed",
|
||||
"x-go-name": "State"
|
||||
},
|
||||
"title": {
|
||||
@@ -27666,14 +27666,14 @@
|
||||
"x-go-name": "LatestCommentURL"
|
||||
},
|
||||
"state": {
|
||||
"description": "State indicates the current state of the notification subject\nopen NotifySubjectStateOpen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged NotifySubjectStateMerged is a merged pull request",
|
||||
"description": "State indicates the current state of the notification subject\nopen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged is a merged pull request",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"closed",
|
||||
"merged"
|
||||
],
|
||||
"x-go-enum-desc": "open NotifySubjectStateOpen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged NotifySubjectStateMerged is a merged pull request",
|
||||
"x-go-enum-desc": "open NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged is a merged pull request",
|
||||
"x-go-name": "State"
|
||||
},
|
||||
"title": {
|
||||
@@ -27682,7 +27682,7 @@
|
||||
"x-go-name": "Title"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type indicates the type of the notification subject\nIssue NotifySubjectIssue NotifySubjectIssue a issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull a pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit a commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository a repository is subject of an notification",
|
||||
"description": "Type indicates the type of the notification subject\nIssue NotifySubjectIssue a issue is subject of an notification\nPull NotifySubjectPull a pull is subject of an notification\nCommit NotifySubjectCommit a commit is subject of an notification\nRepository NotifySubjectRepository a repository is subject of an notification",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Issue",
|
||||
@@ -27690,7 +27690,7 @@
|
||||
"Commit",
|
||||
"Repository"
|
||||
],
|
||||
"x-go-enum-desc": "Issue NotifySubjectIssue NotifySubjectIssue a issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull a pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit a commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository a repository is subject of an notification",
|
||||
"x-go-enum-desc": "Issue NotifySubjectIssue a issue is subject of an notification\nPull NotifySubjectPull a pull is subject of an notification\nCommit NotifySubjectCommit a commit is subject of an notification\nRepository NotifySubjectRepository a repository is subject of an notification",
|
||||
"x-go-name": "Type"
|
||||
},
|
||||
"url": {
|
||||
@@ -28446,13 +28446,13 @@
|
||||
"x-go-name": "ReviewComments"
|
||||
},
|
||||
"state": {
|
||||
"description": "The current state of the pull request\nopen StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed",
|
||||
"description": "The current state of the pull request\nopen StateOpen pr is opened\nclosed StateClosed pr is closed",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"closed"
|
||||
],
|
||||
"x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed",
|
||||
"x-go-enum-desc": "open StateOpen pr is opened\nclosed StateClosed pr is closed",
|
||||
"x-go-name": "State"
|
||||
},
|
||||
"title": {
|
||||
@@ -28553,7 +28553,7 @@
|
||||
"REQUEST_CHANGES",
|
||||
"REQUEST_REVIEW"
|
||||
],
|
||||
"x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user",
|
||||
"x-go-enum-desc": "APPROVED ReviewStateApproved pr is approved\nPENDING ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview review is requested from user",
|
||||
"x-go-name": "State"
|
||||
},
|
||||
"submitted_at": {
|
||||
@@ -29448,7 +29448,7 @@
|
||||
"REQUEST_CHANGES",
|
||||
"REQUEST_REVIEW"
|
||||
],
|
||||
"x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user",
|
||||
"x-go-enum-desc": "APPROVED ReviewStateApproved pr is approved\nPENDING ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview review is requested from user",
|
||||
"x-go-name": "Event"
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+18
-18
@@ -3179,7 +3179,7 @@
|
||||
"$ref": "#/components/schemas/CommitStatusState"
|
||||
}
|
||||
],
|
||||
"description": "State is the overall combined status state\npending CommitStatusPending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped CommitStatusSkipped is for when CommitStatus is Skipped"
|
||||
"description": "State is the overall combined status state\npending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped is for when CommitStatus is Skipped"
|
||||
},
|
||||
"statuses": {
|
||||
"description": "Statuses contains all individual commit statuses",
|
||||
@@ -3445,7 +3445,7 @@
|
||||
"$ref": "#/components/schemas/CommitStatusState"
|
||||
}
|
||||
],
|
||||
"description": "State represents the status state (pending, success, error, failure)\npending CommitStatusPending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped CommitStatusSkipped is for when CommitStatus is Skipped"
|
||||
"description": "State represents the status state (pending, success, error, failure)\npending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped is for when CommitStatus is Skipped"
|
||||
},
|
||||
"target_url": {
|
||||
"description": "TargetURL is the URL to link to for more details",
|
||||
@@ -4673,7 +4673,7 @@
|
||||
"$ref": "#/components/schemas/CommitStatusState"
|
||||
}
|
||||
],
|
||||
"description": "State represents the status state to set (pending, success, error, failure)\npending CommitStatusPending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped CommitStatusSkipped is for when CommitStatus is Skipped"
|
||||
"description": "State represents the status state to set (pending, success, error, failure)\npending CommitStatusPending is for when the CommitStatus is Pending\nsuccess CommitStatusSuccess is for when the CommitStatus is Success\nerror CommitStatusError is for when the CommitStatus is Error\nfailure CommitStatusFailure is for when the CommitStatus is Failure\nwarning CommitStatusWarning is for when the CommitStatus is Warning\nskipped CommitStatusSkipped is for when CommitStatus is Skipped"
|
||||
},
|
||||
"target_url": {
|
||||
"description": "TargetURL is the URL to link to for more details",
|
||||
@@ -7326,20 +7326,20 @@
|
||||
"description": "MarkdownOption markdown options",
|
||||
"properties": {
|
||||
"Context": {
|
||||
"description": "URL path for rendering issue, media and file links\nExpected format: /subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}\n\nin: body",
|
||||
"description": "URL path for rendering issue, media and file links\nExpected format: /subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}",
|
||||
"type": "string"
|
||||
},
|
||||
"Mode": {
|
||||
"description": "Mode to render (markdown, comment, wiki, file)\n\nin: body",
|
||||
"description": "Mode to render (markdown, comment, wiki, file)",
|
||||
"type": "string"
|
||||
},
|
||||
"Text": {
|
||||
"description": "Text markdown to render\n\nin: body",
|
||||
"description": "Text markdown to render",
|
||||
"type": "string"
|
||||
},
|
||||
"Wiki": {
|
||||
"deprecated": true,
|
||||
"description": "Is it a wiki page? (use mode=wiki instead)\n\nDeprecated: true\nin: body",
|
||||
"description": "Is it a wiki page? (use mode=wiki instead)\n\nDeprecated: true",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -7350,24 +7350,24 @@
|
||||
"description": "MarkupOption markup options",
|
||||
"properties": {
|
||||
"Context": {
|
||||
"description": "URL path for rendering issue, media and file links\nExpected format: /subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}\n\nin: body",
|
||||
"description": "URL path for rendering issue, media and file links\nExpected format: /subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}",
|
||||
"type": "string"
|
||||
},
|
||||
"FilePath": {
|
||||
"description": "File path for detecting extension in file mode\n\nin: body",
|
||||
"description": "File path for detecting extension in file mode",
|
||||
"type": "string"
|
||||
},
|
||||
"Mode": {
|
||||
"description": "Mode to render (markdown, comment, wiki, file)\n\nin: body",
|
||||
"description": "Mode to render (markdown, comment, wiki, file)",
|
||||
"type": "string"
|
||||
},
|
||||
"Text": {
|
||||
"description": "Text markup to render\n\nin: body",
|
||||
"description": "Text markup to render",
|
||||
"type": "string"
|
||||
},
|
||||
"Wiki": {
|
||||
"deprecated": true,
|
||||
"description": "Is it a wiki page? (use mode=wiki instead)\n\nDeprecated: true\nin: body",
|
||||
"description": "Is it a wiki page? (use mode=wiki instead)\n\nDeprecated: true",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -7610,7 +7610,7 @@
|
||||
"$ref": "#/components/schemas/StateType"
|
||||
}
|
||||
],
|
||||
"description": "State indicates if the milestone is open or closed\nopen StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed"
|
||||
"description": "State indicates if the milestone is open or closed\nopen StateOpen pr is opened\nclosed StateClosed pr is closed"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title is the title of the milestone",
|
||||
@@ -7827,14 +7827,14 @@
|
||||
"x-go-name": "LatestCommentURL"
|
||||
},
|
||||
"state": {
|
||||
"description": "State indicates the current state of the notification subject\nopen NotifySubjectStateOpen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged NotifySubjectStateMerged is a merged pull request",
|
||||
"description": "State indicates the current state of the notification subject\nopen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged is a merged pull request",
|
||||
"enum": [
|
||||
"open",
|
||||
"closed",
|
||||
"merged"
|
||||
],
|
||||
"type": "string",
|
||||
"x-go-enum-desc": "open NotifySubjectStateOpen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged NotifySubjectStateMerged is a merged pull request",
|
||||
"x-go-enum-desc": "open NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged is a merged pull request",
|
||||
"x-go-name": "State"
|
||||
},
|
||||
"title": {
|
||||
@@ -7843,7 +7843,7 @@
|
||||
"x-go-name": "Title"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type indicates the type of the notification subject\nIssue NotifySubjectIssue NotifySubjectIssue a issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull a pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit a commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository a repository is subject of an notification",
|
||||
"description": "Type indicates the type of the notification subject\nIssue NotifySubjectIssue a issue is subject of an notification\nPull NotifySubjectPull a pull is subject of an notification\nCommit NotifySubjectCommit a commit is subject of an notification\nRepository NotifySubjectRepository a repository is subject of an notification",
|
||||
"enum": [
|
||||
"Issue",
|
||||
"Pull",
|
||||
@@ -7851,7 +7851,7 @@
|
||||
"Repository"
|
||||
],
|
||||
"type": "string",
|
||||
"x-go-enum-desc": "Issue NotifySubjectIssue NotifySubjectIssue a issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull a pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit a commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository a repository is subject of an notification",
|
||||
"x-go-enum-desc": "Issue NotifySubjectIssue a issue is subject of an notification\nPull NotifySubjectPull a pull is subject of an notification\nCommit NotifySubjectCommit a commit is subject of an notification\nRepository NotifySubjectRepository a repository is subject of an notification",
|
||||
"x-go-name": "Type"
|
||||
},
|
||||
"url": {
|
||||
@@ -8626,7 +8626,7 @@
|
||||
"$ref": "#/components/schemas/StateType"
|
||||
}
|
||||
],
|
||||
"description": "The current state of the pull request\nopen StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed"
|
||||
"description": "The current state of the pull request\nopen StateOpen pr is opened\nclosed StateClosed pr is closed"
|
||||
},
|
||||
"title": {
|
||||
"description": "The title of the pull request",
|
||||
|
||||
@@ -89,10 +89,10 @@
|
||||
{{$repo := .Repo}}
|
||||
<div class="tw-flex tw-flex-col tw-gap-1">
|
||||
{{range $pushCommit := $push.Commits}}
|
||||
{{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}}
|
||||
{{$commitLink := printf "%s/commit/%s" $repoLink $pushCommit.Sha1}}
|
||||
<div class="flex-text-block">
|
||||
<img loading="lazy" alt class="ui avatar" src="{{$push.AvatarLink ctx .AuthorEmail}}" title="{{.AuthorName}}" width="16" height="16">
|
||||
<a class="ui sha label" href="{{$commitLink}}">{{ShortSha .Sha1}}</a>
|
||||
{{ctx.RenderUtils.AvatarStackPushCommit $pushCommit}}
|
||||
<a class="ui sha label" href="{{$commitLink}}">{{ShortSha $pushCommit.Sha1}}</a>
|
||||
<span class="tw-inline-block tw-truncate">
|
||||
{{ctx.RenderUtils.RenderCommitMessage $pushCommit.Message $repo}}
|
||||
</span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user