From 80c535925db59712876115c690891177e6497cfa Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Fri, 6 Feb 2026 12:04:23 -0800 Subject: [PATCH] shelley: add GitHub Pages for version metadata to avoid API rate limits. Prompt: In a new worktree, have Shelley's build process also publish into a static page hosted by GitHub the last release version metadata and the last 500 short commit Shas and their subjects. This will be used to have Shelley check for updates instead of the GitHub api. Let me know what I'll need to configure on GitHub to make gh pages work - Add publish-version-metadata.yml workflow that runs after each release - Add scripts/generate-version-metadata.py to generate release.json and commits.json - Update versioncheck.go to use only the static GitHub Pages - Simplify types: ReleaseInfo replaces GitHubRelease, remove GitHubCommit - Remove all GitHub API code from version checking Co-authored-by: Shelley --- .../workflows/publish-version-metadata.yml | 46 ++++ scripts/generate-version-metadata.py | 110 ++++++++ server/versioncheck.go | 251 +++++++++--------- server/versioncheck_test.go | 83 +++--- 4 files changed, 328 insertions(+), 162 deletions(-) create mode 100644 .github/workflows/publish-version-metadata.yml create mode 100755 scripts/generate-version-metadata.py diff --git a/.github/workflows/publish-version-metadata.yml b/.github/workflows/publish-version-metadata.yml new file mode 100644 index 0000000000000000000000000000000000000000..3162f545ada0b8d84d1266941796f1f6825eb164 --- /dev/null +++ b/.github/workflows/publish-version-metadata.yml @@ -0,0 +1,46 @@ +name: Publish Version Metadata + +on: + workflow_run: + workflows: ["Release"] + types: + - completed + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate version metadata + run: python3 scripts/generate-version-metadata.py _site + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/scripts/generate-version-metadata.py b/scripts/generate-version-metadata.py new file mode 100755 index 0000000000000000000000000000000000000000..73127b514070210b8f7f31cd8de283aa86241f12 --- /dev/null +++ b/scripts/generate-version-metadata.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Generate version metadata for GitHub Pages. + +Generates release.json, commits.json, and index.html. +""" + +import json +import subprocess +import sys +from pathlib import Path + + +def generate_release_json(output_dir: Path) -> None: + """Generate release.json with latest release information.""" + # Get latest tag - fail if none exists + result = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print("ERROR: No tags found. Run this after creating a release.", file=sys.stderr) + sys.exit(1) + + latest_tag = result.stdout.strip() + latest_commit = subprocess.check_output( + ["git", "rev-list", "-n", "1", latest_tag], text=True + ).strip() + latest_commit_short = latest_commit[:7] + latest_commit_time = subprocess.check_output( + ["git", "show", "-s", "--format=%cI", latest_commit], text=True + ).strip() + published_at = subprocess.check_output( + ["git", "show", "-s", "--format=%cI", latest_tag], text=True + ).strip() + + version = latest_tag[1:] if latest_tag.startswith("v") else latest_tag + + release_info = { + "tag_name": latest_tag, + "version": version, + "commit": latest_commit_short, + "commit_full": latest_commit, + "commit_time": latest_commit_time, + "published_at": published_at, + "download_urls": { + "darwin_amd64": f"https://github.com/boldsoftware/shelley/releases/download/{latest_tag}/shelley_darwin_amd64", + "darwin_arm64": f"https://github.com/boldsoftware/shelley/releases/download/{latest_tag}/shelley_darwin_arm64", + "linux_amd64": f"https://github.com/boldsoftware/shelley/releases/download/{latest_tag}/shelley_linux_amd64", + "linux_arm64": f"https://github.com/boldsoftware/shelley/releases/download/{latest_tag}/shelley_linux_arm64", + }, + "checksums_url": f"https://github.com/boldsoftware/shelley/releases/download/{latest_tag}/checksums.txt", + } + + output_path = output_dir / "release.json" + with open(output_path, "w") as f: + json.dump(release_info, f, indent=2) + print(f"Generated {output_path}") + + +def generate_commits_json(output_dir: Path, count: int = 500) -> None: + """Generate commits.json with recent commits.""" + output = subprocess.check_output( + ["git", "log", f"--pretty=format:%h%x00%s", f"-{count}", "HEAD"], + text=True, + ) + + commits = [] + for line in output.strip().split("\n"): + if "\x00" in line: + sha, subject = line.split("\x00", 1) + commits.append({"sha": sha, "subject": subject}) + + output_path = output_dir / "commits.json" + with open(output_path, "w") as f: + json.dump(commits, f, indent=2) + print(f"Generated {output_path} with {len(commits)} commits") + + +def generate_index_html(output_dir: Path) -> None: + """Generate index.html.""" + html = """ + +Shelley + +

