Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions TODO.md

This file was deleted.

48 changes: 42 additions & 6 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
2 changes: 1 addition & 1 deletion internal/application/tickets/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 42 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
7 changes: 6 additions & 1 deletion internal/gitutil/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions internal/server/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
7 changes: 4 additions & 3 deletions internal/server/strict_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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:
Expand All @@ -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),
Expand All @@ -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"),
})

Expand Down
36 changes: 32 additions & 4 deletions internal/serverstate/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -86,6 +94,7 @@ func NewStore(path string) (*Store, error) {
if err != nil {
return nil, err
}
store.resetStaleJobs()

return store, nil
}
Expand Down Expand Up @@ -207,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
Expand Down Expand Up @@ -246,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
Expand All @@ -268,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
Expand Down Expand Up @@ -308,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 {
Expand Down
25 changes: 24 additions & 1 deletion internal/state/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading