Compare commits

...

11 Commits

Author SHA1 Message Date
GiteaBot
1698c15cba [skip ci] Updated translations via Crowdin 2023-05-20 00:22:02 +00:00
silverwind
bbc1456542
Update JS dependencies (#24815)
- Update all JS dependencies
- Remove `@vue/compiler-sfc` as per [this
notice](https://github.com/vuejs/core/tree/main/packages/compiler-sfc#vuecompiler-sfc),
still builds as normal
- Tested build and text/image copy
2023-05-19 21:58:12 +00:00
silverwind
3288252dda
Fix duplicate tooltip hiding (#24814)
A tippy instance's role is actually on `props.role`. This makes
duplicate tooltip hiding work again after
https://github.com/go-gitea/gitea/pull/24688.
2023-05-19 20:12:30 +00:00
Yarden Shoham
c641a22f2a
Mute repo names in dashboard repo list (#24811)
# Before

![image](https://github.com/go-gitea/gitea/assets/20454870/24b80212-4a4d-44a7-99d5-a8c6b207225e)

# After

![image](https://github.com/go-gitea/gitea/assets/20454870/565b242a-f65d-450c-b43b-c4539a0f8b28)

Signed-off-by: Yarden Shoham <git@yardenshoham.com>
Co-authored-by: Giteabot <teabot@gitea.io>
2023-05-19 17:03:09 +00:00
silverwind
a103b79f60
Rework label colors (#24790)
Introduce `--color-label-fg`, `--color-label-bg` and
`--color-label-hover-bg`, decoupling the label styles from other color
variables. I've set the colors so that non-interactive labels like on
tabs are dark-on-light on light theme, which imho looks better than
previous light-on-dark.

In the screenshot below, the leftmost label has hover, the second one
has active.

<img width="786" alt="Screenshot 2023-05-18 at 12 48 26"
src="https://github.com/go-gitea/gitea/assets/115237/d989bb68-504a-4406-b5f6-419ed9609f90">
<img width="789" alt="Screenshot 2023-05-18 at 13 04 07"
src="https://github.com/go-gitea/gitea/assets/115237/689a281a-a2b7-45e8-a5ee-dafb7a35e105">

---------

Co-authored-by: Giteabot <teabot@gitea.io>
2023-05-19 16:30:24 +00:00
HesterG
acde12a8a2
Fix max width and margin of comment box on conversation page (#24809)
Fix regression from #23937

The changes should only be limited to `.conversation-holder
.comment-code-cloud`, otherwise it will affect the `.comment-code-cloud`
in conversation tab

Before:

<img width="962" alt="Screen Shot 2023-05-19 at 18 22 25"
src="https://github.com/go-gitea/gitea/assets/17645053/0db01d04-2581-48f9-b46c-497836b1f12b">

After:

<img width="997" alt="Screen Shot 2023-05-19 at 18 35 01"
src="https://github.com/go-gitea/gitea/assets/17645053/5d14b67b-88c1-46c6-b859-fd41752b3ebb">

---------

Co-authored-by: Giteabot <teabot@gitea.io>
2023-05-19 16:02:34 +00:00
Yarden Shoham
f5ce2ed292
Allow all URL schemes in Markdown links by default (#24805)
- Closes #21146
- Closes #16721

## ⚠️ BREAKING ⚠️
This changes the default behavior to now create links for any URL scheme
when the user uses the markdown form for links (`[label](URL)`), this
doesn't affect the rendering of inline links. To opt-out set the
`markdown.CUSTOM_URL_SCHEMES` setting to a list of allowed schemes, all
other schemes (except `http` and `https`) won't be allowed.

# Before

![image](https://github.com/go-gitea/gitea/assets/20454870/35fa18ce-7dda-4995-b5b3-3f360f38296d)

# After

![image](https://github.com/go-gitea/gitea/assets/20454870/0922216b-0b35-4b77-9919-21a5c21dd5d0)

---------

Signed-off-by: Yarden Shoham <git@yardenshoham.com>
Co-authored-by: Giteabot <teabot@gitea.io>
2023-05-19 17:17:07 +02:00
Lunny Xiao
38cf43d060
Some refactors for issues stats (#24793)
This PR

- [x] Move some functions from `issues.go` to `issue_stats.go` and
`issue_label.go`
- [x] Remove duplicated issue options `UserIssueStatsOption` to keep
only one `IssuesOptions`
2023-05-19 22:17:48 +08:00
FuXiaoHei
c757765a9e
Implement actions artifacts (#22738)
Implement action artifacts server api.

This change is used for supporting
https://github.com/actions/upload-artifact and
https://github.com/actions/download-artifact in gitea actions. It can
run sample workflow from doc
https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts.
The api design is inspired by
https://github.com/nektos/act/blob/master/pkg/artifacts/server.go and
includes some changes from gitea internal structs and methods.

Actions artifacts contains two parts:

- Gitea server api and storage (this pr implement basic design without
some complex cases supports)
- Runner communicate with gitea server api (in comming)

Old pr https://github.com/go-gitea/gitea/pull/22345 is outdated after
actions merged. I create new pr from main branch.


![897f7694-3e0f-4f7c-bb4b-9936624ead45](https://user-images.githubusercontent.com/2142787/219382371-eb3cf810-e4e0-456b-a8ff-aecc2b1a1032.jpeg)

Add artifacts list in actions workflow page.
2023-05-19 21:37:57 +08:00
Jason Song
7985cde84d
Fix Actions being enabled accidentally (#24802)
Regression of #24536. If the user doesn't explicitly disable Actions, it
will be enabled.

1. Gitea will call `loadRepositoryFrom` before `loadActionsFrom`.

25d4f95df2/modules/setting/setting.go (L234-L237)
2. In `loadRepositoryFrom`,
`rootCfg.Section("actions").Key("ENABLED").MustBool(true)` will set
`actions.ENABLED` with `true`.

25d4f95df2/modules/setting/repository.go (L313-L315)
3. In `loadActionsFrom`, `rootCfg.Section("actions")` will get a section
with Actions enabled.

25d4f95df2/modules/setting/actions.go (L23-L26)

Although the cause of the problem was using `true` by copy-paste
mistake, it also surprised me that
**`rootCfg.Section("actions").Key("ENABLED").MustBool(true)` doesn't
only read, but also write.**
2023-05-19 19:35:12 +08:00
Zettat123
3eecde3f33
Change add_on in keys_ssh.tmpl (#24803)
Follow #24562
2023-05-19 11:20:18 +02:00
53 changed files with 2842 additions and 1319 deletions

View File

@ -1327,6 +1327,7 @@ ROUTER = console
;; Comma separated list of custom URL-Schemes that are allowed as links when rendering Markdown
;; for example git,magnet,ftp (more at https://en.wikipedia.org/wiki/List_of_URI_schemes)
;; URLs starting with http and https are always displayed, whatever is put in this entry.
;; If this entry is empty, all URL schemes are allowed.
;CUSTOM_URL_SCHEMES =
;;
;; List of file extensions that should be rendered/edited as Markdown

View File

@ -276,7 +276,7 @@ The following configuration set `Content-Type: application/vnd.android.package-a
trailing whitespace to paragraphs is not necessary to force a line break.
- `CUSTOM_URL_SCHEMES`: Use a comma separated list (ftp,git,svn) to indicate additional
URL hyperlinks to be rendered in Markdown. URLs beginning in http and https are
always displayed
always displayed. If this entry is empty, all URL schemes are allowed
- `FILE_EXTENSIONS`: **.md,.markdown,.mdown,.mkd,.livemd**: List of file extensions that should be rendered/edited as Markdown. Separate the extensions with a comma. To render files without any extension as markdown, just put a comma.
- `ENABLE_MATH`: **true**: Enables detection of `\(...\)`, `\[...\]`, `$...$` and `$$...$$` blocks as math blocks.

5
go.mod
View File

@ -76,7 +76,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.16
github.com/meilisearch/meilisearch-go v0.24.0
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.23
github.com/microcosm-cc/bluemonday v1.0.24
github.com/minio/minio-go/v7 v7.0.52
github.com/minio/sha256-simd v1.0.0
github.com/msteinert/pam v1.1.0
@ -109,7 +109,7 @@ require (
github.com/yuin/goldmark-meta v1.1.0
golang.org/x/crypto v0.8.0
golang.org/x/image v0.7.0
golang.org/x/net v0.9.0
golang.org/x/net v0.10.0
golang.org/x/oauth2 v0.7.0
golang.org/x/sys v0.8.0
golang.org/x/text v0.9.0
@ -288,7 +288,6 @@ require (
go.uber.org/zap v1.24.0 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/sync v0.2.0 // indirect
golang.org/x/term v0.8.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4 // indirect

8
go.sum
View File

@ -876,8 +876,8 @@ github.com/mholt/acmez v1.1.0 h1:IQ9CGHKOHokorxnffsqDvmmE30mDenO1lptYZ1AYkHY=
github.com/mholt/acmez v1.1.0/go.mod h1:zwo5+fbLLTowAX8o8ETfQzbDtwGEXnPhkmGdKIP+bgs=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY=
github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4=
github.com/microcosm-cc/bluemonday v1.0.24 h1:NGQoPtwGVcbGkKfvyYk1yRqknzBuoMiUrO6R7uFTPlw=
github.com/microcosm-cc/bluemonday v1.0.24/go.mod h1:ArQySAMps0790cHSkdPEJ7bGkF2VePWH773hsJNSHf8=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.54 h1:5jon9mWcb0sFJGpnI99tOMhCPyJ+RPVz5b63MQG0VWI=
github.com/miekg/dns v1.1.54/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
@ -1418,8 +1418,9 @@ golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -1543,7 +1544,6 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

122
models/actions/artifact.go Normal file
View File

@ -0,0 +1,122 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// This artifact server is inspired by https://github.com/nektos/act/blob/master/pkg/artifacts/server.go.
// It updates url setting and uses ObjectStore to handle artifacts persistence.
package actions
import (
"context"
"errors"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
)
const (
// ArtifactStatusUploadPending is the status of an artifact upload that is pending
ArtifactStatusUploadPending = 1
// ArtifactStatusUploadConfirmed is the status of an artifact upload that is confirmed
ArtifactStatusUploadConfirmed = 2
// ArtifactStatusUploadError is the status of an artifact upload that is errored
ArtifactStatusUploadError = 3
)
func init() {
db.RegisterModel(new(ActionArtifact))
}
// ActionArtifact is a file that is stored in the artifact storage.
type ActionArtifact struct {
ID int64 `xorm:"pk autoincr"`
RunID int64 `xorm:"index UNIQUE(runid_name)"` // The run id of the artifact
RunnerID int64
RepoID int64 `xorm:"index"`
OwnerID int64
CommitSHA string
StoragePath string // The path to the artifact in the storage
FileSize int64 // The size of the artifact in bytes
FileCompressedSize int64 // The size of the artifact in bytes after gzip compression
ContentEncoding string // The content encoding of the artifact
ArtifactPath string // The path to the artifact when runner uploads it
ArtifactName string `xorm:"UNIQUE(runid_name)"` // The name of the artifact when runner uploads it
Status int64 `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
}
// CreateArtifact create a new artifact with task info or get same named artifact in the same run
func CreateArtifact(ctx context.Context, t *ActionTask, artifactName string) (*ActionArtifact, error) {
if err := t.LoadJob(ctx); err != nil {
return nil, err
}
artifact, err := getArtifactByArtifactName(ctx, t.Job.RunID, artifactName)
if errors.Is(err, util.ErrNotExist) {
artifact := &ActionArtifact{
RunID: t.Job.RunID,
RunnerID: t.RunnerID,
RepoID: t.RepoID,
OwnerID: t.OwnerID,
CommitSHA: t.CommitSHA,
Status: ArtifactStatusUploadPending,
}
if _, err := db.GetEngine(ctx).Insert(artifact); err != nil {
return nil, err
}
return artifact, nil
} else if err != nil {
return nil, err
}
return artifact, nil
}
func getArtifactByArtifactName(ctx context.Context, runID int64, name string) (*ActionArtifact, error) {
var art ActionArtifact
has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ?", runID, name).Get(&art)
if err != nil {
return nil, err
} else if !has {
return nil, util.ErrNotExist
}
return &art, nil
}
// GetArtifactByID returns an artifact by id
func GetArtifactByID(ctx context.Context, id int64) (*ActionArtifact, error) {
var art ActionArtifact
has, err := db.GetEngine(ctx).ID(id).Get(&art)
if err != nil {
return nil, err
} else if !has {
return nil, util.ErrNotExist
}
return &art, nil
}
// UpdateArtifactByID updates an artifact by id
func UpdateArtifactByID(ctx context.Context, id int64, art *ActionArtifact) error {
art.ID = id
_, err := db.GetEngine(ctx).ID(id).AllCols().Update(art)
return err
}
// ListArtifactsByRunID returns all artifacts of a run
func ListArtifactsByRunID(ctx context.Context, runID int64) ([]*ActionArtifact, error) {
arts := make([]*ActionArtifact, 0, 10)
return arts, db.GetEngine(ctx).Where("run_id=?", runID).Find(&arts)
}
// ListUploadedArtifactsByRunID returns all uploaded artifacts of a run
func ListUploadedArtifactsByRunID(ctx context.Context, runID int64) ([]*ActionArtifact, error) {
arts := make([]*ActionArtifact, 0, 10)
return arts, db.GetEngine(ctx).Where("run_id=? AND status=?", runID, ArtifactStatusUploadConfirmed).Find(&arts)
}
// ListArtifactsByRepoID returns all artifacts of a repo
func ListArtifactsByRepoID(ctx context.Context, repoID int64) ([]*ActionArtifact, error) {
arts := make([]*ActionArtifact, 0, 10)
return arts, db.GetEngine(ctx).Where("repo_id=?", repoID).Find(&arts)
}

View File

@ -0,0 +1,19 @@
-
id: 791
title: "update actions"
repo_id: 4
owner_id: 1
workflow_id: "artifact.yaml"
index: 187
trigger_user_id: 1
ref: "refs/heads/master"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
is_fork_pull_request: 0
status: 1
started: 1683636528
stopped: 1683636626
created: 1683636108
updated: 1683636626
need_approval: 0
approved_by: 0

View File

@ -0,0 +1,14 @@
-
id: 192
run_id: 791
repo_id: 4
owner_id: 1
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
name: job_2
attempt: 1
job_id: job_2
task_id: 47
status: 1
started: 1683636528
stopped: 1683636626

View File

@ -0,0 +1,20 @@
-
id: 47
job_id: 192
attempt: 3
runner_id: 1
status: 6 # 6 is the status code for "running", running task can upload artifacts
started: 1683636528
stopped: 1683636626
repo_id: 4
owner_id: 1
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
token_hash: 6d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4260c64a69a2cc1508825121b7b8394e48e00b1bf8718b2a867e
token_salt: jVuKnSPGgy
token_last_eight: eeb1a71a
log_filename: artifact-test2/2f/47.log
log_in_storage: 1
log_length: 707
log_size: 90179
log_expired: 0

View File

@ -8,10 +8,8 @@ import (
"context"
"fmt"
"regexp"
"sort"
"code.gitea.io/gitea/models/db"
access_model "code.gitea.io/gitea/models/perm/access"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
@ -212,17 +210,6 @@ func (issue *Issue) GetPullRequest() (pr *PullRequest, err error) {
return pr, err
}
// LoadLabels loads labels
func (issue *Issue) LoadLabels(ctx context.Context) (err error) {
if issue.Labels == nil && issue.ID != 0 {
issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID)
if err != nil {
return fmt.Errorf("getLabelsByIssueID [%d]: %w", issue.ID, err)
}
}
return nil
}
// LoadPoster loads poster
func (issue *Issue) LoadPoster(ctx context.Context) (err error) {
if issue.Poster == nil && issue.PosterID != 0 {
@ -459,175 +446,6 @@ func (issue *Issue) IsPoster(uid int64) bool {
return issue.OriginalAuthorID == 0 && issue.PosterID == uid
}
func (issue *Issue) getLabels(ctx context.Context) (err error) {
if len(issue.Labels) > 0 {
return nil
}
issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID)
if err != nil {
return fmt.Errorf("getLabelsByIssueID: %w", err)
}
return nil
}
func clearIssueLabels(ctx context.Context, issue *Issue, doer *user_model.User) (err error) {
if err = issue.getLabels(ctx); err != nil {
return fmt.Errorf("getLabels: %w", err)
}
for i := range issue.Labels {
if err = deleteIssueLabel(ctx, issue, issue.Labels[i], doer); err != nil {
return fmt.Errorf("removeLabel: %w", err)
}
}
return nil
}
// ClearIssueLabels removes all issue labels as the given user.
// Triggers appropriate WebHooks, if any.
func ClearIssueLabels(issue *Issue, doer *user_model.User) (err error) {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if err := issue.LoadRepo(ctx); err != nil {
return err
} else if err = issue.LoadPullRequest(ctx); err != nil {
return err
}
perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
if err != nil {
return err
}
if !perm.CanWriteIssuesOrPulls(issue.IsPull) {
return ErrRepoLabelNotExist{}
}
if err = clearIssueLabels(ctx, issue, doer); err != nil {
return err
}
if err = committer.Commit(); err != nil {
return fmt.Errorf("Commit: %w", err)
}
return nil
}
type labelSorter []*Label
func (ts labelSorter) Len() int {
return len([]*Label(ts))
}
func (ts labelSorter) Less(i, j int) bool {
return []*Label(ts)[i].ID < []*Label(ts)[j].ID
}
func (ts labelSorter) Swap(i, j int) {
[]*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i]
}
// Ensure only one label of a given scope exists, with labels at the end of the
// array getting preference over earlier ones.
func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label {
validLabels := make([]*Label, 0, len(labels))
for i, label := range labels {
scope := label.ExclusiveScope()
if scope != "" {
foundOther := false
for _, otherLabel := range labels[i+1:] {
if otherLabel.ExclusiveScope() == scope {
foundOther = true
break
}
}
if foundOther {
continue
}
}
validLabels = append(validLabels, label)
}
return validLabels
}
// ReplaceIssueLabels removes all current labels and add new labels to the issue.
// Triggers appropriate WebHooks, if any.
func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if err = issue.LoadRepo(ctx); err != nil {
return err
}
if err = issue.LoadLabels(ctx); err != nil {
return err
}
labels = RemoveDuplicateExclusiveLabels(labels)
sort.Sort(labelSorter(labels))
sort.Sort(labelSorter(issue.Labels))
var toAdd, toRemove []*Label
addIndex, removeIndex := 0, 0
for addIndex < len(labels) && removeIndex < len(issue.Labels) {
addLabel := labels[addIndex]
removeLabel := issue.Labels[removeIndex]
if addLabel.ID == removeLabel.ID {
// Silently drop invalid labels
if removeLabel.RepoID != issue.RepoID && removeLabel.OrgID != issue.Repo.OwnerID {
toRemove = append(toRemove, removeLabel)
}
addIndex++
removeIndex++
} else if addLabel.ID < removeLabel.ID {
// Only add if the label is valid
if addLabel.RepoID == issue.RepoID || addLabel.OrgID == issue.Repo.OwnerID {
toAdd = append(toAdd, addLabel)
}
addIndex++
} else {
toRemove = append(toRemove, removeLabel)
removeIndex++
}
}
toAdd = append(toAdd, labels[addIndex:]...)
toRemove = append(toRemove, issue.Labels[removeIndex:]...)
if len(toAdd) > 0 {
if err = newIssueLabels(ctx, issue, toAdd, doer); err != nil {
return fmt.Errorf("addLabels: %w", err)
}
}
for _, l := range toRemove {
if err = deleteIssueLabel(ctx, issue, l, doer); err != nil {
return fmt.Errorf("removeLabel: %w", err)
}
}
issue.Labels = nil
if err = issue.LoadLabels(ctx); err != nil {
return err
}
return committer.Commit()
}
// GetTasks returns the amount of tasks in the issues content
func (issue *Issue) GetTasks() int {
return len(issueTasksPat.FindAllStringIndex(issue.Content, -1))
@ -862,16 +680,6 @@ func (issue *Issue) GetExternalName() string { return issue.OriginalAuthor }
// GetExternalID ExternalUserRemappable interface
func (issue *Issue) GetExternalID() int64 { return issue.OriginalAuthorID }
// CountOrphanedIssues count issues without a repo
func CountOrphanedIssues(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).
Table("issue").
Join("LEFT", "repository", "issue.repo_id=repository.id").
Where(builder.IsNull{"repository.id"}).
Select("COUNT(`issue`.`id`)").
Count()
}
// HasOriginalAuthor returns if an issue was migrated and has an original author.
func (issue *Issue) HasOriginalAuthor() bool {
return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0

View File

@ -0,0 +1,490 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"sort"
"code.gitea.io/gitea/models/db"
access_model "code.gitea.io/gitea/models/perm/access"
user_model "code.gitea.io/gitea/models/user"
"xorm.io/builder"
)
// IssueLabel represents an issue-label relation.
type IssueLabel struct {
ID int64 `xorm:"pk autoincr"`
IssueID int64 `xorm:"UNIQUE(s)"`
LabelID int64 `xorm:"UNIQUE(s)"`
}
// HasIssueLabel returns true if issue has been labeled.
func HasIssueLabel(ctx context.Context, issueID, labelID int64) bool {
has, _ := db.GetEngine(ctx).Where("issue_id = ? AND label_id = ?", issueID, labelID).Get(new(IssueLabel))
return has
}
// newIssueLabel this function creates a new label it does not check if the label is valid for the issue
// YOU MUST CHECK THIS BEFORE THIS FUNCTION
func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
if err = db.Insert(ctx, &IssueLabel{
IssueID: issue.ID,
LabelID: label.ID,
}); err != nil {
return err
}
if err = issue.LoadRepo(ctx); err != nil {
return
}
opts := &CreateCommentOptions{
Type: CommentTypeLabel,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
Label: label,
Content: "1",
}
if _, err = CreateComment(ctx, opts); err != nil {
return err
}
return updateLabelCols(ctx, label, "num_issues", "num_closed_issue")
}
// Remove all issue labels in the given exclusive scope
func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
scope := label.ExclusiveScope()
if scope == "" {
return nil
}
var toRemove []*Label
for _, issueLabel := range issue.Labels {
if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope {
toRemove = append(toRemove, issueLabel)
}
}
for _, issueLabel := range toRemove {
if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil {
return err
}
}
return nil
}
// NewIssueLabel creates a new issue-label relation.
func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) {
if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) {
return nil
}
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if err = issue.LoadRepo(ctx); err != nil {
return err
}
// Do NOT add invalid labels
if issue.RepoID != label.RepoID && issue.Repo.OwnerID != label.OrgID {
return nil
}
if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil {
return nil
}
if err = newIssueLabel(ctx, issue, label, doer); err != nil {
return err
}
issue.Labels = nil
if err = issue.LoadLabels(ctx); err != nil {
return err
}
return committer.Commit()
}
// newIssueLabels add labels to an issue. It will check if the labels are valid for the issue
func newIssueLabels(ctx context.Context, issue *Issue, labels []*Label, doer *user_model.User) (err error) {
if err = issue.LoadRepo(ctx); err != nil {
return err
}
for _, l := range labels {
// Don't add already present labels and invalid labels
if HasIssueLabel(ctx, issue.ID, l.ID) ||
(l.RepoID != issue.RepoID && l.OrgID != issue.Repo.OwnerID) {
continue
}
if err = newIssueLabel(ctx, issue, l, doer); err != nil {
return fmt.Errorf("newIssueLabel: %w", err)
}
}
return nil
}
// NewIssueLabels creates a list of issue-label relations.
func NewIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if err = newIssueLabels(ctx, issue, labels, doer); err != nil {
return err
}
issue.Labels = nil
if err = issue.LoadLabels(ctx); err != nil {
return err
}
return committer.Commit()
}
func deleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
if count, err := db.DeleteByBean(ctx, &IssueLabel{
IssueID: issue.ID,
LabelID: label.ID,
}); err != nil {
return err
} else if count == 0 {
return nil
}
if err = issue.LoadRepo(ctx); err != nil {
return
}
opts := &CreateCommentOptions{
Type: CommentTypeLabel,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
Label: label,
}
if _, err = CreateComment(ctx, opts); err != nil {
return err
}
return updateLabelCols(ctx, label, "num_issues", "num_closed_issue")
}
// DeleteIssueLabel deletes issue-label relation.
func DeleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) error {
if err := deleteIssueLabel(ctx, issue, label, doer); err != nil {
return err
}
issue.Labels = nil
return issue.LoadLabels(ctx)
}
// DeleteLabelsByRepoID deletes labels of some repository
func DeleteLabelsByRepoID(ctx context.Context, repoID int64) error {
deleteCond := builder.Select("id").From("label").Where(builder.Eq{"label.repo_id": repoID})
if _, err := db.GetEngine(ctx).In("label_id", deleteCond).
Delete(&IssueLabel{}); err != nil {
return err
}
_, err := db.DeleteByBean(ctx, &Label{RepoID: repoID})
return err
}
// CountOrphanedLabels return count of labels witch are broken and not accessible via ui anymore
func CountOrphanedLabels(ctx context.Context) (int64, error) {
noref, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Count()
if err != nil {
return 0, err
}
norepo, err := db.GetEngine(ctx).Table("label").
Where(builder.And(
builder.Gt{"repo_id": 0},
builder.NotIn("repo_id", builder.Select("id").From("`repository`")),
)).
Count()
if err != nil {
return 0, err
}
noorg, err := db.GetEngine(ctx).Table("label").
Where(builder.And(
builder.Gt{"org_id": 0},
builder.NotIn("org_id", builder.Select("id").From("`user`")),
)).
Count()
if err != nil {
return 0, err
}
return noref + norepo + noorg, nil
}
// DeleteOrphanedLabels delete labels witch are broken and not accessible via ui anymore
func DeleteOrphanedLabels(ctx context.Context) error {
// delete labels with no reference
if _, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Delete(new(Label)); err != nil {
return err
}
// delete labels with none existing repos
if _, err := db.GetEngine(ctx).
Where(builder.And(
builder.Gt{"repo_id": 0},
builder.NotIn("repo_id", builder.Select("id").From("`repository`")),
)).
Delete(Label{}); err != nil {
return err
}
// delete labels with none existing orgs
if _, err := db.GetEngine(ctx).
Where(builder.And(
builder.Gt{"org_id": 0},
builder.NotIn("org_id", builder.Select("id").From("`user`")),
)).
Delete(Label{}); err != nil {
return err
}
return nil
}
// CountOrphanedIssueLabels return count of IssueLabels witch have no label behind anymore
func CountOrphanedIssueLabels(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Table("issue_label").
NotIn("label_id", builder.Select("id").From("label")).
Count()
}
// DeleteOrphanedIssueLabels delete IssueLabels witch have no label behind anymore
func DeleteOrphanedIssueLabels(ctx context.Context) error {
_, err := db.GetEngine(ctx).
NotIn("label_id", builder.Select("id").From("label")).
Delete(IssueLabel{})
return err
}
// CountIssueLabelWithOutsideLabels count label comments with outside label
func CountIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Where(builder.Expr("(label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id)")).
Table("issue_label").
Join("inner", "label", "issue_label.label_id = label.id ").
Join("inner", "issue", "issue.id = issue_label.issue_id ").
Join("inner", "repository", "issue.repo_id = repository.id").
Count(new(IssueLabel))
}
// FixIssueLabelWithOutsideLabels fix label comments with outside label
func FixIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) {
res, err := db.GetEngine(ctx).Exec(`DELETE FROM issue_label WHERE issue_label.id IN (
SELECT il_too.id FROM (
SELECT il_too_too.id
FROM issue_label AS il_too_too
INNER JOIN label ON il_too_too.label_id = label.id
INNER JOIN issue on issue.id = il_too_too.issue_id
INNER JOIN repository on repository.id = issue.repo_id
WHERE
(label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id)
) AS il_too )`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// LoadLabels loads labels
func (issue *Issue) LoadLabels(ctx context.Context) (err error) {
if issue.Labels == nil && issue.ID != 0 {
issue.Labels, err = GetLabelsByIssueID(ctx, issue.ID)
if err != nil {
return fmt.Errorf("getLabelsByIssueID [%d]: %w", issue.ID, err)
}
}
return nil
}
// GetLabelsByIssueID returns all labels that belong to given issue by ID.
func GetLabelsByIssueID(ctx context.Context, issueID int64) ([]*Label, error) {
var labels []*Label
return labels, db.GetEngine(ctx).Where("issue_label.issue_id = ?", issueID).
Join("LEFT", "issue_label", "issue_label.label_id = label.id").
Asc("label.name").
Find(&labels)
}
func clearIssueLabels(ctx context.Context, issue *Issue, doer *user_model.User) (err error) {
if err = issue.LoadLabels(ctx); err != nil {
return fmt.Errorf("getLabels: %w", err)
}
for i := range issue.Labels {
if err = deleteIssueLabel(ctx, issue, issue.Labels[i], doer); err != nil {
return fmt.Errorf("removeLabel: %w", err)
}
}
return nil
}
// ClearIssueLabels removes all issue labels as the given user.
// Triggers appropriate WebHooks, if any.
func ClearIssueLabels(issue *Issue, doer *user_model.User) (err error) {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if err := issue.LoadRepo(ctx); err != nil {
return err
} else if err = issue.LoadPullRequest(ctx); err != nil {
return err
}
perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
if err != nil {
return err
}
if !perm.CanWriteIssuesOrPulls(issue.IsPull) {
return ErrRepoLabelNotExist{}
}
if err = clearIssueLabels(ctx, issue, doer); err != nil {
return err
}
if err = committer.Commit(); err != nil {
return fmt.Errorf("Commit: %w", err)
}
return nil
}
type labelSorter []*Label
func (ts labelSorter) Len() int {
return len([]*Label(ts))
}
func (ts labelSorter) Less(i, j int) bool {
return []*Label(ts)[i].ID < []*Label(ts)[j].ID
}
func (ts labelSorter) Swap(i, j int) {
[]*Label(ts)[i], []*Label(ts)[j] = []*Label(ts)[j], []*Label(ts)[i]
}
// Ensure only one label of a given scope exists, with labels at the end of the
// array getting preference over earlier ones.
func RemoveDuplicateExclusiveLabels(labels []*Label) []*Label {
validLabels := make([]*Label, 0, len(labels))
for i, label := range labels {
scope := label.ExclusiveScope()
if scope != "" {
foundOther := false
for _, otherLabel := range labels[i+1:] {
if otherLabel.ExclusiveScope() == scope {
foundOther = true
break
}
}
if foundOther {
continue
}
}
validLabels = append(validLabels, label)
}
return validLabels
}
// ReplaceIssueLabels removes all current labels and add new labels to the issue.
// Triggers appropriate WebHooks, if any.
func ReplaceIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if err = issue.LoadRepo(ctx); err != nil {
return err
}
if err = issue.LoadLabels(ctx); err != nil {
return err
}
labels = RemoveDuplicateExclusiveLabels(labels)
sort.Sort(labelSorter(labels))
sort.Sort(labelSorter(issue.Labels))
var toAdd, toRemove []*Label
addIndex, removeIndex := 0, 0
for addIndex < len(labels) && removeIndex < len(issue.Labels) {
addLabel := labels[addIndex]
removeLabel := issue.Labels[removeIndex]
if addLabel.ID == removeLabel.ID {
// Silently drop invalid labels
if removeLabel.RepoID != issue.RepoID && removeLabel.OrgID != issue.Repo.OwnerID {
toRemove = append(toRemove, removeLabel)
}
addIndex++
removeIndex++
} else if addLabel.ID < removeLabel.ID {
// Only add if the label is valid
if addLabel.RepoID == issue.RepoID || addLabel.OrgID == issue.Repo.OwnerID {
toAdd = append(toAdd, addLabel)
}
addIndex++
} else {
toRemove = append(toRemove, removeLabel)
removeIndex++
}
}
toAdd = append(toAdd, labels[addIndex:]...)
toRemove = append(toRemove, issue.Labels[removeIndex:]...)
if len(toAdd) > 0 {
if err = newIssueLabels(ctx, issue, toAdd, doer); err != nil {
return fmt.Errorf("addLabels: %w", err)
}
}
for _, l := range toRemove {
if err = deleteIssueLabel(ctx, issue, l, doer); err != nil {
return fmt.Errorf("removeLabel: %w", err)
}
}
issue.Labels = nil
if err = issue.LoadLabels(ctx); err != nil {
return err
}
return committer.Commit()
}

View File

@ -22,7 +22,7 @@ import (
// IssuesOptions represents options of an issue.
type IssuesOptions struct { //nolint
db.ListOptions
RepoID int64 // overwrites RepoCond if not 0
RepoIDs []int64 // overwrites RepoCond if the length is not 0
RepoCond builder.Cond
AssigneeID int64
PosterID int64
@ -155,17 +155,24 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) *xorm.Sess
return sess
}
func applyRepoConditions(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
if len(opts.RepoIDs) == 1 {
opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoIDs[0]}
} else if len(opts.RepoIDs) > 1 {
opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs)
}
if opts.RepoCond != nil {
sess.And(opts.RepoCond)
}
return sess
}
func applyConditions(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
if len(opts.IssueIDs) > 0 {
sess.In("issue.id", opts.IssueIDs)
}
if opts.RepoID != 0 {
opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoID}
}
if opts.RepoCond != nil {
sess.And(opts.RepoCond)
}
applyRepoConditions(sess, opts)
if !opts.IsClosed.IsNone() {
sess.And("issue.is_closed=?", opts.IsClosed.IsTrue())
@ -400,31 +407,6 @@ func applySubscribedCondition(sess *xorm.Session, subscriberID int64) *xorm.Sess
)
}
// CountIssuesByRepo map from repoID to number of issues matching the options
func CountIssuesByRepo(ctx context.Context, opts *IssuesOptions) (map[int64]int64, error) {
sess := db.GetEngine(ctx).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
applyConditions(sess, opts)
countsSlice := make([]*struct {
RepoID int64
Count int64
}, 0, 10)
if err := sess.GroupBy("issue.repo_id").
Select("issue.repo_id AS repo_id, COUNT(*) AS count").
Table("issue").
Find(&countsSlice); err != nil {
return nil, fmt.Errorf("unable to CountIssuesByRepo: %w", err)
}
countMap := make(map[int64]int64, len(countsSlice))
for _, c := range countsSlice {
countMap[c.RepoID] = c.Count
}
return countMap, nil
}
// GetRepoIDsForIssuesOptions find all repo ids for the given options
func GetRepoIDsForIssuesOptions(opts *IssuesOptions, user *user_model.User) ([]int64, error) {
repoIDs := make([]int64, 0, 5)
@ -453,351 +435,18 @@ func Issues(ctx context.Context, opts *IssuesOptions) ([]*Issue, error) {
applyConditions(sess, opts)
applySorts(sess, opts.SortType, opts.PriorityRepoID)
issues := make([]*Issue, 0, opts.ListOptions.PageSize)
issues := make(IssueList, 0, opts.ListOptions.PageSize)
if err := sess.Find(&issues); err != nil {
return nil, fmt.Errorf("unable to query Issues: %w", err)
}
if err := IssueList(issues).LoadAttributes(); err != nil {
if err := issues.LoadAttributes(); err != nil {
return nil, fmt.Errorf("unable to LoadAttributes for Issues: %w", err)
}
return issues, nil
}
// CountIssues number return of issues by given conditions.
func CountIssues(ctx context.Context, opts *IssuesOptions) (int64, error) {
sess := db.GetEngine(ctx).
Select("COUNT(issue.id) AS count").
Table("issue").
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
applyConditions(sess, opts)
return sess.Count()
}
// IssueStats represents issue statistic information.
type IssueStats struct {
OpenCount, ClosedCount int64
YourRepositoriesCount int64
AssignCount int64
CreateCount int64
MentionCount int64
ReviewRequestedCount int64
ReviewedCount int64
}
// Filter modes.
const (
FilterModeAll = iota
FilterModeAssign
FilterModeCreate
FilterModeMention
FilterModeReviewRequested
FilterModeReviewed
FilterModeYourRepositories
)
const (
// MaxQueryParameters represents the max query parameters
// When queries are broken down in parts because of the number
// of parameters, attempt to break by this amount
MaxQueryParameters = 300
)
// GetIssueStats returns issue statistic information by given conditions.
func GetIssueStats(opts *IssuesOptions) (*IssueStats, error) {
if len(opts.IssueIDs) <= MaxQueryParameters {
return getIssueStatsChunk(opts, opts.IssueIDs)
}
// If too long a list of IDs is provided, we get the statistics in
// smaller chunks and get accumulates. Note: this could potentially
// get us invalid results. The alternative is to insert the list of
// ids in a temporary table and join from them.
accum := &IssueStats{}
for i := 0; i < len(opts.IssueIDs); {
chunk := i + MaxQueryParameters
if chunk > len(opts.IssueIDs) {
chunk = len(opts.IssueIDs)
}
stats, err := getIssueStatsChunk(opts, opts.IssueIDs[i:chunk])
if err != nil {
return nil, err
}
accum.OpenCount += stats.OpenCount
accum.ClosedCount += stats.ClosedCount
accum.YourRepositoriesCount += stats.YourRepositoriesCount
accum.AssignCount += stats.AssignCount
accum.CreateCount += stats.CreateCount
accum.OpenCount += stats.MentionCount
accum.ReviewRequestedCount += stats.ReviewRequestedCount
accum.ReviewedCount += stats.ReviewedCount
i = chunk
}
return accum, nil
}
func getIssueStatsChunk(opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) {
stats := &IssueStats{}
countSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session {
sess := db.GetEngine(db.DefaultContext).
Where("issue.repo_id = ?", opts.RepoID)
if len(issueIDs) > 0 {
sess.In("issue.id", issueIDs)
}
applyLabelsCondition(sess, opts)
applyMilestoneCondition(sess, opts)
if opts.ProjectID > 0 {
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id").
And("project_issue.project_id=?", opts.ProjectID)
}
if opts.AssigneeID > 0 {
applyAssigneeCondition(sess, opts.AssigneeID)
} else if opts.AssigneeID == db.NoConditionID {
sess.Where("id NOT IN (SELECT issue_id FROM issue_assignees)")
}
if opts.PosterID > 0 {
applyPosterCondition(sess, opts.PosterID)
}
if opts.MentionedID > 0 {
applyMentionedCondition(sess, opts.MentionedID)
}
if opts.ReviewRequestedID > 0 {
applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
}
if opts.ReviewedID > 0 {
applyReviewedCondition(sess, opts.ReviewedID)
}
switch opts.IsPull {
case util.OptionalBoolTrue:
sess.And("issue.is_pull=?", true)
case util.OptionalBoolFalse:
sess.And("issue.is_pull=?", false)
}
return sess
}
var err error
stats.OpenCount, err = countSession(opts, issueIDs).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return stats, err
}
stats.ClosedCount, err = countSession(opts, issueIDs).
And("issue.is_closed = ?", true).
Count(new(Issue))
return stats, err
}
// UserIssueStatsOptions contains parameters accepted by GetUserIssueStats.
type UserIssueStatsOptions struct {
UserID int64
RepoIDs []int64
FilterMode int
IsPull bool
IsClosed bool
IssueIDs []int64
IsArchived util.OptionalBool
LabelIDs []int64
RepoCond builder.Cond
Org *organization.Organization
Team *organization.Team
}
// GetUserIssueStats returns issue statistic information for dashboard by given conditions.
func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) {
var err error
stats := &IssueStats{}
cond := builder.NewCond()
cond = cond.And(builder.Eq{"issue.is_pull": opts.IsPull})
if len(opts.RepoIDs) > 0 {
cond = cond.And(builder.In("issue.repo_id", opts.RepoIDs))
}
if len(opts.IssueIDs) > 0 {
cond = cond.And(builder.In("issue.id", opts.IssueIDs))
}
if opts.RepoCond != nil {
cond = cond.And(opts.RepoCond)
}
if opts.UserID > 0 {
cond = cond.And(issuePullAccessibleRepoCond("issue.repo_id", opts.UserID, opts.Org, opts.Team, opts.IsPull))
}
sess := func(cond builder.Cond) *xorm.Session {
s := db.GetEngine(db.DefaultContext).Where(cond)
if len(opts.LabelIDs) > 0 {
s.Join("INNER", "issue_label", "issue_label.issue_id = issue.id").
In("issue_label.label_id", opts.LabelIDs)
}
if opts.UserID > 0 || opts.IsArchived != util.OptionalBoolNone {
s.Join("INNER", "repository", "issue.repo_id = repository.id")
if opts.IsArchived != util.OptionalBoolNone {
s.And(builder.Eq{"repository.is_archived": opts.IsArchived.IsTrue()})
}
}
return s
}
switch opts.FilterMode {
case FilterModeAll, FilterModeYourRepositories:
stats.OpenCount, err = sess(cond).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = sess(cond).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
case FilterModeAssign:
stats.OpenCount, err = applyAssigneeCondition(sess(cond), opts.UserID).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = applyAssigneeCondition(sess(cond), opts.UserID).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
case FilterModeCreate:
stats.OpenCount, err = applyPosterCondition(sess(cond), opts.UserID).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = applyPosterCondition(sess(cond), opts.UserID).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
case FilterModeMention:
stats.OpenCount, err = applyMentionedCondition(sess(cond), opts.UserID).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = applyMentionedCondition(sess(cond), opts.UserID).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
case FilterModeReviewRequested:
stats.OpenCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
case FilterModeReviewed:
stats.OpenCount, err = applyReviewedCondition(sess(cond), opts.UserID).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = applyReviewedCondition(sess(cond), opts.UserID).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
}
cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed})
stats.AssignCount, err = applyAssigneeCondition(sess(cond), opts.UserID).Count(new(Issue))
if err != nil {
return nil, err
}
stats.CreateCount, err = applyPosterCondition(sess(cond), opts.UserID).Count(new(Issue))
if err != nil {
return nil, err
}
stats.MentionCount, err = applyMentionedCondition(sess(cond), opts.UserID).Count(new(Issue))
if err != nil {
return nil, err
}
stats.YourRepositoriesCount, err = sess(cond).Count(new(Issue))
if err != nil {
return nil, err
}
stats.ReviewRequestedCount, err = applyReviewRequestedCondition(sess(cond), opts.UserID).Count(new(Issue))
if err != nil {
return nil, err
}
stats.ReviewedCount, err = applyReviewedCondition(sess(cond), opts.UserID).Count(new(Issue))
if err != nil {
return nil, err
}
return stats, nil
}
// GetRepoIssueStats returns number of open and closed repository issues by given filter mode.
func GetRepoIssueStats(repoID, uid int64, filterMode int, isPull bool) (numOpen, numClosed int64) {
countSession := func(isClosed, isPull bool, repoID int64) *xorm.Session {
sess := db.GetEngine(db.DefaultContext).
Where("is_closed = ?", isClosed).
And("is_pull = ?", isPull).
And("repo_id = ?", repoID)
return sess
}
openCountSession := countSession(false, isPull, repoID)
closedCountSession := countSession(true, isPull, repoID)
switch filterMode {
case FilterModeAssign:
applyAssigneeCondition(openCountSession, uid)
applyAssigneeCondition(closedCountSession, uid)
case FilterModeCreate:
applyPosterCondition(openCountSession, uid)
applyPosterCondition(closedCountSession, uid)
}
openResult, _ := openCountSession.Count(new(Issue))
closedResult, _ := closedCountSession.Count(new(Issue))
return openResult, closedResult
}
// SearchIssueIDsByKeyword search issues on database
func SearchIssueIDsByKeyword(ctx context.Context, kw string, repoIDs []int64, limit, start int) (int64, []int64, error) {
repoCond := builder.In("repo_id", repoIDs)

View File

@ -0,0 +1,383 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"errors"
"fmt"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder"
"xorm.io/xorm"
)
// IssueStats represents issue statistic information.
type IssueStats struct {
OpenCount, ClosedCount int64
YourRepositoriesCount int64
AssignCount int64
CreateCount int64
MentionCount int64
ReviewRequestedCount int64
ReviewedCount int64
}
// Filter modes.
const (
FilterModeAll = iota
FilterModeAssign
FilterModeCreate
FilterModeMention
FilterModeReviewRequested
FilterModeReviewed
FilterModeYourRepositories
)
const (
// MaxQueryParameters represents the max query parameters
// When queries are broken down in parts because of the number
// of parameters, attempt to break by this amount
MaxQueryParameters = 300
)
// CountIssuesByRepo map from repoID to number of issues matching the options
func CountIssuesByRepo(ctx context.Context, opts *IssuesOptions) (map[int64]int64, error) {
sess := db.GetEngine(ctx).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
applyConditions(sess, opts)
countsSlice := make([]*struct {
RepoID int64
Count int64
}, 0, 10)
if err := sess.GroupBy("issue.repo_id").
Select("issue.repo_id AS repo_id, COUNT(*) AS count").
Table("issue").
Find(&countsSlice); err != nil {
return nil, fmt.Errorf("unable to CountIssuesByRepo: %w", err)
}
countMap := make(map[int64]int64, len(countsSlice))
for _, c := range countsSlice {
countMap[c.RepoID] = c.Count
}
return countMap, nil
}
// CountIssues number return of issues by given conditions.
func CountIssues(ctx context.Context, opts *IssuesOptions) (int64, error) {
sess := db.GetEngine(ctx).
Select("COUNT(issue.id) AS count").
Table("issue").
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
applyConditions(sess, opts)
return sess.Count()
}
// GetIssueStats returns issue statistic information by given conditions.
func GetIssueStats(opts *IssuesOptions) (*IssueStats, error) {
if len(opts.IssueIDs) <= MaxQueryParameters {
return getIssueStatsChunk(opts, opts.IssueIDs)
}
// If too long a list of IDs is provided, we get the statistics in
// smaller chunks and get accumulates. Note: this could potentially
// get us invalid results. The alternative is to insert the list of
// ids in a temporary table and join from them.
accum := &IssueStats{}
for i := 0; i < len(opts.IssueIDs); {
chunk := i + MaxQueryParameters
if chunk > len(opts.IssueIDs) {
chunk = len(opts.IssueIDs)
}
stats, err := getIssueStatsChunk(opts, opts.IssueIDs[i:chunk])
if err != nil {
return nil, err
}
accum.OpenCount += stats.OpenCount
accum.ClosedCount += stats.ClosedCount
accum.YourRepositoriesCount += stats.YourRepositoriesCount
accum.AssignCount += stats.AssignCount
accum.CreateCount += stats.CreateCount
accum.OpenCount += stats.MentionCount
accum.ReviewRequestedCount += stats.ReviewRequestedCount
accum.ReviewedCount += stats.ReviewedCount
i = chunk
}
return accum, nil
}
func getIssueStatsChunk(opts *IssuesOptions, issueIDs []int64) (*IssueStats, error) {
stats := &IssueStats{}
countSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.Session {
sess := db.GetEngine(db.DefaultContext).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
if len(opts.RepoIDs) > 1 {
sess.In("issue.repo_id", opts.RepoIDs)
} else if len(opts.RepoIDs) == 1 {
sess.And("issue.repo_id = ?", opts.RepoIDs[0])
}
if len(issueIDs) > 0 {
sess.In("issue.id", issueIDs)
}
applyLabelsCondition(sess, opts)
applyMilestoneCondition(sess, opts)
if opts.ProjectID > 0 {
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id").
And("project_issue.project_id=?", opts.ProjectID)
}
if opts.AssigneeID > 0 {
applyAssigneeCondition(sess, opts.AssigneeID)
} else if opts.AssigneeID == db.NoConditionID {
sess.Where("id NOT IN (SELECT issue_id FROM issue_assignees)")
}
if opts.PosterID > 0 {
applyPosterCondition(sess, opts.PosterID)
}
if opts.MentionedID > 0 {
applyMentionedCondition(sess, opts.MentionedID)
}
if opts.ReviewRequestedID > 0 {
applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
}
if opts.ReviewedID > 0 {
applyReviewedCondition(sess, opts.ReviewedID)
}
switch opts.IsPull {
case util.OptionalBoolTrue:
sess.And("issue.is_pull=?", true)
case util.OptionalBoolFalse:
sess.And("issue.is_pull=?", false)
}
return sess
}
var err error
stats.OpenCount, err = countSession(opts, issueIDs).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return stats, err
}
stats.ClosedCount, err = countSession(opts, issueIDs).
And("issue.is_closed = ?", true).
Count(new(Issue))
return stats, err
}
// GetUserIssueStats returns issue statistic information for dashboard by given conditions.
func GetUserIssueStats(filterMode int, opts IssuesOptions) (*IssueStats, error) {
if opts.User == nil {
return nil, errors.New("issue stats without user")
}
if opts.IsPull.IsNone() {
return nil, errors.New("unaccepted ispull option")
}
var err error
stats := &IssueStats{}
cond := builder.NewCond()
cond = cond.And(builder.Eq{"issue.is_pull": opts.IsPull.IsTrue()})
if len(opts.RepoIDs) > 0 {
cond = cond.And(builder.In("issue.repo_id", opts.RepoIDs))
}
if len(opts.IssueIDs) > 0 {
cond = cond.And(builder.In("issue.id", opts.IssueIDs))
}
if opts.RepoCond != nil {
cond = cond.And(opts.RepoCond)
}
if opts.User != nil {
cond = cond.And(issuePullAccessibleRepoCond("issue.repo_id", opts.User.ID, opts.Org, opts.Team, opts.IsPull.IsTrue()))
}
sess := func(cond builder.Cond) *xorm.Session {
s := db.GetEngine(db.DefaultContext).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id").
Where(cond)
if len(opts.LabelIDs) > 0 {
s.Join("INNER", "issue_label", "issue_label.issue_id = issue.id").
In("issue_label.label_id", opts.LabelIDs)
}
if opts.IsArchived != util.OptionalBoolNone {
s.And(builder.Eq{"repository.is_archived": opts.IsArchived.IsTrue()})
}
return s
}
switch filterMode {
case FilterModeAll, FilterModeYourRepositories:
stats.OpenCount, err = sess(cond).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = sess(cond).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
case FilterModeAssign:
stats.OpenCount, err = applyAssigneeCondition(sess(cond), opts.User.ID).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = applyAssigneeCondition(sess(cond), opts.User.ID).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
case FilterModeCreate:
stats.OpenCount, err = applyPosterCondition(sess(cond), opts.User.ID).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = applyPosterCondition(sess(cond), opts.User.ID).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
case FilterModeMention:
stats.OpenCount, err = applyMentionedCondition(sess(cond), opts.User.ID).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = applyMentionedCondition(sess(cond), opts.User.ID).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
case FilterModeReviewRequested:
stats.OpenCount, err = applyReviewRequestedCondition(sess(cond), opts.User.ID).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = applyReviewRequestedCondition(sess(cond), opts.User.ID).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
case FilterModeReviewed:
stats.OpenCount, err = applyReviewedCondition(sess(cond), opts.User.ID).
And("issue.is_closed = ?", false).
Count(new(Issue))
if err != nil {
return nil, err
}
stats.ClosedCount, err = applyReviewedCondition(sess(cond), opts.User.ID).
And("issue.is_closed = ?", true).
Count(new(Issue))
if err != nil {
return nil, err
}
}
cond = cond.And(builder.Eq{"issue.is_closed": opts.IsClosed.IsTrue()})
stats.AssignCount, err = applyAssigneeCondition(sess(cond), opts.User.ID).Count(new(Issue))
if err != nil {
return nil, err
}
stats.CreateCount, err = applyPosterCondition(sess(cond), opts.User.ID).Count(new(Issue))
if err != nil {
return nil, err
}
stats.MentionCount, err = applyMentionedCondition(sess(cond), opts.User.ID).Count(new(Issue))
if err != nil {
return nil, err
}
stats.YourRepositoriesCount, err = sess(cond).Count(new(Issue))
if err != nil {
return nil, err
}
stats.ReviewRequestedCount, err = applyReviewRequestedCondition(sess(cond), opts.User.ID).Count(new(Issue))
if err != nil {
return nil, err
}
stats.ReviewedCount, err = applyReviewedCondition(sess(cond), opts.User.ID).Count(new(Issue))
if err != nil {
return nil, err
}
return stats, nil
}
// GetRepoIssueStats returns number of open and closed repository issues by given filter mode.
func GetRepoIssueStats(repoID, uid int64, filterMode int, isPull bool) (numOpen, numClosed int64) {
countSession := func(isClosed, isPull bool, repoID int64) *xorm.Session {
sess := db.GetEngine(db.DefaultContext).
Where("is_closed = ?", isClosed).
And("is_pull = ?", isPull).
And("repo_id = ?", repoID)
return sess
}
openCountSession := countSession(false, isPull, repoID)
closedCountSession := countSession(true, isPull, repoID)
switch filterMode {
case FilterModeAssign:
applyAssigneeCondition(openCountSession, uid)
applyAssigneeCondition(closedCountSession, uid)
case FilterModeCreate:
applyPosterCondition(openCountSession, uid)
applyPosterCondition(closedCountSession, uid)
}
openResult, _ := openCountSession.Count(new(Issue))
closedResult, _ := closedCountSession.Count(new(Issue))
return openResult, closedResult
}
// CountOrphanedIssues count issues without a repo
func CountOrphanedIssues(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).
Table("issue").
Join("LEFT", "repository", "issue.repo_id=repository.id").
Where(builder.IsNull{"repository.id"}).
Select("COUNT(`issue`.`id`)").
Count()
}

