diff --git a/TODO.md b/TODO.md deleted file mode 100644 index e6e1bd2..0000000 --- a/TODO.md +++ /dev/null @@ -1,8 +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. - -# Fixes - 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/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/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/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 { 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 ace842c..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"` @@ -86,6 +94,7 @@ func NewStore(path string) (*Store, error) { if err != nil { return nil, err } + store.resetStaleJobs() return store, nil } @@ -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 @@ -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 @@ -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 @@ -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 { 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) + } +} 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 --quiet` to get the full story details. +- Derive the branch name in this format: `sc--`, 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--`, 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 --json number,title,body,labels,url` to get the issue details. +- Derive the branch name in this format: `gh--`. 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: ` - - `Priority: ` + - `ID: ` - `URL: ` - `Labels: ` - `Branch: ` + - For Shortcut tickets only: `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. 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..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={selectedSummary.status === "running"} - cleanupDisabled={selectedSummary.status === "running"} - moveDisabled={selectedSummary.status === "running"} + rerunDisabled={selectedSummary.busy} + cleanupDisabled={selectedSummary.busy} + moveDisabled={selectedSummary.busy} />