Compare commits

...
8 Commits
Author SHA1 Message Date
d f914c4fade merge upstream 2026-06-10 19:30:39 +00:00
19d1e1d334 test: enable WAL for sqlite integration tests (#37861)
Enable `SQLITE_JOURNAL_MODE = WAL` for the sqlite integration test
config. With modernc as the default driver, concurrent writers serialize
on SQLite's single write lock and the tail of the queue can exceed the
20s busy timeout under CI load. WAL drains the queue fast enough to stay
inside the timeout (removes rollback's fsync-per-commit and
reader-vs-commit blocking) and covers all sqlite integration tests in
one change.

---
This PR was written with the help of Claude Opus 4.7

---------

Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
2026-06-10 10:32:32 +02:00
bircniandGitHub 920b3f8cb6 fix(hostmatcher): block reserved IP ranges from external/private filters (#38039) 2026-06-10 10:03:36 +02:00
wxiaoguangandGitHub 4ba0a545f2 chore: js html (#38056)
remove unnecessary "eslint-disable-line" rules
2026-06-10 07:36:44 +00:00
wxiaoguangandGitHub a51781527b fix: commit display name (#38057)
fix #38054
2026-06-10 15:06:16 +08:00
7134c1f845 fix: bound debian ParseControlFile to a single control stanza (#38044)
**Packages-index stanza injection via Debian control file**

A `.deb` whose `control` file appends extra paragraphs after a blank
line was still accepted, and `ParseControlFile` stored the whole
multi-stanza blob in `p.Control`. That blob is re-emitted verbatim into
the generated `Packages` index, so the embedded blank line splits it
into separate stanzas and an uploader can smuggle a package entry with
an attacker-chosen `Filename` into the shared index. A binary control
file only holds one stanza, so parsing now stops at the blank line that
terminates it; well-formed packages are unaffected and the new subtest
covers the trailing-stanza case.

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-06-09 20:27:57 -07:00
bircniandGitHub 7b4a1a1a11 fix(lfs): require Code-unit access for cross-repo LFS object reuse (#38006) 2026-06-09 17:34:37 +00:00
63df886ba8 fix(actions): keep distinct commit statuses for workflows sharing a name (#37834)
## Summary

Two Gitea Actions workflow files that share the same `name:` and same
job name produced identical commit-status `Context` strings. Because
`GetLatestCommitStatus` groups by `context_hash` (derived from
`Context`), only one row was shown on the PR page — see #35699.

GitHub displays both rows even though they look identical. This change
does the same: the displayed `Context` is unchanged, but `ContextHash`
now mixes in the workflow file path so the two statuses remain distinct
in the dedupe query.

## Notes

- Workflows that omit `name:` now use the workflow file name in the
`Context` (e.g. `ci.yaml / build (push)`) instead of an empty `/ build
(push)`. This changes the `Context` string for unnamed workflows, so any
required-status-check rule that referenced the old string must be
updated after upgrade.
- For statuses created before this change (hashed from `Context` alone),
`createCommitStatus` reuses that legacy hash when a matching row is
still present, so in-flight pending statuses are superseded rather than
orphaned on upgrade.

Fixes #35699

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-06-09 12:59:58 +00:00
15 changed files with 379 additions and 44 deletions
+5
View File
@@ -64,6 +64,11 @@ jobs:
- ".golangci.yml"
- ".editorconfig"
- "options/locale/locale_en-US.json"
- "models/fixtures/**"
- "tests/*.ini.tmpl"
- "tests/gitea-repositories-meta/**"
- "tests/testdata/**"
- "tools/test-integration.sh"
frontend:
- "*.ts"
+12 -3
View File
@@ -505,13 +505,19 @@ func NewCommitStatus(ctx context.Context, opts NewCommitStatusOptions) error {
opts.CommitStatus.Description = strings.TrimSpace(opts.CommitStatus.Description)
opts.CommitStatus.Context = strings.TrimSpace(opts.CommitStatus.Context)
opts.CommitStatus.TargetURL = strings.TrimSpace(opts.CommitStatus.TargetURL)
opts.CommitStatus.ContextHash = strings.TrimSpace(opts.CommitStatus.ContextHash)
opts.CommitStatus.SHA = opts.SHA.String()
opts.CommitStatus.CreatorID = opts.Creator.ID
opts.CommitStatus.RepoID = opts.Repo.ID
opts.CommitStatus.Index = idx
log.Debug("NewCommitStatus[%s, %s]: %d", opts.Repo.FullName(), opts.SHA, opts.CommitStatus.Index)
opts.CommitStatus.ContextHash = hashCommitStatusContext(opts.CommitStatus.Context)
// Callers may pre-compute a ContextHash to keep entries that share a
// human-readable Context separated (e.g. two workflow files with the
// same `name:` — issue #35699). Only derive from Context when unset.
if opts.CommitStatus.ContextHash == "" {
opts.CommitStatus.ContextHash = HashCommitStatusContext(opts.CommitStatus.Context)
}
// Insert new CommitStatus
if err = db.Insert(ctx, opts.CommitStatus); err != nil {
@@ -529,8 +535,11 @@ type SignCommitWithStatuses struct {
*asymkey_model.SignCommit
}
// hashCommitStatusContext hash context
func hashCommitStatusContext(context string) string {
// HashCommitStatusContext returns the sha1 hash used to dedupe commit statuses
// by Context. Callers that need to keep statuses with the same display Context
// separated (e.g. distinct workflow files sharing a `name:`) can mix extra
// disambiguating data into the input.
func HashCommitStatusContext(context string) string {
return fmt.Sprintf("%x", sha1.Sum([]byte(context)))
}
+5 -2
View File
@@ -196,7 +196,10 @@ func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string)
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
return count > 0, err
}
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
// LFS objects are repository code content, so authorization must require
// Code-unit access; other unit accesses (e.g. Issues) must not authorize
// reuse of an existing LFS object across repositories.
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
return count > 0, err
}
@@ -220,7 +223,7 @@ func LFSAutoAssociate(ctx context.Context, metas []*LFSMetaObject, user *user_mo
newMetas := make([]*LFSMetaObject, 0, len(metas))
cond := builder.In(
"`lfs_meta_object`.repository_id",
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)),
)
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
return err
+59 -4
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
)
// HostMatchList is used to check if a host or IP is in a list.
@@ -23,10 +24,64 @@ type HostMatchList struct {
ipNets []*net.IPNet
}
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate)
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
const MatchBuiltinExternal = "external"
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be
// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer
// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are
// picked up automatically, while still covering the ranges it leaves out; otherwise the default
// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition
// endpoints (SSRF), and a "private" block-list would fail to catch them.
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
var nets []*net.IPNet
for _, cidr := range []string{
// IPv4
"100.64.0.0/10", // RFC 6598 Carrier-Grade NAT
"168.63.129.16/32", // Azure WireServer metadata endpoint
"192.0.0.0/24", // RFC 6890 IETF protocol assignments
"192.0.2.0/24", // RFC 5737 TEST-NET-1
"192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated)
"198.18.0.0/15", // RFC 2544 benchmarking
"198.51.100.0/24", // RFC 5737 TEST-NET-2
"203.0.113.0/24", // RFC 5737 TEST-NET-3
// IPv6
"100::/64", // RFC 6666 discard-only
"64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
"64:ff9b:1::/48", // RFC 8215 local-use NAT64
"2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4)
"2001:10::/28", // RFC 4843 ORCHID (deprecated)
"2001:20::/28", // RFC 7343 ORCHIDv2
"2001:db8::/32", // RFC 3849 documentation
"2002::/16", // RFC 3056 6to4 (embeds IPv4)
} {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error())
}
nets = append(nets, ipNet)
}
return nets
})
// isPrivateIP reports whether ip falls in a private (net.IP.IsPrivate) or reserved special-purpose
// range (see reservedIPNets) that must not be considered a public/external destination.
func isPrivateIP(ip net.IP) bool {
if ip.IsPrivate() {
return true
}
for _, ipNet := range reservedIPNets() {
if ipNet.Contains(ip) {
return true
}
}
return false
}
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7),
// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.).
// Also called LAN/Intranet.
const MatchBuiltinPrivate = "private"
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
@@ -100,11 +155,11 @@ func (hl *HostMatchList) checkIP(ip net.IP) bool {
for _, builtin := range hl.builtins {
switch builtin {
case MatchBuiltinExternal:
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
if ip.IsGlobalUnicast() && !isPrivateIP(ip) {
return true
}
case MatchBuiltinPrivate:
if ip.IsPrivate() {
if isPrivateIP(ip) {
return true
}
case MatchBuiltinLoopback:
+55
View File
@@ -159,3 +159,58 @@ func TestHostOrIPMatchesList(t *testing.T) {
}
test(cases)
}
// TestReservedRanges ensures special-purpose ranges that net.IP.IsPrivate misses are kept out of the
// "external" allow-list (the default for webhook delivery and repository migrations) and folded into
// the "private" block-list, so they cannot be used for SSRF to metadata/internal endpoints.
func TestReservedRanges(t *testing.T) {
external := ParseHostMatchList("", "external")
private := ParseHostMatchList("", "private")
// legitimate public destinations: external, not private
for _, ip := range []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "1000::1"} {
addr := net.ParseIP(ip)
assert.Truef(t, external.MatchIPAddr(addr), "public ip %s should be external", ip)
assert.Falsef(t, private.MatchIPAddr(addr), "public ip %s should not be private", ip)
}
// RFC 1918 / RFC 4193 private ranges (now folded into privateIPNets instead of net.IP.IsPrivate):
// not external, blockable as private. Includes range edges to guard the CIDR boundaries.
for _, ip := range []string{
"10.0.0.0", "10.255.255.255", // 10.0.0.0/8
"172.16.0.0", "172.31.255.255", // 172.16.0.0/12
"192.168.0.0", "192.168.255.255", // 192.168.0.0/16
"fc00::", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", // fc00::/7
} {
addr := net.ParseIP(ip)
assert.Falsef(t, external.MatchIPAddr(addr), "private ip %s must not be external", ip)
assert.Truef(t, private.MatchIPAddr(addr), "private ip %s should match private block-list", ip)
}
// 172.32.0.0 is just outside 172.16.0.0/12: a public destination, not private
if addr := net.ParseIP("172.32.0.0"); assert.NotNil(t, addr) {
assert.True(t, external.MatchIPAddr(addr), "172.32.0.0 should be external")
assert.False(t, private.MatchIPAddr(addr), "172.32.0.0 should not be private")
}
// reserved ranges that IsPrivate does not cover: not external, but blockable as private
for _, ip := range []string{
"100.64.0.1", // CGNAT
"100.127.255.254", // CGNAT
"168.63.129.16", // Azure WireServer
"192.0.2.1", // TEST-NET-1
"198.18.0.1", // benchmarking
"198.51.100.1", // TEST-NET-2
"203.0.113.1", // TEST-NET-3
"192.88.99.1", // 6to4 relay anycast
"64:ff9b::1", // NAT64
"64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254
"2001::1", // Teredo
"2002::1", // 6to4
"2001:db8::1", // documentation
} {
addr := net.ParseIP(ip)
assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip)
assert.Truef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip)
}
}
+13 -2
View File
@@ -146,15 +146,26 @@ func ParseControlFile(r io.Reader) (*Package, error) {
var depends strings.Builder
var control strings.Builder
s := bufio.NewScanner(io.TeeReader(r, &control))
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
s := bufio.NewScanner(r)
for s.Scan() {
line := s.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
// A binary package control file holds exactly one stanza. Stop at the
// blank line that terminates it, otherwise a crafted control file could
// smuggle additional stanzas (with attacker-chosen Filename/Package
// fields) into the generated repository "Packages" index.
if control.Len() == 0 {
continue
}
break
}
control.WriteString(line)
control.WriteByte('\n')
if line[0] == ' ' || line[0] == '\t' {
switch key {
case "Description":
+15
View File
@@ -184,4 +184,19 @@ func TestParseControlFile(t *testing.T) {
assert.NotNil(t, p)
}
})
t.Run("SingleStanzaOnly", func(t *testing.T) {
// A control file with a trailing stanza must not leak the extra fields into
// p.Control, otherwise buildPackagesIndices would emit a second package entry
// with an attacker-chosen Filename into the repository "Packages" index.
content := bytes.NewBufferString("Package: realpkg\nVersion: 1.0.0\nArchitecture: amd64\nMaintainer: a <a@b.c>\nDescription: real\n\nPackage: openssl\nVersion: 99.0\nArchitecture: amd64\nFilename: pool/main/o/openssl/evil.deb\nDescription: spoofed\n")
p, err := ParseControlFile(content)
assert.NoError(t, err)
assert.NotNil(t, p)
assert.Equal(t, "realpkg", p.Name)
assert.Equal(t, "1.0.0", p.Version)
assert.NotContains(t, p.Control, "openssl")
assert.NotContains(t, p.Control, "evil.deb")
})
}
+23 -2
View File
@@ -139,10 +139,24 @@ func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, c
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
// TODO: store workflow name as a field in ActionRun to avoid parsing
runName := path.Base(run.WorkflowID)
// fall back to the file name when the workflow has no non-blank `name:`
if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 {
runName = wfs[0].Name
if name := strings.TrimSpace(wfs[0].Name); name != "" {
runName = name
}
}
ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces
// Mix the workflow file path into the hash so two workflow files that
// share the same `name:` and job name produce distinct commit statuses
// even though they render identically — matching GitHub's behavior
// (issue #35699).
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + run.WorkflowID)
// Pre-fix rows were hashed from Context alone. If a pre-existing row with
// the legacy hash is still the "latest" for this SHA, reuse that hash so
// the new row supersedes it; otherwise the old pending status would stay
// stuck forever (it lives in its own dedupe group). Only relevant for
// in-flight workflows at upgrade time.
legacyHash := git_model.HashCommitStatusContext(ctxName)
state := toCommitStatus(job.Status)
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
description := toCommitStatusDescription(job)
@@ -152,7 +166,13 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
return fmt.Errorf("GetLatestCommitStatus: %w", err)
}
for _, v := range statuses {
if v.Context == ctxName {
if v.ContextHash == legacyHash && v.Context == ctxName {
ctxHash = legacyHash
break
}
}
for _, v := range statuses {
if v.ContextHash == ctxHash {
if v.State == state && v.TargetURL == targetURL && v.Description == description {
return nil
}
@@ -166,6 +186,7 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
TargetURL: targetURL,
Description: description,
Context: ctxName,
ContextHash: ctxHash,
State: state,
CreatorID: creator.ID,
}
+154
View File
@@ -11,8 +11,10 @@ import (
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/timeutil"
@@ -146,6 +148,158 @@ func TestGetCommitActionsStatusMap(t *testing.T) {
assert.Empty(t, nilInfo.IconStatus(statuses[0]))
}
// TestCreateCommitStatus_DistinctWorkflowFilesSameName covers issue #35699:
// two workflow files with the same `name:` and same job name must produce
// two distinct commit statuses, not be deduplicated into one.
func TestCreateCommitStatus_DistinctWorkflowFilesSameName(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
payload := []byte(`
name: test-run
on: pull_request
jobs:
my-test:
runs-on: ubuntu-latest
steps:
- run: echo hi
`)
for _, spec := range []struct {
workflowID string
runID, jobID int64
}{
{"workflow1.yaml", 99101, 99201},
{"workflow2.yaml", 99102, 99202},
} {
run := &actions_model.ActionRun{
ID: spec.runID, Index: spec.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: spec.workflowID, CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: spec.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-test", Status: actions_model.StatusWaiting,
WorkflowPayload: payload,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, run, job))
}
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
// Both workflow files should produce a row even though the display
// Context is identical — matching GitHub's behavior.
hashes := map[string]struct{}{}
targets := map[string]struct{}{}
for _, st := range statuses {
hashes[st.ContextHash] = struct{}{}
targets[st.TargetURL] = struct{}{}
assert.Equal(t, "test-run / my-test (pull_request)", st.Context)
}
assert.Len(t, hashes, 2, "expected distinct ContextHash per workflow file")
assert.Len(t, targets, 2, "expected distinct TargetURL per workflow file")
}
// TestCreateCommitStatus_LegacyHashRecovery covers the upgrade path: a pending
// status created before the fix (hashed from Context alone) must still be
// superseded by a follow-up event, instead of being orphaned in its own dedupe
// group while a new row accumulates under the new hash.
func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
ctxName := "legacy.yaml / my-job (push)"
legacyHash := git_model.HashCommitStatusContext(ctxName)
sha, err := git.NewIDFromString(branch.CommitID)
require.NoError(t, err)
creator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
Repo: repo,
Creator: creator,
SHA: sha,
CommitStatus: &git_model.CommitStatus{
State: commitstatus.CommitStatusPending,
Context: ctxName,
ContextHash: legacyHash,
TargetURL: "https://example.invalid/legacy",
Description: "Waiting to run",
},
}))
run := &actions_model.ActionRun{
ID: 99301, Index: 99301, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: "legacy.yaml", CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: 99302, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-job", Status: actions_model.StatusSuccess,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
// The new row must reuse the legacy hash so GetLatestCommitStatus returns
// only one entry for this Context — the success, not the orphaned pending.
matches := 0
for _, s := range latest {
if s.Context == ctxName {
matches++
assert.Equal(t, legacyHash, s.ContextHash)
assert.Equal(t, commitstatus.CommitStatusSuccess, s.State)
}
}
assert.Equal(t, 1, matches)
}
// TestCreateCommitStatus_UnnamedWorkflowUsesFileName: a workflow with no
// non-blank `name:` uses the file name in the Context, not an empty
// "/ job (event)" — covers both an omitted and a whitespace-only name.
func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
for _, tc := range []struct {
workflowID string
runID, jobID int64
payload string
}{
{"unnamed.yaml", 99401, 99411, "on: push\n"},
{"blank.yaml", 99402, 99412, "name: \" \"\non: push\n"},
} {
run := &actions_model.ActionRun{
ID: tc.runID, Index: tc.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: tc.workflowID, CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: tc.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-test", Status: actions_model.StatusWaiting,
WorkflowPayload: []byte(tc.payload + `jobs:
my-test:
runs-on: ubuntu-latest
steps:
- run: echo hi
`),
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
statuses := findCommitStatusesForContext(t, repo.ID, branch.CommitID, tc.workflowID+" / my-test (push)")
require.Len(t, statuses, 1)
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
}
}
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
t.Helper()
+12 -19
View File
@@ -129,11 +129,7 @@
<div class="flex-text-inline">
{{if .Author}}
{{ctx.AvatarUtils.Avatar .Author 20}}
{{if .Author.FullName}}
<a href="{{.Author.HomeLink}}"><strong>{{.Author.FullName}}</strong></a>
{{else}}
<a href="{{.Author.HomeLink}}"><strong>{{.Commit.Author.Name}}</strong></a>
{{end}}
<strong>{{.Author.GetShortDisplayNameLinkHTML}}</strong>
{{else}}
{{ctx.AvatarUtils.AvatarByEmail .Commit.Author.Email .Commit.Author.Email 20}}
<strong>{{.Commit.Author.Name}}</strong>
@@ -141,18 +137,19 @@
</div>
<span class="tw-text-text-light">{{DateUtils.TimeSince .Commit.Committer.When}}</span>
<div class="flex-text-inline">
{{if or (ne .Commit.Committer.Name .Commit.Author.Name) (ne .Commit.Committer.Email .Commit.Author.Email)}}
{{$committerIsAuthor := and (eq .Commit.Committer.Name .Commit.Author.Name) (eq .Commit.Committer.Email .Commit.Author.Email)}}
{{if not $committerIsAuthor}}
<div class="flex-text-inline">
<span class="tw-text-text-light">{{ctx.Locale.Tr "repo.diff.committed_by"}}</span>
{{if and .Verification.CommittingUser .Verification.CommittingUser.ID}}
{{if and .Verification.CommittingUser}}
{{ctx.AvatarUtils.Avatar .Verification.CommittingUser 20}}
<a href="{{.Verification.CommittingUser.HomeLink}}"><strong>{{.Commit.Committer.Name}}</strong></a>
<strong>{{.Verification.CommittingUser.GetShortDisplayNameLinkHTML}}</strong>
{{else}}
{{ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Name 20}}
{{ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Email 20}}
<strong>{{.Commit.Committer.Name}}</strong>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{if .CommitOtherParticipants}}
<div class="flex-text-inline">
@@ -162,16 +159,12 @@
{{$gitIdentity := $participant.GitIdentity}}
{{if $user}}
{{ctx.AvatarUtils.Avatar $user 20}}
<a class="muted" href="{{$user.HomeLink}}"><strong>{{$user.GetDisplayName}}</strong></a>
<strong>{{$user.GetShortDisplayNameLinkHTML}}</strong>
{{else}}
{{$gitName := $gitIdentity.Name}}
{{$gitEmail := $gitIdentity.Email}}
{{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitName 20}}
{{if $gitEmail}}
<a class="muted" href="mailto:{{$gitEmail}}"><strong>{{$gitName}}</strong></a>
{{else}}
<strong>{{$gitName}}</strong>
{{end}}
{{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitEmail 20}}{{/* use the same layout as the "author" above */}}
<strong>{{$gitName}}</strong>
{{end}}
{{end}}
</div>
+11 -7
View File
@@ -638,7 +638,7 @@ jobs:
return false
}
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
return true
}
return false
@@ -680,14 +680,18 @@ func checkCommitStatusAndInsertFakeStatus(t *testing.T, repo *repo_model.Reposit
assert.Len(t, latestCommitStatuses, 1)
assert.Equal(t, commitstatus.CommitStatusPending, latestCommitStatuses[0].State)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
}
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha, targetURL, context string) {
// insertFakeStatus inserts a success status that lands in the same dedupe
// group as `prev` — the actions runner mixes the workflow file path into
// ContextHash, so we must reuse it (rather than recomputing from Context).
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha string, prev *git_model.CommitStatus) {
err := commitstatus_service.CreateCommitStatus(t.Context(), repo, user_model.NewActionsUser(), sha, &git_model.CommitStatus{
State: commitstatus.CommitStatusSuccess,
TargetURL: targetURL,
Context: context,
State: commitstatus.CommitStatusSuccess,
TargetURL: prev.TargetURL,
Context: prev.Context,
ContextHash: prev.ContextHash,
})
assert.NoError(t, err)
}
@@ -822,7 +826,7 @@ jobs:
return false
}
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
return true
}
return false
+1
View File
@@ -5,6 +5,7 @@ RUN_MODE = prod
[database]
DB_TYPE = sqlite3
PATH = gitea-test.db
SQLITE_JOURNAL_MODE = WAL
[indexer]
REPO_INDEXER_ENABLED = true
+2
View File
@@ -9,6 +9,8 @@ import {
test('createElementFromHTML', () => {
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
expect(createElementFromHTML('<tr data-x="1"><td>foo</td></tr>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
expect(createElementFromHTML('<TR data-x="1"><td>foo</td></TR>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
expect(createElementFromHTML('<trx></trx>').outerHTML).toEqual('<trx></trx>');
});
test('createElementFromAttrs', () => {
+7 -2
View File
@@ -267,9 +267,14 @@ export function isElemVisible(el: HTMLElement): boolean {
export function createElementFromHTML<T extends Element>(htmlString: string): T {
htmlString = htmlString.trim();
const isLetter = (code: number) => (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
const startsWithTag = (s: string, tag: string) => {
return s.startsWith('<') &&
s.substring(1, 1 + tag.length).toLowerCase() === tag.toLowerCase() &&
!isLetter(s[1 + tag.length].charCodeAt(0));
};
// There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
// eslint-disable-next-line github/unescaped-html-literal
if (htmlString.startsWith('<tr')) {
if (startsWithTag(htmlString, 'tr')) {
const container = document.createElement('table');
container.innerHTML = htmlString;
return container.querySelector<T>('tr')!;
+5 -3
View File
@@ -1,3 +1,5 @@
import {html, htmlRaw} from './html.ts';
export function urlQueryEscape(s: string) {
// See "TestQueryEscape" in backend
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
@@ -35,9 +37,9 @@ const urlLinkifyPattern = /(<([-\w]+)[^>]*>)|(<\/([-\w]+)[^>]*>)|(https?:\/\/[^\
const trailingPunctPattern = /[.,;:!?]+$/;
// Convert URLs to clickable links in HTML, preserving existing HTML tags
export function linkifyURLs(html: string): string {
export function linkifyURLs(htmlString: string): string {
let inAnchor = false;
return html.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => {
return htmlString.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => {
// skip URLs inside existing <a> tags
if (openTag === 'a') {
inAnchor = true;
@@ -54,6 +56,6 @@ export function linkifyURLs(html: string): string {
const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url;
const trailing = trailingPunct ? trailingPunct[0] : '';
// safe because regexp only matches valid URLs (no quotes or angle brackets)
return `<a href="${cleanUrl}" target="_blank">${cleanUrl}</a>${trailing}`; // eslint-disable-line github/unescaped-html-literal
return html`<a href="${htmlRaw(cleanUrl)}" target="_blank">${htmlRaw(cleanUrl)}</a>${htmlRaw(trailing)}`;
});
}