Compare commits

..
4 Commits
Author SHA1 Message Date
wxiaoguangandGitHub d7bc52beea refactor: git patch apply (#38637) (#38638)
Backport #38637
2026-07-26 17:32:02 +00:00
ccd38f9a70 fix: orgmode render include path (#38642) (#38645)
Backport #38642

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-26 16:58:22 +00:00
f47e3d930d fix(actions): cancel tasks immediately when the runner stopped reporting (#38616) (#38644)
Backport #38616 by @bircni

Fixes jobs that get stuck in `cancelling` after Gitea is restarted while
a job is running.

Reproduction:

1. Run a job with `sleep 100`
2. Stop Gitea and wait 120s (> 100s)
3. Start Gitea — the job is still `running`; cancel it, and it stays in
`cancelling`

## Cause

A cancellation is only ever delivered to a runner as the *response* to
its `UpdateTask`
RPC. A runner that has already given up on the task — it crashed, or it
failed to report
the final state while Gitea was unreachable — never calls `UpdateTask`
again, so it never
learns about the cancellation. The task then sits in `cancelling` until
`stop_zombie_tasks`
reaps it, which needs `ZOMBIE_TASK_TIMEOUT` (10m) of silence and only
runs every 5 minutes.

Cancelling actually made this worse. xorm rewrites the `updated` column
on every `UPDATE`,
even when `Cols()` restricts the update to `status`, so persisting the
`cancelling` status
reset the zombie clock. Pressing cancel pushed the cleanup a full
`ZOMBIE_TASK_TIMEOUT`
into the future instead of bringing it forward.

## Change

`StopTask` now skips the `cancelling` handshake when the task has had no
state report from
its runner for longer than `TaskReportTimeout` (1 minute) and cancels it
directly. This
joins the two existing fallbacks — runner deleted, and runner without
cancelling support —
so every cancel path (web UI, API, concurrency, rerun) is covered.

Runners report the state of a running task every few seconds, so a
minute of silence means
the runner is gone. The value is a constant rather than a setting
because it only decides
whether the runner is still reachable, not whether a task should be
killed —
`ZOMBIE_TASK_TIMEOUT` still owns that.

### Tradeoff

If a runner is alive but has been silent for over a minute and is
cancelled in that window,
it skips the graceful post-step cleanup added in #37275. No work is
lost: the runner still
learns the outcome on its next report, because `UpdateTask` returns the
task status as the
result and the runner stops there. That is the behaviour that existed
before #37275.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-26 14:38:54 +00:00
9972bee41f fix(issues): fix label bulk-load key and reduce log noise in LoadLabel (#38632) (#38643)
Backport #38632 by @eliroca

CommentList.loadLabels keyed the result map by label.ID but looked up by
comment.ID, so every label event fell back to individual DB queries.

This caused log spam for any comment where the label was deleted, since
ToTimelineComment calls LoadLabel unconditionally for all comment types
including those with LabelID=0.

Fix the map key, skip the query when LabelID=0, demote the now rarely
triggered orphaned-label log to Debug, and fix a typo ("Commit" ->
"Comment") in that message.

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Elisei Roca <eroca@suse.de>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-26 13:20:17 +00:00
10 changed files with 266 additions and 328 deletions
+14 -1
View File
@@ -60,6 +60,12 @@ type ActionTask struct {
Updated timeutil.TimeStamp `xorm:"updated index"`
}
// taskReportTimeout is how long a task may go without contact from its runner before the
// runner is assumed gone. Runners report state and stream logs every few seconds, both of
// which refresh ActionTask.Updated. Shorter than setting.Actions.ZombieTaskTimeout because
// it only decides whether the runner is reachable, not whether the task should be killed.
const taskReportTimeout = time.Minute
var successfulTokenTaskCache *lru.Cache[string, any]
func init() {
@@ -567,6 +573,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
status = StatusCancelled
} else if !runner.HasCancellingSupport {
status = StatusCancelled
} else if task.Updated.AddDuration(taskReportTimeout) < now {
// A runner that stopped reporting will never acknowledge the cancellation either,
// so skip the handshake instead of waiting for the zombie task cleanup.
status = StatusCancelled
}
}
@@ -581,7 +591,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
return err
}
return UpdateTask(ctx, task, "status")
// NoAutoTime keeps "updated" at the runner's last contact: re-cancelling an already
// cancelling task must not defer the timeout above or the zombie task cleanup.
_, err := e.ID(task.ID).Cols("status").NoAutoTime().Update(task)
return err
}
task.Status = status
+128 -171
View File
@@ -99,68 +99,11 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
}
func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
newRunningTask := func(t *testing.T) (*ActionTask, *ActionRunJob) {
t.Helper()
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "cancelling-finalization-job",
Attempt: 1,
JobID: "cancelling-finalization-job",
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &ActionRunner{
UUID: "runner-cancelling-supported",
Name: "runner-cancelling-supported",
HasCancellingSupport: true,
}
require.NoError(t, db.Insert(t.Context(), runner))
task := &ActionTask{
JobID: job.ID,
Attempt: 1,
RunnerID: runner.ID,
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
}
require.NoError(t, db.Insert(t.Context(), task))
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
return task, job
}
testResult := func(t *testing.T, result runnerv1.Result) {
t.Helper()
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTask(t)
task, job := newRunningTaskForCancelling(t, "cancelling-finalization-job", true)
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
@@ -190,137 +133,92 @@ func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
})
}
func TestStopTaskCancellingFallsBackForLegacyRunner(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// TestStopTaskCancellingFallsBackToCancelled covers the cases where the cancelling handshake can
// never complete, so StopTask must cancel right away instead of waiting for the zombie task cleanup.
func TestStopTaskCancellingFallsBackToCancelled(t *testing.T) {
assertCancelled := func(t *testing.T, task *ActionTask, job *ActionRunJob) {
t.Helper()
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
assert.NotZero(t, taskAfterStop.Stopped)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
assert.NotZero(t, jobAfterStop.Stopped)
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "legacy-cancelling-job",
Attempt: 1,
JobID: "legacy-cancelling-job",
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
// A runner too old to know the cancelling state can only be stopped by a final status.
t.Run("legacy runner", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "legacy-cancelling-job", false)
runner := &ActionRunner{
UUID: "runner-legacy-no-cancelling",
Name: "runner-legacy-no-cancelling",
HasCancellingSupport: false,
}
require.NoError(t, db.Insert(t.Context(), runner))
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
assertCancelled(t, task, job)
})
task := &ActionTask{
JobID: job.ID,
Attempt: 1,
RunnerID: runner.ID,
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
}
require.NoError(t, db.Insert(t.Context(), task))
// The runner is gone, e.g. an ephemeral runner was cleaned up, so nobody can acknowledge.
t.Run("missing runner", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "missing-runner-cancelling-job", true)
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
_, err := db.DeleteByID[ActionRunner](t.Context(), task.RunnerID)
require.NoError(t, err)
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
assertCancelled(t, task, job)
})
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
assert.NotZero(t, taskAfterStop.Stopped)
// The runner went silent, e.g. it gave up while Gitea was restarting, so it never picks up the request.
t.Run("silent runner", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "silent-runner-cancelling-job", true)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
assert.NotZero(t, jobAfterStop.Stopped)
// NoAutoTime because the point of the test is an "updated" older than xorm would write
task.Updated = timeutil.TimeStampNow().AddDuration(-2 * taskReportTimeout)
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
require.NoError(t, err)
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
assertCancelled(t, task, job)
// A runner coming back still learns the outcome from the UpdateTask response,
// and its late result does not overwrite the cancellation.
late, err := UpdateTaskByState(t.Context(), task.RunnerID, &runnerv1.TaskState{
Id: task.ID,
Result: runnerv1.Result_RESULT_SUCCESS,
StoppedAt: timestamppb.Now(),
})
require.NoError(t, err)
assert.Equal(t, StatusCancelled, late.Status)
assert.Equal(t, runnerv1.Result_RESULT_CANCELLED, late.Status.AsResult())
})
}
func TestStopTaskCancellingFallsBackForMissingRunner(t *testing.T) {
// TestStopTaskCancellingKeepsReportTime makes sure persisting the cancelling status does not refresh
// "updated": re-cancelling would otherwise defer both the fallback to cancelled and the zombie task
// cleanup, no matter how long the runner has been silent.
func TestStopTaskCancellingKeepsReportTime(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, _ := newRunningTaskForCancelling(t, "repeated-cancelling-job", true)
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "missing-runner-cancelling-job",
Attempt: 1,
JobID: "missing-runner-cancelling-job",
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &ActionRunner{
UUID: "runner-cleaned-up-before-cancel",
Name: "runner-cleaned-up-before-cancel",
HasCancellingSupport: true,
}
require.NoError(t, db.Insert(t.Context(), runner))
task := &ActionTask{
JobID: job.ID,
Attempt: 1,
RunnerID: runner.ID,
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
}
require.NoError(t, db.Insert(t.Context(), task))
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
_, err = db.DeleteByID[ActionRunner](t.Context(), runner.ID)
// NoAutoTime because the point of the test is an "updated" older than xorm would write
lastReport := timeutil.TimeStampNow().AddDuration(-taskReportTimeout / 2)
task.Updated = lastReport
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
require.NoError(t, err)
// the runner reported recently enough, so the task waits for it to acknowledge
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
assert.NotZero(t, taskAfterStop.Stopped)
assert.Equal(t, StatusCancelling, taskAfterStop.Status)
assert.Equal(t, lastReport, taskAfterStop.Updated)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
assert.NotZero(t, jobAfterStop.Stopped)
// cancelling it again does not reset the clock either
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterSecondStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelling, taskAfterSecondStop.Status)
assert.Equal(t, lastReport, taskAfterSecondStop.Updated)
}
// TestReleaseTaskForRunner verifies that releasing a freshly-claimed task returns
@@ -458,3 +356,62 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
assert.Equal(t, StatusRunning, claimed.Status)
assert.Equal(t, task.ID, claimed.TaskID)
}
// newRunningTaskForCancelling inserts a running run/job/task assigned to a fresh runner,
// which is the state every cancellation test starts from.
func newRunningTaskForCancelling(t *testing.T, name string, hasCancellingSupport bool) (*ActionTask, *ActionRunJob) {
t.Helper()
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: name,
Attempt: 1,
JobID: name,
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &ActionRunner{
UUID: name,
Name: name,
HasCancellingSupport: hasCancellingSupport,
}
require.NoError(t, db.Insert(t.Context(), runner))
task := &ActionTask{
JobID: job.ID,
Attempt: 1,
RunnerID: runner.ID,
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
}
require.NoError(t, db.Insert(t.Context(), task))
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
return task, job
}
+5 -2
View File
@@ -544,6 +544,9 @@ func (c *Comment) GetSanitizedContentHTML() template.HTML {
// LoadLabel if comment.Type is CommentTypeLabel, then load Label
func (c *Comment) LoadLabel(ctx context.Context) error {
if c.LabelID == 0 {
return nil
}
var label Label
has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label)
if err != nil {
@@ -551,8 +554,8 @@ func (c *Comment) LoadLabel(ctx context.Context) error {
} else if has {
c.Label = &label
} else {
// Ignore Label is deleted, but not clear this table
log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID)
// label was deleted but comment rows referencing it were not cleaned up
log.Debug("Comment %d references deleted label %d", c.ID, c.LabelID)
}
return nil
+1 -1
View File
@@ -81,7 +81,7 @@ func (comments CommentList) loadLabels(ctx context.Context) error {
}
for _, comment := range comments {
comment.Label = commentLabels[comment.ID]
comment.Label = commentLabels[comment.LabelID]
}
return nil
}
+9 -1
View File
@@ -70,7 +70,15 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
htmlWriter.ExtendingWriter = w
res, err := org.New().Silent().Parse(input, "").Write(w)
cfg := org.New()
cfg.ReadFile = func(path string) ([]byte, error) {
// actually the orgmode render doesn't support rendering the content from the content again,
// so just leave the plain text to end users
content := fmt.Sprintf("#+INCLUDE: [[%s]]", path)
return []byte(content), nil
}
doc := cfg.Silent().Parse(input, "")
res, err := doc.Write(w)
if err != nil {
return fmt.Errorf("orgmode.Render failed: %w", err)
}
+31 -43
View File
@@ -21,86 +21,74 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
func TestRender_StandardLinks(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
func testRender(t *testing.T, input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[https://google.com/]]",
func TestRender_StandardLinks(t *testing.T) {
testRender(t, "[[https://google.com/]]",
`<p><a href="https://google.com/">https://google.com/</a></p>`)
test("[[ImageLink.svg][The Image Desc]]",
testRender(t, "[[ImageLink.svg][The Image Desc]]",
`<p><a href="ImageLink.svg">The Image Desc</a></p>`)
}
func TestRender_InternalLinks(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[file:test.org][Test]]",
testRender(t, "[[file:test.org][Test]]",
`<p><a href="test.org">Test</a></p>`)
test("[[./test.org][Test]]",
testRender(t, "[[./test.org][Test]]",
`<p><a href="./test.org">Test</a></p>`)
test("[[test.org][Test]]",
testRender(t, "[[test.org][Test]]",
`<p><a href="test.org">Test</a></p>`)
test("[[path/to/test.org][Test]]",
testRender(t, "[[path/to/test.org][Test]]",
`<p><a href="path/to/test.org">Test</a></p>`)
}
func TestRender_Media(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[file:../../.images/src/02/train.jpg]]",
testRender(t, "[[file:../../.images/src/02/train.jpg]]",
`<p><img src="../../.images/src/02/train.jpg" alt="../../.images/src/02/train.jpg"></p>`)
test("[[file:train.jpg]]",
testRender(t, "[[file:train.jpg]]",
`<p><img src="train.jpg" alt="train.jpg"></p>`)
// With description.
test("[[https://example.com][https://example.com/example.svg]]",
testRender(t, "[[https://example.com][https://example.com/example.svg]]",
`<p><a href="https://example.com"><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></a></p>`)
test("[[https://example.com][pre https://example.com/example.svg post]]",
testRender(t, "[[https://example.com][pre https://example.com/example.svg post]]",
`<p><a href="https://example.com">pre <img src="https://example.com/example.svg" alt="https://example.com/example.svg"> post</a></p>`)
test("[[https://example.com][https://example.com/example.mp4]]",
testRender(t, "[[https://example.com][https://example.com/example.mp4]]",
`<p><a href="https://example.com"><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></a></p>`)
test("[[https://example.com][pre https://example.com/example.mp4 post]]",
testRender(t, "[[https://example.com][pre https://example.com/example.mp4 post]]",
`<p><a href="https://example.com">pre <video src="https://example.com/example.mp4">https://example.com/example.mp4</video> post</a></p>`)
// Without description.
test("[[https://example.com/example.svg]]",
testRender(t, "[[https://example.com/example.svg]]",
`<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></p>`)
test("[[https://example.com/example.mp4]]",
testRender(t, "[[https://example.com/example.mp4]]",
`<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix
test(`[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
testRender(t, `[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
`<p><a href="https://example.com/"><img src="https://example.com/foo%20bar.svg" alt="https://example.com/foo%20bar.svg"></a></p>`)
test(`[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
testRender(t, `[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
`<p><a href="https://example.com/foo%20bar.svg">Goto Image</a></p>`)
test(`[[file:https://example.com/link][https://example.com/image.jpg]]`,
testRender(t, `[[file:https://example.com/link][https://example.com/image.jpg]]`,
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
test(`[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
testRender(t, `[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
}
func TestRender_Source(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test(`#+begin_src c
testRender(t, `#+begin_src c
int a;
#+end_src
`, `<div class="src src-c">
<pre><code class="chroma language-c"><span class="kt">int</span> <span class="n">a</span><span class="p">;</span></code></pre>
</div>`)
}
func TestRender_IncludeLink(t *testing.T) {
testRender(t, `#+INCLUDE: "./other.org" src text`, `<div class="src src-text">
<pre><code class="chroma language-plaintext">#+INCLUDE: [[other.org]]</code></pre>
</div>`)
}
+10 -89
View File
@@ -4,7 +4,6 @@
package files
import (
"context"
"errors"
"fmt"
"strings"
@@ -13,7 +12,7 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/structs"
"gitea.dev/services/pull"
)
@@ -35,57 +34,23 @@ func (err ErrCommitIDDoesNotMatch) Error() string {
}
// CherryPick cherry-picks or reverts a commit to the given repository
func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, revert bool, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
func CherryPick(ctx reqctx.RequestContext, repo *repo_model.Repository, doer *user_model.User, revert bool, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo)
if err != nil {
return nil, err
}
defer closer.Close()
if err := opts.Validate(ctx, repo, gitRepo, doer); err != nil {
return nil, err
}
message := strings.TrimSpace(opts.Message)
t, err := NewTemporaryUploadRepository(repo)
t, err := gitPatchPrepare(ctx, repo, gitRepo, doer, opts)
if err != nil {
log.Error("NewTemporaryUploadRepository failed: %v", err)
return nil, err
}
defer t.Close()
if err := t.Clone(ctx, opts.OldBranch, false); err != nil {
return nil, err
}
if err := t.SetDefaultIndex(ctx); err != nil {
return nil, err
}
if err := t.RefreshIndex(ctx); err != nil {
return nil, err
}
// Get the commit of the original branch
commit, err := t.GetBranchCommit(opts.OldBranch)
err = t.RefreshIndex(ctx)
if err != nil {
return nil, err // Couldn't get a commit for the branch
return nil, err
}
// Assigned LastCommitID in opts if it hasn't been set
if opts.LastCommitID == "" {
opts.LastCommitID = commit.ID.String()
} else {
lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID)
if err != nil {
return nil, fmt.Errorf("CherryPick: Invalid last commit ID: %w", err)
}
opts.LastCommitID = lastCommitID.String()
if commit.ID.String() != opts.LastCommitID {
return nil, ErrCommitIDDoesNotMatch{
GivenCommitID: opts.LastCommitID,
CurrentCommitID: opts.LastCommitID,
}
}
}
commit, err = t.GetCommit(strings.TrimSpace(opts.Content))
commit, err := t.GetCommit(strings.TrimSpace(opts.Content))
if err != nil {
return nil, err
}
@@ -101,8 +66,7 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod
}
description := fmt.Sprintf("CherryPick %s onto %s", right, opts.OldBranch)
conflict, _, err := pull.AttemptThreeWayMerge(ctx,
t.basePath, t.gitRepo, base, opts.LastCommitID, right, description)
conflict, _, err := pull.AttemptThreeWayMerge(ctx, t.basePath, t.gitRepo, base, opts.LastCommitID, right, description)
if err != nil {
return nil, fmt.Errorf("failed to three-way merge %s onto %s: %w", right, opts.OldBranch, err)
}
@@ -111,48 +75,5 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod
return nil, errors.New("failed to merge due to conflicts")
}
treeHash, err := t.WriteTree(ctx)
if err != nil {
// likely non-sensical tree due to merge conflicts...
return nil, err
}
// Now commit the tree
commitOpts := &CommitTreeUserOptions{
ParentCommitID: "HEAD",
TreeHash: treeHash,
CommitMessage: message,
SignOff: opts.Signoff,
DoerUser: doer,
AuthorIdentity: opts.Author,
AuthorTime: nil,
CommitterIdentity: opts.Committer,
CommitterTime: nil,
}
if opts.Dates != nil {
commitOpts.AuthorTime, commitOpts.CommitterTime = &opts.Dates.Author, &opts.Dates.Committer
}
commitHash, err := t.CommitTree(ctx, commitOpts)
if err != nil {
return nil, err
}
// Then push this tree to NewBranch
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, false); err != nil {
return nil, err
}
commit, err = t.GetCommit(commitHash)
if err != nil {
return nil, err
}
fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil
verification := GetPayloadCommitVerification(ctx, commit)
fileResponse := &structs.FileResponse{
Commit: fileCommitResponse,
Verification: verification,
}
return fileResponse, nil
return gitPatchCommitPush(ctx, t, repo, doer, opts)
}
+31 -19
View File
@@ -14,7 +14,7 @@ import (
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
asymkey_service "gitea.dev/services/asymkey"
@@ -110,31 +110,27 @@ func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_mode
return nil
}
// ApplyDiffPatch applies a patch to the given repository
func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
func gitPatchPrepare(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (_ *TemporaryUploadRepository, retErr error) {
err := repo.MustNotBeArchived()
if err != nil {
return nil, err
}
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
if err != nil {
return nil, err
}
defer closer.Close()
if err := opts.Validate(ctx, repo, gitRepo, doer); err != nil {
return nil, err
}
message := strings.TrimSpace(opts.Message)
t, err := NewTemporaryUploadRepository(repo)
if err != nil {
log.Error("NewTemporaryUploadRepository failed: %v", err)
return nil, fmt.Errorf("NewTemporaryUploadRepository failed: %w", err)
}
defer t.Close()
if err := t.Clone(ctx, opts.OldBranch, true); err != nil {
defer func() {
if retErr != nil {
t.Close()
}
}()
// here must NOT use bare repo, because the following git commands might operate working tree ("--index") directly
if err := t.Clone(ctx, opts.OldBranch, false); err != nil {
return nil, err
}
if err := t.SetDefaultIndex(ctx); err != nil {
@@ -153,7 +149,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
} else {
lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID)
if err != nil {
return nil, fmt.Errorf("ApplyPatch: Invalid last commit ID: %w", err)
return nil, fmt.Errorf("invalid last commit ID: %w", err)
}
opts.LastCommitID = lastCommitID.String()
if commit.ID.String() != opts.LastCommitID {
@@ -163,6 +159,20 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
}
}
}
return t, nil
}
// ApplyDiffPatch applies a patch to the given repository
func ApplyDiffPatch(ctx reqctx.RequestContext, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo)
if err != nil {
return nil, err
}
t, err := gitPatchPrepare(ctx, repo, gitRepo, doer, opts)
if err != nil {
return nil, err
}
defer t.Close()
cmdApply := gitcmd.NewCommand("apply", "--index", "--recount", "--cached", "--ignore-whitespace", "--whitespace=fix", "--binary")
if git.DefaultFeatures().CheckVersionAtLeast("2.32") {
@@ -175,17 +185,19 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
return nil, fmt.Errorf("git apply error: %w", err)
}
// Now write the tree
return gitPatchCommitPush(ctx, t, repo, doer, opts)
}
func gitPatchCommitPush(ctx context.Context, t *TemporaryUploadRepository, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
treeHash, err := t.WriteTree(ctx)
if err != nil {
return nil, err
}
// Now commit the tree
commitOpts := &CommitTreeUserOptions{
ParentCommitID: "HEAD",
TreeHash: treeHash,
CommitMessage: message,
CommitMessage: strings.TrimSpace(opts.Message),
SignOff: opts.Signoff,
DoerUser: doer,
AuthorIdentity: opts.Author,
@@ -206,7 +218,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
return nil, err
}
commit, err = t.GetCommit(commitHash)
commit, err := t.GetCommit(commitHash)
if err != nil {
return nil, err
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package files
import (
"os"
"path/filepath"
"testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/gitrepo"
"github.com/stretchr/testify/require"
)
func TestGitPatchPrepare(t *testing.T) {
unittest.PrepareTestEnv(t)
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo1)
require.NoError(t, err)
defer gitRepo.Close()
opts := &ApplyDiffPatchOptions{}
tmpRepo, err := gitPatchPrepare(t.Context(), repo1, gitRepo, user2, opts)
require.NoError(t, err)
defer tmpRepo.Close()
// temporary repository for patch should not be a bare repo because "--index" argument is used,
// while it is questionable whether "--index" argument should be really used, need to figure out in the future.
_, err = os.Stat(filepath.Join(tmpRepo.basePath, ".git"))
require.NoError(t, err)
}
+1 -1
View File
@@ -175,7 +175,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
t, err := NewTemporaryUploadRepository(repo)
if err != nil {
log.Error("NewTemporaryUploadRepository failed: %v", err)
return nil, fmt.Errorf("NewTemporaryUploadRepository failed: %w", err)
}
defer t.Close()
hasOldBranch := true