Compare commits

...

6 Commits

Author SHA1 Message Date
Jonathan Miller 0166a6d02a feat(ci): add upstream bug sync workflow
Branch Policy Check / Verify merge target (pull_request) Failing after 1s
Adds a scheduled workflow that runs daily at 08:00 UTC to automatically
detect new bug fixes merged into upstream Gitea's release/v1.26 branch
and create corresponding issues in the MokoGitea tracker.

The workflow:
- Queries GitHub Search API for recently merged fix/security PRs
- Cross-references against existing MokoGitea issues to avoid duplicates
- Creates labeled issues (type: bug, upstream, priority, security)
- Supports manual dispatch with configurable lookback period

Requires secrets: GH_TOKEN (GitHub), GITEA_TOKEN (MokoGitea)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 03:28:47 -05:00
jmiller 0f543903fb Merge pull request 'fix: backport upstream v1.26.2 critical fixes' (#139) from fix/upstream-v1.26.2-backports into main 2026-05-24 04:30:33 +00:00
Giteabot ec02fb9cf8 fix: treat email addresses case-insensitively (#37600) (#37611)
Branch Policy Check / Verify merge target (pull_request) Failing after 1s
2026-05-23 23:19:32 -05:00
Giteabot f639940608 Fix scheduled action panic with null event payload (#37459) (#37466)
Backport #37459 by cyphercodes

This fixes the scheduled action panic when an event payload is JSON
`null` by initializing the payload map before adding `schedule`. It also
adds regression coverage for the null-payload case.

Fixes #37447.

Co-authored-by: Rayan Salhab <r.salhab@aiyexpertsolutions.com>
Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com>
Co-authored-by: Hermes Agent (GPT-5.5) <hermes-agent@users.noreply.github.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-23 23:17:46 -05:00
jmiller 482ad13ff1 Merge pull request 'fix(ui): actions runs list broken row layout' (#138) from fix/136-actions-concurrency-nil-panic into main 2026-05-24 03:51:51 +00:00
jmiller 7a06e44e24 Merge pull request 'fix(actions): nil pointer dereference in concurrency during PR creation' (#137) from fix/136-actions-concurrency-nil-panic into main
fix(actions): nil pointer dereference in concurrency during PR creation (#137)

Closes #136
2026-05-24 03:26:39 +00:00
10 changed files with 265 additions and 78 deletions
+167
View File
@@ -0,0 +1,167 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# BRIEF: Sync upstream Gitea bug fixes into MokoGitea issue tracker
name: Upstream Bug Sync
on:
schedule:
- cron: '0 8 * * *' # daily at 08:00 UTC
workflow_dispatch:
inputs:
days_back:
description: 'How many days back to scan (default: 7)'
required: false
default: '7'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Sync upstream bugs
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
MOKOGITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
MOKOGITEA_URL: https://git.mokoconsulting.tech
MOKOGITEA_REPO: MokoConsulting/MokoGitea
UPSTREAM_BRANCH: release/v1.26
DAYS_BACK: ${{ github.event.inputs.days_back || '7' }}
run: |
python3 << 'PYEOF'
import json, os, re, sys, urllib.parse, urllib.request
from datetime import datetime, timedelta, timezone
GH_TOKEN = os.environ["GH_TOKEN"]
MOKO_TOKEN = os.environ["MOKOGITEA_TOKEN"]
MOKO_URL = os.environ["MOKOGITEA_URL"]
MOKO_REPO = os.environ["MOKOGITEA_REPO"]
BRANCH = os.environ["UPSTREAM_BRANCH"]
DAYS = int(os.environ.get("DAYS_BACK", "7"))
# Label IDs in MokoGitea
LABELS = {
"type_bug": 5757, "upstream": 5758, "security": 5032,
"critical": 5018, "high": 5019, "medium": 5020, "low": 5021,
}
def gh_get(url):
req = urllib.request.Request(url, headers={
"Authorization": f"token {GH_TOKEN}",
"Accept": "application/vnd.github.v3+json",
})
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def moko_get(path):
req = urllib.request.Request(f"{MOKO_URL}/api/v1/{path}", headers={
"Authorization": f"token {MOKO_TOKEN}",
})
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def moko_post(path, data):
payload = json.dumps(data).encode()
req = urllib.request.Request(f"{MOKO_URL}/api/v1/{path}",
data=payload, method="POST", headers={
"Authorization": f"token {MOKO_TOKEN}",
"Content-Type": "application/json",
})
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
# ── Step 1: Find recently merged upstream PRs ──
since = (datetime.now(timezone.utc) - timedelta(days=DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ")
query = f"repo:go-gitea/gitea is:pr is:merged base:{BRANCH} merged:>={since}"
encoded = urllib.parse.quote(query)
print(f"Scanning: {query}")
result = gh_get(f"https://api.github.com/search/issues?q={encoded}&per_page=100&sort=updated&order=desc")
total = result["total_count"]
print(f"Found {total} merged PRs in the last {DAYS} days")
if total == 0:
print("Nothing to sync.")
sys.exit(0)
# ── Step 2: Filter for bug/security fixes ──
bugs = []
for pr in result["items"]:
title = pr["title"]
label_names = [l["name"].lower() for l in pr.get("labels", [])]
is_fix = title.lower().startswith("fix")
is_security = any("security" in l for l in label_names) or "[security]" in title.lower()
is_bug = any("bug" in l for l in label_names)
if not (is_fix or is_security or is_bug):
continue
refs = re.findall(r"#(\d+)", title)
severity = "critical" if is_security and "[security]" in title.lower() else \
"high" if is_security else "medium"
bugs.append({
"number": pr["number"], "title": title, "url": pr["html_url"],
"severity": severity, "is_security": is_security, "refs": refs,
"merged": pr.get("pull_request", {}).get("merged_at", "")[:10],
})
print(f"Filtered to {len(bugs)} bug/security fixes")
if not bugs:
sys.exit(0)
# ── Step 3: Collect already-tracked PR numbers ──
tracked = set()
for state in ["open", "closed"]:
try:
issues = moko_get(f"repos/{MOKO_REPO}/issues?state={state}&type=issues&limit=50&labels=upstream")
for iss in issues:
text = (iss.get("body") or "") + " " + (iss.get("title") or "")
tracked.update(re.findall(r"(?:#|/pull/)(\d{4,})", text))
except Exception:
pass
print(f"Already tracked: {len(tracked)} upstream PRs")
# ── Step 4: Create issues for new bugs ──
created = skipped = errors = 0
for bug in bugs:
if any(r in tracked for r in bug["refs"]):
print(f" SKIP #{bug['number']}: {bug['title'][:55]} (tracked)")
skipped += 1
continue
labels = [LABELS["type_bug"], LABELS["upstream"], LABELS[bug["severity"]]]
if bug["is_security"]:
labels.append(LABELS["security"])
body = (
f"## Summary\n\n"
f"Upstream bug fix merged into `{BRANCH}`.\n\n"
f"## Upstream Reference\n\n"
f"- PR: {bug['url']}\n"
f"- Merged: {bug['merged']}\n"
f"- Branch: {BRANCH}\n\n"
f"## Severity: {bug['severity'].title()}"
f"{' (security)' if bug['is_security'] else ''}\n\n"
f"## Action\n\n"
f"Cherry-pick from upstream `{BRANCH}` branch.\n\n"
f"---\n"
f"*Auto-created by upstream-bug-sync workflow*\n"
f"*Authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>*"
)
try:
iss = moko_post(f"repos/{MOKO_REPO}/issues", {
"title": bug["title"], "body": body, "labels": labels,
})
print(f" CREATED #{iss['number']}: {bug['title'][:55]}")
created += 1
except Exception as e:
print(f" ERROR #{bug['number']}: {e}")
errors += 1
print(f"\n=== Done: {created} created, {skipped} skipped, {errors} errors ===")
PYEOF
+1 -1
View File
@@ -40,7 +40,7 @@ func CheckPrincipalKeyString(ctx context.Context, user *user_model.User, content
if !email.IsActivated {
continue
}
if content == email.Email {
if strings.EqualFold(content, email.LowerEmail) {
return content, nil
}
}
+6 -15
View File
@@ -12,10 +12,11 @@ import (
"code.gitea.io/gitea/modules/log"
)
const IncomingEmailTokenPlaceholder = "%{token}"
var IncomingEmail = struct {
Enabled bool
ReplyToAddress string
TokenPlaceholder string `ini:"-"`
Host string
Port int
UseTLS bool `ini:"USE_TLS"`
@@ -28,7 +29,6 @@ var IncomingEmail = struct {
}{
Mailbox: "INBOX",
DeleteHandledMessage: true,
TokenPlaceholder: "%{token}",
MaximumMessageSize: 10485760,
}
@@ -54,19 +54,10 @@ func checkReplyToAddress() error {
return errors.New("name must not be set")
}
c := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmail.TokenPlaceholder)
switch c {
case 0:
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder)
case 1:
default:
return fmt.Errorf("%s must appear only once", IncomingEmail.TokenPlaceholder)
placeholderCount := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmailTokenPlaceholder)
userPart, _, _ := strings.Cut(IncomingEmail.ReplyToAddress, "@")
if placeholderCount != 1 || !strings.Contains(userPart, IncomingEmailTokenPlaceholder) {
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmailTokenPlaceholder)
}
parts := strings.Split(IncomingEmail.ReplyToAddress, "@")
if !strings.Contains(parts[0], IncomingEmail.TokenPlaceholder) {
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder)
}
return nil
}
+13 -5
View File
@@ -132,14 +132,22 @@ func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleS
}
func withScheduleInEventPayload(eventPayload, schedule string) string {
if schedule == "" || eventPayload == "" {
if schedule == "" {
return eventPayload
}
event := map[string]any{}
if err := json.Unmarshal([]byte(eventPayload), &event); err != nil {
log.Error("withScheduleInEventPayload: unmarshal: %v", err)
return eventPayload
// eventPayload originates from json.Marshal(input.Payload) in handleSchedules,
// so a nil payload is stored as the literal "null" and pre-existing rows may be
// empty. Both cases start from a fresh map so the schedule field can still be set.
var event map[string]any
if eventPayload != "" {
if err := json.Unmarshal([]byte(eventPayload), &event); err != nil {
log.Error("withScheduleInEventPayload: unmarshal: %v", err)
return eventPayload
}
}
if event == nil {
event = map[string]any{}
}
event["schedule"] = schedule
+13 -2
View File
@@ -22,9 +22,20 @@ func TestWithScheduleInEventPayload(t *testing.T) {
assert.Equal(t, "refs/heads/main", event["ref"])
})
t.Run("keeps empty payload", func(t *testing.T) {
t.Run("adds schedule to null payload", func(t *testing.T) {
updated := withScheduleInEventPayload("null", "37 12 5 1 2")
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
assert.Equal(t, "37 12 5 1 2", event["schedule"])
})
t.Run("adds schedule to empty payload", func(t *testing.T) {
updated := withScheduleInEventPayload("", "37 12 5 1 2")
assert.Empty(t, updated)
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
assert.Equal(t, "37 12 5 1 2", event["schedule"])
})
t.Run("keeps payload when schedule empty", func(t *testing.T) {
+32 -46
View File
@@ -9,7 +9,6 @@ import (
"errors"
"fmt"
net_mail "net/mail"
"regexp"
"strings"
"time"
@@ -24,31 +23,10 @@ import (
"github.com/jhillyerd/enmime/v2"
)
var (
addressTokenRegex *regexp.Regexp
referenceTokenRegex *regexp.Regexp
)
func Init(ctx context.Context) error {
if !setting.IncomingEmail.Enabled {
return nil
}
var err error
addressTokenRegex, err = regexp.Compile(
fmt.Sprintf(
`\A%s\z`,
strings.Replace(regexp.QuoteMeta(setting.IncomingEmail.ReplyToAddress), regexp.QuoteMeta(setting.IncomingEmail.TokenPlaceholder), "(.+)", 1),
),
)
if err != nil {
return err
}
referenceTokenRegex, err = regexp.Compile(fmt.Sprintf(`\Areply-(.+)@%s\z`, regexp.QuoteMeta(setting.Domain)))
if err != nil {
return err
}
go func() {
ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Incoming Email", process.SystemProcessType, true)
defer finished()
@@ -241,7 +219,7 @@ loop:
return nil
}
handlerType, user, payload, err := token.ExtractToken(ctx, t)
handlerType, user, payload, err := token.DecodeToken(ctx, t)
if err != nil {
if _, ok := err.(*token.ErrToken); ok {
log.Info("Invalid incoming email token: %v", err)
@@ -292,22 +270,31 @@ func isAutomaticReply(env *enmime.Envelope) bool {
return autoRespond != ""
}
func extractToken(s, tokenPrefix, tokenSuffix string) string {
if len(s) <= len(tokenPrefix)+len(tokenSuffix) {
return ""
}
prefix, suffix := s[0:len(tokenPrefix)], s[len(s)-len(tokenSuffix):]
if strings.EqualFold(prefix, tokenPrefix) && strings.EqualFold(suffix, tokenSuffix) {
return s[len(tokenPrefix) : len(s)-len(tokenSuffix)]
}
return ""
}
// searchTokenInHeaders looks for the token in To, Delivered-To and References
func searchTokenInHeaders(env *enmime.Envelope) string {
if addressTokenRegex != nil {
to, _ := env.AddressList("To")
to, _ := env.AddressList("To")
token := searchTokenInAddresses(to)
if token != "" {
return token
}
token := searchTokenInAddresses(to)
if token != "" {
return token
}
deliveredTo, _ := env.AddressList("Delivered-To")
deliveredTo, _ := env.AddressList("Delivered-To")
token = searchTokenInAddresses(deliveredTo)
if token != "" {
return token
}
token = searchTokenInAddresses(deliveredTo)
if token != "" {
return token
}
references := env.GetHeader("References")
@@ -322,10 +309,9 @@ func searchTokenInHeaders(env *enmime.Envelope) string {
if end == -1 || begin > end {
break
}
match := referenceTokenRegex.FindStringSubmatch(references[begin:end])
if len(match) == 2 {
return match[1]
t := extractToken(references[begin:end], "reply-", "@"+setting.Domain)
if t != "" {
return t
}
references = references[end+1:]
@@ -336,15 +322,15 @@ func searchTokenInHeaders(env *enmime.Envelope) string {
// searchTokenInAddresses looks for the token in an address
func searchTokenInAddresses(addresses []*net_mail.Address) string {
for _, address := range addresses {
match := addressTokenRegex.FindStringSubmatch(address.Address)
if len(match) != 2 {
continue
}
return match[1]
tokenPrefix, tokenSuffix, _ := strings.Cut(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder)
if tokenSuffix == "" {
return ""
}
for _, address := range addresses {
if t := extractToken(address.Address, tokenPrefix, tokenSuffix); t != "" {
return t
}
}
return ""
}
+14
View File
@@ -7,6 +7,8 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/setting"
"github.com/jhillyerd/enmime/v2"
"github.com/stretchr/testify/assert"
)
@@ -68,6 +70,18 @@ func TestIsAutomaticReply(t *testing.T) {
}
}
func TestSearchTokenInHeadersCaseInsensitive(t *testing.T) {
setting.IncomingEmail.ReplyToAddress = "InComing+%{token}@ExAmPle.com"
setting.Domain = "DoMain.com"
mkEnv := func(s string) *enmime.Envelope {
env, _ := enmime.ReadEnvelope(strings.NewReader(s + "\r\n\r\n"))
return env
}
assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("To: incoming+abc@EXAMPLE.COM")))
assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("Delivered-To: INCOMING+abc@example.com")))
assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("References: <ReplY-abc@DomaiN.COM>")))
}
func TestGetContentFromMailReader(t *testing.T) {
mailString := "Content-Type: multipart/mixed; boundary=message-boundary\r\n" +
"\r\n" +
+2 -2
View File
@@ -182,7 +182,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1)
msg.ReplyTo = replyAddress
msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress))
@@ -194,7 +194,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1)
listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
}
}
+8 -5
View File
@@ -9,6 +9,7 @@ import (
"crypto/sha256"
"encoding/base32"
"fmt"
"strings"
"time"
user_model "code.gitea.io/gitea/models/user"
@@ -73,9 +74,11 @@ func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, er
return encodingWithoutPadding.EncodeToString(append([]byte{tokenVersion1}, packagedData...)), nil
}
// ExtractToken extracts the action/user tuple from the token and verifies the content
func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.User, []byte, error) {
data, err := encodingWithoutPadding.DecodeString(token)
// DecodeToken decodes the handler, user and payload from the token and verifies the content
func DecodeToken(ctx context.Context, token string) (HandlerType, *user_model.User, []byte, error) {
// MTAs are permitted to alter the case of the local-part (RFC 5321 §2.4), so normalize
// to the base32 alphabet before decoding to survive a lowercased reply-to address.
data, err := encodingWithoutPadding.DecodeString(strings.ToUpper(token))
if err != nil {
return UnknownHandlerType, nil, nil, err
}
@@ -118,11 +121,11 @@ func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.U
return handlerType, user, innerPayload, nil
}
// generateHmac creates a trunkated HMAC for the given payload
// generateHmac creates a truncated HMAC for the given payload
func generateHmac(secret, payload []byte) []byte {
mac := crypto_hmac.New(sha256.New, secret)
mac.Write(payload)
hmac := mac.Sum(nil)
return hmac[:10] // RFC2104 recommends not using less then 80 bits
return hmac[:10] // RFC2104 recommends not using less than 80 bits
}
+9 -2
View File
@@ -66,7 +66,14 @@ func TestIncomingEmail(t *testing.T) {
assert.NoError(t, err)
assert.NotEmpty(t, token)
ht, u, p, err := token_service.ExtractToken(t.Context(), token)
ht, u, p, err := token_service.DecodeToken(t.Context(), token)
assert.NoError(t, err)
assert.Equal(t, token_service.ReplyHandlerType, ht)
assert.Equal(t, user.ID, u.ID)
assert.Equal(t, payload, p)
// MTAs may lowercase the local-part of the reply-to address (RFC 5321 §2.4).
ht, u, p, err = token_service.DecodeToken(t.Context(), strings.ToLower(token))
assert.NoError(t, err)
assert.Equal(t, token_service.ReplyHandlerType, ht)
assert.Equal(t, user.ID, u.ID)
@@ -189,7 +196,7 @@ func TestIncomingEmail(t *testing.T) {
assert.NoError(t, err)
msg := sender_service.NewMessageFrom(
strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1),
strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1),
"",
user.Email,
"",