diff --git a/client.go b/client.go new file mode 100644 index 0000000..9898adb --- /dev/null +++ b/client.go @@ -0,0 +1,33 @@ +package sdk + +import ( + "fmt" + "net/http" + "net/url" +) + +func (a *Action) Client() *Client { + c := &Client{Client: &http.Client{}} + context := a.Context() + c.base = context.APIURL + c.token = fmt.Sprintf("Bearer %s", context.Token) + return c +} + +type Client struct { + *http.Client + base string + token string +} + +func (c *Client) Do(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", c.token) + if !req.URL.IsAbs() { + u, err := url.Parse(fmt.Sprintf("%s%s", c.base, req.URL)) + if err != nil { + return nil, err + } + req.URL = u + } + return c.Client.Do(req) +} diff --git a/context.go b/context.go index 76477d2..5f676c9 100644 --- a/context.go +++ b/context.go @@ -2,10 +2,8 @@ package sdk import ( "encoding/json" - "errors" "fmt" "os" - "strconv" ) // GitHubContext of current workflow. @@ -14,7 +12,7 @@ type GitHubContext struct { Action string `env:"GITHUB_ACTION"` ActionPath string `env:"GITHUB_ACTION_PATH"` ActionRepository string `env:"GITHUB_ACTION_REPOSITORY"` - Actions bool `env:"GITHUB_ACTIONS"` + Actions string `env:"GITHUB_ACTIONS"` Actor string `env:"GITHUB_ACTOR"` APIURL string `env:"GITHUB_API_URL,default=https://api.github.com"` BaseRef string `env:"GITHUB_BASE_REF"` @@ -27,34 +25,36 @@ type GitHubContext struct { Path string `env:"GITHUB_PATH"` Ref string `env:"GITHUB_REF"` RefName string `env:"GITHUB_REF_NAME"` - RefProtected bool `env:"GITHUB_REF_PROTECTED"` + RefProtected string `env:"GITHUB_REF_PROTECTED"` RefType string `env:"GITHUB_REF_TYPE"` Repository string `env:"GITHUB_REPOSITORY"` RepositoryOwner string `env:"GITHUB_REPOSITORY_OWNER"` - RetentionDays int64 `env:"GITHUB_RETENTION_DAYS"` - RunAttempt int64 `env:"GITHUB_RUN_ATTEMPT"` - RunID int64 `env:"GITHUB_RUN_ID"` - RunNumber int64 `env:"GITHUB_RUN_NUMBER"` + RetentionDays string `env:"GITHUB_RETENTION_DAYS"` + RunAttempt string `env:"GITHUB_RUN_ATTEMPT"` + RunID string `env:"GITHUB_RUN_ID"` + RunNumber string `env:"GITHUB_RUN_NUMBER"` ServerURL string `env:"GITHUB_SERVER_URL,default=https://github.com"` SHA string `env:"GITHUB_SHA"` StepSummary string `env:"GITHUB_STEP_SUMMARY"` Workflow string `env:"GITHUB_WORKFLOW"` Workspace string `env:"GITHUB_WORKSPACE"` + Token string `env:"GITHUB_TOKEN"` + // Event is populated by parsing the file at EventPath, if it exists. - Event map[string]any + event map[string]any } // Context returns the context of current action with the payload object // that triggered the workflow -func (c *Action) Context() (*GitHubContext, error) { - var merr error +func (c *Action) Context() *GitHubContext { context := &GitHubContext{ APIURL: "https://api.github.com", GraphqlURL: "https://api.github.com/graphql", ServerURL: "https://github.com", + event: map[string]any{}, } if v := c.env("GITHUB_ACTION"); v != "" { @@ -66,10 +66,8 @@ func (c *Action) Context() (*GitHubContext, error) { if v := c.env("GITHUB_ACTION_REPOSITORY"); v != "" { context.ActionRepository = v } - if v, err := parseBool(c.env("GITHUB_ACTIONS")); err == nil { + if v := c.env("GITHUB_ACTIONS"); v != "" { context.Actions = v - } else { - merr = errors.Join(merr, err) } if v := c.env("GITHUB_ACTOR"); v != "" { context.Actor = v @@ -107,41 +105,29 @@ func (c *Action) Context() (*GitHubContext, error) { if v := c.env("GITHUB_REF_NAME"); v != "" { context.RefName = v } - if v, err := parseBool(c.env("GITHUB_REF_PROTECTED")); err == nil { + if v := c.env("GITHUB_REF_PROTECTED"); v != "" { context.RefProtected = v - } else { - merr = errors.Join(merr, err) } if v := c.env("GITHUB_REF_TYPE"); v != "" { context.RefType = v } - if v := c.env("GITHUB_REPOSITORY"); v != "" { context.Repository = v } if v := c.env("GITHUB_REPOSITORY_OWNER"); v != "" { context.RepositoryOwner = v } - - if v, err := parseInt(c.env("GITHUB_RETENTION_DAYS")); err == nil { + if v := c.env("GITHUB_RETENTION_DAYS"); v != "" { context.RetentionDays = v - } else { - merr = errors.Join(merr, err) } - if v, err := parseInt(c.env("GITHUB_RUN_ATTEMPT")); err == nil { + if v := c.env("GITHUB_RUN_ATTEMPT"); v != "" { context.RunAttempt = v - } else { - merr = errors.Join(merr, err) } - if v, err := parseInt(c.env("GITHUB_RUN_ID")); err == nil { + if v := c.env("GITHUB_RUN_ID"); v != "" { context.RunID = v - } else { - merr = errors.Join(merr, err) } - if v, err := parseInt(c.env("GITHUB_RUN_NUMBER")); err == nil { + if v := c.env("GITHUB_RUN_NUMBER"); v != "" { context.RunNumber = v - } else { - merr = errors.Join(merr, err) } if v := c.env("GITHUB_SERVER_URL"); v != "" { context.ServerURL = v @@ -158,32 +144,24 @@ func (c *Action) Context() (*GitHubContext, error) { if v := c.env("GITHUB_WORKSPACE"); v != "" { context.Workspace = v } + if v := c.env("GITHUB_TOKEN"); v != "" { + context.Token = v + } - if context.EventPath != "" { - eventData, err := os.ReadFile(context.EventPath) + return context +} + +func (c *GitHubContext) Event() (map[string]any, error) { + if c.EventPath != "" { + eventData, err := os.ReadFile(c.EventPath) if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("could not read event file: %w", err) } if eventData != nil { - if err := json.Unmarshal(eventData, &context.Event); err != nil { + if err := json.Unmarshal(eventData, &c.event); err != nil { return nil, fmt.Errorf("failed to unmarshal event payload: %w", err) } } } - - return context, merr -} - -func parseBool(v string) (bool, error) { - if v == "" { - return false, nil - } - return strconv.ParseBool(v) -} - -func parseInt(v string) (int64, error) { - if v == "" { - return 0, nil - } - return strconv.ParseInt(v, 10, 64) + return c.event, nil } diff --git a/context_test.go b/context_test.go index f261d42..89f3ab2 100644 --- a/context_test.go +++ b/context_test.go @@ -37,6 +37,7 @@ func TestAction_Context(t *testing.T) { APIURL: "https://api.github.com", ServerURL: "https://github.com", GraphqlURL: "https://api.github.com/graphql", + event: map[string]any{}, }, }, { @@ -70,12 +71,13 @@ func TestAction_Context(t *testing.T) { "GITHUB_STEP_SUMMARY": "/path/to/summary", "GITHUB_WORKFLOW": "test", "GITHUB_WORKSPACE": "/path/to/workspace", + "GITHUB_TOKEN": "somerandomtoken", }, exp: &GitHubContext{ Action: "__repo-owner_name-of-action-repo", ActionPath: "/path/to/action", ActionRepository: "repo-owner/name-of-action-repo", - Actions: true, + Actions: "true", Actor: "sethvargo", APIURL: "https://foo.com", BaseRef: "main", @@ -88,19 +90,21 @@ func TestAction_Context(t *testing.T) { Path: "/path/to/path", Ref: "refs/tags/v1.0", RefName: "v1.0", - RefProtected: true, + RefProtected: "true", RefType: "tag", Repository: "sethvargo/baz", RepositoryOwner: "sethvargo", - RetentionDays: 90, - RunAttempt: 6, - RunID: 56, - RunNumber: 34, + RetentionDays: "90", + RunAttempt: "6", + RunID: "56", + RunNumber: "34", ServerURL: "https://bar.com", SHA: "abcd1234", StepSummary: "/path/to/summary", Workflow: "test", Workspace: "/path/to/workspace", + Token: "somerandomtoken", + event: map[string]any{}, }, }, { @@ -116,7 +120,7 @@ func TestAction_Context(t *testing.T) { ServerURL: "https://github.com", GraphqlURL: "https://api.github.com/graphql", - Event: map[string]any{ + event: map[string]any{ "foo": "bar", }, }, @@ -131,7 +135,8 @@ func TestAction_Context(t *testing.T) { a := New() a.env = func(s string) string { return tc.env[s] } - got, err := a.Context() + got := a.Context() + _, err := got.Event() if err != nil { t.Fatal(err) } diff --git a/go.mod b/go.mod index 5f045b8..f788a0e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module git.geekeey.de/actions/sdk +module code.geekeey.de/actions/sdk go 1.22.5 diff --git a/iox/globfs.go b/iox/globfs.go new file mode 100644 index 0000000..91240cb --- /dev/null +++ b/iox/globfs.go @@ -0,0 +1,99 @@ +package iox + +import ( + "io/fs" + "os" + "path" +) + +type GlobFS struct { + base fs.FS + patterns []string +} + +// NewGlobFS creates a new GlobFS that exposes only files matching any of the given glob patterns. +func NewGlobFS(base fs.FS, patterns ...string) *GlobFS { + return &GlobFS{base: base, patterns: patterns} +} + +// match reports whether the given path matches any of the configured patterns. +func (g *GlobFS) match(name string) bool { + for _, pat := range g.patterns { + if matched, _ := path.Match(pat, name); matched { + return true + } + } + return false +} + +func (g *GlobFS) contains(dir string) bool { + queue := []string{dir} + visited := make(map[string]struct{}) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] // dequeue + + // Prevent visiting same dir multiple times + if _, seen := visited[current]; seen { + continue + } + visited[current] = struct{}{} + + entries, err := fs.ReadDir(g.base, current) + if err != nil { + continue + } + for _, entry := range entries { + rel := path.Join(current, entry.Name()) + if g.match(rel) { + return true + } + if entry.IsDir() { + queue = append(queue, rel) + } + } + } + return false +} + +func (g *GlobFS) Open(name string) (fs.File, error) { + if g.match(name) { + return g.base.Open(name) + } + + fi, err := fs.Stat(g.base, name) + if err != nil || !fi.IsDir() { + return nil, fs.ErrNotExist + } + if g.contains(name) { + return g.base.Open(name) + } + return nil, fs.ErrNotExist +} + +func (g *GlobFS) ReadDir(name string) ([]fs.DirEntry, error) { + if g.match(name) { + return fs.ReadDir(g.base, name) + } + + entries, err := fs.ReadDir(g.base, name) + if err != nil { + return nil, err + } + var children []fs.DirEntry + for _, entry := range entries { + rel := path.Join(name, entry.Name()) + if g.match(rel) { + children = append(children, entry) + } + if entry.IsDir() && g.contains(rel) { + children = append(children, entry) + } + } + + if len(children) == 0 { + return nil, os.ErrNotExist + } + + return children, nil +} diff --git a/iox/globfs_test.go b/iox/globfs_test.go new file mode 100644 index 0000000..e548a2a --- /dev/null +++ b/iox/globfs_test.go @@ -0,0 +1,234 @@ +package iox + +import ( + "io/fs" + "reflect" + "sort" + "testing" + "testing/fstest" +) + +func setupFS() fs.ReadDirFS { + // Create an in-memory FS with a mix of files and directories + return fstest.MapFS{ + "main.go": &fstest.MapFile{Data: []byte("package main")}, + "main_test.go": &fstest.MapFile{Data: []byte("package main_test")}, + "README.md": &fstest.MapFile{Data: []byte("# readme")}, + "LICENSE": &fstest.MapFile{Data: []byte("MIT")}, + "docs/guide.md": &fstest.MapFile{Data: []byte("Docs")}, + "docs/other.txt": &fstest.MapFile{Data: []byte("Other")}, + "docs/hidden/.keep": &fstest.MapFile{Data: []byte("")}, + "assets/img.png": &fstest.MapFile{Data: []byte("PNG")}, + "assets/style.css": &fstest.MapFile{Data: []byte("CSS")}, + ".gitignore": &fstest.MapFile{Data: []byte("*.log")}, + ".hiddenfile": &fstest.MapFile{Data: []byte("")}, + "emptydir/": &fstest.MapFile{Mode: fs.ModeDir}, + } +} + +// helper to get base names for easier comparison +func basenames(entries []fs.DirEntry) []string { + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + sort.Strings(names) + return names +} + +func TestGlobFSMultiplePatterns(t *testing.T) { + memfs := setupFS() + gfs := NewGlobFS(memfs, "*.go", "*.md", "assets/*", "docs/guide.md", ".gitignore") + + tests := []struct { + path string + want []string + wantErr bool + }{ + {path: ".", want: []string{"README.md", "assets", "docs", "main.go", "main_test.go", ".gitignore"}}, + {path: "assets", want: []string{"img.png", "style.css"}}, + {path: "docs", want: []string{"guide.md"}}, + {path: "docs/hidden", want: []string{}, wantErr: true}, + {path: "emptydir", want: []string{}, wantErr: true}, + } + + for _, tc := range tests { + tc := tc // capture range variable + t.Run(tc.path, func(t *testing.T) { + entries, err := fs.ReadDir(gfs, tc.path) + if tc.wantErr && err == nil { + t.Errorf("expected error, got nil") + return + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + return + } + got := basenames(entries) + sort.Strings(tc.want) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %v; want %v", got, tc.want) + } + }) + } +} + +func TestGlobFSOpen(t *testing.T) { + memfs := setupFS() + gfs := NewGlobFS(memfs, "*.go", "*.md", "assets/*", "docs/guide.md", ".gitignore") + + type test struct { + path string + wantErr bool + } + tests := []test{ + {path: "main.go"}, + {path: "README.md"}, + {path: "LICENSE", wantErr: true}, + {path: "assets/img.png"}, + {path: "assets/style.css"}, + {path: "assets/nonexistent.png", wantErr: true}, + {path: "docs/guide.md"}, + {path: "docs/other.txt", wantErr: true}, + {path: ".gitignore"}, + {path: ".hiddenfile", wantErr: true}, + {path: "docs/hidden/.keep", wantErr: true}, + {path: "emptydir", wantErr: true}, + {path: "docs"}, // allowed because it contains matching file(s) + {path: "assets"}, // allowed because it contains matching file(s) + } + for _, tc := range tests { + tc := tc + t.Run(tc.path, func(t *testing.T) { + f, err := gfs.Open(tc.path) + if tc.wantErr && err == nil { + t.Errorf("expected error, got file") + if f != nil { + f.Close() + } + } else if !tc.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } else if !tc.wantErr && err == nil { + info, _ := f.Stat() + if info.IsDir() { + _, derr := fs.ReadDir(gfs, tc.path) + if derr != nil && !tc.wantErr { + t.Errorf("unexpected error: %v", derr) + } + } + f.Close() + } + }) + } +} + +func TestGlobFSReadFile(t *testing.T) { + memfs := setupFS() + gfs := NewGlobFS(memfs, "*.go", "*.md", "assets/*", ".gitignore") + + tests := []struct { + name string + want []byte + wantErr bool + }{ + {name: "main.go", want: []byte("package main")}, + {name: "main_test.go", want: []byte("package main_test")}, + {name: "README.md", want: []byte("# readme")}, + {name: "assets/img.png", want: []byte("PNG")}, + {name: "assets/style.css", want: []byte("CSS")}, + {name: ".gitignore", want: []byte("*.log")}, + {name: "LICENSE", wantErr: true}, // not allowed by filter + {name: "docs/guide.md", wantErr: true}, // not allowed by filter + {name: "docs/hidden/.keep", wantErr: true}, // not allowed by filter + {name: "doesnotexist.txt", wantErr: true}, // does not exist + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got, err := fs.ReadFile(gfs, tc.name) + if tc.wantErr { + if err == nil { + t.Errorf("expected error, got nil (got=%q)", got) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if string(got) != string(tc.want) { + t.Errorf("got %q; want %q", got, tc.want) + } + } + }) + } +} + +func TestGlobFSRelativePaths(t *testing.T) { + memfs := setupFS() + gfs := NewGlobFS(memfs, "docs/*.md") + entries, err := fs.ReadDir(gfs, "docs") + if err != nil { + t.Fatal(err) + } + got := basenames(entries) + want := []string{"guide.md"} + if !reflect.DeepEqual(got, want) { + t.Errorf("docs/*.md: got %v, want %v", got, want) + } +} + +func TestGlobFSNoMatchesOpen(t *testing.T) { + gfs := NewGlobFS(setupFS(), "*.xyz") + _, err := gfs.Open("main.go") + if err == nil { + t.Fatal("expected error when opening file with no matches") + } +} + +func TestGlobFSNoMatchesStat(t *testing.T) { + gfs := NewGlobFS(setupFS(), "*.xyz") + _, err := fs.Stat(gfs, "main.go") + if err == nil { + t.Fatal("expected error with no matches: stat") + } +} + +func TestGlobFSNoMatchesReadDir(t *testing.T) { + gfs := NewGlobFS(setupFS(), "*.xyz") + _, err := fs.ReadDir(gfs, "main.go") + if err == nil { + t.Fatal("expected error with no matches: readdir") + } +} + +func TestGlobFSNoMatchesReadFile(t *testing.T) { + gfs := NewGlobFS(setupFS(), "*.xyz") + _, err := fs.ReadFile(gfs, "main.go") + if err == nil { + t.Fatal("expected error with no matches: readfile") + } +} + +func TestGlobFS_IntegrationWithStdlib(t *testing.T) { + memfs := setupFS() + gfs := NewGlobFS(memfs, "*.go", "docs/guide.md") + // Use fs.WalkDir with our filtered FS + var walked []string + err := fs.WalkDir(gfs, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + walked = append(walked, path) + return nil + }) + if err != nil { + t.Fatal(err) + } + // Only files and dirs matching or containing matches should appear + for _, p := range walked { + if p == "." || p == "main.go" || p == "main_test.go" || p == "docs" || p == "docs/guide.md" { + continue + } + t.Errorf("WalkDir: unexpected path %q", p) + } +}