github.com/boldsoftware/shelley

+ + + +""" + output_path = output_dir / "index.html" + with open(output_path, "w") as f: + f.write(html) + print(f"Generated {output_path}") + + +def main() -> None: + output_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("_site") + output_dir.mkdir(parents=True, exist_ok=True) + + generate_release_json(output_dir) + generate_commits_json(output_dir) + generate_index_html(output_dir) + + +if __name__ == "__main__": + main() diff --git a/server/versioncheck.go b/server/versioncheck.go index 4ed6b349422b4fb68d87fb2d155ab6b75d296ef7..b97ee21bcb0689adc25c2750c7b5180006cafe25 100644 --- a/server/versioncheck.go +++ b/server/versioncheck.go @@ -15,7 +15,6 @@ import ( "os" "os/exec" "runtime" - "sort" "strings" "sync" "time" @@ -37,22 +36,22 @@ type VersionChecker struct { // VersionInfo contains version check results. type VersionInfo struct { - CurrentVersion string `json:"current_version"` - CurrentTag string `json:"current_tag,omitempty"` - CurrentCommit string `json:"current_commit,omitempty"` - CurrentCommitTime string `json:"current_commit_time,omitempty"` - LatestVersion string `json:"latest_version,omitempty"` - LatestTag string `json:"latest_tag,omitempty"` - PublishedAt time.Time `json:"published_at,omitempty"` - HasUpdate bool `json:"has_update"` // True if minor version is newer (for showing upgrade button) - ShouldNotify bool `json:"should_notify"` // True if should show red dot (newer + 5 days old) - DownloadURL string `json:"download_url,omitempty"` - ExecutablePath string `json:"executable_path,omitempty"` - Commits []CommitInfo `json:"commits,omitempty"` - CheckedAt time.Time `json:"checked_at"` - Error string `json:"error,omitempty"` - RunningUnderSystemd bool `json:"running_under_systemd"` // True if INVOCATION_ID env var is set (systemd) - ReleaseInfo *GitHubRelease `json:"-"` // Internal, not exposed to JSON + CurrentVersion string `json:"current_version"` + CurrentTag string `json:"current_tag,omitempty"` + CurrentCommit string `json:"current_commit,omitempty"` + CurrentCommitTime string `json:"current_commit_time,omitempty"` + LatestVersion string `json:"latest_version,omitempty"` + LatestTag string `json:"latest_tag,omitempty"` + PublishedAt time.Time `json:"published_at,omitempty"` + HasUpdate bool `json:"has_update"` // True if minor version is newer (for showing upgrade button) + ShouldNotify bool `json:"should_notify"` // True if should show red dot (newer + 5 days old) + DownloadURL string `json:"download_url,omitempty"` + ExecutablePath string `json:"executable_path,omitempty"` + Commits []CommitInfo `json:"commits,omitempty"` + CheckedAt time.Time `json:"checked_at"` + Error string `json:"error,omitempty"` + RunningUnderSystemd bool `json:"running_under_systemd"` // True if INVOCATION_ID env var is set (systemd) + ReleaseInfo *ReleaseInfo `json:"-"` // Internal, not exposed to JSON } // CommitInfo represents a commit in the changelog. @@ -63,29 +62,30 @@ type CommitInfo struct { Date time.Time `json:"date"` } -// GitHubRelease represents a GitHub release from the API. -type GitHubRelease struct { - TagName string `json:"tag_name"` - Name string `json:"name"` - PublishedAt time.Time `json:"published_at"` - Assets []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - } `json:"assets"` +// ReleaseInfo represents release metadata. +type ReleaseInfo struct { + TagName string `json:"tag_name"` + Version string `json:"version"` + Commit string `json:"commit"` + CommitFull string `json:"commit_full"` + CommitTime string `json:"commit_time"` + PublishedAt string `json:"published_at"` + DownloadURLs map[string]string `json:"download_urls"` + ChecksumsURL string `json:"checksums_url"` } -// GitHubCommit represents a commit from the GitHub API. -type GitHubCommit struct { - SHA string `json:"sha"` - Commit struct { - Message string `json:"message"` - Author struct { - Name string `json:"name"` - Date time.Time `json:"date"` - } `json:"author"` - } `json:"commit"` +// StaticCommitInfo represents a commit from commits.json. +type StaticCommitInfo struct { + SHA string `json:"sha"` + Subject string `json:"subject"` } +const ( + // staticMetadataURL is the base URL for version metadata on GitHub Pages. + // This avoids GitHub API rate limits. + staticMetadataURL = "https://boldsoftware.github.io/shelley" +) + // NewVersionChecker creates a new version checker. func NewVersionChecker() *VersionChecker { skipCheck := os.Getenv("SHELLEY_SKIP_VERSION_CHECK") == "true" @@ -138,7 +138,7 @@ func (vc *VersionChecker) Check(ctx context.Context, forceRefresh bool) (*Versio return info, nil } -// fetchVersionInfo fetches the latest release info from GitHub. +// fetchVersionInfo fetches the latest release info from GitHub Pages. func (vc *VersionChecker) fetchVersionInfo(ctx context.Context) (*VersionInfo, error) { currentInfo := version.GetInfo() execPath, _ := os.Executable() @@ -152,7 +152,7 @@ func (vc *VersionChecker) fetchVersionInfo(ctx context.Context) (*VersionInfo, e RunningUnderSystemd: os.Getenv("INVOCATION_ID") != "", } - // Fetch latest release + // Fetch latest release from static metadata latestRelease, err := vc.fetchLatestRelease(ctx) if err != nil { return nil, fmt.Errorf("failed to fetch latest release: %w", err) @@ -160,25 +160,29 @@ func (vc *VersionChecker) fetchVersionInfo(ctx context.Context) (*VersionInfo, e info.LatestTag = latestRelease.TagName info.LatestVersion = latestRelease.TagName - info.PublishedAt = latestRelease.PublishedAt info.ReleaseInfo = latestRelease + // Parse the published_at time + if publishedAt, err := time.Parse(time.RFC3339, latestRelease.PublishedAt); err == nil { + info.PublishedAt = publishedAt + } + // Find the download URL for the current platform info.DownloadURL = vc.findDownloadURL(latestRelease) // Check if latest has a newer minor version info.HasUpdate = vc.isNewerMinor(currentInfo.Tag, latestRelease.TagName) - // For ShouldNotify, we need to check if the versions are 5+ days apart - // Fetch the current version's release to compare dates - if info.HasUpdate && currentInfo.Tag != "" { - currentRelease, err := vc.fetchRelease(ctx, currentInfo.Tag) - if err == nil && currentRelease != nil { + // For ShouldNotify, compare commit times if we have an update + if info.HasUpdate && currentInfo.CommitTime != "" { + currentTime, err1 := time.Parse(time.RFC3339, currentInfo.CommitTime) + latestTime, err2 := time.Parse(time.RFC3339, latestRelease.CommitTime) + if err1 == nil && err2 == nil { // Show notification if the latest release is 5+ days newer than current - timeBetween := latestRelease.PublishedAt.Sub(currentRelease.PublishedAt) + timeBetween := latestTime.Sub(currentTime) info.ShouldNotify = timeBetween >= 5*24*time.Hour } else { - // Can't fetch current release info, just notify if there's an update + // Can't parse times, just notify if there's an update info.ShouldNotify = true } } @@ -192,14 +196,12 @@ func (vc *VersionChecker) FetchChangelog(ctx context.Context, currentTag, latest return nil, nil } - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/compare/%s...%s", - vc.githubOwner, vc.githubRepo, currentTag, latestTag) + url := staticMetadataURL + "/commits.json" req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } - req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("User-Agent", "Shelley-VersionChecker") resp, err := http.DefaultClient.Do(req) @@ -209,88 +211,103 @@ func (vc *VersionChecker) FetchChangelog(ctx context.Context, currentTag, latest defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + return nil, fmt.Errorf("static commits returned status %d", resp.StatusCode) } - var compareResp struct { - Commits []GitHubCommit `json:"commits"` - } - if err := json.NewDecoder(resp.Body).Decode(&compareResp); err != nil { + var staticCommits []StaticCommitInfo + if err := json.NewDecoder(resp.Body).Decode(&staticCommits); err != nil { return nil, err } + // Extract short SHAs from tags (tags are v0.X.YSHA where SHA is octal-encoded) + currentSHA := extractSHAFromTag(currentTag) + latestSHA := extractSHAFromTag(latestTag) + + if currentSHA == "" || latestSHA == "" { + return nil, fmt.Errorf("could not extract SHAs from tags") + } + + // Find the range of commits between current and latest var commits []CommitInfo - for _, c := range compareResp.Commits { - // Get first line of commit message - message := c.Commit.Message - if idx := indexOf(message, '\n'); idx != -1 { - message = message[:idx] + var foundLatest, foundCurrent bool + + for _, c := range staticCommits { + if c.SHA == latestSHA { + foundLatest = true + } + if foundLatest && !foundCurrent { + commits = append(commits, CommitInfo{ + SHA: c.SHA, + Message: c.Subject, + }) + } + if c.SHA == currentSHA { + foundCurrent = true + break } - commits = append(commits, CommitInfo{ - SHA: c.SHA[:7], - Message: message, - Author: c.Commit.Author.Name, - Date: c.Commit.Author.Date, - }) } - // Sort commits by date, newest first - sort.Slice(commits, func(i, j int) bool { - return commits[i].Date.After(commits[j].Date) - }) + // If we didn't find both SHAs, the commits might be too old (outside 500 range) + if !foundLatest || !foundCurrent { + return nil, fmt.Errorf("commits not found in static list") + } + + // Remove the current commit itself from the list (we want commits after current) + if len(commits) > 0 && commits[len(commits)-1].SHA == currentSHA { + commits = commits[:len(commits)-1] + } return commits, nil } -func indexOf(s string, c byte) int { - for i := 0; i < len(s); i++ { - if s[i] == c { - return i - } +// extractSHAFromTag extracts the short commit SHA from a version tag. +// Tags are formatted as v0.COUNT.9OCTAL where OCTAL is the SHA in octal. +func extractSHAFromTag(tag string) string { + // Tag format: v0.178.9XXXXX where XXXXX is octal-encoded 6-char hex SHA + if len(tag) < 3 || tag[0] != 'v' { + return "" } - return -1 -} - -// fetchRelease fetches a specific release by tag from GitHub. -func (vc *VersionChecker) fetchRelease(ctx context.Context, tag string) (*GitHubRelease, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", - vc.githubOwner, vc.githubRepo, tag) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err + // Find the last dot + lastDot := -1 + for i := len(tag) - 1; i >= 0; i-- { + if tag[i] == '.' { + lastDot = i + break + } } - req.Header.Set("Accept", "application/vnd.github.v3+json") - req.Header.Set("User-Agent", "Shelley-VersionChecker") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err + if lastDot == -1 { + return "" } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + // Extract the patch part (9XXXXX) + patch := tag[lastDot+1:] + if len(patch) < 2 || patch[0] != '9' { + return "" } - var release GitHubRelease - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return nil, err + // Parse the octal number after '9' + octal := patch[1:] + var hexVal uint64 + for _, c := range octal { + if c < '0' || c > '7' { + return "" + } + hexVal = hexVal*8 + uint64(c-'0') } - return &release, nil + // Convert back to 6-char hex SHA (short SHA) + return fmt.Sprintf("%06x", hexVal) } -// fetchLatestRelease fetches the latest release from GitHub. -func (vc *VersionChecker) fetchLatestRelease(ctx context.Context) (*GitHubRelease, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", - vc.githubOwner, vc.githubRepo) +// fetchLatestRelease fetches the latest release info from GitHub Pages. +func (vc *VersionChecker) fetchLatestRelease(ctx context.Context) (*ReleaseInfo, error) { + url := staticMetadataURL + "/release.json" req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } - req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("User-Agent", "Shelley-VersionChecker") resp, err := http.DefaultClient.Do(req) @@ -300,10 +317,10 @@ func (vc *VersionChecker) fetchLatestRelease(ctx context.Context) (*GitHubReleas defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + return nil, fmt.Errorf("failed to fetch release info: status %d", resp.StatusCode) } - var release GitHubRelease + var release ReleaseInfo if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, err } @@ -312,16 +329,11 @@ func (vc *VersionChecker) fetchLatestRelease(ctx context.Context) (*GitHubReleas } // findDownloadURL finds the appropriate download URL for the current platform. -func (vc *VersionChecker) findDownloadURL(release *GitHubRelease) string { - // Build expected asset name: shelley__ - expectedName := fmt.Sprintf("shelley_%s_%s", runtime.GOOS, runtime.GOARCH) - - for _, asset := range release.Assets { - if asset.Name == expectedName { - return asset.BrowserDownloadURL - } +func (vc *VersionChecker) findDownloadURL(release *ReleaseInfo) string { + key := fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH) + if url, ok := release.DownloadURLs[key]; ok { + return url } - return "" } @@ -524,17 +536,10 @@ func (vc *VersionChecker) doSudoUpgrade(binaryData []byte) error { } // fetchExpectedChecksum downloads checksums.txt and extracts the expected checksum for our binary. -func (vc *VersionChecker) fetchExpectedChecksum(ctx context.Context, release *GitHubRelease) (string, error) { - // Find checksums.txt URL - var checksumURL string - for _, asset := range release.Assets { - if asset.Name == "checksums.txt" { - checksumURL = asset.BrowserDownloadURL - break - } - } +func (vc *VersionChecker) fetchExpectedChecksum(ctx context.Context, release *ReleaseInfo) (string, error) { + checksumURL := release.ChecksumsURL if checksumURL == "" { - return "", fmt.Errorf("checksums.txt not found in release") + return "", fmt.Errorf("checksums.txt URL not found in release") } // Download checksums.txt diff --git a/server/versioncheck_test.go b/server/versioncheck_test.go index 5a38174e6e3f1c280fdbfc9af07de5c25f24f81a..a228e8727a0086a8760460a9fff5c008a913ed67 100644 --- a/server/versioncheck_test.go +++ b/server/versioncheck_test.go @@ -12,6 +12,36 @@ import ( "time" ) +func TestExtractSHAFromTag(t *testing.T) { + tests := []struct { + tag string + expected string + }{ + // Tag format: v0.COUNT.9OCTAL where OCTAL is the SHA in octal + // For example, 6-char hex SHA "abc123" (hex) = 0xabc123 = 11256099 (decimal) + // In octal: 52740443 + {"v0.178.952740443", "abc123"}, // SHA abc123 in octal is 52740443 + {"v0.178.933471105", "6e7245"}, // Real release tag + {"v0.1.90", "000000"}, // SHA 0 + {"", ""}, + {"invalid", ""}, + {"v", ""}, + {"v0", ""}, + {"v0.1", ""}, + {"v0.1.0", ""}, // No '9' prefix + {"v0.1.8x", ""}, // Invalid octal digit + } + + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + result := extractSHAFromTag(tt.tag) + if result != tt.expected { + t.Errorf("extractSHAFromTag(%q) = %q, want %q", tt.tag, result, tt.expected) + } + }) + } +} + func TestParseMinorVersion(t *testing.T) { tests := []struct { tag string @@ -113,14 +143,14 @@ func TestVersionCheckerCache(t *testing.T) { callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ - release := GitHubRelease{ + release := ReleaseInfo{ TagName: "v0.10.0", - Name: "Release v0.10.0", - PublishedAt: time.Now().Add(-10 * 24 * time.Hour), - Assets: []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - }{}, + Version: "0.10.0", + PublishedAt: time.Now().Add(-10 * 24 * time.Hour).Format(time.RFC3339), + DownloadURLs: map[string]string{ + "linux_amd64": "https://example.com/linux_amd64", + "darwin_arm64": "https://example.com/darwin_arm64", + }, } json.NewEncoder(w).Encode(release) })) @@ -138,7 +168,7 @@ func TestVersionCheckerCache(t *testing.T) { // First call - should not use cache _, err := vc.Check(ctx, false) - // Will fail because we're not actually calling GitHub, but that's OK for this test + // Will fail because we're not actually calling the static site, but that's OK for this test // The important thing is that it tried to fetch // Second call immediately after - should use cache if first succeeded @@ -153,16 +183,13 @@ func TestVersionCheckerCache(t *testing.T) { func TestFindDownloadURL(t *testing.T) { vc := &VersionChecker{} - release := &GitHubRelease{ + release := &ReleaseInfo{ TagName: "v0.1.0", - Assets: []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - }{ - {Name: "shelley_linux_amd64", BrowserDownloadURL: "https://example.com/linux_amd64"}, - {Name: "shelley_linux_arm64", BrowserDownloadURL: "https://example.com/linux_arm64"}, - {Name: "shelley_darwin_amd64", BrowserDownloadURL: "https://example.com/darwin_amd64"}, - {Name: "shelley_darwin_arm64", BrowserDownloadURL: "https://example.com/darwin_arm64"}, + DownloadURLs: map[string]string{ + "linux_amd64": "https://example.com/linux_amd64", + "linux_arm64": "https://example.com/linux_arm64", + "darwin_amd64": "https://example.com/darwin_amd64", + "darwin_arm64": "https://example.com/darwin_arm64", }, } @@ -174,28 +201,6 @@ func TestFindDownloadURL(t *testing.T) { } } -func TestIndexOf(t *testing.T) { - tests := []struct { - s string - c byte - expected int - }{ - {"hello\nworld", '\n', 5}, - {"hello", '\n', -1}, - {"", '\n', -1}, - {"\n", '\n', 0}, - } - - for _, tt := range tests { - t.Run(tt.s, func(t *testing.T) { - result := indexOf(tt.s, tt.c) - if result != tt.expected { - t.Errorf("indexOf(%q, %q) = %d, want %d", tt.s, tt.c, result, tt.expected) - } - }) - } -} - func TestIsPermissionError(t *testing.T) { tests := []struct { name string