From 486c8bc91b872a9627a3aa53464cc42152103f1a Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Wed, 6 May 2026 08:49:35 +0200 Subject: [PATCH 01/14] add new ideas --- TODO.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TODO.md b/TODO.md index e6e1bd2..1bd395b 100644 --- a/TODO.md +++ b/TODO.md @@ -3,6 +3,8 @@ - Add a UI for the Open Questions in the investigation phase. This means for every question we get a text field that we can use to provide feedback for this specific question. The general feedback field should be moved to the bottom of the page. When we now click on "provide feedback" the answers to the open questions and the general feedback should both be transmitted to the llm. - Can we show the upcoming state already? So when I for example click on "Approve" for an Investigation step the new "Implementation" step is already showing up, we switch to it and the body shows a "running" indicator. - Add support for multiple workflows. For example one workflow that is just there to refine tickets. One workflow to actually then work on them. +- Allow to define the model/provider per prompt. Some models may be better in analyzing, some in coding and some might be cheap but sufficient for tasks like writing commit messages. +- Add in "after-scripts" that receive the prompt output. That way we could do things like "generate commit message" as prompt and pipe that into a fixed "commit + push" script. # Fixes From e9076126fea07db18a8aaad9eb41bd3401b8492f Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Fri, 8 May 2026 13:34:37 +0200 Subject: [PATCH 02/14] base new worktrees on the repo default branch, not HEAD When no base branch is configured, WorktreeAdd previously fell back to HEAD (the currently checked-out branch). It now resolves origin/HEAD via DefaultBranch() so worktrees are always forked from the repo default branch. Co-Authored-By: Claude Sonnet 4.6 --- internal/gitutil/git.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/gitutil/git.go b/internal/gitutil/git.go index 79ef04c..cec39ab 100644 --- a/internal/gitutil/git.go +++ b/internal/gitutil/git.go @@ -95,7 +95,12 @@ func parseGitHubOwnerRepo(origin string) (string, error) { // WorktreeAdd creates a new git worktree at worktreePath on branch, forked from baseBranch. func WorktreeAdd(ctx context.Context, repoRoot, branch, worktreePath, baseBranch string) error { if baseBranch == "" { - baseBranch = "HEAD" + def, err := DefaultBranch(ctx, repoRoot) + if err != nil || strings.TrimSpace(def) == "" { + baseBranch = "main" + } else { + baseBranch = def + } } _, err := shell.Run(ctx, repoRoot, nil, "", "git", "worktree", "add", "-B", branch, worktreePath, baseBranch) if err != nil { From 284e2f53e3287a2227acb4e6ce786e3535f23a23 Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Fri, 8 May 2026 13:51:24 +0200 Subject: [PATCH 03/14] disable ticket buttons per-ticket, not globally When any job was active, all ticket action buttons were disabled via a global isRunning flag. Buttons are now disabled only for the ticket that is currently in running state, matching the existing behaviour of the rerun/cleanup/move buttons. Co-Authored-By: Claude Sonnet 4.6 --- TODO.md | 4 ++-- web/src/App.tsx | 1 - web/src/TicketDetailPanel.tsx | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 1bd395b..70c7861 100644 --- a/TODO.md +++ b/TODO.md @@ -6,5 +6,5 @@ - Allow to define the model/provider per prompt. Some models may be better in analyzing, some in coding and some might be cheap but sufficient for tasks like writing commit messages. - Add in "after-scripts" that receive the prompt output. That way we could do things like "generate commit message" as prompt and pipe that into a fixed "commit + push" script. -# Fixes - +# Bugs +- diff --git a/web/src/App.tsx b/web/src/App.tsx index 3594cf4..40c6304 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -522,7 +522,6 @@ export function App() { artifactLoading={artifactLoading} feedbackAction={feedbackAction} feedbackMessage={feedbackMessage} - isRunning={!!activeJobId} onSelectRun={setSelectedRunId} onFeedbackMessageChange={setFeedbackMessage} onSubmitFeedback={submitFeedback} diff --git a/web/src/TicketDetailPanel.tsx b/web/src/TicketDetailPanel.tsx index 1e38e0e..1c714c8 100644 --- a/web/src/TicketDetailPanel.tsx +++ b/web/src/TicketDetailPanel.tsx @@ -13,7 +13,6 @@ type TicketDetailPanelProps = { artifactLoading: boolean; feedbackAction?: ActionInfo; feedbackMessage: string; - isRunning: boolean; onSelectRun: (runId: string) => void; onFeedbackMessageChange: (value: string) => void; onSubmitFeedback: () => void; @@ -33,7 +32,6 @@ export function TicketDetailPanel({ artifactLoading, feedbackAction, feedbackMessage, - isRunning, onSelectRun, onFeedbackMessageChange, onSubmitFeedback, @@ -73,7 +71,7 @@ export function TicketDetailPanel({
{actionButtons.map((action) => ( - ))} From 31736eb2f40dc3f1c5094122dca37ad7d97ff158 Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Fri, 8 May 2026 13:55:18 +0200 Subject: [PATCH 04/14] add a bug to todo list --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 70c7861..466035c 100644 --- a/TODO.md +++ b/TODO.md @@ -7,4 +7,4 @@ - Add in "after-scripts" that receive the prompt output. That way we could do things like "generate commit message" as prompt and pipe that into a fixed "commit + push" script. # Bugs -- +- When I am adding a Ticket using the "Discover Ticket" functionality the Panel closes immediately (but after adding one it should go back to the overview). Also when I reopen it then the Ticket that I just added shows up in the list with an add button. Tickets that are already added to the tool should still show up but have no "add" button, but an "added" label. \ No newline at end of file From 8f2b1ec831e6027c695cab8202a4810a7df30e89 Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Fri, 8 May 2026 14:15:57 +0200 Subject: [PATCH 05/14] update todo.md --- TODO.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 466035c..50f7bb3 100644 --- a/TODO.md +++ b/TODO.md @@ -7,4 +7,5 @@ - Add in "after-scripts" that receive the prompt output. That way we could do things like "generate commit message" as prompt and pipe that into a fixed "commit + push" script. # Bugs -- When I am adding a Ticket using the "Discover Ticket" functionality the Panel closes immediately (but after adding one it should go back to the overview). Also when I reopen it then the Ticket that I just added shows up in the list with an add button. Tickets that are already added to the tool should still show up but have no "add" button, but an "added" label. \ No newline at end of file +- When I am adding a Ticket using the "Discover Ticket" functionality the Panel closes immediately (but after adding one it should go back to the overview). Also when I reopen it then the Ticket that I just added shows up in the list with an add button. Tickets that are already added to the tool should still show up but have no "add" button, but an "added" label. +- There are still directories created for each ticket directly in ".autopr" folder in the project. Those are empty but they are there. Do we need those? If not we should never create them. If we need them temporarily we should remove them after we are done. \ No newline at end of file From eaa08fa91f729c6919c1cf5956f049a997aad065 Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Fri, 8 May 2026 14:24:59 +0200 Subject: [PATCH 06/14] use per-ticket busy flag for button disabling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used selectedSummary.status === "running", but the server never broadcasts a ticket_updated event with status "running" during execution — it only broadcasts after the job completes (when the status is "waiting", "done", etc.). So that condition was never true. The busy field is updated immediately via SSE job events when a job is queued or running for a specific ticket, making it the correct per-ticket signal. Buttons are now disabled only while the selected ticket itself has an active job, leaving all other tickets' buttons fully interactive. Co-Authored-By: Claude Sonnet 4.6 --- web/src/TicketDetailPanel.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/TicketDetailPanel.tsx b/web/src/TicketDetailPanel.tsx index 1c714c8..ccf2576 100644 --- a/web/src/TicketDetailPanel.tsx +++ b/web/src/TicketDetailPanel.tsx @@ -71,7 +71,7 @@ export function TicketDetailPanel({
{actionButtons.map((action) => ( - ))} @@ -89,9 +89,9 @@ export function TicketDetailPanel({ workflowStates={details?.workflow_states ?? []} currentStateName={details?.state.current_state} rerunLabel={selectedSummary.status === "failed" ? "Retry" : "Rerun"} - rerunDisabled={selectedSummary.status === "running"} - cleanupDisabled={selectedSummary.status === "running"} - moveDisabled={selectedSummary.status === "running"} + rerunDisabled={selectedSummary.busy} + cleanupDisabled={selectedSummary.busy} + moveDisabled={selectedSummary.busy} />
From a7c65d0be80815a494ecbb4e9ec7b5ba5d1ecff7 Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Fri, 8 May 2026 18:12:27 +0200 Subject: [PATCH 07/14] update todo --- TODO.md | 1 + 1 file changed, 1 insertion(+) diff --git a/TODO.md b/TODO.md index 50f7bb3..0dec801 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,7 @@ - Add support for multiple workflows. For example one workflow that is just there to refine tickets. One workflow to actually then work on them. - Allow to define the model/provider per prompt. Some models may be better in analyzing, some in coding and some might be cheap but sufficient for tasks like writing commit messages. - Add in "after-scripts" that receive the prompt output. That way we could do things like "generate commit message" as prompt and pipe that into a fixed "commit + push" script. +- After the Implementation Step is done we should provide a link to the PR. # Bugs - When I am adding a Ticket using the "Discover Ticket" functionality the Panel closes immediately (but after adding one it should go back to the overview). Also when I reopen it then the Ticket that I just added shows up in the list with an add button. Tickets that are already added to the tool should still show up but have no "add" button, but an "added" label. From bb6d904971a963e61df68de27758dd3337c92153 Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Fri, 8 May 2026 18:12:47 +0200 Subject: [PATCH 08/14] fix per-ticket button disabling and stale job state on restart Track which ticket triggered an action (activeJobTicketKey) so the detail panel's isRunning flag is scoped to that specific ticket only. Also reset queued/running jobs to failed on server startup to prevent stale job state from permanently disabling buttons after a restart. Co-Authored-By: Claude Sonnet 4.6 --- internal/serverstate/store.go | 20 ++++++++++++++++++++ web/src/App.tsx | 19 +++++++++++++------ web/src/TicketDetailPanel.tsx | 10 ++++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/internal/serverstate/store.go b/internal/serverstate/store.go index ace842c..f2030ac 100644 --- a/internal/serverstate/store.go +++ b/internal/serverstate/store.go @@ -86,10 +86,30 @@ func NewStore(path string) (*Store, error) { if err != nil { return nil, err } + store.resetStaleJobs() return store, nil } +// resetStaleJobs marks any queued or running jobs as failed, since they could not have +// completed if the server was not running. +func (s *Store) resetStaleJobs() { + now := time.Now().UTC() + changed := false + for id, job := range s.data.Jobs { + if job.Status == "queued" || job.Status == "running" { + job.Status = "failed" + job.Error = "interrupted: server restarted" + job.FinishedAt = &now + s.data.Jobs[id] = job + changed = true + } + } + if changed { + _ = s.saveLocked() + } +} + // UpsertRepo inserts or updates the record for repoPath and returns it. func (s *Store) UpsertRepo(repoPath string) (RepoRecord, error) { s.mu.Lock() diff --git a/web/src/App.tsx b/web/src/App.tsx index 40c6304..f1751da 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -40,6 +40,7 @@ export function App() { const [selectedArtifactContent, setSelectedArtifactContent] = useState(""); const [feedbackMessage, setFeedbackMessage] = useState(""); const [activeJobId, setActiveJobId] = useState(""); + const [activeJobTicketKey, setActiveJobTicketKey] = useState(""); const [activeJob, setActiveJob] = useState(null); const [loading, setLoading] = useState(false); const [artifactLoading, setArtifactLoading] = useState(false); @@ -276,10 +277,12 @@ export function App() { setError(err instanceof Error ? err.message : "failed to refresh job"); } finally { setActiveJobId(""); + setActiveJobTicketKey(""); } } else if (status === "done") { setActiveJob(null); setActiveJobId(""); + setActiveJobTicketKey(""); } else if (status === "queued" || status === "running") { setActiveJob((current) => { if (!current) { @@ -326,11 +329,12 @@ export function App() { }, 250); } - async function queueAction(fn: () => Promise<{ job_id: string }>): Promise { + async function queueAction(fn: () => Promise<{ job_id: string }>, forTicketKey = ""): Promise { setError(""); try { const accepted = await fn(); setActiveJobId(accepted.job_id); + setActiveJobTicketKey(forTicketKey); setActiveJob(null); return true; } catch (err) { @@ -405,13 +409,15 @@ export function App() { if (!selectedSummary || !feedbackAction || !feedbackMessage.trim()) { return; } + const key = ticketKey(selectedSummary); void queueAction(() => applyAction( selectedSummary.repo_path, selectedSummary.ticket_number, feedbackAction.label, feedbackMessage - ) + ), + key ).then((ok) => { if (ok) { setFeedbackMessage(""); @@ -423,28 +429,28 @@ export function App() { if (!selectedSummary) { return; } - void queueAction(() => applyAction(selectedSummary.repo_path, selectedSummary.ticket_number, label)); + void queueAction(() => applyAction(selectedSummary.repo_path, selectedSummary.ticket_number, label), ticketKey(selectedSummary)); } function rerunSelectedTicket() { if (!selectedSummary) { return; } - void queueAction(() => runTicket(selectedSummary.repo_path, selectedSummary.ticket_number)); + void queueAction(() => runTicket(selectedSummary.repo_path, selectedSummary.ticket_number), ticketKey(selectedSummary)); } function cleanupSelectedTicket() { if (!selectedSummary) { return; } - void queueAction(() => cleanupTicket(selectedSummary.repo_path, selectedSummary.ticket_number)); + void queueAction(() => cleanupTicket(selectedSummary.repo_path, selectedSummary.ticket_number), ticketKey(selectedSummary)); } function moveSelectedTicket(target: string) { if (!selectedSummary) { return; } - void queueAction(() => moveToState(selectedSummary.repo_path, selectedSummary.ticket_number, target)); + void queueAction(() => moveToState(selectedSummary.repo_path, selectedSummary.ticket_number, target), ticketKey(selectedSummary)); } function openDiscoverModal() { @@ -522,6 +528,7 @@ export function App() { artifactLoading={artifactLoading} feedbackAction={feedbackAction} feedbackMessage={feedbackMessage} + isRunning={!!activeJobId && activeJobTicketKey === (selectedSummary ? ticketKey(selectedSummary) : "")} onSelectRun={setSelectedRunId} onFeedbackMessageChange={setFeedbackMessage} onSubmitFeedback={submitFeedback} diff --git a/web/src/TicketDetailPanel.tsx b/web/src/TicketDetailPanel.tsx index ccf2576..3f88d29 100644 --- a/web/src/TicketDetailPanel.tsx +++ b/web/src/TicketDetailPanel.tsx @@ -13,6 +13,7 @@ type TicketDetailPanelProps = { artifactLoading: boolean; feedbackAction?: ActionInfo; feedbackMessage: string; + isRunning: boolean; onSelectRun: (runId: string) => void; onFeedbackMessageChange: (value: string) => void; onSubmitFeedback: () => void; @@ -32,6 +33,7 @@ export function TicketDetailPanel({ artifactLoading, feedbackAction, feedbackMessage, + isRunning, onSelectRun, onFeedbackMessageChange, onSubmitFeedback, @@ -71,7 +73,7 @@ export function TicketDetailPanel({
{actionButtons.map((action) => ( - ))} @@ -89,9 +91,9 @@ export function TicketDetailPanel({ workflowStates={details?.workflow_states ?? []} currentStateName={details?.state.current_state} rerunLabel={selectedSummary.status === "failed" ? "Retry" : "Rerun"} - rerunDisabled={selectedSummary.busy} - cleanupDisabled={selectedSummary.busy} - moveDisabled={selectedSummary.busy} + rerunDisabled={isRunning} + cleanupDisabled={isRunning} + moveDisabled={isRunning} />
From aa18c8cdc87f51231dbfeba283e582127374bd11 Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Mon, 11 May 2026 11:51:46 +0200 Subject: [PATCH 09/14] define job status constants and use them consistently Co-Authored-By: Claude Sonnet 4.6 --- internal/server/jobs.go | 6 ++-- internal/server/strict_api.go | 7 +++-- internal/serverstate/store.go | 54 ++++++++++++++++++++--------------- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/internal/server/jobs.go b/internal/server/jobs.go index b34d1cd..33eaeff 100644 --- a/internal/server/jobs.go +++ b/internal/server/jobs.go @@ -12,14 +12,14 @@ import ( func (s *server) workerLoop() { for job := range s.jobs { - s.setJobStatus(job.record, "running", "") + s.setJobStatus(job.record, serverstate.JobStatusRunning, "") err := s.executeJob(job) if err != nil { - s.setJobStatus(job.record, "failed", err.Error()) + s.setJobStatus(job.record, serverstate.JobStatusFailed, err.Error()) continue } - s.setJobStatus(job.record, "done", "") + s.setJobStatus(job.record, serverstate.JobStatusDone, "") if job.record.Action == jobCleanup && strings.TrimSpace(job.record.TicketNumber) != "" { _ = s.meta.DeleteJobs(job.record.RepoID, job.record.TicketNumber) } diff --git a/internal/server/strict_api.go b/internal/server/strict_api.go index c86dc9e..801ab78 100644 --- a/internal/server/strict_api.go +++ b/internal/server/strict_api.go @@ -15,6 +15,7 @@ import ( "github.com/Neokil/AutoPR/internal/api" workflowstate "github.com/Neokil/AutoPR/internal/domain/workflowstate" "github.com/Neokil/AutoPR/internal/gitutil" + "github.com/Neokil/AutoPR/internal/serverstate" "github.com/Neokil/AutoPR/internal/state" "github.com/Neokil/AutoPR/internal/workflow" ) @@ -410,7 +411,7 @@ func (s *server) enqueueJob(action, repoID, repoPath, ticket string, opts enqueu JobId: stringPtr(job.ID), Action: stringPtr(action), Scope: stringPtr(opts.scope), - Status: stringPtr("queued"), + Status: stringPtr(serverstate.JobStatusQueued), }) select { case s.jobs <- queued: @@ -423,7 +424,7 @@ func (s *server) enqueueJob(action, repoID, repoPath, ticket string, opts enqueu TicketNumber: stringPtr(ticket), }, http.StatusAccepted, nil default: - _ = s.meta.UpdateJobStatus(job.ID, "failed", "job queue is full") + _ = s.meta.UpdateJobStatus(job.ID, serverstate.JobStatusFailed, "job queue is full") s.broadcast(api.ServerEvent{ Type: eventTypeJob, RepoId: stringPtr(repoID), @@ -432,7 +433,7 @@ func (s *server) enqueueJob(action, repoID, repoPath, ticket string, opts enqueu JobId: stringPtr(job.ID), Action: stringPtr(action), Scope: stringPtr(opts.scope), - Status: stringPtr("failed"), + Status: stringPtr(serverstate.JobStatusFailed), Error: stringPtr("job queue is full"), }) diff --git a/internal/serverstate/store.go b/internal/serverstate/store.go index f2030ac..120a0cb 100644 --- a/internal/serverstate/store.go +++ b/internal/serverstate/store.go @@ -43,6 +43,14 @@ type Data struct { Jobs map[string]JobRecord `json:"jobs"` } +// Job status values used across the server and store. +const ( + JobStatusQueued = "queued" + JobStatusRunning = "running" + JobStatusDone = "done" + JobStatusFailed = "failed" +) + // JobRecord represents a single background job and its lifecycle timestamps. type JobRecord struct { ID string `json:"id"` @@ -91,25 +99,6 @@ func NewStore(path string) (*Store, error) { return store, nil } -// resetStaleJobs marks any queued or running jobs as failed, since they could not have -// completed if the server was not running. -func (s *Store) resetStaleJobs() { - now := time.Now().UTC() - changed := false - for id, job := range s.data.Jobs { - if job.Status == "queued" || job.Status == "running" { - job.Status = "failed" - job.Error = "interrupted: server restarted" - job.FinishedAt = &now - s.data.Jobs[id] = job - changed = true - } - } - if changed { - _ = s.saveLocked() - } -} - // UpsertRepo inserts or updates the record for repoPath and returns it. func (s *Store) UpsertRepo(repoPath string) (RepoRecord, error) { s.mu.Lock() @@ -227,7 +216,7 @@ func (s *Store) ListTickets(repoID string) []TicketRecord { rec.Jobs = append([]JobRecord(nil), jobsByTicket[ticketKey(rec.RepoID, rec.TicketNumber)]...) rec.Busy = false for _, job := range rec.Jobs { - if job.Status == "queued" || job.Status == "running" { + if job.Status == JobStatusQueued || job.Status == JobStatusRunning { rec.Busy = true break @@ -266,7 +255,7 @@ func (s *Store) NewJob(action, repoID, repoPath, ticketNumber, scope string) (Jo RepoPath: repoPath, TicketNumber: ticketNumber, Scope: scope, - Status: "queued", + Status: JobStatusQueued, CreatedAt: now, } s.data.Jobs[id] = rec @@ -288,9 +277,9 @@ func (s *Store) UpdateJobStatus(id, status, errMsg string) error { } now := time.Now().UTC() switch status { - case "running": + case JobStatusRunning: rec.StartedAt = &now - case "done", "failed": + case JobStatusDone, JobStatusFailed: rec.FinishedAt = &now } rec.Status = status @@ -328,6 +317,25 @@ func (s *Store) ListRepos() []RepoRecord { return out } +// resetStaleJobs marks any queued or running jobs as failed, since they could not have +// completed if the server was not running. +func (s *Store) resetStaleJobs() { + now := time.Now().UTC() + changed := false + for id, job := range s.data.Jobs { + if job.Status == JobStatusQueued || job.Status == JobStatusRunning { + job.Status = JobStatusFailed + job.Error = "interrupted: server restarted" + job.FinishedAt = &now + s.data.Jobs[id] = job + changed = true + } + } + if changed { + _ = s.saveLocked() + } +} + func (s *Store) load() error { err := os.MkdirAll(filepath.Dir(s.path), 0o755) if err != nil { From 0eacdd4252d50aa405583bd6a64b2d400ce6abff Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Mon, 11 May 2026 16:09:22 +0200 Subject: [PATCH 10/14] fix e2e test: use ticket.busy to disable buttons instead of activeJobId ref The Cleanup button was permanently disabled after clicking Accept because of a race condition: the server processes move_to_state actions nearly instantly, so the job-done SSE event arrived before React's useEffect had updated activeJobIdRef.current with the new job ID. The handler check evt.job_id === trackedJobID failed silently, leaving activeJobId set forever. Fix by replacing isRunning (derived from activeJobId + ref) with selectedSummary.busy for all button disabled states in TicketDetailPanel. The busy flag is updated directly in applyTicketEvent on every job event, with no ref indirection and no race condition. Also updates the mock provider comment to reflect its actual purpose (simulating realistic latency) now that the race condition is fixed. Co-Authored-By: Claude Sonnet 4.6 --- e2e/global-setup.ts | 2 +- web/src/App.tsx | 1 - web/src/TicketDetailPanel.tsx | 10 ++++------ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 5f3021b..ba3b7a1 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -93,7 +93,7 @@ function writeMockProvider(mockProviderPath: string): void { mockProviderPath, `#!/bin/sh # Discard stdin (the prompt content) and return a canned response. -# Sleep briefly so the job doesn't complete before the frontend registers activeJobId. +# Sleep 100ms to simulate realistic AI provider response latency. cat > /dev/null sleep 0.1 echo "Mock provider: analysis complete." diff --git a/web/src/App.tsx b/web/src/App.tsx index f1751da..5170482 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -528,7 +528,6 @@ export function App() { artifactLoading={artifactLoading} feedbackAction={feedbackAction} feedbackMessage={feedbackMessage} - isRunning={!!activeJobId && activeJobTicketKey === (selectedSummary ? ticketKey(selectedSummary) : "")} onSelectRun={setSelectedRunId} onFeedbackMessageChange={setFeedbackMessage} onSubmitFeedback={submitFeedback} diff --git a/web/src/TicketDetailPanel.tsx b/web/src/TicketDetailPanel.tsx index 3f88d29..ccf2576 100644 --- a/web/src/TicketDetailPanel.tsx +++ b/web/src/TicketDetailPanel.tsx @@ -13,7 +13,6 @@ type TicketDetailPanelProps = { artifactLoading: boolean; feedbackAction?: ActionInfo; feedbackMessage: string; - isRunning: boolean; onSelectRun: (runId: string) => void; onFeedbackMessageChange: (value: string) => void; onSubmitFeedback: () => void; @@ -33,7 +32,6 @@ export function TicketDetailPanel({ artifactLoading, feedbackAction, feedbackMessage, - isRunning, onSelectRun, onFeedbackMessageChange, onSubmitFeedback, @@ -73,7 +71,7 @@ export function TicketDetailPanel({
{actionButtons.map((action) => ( - ))} @@ -91,9 +89,9 @@ export function TicketDetailPanel({ workflowStates={details?.workflow_states ?? []} currentStateName={details?.state.current_state} rerunLabel={selectedSummary.status === "failed" ? "Retry" : "Rerun"} - rerunDisabled={isRunning} - cleanupDisabled={isRunning} - moveDisabled={isRunning} + rerunDisabled={selectedSummary.busy} + cleanupDisabled={selectedSummary.busy} + moveDisabled={selectedSummary.busy} />
From ac59b17b3090e5ff56d2a15d589debb8ca5ff162 Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Tue, 12 May 2026 10:00:16 +0200 Subject: [PATCH 11/14] =?UTF-8?q?remove=20TODO.md=20=E2=80=94=20items=20mi?= =?UTF-8?q?grated=20to=20GitHub=20issues=20#4=E2=80=93#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- TODO.md | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 0dec801..0000000 --- a/TODO.md +++ /dev/null @@ -1,12 +0,0 @@ -# Features -- Remove Shortcut references from UI. AutoPR should be tool agnoistic and only the prompts should contain the tool references. -- Add a UI for the Open Questions in the investigation phase. This means for every question we get a text field that we can use to provide feedback for this specific question. The general feedback field should be moved to the bottom of the page. When we now click on "provide feedback" the answers to the open questions and the general feedback should both be transmitted to the llm. -- Can we show the upcoming state already? So when I for example click on "Approve" for an Investigation step the new "Implementation" step is already showing up, we switch to it and the body shows a "running" indicator. -- Add support for multiple workflows. For example one workflow that is just there to refine tickets. One workflow to actually then work on them. -- Allow to define the model/provider per prompt. Some models may be better in analyzing, some in coding and some might be cheap but sufficient for tasks like writing commit messages. -- Add in "after-scripts" that receive the prompt output. That way we could do things like "generate commit message" as prompt and pipe that into a fixed "commit + push" script. -- After the Implementation Step is done we should provide a link to the PR. - -# Bugs -- When I am adding a Ticket using the "Discover Ticket" functionality the Panel closes immediately (but after adding one it should go back to the overview). Also when I reopen it then the Ticket that I just added shows up in the list with an add button. Tickets that are already added to the tool should still show up but have no "add" button, but an "added" label. -- There are still directories created for each ticket directly in ".autopr" folder in the project. Those are empty but they are there. Do we need those? If not we should never create them. If we need them temporarily we should remove them after we are done. \ No newline at end of file From 335b93f72c388387e22c066fbea097439ff2e630 Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Fri, 15 May 2026 12:36:18 +0200 Subject: [PATCH 12/14] remove unused activeJobTicketKey state and forTicketKey parameter Co-Authored-By: Claude Sonnet 4.6 --- web/src/App.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 5170482..40c6304 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -40,7 +40,6 @@ export function App() { const [selectedArtifactContent, setSelectedArtifactContent] = useState(""); const [feedbackMessage, setFeedbackMessage] = useState(""); const [activeJobId, setActiveJobId] = useState(""); - const [activeJobTicketKey, setActiveJobTicketKey] = useState(""); const [activeJob, setActiveJob] = useState(null); const [loading, setLoading] = useState(false); const [artifactLoading, setArtifactLoading] = useState(false); @@ -277,12 +276,10 @@ export function App() { setError(err instanceof Error ? err.message : "failed to refresh job"); } finally { setActiveJobId(""); - setActiveJobTicketKey(""); } } else if (status === "done") { setActiveJob(null); setActiveJobId(""); - setActiveJobTicketKey(""); } else if (status === "queued" || status === "running") { setActiveJob((current) => { if (!current) { @@ -329,12 +326,11 @@ export function App() { }, 250); } - async function queueAction(fn: () => Promise<{ job_id: string }>, forTicketKey = ""): Promise { + async function queueAction(fn: () => Promise<{ job_id: string }>): Promise { setError(""); try { const accepted = await fn(); setActiveJobId(accepted.job_id); - setActiveJobTicketKey(forTicketKey); setActiveJob(null); return true; } catch (err) { @@ -409,15 +405,13 @@ export function App() { if (!selectedSummary || !feedbackAction || !feedbackMessage.trim()) { return; } - const key = ticketKey(selectedSummary); void queueAction(() => applyAction( selectedSummary.repo_path, selectedSummary.ticket_number, feedbackAction.label, feedbackMessage - ), - key + ) ).then((ok) => { if (ok) { setFeedbackMessage(""); @@ -429,28 +423,28 @@ export function App() { if (!selectedSummary) { return; } - void queueAction(() => applyAction(selectedSummary.repo_path, selectedSummary.ticket_number, label), ticketKey(selectedSummary)); + void queueAction(() => applyAction(selectedSummary.repo_path, selectedSummary.ticket_number, label)); } function rerunSelectedTicket() { if (!selectedSummary) { return; } - void queueAction(() => runTicket(selectedSummary.repo_path, selectedSummary.ticket_number), ticketKey(selectedSummary)); + void queueAction(() => runTicket(selectedSummary.repo_path, selectedSummary.ticket_number)); } function cleanupSelectedTicket() { if (!selectedSummary) { return; } - void queueAction(() => cleanupTicket(selectedSummary.repo_path, selectedSummary.ticket_number), ticketKey(selectedSummary)); + void queueAction(() => cleanupTicket(selectedSummary.repo_path, selectedSummary.ticket_number)); } function moveSelectedTicket(target: string) { if (!selectedSummary) { return; } - void queueAction(() => moveToState(selectedSummary.repo_path, selectedSummary.ticket_number, target), ticketKey(selectedSummary)); + void queueAction(() => moveToState(selectedSummary.repo_path, selectedSummary.ticket_number, target)); } function openDiscoverModal() { From a3d13620d545c1d5402e415816e25e4ad753aa9d Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Fri, 15 May 2026 13:12:24 +0200 Subject: [PATCH 13/14] add GitHub Issues to default discover_tickets_command The default discovery script now fetches open GitHub Issues (via `gh issue list`) alongside Shortcut stories. A GITHUB_REPOS array at the top of the script makes it easy to adjust which repos are queried. Both sources are optional: Shortcut is skipped when SHORTCUT_API_TOKEN is unset; GitHub is skipped when gh is not installed or not authenticated. The discover command runner is switched from /bin/sh to /bin/bash so the array syntax works. The fetch-ticket prompt is updated to branch on the ticket-number prefix: GH-N tickets use `gh issue view` and a gh-- branch name; SC-N tickets continue to use `short story` and the existing sc-<id>-<title> format. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- config.example.yaml | 48 +++++++++++++++++--- internal/application/tickets/orchestrator.go | 2 +- internal/config/config.go | 48 +++++++++++++++++--- internal/workflow/prompts/fetch-ticket.md | 20 +++++--- 4 files changed, 98 insertions(+), 20 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 9f8b8b6..2e6a0ff 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -13,12 +13,48 @@ check_commands: [] format_commands: [] lint_commands: [] discover_tickets_command: | - curl -fsSL \ - -H "Content-Type: application/json" \ - -H "Shortcut-Token: ${SHORTCUT_API_TOKEN:?SHORTCUT_API_TOKEN is required}" \ - -d '{"label_name":"auto-pr","workflow_state_types":["backlog","unstarted"]}' \ - https://api.app.shortcut.com/api/v3/stories/search \ - | python3 -c 'import json, sys; stories = json.load(sys.stdin); print(json.dumps([{"ticket_number": "SC-{}".format(story["id"]), "title": story["name"]} for story in stories]))' + # GitHub repositories to include in ticket discovery (owner/repo format). + # Add or remove entries to match the repos you want to track. + GITHUB_REPOS=( + "Neokil/AutoPR" + ) + + _dir=$(mktemp -d) + trap 'rm -rf "$_dir"' EXIT + + # Shortcut: fetch stories labeled "auto-pr" in backlog/unstarted state. + if [ -n "${SHORTCUT_API_TOKEN:-}" ]; then + curl -fsSL \ + -H "Content-Type: application/json" \ + -H "Shortcut-Token: ${SHORTCUT_API_TOKEN}" \ + -d '{"label_name":"auto-pr","workflow_state_types":["backlog","unstarted"]}' \ + https://api.app.shortcut.com/api/v3/stories/search 2>/dev/null \ + | python3 -c 'import json,sys; s=json.load(sys.stdin); print(json.dumps([{"ticket_number":"SC-{}".format(x["id"]),"title":x["name"]} for x in s]))' \ + > "$_dir/shortcut.json" 2>/dev/null || true + fi + + # GitHub: fetch open issues for each configured repo. + # Silently skipped if gh is not installed or not authenticated. + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + _i=0 + for _repo in "${GITHUB_REPOS[@]}"; do + gh issue list --repo "$_repo" --state open --json number,title 2>/dev/null \ + | python3 -c 'import json,sys; issues=json.load(sys.stdin); print(json.dumps([{"ticket_number":"GH-{}".format(x["number"]),"title":x["title"]} for x in issues]))' \ + > "$_dir/github_$_i.json" 2>/dev/null || true + _i=$((_i+1)) + done + fi + + python3 -c ' + import json, os, sys + result = [] + for f in sorted(os.listdir(sys.argv[1])): + try: + result.extend(json.load(open(os.path.join(sys.argv[1], f)))) + except Exception: + pass + print(json.dumps(result)) + ' "$_dir" providers: gemini: diff --git a/internal/application/tickets/orchestrator.go b/internal/application/tickets/orchestrator.go index 0e5df2f..00c3ef7 100644 --- a/internal/application/tickets/orchestrator.go +++ b/internal/application/tickets/orchestrator.go @@ -300,7 +300,7 @@ func (o *Orchestrator) DiscoverTickets(ctx context.Context) ([]DiscoveredTicket, "AUTOPR_REPO_ROOT": o.RepoRoot, "AUTOPR_DISCOVER_LOG_DIR": runDir, "AUTOPR_DISCOVER_REPO_PATH": o.RepoRoot, - }, "", "/bin/sh", "-c", command) + }, "", "/bin/bash", "-c", command) _ = os.WriteFile(filepath.Join(runDir, "command-output.json"), []byte(result.Stdout), 0o644) _ = os.WriteFile(filepath.Join(runDir, "command-stderr.log"), []byte(result.Stderr), 0o644) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index a3d52f8..b95fde7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,12 +89,48 @@ func Default() Config { CreatePR: true, MaxFixAttempts: 1, CheckCommands: []string{}, - DiscoverTicketsCommand: `curl -fsSL \ - -H "Content-Type: application/json" \ - -H "Shortcut-Token: ${SHORTCUT_API_TOKEN:?SHORTCUT_API_TOKEN is required}" \ - -d '{"label_name":"auto-pr","workflow_state_types":["backlog","unstarted"]}' \ - https://api.app.shortcut.com/api/v3/stories/search \ -| python3 -c 'import json, sys; stories = json.load(sys.stdin); print(json.dumps([{"ticket_number": "SC-{}".format(story["id"]), "title": story["name"]} for story in stories]))'`, + DiscoverTicketsCommand: `# GitHub repositories to include in ticket discovery (owner/repo format). +# Add or remove entries to match the repos you want to track. +GITHUB_REPOS=( + "Neokil/AutoPR" +) + +_dir=$(mktemp -d) +trap 'rm -rf "$_dir"' EXIT + +# Shortcut: fetch stories labeled "auto-pr" in backlog/unstarted state. +if [ -n "${SHORTCUT_API_TOKEN:-}" ]; then + curl -fsSL \ + -H "Content-Type: application/json" \ + -H "Shortcut-Token: ${SHORTCUT_API_TOKEN}" \ + -d '{"label_name":"auto-pr","workflow_state_types":["backlog","unstarted"]}' \ + https://api.app.shortcut.com/api/v3/stories/search 2>/dev/null \ + | python3 -c 'import json,sys; s=json.load(sys.stdin); print(json.dumps([{"ticket_number":"SC-{}".format(x["id"]),"title":x["name"]} for x in s]))' \ + > "$_dir/shortcut.json" 2>/dev/null || true +fi + +# GitHub: fetch open issues for each configured repo. +# Silently skipped if gh is not installed or not authenticated. +if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + _i=0 + for _repo in "${GITHUB_REPOS[@]}"; do + gh issue list --repo "$_repo" --state open --json number,title 2>/dev/null \ + | python3 -c 'import json,sys; issues=json.load(sys.stdin); print(json.dumps([{"ticket_number":"GH-{}".format(x["number"]),"title":x["title"]} for x in issues]))' \ + > "$_dir/github_$_i.json" 2>/dev/null || true + _i=$((_i+1)) + done +fi + +python3 -c ' +import json, os, sys +result = [] +for f in sorted(os.listdir(sys.argv[1])): + try: + result.extend(json.load(open(os.path.join(sys.argv[1], f)))) + except Exception: + pass +print(json.dumps(result)) +' "$_dir"`, Providers: map[string]ProviderCommand{ "gemini": {Command: "gemini", Args: []string{}}, "codex": { diff --git a/internal/workflow/prompts/fetch-ticket.md b/internal/workflow/prompts/fetch-ticket.md index 243aa3f..464aacd 100644 --- a/internal/workflow/prompts/fetch-ticket.md +++ b/internal/workflow/prompts/fetch-ticket.md @@ -2,25 +2,31 @@ Read `.auto-pr/run-context.md`. Read the ticket number from the `Ticket Number` field in `.auto-pr/run-context.md`. -Fetch the Shortcut ticket details for that ticket number using the `short` CLI: +Determine the ticket source from the prefix of the ticket number and fetch the details accordingly: + +**Shortcut ticket (`SC-` prefix):** - Run `short story <ticket-number> --quiet` to get the full story details. +- Derive the branch name in this format: `sc-<id>-<slugified-title>`, where the slugified title is lowercase, with non-alphanumeric characters replaced by hyphens, consecutive hyphens collapsed, and leading/trailing hyphens removed. Example: `SC-67523` titled "Extend internal payload of loyalty" → `sc-67523-extend-internal-payload-of-loyalty`. -Derive the branch name from the ticket number and title using this format: `sc-<id>-<slugified-title>`, where the slugified title is lowercase, with non-alphanumeric characters replaced by hyphens, consecutive hyphens collapsed, and leading/trailing hyphens removed. Example: ticket `67523` titled "Extend internal payload of loyalty" → `sc-67523-extend-internal-payload-of-loyalty`. +**GitHub Issue (`GH-` prefix):** +- Extract the numeric issue number (the part after `GH-`). +- Run `gh issue view <number> --json number,title,body,labels,url` to get the issue details. +- Derive the branch name in this format: `gh-<number>-<slugified-title>`. Example: `GH-14` titled "Add GitHub Issues support" → `gh-14-add-github-issues-support`. Write the full ticket details as a markdown document to the `Current Primary Artifact` path listed in `.auto-pr/run-context.md`. Use the following structure exactly: 1. A top-level heading (`#`) containing the ticket title. 2. Single-line metadata entries (no sub-headings) immediately after the title, each separated by a blank line: - - `ID: <id>` - - `Priority: <priority>` + - `ID: <ticket-number>` - `URL: <url>` - `Labels: <labels>` - `Branch: <branch-name>` + - For Shortcut tickets only: `Priority: <priority>` 3. Sections (`##`) for the richer content: - Description - - Acceptance criteria - - Parent ticket context (if any) - - Epic context (if any) + - Acceptance criteria (if present) + - Parent ticket context (if any, Shortcut only) + - Epic context (if any, Shortcut only) If the content of any section contains markdown headings, demote them so they nest correctly under the section. A `#` heading inside a `##` section becomes `###`, a `##` becomes `####`, and so on. From 822d51e3c6decbdef99ae2a74da2a6b27c839d37 Mon Sep 17 00:00:00 2001 From: Simon Schulte <github@neokil.de> Date: Fri, 15 May 2026 13:40:05 +0200 Subject: [PATCH 14/14] Remove empty legacy ticket directories --- internal/state/store.go | 25 ++++++- internal/state/store_test.go | 123 +++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 internal/state/store_test.go diff --git a/internal/state/store.go b/internal/state/store.go index d2a3c54..f9912a3 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -3,9 +3,11 @@ package state import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" + "syscall" "github.com/Neokil/AutoPR/internal/domain/workflowstate" "github.com/Neokil/AutoPR/internal/gitutil" @@ -72,7 +74,10 @@ func (s *Store) SaveState(ticketNumber string, state workflowstate.State) error return fmt.Errorf("write worktree state: %w", err) } // Remove the pre-worktree copy so there is only one source of truth. - _ = os.Remove(filepath.Join(s.TicketDir(ticketNumber), StateFileName)) + err = s.cleanupLegacyTicketDir(ticketNumber) + if err != nil { + return err + } return nil } @@ -160,6 +165,24 @@ func (s *Store) ensureTicketDir(ticketNumber string) (string, error) { return dir, nil } +func (s *Store) cleanupLegacyTicketDir(ticketNumber string) error { + statePath := filepath.Join(s.TicketDir(ticketNumber), StateFileName) + err := os.Remove(statePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove legacy state file: %w", err) + } + + err = os.Remove(s.TicketDir(ticketNumber)) + if err == nil || os.IsNotExist(err) { + return nil + } + if errors.Is(err, syscall.ENOTEMPTY) { + return nil + } + + return fmt.Errorf("remove legacy ticket dir: %w", err) +} + func parseStateJSON(_ string, data []byte) (workflowstate.State, error) { var state workflowstate.State err := json.Unmarshal(data, &state) diff --git a/internal/state/store_test.go b/internal/state/store_test.go new file mode 100644 index 0000000..522da8d --- /dev/null +++ b/internal/state/store_test.go @@ -0,0 +1,123 @@ +package state_test + +import ( + "os" + "path/filepath" + "slices" + "testing" + + workflowstate "github.com/Neokil/AutoPR/internal/domain/workflowstate" + "github.com/Neokil/AutoPR/internal/state" +) + +func TestSaveStateCreatesPreWorktreeStateFile(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + store := state.NewStore(repoRoot, ".auto-pr-state") + ticketState := workflowstate.New("GH-12") + + err := store.SaveState(ticketState.TicketNumber, ticketState) + if err != nil { + t.Fatalf("SaveState() error = %v", err) + } + + statePath := filepath.Join(store.TicketDir(ticketState.TicketNumber), state.StateFileName) + _, statErr := os.Stat(statePath) + if statErr != nil { + t.Fatalf("expected pre-worktree state file at %s: %v", statePath, statErr) + } +} + +func TestSaveStateMigratesStateAndRemovesEmptyLegacyDir(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + store := state.NewStore(repoRoot, ".auto-pr-state") + ticketState := workflowstate.New("GH-12") + + err := store.SaveState(ticketState.TicketNumber, ticketState) + if err != nil { + t.Fatalf("initial SaveState() error = %v", err) + } + + ticketState.WorktreePath = filepath.Join(repoRoot, ".auto-pr-state", "worktrees", ticketState.TicketNumber) + err = store.SaveState(ticketState.TicketNumber, ticketState) + if err != nil { + t.Fatalf("migrating SaveState() error = %v", err) + } + + worktreeStatePath := filepath.Join(ticketState.WorktreePath, ".auto-pr", state.StateFileName) + _, worktreeStateErr := os.Stat(worktreeStatePath) + if worktreeStateErr != nil { + t.Fatalf("expected worktree state file at %s: %v", worktreeStatePath, worktreeStateErr) + } + + legacyStatePath := filepath.Join(store.TicketDir(ticketState.TicketNumber), state.StateFileName) + _, legacyStateErr := os.Stat(legacyStatePath) + if !os.IsNotExist(legacyStateErr) { + t.Fatalf("expected legacy state file to be removed, got err=%v", legacyStateErr) + } + + _, legacyDirErr := os.Stat(store.TicketDir(ticketState.TicketNumber)) + if !os.IsNotExist(legacyDirErr) { + t.Fatalf("expected empty legacy ticket dir to be removed, got err=%v", legacyDirErr) + } +} + +func TestSaveStateKeepsLegacyDirWhenItContainsOtherFiles(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + store := state.NewStore(repoRoot, ".auto-pr-state") + ticketState := workflowstate.New("GH-12") + + err := store.SaveState(ticketState.TicketNumber, ticketState) + if err != nil { + t.Fatalf("initial SaveState() error = %v", err) + } + + notePath := filepath.Join(store.TicketDir(ticketState.TicketNumber), "note.txt") + err = os.WriteFile(notePath, []byte("keep me\n"), 0o644) + if err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + ticketState.WorktreePath = filepath.Join(repoRoot, ".auto-pr-state", "worktrees", ticketState.TicketNumber) + err = store.SaveState(ticketState.TicketNumber, ticketState) + if err != nil { + t.Fatalf("migrating SaveState() error = %v", err) + } + + _, noteStatErr := os.Stat(notePath) + if noteStatErr != nil { + t.Fatalf("expected extra file to remain in legacy dir: %v", noteStatErr) + } +} + +func TestListTicketDirsIncludesMigratedWorktreeState(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + store := state.NewStore(repoRoot, ".auto-pr-state") + ticketState := workflowstate.New("GH-12") + + err := store.SaveState(ticketState.TicketNumber, ticketState) + if err != nil { + t.Fatalf("initial SaveState() error = %v", err) + } + + ticketState.WorktreePath = filepath.Join(repoRoot, ".auto-pr-state", "worktrees", ticketState.TicketNumber) + err = store.SaveState(ticketState.TicketNumber, ticketState) + if err != nil { + t.Fatalf("migrating SaveState() error = %v", err) + } + + tickets, err := store.ListTicketDirs() + if err != nil { + t.Fatalf("ListTicketDirs() error = %v", err) + } + if !slices.Contains(tickets, ticketState.TicketNumber) { + t.Fatalf("expected migrated ticket %q in %v", ticketState.TicketNumber, tickets) + } +}