Repository navigation
Add aw.json project support to the add command #58267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
ffb974f
284d146
96f429d
64aa0db
ce23867
3abc5fb
6963778
0315be0
74d5a5e
75ff0bb
76041f7
4e8d914
0e5dd8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
|
|
||
| "github.com/github/gh-aw/pkg/constants" | ||
| "github.com/github/gh-aw/pkg/fileutil" | ||
| "github.com/github/gh-aw/pkg/workflow" | ||
| ) | ||
|
|
||
| func mergeProjectFileWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, gitRoot string) error { | ||
| destFile := filepath.Join(gitRoot, workflow.RepoConfigFileName) | ||
|
|
||
| existing := []byte("{}") | ||
| fileExists := fileutil.FileExists(destFile) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Merging a package aw.json blindly overwrites any existing repository-owned value in 💡 Why this should block merge
A safer approach is to apply the same overwrite gate before merging, or at minimum fail when |
||
| if fileExists { | ||
| var err error | ||
| existing, err = os.ReadFile(filepath.Clean(destFile)) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to read project file %q: %w", workflow.RepoConfigFileName, err) | ||
| } | ||
| } | ||
|
|
||
| merged, err := mergeProjectJSON(existing, resolved.Content) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to merge package project file %q: %w", resolved.Spec.WorkflowPath, err) | ||
| } | ||
| if err := validateMergedProjectJSON(merged); err != nil { | ||
| return fmt.Errorf("added package project settings are invalid: %w", err) | ||
| } | ||
| if err := os.MkdirAll(filepath.Dir(destFile), constants.DirPermPublic); err != nil { | ||
| return fmt.Errorf("failed to create project file directory: %w", err) | ||
| } | ||
|
|
||
| if tracker != nil { | ||
| if fileExists { | ||
| tracker.TrackModified(destFile) | ||
| } else { | ||
| tracker.TrackCreated(destFile) | ||
| } | ||
| } | ||
|
Comment on lines
+47
to
+60
|
||
| if err := os.WriteFile(destFile, merged, constants.FilePermPublic); err != nil { | ||
| return fmt.Errorf("failed to write project file %q: %w", workflow.RepoConfigFileName, err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func validateMergedProjectJSON(merged []byte) error { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pkg/cli/add_project_file.go:51-66: shrink: temp-directory round-trip only to validate merged aw.json. Validate the merged JSON in memory and drop the temp-file scaffolding. |
||
| tempRoot, err := os.MkdirTemp("", "gh-aw-project-config-*") | ||
| if err != nil { | ||
| return fmt.Errorf("failed to create temporary project config directory: %w", err) | ||
| } | ||
| defer os.RemoveAll(tempRoot) | ||
| configPath := filepath.Join(tempRoot, workflow.RepoConfigFileName) | ||
| if err := os.MkdirAll(filepath.Dir(configPath), constants.DirPermPublic); err != nil { | ||
| return fmt.Errorf("failed to create temporary project config directory: %w", err) | ||
| } | ||
| if err := os.WriteFile(configPath, merged, constants.FilePermPublic); err != nil { | ||
| return fmt.Errorf("failed to write temporary project config: %w", err) | ||
| } | ||
| _, err = workflow.LoadRepoConfig(tempRoot) | ||
| return err | ||
| } | ||
|
|
||
| func mergeProjectJSON(existing, added []byte) ([]byte, error) { | ||
| var existingSettings map[string]any | ||
| if err := json.Unmarshal(existing, &existingSettings); err != nil { | ||
| return nil, fmt.Errorf("target project file is not valid JSON: %w", err) | ||
| } | ||
| var addedSettings map[string]any | ||
| if err := json.Unmarshal(added, &addedSettings); err != nil { | ||
|
|
||
| return nil, fmt.Errorf("added project file is not valid JSON: %w", err) | ||
| } | ||
| if existingSettings == nil || addedSettings == nil { | ||
| return nil, errors.New("project files must contain JSON objects") | ||
| } | ||
| mergeProjectSettings(existingSettings, addedSettings) | ||
| merged, err := json.MarshalIndent(existingSettings, "", " ") | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to encode merged project file: %w", err) | ||
| } | ||
| return append(merged, '\n'), nil | ||
| } | ||
|
|
||
| func mergeProjectSettings(target, added map[string]any) { | ||
| for key, addedValue := range added { | ||
| addedObject, addedIsObject := addedValue.(map[string]any) | ||
| targetObject, targetIsObject := target[key].(map[string]any) | ||
| if addedIsObject && targetIsObject { | ||
| mergeProjectSettings(targetObject, addedObject) | ||
| continue | ||
| } | ||
| target[key] = addedValue | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/github/gh-aw/pkg/workflow" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestMergeProjectJSONAddedSettingsWin(t *testing.T) { | ||
| existing := []byte(`{ | ||
| "utc": "-08:00", | ||
| "help_command": true, | ||
| "maintenance": { | ||
| "runs_on": "self-hosted", | ||
| "label_triggers": false | ||
| }, | ||
| "action_pins": { | ||
| "actions/checkout@v4": "internal/checkout@v4" | ||
| } | ||
| }`) | ||
| added := []byte(`{ | ||
| "utc": "+01:00", | ||
| "maintenance": { | ||
| "label_triggers": true | ||
| }, | ||
| "action_pins": { | ||
| "actions/setup-go@v5": "internal/setup-go@v5" | ||
| } | ||
| }`) | ||
|
|
||
| merged, err := mergeProjectJSON(existing, added) | ||
| require.NoError(t, err) | ||
|
|
||
| var settings map[string]any | ||
| require.NoError(t, json.Unmarshal(merged, &settings)) | ||
| assert.Equal(t, "+01:00", settings["utc"]) | ||
| assert.Equal(t, true, settings["help_command"]) | ||
| assert.Equal(t, map[string]any{ | ||
| "runs_on": "self-hosted", | ||
| "label_triggers": true, | ||
| }, settings["maintenance"]) | ||
| assert.Equal(t, map[string]any{ | ||
| "actions/checkout@v4": "internal/checkout@v4", | ||
| "actions/setup-go@v5": "internal/setup-go@v5", | ||
| }, settings["action_pins"]) | ||
| } | ||
|
|
||
| func TestMergeProjectJSONRequiresObjects(t *testing.T) { | ||
| _, err := mergeProjectJSON([]byte(`{}`), []byte(`[]`)) | ||
| require.ErrorContains(t, err, "added project file is not valid JSON") | ||
| } | ||
|
|
||
| func TestMergeProjectFileWithTracking(t *testing.T) { | ||
| gitRoot := t.TempDir() | ||
| destFile := filepath.Join(gitRoot, workflow.RepoConfigFileName) | ||
| require.NoError(t, os.MkdirAll(filepath.Dir(destFile), 0o755)) | ||
| require.NoError(t, os.WriteFile(destFile, []byte("{\"utc\":\"-08:00\",\"help_command\":true}\n"), 0o644)) | ||
|
|
||
| resolved := &ResolvedWorkflow{ | ||
| Spec: &WorkflowSpec{ | ||
| WorkflowPath: "aw.json", | ||
| DestinationPath: workflow.RepoConfigFileName, | ||
| IsPackageResourceFile: true, | ||
| }, | ||
| Content: []byte(`{"utc":"+01:00"}`), | ||
| IsPackageProjectFile: true, | ||
| } | ||
| tracker := NewFileTracker() | ||
|
|
||
| require.NoError(t, mergeProjectFileWithTracking(resolved, tracker, gitRoot)) | ||
| merged, err := os.ReadFile(destFile) | ||
| require.NoError(t, err) | ||
| assert.JSONEq(t, `{"utc":"+01:00","help_command":true}`, string(merged)) | ||
| } | ||
|
|
||
| func TestMergeProjectFileRejectsInvalidSettingsWithoutWriting(t *testing.T) { | ||
| gitRoot := t.TempDir() | ||
| destFile := filepath.Join(gitRoot, workflow.RepoConfigFileName) | ||
| require.NoError(t, os.MkdirAll(filepath.Dir(destFile), 0o755)) | ||
| original := []byte("{\"utc\":\"-08:00\"}\n") | ||
| require.NoError(t, os.WriteFile(destFile, original, 0o644)) | ||
|
|
||
| resolved := &ResolvedWorkflow{ | ||
| Spec: &WorkflowSpec{WorkflowPath: "aw.json"}, | ||
| Content: []byte(`{"utc":"invalid"}`), | ||
| } | ||
| err := mergeProjectFileWithTracking(resolved, NewFileTracker(), gitRoot) | ||
| require.Error(t, err) | ||
|
|
||
| actual, readErr := os.ReadFile(destFile) | ||
| require.NoError(t, readErr) | ||
| assert.Equal(t, original, actual) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mergeProjectFileWithTrackingunconditionally merges into an existingaw.json, unlike every sibling resource writer in this file (addResourceFileWithTracking,addActionWorkflowWithTracking,addSkillFileWithTracking,addAgentFileWithTracking), which all gate overwrites behindif fileExists && !opts.Force { ... }and emit verbose/quiet messages. Here,optsisn't even passed tomergeProjectFileWithTracking(its signature isfunc mergeProjectFileWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, gitRoot string) error), so there's no way to respect--force, and no console output at all when an existing project file is modified.Practically this means running
gh aw add owner/repoagainst a package that ships anaw.jsonwill silently rewrite the user's local.github/workflows/aw.jsonsettings even without--force, with zero indication that anything changed. That's inconsistent with the rest of theaddcommand's overwrite-protection model and could surprise users who expect--forceto gate all destination file writes.Recommend: thread
opts AddOptionsthrough tomergeProjectFileWithTracking, and either (a) skip/error on conflicting existing settings unlessopts.Forceis set (matching sibling behavior), or if merging without--forceis intentional, at least emit a verbose/quiet message so the user knowsaw.jsonwas modified.@copilot please address this.