View File

@ -17,6 +17,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"xorm.io/builder"
@ -204,14 +205,16 @@ func TestIssues(t *testing.T) {
func TestGetUserIssueStats(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
for _, test := range []struct {
Opts issues_model.UserIssueStatsOptions
FilterMode int
Opts issues_model.IssuesOptions
ExpectedIssueStats issues_model.IssueStats
}{
{
issues_model.UserIssueStatsOptions{
UserID: 1,
issues_model.FilterModeAll,
issues_model.IssuesOptions{
User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}),
RepoIDs: []int64{1},
FilterMode: issues_model.FilterModeAll,
IsPull: util.OptionalBoolFalse,
},
issues_model.IssueStats{
YourRepositoriesCount: 1, // 6
@ -222,11 +225,12 @@ func TestGetUserIssueStats(t *testing.T) {
},
},
{
issues_model.UserIssueStatsOptions{
UserID: 1,
issues_model.FilterModeAll,
issues_model.IssuesOptions{
User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}),
RepoIDs: []int64{1},
FilterMode: issues_model.FilterModeAll,
IsClosed: true,
IsPull: util.OptionalBoolFalse,
IsClosed: util.OptionalBoolTrue,
},
issues_model.IssueStats{
YourRepositoriesCount: 1, // 6
@ -237,9 +241,10 @@ func TestGetUserIssueStats(t *testing.T) {
},
},
{
issues_model.UserIssueStatsOptions{
UserID: 1,
FilterMode: issues_model.FilterModeAssign,
issues_model.FilterModeAssign,
issues_model.IssuesOptions{
User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}),
IsPull: util.OptionalBoolFalse,
},
issues_model.IssueStats{
YourRepositoriesCount: 1, // 6
@ -250,9 +255,10 @@ func TestGetUserIssueStats(t *testing.T) {
},
},
{
issues_model.UserIssueStatsOptions{
UserID: 1,
FilterMode: issues_model.FilterModeCreate,
issues_model.FilterModeCreate,
issues_model.IssuesOptions{
User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}),
IsPull: util.OptionalBoolFalse,
},
issues_model.IssueStats{
YourRepositoriesCount: 1, // 6
@ -263,9 +269,10 @@ func TestGetUserIssueStats(t *testing.T) {
},
},
{
issues_model.UserIssueStatsOptions{
UserID: 1,
FilterMode: issues_model.FilterModeMention,
issues_model.FilterModeMention,
issues_model.IssuesOptions{
User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}),
IsPull: util.OptionalBoolFalse,
},
issues_model.IssueStats{
YourRepositoriesCount: 1, // 6
@ -277,10 +284,11 @@ func TestGetUserIssueStats(t *testing.T) {
},
},
{
issues_model.UserIssueStatsOptions{
UserID: 1,
FilterMode: issues_model.FilterModeCreate,
issues_model.FilterModeCreate,
issues_model.IssuesOptions{
User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}),
IssueIDs: []int64{1},
IsPull: util.OptionalBoolFalse,
},
issues_model.IssueStats{
YourRepositoriesCount: 1, // 1
@ -291,11 +299,12 @@ func TestGetUserIssueStats(t *testing.T) {
},
},
{
issues_model.UserIssueStatsOptions{
UserID: 2,
issues_model.FilterModeAll,
issues_model.IssuesOptions{
User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}),
Org: unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}),
Team: unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 7}),
FilterMode: issues_model.FilterModeAll,
IsPull: util.OptionalBoolFalse,
},
issues_model.IssueStats{
YourRepositoriesCount: 2,
@ -306,7 +315,7 @@ func TestGetUserIssueStats(t *testing.T) {
},
} {
t.Run(fmt.Sprintf("%#v", test.Opts), func(t *testing.T) {
stats, err := issues_model.GetUserIssueStats(test.Opts)
stats, err := issues_model.GetUserIssueStats(test.FilterMode, test.Opts)
if !assert.NoError(t, err) {
return
}
@ -495,7 +504,7 @@ func TestCorrectIssueStats(t *testing.T) {
// Now we will call the GetIssueStats with these IDs and if working,
// get the correct stats back.
issueStats, err := issues_model.GetIssueStats(&issues_model.IssuesOptions{
RepoID: 1,
RepoIDs: []int64{1},
IssueIDs: ids,
})

View File

@ -81,7 +81,7 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use
}
// Update issue count of labels
if err := issue.getLabels(ctx); err != nil {
if err := issue.LoadLabels(ctx); err != nil {
return nil, err
}
for idx := range issue.Labels {

View File

@ -11,7 +11,6 @@ import (
"strings"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
@ -113,7 +112,7 @@ func (l *Label) CalOpenIssues() {
// CalOpenOrgIssues calculates the open issues of a label for a specific repo
func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) {
counts, _ := CountIssuesByRepo(ctx, &IssuesOptions{
RepoID: repoID,
RepoIDs: []int64{repoID},
LabelIDs: []int64{labelID},
IsClosed: util.OptionalBoolFalse,
})
@ -282,13 +281,6 @@ func GetLabelsByIDs(labelIDs []int64) ([]*Label, error) {
Find(&labels)
}
// __________ .__ __
// \______ \ ____ ______ ____ _____|__|/ |_ ___________ ___.__.
// | _// __ \\____ \ / _ \/ ___/ \ __\/ _ \_ __ < | |
// | | \ ___/| |_> > <_> )___ \| || | ( <_> ) | \/\___ |
// |____|_ /\___ > __/ \____/____ >__||__| \____/|__| / ____|
// \/ \/|__| \/ \/
// GetLabelInRepoByName returns a label by name in given repository.
func GetLabelInRepoByName(ctx context.Context, repoID int64, labelName string) (*Label, error) {
if len(labelName) == 0 || repoID <= 0 {
@ -393,13 +385,6 @@ func CountLabelsByRepoID(repoID int64) (int64, error) {
return db.GetEngine(db.DefaultContext).Where("repo_id = ?", repoID).Count(&Label{})
}
// ________
// \_____ \_______ ____
// / | \_ __ \/ ___\
// / | \ | \/ /_/ >
// \_______ /__| \___ /
// \/ /_____/
// GetLabelInOrgByName returns a label by name in given organization.
func GetLabelInOrgByName(ctx context.Context, orgID int64, labelName string) (*Label, error) {
if len(labelName) == 0 || orgID <= 0 {
@ -496,22 +481,6 @@ func CountLabelsByOrgID(orgID int64) (int64, error) {
return db.GetEngine(db.DefaultContext).Where("org_id = ?", orgID).Count(&Label{})
}
// .___
// | | ______ ________ __ ____
// | |/ ___// ___/ | \_/ __ \
// | |\___ \ \___ \| | /\ ___/
// |___/____ >____ >____/ \___ |
// \/ \/ \/
// GetLabelsByIssueID returns all labels that belong to given issue by ID.
func GetLabelsByIssueID(ctx context.Context, issueID int64) ([]*Label, error) {
var labels []*Label
return labels, db.GetEngine(ctx).Where("issue_label.issue_id = ?", issueID).
Join("LEFT", "issue_label", "issue_label.label_id = label.id").
Asc("label.name").
Find(&labels)
}
func updateLabelCols(ctx context.Context, l *Label, cols ...string) error {
_, err := db.GetEngine(ctx).ID(l.ID).
SetExpr("num_issues",
@ -529,307 +498,3 @@ func updateLabelCols(ctx context.Context, l *Label, cols ...string) error {
Cols(cols...).Update(l)
return err
}
// .___ .____ ___. .__
// | | ______ ________ __ ____ | | _____ \_ |__ ____ | |
// | |/ ___// ___/ | \_/ __ \| | \__ \ | __ \_/ __ \| |
// | |\___ \ \___ \| | /\ ___/| |___ / __ \| \_\ \ ___/| |__
// |___/____ >____ >____/ \___ >_______ (____ /___ /\___ >____/
// \/ \/ \/ \/ \/ \/ \/
// IssueLabel represents an issue-label relation.
type IssueLabel struct {
ID int64 `xorm:"pk autoincr"`
IssueID int64 `xorm:"UNIQUE(s)"`
LabelID int64 `xorm:"UNIQUE(s)"`
}
// HasIssueLabel returns true if issue has been labeled.
func HasIssueLabel(ctx context.Context, issueID, labelID int64) bool {
has, _ := db.GetEngine(ctx).Where("issue_id = ? AND label_id = ?", issueID, labelID).Get(new(IssueLabel))
return has
}
// newIssueLabel this function creates a new label it does not check if the label is valid for the issue
// YOU MUST CHECK THIS BEFORE THIS FUNCTION
func newIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
if err = db.Insert(ctx, &IssueLabel{
IssueID: issue.ID,
LabelID: label.ID,
}); err != nil {
return err
}
if err = issue.LoadRepo(ctx); err != nil {
return
}
opts := &CreateCommentOptions{
Type: CommentTypeLabel,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
Label: label,
Content: "1",
}
if _, err = CreateComment(ctx, opts); err != nil {
return err
}
return updateLabelCols(ctx, label, "num_issues", "num_closed_issue")
}
// Remove all issue labels in the given exclusive scope
func RemoveDuplicateExclusiveIssueLabels(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
scope := label.ExclusiveScope()
if scope == "" {
return nil
}
var toRemove []*Label
for _, issueLabel := range issue.Labels {
if label.ID != issueLabel.ID && issueLabel.ExclusiveScope() == scope {
toRemove = append(toRemove, issueLabel)
}
}
for _, issueLabel := range toRemove {
if err = deleteIssueLabel(ctx, issue, issueLabel, doer); err != nil {
return err
}
}
return nil
}
// NewIssueLabel creates a new issue-label relation.
func NewIssueLabel(issue *Issue, label *Label, doer *user_model.User) (err error) {
if HasIssueLabel(db.DefaultContext, issue.ID, label.ID) {
return nil
}
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if err = issue.LoadRepo(ctx); err != nil {
return err
}
// Do NOT add invalid labels
if issue.RepoID != label.RepoID && issue.Repo.OwnerID != label.OrgID {
return nil
}
if err = RemoveDuplicateExclusiveIssueLabels(ctx, issue, label, doer); err != nil {
return nil
}
if err = newIssueLabel(ctx, issue, label, doer); err != nil {
return err
}
issue.Labels = nil
if err = issue.LoadLabels(ctx); err != nil {
return err
}
return committer.Commit()
}
// newIssueLabels add labels to an issue. It will check if the labels are valid for the issue
func newIssueLabels(ctx context.Context, issue *Issue, labels []*Label, doer *user_model.User) (err error) {
if err = issue.LoadRepo(ctx); err != nil {
return err
}
for _, l := range labels {
// Don't add already present labels and invalid labels
if HasIssueLabel(ctx, issue.ID, l.ID) ||
(l.RepoID != issue.RepoID && l.OrgID != issue.Repo.OwnerID) {
continue
}
if err = newIssueLabel(ctx, issue, l, doer); err != nil {
return fmt.Errorf("newIssueLabel: %w", err)
}
}
return nil
}
// NewIssueLabels creates a list of issue-label relations.
func NewIssueLabels(issue *Issue, labels []*Label, doer *user_model.User) (err error) {
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if err = newIssueLabels(ctx, issue, labels, doer); err != nil {
return err
}
issue.Labels = nil
if err = issue.LoadLabels(ctx); err != nil {
return err
}
return committer.Commit()
}
func deleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) (err error) {
if count, err := db.DeleteByBean(ctx, &IssueLabel{
IssueID: issue.ID,
LabelID: label.ID,
}); err != nil {
return err
} else if count == 0 {
return nil
}
if err = issue.LoadRepo(ctx); err != nil {
return
}
opts := &CreateCommentOptions{
Type: CommentTypeLabel,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
Label: label,
}
if _, err = CreateComment(ctx, opts); err != nil {
return err
}
return updateLabelCols(ctx, label, "num_issues", "num_closed_issue")
}
// DeleteIssueLabel deletes issue-label relation.
func DeleteIssueLabel(ctx context.Context, issue *Issue, label *Label, doer *user_model.User) error {
if err := deleteIssueLabel(ctx, issue, label, doer); err != nil {
return err
}
issue.Labels = nil
return issue.LoadLabels(ctx)
}
// DeleteLabelsByRepoID deletes labels of some repository
func DeleteLabelsByRepoID(ctx context.Context, repoID int64) error {
deleteCond := builder.Select("id").From("label").Where(builder.Eq{"label.repo_id": repoID})
if _, err := db.GetEngine(ctx).In("label_id", deleteCond).
Delete(&IssueLabel{}); err != nil {
return err
}
_, err := db.DeleteByBean(ctx, &Label{RepoID: repoID})
return err
}
// CountOrphanedLabels return count of labels witch are broken and not accessible via ui anymore
func CountOrphanedLabels(ctx context.Context) (int64, error) {
noref, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Count()
if err != nil {
return 0, err
}
norepo, err := db.GetEngine(ctx).Table("label").
Where(builder.And(
builder.Gt{"repo_id": 0},
builder.NotIn("repo_id", builder.Select("id").From("`repository`")),
)).
Count()
if err != nil {
return 0, err
}
noorg, err := db.GetEngine(ctx).Table("label").
Where(builder.And(
builder.Gt{"org_id": 0},
builder.NotIn("org_id", builder.Select("id").From("`user`")),
)).
Count()
if err != nil {
return 0, err
}
return noref + norepo + noorg, nil
}
// DeleteOrphanedLabels delete labels witch are broken and not accessible via ui anymore
func DeleteOrphanedLabels(ctx context.Context) error {
// delete labels with no reference
if _, err := db.GetEngine(ctx).Table("label").Where("repo_id=? AND org_id=?", 0, 0).Delete(new(Label)); err != nil {
return err
}
// delete labels with none existing repos
if _, err := db.GetEngine(ctx).
Where(builder.And(
builder.Gt{"repo_id": 0},
builder.NotIn("repo_id", builder.Select("id").From("`repository`")),
)).
Delete(Label{}); err != nil {
return err
}
// delete labels with none existing orgs
if _, err := db.GetEngine(ctx).
Where(builder.And(
builder.Gt{"org_id": 0},
builder.NotIn("org_id", builder.Select("id").From("`user`")),
)).
Delete(Label{}); err != nil {
return err
}
return nil
}
// CountOrphanedIssueLabels return count of IssueLabels witch have no label behind anymore
func CountOrphanedIssueLabels(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Table("issue_label").
NotIn("label_id", builder.Select("id").From("label")).
Count()
}
// DeleteOrphanedIssueLabels delete IssueLabels witch have no label behind anymore
func DeleteOrphanedIssueLabels(ctx context.Context) error {
_, err := db.GetEngine(ctx).
NotIn("label_id", builder.Select("id").From("label")).
Delete(IssueLabel{})
return err
}
// CountIssueLabelWithOutsideLabels count label comments with outside label
func CountIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Where(builder.Expr("(label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id)")).
Table("issue_label").
Join("inner", "label", "issue_label.label_id = label.id ").
Join("inner", "issue", "issue.id = issue_label.issue_id ").
Join("inner", "repository", "issue.repo_id = repository.id").
Count(new(IssueLabel))
}
// FixIssueLabelWithOutsideLabels fix label comments with outside label
func FixIssueLabelWithOutsideLabels(ctx context.Context) (int64, error) {
res, err := db.GetEngine(ctx).Exec(`DELETE FROM issue_label WHERE issue_label.id IN (
SELECT il_too.id FROM (
SELECT il_too_too.id
FROM issue_label AS il_too_too
INNER JOIN label ON il_too_too.label_id = label.id
INNER JOIN issue on issue.id = il_too_too.issue_id
INNER JOIN repository on repository.id = issue.repo_id
WHERE
(label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id)
) AS il_too )`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}

View File

@ -491,6 +491,8 @@ var migrations = []Migration{
NewMigration("Add ArchivedUnix Column", v1_20.AddArchivedUnixToRepository),
// v256 -> v257
NewMigration("Add is_internal column to package", v1_20.AddIsInternalColumnToPackage),
// v257 -> v258
NewMigration("Add Actions Artifact table", v1_20.CreateActionArtifactTable),
}
// GetCurrentDBVersion returns the current db version

View File

@ -0,0 +1,33 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_20 //nolint
import (
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"
)
func CreateActionArtifactTable(x *xorm.Engine) error {
// ActionArtifact is a file that is stored in the artifact storage.
type ActionArtifact struct {
ID int64 `xorm:"pk autoincr"`
RunID int64 `xorm:"index UNIQUE(runid_name)"` // The run id of the artifact
RunnerID int64
RepoID int64 `xorm:"index"`
OwnerID int64
CommitSHA string
StoragePath string // The path to the artifact in the storage
FileSize int64 // The size of the artifact in bytes
FileCompressedSize int64 // The size of the artifact in bytes after gzip compression
ContentEncoding string // The content encoding of the artifact
ArtifactPath string // The path to the artifact when runner uploads it
ArtifactName string `xorm:"UNIQUE(runid_name)"` // The name of the artifact when runner uploads it
Status int64 `xorm:"index"` // The status of the artifact
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
}
return x.Sync(new(ActionArtifact))
}

View File

@ -59,6 +59,12 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error {
return fmt.Errorf("find actions tasks of repo %v: %w", repoID, err)
}
// Query the artifacts of this repo, they will be needed after they have been deleted to remove artifacts files in ObjectStorage
artifacts, err := actions_model.ListArtifactsByRepoID(ctx, repoID)
if err != nil {
return fmt.Errorf("list actions artifacts of repo %v: %w", repoID, err)
}
// In case is a organization.
org, err := user_model.GetUserByID(ctx, uid)
if err != nil {
@ -164,6 +170,7 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error {
&actions_model.ActionRunJob{RepoID: repoID},
&actions_model.ActionRun{RepoID: repoID},
&actions_model.ActionRunner{RepoID: repoID},
&actions_model.ActionArtifact{RepoID: repoID},
); err != nil {
return fmt.Errorf("deleteBeans: %w", err)
}
@ -336,6 +343,14 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error {
}
}
// delete actions artifacts in ObjectStorage after the repo have already been deleted
for _, art := range artifacts {
if err := storage.ActionsArtifacts.Delete(art.StoragePath); err != nil {
log.Error("remove artifact file %q: %v", art.StoragePath, err)
// go on
}
}
return nil
}

View File

@ -126,7 +126,7 @@ func MainTest(m *testing.M, testOpts *TestOptions) {
setting.Packages.Storage.Path = filepath.Join(setting.AppDataPath, "packages")
setting.Actions.Storage.Path = filepath.Join(setting.AppDataPath, "actions_log")
setting.Actions.LogStorage.Path = filepath.Join(setting.AppDataPath, "actions_log")
setting.Git.HomePath = filepath.Join(setting.AppDataPath, "home")

View File

@ -302,7 +302,7 @@ func populateIssueIndexer(ctx context.Context) {
// UpdateRepoIndexer add/update all issues of the repositories
func UpdateRepoIndexer(ctx context.Context, repo *repo_model.Repository) {
is, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{
RepoID: repo.ID,
RepoIDs: []int64{repo.ID},
IsClosed: util.OptionalBoolNone,
IsPull: util.OptionalBoolNone,
})

View File

@ -22,7 +22,10 @@ type Sanitizer struct {
init sync.Once
}
var sanitizer = &Sanitizer{}
var (
sanitizer = &Sanitizer{}
allowAllRegex = regexp.MustCompile(".+")
)
// NewSanitizer initializes sanitizer with allowed attributes based on settings.
// Multiple calls to this function will only create one instance of Sanitizer during
@ -74,6 +77,8 @@ func createDefaultPolicy() *bluemonday.Policy {
// Custom URL-Schemes
if len(setting.Markdown.CustomURLSchemes) > 0 {
policy.AllowURLSchemes(setting.Markdown.CustomURLSchemes...)
} else {
policy.AllowURLSchemesMatching(allowAllRegex)
}
// Allow classes for anchors

View File

@ -52,6 +52,10 @@ func Test_Sanitizer(t *testing.T) {
`<span style="bad-color: red">Hello World</span>`, `<span>Hello World</span>`,
`<p style="bad-color: red">Hello World</p>`, `<p>Hello World</p>`,
`<code style="bad-color: red">Hello World</code>`, `<code>Hello World</code>`,
// URLs
`[my custom URL scheme](cbthunderlink://somebase64string)`, `[my custom URL scheme](cbthunderlink://somebase64string)`,
`[my custom URL scheme](matrix:roomid/psumPMeAfzgAeQpXMG:feneas.org?action=join)`, `[my custom URL scheme](matrix:roomid/psumPMeAfzgAeQpXMG:feneas.org?action=join)`,
}
for i := 0; i < len(testCases); i += 2 {

View File

@ -10,7 +10,8 @@ import (
// Actions settings
var (
Actions = struct {
Storage // how the created logs should be stored
LogStorage Storage // how the created logs should be stored
ArtifactStorage Storage // how the created artifacts should be stored
Enabled bool
DefaultActionsURL string `ini:"DEFAULT_ACTIONS_URL"`
}{
@ -25,5 +26,9 @@ func loadActionsFrom(rootCfg ConfigProvider) {
log.Fatal("Failed to map Actions settings: %v", err)
}
Actions.Storage = getStorage(rootCfg, "actions_log", "", nil)
actionsSec := rootCfg.Section("actions.artifacts")
storageType := actionsSec.Key("STORAGE_TYPE").MustString("")
Actions.LogStorage = getStorage(rootCfg, "actions_log", "", nil)
Actions.ArtifactStorage = getStorage(rootCfg, "actions_artifacts", storageType, actionsSec)
}

View File

@ -306,11 +306,11 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
log.Fatal("Failed to map Repository.PullRequest settings: %v", err)
}
if !rootCfg.Section("packages").Key("ENABLED").MustBool(true) {
if !rootCfg.Section("packages").Key("ENABLED").MustBool(Packages.Enabled) {
Repository.DisabledRepoUnits = append(Repository.DisabledRepoUnits, "repo.packages")
}
if !rootCfg.Section("actions").Key("ENABLED").MustBool(true) {
if !rootCfg.Section("actions").Key("ENABLED").MustBool(Actions.Enabled) {
Repository.DisabledRepoUnits = append(Repository.DisabledRepoUnits, "repo.actions")
}

View File

@ -128,6 +128,8 @@ var (
// Actions represents actions storage
Actions ObjectStorage = uninitializedStorage
// Actions Artifacts represents actions artifacts storage
ActionsArtifacts ObjectStorage = uninitializedStorage
)
// Init init the stoarge
@ -212,9 +214,14 @@ func initPackages() (err error) {
func initActions() (err error) {
if !setting.Actions.Enabled {
Actions = discardStorage("Actions isn't enabled")
ActionsArtifacts = discardStorage("ActionsArtifacts isn't enabled")
return nil
}
log.Info("Initialising Actions storage with type: %s", setting.Actions.Storage.Type)
Actions, err = NewStorage(setting.Actions.Storage.Type, &setting.Actions.Storage)
log.Info("Initialising Actions storage with type: %s", setting.Actions.LogStorage.Type)
if Actions, err = NewStorage(setting.Actions.LogStorage.Type, &setting.Actions.LogStorage); err != nil {
return err
}
log.Info("Initialising ActionsArtifacts storage with type: %s", setting.Actions.ArtifactStorage.Type)
ActionsArtifacts, err = NewStorage(setting.Actions.ArtifactStorage.Type, &setting.Actions.ArtifactStorage)
return err
}

View File

@ -114,6 +114,8 @@ unknown = Unknown
rss_feed = RSS Feed
artifacts = Artifacts
concept_system_global = Global
concept_user_individual = Individual
concept_code_repository = Repository

View File

@ -318,6 +318,7 @@ repo_no_results=Aucun dépôt correspondant n'a été trouvé.
user_no_results=Aucun utilisateur correspondant n'a été trouvé.
org_no_results=Aucune organisation correspondante n'a été trouvée.
code_no_results=Aucun code source correspondant à votre terme de recherche n'a été trouvé.
code_search_results=Résultats de la recherche pour « %s »
code_last_indexed_at=Dernière indexation %s
relevant_repositories_tooltip=Les dépôts qui sont des forks ou qui n'ont aucun sujet, aucune icône et aucune description sont cachés.
relevant_repositories=Seuls les dépôts pertinents sont affichés, <a href="%s">afficher les résultats non filtrés</a>.
@ -394,6 +395,7 @@ password_pwned_err=Impossible d'envoyer la demande à HaveIBeenPwned
[mail]
view_it_on=Voir sur %s
reply=ou répondez directement à cet e-mail
link_not_working_do_paste=Le lien ne fonctionne pas ? Essayez de le copier-coller dans votre navigateur.
hi_user_x=Bonjour <b>%s</b>,
@ -495,6 +497,7 @@ size_error=` doit être à la taille de %s.`
min_size_error=` %s caractères minimum `
max_size_error=` %s caractères maximum `
email_error=` adresse e-mail invalide `
url_error=`"%s" n'est pas une URL valide.`
include_error=` doit contenir la sous-chaîne "%s".`
glob_pattern_error=` le motif de développement est invalide : %s.`
regex_pattern_error=` le motif regex est invalide : %s.`
@ -540,8 +543,10 @@ organization_leave_success=Vous avez quitté l'organisation %s avec succès.
invalid_ssh_key=Impossible de vérifier votre clé SSH : %s
invalid_gpg_key=Impossible de vérifier votre clé GPG : %s
invalid_ssh_principal=Principal invalide : %s
must_use_public_key=La clé que vous avez fournie est une clé privée. Veuillez ne pas divulguer votre clé privée. Utilisez votre clé publique à la place.
auth_failed=Échec d'authentification : %v
still_own_repo=Votre compte possède toujours un ou plusieurs dépôts, vous devez dabord les supprimer ou les transférer.
still_has_org=Votre compte est un membre dune ou plusieurs organisations, veuillez dabord les quitter.
still_own_packages=Votre compte possède toujours un ou plusieurs paquets, vous devez dabord les supprimer ou les transférer.
org_still_own_repo=Cette organisation possède encore un ou plusieurs dépôts. Vous devez dabord les supprimer ou les transférer.
@ -569,6 +574,7 @@ email_visibility.limited=Votre adresse e-mail est visible pour tous les utilisat
email_visibility.private=Votre adresse e-mail n'est visible que pour vous et les administrateurs
form.name_reserved=Le nom dutilisateur "%s" est réservé.
form.name_pattern_not_allowed=Le motif "%s" n'est pas autorisé dans un nom de d'utilisateur.
form.name_chars_not_allowed=Le nom d'utilisateur "%s" contient des caractères non valides.
[settings]
@ -578,6 +584,7 @@ appearance=Apparence
password=Mot de passe
security=Sécurité
avatar=Avatar
ssh_gpg_keys=Clés SSH / GPG
social=Réseaux Sociaux
applications=Applications
orgs=Gérer les organisations
@ -586,10 +593,13 @@ delete=Supprimer le compte
twofa=Authentification à deux facteurs
account_link=Comptes liés
organization=Organisations
uid=Uid
webauthn=Clés de sécurité
public_profile=Profil public
biography_placeholder=Parlez-nous un peu de vous.
profile_desc=Votre adresse e-mail sera utilisée pour les notifications et d'autres opérations.
password_username_disabled=Les utilisateurs externes ne sont pas autorisés à modifier leur nom d'utilisateur. Veuillez contacter l'administrateur de votre site pour plus de détails.
full_name=Non Complet
website=Site Web
location=Localisation
@ -604,12 +614,19 @@ cancel=Annuler
language=Langue
ui=Thème
hidden_comment_types=Types de commentaires masqués
hidden_comment_types.issue_ref_tooltip=Commentaires où lutilisateur change la branche/étiquette associée au ticket
comment_type_group_reference=Référence
comment_type_group_label=Libellé
comment_type_group_milestone=Jalon
comment_type_group_assignee=Assigné à
comment_type_group_title=Titre
comment_type_group_branch=Branche
comment_type_group_time_tracking=Minuteur
comment_type_group_deadline=Échéance
comment_type_group_dependency=Dépendance
comment_type_group_lock=Verrouiller le statut
comment_type_group_review_request=Demande de revue
comment_type_group_pull_request_push=Révisions ajoutées
comment_type_group_project=Projet
comment_type_group_issue_ref=Référence du ticket
saved_successfully=Vos paramètres ont été enregistrés avec succès.
@ -618,38 +635,123 @@ keep_activity_private=Masquer l'activité de la page de profil
keep_activity_private_popup=Rend l'activité visible uniquement pour vous et les administrateurs
lookup_avatar_by_mail=Rechercher un avatar par adresse e-mail
federated_avatar_lookup=Recherche d'avatars fédérés
enable_custom_avatar=Utiliser un avatar personnalisé
choose_new_avatar=Sélectionner un nouvel avatar
delete_current_avatar=Supprimer l'avatar actuel
uploaded_avatar_not_a_image=Le fichier téléchargé n'est pas une image.
uploaded_avatar_is_too_big=Le fichier téléchargé dépasse la taille limite.
update_avatar_success=Votre avatar a été mis à jour.
update_user_avatar_success=L'avatar de l'utilisateur a été mis à jour.
old_password=Mot de passe actuel
new_password=Nouveau mot de passe
retype_new_password=Retapez le nouveau mot de passe
password_incorrect=Le mot de passe actuel est incorrect.
change_password_success=Votre mot de passe a été mis à jour. Désormais, connectez-vous avec votre nouveau mot de passe.
password_change_disabled=Les mots de passes des comptes utilisateurs externes ne peuvent pas être modifiées depuis l'interface web Gitea.
emails=Adresses e-mail
manage_emails=Gérer les adresses e-mail
manage_themes=Sélectionner le thème par défaut
manage_openid=Gérer les adresses OpenID
email_desc=Votre adresse e-mail principale sera utilisée pour les notifications et d'autres opérations.
theme_desc=Ce sera votre thème par défaut sur le site.
primary=Principale
activated=Activé
requires_activation=Nécessite une activation
primary_email=Faire de cette adresse votre adresse principale
delete_email=Exclure
openid_deletion_success=L'adresse OpenID a été supprimée.
add_new_openid=Ajouter une nouvelle URI OpenID
add_openid=Ajouter une URI OpenID
email_preference_set_success=L'e-mail de préférence a été défini avec succès.
add_openid_success=La nouvelle adresse OpenID a été ajoutée.
keep_email_private=Cacher l'adresse e-mail
keep_email_private_popup=Votre adresse e-mail ne sera visible que de vous et des administrateurs
openid_desc=OpenID vous permet de confier l'authentification à une tierce partie.
manage_ssh_keys=Gérer les clés SSH
manage_ssh_principals=Gérer les certificats principaux SSH
manage_gpg_keys=Gérer les clés GPG
add_key=Ajouter une clé
ssh_desc=Ces clefs SSH publiques sont associées à votre compte. Les clefs privées correspondantes permettent l'accès complet à vos repos.
principal_desc=Ces Principaux de certificats SSH sont associés à votre compte et permettent un accès complet à vos dépôts.
gpg_desc=Ces clefs GPG sont associées avec votre compte. Conservez-les en lieu sûr, car elles permettent la vérification de vos commits.
ssh_helper=<strong>Besoin d'aide ?</strong> Consultez le guide de GitHub pour <a href="%s">créer vos propres clés SSH</a> ou <a href="%s">résoudre les problèmes courants</a> que vous pourriez rencontrer en utilisant SSH.
gpg_helper=<strong>Besoin d'aide ?</strong> Consultez le guide de GitHub <a href="%s">sur GPG</a>.
add_new_key=Ajouter une clé SSH
add_new_gpg_key=Ajouter une clé GPG
key_content_ssh_placeholder=Commence par 'ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'sk-ecdsa-sha2-nistp256@openssh.com' ou par 'sk-ssh-ed25519@openssh.com'
key_content_gpg_placeholder=Commence par '-----BEGIN PGP PUBLIC KEY BLOCK-----'
add_new_principal=Ajouter le principal
ssh_key_been_used=Cette clé SSH a déjà été ajoutée au serveur.
ssh_key_name_used=Une clé SSH avec le même nom existe déjà sur votre compte.
ssh_principal_been_used=Ce principal a déjà été ajouté au serveur.
gpg_key_id_used=Une clé publique GPG avec le même ID existe déjà.
gpg_no_key_email_found=Cette clé GPG ne correspond à aucune adresse e-mail activée associée à votre compte. Elle peut toujours être ajoutée si vous signez le jeton fourni.
gpg_key_matched_identities=Identités correspondantes :
gpg_key_matched_identities_long=Les identités intégrées dans cette clé correspondent aux adresses e-mail activées suivantes pour cet utilisateur. Les commits correspondant à ces adresses e-mail peuvent être vérifiés avec cette clé.
gpg_key_verified=Clé vérifiée
gpg_key_verified_long=La clé a été vérifiée avec un jeton et peut être utilisée pour vérifier les révisions correspondant à toutes les adresses e-mails pour cet utilisateur en plus de toutes les identités pour cette clé.
gpg_key_verify=Vérifier
gpg_invalid_token_signature=La clé GPG, la signature et le jeton fournis ne correspondent pas ou le jeton n'est pas à jour.
gpg_token_required=Vous devez fournir une signature pour le jeton ci-dessous
gpg_token=Jeton
gpg_token_help=Vous pouvez générer une signature en utilisant :
gpg_token_signature=Signature GPG renforcée
key_signature_gpg_placeholder=Commence par '-----BEGIN PGP SIGNATURE-----'
verify_gpg_key_success=La clé GPG "%s" a été vérifiée.
ssh_key_verified=Clé vérifiée
ssh_key_verified_long=La clé a été vérifiée avec un jeton et peut être utilisée pour vérifier les commits correspondant à toutes les adresses mail activées pour cet utilisateur.
ssh_key_verify=Vérifier
ssh_invalid_token_signature=La clé SSH, la signature ou le jeton fournis ne correspondent pas ou le jeton est périmé.
ssh_token_required=Vous devez fournir une signature pour le jeton ci-dessous
ssh_token=Jeton
ssh_token_help=Vous pouvez générer une signature en utilisant :
ssh_token_signature=Signature SSH renforcée
key_signature_ssh_placeholder=Commence par '-----BEGIN SSH SIGNATURE-----'
verify_ssh_key_success=La clé SSH "%s" a été vérifiée.
subkeys=Sous-clés
key_id=ID de la clé
key_name=Nom de la Clé
key_content=Contenu
principal_content=Contenu
delete_key=Exclure
gpg_key_deletion_desc=Supprimer une clé GPG renie les révisions signées par celle-ci. Continuer ?
can_read_info=Lue(s)
can_write_info=Écriture
delete_token=Supprimer
access_token_deletion_cancel_action=Annuler
access_token_deletion_confirm_action=Supprimer
delete_token_success=Ce jeton a été supprimé. Les applications l'utilisant n'ont plus accès à votre compte.
select_scopes=Sélectionner les périmètres
scopes_list=Périmètres :
manage_oauth2_applications=Gérer les applications OAuth2
edit_oauth2_application=Modifier l'application OAuth2
oauth2_applications_desc=Les applications OAuth2 permettent à votre application tierce d'authentifier en toute sécurité les utilisateurs de cette instance Gitea.
remove_oauth2_application=Supprimer l'application OAuth2
remove_oauth2_application_desc=La suppression d'une application OAuth2 révoquera l'accès à tous les jetons d'accès signés. Continuer ?
remove_oauth2_application_success=L'application a été supprimée.
create_oauth2_application=Créer une nouvelle application OAuth2
create_oauth2_application_button=Créer une application
create_oauth2_application_success=Vous avez créé une nouvelle application OAuth2 avec succès.
update_oauth2_application_success=Vous avez mis à jour l'application OAuth2 avec succès.
oauth2_application_name=Nom de l'Application
oauth2_redirect_uri=URI de redirection
save_application=Enregistrer
oauth2_client_id=ID du client
oauth2_client_secret=Secret du client
oauth2_regenerate_secret=Regénérer le secret
oauth2_regenerate_secret_hint=Avez-vous perdu votre secret ?
oauth2_client_secret_hint=Le secret ne sera plus visible si vous revenez sur cette page. Veuillez sauvegarder votre secret.
oauth2_application_edit=Éditer
oauth2_application_create_description=Les applications OAuth2 permettent à votre application tierce d'accéder aux comptes d'utilisateurs de cette instance.
oauth2_application_remove_description=La suppression d'une application OAuth2 l'empêchera d'accéder aux comptes d'utilisateurs autorisés sur cette instance. Poursuivre ?
authorized_oauth2_applications=Applications OAuth2 autorisées
@ -661,6 +763,7 @@ confirm_delete_account=Confirmez la suppression
delete_account_title=Supprimer cet utilisateur
email_notifications.enable=Activer les notifications par e-mail
email_notifications.disable=Désactiver les notifications par e-mail
visibility.public=Public
visibility.limited=Limité
@ -864,6 +967,7 @@ issues=Tickets
pulls=Demandes d'ajout
project_board=Projets
packages=Paquets
actions=Actions
labels=Étiquettes
org_labels_desc=Les étiquettes de niveau d'une organisation peuvent être utilisés avec <strong>tous les dépôts</strong> de cette organisation
org_labels_desc_manage=gérer
@ -937,6 +1041,7 @@ editor.commit_directly_to_this_branch=Soumettre directement dans la branche <str
editor.create_new_branch=Créer une <strong>nouvelle branche</strong> pour cette révision et envoyer une nouvelle demande d'ajout.
editor.create_new_branch_np=Créer une <strong>nouvelle branche</strong> pour cette révision.
editor.propose_file_change=Proposer une modification du fichier
editor.new_branch_name=Nommer la nouvelle branche pour cette révision
editor.new_branch_name_desc=Nouveau nom de la branche…
editor.cancel=Annuler
editor.filename_cannot_be_empty=Le nom de fichier ne peut être vide.
@ -959,6 +1064,7 @@ commits.desc=Naviguer dans l'historique des modifications.
commits.commits=Révisions
commits.nothing_to_compare=Ces branches sont égales.
commits.search=Rechercher des révisions…
commits.search.tooltip=Vous pouvez préfixer les mots-clés avec "author:", "committer:", "after:", ou "before:", par exemple "revert author:Alice before:2019-01-13".
commits.find=Chercher
commits.search_all=Toutes les branches
commits.author=Auteur
@ -1053,6 +1159,7 @@ issues.label_templates.title=Charger un ensemble prédéfini d'étiquettes
issues.label_templates.info=Il n'existe pas encore d'étiquettes. Créez une étiquette avec 'Nouvelle étiquette' ou utilisez un jeu d'étiquettes prédéfini :
issues.label_templates.helper=Sélectionnez un ensemble d'étiquettes
issues.label_templates.use=Utiliser le jeu de labels
issues.label_templates.fail_to_load_file=Impossible de charger le fichier de modèle de libellé "%s" : %v
issues.add_label=a ajouté l'étiquette %s %s
issues.add_labels=a ajouté les étiquettes %s %s
issues.remove_label=a supprimé l'étiquette %s %s
@ -1157,6 +1264,8 @@ issues.save=Enregistrer
issues.label_title=Nom de l'étiquette
issues.label_description=Description de létiquette
issues.label_color=Couleur de l'étiquette
issues.label_exclusive_desc=Nommez le libellé <code>périmètre/élément</code> pour qu'il soit mutuellement exclusif avec d'autres libellés du <code>périmètre</code>.
issues.label_exclusive_warning=Tout libellé conflictuel sera supprimé lors de l'édition des libellés d'un ticket ou d'une demande de tirage.
issues.label_count=%d étiquettes
issues.label_open_issues=%d tickets ouverts
issues.label_edit=Éditer
@ -1326,6 +1435,7 @@ pulls.remove_prefix=Enlever le préfixe <strong>%s</strong>
pulls.data_broken=Cette demande de fusion est impossible par manque d'informations de bifurcation.
pulls.files_conflicted=Cette demande d'ajout contient des modifications en conflit avec la branche ciblée.
pulls.is_checking=Vérification des conflits de fusion en cours. Réessayez dans quelques instants.
pulls.is_empty=Les changements sur cette branche sont déjà sur la branche cible. Cette révision sera vide.
pulls.required_status_check_failed=Certains contrôles requis n'ont pas réussi.
pulls.required_status_check_missing=Certains contrôles requis sont manquants.
pulls.required_status_check_administrator=En tant qu'administrateur, vous pouvez toujours fusionner cette requête de pull.
@ -1552,6 +1662,7 @@ settings.hooks=Webhooks
settings.githooks=Déclencheurs Git
settings.basic_settings=Paramètres de base
settings.mirror_settings=Réglages Miroir
settings.mirror_settings.docs=Configurez votre projet pour automatiquement pousser ou tirer les modifications d'un autre dépôt. Les branches, étiquettes et révisions seront automatiquement synchronisées. <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/repo-mirror/">Comment mettre en miroir les dépôts ?</a>
settings.mirror_settings.mirrored_repository=Dépôt en miroir
settings.mirror_settings.direction=Direction
settings.mirror_settings.direction.push=Pousser
@ -1737,6 +1848,7 @@ settings.event_pull_request_review_desc=Demande d'ajout approvée, rejetée ou c
settings.event_pull_request_sync=Demande d'ajout synchronisée
settings.event_pull_request_sync_desc=Demande d'ajout synchronisée.
settings.branch_filter=Filtre de branche
settings.branch_filter_desc=Liste blanche pour les poussées, la création et la suppression de branches, spécifiées par motif de glob. Si vide ou <code>*</code>, les événements pour toutes les branches sont signalés. Voir la documentation <a href="https://pkg.go.dev/github.com/gobwas/glob#Compile">github.com/gobwas/glob</a> pour la syntaxe. Exemples: <code>master</code>, <code>{master,release*}</code>.
settings.active=Actif
settings.active_helper=Les informations sur les événements déclenchés seront envoyées à cette url de Webhook.
settings.add_hook_success=Nouveau Webhook ajouté.
@ -1813,7 +1925,12 @@ settings.choose_branch=Choisissez une branche…
settings.no_protected_branch=Il n'y a pas de branche protégée.
settings.edit_protected_branch=Éditer
settings.protected_branch_required_approvals_min=Le nombre de revues nécessaires ne peut être négatif.
settings.tags=Tags
settings.tags=Étiquettes
settings.tags.protection=Protection d'étiquette
settings.tags.protection.pattern=Motif d'étiquette
settings.tags.protection.create=Protéger l'étiquette
settings.tags.protection.none=Il n'y a pas d'étiquettes protégées.
settings.tags.protection.pattern.description=Vous pouvez utiliser soit un nom unique, soit un motif de glob ou une expression régulière qui correspondront à plusieurs étiquettes. Pour plus d'informations, veuillez vous reporter au <a target="_blank" rel="noopener" href="https://docs.gitea.io/fr-fr/protected-tags/">guide sur les étiquettes protégées</a>.
settings.bot_token=Jeton de Bot
settings.chat_id=ID de conversation
settings.matrix.homeserver_url=URL du serveur d'accueil
@ -1826,6 +1943,7 @@ settings.archive.success=Ce dépôt a été archivé avec succès.
settings.archive.error=Une erreur s'est produite lors de l'archivage du dépôt. Voir le journal pour plus de détails.
settings.archive.error_ismirror=Vous ne pouvez pas archiver un dépôt en miroir.
settings.archive.branchsettings_unavailable=Le paramétrage des branches n'est pas disponible quand le dépôt est archivé.
settings.archive.tagsettings_unavailable=Le paramétrage des étiquettes n'est pas disponible si le dépôt est archivé.
settings.unarchive.button=Désarchiver ce dépôt
settings.unarchive.header=Désarchiver ce dépôt
settings.unarchive.text=Désarchiver le dépôt lui permettra de recevoir des révisions, ainsi que des nouveaux tickets ou demandes d'ajout.
@ -1886,6 +2004,7 @@ diff.file_image_height=Hauteur
diff.file_byte_size=Taille
diff.file_suppressed=Fichier diff supprimé car celui-ci est trop grand
diff.file_suppressed_line_too_long=Diff de fichier supprimé car une ou plusieurs lignes sont trop longues
diff.generated=générée
diff.comment.placeholder=Laisser un commentaire
diff.comment.markdown_info=Mise en page avec markdown est prise en charge.
diff.comment.add_single_comment=Ajouter un commentaire
@ -1914,13 +2033,17 @@ release.prerelease=Pré-publication
release.stable=Stable
release.compare=Comparer
release.edit=Éditer
release.ahead.commits=<strong>%d</strong> révisions
release.ahead.target=à %s depuis cette livraison
tag.ahead.target=à %s depuis cette étiquette
release.source_code=Code source
release.new_subheader=Les versions organisent les versions publiées du projet.
release.edit_subheader=Les versions organisent les versions publiées du projet.
release.tag_name=Nom du tag
release.target=Cible
release.tag_helper=Choisissez une étiquette existante ou créez une nouvelle étiquette.
release.tag_helper_new=Nouvelle étiquette. Cette étiquette sera créée à partir de la cible.
release.tag_helper_existing=Étiquette existante.
release.prerelease_desc=Marquer comme pré-version
release.prerelease_helper=Marquer cette version comme impropre à la production.
release.cancel=Annuler
@ -1930,13 +2053,19 @@ release.edit_release=Modifier la version
release.delete_release=Supprimer cette version
release.delete_tag=Supprimer l'étiquette
release.deletion=Supprimer cette version
release.deletion_desc=La suppression d'une version la supprime seulement de Gitea. Les étiquettes Git, le contenu du dépôt et l'historique restent inchangés. Voulez vous continuer ?
release.deletion_success=Cette livraison a été supprimée.
release.deletion_tag_desc=Ceci supprimera cette étiquette du dépôt. Le contenu du dépôt et l'historique resteront inchangés. Continuer ?
release.deletion_tag_success=L'étiquette a été supprimée.
release.tag_name_already_exist=Une version avec ce nom d'étiquette existe déjà.
release.tag_name_invalid=Le nom de l'étiquette est invalide.
release.tag_name_protected=Ce nom d'étiquette est protégé.
release.tag_already_exist=Ce nom d'étiquette existe déjà.
release.downloads=Téléchargements
release.download_count=Télécharger: %s
release.add_tag_msg=Utiliser le titre et le contenu de la version comme message d'étiquette.
release.add_tag=Créer uniquement une Balise
release.tags_for=Étiquettes pour %s
branch.name=Nom de la branche
branch.search=Rechercher des branches
@ -1944,22 +2073,38 @@ branch.delete_head=Supprimer
branch.delete_html=Supprimer la branche
branch.delete_desc=Supprimer une branche est permanent. Cela <strong>NE PEUVENT</strong> être annulées. Continuer ?
branch.create_branch=Créer la branche <strong>%s</strong>
branch.create_from=`de "%s"`
branch.tag_collision=La branche "%s" ne peut être créée car une étiquette avec un nom identique existe déjà dans le dépôt.
branch.deleted_by=Supprimée par %s
branch.restore=`Restaurer la branche "%s"`
branch.download=`Télécharger la branche "%s"`
branch.included_desc=Cette branche fait partie de la branche par défaut
branch.included=Incluses
branch.create_new_branch=Créer une branche à partir de la branche :
branch.confirm_create_branch=Créer une branche
branch.create_branch_operation=Créer une branche
branch.new_branch=Créer une nouvelle branche
branch.new_branch_from=`Créer une nouvelle branche à partir de "%s"`
branch.renamed=La branche %s à été renommée en %s.
tag.create_tag=Créer l'étiquette <strong>%s</strong>
tag.create_tag_operation=Créer une étiquette
tag.confirm_create_tag=Créer une étiquette
tag.create_tag_from=`Créer une nouvelle étiquette à partir de "%s"`
tag.create_success=L'étiquette "%s" a été créée.
topic.manage_topics=Gérer les sujets
topic.done=Terminé
topic.count_prompt=Vous ne pouvez pas sélectionner plus de 25 sujets
topic.format_prompt=Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
find_file.go_to_file=Aller au fichier
find_file.no_matching=Aucun fichier correspondant trouvé
error.csv.too_large=Impossible de visualiser le fichier car il est trop volumineux.
error.csv.unexpected=Impossible de visualiser ce fichier car il contient un caractère inattendu ligne %d, colonne %d.
error.csv.invalid_field_count=Impossible de visualiser ce fichier car il contient un nombre de champs incorrect à la ligne %d.
[org]
org_name_holder=Nom de l'organisation
@ -1969,6 +2114,7 @@ create_org=Créer une organisation
repo_updated=Mis à jour
members=Membres
teams=Équipes
code=Code
lower_members=Membres
lower_repositories=dépôts
create_new_team=Nouvelle équipe
@ -1983,6 +2129,7 @@ team_permission_desc=Autorisation
team_unit_desc=Permettre laccès aux Sections du dépôt
team_unit_disabled=(Désactivé)
form.name_pattern_not_allowed=Le motif "%s" n'est pas autorisé dans un nom d'organisation.
form.create_org_not_allowed=Vous n'êtes pas autorisé à créer une organisation.
settings=Paramètres
@ -1994,6 +2141,7 @@ settings.permission=Autorisations
settings.repoadminchangeteam=L'administrateur de dépôt peut ajouter et supprimer l'accès aux équipes
settings.visibility=Visibilité
settings.visibility.public=Public
settings.visibility.limited=Limité (Visible uniquement aux utilisateurs authentifiés)
settings.visibility.limited_shortname=Limité
settings.visibility.private=Privé (Visible uniquement aux membres de lorganisation)
settings.visibility.private_shortname=Privé
@ -2033,8 +2181,13 @@ teams.leave=Quitter
teams.leave.detail=Quitter %s?
teams.can_create_org_repo=Créer des dépôts
teams.can_create_org_repo_helper=Les membres peuvent créer de nouveaux dépôts dans l'organisation. Le créateur obtiendra l'accès administrateur au nouveau dépôt.
teams.read_access=Lue(s)
teams.none_access=Aucun accès
teams.none_access_helper=Les membres ne peuvent voir ou faire quoi que ce soit sur cette partie. Sans effet pour les dépôts publics.
teams.general_access=Accès général
teams.general_access_helper=Les permissions des membres seront déterminées par la table des permissions ci-dessous.
teams.read_access=Lecture
teams.read_access_helper=Les membres peuvent voir et cloner les dépôts de l'équipe.
teams.write_access=Écriture
teams.write_access_helper=Les membres peuvent voir et pousser dans les dépôts de l'équipe.
teams.admin_access=Accès Administrateur
teams.admin_access_helper=Les membres peuvent tirer et pousser des modifications vers les dépôts de l'équipe, et y ajouter des collaborateurs.
@ -2045,6 +2198,8 @@ teams.members=Membres de L'Équipe
teams.update_settings=Valider
teams.delete_team=Supprimer l'équipe
teams.add_team_member=Ajouter un Membre
teams.invite_team_member=Inviter à %s
teams.invite_team_member.list=Invitations en attente
teams.delete_team_title=Supprimer l'équipe
teams.delete_team_desc=Supprimer une équipe supprime l'accès aux dépôts à ses membres. Continuer ?
teams.delete_team_success=Léquipe a été supprimée.
@ -2058,6 +2213,7 @@ teams.remove_all_repos_title=Supprimer tous les dépôts de l'équipe
teams.remove_all_repos_desc=Ceci supprimera tous les dépôts de l'équipe.
teams.add_all_repos_title=Ajouter tous les dépôts
teams.add_all_repos_desc=Ceci ajoutera tous les dépôts de l'organisation à l'équipe.
teams.add_nonexistent_repo=Le dépôt que vous essayez d'ajouter n'existe pas, veuillez le créer d'abord.
teams.add_duplicate_users=Lutilisateur est déjà un membre de léquipe.
teams.repos.none=Aucun dépôt n'est accessible par cette équipe.
teams.members.none=Aucun membre dans cette équipe.
@ -2068,12 +2224,16 @@ teams.all_repositories_helper=L'équipe a accès à tous les dépôts. Sélectio
teams.all_repositories_read_permission_desc=Cette équipe accorde l'accès <strong>en lecture</strong> à <strong>tous les dépôts</strong> : les membres peuvent voir et cloner les dépôts.
teams.all_repositories_write_permission_desc=Cette équipe accorde l'accès <strong>en écriture</strong> à <strong>tous les dépôts</strong> : les membres peuvent lire et écrire dans les dépôts.
teams.all_repositories_admin_permission_desc=Cette équipe accorde l'accès <strong>administrateur</strong> à <strong>tous les dépôts</strong> : les membres peuvent lire, écrire dans et ajouter des collaborateurs aux dépôts.
teams.invite.title=Vous avez été invité à rejoindre léquipe <strong>%s</strong> dans lorganisation <strong>%s</strong>.
teams.invite.by=Invité par %s
teams.invite.description=Veuillez cliquer sur le bouton ci-dessous pour rejoindre léquipe.
[admin]
dashboard=Tableau de bord
users=Comptes utilisateurs
organizations=Organisations
repositories=Dépôts
hooks=Déclencheurs web
authentication=Sources d'authentification
emails=Courriels de l'utilisateur
config=Configuration
@ -2083,6 +2243,7 @@ first_page=Première
last_page=Dernière
total=Total : %d
dashboard.new_version_hint=Gitea %s est maintenant disponible, vous utilisez %s. Consultez <a target="_blank" rel="noreferrer" href="https://blog.gitea.io">le blog</a> pour plus de détails.
dashboard.statistic=Résumé
dashboard.operations=Opérations de maintenance
dashboard.system_status=État du système
@ -2097,6 +2258,7 @@ dashboard.task.cancelled=Tâche: %[1]s a annulé: %[3]s
dashboard.task.error=Erreur dans la tâche: %[1]s: %[3]s
dashboard.task.finished=Tâche: %[1]s démarrée par %[2]s est terminée
dashboard.task.unknown=Tâche inconnue: %[1]s
dashboard.cron.started=Tâche planifiée démarrée : %[1]s
dashboard.cron.process=Tâche planifiée: %[1]s
dashboard.cron.cancelled=Tâche planifiée : %s annulée : %[3]s
dashboard.cron.error=Erreur dans la tâche planifiée : %s: %[3]s
@ -2120,6 +2282,7 @@ dashboard.resync_all_hooks=Re-synchroniser les déclencheurs Git pre-receive, up
dashboard.reinit_missing_repos=Réinitialiser tous les dépôts Git manquants pour lesquels un enregistrement existe
dashboard.sync_external_users=Synchroniser les données de lutilisateur externe
dashboard.cleanup_hook_task_table=Nettoyer la table hook_task
dashboard.cleanup_packages=Nettoyer des paquets expirés
dashboard.server_uptime=Uptime du serveur
dashboard.current_goroutine=Goroutines actuelles
dashboard.current_memory_usage=Utilisation Mémoire actuelle
@ -2150,6 +2313,12 @@ dashboard.total_gc_pause=Pause GC
dashboard.last_gc_pause=Dernière Pause GC
dashboard.gc_times=Nombres de GC
dashboard.delete_old_actions=Supprimer toutes les anciennes actions de la base de données
dashboard.delete_old_actions.started=Suppression de toutes les anciennes actions de la base de données démarrée.
dashboard.update_checker=Vérificateur de mise à jour
dashboard.delete_old_system_notices=Supprimer toutes les anciennes observations de la base de données
dashboard.stop_zombie_tasks=Arrêter les tâches zombies
dashboard.stop_endless_tasks=Arrêter les tâches sans fin
dashboard.cancel_abandoned_jobs=Annuler les jobs abandonnés
users.user_manage_panel=Gestion du compte utilisateur
users.new_account=Créer un compte
@ -2164,6 +2333,7 @@ users.created=Créés
users.last_login=Dernière connexion
users.never_login=Jamais connecté
users.send_register_notify=Envoyer une notification d'inscription
users.new_success=Le compte "%s" a bien été créé.
users.edit=Éditer
users.auth_source=Sources d'authentification
users.local=Locales
@ -2178,14 +2348,21 @@ users.prohibit_login=Désactiver la connexion
users.is_admin=Est Administrateur
users.is_restricted=Est restreint
users.allow_git_hook=Autoriser la création de Git Hooks
users.allow_git_hook_tooltip=Les Déclencheurs Git sont exécutés par le même utilisateur que Gitea, qui a des privilèges systèmes élevés. Les utilisateurs ayant ce droit peuvent altérer touts les dépôts, compromettre la base de données applicative, et se promouvoir administrateurs de Gitea.
users.allow_import_local=Autoriser l'importation de dépôts locaux
users.allow_create_organization=Autoriser la création d'organisations
users.update_profile=Modifier un compte
users.delete_account=Supprimer cet utilisateur
users.cannot_delete_self=Vous ne pouvez pas vous supprimer vous-même
users.still_own_repo=Cet utilisateur possède un ou plusieurs dépôts. Veuillez les supprimer ou les transférer à un autre utilisateur.
users.still_has_org=Cet utilisateur est membre d'une organisation. Veuillez le retirer de toutes les organisations dont il est membre au préalable.
users.purge=Purger l'utilisateur
users.purge_help=Éradique l'utilisateur et tous ses dépôts, organisations, commentaires et paquets.
users.still_own_packages=Cet utilisateur possède encore un ou plusieurs paquets. Supprimez dabord ces paquets.
users.deletion_success=Le compte a été supprimé.
users.reset_2fa=Réinitialiser l'authentification à deux facteurs
users.list_status_filter.menu_text=Filtrer
users.list_status_filter.reset=Réinitialiser
users.list_status_filter.is_active=Actif
users.list_status_filter.is_admin=Administrateur
users.list_status_filter.is_restricted=Restreint
@ -2313,6 +2490,7 @@ auths.tip.yandex=`Créez une nouvelle application sur https://oauth.yandex.com/c
auths.tip.mastodon=Entrez une URL d'instance personnalisée pour l'instance mastodon avec laquelle vous voulez vous authentifier (ou utiliser celle par défaut)
auths.edit=Mettre à jour la source d'authentification
auths.activated=Cette source d'authentification est activée
auths.new_success=L'authentification "%s" a été ajoutée.
auths.update_success=La source d'authentification a été mise à jour.
auths.update=Mettre à jour la source d'authentification
auths.delete=Supprimer la source d'authentification
@ -2320,7 +2498,10 @@ auths.delete_auth_title=Suppression de la source d'authentification
auths.delete_auth_desc=La suppression d'une source d'authentification empêche les utilisateurs de l'utiliser pour se connecter. Continuer ?
auths.still_in_used=Cette source d'authentification est utilisée par un ou plusieurs utilisateurs, veuillez convertir ou supprimer ces comptes utilisateurs avant toute action.
auths.deletion_success=La source d'authentification a été supprimée.
auths.login_source_exist=La source d'authentification "%s" existe déjà.
auths.login_source_of_type_exist=Une source d'authentification de ce type existe déjà.
auths.unable_to_initialize_openid=Impossible d'initialiser le fournisseur OpenID Connect : %s
auths.invalid_openIdConnectAutoDiscoveryURL=URL de découverte automatique invalide (une URL valide commence par http:// ou https://)
config.server_config=Configuration du serveur
config.app_name=Titre du site
@ -2328,6 +2509,7 @@ config.app_ver=Version de Gitea
config.app_url=URL de base de Gitea
config.custom_conf=Chemin du fichier de configuration
config.custom_file_root_path=Emplacement personnalisé du fichier racine
config.domain=Domaine du serveur
config.offline_mode=Mode hors-ligne
config.disable_router_log=Désactiver la Journalisation du Routeur
config.run_user=Exécuter avec l'utilisateur
@ -2343,6 +2525,7 @@ config.reverse_auth_user=Annuler l'Authentification de l'Utilisateur
config.ssh_config=Configuration SSH
config.ssh_enabled=Activé
config.ssh_start_builtin_server=Utiliser le serveur incorporé
config.ssh_domain=Domaine du serveur SSH
config.ssh_port=Port
config.ssh_listen_port=Port d'écoute
config.ssh_root_path=Emplacement racine
@ -2393,16 +2576,23 @@ config.queue_length=Longueur de la file d'attente
config.deliver_timeout=Expiration d'Envoi
config.skip_tls_verify=Passer la vérification TLS
config.mailer_config=Configuration du service SMTP
config.mailer_enabled=Activé
config.mailer_enable_helo=Activer HELO
config.mailer_name=Nom
config.mailer_protocol=Protocole
config.mailer_smtp_addr=Adresse SMTP
config.mailer_smtp_port=Port SMTP
config.mailer_user=Utilisateur
config.mailer_use_sendmail=Utiliser Sendmail
config.mailer_sendmail_path=Chemin daccès à Sendmail
config.mailer_sendmail_args=Arguments supplémentaires pour Sendmail
config.mailer_sendmail_timeout=Délai dattente de Sendmail
config.mailer_use_dummy=Factice
config.test_email_placeholder=E-mail (ex: test@example.com)
config.send_test_mail=Envoyer un e-mail de test
config.test_mail_failed=Impossible d'envoyer un email de test à "%s" : %v
config.test_mail_sent=Un e-mail de test a été envoyé à "%s".
config.oauth_config=Configuration OAuth
config.oauth_enabled=Activé
@ -2432,6 +2622,8 @@ config.git_disable_diff_highlight=Désactiver la surbrillance syntaxique de Diff
config.git_max_diff_lines=Lignes de Diff Max (pour un seul fichier)
config.git_max_diff_line_characters=Nombre max de caractères de Diff (pour une seule ligne)
config.git_max_diff_files=Nombre max de fichiers de Diff (à afficher)
config.git_enable_reflogs=Activer les reflogs
config.git_reflog_expiry_time=Délai d'expiration
config.git_gc_args=Arguments de GC
config.git_migrate_timeout=Délai imparti pour une migration
config.git_mirror_timeout=Délai imparti pour mettre à jour le miroir
@ -2451,6 +2643,8 @@ config.access_log_template=Modèle
config.xorm_log_mode=Mode de journalisation de XORM
config.xorm_log_sql=Activer la journalisation SQL
config.get_setting_failed=Impossible d'obtenir le paramètre %s
config.set_setting_failed=Impossible de définir le paramètre %s
monitor.cron=Tâches récurrentes
monitor.name=Nom
@ -2459,12 +2653,16 @@ monitor.next=Suivant
monitor.previous=Précédent
monitor.execute_times=Exécutions
monitor.process=Processus en cours d'exécution
monitor.stacktrace=Piles d'execution
monitor.goroutines=%d Goroutines
monitor.desc=Description
monitor.start=Heure de démarrage
monitor.execute_time=Heure d'Éxécution
monitor.last_execution_result=Résultat
monitor.process.cancel=Annuler le processus
monitor.process.cancel_desc=L'annulation d'un processus peut entraîner une perte de données
monitor.process.cancel_notices=Annuler : <strong>%s</strong>?
monitor.process.children=Enfant
monitor.queues=Files d'attente
monitor.queue=File d'attente : %s
monitor.queue.name=Nom
@ -2472,6 +2670,7 @@ monitor.queue.type=Type
monitor.queue.exemplar=Type d'exemple
monitor.queue.numberworkers=Nombre de processus
monitor.queue.maxnumberworkers=Nombre maximale de processus
monitor.queue.numberinqueue=Position dans la queue
monitor.queue.review=Revoir la configuration
monitor.queue.review_add=Réviser/Ajouter des processus
monitor.queue.configuration=Configuration initiale
@ -2479,6 +2678,7 @@ monitor.queue.nopool.title=Pas de réservoir de processus
monitor.queue.nopool.desc=Cette file d'attente contient d'autres files d'attente et ne possède pas de réservoir de processus.
monitor.queue.wrapped.desc=Une file d'attente enveloppée, enveloppe une file d'attente de démarrage lente, mettant en mémoire tampon les requêtes dans un canal. Elle n'a pas de pool de processus elle-même.
monitor.queue.persistable-channel.desc=Un canal persistant enveloppe deux files d'attente, une file d'attente de canal qui a son propre pool de processus et une file d'attente de niveau pour les requêtes persistantes des arrêts précédents. Il ne dispose pas d'un pool de travailleurs lui-même.
monitor.queue.flush=Évacuer la queue
monitor.queue.pool.timeout=Expiration du délai
monitor.queue.pool.addworkers.title=Ajouter un processus
monitor.queue.pool.addworkers.submit=Ajouter un processus
@ -2491,6 +2691,12 @@ monitor.queue.pool.flush.title=Vider la file d'attente
monitor.queue.pool.flush.desc=Vider va ajouter un processus qui se terminera une fois que la file d'attente est vide, ou qu'elle est en délai d'attente dépassé.
monitor.queue.pool.flush.submit=Ajouter un processus de vidage
monitor.queue.pool.flush.added=Processus de vidage ajouté pour %[1]s
monitor.queue.pool.pause.title=Suspendre la queue
monitor.queue.pool.pause.desc=Suspendre une queue l'empêchera de traiter les données
monitor.queue.pool.pause.submit=Suspendre la queue
monitor.queue.pool.resume.title=Reprendre la queue
monitor.queue.pool.resume.desc=Définit cette queue pour reprendre le travail
monitor.queue.pool.resume.submit=Reprendre la queue
monitor.queue.settings.title=Paramètres du réservoir
monitor.queue.settings.desc=Les pools se développent dynamiquement en réponse au blocage de la file d'attente des processus. Ces changements n'affecteront pas les groupes de processus actuels.
@ -2520,6 +2726,7 @@ monitor.queue.pool.cancel_desc=Quitter une file d'attente sans aucun groupe de p
notices.system_notice_list=Informations
notices.view_detail_header=Voir les détails de l'information système
notices.operations=Opérations
notices.select_all=Tout Sélectionner
notices.deselect_all=Tout désélectionner
notices.inverse_selection=Inverser la sélection
@ -2535,12 +2742,15 @@ notices.delete_success=Les informations systèmes ont été supprimées.
[action]
create_repo=a créé le dépôt <a href="%s">%s</a>
rename_repo=a rebaptisé le dépôt de <code>%[1]s</code> vers <a href="%[2]s">%[3]s</a>
create_issue=`ticket ouvert <a href="%[1]s">%[3]s#%[2]s</a>`
close_issue=`ticket fermé <a href="%[1]s">%[3]s#%[2]s</a>`
create_pull_request=`a créé la demande d'ajout <a href="%[1]s">%[3]s#%[2]s</a>`
close_pull_request=`a fermé la demande d'ajout <a href="%[1]s">%[3]s#%[2]s</a>`
reopen_pull_request=`a réouvert la demande d'ajout <a href="%[1]s">%[3]s#%[2]s</a>`
comment_pull=`a commenté la demande d'ajout <a href="%[1]s">%[3]s#%[2]s</a>`
merge_pull_request=`a fusionné la demande d'ajout <a href="%[1]s">%[3]s#%[2]s</a>`
transfer_repo=a transféré le dépôt <code>%s</code> à <a href="%s">%s</a>
push_tag=a poussé l'étiquette <a href="%[2]s">%[3]s</a> vers <a href="%[1]s">%[4]s</a>
delete_tag=étiquette supprimée %[2]s de <a href="%[1]s">%[3]s</a>
delete_branch=branche %[2]s supprimée de <a href="%[1]s">%[3]s</a>
compare_branch=Comparer
@ -2588,6 +2798,9 @@ pin=Epingler la notification
mark_as_read=Marquer comme lu
mark_as_unread=Marquer comme non lue
mark_all_as_read=Tout marquer comme lu
subscriptions=Abonnements
watching=Suivi
no_subscriptions=Pas d'abonnements
[gpg]
default_key=Signé avec la clé par défaut
@ -2601,35 +2814,216 @@ error.probable_bad_signature=AVERTISSEMENT ! Bien qu'il y ait une clé avec cet
error.probable_bad_default_signature=AVERTISSEMENT ! Bien que la clé par défaut ait cet ID, elle ne vérifie pas cette livraison ! Cette livraison est SUSPECTE.
[units]
unit=Unité
error.no_unit_allowed_repo=Vous n'êtes pas autorisé à accéder à n'importe quelle section de ce dépôt.
error.unit_not_allowed=Vous n'êtes pas autorisé à accéder à cette section du dépôt.
[packages]
title=Paquets
desc=Gérer les paquets du dépôt.
empty=Il n'y pas de paquet pour le moment.
empty.documentation=Pour plus d'informations sur le registre de paquets, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/usage/packages/overview/">la documentation</a>.
empty.repo=Avez-vous téléchargé un paquet, mais il n'est pas affiché ici? Allez dans les <a href="%[1]s">paramètres du paquet</a> et liez le à ce dépôt.
filter.type=Type
filter.type.all=Tous
filter.no_result=Votre filtre n'affiche aucun résultat.
filter.container.tagged=Balisé
filter.container.untagged=Débalisé
published_by=%[1]s publié par <a href="%[2]s">%[3]s</a>
published_by_in=%[1]s publié par <a href="%[2]s">%[3]s</a> en <a href="%[4]s"><strong>%[5]s</strong></a>
installation=Installation
about=À propos de ce paquet
requirements=Exigences
dependencies=Dépendances
keywords=Mots-clés
details=Détails
details.author=Auteur
details.project_site=Site du projet
details.repository_site=Site du dépôt
details.documentation_site=Site de documentation
details.license=Licence
assets=Ressources
versions=Versions
versions.view_all=Voir tout
dependency.id=ID
dependency.version=Version
cargo.install=Pour installer le paquet en utilisant Cargo, exécutez la commande suivante :
cargo.documentation=Pour plus d'informations sur le registre Cargo, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/cargo/">la documentation</a>.
cargo.details.repository_site=Site du dépôt
cargo.details.documentation_site=Site de documentation
chef.registry=Configurer ce registre dans votre fichier <code>~/.chef/config.rb</code>:
chef.install=Pour installer le paquet, exécutez la commande suivante :
chef.documentation=Pour plus d'informations sur le registre Chef, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/chef/">la documentation</a>.
composer.registry=Configurez ce registre dans votre fichier <code>~/.composer/config.json</code> :
composer.install=Pour installer le paquet en utilisant Composer, exécutez la commande suivante :
composer.documentation=Pour plus d'informations sur le registre Composer voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/composer/">la documentation</a>.
composer.dependencies=Dépendances
composer.dependencies.development=Dépendances de développement
conan.details.repository=Dépôt
conan.registry=Configurez ce registre à partir d'un terminal :
conan.install=Pour installer le paquet en utilisant Conan, exécutez la commande suivante :
conan.documentation=Pour plus d'informations sur le registre Conan, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/conan/">la documentation</a>.
conda.documentation=Pour plus d'informations sur le registre Conda, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/conda/">la documentation</a>.
conda.details.repository_site=Site du dépôt
conda.details.documentation_site=Site de documentation
container.details.type=Type d'image
container.details.platform=Plateforme
container.pull=Tirez l'image depuis un terminal :
container.digest=Empreinte :
container.documentation=Pour plus d'informations sur le registre Container, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/container/">la documentation</a>.
container.multi_arch=SE / Arch
container.layers=Calques d'image
container.labels=Étiquettes
container.labels.key=Clé
container.labels.value=Valeur
generic.download=Télécharger le paquet depuis un terminal :
generic.documentation=Pour plus d'informations sur le registre générique, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/generic/">la documentation</a>.
helm.registry=Configurer ce registre à partir d'un terminal :
helm.install=Pour installer le paquet, exécutez la commande suivante :
helm.documentation=Pour plus d'informations sur le registre Helm, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/helm/">la documentation</a>.
maven.install2=Exécuter dans un terminal :
maven.documentation=Pour plus d'informations sur le registre Maven, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/maven/">la documentation</a>.
nuget.registry=Configurer ce registre à partir d'un terminal :
nuget.documentation=Pour plus d'informations sur le registre NuGet, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/nuget/">la documentation</a>.
nuget.dependency.framework=Cadriciel cible
npm.install2=ou ajoutez-le au fichier package.json :
npm.documentation=Pour plus d'informations sur le registre npm, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/npm/">la documentation</a>.
npm.dependencies=Dépendances
npm.dependencies.development=Dépendances de développement
npm.dependencies.peer=Dépendances de pairs
npm.dependencies.optional=Dépendances optionnelles
npm.details.tag=Balise
pub.documentation=Pour davantage d'informations sur le registre Pub, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/pub/">la documentation</a>.
pypi.requires=Nécessite Python
pypi.documentation=Pour plus d'informations sur le registre PyPI, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/pypi/">la documentation</a>.
rubygems.install2=ou ajoutez-le au Gemfile :
rubygems.dependencies.runtime=Dépendances d'exécution
rubygems.dependencies.development=Dépendances de développement
rubygems.required.ruby=Nécessite la version de Ruby
rubygems.required.rubygems=Nécessite la version de RubyGem
rubygems.documentation=Pour plus d'informations sur le registre RubyGems, consulter <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/rubygems/">la documentation</a>.
swift.registry=Configurez ce registre à partir d'un terminal :
swift.install=Ajoutez le paquet dans votre fichier <code>Package.swift</code>:
swift.install2=et exécutez la commande suivante :
swift.documentation=Pour plus d'informations sur le registre Swift, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/swift/">la documentation</a>.
vagrant.documentation=Pour plus d'informations sur le registre Vagrant, voir <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/fr-fr/packages/vagrant/">la documentation</a>.
settings.link=Lier ce paquet à un dépôt
settings.link.select=Sélectionner un dépôt
settings.link.button=Actualiser le lien du dépôt
settings.link.success=Le lien du dépôt a été mis à jour avec succès.
settings.link.error=Impossible de mettre à jour le lien du dépôt.
settings.delete=Supprimer le paquet
settings.delete.success=Le paquet a été supprimé.
settings.delete.error=Impossible de supprimer le paquet.
owner.settings.cargo.title=Index du Registre Cargo
owner.settings.cargo.initialize=Initialiser l'index
owner.settings.cargo.initialize.description=Pour utiliser le registre Cargo, un dépôt git d'index spécial est nécessaire. Ici, vous pouvez le (re)créer avec la configuration requise.
owner.settings.cargo.initialize.error=Impossible d'initialiser l'index de Cargo : %v
owner.settings.cargo.initialize.success=L'index Cargo a été créé avec succès.
owner.settings.cargo.rebuild=Reconstruire l'index
owner.settings.cargo.rebuild.description=Si lindex est désynchronisé avec les paquets cargo stockés, vous pouvez le reconstruire ici.
owner.settings.cargo.rebuild.error=Impossible de reconstruire l'index Cargo : %v
owner.settings.cargo.rebuild.success=L'index Cargo a été reconstruit avec succès.
owner.settings.cleanuprules.title=Gérer les règles de nettoyage
owner.settings.cleanuprules.add=Ajouter une règle de nettoyage
owner.settings.cleanuprules.edit=Modifier la règle de nettoyage
owner.settings.cleanuprules.none=Aucune règle de nettoyage disponible. Consultez la documentation pour en savoir plus.
owner.settings.cleanuprules.preview=Aperçu des règles de nettoyage
owner.settings.cleanuprules.preview.overview=%d paquets sont programmés pour être supprimés.
owner.settings.cleanuprules.preview.none=La règle de nettoyage ne correspond à aucun paquet.
owner.settings.cleanuprules.enabled=Activé
owner.settings.cleanuprules.pattern_full_match=Appliquer le modèle au nom complet du paquet
owner.settings.cleanuprules.keep.title=Les versions qui correspondent à ces règles sont conservées, même si elles correspondent à une règle de suppression ci-dessous.
owner.settings.cleanuprules.keep.count=Garder le plus récent
owner.settings.cleanuprules.keep.count.1=1 version par paquet
owner.settings.cleanuprules.keep.count.n=%d versions par paquet
owner.settings.cleanuprules.keep.pattern=Garder les versions correspondantes
owner.settings.cleanuprules.keep.pattern.container=La version <code>la plus récente</code> est toujours conservée pour les paquets Container.
owner.settings.cleanuprules.remove.title=Les versions qui correspondent à ces règles sont supprimées, sauf si une règle ci-dessus dit de les garder.
owner.settings.cleanuprules.remove.days=Supprimer les versions antérieures à
owner.settings.cleanuprules.remove.pattern=Supprimer les versions correspondantes
owner.settings.cleanuprules.success.update=La règle de nettoyage a été mise à jour.
owner.settings.cleanuprules.success.delete=La règle de nettoyage a été supprimée.
owner.settings.chef.title=Dépôt Chef
owner.settings.chef.keypair=Générer une paire de clés
owner.settings.chef.keypair.description=Génère une paire de clés utilisée pour sauthentifier auprès du registre Chef. La précédente clé ne pourra plus être utilisée.
[secrets]
secrets=Secrets
description=Les secrets seront transmis à certaines actions et ne pourront pas être lus autrement.
none=Il n'y a pas encore de secrets.
value=Valeur
name=Nom
creation=Ajouter un secret
creation.name_placeholder=Caractères alphanumériques ou tirets bas uniquement, insensibles à la casse, ne peut commencer par GITEA_ ou GITHUB_
creation.success=Le secret "%s" a été ajouté.
creation.failed=Impossible d'ajouter le secret.
deletion=Supprimer le secret
deletion.description=La suppression d'un secret est permanente et ne peut pas être annulée. Continuer ?
deletion.success=Le secret a été supprimé.
deletion.failed=Impossible de supprimer le secret.
[actions]
actions=Actions
unit.desc=Gérer les actions
status.unknown=Inconnu
status.waiting=En attente
status.running=En cours d'exécution
status.success=Succès
status.failure=Échec
status.cancelled=Annulé
status.skipped=Ignoré
status.blocked=Bloqué
runners=Exécuteurs
runners.runner_manage_panel=Gestion des exécuteurs
runners.new=Créer un nouvel exécuteur
runners.new_notice=Comment démarrer un exécuteur
runners.status=Statut
runners.id=ID
runners.name=Nom
runners.owner_type=Type
runners.description=Description
runners.labels=Étiquettes
runners.last_online=Dernière fois en ligne
runners.agent_labels=Étiquettes de l'agent
runners.custom_labels=Étiquettes personnalisées
runners.custom_labels_helper=Les libellés personnalisées sont des libellés ajoutées manuellement par un administrateur. Ils sont séparés par des virgules et les blancs les cernant seront taillés.
runners.runner_title=Exécuteur
runners.task_list=Tâches récentes sur cet exécuteur
runners.task_list.run=Exécuter
runners.task_list.status=Statut
runners.task_list.repository=Dépôt
runners.task_list.commit=Commit
runners.task_list.done_at=Fait à
runners.edit_runner=Éditer l'Exécuteur
runners.update_runner=Mettre à jour les modifications
runners.update_runner_success=Exécuteur mis à jour avec succès
runners.update_runner_failed=Impossible de mettre à jour l'Exécuteur
runners.delete_runner=Supprimer cet exécuteur
runners.delete_runner_success=Exécuteur supprimé avec succès
runners.delete_runner_failed=Impossible de supprimer l'Exécuteur
runners.delete_runner_header=Êtes-vous sûr de vouloir supprimer cet exécuteur ?
runners.none=Aucun exécuteur disponible
runners.status.unspecified=Inconnu
runners.status.idle=Inactif
runners.status.active=Actif
runners.status.offline=Hors-ligne
runners.version=Version
runners.reset_registration_token_success=Le jeton dinscription de lexécuteur a été réinitialisé avec succès
runs.open_tab=%d Ouvert
runs.closed_tab=%d Fermé
runs.commit=Commit
runs.pushed_by=Poussée par
runs.no_matching_runner_helper=Aucun exécuteur correspondant : %s
need_approval_desc=Besoin d'approbation pour exécuter des workflows pour une demande de fusion de bifurcation.
[projects]
type-1.display_name=Projet personnel
type-2.display_name=Projet de dépôt
type-3.display_name=Projet dorganisation

View File

@ -462,6 +462,7 @@ team_invite.text_3=Not: Bu davet %[1] içindi. Bu daveti beklemiyorsanız, e-pos
[modal]
yes=Evet
no=Hayır
confirm=Onayla
cancel=İptal
modify=Güncelle
@ -1618,7 +1619,10 @@ pulls.tab_files=Değiştirilen Dosyalar
pulls.reopen_to_merge=Lütfen birleştirme gerçekleştirmek için bu değişiklik isteğini yeniden açın.
pulls.cant_reopen_deleted_branch=Dal silindiğinden bu değişiklik isteği yeniden açılamaz.
pulls.merged=Birleştirildi
pulls.merged_success=Değişiklik isteği başarıyla birleştirildi ve kapatıldı
pulls.closed=Değişiklik isteği kapatıldı
pulls.manually_merged=Elle birleştirildi
pulls.merged_info_text=%s dalı şimdi silinebilir.
pulls.is_closed=Değişiklik isteği kapatıldı.
pulls.title_wip_desc=`Değişiklik isteğinin yanlışlıkla birleştirilmesini önlemek için, <a href="#">başlığı <strong>%s</strong> ile başlatın</a>`
pulls.cannot_merge_work_in_progress=Bu değişiklik isteği, devam eden bir çalışma olarak işaretlendi.
@ -3221,6 +3225,7 @@ cargo.details.repository_site=Depo Sitesi
cargo.details.documentation_site=Belge Sitesi
chef.registry=Bu kütüğü <code>~/.chef/config.rb</code> dosyasında ayarlayın:
chef.install=Paketi kurmak için, aşağıdaki komutu çalıştırın:
chef.documentation=Chef kütüğü hakkında daha fazla bilgi için, <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/chef/">belgeye</a> bakabilirsiniz.
composer.registry=Bu kütüğü <code>~/.composer/config.json</code> dosyasında ayarlayın:
composer.install=Paketi Composer ile kurmak için, şu komutu çalıştırın:
composer.documentation=Composer kütüğü hakkında daha fazla bilgi için, <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/composer/">belgeye</a> bakabilirsiniz.
@ -3232,6 +3237,7 @@ conan.install=Conan ile paket kurmak için aşağıdaki komutu çalıştırın:
conan.documentation=Conan kütüğü hakkında daha fazla bilgi için, <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/conan/">belgeye</a> bakabilirsiniz.
conda.registry=Bu kütüğü <code>.condarc</code> dosyasında bir Conda deposu olarak ayarlayın:
conda.install=Conda ile paket kurmak için aşağıdaki komutu çalıştırın:
conda.documentation=Conda kütüğü hakkında daha fazla bilgi için, <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/conda/">belgeye</a> bakabilirsiniz.
conda.details.repository_site=Depo Sitesi
conda.details.documentation_site=Belge Sitesi
container.details.type=Görüntü Türü
@ -3282,6 +3288,7 @@ rubygems.documentation=RubyGems kütüğü hakkında daha fazla bilgi için, <a
swift.registry=Bu kütüğü komut satırını kullanarak kurun:
swift.install=Paketi <code>Package.swift</code> dosyanıza ekleyin:
swift.install2=ve şu komutu çalıştırın:
swift.documentation=Swift kütüğü hakkında daha fazla bilgi için, <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/swift/">belgeye</a> bakabilirsiniz.
vagrant.install=Vagrant paketi eklemek için aşağıdaki komutu çalıştırın:
vagrant.documentation=Vagrant kütüğü hakkında daha fazla bilgi için, <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/vagrant/">belgeye</a> bakabilirsiniz.
settings.link=Bu paketi bir depoya bağlayın

618
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,13 +17,12 @@
"@github/text-expander-element": "2.3.0",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "19.1.0",
"@vue/compiler-sfc": "3.3.2",
"@webcomponents/custom-elements": "1.6.0",
"add-asset-webpack-plugin": "2.0.1",
"ansi-to-html": "0.7.2",
"asciinema-player": "3.3.0",
"clippie": "3.1.4",
"css-loader": "6.7.3",
"asciinema-player": "3.4.0",
"clippie": "4.0.1",
"css-loader": "6.7.4",
"dropzone": "6.0.0-beta.2",
"easymde": "2.18.0",
"esbuild-loader": "3.0.1",
@ -34,7 +33,7 @@
"katex": "0.16.7",
"license-checker-webpack-plugin": "0.2.1",
"mermaid": "10.1.0",
"mini-css-extract-plugin": "2.7.5",
"mini-css-extract-plugin": "2.7.6",
"minimatch": "9.0.0",
"monaco-editor": "0.38.0",
"monaco-editor-webpack-plugin": "7.0.1",
@ -45,11 +44,11 @@
"tippy.js": "6.3.7",
"tributejs": "5.1.3",
"uint8-to-base64": "0.2.0",
"vue": "3.3.2",
"vue": "3.3.4",
"vue-bar-graph": "2.0.0",
"vue-loader": "17.1.1",
"vue3-calendar-heatmap": "2.0.5",
"webpack": "5.82.1",
"webpack": "5.83.1",
"webpack-cli": "5.1.1",
"workbox-routing": "6.5.4",
"workbox-strategies": "6.5.4",
@ -71,15 +70,15 @@
"eslint-plugin-regexp": "1.15.0",
"eslint-plugin-sonarjs": "0.19.0",
"eslint-plugin-unicorn": "47.0.0",
"eslint-plugin-vue": "9.12.0",
"eslint-plugin-vue": "9.13.0",
"eslint-plugin-wc": "1.5.0",
"jsdom": "22.0.0",
"markdownlint-cli": "0.34.0",
"stylelint": "15.6.1",
"stylelint": "15.6.2",
"stylelint-declaration-strict-value": "1.9.2",
"svgo": "3.0.2",
"updates": "14.1.0",
"vitest": "0.31.0"
"vitest": "0.31.1"
},
"browserslist": [
"defaults",

View File

@ -0,0 +1,587 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
// Github Actions Artifacts API Simple Description
//
// 1. Upload artifact
// 1.1. Post upload url
// Post: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview
// Request:
// {
// "Type": "actions_storage",
// "Name": "artifact"
// }
// Response:
// {
// "fileContainerResourceUrl":"/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload"
// }
// it acquires an upload url for artifact upload
// 1.2. Upload artifact
// PUT: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename
// it upload chunk with headers:
// x-tfs-filelength: 1024 // total file length
// content-length: 1024 // chunk length
// x-actions-results-md5: md5sum // md5sum of chunk
// content-range: bytes 0-1023/1024 // chunk range
// we save all chunks to one storage directory after md5sum check
// 1.3. Confirm upload
// PATCH: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename
// it confirm upload and merge all chunks to one file, save this file to storage
//
// 2. Download artifact
// 2.1 list artifacts
// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview
// Response:
// {
// "count": 1,
// "value": [
// {
// "name": "artifact",
// "fileContainerResourceUrl": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path"
// }
// ]
// }
// 2.2 download artifact
// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path?api-version=6.0-preview
// Response:
// {
// "value": [
// {
// "contentLocation": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download",
// "path": "artifact/filename",
// "itemType": "file"
// }
// ]
// }
// 2.3 download artifact file
// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download?itemPath=artifact%2Ffilename
// Response:
// download file
//
import (
"compress/gzip"
gocontext "context"
"crypto/md5"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
)
const (
artifactXTfsFileLengthHeader = "x-tfs-filelength"
artifactXActionsResultsMD5Header = "x-actions-results-md5"
)
const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts"
func ArtifactsRoutes(goctx gocontext.Context, prefix string) *web.Route {
m := web.NewRoute()
m.Use(withContexter(goctx))
r := artifactRoutes{
prefix: prefix,
fs: storage.ActionsArtifacts,
}
m.Group(artifactRouteBase, func() {
// retrieve, list and confirm artifacts
m.Combo("").Get(r.listArtifacts).Post(r.getUploadArtifactURL).Patch(r.comfirmUploadArtifact)
// handle container artifacts list and download
m.Group("/{artifact_id}", func() {
m.Put("/upload", r.uploadArtifact)
m.Get("/path", r.getDownloadArtifactURL)
m.Get("/download", r.downloadArtifact)
})
})
return m
}
// withContexter initializes a package context for a request.
func withContexter(goctx gocontext.Context) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
ctx := context.Context{
Resp: context.NewResponse(resp),
Data: map[string]interface{}{},
}
defer ctx.Close()
// action task call server api with Bearer ACTIONS_RUNTIME_TOKEN
// we should verify the ACTIONS_RUNTIME_TOKEN
authHeader := req.Header.Get("Authorization")
if len(authHeader) == 0 || !strings.HasPrefix(authHeader, "Bearer ") {
ctx.Error(http.StatusUnauthorized, "Bad authorization header")
return
}
authToken := strings.TrimPrefix(authHeader, "Bearer ")
task, err := actions.GetRunningTaskByToken(req.Context(), authToken)
if err != nil {
log.Error("Error runner api getting task: %v", err)
ctx.Error(http.StatusInternalServerError, "Error runner api getting task")
return
}
ctx.Data["task"] = task
if err := task.LoadJob(goctx); err != nil {
log.Error("Error runner api getting job: %v", err)
ctx.Error(http.StatusInternalServerError, "Error runner api getting job")
return
}
ctx.Req = context.WithContext(req, &ctx)
next.ServeHTTP(ctx.Resp, ctx.Req)
})
}
}
type artifactRoutes struct {
prefix string
fs storage.ObjectStorage
}
func (ar artifactRoutes) buildArtifactURL(runID, artifactID int64, suffix string) string {
uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ar.prefix, "/") +
strings.ReplaceAll(artifactRouteBase, "{run_id}", strconv.FormatInt(runID, 10)) +
"/" + strconv.FormatInt(artifactID, 10) + "/" + suffix
return uploadURL
}
type getUploadArtifactRequest struct {
Type string
Name string
}
type getUploadArtifactResponse struct {
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
}
func (ar artifactRoutes) validateRunID(ctx *context.Context) (*actions.ActionTask, int64, bool) {
task, ok := ctx.Data["task"].(*actions.ActionTask)
if !ok {
log.Error("Error getting task in context")
ctx.Error(http.StatusInternalServerError, "Error getting task in context")
return nil, 0, false
}
runID := ctx.ParamsInt64("run_id")
if task.Job.RunID != runID {
log.Error("Error runID not match")
ctx.Error(http.StatusBadRequest, "run-id does not match")
return nil, 0, false
}
return task, runID, true
}
// getUploadArtifactURL generates a URL for uploading an artifact
func (ar artifactRoutes) getUploadArtifactURL(ctx *context.Context) {
task, runID, ok := ar.validateRunID(ctx)
if !ok {
return
}
var req getUploadArtifactRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
log.Error("Error decode request body: %v", err)
ctx.Error(http.StatusInternalServerError, "Error decode request body")
return
}
artifact, err := actions.CreateArtifact(ctx, task, req.Name)
if err != nil {
log.Error("Error creating artifact: %v", err)
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
resp := getUploadArtifactResponse{
FileContainerResourceURL: ar.buildArtifactURL(runID, artifact.ID, "upload"),
}
log.Debug("[artifact] get upload url: %s, artifact id: %d", resp.FileContainerResourceURL, artifact.ID)
ctx.JSON(http.StatusOK, resp)
}
// getUploadFileSize returns the size of the file to be uploaded.
// The raw size is the size of the file as reported by the header X-TFS-FileLength.
func (ar artifactRoutes) getUploadFileSize(ctx *context.Context) (int64, int64, error) {
contentLength := ctx.Req.ContentLength
xTfsLength, _ := strconv.ParseInt(ctx.Req.Header.Get(artifactXTfsFileLengthHeader), 10, 64)
if xTfsLength > 0 {
return xTfsLength, contentLength, nil
}
return contentLength, contentLength, nil
}
func (ar artifactRoutes) saveUploadChunk(ctx *context.Context,
artifact *actions.ActionArtifact,
contentSize, runID int64,
) (int64, error) {
contentRange := ctx.Req.Header.Get("Content-Range")
start, end, length := int64(0), int64(0), int64(0)
if _, err := fmt.Sscanf(contentRange, "bytes %d-%d/%d", &start, &end, &length); err != nil {
return -1, fmt.Errorf("parse content range error: %v", err)
}
storagePath := fmt.Sprintf("tmp%d/%d-%d-%d.chunk", runID, artifact.ID, start, end)
// use io.TeeReader to avoid reading all body to md5 sum.
// it writes data to hasher after reading end
// if hash is not matched, delete the read-end result
hasher := md5.New()
r := io.TeeReader(ctx.Req.Body, hasher)
// save chunk to storage
writtenSize, err := ar.fs.Save(storagePath, r, -1)
if err != nil {
return -1, fmt.Errorf("save chunk to storage error: %v", err)
}
// check md5
reqMd5String := ctx.Req.Header.Get(artifactXActionsResultsMD5Header)
chunkMd5String := base64.StdEncoding.EncodeToString(hasher.Sum(nil))
log.Debug("[artifact] check chunk md5, sum: %s, header: %s", chunkMd5String, reqMd5String)
if reqMd5String != chunkMd5String || writtenSize != contentSize {
if err := ar.fs.Delete(storagePath); err != nil {
log.Error("Error deleting chunk: %s, %v", storagePath, err)
}
return -1, fmt.Errorf("md5 not match")
}
log.Debug("[artifact] save chunk %s, size: %d, artifact id: %d, start: %d, end: %d",
storagePath, contentSize, artifact.ID, start, end)
return length, nil
}
// The rules are from https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/path-and-artifact-name-validation.ts#L32
var invalidArtifactNameChars = strings.Join([]string{"\\", "/", "\"", ":", "<", ">", "|", "*", "?", "\r", "\n"}, "")
func (ar artifactRoutes) uploadArtifact(ctx *context.Context) {
_, runID, ok := ar.validateRunID(ctx)
if !ok {
return
}
artifactID := ctx.ParamsInt64("artifact_id")
artifact, err := actions.GetArtifactByID(ctx, artifactID)
if errors.Is(err, util.ErrNotExist) {
log.Error("Error getting artifact: %v", err)
ctx.Error(http.StatusNotFound, err.Error())
return
} else if err != nil {
log.Error("Error getting artifact: %v", err)
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
// itemPath is generated from upload-artifact action
// it's formatted as {artifact_name}/{artfict_path_in_runner}
itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath"))
artifactName := strings.Split(itemPath, "/")[0]
// checkArtifactName checks if the artifact name contains invalid characters.
// If the name contains invalid characters, an error is returned.
if strings.ContainsAny(artifactName, invalidArtifactNameChars) {
log.Error("Error checking artifact name contains invalid character")
ctx.Error(http.StatusBadRequest, err.Error())
return
}
// get upload file size
fileSize, contentLength, err := ar.getUploadFileSize(ctx)
if err != nil {
log.Error("Error getting upload file size: %v", err)
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
// save chunk
chunkAllLength, err := ar.saveUploadChunk(ctx, artifact, contentLength, runID)
if err != nil {
log.Error("Error saving upload chunk: %v", err)
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
// if artifact name is not set, update it
if artifact.ArtifactName == "" {
artifact.ArtifactName = artifactName
artifact.ArtifactPath = itemPath // path in container
artifact.FileSize = fileSize // this is total size of all chunks
artifact.FileCompressedSize = chunkAllLength
artifact.ContentEncoding = ctx.Req.Header.Get("Content-Encoding")
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
log.Error("Error updating artifact: %v", err)
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
}
ctx.JSON(http.StatusOK, map[string]string{
"message": "success",
})
}
// comfirmUploadArtifact comfirm upload artifact.
// if all chunks are uploaded, merge them to one file.
func (ar artifactRoutes) comfirmUploadArtifact(ctx *context.Context) {
_, runID, ok := ar.validateRunID(ctx)
if !ok {
return
}
if err := ar.mergeArtifactChunks(ctx, runID); err != nil {
log.Error("Error merging chunks: %v", err)
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
ctx.JSON(http.StatusOK, map[string]string{
"message": "success",
})
}
type chunkItem struct {
ArtifactID int64
Start int64
End int64
Path string
}
func (ar artifactRoutes) mergeArtifactChunks(ctx *context.Context, runID int64) error {
storageDir := fmt.Sprintf("tmp%d", runID)
var chunks []*chunkItem
if err := ar.fs.IterateObjects(storageDir, func(path string, obj storage.Object) error {
item := chunkItem{Path: path}
if _, err := fmt.Sscanf(path, storageDir+"/%d-%d-%d.chunk", &item.ArtifactID, &item.Start, &item.End); err != nil {
return fmt.Errorf("parse content range error: %v", err)
}
chunks = append(chunks, &item)
return nil
}); err != nil {
return err
}
// group chunks by artifact id
chunksMap := make(map[int64][]*chunkItem)
for _, c := range chunks {
chunksMap[c.ArtifactID] = append(chunksMap[c.ArtifactID], c)
}
for artifactID, cs := range chunksMap {
// get artifact to handle merged chunks
artifact, err := actions.GetArtifactByID(ctx, cs[0].ArtifactID)
if err != nil {
return fmt.Errorf("get artifact error: %v", err)
}
sort.Slice(cs, func(i, j int) bool {
return cs[i].Start < cs[j].Start
})
allChunks := make([]*chunkItem, 0)
startAt := int64(-1)
// check if all chunks are uploaded and in order and clean repeated chunks
for _, c := range cs {
// startAt is -1 means this is the first chunk
// previous c.ChunkEnd + 1 == c.ChunkStart means this chunk is in order
// StartAt is not -1 and c.ChunkStart is not startAt + 1 means there is a chunk missing
if c.Start == (startAt + 1) {
allChunks = append(allChunks, c)
startAt = c.End
}
}
// if the last chunk.End + 1 is not equal to chunk.ChunkLength, means chunks are not uploaded completely
if startAt+1 != artifact.FileCompressedSize {
log.Debug("[artifact] chunks are not uploaded completely, artifact_id: %d", artifactID)
break
}
// use multiReader
readers := make([]io.Reader, 0, len(allChunks))
readerClosers := make([]io.Closer, 0, len(allChunks))
for _, c := range allChunks {
reader, err := ar.fs.Open(c.Path)
if err != nil {
return fmt.Errorf("open chunk error: %v, %s", err, c.Path)
}
readers = append(readers, reader)
readerClosers = append(readerClosers, reader)
}
mergedReader := io.MultiReader(readers...)
// if chunk is gzip, decompress it
if artifact.ContentEncoding == "gzip" {
var err error
mergedReader, err = gzip.NewReader(mergedReader)
if err != nil {
return fmt.Errorf("gzip reader error: %v", err)
}
}
// save merged file
storagePath := fmt.Sprintf("%d/%d/%d.chunk", runID%255, artifactID%255, time.Now().UnixNano())
written, err := ar.fs.Save(storagePath, mergedReader, -1)
if err != nil {
return fmt.Errorf("save merged file error: %v", err)
}
if written != artifact.FileSize {
return fmt.Errorf("merged file size is not equal to chunk length")
}
// close readers
for _, r := range readerClosers {
r.Close()
}
// save storage path to artifact
log.Debug("[artifact] merge chunks to artifact: %d, %s", artifact.ID, storagePath)
artifact.StoragePath = storagePath
artifact.Status = actions.ArtifactStatusUploadConfirmed
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
return fmt.Errorf("update artifact error: %v", err)
}
// drop chunks
for _, c := range cs {
if err := ar.fs.Delete(c.Path); err != nil {
return fmt.Errorf("delete chunk file error: %v", err)
}
}
}
return nil
}
type (
listArtifactsResponse struct {
Count int64 `json:"count"`
Value []listArtifactsResponseItem `json:"value"`
}
listArtifactsResponseItem struct {
Name string `json:"name"`
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
}
)
func (ar artifactRoutes) listArtifacts(ctx *context.Context) {
_, runID, ok := ar.validateRunID(ctx)
if !ok {
return
}
artficats, err := actions.ListArtifactsByRunID(ctx, runID)
if err != nil {
log.Error("Error getting artifacts: %v", err)
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
artficatsData := make([]listArtifactsResponseItem, 0, len(artficats))
for _, a := range artficats {
artficatsData = append(artficatsData, listArtifactsResponseItem{
Name: a.ArtifactName,
FileContainerResourceURL: ar.buildArtifactURL(runID, a.ID, "path"),
})
}
respData := listArtifactsResponse{
Count: int64(len(artficatsData)),
Value: artficatsData,
}
ctx.JSON(http.StatusOK, respData)
}
type (
downloadArtifactResponse struct {
Value []downloadArtifactResponseItem `json:"value"`
}
downloadArtifactResponseItem struct {
Path string `json:"path"`
ItemType string `json:"itemType"`
ContentLocation string `json:"contentLocation"`
}
)
func (ar artifactRoutes) getDownloadArtifactURL(ctx *context.Context) {
_, runID, ok := ar.validateRunID(ctx)
if !ok {
return
}
artifactID := ctx.ParamsInt64("artifact_id")
artifact, err := actions.GetArtifactByID(ctx, artifactID)
if errors.Is(err, util.ErrNotExist) {
log.Error("Error getting artifact: %v", err)
ctx.Error(http.StatusNotFound, err.Error())
return
} else if err != nil {
log.Error("Error getting artifact: %v", err)
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
downloadURL := ar.buildArtifactURL(runID, artifact.ID, "download")
itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath"))
respData := downloadArtifactResponse{
Value: []downloadArtifactResponseItem{{
Path: util.PathJoinRel(itemPath, artifact.ArtifactPath),
ItemType: "file",
ContentLocation: downloadURL,
}},
}
ctx.JSON(http.StatusOK, respData)
}
func (ar artifactRoutes) downloadArtifact(ctx *context.Context) {
_, runID, ok := ar.validateRunID(ctx)
if !ok {
return
}
artifactID := ctx.ParamsInt64("artifact_id")
artifact, err := actions.GetArtifactByID(ctx, artifactID)
if errors.Is(err, util.ErrNotExist) {
log.Error("Error getting artifact: %v", err)
ctx.Error(http.StatusNotFound, err.Error())
return
} else if err != nil {
log.Error("Error getting artifact: %v", err)
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
if artifact.RunID != runID {
log.Error("Error dismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID)
ctx.Error(http.StatusBadRequest, err.Error())
return
}
fd, err := ar.fs.Open(artifact.StoragePath)
if err != nil {
log.Error("Error opening file: %v", err)
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
defer fd.Close()
if strings.HasSuffix(artifact.ArtifactPath, ".gz") {
ctx.Resp.Header().Set("Content-Encoding", "gzip")
}
ctx.ServeContent(fd, &context.ServeHeaderOptions{
Filename: artifact.ArtifactName,
LastModified: artifact.CreatedUnix.AsLocalTime(),
})
}

View File

@ -470,7 +470,7 @@ func ListIssues(ctx *context.APIContext) {
if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 {
issuesOpt := &issues_model.IssuesOptions{
ListOptions: listOptions,
RepoID: ctx.Repo.Repository.ID,
RepoIDs: []int64{ctx.Repo.Repository.ID},
IsClosed: isClosed,
IssueIDs: issueIDs,
LabelIDs: labelIDs,

View File

@ -193,6 +193,12 @@ func NormalRoutes(ctx context.Context) *web.Route {
if setting.Actions.Enabled {
prefix := "/api/actions"
r.Mount(prefix, actions_router.Routes(ctx, prefix))
// TODO: Pipeline api used for runner internal communication with gitea server. but only artifact is used for now.
// In Github, it uses ACTIONS_RUNTIME_URL=https://pipelines.actions.githubusercontent.com/fLgcSHkPGySXeIFrg8W8OBSfeg3b5Fls1A1CwX566g8PayEGlg/
// TODO: this prefix should be generated with a token string with runner ?
prefix = "/api/actions_pipeline"
r.Mount(prefix, actions_router.ArtifactsRoutes(ctx, prefix))
}
return r

View File

@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/base"
context_module "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
@ -418,3 +419,80 @@ func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions
}
return jobs[0], jobs
}
type ArtifactsViewResponse struct {
Artifacts []*ArtifactsViewItem `json:"artifacts"`
}
type ArtifactsViewItem struct {
Name string `json:"name"`
Size int64 `json:"size"`
ID int64 `json:"id"`
}
func ArtifactsView(ctx *context_module.Context) {
runIndex := ctx.ParamsInt64("run")
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Error(http.StatusNotFound, err.Error())
return
}
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
artifacts, err := actions_model.ListUploadedArtifactsByRunID(ctx, run.ID)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
artifactsResponse := ArtifactsViewResponse{
Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)),
}
for _, art := range artifacts {
artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{
Name: art.ArtifactName,
Size: art.FileSize,
ID: art.ID,
})
}
ctx.JSON(http.StatusOK, artifactsResponse)
}
func ArtifactsDownloadView(ctx *context_module.Context) {
runIndex := ctx.ParamsInt64("run")
artifactID := ctx.ParamsInt64("id")
artifact, err := actions_model.GetArtifactByID(ctx, artifactID)
if errors.Is(err, util.ErrNotExist) {
ctx.Error(http.StatusNotFound, err.Error())
} else if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Error(http.StatusNotFound, err.Error())
return
}
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
if artifact.RunID != run.ID {
ctx.Error(http.StatusNotFound, "artifact not found")
return
}
f, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
defer f.Close()
ctx.ServeContent(f, &context_module.ServeHeaderOptions{
Filename: artifact.ArtifactName,
LastModified: artifact.CreatedUnix.AsLocalTime(),
})
}

View File

@ -207,7 +207,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
issueStats = &issues_model.IssueStats{}
} else {
issueStats, err = issues_model.GetIssueStats(&issues_model.IssuesOptions{
RepoID: repo.ID,
RepoIDs: []int64{repo.ID},
LabelIDs: labelIDs,
MilestoneIDs: []int64{milestoneID},
ProjectID: projectID,
@ -258,7 +258,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
Page: pager.Paginater.Current(),
PageSize: setting.UI.IssuePagingNum,
},
RepoID: repo.ID,
RepoIDs: []int64{repo.ID},
AssigneeID: assigneeID,
PosterID: posterID,
MentionedID: mentionedID,
@ -2652,7 +2652,7 @@ func ListIssues(ctx *context.Context) {
if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 {
issuesOpt := &issues_model.IssuesOptions{
ListOptions: listOptions,
RepoID: ctx.Repo.Repository.ID,
RepoIDs: []int64{ctx.Repo.Repository.ID},
IsClosed: isClosed,
IssueIDs: issueIDs,
LabelIDs: labelIDs,

View File

@ -521,10 +521,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// Parse ctx.FormString("repos") and remember matched repo IDs for later.
// Gets set when clicking filters on the issues overview page.
repoIDs := getRepoIDs(ctx.FormString("repos"))
if len(repoIDs) > 0 {
opts.RepoCond = builder.In("issue.repo_id", repoIDs)
}
opts.RepoIDs = getRepoIDs(ctx.FormString("repos"))
// ------------------------------
// Get issues as defined by opts.
@ -580,11 +577,10 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// -------------------------------
var issueStats *issues_model.IssueStats
if !forceEmpty {
statsOpts := issues_model.UserIssueStatsOptions{
UserID: ctx.Doer.ID,
FilterMode: filterMode,
IsPull: isPullList,
IsClosed: isShowClosed,
statsOpts := issues_model.IssuesOptions{
User: ctx.Doer,
IsPull: util.OptionalBoolOf(isPullList),
IsClosed: util.OptionalBoolOf(isShowClosed),
IssueIDs: issueIDsFromSearch,
IsArchived: util.OptionalBoolFalse,
LabelIDs: opts.LabelIDs,
@ -593,7 +589,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
RepoCond: opts.RepoCond,
}
issueStats, err = issues_model.GetUserIssueStats(statsOpts)
issueStats, err = issues_model.GetUserIssueStats(filterMode, statsOpts)
if err != nil {
ctx.ServerError("GetUserIssueStats Shown", err)
return
@ -609,9 +605,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
} else {
shownIssues = int(issueStats.ClosedCount)
}
if len(repoIDs) != 0 {
if len(opts.RepoIDs) != 0 {
shownIssues = 0
for _, repoID := range repoIDs {
for _, repoID := range opts.RepoIDs {
shownIssues += int(issueCountByRepo[repoID])
}
}
@ -622,8 +618,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
}
ctx.Data["TotalIssueCount"] = allIssueCount
if len(repoIDs) == 1 {
repo := showReposMap[repoIDs[0]]
if len(opts.RepoIDs) == 1 {
repo := showReposMap[opts.RepoIDs[0]]
if repo != nil {
ctx.Data["SingleRepoLink"] = repo.Link()
}
@ -665,7 +661,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["IssueStats"] = issueStats
ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType
ctx.Data["RepoIDs"] = repoIDs
ctx.Data["RepoIDs"] = opts.RepoIDs
ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["SelectLabels"] = selectedLabels
@ -676,7 +672,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
}
// Convert []int64 to string
reposParam, _ := json.Marshal(repoIDs)
reposParam, _ := json.Marshal(opts.RepoIDs)
ctx.Data["ReposParam"] = string(reposParam)

View File

@ -1192,6 +1192,8 @@ func registerRoutes(m *web.Route) {
})
m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
m.Post("/approve", reqRepoActionsWriter, actions.Approve)
m.Post("/artifacts", actions.ArtifactsView)
m.Get("/artifacts/{id}", actions.ArtifactsDownloadView)
m.Post("/rerun", reqRepoActionsWriter, actions.RerunAll)
})
}, reqRepoActionsReader, actions.MustEnableActions)

View File

@ -104,7 +104,7 @@ func TestGiteaUploadRepo(t *testing.T) {
assert.Len(t, releases, 1)
issues, err := issues_model.Issues(db.DefaultContext, &issues_model.IssuesOptions{
RepoID: repo.ID,
RepoIDs: []int64{repo.ID},
IsPull: util.OptionalBoolFalse,
SortType: "oldest",
})

View File

@ -17,6 +17,7 @@
data-locale-status-cancelled="{{.locale.Tr "actions.status.cancelled"}}"
data-locale-status-skipped="{{.locale.Tr "actions.status.skipped"}}"
data-locale-status-blocked="{{.locale.Tr "actions.status.blocked"}}"
data-locale-artifacts-title="{{$.locale.Tr "artifacts"}}"
>
</div>
</div>

View File

@ -59,7 +59,7 @@
{{.Fingerprint}}
</div>
<div class="activity meta">
<i>{{$.locale.Tr "settings.add_on" (DateTime "short" .CreatedUnix) | Safe}}{{svg "octicon-info"}} {{if .HasUsed}}{{$.locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{DateTime "short" .UpdatedUnix}}</span>{{else}}{{$.locale.Tr "settings.no_activity"}}{{end}}</i>
<i>{{$.locale.Tr "settings.added_on" (DateTime "short" .CreatedUnix) | Safe}}{{svg "octicon-info"}} {{if .HasUsed}}{{$.locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{DateTime "short" .UpdatedUnix}}</span>{{else}}{{$.locale.Tr "settings.no_activity"}}{{end}}</i>
</div>
</div>
</div>

View File

@ -0,0 +1,143 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"strings"
"testing"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func TestActionsArtifactUpload(t *testing.T) {
defer tests.PrepareTestEnv(t)()
type uploadArtifactResponse struct {
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
}
type getUploadArtifactRequest struct {
Type string
Name string
}
// acquire artifact upload url
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
Type: "actions_storage",
Name: "artifact",
})
req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var uploadResp uploadArtifactResponse
DecodeJSON(t, resp, &uploadResp)
assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
// get upload url
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact/abc.txt"
// upload artifact chunk
body := strings.Repeat("A", 1024)
req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body))
req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
req.Header.Add("Content-Range", "bytes 0-1023/1024")
req.Header.Add("x-tfs-filelength", "1024")
req.Header.Add("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body))
MakeRequest(t, req, http.StatusOK)
t.Logf("Create artifact confirm")
// confirm artifact upload
req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
MakeRequest(t, req, http.StatusOK)
}
func TestActionsArtifactUploadNotExist(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// artifact id 54321 not exist
url := "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts/54321/upload?itemPath=artifact/abc.txt"
body := strings.Repeat("A", 1024)
req := NewRequestWithBody(t, "PUT", url, strings.NewReader(body))
req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
req.Header.Add("Content-Range", "bytes 0-1023/1024")
req.Header.Add("x-tfs-filelength", "1024")
req.Header.Add("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body))
MakeRequest(t, req, http.StatusNotFound)
}
func TestActionsArtifactConfirmUpload(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "success")
}
func TestActionsArtifactUploadWithoutToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts", nil)
MakeRequest(t, req, http.StatusUnauthorized)
}
func TestActionsArtifactDownload(t *testing.T) {
defer tests.PrepareTestEnv(t)()
type (
listArtifactsResponseItem struct {
Name string `json:"name"`
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
}
listArtifactsResponse struct {
Count int64 `json:"count"`
Value []listArtifactsResponseItem `json:"value"`
}
)
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var listResp listArtifactsResponse
DecodeJSON(t, resp, &listResp)
assert.Equal(t, int64(1), listResp.Count)
assert.Equal(t, "artifact", listResp.Value[0].Name)
assert.Contains(t, listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
type (
downloadArtifactResponseItem struct {
Path string `json:"path"`
ItemType string `json:"itemType"`
ContentLocation string `json:"contentLocation"`
}
downloadArtifactResponse struct {
Value []downloadArtifactResponseItem `json:"value"`
}
)
idx := strings.Index(listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := listResp.Value[0].FileContainerResourceURL[idx+1:]
req = NewRequest(t, "GET", url)
req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
var downloadResp downloadArtifactResponse
DecodeJSON(t, resp, &downloadResp)
assert.Len(t, downloadResp.Value, 1)
assert.Equal(t, "artifact/abc.txt", downloadResp.Value[0].Path)
assert.Equal(t, "file", downloadResp.Value[0].ItemType)
assert.Contains(t, downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
url = downloadResp.Value[0].ContentLocation[idx:]
req = NewRequest(t, "GET", url)
req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
body := strings.Repeat("A", 1024)
assert.Equal(t, resp.Body.String(), body)
}

View File

@ -108,3 +108,6 @@ PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mssql/data/lfs
[packages]
ENABLED = true
[actions]
ENABLED = true

View File

@ -117,3 +117,6 @@ PASSWORD = debug
USE_TLS = true
SKIP_TLS_VERIFY = true
REPLY_TO_ADDRESS = incoming+%{token}@localhost
[actions]
ENABLED = true

View File

@ -105,3 +105,6 @@ PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql8/data/lfs
[packages]
ENABLED = true
[actions]
ENABLED = true

View File

@ -129,3 +129,6 @@ MINIO_CHECKSUM_ALGORITHM = md5
[packages]
ENABLED = true
[actions]
ENABLED = true

View File

@ -114,3 +114,6 @@ FILE_EXTENSIONS = .html
RENDER_COMMAND = `go run build/test-echo.go`
IS_INPUT_FILE = false
RENDER_CONTENT_MODE=sanitized
[actions]
ENABLED = true

View File

@ -180,10 +180,13 @@
--color-caret: var(--color-text-dark);
--color-reaction-bg: #0000000a;
--color-reaction-active-bg: var(--color-primary-alpha-20);
--color-tooltip-bg: #000000f0;
--color-tooltip-text: #ffffff;
--color-tooltip-bg: #000000f0;
--color-header-bar: #ffffff;
--color-label-active-bg: #d0d0d0;
--color-label-text: #232323;
--color-label-bg: #cacaca5b;
--color-label-hover-bg: #cacacaa0;
--color-label-active-bg: #cacacaff;
--color-accent: var(--color-primary-light-1);
--color-small-accent: var(--color-primary-light-6);
--color-active-line: #fffbdd;
@ -820,16 +823,6 @@ a.label,
margin-right: 0.35em;
}
.ui.menu .item > .label {
background: var(--color-grey);
}
.ui.active.label {
background: var(--color-label-active-bg);
border-color: var(--color-label-active-bg);
color: var(--color-text-dark);
}
.ui.menu .dropdown.item:hover,
.ui.menu a.item:hover {
color: var(--color-text);
@ -1976,22 +1969,32 @@ i.icon.centerlock {
.ui.label {
padding: 0.3em 0.5em;
background: var(--color-light);
color: var(--color-text-light);
}
.ui.label,
.ui.menu .item > .label {
background: var(--color-label-bg);
color: var(--color-label-text);
}
.ui.active.label {
background: var(--color-label-active-bg);
border-color: var(--color-label-active-bg);
color: var(--color-label-text);
}
.ui.labels a.label:hover,
a.ui.label:hover {
background: var(--color-hover);
border-color: var(--color-hover);
color: var(--color-text);
background: var(--color-label-hover-bg);
border-color: var(--color-label-hover-bg);
color: var(--color-label-text);
}
.ui.labels a.active.label:hover,
a.ui.active.label:hover {
background: var(--color-active);
border-color: var(--color-active);
color: var(--color-text);
background: var(--color-label-active-bg);
border-color: var(--color-label-active-bg);
color: var(--color-label-text);
}
.ui.label > .detail .icons {

View File

@ -61,6 +61,9 @@
.comment-code-cloud {
padding: 0.5rem 1rem !important;
position: relative;
}
.conversation-holder .comment-code-cloud {
max-width: 820px;
}

View File

@ -165,8 +165,13 @@
--color-caret: var(--color-text); /* should ideally be --color-text-dark, see #15651 */
--color-reaction-bg: #ffffff12;
--color-reaction-active-bg: var(--color-primary-alpha-40);
--color-tooltip-text: #ffffff;
--color-tooltip-bg: #000000f0;
--color-header-bar: #2e323e;
--color-label-active-bg: #4c525e;
--color-label-text: #dfe3ec;
--color-label-bg: #7c84974b;
--color-label-hover-bg: #7c8497a0;
--color-label-active-bg: #7c8497ff;
--color-accent: var(--color-primary-light-1);
--color-small-accent: var(--color-primary-light-5);
--color-active-line: #534d1b;

View File

@ -71,7 +71,7 @@
<div v-if="repos.length" class="ui attached table segment gt-rounded-bottom">
<ul class="repo-owner-name-list">
<li v-for="repo in repos" :key="repo.id">
<a class="repo-list-link gt-df gt-ac gt-sb" :href="repo.link">
<a class="repo-list-link muted gt-df gt-ac gt-sb" :href="repo.link">
<div class="item-name gt-df gt-ac gt-f1">
<svg-icon :name="repoIcon(repo)" :size="16" class-name="gt-mr-2"/>
<div class="text gt-bold truncate gt-ml-1">{{ repo.full_name }}</div>

View File

@ -42,6 +42,18 @@
</div>
</div>
</div>
<div class="job-artifacts" v-if="artifacts.length > 0">
<div class="job-artifacts-title">
{{ locale.artifactsTitle }}
</div>
<ul class="job-artifacts-list">
<li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.id">
<a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.id">
<SvgIcon name="octicon-file" class="ui text black job-artifacts-icon" />{{ artifact.name }}
</a>
</li>
</ul>
</div>
</div>
<div class="action-view-right">
@ -102,6 +114,7 @@ const sfc = {
loading: false,
intervalID: null,
currentJobStepsStates: [],
artifacts: [],
// provided by backend
run: {
@ -156,6 +169,15 @@ const sfc = {
this.intervalID = setInterval(this.loadJob, 1000);
},
unmounted() {
// clear the interval timer when the component is unmounted
// even our page is rendered once, not spa style
if (this.intervalID) {
clearInterval(this.intervalID);
this.intervalID = null;
}
},
methods: {
// get the active container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
getLogsContainer(idx) {
@ -259,6 +281,11 @@ const sfc = {
try {
this.loading = true;
// refresh artifacts if upload-artifact step done
const resp = await this.fetchPost(`${this.actionsURL}/runs/${this.runIndex}/artifacts`);
const artifacts = await resp.json();
this.artifacts = artifacts['artifacts'] || [];
const response = await this.fetchJob();
// save the state to Vue data, then the UI will be updated
@ -287,6 +314,7 @@ const sfc = {
}
},
fetchPost(url, body) {
return fetch(url, {
method: 'POST',
@ -319,6 +347,7 @@ export function initRepositoryActionView() {
approve: el.getAttribute('data-locale-approve'),
cancel: el.getAttribute('data-locale-cancel'),
rerun: el.getAttribute('data-locale-rerun'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
status: {
unknown: el.getAttribute('data-locale-status-unknown'),
waiting: el.getAttribute('data-locale-status-waiting'),
@ -423,6 +452,27 @@ export function ansiLogToHTML(line) {
padding: 10px;
}
.job-artifacts-title {
font-size: 18px;
margin-top: 16px;
padding: 16px 10px 0px 20px;
border-top: 1px solid var(--color-secondary);
}
.job-artifacts-item {
margin: 5px 0;
padding: 6px;
}
.job-artifacts-list {
padding-left: 12px;
list-style: none;
}
.job-artifacts-icon {
padding-right: 3px;
}
.job-group-section .job-brief-list .job-brief-item {
margin: 5px 0;
padding: 10px;

View File

@ -20,7 +20,7 @@ export function createTippy(target, opts = {}) {
onShow: (instance) => {
// hide other tooltip instances so only one tooltip shows at a time
for (const visibleInstance of visibleInstances) {
if (visibleInstance.role === 'tooltip') {
if (visibleInstance.props.role === 'tooltip') {
visibleInstance.hide();
}
}