diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index d11c0feec18..58c00b1afa5 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -124,7 +124,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' - name: Install GitHub Copilot CLI run: npm install -g @github/copilot - name: Setup Safe Outputs Collector MCP @@ -2181,7 +2181,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' - name: Install GitHub Copilot CLI run: npm install -g @github/copilot - name: Execute GitHub Copilot CLI diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index 41617db6fcd..87fed0a4dce 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -23,6 +23,8 @@ The YAML frontmatter supports standard GitHub Actions properties plus additional - `cache`: Cache configuration for workflow dependencies **Properties specific to GitHub Agentic Workflows:** +- `description`: Human-readable description rendered as a comment in the lock file +- `source`: Source reference tracking where the workflow was added from (format: `owner/repo/path@ref`) - `engine`: AI engine configuration (copilot/claude/codex) with optional max-turns setting - `strict`: Enable strict mode validation (boolean, defaults to false) - `roles`: Permission restrictions based on repository access levels @@ -140,6 +142,81 @@ on: This filtering is especially useful for [LabelOps workflows](/gh-aw/guides/labelops/) where specific labels trigger different automation behaviors. +## Description (`description:`) + +The `description:` field provides a human-readable description of the workflow that is rendered as a comment in the generated lock file. This helps document the purpose and functionality of the workflow. + +```yaml +description: "Workflow that analyzes pull requests and provides feedback" +``` + +The description appears in the lock file header as a comment: + +```yaml +# This file was automatically generated by gh-aw. DO NOT EDIT. +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# For more information: https://github.com/githubnext/gh-aw/blob/main/.github/instructions/github-agentic-workflows.instructions.md +# +# Workflow that analyzes pull requests and provides feedback + +name: "PR Analyzer" +... +``` + +## Source Tracking (`source:`) + +The `source:` field tracks the origin of workflows added using the `gh aw add` command. This field is automatically populated when installing workflows from external repositories and provides traceability for workflow provenance. + +**Format:** `owner/repo/path@ref` + +```yaml +source: "githubnext/agentics/workflows/ci-doctor.md@v1.0.0" +``` + +**Examples:** +- `githubnext/agentics/workflows/ci-doctor.md@v1.0.0` - Workflow from a specific version tag +- `githubnext/agentics/workflows/daily-plan.md@main` - Workflow from the main branch +- `githubnext/agentics/workflows/helper-bot.md` - Workflow without version specification + +**Automatic Population:** + +When you use the `gh aw add` command, the source field is automatically added to the workflow frontmatter: + +```bash +# Command +gh aw add githubnext/agentics/ci-doctor@v1.0.0 + +# Generated frontmatter includes: +source: "githubnext/agentics/workflows/ci-doctor.md@v1.0.0" +``` + +**Rendering in Lock Files:** + +The source field is rendered as a comment in the lock file header, positioned after the description: + +```yaml +# This file was automatically generated by gh-aw. DO NOT EDIT. +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# For more information: https://github.com/githubnext/gh-aw/blob/main/.github/instructions/github-agentic-workflows.instructions.md +# +# CI Doctor workflow - added from githubnext/agentics +# +# Source: githubnext/agentics/workflows/ci-doctor.md@v1.0.0 + +name: "CI Doctor" +... +``` + +**Benefits:** +- **Traceability**: Know exactly where a workflow came from and which version +- **Updates**: Easy identification of the source repository for checking updates +- **Documentation**: Automatic documentation of workflow provenance +- **Auditing**: Track workflow origins for security and compliance purposes + +**Note:** The `source` field is optional. Workflows created manually or without using `gh aw add` don't need to include it. + ## Permissions (`permissions:`) The `permissions:` section uses standard GitHub Actions permissions syntax to specify the permissions relevant to the agentic (natural language) part of the execution of the workflow. See [GitHub Actions permissions documentation](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions). diff --git a/pkg/cli/add_command.go b/pkg/cli/add_command.go index 5d2febec963..79d7be997f2 100644 --- a/pkg/cli/add_command.go +++ b/pkg/cli/add_command.go @@ -11,7 +11,9 @@ import ( "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/constants" + "github.com/githubnext/gh-aw/pkg/parser" "github.com/githubnext/gh-aw/pkg/workflow" + "github.com/goccy/go-yaml" "github.com/spf13/cobra" ) @@ -442,6 +444,19 @@ func addWorkflowWithTracking(workflow *WorkflowSpec, number int, verbose bool, e content = updateWorkflowTitle(content, i) } + // Add source field to frontmatter + sourceString := buildSourceString(workflow) + if sourceString != "" { + updatedContent, err := addSourceToWorkflow(content, sourceString, verbose) + if err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to add source field: %v", err))) + } + } else { + content = updatedContent + } + } + // Track the file based on whether it existed before (if tracker is available) if tracker != nil { if fileExists { @@ -804,3 +819,59 @@ func createPR(branchName, title, body string, verbose bool) error { return nil } + +// buildSourceString builds the source string in the format owner/repo/path@ref +func buildSourceString(workflow *WorkflowSpec) string { + if workflow.Repo == "" || workflow.WorkflowPath == "" { + return "" + } + + // Format: owner/repo/path@ref (consistent with add command syntax) + source := workflow.Repo + "/" + workflow.WorkflowPath + if workflow.Version != "" { + source += "@" + workflow.Version + } + + return source +} + +// addSourceToWorkflow adds the source field to the workflow's frontmatter +func addSourceToWorkflow(content, source string, verbose bool) (string, error) { + // Parse frontmatter using parser package + result, err := parser.ExtractFrontmatterFromContent(content) + if err != nil { + return "", fmt.Errorf("failed to parse frontmatter: %w", err) + } + + // Initialize frontmatter if it doesn't exist + if result.Frontmatter == nil { + result.Frontmatter = make(map[string]any) + } + + // Add source field (will be last in YAML output due to alphabetical sorting) + result.Frontmatter["source"] = source + + // Convert back to YAML + updatedFrontmatter, err := yaml.Marshal(result.Frontmatter) + if err != nil { + return "", fmt.Errorf("failed to marshal updated frontmatter: %w", err) + } + + // Clean up quoted keys - replace "on": with on: at the start of a line + // This handles cases where YAML marshaling adds unnecessary quotes around reserved words like "on" + frontmatterStr := strings.TrimSuffix(string(updatedFrontmatter), "\n") + frontmatterStr = workflow.UnquoteYAMLKey(frontmatterStr, "on") + + // Reconstruct the file + var lines []string + lines = append(lines, "---") + if frontmatterStr != "" { + lines = append(lines, strings.Split(frontmatterStr, "\n")...) + } + lines = append(lines, "---") + if result.Markdown != "" { + lines = append(lines, result.Markdown) + } + + return strings.Join(lines, "\n"), nil +} diff --git a/pkg/cli/add_source_test.go b/pkg/cli/add_source_test.go new file mode 100644 index 00000000000..b9a76c401fb --- /dev/null +++ b/pkg/cli/add_source_test.go @@ -0,0 +1,188 @@ +package cli + +import ( + "strings" + "testing" +) + +// TestBuildSourceString tests the buildSourceString function +func TestBuildSourceString(t *testing.T) { + tests := []struct { + name string + workflow *WorkflowSpec + expected string + }{ + { + name: "full_spec_with_version", + workflow: &WorkflowSpec{ + Repo: "githubnext/agentics", + WorkflowPath: "workflows/ci-doctor.md", + Version: "v1.0.0", + }, + expected: "githubnext/agentics/workflows/ci-doctor.md@v1.0.0", + }, + { + name: "spec_without_version", + workflow: &WorkflowSpec{ + Repo: "githubnext/agentics", + WorkflowPath: "workflows/ci-doctor.md", + Version: "", + }, + expected: "githubnext/agentics/workflows/ci-doctor.md", + }, + { + name: "spec_with_branch", + workflow: &WorkflowSpec{ + Repo: "githubnext/agentics", + WorkflowPath: "workflows/daily-plan.md", + Version: "main", + }, + expected: "githubnext/agentics/workflows/daily-plan.md@main", + }, + { + name: "empty_repo", + workflow: &WorkflowSpec{ + Repo: "", + WorkflowPath: "workflows/test.md", + Version: "v1.0.0", + }, + expected: "", + }, + { + name: "empty_workflow_path", + workflow: &WorkflowSpec{ + Repo: "githubnext/agentics", + WorkflowPath: "", + Version: "v1.0.0", + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildSourceString(tt.workflow) + if result != tt.expected { + t.Errorf("buildSourceString() = %v, want %v", result, tt.expected) + } + }) + } +} + +// TestAddSourceToWorkflow tests the addSourceToWorkflow function +func TestAddSourceToWorkflow(t *testing.T) { + tests := []struct { + name string + content string + source string + expectError bool + checkSource bool + }{ + { + name: "add_source_to_workflow_with_frontmatter", + content: `--- +on: push +permissions: + contents: read +engine: claude +--- + +# Test Workflow + +This is a test workflow.`, + source: "githubnext/agentics/workflows/ci-doctor.md@v1.0.0", + expectError: false, + checkSource: true, + }, + { + name: "add_source_to_workflow_without_frontmatter", + content: `# Test Workflow + +This is a test workflow without frontmatter.`, + source: "githubnext/agentics/workflows/test.md@main", + expectError: false, + checkSource: true, + }, + { + name: "add_source_to_existing_workflow_with_fields", + content: `--- +description: "Test workflow description" +on: push +permissions: + contents: read +engine: claude +tools: + github: + allowed: [list_commits] +--- + +# Test Workflow + +This is a test workflow.`, + source: "githubnext/agentics/workflows/complex.md@v1.0.0", + expectError: false, + checkSource: true, + }, + { + name: "verify_on_keyword_not_quoted", + content: `--- +on: + push: + branches: [main] + pull_request: + types: [opened] +permissions: + contents: read +engine: claude +--- + +# Test Workflow + +This workflow has complex 'on' triggers.`, + source: "githubnext/agentics/workflows/test.md@v1.0.0", + expectError: false, + checkSource: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := addSourceToWorkflow(tt.content, tt.source, false) + + if tt.expectError && err == nil { + t.Errorf("addSourceToWorkflow() expected error, got nil") + return + } + + if !tt.expectError && err != nil { + t.Errorf("addSourceToWorkflow() error = %v", err) + return + } + + if !tt.expectError && tt.checkSource { + // Verify that the source field is present in the result + if !strings.Contains(result, "source:") { + t.Errorf("addSourceToWorkflow() result does not contain 'source:' field") + } + if !strings.Contains(result, tt.source) { + t.Errorf("addSourceToWorkflow() result does not contain source value '%s'", tt.source) + } + + // Verify that frontmatter delimiters are present + if !strings.Contains(result, "---") { + t.Errorf("addSourceToWorkflow() result does not contain frontmatter delimiters") + } + + // Verify that markdown content is preserved + if strings.Contains(tt.content, "# Test Workflow") && !strings.Contains(result, "# Test Workflow") { + t.Errorf("addSourceToWorkflow() result does not preserve markdown content") + } + + // Verify that "on" keyword is not quoted + if strings.Contains(result, `"on":`) { + t.Errorf("addSourceToWorkflow() result contains quoted 'on' keyword, should be unquoted. Result:\n%s", result) + } + } + }) + } +} diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index ed3a3e7fec9..ab08801413f 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -10,6 +10,10 @@ "type": "string", "description": "Optional workflow description that is rendered as a comment in the generated GitHub Actions YAML file (.lock.yml)" }, + "source": { + "type": "string", + "description": "Optional source reference indicating where this workflow was added from. Format: owner/repo/path@ref (e.g., githubnext/agentics/workflows/ci-doctor.md@v1.0.0). Rendered as a comment in the generated lock file." + }, "on": { "description": "Workflow triggers that define when the agentic workflow should run. Supports standard GitHub Actions trigger events plus special command triggers for /commands (required)", "oneOf": [ diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 55a0a431c97..94439f66997 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -111,6 +111,7 @@ type WorkflowData struct { TrialMode bool // whether the workflow is running in trial mode FrontmatterName string // name field from frontmatter (for code scanning alert driver default) Description string // optional description rendered as comment in lock file + Source string // optional source field (owner/repo@ref/path) rendered as comment in lock file On string Permissions string Network string // top-level network permissions configuration @@ -615,6 +616,7 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) Name: workflowName, FrontmatterName: frontmatterName, Description: c.extractDescription(result.Frontmatter), + Source: c.extractSource(result.Frontmatter), Tools: tools, MarkdownContent: markdownContent, AI: engineSetting, @@ -715,7 +717,7 @@ func (c *Compiler) extractTopLevelYAMLSection(frontmatter map[string]any, key st // Clean up quoted keys - replace "key": with key: at the start of a line // This handles cases where YAML marshaling adds unnecessary quotes around reserved words like "on" - yamlStr = unquoteYAMLKey(yamlStr, key) + yamlStr = UnquoteYAMLKey(yamlStr, key) // Special handling for "on" section - comment out draft and fork fields from pull_request if key == "on" { @@ -756,6 +758,21 @@ func (c *Compiler) extractDescription(frontmatter map[string]any) string { return "" } +// extractSource extracts the source field from frontmatter +func (c *Compiler) extractSource(frontmatter map[string]any) string { + value, exists := frontmatter["source"] + if !exists { + return "" + } + + // Convert the value to string + if strValue, ok := value.(string); ok { + return strings.TrimSpace(strValue) + } + + return "" +} + // extractSafetyPromptSetting extracts the safety-prompt setting from tools // Returns true by default (safety prompt is enabled by default) func (c *Compiler) extractSafetyPromptSetting(tools map[string]any) bool { @@ -988,7 +1005,7 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work // Clean up quoted keys - replace "on": with on: at the start of a line // This handles cases where YAML marshaling adds unnecessary quotes around reserved words like "on" - yamlStr = unquoteYAMLKey(yamlStr, "on") + yamlStr = UnquoteYAMLKey(yamlStr, "on") workflowData.On = yamlStr } else { @@ -1330,6 +1347,12 @@ func (c *Compiler) generateYAML(data *WorkflowData, markdownPath string) (string } } + // Add source comment if provided + if data.Source != "" { + yaml.WriteString("#\n") + yaml.WriteString(fmt.Sprintf("# Source: %s\n", data.Source)) + } + // Add stop-time comment if configured if data.StopTime != "" { yaml.WriteString("#\n") diff --git a/pkg/workflow/source_field_test.go b/pkg/workflow/source_field_test.go new file mode 100644 index 00000000000..ad17432c3d2 --- /dev/null +++ b/pkg/workflow/source_field_test.go @@ -0,0 +1,215 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestSourceFieldRendering tests that the source field from frontmatter +// is correctly rendered as a comment in the generated lock file +func TestSourceFieldRendering(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "source-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + compiler := NewCompiler(false, "", "test") + + tests := []struct { + name string + frontmatter string + expectedSource string + description string + }{ + { + name: "source_field_present", + frontmatter: `--- +source: "githubnext/agentics/workflows/ci-doctor.md@v1.0.0" +on: + push: + branches: [main] +permissions: + contents: read +engine: claude +tools: + github: + allowed: [list_commits] +---`, + expectedSource: "# Source: githubnext/agentics/workflows/ci-doctor.md@v1.0.0", + description: "Should render source field as comment", + }, + { + name: "source_field_with_branch", + frontmatter: `--- +source: "githubnext/agentics/workflows/ci-doctor.md@main" +on: + push: + branches: [main] +permissions: + contents: read +engine: claude +tools: + github: + allowed: [list_commits] +---`, + expectedSource: "# Source: githubnext/agentics/workflows/ci-doctor.md@main", + description: "Should render source field with branch ref", + }, + { + name: "no_source_field", + frontmatter: `--- +on: + push: + branches: [main] +permissions: + contents: read +engine: claude +tools: + github: + allowed: [list_commits] +---`, + expectedSource: "", + description: "Should not render any source comments when no source is provided", + }, + { + name: "source_and_description", + frontmatter: `--- +description: "This is a test workflow" +source: "githubnext/agentics/workflows/test.md@v1.0.0" +on: + push: + branches: [main] +permissions: + contents: read +engine: claude +tools: + github: + allowed: [list_commits] +---`, + expectedSource: "# Source: githubnext/agentics/workflows/test.md@v1.0.0", + description: "Should render both description and source", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testContent := tt.frontmatter + ` + +# Test Workflow + +This is a test workflow to verify source field rendering. +` + + testFile := filepath.Join(tmpDir, tt.name+"-workflow.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + // Compile the workflow + err := compiler.CompileWorkflow(testFile) + if err != nil { + t.Fatalf("Unexpected error compiling workflow: %v", err) + } + + // Read the generated lock file + lockFile := strings.TrimSuffix(testFile, ".md") + ".lock.yml" + content, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + lockContent := string(content) + + if tt.expectedSource == "" { + // Verify no source comments are present + if strings.Contains(lockContent, "# Source:") { + t.Errorf("Expected no source comment, but found one in:\n%s", lockContent) + } + } else { + // Verify source comment is present + if !strings.Contains(lockContent, tt.expectedSource) { + t.Errorf("Expected source comment '%s' not found in generated YAML:\n%s", tt.expectedSource, lockContent) + } + + // Verify ordering: standard header -> description (if any) -> source -> workflow content + headerPattern := "# For more information:" + sourcePattern := tt.expectedSource + workflowStartPattern := "name: \"" + + headerPos := strings.Index(lockContent, headerPattern) + sourcePos := strings.Index(lockContent, sourcePattern) + workflowPos := strings.Index(lockContent, workflowStartPattern) + + if headerPos == -1 { + t.Error("Standard header not found in generated YAML") + } + if sourcePos == -1 { + t.Error("Source comment not found in generated YAML") + } + if workflowPos == -1 { + t.Error("Workflow content not found in generated YAML") + } + + if headerPos >= sourcePos { + t.Error("Source should come after standard header") + } + if sourcePos >= workflowPos { + t.Error("Source should come before workflow content") + } + } + + // Clean up generated lock file + os.Remove(lockFile) + }) + } +} + +// TestSourceFieldExtraction tests that the extractSource method works correctly +func TestSourceFieldExtraction(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + tests := []struct { + name string + frontmatter map[string]any + expected string + }{ + { + name: "source_field_present", + frontmatter: map[string]any{ + "source": "githubnext/agentics/workflows/ci-doctor.md@v1.0.0", + }, + expected: "githubnext/agentics/workflows/ci-doctor.md@v1.0.0", + }, + { + name: "source_field_with_spaces", + frontmatter: map[string]any{ + "source": " githubnext/agentics/workflows/test.md@main ", + }, + expected: "githubnext/agentics/workflows/test.md@main", + }, + { + name: "source_field_missing", + frontmatter: map[string]any{}, + expected: "", + }, + { + name: "source_field_wrong_type", + frontmatter: map[string]any{ + "source": 123, + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := compiler.extractSource(tt.frontmatter) + if result != tt.expected { + t.Errorf("extractSource() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go index b6c722ecc3a..fe94b437375 100644 --- a/pkg/workflow/tools.go +++ b/pkg/workflow/tools.go @@ -62,7 +62,7 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) { // Clean up quoted keys - replace "on": with on: at the start of a line // This handles cases where YAML marshaling adds unnecessary quotes around reserved words like "on" - yamlStr = unquoteYAMLKey(yamlStr, "on") + yamlStr = UnquoteYAMLKey(yamlStr, "on") data.On = yamlStr } else { diff --git a/pkg/workflow/yaml.go b/pkg/workflow/yaml.go index 24911c63055..68b983d0053 100644 --- a/pkg/workflow/yaml.go +++ b/pkg/workflow/yaml.go @@ -4,11 +4,11 @@ import ( "regexp" ) -// unquoteYAMLKey removes quotes from a YAML key at the start of a line. +// UnquoteYAMLKey removes quotes from a YAML key at the start of a line. // This is necessary because yaml.Marshal adds quotes around reserved words like "on". // The function only replaces the quoted key if it appears at the start of a line // (optionally preceded by whitespace) to avoid replacing quoted strings in values. -func unquoteYAMLKey(yamlStr string, key string) string { +func UnquoteYAMLKey(yamlStr string, key string) string { // Create a regex pattern that matches the quoted key at the start of a line // Pattern: (start of line or newline) + (optional whitespace) + quoted key + colon pattern := `(^|\n)([ \t]*)"` + regexp.QuoteMeta(key) + `":` diff --git a/pkg/workflow/yaml_test.go b/pkg/workflow/yaml_test.go index f9caa1de703..143920eb23e 100644 --- a/pkg/workflow/yaml_test.go +++ b/pkg/workflow/yaml_test.go @@ -119,9 +119,9 @@ on: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := unquoteYAMLKey(tt.input, tt.key) + result := UnquoteYAMLKey(tt.input, tt.key) if result != tt.expected { - t.Errorf("unquoteYAMLKey() failed\nInput:\n%s\n\nExpected:\n%s\n\nGot:\n%s", + t.Errorf("UnquoteYAMLKey() failed\nInput:\n%s\n\nExpected:\n%s\n\nGot:\n%s", tt.input, tt.expected, result) } })