From 8fa9c3b55b8cecb2524e0cf299ef8b6db20dae5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:15:22 +0000 Subject: [PATCH 1/3] Initial plan From f9b43217acc97f1079b3d83a543cfa85c5a6cd8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:28:48 +0000 Subject: [PATCH 2/3] Add format-json option to repo-memory for pretty-printing JSON files Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> --- actions/setup/js/push_repo_memory.cjs | 45 +++++++ .../src/content/docs/reference/repo-memory.md | 3 + pkg/parser/schemas/main_workflow_schema.json | 8 ++ pkg/workflow/repo_memory.go | 18 +++ pkg/workflow/repo_memory_test.go | 127 ++++++++++++++++++ 5 files changed, 201 insertions(+) diff --git a/actions/setup/js/push_repo_memory.cjs b/actions/setup/js/push_repo_memory.cjs index 50e7ec4ce70..41641a5cc35 100644 --- a/actions/setup/js/push_repo_memory.cjs +++ b/actions/setup/js/push_repo_memory.cjs @@ -48,6 +48,7 @@ async function main() { const maxFileCount = parseInt(process.env.MAX_FILE_COUNT || "100", 10); const maxPatchSize = parseInt(process.env.MAX_PATCH_SIZE || "10240", 10); const fileGlobFilter = process.env.FILE_GLOB_FILTER || ""; + const formatJSON = process.env.FORMAT_JSON === "true"; // Parse allowed extensions with error handling let allowedExtensions = [".json", ".jsonl", ".txt", ".md", ".csv"]; @@ -74,6 +75,7 @@ async function main() { core.info(` ALLOWED_EXTENSIONS: ${JSON.stringify(allowedExtensions)}`); core.info(` FILE_GLOB_FILTER: ${fileGlobFilter ? `"${fileGlobFilter}"` : "(empty - all files accepted)"}`); core.info(` FILE_GLOB_FILTER length: ${fileGlobFilter.length}`); + core.info(` FORMAT_JSON: ${formatJSON}`); /** @param {unknown} value */ function isPlainObject(value) { @@ -359,6 +361,49 @@ async function main() { } } + // Format JSON files if requested + if (formatJSON) { + core.info("FORMAT_JSON is enabled: formatting .json files as human-readable..."); + + /** + * Recursively find and format all .json files under a directory + * @param {string} dirPath - Directory to scan + */ + function formatJSONFilesInDir(dirPath) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + if (entry.name !== ".git") { + formatJSONFilesInDir(fullPath); + } + } else if (entry.isFile() && entry.name.endsWith(".json")) { + try { + const raw = fs.readFileSync(fullPath, "utf8"); + if (!raw.trim()) { + continue; + } + const parsed = JSON.parse(raw); + const formatted = JSON.stringify(parsed, null, 2) + "\n"; + if (raw !== formatted) { + fs.writeFileSync(fullPath, formatted, "utf8"); + core.info(`Formatted JSON: ${path.relative(destMemoryPath, fullPath)}`); + } + } catch (/** @type {any} */ error) { + core.warning(`Skipping JSON formatting for ${path.relative(destMemoryPath, fullPath)}: ${error.message}`); + } + } + } + } + + try { + formatJSONFilesInDir(destMemoryPath); + } catch (error) { + core.setFailed(`Failed to format JSON files: ${getErrorMessage(error)}`); + return; + } + } + // Check if we have any changes to commit let changedFileCount = 0; try { diff --git a/docs/src/content/docs/reference/repo-memory.md b/docs/src/content/docs/reference/repo-memory.md index a0c474888fd..a10751c7ca9 100644 --- a/docs/src/content/docs/reference/repo-memory.md +++ b/docs/src/content/docs/reference/repo-memory.md @@ -34,6 +34,7 @@ tools: target-repo: "owner/repository" create-orphan: true # default allowed-extensions: [".json", ".txt", ".md"] # Restrict file types (default: empty/all files allowed) + format-json: true # Pretty-print .json files (default: false) --- ``` @@ -41,6 +42,8 @@ tools: **File Type Restrictions**: Use `allowed-extensions` to restrict which file types can be stored (default: empty/all files allowed). When specified, only files with listed extensions (e.g., `[".json", ".txt", ".md"]`) can be saved. Files with disallowed extensions will trigger validation failures. +**JSON Formatting**: Use `format-json: true` to automatically pretty-print all `.json` files (2-space indent, trailing newline) before they are committed. This makes JSON memory files human-readable in the repository and easier to review and edit manually. Invalid JSON files are skipped with a warning. This option has no effect on `.jsonl` or other file types. + **Patch Size Limit**: Use `max-patch-size` to limit the total size of changes in a single push (default: 10KB, max: 1MB). The total size of the git diff (all staged changes combined) must not exceed this value. If it does, the push is rejected with an error. Use this to prevent large unintentional memory updates. **Note**: File glob patterns are matched against the **relative file path** within the artifact directory, not the branch path. Use bare extension patterns like `*.json` or `*.md` — do **not** include the branch name (e.g. `memory/custom-agent-for-aw/*.json` is incorrect). diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index b5040a2d38e..6590ca6d25b 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4606,6 +4606,10 @@ "type": "string" }, "description": "List of allowed file extensions (e.g., [\".json\", \".txt\"]). Default: [\".json\", \".jsonl\", \".txt\", \".md\", \".csv\"]" + }, + "format-json": { + "type": "boolean", + "description": "When true, all .json files are pretty-printed (2-space indent) before being committed, making them human-readable in the repository (default: false)" } }, "additionalProperties": false, @@ -4697,6 +4701,10 @@ "type": "string" }, "description": "List of allowed file extensions (e.g., [\".json\", \".txt\"]). Default: [\".json\", \".jsonl\", \".txt\", \".md\", \".csv\"]" + }, + "format-json": { + "type": "boolean", + "description": "When true, all .json files are pretty-printed (2-space indent) before being committed, making them human-readable in the repository (default: false)" } }, "additionalProperties": false diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index c25d6eb5cdd..abc07017425 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -56,6 +56,7 @@ type RepoMemoryEntry struct { CreateOrphan bool `yaml:"create-orphan,omitempty"` // create orphaned branch if missing (default: true) AllowedExtensions []string `yaml:"allowed-extensions,omitempty"` // allowed file extensions (default: [".json", ".jsonl", ".txt", ".md", ".csv"]) Wiki bool `yaml:"wiki,omitempty"` // use the GitHub Wiki git repository instead of the regular repo + FormatJSON bool `yaml:"format-json,omitempty"` // pretty-print all .json files before committing (default: false) } // RepoMemoryToolConfig represents the configuration for repo-memory in tools @@ -308,6 +309,13 @@ func (c *Compiler) extractRepoMemoryConfig(toolsConfig *ToolsConfig, workflowID entry.AllowedExtensions = constants.DefaultAllowedMemoryExtensions } + // Parse format-json field + if formatJSON, exists := memoryMap["format-json"]; exists { + if formatJSONBool, ok := formatJSON.(bool); ok { + entry.FormatJSON = formatJSONBool + } + } + config.Memories = append(config.Memories, entry) } } @@ -465,6 +473,13 @@ func (c *Compiler) extractRepoMemoryConfig(toolsConfig *ToolsConfig, workflowID entry.AllowedExtensions = constants.DefaultAllowedMemoryExtensions } + // Parse format-json field + if formatJSON, exists := configMap["format-json"]; exists { + if formatJSONBool, ok := formatJSON.(bool); ok { + entry.FormatJSON = formatJSONBool + } + } + config.Memories = []RepoMemoryEntry{entry} return config, nil } @@ -720,6 +735,9 @@ func (c *Compiler) buildPushRepoMemoryJob(data *WorkflowData, threatDetectionEna // Quote the value to prevent YAML alias interpretation of patterns like *.md fmt.Fprintf(&step, " FILE_GLOB_FILTER: \"%s\"\n", fileGlobFilter) } + if memory.FormatJSON { + step.WriteString(" FORMAT_JSON: 'true'\n") + } step.WriteString(" with:\n") step.WriteString(" script: |\n") diff --git a/pkg/workflow/repo_memory_test.go b/pkg/workflow/repo_memory_test.go index a3d65acb861..a53841e54f1 100644 --- a/pkg/workflow/repo_memory_test.go +++ b/pkg/workflow/repo_memory_test.go @@ -1417,3 +1417,130 @@ func TestPushRepoMemoryJobConditionGatesOnAgentNotSkipped(t *testing.T) { "Condition should NOT use != 'skipped' for agent check") }) } + +// TestRepoMemoryFormatJSONObjectConfig tests that format-json is parsed in object notation +func TestRepoMemoryFormatJSONObjectConfig(t *testing.T) { + toolsMap := map[string]any{ + "repo-memory": map[string]any{ + "branch-name": "memory/notes", + "format-json": true, + }, + } + + toolsConfig, err := ParseToolsConfig(toolsMap) + require.NoError(t, err, "Failed to parse tools config") + + compiler := NewCompiler() + config, err := compiler.extractRepoMemoryConfig(toolsConfig, "") + require.NoError(t, err, "Failed to extract repo-memory config") + require.NotNil(t, config) + require.Len(t, config.Memories, 1) + + memory := config.Memories[0] + assert.True(t, memory.FormatJSON, "Expected format-json to be true") +} + +// TestRepoMemoryFormatJSONObjectConfigFalse tests that format-json defaults to false in object notation +func TestRepoMemoryFormatJSONObjectConfigFalse(t *testing.T) { + toolsMap := map[string]any{ + "repo-memory": map[string]any{ + "branch-name": "memory/notes", + }, + } + + toolsConfig, err := ParseToolsConfig(toolsMap) + require.NoError(t, err, "Failed to parse tools config") + + compiler := NewCompiler() + config, err := compiler.extractRepoMemoryConfig(toolsConfig, "") + require.NoError(t, err, "Failed to extract repo-memory config") + require.NotNil(t, config) + require.Len(t, config.Memories, 1) + + memory := config.Memories[0] + assert.False(t, memory.FormatJSON, "Expected format-json to be false by default") +} + +// TestRepoMemoryFormatJSONArrayConfig tests that format-json is parsed in array notation +func TestRepoMemoryFormatJSONArrayConfig(t *testing.T) { + toolsMap := map[string]any{ + "repo-memory": []any{ + map[string]any{ + "id": "notes", + "branch-name": "memory/notes", + "format-json": true, + }, + map[string]any{ + "id": "logs", + "branch-name": "memory/logs", + }, + }, + } + + toolsConfig, err := ParseToolsConfig(toolsMap) + require.NoError(t, err, "Failed to parse tools config") + + compiler := NewCompiler() + config, err := compiler.extractRepoMemoryConfig(toolsConfig, "") + require.NoError(t, err, "Failed to extract repo-memory config") + require.NotNil(t, config) + require.Len(t, config.Memories, 2) + + assert.True(t, config.Memories[0].FormatJSON, "Expected notes memory to have format-json=true") + assert.False(t, config.Memories[1].FormatJSON, "Expected logs memory to have format-json=false by default") +} + +// TestRepoMemoryFormatJSONPushStepEnvVar tests that FORMAT_JSON env var is emitted in push steps +func TestRepoMemoryFormatJSONPushStepEnvVar(t *testing.T) { + t.Run("format-json=true emits FORMAT_JSON env var", func(t *testing.T) { + config := &RepoMemoryConfig{ + Memories: []RepoMemoryEntry{ + { + ID: "default", + BranchName: "memory/default", + MaxFileSize: 102400, + MaxFileCount: 100, + MaxPatchSize: 10240, + CreateOrphan: true, + FormatJSON: true, + }, + }, + } + data := &WorkflowData{RepoMemoryConfig: config} + + compiler := NewCompiler() + pushJob, err := compiler.buildPushRepoMemoryJob(data, false) + require.NoError(t, err) + require.NotNil(t, pushJob) + + pushJobOutput := strings.Join(pushJob.Steps, "\n") + assert.Contains(t, pushJobOutput, "FORMAT_JSON: 'true'", + "Push step should include FORMAT_JSON env var when format-json is true") + }) + + t.Run("format-json=false omits FORMAT_JSON env var", func(t *testing.T) { + config := &RepoMemoryConfig{ + Memories: []RepoMemoryEntry{ + { + ID: "default", + BranchName: "memory/default", + MaxFileSize: 102400, + MaxFileCount: 100, + MaxPatchSize: 10240, + CreateOrphan: true, + FormatJSON: false, + }, + }, + } + data := &WorkflowData{RepoMemoryConfig: config} + + compiler := NewCompiler() + pushJob, err := compiler.buildPushRepoMemoryJob(data, false) + require.NoError(t, err) + require.NotNil(t, pushJob) + + pushJobOutput := strings.Join(pushJob.Steps, "\n") + assert.NotContains(t, pushJobOutput, "FORMAT_JSON", + "Push step should NOT include FORMAT_JSON env var when format-json is false") + }) +} From d0fadadd6fbc7d629d240aa729b9d4215cad5537 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:17:50 +0000 Subject: [PATCH 3/3] fix(repo-memory): enforce max size after json formatting Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_repo_memory.cjs | 9 +++++++++ actions/setup/js/push_repo_memory.test.cjs | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/actions/setup/js/push_repo_memory.cjs b/actions/setup/js/push_repo_memory.cjs index 41641a5cc35..46b2d3ec758 100644 --- a/actions/setup/js/push_repo_memory.cjs +++ b/actions/setup/js/push_repo_memory.cjs @@ -386,10 +386,19 @@ async function main() { const parsed = JSON.parse(raw); const formatted = JSON.stringify(parsed, null, 2) + "\n"; if (raw !== formatted) { + const formattedSize = Buffer.byteLength(formatted, "utf8"); + if (formattedSize > maxFileSize) { + const sizeError = new Error(`Formatted JSON exceeds MAX_FILE_SIZE: ${path.relative(destMemoryPath, fullPath)} (${formattedSize} bytes > ${maxFileSize} bytes)`); + sizeError.name = "FormatJSONSizeLimitError"; + throw sizeError; + } fs.writeFileSync(fullPath, formatted, "utf8"); core.info(`Formatted JSON: ${path.relative(destMemoryPath, fullPath)}`); } } catch (/** @type {any} */ error) { + if (error?.name === "FormatJSONSizeLimitError") { + throw error; + } core.warning(`Skipping JSON formatting for ${path.relative(destMemoryPath, fullPath)}: ${error.message}`); } } diff --git a/actions/setup/js/push_repo_memory.test.cjs b/actions/setup/js/push_repo_memory.test.cjs index 2f544ea3a3b..da845acffc2 100644 --- a/actions/setup/js/push_repo_memory.test.cjs +++ b/actions/setup/js/push_repo_memory.test.cjs @@ -1459,6 +1459,17 @@ describe("push_repo_memory.cjs - changed-file limit checks", () => { expect(scriptContent).not.toContain("if (filesToCopy.length > maxFileCount)"); expect(scriptContent).toContain("if (changedFileCount > maxFileCount)"); }); + + it("should fail when formatting expands a JSON file beyond MAX_FILE_SIZE (source check)", () => { + const nodeFs = require("fs"); + const nodePath = require("path"); + const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs"); + const scriptContent = nodeFs.readFileSync(scriptPath, "utf8"); + + expect(scriptContent).toContain('Buffer.byteLength(formatted, "utf8")'); + expect(scriptContent).toContain("Formatted JSON exceeds MAX_FILE_SIZE"); + expect(scriptContent).toContain("FormatJSONSizeLimitError"); + }); }); // ──────────────────────────────────────────────────────────────────────────────