diff --git a/.gitignore b/.gitignore index 89ddb6c..0c48a36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.swp .env* tempkey +.DS_Store diff --git a/dist/pullpreview-linux-amd64 b/dist/pullpreview-linux-amd64 index 6c3b4df..ddc586f 100755 Binary files a/dist/pullpreview-linux-amd64 and b/dist/pullpreview-linux-amd64 differ diff --git a/internal/pullpreview/deploy_context.go b/internal/pullpreview/deploy_context.go index b925605..ac06e35 100644 --- a/internal/pullpreview/deploy_context.go +++ b/internal/pullpreview/deploy_context.go @@ -512,7 +512,7 @@ func (i *Instance) runComposeOnRemoteContext(composeConfig []byte) error { upArgs := []string{"up", "--wait", "--remove-orphans", "-d"} upArgs = append(upArgs, i.ComposeOptions...) if err := i.runComposeCommandWithConfig(env, contextName, composeConfig, upArgs...); err != nil { - i.emitComposeFailureReport(env, contextName, composeConfig, upArgs, err) + i.emitComposeFailureReport(env, contextName, upArgs, err) return err } return nil @@ -636,8 +636,25 @@ func mergeCommandOutput(stdout, stderr string) string { return stdout + "\n" + stderr } -func (i *Instance) emitComposeFailureReport(env []string, contextName string, composeConfig []byte, upArgs []string, upErr error) { - report := i.composeFailureReport(env, contextName, composeConfig, upArgs, upErr) +func (i *Instance) runDockerCommandOnContextCapture(env []string, contextName string, args ...string) (string, error) { + cmdArgs := []string{"--context", contextName} + cmdArgs = append(cmdArgs, args...) + cmd := exec.CommandContext(i.Context, "docker", cmdArgs...) + cmd.Env = env + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + output := mergeCommandOutput(stdout.String(), stderr.String()) + if err != nil { + return output, fmt.Errorf("docker %s failed: %w", strings.Join(args, " "), err) + } + return output, nil +} + +func (i *Instance) emitComposeFailureReport(env []string, contextName string, upArgs []string, upErr error) { + report := i.composeFailureReport(env, contextName, upArgs, upErr) if strings.TrimSpace(report) == "" { return } @@ -649,116 +666,137 @@ func (i *Instance) emitComposeFailureReport(env []string, contextName string, co appendStepSummary(report, i.Logger) } -func (i *Instance) composeFailureReport(env []string, contextName string, composeConfig []byte, upArgs []string, upErr error) string { +func (i *Instance) composeFailureReport(env []string, contextName string, upArgs []string, upErr error) string { diagnostics := []string{} - psOutput, psErr := i.runComposeCommandCaptureWithConfig(env, contextName, composeConfig, "ps", "-a", "--format", "json") - if psErr != nil { - diagnostics = append(diagnostics, fmt.Sprintf("Unable to run docker compose ps -a --format json: %v", psErr)) - psOutput, psErr = i.runComposeCommandCaptureWithConfig(env, contextName, composeConfig, "ps", "-a") - if psErr != nil { - diagnostics = append(diagnostics, fmt.Sprintf("Unable to run docker compose ps -a: %v", psErr)) - } + projectFilter := "label=com.docker.compose.project=" + dockerProjectName + + dockerPSOutput, dockerPSErr := i.runDockerCommandOnContextCapture(env, contextName, "ps", "-a", "--filter", projectFilter) + if dockerPSErr != nil { + diagnostics = append(diagnostics, fmt.Sprintf("Unable to run docker ps -a on runner context: %v", dockerPSErr)) + dockerPSOutput = "" + } + + dockerPSJSONOutput, dockerPSJSONErr := i.runDockerCommandOnContextCapture( + env, + contextName, + "ps", + "-a", + "--filter", + projectFilter, + "--format", + "{{json .}}", + ) + if dockerPSJSONErr != nil { + diagnostics = append(diagnostics, fmt.Sprintf("Unable to run docker ps -a --format '{{json .}}' on runner context: %v", dockerPSJSONErr)) } containers := []composePSContainer{} - if strings.TrimSpace(psOutput) != "" { - parsed, err := parseComposePSOutput(psOutput) + if dockerPSJSONErr == nil && strings.TrimSpace(dockerPSJSONOutput) != "" { + parsed, err := parseDockerPSOutput(dockerPSJSONOutput) if err != nil { - diagnostics = append(diagnostics, fmt.Sprintf("Unable to parse docker compose ps output: %v", err)) + diagnostics = append(diagnostics, fmt.Sprintf("Unable to parse docker ps output: %v", err)) } else { containers = parsed } } failed := selectFailedContainers(containers) - return renderComposeFailureReport(i, upArgs, upErr, containers, failed, psOutput, diagnostics) + return renderComposeFailureReport(i, upArgs, upErr, containers, failed, dockerPSOutput, diagnostics) } -func parseComposePSOutput(raw string) ([]composePSContainer, error) { +func parseDockerPSOutput(raw string) ([]composePSContainer, error) { raw = strings.TrimSpace(raw) if raw == "" { - return nil, fmt.Errorf("empty output") + return []composePSContainer{}, nil } - objects := []map[string]any{} - if strings.HasPrefix(raw, "[") { - if err := json.Unmarshal([]byte(raw), &objects); err != nil { + containers := []composePSContainer{} + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var row struct { + Names string `json:"Names"` + Status string `json:"Status"` + Labels string `json:"Labels"` + } + if err := json.Unmarshal([]byte(line), &row); err != nil { return nil, err } - } else { - for _, line := range strings.Split(raw, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - var object map[string]any - if err := json.Unmarshal([]byte(line), &object); err != nil { - return nil, err - } - objects = append(objects, object) + state, health, exitCode := parseDockerPSStatus(row.Status) + service := dockerPSLabelValue(row.Labels, "com.docker.compose.service") + if service == "" { + service = strings.TrimSpace(row.Names) } - } - if len(objects) == 0 { - return nil, fmt.Errorf("no containers found") - } - - containers := make([]composePSContainer, 0, len(objects)) - for _, object := range objects { containers = append(containers, composePSContainer{ - Service: composePSFieldString(object, "Service", "service"), - Name: composePSFieldString(object, "Name", "name"), - State: composePSFieldString(object, "State", "Status", "state"), - Health: composePSFieldString(object, "Health", "health"), - ExitCode: composePSFieldInt(object, "ExitCode", "exit_code"), + Service: service, + Name: strings.TrimSpace(row.Names), + State: state, + Health: health, + ExitCode: exitCode, }) } return containers, nil } -func composePSFieldString(values map[string]any, keys ...string) string { - for _, key := range keys { - value, ok := values[key] - if !ok { +func dockerPSLabelValue(labels string, key string) string { + for _, pair := range strings.Split(labels, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { continue } - switch typed := value.(type) { - case string: - if strings.TrimSpace(typed) != "" { - return strings.TrimSpace(typed) - } - case float64: - return strconv.Itoa(int(typed)) - case json.Number: - return typed.String() + parts := strings.SplitN(pair, "=", 2) + if len(parts) != 2 { + continue + } + if strings.TrimSpace(parts[0]) == key { + return strings.TrimSpace(parts[1]) } } return "" } -func composePSFieldInt(values map[string]any, keys ...string) int { - for _, key := range keys { - value, ok := values[key] - if !ok { - continue - } - switch typed := value.(type) { - case float64: - return int(typed) - case int: - return typed - case int64: - return int(typed) - case json.Number: - if parsed, err := typed.Int64(); err == nil { - return int(parsed) - } - case string: - if parsed, err := strconv.Atoi(strings.TrimSpace(typed)); err == nil { - return parsed +func parseDockerPSStatus(status string) (string, string, int) { + s := strings.ToLower(strings.TrimSpace(status)) + state := "unknown" + switch { + case strings.HasPrefix(s, "up "): + state = "running" + case strings.HasPrefix(s, "exited "): + state = "exited" + case strings.HasPrefix(s, "created"): + state = "created" + case strings.HasPrefix(s, "restarting"): + state = "restarting" + case strings.HasPrefix(s, "paused"): + state = "paused" + case strings.HasPrefix(s, "dead"): + state = "dead" + case strings.HasPrefix(s, "removing"): + state = "removing" + } + + health := "" + switch { + case strings.Contains(s, "(unhealthy)"): + health = "unhealthy" + case strings.Contains(s, "(healthy)"): + health = "healthy" + case strings.Contains(s, "(health: starting)"): + health = "starting" + } + + exitCode := 0 + if strings.HasPrefix(s, "exited (") { + rest := strings.TrimPrefix(s, "exited (") + if idx := strings.Index(rest, ")"); idx > 0 { + if code, err := strconv.Atoi(strings.TrimSpace(rest[:idx])); err == nil { + exitCode = code } } } - return 0 + return state, health, exitCode } func selectFailedContainers(containers []composePSContainer) []composePSContainer { @@ -855,7 +893,7 @@ func renderComposeFailureReport( } if strings.TrimSpace(psOutput) != "" { - b.WriteString("### `docker compose ps -a`\n\n") + b.WriteString("### `docker ps -a` (runner context)\n\n") b.WriteString("```text\n") b.WriteString(truncateReportOutput(psOutput, composeFailureReportOutputLimit)) b.WriteString("\n```\n\n") diff --git a/internal/pullpreview/deploy_context_test.go b/internal/pullpreview/deploy_context_test.go index c3c32df..03919d2 100644 --- a/internal/pullpreview/deploy_context_test.go +++ b/internal/pullpreview/deploy_context_test.go @@ -244,23 +244,26 @@ func TestInlinePreScriptLoadsLocalScriptContent(t *testing.T) { } } -func TestParseComposePSOutputJSON(t *testing.T) { - raw := `[ - {"Service":"web","Name":"app-web-1","State":"exited","Health":"","ExitCode":1}, - {"Service":"db","Name":"app-db-1","State":"running","Health":"unhealthy","ExitCode":0}, - {"Service":"cache","Name":"app-cache-1","State":"running","Health":"","ExitCode":0} - ]` +func TestParseDockerPSOutputJSONLines(t *testing.T) { + raw := strings.Join([]string{ + `{"Names":"app-web-1","Status":"Exited (1) 5 seconds ago","Labels":"com.docker.compose.project=app,com.docker.compose.service=web"}`, + `{"Names":"app-db-1","Status":"Up 2 minutes (unhealthy)","Labels":"com.docker.compose.project=app,com.docker.compose.service=db"}`, + `{"Names":"app-cache-1","Status":"Up 2 minutes","Labels":"com.docker.compose.project=app,com.docker.compose.service=cache"}`, + }, "\n") - containers, err := parseComposePSOutput(raw) + containers, err := parseDockerPSOutput(raw) if err != nil { - t.Fatalf("parseComposePSOutput() error: %v", err) + t.Fatalf("parseDockerPSOutput() error: %v", err) } if len(containers) != 3 { t.Fatalf("expected 3 containers, got %d", len(containers)) } - if containers[0].Service != "web" || containers[0].Name != "app-web-1" || containers[0].ExitCode != 1 { + if containers[0].Service != "web" || containers[0].Name != "app-web-1" || containers[0].State != "exited" || containers[0].ExitCode != 1 { t.Fatalf("unexpected first container: %#v", containers[0]) } + if containers[1].Service != "db" || containers[1].Health != "unhealthy" || containers[1].State != "running" { + t.Fatalf("unexpected second container: %#v", containers[1]) + } } func TestSelectFailedContainers(t *testing.T) { @@ -325,6 +328,9 @@ func TestRenderComposeFailureReportIncludesTroubleshooting(t *testing.T) { if strings.Contains(report, "Last 20 log lines") { t.Fatalf("did not expect report to include container logs, got:\n%s", report) } + if strings.Contains(report, "docker compose ps -a") { + t.Fatalf("did not expect report to include docker compose ps command, got:\n%s", report) + } } func TestFailedContainerLogKeyPrefersContainerName(t *testing.T) { diff --git a/skills/pullpreview-demo-flow/SKILL.md b/skills/pullpreview-demo-flow/SKILL.md deleted file mode 100644 index 370e1fb..0000000 --- a/skills/pullpreview-demo-flow/SKILL.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: pullpreview-demo-flow -description: Repeatable PullPreview demo workflow that creates a PR, applies label, captures lifecycle screenshots, and verifies deploy/destroy comment transitions. -allowed-tools: Bash(gh:*), Bash(agent-browser:*), Bash(jq:*), Bash(git:*) ---- - -# PullPreview Demo Flow (Screenshots) - -Use this skill to run a full, repeatable PullPreview demo with screenshots. - -## Non-negotiable rules - -1. Demo PR title must always be exactly: `Auto-deploy app with PullPreview`. -2. Before taking PR comment screenshots, scroll the PR page so the comment card is visible in viewport. -3. Keep the screenshot filename set stable: - - `01-pr-open-with-label.png` - - `02-comment-deploying.png` - - `03-action-running.png` - - `04-view-deployment-button.png` - - `05-unlabelled-pr.png` - - `06-comment-destroyed.png` - -## Prerequisites - -- `gh` authenticated with access to `pullpreview/action`. -- `agent-browser` installed. -- Repo label `pullpreview` exists. -- Workflow on base branch supports comment and deployment lifecycle. - -## Create demo PR - -Run helper script: - -```bash -./skills/pullpreview-demo-flow/scripts/create_demo_pr.sh -``` - -Script output includes: - -- `PR_URL=...` -- `PR_NUMBER=...` -- `BRANCH=...` - -The script always creates a PR with title `Auto-deploy app with PullPreview`. - -## Capture screenshots - -Use: - -```bash -export REPO="pullpreview/action" -export PR_NUMBER="" -export BRANCH="" -export SCREEN_DIR="docs/demo-flow-screenshots" -mkdir -p "${SCREEN_DIR}" -``` - -### 1) PR opened with label - -```bash -agent-browser --session pp-demo open "https://github.com/${REPO}/pull/${PR_NUMBER}" -agent-browser --session pp-demo wait --load networkidle -agent-browser --session pp-demo screenshot "${SCREEN_DIR}/01-pr-open-with-label.png" -``` - -### 2) Deploying PR comment (must scroll first) - -```bash -agent-browser --session pp-demo open "https://github.com/${REPO}/pull/${PR_NUMBER}" -agent-browser --session pp-demo eval "window.scrollTo(0, document.body.scrollHeight)" -agent-browser --session pp-demo wait --text "Deploying action with" -agent-browser --session pp-demo screenshot "${SCREEN_DIR}/02-comment-deploying.png" -``` - -### 3) Workflow run in progress - -```bash -RUN_URL="$(gh run list --repo "${REPO}" --branch "${BRANCH}" --workflow pullpreview --limit 1 --json url | jq -r '.[0].url')" -agent-browser --session pp-demo open "${RUN_URL}" -agent-browser --session pp-demo wait --text "in progress" -agent-browser --session pp-demo screenshot "${SCREEN_DIR}/03-action-running.png" -``` - -### 4) Successful deploy with "View deployment" - -Wait for completion: - -```bash -RUN_ID="$(echo "${RUN_URL}" | awk -F/ '{print $NF}')" -gh run watch "${RUN_ID}" --repo "${REPO}" -``` - -Then capture: - -```bash -agent-browser --session pp-demo open "https://github.com/${REPO}/pull/${PR_NUMBER}" -agent-browser --session pp-demo wait --text "View deployment" -agent-browser --session pp-demo screenshot "${SCREEN_DIR}/04-view-deployment-button.png" -``` - -### 5) Remove label and capture unlabeled PR - -```bash -gh pr edit "${PR_NUMBER}" --repo "${REPO}" --remove-label pullpreview -agent-browser --session pp-demo open "https://github.com/${REPO}/pull/${PR_NUMBER}" -agent-browser --session pp-demo wait --load networkidle -agent-browser --session pp-demo screenshot "${SCREEN_DIR}/05-unlabelled-pr.png" -``` - -### 6) Destroyed PR comment (must scroll first) - -```bash -agent-browser --session pp-demo open "https://github.com/${REPO}/pull/${PR_NUMBER}" -agent-browser --session pp-demo eval "window.scrollTo(0, document.body.scrollHeight)" -agent-browser --session pp-demo wait --text "Preview destroyed" -agent-browser --session pp-demo screenshot "${SCREEN_DIR}/06-comment-destroyed.png" -``` - -## Verification checklist - -- PR title is `Auto-deploy app with PullPreview`. -- Deploy comment shows pending then success. -- "View deployment" is visible after success. -- Destroy comment shows after label removal. -- Comment screenshots show the comment body in viewport (not off-screen). diff --git a/skills/pullpreview-demo-flow/scripts/create_demo_pr.sh b/skills/pullpreview-demo-flow/scripts/create_demo_pr.sh deleted file mode 100755 index 482a3fd..0000000 --- a/skills/pullpreview-demo-flow/scripts/create_demo_pr.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO="${REPO:-pullpreview/action}" -BASE_BRANCH="${BASE_BRANCH:-codex/go-context}" -LABEL="${LABEL:-pullpreview}" -PR_TITLE="Auto-deploy app with PullPreview" -STAMP="$(date -u +%Y%m%d-%H%M%S)" -BRANCH="codex/demo-flow-${STAMP}" -WORKDIR="${WORKDIR:-$(mktemp -d /tmp/pullpreview-demo-XXXXXX)}" -CLONE_DIR="${WORKDIR}/repo" - -echo "Using workdir: ${WORKDIR}" - -git clone "https://github.com/${REPO}.git" "${CLONE_DIR}" -cd "${CLONE_DIR}" -git checkout "${BASE_BRANCH}" -git switch -c "${BRANCH}" - -mkdir -p demo -cat > demo/go-flow-marker.txt <