diff --git a/.changeset/patch-document-current-checkout.md b/.changeset/patch-document-current-checkout.md new file mode 100644 index 0000000000..0bf85ef8a3 --- /dev/null +++ b/.changeset/patch-document-current-checkout.md @@ -0,0 +1,4 @@ +--- +"gh-aw": patch +--- +Introduce the `current` checkout metadata so the workspace prompt, CLI behavior, and docs can highlight the primary repository. diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index a4d03c97bf..5dcad1c459 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -28,7 +28,7 @@ # - shared/gh.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"bb46b86a2eb0aa7f857448cb6c55f108cb59f8457436996aa7af27d40bc7bff5"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"3ebe89d515d272dc2a563f4cde2e033400a08293c12461af6ae3b7134c657493"} name: "Smoke Codex" "on": @@ -188,6 +188,8 @@ jobs: {{#if __GH_AW_GITHUB_RUN_ID__ }} - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ {{/if}} + - **checkouts**: The following repositories have been checked out and are available in the workspace: + - `$GITHUB_WORKSPACE` → `__GH_AW_GITHUB_REPOSITORY__` (cwd) (**current** - this is the repository you are working on; use this as the target for all GitHub operations unless otherwise specified) GH_AW_PROMPT_EOF diff --git a/.github/workflows/smoke-codex.md b/.github/workflows/smoke-codex.md index bccabd34f9..1cce992416 100644 --- a/.github/workflows/smoke-codex.md +++ b/.github/workflows/smoke-codex.md @@ -63,7 +63,8 @@ safe-outputs: run-failure: "🌑 The shadows whisper... [{workflow_name}]({run_url}) {status}. The oracle requires further meditation..." timeout-minutes: 15 checkout: - fetch-depth: 2 + - fetch-depth: 2 + current: true --- # Smoke Test: Codex Engine Validation diff --git a/docs/src/content/docs/reference/cross-repository.md b/docs/src/content/docs/reference/cross-repository.md index 5479385bb2..14d72ac341 100644 --- a/docs/src/content/docs/reference/cross-repository.md +++ b/docs/src/content/docs/reference/cross-repository.md @@ -33,8 +33,7 @@ You can also use `checkout:` to check out additional repositories alongside the ```yaml wrap checkout: - - path: . - fetch-depth: 0 + - fetch-depth: 0 - repository: owner/other-repo path: ./libs/other ref: main @@ -53,6 +52,7 @@ checkout: | `sparse-checkout` | string | Newline-separated patterns for sparse checkout (e.g., `.github/\nsrc/`). | | `submodules` | string/bool | Submodule handling: `"recursive"`, `"true"`, or `"false"`. | | `lfs` | boolean | Download Git LFS objects. | +| `current` | boolean | Marks this checkout as the primary working repository. The agent uses this as the default target for all GitHub operations. Only one checkout may set `current: true`; the compiler rejects workflows where multiple checkouts enable it. | ### Checkout Merging @@ -66,6 +66,18 @@ When multiple `checkout:` entries target the same repository and path, their con - **Submodules**: First non-empty value wins for each `(repository, path)`; once set, later values are ignored - **Ref/Token**: First-seen wins +### Marking a Primary Repository (`current: true`) + +When a workflow running from a central repository targets a different repository, use `current: true` to tell the agent which repository to treat as its primary working target. The agent uses this as the default for all GitHub operations (creating issues, opening PRs, reading content) unless the prompt instructs otherwise. When omitted, the agent defaults to the repository where the workflow is running. + +```yaml wrap +checkout: + - repository: org/target-repo + path: ./target + github-token: ${{ secrets.CROSS_REPO_PAT }} + current: true # agent's primary target +``` + ## GitHub Tools - Reading Other Repositories When using [GitHub Tools](/gh-aw/reference/github-tools/) to read information from repositories other than the one where the workflow is running, you must configure additional authorization. The default `GITHUB_TOKEN` is scoped to the current repository only and cannot access other repositories. @@ -79,6 +91,7 @@ tools: github-token: ${{ secrets.CROSS_REPO_PAT }} ``` + See [GitHub Tools Reference](/gh-aw/reference/github-tools/#cross-repository-reading) for complete details on configuring cross-repository read access for GitHub Tools. This authentication is for **reading** information from GitHub. Authorization for **writing** to other repositories (creating issues, PRs, comments) is configured separately, see below. @@ -133,8 +146,7 @@ on: types: [opened, synchronize] checkout: - - path: . - fetch-depth: 0 + - fetch-depth: 0 - repository: org/shared-libs path: ./libs/shared ref: main diff --git a/pkg/cli/docker_build_integration_test.go b/pkg/cli/docker_build_integration_test.go index d09120f5fb..771264fb57 100644 --- a/pkg/cli/docker_build_integration_test.go +++ b/pkg/cli/docker_build_integration_test.go @@ -10,6 +10,14 @@ import ( "testing" ) +// isDockerAvailable checks if Docker is available on the system +func isDockerAvailable() bool { + cmd := exec.Command("docker", "version") + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() == nil +} + // TestDockerfile_Exists verifies the Dockerfile exists and has expected content func TestDockerfile_Exists(t *testing.T) { // Get the repository root diff --git a/pkg/cli/docker_images.go b/pkg/cli/docker_images.go index e8dce6a096..a10b48897a 100644 --- a/pkg/cli/docker_images.go +++ b/pkg/cli/docker_images.go @@ -226,14 +226,6 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use return nil } -// isDockerAvailable checks if Docker is available on the system -func isDockerAvailable() bool { - cmd := exec.Command("docker", "version") - cmd.Stdout = nil - cmd.Stderr = nil - return cmd.Run() == nil -} - // ResetDockerPullState resets the internal pull state (for testing) func ResetDockerPullState() { pullState.mu.Lock() diff --git a/pkg/parser/schema_validation.go b/pkg/parser/schema_validation.go index 3e4ead0fb0..3c12c4dbe5 100644 --- a/pkg/parser/schema_validation.go +++ b/pkg/parser/schema_validation.go @@ -5,11 +5,8 @@ import ( "maps" "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/logger" ) -var schemaValidationLog = logger.New("parser:schema_validation") - // sharedWorkflowForbiddenFields is a map for O(1) lookup of forbidden fields in shared workflows var sharedWorkflowForbiddenFields = buildForbiddenFieldsMap() diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index ae338c5e7b..b18826631f 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -7898,6 +7898,10 @@ "type": "string", "description": "GitHub token for authentication. Use ${{ secrets.MY_TOKEN }} to reference a secret. Credentials are always removed after checkout (persist-credentials: false is enforced).", "examples": ["${{ secrets.MY_PAT }}", "${{ secrets.GITHUB_TOKEN }}"] + }, + "current": { + "type": "boolean", + "description": "Marks this checkout as the logical current repository for the workflow. When set to true, the AI agent will treat this repository as its primary working target. Only one checkout may have current set to true. Useful for central-repo workflows targeting a different repository." } } } diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go index 2ee90e9797..fb5ed9fe85 100644 --- a/pkg/workflow/checkout_manager.go +++ b/pkg/workflow/checkout_manager.go @@ -22,8 +22,7 @@ var checkoutManagerLog = logger.New("workflow:checkout_manager") // Or multiple checkouts: // // checkout: -// - path: . -// fetch-depth: 0 +// - fetch-depth: 0 // - repository: owner/other-repo // path: ./libs/other // ref: main @@ -39,6 +38,10 @@ type CheckoutConfig struct { // GitHubToken overrides the default GITHUB_TOKEN for authentication. // Use ${{ secrets.MY_TOKEN }} to reference a repository secret. + // + // Frontmatter key: "github-token" (user-facing name used here and in the schema) + // Generated YAML key: "token" (the actual input name for actions/checkout) + // The compiler maps frontmatter "github-token" → lock.yml "token" during step generation. GitHubToken string `json:"github-token,omitempty"` // FetchDepth controls the number of commits to fetch. @@ -55,6 +58,12 @@ type CheckoutConfig struct { // LFS enables checkout of Git LFS objects. LFS bool `json:"lfs,omitempty"` + + // Current marks this checkout as the logical "current" repository for the workflow. + // When set, the AI agent will treat this repository as its primary working target. + // Only one checkout may have Current set to true. + // This is useful for workflows that run from a central repo targeting a different repo. + Current bool `json:"current,omitempty"` } // checkoutKey uniquely identifies a checkout target used for grouping/deduplication. @@ -74,6 +83,7 @@ type resolvedCheckout struct { sparsePatterns []string // merged sparse-checkout patterns submodules string lfs bool + current bool // true if this checkout is the logical current repository } // CheckoutManager collects checkout requests and merges them to minimize @@ -111,9 +121,14 @@ func (cm *CheckoutManager) add(cfg *CheckoutConfig) { return } + // Normalize path: "." and "" both refer to the workspace root. + normalizedPath := cfg.Path + if normalizedPath == "." { + normalizedPath = "" + } key := checkoutKey{ repository: cfg.Repository, - path: cfg.Path, + path: normalizedPath, } if idx, exists := cm.index[key]; exists { @@ -132,6 +147,9 @@ func (cm *CheckoutManager) add(cfg *CheckoutConfig) { if cfg.LFS { entry.lfs = true } + if cfg.Current { + entry.current = true + } if cfg.Submodules != "" && entry.submodules == "" { entry.submodules = cfg.Submodules } @@ -144,6 +162,7 @@ func (cm *CheckoutManager) add(cfg *CheckoutConfig) { fetchDepth: cfg.FetchDepth, submodules: cfg.Submodules, lfs: cfg.LFS, + current: cfg.Current, } if cfg.SparseCheckout != "" { entry.sparsePatterns = mergeSparsePatterns(nil, cfg.SparseCheckout) @@ -164,6 +183,18 @@ func (cm *CheckoutManager) GetDefaultCheckoutOverride() *resolvedCheckout { return nil } +// GetCurrentRepository returns the repository of the checkout marked as current (current: true). +// Returns an empty string if no checkout is marked as current or if the current checkout +// uses the default repository (empty Repository field). +func (cm *CheckoutManager) GetCurrentRepository() string { + for _, entry := range cm.ordered { + if entry.current { + return entry.key.repository + } + } + return "" +} + // GenerateAdditionalCheckoutSteps generates YAML step lines for all non-default // (additional) checkouts — those that target a specific path other than the root. // The caller is responsible for emitting the default workspace checkout separately. @@ -226,7 +257,8 @@ func (cm *CheckoutManager) GenerateDefaultCheckoutStep( fmt.Fprintf(&sb, " ref: %s\n", override.ref) } if override.token != "" { - fmt.Fprintf(&sb, " github-token: %s\n", override.token) + // actions/checkout input is "token", not "github-token" + fmt.Fprintf(&sb, " token: %s\n", override.token) } if override.fetchDepth != nil { fmt.Fprintf(&sb, " fetch-depth: %d\n", *override.fetchDepth) @@ -269,7 +301,8 @@ func generateCheckoutStepLines(entry *resolvedCheckout, getActionPin func(string fmt.Fprintf(&sb, " path: %s\n", entry.key.path) } if entry.token != "" { - fmt.Fprintf(&sb, " github-token: %s\n", entry.token) + // actions/checkout input is "token", not "github-token" + fmt.Fprintf(&sb, " token: %s\n", entry.token) } if entry.fetchDepth != nil { fmt.Fprintf(&sb, " fetch-depth: %d\n", *entry.fetchDepth) @@ -364,18 +397,18 @@ func ParseCheckoutConfigs(raw any) ([]*CheckoutConfig, error) { } checkoutManagerLog.Printf("Parsing checkout configuration: type=%T", raw) + var configs []*CheckoutConfig + // Try single object first if singleMap, ok := raw.(map[string]any); ok { cfg, err := checkoutConfigFromMap(singleMap) if err != nil { return nil, fmt.Errorf("invalid checkout configuration: %w", err) } - return []*CheckoutConfig{cfg}, nil - } - - // Try array of objects - if arr, ok := raw.([]any); ok { - configs := make([]*CheckoutConfig, 0, len(arr)) + configs = []*CheckoutConfig{cfg} + } else if arr, ok := raw.([]any); ok { + // Try array of objects + configs = make([]*CheckoutConfig, 0, len(arr)) for i, item := range arr { itemMap, ok := item.(map[string]any) if !ok { @@ -387,10 +420,31 @@ func ParseCheckoutConfigs(raw any) ([]*CheckoutConfig, error) { } configs = append(configs, cfg) } - return configs, nil + } else { + return nil, fmt.Errorf("checkout must be an object or an array of objects, got %T", raw) + } + + // Validate that at most one logical checkout target has current: true. + // Multiple current checkouts are not allowed since only one repo/path pair can be + // the primary target for the agent at a time. Multiple configs that merge into the + // same (repository, path) pair are treated as a single logical checkout. + currentTargets := make(map[string]struct{}) + for _, cfg := range configs { + if !cfg.Current { + continue + } + + repo := strings.TrimSpace(cfg.Repository) + path := strings.TrimSpace(cfg.Path) + key := repo + "\x00" + path + + currentTargets[key] = struct{}{} + } + if len(currentTargets) > 1 { + return nil, fmt.Errorf("only one checkout target may have current: true, found %d", len(currentTargets)) } - return nil, fmt.Errorf("checkout must be an object or an array of objects, got %T", raw) + return configs, nil } // checkoutConfigFromMap converts a raw map to a CheckoutConfig. @@ -418,6 +472,11 @@ func checkoutConfigFromMap(m map[string]any) (*CheckoutConfig, error) { if !ok { return nil, errors.New("checkout.path must be a string") } + // Normalize "." to empty string: both mean the workspace root and + // are treated identically by the checkout step generator. + if s == "." { + s = "" + } cfg.Path = s } @@ -482,5 +541,79 @@ func checkoutConfigFromMap(m map[string]any) (*CheckoutConfig, error) { cfg.LFS = b } + if v, ok := m["current"]; ok { + b, ok := v.(bool) + if !ok { + return nil, errors.New("checkout.current must be a boolean") + } + cfg.Current = b + } + return cfg, nil } + +// getCurrentCheckoutRepository returns the repository of the checkout marked as current (current: true). +// Returns an empty string if no checkout has current: true or if the current checkout +// uses the default repository (empty Repository field). +func getCurrentCheckoutRepository(checkouts []*CheckoutConfig) string { + for _, cfg := range checkouts { + if cfg != nil && cfg.Current { + return cfg.Repository + } + } + return "" +} + +// buildCheckoutsPromptContent returns a markdown bullet list describing all user-configured +// checkouts for inclusion in the GitHub context prompt. +// Returns an empty string when no checkouts are configured. +// +// Each checkout is shown with its full absolute path relative to $GITHUB_WORKSPACE. +// The root checkout (path == "") is annotated as "(cwd)" since that is the working +// directory of the agent process. The generated content may include +// "${{ github.repository }}" for any checkout that does not have an explicit repository +// configured; callers must ensure these expressions are processed by an ExpressionExtractor +// so the placeholder substitution step can resolve them at runtime. +func buildCheckoutsPromptContent(checkouts []*CheckoutConfig) string { + if len(checkouts) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString("- **checkouts**: The following repositories have been checked out and are available in the workspace:\n") + + for _, cfg := range checkouts { + if cfg == nil { + continue + } + + // Build the full absolute path using $GITHUB_WORKSPACE as root. + // Normalize the path: strip "./" prefix; bare "." and "" both mean root. + relPath := strings.TrimPrefix(cfg.Path, "./") + if relPath == "." { + relPath = "" + } + isRoot := relPath == "" + absPath := "$GITHUB_WORKSPACE" + if !isRoot { + absPath += "/" + relPath + } + + // Determine repo: use configured value or fall back to the triggering repository expression + repo := cfg.Repository + if repo == "" { + repo = "${{ github.repository }}" + } + + line := fmt.Sprintf(" - `%s` → `%s`", absPath, repo) + if isRoot { + line += " (cwd)" + } + if cfg.Current { + line += " (**current** - this is the repository you are working on; use this as the target for all GitHub operations unless otherwise specified)" + } + sb.WriteString(line + "\n") + } + + return sb.String() +} diff --git a/pkg/workflow/checkout_manager_test.go b/pkg/workflow/checkout_manager_test.go index a142f052f8..0ece10301e 100644 --- a/pkg/workflow/checkout_manager_test.go +++ b/pkg/workflow/checkout_manager_test.go @@ -103,6 +103,18 @@ func TestCheckoutManagerMerging(t *testing.T) { assert.Len(t, cm.ordered, 1, "same path should be merged") assert.Equal(t, "main", cm.ordered[0].ref, "first-seen ref should win") }) + + t.Run("path dot and empty path are normalized to the same root checkout", func(t *testing.T) { + depth0 := 0 + cm := NewCheckoutManager([]*CheckoutConfig{ + {Path: ".", FetchDepth: nil}, + {Path: "", FetchDepth: &depth0}, + }) + assert.Len(t, cm.ordered, 1, "path '.' and '' should merge as the same root checkout") + assert.Empty(t, cm.ordered[0].key.path, "normalized path should be empty string") + require.NotNil(t, cm.ordered[0].fetchDepth, "fetch depth should be set from second config") + assert.Equal(t, 0, *cm.ordered[0].fetchDepth, "fetch depth 0 should win") + }) } // TestGenerateDefaultCheckoutStep verifies the default checkout step output. @@ -124,7 +136,7 @@ func TestGenerateDefaultCheckoutStep(t *testing.T) { }) lines := cm.GenerateDefaultCheckoutStep(false, "", getPin) combined := strings.Join(lines, "") - assert.Contains(t, combined, "github-token: ${{ secrets.MY_TOKEN }}", "should include custom token") + assert.Contains(t, combined, "token: ${{ secrets.MY_TOKEN }}", "should include custom token") assert.Contains(t, combined, "persist-credentials: false", "must always have persist-credentials: false even with custom token") }) @@ -243,7 +255,7 @@ func TestParseCheckoutConfigs(t *testing.T) { configs, err := ParseCheckoutConfigs(raw) require.NoError(t, err, "array should parse without error") require.Len(t, configs, 2, "should produce two configs") - assert.Equal(t, ".", configs[0].Path, "first path should be set") + assert.Empty(t, configs[0].Path, "first path should be normalized from '.' to empty") assert.Equal(t, "owner/repo", configs[1].Repository, "second repo should be set") }) @@ -336,3 +348,176 @@ func TestMergeSparsePatterns(t *testing.T) { assert.Equal(t, []string{"src/"}, result, "should preserve existing patterns") }) } + +// TestCheckoutCurrentFlag verifies the current: true checkout flag behavior. +func TestCheckoutCurrentFlag(t *testing.T) { + t.Run("parse current: true from single object", func(t *testing.T) { + raw := map[string]any{ + "repository": "owner/target-repo", + "current": true, + } + configs, err := ParseCheckoutConfigs(raw) + require.NoError(t, err, "should parse without error") + require.Len(t, configs, 1, "should produce one config") + assert.True(t, configs[0].Current, "current flag should be true") + assert.Equal(t, "owner/target-repo", configs[0].Repository, "repository should be set") + }) + + t.Run("parse current: false from map", func(t *testing.T) { + raw := map[string]any{"current": false} + configs, err := ParseCheckoutConfigs(raw) + require.NoError(t, err, "should parse without error") + require.Len(t, configs, 1) + assert.False(t, configs[0].Current, "current flag should be false") + }) + + t.Run("invalid current type returns error", func(t *testing.T) { + raw := map[string]any{"current": "yes"} + _, err := ParseCheckoutConfigs(raw) + assert.Error(t, err, "non-boolean current should return error") + }) + + t.Run("multiple current: true in array returns error", func(t *testing.T) { + raw := []any{ + map[string]any{"repository": "owner/repo1", "path": "./r1", "current": true}, + map[string]any{"repository": "owner/repo2", "path": "./r2", "current": true}, + } + _, err := ParseCheckoutConfigs(raw) + require.Error(t, err, "multiple current: true should return error") + assert.Contains(t, err.Error(), "only one checkout target may have current: true", "error should mention the constraint") + }) + + t.Run("single current: true in array is valid", func(t *testing.T) { + raw := []any{ + map[string]any{"path": "."}, + map[string]any{"repository": "owner/target", "path": "./target", "current": true}, + } + configs, err := ParseCheckoutConfigs(raw) + require.NoError(t, err, "single current: true in array should be valid") + require.Len(t, configs, 2) + assert.False(t, configs[0].Current, "first checkout should not be current") + assert.True(t, configs[1].Current, "second checkout should be current") + }) +} + +// TestGetCurrentRepository verifies CheckoutManager.GetCurrentRepository behavior. +func TestGetCurrentRepository(t *testing.T) { + t.Run("returns empty string when no current checkout", func(t *testing.T) { + cm := NewCheckoutManager([]*CheckoutConfig{ + {Repository: "owner/repo", Path: "./libs"}, + }) + assert.Empty(t, cm.GetCurrentRepository(), "should return empty string without current flag") + }) + + t.Run("returns repository when current: true is set", func(t *testing.T) { + cm := NewCheckoutManager([]*CheckoutConfig{ + {Repository: "owner/target-repo", Path: "./target", Current: true}, + }) + assert.Equal(t, "owner/target-repo", cm.GetCurrentRepository(), "should return current checkout repository") + }) + + t.Run("returns empty string when current: true but no repository", func(t *testing.T) { + cm := NewCheckoutManager([]*CheckoutConfig{ + {Path: ".", Current: true}, + }) + assert.Empty(t, cm.GetCurrentRepository(), "should return empty string when repository is not set") + }) + + t.Run("returns repository from current in multiple checkouts", func(t *testing.T) { + cm := NewCheckoutManager([]*CheckoutConfig{ + {Path: "."}, + {Repository: "owner/central", Path: "./central"}, + {Repository: "owner/target", Path: "./target", Current: true}, + }) + assert.Equal(t, "owner/target", cm.GetCurrentRepository(), "should return the current checkout repository") + }) +} + +// TestGetCurrentCheckoutRepository verifies the standalone helper function. +func TestGetCurrentCheckoutRepository(t *testing.T) { + t.Run("nil slice returns empty string", func(t *testing.T) { + assert.Empty(t, getCurrentCheckoutRepository(nil), "nil slice should return empty string") + }) + + t.Run("no current flag returns empty string", func(t *testing.T) { + configs := []*CheckoutConfig{ + {Repository: "owner/repo"}, + } + assert.Empty(t, getCurrentCheckoutRepository(configs), "no current flag should return empty string") + }) + + t.Run("current: true returns repository", func(t *testing.T) { + configs := []*CheckoutConfig{ + {Repository: "owner/other"}, + {Repository: "owner/target", Current: true}, + } + assert.Equal(t, "owner/target", getCurrentCheckoutRepository(configs), "should return current checkout repository") + }) + + t.Run("current: true with no repository returns empty string", func(t *testing.T) { + configs := []*CheckoutConfig{ + {Current: true}, + } + assert.Empty(t, getCurrentCheckoutRepository(configs), "current without repository should return empty string") + }) +} + +// TestBuildCheckoutsPromptContent verifies the prompt content generation for the checkout list. +func TestBuildCheckoutsPromptContent(t *testing.T) { + t.Run("nil slice returns empty string", func(t *testing.T) { + assert.Empty(t, buildCheckoutsPromptContent(nil), "nil should return empty string") + }) + + t.Run("empty slice returns empty string", func(t *testing.T) { + assert.Empty(t, buildCheckoutsPromptContent([]*CheckoutConfig{}), "empty slice should return empty string") + }) + + t.Run("default checkout with no repo uses github.repository expression and cwd", func(t *testing.T) { + content := buildCheckoutsPromptContent([]*CheckoutConfig{ + {}, + }) + assert.Contains(t, content, "$GITHUB_WORKSPACE", "should show full workspace path for root checkout") + assert.Contains(t, content, "(cwd)", "root checkout should be marked as cwd") + assert.Contains(t, content, "${{ github.repository }}", "should reference github.repository expression for default checkout") + }) + + t.Run("checkout with explicit repo shows full path", func(t *testing.T) { + content := buildCheckoutsPromptContent([]*CheckoutConfig{ + {Repository: "owner/target", Path: "./target"}, + }) + assert.Contains(t, content, "$GITHUB_WORKSPACE/target", "should show full workspace path") + assert.Contains(t, content, "owner/target", "should show the configured repo") + assert.NotContains(t, content, "github.repository", "should not include github.repository expression for explicit repo") + assert.NotContains(t, content, "(cwd)", "non-root checkout should not be marked as cwd") + }) + + t.Run("current checkout is marked", func(t *testing.T) { + content := buildCheckoutsPromptContent([]*CheckoutConfig{ + {Repository: "owner/target", Path: "./target", Current: true}, + }) + assert.Contains(t, content, "**current**", "current checkout should be marked") + assert.Contains(t, content, "this is the repository you are working on", "current checkout should have instructions") + }) + + t.Run("non-current checkout is not marked", func(t *testing.T) { + content := buildCheckoutsPromptContent([]*CheckoutConfig{ + {Repository: "owner/libs", Path: "./libs"}, + }) + assert.NotContains(t, content, "**current**", "non-current checkout should not be marked") + }) + + t.Run("multiple checkouts all listed", func(t *testing.T) { + content := buildCheckoutsPromptContent([]*CheckoutConfig{ + {Path: ""}, + {Repository: "owner/target", Path: "./target", Current: true}, + {Repository: "owner/libs", Path: "./libs"}, + }) + assert.Contains(t, content, "$GITHUB_WORKSPACE", "should include workspace root for root checkout") + assert.Contains(t, content, "(cwd)", "root checkout should be marked as cwd") + assert.Contains(t, content, "$GITHUB_WORKSPACE/target", "should include full path for target checkout") + assert.Contains(t, content, "owner/target", "should include target repo") + assert.Contains(t, content, "$GITHUB_WORKSPACE/libs", "should include full path for libs checkout") + assert.Contains(t, content, "owner/libs", "should include libs repo") + assert.Contains(t, content, "**current**", "current checkout should be marked") + }) +} diff --git a/pkg/workflow/unified_prompt_step.go b/pkg/workflow/unified_prompt_step.go index 459f28eda0..3a2b3ae816 100644 --- a/pkg/workflow/unified_prompt_step.go +++ b/pkg/workflow/unified_prompt_step.go @@ -297,12 +297,27 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection { // 8. GitHub context (if GitHub tool is enabled) if hasGitHubTool(data.ParsedTools) { unifiedPromptLog.Print("Adding GitHub context section") - // Extract expressions from GitHub context prompt + + // Build the combined prompt text: base github context + optional checkout list. + // The checkout list may contain ${{ github.repository }} which must go through + // the expression extractor so the placeholder substitution step can resolve it. + combinedPromptText := githubContextPromptText + if checkoutsContent := buildCheckoutsPromptContent(data.CheckoutConfigs); checkoutsContent != "" { + unifiedPromptLog.Printf("Injecting checkout list into GitHub context (%d checkouts)", len(data.CheckoutConfigs)) + const closeTag = "" + if idx := strings.LastIndex(combinedPromptText, closeTag); idx >= 0 { + combinedPromptText = combinedPromptText[:idx] + checkoutsContent + combinedPromptText[idx:] + } else { + combinedPromptText += "\n" + checkoutsContent + } + } + + // Extract expressions from the combined content (includes any new expressions + // introduced by the checkout list, e.g. ${{ github.repository }}). extractor := NewExpressionExtractor() - expressionMappings, err := extractor.ExtractExpressions(githubContextPromptText) + expressionMappings, err := extractor.ExtractExpressions(combinedPromptText) if err == nil && len(expressionMappings) > 0 { - // Replace expressions with environment variable references - modifiedPromptText := extractor.ReplaceExpressionsWithEnvVars(githubContextPromptText) + modifiedPromptText := extractor.ReplaceExpressionsWithEnvVars(combinedPromptText) // Build environment variables map envVars := make(map[string]string)