mirror of
https://github.com/go-gitea/gitea.git
synced 2025-08-13 00:03:31 -04:00
Compare commits
11 Commits
25d4f95df2
...
1698c15cba
Author | SHA1 | Date | |
---|---|---|---|
|
1698c15cba | ||
|
bbc1456542 | ||
|
3288252dda | ||
|
c641a22f2a | ||
|
a103b79f60 | ||
|
acde12a8a2 | ||
|
f5ce2ed292 | ||
|
38cf43d060 | ||
|
c757765a9e | ||
|
7985cde84d | ||
|
3eecde3f33 |
@ -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
|
||||
|
@ -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
5
go.mod
@ -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
8
go.sum
@ -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
122
models/actions/artifact.go
Normal 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)
|
||||
}
|
@ -30,4 +30,4 @@
|
||||
token_last_eight: 69d28c91
|
||||
created_unix: 946687980
|
||||
updated_unix: 946687980
|
||||
#commented out tokens so you can see what they are in plaintext
|
||||
#commented out tokens so you can see what they are in plaintext
|
||||
|
19
models/fixtures/action_run.yml
Normal file
19
models/fixtures/action_run.yml
Normal 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
|
14
models/fixtures/action_run_job.yml
Normal file
14
models/fixtures/action_run_job.yml
Normal 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
|
20
models/fixtures/action_task.yml
Normal file
20
models/fixtures/action_task.yml
Normal 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
|
@ -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
|
||||
|
490
models/issues/issue_label.go
Normal file
490
models/issues/issue_label.go
Normal 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()
|
||||
}
|
@ -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)
|
||||
|
383
models/issues/issue_stats.go
Normal file
383
models/issues/issue_stats.go
Normal 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()
|
||||
}
|
@ -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,
|
||||
RepoIDs: []int64{1},
|
||||
FilterMode: issues_model.FilterModeAll,
|
||||
issues_model.FilterModeAll,
|
||||
issues_model.IssuesOptions{
|
||||
User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}),
|
||||
RepoIDs: []int64{1},
|
||||
IsPull: util.OptionalBoolFalse,
|
||||
},
|
||||
issues_model.IssueStats{
|
||||
YourRepositoriesCount: 1, // 6
|
||||
@ -222,11 +225,12 @@ func TestGetUserIssueStats(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
issues_model.UserIssueStatsOptions{
|
||||
UserID: 1,
|
||||
RepoIDs: []int64{1},
|
||||
FilterMode: issues_model.FilterModeAll,
|
||||
IsClosed: true,
|
||||
issues_model.FilterModeAll,
|
||||
issues_model.IssuesOptions{
|
||||
User: unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}),
|
||||
RepoIDs: []int64{1},
|
||||
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,
|
||||
IssueIDs: []int64{1},
|
||||
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,
|
||||
Org: unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}),
|
||||
Team: unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 7}),
|
||||
FilterMode: issues_model.FilterModeAll,
|
||||
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}),
|
||||
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,
|
||||
})
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
|
33
models/migrations/v1_20/v257.go
Normal file
33
models/migrations/v1_20/v257.go
Normal 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))
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -114,6 +114,8 @@ unknown = Unknown
|
||||
|
||||
rss_feed = RSS Feed
|
||||
|
||||
artifacts = Artifacts
|
||||
|
||||
concept_system_global = Global
|
||||
concept_user_individual = Individual
|
||||
concept_code_repository = Repository
|
||||
|
@ -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 d’abord les supprimer ou les transférer.
|
||||
still_has_org=Votre compte est un membre d’une ou plusieurs organisations, veuillez d’abord les quitter.
|
||||
still_own_packages=Votre compte possède toujours un ou plusieurs paquets, vous devez d’abord les supprimer ou les transférer.
|
||||
org_still_own_repo=Cette organisation possède encore un ou plusieurs dépôts. Vous devez d’abord 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 d’utilisateur "%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ù l’utilisateur 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 l’accè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 l’organisation)
|
||||
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=L’utilisateur 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 l’organisation <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 l’utilisateur 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 d’abord 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 d’accès à Sendmail
|
||||
config.mailer_sendmail_args=Arguments supplémentaires pour Sendmail
|
||||
config.mailer_sendmail_timeout=Délai d’attente 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 l’index 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 s’authentifier 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 d’inscription de l’exé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 d’organisation
|
||||
|
||||
|
@ -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
618
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -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",
|
||||
|
587
routers/api/actions/artifacts.go
Normal file
587
routers/api/actions/artifacts.go
Normal 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(),
|
||||
})
|
||||
}
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
143
tests/integration/api_actions_artifact_test.go
Normal file
143
tests/integration/api_actions_artifact_test.go
Normal 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)
|
||||
}
|
@ -108,3 +108,6 @@ PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mssql/data/lfs
|
||||
|
||||
[packages]
|
||||
ENABLED = true
|
||||
|
||||
[actions]
|
||||
ENABLED = true
|
||||
|
@ -117,3 +117,6 @@ PASSWORD = debug
|
||||
USE_TLS = true
|
||||
SKIP_TLS_VERIFY = true
|
||||
REPLY_TO_ADDRESS = incoming+%{token}@localhost
|
||||
|
||||
[actions]
|
||||
ENABLED = true
|
||||
|
@ -105,3 +105,6 @@ PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql8/data/lfs
|
||||
|
||||
[packages]
|
||||
ENABLED = true
|
||||
|
||||
[actions]
|
||||
ENABLED = true
|
||||
|
@ -129,3 +129,6 @@ MINIO_CHECKSUM_ALGORITHM = md5
|
||||
|
||||
[packages]
|
||||
ENABLED = true
|
||||
|
||||
[actions]
|
||||
ENABLED = true
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -61,6 +61,9 @@
|
||||
.comment-code-cloud {
|
||||
padding: 0.5rem 1rem !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.conversation-holder .comment-code-cloud {
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user