diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..e9762fe --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,21 @@ +name: Pull Request +on: + pull_request: + types: [opened, synchronize] + +jobs: + build_test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: 1.20 + + - name: Build and test + run: | + go build -v + go test -v ./... diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..fe7c740 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${fileDirname}", + } + ] +} \ No newline at end of file diff --git a/apt_query.go b/apt_query.go new file mode 100644 index 0000000..91dcfe7 --- /dev/null +++ b/apt_query.go @@ -0,0 +1,106 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "sort" + "strings" +) + +func contains(arr []string, element string) bool { + for _, x := range arr { + if x == element { + return true + } + } + return false +} + +// Writes a message to STDERR and exits with status 1. +func exitOnError(format string, arg ...any) { + fmt.Fprintln(os.Stderr, fmt.Errorf(format+"\n", arg...)) + fmt.Println("Usage: apt_query.go normalized-list ") + os.Exit(1) +} + +type AptPackage struct { + Name string + Version string +} + +// Executes a command and either returns the output or exits the programs and writes the output (including error) to STDERR. +func execCommand(name string, arg ...string) string { + cmd := exec.Command(name, arg...) + out, err := cmd.CombinedOutput() + if err != nil { + fmt.Fprintln(os.Stderr, fmt.Sprintf("Error code %d encountered while running %s\n%s", cmd.ProcessState.ExitCode(), strings.Join(cmd.Args, " "), string(out))) + os.Exit(2) + } + return string(out) +} + +// Gets the APT based packages as a sorted by name list (normalized). +func getPackages(names []string) []AptPackage { + prefixArgs := []string{"--quiet=0", "--no-all-versions", "show"} + out := execCommand("apt-cache", append(prefixArgs, names...)...) + + packages := []AptPackage{} + errorMessages := []string{} + + for _, paragraph := range strings.Split(string(out), "\n\n") { + pkg := AptPackage{} + for _, line := range strings.Split(paragraph, "\n") { + if strings.HasPrefix(line, "Package: ") { + pkg.Name = strings.TrimSpace(strings.Split(line, ":")[1]) + } else if strings.HasPrefix(line, "Version: ") { + pkg.Version = strings.TrimSpace(strings.Split(line, ":")[1]) + } else if strings.HasPrefix(line, "N: ") || strings.HasPrefix(line, "E: ") { + if !contains(errorMessages, line) { + errorMessages = append(errorMessages, line) + } + } + } + if pkg.Name != "" { + packages = append(packages, pkg) + } + } + + if len(errorMessages) > 0 { + exitOnError("Errors encountered in apt-cache output (see below):\n%s", strings.Join(errorMessages, "\n")) + } + + sort.Slice(packages, func(i, j int) bool { + return packages[i].Name < packages[j].Name + }) + + return packages +} + +func serialize(packages []AptPackage) string { + tokens := []string{} + for _, pkg := range packages { + tokens = append(tokens, pkg.Name+"="+pkg.Version) + } + return strings.Join(tokens, " ") +} + +func main() { + const usageText string = "Usage: apt_query.go [normalized-list] " + + if len(os.Args) < 3 { + exitOnError("Expected at least 2 arguments but found %d.", len(os.Args)-1) + return + } + + command := os.Args[1] + packageNames := os.Args[2:] + + switch command { + case "normalized-list": + fmt.Println(serialize(getPackages(packageNames))) + break + default: + exitOnError("Command '%s' not recognized.", command) + } +} diff --git a/apt_query_test.go b/apt_query_test.go new file mode 100644 index 0000000..65beae0 --- /dev/null +++ b/apt_query_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "bytes" + "os/exec" + "testing" +) + +type RunResult struct { + TestContext *testing.T + Stdout string + Stderr string + Err error +} + +func run(t *testing.T, command string, pkgNames ...string) RunResult { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd := exec.Command("go", append([]string{"run", "apt_query.go", command}, pkgNames...)...) + cmd.Stdout = stdout + cmd.Stderr = stderr + err := cmd.Run() + return RunResult{TestContext: t, Stdout: stdout.String(), Stderr: stderr.String(), Err: err} +} + +func (r *RunResult) expectSuccessfulOut(expected string) { + if r.Err != nil { + r.TestContext.Errorf("Error running command: %v", r.Err) + return + } + if r.Stdout != expected+"\n" { // Output will always have a end of output newline. + r.TestContext.Errorf("Unexpected stdout found.\nExpected:\n'%s'\nActual:\n'%s'", expected, r.Stdout) + } +} + +func (r *RunResult) expectError(expected string) { + if r.Stderr != expected+"\n" { // Output will always have a end of output newline. + r.TestContext.Errorf("Unexpected stderr found.\nExpected:\n'%s'\nActual:\n'%s'", expected, r.Stderr) + } +} + +func TestNormalizedList_MultiplePackagesExists_StdoutsAlphaSortedPackageNameVersionPairs(t *testing.T) { + result := run(t, "normalized-list", "xdot", "rolldice") + result.expectSuccessfulOut("rolldice=1.16-1build1 xdot=1.2-3") +} + +func TestNormalizedList_SamePackagesDifferentOrder_StdoutsMatch(t *testing.T) { + expected := "rolldice=1.16-1build1 xdot=1.2-3" + + result := run(t, "normalized-list", "rolldice", "xdot") + result.expectSuccessfulOut(expected) + + result = run(t, "normalized-list", "xdot", "rolldice") + result.expectSuccessfulOut(expected) +} + +func TestNormalizedList_SinglePackageExists_StdoutsSinglePackageNameVersionPair(t *testing.T) { + var result = run(t, "normalized-list", "xdot") + result.expectSuccessfulOut("xdot=1.2-3") +} + +func TestNormalizedList_NonExistentPackageName_StderrsAptCacheErrors(t *testing.T) { + var result = run(t, "normalized-list", "nonexistentpackagename") + result.expectError( + `Error code 100 encountered while running apt-cache --quiet=0 --no-all-versions show nonexistentpackagename +N: Unable to locate package nonexistentpackagename +N: Unable to locate package nonexistentpackagename +E: No packages found + +exit status 2`) +} + +func TestNormalizedList_NoPackagesGiven_StderrsArgMismatch(t *testing.T) { + var result = run(t, "normalized-list") + result.expectError( + `Expected at least 2 arguments but found 1. + +exit status 1`) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..96467f4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module awalsh128.com/cache-apt-pkgs-action + +go 1.18