package github import ( "context" "encoding/base64" "encoding/json" "fmt" "net/url" "strings" "gitea.weiker.me/rodin/review-bot/vcs" ) // GetFileContent fetches a file from a repo at the given ref. // Delegates to GetFileContentAtRef with the provided ref. func (c *Client) GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error) { return c.GetFileContentAtRef(ctx, owner, repo, path, ref) } // ListContents lists files and directories at a given path in a repo. // Returns the directory listing from the GitHub contents API. // If the path points to a single file (not a directory), the API returns // a JSON object instead of an array; this is handled by returning a // single-element slice. func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) { reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path)) body, err := c.doGet(ctx, reqURL) if err != nil { return nil, fmt.Errorf("list contents %s: %w", path, err) } type entry struct { Name string `json:"name"` Path string `json:"path"` Type string `json:"type"` } // The GitHub contents API returns an array for directories and an object // for single files. Try array first (common case), then fall back to object. var entries []entry if err := json.Unmarshal(body, &entries); err != nil { var single entry if err2 := json.Unmarshal(body, &single); err2 != nil { return nil, fmt.Errorf("parse contents JSON: %w", err) } entries = []entry{single} } result := make([]vcs.ContentEntry, len(entries)) for i, e := range entries { result[i] = vcs.ContentEntry{ Name: e.Name, Path: e.Path, Type: e.Type, } } return result, nil } // escapePath escapes each segment of a relative file path for use in URLs. // Slashes are preserved as path separators; other special characters are escaped. // Dot-segments ("." and "..") are silently removed to prevent path traversal. // This is intentional: callers may receive a different path than requested without // error. The function is package-private, and all callers (GetFileContentAtRef, // ListContents) already handle missing-file errors from the API if the cleaned // path doesn't match what the caller intended. func escapePath(p string) string { parts := strings.Split(p, "/") var clean []string for _, part := range parts { if part == "." || part == ".." || part == "" { continue } clean = append(clean, url.PathEscape(part)) } return strings.Join(clean, "/") } // decodeBase64Content decodes base64-encoded content from the GitHub contents API. // GitHub returns base64 content with line breaks for formatting; we strip \r and \n before decoding. func decodeBase64Content(encoded string) (string, error) { // GitHub inserts newlines in base64 content cleaned := strings.NewReplacer("\n", "", "\r", "").Replace(encoded) decoded, err := base64.StdEncoding.DecodeString(cleaned) if err != nil { return "", err } return string(decoded), nil }