Initial import of the rickub CLI as a standalone public projectUnverified
Extracted from the rickub monorepo (history not carried over) after a pre-publication security review. Hardening applied on import: - tokens are stored per host and only sent to the host they were minted for (explicit --token / RICKUB_TOKEN still work for any host) - warn before sending a token to a non-https host (loopback exempt) - server-supplied URLs are scheme-validated before being handed to the OS browser opener - auth status redacts the token down to its prefix CI on rickub's own actions runner: every push to main publishes a v<VERSION>-rc.<run> prerelease with linux/darwin amd64+arm64 archives; a manual release.yml dispatch cuts a real release. See RELEASING.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PBYab4T8KztAyUMyyQBbK
1a1d430 added
.github/workflows/ci.yml +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +name: CI | |
| 2 | + | |
| 3 | +# Fast feedback on every pull request and on every push to main. | |
| 4 | +# NOTE: this workflow deliberately has NO workflow_dispatch trigger — the rickub | |
| 5 | +# dispatch API fires *every* dispatchable workflow on a ref, so only | |
| 6 | +# release.yml may declare workflow_dispatch. See RELEASING.md. | |
| 7 | +on: | |
| 8 | + push: | |
| 9 | + branches: | |
| 10 | + - main | |
| 11 | + pull_request: | |
| 12 | + | |
| 13 | +permissions: | |
| 14 | + contents: read | |
| 15 | + | |
| 16 | +concurrency: | |
| 17 | + group: ci-${{ github.ref }} | |
| 18 | + cancel-in-progress: true | |
| 19 | + | |
| 20 | +jobs: | |
| 21 | + check: | |
| 22 | + name: fmt / vet / test / build | |
| 23 | + runs-on: ubuntu-latest | |
| 24 | + steps: | |
| 25 | + - name: Checkout | |
| 26 | + uses: actions/checkout@v4 | |
| 27 | + | |
| 28 | + - name: Set up Go | |
| 29 | + uses: actions/setup-go@v5 | |
| 30 | + with: | |
| 31 | + go-version-file: go.mod | |
| 32 | + cache: true | |
| 33 | + | |
| 34 | + - name: gofmt | |
| 35 | + run: | | |
| 36 | + set -euo pipefail | |
| 37 | + unformatted="$(gofmt -l .)" | |
| 38 | + if [ -n "${unformatted}" ]; then | |
| 39 | + echo "::error::The following files are not gofmt-formatted:" | |
| 40 | + echo "${unformatted}" | |
| 41 | + echo | |
| 42 | + echo "Run: gofmt -w ." | |
| 43 | + gofmt -d . | |
| 44 | + exit 1 | |
| 45 | + fi | |
| 46 | + echo "gofmt: all files formatted" | |
| 47 | + | |
| 48 | + - name: go vet | |
| 49 | + run: go vet ./... | |
| 50 | + | |
| 51 | + - name: go test | |
| 52 | + run: go test ./... -count=1 | |
| 53 | + | |
| 54 | + - name: go build | |
| 55 | + run: | | |
| 56 | + set -euo pipefail | |
| 57 | + go build ./... | |
| 58 | + CGO_ENABLED=0 go build -trimpath -o "${RUNNER_TEMP}/rickub" . | |
| 59 | + | |
| 60 | + - name: Cross-compilation smoke test | |
| 61 | + # The runner fleet is Linux/amd64 only, so releases cross-compile every | |
| 62 | + # target. Catch a break here rather than in rc.yml/release.yml. | |
| 63 | + run: | | |
| 64 | + set -euo pipefail | |
| 65 | + for target in linux/arm64 darwin/amd64 darwin/arm64; do | |
| 66 | + echo "==> ${target}" | |
| 67 | + CGO_ENABLED=0 GOOS="${target%%/*}" GOARCH="${target##*/}" \ | |
| 68 | + go build -trimpath -o /dev/null . | |
| 69 | + done | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +name: CI | ||
| 2 | + | ||
| 3 | +# Fast feedback on every pull request and on every push to main. | ||
| 4 | +# NOTE: this workflow deliberately has NO workflow_dispatch trigger — the rickub | ||
| 5 | +# dispatch API fires *every* dispatchable workflow on a ref, so only | ||
| 6 | +# release.yml may declare workflow_dispatch. See RELEASING.md. | ||
| 7 | +on: | ||
| 8 | + push: | ||
| 9 | + branches: | ||
| 10 | + - main | ||
| 11 | + pull_request: | ||
| 12 | + | ||
| 13 | +permissions: | ||
| 14 | + contents: read | ||
| 15 | + | ||
| 16 | +concurrency: | ||
| 17 | + group: ci-${{ github.ref }} | ||
| 18 | + cancel-in-progress: true | ||
| 19 | + | ||
| 20 | +jobs: | ||
| 21 | + check: | ||
| 22 | + name: fmt / vet / test / build | ||
| 23 | + runs-on: ubuntu-latest | ||
| 24 | + steps: | ||
| 25 | + - name: Checkout | ||
| 26 | + uses: actions/checkout@v4 | ||
| 27 | + | ||
| 28 | + - name: Set up Go | ||
| 29 | + uses: actions/setup-go@v5 | ||
| 30 | + with: | ||
| 31 | + go-version-file: go.mod | ||
| 32 | + cache: true | ||
| 33 | + | ||
| 34 | + - name: gofmt | ||
| 35 | + run: | | ||
| 36 | + set -euo pipefail | ||
| 37 | + unformatted="$(gofmt -l .)" | ||
| 38 | + if [ -n "${unformatted}" ]; then | ||
| 39 | + echo "::error::The following files are not gofmt-formatted:" | ||
| 40 | + echo "${unformatted}" | ||
| 41 | + echo | ||
| 42 | + echo "Run: gofmt -w ." | ||
| 43 | + gofmt -d . | ||
| 44 | + exit 1 | ||
| 45 | + fi | ||
| 46 | + echo "gofmt: all files formatted" | ||
| 47 | + | ||
| 48 | + - name: go vet | ||
| 49 | + run: go vet ./... | ||
| 50 | + | ||
| 51 | + - name: go test | ||
| 52 | + run: go test ./... -count=1 | ||
| 53 | + | ||
| 54 | + - name: go build | ||
| 55 | + run: | | ||
| 56 | + set -euo pipefail | ||
| 57 | + go build ./... | ||
| 58 | + CGO_ENABLED=0 go build -trimpath -o "${RUNNER_TEMP}/rickub" . | ||
| 59 | + | ||
| 60 | + - name: Cross-compilation smoke test | ||
| 61 | + # The runner fleet is Linux/amd64 only, so releases cross-compile every | ||
| 62 | + # target. Catch a break here rather than in rc.yml/release.yml. | ||
| 63 | + run: | | ||
| 64 | + set -euo pipefail | ||
| 65 | + for target in linux/arm64 darwin/amd64 darwin/arm64; do | ||
| 66 | + echo "==> ${target}" | ||
| 67 | + CGO_ENABLED=0 GOOS="${target%%/*}" GOARCH="${target##*/}" \ | ||
| 68 | + go build -trimpath -o /dev/null . | ||
| 69 | + done | ||
added
.github/workflows/rc.yml +111 -0 | new file mode 100644 | ||
| @@ -0,0 +1,111 @@ | ||
| 1 | +name: RC | |
| 2 | + | |
| 3 | +# Every push to main publishes a release candidate prerelease: | |
| 4 | +# tag v<BASE>-rc.<run_number>, where <BASE> comes from the VERSION file. | |
| 5 | +# Only the newest push matters, so older in-flight RC runs are cancelled. | |
| 6 | +# | |
| 7 | +# NOTE: no workflow_dispatch trigger here on purpose — the rickub dispatch API | |
| 8 | +# fires *every* dispatchable workflow on a ref, so release.yml is the only | |
| 9 | +# workflow allowed to declare it. See RELEASING.md. | |
| 10 | +on: | |
| 11 | + push: | |
| 12 | + branches: | |
| 13 | + - main | |
| 14 | + | |
| 15 | +# Creating a release requires write access; the default token is read-only. | |
| 16 | +permissions: | |
| 17 | + contents: write | |
| 18 | + | |
| 19 | +concurrency: | |
| 20 | + group: rc | |
| 21 | + cancel-in-progress: true | |
| 22 | + | |
| 23 | +jobs: | |
| 24 | + rc: | |
| 25 | + name: build and publish release candidate | |
| 26 | + runs-on: ubuntu-latest | |
| 27 | + steps: | |
| 28 | + - name: Checkout | |
| 29 | + uses: actions/checkout@v4 | |
| 30 | + | |
| 31 | + - name: Set up Go | |
| 32 | + uses: actions/setup-go@v5 | |
| 33 | + with: | |
| 34 | + go-version-file: go.mod | |
| 35 | + cache: true | |
| 36 | + | |
| 37 | + - name: go test | |
| 38 | + run: go test ./... -count=1 | |
| 39 | + | |
| 40 | + - name: Compute RC version | |
| 41 | + id: version | |
| 42 | + run: | | |
| 43 | + set -euo pipefail | |
| 44 | + if [ ! -f VERSION ]; then | |
| 45 | + echo "::error::VERSION file is missing at the repository root" | |
| 46 | + exit 1 | |
| 47 | + fi | |
| 48 | + base="$(tr -d ' \t\r\n' < VERSION)" | |
| 49 | + if ! printf '%s' "${base}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then | |
| 50 | + echo "::error::VERSION must contain a bare MAJOR.MINOR.PATCH version (got '${base}')" | |
| 51 | + exit 1 | |
| 52 | + fi | |
| 53 | + version="${base}-rc.${{ github.run_number }}" | |
| 54 | + { | |
| 55 | + echo "base=${base}" | |
| 56 | + echo "version=${version}" | |
| 57 | + echo "tag=v${version}" | |
| 58 | + } >> "$GITHUB_OUTPUT" | |
| 59 | + echo "RC version: ${version}" | |
| 60 | + | |
| 61 | + - name: Build distribution | |
| 62 | + run: bash scripts/build-dist.sh "${{ steps.version.outputs.version }}" | |
| 63 | + | |
| 64 | + - name: Release notes | |
| 65 | + id: notes | |
| 66 | + env: | |
| 67 | + RC_VERSION: ${{ steps.version.outputs.version }} | |
| 68 | + BASE_VERSION: ${{ steps.version.outputs.base }} | |
| 69 | + run: | | |
| 70 | + set -euo pipefail | |
| 71 | + { | |
| 72 | + echo "Automated release candidate for the upcoming **v${BASE_VERSION}**." | |
| 73 | + echo | |
| 74 | + echo "- Commit: \`${GITHUB_SHA}\`" | |
| 75 | + echo "- Build: run #${GITHUB_RUN_NUMBER} of \`${GITHUB_WORKFLOW}\`" | |
| 76 | + echo "- Version stamp: \`${RC_VERSION}\` (\`rickub version\`)" | |
| 77 | + echo | |
| 78 | + echo "> This is a prerelease built automatically from \`main\`." | |
| 79 | + echo "> It is not a supported release — use it for testing only." | |
| 80 | + echo | |
| 81 | + echo '## Checksums' | |
| 82 | + echo | |
| 83 | + echo '```' | |
| 84 | + cat dist/SHA256SUMS | |
| 85 | + echo '```' | |
| 86 | + } > "${RUNNER_TEMP}/rc-notes.md" | |
| 87 | + echo "path=${RUNNER_TEMP}/rc-notes.md" >> "$GITHUB_OUTPUT" | |
| 88 | + | |
| 89 | + - name: Upload build artifacts | |
| 90 | + uses: actions/upload-artifact@v4 | |
| 91 | + with: | |
| 92 | + name: rickub-${{ steps.version.outputs.version }} | |
| 93 | + path: dist/ | |
| 94 | + if-no-files-found: error | |
| 95 | + retention-days: 14 | |
| 96 | + | |
| 97 | + - name: Publish prerelease | |
| 98 | + uses: softprops/action-gh-release@v2 | |
| 99 | + with: | |
| 100 | + tag_name: ${{ steps.version.outputs.tag }} | |
| 101 | + name: ${{ steps.version.outputs.tag }} | |
| 102 | + body_path: ${{ steps.notes.outputs.path }} | |
| 103 | + # The tag does not exist yet: the rickub release shim creates it at | |
| 104 | + # target_commitish, so CI cuts its own RC tags. | |
| 105 | + target_commitish: ${{ github.sha }} | |
| 106 | + draft: false | |
| 107 | + prerelease: true | |
| 108 | + files: | | |
| 109 | + dist/*.tar.gz | |
| 110 | + dist/SHA256SUMS | |
| 111 | + fail_on_unmatched_files: true | |
| new file mode 100644 | |||
| @@ -0,0 +1,111 @@ | |||
| 1 | +name: RC | ||
| 2 | + | ||
| 3 | +# Every push to main publishes a release candidate prerelease: | ||
| 4 | +# tag v<BASE>-rc.<run_number>, where <BASE> comes from the VERSION file. | ||
| 5 | +# Only the newest push matters, so older in-flight RC runs are cancelled. | ||
| 6 | +# | ||
| 7 | +# NOTE: no workflow_dispatch trigger here on purpose — the rickub dispatch API | ||
| 8 | +# fires *every* dispatchable workflow on a ref, so release.yml is the only | ||
| 9 | +# workflow allowed to declare it. See RELEASING.md. | ||
| 10 | +on: | ||
| 11 | + push: | ||
| 12 | + branches: | ||
| 13 | + - main | ||
| 14 | + | ||
| 15 | +# Creating a release requires write access; the default token is read-only. | ||
| 16 | +permissions: | ||
| 17 | + contents: write | ||
| 18 | + | ||
| 19 | +concurrency: | ||
| 20 | + group: rc | ||
| 21 | + cancel-in-progress: true | ||
| 22 | + | ||
| 23 | +jobs: | ||
| 24 | + rc: | ||
| 25 | + name: build and publish release candidate | ||
| 26 | + runs-on: ubuntu-latest | ||
| 27 | + steps: | ||
| 28 | + - name: Checkout | ||
| 29 | + uses: actions/checkout@v4 | ||
| 30 | + | ||
| 31 | + - name: Set up Go | ||
| 32 | + uses: actions/setup-go@v5 | ||
| 33 | + with: | ||
| 34 | + go-version-file: go.mod | ||
| 35 | + cache: true | ||
| 36 | + | ||
| 37 | + - name: go test | ||
| 38 | + run: go test ./... -count=1 | ||
| 39 | + | ||
| 40 | + - name: Compute RC version | ||
| 41 | + id: version | ||
| 42 | + run: | | ||
| 43 | + set -euo pipefail | ||
| 44 | + if [ ! -f VERSION ]; then | ||
| 45 | + echo "::error::VERSION file is missing at the repository root" | ||
| 46 | + exit 1 | ||
| 47 | + fi | ||
| 48 | + base="$(tr -d ' \t\r\n' < VERSION)" | ||
| 49 | + if ! printf '%s' "${base}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then | ||
| 50 | + echo "::error::VERSION must contain a bare MAJOR.MINOR.PATCH version (got '${base}')" | ||
| 51 | + exit 1 | ||
| 52 | + fi | ||
| 53 | + version="${base}-rc.${{ github.run_number }}" | ||
| 54 | + { | ||
| 55 | + echo "base=${base}" | ||
| 56 | + echo "version=${version}" | ||
| 57 | + echo "tag=v${version}" | ||
| 58 | + } >> "$GITHUB_OUTPUT" | ||
| 59 | + echo "RC version: ${version}" | ||
| 60 | + | ||
| 61 | + - name: Build distribution | ||
| 62 | + run: bash scripts/build-dist.sh "${{ steps.version.outputs.version }}" | ||
| 63 | + | ||
| 64 | + - name: Release notes | ||
| 65 | + id: notes | ||
| 66 | + env: | ||
| 67 | + RC_VERSION: ${{ steps.version.outputs.version }} | ||
| 68 | + BASE_VERSION: ${{ steps.version.outputs.base }} | ||
| 69 | + run: | | ||
| 70 | + set -euo pipefail | ||
| 71 | + { | ||
| 72 | + echo "Automated release candidate for the upcoming **v${BASE_VERSION}**." | ||
| 73 | + echo | ||
| 74 | + echo "- Commit: \`${GITHUB_SHA}\`" | ||
| 75 | + echo "- Build: run #${GITHUB_RUN_NUMBER} of \`${GITHUB_WORKFLOW}\`" | ||
| 76 | + echo "- Version stamp: \`${RC_VERSION}\` (\`rickub version\`)" | ||
| 77 | + echo | ||
| 78 | + echo "> This is a prerelease built automatically from \`main\`." | ||
| 79 | + echo "> It is not a supported release — use it for testing only." | ||
| 80 | + echo | ||
| 81 | + echo '## Checksums' | ||
| 82 | + echo | ||
| 83 | + echo '```' | ||
| 84 | + cat dist/SHA256SUMS | ||
| 85 | + echo '```' | ||
| 86 | + } > "${RUNNER_TEMP}/rc-notes.md" | ||
| 87 | + echo "path=${RUNNER_TEMP}/rc-notes.md" >> "$GITHUB_OUTPUT" | ||
| 88 | + | ||
| 89 | + - name: Upload build artifacts | ||
| 90 | + uses: actions/upload-artifact@v4 | ||
| 91 | + with: | ||
| 92 | + name: rickub-${{ steps.version.outputs.version }} | ||
| 93 | + path: dist/ | ||
| 94 | + if-no-files-found: error | ||
| 95 | + retention-days: 14 | ||
| 96 | + | ||
| 97 | + - name: Publish prerelease | ||
| 98 | + uses: softprops/action-gh-release@v2 | ||
| 99 | + with: | ||
| 100 | + tag_name: ${{ steps.version.outputs.tag }} | ||
| 101 | + name: ${{ steps.version.outputs.tag }} | ||
| 102 | + body_path: ${{ steps.notes.outputs.path }} | ||
| 103 | + # The tag does not exist yet: the rickub release shim creates it at | ||
| 104 | + # target_commitish, so CI cuts its own RC tags. | ||
| 105 | + target_commitish: ${{ github.sha }} | ||
| 106 | + draft: false | ||
| 107 | + prerelease: true | ||
| 108 | + files: | | ||
| 109 | + dist/*.tar.gz | ||
| 110 | + dist/SHA256SUMS | ||
| 111 | + fail_on_unmatched_files: true | ||
added
.github/workflows/release.yml +211 -0 | new file mode 100644 | ||
| @@ -0,0 +1,211 @@ | ||
| 1 | +name: Release | |
| 2 | + | |
| 3 | +run-name: Release v${{ github.event.inputs.version }} from ${{ github.ref_name }} | |
| 4 | + | |
| 5 | +# THE ONLY workflow in this repository that declares workflow_dispatch. | |
| 6 | +# | |
| 7 | +# The rickub dispatch API (POST /api/v1/repos/{owner}/{repo}/actions/dispatch, | |
| 8 | +# and `rickub run dispatch`) triggers EVERY workflow_dispatch workflow on the | |
| 9 | +# ref, so keeping this trigger unique means a dispatch can never accidentally | |
| 10 | +# fan out into other pipelines. Cut releases from the web UI ("Run workflow"), | |
| 11 | +# which can target a single workflow. See RELEASING.md. | |
| 12 | +on: | |
| 13 | + workflow_dispatch: | |
| 14 | + inputs: | |
| 15 | + version: | |
| 16 | + description: "Version to release, without the leading v (e.g. 1.2.3)" | |
| 17 | + required: true | |
| 18 | + type: string | |
| 19 | + dry_run: | |
| 20 | + description: "Build and verify everything, but do not publish the release" | |
| 21 | + required: false | |
| 22 | + default: false | |
| 23 | + type: boolean | |
| 24 | + | |
| 25 | +# Creating a release requires write access; the default token is read-only. | |
| 26 | +permissions: | |
| 27 | + contents: write | |
| 28 | + | |
| 29 | +concurrency: | |
| 30 | + group: release-${{ github.event.inputs.version }} | |
| 31 | + cancel-in-progress: false | |
| 32 | + | |
| 33 | +jobs: | |
| 34 | + release: | |
| 35 | + name: build and publish release | |
| 36 | + runs-on: ubuntu-latest | |
| 37 | + steps: | |
| 38 | + - name: Checkout | |
| 39 | + uses: actions/checkout@v4 | |
| 40 | + with: | |
| 41 | + # Full history + tags so the "tag already exists" check is meaningful. | |
| 42 | + fetch-depth: 0 | |
| 43 | + | |
| 44 | + - name: Validate inputs | |
| 45 | + id: check | |
| 46 | + env: | |
| 47 | + VERSION_INPUT: ${{ github.event.inputs.version }} | |
| 48 | + DRY_RUN_INPUT: ${{ github.event.inputs.dry_run }} | |
| 49 | + run: | | |
| 50 | + set -euo pipefail | |
| 51 | + | |
| 52 | + version="$(printf '%s' "${VERSION_INPUT}" | tr -d ' \t\r\n')" | |
| 53 | + if [ -z "${version}" ]; then | |
| 54 | + echo "::error::version input is empty" | |
| 55 | + exit 1 | |
| 56 | + fi | |
| 57 | + if [ "${version#v}" != "${version}" ]; then | |
| 58 | + echo "::error::Do not include the leading 'v'. Pass '${version#v}', not '${version}'." | |
| 59 | + exit 1 | |
| 60 | + fi | |
| 61 | + if ! printf '%s' "${version}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then | |
| 62 | + echo "::error::version must be MAJOR.MINOR.PATCH (e.g. 1.2.3), got '${version}'" | |
| 63 | + exit 1 | |
| 64 | + fi | |
| 65 | + tag="v${version}" | |
| 66 | + | |
| 67 | + # Normalise the boolean input to an unambiguous string output; | |
| 68 | + # step outputs are always strings, so `!= 'true'` is safe downstream. | |
| 69 | + dry_run=false | |
| 70 | + case "$(printf '%s' "${DRY_RUN_INPUT}" | tr '[:upper:]' '[:lower:]')" in | |
| 71 | + true|1|yes|on) dry_run=true ;; | |
| 72 | + esac | |
| 73 | + | |
| 74 | + # Cross-check the VERSION file. A mismatch is usually a forgotten bump | |
| 75 | + # PR; it is a warning, not a hard failure, so hotfixes stay possible. | |
| 76 | + if [ -f VERSION ]; then | |
| 77 | + base="$(tr -d ' \t\r\n' < VERSION)" | |
| 78 | + if [ "${base}" != "${version}" ]; then | |
| 79 | + echo "::warning::VERSION file says '${base}' but you are releasing '${version}'." \ | |
| 80 | + "Open a PR bumping VERSION to '${version}' so future RCs are numbered from it." | |
| 81 | + fi | |
| 82 | + else | |
| 83 | + echo "::warning::No VERSION file at the repository root." | |
| 84 | + fi | |
| 85 | + | |
| 86 | + { | |
| 87 | + echo "version=${version}" | |
| 88 | + echo "tag=${tag}" | |
| 89 | + echo "dry_run=${dry_run}" | |
| 90 | + } >> "$GITHUB_OUTPUT" | |
| 91 | + | |
| 92 | + echo "Releasing ${tag} at ${GITHUB_SHA} (dry_run=${dry_run})" | |
| 93 | + | |
| 94 | + - name: Refuse to overwrite an existing release | |
| 95 | + env: | |
| 96 | + TAG: ${{ steps.check.outputs.tag }} | |
| 97 | + GH_TOKEN: ${{ github.token }} | |
| 98 | + run: | | |
| 99 | + set -euo pipefail | |
| 100 | + | |
| 101 | + # 1. Local/remote git tag — authoritative and always available. | |
| 102 | + git fetch --tags --force --quiet || true | |
| 103 | + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null 2>&1; then | |
| 104 | + echo "::error::Tag ${TAG} already exists. Bump the version instead of re-releasing." | |
| 105 | + exit 1 | |
| 106 | + fi | |
| 107 | + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then | |
| 108 | + echo "::error::Tag ${TAG} already exists on the remote." | |
| 109 | + exit 1 | |
| 110 | + fi | |
| 111 | + | |
| 112 | + # 2. Release API — best effort. $GITHUB_API_URL already carries the | |
| 113 | + # /gh prefix of the rickub REST shim. Anything other than a clear | |
| 114 | + # 200 is treated as "cannot tell" so a shim/proxy hiccup does not | |
| 115 | + # block a legitimate release (the git tag check above is the gate). | |
| 116 | + code="$(curl -sS -o /tmp/release-probe.json -w '%{http_code}' \ | |
| 117 | + -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| 118 | + -H "Accept: application/vnd.github+json" \ | |
| 119 | + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" || echo 000)" | |
| 120 | + case "${code}" in | |
| 121 | + 200) | |
| 122 | + echo "::error::A release for ${TAG} already exists (API returned 200)." | |
| 123 | + exit 1 | |
| 124 | + ;; | |
| 125 | + 404) | |
| 126 | + echo "No existing release for ${TAG}." | |
| 127 | + ;; | |
| 128 | + *) | |
| 129 | + echo "::warning::Could not check for an existing ${TAG} release (HTTP ${code});" \ | |
| 130 | + "relying on the git tag check. A 403 here means the CI proxy still blocks" \ | |
| 131 | + "/gh paths — see RELEASING.md." | |
| 132 | + ;; | |
| 133 | + esac | |
| 134 | + | |
| 135 | + - name: Set up Go | |
| 136 | + uses: actions/setup-go@v5 | |
| 137 | + with: | |
| 138 | + go-version-file: go.mod | |
| 139 | + cache: true | |
| 140 | + | |
| 141 | + - name: go vet | |
| 142 | + run: go vet ./... | |
| 143 | + | |
| 144 | + - name: go test | |
| 145 | + run: go test ./... -count=1 | |
| 146 | + | |
| 147 | + - name: Build distribution | |
| 148 | + run: bash scripts/build-dist.sh "${{ steps.check.outputs.version }}" | |
| 149 | + | |
| 150 | + - name: Release notes | |
| 151 | + id: notes | |
| 152 | + env: | |
| 153 | + TAG: ${{ steps.check.outputs.tag }} | |
| 154 | + run: | | |
| 155 | + set -euo pipefail | |
| 156 | + { | |
| 157 | + echo "## rickub ${TAG}" | |
| 158 | + echo | |
| 159 | + echo "- Commit: \`${GITHUB_SHA}\` (\`${GITHUB_REF_NAME}\`)" | |
| 160 | + echo "- Built by \`${GITHUB_WORKFLOW}\` run #${GITHUB_RUN_NUMBER}" | |
| 161 | + echo | |
| 162 | + echo '### Install' | |
| 163 | + echo | |
| 164 | + echo 'Download the archive for your platform, verify it, and drop the binary on your PATH:' | |
| 165 | + echo | |
| 166 | + echo '```sh' | |
| 167 | + echo "tar -xzf rickub_${TAG#v}_\$(uname -s | tr '[:upper:]' '[:lower:]')_amd64.tar.gz" | |
| 168 | + echo 'sha256sum -c SHA256SUMS --ignore-missing' | |
| 169 | + echo 'install -m 0755 rickub /usr/local/bin/rickub' | |
| 170 | + echo '```' | |
| 171 | + echo | |
| 172 | + echo '### Checksums' | |
| 173 | + echo | |
| 174 | + echo '```' | |
| 175 | + cat dist/SHA256SUMS | |
| 176 | + echo '```' | |
| 177 | + } > "${RUNNER_TEMP}/release-notes.md" | |
| 178 | + echo "path=${RUNNER_TEMP}/release-notes.md" >> "$GITHUB_OUTPUT" | |
| 179 | + | |
| 180 | + - name: Upload build artifacts | |
| 181 | + uses: actions/upload-artifact@v4 | |
| 182 | + with: | |
| 183 | + name: rickub-${{ steps.check.outputs.version }} | |
| 184 | + path: dist/ | |
| 185 | + if-no-files-found: error | |
| 186 | + retention-days: 90 | |
| 187 | + | |
| 188 | + - name: Publish release | |
| 189 | + if: steps.check.outputs.dry_run != 'true' | |
| 190 | + uses: softprops/action-gh-release@v2 | |
| 191 | + with: | |
| 192 | + tag_name: ${{ steps.check.outputs.tag }} | |
| 193 | + name: ${{ steps.check.outputs.tag }} | |
| 194 | + body_path: ${{ steps.notes.outputs.path }} | |
| 195 | + # The tag does not exist yet: the rickub release shim creates it at | |
| 196 | + # target_commitish, so the dispatched ref's commit is what gets tagged. | |
| 197 | + target_commitish: ${{ github.sha }} | |
| 198 | + draft: false | |
| 199 | + prerelease: false | |
| 200 | + files: | | |
| 201 | + dist/*.tar.gz | |
| 202 | + dist/SHA256SUMS | |
| 203 | + fail_on_unmatched_files: true | |
| 204 | + | |
| 205 | + - name: Dry run summary | |
| 206 | + if: steps.check.outputs.dry_run == 'true' | |
| 207 | + run: | | |
| 208 | + set -euo pipefail | |
| 209 | + echo "::notice::Dry run — ${{ steps.check.outputs.tag }} was NOT published." | |
| 210 | + echo "Artifacts that would have been released:" | |
| 211 | + ls -l dist/ | |
| new file mode 100644 | |||
| @@ -0,0 +1,211 @@ | |||
| 1 | +name: Release | ||
| 2 | + | ||
| 3 | +run-name: Release v${{ github.event.inputs.version }} from ${{ github.ref_name }} | ||
| 4 | + | ||
| 5 | +# THE ONLY workflow in this repository that declares workflow_dispatch. | ||
| 6 | +# | ||
| 7 | +# The rickub dispatch API (POST /api/v1/repos/{owner}/{repo}/actions/dispatch, | ||
| 8 | +# and `rickub run dispatch`) triggers EVERY workflow_dispatch workflow on the | ||
| 9 | +# ref, so keeping this trigger unique means a dispatch can never accidentally | ||
| 10 | +# fan out into other pipelines. Cut releases from the web UI ("Run workflow"), | ||
| 11 | +# which can target a single workflow. See RELEASING.md. | ||
| 12 | +on: | ||
| 13 | + workflow_dispatch: | ||
| 14 | + inputs: | ||
| 15 | + version: | ||
| 16 | + description: "Version to release, without the leading v (e.g. 1.2.3)" | ||
| 17 | + required: true | ||
| 18 | + type: string | ||
| 19 | + dry_run: | ||
| 20 | + description: "Build and verify everything, but do not publish the release" | ||
| 21 | + required: false | ||
| 22 | + default: false | ||
| 23 | + type: boolean | ||
| 24 | + | ||
| 25 | +# Creating a release requires write access; the default token is read-only. | ||
| 26 | +permissions: | ||
| 27 | + contents: write | ||
| 28 | + | ||
| 29 | +concurrency: | ||
| 30 | + group: release-${{ github.event.inputs.version }} | ||
| 31 | + cancel-in-progress: false | ||
| 32 | + | ||
| 33 | +jobs: | ||
| 34 | + release: | ||
| 35 | + name: build and publish release | ||
| 36 | + runs-on: ubuntu-latest | ||
| 37 | + steps: | ||
| 38 | + - name: Checkout | ||
| 39 | + uses: actions/checkout@v4 | ||
| 40 | + with: | ||
| 41 | + # Full history + tags so the "tag already exists" check is meaningful. | ||
| 42 | + fetch-depth: 0 | ||
| 43 | + | ||
| 44 | + - name: Validate inputs | ||
| 45 | + id: check | ||
| 46 | + env: | ||
| 47 | + VERSION_INPUT: ${{ github.event.inputs.version }} | ||
| 48 | + DRY_RUN_INPUT: ${{ github.event.inputs.dry_run }} | ||
| 49 | + run: | | ||
| 50 | + set -euo pipefail | ||
| 51 | + | ||
| 52 | + version="$(printf '%s' "${VERSION_INPUT}" | tr -d ' \t\r\n')" | ||
| 53 | + if [ -z "${version}" ]; then | ||
| 54 | + echo "::error::version input is empty" | ||
| 55 | + exit 1 | ||
| 56 | + fi | ||
| 57 | + if [ "${version#v}" != "${version}" ]; then | ||
| 58 | + echo "::error::Do not include the leading 'v'. Pass '${version#v}', not '${version}'." | ||
| 59 | + exit 1 | ||
| 60 | + fi | ||
| 61 | + if ! printf '%s' "${version}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then | ||
| 62 | + echo "::error::version must be MAJOR.MINOR.PATCH (e.g. 1.2.3), got '${version}'" | ||
| 63 | + exit 1 | ||
| 64 | + fi | ||
| 65 | + tag="v${version}" | ||
| 66 | + | ||
| 67 | + # Normalise the boolean input to an unambiguous string output; | ||
| 68 | + # step outputs are always strings, so `!= 'true'` is safe downstream. | ||
| 69 | + dry_run=false | ||
| 70 | + case "$(printf '%s' "${DRY_RUN_INPUT}" | tr '[:upper:]' '[:lower:]')" in | ||
| 71 | + true|1|yes|on) dry_run=true ;; | ||
| 72 | + esac | ||
| 73 | + | ||
| 74 | + # Cross-check the VERSION file. A mismatch is usually a forgotten bump | ||
| 75 | + # PR; it is a warning, not a hard failure, so hotfixes stay possible. | ||
| 76 | + if [ -f VERSION ]; then | ||
| 77 | + base="$(tr -d ' \t\r\n' < VERSION)" | ||
| 78 | + if [ "${base}" != "${version}" ]; then | ||
| 79 | + echo "::warning::VERSION file says '${base}' but you are releasing '${version}'." \ | ||
| 80 | + "Open a PR bumping VERSION to '${version}' so future RCs are numbered from it." | ||
| 81 | + fi | ||
| 82 | + else | ||
| 83 | + echo "::warning::No VERSION file at the repository root." | ||
| 84 | + fi | ||
| 85 | + | ||
| 86 | + { | ||
| 87 | + echo "version=${version}" | ||
| 88 | + echo "tag=${tag}" | ||
| 89 | + echo "dry_run=${dry_run}" | ||
| 90 | + } >> "$GITHUB_OUTPUT" | ||
| 91 | + | ||
| 92 | + echo "Releasing ${tag} at ${GITHUB_SHA} (dry_run=${dry_run})" | ||
| 93 | + | ||
| 94 | + - name: Refuse to overwrite an existing release | ||
| 95 | + env: | ||
| 96 | + TAG: ${{ steps.check.outputs.tag }} | ||
| 97 | + GH_TOKEN: ${{ github.token }} | ||
| 98 | + run: | | ||
| 99 | + set -euo pipefail | ||
| 100 | + | ||
| 101 | + # 1. Local/remote git tag — authoritative and always available. | ||
| 102 | + git fetch --tags --force --quiet || true | ||
| 103 | + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null 2>&1; then | ||
| 104 | + echo "::error::Tag ${TAG} already exists. Bump the version instead of re-releasing." | ||
| 105 | + exit 1 | ||
| 106 | + fi | ||
| 107 | + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then | ||
| 108 | + echo "::error::Tag ${TAG} already exists on the remote." | ||
| 109 | + exit 1 | ||
| 110 | + fi | ||
| 111 | + | ||
| 112 | + # 2. Release API — best effort. $GITHUB_API_URL already carries the | ||
| 113 | + # /gh prefix of the rickub REST shim. Anything other than a clear | ||
| 114 | + # 200 is treated as "cannot tell" so a shim/proxy hiccup does not | ||
| 115 | + # block a legitimate release (the git tag check above is the gate). | ||
| 116 | + code="$(curl -sS -o /tmp/release-probe.json -w '%{http_code}' \ | ||
| 117 | + -H "Authorization: Bearer ${GH_TOKEN}" \ | ||
| 118 | + -H "Accept: application/vnd.github+json" \ | ||
| 119 | + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" || echo 000)" | ||
| 120 | + case "${code}" in | ||
| 121 | + 200) | ||
| 122 | + echo "::error::A release for ${TAG} already exists (API returned 200)." | ||
| 123 | + exit 1 | ||
| 124 | + ;; | ||
| 125 | + 404) | ||
| 126 | + echo "No existing release for ${TAG}." | ||
| 127 | + ;; | ||
| 128 | + *) | ||
| 129 | + echo "::warning::Could not check for an existing ${TAG} release (HTTP ${code});" \ | ||
| 130 | + "relying on the git tag check. A 403 here means the CI proxy still blocks" \ | ||
| 131 | + "/gh paths — see RELEASING.md." | ||
| 132 | + ;; | ||
| 133 | + esac | ||
| 134 | + | ||
| 135 | + - name: Set up Go | ||
| 136 | + uses: actions/setup-go@v5 | ||
| 137 | + with: | ||
| 138 | + go-version-file: go.mod | ||
| 139 | + cache: true | ||
| 140 | + | ||
| 141 | + - name: go vet | ||
| 142 | + run: go vet ./... | ||
| 143 | + | ||
| 144 | + - name: go test | ||
| 145 | + run: go test ./... -count=1 | ||
| 146 | + | ||
| 147 | + - name: Build distribution | ||
| 148 | + run: bash scripts/build-dist.sh "${{ steps.check.outputs.version }}" | ||
| 149 | + | ||
| 150 | + - name: Release notes | ||
| 151 | + id: notes | ||
| 152 | + env: | ||
| 153 | + TAG: ${{ steps.check.outputs.tag }} | ||
| 154 | + run: | | ||
| 155 | + set -euo pipefail | ||
| 156 | + { | ||
| 157 | + echo "## rickub ${TAG}" | ||
| 158 | + echo | ||
| 159 | + echo "- Commit: \`${GITHUB_SHA}\` (\`${GITHUB_REF_NAME}\`)" | ||
| 160 | + echo "- Built by \`${GITHUB_WORKFLOW}\` run #${GITHUB_RUN_NUMBER}" | ||
| 161 | + echo | ||
| 162 | + echo '### Install' | ||
| 163 | + echo | ||
| 164 | + echo 'Download the archive for your platform, verify it, and drop the binary on your PATH:' | ||
| 165 | + echo | ||
| 166 | + echo '```sh' | ||
| 167 | + echo "tar -xzf rickub_${TAG#v}_\$(uname -s | tr '[:upper:]' '[:lower:]')_amd64.tar.gz" | ||
| 168 | + echo 'sha256sum -c SHA256SUMS --ignore-missing' | ||
| 169 | + echo 'install -m 0755 rickub /usr/local/bin/rickub' | ||
| 170 | + echo '```' | ||
| 171 | + echo | ||
| 172 | + echo '### Checksums' | ||
| 173 | + echo | ||
| 174 | + echo '```' | ||
| 175 | + cat dist/SHA256SUMS | ||
| 176 | + echo '```' | ||
| 177 | + } > "${RUNNER_TEMP}/release-notes.md" | ||
| 178 | + echo "path=${RUNNER_TEMP}/release-notes.md" >> "$GITHUB_OUTPUT" | ||
| 179 | + | ||
| 180 | + - name: Upload build artifacts | ||
| 181 | + uses: actions/upload-artifact@v4 | ||
| 182 | + with: | ||
| 183 | + name: rickub-${{ steps.check.outputs.version }} | ||
| 184 | + path: dist/ | ||
| 185 | + if-no-files-found: error | ||
| 186 | + retention-days: 90 | ||
| 187 | + | ||
| 188 | + - name: Publish release | ||
| 189 | + if: steps.check.outputs.dry_run != 'true' | ||
| 190 | + uses: softprops/action-gh-release@v2 | ||
| 191 | + with: | ||
| 192 | + tag_name: ${{ steps.check.outputs.tag }} | ||
| 193 | + name: ${{ steps.check.outputs.tag }} | ||
| 194 | + body_path: ${{ steps.notes.outputs.path }} | ||
| 195 | + # The tag does not exist yet: the rickub release shim creates it at | ||
| 196 | + # target_commitish, so the dispatched ref's commit is what gets tagged. | ||
| 197 | + target_commitish: ${{ github.sha }} | ||
| 198 | + draft: false | ||
| 199 | + prerelease: false | ||
| 200 | + files: | | ||
| 201 | + dist/*.tar.gz | ||
| 202 | + dist/SHA256SUMS | ||
| 203 | + fail_on_unmatched_files: true | ||
| 204 | + | ||
| 205 | + - name: Dry run summary | ||
| 206 | + if: steps.check.outputs.dry_run == 'true' | ||
| 207 | + run: | | ||
| 208 | + set -euo pipefail | ||
| 209 | + echo "::notice::Dry run — ${{ steps.check.outputs.tag }} was NOT published." | ||
| 210 | + echo "Artifacts that would have been released:" | ||
| 211 | + ls -l dist/ | ||
added
.gitignore +12 -0 | new file mode 100644 | ||
| @@ -0,0 +1,12 @@ | ||
| 1 | +# Built binaries | |
| 2 | +/rickub | |
| 3 | +rickub | |
| 4 | +*.exe | |
| 5 | + | |
| 6 | +# Release artifacts | |
| 7 | +/dist/ | |
| 8 | + | |
| 9 | +# Test & coverage output | |
| 10 | +*.out | |
| 11 | +coverage.txt | |
| 12 | +coverage.html | |
| new file mode 100644 | |||
| @@ -0,0 +1,12 @@ | |||
| 1 | +# Built binaries | ||
| 2 | +/rickub | ||
| 3 | +rickub | ||
| 4 | +*.exe | ||
| 5 | + | ||
| 6 | +# Release artifacts | ||
| 7 | +/dist/ | ||
| 8 | + | ||
| 9 | +# Test & coverage output | ||
| 10 | +*.out | ||
| 11 | +coverage.txt | ||
| 12 | +coverage.html | ||
added
LICENSE +21 -0 | new file mode 100644 | ||
| @@ -0,0 +1,21 @@ | ||
| 1 | +MIT License | |
| 2 | + | |
| 3 | +Copyright (c) 2026 Olivier Girardot | |
| 4 | + | |
| 5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | +of this software and associated documentation files (the "Software"), to deal | |
| 7 | +in the Software without restriction, including without limitation the rights | |
| 8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | +copies of the Software, and to permit persons to whom the Software is | |
| 10 | +furnished to do so, subject to the following conditions: | |
| 11 | + | |
| 12 | +The above copyright notice and this permission notice shall be included in all | |
| 13 | +copies or substantial portions of the Software. | |
| 14 | + | |
| 15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | +SOFTWARE. | |
| new file mode 100644 | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | +MIT License | ||
| 2 | + | ||
| 3 | +Copyright (c) 2026 Olivier Girardot | ||
| 4 | + | ||
| 5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 6 | +of this software and associated documentation files (the "Software"), to deal | ||
| 7 | +in the Software without restriction, including without limitation the rights | ||
| 8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 9 | +copies of the Software, and to permit persons to whom the Software is | ||
| 10 | +furnished to do so, subject to the following conditions: | ||
| 11 | + | ||
| 12 | +The above copyright notice and this permission notice shall be included in all | ||
| 13 | +copies or substantial portions of the Software. | ||
| 14 | + | ||
| 15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 21 | +SOFTWARE. | ||
added
README.md +241 -0 | new file mode 100644 | ||
| @@ -0,0 +1,241 @@ | ||
| 1 | +# rickub CLI | |
| 2 | + | |
| 3 | +`rickub` is the command-line interface to a [rickub](https://rickub.com) git host — | |
| 4 | +*the smartest git in the universe, on the command line.* It is a thin, standalone | |
| 5 | +HTTP client for the rickub JSON API (`/api/v1`), authenticated with a personal | |
| 6 | +access token (PAT). It imports none of the server's code: a clean client boundary. | |
| 7 | + | |
| 8 | +- Repo: `ssh://git@rickub.com/rickub/cli.git` | |
| 9 | +- Home: <https://rickub.com/rickub/cli> | |
| 10 | + | |
| 11 | +## Install | |
| 12 | + | |
| 13 | +**Download a release.** Binaries for linux (amd64, arm64) and macos (amd64, | |
| 14 | +arm64) are attached to each release at <https://rickub.com/rickub/cli/releases>. | |
| 15 | +Open that page, download the archive matching your OS and architecture, extract | |
| 16 | +it, and put `rickub` on your `PATH`: | |
| 17 | + | |
| 18 | +```sh | |
| 19 | +# after downloading the asset for your platform from the releases page: | |
| 20 | +tar -xzf rickub_<version>_<os>_<arch>.tar.gz rickub | |
| 21 | +sudo install rickub /usr/local/bin/rickub | |
| 22 | +``` | |
| 23 | + | |
| 24 | +> Note: rickub.com serves release assets from the release page itself — there is | |
| 25 | +> no `/releases/latest/download/…` redirect, so pick the asset from the page (or | |
| 26 | +> from the release's API entry) rather than guessing a URL. | |
| 27 | + | |
| 28 | +**Build from source** (Go 1.26+): | |
| 29 | + | |
| 30 | +```sh | |
| 31 | +git clone ssh://git@rickub.com/rickub/cli.git | |
| 32 | +cd cli | |
| 33 | +go build -o rickub . # produces ./rickub at the repo root | |
| 34 | +``` | |
| 35 | + | |
| 36 | +To stamp a version into the binary: | |
| 37 | + | |
| 38 | +```sh | |
| 39 | +go build -ldflags "-X rickub.com/rickub/cli/cmd.Version=$(git describe --tags)" -o rickub . | |
| 40 | +``` | |
| 41 | + | |
| 42 | +Verify: | |
| 43 | + | |
| 44 | +```sh | |
| 45 | +./rickub version | |
| 46 | +``` | |
| 47 | + | |
| 48 | +## Authenticate | |
| 49 | + | |
| 50 | +```sh | |
| 51 | +# browser (device) flow — the default. Prints a code, opens your browser, | |
| 52 | +# waits for you to approve the sign-in while logged in to the website, and | |
| 53 | +# stores the PAT the server mints. Nothing is copy-pasted. | |
| 54 | +rickub auth login | |
| 55 | +rickub auth login --host https://dev.rickub.com # any rickub host | |
| 56 | +rickub auth login --scope read # a read-only token | |
| 57 | +rickub auth login --no-browser # print the URL, don't open it | |
| 58 | +``` | |
| 59 | + | |
| 60 | +To use an existing PAT from *Settings → Tokens* instead, prefer one of the two | |
| 61 | +forms that keep the secret out of your shell: | |
| 62 | + | |
| 63 | +```sh | |
| 64 | +# 1. environment variable — nothing is written to disk | |
| 65 | +export RICKUB_TOKEN=rickub_pat_xxx | |
| 66 | +rickub repo list | |
| 67 | + | |
| 68 | +# 2. stdin, for `auth login` to verify and store it | |
| 69 | +echo "$RICKUB_PAT" | rickub auth login --with-token --host https://rickub.com | |
| 70 | +``` | |
| 71 | + | |
| 72 | +There is also a `--token rickub_pat_xxx` flag on any command. Use it only when | |
| 73 | +neither of the above fits: **arguments are visible to every process on the | |
| 74 | +machine via `ps`, and land in your shell history and in CI logs.** | |
| 75 | + | |
| 76 | +The stored token lives in `~/.config/rickub/config.yaml` (mode `0600`). The | |
| 77 | +browser flow mints a normal PAT named "CLI device login" — revoke it any time | |
| 78 | +in Settings → Tokens. | |
| 79 | + | |
| 80 | +```sh | |
| 81 | +rickub auth status # show the active host, where the token came from, and verify it | |
| 82 | +rickub auth logout # remove the stored token for the active host | |
| 83 | +``` | |
| 84 | + | |
| 85 | +### Tokens are bound to their host | |
| 86 | + | |
| 87 | +A token stored by `auth login` is saved **under the host it was verified | |
| 88 | +against** and is only ever sent back to that host. Pointing the CLI at a | |
| 89 | +different server — `--host`, `RICKUB_HOST`, or a typo — will not hand your | |
| 90 | +production credential to it; you get "no token stored for that host" instead. | |
| 91 | +Log in per host as needed: | |
| 92 | + | |
| 93 | +```sh | |
| 94 | +rickub auth login --host https://rickub.com # stored for rickub.com | |
| 95 | +rickub auth login --host http://localhost:3000 # stored separately | |
| 96 | +rickub auth logout --host http://localhost:3000 # removes only that one | |
| 97 | +``` | |
| 98 | + | |
| 99 | +A token you pass explicitly with `--token` or `RICKUB_TOKEN` is always honoured | |
| 100 | +for whatever host is in effect — that is your call to make, not the config's. | |
| 101 | + | |
| 102 | +The CLI also prints a warning to stderr before sending a token to a host over | |
| 103 | +plain `http://`, unless that host is loopback (`localhost`, `127.0.0.1`, `[::1]`), | |
| 104 | +where the request never reaches the network. | |
| 105 | + | |
| 106 | +### Config & environment | |
| 107 | + | |
| 108 | +Effective host and token are resolved with this precedence (first wins): | |
| 109 | + | |
| 110 | +| Value | Precedence | | |
| 111 | +|-------|-----------| | |
| 112 | +| host | `--host` flag → `RICKUB_HOST` env → config file → `https://rickub.com` | | |
| 113 | +| token | `--token` flag → `RICKUB_TOKEN` env → config file entry **for that host** | | |
| 114 | + | |
| 115 | +Point the CLI at a dev instance with `--host http://localhost:3000` (or set | |
| 116 | +`RICKUB_HOST`). `XDG_CONFIG_HOME` is honoured for the config file location. | |
| 117 | + | |
| 118 | +The config file looks like this: | |
| 119 | + | |
| 120 | +```yaml | |
| 121 | +host: https://rickub.com | |
| 122 | +hosts: | |
| 123 | + https://rickub.com: | |
| 124 | + token: rickub_pat_… | |
| 125 | + http://localhost:3000: | |
| 126 | + token: rickub_pat_… | |
| 127 | +``` | |
| 128 | + | |
| 129 | +## Command reference | |
| 130 | + | |
| 131 | +Every command supports `--help`, and `--json` for raw JSON output instead of a table. | |
| 132 | + | |
| 133 | +``` | |
| 134 | +rickub auth login|status|logout | |
| 135 | + | |
| 136 | +rickub repo list [--user H | --org H] [--page N] [--per-page N] | |
| 137 | +rickub repo create <name> [--org H] [--public|--private] [-d desc] | |
| 138 | +rickub repo view <owner/repo> | |
| 139 | +rickub repo edit <owner/repo> [--visibility public|private] [-d desc] [--default-branch B] | |
| 140 | +rickub repo delete <owner/repo> [--yes] | |
| 141 | +rickub repo clone <owner/repo> [dir] [-- git-args…] | |
| 142 | +rickub repo files <owner/repo> [path] [--ref R] | |
| 143 | +rickub repo cat <owner/repo> <path> [--ref R] | |
| 144 | +rickub repo commits <owner/repo> [ref] [--page N] [--per-page N] | |
| 145 | +rickub repo compare <owner/repo> <base...head> | |
| 146 | +rickub repo collaborator list <owner/repo> | |
| 147 | +rickub repo collaborator add <owner/repo> <user> [--permission read|write|admin] | |
| 148 | +rickub repo collaborator remove <owner/repo> <user> | |
| 149 | + | |
| 150 | +rickub pr list [-R owner/repo] [--state open|closed|merged|all] | |
| 151 | +rickub pr view <number> [-R owner/repo] | |
| 152 | +rickub pr create [-R owner/repo] --base B --head H --title T [-b body] [--head-owner O --head-repo R] | |
| 153 | +rickub pr merge <number> [-R owner/repo] [--method merge|squash|ff-only] | |
| 154 | +rickub pr close <number> [-R owner/repo] | |
| 155 | +rickub pr comment <number> [-R owner/repo] -b "text" | |
| 156 | + | |
| 157 | +rickub run list [-R owner/repo] | |
| 158 | +rickub run view <number> [-R owner/repo] | |
| 159 | +rickub run logs <number> [-R owner/repo] | |
| 160 | +rickub run rerun <number> [-R owner/repo] | |
| 161 | +rickub run cancel <number> [-R owner/repo] | |
| 162 | +rickub run dispatch [-R owner/repo] [--ref B] | |
| 163 | +rickub run watch <number> [-R owner/repo] [--interval 2s] [--timeout 30m] [--logs] | |
| 164 | + # follow until terminal; exit 0 on success, 1 otherwise | |
| 165 | + | |
| 166 | +rickub issue list [-R owner/repo] [--state open|closed|all] [--page N] | |
| 167 | +rickub issue view <number> [-R owner/repo] | |
| 168 | +rickub issue create [-R owner/repo] -t "title" [-b "body" | -b - < file] | |
| 169 | +rickub issue close <number> [-R owner/repo] | |
| 170 | +rickub issue reopen <number> [-R owner/repo] | |
| 171 | +rickub issue comment <number> [-R owner/repo] -b "text" | |
| 172 | +rickub issue label <number> [-R owner/repo] --labels "bug,help wanted" | --clear | |
| 173 | +rickub issue milestone <number> [-R owner/repo] --milestone "v1.0" | --clear | |
| 174 | +rickub issue assign <number> [-R owner/repo] --user H [--remove] | |
| 175 | +rickub issue labels [-R owner/repo] | |
| 176 | + | |
| 177 | +rickub milestone list [-R owner/repo] [--state open|closed|all] | |
| 178 | +rickub milestone create [-R owner/repo] -t "v1.0" [-d desc] [--due YYYY-MM-DD] | |
| 179 | +rickub milestone close <id> [-R owner/repo] | |
| 180 | +rickub milestone reopen <id> [-R owner/repo] | |
| 181 | +rickub milestone delete <id> [-R owner/repo] | |
| 182 | + | |
| 183 | +rickub org view <handle> | |
| 184 | +rickub org members <handle> | |
| 185 | +rickub org teams <handle> | |
| 186 | + | |
| 187 | +rickub search repos <query> [--page N] [--per-page N] | |
| 188 | + | |
| 189 | +rickub api <METHOD> <path> [-f key=value] [-F key=value] # raw escape hatch | |
| 190 | +rickub browse [owner/repo] [--print] | |
| 191 | +rickub version | |
| 192 | +``` | |
| 193 | + | |
| 194 | +### Repo selector | |
| 195 | + | |
| 196 | +`pr`, `run`, `issue`, and `milestone` subcommands take `-R/--repo owner/repo`. When omitted, the repo is | |
| 197 | +inferred from the current directory's git `origin` remote (any of `https://`, | |
| 198 | +`ssh://`, or `git@host:owner/repo` forms). | |
| 199 | + | |
| 200 | +### `rickub api` — raw escape hatch | |
| 201 | + | |
| 202 | +Like `gh api`. `PATH` is relative to `/api/v1` (a leading `/api/v1` or `/` is | |
| 203 | +optional). `--field/-f` values are type-inferred (`true`/`false`/`null`/numbers); | |
| 204 | +`--raw-field/-F` forces a string. Fields become query parameters for `GET`/`HEAD` | |
| 205 | +and a JSON body otherwise. | |
| 206 | + | |
| 207 | +```sh | |
| 208 | +rickub api GET /user | |
| 209 | +rickub api GET search/repos -f q=api | |
| 210 | +rickub api POST /repos -f name=demo -f visibility=public | |
| 211 | +``` | |
| 212 | + | |
| 213 | +## Errors & exit codes | |
| 214 | + | |
| 215 | +API errors are surfaced from the `{error:{code,message}}` envelope, e.g. | |
| 216 | +`rickub: repository not found (not_found)`, and the process exits non-zero. | |
| 217 | + | |
| 218 | +## Pointing at a dev host | |
| 219 | + | |
| 220 | +```sh | |
| 221 | +# run a rickub instance on a spare port, then: | |
| 222 | +echo "$DEV_PAT" | rickub auth login --with-token --host http://localhost:3000 | |
| 223 | +rickub repo list --user <you> | |
| 224 | +``` | |
| 225 | + | |
| 226 | +The dev token is stored separately from your rickub.com token; both stay put. | |
| 227 | + | |
| 228 | +## Development | |
| 229 | + | |
| 230 | +```sh | |
| 231 | +go build ./... && go test ./... && go vet ./... && gofmt -l . | |
| 232 | +``` | |
| 233 | + | |
| 234 | +Layout: | |
| 235 | + | |
| 236 | +``` | |
| 237 | +main.go # entrypoint; maps API errors to exit codes | |
| 238 | +cmd/ # Cobra command tree (auth, repo, pr, issue, milestone, run, org, search, api, browse) | |
| 239 | +internal/api/ # typed HTTP client for the rickub JSON API | |
| 240 | +internal/config/ # config load/save + host/token resolution and binding | |
| 241 | +``` | |
| new file mode 100644 | |||
| @@ -0,0 +1,241 @@ | |||
| 1 | +# rickub CLI | ||
| 2 | + | ||
| 3 | +`rickub` is the command-line interface to a [rickub](https://rickub.com) git host — | ||
| 4 | +*the smartest git in the universe, on the command line.* It is a thin, standalone | ||
| 5 | +HTTP client for the rickub JSON API (`/api/v1`), authenticated with a personal | ||
| 6 | +access token (PAT). It imports none of the server's code: a clean client boundary. | ||
| 7 | + | ||
| 8 | +- Repo: `ssh://git@rickub.com/rickub/cli.git` | ||
| 9 | +- Home: <https://rickub.com/rickub/cli> | ||
| 10 | + | ||
| 11 | +## Install | ||
| 12 | + | ||
| 13 | +**Download a release.** Binaries for linux (amd64, arm64) and macos (amd64, | ||
| 14 | +arm64) are attached to each release at <https://rickub.com/rickub/cli/releases>. | ||
| 15 | +Open that page, download the archive matching your OS and architecture, extract | ||
| 16 | +it, and put `rickub` on your `PATH`: | ||
| 17 | + | ||
| 18 | +```sh | ||
| 19 | +# after downloading the asset for your platform from the releases page: | ||
| 20 | +tar -xzf rickub_<version>_<os>_<arch>.tar.gz rickub | ||
| 21 | +sudo install rickub /usr/local/bin/rickub | ||
| 22 | +``` | ||
| 23 | + | ||
| 24 | +> Note: rickub.com serves release assets from the release page itself — there is | ||
| 25 | +> no `/releases/latest/download/…` redirect, so pick the asset from the page (or | ||
| 26 | +> from the release's API entry) rather than guessing a URL. | ||
| 27 | + | ||
| 28 | +**Build from source** (Go 1.26+): | ||
| 29 | + | ||
| 30 | +```sh | ||
| 31 | +git clone ssh://git@rickub.com/rickub/cli.git | ||
| 32 | +cd cli | ||
| 33 | +go build -o rickub . # produces ./rickub at the repo root | ||
| 34 | +``` | ||
| 35 | + | ||
| 36 | +To stamp a version into the binary: | ||
| 37 | + | ||
| 38 | +```sh | ||
| 39 | +go build -ldflags "-X rickub.com/rickub/cli/cmd.Version=$(git describe --tags)" -o rickub . | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +Verify: | ||
| 43 | + | ||
| 44 | +```sh | ||
| 45 | +./rickub version | ||
| 46 | +``` | ||
| 47 | + | ||
| 48 | +## Authenticate | ||
| 49 | + | ||
| 50 | +```sh | ||
| 51 | +# browser (device) flow — the default. Prints a code, opens your browser, | ||
| 52 | +# waits for you to approve the sign-in while logged in to the website, and | ||
| 53 | +# stores the PAT the server mints. Nothing is copy-pasted. | ||
| 54 | +rickub auth login | ||
| 55 | +rickub auth login --host https://dev.rickub.com # any rickub host | ||
| 56 | +rickub auth login --scope read # a read-only token | ||
| 57 | +rickub auth login --no-browser # print the URL, don't open it | ||
| 58 | +``` | ||
| 59 | + | ||
| 60 | +To use an existing PAT from *Settings → Tokens* instead, prefer one of the two | ||
| 61 | +forms that keep the secret out of your shell: | ||
| 62 | + | ||
| 63 | +```sh | ||
| 64 | +# 1. environment variable — nothing is written to disk | ||
| 65 | +export RICKUB_TOKEN=rickub_pat_xxx | ||
| 66 | +rickub repo list | ||
| 67 | + | ||
| 68 | +# 2. stdin, for `auth login` to verify and store it | ||
| 69 | +echo "$RICKUB_PAT" | rickub auth login --with-token --host https://rickub.com | ||
| 70 | +``` | ||
| 71 | + | ||
| 72 | +There is also a `--token rickub_pat_xxx` flag on any command. Use it only when | ||
| 73 | +neither of the above fits: **arguments are visible to every process on the | ||
| 74 | +machine via `ps`, and land in your shell history and in CI logs.** | ||
| 75 | + | ||
| 76 | +The stored token lives in `~/.config/rickub/config.yaml` (mode `0600`). The | ||
| 77 | +browser flow mints a normal PAT named "CLI device login" — revoke it any time | ||
| 78 | +in Settings → Tokens. | ||
| 79 | + | ||
| 80 | +```sh | ||
| 81 | +rickub auth status # show the active host, where the token came from, and verify it | ||
| 82 | +rickub auth logout # remove the stored token for the active host | ||
| 83 | +``` | ||
| 84 | + | ||
| 85 | +### Tokens are bound to their host | ||
| 86 | + | ||
| 87 | +A token stored by `auth login` is saved **under the host it was verified | ||
| 88 | +against** and is only ever sent back to that host. Pointing the CLI at a | ||
| 89 | +different server — `--host`, `RICKUB_HOST`, or a typo — will not hand your | ||
| 90 | +production credential to it; you get "no token stored for that host" instead. | ||
| 91 | +Log in per host as needed: | ||
| 92 | + | ||
| 93 | +```sh | ||
| 94 | +rickub auth login --host https://rickub.com # stored for rickub.com | ||
| 95 | +rickub auth login --host http://localhost:3000 # stored separately | ||
| 96 | +rickub auth logout --host http://localhost:3000 # removes only that one | ||
| 97 | +``` | ||
| 98 | + | ||
| 99 | +A token you pass explicitly with `--token` or `RICKUB_TOKEN` is always honoured | ||
| 100 | +for whatever host is in effect — that is your call to make, not the config's. | ||
| 101 | + | ||
| 102 | +The CLI also prints a warning to stderr before sending a token to a host over | ||
| 103 | +plain `http://`, unless that host is loopback (`localhost`, `127.0.0.1`, `[::1]`), | ||
| 104 | +where the request never reaches the network. | ||
| 105 | + | ||
| 106 | +### Config & environment | ||
| 107 | + | ||
| 108 | +Effective host and token are resolved with this precedence (first wins): | ||
| 109 | + | ||
| 110 | +| Value | Precedence | | ||
| 111 | +|-------|-----------| | ||
| 112 | +| host | `--host` flag → `RICKUB_HOST` env → config file → `https://rickub.com` | | ||
| 113 | +| token | `--token` flag → `RICKUB_TOKEN` env → config file entry **for that host** | | ||
| 114 | + | ||
| 115 | +Point the CLI at a dev instance with `--host http://localhost:3000` (or set | ||
| 116 | +`RICKUB_HOST`). `XDG_CONFIG_HOME` is honoured for the config file location. | ||
| 117 | + | ||
| 118 | +The config file looks like this: | ||
| 119 | + | ||
| 120 | +```yaml | ||
| 121 | +host: https://rickub.com | ||
| 122 | +hosts: | ||
| 123 | + https://rickub.com: | ||
| 124 | + token: rickub_pat_… | ||
| 125 | + http://localhost:3000: | ||
| 126 | + token: rickub_pat_… | ||
| 127 | +``` | ||
| 128 | + | ||
| 129 | +## Command reference | ||
| 130 | + | ||
| 131 | +Every command supports `--help`, and `--json` for raw JSON output instead of a table. | ||
| 132 | + | ||
| 133 | +``` | ||
| 134 | +rickub auth login|status|logout | ||
| 135 | + | ||
| 136 | +rickub repo list [--user H | --org H] [--page N] [--per-page N] | ||
| 137 | +rickub repo create <name> [--org H] [--public|--private] [-d desc] | ||
| 138 | +rickub repo view <owner/repo> | ||
| 139 | +rickub repo edit <owner/repo> [--visibility public|private] [-d desc] [--default-branch B] | ||
| 140 | +rickub repo delete <owner/repo> [--yes] | ||
| 141 | +rickub repo clone <owner/repo> [dir] [-- git-args…] | ||
| 142 | +rickub repo files <owner/repo> [path] [--ref R] | ||
| 143 | +rickub repo cat <owner/repo> <path> [--ref R] | ||
| 144 | +rickub repo commits <owner/repo> [ref] [--page N] [--per-page N] | ||
| 145 | +rickub repo compare <owner/repo> <base...head> | ||
| 146 | +rickub repo collaborator list <owner/repo> | ||
| 147 | +rickub repo collaborator add <owner/repo> <user> [--permission read|write|admin] | ||
| 148 | +rickub repo collaborator remove <owner/repo> <user> | ||
| 149 | + | ||
| 150 | +rickub pr list [-R owner/repo] [--state open|closed|merged|all] | ||
| 151 | +rickub pr view <number> [-R owner/repo] | ||
| 152 | +rickub pr create [-R owner/repo] --base B --head H --title T [-b body] [--head-owner O --head-repo R] | ||
| 153 | +rickub pr merge <number> [-R owner/repo] [--method merge|squash|ff-only] | ||
| 154 | +rickub pr close <number> [-R owner/repo] | ||
| 155 | +rickub pr comment <number> [-R owner/repo] -b "text" | ||
| 156 | + | ||
| 157 | +rickub run list [-R owner/repo] | ||
| 158 | +rickub run view <number> [-R owner/repo] | ||
| 159 | +rickub run logs <number> [-R owner/repo] | ||
| 160 | +rickub run rerun <number> [-R owner/repo] | ||
| 161 | +rickub run cancel <number> [-R owner/repo] | ||
| 162 | +rickub run dispatch [-R owner/repo] [--ref B] | ||
| 163 | +rickub run watch <number> [-R owner/repo] [--interval 2s] [--timeout 30m] [--logs] | ||
| 164 | + # follow until terminal; exit 0 on success, 1 otherwise | ||
| 165 | + | ||
| 166 | +rickub issue list [-R owner/repo] [--state open|closed|all] [--page N] | ||
| 167 | +rickub issue view <number> [-R owner/repo] | ||
| 168 | +rickub issue create [-R owner/repo] -t "title" [-b "body" | -b - < file] | ||
| 169 | +rickub issue close <number> [-R owner/repo] | ||
| 170 | +rickub issue reopen <number> [-R owner/repo] | ||
| 171 | +rickub issue comment <number> [-R owner/repo] -b "text" | ||
| 172 | +rickub issue label <number> [-R owner/repo] --labels "bug,help wanted" | --clear | ||
| 173 | +rickub issue milestone <number> [-R owner/repo] --milestone "v1.0" | --clear | ||
| 174 | +rickub issue assign <number> [-R owner/repo] --user H [--remove] | ||
| 175 | +rickub issue labels [-R owner/repo] | ||
| 176 | + | ||
| 177 | +rickub milestone list [-R owner/repo] [--state open|closed|all] | ||
| 178 | +rickub milestone create [-R owner/repo] -t "v1.0" [-d desc] [--due YYYY-MM-DD] | ||
| 179 | +rickub milestone close <id> [-R owner/repo] | ||
| 180 | +rickub milestone reopen <id> [-R owner/repo] | ||
| 181 | +rickub milestone delete <id> [-R owner/repo] | ||
| 182 | + | ||
| 183 | +rickub org view <handle> | ||
| 184 | +rickub org members <handle> | ||
| 185 | +rickub org teams <handle> | ||
| 186 | + | ||
| 187 | +rickub search repos <query> [--page N] [--per-page N] | ||
| 188 | + | ||
| 189 | +rickub api <METHOD> <path> [-f key=value] [-F key=value] # raw escape hatch | ||
| 190 | +rickub browse [owner/repo] [--print] | ||
| 191 | +rickub version | ||
| 192 | +``` | ||
| 193 | + | ||
| 194 | +### Repo selector | ||
| 195 | + | ||
| 196 | +`pr`, `run`, `issue`, and `milestone` subcommands take `-R/--repo owner/repo`. When omitted, the repo is | ||
| 197 | +inferred from the current directory's git `origin` remote (any of `https://`, | ||
| 198 | +`ssh://`, or `git@host:owner/repo` forms). | ||
| 199 | + | ||
| 200 | +### `rickub api` — raw escape hatch | ||
| 201 | + | ||
| 202 | +Like `gh api`. `PATH` is relative to `/api/v1` (a leading `/api/v1` or `/` is | ||
| 203 | +optional). `--field/-f` values are type-inferred (`true`/`false`/`null`/numbers); | ||
| 204 | +`--raw-field/-F` forces a string. Fields become query parameters for `GET`/`HEAD` | ||
| 205 | +and a JSON body otherwise. | ||
| 206 | + | ||
| 207 | +```sh | ||
| 208 | +rickub api GET /user | ||
| 209 | +rickub api GET search/repos -f q=api | ||
| 210 | +rickub api POST /repos -f name=demo -f visibility=public | ||
| 211 | +``` | ||
| 212 | + | ||
| 213 | +## Errors & exit codes | ||
| 214 | + | ||
| 215 | +API errors are surfaced from the `{error:{code,message}}` envelope, e.g. | ||
| 216 | +`rickub: repository not found (not_found)`, and the process exits non-zero. | ||
| 217 | + | ||
| 218 | +## Pointing at a dev host | ||
| 219 | + | ||
| 220 | +```sh | ||
| 221 | +# run a rickub instance on a spare port, then: | ||
| 222 | +echo "$DEV_PAT" | rickub auth login --with-token --host http://localhost:3000 | ||
| 223 | +rickub repo list --user <you> | ||
| 224 | +``` | ||
| 225 | + | ||
| 226 | +The dev token is stored separately from your rickub.com token; both stay put. | ||
| 227 | + | ||
| 228 | +## Development | ||
| 229 | + | ||
| 230 | +```sh | ||
| 231 | +go build ./... && go test ./... && go vet ./... && gofmt -l . | ||
| 232 | +``` | ||
| 233 | + | ||
| 234 | +Layout: | ||
| 235 | + | ||
| 236 | +``` | ||
| 237 | +main.go # entrypoint; maps API errors to exit codes | ||
| 238 | +cmd/ # Cobra command tree (auth, repo, pr, issue, milestone, run, org, search, api, browse) | ||
| 239 | +internal/api/ # typed HTTP client for the rickub JSON API | ||
| 240 | +internal/config/ # config load/save + host/token resolution and binding | ||
| 241 | +``` | ||
added
RELEASING.md +228 -0 | new file mode 100644 | ||
| @@ -0,0 +1,228 @@ | ||
| 1 | +# Releasing `rickub` | |
| 2 | + | |
| 3 | +This repository ships three CI workflows, all running on rickub's own | |
| 4 | +GitHub-Actions-compatible CI: | |
| 5 | + | |
| 6 | +| Workflow | Trigger | What it does | | |
| 7 | +| --- | --- | --- | | |
| 8 | +| [`.github/workflows/ci.yml`](.github/workflows/ci.yml) | every pull request, and pushes to `main` | `gofmt` check, `go vet`, `go test`, `go build` | | |
| 9 | +| [`.github/workflows/rc.yml`](.github/workflows/rc.yml) | every push to `main` | tests, cross-compiles all four targets, publishes a **prerelease** `v<BASE>-rc.<run_number>` | | |
| 10 | +| [`.github/workflows/release.yml`](.github/workflows/release.yml) | **manual only** (`workflow_dispatch`) | tests, cross-compiles, publishes a real release `v<version>` | | |
| 11 | + | |
| 12 | +Both release-producing workflows call the same script, | |
| 13 | +[`scripts/build-dist.sh`](scripts/build-dist.sh), so an RC and a real release | |
| 14 | +are byte-for-byte the same pipeline with a different version stamp. | |
| 15 | + | |
| 16 | +--- | |
| 17 | + | |
| 18 | +## ⚠️ Prerequisite: the CI proxy must allow `/gh/` | |
| 19 | + | |
| 20 | +**Release publishing from CI fails until an operator changes the rickub server.** | |
| 21 | + | |
| 22 | +The CI guest reaches the outside world through `web/ciproxy`, whose allowlist | |
| 23 | +today only covers the artifact/cache results service. The release REST shim | |
| 24 | +lives under the `/gh` prefix (`$GITHUB_API_URL`), and the proxy currently | |
| 25 | +answers **403** for those paths, so `softprops/action-gh-release@v2` cannot | |
| 26 | +create the release or upload assets. | |
| 27 | + | |
| 28 | +To unblock it, in the **rickhub server repo**: | |
| 29 | + | |
| 30 | +1. `web/ciproxy/proxy.go` — add the release shim prefix to `allowedPathPrefixes`: | |
| 31 | + | |
| 32 | + ```go | |
| 33 | + var allowedPathPrefixes = []string{ | |
| 34 | + "/twirp/github.actions.results.api.v1.ArtifactService/", | |
| 35 | + "/twirp/github.actions.results.api.v1.CacheService/", | |
| 36 | + "/gh/", // GitHub-compatible REST shim: releases + asset upload | |
| 37 | + } | |
| 38 | + ``` | |
| 39 | + | |
| 40 | +2. `web/ciproxy/proxy.go` — `allowedMethods` is currently | |
| 41 | + `GET / HEAD / POST / PUT` only. Release *creation* and asset upload are | |
| 42 | + `POST`, but editing a release is `PATCH` and removing an asset is `DELETE`; | |
| 43 | + add whichever verbs you intend to support. | |
| 44 | + | |
| 45 | +3. `web/ciproxy/proxy_test.go` — add coverage for the new prefix: a `/gh/repos/…` | |
| 46 | + path is allowed, a non-`/gh` path is still denied, and path traversal such as | |
| 47 | + `/gh/../login` is still refused (`Allowed` rejects anything non-canonical). | |
| 48 | + Also re-run `web/ci_proxy_allowlist_conformance_test.go`, which cross-checks | |
| 49 | + the allowlist against the registered mux routes. | |
| 50 | + | |
| 51 | +Until that ships, `rc.yml` and `release.yml` will build and upload their | |
| 52 | +`actions/upload-artifact` bundle successfully and then fail on the | |
| 53 | +"Publish …" step. The archives are still downloadable from the run's artifacts, | |
| 54 | +so you can release by hand in the meantime. | |
| 55 | + | |
| 56 | +You can rehearse the whole pipeline without touching the shim by dispatching | |
| 57 | +`release.yml` with **`dry_run: true`** — it builds, tests, verifies the tag is | |
| 58 | +free and uploads the artifacts, but never calls the release API. | |
| 59 | + | |
| 60 | +--- | |
| 61 | + | |
| 62 | +## The `VERSION` file | |
| 63 | + | |
| 64 | +[`VERSION`](VERSION) at the repository root holds the **next** version to be | |
| 65 | +released, as a bare `MAJOR.MINOR.PATCH` string with no leading `v`: | |
| 66 | + | |
| 67 | +``` | |
| 68 | +0.1.0 | |
| 69 | +``` | |
| 70 | + | |
| 71 | +* `rc.yml` reads it to name release candidates: with `VERSION` = `0.1.0`, the | |
| 72 | + 17th push to `main` publishes the prerelease `v0.1.0-rc.17`. | |
| 73 | +* `release.yml` compares it against the dispatched version and emits a | |
| 74 | + **warning** (not a failure) on a mismatch, so an out-of-band hotfix is still | |
| 75 | + possible. | |
| 76 | + | |
| 77 | +**Bumping `VERSION` is a normal pull request.** After releasing `v0.1.0`, open a | |
| 78 | +PR setting `VERSION` to `0.2.0` (or `0.1.1`); once it merges, RCs on `main` | |
| 79 | +start counting toward the next release. Nothing in CI ever writes to this file. | |
| 80 | + | |
| 81 | +--- | |
| 82 | + | |
| 83 | +## Release candidates (automatic) | |
| 84 | + | |
| 85 | +Every push to `main` runs `rc.yml`: | |
| 86 | + | |
| 87 | +1. `go test ./...` | |
| 88 | +2. `scripts/build-dist.sh <BASE>-rc.<run_number>` | |
| 89 | +3. publishes a **prerelease** at tag `v<BASE>-rc.<run_number>`, with the four | |
| 90 | + `.tar.gz` archives and `SHA256SUMS` attached. | |
| 91 | + | |
| 92 | +The tag does not exist beforehand — the release shim creates it at | |
| 93 | +`target_commitish`, which the workflow sets to `${{ github.sha }}`, so CI cuts | |
| 94 | +its own RC tags. | |
| 95 | + | |
| 96 | +`concurrency: { group: rc, cancel-in-progress: true }` means only the newest | |
| 97 | +push to `main` is building at any moment; superseded RC runs are cancelled, so | |
| 98 | +RC numbers are not contiguous. That is expected — `run_number` is the source of | |
| 99 | +uniqueness, not a count of published RCs. | |
| 100 | + | |
| 101 | +Releases created by CI do **not** re-trigger workflows (the server has a | |
| 102 | +recursion guard), so an RC never kicks off another build. | |
| 103 | + | |
| 104 | +--- | |
| 105 | + | |
| 106 | +## Cutting a real release | |
| 107 | + | |
| 108 | +1. Make sure `main` is green and the latest RC is the build you want to ship. | |
| 109 | +2. If needed, land a PR bumping [`VERSION`](VERSION) to the version you are | |
| 110 | + about to release. | |
| 111 | +3. In the rickub web UI, open **Actions → Release → Run workflow**. | |
| 112 | +4. Pick the branch/ref (normally `main`), enter the version as bare | |
| 113 | + `MAJOR.MINOR.PATCH` — e.g. `1.2.3`, **no leading `v`** — leave `dry_run` | |
| 114 | + unchecked, and run it. | |
| 115 | + | |
| 116 | +The workflow then: | |
| 117 | + | |
| 118 | +* validates the format and refuses a leading `v` or anything that is not | |
| 119 | + `X.Y.Z`; | |
| 120 | +* refuses to continue if the tag `v<version>` already exists locally or on the | |
| 121 | + remote, and additionally probes the releases API for that tag (a non-200, | |
| 122 | + non-404 answer is only a warning — the git tag check is the real gate); | |
| 123 | +* runs `go vet` and `go test`; | |
| 124 | +* builds the four archives + `SHA256SUMS` via `scripts/build-dist.sh`; | |
| 125 | +* publishes a non-prerelease release at `v<version>`, targeting | |
| 126 | + `${{ github.sha }}` — the exact commit of the ref you dispatched — so the tag | |
| 127 | + is created at that commit. | |
| 128 | + | |
| 129 | +There is no approval gate: rickub CI silently drops `environment:`, so the | |
| 130 | +permission to run this workflow *is* the permission to release. | |
| 131 | + | |
| 132 | +### ⚠️ Use the web UI, not the dispatch API | |
| 133 | + | |
| 134 | +`release.yml` is the **only** workflow in this repository that declares | |
| 135 | +`workflow_dispatch`, and it must stay that way. | |
| 136 | + | |
| 137 | +The rickub dispatch API — `POST /api/v1/repos/{owner}/{repo}/actions/dispatch` | |
| 138 | +and the `rickub run dispatch` CLI — does not take a workflow name: it fires | |
| 139 | +**every** `workflow_dispatch` workflow on the ref. If a second dispatchable | |
| 140 | +workflow were added here, one `rickub run dispatch` would start both. Keeping | |
| 141 | +the trigger unique makes that failure mode impossible. | |
| 142 | + | |
| 143 | +The web UI's **Run workflow** button *can* target a single workflow, so it is | |
| 144 | +the supported way to cut a release. If you must use the API, remember it will | |
| 145 | +run `release.yml` (and only `release.yml`, as long as this rule holds), and it | |
| 146 | +still needs the `version` input. | |
| 147 | + | |
| 148 | +If you ever need a second manually-triggered pipeline, give it a | |
| 149 | +`workflow_call` trigger and invoke it as a reusable workflow from `release.yml` | |
| 150 | +rather than adding another `workflow_dispatch`. | |
| 151 | + | |
| 152 | +--- | |
| 153 | + | |
| 154 | +## Artifacts | |
| 155 | + | |
| 156 | +`scripts/build-dist.sh` produces, in `dist/`: | |
| 157 | + | |
| 158 | +``` | |
| 159 | +rickub_<version>_linux_amd64.tar.gz | |
| 160 | +rickub_<version>_linux_arm64.tar.gz | |
| 161 | +rickub_<version>_darwin_amd64.tar.gz | |
| 162 | +rickub_<version>_darwin_arm64.tar.gz | |
| 163 | +SHA256SUMS | |
| 164 | +``` | |
| 165 | + | |
| 166 | +`<version>` is the bare version (no `v`): `1.2.3` for a release, | |
| 167 | +`0.1.0-rc.17` for a candidate. Each archive contains the `rickub` binary at the | |
| 168 | +top level plus `README.md` (and `LICENSE`, automatically, once this repo has | |
| 169 | +one). | |
| 170 | + | |
| 171 | +All four are cross-compiled on a single `ubuntu-latest` runner with | |
| 172 | +`CGO_ENABLED=0` and `-trimpath`. The rickub runner fleet is Linux/amd64 only — | |
| 173 | +there are no macOS or arm64 hosts — so the darwin and arm64 binaries are never | |
| 174 | +executed by CI. Keeping the CLI pure Go is what makes this work; introducing | |
| 175 | +cgo would break three of the four targets. | |
| 176 | + | |
| 177 | +The version is stamped with | |
| 178 | +`-ldflags "-X rickub.com/rickub/cli/cmd.Version=<version>"` (plus `-s -w`), so | |
| 179 | +`rickub version` reports the release version instead of the `dev` default. | |
| 180 | + | |
| 181 | +To verify a download: | |
| 182 | + | |
| 183 | +```sh | |
| 184 | +sha256sum -c SHA256SUMS --ignore-missing | |
| 185 | +``` | |
| 186 | + | |
| 187 | +Both workflows also upload `dist/` via `actions/upload-artifact@v4`, so the | |
| 188 | +binaries are retrievable from the run page even if the release API call fails. | |
| 189 | + | |
| 190 | +--- | |
| 191 | + | |
| 192 | +## Building locally | |
| 193 | + | |
| 194 | +```sh | |
| 195 | +# same script CI runs | |
| 196 | +scripts/build-dist.sh 1.2.3 | |
| 197 | + | |
| 198 | +# single local binary | |
| 199 | +go build -ldflags "-X rickub.com/rickub/cli/cmd.Version=$(cat VERSION)-dev" -o rickub . | |
| 200 | +``` | |
| 201 | + | |
| 202 | +The script needs Go and `tar`; it uses `sha256sum` where available and falls | |
| 203 | +back to `shasum -a 256` on macOS. It stages into a temporary directory and | |
| 204 | +leaves nothing untracked behind — `dist/` is already in `.gitignore`. | |
| 205 | + | |
| 206 | +--- | |
| 207 | + | |
| 208 | +## Troubleshooting | |
| 209 | + | |
| 210 | +**`403` publishing the release.** The `/gh` ciproxy allowlist prerequisite at | |
| 211 | +the top of this document has not landed yet. | |
| 212 | + | |
| 213 | +**`Resource not accessible by integration` / `404` on release creation.** The | |
| 214 | +`GITHUB_TOKEN` is read-only by default; the workflow must declare | |
| 215 | +`permissions: { contents: write }`. Both `rc.yml` and `release.yml` do. | |
| 216 | + | |
| 217 | +**`422` uploading an asset.** An asset with that name already exists on the | |
| 218 | +release — the shim rejects duplicates. This normally means a partially | |
| 219 | +completed run is being retried; delete the release (or the asset) and re-run. | |
| 220 | + | |
| 221 | +**Editing a release via `PATCH`.** The shim reads `draft` and `prerelease` | |
| 222 | +unconditionally, so **always send both fields** in a PATCH body or you will | |
| 223 | +silently flip a release to draft. | |
| 224 | + | |
| 225 | +**Nothing to release / `make_latest` ignored.** The shim does not implement | |
| 226 | +`make_latest` or `generate_release_notes`, and there is no | |
| 227 | +`GET /releases/latest`. The workflows therefore write their own release notes | |
| 228 | +and never ask the API to pick a "latest" release. | |
| new file mode 100644 | |||
| @@ -0,0 +1,228 @@ | |||
| 1 | +# Releasing `rickub` | ||
| 2 | + | ||
| 3 | +This repository ships three CI workflows, all running on rickub's own | ||
| 4 | +GitHub-Actions-compatible CI: | ||
| 5 | + | ||
| 6 | +| Workflow | Trigger | What it does | | ||
| 7 | +| --- | --- | --- | | ||
| 8 | +| [`.github/workflows/ci.yml`](.github/workflows/ci.yml) | every pull request, and pushes to `main` | `gofmt` check, `go vet`, `go test`, `go build` | | ||
| 9 | +| [`.github/workflows/rc.yml`](.github/workflows/rc.yml) | every push to `main` | tests, cross-compiles all four targets, publishes a **prerelease** `v<BASE>-rc.<run_number>` | | ||
| 10 | +| [`.github/workflows/release.yml`](.github/workflows/release.yml) | **manual only** (`workflow_dispatch`) | tests, cross-compiles, publishes a real release `v<version>` | | ||
| 11 | + | ||
| 12 | +Both release-producing workflows call the same script, | ||
| 13 | +[`scripts/build-dist.sh`](scripts/build-dist.sh), so an RC and a real release | ||
| 14 | +are byte-for-byte the same pipeline with a different version stamp. | ||
| 15 | + | ||
| 16 | +--- | ||
| 17 | + | ||
| 18 | +## ⚠️ Prerequisite: the CI proxy must allow `/gh/` | ||
| 19 | + | ||
| 20 | +**Release publishing from CI fails until an operator changes the rickub server.** | ||
| 21 | + | ||
| 22 | +The CI guest reaches the outside world through `web/ciproxy`, whose allowlist | ||
| 23 | +today only covers the artifact/cache results service. The release REST shim | ||
| 24 | +lives under the `/gh` prefix (`$GITHUB_API_URL`), and the proxy currently | ||
| 25 | +answers **403** for those paths, so `softprops/action-gh-release@v2` cannot | ||
| 26 | +create the release or upload assets. | ||
| 27 | + | ||
| 28 | +To unblock it, in the **rickhub server repo**: | ||
| 29 | + | ||
| 30 | +1. `web/ciproxy/proxy.go` — add the release shim prefix to `allowedPathPrefixes`: | ||
| 31 | + | ||
| 32 | + ```go | ||
| 33 | + var allowedPathPrefixes = []string{ | ||
| 34 | + "/twirp/github.actions.results.api.v1.ArtifactService/", | ||
| 35 | + "/twirp/github.actions.results.api.v1.CacheService/", | ||
| 36 | + "/gh/", // GitHub-compatible REST shim: releases + asset upload | ||
| 37 | + } | ||
| 38 | + ``` | ||
| 39 | + | ||
| 40 | +2. `web/ciproxy/proxy.go` — `allowedMethods` is currently | ||
| 41 | + `GET / HEAD / POST / PUT` only. Release *creation* and asset upload are | ||
| 42 | + `POST`, but editing a release is `PATCH` and removing an asset is `DELETE`; | ||
| 43 | + add whichever verbs you intend to support. | ||
| 44 | + | ||
| 45 | +3. `web/ciproxy/proxy_test.go` — add coverage for the new prefix: a `/gh/repos/…` | ||
| 46 | + path is allowed, a non-`/gh` path is still denied, and path traversal such as | ||
| 47 | + `/gh/../login` is still refused (`Allowed` rejects anything non-canonical). | ||
| 48 | + Also re-run `web/ci_proxy_allowlist_conformance_test.go`, which cross-checks | ||
| 49 | + the allowlist against the registered mux routes. | ||
| 50 | + | ||
| 51 | +Until that ships, `rc.yml` and `release.yml` will build and upload their | ||
| 52 | +`actions/upload-artifact` bundle successfully and then fail on the | ||
| 53 | +"Publish …" step. The archives are still downloadable from the run's artifacts, | ||
| 54 | +so you can release by hand in the meantime. | ||
| 55 | + | ||
| 56 | +You can rehearse the whole pipeline without touching the shim by dispatching | ||
| 57 | +`release.yml` with **`dry_run: true`** — it builds, tests, verifies the tag is | ||
| 58 | +free and uploads the artifacts, but never calls the release API. | ||
| 59 | + | ||
| 60 | +--- | ||
| 61 | + | ||
| 62 | +## The `VERSION` file | ||
| 63 | + | ||
| 64 | +[`VERSION`](VERSION) at the repository root holds the **next** version to be | ||
| 65 | +released, as a bare `MAJOR.MINOR.PATCH` string with no leading `v`: | ||
| 66 | + | ||
| 67 | +``` | ||
| 68 | +0.1.0 | ||
| 69 | +``` | ||
| 70 | + | ||
| 71 | +* `rc.yml` reads it to name release candidates: with `VERSION` = `0.1.0`, the | ||
| 72 | + 17th push to `main` publishes the prerelease `v0.1.0-rc.17`. | ||
| 73 | +* `release.yml` compares it against the dispatched version and emits a | ||
| 74 | + **warning** (not a failure) on a mismatch, so an out-of-band hotfix is still | ||
| 75 | + possible. | ||
| 76 | + | ||
| 77 | +**Bumping `VERSION` is a normal pull request.** After releasing `v0.1.0`, open a | ||
| 78 | +PR setting `VERSION` to `0.2.0` (or `0.1.1`); once it merges, RCs on `main` | ||
| 79 | +start counting toward the next release. Nothing in CI ever writes to this file. | ||
| 80 | + | ||
| 81 | +--- | ||
| 82 | + | ||
| 83 | +## Release candidates (automatic) | ||
| 84 | + | ||
| 85 | +Every push to `main` runs `rc.yml`: | ||
| 86 | + | ||
| 87 | +1. `go test ./...` | ||
| 88 | +2. `scripts/build-dist.sh <BASE>-rc.<run_number>` | ||
| 89 | +3. publishes a **prerelease** at tag `v<BASE>-rc.<run_number>`, with the four | ||
| 90 | + `.tar.gz` archives and `SHA256SUMS` attached. | ||
| 91 | + | ||
| 92 | +The tag does not exist beforehand — the release shim creates it at | ||
| 93 | +`target_commitish`, which the workflow sets to `${{ github.sha }}`, so CI cuts | ||
| 94 | +its own RC tags. | ||
| 95 | + | ||
| 96 | +`concurrency: { group: rc, cancel-in-progress: true }` means only the newest | ||
| 97 | +push to `main` is building at any moment; superseded RC runs are cancelled, so | ||
| 98 | +RC numbers are not contiguous. That is expected — `run_number` is the source of | ||
| 99 | +uniqueness, not a count of published RCs. | ||
| 100 | + | ||
| 101 | +Releases created by CI do **not** re-trigger workflows (the server has a | ||
| 102 | +recursion guard), so an RC never kicks off another build. | ||
| 103 | + | ||
| 104 | +--- | ||
| 105 | + | ||
| 106 | +## Cutting a real release | ||
| 107 | + | ||
| 108 | +1. Make sure `main` is green and the latest RC is the build you want to ship. | ||
| 109 | +2. If needed, land a PR bumping [`VERSION`](VERSION) to the version you are | ||
| 110 | + about to release. | ||
| 111 | +3. In the rickub web UI, open **Actions → Release → Run workflow**. | ||
| 112 | +4. Pick the branch/ref (normally `main`), enter the version as bare | ||
| 113 | + `MAJOR.MINOR.PATCH` — e.g. `1.2.3`, **no leading `v`** — leave `dry_run` | ||
| 114 | + unchecked, and run it. | ||
| 115 | + | ||
| 116 | +The workflow then: | ||
| 117 | + | ||
| 118 | +* validates the format and refuses a leading `v` or anything that is not | ||
| 119 | + `X.Y.Z`; | ||
| 120 | +* refuses to continue if the tag `v<version>` already exists locally or on the | ||
| 121 | + remote, and additionally probes the releases API for that tag (a non-200, | ||
| 122 | + non-404 answer is only a warning — the git tag check is the real gate); | ||
| 123 | +* runs `go vet` and `go test`; | ||
| 124 | +* builds the four archives + `SHA256SUMS` via `scripts/build-dist.sh`; | ||
| 125 | +* publishes a non-prerelease release at `v<version>`, targeting | ||
| 126 | + `${{ github.sha }}` — the exact commit of the ref you dispatched — so the tag | ||
| 127 | + is created at that commit. | ||
| 128 | + | ||
| 129 | +There is no approval gate: rickub CI silently drops `environment:`, so the | ||
| 130 | +permission to run this workflow *is* the permission to release. | ||
| 131 | + | ||
| 132 | +### ⚠️ Use the web UI, not the dispatch API | ||
| 133 | + | ||
| 134 | +`release.yml` is the **only** workflow in this repository that declares | ||
| 135 | +`workflow_dispatch`, and it must stay that way. | ||
| 136 | + | ||
| 137 | +The rickub dispatch API — `POST /api/v1/repos/{owner}/{repo}/actions/dispatch` | ||
| 138 | +and the `rickub run dispatch` CLI — does not take a workflow name: it fires | ||
| 139 | +**every** `workflow_dispatch` workflow on the ref. If a second dispatchable | ||
| 140 | +workflow were added here, one `rickub run dispatch` would start both. Keeping | ||
| 141 | +the trigger unique makes that failure mode impossible. | ||
| 142 | + | ||
| 143 | +The web UI's **Run workflow** button *can* target a single workflow, so it is | ||
| 144 | +the supported way to cut a release. If you must use the API, remember it will | ||
| 145 | +run `release.yml` (and only `release.yml`, as long as this rule holds), and it | ||
| 146 | +still needs the `version` input. | ||
| 147 | + | ||
| 148 | +If you ever need a second manually-triggered pipeline, give it a | ||
| 149 | +`workflow_call` trigger and invoke it as a reusable workflow from `release.yml` | ||
| 150 | +rather than adding another `workflow_dispatch`. | ||
| 151 | + | ||
| 152 | +--- | ||
| 153 | + | ||
| 154 | +## Artifacts | ||
| 155 | + | ||
| 156 | +`scripts/build-dist.sh` produces, in `dist/`: | ||
| 157 | + | ||
| 158 | +``` | ||
| 159 | +rickub_<version>_linux_amd64.tar.gz | ||
| 160 | +rickub_<version>_linux_arm64.tar.gz | ||
| 161 | +rickub_<version>_darwin_amd64.tar.gz | ||
| 162 | +rickub_<version>_darwin_arm64.tar.gz | ||
| 163 | +SHA256SUMS | ||
| 164 | +``` | ||
| 165 | + | ||
| 166 | +`<version>` is the bare version (no `v`): `1.2.3` for a release, | ||
| 167 | +`0.1.0-rc.17` for a candidate. Each archive contains the `rickub` binary at the | ||
| 168 | +top level plus `README.md` (and `LICENSE`, automatically, once this repo has | ||
| 169 | +one). | ||
| 170 | + | ||
| 171 | +All four are cross-compiled on a single `ubuntu-latest` runner with | ||
| 172 | +`CGO_ENABLED=0` and `-trimpath`. The rickub runner fleet is Linux/amd64 only — | ||
| 173 | +there are no macOS or arm64 hosts — so the darwin and arm64 binaries are never | ||
| 174 | +executed by CI. Keeping the CLI pure Go is what makes this work; introducing | ||
| 175 | +cgo would break three of the four targets. | ||
| 176 | + | ||
| 177 | +The version is stamped with | ||
| 178 | +`-ldflags "-X rickub.com/rickub/cli/cmd.Version=<version>"` (plus `-s -w`), so | ||
| 179 | +`rickub version` reports the release version instead of the `dev` default. | ||
| 180 | + | ||
| 181 | +To verify a download: | ||
| 182 | + | ||
| 183 | +```sh | ||
| 184 | +sha256sum -c SHA256SUMS --ignore-missing | ||
| 185 | +``` | ||
| 186 | + | ||
| 187 | +Both workflows also upload `dist/` via `actions/upload-artifact@v4`, so the | ||
| 188 | +binaries are retrievable from the run page even if the release API call fails. | ||
| 189 | + | ||
| 190 | +--- | ||
| 191 | + | ||
| 192 | +## Building locally | ||
| 193 | + | ||
| 194 | +```sh | ||
| 195 | +# same script CI runs | ||
| 196 | +scripts/build-dist.sh 1.2.3 | ||
| 197 | + | ||
| 198 | +# single local binary | ||
| 199 | +go build -ldflags "-X rickub.com/rickub/cli/cmd.Version=$(cat VERSION)-dev" -o rickub . | ||
| 200 | +``` | ||
| 201 | + | ||
| 202 | +The script needs Go and `tar`; it uses `sha256sum` where available and falls | ||
| 203 | +back to `shasum -a 256` on macOS. It stages into a temporary directory and | ||
| 204 | +leaves nothing untracked behind — `dist/` is already in `.gitignore`. | ||
| 205 | + | ||
| 206 | +--- | ||
| 207 | + | ||
| 208 | +## Troubleshooting | ||
| 209 | + | ||
| 210 | +**`403` publishing the release.** The `/gh` ciproxy allowlist prerequisite at | ||
| 211 | +the top of this document has not landed yet. | ||
| 212 | + | ||
| 213 | +**`Resource not accessible by integration` / `404` on release creation.** The | ||
| 214 | +`GITHUB_TOKEN` is read-only by default; the workflow must declare | ||
| 215 | +`permissions: { contents: write }`. Both `rc.yml` and `release.yml` do. | ||
| 216 | + | ||
| 217 | +**`422` uploading an asset.** An asset with that name already exists on the | ||
| 218 | +release — the shim rejects duplicates. This normally means a partially | ||
| 219 | +completed run is being retried; delete the release (or the asset) and re-run. | ||
| 220 | + | ||
| 221 | +**Editing a release via `PATCH`.** The shim reads `draft` and `prerelease` | ||
| 222 | +unconditionally, so **always send both fields** in a PATCH body or you will | ||
| 223 | +silently flip a release to draft. | ||
| 224 | + | ||
| 225 | +**Nothing to release / `make_latest` ignored.** The shim does not implement | ||
| 226 | +`make_latest` or `generate_release_notes`, and there is no | ||
| 227 | +`GET /releases/latest`. The workflows therefore write their own release notes | ||
| 228 | +and never ask the API to pick a "latest" release. | ||
added
VERSION +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +0.1.0 | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +0.1.0 | ||
added
cmd/api.go +127 -0 | new file mode 100644 | ||
| @@ -0,0 +1,127 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "fmt" | |
| 6 | + "net/url" | |
| 7 | + "os" | |
| 8 | + "strconv" | |
| 9 | + "strings" | |
| 10 | + | |
| 11 | + "github.com/spf13/cobra" | |
| 12 | +) | |
| 13 | + | |
| 14 | +var ( | |
| 15 | + apiFields []string | |
| 16 | + apiRawFields []string | |
| 17 | +) | |
| 18 | + | |
| 19 | +func init() { | |
| 20 | + apiCmd := &cobra.Command{ | |
| 21 | + Use: "api <method> <path>", | |
| 22 | + Short: "Make an authenticated request to an arbitrary API endpoint", | |
| 23 | + Long: `Low-level escape hatch, like "gh api". METHOD is GET, POST, PATCH, PUT, DELETE. | |
| 24 | +PATH is relative to /api/v1 (a leading /api/v1 or / is optional). | |
| 25 | + | |
| 26 | +Fields (--field/-f) are added as query parameters for GET/HEAD and as a JSON | |
| 27 | +body otherwise. --field values are type-inferred (true/false/null/numbers); | |
| 28 | +use --raw-field/-F to force a string. The JSON response is printed to stdout. | |
| 29 | + | |
| 30 | +Examples: | |
| 31 | + rickub api GET /user | |
| 32 | + rickub api GET search/repos -f q=api | |
| 33 | + rickub api POST /repos -f name=demo -f visibility=public | |
| 34 | + rickub api PATCH repos/me/demo -F description="hello world"`, | |
| 35 | + Args: cobra.ExactArgs(2), | |
| 36 | + RunE: runAPI, | |
| 37 | + } | |
| 38 | + apiCmd.Flags().StringArrayVarP(&apiFields, "field", "f", nil, "typed field key=value (repeatable)") | |
| 39 | + apiCmd.Flags().StringArrayVarP(&apiRawFields, "raw-field", "F", nil, "string field key=value (repeatable)") | |
| 40 | + rootCmd.AddCommand(apiCmd) | |
| 41 | +} | |
| 42 | + | |
| 43 | +func runAPI(cmd *cobra.Command, args []string) error { | |
| 44 | + client, err := newClient() | |
| 45 | + if err != nil { | |
| 46 | + return err | |
| 47 | + } | |
| 48 | + method := strings.ToUpper(args[0]) | |
| 49 | + path := normalizeAPIPath(args[1]) | |
| 50 | + | |
| 51 | + fields := map[string]any{} | |
| 52 | + for _, f := range apiRawFields { | |
| 53 | + k, v, ok := splitKV(f) | |
| 54 | + if !ok { | |
| 55 | + return fmt.Errorf("invalid --raw-field %q (want key=value)", f) | |
| 56 | + } | |
| 57 | + fields[k] = v | |
| 58 | + } | |
| 59 | + for _, f := range apiFields { | |
| 60 | + k, v, ok := splitKV(f) | |
| 61 | + if !ok { | |
| 62 | + return fmt.Errorf("invalid --field %q (want key=value)", f) | |
| 63 | + } | |
| 64 | + fields[k] = inferType(v) | |
| 65 | + } | |
| 66 | + | |
| 67 | + var query url.Values | |
| 68 | + var body any | |
| 69 | + isRead := method == "GET" || method == "HEAD" | |
| 70 | + if len(fields) > 0 { | |
| 71 | + if isRead { | |
| 72 | + query = url.Values{} | |
| 73 | + for k, v := range fields { | |
| 74 | + query.Set(k, fmt.Sprintf("%v", v)) | |
| 75 | + } | |
| 76 | + } else { | |
| 77 | + body = fields | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + data, _, err := client.RawJSON(cmd.Context(), method, path, query, body) | |
| 82 | + if err != nil { | |
| 83 | + return err | |
| 84 | + } | |
| 85 | + // Pretty-print JSON when possible, else emit raw bytes. | |
| 86 | + var pretty any | |
| 87 | + if len(strings.TrimSpace(string(data))) > 0 && json.Unmarshal(data, &pretty) == nil { | |
| 88 | + return printJSON(cmd.OutOrStdout(), pretty) | |
| 89 | + } | |
| 90 | + _, err = os.Stdout.Write(data) | |
| 91 | + return err | |
| 92 | +} | |
| 93 | + | |
| 94 | +func normalizeAPIPath(p string) string { | |
| 95 | + p = strings.TrimPrefix(p, "/api/v1") | |
| 96 | + if !strings.HasPrefix(p, "/") { | |
| 97 | + p = "/" + p | |
| 98 | + } | |
| 99 | + return p | |
| 100 | +} | |
| 101 | + | |
| 102 | +func splitKV(s string) (string, string, bool) { | |
| 103 | + i := strings.IndexByte(s, '=') | |
| 104 | + if i < 0 { | |
| 105 | + return "", "", false | |
| 106 | + } | |
| 107 | + return s[:i], s[i+1:], true | |
| 108 | +} | |
| 109 | + | |
| 110 | +// inferType coerces common scalar spellings so JSON bodies get proper types. | |
| 111 | +func inferType(v string) any { | |
| 112 | + switch v { | |
| 113 | + case "true": | |
| 114 | + return true | |
| 115 | + case "false": | |
| 116 | + return false | |
| 117 | + case "null": | |
| 118 | + return nil | |
| 119 | + } | |
| 120 | + if n, err := strconv.Atoi(v); err == nil { | |
| 121 | + return n | |
| 122 | + } | |
| 123 | + if f, err := strconv.ParseFloat(v, 64); err == nil { | |
| 124 | + return f | |
| 125 | + } | |
| 126 | + return v | |
| 127 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,127 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "fmt" | ||
| 6 | + "net/url" | ||
| 7 | + "os" | ||
| 8 | + "strconv" | ||
| 9 | + "strings" | ||
| 10 | + | ||
| 11 | + "github.com/spf13/cobra" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +var ( | ||
| 15 | + apiFields []string | ||
| 16 | + apiRawFields []string | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +func init() { | ||
| 20 | + apiCmd := &cobra.Command{ | ||
| 21 | + Use: "api <method> <path>", | ||
| 22 | + Short: "Make an authenticated request to an arbitrary API endpoint", | ||
| 23 | + Long: `Low-level escape hatch, like "gh api". METHOD is GET, POST, PATCH, PUT, DELETE. | ||
| 24 | +PATH is relative to /api/v1 (a leading /api/v1 or / is optional). | ||
| 25 | + | ||
| 26 | +Fields (--field/-f) are added as query parameters for GET/HEAD and as a JSON | ||
| 27 | +body otherwise. --field values are type-inferred (true/false/null/numbers); | ||
| 28 | +use --raw-field/-F to force a string. The JSON response is printed to stdout. | ||
| 29 | + | ||
| 30 | +Examples: | ||
| 31 | + rickub api GET /user | ||
| 32 | + rickub api GET search/repos -f q=api | ||
| 33 | + rickub api POST /repos -f name=demo -f visibility=public | ||
| 34 | + rickub api PATCH repos/me/demo -F description="hello world"`, | ||
| 35 | + Args: cobra.ExactArgs(2), | ||
| 36 | + RunE: runAPI, | ||
| 37 | + } | ||
| 38 | + apiCmd.Flags().StringArrayVarP(&apiFields, "field", "f", nil, "typed field key=value (repeatable)") | ||
| 39 | + apiCmd.Flags().StringArrayVarP(&apiRawFields, "raw-field", "F", nil, "string field key=value (repeatable)") | ||
| 40 | + rootCmd.AddCommand(apiCmd) | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +func runAPI(cmd *cobra.Command, args []string) error { | ||
| 44 | + client, err := newClient() | ||
| 45 | + if err != nil { | ||
| 46 | + return err | ||
| 47 | + } | ||
| 48 | + method := strings.ToUpper(args[0]) | ||
| 49 | + path := normalizeAPIPath(args[1]) | ||
| 50 | + | ||
| 51 | + fields := map[string]any{} | ||
| 52 | + for _, f := range apiRawFields { | ||
| 53 | + k, v, ok := splitKV(f) | ||
| 54 | + if !ok { | ||
| 55 | + return fmt.Errorf("invalid --raw-field %q (want key=value)", f) | ||
| 56 | + } | ||
| 57 | + fields[k] = v | ||
| 58 | + } | ||
| 59 | + for _, f := range apiFields { | ||
| 60 | + k, v, ok := splitKV(f) | ||
| 61 | + if !ok { | ||
| 62 | + return fmt.Errorf("invalid --field %q (want key=value)", f) | ||
| 63 | + } | ||
| 64 | + fields[k] = inferType(v) | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + var query url.Values | ||
| 68 | + var body any | ||
| 69 | + isRead := method == "GET" || method == "HEAD" | ||
| 70 | + if len(fields) > 0 { | ||
| 71 | + if isRead { | ||
| 72 | + query = url.Values{} | ||
| 73 | + for k, v := range fields { | ||
| 74 | + query.Set(k, fmt.Sprintf("%v", v)) | ||
| 75 | + } | ||
| 76 | + } else { | ||
| 77 | + body = fields | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + data, _, err := client.RawJSON(cmd.Context(), method, path, query, body) | ||
| 82 | + if err != nil { | ||
| 83 | + return err | ||
| 84 | + } | ||
| 85 | + // Pretty-print JSON when possible, else emit raw bytes. | ||
| 86 | + var pretty any | ||
| 87 | + if len(strings.TrimSpace(string(data))) > 0 && json.Unmarshal(data, &pretty) == nil { | ||
| 88 | + return printJSON(cmd.OutOrStdout(), pretty) | ||
| 89 | + } | ||
| 90 | + _, err = os.Stdout.Write(data) | ||
| 91 | + return err | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +func normalizeAPIPath(p string) string { | ||
| 95 | + p = strings.TrimPrefix(p, "/api/v1") | ||
| 96 | + if !strings.HasPrefix(p, "/") { | ||
| 97 | + p = "/" + p | ||
| 98 | + } | ||
| 99 | + return p | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +func splitKV(s string) (string, string, bool) { | ||
| 103 | + i := strings.IndexByte(s, '=') | ||
| 104 | + if i < 0 { | ||
| 105 | + return "", "", false | ||
| 106 | + } | ||
| 107 | + return s[:i], s[i+1:], true | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +// inferType coerces common scalar spellings so JSON bodies get proper types. | ||
| 111 | +func inferType(v string) any { | ||
| 112 | + switch v { | ||
| 113 | + case "true": | ||
| 114 | + return true | ||
| 115 | + case "false": | ||
| 116 | + return false | ||
| 117 | + case "null": | ||
| 118 | + return nil | ||
| 119 | + } | ||
| 120 | + if n, err := strconv.Atoi(v); err == nil { | ||
| 121 | + return n | ||
| 122 | + } | ||
| 123 | + if f, err := strconv.ParseFloat(v, 64); err == nil { | ||
| 124 | + return f | ||
| 125 | + } | ||
| 126 | + return v | ||
| 127 | +} | ||
added
cmd/api_test.go +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "net/http" | |
| 6 | + "net/http/httptest" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | +) | |
| 10 | + | |
| 11 | +func TestNormalizeAPIPath(t *testing.T) { | |
| 12 | + cases := map[string]string{ | |
| 13 | + "/user": "/user", | |
| 14 | + "user": "/user", | |
| 15 | + "/api/v1/user": "/user", | |
| 16 | + "search/repos": "/search/repos", | |
| 17 | + "/api/v1/repos": "/repos", | |
| 18 | + } | |
| 19 | + for in, want := range cases { | |
| 20 | + if got := normalizeAPIPath(in); got != want { | |
| 21 | + t.Errorf("normalizeAPIPath(%q) = %q, want %q", in, got, want) | |
| 22 | + } | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +func TestInferType(t *testing.T) { | |
| 27 | + if inferType("true") != true { | |
| 28 | + t.Error("true") | |
| 29 | + } | |
| 30 | + if inferType("false") != false { | |
| 31 | + t.Error("false") | |
| 32 | + } | |
| 33 | + if inferType("null") != nil { | |
| 34 | + t.Error("null") | |
| 35 | + } | |
| 36 | + if inferType("42") != 42 { | |
| 37 | + t.Error("42") | |
| 38 | + } | |
| 39 | + if inferType("hi") != "hi" { | |
| 40 | + t.Error("hi") | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +func TestAPICommandGET(t *testing.T) { | |
| 45 | + var gotPath, gotQuery string | |
| 46 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 47 | + gotPath = r.URL.Path | |
| 48 | + gotQuery = r.URL.RawQuery | |
| 49 | + json.NewEncoder(w).Encode(map[string]string{"handle": "ricktester"}) | |
| 50 | + })) | |
| 51 | + defer srv.Close() | |
| 52 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 53 | + t.Setenv("RICKUB_HOST", srv.URL) | |
| 54 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | |
| 55 | + // reset escape-hatch field flags | |
| 56 | + apiFields = nil | |
| 57 | + apiRawFields = nil | |
| 58 | + | |
| 59 | + out, _, err := execute(t, "api", "GET", "search/repos", "-f", "q=api") | |
| 60 | + if err != nil { | |
| 61 | + t.Fatalf("execute: %v", err) | |
| 62 | + } | |
| 63 | + if gotPath != "/api/v1/search/repos" { | |
| 64 | + t.Errorf("path = %q", gotPath) | |
| 65 | + } | |
| 66 | + if gotQuery != "q=api" { | |
| 67 | + t.Errorf("query = %q", gotQuery) | |
| 68 | + } | |
| 69 | + if !strings.Contains(out, "ricktester") { | |
| 70 | + t.Errorf("output = %q", out) | |
| 71 | + } | |
| 72 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "net/http" | ||
| 6 | + "net/http/httptest" | ||
| 7 | + "strings" | ||
| 8 | + "testing" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +func TestNormalizeAPIPath(t *testing.T) { | ||
| 12 | + cases := map[string]string{ | ||
| 13 | + "/user": "/user", | ||
| 14 | + "user": "/user", | ||
| 15 | + "/api/v1/user": "/user", | ||
| 16 | + "search/repos": "/search/repos", | ||
| 17 | + "/api/v1/repos": "/repos", | ||
| 18 | + } | ||
| 19 | + for in, want := range cases { | ||
| 20 | + if got := normalizeAPIPath(in); got != want { | ||
| 21 | + t.Errorf("normalizeAPIPath(%q) = %q, want %q", in, got, want) | ||
| 22 | + } | ||
| 23 | + } | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +func TestInferType(t *testing.T) { | ||
| 27 | + if inferType("true") != true { | ||
| 28 | + t.Error("true") | ||
| 29 | + } | ||
| 30 | + if inferType("false") != false { | ||
| 31 | + t.Error("false") | ||
| 32 | + } | ||
| 33 | + if inferType("null") != nil { | ||
| 34 | + t.Error("null") | ||
| 35 | + } | ||
| 36 | + if inferType("42") != 42 { | ||
| 37 | + t.Error("42") | ||
| 38 | + } | ||
| 39 | + if inferType("hi") != "hi" { | ||
| 40 | + t.Error("hi") | ||
| 41 | + } | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +func TestAPICommandGET(t *testing.T) { | ||
| 45 | + var gotPath, gotQuery string | ||
| 46 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 47 | + gotPath = r.URL.Path | ||
| 48 | + gotQuery = r.URL.RawQuery | ||
| 49 | + json.NewEncoder(w).Encode(map[string]string{"handle": "ricktester"}) | ||
| 50 | + })) | ||
| 51 | + defer srv.Close() | ||
| 52 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 53 | + t.Setenv("RICKUB_HOST", srv.URL) | ||
| 54 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | ||
| 55 | + // reset escape-hatch field flags | ||
| 56 | + apiFields = nil | ||
| 57 | + apiRawFields = nil | ||
| 58 | + | ||
| 59 | + out, _, err := execute(t, "api", "GET", "search/repos", "-f", "q=api") | ||
| 60 | + if err != nil { | ||
| 61 | + t.Fatalf("execute: %v", err) | ||
| 62 | + } | ||
| 63 | + if gotPath != "/api/v1/search/repos" { | ||
| 64 | + t.Errorf("path = %q", gotPath) | ||
| 65 | + } | ||
| 66 | + if gotQuery != "q=api" { | ||
| 67 | + t.Errorf("query = %q", gotQuery) | ||
| 68 | + } | ||
| 69 | + if !strings.Contains(out, "ricktester") { | ||
| 70 | + t.Errorf("output = %q", out) | ||
| 71 | + } | ||
| 72 | +} | ||
added
cmd/auth.go +299 -0 | new file mode 100644 | ||
| @@ -0,0 +1,299 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bufio" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "os" | |
| 9 | + "strings" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + "rickub.com/rickub/cli/internal/api" | |
| 13 | + "rickub.com/rickub/cli/internal/config" | |
| 14 | + | |
| 15 | + "github.com/spf13/cobra" | |
| 16 | +) | |
| 17 | + | |
| 18 | +var ( | |
| 19 | + authLoginHost string | |
| 20 | + authLoginToken string | |
| 21 | + authLoginWithToken bool | |
| 22 | + authLoginScope string | |
| 23 | + authLoginNoBrowser bool | |
| 24 | +) | |
| 25 | + | |
| 26 | +func init() { | |
| 27 | + authCmd := &cobra.Command{ | |
| 28 | + Use: "auth", | |
| 29 | + Short: "Authenticate rickub with the website or a personal access token", | |
| 30 | + } | |
| 31 | + | |
| 32 | + loginCmd := &cobra.Command{ | |
| 33 | + Use: "login", | |
| 34 | + Short: "Log in to a rickub host", | |
| 35 | + Long: `Store a rickub host and personal access token in ~/.config/rickub/config.yaml. | |
| 36 | + | |
| 37 | +By default this runs the browser (device) flow: it prints a code and a URL, | |
| 38 | +waits for you to approve the sign-in while logged in to the website on any | |
| 39 | +device, and stores the token the server mints — nothing is copy-pasted. | |
| 40 | + | |
| 41 | + rickub auth login # browser flow | |
| 42 | + rickub auth login --host https://dev.rickub.com | |
| 43 | + | |
| 44 | +The token is stored under the host it was verified against and is only ever | |
| 45 | +sent back to that host, so a later --host or RICKUB_HOST pointing elsewhere | |
| 46 | +cannot leak it. Log in once per host you use. | |
| 47 | + | |
| 48 | +To use an existing personal access token instead, pipe it via --with-token: | |
| 49 | + | |
| 50 | + echo $PAT | rickub auth login --with-token --host http://localhost:3000 | |
| 51 | + | |
| 52 | +There is also --token, but arguments are visible to other processes (ps) and | |
| 53 | +land in shell history, so prefer --with-token or the RICKUB_TOKEN env var.`, | |
| 54 | + Args: cobra.NoArgs, | |
| 55 | + RunE: runAuthLogin, | |
| 56 | + } | |
| 57 | + loginCmd.Flags().StringVar(&authLoginHost, "host", "", "API host to log in to (default https://rickub.com)") | |
| 58 | + loginCmd.Flags().StringVar(&authLoginToken, "token", "", "personal access token (skips the prompt)") | |
| 59 | + loginCmd.Flags().BoolVar(&authLoginWithToken, "with-token", false, "read the token from stdin") | |
| 60 | + loginCmd.Flags().StringVar(&authLoginScope, "scope", "all", "requested token scope: all | read") | |
| 61 | + loginCmd.Flags().BoolVar(&authLoginNoBrowser, "no-browser", false, "print the URL instead of opening a browser") | |
| 62 | + | |
| 63 | + statusCmd := &cobra.Command{ | |
| 64 | + Use: "status", | |
| 65 | + Short: "Show the active host and verify the stored token", | |
| 66 | + Args: cobra.NoArgs, | |
| 67 | + RunE: runAuthStatus, | |
| 68 | + } | |
| 69 | + | |
| 70 | + logoutCmd := &cobra.Command{ | |
| 71 | + Use: "logout", | |
| 72 | + Short: "Remove the stored token", | |
| 73 | + Args: cobra.NoArgs, | |
| 74 | + RunE: runAuthLogout, | |
| 75 | + } | |
| 76 | + | |
| 77 | + authCmd.AddCommand(loginCmd, statusCmd, logoutCmd) | |
| 78 | + rootCmd.AddCommand(authCmd) | |
| 79 | +} | |
| 80 | + | |
| 81 | +func runAuthLogin(cmd *cobra.Command, _ []string) error { | |
| 82 | + cfg, err := loadConfig() | |
| 83 | + if err != nil { | |
| 84 | + return err | |
| 85 | + } | |
| 86 | + | |
| 87 | + // Resolve host: --host on login, else the global --host, else prompt, else default. | |
| 88 | + host := authLoginHost | |
| 89 | + if host == "" { | |
| 90 | + host = flagHost | |
| 91 | + } | |
| 92 | + | |
| 93 | + token := authLoginToken | |
| 94 | + | |
| 95 | + switch { | |
| 96 | + case authLoginWithToken: | |
| 97 | + // Read the token from stdin (trimmed). | |
| 98 | + b, err := io.ReadAll(cmd.InOrStdin()) | |
| 99 | + if err != nil { | |
| 100 | + return fmt.Errorf("read token from stdin: %w", err) | |
| 101 | + } | |
| 102 | + token = strings.TrimSpace(string(b)) | |
| 103 | + case token == "": | |
| 104 | + // Browser (device) flow: approve on the website, nothing copy-pasted. | |
| 105 | + if host == "" { | |
| 106 | + host = prompt(cmd, fmt.Sprintf("rickub host [%s]: ", config.DefaultHost)) | |
| 107 | + if host == "" { | |
| 108 | + host = config.DefaultHost | |
| 109 | + } | |
| 110 | + } | |
| 111 | + host = config.NormalizeHost(host) | |
| 112 | + token, err = loginViaBrowser(cmd, host) | |
| 113 | + if err != nil { | |
| 114 | + return err | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + token = strings.TrimSpace(token) | |
| 119 | + if token == "" { | |
| 120 | + return fmt.Errorf("a token is required") | |
| 121 | + } | |
| 122 | + if host == "" { | |
| 123 | + host = config.DefaultHost | |
| 124 | + } | |
| 125 | + host = config.NormalizeHost(host) | |
| 126 | + config.WarnIfInsecure(cmd.ErrOrStderr(), host) | |
| 127 | + | |
| 128 | + // Verify the token before persisting it. | |
| 129 | + client := api.New(host, token) | |
| 130 | + user, err := client.GetUser(cmd.Context()) | |
| 131 | + if err != nil { | |
| 132 | + return fmt.Errorf("token verification failed against %s: %w", host, err) | |
| 133 | + } | |
| 134 | + | |
| 135 | + // Store the token under the host it was just verified against, so it is | |
| 136 | + // never sent anywhere else. | |
| 137 | + cfg.SetToken(host, token) | |
| 138 | + if err := cfg.Save(); err != nil { | |
| 139 | + return err | |
| 140 | + } | |
| 141 | + | |
| 142 | + path, _ := config.Path() | |
| 143 | + fmt.Fprintf(cmd.OutOrStdout(), "Logged in to %s as %s (config: %s)\n", host, user.Handle, path) | |
| 144 | + return nil | |
| 145 | +} | |
| 146 | + | |
| 147 | +func runAuthStatus(cmd *cobra.Command, _ []string) error { | |
| 148 | + cfg, err := loadConfig() | |
| 149 | + if err != nil { | |
| 150 | + return err | |
| 151 | + } | |
| 152 | + host := config.ResolveHost(flagHost, cfg) | |
| 153 | + token := config.ResolveToken(flagToken, cfg, host) | |
| 154 | + out := cmd.OutOrStdout() | |
| 155 | + fmt.Fprintf(out, "Host: %s\n", host) | |
| 156 | + if token == "" { | |
| 157 | + if cfg.Host != "" && cfg.TokenFor(cfg.Host) != "" { | |
| 158 | + fmt.Fprintf(out, "No token stored for this host (the stored token belongs to %s).\n", cfg.Host) | |
| 159 | + fmt.Fprintf(out, "Run `rickub auth login --host %s`.\n", host) | |
| 160 | + } else { | |
| 161 | + fmt.Fprintln(out, "Not logged in (no token). Run `rickub auth login`.") | |
| 162 | + } | |
| 163 | + return fmt.Errorf("not logged in") | |
| 164 | + } | |
| 165 | + switch { | |
| 166 | + case flagToken != "": | |
| 167 | + fmt.Fprintln(out, "Token source: --token flag") | |
| 168 | + case os.Getenv(config.EnvToken) != "": | |
| 169 | + fmt.Fprintf(out, "Token source: %s\n", config.EnvToken) | |
| 170 | + default: | |
| 171 | + fmt.Fprintf(out, "Token source: config file (bound to %s)\n", host) | |
| 172 | + } | |
| 173 | + config.WarnIfInsecure(cmd.ErrOrStderr(), host) | |
| 174 | + client := api.New(host, token) | |
| 175 | + user, err := client.GetUser(cmd.Context()) | |
| 176 | + if err != nil { | |
| 177 | + fmt.Fprintf(out, "Token: %s (invalid)\n", redact(token)) | |
| 178 | + return fmt.Errorf("token check failed: %w", err) | |
| 179 | + } | |
| 180 | + fmt.Fprintf(out, "Token: %s (valid)\n", redact(token)) | |
| 181 | + fmt.Fprintf(out, "Logged in as: %s", user.Handle) | |
| 182 | + if user.DisplayName != "" { | |
| 183 | + fmt.Fprintf(out, " (%s)", user.DisplayName) | |
| 184 | + } | |
| 185 | + fmt.Fprintln(out) | |
| 186 | + return nil | |
| 187 | +} | |
| 188 | + | |
| 189 | +func runAuthLogout(cmd *cobra.Command, _ []string) error { | |
| 190 | + cfg, err := loadConfig() | |
| 191 | + if err != nil { | |
| 192 | + return err | |
| 193 | + } | |
| 194 | + // Log out of the host currently in effect, not every host at once. | |
| 195 | + host := config.ResolveHost(flagHost, cfg) | |
| 196 | + if !cfg.ClearToken(host) { | |
| 197 | + fmt.Fprintf(cmd.OutOrStdout(), "No stored token for %s to remove.\n", host) | |
| 198 | + return nil | |
| 199 | + } | |
| 200 | + if err := cfg.Save(); err != nil { | |
| 201 | + return err | |
| 202 | + } | |
| 203 | + fmt.Fprintf(cmd.OutOrStdout(), "Logged out of %s (token removed).\n", host) | |
| 204 | + return nil | |
| 205 | +} | |
| 206 | + | |
| 207 | +// loginViaBrowser runs the device flow against host: it starts a login request, | |
| 208 | +// shows the approval URL + code (optionally opening a browser), and polls until | |
| 209 | +// the user approves, denies, or the code expires. It returns the minted token. | |
| 210 | +func loginViaBrowser(cmd *cobra.Command, host string) (string, error) { | |
| 211 | + if authLoginScope != "read" && authLoginScope != "all" { | |
| 212 | + return "", fmt.Errorf("invalid --scope %q (all or read)", authLoginScope) | |
| 213 | + } | |
| 214 | + clientName := "rickub CLI" | |
| 215 | + if hn, err := os.Hostname(); err == nil && hn != "" { | |
| 216 | + clientName += " on " + hn | |
| 217 | + } | |
| 218 | + client := api.New(host, "") | |
| 219 | + start, err := client.StartDeviceLogin(cmd.Context(), authLoginScope, clientName) | |
| 220 | + if err != nil { | |
| 221 | + return "", fmt.Errorf("start device login against %s: %w", host, err) | |
| 222 | + } | |
| 223 | + | |
| 224 | + out := cmd.OutOrStdout() | |
| 225 | + fmt.Fprintln(out) | |
| 226 | + fmt.Fprintln(out, " Sign in with your rickub account.") | |
| 227 | + fmt.Fprintf(out, " Open %s and enter code:\n", start.VerificationURL) | |
| 228 | + fmt.Fprintf(out, "\n %s\n\n", start.UserCode) | |
| 229 | + if !authLoginNoBrowser { | |
| 230 | + // The URL comes from the server: never hand an arbitrary scheme to the | |
| 231 | + // platform opener, which would happily launch a registered handler for | |
| 232 | + // it. Anything but http/https is printed for the user to judge. | |
| 233 | + if err := checkBrowserURL(start.VerificationURIComplete); err != nil { | |
| 234 | + fmt.Fprintf(out, " Not opening a browser: %v\n", err) | |
| 235 | + fmt.Fprintf(out, " Open this URL yourself if you trust it: %s\n", start.VerificationURIComplete) | |
| 236 | + } else if err := openBrowser(start.VerificationURIComplete); err == nil { | |
| 237 | + fmt.Fprintln(out, " Opening your browser…") | |
| 238 | + } | |
| 239 | + } | |
| 240 | + | |
| 241 | + interval := time.Duration(start.Interval) * time.Second | |
| 242 | + if interval < time.Second { | |
| 243 | + interval = time.Second | |
| 244 | + } | |
| 245 | + deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second) | |
| 246 | + ticker := time.NewTicker(interval) | |
| 247 | + defer ticker.Stop() | |
| 248 | + fmt.Fprintf(out, " Waiting for approval (expires in %ds)…\n", start.ExpiresIn) | |
| 249 | + for { | |
| 250 | + select { | |
| 251 | + case <-cmd.Context().Done(): | |
| 252 | + return "", cmd.Context().Err() | |
| 253 | + case <-ticker.C: | |
| 254 | + } | |
| 255 | + if time.Now().After(deadline) { | |
| 256 | + return "", fmt.Errorf("the sign-in code expired; run `rickub auth login` again") | |
| 257 | + } | |
| 258 | + tok, err := client.PollDeviceToken(cmd.Context(), start.DeviceCode) | |
| 259 | + if err == nil { | |
| 260 | + fmt.Fprintln(out, " Approved.") | |
| 261 | + return tok.AccessToken, nil | |
| 262 | + } | |
| 263 | + var apiErr *api.APIError | |
| 264 | + if !errors.As(err, &apiErr) { | |
| 265 | + return "", err | |
| 266 | + } | |
| 267 | + switch apiErr.Code { | |
| 268 | + case "authorization_pending": | |
| 269 | + // keep waiting | |
| 270 | + case "slow_down": | |
| 271 | + interval *= 2 | |
| 272 | + ticker.Reset(interval) | |
| 273 | + default: | |
| 274 | + // access_denied, expired_token, invalid_grant: all terminal. | |
| 275 | + return "", fmt.Errorf("sign-in failed: %s", apiErr.Message) | |
| 276 | + } | |
| 277 | + } | |
| 278 | +} | |
| 279 | + | |
| 280 | +// prompt writes a message and reads a trimmed line from stdin. | |
| 281 | +func prompt(cmd *cobra.Command, msg string) string { | |
| 282 | + fmt.Fprint(cmd.OutOrStdout(), msg) | |
| 283 | + r := bufio.NewReader(cmd.InOrStdin()) | |
| 284 | + line, _ := r.ReadString('\n') | |
| 285 | + return strings.TrimSpace(line) | |
| 286 | +} | |
| 287 | + | |
| 288 | +// tokenPrefix is the non-secret marker every rickub PAT starts with; the bytes | |
| 289 | +// after it are secret material and are never displayed. | |
| 290 | +const tokenPrefix = "rickub_pat_" | |
| 291 | + | |
| 292 | +// redact masks a token for display. It shows only the non-secret prefix, so the | |
| 293 | +// output identifies the kind of credential without leaking any of it. | |
| 294 | +func redact(token string) string { | |
| 295 | + if strings.HasPrefix(token, tokenPrefix) { | |
| 296 | + return tokenPrefix + "…" | |
| 297 | + } | |
| 298 | + return "****" | |
| 299 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,299 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bufio" | ||
| 5 | + "errors" | ||
| 6 | + "fmt" | ||
| 7 | + "io" | ||
| 8 | + "os" | ||
| 9 | + "strings" | ||
| 10 | + "time" | ||
| 11 | + | ||
| 12 | + "rickub.com/rickub/cli/internal/api" | ||
| 13 | + "rickub.com/rickub/cli/internal/config" | ||
| 14 | + | ||
| 15 | + "github.com/spf13/cobra" | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +var ( | ||
| 19 | + authLoginHost string | ||
| 20 | + authLoginToken string | ||
| 21 | + authLoginWithToken bool | ||
| 22 | + authLoginScope string | ||
| 23 | + authLoginNoBrowser bool | ||
| 24 | +) | ||
| 25 | + | ||
| 26 | +func init() { | ||
| 27 | + authCmd := &cobra.Command{ | ||
| 28 | + Use: "auth", | ||
| 29 | + Short: "Authenticate rickub with the website or a personal access token", | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + loginCmd := &cobra.Command{ | ||
| 33 | + Use: "login", | ||
| 34 | + Short: "Log in to a rickub host", | ||
| 35 | + Long: `Store a rickub host and personal access token in ~/.config/rickub/config.yaml. | ||
| 36 | + | ||
| 37 | +By default this runs the browser (device) flow: it prints a code and a URL, | ||
| 38 | +waits for you to approve the sign-in while logged in to the website on any | ||
| 39 | +device, and stores the token the server mints — nothing is copy-pasted. | ||
| 40 | + | ||
| 41 | + rickub auth login # browser flow | ||
| 42 | + rickub auth login --host https://dev.rickub.com | ||
| 43 | + | ||
| 44 | +The token is stored under the host it was verified against and is only ever | ||
| 45 | +sent back to that host, so a later --host or RICKUB_HOST pointing elsewhere | ||
| 46 | +cannot leak it. Log in once per host you use. | ||
| 47 | + | ||
| 48 | +To use an existing personal access token instead, pipe it via --with-token: | ||
| 49 | + | ||
| 50 | + echo $PAT | rickub auth login --with-token --host http://localhost:3000 | ||
| 51 | + | ||
| 52 | +There is also --token, but arguments are visible to other processes (ps) and | ||
| 53 | +land in shell history, so prefer --with-token or the RICKUB_TOKEN env var.`, | ||
| 54 | + Args: cobra.NoArgs, | ||
| 55 | + RunE: runAuthLogin, | ||
| 56 | + } | ||
| 57 | + loginCmd.Flags().StringVar(&authLoginHost, "host", "", "API host to log in to (default https://rickub.com)") | ||
| 58 | + loginCmd.Flags().StringVar(&authLoginToken, "token", "", "personal access token (skips the prompt)") | ||
| 59 | + loginCmd.Flags().BoolVar(&authLoginWithToken, "with-token", false, "read the token from stdin") | ||
| 60 | + loginCmd.Flags().StringVar(&authLoginScope, "scope", "all", "requested token scope: all | read") | ||
| 61 | + loginCmd.Flags().BoolVar(&authLoginNoBrowser, "no-browser", false, "print the URL instead of opening a browser") | ||
| 62 | + | ||
| 63 | + statusCmd := &cobra.Command{ | ||
| 64 | + Use: "status", | ||
| 65 | + Short: "Show the active host and verify the stored token", | ||
| 66 | + Args: cobra.NoArgs, | ||
| 67 | + RunE: runAuthStatus, | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + logoutCmd := &cobra.Command{ | ||
| 71 | + Use: "logout", | ||
| 72 | + Short: "Remove the stored token", | ||
| 73 | + Args: cobra.NoArgs, | ||
| 74 | + RunE: runAuthLogout, | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + authCmd.AddCommand(loginCmd, statusCmd, logoutCmd) | ||
| 78 | + rootCmd.AddCommand(authCmd) | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +func runAuthLogin(cmd *cobra.Command, _ []string) error { | ||
| 82 | + cfg, err := loadConfig() | ||
| 83 | + if err != nil { | ||
| 84 | + return err | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + // Resolve host: --host on login, else the global --host, else prompt, else default. | ||
| 88 | + host := authLoginHost | ||
| 89 | + if host == "" { | ||
| 90 | + host = flagHost | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + token := authLoginToken | ||
| 94 | + | ||
| 95 | + switch { | ||
| 96 | + case authLoginWithToken: | ||
| 97 | + // Read the token from stdin (trimmed). | ||
| 98 | + b, err := io.ReadAll(cmd.InOrStdin()) | ||
| 99 | + if err != nil { | ||
| 100 | + return fmt.Errorf("read token from stdin: %w", err) | ||
| 101 | + } | ||
| 102 | + token = strings.TrimSpace(string(b)) | ||
| 103 | + case token == "": | ||
| 104 | + // Browser (device) flow: approve on the website, nothing copy-pasted. | ||
| 105 | + if host == "" { | ||
| 106 | + host = prompt(cmd, fmt.Sprintf("rickub host [%s]: ", config.DefaultHost)) | ||
| 107 | + if host == "" { | ||
| 108 | + host = config.DefaultHost | ||
| 109 | + } | ||
| 110 | + } | ||
| 111 | + host = config.NormalizeHost(host) | ||
| 112 | + token, err = loginViaBrowser(cmd, host) | ||
| 113 | + if err != nil { | ||
| 114 | + return err | ||
| 115 | + } | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + token = strings.TrimSpace(token) | ||
| 119 | + if token == "" { | ||
| 120 | + return fmt.Errorf("a token is required") | ||
| 121 | + } | ||
| 122 | + if host == "" { | ||
| 123 | + host = config.DefaultHost | ||
| 124 | + } | ||
| 125 | + host = config.NormalizeHost(host) | ||
| 126 | + config.WarnIfInsecure(cmd.ErrOrStderr(), host) | ||
| 127 | + | ||
| 128 | + // Verify the token before persisting it. | ||
| 129 | + client := api.New(host, token) | ||
| 130 | + user, err := client.GetUser(cmd.Context()) | ||
| 131 | + if err != nil { | ||
| 132 | + return fmt.Errorf("token verification failed against %s: %w", host, err) | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + // Store the token under the host it was just verified against, so it is | ||
| 136 | + // never sent anywhere else. | ||
| 137 | + cfg.SetToken(host, token) | ||
| 138 | + if err := cfg.Save(); err != nil { | ||
| 139 | + return err | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + path, _ := config.Path() | ||
| 143 | + fmt.Fprintf(cmd.OutOrStdout(), "Logged in to %s as %s (config: %s)\n", host, user.Handle, path) | ||
| 144 | + return nil | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +func runAuthStatus(cmd *cobra.Command, _ []string) error { | ||
| 148 | + cfg, err := loadConfig() | ||
| 149 | + if err != nil { | ||
| 150 | + return err | ||
| 151 | + } | ||
| 152 | + host := config.ResolveHost(flagHost, cfg) | ||
| 153 | + token := config.ResolveToken(flagToken, cfg, host) | ||
| 154 | + out := cmd.OutOrStdout() | ||
| 155 | + fmt.Fprintf(out, "Host: %s\n", host) | ||
| 156 | + if token == "" { | ||
| 157 | + if cfg.Host != "" && cfg.TokenFor(cfg.Host) != "" { | ||
| 158 | + fmt.Fprintf(out, "No token stored for this host (the stored token belongs to %s).\n", cfg.Host) | ||
| 159 | + fmt.Fprintf(out, "Run `rickub auth login --host %s`.\n", host) | ||
| 160 | + } else { | ||
| 161 | + fmt.Fprintln(out, "Not logged in (no token). Run `rickub auth login`.") | ||
| 162 | + } | ||
| 163 | + return fmt.Errorf("not logged in") | ||
| 164 | + } | ||
| 165 | + switch { | ||
| 166 | + case flagToken != "": | ||
| 167 | + fmt.Fprintln(out, "Token source: --token flag") | ||
| 168 | + case os.Getenv(config.EnvToken) != "": | ||
| 169 | + fmt.Fprintf(out, "Token source: %s\n", config.EnvToken) | ||
| 170 | + default: | ||
| 171 | + fmt.Fprintf(out, "Token source: config file (bound to %s)\n", host) | ||
| 172 | + } | ||
| 173 | + config.WarnIfInsecure(cmd.ErrOrStderr(), host) | ||
| 174 | + client := api.New(host, token) | ||
| 175 | + user, err := client.GetUser(cmd.Context()) | ||
| 176 | + if err != nil { | ||
| 177 | + fmt.Fprintf(out, "Token: %s (invalid)\n", redact(token)) | ||
| 178 | + return fmt.Errorf("token check failed: %w", err) | ||
| 179 | + } | ||
| 180 | + fmt.Fprintf(out, "Token: %s (valid)\n", redact(token)) | ||
| 181 | + fmt.Fprintf(out, "Logged in as: %s", user.Handle) | ||
| 182 | + if user.DisplayName != "" { | ||
| 183 | + fmt.Fprintf(out, " (%s)", user.DisplayName) | ||
| 184 | + } | ||
| 185 | + fmt.Fprintln(out) | ||
| 186 | + return nil | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +func runAuthLogout(cmd *cobra.Command, _ []string) error { | ||
| 190 | + cfg, err := loadConfig() | ||
| 191 | + if err != nil { | ||
| 192 | + return err | ||
| 193 | + } | ||
| 194 | + // Log out of the host currently in effect, not every host at once. | ||
| 195 | + host := config.ResolveHost(flagHost, cfg) | ||
| 196 | + if !cfg.ClearToken(host) { | ||
| 197 | + fmt.Fprintf(cmd.OutOrStdout(), "No stored token for %s to remove.\n", host) | ||
| 198 | + return nil | ||
| 199 | + } | ||
| 200 | + if err := cfg.Save(); err != nil { | ||
| 201 | + return err | ||
| 202 | + } | ||
| 203 | + fmt.Fprintf(cmd.OutOrStdout(), "Logged out of %s (token removed).\n", host) | ||
| 204 | + return nil | ||
| 205 | +} | ||
| 206 | + | ||
| 207 | +// loginViaBrowser runs the device flow against host: it starts a login request, | ||
| 208 | +// shows the approval URL + code (optionally opening a browser), and polls until | ||
| 209 | +// the user approves, denies, or the code expires. It returns the minted token. | ||
| 210 | +func loginViaBrowser(cmd *cobra.Command, host string) (string, error) { | ||
| 211 | + if authLoginScope != "read" && authLoginScope != "all" { | ||
| 212 | + return "", fmt.Errorf("invalid --scope %q (all or read)", authLoginScope) | ||
| 213 | + } | ||
| 214 | + clientName := "rickub CLI" | ||
| 215 | + if hn, err := os.Hostname(); err == nil && hn != "" { | ||
| 216 | + clientName += " on " + hn | ||
| 217 | + } | ||
| 218 | + client := api.New(host, "") | ||
| 219 | + start, err := client.StartDeviceLogin(cmd.Context(), authLoginScope, clientName) | ||
| 220 | + if err != nil { | ||
| 221 | + return "", fmt.Errorf("start device login against %s: %w", host, err) | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + out := cmd.OutOrStdout() | ||
| 225 | + fmt.Fprintln(out) | ||
| 226 | + fmt.Fprintln(out, " Sign in with your rickub account.") | ||
| 227 | + fmt.Fprintf(out, " Open %s and enter code:\n", start.VerificationURL) | ||
| 228 | + fmt.Fprintf(out, "\n %s\n\n", start.UserCode) | ||
| 229 | + if !authLoginNoBrowser { | ||
| 230 | + // The URL comes from the server: never hand an arbitrary scheme to the | ||
| 231 | + // platform opener, which would happily launch a registered handler for | ||
| 232 | + // it. Anything but http/https is printed for the user to judge. | ||
| 233 | + if err := checkBrowserURL(start.VerificationURIComplete); err != nil { | ||
| 234 | + fmt.Fprintf(out, " Not opening a browser: %v\n", err) | ||
| 235 | + fmt.Fprintf(out, " Open this URL yourself if you trust it: %s\n", start.VerificationURIComplete) | ||
| 236 | + } else if err := openBrowser(start.VerificationURIComplete); err == nil { | ||
| 237 | + fmt.Fprintln(out, " Opening your browser…") | ||
| 238 | + } | ||
| 239 | + } | ||
| 240 | + | ||
| 241 | + interval := time.Duration(start.Interval) * time.Second | ||
| 242 | + if interval < time.Second { | ||
| 243 | + interval = time.Second | ||
| 244 | + } | ||
| 245 | + deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second) | ||
| 246 | + ticker := time.NewTicker(interval) | ||
| 247 | + defer ticker.Stop() | ||
| 248 | + fmt.Fprintf(out, " Waiting for approval (expires in %ds)…\n", start.ExpiresIn) | ||
| 249 | + for { | ||
| 250 | + select { | ||
| 251 | + case <-cmd.Context().Done(): | ||
| 252 | + return "", cmd.Context().Err() | ||
| 253 | + case <-ticker.C: | ||
| 254 | + } | ||
| 255 | + if time.Now().After(deadline) { | ||
| 256 | + return "", fmt.Errorf("the sign-in code expired; run `rickub auth login` again") | ||
| 257 | + } | ||
| 258 | + tok, err := client.PollDeviceToken(cmd.Context(), start.DeviceCode) | ||
| 259 | + if err == nil { | ||
| 260 | + fmt.Fprintln(out, " Approved.") | ||
| 261 | + return tok.AccessToken, nil | ||
| 262 | + } | ||
| 263 | + var apiErr *api.APIError | ||
| 264 | + if !errors.As(err, &apiErr) { | ||
| 265 | + return "", err | ||
| 266 | + } | ||
| 267 | + switch apiErr.Code { | ||
| 268 | + case "authorization_pending": | ||
| 269 | + // keep waiting | ||
| 270 | + case "slow_down": | ||
| 271 | + interval *= 2 | ||
| 272 | + ticker.Reset(interval) | ||
| 273 | + default: | ||
| 274 | + // access_denied, expired_token, invalid_grant: all terminal. | ||
| 275 | + return "", fmt.Errorf("sign-in failed: %s", apiErr.Message) | ||
| 276 | + } | ||
| 277 | + } | ||
| 278 | +} | ||
| 279 | + | ||
| 280 | +// prompt writes a message and reads a trimmed line from stdin. | ||
| 281 | +func prompt(cmd *cobra.Command, msg string) string { | ||
| 282 | + fmt.Fprint(cmd.OutOrStdout(), msg) | ||
| 283 | + r := bufio.NewReader(cmd.InOrStdin()) | ||
| 284 | + line, _ := r.ReadString('\n') | ||
| 285 | + return strings.TrimSpace(line) | ||
| 286 | +} | ||
| 287 | + | ||
| 288 | +// tokenPrefix is the non-secret marker every rickub PAT starts with; the bytes | ||
| 289 | +// after it are secret material and are never displayed. | ||
| 290 | +const tokenPrefix = "rickub_pat_" | ||
| 291 | + | ||
| 292 | +// redact masks a token for display. It shows only the non-secret prefix, so the | ||
| 293 | +// output identifies the kind of credential without leaking any of it. | ||
| 294 | +func redact(token string) string { | ||
| 295 | + if strings.HasPrefix(token, tokenPrefix) { | ||
| 296 | + return tokenPrefix + "…" | ||
| 297 | + } | ||
| 298 | + return "****" | ||
| 299 | +} | ||
added
cmd/auth_test.go +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | +) | |
| 7 | + | |
| 8 | +// redact must not reveal any secret material — only the non-secret prefix that | |
| 9 | +// identifies the credential type. | |
| 10 | +func TestRedactRevealsNoSecretMaterial(t *testing.T) { | |
| 11 | + const secret = "rickub_pat_S3CRETMATERIAL" | |
| 12 | + got := redact(secret) | |
| 13 | + if got != "rickub_pat_…" { | |
| 14 | + t.Errorf("redact = %q, want %q", got, "rickub_pat_…") | |
| 15 | + } | |
| 16 | + if rest := strings.TrimPrefix(secret, tokenPrefix); strings.Contains(got, rest[:1]) { | |
| 17 | + t.Errorf("redact leaked secret material: %q", got) | |
| 18 | + } | |
| 19 | + if got := redact("short"); got != "****" { | |
| 20 | + t.Errorf("redact(non-PAT) = %q, want ****", got) | |
| 21 | + } | |
| 22 | + if got := redact(""); got != "****" { | |
| 23 | + t.Errorf("redact(empty) = %q, want ****", got) | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +// A URL handed to the platform opener must be a plain web address: the opener | |
| 28 | +// launches whatever handler is registered for a scheme. | |
| 29 | +func TestCheckBrowserURLRejectsNonWebSchemes(t *testing.T) { | |
| 30 | + ok := []string{ | |
| 31 | + "https://rickub.com/login/device?code=ABCD", | |
| 32 | + "http://localhost:3000/login/device", | |
| 33 | + } | |
| 34 | + for _, u := range ok { | |
| 35 | + if err := checkBrowserURL(u); err != nil { | |
| 36 | + t.Errorf("checkBrowserURL(%q) = %v, want nil", u, err) | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + bad := []string{ | |
| 41 | + "file:///etc/passwd", | |
| 42 | + "javascript:alert(1)", | |
| 43 | + "data:text/html,<script>alert(1)</script>", | |
| 44 | + "ssh://evil.example/x", | |
| 45 | + "vscode://evil", | |
| 46 | + "/login/device", | |
| 47 | + "https://", | |
| 48 | + "", | |
| 49 | + "ht tp://bad", | |
| 50 | + } | |
| 51 | + for _, u := range bad { | |
| 52 | + if err := checkBrowserURL(u); err == nil { | |
| 53 | + t.Errorf("checkBrowserURL(%q) = nil, want an error", u) | |
| 54 | + } | |
| 55 | + } | |
| 56 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +// redact must not reveal any secret material — only the non-secret prefix that | ||
| 9 | +// identifies the credential type. | ||
| 10 | +func TestRedactRevealsNoSecretMaterial(t *testing.T) { | ||
| 11 | + const secret = "rickub_pat_S3CRETMATERIAL" | ||
| 12 | + got := redact(secret) | ||
| 13 | + if got != "rickub_pat_…" { | ||
| 14 | + t.Errorf("redact = %q, want %q", got, "rickub_pat_…") | ||
| 15 | + } | ||
| 16 | + if rest := strings.TrimPrefix(secret, tokenPrefix); strings.Contains(got, rest[:1]) { | ||
| 17 | + t.Errorf("redact leaked secret material: %q", got) | ||
| 18 | + } | ||
| 19 | + if got := redact("short"); got != "****" { | ||
| 20 | + t.Errorf("redact(non-PAT) = %q, want ****", got) | ||
| 21 | + } | ||
| 22 | + if got := redact(""); got != "****" { | ||
| 23 | + t.Errorf("redact(empty) = %q, want ****", got) | ||
| 24 | + } | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +// A URL handed to the platform opener must be a plain web address: the opener | ||
| 28 | +// launches whatever handler is registered for a scheme. | ||
| 29 | +func TestCheckBrowserURLRejectsNonWebSchemes(t *testing.T) { | ||
| 30 | + ok := []string{ | ||
| 31 | + "https://rickub.com/login/device?code=ABCD", | ||
| 32 | + "http://localhost:3000/login/device", | ||
| 33 | + } | ||
| 34 | + for _, u := range ok { | ||
| 35 | + if err := checkBrowserURL(u); err != nil { | ||
| 36 | + t.Errorf("checkBrowserURL(%q) = %v, want nil", u, err) | ||
| 37 | + } | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + bad := []string{ | ||
| 41 | + "file:///etc/passwd", | ||
| 42 | + "javascript:alert(1)", | ||
| 43 | + "data:text/html,<script>alert(1)</script>", | ||
| 44 | + "ssh://evil.example/x", | ||
| 45 | + "vscode://evil", | ||
| 46 | + "/login/device", | ||
| 47 | + "https://", | ||
| 48 | + "", | ||
| 49 | + "ht tp://bad", | ||
| 50 | + } | ||
| 51 | + for _, u := range bad { | ||
| 52 | + if err := checkBrowserURL(u); err == nil { | ||
| 53 | + t.Errorf("checkBrowserURL(%q) = nil, want an error", u) | ||
| 54 | + } | ||
| 55 | + } | ||
| 56 | +} | ||
added
cmd/browse.go +91 -0 | new file mode 100644 | ||
| @@ -0,0 +1,91 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + neturl "net/url" | |
| 6 | + "os/exec" | |
| 7 | + "runtime" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "github.com/spf13/cobra" | |
| 11 | +) | |
| 12 | + | |
| 13 | +var browsePrintOnly bool | |
| 14 | + | |
| 15 | +func init() { | |
| 16 | + browseCmd := &cobra.Command{ | |
| 17 | + Use: "browse [owner/repo]", | |
| 18 | + Short: "Open a repository in your browser", | |
| 19 | + Long: `Open a repository's web page. With no argument the repo is inferred from the | |
| 20 | +current directory's git remote. Use --print to only print the URL.`, | |
| 21 | + Args: cobra.RangeArgs(0, 1), | |
| 22 | + RunE: runBrowse, | |
| 23 | + } | |
| 24 | + browseCmd.Flags().BoolVarP(&browsePrintOnly, "print", "p", false, "print the URL instead of opening it") | |
| 25 | + rootCmd.AddCommand(browseCmd) | |
| 26 | +} | |
| 27 | + | |
| 28 | +func runBrowse(cmd *cobra.Command, args []string) error { | |
| 29 | + cfg, err := loadConfig() | |
| 30 | + if err != nil { | |
| 31 | + return err | |
| 32 | + } | |
| 33 | + spec := "" | |
| 34 | + if len(args) == 1 { | |
| 35 | + spec = args[0] | |
| 36 | + } | |
| 37 | + owner, repo, err := resolveRepo(spec) | |
| 38 | + if err != nil { | |
| 39 | + return err | |
| 40 | + } | |
| 41 | + url := fmt.Sprintf("%s/%s/%s", hostFor(cfg), owner, repo) | |
| 42 | + if browsePrintOnly { | |
| 43 | + fmt.Fprintln(cmd.OutOrStdout(), url) | |
| 44 | + return nil | |
| 45 | + } | |
| 46 | + fmt.Fprintf(cmd.OutOrStdout(), "Opening %s\n", url) | |
| 47 | + return openBrowser(url) | |
| 48 | +} | |
| 49 | + | |
| 50 | +// checkBrowserURL rejects anything the platform opener should not be handed. | |
| 51 | +// The opener will launch whatever handler is registered for a scheme, so a URL | |
| 52 | +// that came from a server (or a stale config) must be a plain web address | |
| 53 | +// before we exec it. | |
| 54 | +func checkBrowserURL(raw string) error { | |
| 55 | + u, err := neturl.Parse(raw) | |
| 56 | + if err != nil { | |
| 57 | + return fmt.Errorf("not a valid URL") | |
| 58 | + } | |
| 59 | + switch strings.ToLower(u.Scheme) { | |
| 60 | + case "http", "https": | |
| 61 | + default: | |
| 62 | + if u.Scheme == "" { | |
| 63 | + return fmt.Errorf("URL has no scheme; only http and https are opened") | |
| 64 | + } | |
| 65 | + return fmt.Errorf("refusing to open a %q URL; only http and https are opened", u.Scheme) | |
| 66 | + } | |
| 67 | + if u.Host == "" { | |
| 68 | + return fmt.Errorf("URL has no host") | |
| 69 | + } | |
| 70 | + return nil | |
| 71 | +} | |
| 72 | + | |
| 73 | +func openBrowser(url string) error { | |
| 74 | + if err := checkBrowserURL(url); err != nil { | |
| 75 | + return err | |
| 76 | + } | |
| 77 | + var name string | |
| 78 | + var args []string | |
| 79 | + switch runtime.GOOS { | |
| 80 | + case "darwin": | |
| 81 | + name = "open" | |
| 82 | + args = []string{url} | |
| 83 | + case "windows": | |
| 84 | + name = "rundll32" | |
| 85 | + args = []string{"url.dll,FileProtocolHandler", url} | |
| 86 | + default: | |
| 87 | + name = "xdg-open" | |
| 88 | + args = []string{url} | |
| 89 | + } | |
| 90 | + return exec.Command(name, args...).Start() | |
| 91 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,91 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + neturl "net/url" | ||
| 6 | + "os/exec" | ||
| 7 | + "runtime" | ||
| 8 | + "strings" | ||
| 9 | + | ||
| 10 | + "github.com/spf13/cobra" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +var browsePrintOnly bool | ||
| 14 | + | ||
| 15 | +func init() { | ||
| 16 | + browseCmd := &cobra.Command{ | ||
| 17 | + Use: "browse [owner/repo]", | ||
| 18 | + Short: "Open a repository in your browser", | ||
| 19 | + Long: `Open a repository's web page. With no argument the repo is inferred from the | ||
| 20 | +current directory's git remote. Use --print to only print the URL.`, | ||
| 21 | + Args: cobra.RangeArgs(0, 1), | ||
| 22 | + RunE: runBrowse, | ||
| 23 | + } | ||
| 24 | + browseCmd.Flags().BoolVarP(&browsePrintOnly, "print", "p", false, "print the URL instead of opening it") | ||
| 25 | + rootCmd.AddCommand(browseCmd) | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +func runBrowse(cmd *cobra.Command, args []string) error { | ||
| 29 | + cfg, err := loadConfig() | ||
| 30 | + if err != nil { | ||
| 31 | + return err | ||
| 32 | + } | ||
| 33 | + spec := "" | ||
| 34 | + if len(args) == 1 { | ||
| 35 | + spec = args[0] | ||
| 36 | + } | ||
| 37 | + owner, repo, err := resolveRepo(spec) | ||
| 38 | + if err != nil { | ||
| 39 | + return err | ||
| 40 | + } | ||
| 41 | + url := fmt.Sprintf("%s/%s/%s", hostFor(cfg), owner, repo) | ||
| 42 | + if browsePrintOnly { | ||
| 43 | + fmt.Fprintln(cmd.OutOrStdout(), url) | ||
| 44 | + return nil | ||
| 45 | + } | ||
| 46 | + fmt.Fprintf(cmd.OutOrStdout(), "Opening %s\n", url) | ||
| 47 | + return openBrowser(url) | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +// checkBrowserURL rejects anything the platform opener should not be handed. | ||
| 51 | +// The opener will launch whatever handler is registered for a scheme, so a URL | ||
| 52 | +// that came from a server (or a stale config) must be a plain web address | ||
| 53 | +// before we exec it. | ||
| 54 | +func checkBrowserURL(raw string) error { | ||
| 55 | + u, err := neturl.Parse(raw) | ||
| 56 | + if err != nil { | ||
| 57 | + return fmt.Errorf("not a valid URL") | ||
| 58 | + } | ||
| 59 | + switch strings.ToLower(u.Scheme) { | ||
| 60 | + case "http", "https": | ||
| 61 | + default: | ||
| 62 | + if u.Scheme == "" { | ||
| 63 | + return fmt.Errorf("URL has no scheme; only http and https are opened") | ||
| 64 | + } | ||
| 65 | + return fmt.Errorf("refusing to open a %q URL; only http and https are opened", u.Scheme) | ||
| 66 | + } | ||
| 67 | + if u.Host == "" { | ||
| 68 | + return fmt.Errorf("URL has no host") | ||
| 69 | + } | ||
| 70 | + return nil | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +func openBrowser(url string) error { | ||
| 74 | + if err := checkBrowserURL(url); err != nil { | ||
| 75 | + return err | ||
| 76 | + } | ||
| 77 | + var name string | ||
| 78 | + var args []string | ||
| 79 | + switch runtime.GOOS { | ||
| 80 | + case "darwin": | ||
| 81 | + name = "open" | ||
| 82 | + args = []string{url} | ||
| 83 | + case "windows": | ||
| 84 | + name = "rundll32" | ||
| 85 | + args = []string{"url.dll,FileProtocolHandler", url} | ||
| 86 | + default: | ||
| 87 | + name = "xdg-open" | ||
| 88 | + args = []string{url} | ||
| 89 | + } | ||
| 90 | + return exec.Command(name, args...).Start() | ||
| 91 | +} | ||
added
cmd/collaborator.go +109 -0 | new file mode 100644 | ||
| @@ -0,0 +1,109 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + | |
| 6 | + "github.com/spf13/cobra" | |
| 7 | +) | |
| 8 | + | |
| 9 | +var collabPermission string | |
| 10 | + | |
| 11 | +// collaboratorCmd builds the `rickub repo collaborator` subtree. | |
| 12 | +func collaboratorCmd() *cobra.Command { | |
| 13 | + c := &cobra.Command{ | |
| 14 | + Use: "collaborator", | |
| 15 | + Aliases: []string{"collab"}, | |
| 16 | + Short: "Manage repository collaborators", | |
| 17 | + } | |
| 18 | + | |
| 19 | + listCmd := &cobra.Command{ | |
| 20 | + Use: "list <owner/repo>", | |
| 21 | + Short: "List collaborators (admin only)", | |
| 22 | + Args: cobra.ExactArgs(1), | |
| 23 | + RunE: runCollabList, | |
| 24 | + } | |
| 25 | + | |
| 26 | + addCmd := &cobra.Command{ | |
| 27 | + Use: "add <owner/repo> <user>", | |
| 28 | + Short: "Add or update a collaborator", | |
| 29 | + Args: cobra.ExactArgs(2), | |
| 30 | + RunE: runCollabAdd, | |
| 31 | + } | |
| 32 | + addCmd.Flags().StringVar(&collabPermission, "permission", "write", "read | write | admin") | |
| 33 | + | |
| 34 | + removeCmd := &cobra.Command{ | |
| 35 | + Use: "remove <owner/repo> <user>", | |
| 36 | + Aliases: []string{"rm"}, | |
| 37 | + Short: "Remove a collaborator", | |
| 38 | + Args: cobra.ExactArgs(2), | |
| 39 | + RunE: runCollabRemove, | |
| 40 | + } | |
| 41 | + | |
| 42 | + c.AddCommand(listCmd, addCmd, removeCmd) | |
| 43 | + return c | |
| 44 | +} | |
| 45 | + | |
| 46 | +func runCollabList(cmd *cobra.Command, args []string) error { | |
| 47 | + client, err := newClient() | |
| 48 | + if err != nil { | |
| 49 | + return err | |
| 50 | + } | |
| 51 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 52 | + if err != nil { | |
| 53 | + return err | |
| 54 | + } | |
| 55 | + cols, err := client.ListCollaborators(cmd.Context(), owner, repo) | |
| 56 | + if err != nil { | |
| 57 | + return err | |
| 58 | + } | |
| 59 | + if flagJSON { | |
| 60 | + return printJSON(cmd.OutOrStdout(), cols) | |
| 61 | + } | |
| 62 | + if len(cols) == 0 { | |
| 63 | + fmt.Fprintln(cmd.OutOrStdout(), "No collaborators.") | |
| 64 | + return nil | |
| 65 | + } | |
| 66 | + tw := newTabw(cmd.OutOrStdout()) | |
| 67 | + fmt.Fprintln(tw, "HANDLE\tPERMISSION\tNAME") | |
| 68 | + for _, c := range cols { | |
| 69 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Handle, c.Permission, dash(c.DisplayName)) | |
| 70 | + } | |
| 71 | + tw.Flush() | |
| 72 | + return nil | |
| 73 | +} | |
| 74 | + | |
| 75 | +func runCollabAdd(cmd *cobra.Command, args []string) error { | |
| 76 | + client, err := newClient() | |
| 77 | + if err != nil { | |
| 78 | + return err | |
| 79 | + } | |
| 80 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 81 | + if err != nil { | |
| 82 | + return err | |
| 83 | + } | |
| 84 | + col, err := client.PutCollaborator(cmd.Context(), owner, repo, args[1], collabPermission) | |
| 85 | + if err != nil { | |
| 86 | + return err | |
| 87 | + } | |
| 88 | + if flagJSON { | |
| 89 | + return printJSON(cmd.OutOrStdout(), col) | |
| 90 | + } | |
| 91 | + fmt.Fprintf(cmd.OutOrStdout(), "Granted %s %s on %s/%s\n", col.Handle, col.Permission, owner, repo) | |
| 92 | + return nil | |
| 93 | +} | |
| 94 | + | |
| 95 | +func runCollabRemove(cmd *cobra.Command, args []string) error { | |
| 96 | + client, err := newClient() | |
| 97 | + if err != nil { | |
| 98 | + return err | |
| 99 | + } | |
| 100 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 101 | + if err != nil { | |
| 102 | + return err | |
| 103 | + } | |
| 104 | + if err := client.DeleteCollaborator(cmd.Context(), owner, repo, args[1]); err != nil { | |
| 105 | + return err | |
| 106 | + } | |
| 107 | + fmt.Fprintf(cmd.OutOrStdout(), "Removed %s from %s/%s\n", args[1], owner, repo) | |
| 108 | + return nil | |
| 109 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,109 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + | ||
| 6 | + "github.com/spf13/cobra" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +var collabPermission string | ||
| 10 | + | ||
| 11 | +// collaboratorCmd builds the `rickub repo collaborator` subtree. | ||
| 12 | +func collaboratorCmd() *cobra.Command { | ||
| 13 | + c := &cobra.Command{ | ||
| 14 | + Use: "collaborator", | ||
| 15 | + Aliases: []string{"collab"}, | ||
| 16 | + Short: "Manage repository collaborators", | ||
| 17 | + } | ||
| 18 | + | ||
| 19 | + listCmd := &cobra.Command{ | ||
| 20 | + Use: "list <owner/repo>", | ||
| 21 | + Short: "List collaborators (admin only)", | ||
| 22 | + Args: cobra.ExactArgs(1), | ||
| 23 | + RunE: runCollabList, | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + addCmd := &cobra.Command{ | ||
| 27 | + Use: "add <owner/repo> <user>", | ||
| 28 | + Short: "Add or update a collaborator", | ||
| 29 | + Args: cobra.ExactArgs(2), | ||
| 30 | + RunE: runCollabAdd, | ||
| 31 | + } | ||
| 32 | + addCmd.Flags().StringVar(&collabPermission, "permission", "write", "read | write | admin") | ||
| 33 | + | ||
| 34 | + removeCmd := &cobra.Command{ | ||
| 35 | + Use: "remove <owner/repo> <user>", | ||
| 36 | + Aliases: []string{"rm"}, | ||
| 37 | + Short: "Remove a collaborator", | ||
| 38 | + Args: cobra.ExactArgs(2), | ||
| 39 | + RunE: runCollabRemove, | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + c.AddCommand(listCmd, addCmd, removeCmd) | ||
| 43 | + return c | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +func runCollabList(cmd *cobra.Command, args []string) error { | ||
| 47 | + client, err := newClient() | ||
| 48 | + if err != nil { | ||
| 49 | + return err | ||
| 50 | + } | ||
| 51 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 52 | + if err != nil { | ||
| 53 | + return err | ||
| 54 | + } | ||
| 55 | + cols, err := client.ListCollaborators(cmd.Context(), owner, repo) | ||
| 56 | + if err != nil { | ||
| 57 | + return err | ||
| 58 | + } | ||
| 59 | + if flagJSON { | ||
| 60 | + return printJSON(cmd.OutOrStdout(), cols) | ||
| 61 | + } | ||
| 62 | + if len(cols) == 0 { | ||
| 63 | + fmt.Fprintln(cmd.OutOrStdout(), "No collaborators.") | ||
| 64 | + return nil | ||
| 65 | + } | ||
| 66 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 67 | + fmt.Fprintln(tw, "HANDLE\tPERMISSION\tNAME") | ||
| 68 | + for _, c := range cols { | ||
| 69 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Handle, c.Permission, dash(c.DisplayName)) | ||
| 70 | + } | ||
| 71 | + tw.Flush() | ||
| 72 | + return nil | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +func runCollabAdd(cmd *cobra.Command, args []string) error { | ||
| 76 | + client, err := newClient() | ||
| 77 | + if err != nil { | ||
| 78 | + return err | ||
| 79 | + } | ||
| 80 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 81 | + if err != nil { | ||
| 82 | + return err | ||
| 83 | + } | ||
| 84 | + col, err := client.PutCollaborator(cmd.Context(), owner, repo, args[1], collabPermission) | ||
| 85 | + if err != nil { | ||
| 86 | + return err | ||
| 87 | + } | ||
| 88 | + if flagJSON { | ||
| 89 | + return printJSON(cmd.OutOrStdout(), col) | ||
| 90 | + } | ||
| 91 | + fmt.Fprintf(cmd.OutOrStdout(), "Granted %s %s on %s/%s\n", col.Handle, col.Permission, owner, repo) | ||
| 92 | + return nil | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +func runCollabRemove(cmd *cobra.Command, args []string) error { | ||
| 96 | + client, err := newClient() | ||
| 97 | + if err != nil { | ||
| 98 | + return err | ||
| 99 | + } | ||
| 100 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 101 | + if err != nil { | ||
| 102 | + return err | ||
| 103 | + } | ||
| 104 | + if err := client.DeleteCollaborator(cmd.Context(), owner, repo, args[1]); err != nil { | ||
| 105 | + return err | ||
| 106 | + } | ||
| 107 | + fmt.Fprintf(cmd.OutOrStdout(), "Removed %s from %s/%s\n", args[1], owner, repo) | ||
| 108 | + return nil | ||
| 109 | +} | ||
added
cmd/issue.go +445 -0 | new file mode 100644 | ||
| @@ -0,0 +1,445 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strconv" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "github.com/spf13/cobra" | |
| 9 | +) | |
| 10 | + | |
| 11 | +var ( | |
| 12 | + issueRepo string | |
| 13 | + issueState string | |
| 14 | + issueTitle string | |
| 15 | + issueBody string | |
| 16 | + issueLabels string | |
| 17 | + issueMilestone string | |
| 18 | + issueAssignee string | |
| 19 | + issueRemove bool | |
| 20 | +) | |
| 21 | + | |
| 22 | +func init() { | |
| 23 | + issueCmd := &cobra.Command{ | |
| 24 | + Use: "issue", | |
| 25 | + Aliases: []string{"issues"}, | |
| 26 | + Short: "Work with issues (and labels)", | |
| 27 | + } | |
| 28 | + issueCmd.PersistentFlags().StringVarP(&issueRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") | |
| 29 | + | |
| 30 | + listCmd := &cobra.Command{ | |
| 31 | + Use: "list", | |
| 32 | + Short: "List issues", | |
| 33 | + Args: cobra.NoArgs, | |
| 34 | + RunE: runIssueList, | |
| 35 | + } | |
| 36 | + listCmd.Flags().StringVar(&issueState, "state", "open", "open | closed | all") | |
| 37 | + addPaging(listCmd) | |
| 38 | + | |
| 39 | + viewCmd := &cobra.Command{ | |
| 40 | + Use: "view <number>", | |
| 41 | + Short: "Show an issue with its comments", | |
| 42 | + Args: cobra.ExactArgs(1), | |
| 43 | + RunE: runIssueView, | |
| 44 | + } | |
| 45 | + | |
| 46 | + createCmd := &cobra.Command{ | |
| 47 | + Use: "create", | |
| 48 | + Short: "Open an issue", | |
| 49 | + Args: cobra.NoArgs, | |
| 50 | + RunE: runIssueCreate, | |
| 51 | + } | |
| 52 | + createCmd.Flags().StringVarP(&issueTitle, "title", "t", "", "title (required)") | |
| 53 | + createCmd.Flags().StringVarP(&issueBody, "body", "b", "", "description body ('-' reads stdin)") | |
| 54 | + | |
| 55 | + closeCmd := &cobra.Command{ | |
| 56 | + Use: "close <number>", | |
| 57 | + Short: "Close an issue", | |
| 58 | + Args: cobra.ExactArgs(1), | |
| 59 | + RunE: func(cmd *cobra.Command, args []string) error { return runIssueState(cmd, args, "closed") }, | |
| 60 | + } | |
| 61 | + | |
| 62 | + reopenCmd := &cobra.Command{ | |
| 63 | + Use: "reopen <number>", | |
| 64 | + Short: "Reopen a closed issue", | |
| 65 | + Args: cobra.ExactArgs(1), | |
| 66 | + RunE: func(cmd *cobra.Command, args []string) error { return runIssueState(cmd, args, "open") }, | |
| 67 | + } | |
| 68 | + | |
| 69 | + commentCmd := &cobra.Command{ | |
| 70 | + Use: "comment <number>", | |
| 71 | + Short: "Comment on an issue", | |
| 72 | + Args: cobra.ExactArgs(1), | |
| 73 | + RunE: runIssueComment, | |
| 74 | + } | |
| 75 | + commentCmd.Flags().StringVarP(&issueBody, "body", "b", "", "comment body ('-' reads stdin)") | |
| 76 | + | |
| 77 | + labelCmd := &cobra.Command{ | |
| 78 | + Use: "label <number>", | |
| 79 | + Short: "Replace an issue's labels (by name, comma-separated; --clear empties)", | |
| 80 | + Args: cobra.ExactArgs(1), | |
| 81 | + RunE: runIssueLabel, | |
| 82 | + } | |
| 83 | + labelCmd.Flags().StringVar(&issueLabels, "labels", "", "comma-separated label names") | |
| 84 | + labelCmd.Flags().BoolVar(&issueRemove, "clear", false, "remove all labels") | |
| 85 | + | |
| 86 | + milestoneCmd := &cobra.Command{ | |
| 87 | + Use: "milestone <number>", | |
| 88 | + Short: "Assign an issue to a milestone (by title or id; --clear removes)", | |
| 89 | + Args: cobra.ExactArgs(1), | |
| 90 | + RunE: runIssueMilestone, | |
| 91 | + } | |
| 92 | + milestoneCmd.Flags().StringVar(&issueMilestone, "milestone", "", "milestone title or id") | |
| 93 | + milestoneCmd.Flags().BoolVar(&issueRemove, "clear", false, "remove the milestone") | |
| 94 | + | |
| 95 | + assignCmd := &cobra.Command{ | |
| 96 | + Use: "assign <number>", | |
| 97 | + Short: "Add or remove an assignee (--remove)", | |
| 98 | + Args: cobra.ExactArgs(1), | |
| 99 | + RunE: runIssueAssign, | |
| 100 | + } | |
| 101 | + assignCmd.Flags().StringVar(&issueAssignee, "user", "", "user handle") | |
| 102 | + assignCmd.Flags().BoolVar(&issueRemove, "remove", false, "remove instead of add") | |
| 103 | + | |
| 104 | + labelsCmd := &cobra.Command{ | |
| 105 | + Use: "labels", | |
| 106 | + Short: "List the repo's labels", | |
| 107 | + Args: cobra.NoArgs, | |
| 108 | + RunE: runIssueLabelsList, | |
| 109 | + } | |
| 110 | + | |
| 111 | + issueCmd.AddCommand(listCmd, viewCmd, createCmd, closeCmd, reopenCmd, commentCmd, labelCmd, milestoneCmd, assignCmd, labelsCmd) | |
| 112 | + rootCmd.AddCommand(issueCmd) | |
| 113 | +} | |
| 114 | + | |
| 115 | +// issueNumber parses a positive issue number argument. | |
| 116 | +func issueNumber(arg string) (int, error) { | |
| 117 | + n, err := strconv.Atoi(arg) | |
| 118 | + if err != nil || n <= 0 { | |
| 119 | + return 0, fmt.Errorf("invalid issue number %q", arg) | |
| 120 | + } | |
| 121 | + return n, nil | |
| 122 | +} | |
| 123 | + | |
| 124 | +// labelNames splits a comma-separated --labels value. | |
| 125 | +func labelNames(s string) []string { | |
| 126 | + if strings.TrimSpace(s) == "" { | |
| 127 | + return []string{} | |
| 128 | + } | |
| 129 | + parts := strings.Split(s, ",") | |
| 130 | + out := make([]string, 0, len(parts)) | |
| 131 | + for _, p := range parts { | |
| 132 | + if name := strings.TrimSpace(p); name != "" { | |
| 133 | + out = append(out, name) | |
| 134 | + } | |
| 135 | + } | |
| 136 | + return out | |
| 137 | +} | |
| 138 | + | |
| 139 | +func runIssueList(cmd *cobra.Command, _ []string) error { | |
| 140 | + client, err := newClient() | |
| 141 | + if err != nil { | |
| 142 | + return err | |
| 143 | + } | |
| 144 | + owner, repo, err := resolveRepo(issueRepo) | |
| 145 | + if err != nil { | |
| 146 | + return err | |
| 147 | + } | |
| 148 | + page, err := client.ListIssues(cmd.Context(), owner, repo, issueState, pageFlag, perPageFlag) | |
| 149 | + if err != nil { | |
| 150 | + return err | |
| 151 | + } | |
| 152 | + if flagJSON { | |
| 153 | + return printJSON(cmd.OutOrStdout(), page) | |
| 154 | + } | |
| 155 | + if len(page.Items) == 0 { | |
| 156 | + fmt.Fprintln(cmd.OutOrStdout(), "No issues.") | |
| 157 | + return nil | |
| 158 | + } | |
| 159 | + tw := newTabw(cmd.OutOrStdout()) | |
| 160 | + fmt.Fprintln(tw, "#\tSTATE\tLABELS\tMILESTONE\tTITLE") | |
| 161 | + for _, i := range page.Items { | |
| 162 | + var labels, milestone string | |
| 163 | + if len(i.Labels) > 0 { | |
| 164 | + names := make([]string, len(i.Labels)) | |
| 165 | + for j, l := range i.Labels { | |
| 166 | + names[j] = l.Name | |
| 167 | + } | |
| 168 | + labels = strings.Join(names, ",") | |
| 169 | + } | |
| 170 | + if i.Milestone != nil { | |
| 171 | + milestone = i.Milestone.Title | |
| 172 | + } | |
| 173 | + fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", i.Number, i.State, dash(labels), dash(milestone), i.Title) | |
| 174 | + } | |
| 175 | + tw.Flush() | |
| 176 | + printPageFooter(cmd, page.Page) | |
| 177 | + return nil | |
| 178 | +} | |
| 179 | + | |
| 180 | +func runIssueView(cmd *cobra.Command, args []string) error { | |
| 181 | + client, err := newClient() | |
| 182 | + if err != nil { | |
| 183 | + return err | |
| 184 | + } | |
| 185 | + owner, repo, err := resolveRepo(issueRepo) | |
| 186 | + if err != nil { | |
| 187 | + return err | |
| 188 | + } | |
| 189 | + n, err := issueNumber(args[0]) | |
| 190 | + if err != nil { | |
| 191 | + return err | |
| 192 | + } | |
| 193 | + i, err := client.GetIssue(cmd.Context(), owner, repo, n) | |
| 194 | + if err != nil { | |
| 195 | + return err | |
| 196 | + } | |
| 197 | + if flagJSON { | |
| 198 | + return printJSON(cmd.OutOrStdout(), i) | |
| 199 | + } | |
| 200 | + out := cmd.OutOrStdout() | |
| 201 | + state := i.State | |
| 202 | + if i.Milestone != nil { | |
| 203 | + state += " · " + i.Milestone.Title | |
| 204 | + } | |
| 205 | + fmt.Fprintf(out, "Issue #%d %s [%s]\n", i.Number, i.Title, state) | |
| 206 | + fmt.Fprintf(out, "by %s · %s\n", dash(i.Author), humanTime(i.CreatedAt)) | |
| 207 | + if len(i.Labels) > 0 { | |
| 208 | + names := make([]string, len(i.Labels)) | |
| 209 | + for j, l := range i.Labels { | |
| 210 | + names[j] = l.Name | |
| 211 | + } | |
| 212 | + fmt.Fprintf(out, "labels: %s\n", strings.Join(names, ", ")) | |
| 213 | + } | |
| 214 | + if len(i.Assignees) > 0 { | |
| 215 | + handles := make([]string, len(i.Assignees)) | |
| 216 | + for j, a := range i.Assignees { | |
| 217 | + handles[j] = a.Handle | |
| 218 | + } | |
| 219 | + fmt.Fprintf(out, "assignees: %s\n", strings.Join(handles, ", ")) | |
| 220 | + } | |
| 221 | + fmt.Fprintln(out) | |
| 222 | + if i.Body != "" { | |
| 223 | + fmt.Fprintln(out, i.Body) | |
| 224 | + } | |
| 225 | + for _, c := range i.Comments { | |
| 226 | + fmt.Fprintf(out, "\n--- %s (%s) ---\n%s\n", c.Author, humanTime(c.CreatedAt), c.Body) | |
| 227 | + } | |
| 228 | + return nil | |
| 229 | +} | |
| 230 | + | |
| 231 | +// bodyOrStdin resolves a --body flag value, reading stdin when it is "-". | |
| 232 | +func bodyOrStdin(cmd *cobra.Command, body string) (string, error) { | |
| 233 | + if body != "-" { | |
| 234 | + return body, nil | |
| 235 | + } | |
| 236 | + var sb strings.Builder | |
| 237 | + buf := make([]byte, 32*1024) | |
| 238 | + in := cmd.InOrStdin() | |
| 239 | + for { | |
| 240 | + n, err := in.Read(buf) | |
| 241 | + sb.Write(buf[:n]) | |
| 242 | + if err != nil { | |
| 243 | + break | |
| 244 | + } | |
| 245 | + } | |
| 246 | + return strings.TrimRight(sb.String(), "\n"), nil | |
| 247 | +} | |
| 248 | + | |
| 249 | +func runIssueCreate(cmd *cobra.Command, _ []string) error { | |
| 250 | + if issueTitle == "" { | |
| 251 | + return fmt.Errorf("--title is required") | |
| 252 | + } | |
| 253 | + body, err := bodyOrStdin(cmd, issueBody) | |
| 254 | + if err != nil { | |
| 255 | + return err | |
| 256 | + } | |
| 257 | + client, err := newClient() | |
| 258 | + if err != nil { | |
| 259 | + return err | |
| 260 | + } | |
| 261 | + owner, repo, err := resolveRepo(issueRepo) | |
| 262 | + if err != nil { | |
| 263 | + return err | |
| 264 | + } | |
| 265 | + i, err := client.CreateIssue(cmd.Context(), owner, repo, issueTitle, body) | |
| 266 | + if err != nil { | |
| 267 | + return err | |
| 268 | + } | |
| 269 | + if flagJSON { | |
| 270 | + return printJSON(cmd.OutOrStdout(), i) | |
| 271 | + } | |
| 272 | + fmt.Fprintf(cmd.OutOrStdout(), "Opened issue #%d: %s\n", i.Number, i.Title) | |
| 273 | + return nil | |
| 274 | +} | |
| 275 | + | |
| 276 | +func runIssueState(cmd *cobra.Command, args []string, state string) error { | |
| 277 | + client, err := newClient() | |
| 278 | + if err != nil { | |
| 279 | + return err | |
| 280 | + } | |
| 281 | + owner, repo, err := resolveRepo(issueRepo) | |
| 282 | + if err != nil { | |
| 283 | + return err | |
| 284 | + } | |
| 285 | + n, err := issueNumber(args[0]) | |
| 286 | + if err != nil { | |
| 287 | + return err | |
| 288 | + } | |
| 289 | + i, err := client.SetIssueState(cmd.Context(), owner, repo, n, state) | |
| 290 | + if err != nil { | |
| 291 | + return err | |
| 292 | + } | |
| 293 | + if flagJSON { | |
| 294 | + return printJSON(cmd.OutOrStdout(), i) | |
| 295 | + } | |
| 296 | + fmt.Fprintf(cmd.OutOrStdout(), "Issue #%d is now %s.\n", i.Number, i.State) | |
| 297 | + return nil | |
| 298 | +} | |
| 299 | + | |
| 300 | +func runIssueComment(cmd *cobra.Command, args []string) error { | |
| 301 | + body, err := bodyOrStdin(cmd, issueBody) | |
| 302 | + if err != nil { | |
| 303 | + return err | |
| 304 | + } | |
| 305 | + if strings.TrimSpace(body) == "" { | |
| 306 | + return fmt.Errorf("--body is required") | |
| 307 | + } | |
| 308 | + client, err := newClient() | |
| 309 | + if err != nil { | |
| 310 | + return err | |
| 311 | + } | |
| 312 | + owner, repo, err := resolveRepo(issueRepo) | |
| 313 | + if err != nil { | |
| 314 | + return err | |
| 315 | + } | |
| 316 | + n, err := issueNumber(args[0]) | |
| 317 | + if err != nil { | |
| 318 | + return err | |
| 319 | + } | |
| 320 | + c, err := client.CommentIssue(cmd.Context(), owner, repo, n, body) | |
| 321 | + if err != nil { | |
| 322 | + return err | |
| 323 | + } | |
| 324 | + if flagJSON { | |
| 325 | + return printJSON(cmd.OutOrStdout(), c) | |
| 326 | + } | |
| 327 | + fmt.Fprintf(cmd.OutOrStdout(), "Commented on issue #%d.\n", n) | |
| 328 | + return nil | |
| 329 | +} | |
| 330 | + | |
| 331 | +func runIssueLabel(cmd *cobra.Command, args []string) error { | |
| 332 | + client, err := newClient() | |
| 333 | + if err != nil { | |
| 334 | + return err | |
| 335 | + } | |
| 336 | + owner, repo, err := resolveRepo(issueRepo) | |
| 337 | + if err != nil { | |
| 338 | + return err | |
| 339 | + } | |
| 340 | + n, err := issueNumber(args[0]) | |
| 341 | + if err != nil { | |
| 342 | + return err | |
| 343 | + } | |
| 344 | + names := []string{} | |
| 345 | + if !issueRemove { | |
| 346 | + names = labelNames(issueLabels) | |
| 347 | + } | |
| 348 | + if err := client.SetIssueLabels(cmd.Context(), owner, repo, n, names); err != nil { | |
| 349 | + return err | |
| 350 | + } | |
| 351 | + if len(names) == 0 { | |
| 352 | + fmt.Fprintf(cmd.OutOrStdout(), "Cleared labels on issue #%d.\n", n) | |
| 353 | + } else { | |
| 354 | + fmt.Fprintf(cmd.OutOrStdout(), "Set labels on issue #%d: %s\n", n, strings.Join(names, ", ")) | |
| 355 | + } | |
| 356 | + return nil | |
| 357 | +} | |
| 358 | + | |
| 359 | +func runIssueMilestone(cmd *cobra.Command, args []string) error { | |
| 360 | + client, err := newClient() | |
| 361 | + if err != nil { | |
| 362 | + return err | |
| 363 | + } | |
| 364 | + owner, repo, err := resolveRepo(issueRepo) | |
| 365 | + if err != nil { | |
| 366 | + return err | |
| 367 | + } | |
| 368 | + n, err := issueNumber(args[0]) | |
| 369 | + if err != nil { | |
| 370 | + return err | |
| 371 | + } | |
| 372 | + ref := issueMilestone | |
| 373 | + if issueRemove { | |
| 374 | + ref = "" | |
| 375 | + } | |
| 376 | + if err := client.SetIssueMilestone(cmd.Context(), owner, repo, n, ref); err != nil { | |
| 377 | + return err | |
| 378 | + } | |
| 379 | + if ref == "" { | |
| 380 | + fmt.Fprintf(cmd.OutOrStdout(), "Removed the milestone from issue #%d.\n", n) | |
| 381 | + } else { | |
| 382 | + fmt.Fprintf(cmd.OutOrStdout(), "Set issue #%d's milestone to %s.\n", n, ref) | |
| 383 | + } | |
| 384 | + return nil | |
| 385 | +} | |
| 386 | + | |
| 387 | +func runIssueAssign(cmd *cobra.Command, args []string) error { | |
| 388 | + if issueAssignee == "" { | |
| 389 | + return fmt.Errorf("--user is required") | |
| 390 | + } | |
| 391 | + client, err := newClient() | |
| 392 | + if err != nil { | |
| 393 | + return err | |
| 394 | + } | |
| 395 | + owner, repo, err := resolveRepo(issueRepo) | |
| 396 | + if err != nil { | |
| 397 | + return err | |
| 398 | + } | |
| 399 | + n, err := issueNumber(args[0]) | |
| 400 | + if err != nil { | |
| 401 | + return err | |
| 402 | + } | |
| 403 | + op := "add" | |
| 404 | + if issueRemove { | |
| 405 | + op = "remove" | |
| 406 | + } | |
| 407 | + if err := client.SetIssueAssignee(cmd.Context(), owner, repo, n, op, issueAssignee); err != nil { | |
| 408 | + return err | |
| 409 | + } | |
| 410 | + verb := "Assigned" | |
| 411 | + if issueRemove { | |
| 412 | + verb = "Unassigned" | |
| 413 | + } | |
| 414 | + fmt.Fprintf(cmd.OutOrStdout(), "%s %s on issue #%d.\n", verb, issueAssignee, n) | |
| 415 | + return nil | |
| 416 | +} | |
| 417 | + | |
| 418 | +func runIssueLabelsList(cmd *cobra.Command, _ []string) error { | |
| 419 | + client, err := newClient() | |
| 420 | + if err != nil { | |
| 421 | + return err | |
| 422 | + } | |
| 423 | + owner, repo, err := resolveRepo(issueRepo) | |
| 424 | + if err != nil { | |
| 425 | + return err | |
| 426 | + } | |
| 427 | + labels, err := client.ListLabels(cmd.Context(), owner, repo) | |
| 428 | + if err != nil { | |
| 429 | + return err | |
| 430 | + } | |
| 431 | + if flagJSON { | |
| 432 | + return printJSON(cmd.OutOrStdout(), labels) | |
| 433 | + } | |
| 434 | + if len(labels) == 0 { | |
| 435 | + fmt.Fprintln(cmd.OutOrStdout(), "No labels.") | |
| 436 | + return nil | |
| 437 | + } | |
| 438 | + tw := newTabw(cmd.OutOrStdout()) | |
| 439 | + fmt.Fprintln(tw, "NAME\tCOLOR") | |
| 440 | + for _, l := range labels { | |
| 441 | + fmt.Fprintf(tw, "%s\t#%s\n", l.Name, l.Color) | |
| 442 | + } | |
| 443 | + tw.Flush() | |
| 444 | + return nil | |
| 445 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,445 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "strconv" | ||
| 6 | + "strings" | ||
| 7 | + | ||
| 8 | + "github.com/spf13/cobra" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +var ( | ||
| 12 | + issueRepo string | ||
| 13 | + issueState string | ||
| 14 | + issueTitle string | ||
| 15 | + issueBody string | ||
| 16 | + issueLabels string | ||
| 17 | + issueMilestone string | ||
| 18 | + issueAssignee string | ||
| 19 | + issueRemove bool | ||
| 20 | +) | ||
| 21 | + | ||
| 22 | +func init() { | ||
| 23 | + issueCmd := &cobra.Command{ | ||
| 24 | + Use: "issue", | ||
| 25 | + Aliases: []string{"issues"}, | ||
| 26 | + Short: "Work with issues (and labels)", | ||
| 27 | + } | ||
| 28 | + issueCmd.PersistentFlags().StringVarP(&issueRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") | ||
| 29 | + | ||
| 30 | + listCmd := &cobra.Command{ | ||
| 31 | + Use: "list", | ||
| 32 | + Short: "List issues", | ||
| 33 | + Args: cobra.NoArgs, | ||
| 34 | + RunE: runIssueList, | ||
| 35 | + } | ||
| 36 | + listCmd.Flags().StringVar(&issueState, "state", "open", "open | closed | all") | ||
| 37 | + addPaging(listCmd) | ||
| 38 | + | ||
| 39 | + viewCmd := &cobra.Command{ | ||
| 40 | + Use: "view <number>", | ||
| 41 | + Short: "Show an issue with its comments", | ||
| 42 | + Args: cobra.ExactArgs(1), | ||
| 43 | + RunE: runIssueView, | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + createCmd := &cobra.Command{ | ||
| 47 | + Use: "create", | ||
| 48 | + Short: "Open an issue", | ||
| 49 | + Args: cobra.NoArgs, | ||
| 50 | + RunE: runIssueCreate, | ||
| 51 | + } | ||
| 52 | + createCmd.Flags().StringVarP(&issueTitle, "title", "t", "", "title (required)") | ||
| 53 | + createCmd.Flags().StringVarP(&issueBody, "body", "b", "", "description body ('-' reads stdin)") | ||
| 54 | + | ||
| 55 | + closeCmd := &cobra.Command{ | ||
| 56 | + Use: "close <number>", | ||
| 57 | + Short: "Close an issue", | ||
| 58 | + Args: cobra.ExactArgs(1), | ||
| 59 | + RunE: func(cmd *cobra.Command, args []string) error { return runIssueState(cmd, args, "closed") }, | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + reopenCmd := &cobra.Command{ | ||
| 63 | + Use: "reopen <number>", | ||
| 64 | + Short: "Reopen a closed issue", | ||
| 65 | + Args: cobra.ExactArgs(1), | ||
| 66 | + RunE: func(cmd *cobra.Command, args []string) error { return runIssueState(cmd, args, "open") }, | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + commentCmd := &cobra.Command{ | ||
| 70 | + Use: "comment <number>", | ||
| 71 | + Short: "Comment on an issue", | ||
| 72 | + Args: cobra.ExactArgs(1), | ||
| 73 | + RunE: runIssueComment, | ||
| 74 | + } | ||
| 75 | + commentCmd.Flags().StringVarP(&issueBody, "body", "b", "", "comment body ('-' reads stdin)") | ||
| 76 | + | ||
| 77 | + labelCmd := &cobra.Command{ | ||
| 78 | + Use: "label <number>", | ||
| 79 | + Short: "Replace an issue's labels (by name, comma-separated; --clear empties)", | ||
| 80 | + Args: cobra.ExactArgs(1), | ||
| 81 | + RunE: runIssueLabel, | ||
| 82 | + } | ||
| 83 | + labelCmd.Flags().StringVar(&issueLabels, "labels", "", "comma-separated label names") | ||
| 84 | + labelCmd.Flags().BoolVar(&issueRemove, "clear", false, "remove all labels") | ||
| 85 | + | ||
| 86 | + milestoneCmd := &cobra.Command{ | ||
| 87 | + Use: "milestone <number>", | ||
| 88 | + Short: "Assign an issue to a milestone (by title or id; --clear removes)", | ||
| 89 | + Args: cobra.ExactArgs(1), | ||
| 90 | + RunE: runIssueMilestone, | ||
| 91 | + } | ||
| 92 | + milestoneCmd.Flags().StringVar(&issueMilestone, "milestone", "", "milestone title or id") | ||
| 93 | + milestoneCmd.Flags().BoolVar(&issueRemove, "clear", false, "remove the milestone") | ||
| 94 | + | ||
| 95 | + assignCmd := &cobra.Command{ | ||
| 96 | + Use: "assign <number>", | ||
| 97 | + Short: "Add or remove an assignee (--remove)", | ||
| 98 | + Args: cobra.ExactArgs(1), | ||
| 99 | + RunE: runIssueAssign, | ||
| 100 | + } | ||
| 101 | + assignCmd.Flags().StringVar(&issueAssignee, "user", "", "user handle") | ||
| 102 | + assignCmd.Flags().BoolVar(&issueRemove, "remove", false, "remove instead of add") | ||
| 103 | + | ||
| 104 | + labelsCmd := &cobra.Command{ | ||
| 105 | + Use: "labels", | ||
| 106 | + Short: "List the repo's labels", | ||
| 107 | + Args: cobra.NoArgs, | ||
| 108 | + RunE: runIssueLabelsList, | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + issueCmd.AddCommand(listCmd, viewCmd, createCmd, closeCmd, reopenCmd, commentCmd, labelCmd, milestoneCmd, assignCmd, labelsCmd) | ||
| 112 | + rootCmd.AddCommand(issueCmd) | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +// issueNumber parses a positive issue number argument. | ||
| 116 | +func issueNumber(arg string) (int, error) { | ||
| 117 | + n, err := strconv.Atoi(arg) | ||
| 118 | + if err != nil || n <= 0 { | ||
| 119 | + return 0, fmt.Errorf("invalid issue number %q", arg) | ||
| 120 | + } | ||
| 121 | + return n, nil | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +// labelNames splits a comma-separated --labels value. | ||
| 125 | +func labelNames(s string) []string { | ||
| 126 | + if strings.TrimSpace(s) == "" { | ||
| 127 | + return []string{} | ||
| 128 | + } | ||
| 129 | + parts := strings.Split(s, ",") | ||
| 130 | + out := make([]string, 0, len(parts)) | ||
| 131 | + for _, p := range parts { | ||
| 132 | + if name := strings.TrimSpace(p); name != "" { | ||
| 133 | + out = append(out, name) | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + return out | ||
| 137 | +} | ||
| 138 | + | ||
| 139 | +func runIssueList(cmd *cobra.Command, _ []string) error { | ||
| 140 | + client, err := newClient() | ||
| 141 | + if err != nil { | ||
| 142 | + return err | ||
| 143 | + } | ||
| 144 | + owner, repo, err := resolveRepo(issueRepo) | ||
| 145 | + if err != nil { | ||
| 146 | + return err | ||
| 147 | + } | ||
| 148 | + page, err := client.ListIssues(cmd.Context(), owner, repo, issueState, pageFlag, perPageFlag) | ||
| 149 | + if err != nil { | ||
| 150 | + return err | ||
| 151 | + } | ||
| 152 | + if flagJSON { | ||
| 153 | + return printJSON(cmd.OutOrStdout(), page) | ||
| 154 | + } | ||
| 155 | + if len(page.Items) == 0 { | ||
| 156 | + fmt.Fprintln(cmd.OutOrStdout(), "No issues.") | ||
| 157 | + return nil | ||
| 158 | + } | ||
| 159 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 160 | + fmt.Fprintln(tw, "#\tSTATE\tLABELS\tMILESTONE\tTITLE") | ||
| 161 | + for _, i := range page.Items { | ||
| 162 | + var labels, milestone string | ||
| 163 | + if len(i.Labels) > 0 { | ||
| 164 | + names := make([]string, len(i.Labels)) | ||
| 165 | + for j, l := range i.Labels { | ||
| 166 | + names[j] = l.Name | ||
| 167 | + } | ||
| 168 | + labels = strings.Join(names, ",") | ||
| 169 | + } | ||
| 170 | + if i.Milestone != nil { | ||
| 171 | + milestone = i.Milestone.Title | ||
| 172 | + } | ||
| 173 | + fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", i.Number, i.State, dash(labels), dash(milestone), i.Title) | ||
| 174 | + } | ||
| 175 | + tw.Flush() | ||
| 176 | + printPageFooter(cmd, page.Page) | ||
| 177 | + return nil | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +func runIssueView(cmd *cobra.Command, args []string) error { | ||
| 181 | + client, err := newClient() | ||
| 182 | + if err != nil { | ||
| 183 | + return err | ||
| 184 | + } | ||
| 185 | + owner, repo, err := resolveRepo(issueRepo) | ||
| 186 | + if err != nil { | ||
| 187 | + return err | ||
| 188 | + } | ||
| 189 | + n, err := issueNumber(args[0]) | ||
| 190 | + if err != nil { | ||
| 191 | + return err | ||
| 192 | + } | ||
| 193 | + i, err := client.GetIssue(cmd.Context(), owner, repo, n) | ||
| 194 | + if err != nil { | ||
| 195 | + return err | ||
| 196 | + } | ||
| 197 | + if flagJSON { | ||
| 198 | + return printJSON(cmd.OutOrStdout(), i) | ||
| 199 | + } | ||
| 200 | + out := cmd.OutOrStdout() | ||
| 201 | + state := i.State | ||
| 202 | + if i.Milestone != nil { | ||
| 203 | + state += " · " + i.Milestone.Title | ||
| 204 | + } | ||
| 205 | + fmt.Fprintf(out, "Issue #%d %s [%s]\n", i.Number, i.Title, state) | ||
| 206 | + fmt.Fprintf(out, "by %s · %s\n", dash(i.Author), humanTime(i.CreatedAt)) | ||
| 207 | + if len(i.Labels) > 0 { | ||
| 208 | + names := make([]string, len(i.Labels)) | ||
| 209 | + for j, l := range i.Labels { | ||
| 210 | + names[j] = l.Name | ||
| 211 | + } | ||
| 212 | + fmt.Fprintf(out, "labels: %s\n", strings.Join(names, ", ")) | ||
| 213 | + } | ||
| 214 | + if len(i.Assignees) > 0 { | ||
| 215 | + handles := make([]string, len(i.Assignees)) | ||
| 216 | + for j, a := range i.Assignees { | ||
| 217 | + handles[j] = a.Handle | ||
| 218 | + } | ||
| 219 | + fmt.Fprintf(out, "assignees: %s\n", strings.Join(handles, ", ")) | ||
| 220 | + } | ||
| 221 | + fmt.Fprintln(out) | ||
| 222 | + if i.Body != "" { | ||
| 223 | + fmt.Fprintln(out, i.Body) | ||
| 224 | + } | ||
| 225 | + for _, c := range i.Comments { | ||
| 226 | + fmt.Fprintf(out, "\n--- %s (%s) ---\n%s\n", c.Author, humanTime(c.CreatedAt), c.Body) | ||
| 227 | + } | ||
| 228 | + return nil | ||
| 229 | +} | ||
| 230 | + | ||
| 231 | +// bodyOrStdin resolves a --body flag value, reading stdin when it is "-". | ||
| 232 | +func bodyOrStdin(cmd *cobra.Command, body string) (string, error) { | ||
| 233 | + if body != "-" { | ||
| 234 | + return body, nil | ||
| 235 | + } | ||
| 236 | + var sb strings.Builder | ||
| 237 | + buf := make([]byte, 32*1024) | ||
| 238 | + in := cmd.InOrStdin() | ||
| 239 | + for { | ||
| 240 | + n, err := in.Read(buf) | ||
| 241 | + sb.Write(buf[:n]) | ||
| 242 | + if err != nil { | ||
| 243 | + break | ||
| 244 | + } | ||
| 245 | + } | ||
| 246 | + return strings.TrimRight(sb.String(), "\n"), nil | ||
| 247 | +} | ||
| 248 | + | ||
| 249 | +func runIssueCreate(cmd *cobra.Command, _ []string) error { | ||
| 250 | + if issueTitle == "" { | ||
| 251 | + return fmt.Errorf("--title is required") | ||
| 252 | + } | ||
| 253 | + body, err := bodyOrStdin(cmd, issueBody) | ||
| 254 | + if err != nil { | ||
| 255 | + return err | ||
| 256 | + } | ||
| 257 | + client, err := newClient() | ||
| 258 | + if err != nil { | ||
| 259 | + return err | ||
| 260 | + } | ||
| 261 | + owner, repo, err := resolveRepo(issueRepo) | ||
| 262 | + if err != nil { | ||
| 263 | + return err | ||
| 264 | + } | ||
| 265 | + i, err := client.CreateIssue(cmd.Context(), owner, repo, issueTitle, body) | ||
| 266 | + if err != nil { | ||
| 267 | + return err | ||
| 268 | + } | ||
| 269 | + if flagJSON { | ||
| 270 | + return printJSON(cmd.OutOrStdout(), i) | ||
| 271 | + } | ||
| 272 | + fmt.Fprintf(cmd.OutOrStdout(), "Opened issue #%d: %s\n", i.Number, i.Title) | ||
| 273 | + return nil | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +func runIssueState(cmd *cobra.Command, args []string, state string) error { | ||
| 277 | + client, err := newClient() | ||
| 278 | + if err != nil { | ||
| 279 | + return err | ||
| 280 | + } | ||
| 281 | + owner, repo, err := resolveRepo(issueRepo) | ||
| 282 | + if err != nil { | ||
| 283 | + return err | ||
| 284 | + } | ||
| 285 | + n, err := issueNumber(args[0]) | ||
| 286 | + if err != nil { | ||
| 287 | + return err | ||
| 288 | + } | ||
| 289 | + i, err := client.SetIssueState(cmd.Context(), owner, repo, n, state) | ||
| 290 | + if err != nil { | ||
| 291 | + return err | ||
| 292 | + } | ||
| 293 | + if flagJSON { | ||
| 294 | + return printJSON(cmd.OutOrStdout(), i) | ||
| 295 | + } | ||
| 296 | + fmt.Fprintf(cmd.OutOrStdout(), "Issue #%d is now %s.\n", i.Number, i.State) | ||
| 297 | + return nil | ||
| 298 | +} | ||
| 299 | + | ||
| 300 | +func runIssueComment(cmd *cobra.Command, args []string) error { | ||
| 301 | + body, err := bodyOrStdin(cmd, issueBody) | ||
| 302 | + if err != nil { | ||
| 303 | + return err | ||
| 304 | + } | ||
| 305 | + if strings.TrimSpace(body) == "" { | ||
| 306 | + return fmt.Errorf("--body is required") | ||
| 307 | + } | ||
| 308 | + client, err := newClient() | ||
| 309 | + if err != nil { | ||
| 310 | + return err | ||
| 311 | + } | ||
| 312 | + owner, repo, err := resolveRepo(issueRepo) | ||
| 313 | + if err != nil { | ||
| 314 | + return err | ||
| 315 | + } | ||
| 316 | + n, err := issueNumber(args[0]) | ||
| 317 | + if err != nil { | ||
| 318 | + return err | ||
| 319 | + } | ||
| 320 | + c, err := client.CommentIssue(cmd.Context(), owner, repo, n, body) | ||
| 321 | + if err != nil { | ||
| 322 | + return err | ||
| 323 | + } | ||
| 324 | + if flagJSON { | ||
| 325 | + return printJSON(cmd.OutOrStdout(), c) | ||
| 326 | + } | ||
| 327 | + fmt.Fprintf(cmd.OutOrStdout(), "Commented on issue #%d.\n", n) | ||
| 328 | + return nil | ||
| 329 | +} | ||
| 330 | + | ||
| 331 | +func runIssueLabel(cmd *cobra.Command, args []string) error { | ||
| 332 | + client, err := newClient() | ||
| 333 | + if err != nil { | ||
| 334 | + return err | ||
| 335 | + } | ||
| 336 | + owner, repo, err := resolveRepo(issueRepo) | ||
| 337 | + if err != nil { | ||
| 338 | + return err | ||
| 339 | + } | ||
| 340 | + n, err := issueNumber(args[0]) | ||
| 341 | + if err != nil { | ||
| 342 | + return err | ||
| 343 | + } | ||
| 344 | + names := []string{} | ||
| 345 | + if !issueRemove { | ||
| 346 | + names = labelNames(issueLabels) | ||
| 347 | + } | ||
| 348 | + if err := client.SetIssueLabels(cmd.Context(), owner, repo, n, names); err != nil { | ||
| 349 | + return err | ||
| 350 | + } | ||
| 351 | + if len(names) == 0 { | ||
| 352 | + fmt.Fprintf(cmd.OutOrStdout(), "Cleared labels on issue #%d.\n", n) | ||
| 353 | + } else { | ||
| 354 | + fmt.Fprintf(cmd.OutOrStdout(), "Set labels on issue #%d: %s\n", n, strings.Join(names, ", ")) | ||
| 355 | + } | ||
| 356 | + return nil | ||
| 357 | +} | ||
| 358 | + | ||
| 359 | +func runIssueMilestone(cmd *cobra.Command, args []string) error { | ||
| 360 | + client, err := newClient() | ||
| 361 | + if err != nil { | ||
| 362 | + return err | ||
| 363 | + } | ||
| 364 | + owner, repo, err := resolveRepo(issueRepo) | ||
| 365 | + if err != nil { | ||
| 366 | + return err | ||
| 367 | + } | ||
| 368 | + n, err := issueNumber(args[0]) | ||
| 369 | + if err != nil { | ||
| 370 | + return err | ||
| 371 | + } | ||
| 372 | + ref := issueMilestone | ||
| 373 | + if issueRemove { | ||
| 374 | + ref = "" | ||
| 375 | + } | ||
| 376 | + if err := client.SetIssueMilestone(cmd.Context(), owner, repo, n, ref); err != nil { | ||
| 377 | + return err | ||
| 378 | + } | ||
| 379 | + if ref == "" { | ||
| 380 | + fmt.Fprintf(cmd.OutOrStdout(), "Removed the milestone from issue #%d.\n", n) | ||
| 381 | + } else { | ||
| 382 | + fmt.Fprintf(cmd.OutOrStdout(), "Set issue #%d's milestone to %s.\n", n, ref) | ||
| 383 | + } | ||
| 384 | + return nil | ||
| 385 | +} | ||
| 386 | + | ||
| 387 | +func runIssueAssign(cmd *cobra.Command, args []string) error { | ||
| 388 | + if issueAssignee == "" { | ||
| 389 | + return fmt.Errorf("--user is required") | ||
| 390 | + } | ||
| 391 | + client, err := newClient() | ||
| 392 | + if err != nil { | ||
| 393 | + return err | ||
| 394 | + } | ||
| 395 | + owner, repo, err := resolveRepo(issueRepo) | ||
| 396 | + if err != nil { | ||
| 397 | + return err | ||
| 398 | + } | ||
| 399 | + n, err := issueNumber(args[0]) | ||
| 400 | + if err != nil { | ||
| 401 | + return err | ||
| 402 | + } | ||
| 403 | + op := "add" | ||
| 404 | + if issueRemove { | ||
| 405 | + op = "remove" | ||
| 406 | + } | ||
| 407 | + if err := client.SetIssueAssignee(cmd.Context(), owner, repo, n, op, issueAssignee); err != nil { | ||
| 408 | + return err | ||
| 409 | + } | ||
| 410 | + verb := "Assigned" | ||
| 411 | + if issueRemove { | ||
| 412 | + verb = "Unassigned" | ||
| 413 | + } | ||
| 414 | + fmt.Fprintf(cmd.OutOrStdout(), "%s %s on issue #%d.\n", verb, issueAssignee, n) | ||
| 415 | + return nil | ||
| 416 | +} | ||
| 417 | + | ||
| 418 | +func runIssueLabelsList(cmd *cobra.Command, _ []string) error { | ||
| 419 | + client, err := newClient() | ||
| 420 | + if err != nil { | ||
| 421 | + return err | ||
| 422 | + } | ||
| 423 | + owner, repo, err := resolveRepo(issueRepo) | ||
| 424 | + if err != nil { | ||
| 425 | + return err | ||
| 426 | + } | ||
| 427 | + labels, err := client.ListLabels(cmd.Context(), owner, repo) | ||
| 428 | + if err != nil { | ||
| 429 | + return err | ||
| 430 | + } | ||
| 431 | + if flagJSON { | ||
| 432 | + return printJSON(cmd.OutOrStdout(), labels) | ||
| 433 | + } | ||
| 434 | + if len(labels) == 0 { | ||
| 435 | + fmt.Fprintln(cmd.OutOrStdout(), "No labels.") | ||
| 436 | + return nil | ||
| 437 | + } | ||
| 438 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 439 | + fmt.Fprintln(tw, "NAME\tCOLOR") | ||
| 440 | + for _, l := range labels { | ||
| 441 | + fmt.Fprintf(tw, "%s\t#%s\n", l.Name, l.Color) | ||
| 442 | + } | ||
| 443 | + tw.Flush() | ||
| 444 | + return nil | ||
| 445 | +} | ||
added
cmd/issues_watch_test.go +197 -0 | new file mode 100644 | ||
| @@ -0,0 +1,197 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 9 | + "os" | |
| 10 | + "path/filepath" | |
| 11 | + "strings" | |
| 12 | + "testing" | |
| 13 | +) | |
| 14 | + | |
| 15 | +// issueCmdServer stubs the pieces the `issue` and `milestone` commands touch. | |
| 16 | +func issueCmdServer(t *testing.T) *httptest.Server { | |
| 17 | + t.Helper() | |
| 18 | + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 19 | + switch { | |
| 20 | + case r.Method == "GET" && r.URL.Path == "/api/v1/repos/ricktester/portal/issues": | |
| 21 | + _ = json.NewEncoder(w).Encode(map[string]any{ | |
| 22 | + "items": []map[string]any{ | |
| 23 | + {"number": 1, "title": "build broke", "state": "open", "author": "rick", "labels": []map[string]any{{"name": "bug"}}, "milestone": map[string]any{"title": "v1.0"}}, | |
| 24 | + }, | |
| 25 | + "page": 1, "per_page": 30, "has_next": false, | |
| 26 | + }) | |
| 27 | + case r.Method == "POST" && r.URL.Path == "/api/v1/repos/ricktester/portal/issues": | |
| 28 | + var body map[string]string | |
| 29 | + _ = json.NewDecoder(r.Body).Decode(&body) | |
| 30 | + _ = json.NewEncoder(w).Encode(map[string]any{"number": 9, "title": body["title"], "state": "open"}) | |
| 31 | + case r.Method == "POST" && r.URL.Path == "/api/v1/repos/ricktester/portal/issues/1/state": | |
| 32 | + var body map[string]string | |
| 33 | + _ = json.NewDecoder(r.Body).Decode(&body) | |
| 34 | + _ = json.NewEncoder(w).Encode(map[string]any{"number": 1, "state": body["state"], "title": "build broke"}) | |
| 35 | + default: | |
| 36 | + w.WriteHeader(http.StatusNotFound) | |
| 37 | + _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"no"}}`)) | |
| 38 | + } | |
| 39 | + })) | |
| 40 | +} | |
| 41 | + | |
| 42 | +func TestIssueListCreateClose(t *testing.T) { | |
| 43 | + srv := issueCmdServer(t) | |
| 44 | + defer srv.Close() | |
| 45 | + | |
| 46 | + out, _, err := execute(t, "issue", "list", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t") | |
| 47 | + if err != nil { | |
| 48 | + t.Fatalf("issue list: %v (out=%s)", err, out) | |
| 49 | + } | |
| 50 | + for _, want := range []string{"1", "open", "bug", "v1.0", "build broke"} { | |
| 51 | + if !strings.Contains(out, want) { | |
| 52 | + t.Errorf("issue list output missing %q: %s", want, out) | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + out, _, err = execute(t, "issue", "create", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t", "-t", "a new one", "-b", "body text") | |
| 57 | + if err != nil { | |
| 58 | + t.Fatalf("issue create: %v (out=%s)", err, out) | |
| 59 | + } | |
| 60 | + if !strings.Contains(out, "Opened issue #9") { | |
| 61 | + t.Errorf("issue create output: %s", out) | |
| 62 | + } | |
| 63 | + | |
| 64 | + out, _, err = execute(t, "issue", "close", "1", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t") | |
| 65 | + if err != nil { | |
| 66 | + t.Fatalf("issue close: %v (out=%s)", err, out) | |
| 67 | + } | |
| 68 | + if !strings.Contains(out, "now closed") { | |
| 69 | + t.Errorf("issue close output: %s", out) | |
| 70 | + } | |
| 71 | + | |
| 72 | + // create without --title fails fast, client-side. | |
| 73 | + if _, _, err := execute(t, "issue", "create", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t"); err == nil { | |
| 74 | + t.Error("issue create without --title should fail") | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +// runWatchServer serves a run that is queued for the first two GETs and | |
| 79 | +// success afterwards (or failure, per wantStatus). | |
| 80 | +func runWatchServer(t *testing.T, wantStatus string) *httptest.Server { | |
| 81 | + t.Helper() | |
| 82 | + calls := 0 | |
| 83 | + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 84 | + if r.URL.Path != "/api/v1/repos/ricktester/portal/actions/runs/7" { | |
| 85 | + w.WriteHeader(http.StatusNotFound) | |
| 86 | + return | |
| 87 | + } | |
| 88 | + calls++ | |
| 89 | + status := "queued" | |
| 90 | + if calls > 2 { | |
| 91 | + status = wantStatus | |
| 92 | + } | |
| 93 | + _ = json.NewEncoder(w).Encode(map[string]any{ | |
| 94 | + "number": 7, "workflow": "CI", "status": status, "branch": "main", "head_sha": "abc12345", | |
| 95 | + "jobs": []map[string]any{{"name": "build", "status": status, "steps": []map[string]any{{"ordinal": 1, "name": "compile", "status": status}}}}, | |
| 96 | + }) | |
| 97 | + })) | |
| 98 | +} | |
| 99 | + | |
| 100 | +func TestRunWatchSuccessAndFailure(t *testing.T) { | |
| 101 | + srv := runWatchServer(t, "success") | |
| 102 | + defer srv.Close() | |
| 103 | + out, _, err := execute(t, "run", "watch", "7", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t", "--interval", "5ms", "--timeout", "5s") | |
| 104 | + if err != nil { | |
| 105 | + t.Fatalf("watch success: %v (out=%s)", err, out) | |
| 106 | + } | |
| 107 | + if !strings.Contains(out, "finished: success") { | |
| 108 | + t.Errorf("watch success output: %s", out) | |
| 109 | + } | |
| 110 | + | |
| 111 | + srv2 := runWatchServer(t, "failure") | |
| 112 | + defer srv2.Close() | |
| 113 | + out, _, err = execute(t, "run", "watch", "7", "-R", "ricktester/portal", "--host", srv2.URL, "--token", "rickub_pat_t", "--interval", "5ms", "--timeout", "5s") | |
| 114 | + var exitErr *runWatchExitError | |
| 115 | + if err == nil { | |
| 116 | + t.Fatal("watch failure should error") | |
| 117 | + } | |
| 118 | + if !errors.As(err, &exitErr) || exitErr.status != "failure" { | |
| 119 | + t.Fatalf("watch failure error: %v", err) | |
| 120 | + } | |
| 121 | + if !strings.Contains(out, "finished: failure") { | |
| 122 | + t.Errorf("watch failure output: %s", out) | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +func TestRunWatchTimeout(t *testing.T) { | |
| 127 | + srv := runWatchServer(t, "running") // never terminal | |
| 128 | + defer srv.Close() | |
| 129 | + _, _, err := execute(t, "run", "watch", "7", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t", "--interval", "5ms", "--timeout", "30ms") | |
| 130 | + if err == nil || !strings.Contains(fmt.Sprint(err), "timed out") { | |
| 131 | + t.Fatalf("watch timeout error: %v", err) | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 135 | +// TestAuthLoginDeviceFlow drives `rickub auth login` end-to-end against a stub | |
| 136 | +// host: code issuance, a pending poll, approval, whoami verification, and the | |
| 137 | +// config write (isolated via XDG_CONFIG_HOME). | |
| 138 | +func TestAuthLoginDeviceFlow(t *testing.T) { | |
| 139 | + xdg := t.TempDir() | |
| 140 | + t.Setenv("XDG_CONFIG_HOME", xdg) | |
| 141 | + t.Setenv("RICKUB_HOST", "") | |
| 142 | + t.Setenv("RICKUB_TOKEN", "") | |
| 143 | + | |
| 144 | + pollCalls := 0 | |
| 145 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 146 | + switch { | |
| 147 | + case r.Method == "POST" && r.URL.Path == "/api/v1/device/code": | |
| 148 | + if h := r.Header.Get("Authorization"); h != "" { | |
| 149 | + t.Errorf("device/code sent an Authorization header: %q", h) | |
| 150 | + } | |
| 151 | + _ = json.NewEncoder(w).Encode(map[string]any{ | |
| 152 | + "device_code": "dc-123", "user_code": "ABCD-EFGH", | |
| 153 | + "verification_url": "https://stub/login/device", | |
| 154 | + "verification_uri_complete": "https://stub/login/device?user_code=ABCD-EFGH", | |
| 155 | + "expires_in": 600, "interval": 1, | |
| 156 | + }) | |
| 157 | + case r.Method == "POST" && r.URL.Path == "/api/v1/device/token": | |
| 158 | + pollCalls++ | |
| 159 | + if pollCalls == 1 { | |
| 160 | + w.WriteHeader(http.StatusBadRequest) | |
| 161 | + _, _ = w.Write([]byte(`{"error":{"code":"authorization_pending","message":"keep polling"}}`)) | |
| 162 | + return | |
| 163 | + } | |
| 164 | + _, _ = w.Write([]byte(`{"access_token":"rickub_pat_minted","token_type":"bearer","scope":"all"}`)) | |
| 165 | + case r.Method == "GET" && r.URL.Path == "/api/v1/user": | |
| 166 | + if got := r.Header.Get("Authorization"); got != "Bearer rickub_pat_minted" { | |
| 167 | + t.Errorf("whoami auth = %q", got) | |
| 168 | + } | |
| 169 | + _ = json.NewEncoder(w).Encode(map[string]any{"handle": "ricktester"}) | |
| 170 | + default: | |
| 171 | + w.WriteHeader(http.StatusNotFound) | |
| 172 | + _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"no"}}`)) | |
| 173 | + } | |
| 174 | + })) | |
| 175 | + defer srv.Close() | |
| 176 | + | |
| 177 | + out, _, err := execute(t, "auth", "login", "--host", srv.URL, "--no-browser") | |
| 178 | + if err != nil { | |
| 179 | + t.Fatalf("auth login: %v (out=%s)", err, out) | |
| 180 | + } | |
| 181 | + for _, want := range []string{"ABCD-EFGH", "https://stub/login/device", "Approved", "Logged in", "ricktester"} { | |
| 182 | + if !strings.Contains(out, want) { | |
| 183 | + t.Errorf("auth login output missing %q:\n%s", want, out) | |
| 184 | + } | |
| 185 | + } | |
| 186 | + // The minted token landed in the isolated config file. | |
| 187 | + b, err := os.ReadFile(filepath.Join(xdg, "rickub", "config.yaml")) | |
| 188 | + if err != nil { | |
| 189 | + t.Fatalf("read config: %v", err) | |
| 190 | + } | |
| 191 | + if !strings.Contains(string(b), "rickub_pat_minted") || !strings.Contains(string(b), srv.URL) { | |
| 192 | + t.Errorf("config file content: %s", string(b)) | |
| 193 | + } | |
| 194 | + if pollCalls != 2 { | |
| 195 | + t.Errorf("poll calls = %d, want 2 (pending then minted)", pollCalls) | |
| 196 | + } | |
| 197 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,197 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "errors" | ||
| 6 | + "fmt" | ||
| 7 | + "net/http" | ||
| 8 | + "net/http/httptest" | ||
| 9 | + "os" | ||
| 10 | + "path/filepath" | ||
| 11 | + "strings" | ||
| 12 | + "testing" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +// issueCmdServer stubs the pieces the `issue` and `milestone` commands touch. | ||
| 16 | +func issueCmdServer(t *testing.T) *httptest.Server { | ||
| 17 | + t.Helper() | ||
| 18 | + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 19 | + switch { | ||
| 20 | + case r.Method == "GET" && r.URL.Path == "/api/v1/repos/ricktester/portal/issues": | ||
| 21 | + _ = json.NewEncoder(w).Encode(map[string]any{ | ||
| 22 | + "items": []map[string]any{ | ||
| 23 | + {"number": 1, "title": "build broke", "state": "open", "author": "rick", "labels": []map[string]any{{"name": "bug"}}, "milestone": map[string]any{"title": "v1.0"}}, | ||
| 24 | + }, | ||
| 25 | + "page": 1, "per_page": 30, "has_next": false, | ||
| 26 | + }) | ||
| 27 | + case r.Method == "POST" && r.URL.Path == "/api/v1/repos/ricktester/portal/issues": | ||
| 28 | + var body map[string]string | ||
| 29 | + _ = json.NewDecoder(r.Body).Decode(&body) | ||
| 30 | + _ = json.NewEncoder(w).Encode(map[string]any{"number": 9, "title": body["title"], "state": "open"}) | ||
| 31 | + case r.Method == "POST" && r.URL.Path == "/api/v1/repos/ricktester/portal/issues/1/state": | ||
| 32 | + var body map[string]string | ||
| 33 | + _ = json.NewDecoder(r.Body).Decode(&body) | ||
| 34 | + _ = json.NewEncoder(w).Encode(map[string]any{"number": 1, "state": body["state"], "title": "build broke"}) | ||
| 35 | + default: | ||
| 36 | + w.WriteHeader(http.StatusNotFound) | ||
| 37 | + _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"no"}}`)) | ||
| 38 | + } | ||
| 39 | + })) | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +func TestIssueListCreateClose(t *testing.T) { | ||
| 43 | + srv := issueCmdServer(t) | ||
| 44 | + defer srv.Close() | ||
| 45 | + | ||
| 46 | + out, _, err := execute(t, "issue", "list", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t") | ||
| 47 | + if err != nil { | ||
| 48 | + t.Fatalf("issue list: %v (out=%s)", err, out) | ||
| 49 | + } | ||
| 50 | + for _, want := range []string{"1", "open", "bug", "v1.0", "build broke"} { | ||
| 51 | + if !strings.Contains(out, want) { | ||
| 52 | + t.Errorf("issue list output missing %q: %s", want, out) | ||
| 53 | + } | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + out, _, err = execute(t, "issue", "create", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t", "-t", "a new one", "-b", "body text") | ||
| 57 | + if err != nil { | ||
| 58 | + t.Fatalf("issue create: %v (out=%s)", err, out) | ||
| 59 | + } | ||
| 60 | + if !strings.Contains(out, "Opened issue #9") { | ||
| 61 | + t.Errorf("issue create output: %s", out) | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + out, _, err = execute(t, "issue", "close", "1", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t") | ||
| 65 | + if err != nil { | ||
| 66 | + t.Fatalf("issue close: %v (out=%s)", err, out) | ||
| 67 | + } | ||
| 68 | + if !strings.Contains(out, "now closed") { | ||
| 69 | + t.Errorf("issue close output: %s", out) | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + // create without --title fails fast, client-side. | ||
| 73 | + if _, _, err := execute(t, "issue", "create", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t"); err == nil { | ||
| 74 | + t.Error("issue create without --title should fail") | ||
| 75 | + } | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +// runWatchServer serves a run that is queued for the first two GETs and | ||
| 79 | +// success afterwards (or failure, per wantStatus). | ||
| 80 | +func runWatchServer(t *testing.T, wantStatus string) *httptest.Server { | ||
| 81 | + t.Helper() | ||
| 82 | + calls := 0 | ||
| 83 | + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 84 | + if r.URL.Path != "/api/v1/repos/ricktester/portal/actions/runs/7" { | ||
| 85 | + w.WriteHeader(http.StatusNotFound) | ||
| 86 | + return | ||
| 87 | + } | ||
| 88 | + calls++ | ||
| 89 | + status := "queued" | ||
| 90 | + if calls > 2 { | ||
| 91 | + status = wantStatus | ||
| 92 | + } | ||
| 93 | + _ = json.NewEncoder(w).Encode(map[string]any{ | ||
| 94 | + "number": 7, "workflow": "CI", "status": status, "branch": "main", "head_sha": "abc12345", | ||
| 95 | + "jobs": []map[string]any{{"name": "build", "status": status, "steps": []map[string]any{{"ordinal": 1, "name": "compile", "status": status}}}}, | ||
| 96 | + }) | ||
| 97 | + })) | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +func TestRunWatchSuccessAndFailure(t *testing.T) { | ||
| 101 | + srv := runWatchServer(t, "success") | ||
| 102 | + defer srv.Close() | ||
| 103 | + out, _, err := execute(t, "run", "watch", "7", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t", "--interval", "5ms", "--timeout", "5s") | ||
| 104 | + if err != nil { | ||
| 105 | + t.Fatalf("watch success: %v (out=%s)", err, out) | ||
| 106 | + } | ||
| 107 | + if !strings.Contains(out, "finished: success") { | ||
| 108 | + t.Errorf("watch success output: %s", out) | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + srv2 := runWatchServer(t, "failure") | ||
| 112 | + defer srv2.Close() | ||
| 113 | + out, _, err = execute(t, "run", "watch", "7", "-R", "ricktester/portal", "--host", srv2.URL, "--token", "rickub_pat_t", "--interval", "5ms", "--timeout", "5s") | ||
| 114 | + var exitErr *runWatchExitError | ||
| 115 | + if err == nil { | ||
| 116 | + t.Fatal("watch failure should error") | ||
| 117 | + } | ||
| 118 | + if !errors.As(err, &exitErr) || exitErr.status != "failure" { | ||
| 119 | + t.Fatalf("watch failure error: %v", err) | ||
| 120 | + } | ||
| 121 | + if !strings.Contains(out, "finished: failure") { | ||
| 122 | + t.Errorf("watch failure output: %s", out) | ||
| 123 | + } | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +func TestRunWatchTimeout(t *testing.T) { | ||
| 127 | + srv := runWatchServer(t, "running") // never terminal | ||
| 128 | + defer srv.Close() | ||
| 129 | + _, _, err := execute(t, "run", "watch", "7", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t", "--interval", "5ms", "--timeout", "30ms") | ||
| 130 | + if err == nil || !strings.Contains(fmt.Sprint(err), "timed out") { | ||
| 131 | + t.Fatalf("watch timeout error: %v", err) | ||
| 132 | + } | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +// TestAuthLoginDeviceFlow drives `rickub auth login` end-to-end against a stub | ||
| 136 | +// host: code issuance, a pending poll, approval, whoami verification, and the | ||
| 137 | +// config write (isolated via XDG_CONFIG_HOME). | ||
| 138 | +func TestAuthLoginDeviceFlow(t *testing.T) { | ||
| 139 | + xdg := t.TempDir() | ||
| 140 | + t.Setenv("XDG_CONFIG_HOME", xdg) | ||
| 141 | + t.Setenv("RICKUB_HOST", "") | ||
| 142 | + t.Setenv("RICKUB_TOKEN", "") | ||
| 143 | + | ||
| 144 | + pollCalls := 0 | ||
| 145 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 146 | + switch { | ||
| 147 | + case r.Method == "POST" && r.URL.Path == "/api/v1/device/code": | ||
| 148 | + if h := r.Header.Get("Authorization"); h != "" { | ||
| 149 | + t.Errorf("device/code sent an Authorization header: %q", h) | ||
| 150 | + } | ||
| 151 | + _ = json.NewEncoder(w).Encode(map[string]any{ | ||
| 152 | + "device_code": "dc-123", "user_code": "ABCD-EFGH", | ||
| 153 | + "verification_url": "https://stub/login/device", | ||
| 154 | + "verification_uri_complete": "https://stub/login/device?user_code=ABCD-EFGH", | ||
| 155 | + "expires_in": 600, "interval": 1, | ||
| 156 | + }) | ||
| 157 | + case r.Method == "POST" && r.URL.Path == "/api/v1/device/token": | ||
| 158 | + pollCalls++ | ||
| 159 | + if pollCalls == 1 { | ||
| 160 | + w.WriteHeader(http.StatusBadRequest) | ||
| 161 | + _, _ = w.Write([]byte(`{"error":{"code":"authorization_pending","message":"keep polling"}}`)) | ||
| 162 | + return | ||
| 163 | + } | ||
| 164 | + _, _ = w.Write([]byte(`{"access_token":"rickub_pat_minted","token_type":"bearer","scope":"all"}`)) | ||
| 165 | + case r.Method == "GET" && r.URL.Path == "/api/v1/user": | ||
| 166 | + if got := r.Header.Get("Authorization"); got != "Bearer rickub_pat_minted" { | ||
| 167 | + t.Errorf("whoami auth = %q", got) | ||
| 168 | + } | ||
| 169 | + _ = json.NewEncoder(w).Encode(map[string]any{"handle": "ricktester"}) | ||
| 170 | + default: | ||
| 171 | + w.WriteHeader(http.StatusNotFound) | ||
| 172 | + _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"no"}}`)) | ||
| 173 | + } | ||
| 174 | + })) | ||
| 175 | + defer srv.Close() | ||
| 176 | + | ||
| 177 | + out, _, err := execute(t, "auth", "login", "--host", srv.URL, "--no-browser") | ||
| 178 | + if err != nil { | ||
| 179 | + t.Fatalf("auth login: %v (out=%s)", err, out) | ||
| 180 | + } | ||
| 181 | + for _, want := range []string{"ABCD-EFGH", "https://stub/login/device", "Approved", "Logged in", "ricktester"} { | ||
| 182 | + if !strings.Contains(out, want) { | ||
| 183 | + t.Errorf("auth login output missing %q:\n%s", want, out) | ||
| 184 | + } | ||
| 185 | + } | ||
| 186 | + // The minted token landed in the isolated config file. | ||
| 187 | + b, err := os.ReadFile(filepath.Join(xdg, "rickub", "config.yaml")) | ||
| 188 | + if err != nil { | ||
| 189 | + t.Fatalf("read config: %v", err) | ||
| 190 | + } | ||
| 191 | + if !strings.Contains(string(b), "rickub_pat_minted") || !strings.Contains(string(b), srv.URL) { | ||
| 192 | + t.Errorf("config file content: %s", string(b)) | ||
| 193 | + } | ||
| 194 | + if pollCalls != 2 { | ||
| 195 | + t.Errorf("poll calls = %d, want 2 (pending then minted)", pollCalls) | ||
| 196 | + } | ||
| 197 | +} | ||
added
cmd/milestone.go +163 -0 | new file mode 100644 | ||
| @@ -0,0 +1,163 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strings" | |
| 6 | + | |
| 7 | + "github.com/spf13/cobra" | |
| 8 | +) | |
| 9 | + | |
| 10 | +var ( | |
| 11 | + milestoneRepo string | |
| 12 | + milestoneState string | |
| 13 | + milestoneTitle string | |
| 14 | + milestoneDue string | |
| 15 | + milestoneDescription string | |
| 16 | +) | |
| 17 | + | |
| 18 | +func init() { | |
| 19 | + msCmd := &cobra.Command{ | |
| 20 | + Use: "milestone", | |
| 21 | + Aliases: []string{"milestones"}, | |
| 22 | + Short: "Work with milestones", | |
| 23 | + } | |
| 24 | + msCmd.PersistentFlags().StringVarP(&milestoneRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") | |
| 25 | + | |
| 26 | + listCmd := &cobra.Command{ | |
| 27 | + Use: "list", | |
| 28 | + Short: "List milestones", | |
| 29 | + Args: cobra.NoArgs, | |
| 30 | + RunE: runMilestoneList, | |
| 31 | + } | |
| 32 | + listCmd.Flags().StringVar(&milestoneState, "state", "open", "open | closed | all") | |
| 33 | + | |
| 34 | + createCmd := &cobra.Command{ | |
| 35 | + Use: "create", | |
| 36 | + Short: "Create a milestone", | |
| 37 | + Args: cobra.NoArgs, | |
| 38 | + RunE: runMilestoneCreate, | |
| 39 | + } | |
| 40 | + createCmd.Flags().StringVarP(&milestoneTitle, "title", "t", "", "title (required)") | |
| 41 | + createCmd.Flags().StringVarP(&milestoneDescription, "description", "d", "", "description") | |
| 42 | + createCmd.Flags().StringVar(&milestoneDue, "due", "", "due date, YYYY-MM-DD") | |
| 43 | + | |
| 44 | + closeCmd := &cobra.Command{ | |
| 45 | + Use: "close <id>", | |
| 46 | + Short: "Close a milestone (by id; see `milestone list`)", | |
| 47 | + Args: cobra.ExactArgs(1), | |
| 48 | + RunE: func(cmd *cobra.Command, args []string) error { return runMilestoneSetState(cmd, args, "closed") }, | |
| 49 | + } | |
| 50 | + | |
| 51 | + reopenCmd := &cobra.Command{ | |
| 52 | + Use: "reopen <id>", | |
| 53 | + Short: "Reopen a closed milestone", | |
| 54 | + Args: cobra.ExactArgs(1), | |
| 55 | + RunE: func(cmd *cobra.Command, args []string) error { return runMilestoneSetState(cmd, args, "open") }, | |
| 56 | + } | |
| 57 | + | |
| 58 | + deleteCmd := &cobra.Command{ | |
| 59 | + Use: "delete <id>", | |
| 60 | + Short: "Delete a milestone (issues keep going, milestone cleared)", | |
| 61 | + Args: cobra.ExactArgs(1), | |
| 62 | + RunE: runMilestoneDelete, | |
| 63 | + } | |
| 64 | + | |
| 65 | + msCmd.AddCommand(listCmd, createCmd, closeCmd, reopenCmd, deleteCmd) | |
| 66 | + rootCmd.AddCommand(msCmd) | |
| 67 | +} | |
| 68 | + | |
| 69 | +func runMilestoneList(cmd *cobra.Command, _ []string) error { | |
| 70 | + client, err := newClient() | |
| 71 | + if err != nil { | |
| 72 | + return err | |
| 73 | + } | |
| 74 | + owner, repo, err := resolveRepo(milestoneRepo) | |
| 75 | + if err != nil { | |
| 76 | + return err | |
| 77 | + } | |
| 78 | + mses, err := client.ListMilestones(cmd.Context(), owner, repo, milestoneState) | |
| 79 | + if err != nil { | |
| 80 | + return err | |
| 81 | + } | |
| 82 | + if flagJSON { | |
| 83 | + return printJSON(cmd.OutOrStdout(), mses) | |
| 84 | + } | |
| 85 | + if len(mses) == 0 { | |
| 86 | + fmt.Fprintln(cmd.OutOrStdout(), "No milestones.") | |
| 87 | + return nil | |
| 88 | + } | |
| 89 | + tw := newTabw(cmd.OutOrStdout()) | |
| 90 | + fmt.Fprintln(tw, "ID\tSTATE\tDUE\tPROGRESS\tTITLE") | |
| 91 | + for _, m := range mses { | |
| 92 | + progress := fmt.Sprintf("%d/%d", m.ClosedCount, m.OpenCount+m.ClosedCount) | |
| 93 | + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", m.ID, m.State, dash(deref(m.DueOn)), progress, m.Title) | |
| 94 | + } | |
| 95 | + tw.Flush() | |
| 96 | + return nil | |
| 97 | +} | |
| 98 | + | |
| 99 | +func deref(s *string) string { | |
| 100 | + if s == nil { | |
| 101 | + return "" | |
| 102 | + } | |
| 103 | + return *s | |
| 104 | +} | |
| 105 | + | |
| 106 | +func runMilestoneCreate(cmd *cobra.Command, _ []string) error { | |
| 107 | + if strings.TrimSpace(milestoneTitle) == "" { | |
| 108 | + return fmt.Errorf("--title is required") | |
| 109 | + } | |
| 110 | + client, err := newClient() | |
| 111 | + if err != nil { | |
| 112 | + return err | |
| 113 | + } | |
| 114 | + owner, repo, err := resolveRepo(milestoneRepo) | |
| 115 | + if err != nil { | |
| 116 | + return err | |
| 117 | + } | |
| 118 | + m, err := client.CreateMilestone(cmd.Context(), owner, repo, milestoneTitle, milestoneDescription, milestoneDue) | |
| 119 | + if err != nil { | |
| 120 | + return err | |
| 121 | + } | |
| 122 | + if flagJSON { | |
| 123 | + return printJSON(cmd.OutOrStdout(), m) | |
| 124 | + } | |
| 125 | + fmt.Fprintf(cmd.OutOrStdout(), "Created milestone %s (%s)\n", m.Title, m.ID) | |
| 126 | + return nil | |
| 127 | +} | |
| 128 | + | |
| 129 | +func runMilestoneSetState(cmd *cobra.Command, args []string, state string) error { | |
| 130 | + client, err := newClient() | |
| 131 | + if err != nil { | |
| 132 | + return err | |
| 133 | + } | |
| 134 | + owner, repo, err := resolveRepo(milestoneRepo) | |
| 135 | + if err != nil { | |
| 136 | + return err | |
| 137 | + } | |
| 138 | + m, err := client.UpdateMilestone(cmd.Context(), owner, repo, args[0], map[string]any{"state": state}) | |
| 139 | + if err != nil { | |
| 140 | + return err | |
| 141 | + } | |
| 142 | + if flagJSON { | |
| 143 | + return printJSON(cmd.OutOrStdout(), m) | |
| 144 | + } | |
| 145 | + fmt.Fprintf(cmd.OutOrStdout(), "Milestone %s is now %s.\n", m.Title, m.State) | |
| 146 | + return nil | |
| 147 | +} | |
| 148 | + | |
| 149 | +func runMilestoneDelete(cmd *cobra.Command, args []string) error { | |
| 150 | + client, err := newClient() | |
| 151 | + if err != nil { | |
| 152 | + return err | |
| 153 | + } | |
| 154 | + owner, repo, err := resolveRepo(milestoneRepo) | |
| 155 | + if err != nil { | |
| 156 | + return err | |
| 157 | + } | |
| 158 | + if err := client.DeleteMilestone(cmd.Context(), owner, repo, args[0]); err != nil { | |
| 159 | + return err | |
| 160 | + } | |
| 161 | + fmt.Fprintf(cmd.OutOrStdout(), "Deleted milestone %s.\n", args[0]) | |
| 162 | + return nil | |
| 163 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,163 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "strings" | ||
| 6 | + | ||
| 7 | + "github.com/spf13/cobra" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +var ( | ||
| 11 | + milestoneRepo string | ||
| 12 | + milestoneState string | ||
| 13 | + milestoneTitle string | ||
| 14 | + milestoneDue string | ||
| 15 | + milestoneDescription string | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +func init() { | ||
| 19 | + msCmd := &cobra.Command{ | ||
| 20 | + Use: "milestone", | ||
| 21 | + Aliases: []string{"milestones"}, | ||
| 22 | + Short: "Work with milestones", | ||
| 23 | + } | ||
| 24 | + msCmd.PersistentFlags().StringVarP(&milestoneRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") | ||
| 25 | + | ||
| 26 | + listCmd := &cobra.Command{ | ||
| 27 | + Use: "list", | ||
| 28 | + Short: "List milestones", | ||
| 29 | + Args: cobra.NoArgs, | ||
| 30 | + RunE: runMilestoneList, | ||
| 31 | + } | ||
| 32 | + listCmd.Flags().StringVar(&milestoneState, "state", "open", "open | closed | all") | ||
| 33 | + | ||
| 34 | + createCmd := &cobra.Command{ | ||
| 35 | + Use: "create", | ||
| 36 | + Short: "Create a milestone", | ||
| 37 | + Args: cobra.NoArgs, | ||
| 38 | + RunE: runMilestoneCreate, | ||
| 39 | + } | ||
| 40 | + createCmd.Flags().StringVarP(&milestoneTitle, "title", "t", "", "title (required)") | ||
| 41 | + createCmd.Flags().StringVarP(&milestoneDescription, "description", "d", "", "description") | ||
| 42 | + createCmd.Flags().StringVar(&milestoneDue, "due", "", "due date, YYYY-MM-DD") | ||
| 43 | + | ||
| 44 | + closeCmd := &cobra.Command{ | ||
| 45 | + Use: "close <id>", | ||
| 46 | + Short: "Close a milestone (by id; see `milestone list`)", | ||
| 47 | + Args: cobra.ExactArgs(1), | ||
| 48 | + RunE: func(cmd *cobra.Command, args []string) error { return runMilestoneSetState(cmd, args, "closed") }, | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + reopenCmd := &cobra.Command{ | ||
| 52 | + Use: "reopen <id>", | ||
| 53 | + Short: "Reopen a closed milestone", | ||
| 54 | + Args: cobra.ExactArgs(1), | ||
| 55 | + RunE: func(cmd *cobra.Command, args []string) error { return runMilestoneSetState(cmd, args, "open") }, | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + deleteCmd := &cobra.Command{ | ||
| 59 | + Use: "delete <id>", | ||
| 60 | + Short: "Delete a milestone (issues keep going, milestone cleared)", | ||
| 61 | + Args: cobra.ExactArgs(1), | ||
| 62 | + RunE: runMilestoneDelete, | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + msCmd.AddCommand(listCmd, createCmd, closeCmd, reopenCmd, deleteCmd) | ||
| 66 | + rootCmd.AddCommand(msCmd) | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +func runMilestoneList(cmd *cobra.Command, _ []string) error { | ||
| 70 | + client, err := newClient() | ||
| 71 | + if err != nil { | ||
| 72 | + return err | ||
| 73 | + } | ||
| 74 | + owner, repo, err := resolveRepo(milestoneRepo) | ||
| 75 | + if err != nil { | ||
| 76 | + return err | ||
| 77 | + } | ||
| 78 | + mses, err := client.ListMilestones(cmd.Context(), owner, repo, milestoneState) | ||
| 79 | + if err != nil { | ||
| 80 | + return err | ||
| 81 | + } | ||
| 82 | + if flagJSON { | ||
| 83 | + return printJSON(cmd.OutOrStdout(), mses) | ||
| 84 | + } | ||
| 85 | + if len(mses) == 0 { | ||
| 86 | + fmt.Fprintln(cmd.OutOrStdout(), "No milestones.") | ||
| 87 | + return nil | ||
| 88 | + } | ||
| 89 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 90 | + fmt.Fprintln(tw, "ID\tSTATE\tDUE\tPROGRESS\tTITLE") | ||
| 91 | + for _, m := range mses { | ||
| 92 | + progress := fmt.Sprintf("%d/%d", m.ClosedCount, m.OpenCount+m.ClosedCount) | ||
| 93 | + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", m.ID, m.State, dash(deref(m.DueOn)), progress, m.Title) | ||
| 94 | + } | ||
| 95 | + tw.Flush() | ||
| 96 | + return nil | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +func deref(s *string) string { | ||
| 100 | + if s == nil { | ||
| 101 | + return "" | ||
| 102 | + } | ||
| 103 | + return *s | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +func runMilestoneCreate(cmd *cobra.Command, _ []string) error { | ||
| 107 | + if strings.TrimSpace(milestoneTitle) == "" { | ||
| 108 | + return fmt.Errorf("--title is required") | ||
| 109 | + } | ||
| 110 | + client, err := newClient() | ||
| 111 | + if err != nil { | ||
| 112 | + return err | ||
| 113 | + } | ||
| 114 | + owner, repo, err := resolveRepo(milestoneRepo) | ||
| 115 | + if err != nil { | ||
| 116 | + return err | ||
| 117 | + } | ||
| 118 | + m, err := client.CreateMilestone(cmd.Context(), owner, repo, milestoneTitle, milestoneDescription, milestoneDue) | ||
| 119 | + if err != nil { | ||
| 120 | + return err | ||
| 121 | + } | ||
| 122 | + if flagJSON { | ||
| 123 | + return printJSON(cmd.OutOrStdout(), m) | ||
| 124 | + } | ||
| 125 | + fmt.Fprintf(cmd.OutOrStdout(), "Created milestone %s (%s)\n", m.Title, m.ID) | ||
| 126 | + return nil | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +func runMilestoneSetState(cmd *cobra.Command, args []string, state string) error { | ||
| 130 | + client, err := newClient() | ||
| 131 | + if err != nil { | ||
| 132 | + return err | ||
| 133 | + } | ||
| 134 | + owner, repo, err := resolveRepo(milestoneRepo) | ||
| 135 | + if err != nil { | ||
| 136 | + return err | ||
| 137 | + } | ||
| 138 | + m, err := client.UpdateMilestone(cmd.Context(), owner, repo, args[0], map[string]any{"state": state}) | ||
| 139 | + if err != nil { | ||
| 140 | + return err | ||
| 141 | + } | ||
| 142 | + if flagJSON { | ||
| 143 | + return printJSON(cmd.OutOrStdout(), m) | ||
| 144 | + } | ||
| 145 | + fmt.Fprintf(cmd.OutOrStdout(), "Milestone %s is now %s.\n", m.Title, m.State) | ||
| 146 | + return nil | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +func runMilestoneDelete(cmd *cobra.Command, args []string) error { | ||
| 150 | + client, err := newClient() | ||
| 151 | + if err != nil { | ||
| 152 | + return err | ||
| 153 | + } | ||
| 154 | + owner, repo, err := resolveRepo(milestoneRepo) | ||
| 155 | + if err != nil { | ||
| 156 | + return err | ||
| 157 | + } | ||
| 158 | + if err := client.DeleteMilestone(cmd.Context(), owner, repo, args[0]); err != nil { | ||
| 159 | + return err | ||
| 160 | + } | ||
| 161 | + fmt.Fprintf(cmd.OutOrStdout(), "Deleted milestone %s.\n", args[0]) | ||
| 162 | + return nil | ||
| 163 | +} | ||
added
cmd/org.go +107 -0 | new file mode 100644 | ||
| @@ -0,0 +1,107 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + | |
| 6 | + "github.com/spf13/cobra" | |
| 7 | +) | |
| 8 | + | |
| 9 | +func init() { | |
| 10 | + orgCmd := &cobra.Command{ | |
| 11 | + Use: "org <handle>", | |
| 12 | + Short: "Read organization info, members, and teams", | |
| 13 | + } | |
| 14 | + | |
| 15 | + viewCmd := &cobra.Command{ | |
| 16 | + Use: "view <handle>", | |
| 17 | + Short: "Show basic org info", | |
| 18 | + Args: cobra.ExactArgs(1), | |
| 19 | + RunE: runOrgView, | |
| 20 | + } | |
| 21 | + | |
| 22 | + membersCmd := &cobra.Command{ | |
| 23 | + Use: "members <handle>", | |
| 24 | + Short: "List org members (members only)", | |
| 25 | + Args: cobra.ExactArgs(1), | |
| 26 | + RunE: runOrgMembers, | |
| 27 | + } | |
| 28 | + | |
| 29 | + teamsCmd := &cobra.Command{ | |
| 30 | + Use: "teams <handle>", | |
| 31 | + Short: "List org teams (members only)", | |
| 32 | + Args: cobra.ExactArgs(1), | |
| 33 | + RunE: runOrgTeams, | |
| 34 | + } | |
| 35 | + | |
| 36 | + orgCmd.AddCommand(viewCmd, membersCmd, teamsCmd) | |
| 37 | + rootCmd.AddCommand(orgCmd) | |
| 38 | +} | |
| 39 | + | |
| 40 | +func runOrgView(cmd *cobra.Command, args []string) error { | |
| 41 | + client, err := newClient() | |
| 42 | + if err != nil { | |
| 43 | + return err | |
| 44 | + } | |
| 45 | + o, err := client.GetOrg(cmd.Context(), args[0]) | |
| 46 | + if err != nil { | |
| 47 | + return err | |
| 48 | + } | |
| 49 | + if flagJSON { | |
| 50 | + return printJSON(cmd.OutOrStdout(), o) | |
| 51 | + } | |
| 52 | + out := cmd.OutOrStdout() | |
| 53 | + fmt.Fprintf(out, "%s\n", o.Handle) | |
| 54 | + fmt.Fprintf(out, "Display name: %s\n", dash(o.DisplayName)) | |
| 55 | + fmt.Fprintf(out, "Created: %s\n", humanTime(o.CreatedAt)) | |
| 56 | + return nil | |
| 57 | +} | |
| 58 | + | |
| 59 | +func runOrgMembers(cmd *cobra.Command, args []string) error { | |
| 60 | + client, err := newClient() | |
| 61 | + if err != nil { | |
| 62 | + return err | |
| 63 | + } | |
| 64 | + members, err := client.ListOrgMembers(cmd.Context(), args[0]) | |
| 65 | + if err != nil { | |
| 66 | + return err | |
| 67 | + } | |
| 68 | + if flagJSON { | |
| 69 | + return printJSON(cmd.OutOrStdout(), members) | |
| 70 | + } | |
| 71 | + if len(members) == 0 { | |
| 72 | + fmt.Fprintln(cmd.OutOrStdout(), "No members.") | |
| 73 | + return nil | |
| 74 | + } | |
| 75 | + tw := newTabw(cmd.OutOrStdout()) | |
| 76 | + fmt.Fprintln(tw, "HANDLE\tROLE\tNAME") | |
| 77 | + for _, m := range members { | |
| 78 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", m.Handle, m.Role, dash(m.DisplayName)) | |
| 79 | + } | |
| 80 | + tw.Flush() | |
| 81 | + return nil | |
| 82 | +} | |
| 83 | + | |
| 84 | +func runOrgTeams(cmd *cobra.Command, args []string) error { | |
| 85 | + client, err := newClient() | |
| 86 | + if err != nil { | |
| 87 | + return err | |
| 88 | + } | |
| 89 | + teams, err := client.ListOrgTeams(cmd.Context(), args[0]) | |
| 90 | + if err != nil { | |
| 91 | + return err | |
| 92 | + } | |
| 93 | + if flagJSON { | |
| 94 | + return printJSON(cmd.OutOrStdout(), teams) | |
| 95 | + } | |
| 96 | + if len(teams) == 0 { | |
| 97 | + fmt.Fprintln(cmd.OutOrStdout(), "No teams.") | |
| 98 | + return nil | |
| 99 | + } | |
| 100 | + tw := newTabw(cmd.OutOrStdout()) | |
| 101 | + fmt.Fprintln(tw, "SLUG\tNAME\tSUB-TEAM\tDESCRIPTION") | |
| 102 | + for _, t := range teams { | |
| 103 | + fmt.Fprintf(tw, "%s\t%s\t%v\t%s\n", t.Slug, dash(t.Name), t.SubTeam, dash(t.Description)) | |
| 104 | + } | |
| 105 | + tw.Flush() | |
| 106 | + return nil | |
| 107 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,107 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + | ||
| 6 | + "github.com/spf13/cobra" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +func init() { | ||
| 10 | + orgCmd := &cobra.Command{ | ||
| 11 | + Use: "org <handle>", | ||
| 12 | + Short: "Read organization info, members, and teams", | ||
| 13 | + } | ||
| 14 | + | ||
| 15 | + viewCmd := &cobra.Command{ | ||
| 16 | + Use: "view <handle>", | ||
| 17 | + Short: "Show basic org info", | ||
| 18 | + Args: cobra.ExactArgs(1), | ||
| 19 | + RunE: runOrgView, | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + membersCmd := &cobra.Command{ | ||
| 23 | + Use: "members <handle>", | ||
| 24 | + Short: "List org members (members only)", | ||
| 25 | + Args: cobra.ExactArgs(1), | ||
| 26 | + RunE: runOrgMembers, | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + teamsCmd := &cobra.Command{ | ||
| 30 | + Use: "teams <handle>", | ||
| 31 | + Short: "List org teams (members only)", | ||
| 32 | + Args: cobra.ExactArgs(1), | ||
| 33 | + RunE: runOrgTeams, | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + orgCmd.AddCommand(viewCmd, membersCmd, teamsCmd) | ||
| 37 | + rootCmd.AddCommand(orgCmd) | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +func runOrgView(cmd *cobra.Command, args []string) error { | ||
| 41 | + client, err := newClient() | ||
| 42 | + if err != nil { | ||
| 43 | + return err | ||
| 44 | + } | ||
| 45 | + o, err := client.GetOrg(cmd.Context(), args[0]) | ||
| 46 | + if err != nil { | ||
| 47 | + return err | ||
| 48 | + } | ||
| 49 | + if flagJSON { | ||
| 50 | + return printJSON(cmd.OutOrStdout(), o) | ||
| 51 | + } | ||
| 52 | + out := cmd.OutOrStdout() | ||
| 53 | + fmt.Fprintf(out, "%s\n", o.Handle) | ||
| 54 | + fmt.Fprintf(out, "Display name: %s\n", dash(o.DisplayName)) | ||
| 55 | + fmt.Fprintf(out, "Created: %s\n", humanTime(o.CreatedAt)) | ||
| 56 | + return nil | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +func runOrgMembers(cmd *cobra.Command, args []string) error { | ||
| 60 | + client, err := newClient() | ||
| 61 | + if err != nil { | ||
| 62 | + return err | ||
| 63 | + } | ||
| 64 | + members, err := client.ListOrgMembers(cmd.Context(), args[0]) | ||
| 65 | + if err != nil { | ||
| 66 | + return err | ||
| 67 | + } | ||
| 68 | + if flagJSON { | ||
| 69 | + return printJSON(cmd.OutOrStdout(), members) | ||
| 70 | + } | ||
| 71 | + if len(members) == 0 { | ||
| 72 | + fmt.Fprintln(cmd.OutOrStdout(), "No members.") | ||
| 73 | + return nil | ||
| 74 | + } | ||
| 75 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 76 | + fmt.Fprintln(tw, "HANDLE\tROLE\tNAME") | ||
| 77 | + for _, m := range members { | ||
| 78 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", m.Handle, m.Role, dash(m.DisplayName)) | ||
| 79 | + } | ||
| 80 | + tw.Flush() | ||
| 81 | + return nil | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +func runOrgTeams(cmd *cobra.Command, args []string) error { | ||
| 85 | + client, err := newClient() | ||
| 86 | + if err != nil { | ||
| 87 | + return err | ||
| 88 | + } | ||
| 89 | + teams, err := client.ListOrgTeams(cmd.Context(), args[0]) | ||
| 90 | + if err != nil { | ||
| 91 | + return err | ||
| 92 | + } | ||
| 93 | + if flagJSON { | ||
| 94 | + return printJSON(cmd.OutOrStdout(), teams) | ||
| 95 | + } | ||
| 96 | + if len(teams) == 0 { | ||
| 97 | + fmt.Fprintln(cmd.OutOrStdout(), "No teams.") | ||
| 98 | + return nil | ||
| 99 | + } | ||
| 100 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 101 | + fmt.Fprintln(tw, "SLUG\tNAME\tSUB-TEAM\tDESCRIPTION") | ||
| 102 | + for _, t := range teams { | ||
| 103 | + fmt.Fprintf(tw, "%s\t%s\t%v\t%s\n", t.Slug, dash(t.Name), t.SubTeam, dash(t.Description)) | ||
| 104 | + } | ||
| 105 | + tw.Flush() | ||
| 106 | + return nil | ||
| 107 | +} | ||
added
cmd/pr.go +326 -0 | new file mode 100644 | ||
| @@ -0,0 +1,326 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strconv" | |
| 6 | + | |
| 7 | + "rickub.com/rickub/cli/internal/api" | |
| 8 | + | |
| 9 | + "github.com/spf13/cobra" | |
| 10 | +) | |
| 11 | + | |
| 12 | +var ( | |
| 13 | + prRepo string | |
| 14 | + prState string | |
| 15 | + prBase string | |
| 16 | + prHead string | |
| 17 | + prTitle string | |
| 18 | + prBody string | |
| 19 | + prHeadOwner string | |
| 20 | + prHeadRepo string | |
| 21 | + prMergeMethod string | |
| 22 | +) | |
| 23 | + | |
| 24 | +func init() { | |
| 25 | + prCmd := &cobra.Command{ | |
| 26 | + Use: "pr", | |
| 27 | + Aliases: []string{"mr", "pull"}, | |
| 28 | + Short: "Work with merge requests (pull requests)", | |
| 29 | + } | |
| 30 | + // --repo is shared by all subcommands; defaults to the cwd git remote. | |
| 31 | + prCmd.PersistentFlags().StringVarP(&prRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") | |
| 32 | + | |
| 33 | + listCmd := &cobra.Command{ | |
| 34 | + Use: "list", | |
| 35 | + Short: "List merge requests", | |
| 36 | + Args: cobra.NoArgs, | |
| 37 | + RunE: runPRList, | |
| 38 | + } | |
| 39 | + listCmd.Flags().StringVar(&prState, "state", "open", "open | closed | merged | all") | |
| 40 | + addPaging(listCmd) | |
| 41 | + | |
| 42 | + viewCmd := &cobra.Command{ | |
| 43 | + Use: "view <number>", | |
| 44 | + Short: "Show a merge request with comments and reviews", | |
| 45 | + Args: cobra.ExactArgs(1), | |
| 46 | + RunE: runPRView, | |
| 47 | + } | |
| 48 | + | |
| 49 | + createCmd := &cobra.Command{ | |
| 50 | + Use: "create", | |
| 51 | + Short: "Open a merge request", | |
| 52 | + Args: cobra.NoArgs, | |
| 53 | + RunE: runPRCreate, | |
| 54 | + } | |
| 55 | + createCmd.Flags().StringVar(&prBase, "base", "", "base branch (required)") | |
| 56 | + createCmd.Flags().StringVar(&prHead, "head", "", "head branch (required)") | |
| 57 | + createCmd.Flags().StringVarP(&prTitle, "title", "t", "", "title (required)") | |
| 58 | + createCmd.Flags().StringVarP(&prBody, "body", "b", "", "description body") | |
| 59 | + createCmd.Flags().StringVar(&prHeadOwner, "head-owner", "", "fork owner for a cross-repo MR") | |
| 60 | + createCmd.Flags().StringVar(&prHeadRepo, "head-repo", "", "fork name for a cross-repo MR") | |
| 61 | + | |
| 62 | + mergeCmd := &cobra.Command{ | |
| 63 | + Use: "merge <number>", | |
| 64 | + Short: "Merge a merge request", | |
| 65 | + Args: cobra.ExactArgs(1), | |
| 66 | + RunE: runPRMerge, | |
| 67 | + } | |
| 68 | + mergeCmd.Flags().StringVar(&prMergeMethod, "method", "merge", "merge | squash | ff-only") | |
| 69 | + | |
| 70 | + closeCmd := &cobra.Command{ | |
| 71 | + Use: "close <number>", | |
| 72 | + Short: "Close a merge request", | |
| 73 | + Args: cobra.ExactArgs(1), | |
| 74 | + RunE: runPRClose, | |
| 75 | + } | |
| 76 | + | |
| 77 | + reopenCmd := &cobra.Command{ | |
| 78 | + Use: "reopen <number>", | |
| 79 | + Short: "Reopen a closed merge request", | |
| 80 | + Args: cobra.ExactArgs(1), | |
| 81 | + RunE: runPRReopen, | |
| 82 | + } | |
| 83 | + | |
| 84 | + commentCmd := &cobra.Command{ | |
| 85 | + Use: "comment <number>", | |
| 86 | + Short: "Comment on a merge request", | |
| 87 | + Args: cobra.ExactArgs(1), | |
| 88 | + RunE: runPRComment, | |
| 89 | + } | |
| 90 | + commentCmd.Flags().StringVarP(&prBody, "body", "b", "", "comment body (required)") | |
| 91 | + | |
| 92 | + prCmd.AddCommand(listCmd, viewCmd, createCmd, mergeCmd, closeCmd, reopenCmd, commentCmd) | |
| 93 | + rootCmd.AddCommand(prCmd) | |
| 94 | +} | |
| 95 | + | |
| 96 | +func prNumber(arg string) (int, error) { | |
| 97 | + n, err := strconv.Atoi(arg) | |
| 98 | + if err != nil || n <= 0 { | |
| 99 | + return 0, fmt.Errorf("invalid merge-request number %q", arg) | |
| 100 | + } | |
| 101 | + return n, nil | |
| 102 | +} | |
| 103 | + | |
| 104 | +func runPRList(cmd *cobra.Command, _ []string) error { | |
| 105 | + client, err := newClient() | |
| 106 | + if err != nil { | |
| 107 | + return err | |
| 108 | + } | |
| 109 | + owner, repo, err := resolveRepo(prRepo) | |
| 110 | + if err != nil { | |
| 111 | + return err | |
| 112 | + } | |
| 113 | + page, err := client.ListMergeRequests(cmd.Context(), owner, repo, prState, pageFlag, perPageFlag) | |
| 114 | + if err != nil { | |
| 115 | + return err | |
| 116 | + } | |
| 117 | + if flagJSON { | |
| 118 | + return printJSON(cmd.OutOrStdout(), page) | |
| 119 | + } | |
| 120 | + if len(page.Items) == 0 { | |
| 121 | + fmt.Fprintln(cmd.OutOrStdout(), "No merge requests.") | |
| 122 | + return nil | |
| 123 | + } | |
| 124 | + tw := newTabw(cmd.OutOrStdout()) | |
| 125 | + fmt.Fprintln(tw, "#\tSTATE\tTITLE\tHEAD→BASE\tAUTHOR") | |
| 126 | + for _, mr := range page.Items { | |
| 127 | + fmt.Fprintf(tw, "%d\t%s\t%s\t%s→%s\t%s\n", mr.Number, mr.State, mr.Title, mr.HeadBranch, mr.BaseBranch, mr.Author) | |
| 128 | + } | |
| 129 | + tw.Flush() | |
| 130 | + printPageFooter(cmd, page.Page) | |
| 131 | + return nil | |
| 132 | +} | |
| 133 | + | |
| 134 | +func runPRView(cmd *cobra.Command, args []string) error { | |
| 135 | + client, err := newClient() | |
| 136 | + if err != nil { | |
| 137 | + return err | |
| 138 | + } | |
| 139 | + owner, repo, err := resolveRepo(prRepo) | |
| 140 | + if err != nil { | |
| 141 | + return err | |
| 142 | + } | |
| 143 | + n, err := prNumber(args[0]) | |
| 144 | + if err != nil { | |
| 145 | + return err | |
| 146 | + } | |
| 147 | + mr, err := client.GetMergeRequest(cmd.Context(), owner, repo, n) | |
| 148 | + if err != nil { | |
| 149 | + return err | |
| 150 | + } | |
| 151 | + if flagJSON { | |
| 152 | + return printJSON(cmd.OutOrStdout(), mr) | |
| 153 | + } | |
| 154 | + out := cmd.OutOrStdout() | |
| 155 | + fmt.Fprintf(out, "#%d %s [%s]\n", mr.Number, mr.Title, mr.State) | |
| 156 | + fmt.Fprintf(out, "%s wants to merge %s → %s\n", mr.Author, mr.HeadBranch, mr.BaseBranch) | |
| 157 | + if mr.CrossRepo { | |
| 158 | + fmt.Fprintf(out, "cross-repo from %s/%s\n", mr.HeadOwner, mr.HeadRepo) | |
| 159 | + } | |
| 160 | + if mr.Body != "" { | |
| 161 | + fmt.Fprintf(out, "\n%s\n", mr.Body) | |
| 162 | + } | |
| 163 | + if len(mr.Reviewers) > 0 { | |
| 164 | + fmt.Fprintf(out, "\nReviewers: %s\n", joinSubjects(mr.Reviewers)) | |
| 165 | + } | |
| 166 | + if len(mr.Assignees) > 0 { | |
| 167 | + fmt.Fprintf(out, "Assignees: %s\n", joinSubjects(mr.Assignees)) | |
| 168 | + } | |
| 169 | + if len(mr.Reviews) > 0 { | |
| 170 | + fmt.Fprintln(out, "\nReviews:") | |
| 171 | + for _, r := range mr.Reviews { | |
| 172 | + fmt.Fprintf(out, " %s: %s\n", r.Reviewer, r.Verdict) | |
| 173 | + } | |
| 174 | + } | |
| 175 | + if len(mr.Comments) > 0 { | |
| 176 | + fmt.Fprintln(out, "\nComments:") | |
| 177 | + for _, c := range mr.Comments { | |
| 178 | + fmt.Fprintf(out, " %s (%s):\n %s\n", c.Author, humanTime(c.CreatedAt), c.Body) | |
| 179 | + } | |
| 180 | + } | |
| 181 | + return nil | |
| 182 | +} | |
| 183 | + | |
| 184 | +func joinSubjects(subs []api.Subject) string { | |
| 185 | + out := "" | |
| 186 | + for i, s := range subs { | |
| 187 | + if i > 0 { | |
| 188 | + out += ", " | |
| 189 | + } | |
| 190 | + label := s.Name | |
| 191 | + if s.Type == "team" { | |
| 192 | + label = "@team/" + s.Name | |
| 193 | + } | |
| 194 | + out += label | |
| 195 | + } | |
| 196 | + return out | |
| 197 | +} | |
| 198 | + | |
| 199 | +func runPRCreate(cmd *cobra.Command, _ []string) error { | |
| 200 | + client, err := newClient() | |
| 201 | + if err != nil { | |
| 202 | + return err | |
| 203 | + } | |
| 204 | + owner, repo, err := resolveRepo(prRepo) | |
| 205 | + if err != nil { | |
| 206 | + return err | |
| 207 | + } | |
| 208 | + if prBase == "" || prHead == "" || prTitle == "" { | |
| 209 | + return fmt.Errorf("--base, --head, and --title are required") | |
| 210 | + } | |
| 211 | + mr, err := client.CreateMergeRequest(cmd.Context(), owner, repo, api.MergeRequestCreate{ | |
| 212 | + Base: prBase, | |
| 213 | + Head: prHead, | |
| 214 | + Title: prTitle, | |
| 215 | + Body: prBody, | |
| 216 | + HeadOwner: prHeadOwner, | |
| 217 | + HeadRepo: prHeadRepo, | |
| 218 | + }) | |
| 219 | + if err != nil { | |
| 220 | + return err | |
| 221 | + } | |
| 222 | + if flagJSON { | |
| 223 | + return printJSON(cmd.OutOrStdout(), mr) | |
| 224 | + } | |
| 225 | + fmt.Fprintf(cmd.OutOrStdout(), "Opened merge request #%d: %s\n", mr.Number, mr.Title) | |
| 226 | + return nil | |
| 227 | +} | |
| 228 | + | |
| 229 | +func runPRMerge(cmd *cobra.Command, args []string) error { | |
| 230 | + client, err := newClient() | |
| 231 | + if err != nil { | |
| 232 | + return err | |
| 233 | + } | |
| 234 | + owner, repo, err := resolveRepo(prRepo) | |
| 235 | + if err != nil { | |
| 236 | + return err | |
| 237 | + } | |
| 238 | + n, err := prNumber(args[0]) | |
| 239 | + if err != nil { | |
| 240 | + return err | |
| 241 | + } | |
| 242 | + mr, err := client.MergeMergeRequest(cmd.Context(), owner, repo, n, prMergeMethod) | |
| 243 | + if err != nil { | |
| 244 | + return err | |
| 245 | + } | |
| 246 | + if flagJSON { | |
| 247 | + return printJSON(cmd.OutOrStdout(), mr) | |
| 248 | + } | |
| 249 | + fmt.Fprintf(cmd.OutOrStdout(), "Merged #%d (%s) → %s\n", mr.Number, prMergeMethod, dash(mr.MergeSHA)) | |
| 250 | + return nil | |
| 251 | +} | |
| 252 | + | |
| 253 | +func runPRClose(cmd *cobra.Command, args []string) error { | |
| 254 | + client, err := newClient() | |
| 255 | + if err != nil { | |
| 256 | + return err | |
| 257 | + } | |
| 258 | + owner, repo, err := resolveRepo(prRepo) | |
| 259 | + if err != nil { | |
| 260 | + return err | |
| 261 | + } | |
| 262 | + n, err := prNumber(args[0]) | |
| 263 | + if err != nil { | |
| 264 | + return err | |
| 265 | + } | |
| 266 | + mr, err := client.CloseMergeRequest(cmd.Context(), owner, repo, n) | |
| 267 | + if err != nil { | |
| 268 | + return err | |
| 269 | + } | |
| 270 | + if flagJSON { | |
| 271 | + return printJSON(cmd.OutOrStdout(), mr) | |
| 272 | + } | |
| 273 | + fmt.Fprintf(cmd.OutOrStdout(), "Closed #%d\n", mr.Number) | |
| 274 | + return nil | |
| 275 | +} | |
| 276 | + | |
| 277 | +func runPRReopen(cmd *cobra.Command, args []string) error { | |
| 278 | + client, err := newClient() | |
| 279 | + if err != nil { | |
| 280 | + return err | |
| 281 | + } | |
| 282 | + owner, repo, err := resolveRepo(prRepo) | |
| 283 | + if err != nil { | |
| 284 | + return err | |
| 285 | + } | |
| 286 | + n, err := prNumber(args[0]) | |
| 287 | + if err != nil { | |
| 288 | + return err | |
| 289 | + } | |
| 290 | + mr, err := client.ReopenMergeRequest(cmd.Context(), owner, repo, n) | |
| 291 | + if err != nil { | |
| 292 | + return err | |
| 293 | + } | |
| 294 | + if flagJSON { | |
| 295 | + return printJSON(cmd.OutOrStdout(), mr) | |
| 296 | + } | |
| 297 | + fmt.Fprintf(cmd.OutOrStdout(), "Reopened #%d\n", mr.Number) | |
| 298 | + return nil | |
| 299 | +} | |
| 300 | + | |
| 301 | +func runPRComment(cmd *cobra.Command, args []string) error { | |
| 302 | + client, err := newClient() | |
| 303 | + if err != nil { | |
| 304 | + return err | |
| 305 | + } | |
| 306 | + owner, repo, err := resolveRepo(prRepo) | |
| 307 | + if err != nil { | |
| 308 | + return err | |
| 309 | + } | |
| 310 | + n, err := prNumber(args[0]) | |
| 311 | + if err != nil { | |
| 312 | + return err | |
| 313 | + } | |
| 314 | + if prBody == "" { | |
| 315 | + return fmt.Errorf("--body is required") | |
| 316 | + } | |
| 317 | + c, err := client.CommentMergeRequest(cmd.Context(), owner, repo, n, prBody) | |
| 318 | + if err != nil { | |
| 319 | + return err | |
| 320 | + } | |
| 321 | + if flagJSON { | |
| 322 | + return printJSON(cmd.OutOrStdout(), c) | |
| 323 | + } | |
| 324 | + fmt.Fprintf(cmd.OutOrStdout(), "Commented on #%d\n", n) | |
| 325 | + return nil | |
| 326 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,326 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "strconv" | ||
| 6 | + | ||
| 7 | + "rickub.com/rickub/cli/internal/api" | ||
| 8 | + | ||
| 9 | + "github.com/spf13/cobra" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +var ( | ||
| 13 | + prRepo string | ||
| 14 | + prState string | ||
| 15 | + prBase string | ||
| 16 | + prHead string | ||
| 17 | + prTitle string | ||
| 18 | + prBody string | ||
| 19 | + prHeadOwner string | ||
| 20 | + prHeadRepo string | ||
| 21 | + prMergeMethod string | ||
| 22 | +) | ||
| 23 | + | ||
| 24 | +func init() { | ||
| 25 | + prCmd := &cobra.Command{ | ||
| 26 | + Use: "pr", | ||
| 27 | + Aliases: []string{"mr", "pull"}, | ||
| 28 | + Short: "Work with merge requests (pull requests)", | ||
| 29 | + } | ||
| 30 | + // --repo is shared by all subcommands; defaults to the cwd git remote. | ||
| 31 | + prCmd.PersistentFlags().StringVarP(&prRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") | ||
| 32 | + | ||
| 33 | + listCmd := &cobra.Command{ | ||
| 34 | + Use: "list", | ||
| 35 | + Short: "List merge requests", | ||
| 36 | + Args: cobra.NoArgs, | ||
| 37 | + RunE: runPRList, | ||
| 38 | + } | ||
| 39 | + listCmd.Flags().StringVar(&prState, "state", "open", "open | closed | merged | all") | ||
| 40 | + addPaging(listCmd) | ||
| 41 | + | ||
| 42 | + viewCmd := &cobra.Command{ | ||
| 43 | + Use: "view <number>", | ||
| 44 | + Short: "Show a merge request with comments and reviews", | ||
| 45 | + Args: cobra.ExactArgs(1), | ||
| 46 | + RunE: runPRView, | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + createCmd := &cobra.Command{ | ||
| 50 | + Use: "create", | ||
| 51 | + Short: "Open a merge request", | ||
| 52 | + Args: cobra.NoArgs, | ||
| 53 | + RunE: runPRCreate, | ||
| 54 | + } | ||
| 55 | + createCmd.Flags().StringVar(&prBase, "base", "", "base branch (required)") | ||
| 56 | + createCmd.Flags().StringVar(&prHead, "head", "", "head branch (required)") | ||
| 57 | + createCmd.Flags().StringVarP(&prTitle, "title", "t", "", "title (required)") | ||
| 58 | + createCmd.Flags().StringVarP(&prBody, "body", "b", "", "description body") | ||
| 59 | + createCmd.Flags().StringVar(&prHeadOwner, "head-owner", "", "fork owner for a cross-repo MR") | ||
| 60 | + createCmd.Flags().StringVar(&prHeadRepo, "head-repo", "", "fork name for a cross-repo MR") | ||
| 61 | + | ||
| 62 | + mergeCmd := &cobra.Command{ | ||
| 63 | + Use: "merge <number>", | ||
| 64 | + Short: "Merge a merge request", | ||
| 65 | + Args: cobra.ExactArgs(1), | ||
| 66 | + RunE: runPRMerge, | ||
| 67 | + } | ||
| 68 | + mergeCmd.Flags().StringVar(&prMergeMethod, "method", "merge", "merge | squash | ff-only") | ||
| 69 | + | ||
| 70 | + closeCmd := &cobra.Command{ | ||
| 71 | + Use: "close <number>", | ||
| 72 | + Short: "Close a merge request", | ||
| 73 | + Args: cobra.ExactArgs(1), | ||
| 74 | + RunE: runPRClose, | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + reopenCmd := &cobra.Command{ | ||
| 78 | + Use: "reopen <number>", | ||
| 79 | + Short: "Reopen a closed merge request", | ||
| 80 | + Args: cobra.ExactArgs(1), | ||
| 81 | + RunE: runPRReopen, | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + commentCmd := &cobra.Command{ | ||
| 85 | + Use: "comment <number>", | ||
| 86 | + Short: "Comment on a merge request", | ||
| 87 | + Args: cobra.ExactArgs(1), | ||
| 88 | + RunE: runPRComment, | ||
| 89 | + } | ||
| 90 | + commentCmd.Flags().StringVarP(&prBody, "body", "b", "", "comment body (required)") | ||
| 91 | + | ||
| 92 | + prCmd.AddCommand(listCmd, viewCmd, createCmd, mergeCmd, closeCmd, reopenCmd, commentCmd) | ||
| 93 | + rootCmd.AddCommand(prCmd) | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +func prNumber(arg string) (int, error) { | ||
| 97 | + n, err := strconv.Atoi(arg) | ||
| 98 | + if err != nil || n <= 0 { | ||
| 99 | + return 0, fmt.Errorf("invalid merge-request number %q", arg) | ||
| 100 | + } | ||
| 101 | + return n, nil | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +func runPRList(cmd *cobra.Command, _ []string) error { | ||
| 105 | + client, err := newClient() | ||
| 106 | + if err != nil { | ||
| 107 | + return err | ||
| 108 | + } | ||
| 109 | + owner, repo, err := resolveRepo(prRepo) | ||
| 110 | + if err != nil { | ||
| 111 | + return err | ||
| 112 | + } | ||
| 113 | + page, err := client.ListMergeRequests(cmd.Context(), owner, repo, prState, pageFlag, perPageFlag) | ||
| 114 | + if err != nil { | ||
| 115 | + return err | ||
| 116 | + } | ||
| 117 | + if flagJSON { | ||
| 118 | + return printJSON(cmd.OutOrStdout(), page) | ||
| 119 | + } | ||
| 120 | + if len(page.Items) == 0 { | ||
| 121 | + fmt.Fprintln(cmd.OutOrStdout(), "No merge requests.") | ||
| 122 | + return nil | ||
| 123 | + } | ||
| 124 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 125 | + fmt.Fprintln(tw, "#\tSTATE\tTITLE\tHEAD→BASE\tAUTHOR") | ||
| 126 | + for _, mr := range page.Items { | ||
| 127 | + fmt.Fprintf(tw, "%d\t%s\t%s\t%s→%s\t%s\n", mr.Number, mr.State, mr.Title, mr.HeadBranch, mr.BaseBranch, mr.Author) | ||
| 128 | + } | ||
| 129 | + tw.Flush() | ||
| 130 | + printPageFooter(cmd, page.Page) | ||
| 131 | + return nil | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +func runPRView(cmd *cobra.Command, args []string) error { | ||
| 135 | + client, err := newClient() | ||
| 136 | + if err != nil { | ||
| 137 | + return err | ||
| 138 | + } | ||
| 139 | + owner, repo, err := resolveRepo(prRepo) | ||
| 140 | + if err != nil { | ||
| 141 | + return err | ||
| 142 | + } | ||
| 143 | + n, err := prNumber(args[0]) | ||
| 144 | + if err != nil { | ||
| 145 | + return err | ||
| 146 | + } | ||
| 147 | + mr, err := client.GetMergeRequest(cmd.Context(), owner, repo, n) | ||
| 148 | + if err != nil { | ||
| 149 | + return err | ||
| 150 | + } | ||
| 151 | + if flagJSON { | ||
| 152 | + return printJSON(cmd.OutOrStdout(), mr) | ||
| 153 | + } | ||
| 154 | + out := cmd.OutOrStdout() | ||
| 155 | + fmt.Fprintf(out, "#%d %s [%s]\n", mr.Number, mr.Title, mr.State) | ||
| 156 | + fmt.Fprintf(out, "%s wants to merge %s → %s\n", mr.Author, mr.HeadBranch, mr.BaseBranch) | ||
| 157 | + if mr.CrossRepo { | ||
| 158 | + fmt.Fprintf(out, "cross-repo from %s/%s\n", mr.HeadOwner, mr.HeadRepo) | ||
| 159 | + } | ||
| 160 | + if mr.Body != "" { | ||
| 161 | + fmt.Fprintf(out, "\n%s\n", mr.Body) | ||
| 162 | + } | ||
| 163 | + if len(mr.Reviewers) > 0 { | ||
| 164 | + fmt.Fprintf(out, "\nReviewers: %s\n", joinSubjects(mr.Reviewers)) | ||
| 165 | + } | ||
| 166 | + if len(mr.Assignees) > 0 { | ||
| 167 | + fmt.Fprintf(out, "Assignees: %s\n", joinSubjects(mr.Assignees)) | ||
| 168 | + } | ||
| 169 | + if len(mr.Reviews) > 0 { | ||
| 170 | + fmt.Fprintln(out, "\nReviews:") | ||
| 171 | + for _, r := range mr.Reviews { | ||
| 172 | + fmt.Fprintf(out, " %s: %s\n", r.Reviewer, r.Verdict) | ||
| 173 | + } | ||
| 174 | + } | ||
| 175 | + if len(mr.Comments) > 0 { | ||
| 176 | + fmt.Fprintln(out, "\nComments:") | ||
| 177 | + for _, c := range mr.Comments { | ||
| 178 | + fmt.Fprintf(out, " %s (%s):\n %s\n", c.Author, humanTime(c.CreatedAt), c.Body) | ||
| 179 | + } | ||
| 180 | + } | ||
| 181 | + return nil | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +func joinSubjects(subs []api.Subject) string { | ||
| 185 | + out := "" | ||
| 186 | + for i, s := range subs { | ||
| 187 | + if i > 0 { | ||
| 188 | + out += ", " | ||
| 189 | + } | ||
| 190 | + label := s.Name | ||
| 191 | + if s.Type == "team" { | ||
| 192 | + label = "@team/" + s.Name | ||
| 193 | + } | ||
| 194 | + out += label | ||
| 195 | + } | ||
| 196 | + return out | ||
| 197 | +} | ||
| 198 | + | ||
| 199 | +func runPRCreate(cmd *cobra.Command, _ []string) error { | ||
| 200 | + client, err := newClient() | ||
| 201 | + if err != nil { | ||
| 202 | + return err | ||
| 203 | + } | ||
| 204 | + owner, repo, err := resolveRepo(prRepo) | ||
| 205 | + if err != nil { | ||
| 206 | + return err | ||
| 207 | + } | ||
| 208 | + if prBase == "" || prHead == "" || prTitle == "" { | ||
| 209 | + return fmt.Errorf("--base, --head, and --title are required") | ||
| 210 | + } | ||
| 211 | + mr, err := client.CreateMergeRequest(cmd.Context(), owner, repo, api.MergeRequestCreate{ | ||
| 212 | + Base: prBase, | ||
| 213 | + Head: prHead, | ||
| 214 | + Title: prTitle, | ||
| 215 | + Body: prBody, | ||
| 216 | + HeadOwner: prHeadOwner, | ||
| 217 | + HeadRepo: prHeadRepo, | ||
| 218 | + }) | ||
| 219 | + if err != nil { | ||
| 220 | + return err | ||
| 221 | + } | ||
| 222 | + if flagJSON { | ||
| 223 | + return printJSON(cmd.OutOrStdout(), mr) | ||
| 224 | + } | ||
| 225 | + fmt.Fprintf(cmd.OutOrStdout(), "Opened merge request #%d: %s\n", mr.Number, mr.Title) | ||
| 226 | + return nil | ||
| 227 | +} | ||
| 228 | + | ||
| 229 | +func runPRMerge(cmd *cobra.Command, args []string) error { | ||
| 230 | + client, err := newClient() | ||
| 231 | + if err != nil { | ||
| 232 | + return err | ||
| 233 | + } | ||
| 234 | + owner, repo, err := resolveRepo(prRepo) | ||
| 235 | + if err != nil { | ||
| 236 | + return err | ||
| 237 | + } | ||
| 238 | + n, err := prNumber(args[0]) | ||
| 239 | + if err != nil { | ||
| 240 | + return err | ||
| 241 | + } | ||
| 242 | + mr, err := client.MergeMergeRequest(cmd.Context(), owner, repo, n, prMergeMethod) | ||
| 243 | + if err != nil { | ||
| 244 | + return err | ||
| 245 | + } | ||
| 246 | + if flagJSON { | ||
| 247 | + return printJSON(cmd.OutOrStdout(), mr) | ||
| 248 | + } | ||
| 249 | + fmt.Fprintf(cmd.OutOrStdout(), "Merged #%d (%s) → %s\n", mr.Number, prMergeMethod, dash(mr.MergeSHA)) | ||
| 250 | + return nil | ||
| 251 | +} | ||
| 252 | + | ||
| 253 | +func runPRClose(cmd *cobra.Command, args []string) error { | ||
| 254 | + client, err := newClient() | ||
| 255 | + if err != nil { | ||
| 256 | + return err | ||
| 257 | + } | ||
| 258 | + owner, repo, err := resolveRepo(prRepo) | ||
| 259 | + if err != nil { | ||
| 260 | + return err | ||
| 261 | + } | ||
| 262 | + n, err := prNumber(args[0]) | ||
| 263 | + if err != nil { | ||
| 264 | + return err | ||
| 265 | + } | ||
| 266 | + mr, err := client.CloseMergeRequest(cmd.Context(), owner, repo, n) | ||
| 267 | + if err != nil { | ||
| 268 | + return err | ||
| 269 | + } | ||
| 270 | + if flagJSON { | ||
| 271 | + return printJSON(cmd.OutOrStdout(), mr) | ||
| 272 | + } | ||
| 273 | + fmt.Fprintf(cmd.OutOrStdout(), "Closed #%d\n", mr.Number) | ||
| 274 | + return nil | ||
| 275 | +} | ||
| 276 | + | ||
| 277 | +func runPRReopen(cmd *cobra.Command, args []string) error { | ||
| 278 | + client, err := newClient() | ||
| 279 | + if err != nil { | ||
| 280 | + return err | ||
| 281 | + } | ||
| 282 | + owner, repo, err := resolveRepo(prRepo) | ||
| 283 | + if err != nil { | ||
| 284 | + return err | ||
| 285 | + } | ||
| 286 | + n, err := prNumber(args[0]) | ||
| 287 | + if err != nil { | ||
| 288 | + return err | ||
| 289 | + } | ||
| 290 | + mr, err := client.ReopenMergeRequest(cmd.Context(), owner, repo, n) | ||
| 291 | + if err != nil { | ||
| 292 | + return err | ||
| 293 | + } | ||
| 294 | + if flagJSON { | ||
| 295 | + return printJSON(cmd.OutOrStdout(), mr) | ||
| 296 | + } | ||
| 297 | + fmt.Fprintf(cmd.OutOrStdout(), "Reopened #%d\n", mr.Number) | ||
| 298 | + return nil | ||
| 299 | +} | ||
| 300 | + | ||
| 301 | +func runPRComment(cmd *cobra.Command, args []string) error { | ||
| 302 | + client, err := newClient() | ||
| 303 | + if err != nil { | ||
| 304 | + return err | ||
| 305 | + } | ||
| 306 | + owner, repo, err := resolveRepo(prRepo) | ||
| 307 | + if err != nil { | ||
| 308 | + return err | ||
| 309 | + } | ||
| 310 | + n, err := prNumber(args[0]) | ||
| 311 | + if err != nil { | ||
| 312 | + return err | ||
| 313 | + } | ||
| 314 | + if prBody == "" { | ||
| 315 | + return fmt.Errorf("--body is required") | ||
| 316 | + } | ||
| 317 | + c, err := client.CommentMergeRequest(cmd.Context(), owner, repo, n, prBody) | ||
| 318 | + if err != nil { | ||
| 319 | + return err | ||
| 320 | + } | ||
| 321 | + if flagJSON { | ||
| 322 | + return printJSON(cmd.OutOrStdout(), c) | ||
| 323 | + } | ||
| 324 | + fmt.Fprintf(cmd.OutOrStdout(), "Commented on #%d\n", n) | ||
| 325 | + return nil | ||
| 326 | +} | ||
added
cmd/repo.go +519 -0 | new file mode 100644 | ||
| @@ -0,0 +1,519 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/base64" | |
| 5 | + "fmt" | |
| 6 | + "io" | |
| 7 | + "os/exec" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "rickub.com/rickub/cli/internal/api" | |
| 11 | + "rickub.com/rickub/cli/internal/config" | |
| 12 | + | |
| 13 | + "github.com/spf13/cobra" | |
| 14 | +) | |
| 15 | + | |
| 16 | +var ( | |
| 17 | + repoListUser string | |
| 18 | + repoListOrg string | |
| 19 | + repoCreateOrg string | |
| 20 | + repoCreatePriv bool | |
| 21 | + repoCreatePub bool | |
| 22 | + repoCreateDesc string | |
| 23 | + repoEditVis string | |
| 24 | + repoEditDesc string | |
| 25 | + repoEditBranch string | |
| 26 | + repoDeleteYes bool | |
| 27 | + repoContentsRef string | |
| 28 | + pageFlag int | |
| 29 | + perPageFlag int | |
| 30 | +) | |
| 31 | + | |
| 32 | +func init() { | |
| 33 | + repoCmd := &cobra.Command{ | |
| 34 | + Use: "repo", | |
| 35 | + Aliases: []string{"repos"}, | |
| 36 | + Short: "Manage repositories", | |
| 37 | + } | |
| 38 | + | |
| 39 | + // create | |
| 40 | + createCmd := &cobra.Command{ | |
| 41 | + Use: "create <name>", | |
| 42 | + Short: "Create a repository", | |
| 43 | + Long: `Create a repository owned by you, or by an org via --org. | |
| 44 | + | |
| 45 | +By default repositories are private; pass --public to create a public one.`, | |
| 46 | + Args: cobra.ExactArgs(1), | |
| 47 | + RunE: runRepoCreate, | |
| 48 | + } | |
| 49 | + createCmd.Flags().StringVar(&repoCreateOrg, "org", "", "create under this org (default: your account)") | |
| 50 | + createCmd.Flags().BoolVar(&repoCreatePub, "public", false, "make the repository public") | |
| 51 | + createCmd.Flags().BoolVar(&repoCreatePriv, "private", false, "make the repository private (default)") | |
| 52 | + createCmd.Flags().StringVarP(&repoCreateDesc, "description", "d", "", "repository description") | |
| 53 | + | |
| 54 | + // list | |
| 55 | + listCmd := &cobra.Command{ | |
| 56 | + Use: "list", | |
| 57 | + Short: "List repositories for a user or org", | |
| 58 | + Args: cobra.NoArgs, | |
| 59 | + RunE: runRepoList, | |
| 60 | + } | |
| 61 | + listCmd.Flags().StringVar(&repoListUser, "user", "", "list this user's repositories") | |
| 62 | + listCmd.Flags().StringVar(&repoListOrg, "org", "", "list this org's repositories") | |
| 63 | + addPaging(listCmd) | |
| 64 | + | |
| 65 | + // view | |
| 66 | + viewCmd := &cobra.Command{ | |
| 67 | + Use: "view <owner/repo>", | |
| 68 | + Short: "Show a repository", | |
| 69 | + Args: cobra.ExactArgs(1), | |
| 70 | + RunE: runRepoView, | |
| 71 | + } | |
| 72 | + | |
| 73 | + // edit | |
| 74 | + editCmd := &cobra.Command{ | |
| 75 | + Use: "edit <owner/repo>", | |
| 76 | + Short: "Edit visibility, description, or default branch", | |
| 77 | + Args: cobra.ExactArgs(1), | |
| 78 | + RunE: runRepoEdit, | |
| 79 | + } | |
| 80 | + editCmd.Flags().StringVar(&repoEditVis, "visibility", "", "public | private") | |
| 81 | + editCmd.Flags().StringVarP(&repoEditDesc, "description", "d", "", "new description") | |
| 82 | + editCmd.Flags().StringVar(&repoEditBranch, "default-branch", "", "new default branch") | |
| 83 | + | |
| 84 | + // delete | |
| 85 | + deleteCmd := &cobra.Command{ | |
| 86 | + Use: "delete <owner/repo>", | |
| 87 | + Short: "Delete a repository and all of its contents", | |
| 88 | + Args: cobra.ExactArgs(1), | |
| 89 | + RunE: runRepoDelete, | |
| 90 | + } | |
| 91 | + deleteCmd.Flags().BoolVar(&repoDeleteYes, "yes", false, "skip the confirmation prompt") | |
| 92 | + | |
| 93 | + // clone | |
| 94 | + cloneCmd := &cobra.Command{ | |
| 95 | + Use: "clone <owner/repo> [dir] [-- git-args…]", | |
| 96 | + Short: "Clone a repository with git", | |
| 97 | + Long: `Clone a repository by shelling out to git. | |
| 98 | + | |
| 99 | +The clone URL is derived from the configured host as <host>/<owner>/<repo>.git. | |
| 100 | +Extra arguments after -- are passed through to git clone.`, | |
| 101 | + Args: cobra.MinimumNArgs(1), | |
| 102 | + RunE: runRepoClone, | |
| 103 | + } | |
| 104 | + | |
| 105 | + // files (list dir) | |
| 106 | + filesCmd := &cobra.Command{ | |
| 107 | + Use: "files <owner/repo> [path]", | |
| 108 | + Short: "List a directory in a repository", | |
| 109 | + Args: cobra.RangeArgs(1, 2), | |
| 110 | + RunE: runRepoFiles, | |
| 111 | + } | |
| 112 | + filesCmd.Flags().StringVar(&repoContentsRef, "ref", "", "branch, tag, or SHA (default: default branch)") | |
| 113 | + | |
| 114 | + // cat (file content) | |
| 115 | + catCmd := &cobra.Command{ | |
| 116 | + Use: "cat <owner/repo> <path>", | |
| 117 | + Short: "Print a file's contents", | |
| 118 | + Args: cobra.ExactArgs(2), | |
| 119 | + RunE: runRepoCat, | |
| 120 | + } | |
| 121 | + catCmd.Flags().StringVar(&repoContentsRef, "ref", "", "branch, tag, or SHA (default: default branch)") | |
| 122 | + | |
| 123 | + // commits | |
| 124 | + commitsCmd := &cobra.Command{ | |
| 125 | + Use: "commits <owner/repo> [ref]", | |
| 126 | + Short: "List commit history reachable from a ref", | |
| 127 | + Args: cobra.RangeArgs(1, 2), | |
| 128 | + RunE: runRepoCommits, | |
| 129 | + } | |
| 130 | + addPaging(commitsCmd) | |
| 131 | + | |
| 132 | + // compare | |
| 133 | + compareCmd := &cobra.Command{ | |
| 134 | + Use: "compare <owner/repo> <base...head>", | |
| 135 | + Short: "Compare two refs (base...head)", | |
| 136 | + Args: cobra.ExactArgs(2), | |
| 137 | + RunE: runRepoCompare, | |
| 138 | + } | |
| 139 | + | |
| 140 | + repoCmd.AddCommand(createCmd, listCmd, viewCmd, editCmd, deleteCmd, cloneCmd, filesCmd, catCmd, commitsCmd, compareCmd, collaboratorCmd()) | |
| 141 | + rootCmd.AddCommand(repoCmd) | |
| 142 | +} | |
| 143 | + | |
| 144 | +func addPaging(c *cobra.Command) { | |
| 145 | + c.Flags().IntVar(&pageFlag, "page", 0, "page number (1-based)") | |
| 146 | + c.Flags().IntVar(&perPageFlag, "per-page", 0, "results per page (max 100)") | |
| 147 | +} | |
| 148 | + | |
| 149 | +func runRepoCreate(cmd *cobra.Command, args []string) error { | |
| 150 | + client, err := newClient() | |
| 151 | + if err != nil { | |
| 152 | + return err | |
| 153 | + } | |
| 154 | + if repoCreatePub && repoCreatePriv { | |
| 155 | + return fmt.Errorf("--public and --private are mutually exclusive") | |
| 156 | + } | |
| 157 | + vis := "" | |
| 158 | + if repoCreatePub { | |
| 159 | + vis = "public" | |
| 160 | + } else if repoCreatePriv { | |
| 161 | + vis = "private" | |
| 162 | + } | |
| 163 | + r, err := client.CreateRepo(cmd.Context(), api.RepoCreate{ | |
| 164 | + Owner: repoCreateOrg, | |
| 165 | + Name: args[0], | |
| 166 | + Visibility: vis, | |
| 167 | + Description: repoCreateDesc, | |
| 168 | + }) | |
| 169 | + if err != nil { | |
| 170 | + return err | |
| 171 | + } | |
| 172 | + if flagJSON { | |
| 173 | + return printJSON(cmd.OutOrStdout(), r) | |
| 174 | + } | |
| 175 | + fmt.Fprintf(cmd.OutOrStdout(), "Created %s (%s)\n", r.FullName, r.Visibility) | |
| 176 | + return nil | |
| 177 | +} | |
| 178 | + | |
| 179 | +func runRepoList(cmd *cobra.Command, _ []string) error { | |
| 180 | + client, err := newClient() | |
| 181 | + if err != nil { | |
| 182 | + return err | |
| 183 | + } | |
| 184 | + if repoListUser != "" && repoListOrg != "" { | |
| 185 | + return fmt.Errorf("--user and --org are mutually exclusive") | |
| 186 | + } | |
| 187 | + var page *api.RepoPage | |
| 188 | + switch { | |
| 189 | + case repoListOrg != "": | |
| 190 | + page, err = client.ListOrgRepos(cmd.Context(), repoListOrg, pageFlag, perPageFlag) | |
| 191 | + case repoListUser != "": | |
| 192 | + page, err = client.ListUserRepos(cmd.Context(), repoListUser, pageFlag, perPageFlag) | |
| 193 | + default: | |
| 194 | + // Default to the authenticated user's repos. | |
| 195 | + u, uerr := client.GetUser(cmd.Context()) | |
| 196 | + if uerr != nil { | |
| 197 | + return uerr | |
| 198 | + } | |
| 199 | + page, err = client.ListUserRepos(cmd.Context(), u.Handle, pageFlag, perPageFlag) | |
| 200 | + } | |
| 201 | + if err != nil { | |
| 202 | + return err | |
| 203 | + } | |
| 204 | + if flagJSON { | |
| 205 | + return printJSON(cmd.OutOrStdout(), page) | |
| 206 | + } | |
| 207 | + if len(page.Items) == 0 { | |
| 208 | + fmt.Fprintln(cmd.OutOrStdout(), "No repositories found.") | |
| 209 | + return nil | |
| 210 | + } | |
| 211 | + tw := newTabw(cmd.OutOrStdout()) | |
| 212 | + fmt.Fprintln(tw, "NAME\tVISIBILITY\tDESCRIPTION") | |
| 213 | + for _, r := range page.Items { | |
| 214 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", r.FullName, r.Visibility, dash(r.Description)) | |
| 215 | + } | |
| 216 | + tw.Flush() | |
| 217 | + printPageFooter(cmd, page.Page) | |
| 218 | + return nil | |
| 219 | +} | |
| 220 | + | |
| 221 | +func runRepoView(cmd *cobra.Command, args []string) error { | |
| 222 | + client, err := newClient() | |
| 223 | + if err != nil { | |
| 224 | + return err | |
| 225 | + } | |
| 226 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 227 | + if err != nil { | |
| 228 | + return err | |
| 229 | + } | |
| 230 | + r, err := client.GetRepo(cmd.Context(), owner, repo) | |
| 231 | + if err != nil { | |
| 232 | + return err | |
| 233 | + } | |
| 234 | + if flagJSON { | |
| 235 | + return printJSON(cmd.OutOrStdout(), r) | |
| 236 | + } | |
| 237 | + out := cmd.OutOrStdout() | |
| 238 | + fmt.Fprintf(out, "%s\n", r.FullName) | |
| 239 | + fmt.Fprintf(out, "Visibility: %s\n", r.Visibility) | |
| 240 | + fmt.Fprintf(out, "Default branch: %s\n", dash(r.DefaultBranch)) | |
| 241 | + fmt.Fprintf(out, "Description: %s\n", dash(r.Description)) | |
| 242 | + if r.Fork { | |
| 243 | + fmt.Fprintf(out, "Fork of: %s/%s\n", r.ForkedFromOwner, r.ForkedFromName) | |
| 244 | + } | |
| 245 | + fmt.Fprintf(out, "Created: %s\n", humanTime(r.CreatedAt)) | |
| 246 | + return nil | |
| 247 | +} | |
| 248 | + | |
| 249 | +func runRepoEdit(cmd *cobra.Command, args []string) error { | |
| 250 | + client, err := newClient() | |
| 251 | + if err != nil { | |
| 252 | + return err | |
| 253 | + } | |
| 254 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 255 | + if err != nil { | |
| 256 | + return err | |
| 257 | + } | |
| 258 | + var in api.RepoUpdate | |
| 259 | + if cmd.Flags().Changed("visibility") { | |
| 260 | + in.Visibility = &repoEditVis | |
| 261 | + } | |
| 262 | + if cmd.Flags().Changed("description") { | |
| 263 | + in.Description = &repoEditDesc | |
| 264 | + } | |
| 265 | + if cmd.Flags().Changed("default-branch") { | |
| 266 | + in.DefaultBranch = &repoEditBranch | |
| 267 | + } | |
| 268 | + if in.Visibility == nil && in.Description == nil && in.DefaultBranch == nil { | |
| 269 | + return fmt.Errorf("nothing to edit: pass --visibility, --description, or --default-branch") | |
| 270 | + } | |
| 271 | + r, err := client.UpdateRepo(cmd.Context(), owner, repo, in) | |
| 272 | + if err != nil { | |
| 273 | + return err | |
| 274 | + } | |
| 275 | + if flagJSON { | |
| 276 | + return printJSON(cmd.OutOrStdout(), r) | |
| 277 | + } | |
| 278 | + fmt.Fprintf(cmd.OutOrStdout(), "Updated %s\n", r.FullName) | |
| 279 | + return nil | |
| 280 | +} | |
| 281 | + | |
| 282 | +func runRepoDelete(cmd *cobra.Command, args []string) error { | |
| 283 | + client, err := newClient() | |
| 284 | + if err != nil { | |
| 285 | + return err | |
| 286 | + } | |
| 287 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 288 | + if err != nil { | |
| 289 | + return err | |
| 290 | + } | |
| 291 | + if !repoDeleteYes { | |
| 292 | + ans := prompt(cmd, fmt.Sprintf("Delete %s/%s and its storage? This cannot be undone. Type the repo name to confirm: ", owner, repo)) | |
| 293 | + if ans != repo { | |
| 294 | + return fmt.Errorf("confirmation did not match; aborted") | |
| 295 | + } | |
| 296 | + } | |
| 297 | + if err := client.DeleteRepo(cmd.Context(), owner, repo); err != nil { | |
| 298 | + return err | |
| 299 | + } | |
| 300 | + fmt.Fprintf(cmd.OutOrStdout(), "Deleted %s/%s\n", owner, repo) | |
| 301 | + return nil | |
| 302 | +} | |
| 303 | + | |
| 304 | +func runRepoClone(cmd *cobra.Command, args []string) error { | |
| 305 | + cfg, err := loadConfig() | |
| 306 | + if err != nil { | |
| 307 | + return err | |
| 308 | + } | |
| 309 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 310 | + if err != nil { | |
| 311 | + return err | |
| 312 | + } | |
| 313 | + host := hostFor(cfg) | |
| 314 | + cloneURL := fmt.Sprintf("%s/%s/%s.git", host, owner, repo) | |
| 315 | + gitArgs := []string{"clone", cloneURL} | |
| 316 | + gitArgs = append(gitArgs, args[1:]...) | |
| 317 | + fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s…\n", cloneURL) | |
| 318 | + g := exec.Command("git", gitArgs...) | |
| 319 | + g.Stdout = cmd.OutOrStdout() | |
| 320 | + g.Stderr = cmd.ErrOrStderr() | |
| 321 | + g.Stdin = cmd.InOrStdin() | |
| 322 | + return g.Run() | |
| 323 | +} | |
| 324 | + | |
| 325 | +func runRepoFiles(cmd *cobra.Command, args []string) error { | |
| 326 | + client, err := newClient() | |
| 327 | + if err != nil { | |
| 328 | + return err | |
| 329 | + } | |
| 330 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 331 | + if err != nil { | |
| 332 | + return err | |
| 333 | + } | |
| 334 | + path := "" | |
| 335 | + if len(args) == 2 { | |
| 336 | + path = args[1] | |
| 337 | + } | |
| 338 | + ref := repoContentsRef | |
| 339 | + if ref == "" { | |
| 340 | + ref, err = defaultBranch(cmd, client, owner, repo) | |
| 341 | + if err != nil { | |
| 342 | + return err | |
| 343 | + } | |
| 344 | + } | |
| 345 | + c, err := client.GetContents(cmd.Context(), owner, repo, ref, path) | |
| 346 | + if err != nil { | |
| 347 | + return err | |
| 348 | + } | |
| 349 | + if flagJSON { | |
| 350 | + return printJSON(cmd.OutOrStdout(), c) | |
| 351 | + } | |
| 352 | + if c.Type == "file" { | |
| 353 | + fmt.Fprintf(cmd.OutOrStdout(), "%s is a file (%d bytes); use `rickub repo cat`.\n", c.Path, c.File.Size) | |
| 354 | + return nil | |
| 355 | + } | |
| 356 | + if len(c.Entries) == 0 { | |
| 357 | + fmt.Fprintln(cmd.OutOrStdout(), "(empty)") | |
| 358 | + return nil | |
| 359 | + } | |
| 360 | + tw := newTabw(cmd.OutOrStdout()) | |
| 361 | + fmt.Fprintln(tw, "TYPE\tNAME\tSIZE") | |
| 362 | + for _, e := range c.Entries { | |
| 363 | + kind := "dir" | |
| 364 | + size := "-" | |
| 365 | + if e.Type == "blob" { | |
| 366 | + kind = "file" | |
| 367 | + size = fmt.Sprintf("%d", e.Size) | |
| 368 | + } | |
| 369 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", kind, e.Name, size) | |
| 370 | + } | |
| 371 | + tw.Flush() | |
| 372 | + return nil | |
| 373 | +} | |
| 374 | + | |
| 375 | +func runRepoCat(cmd *cobra.Command, args []string) error { | |
| 376 | + client, err := newClient() | |
| 377 | + if err != nil { | |
| 378 | + return err | |
| 379 | + } | |
| 380 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 381 | + if err != nil { | |
| 382 | + return err | |
| 383 | + } | |
| 384 | + ref := repoContentsRef | |
| 385 | + if ref == "" { | |
| 386 | + ref, err = defaultBranch(cmd, client, owner, repo) | |
| 387 | + if err != nil { | |
| 388 | + return err | |
| 389 | + } | |
| 390 | + } | |
| 391 | + c, err := client.GetContents(cmd.Context(), owner, repo, ref, args[1]) | |
| 392 | + if err != nil { | |
| 393 | + return err | |
| 394 | + } | |
| 395 | + if c.Type != "file" || c.File == nil { | |
| 396 | + return fmt.Errorf("%s is not a file", args[1]) | |
| 397 | + } | |
| 398 | + if flagJSON { | |
| 399 | + return printJSON(cmd.OutOrStdout(), c) | |
| 400 | + } | |
| 401 | + out := cmd.OutOrStdout() | |
| 402 | + if c.File.IsBinary { | |
| 403 | + raw, derr := base64.StdEncoding.DecodeString(c.File.Content) | |
| 404 | + if derr != nil { | |
| 405 | + return fmt.Errorf("decode binary content: %w", derr) | |
| 406 | + } | |
| 407 | + _, err = out.Write(raw) | |
| 408 | + return err | |
| 409 | + } | |
| 410 | + _, err = io.WriteString(out, c.File.Content) | |
| 411 | + if err == nil && !strings.HasSuffix(c.File.Content, "\n") { | |
| 412 | + fmt.Fprintln(out) | |
| 413 | + } | |
| 414 | + if c.File.Truncated { | |
| 415 | + fmt.Fprintln(cmd.ErrOrStderr(), "(file truncated by the server)") | |
| 416 | + } | |
| 417 | + return err | |
| 418 | +} | |
| 419 | + | |
| 420 | +func runRepoCommits(cmd *cobra.Command, args []string) error { | |
| 421 | + client, err := newClient() | |
| 422 | + if err != nil { | |
| 423 | + return err | |
| 424 | + } | |
| 425 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 426 | + if err != nil { | |
| 427 | + return err | |
| 428 | + } | |
| 429 | + ref := "" | |
| 430 | + if len(args) == 2 { | |
| 431 | + ref = args[1] | |
| 432 | + } | |
| 433 | + if ref == "" { | |
| 434 | + ref, err = defaultBranch(cmd, client, owner, repo) | |
| 435 | + if err != nil { | |
| 436 | + return err | |
| 437 | + } | |
| 438 | + } | |
| 439 | + page, err := client.GetCommits(cmd.Context(), owner, repo, ref, pageFlag, perPageFlag) | |
| 440 | + if err != nil { | |
| 441 | + return err | |
| 442 | + } | |
| 443 | + if flagJSON { | |
| 444 | + return printJSON(cmd.OutOrStdout(), page) | |
| 445 | + } | |
| 446 | + if len(page.Items) == 0 { | |
| 447 | + fmt.Fprintln(cmd.OutOrStdout(), "No commits.") | |
| 448 | + return nil | |
| 449 | + } | |
| 450 | + tw := newTabw(cmd.OutOrStdout()) | |
| 451 | + fmt.Fprintln(tw, "SHA\tAUTHOR\tDATE\tSUBJECT") | |
| 452 | + for _, c := range page.Items { | |
| 453 | + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", c.Short, dash(c.Author), dash(c.Date), c.Subject) | |
| 454 | + } | |
| 455 | + tw.Flush() | |
| 456 | + printPageFooter(cmd, page.Page) | |
| 457 | + return nil | |
| 458 | +} | |
| 459 | + | |
| 460 | +func runRepoCompare(cmd *cobra.Command, args []string) error { | |
| 461 | + client, err := newClient() | |
| 462 | + if err != nil { | |
| 463 | + return err | |
| 464 | + } | |
| 465 | + owner, repo, err := parseOwnerRepo(args[0]) | |
| 466 | + if err != nil { | |
| 467 | + return err | |
| 468 | + } | |
| 469 | + cmp, err := client.Compare(cmd.Context(), owner, repo, args[1]) | |
| 470 | + if err != nil { | |
| 471 | + return err | |
| 472 | + } | |
| 473 | + if flagJSON { | |
| 474 | + return printJSON(cmd.OutOrStdout(), cmp) | |
| 475 | + } | |
| 476 | + out := cmd.OutOrStdout() | |
| 477 | + fmt.Fprintf(out, "merge-base: %s\n", cmp.MergeBase) | |
| 478 | + fmt.Fprintf(out, "ahead by %d, behind by %d\n\n", cmp.AheadBy, cmp.BehindBy) | |
| 479 | + if len(cmp.Commits) > 0 { | |
| 480 | + tw := newTabw(out) | |
| 481 | + fmt.Fprintln(tw, "SHA\tAUTHOR\tSUBJECT") | |
| 482 | + for _, c := range cmp.Commits { | |
| 483 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Short, dash(c.Author), c.Subject) | |
| 484 | + } | |
| 485 | + tw.Flush() | |
| 486 | + } | |
| 487 | + if len(cmp.Files) > 0 { | |
| 488 | + fmt.Fprintf(out, "\n%d file(s) changed:\n", len(cmp.Files)) | |
| 489 | + for _, f := range cmp.Files { | |
| 490 | + fmt.Fprintf(out, " %s +%d -%d (%s)\n", f.Path, f.Additions, f.Deletions, f.Status) | |
| 491 | + } | |
| 492 | + } | |
| 493 | + return nil | |
| 494 | +} | |
| 495 | + | |
| 496 | +// defaultBranch resolves the repo's default branch via the refs endpoint, | |
| 497 | +// falling back to the repo record. | |
| 498 | +func defaultBranch(cmd *cobra.Command, client *api.Client, owner, repo string) (string, error) { | |
| 499 | + refs, err := client.GetRefs(cmd.Context(), owner, repo) | |
| 500 | + if err == nil && refs.DefaultBranch != "" { | |
| 501 | + return refs.DefaultBranch, nil | |
| 502 | + } | |
| 503 | + r, rerr := client.GetRepo(cmd.Context(), owner, repo) | |
| 504 | + if rerr != nil { | |
| 505 | + if err != nil { | |
| 506 | + return "", err | |
| 507 | + } | |
| 508 | + return "", rerr | |
| 509 | + } | |
| 510 | + if r.DefaultBranch == "" { | |
| 511 | + return "", fmt.Errorf("could not determine default branch; pass --ref") | |
| 512 | + } | |
| 513 | + return r.DefaultBranch, nil | |
| 514 | +} | |
| 515 | + | |
| 516 | +// hostFor returns the effective host from flags/env/config. | |
| 517 | +func hostFor(cfg *config.Config) string { | |
| 518 | + return config.ResolveHost(flagHost, cfg) | |
| 519 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,519 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/base64" | ||
| 5 | + "fmt" | ||
| 6 | + "io" | ||
| 7 | + "os/exec" | ||
| 8 | + "strings" | ||
| 9 | + | ||
| 10 | + "rickub.com/rickub/cli/internal/api" | ||
| 11 | + "rickub.com/rickub/cli/internal/config" | ||
| 12 | + | ||
| 13 | + "github.com/spf13/cobra" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +var ( | ||
| 17 | + repoListUser string | ||
| 18 | + repoListOrg string | ||
| 19 | + repoCreateOrg string | ||
| 20 | + repoCreatePriv bool | ||
| 21 | + repoCreatePub bool | ||
| 22 | + repoCreateDesc string | ||
| 23 | + repoEditVis string | ||
| 24 | + repoEditDesc string | ||
| 25 | + repoEditBranch string | ||
| 26 | + repoDeleteYes bool | ||
| 27 | + repoContentsRef string | ||
| 28 | + pageFlag int | ||
| 29 | + perPageFlag int | ||
| 30 | +) | ||
| 31 | + | ||
| 32 | +func init() { | ||
| 33 | + repoCmd := &cobra.Command{ | ||
| 34 | + Use: "repo", | ||
| 35 | + Aliases: []string{"repos"}, | ||
| 36 | + Short: "Manage repositories", | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + // create | ||
| 40 | + createCmd := &cobra.Command{ | ||
| 41 | + Use: "create <name>", | ||
| 42 | + Short: "Create a repository", | ||
| 43 | + Long: `Create a repository owned by you, or by an org via --org. | ||
| 44 | + | ||
| 45 | +By default repositories are private; pass --public to create a public one.`, | ||
| 46 | + Args: cobra.ExactArgs(1), | ||
| 47 | + RunE: runRepoCreate, | ||
| 48 | + } | ||
| 49 | + createCmd.Flags().StringVar(&repoCreateOrg, "org", "", "create under this org (default: your account)") | ||
| 50 | + createCmd.Flags().BoolVar(&repoCreatePub, "public", false, "make the repository public") | ||
| 51 | + createCmd.Flags().BoolVar(&repoCreatePriv, "private", false, "make the repository private (default)") | ||
| 52 | + createCmd.Flags().StringVarP(&repoCreateDesc, "description", "d", "", "repository description") | ||
| 53 | + | ||
| 54 | + // list | ||
| 55 | + listCmd := &cobra.Command{ | ||
| 56 | + Use: "list", | ||
| 57 | + Short: "List repositories for a user or org", | ||
| 58 | + Args: cobra.NoArgs, | ||
| 59 | + RunE: runRepoList, | ||
| 60 | + } | ||
| 61 | + listCmd.Flags().StringVar(&repoListUser, "user", "", "list this user's repositories") | ||
| 62 | + listCmd.Flags().StringVar(&repoListOrg, "org", "", "list this org's repositories") | ||
| 63 | + addPaging(listCmd) | ||
| 64 | + | ||
| 65 | + // view | ||
| 66 | + viewCmd := &cobra.Command{ | ||
| 67 | + Use: "view <owner/repo>", | ||
| 68 | + Short: "Show a repository", | ||
| 69 | + Args: cobra.ExactArgs(1), | ||
| 70 | + RunE: runRepoView, | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + // edit | ||
| 74 | + editCmd := &cobra.Command{ | ||
| 75 | + Use: "edit <owner/repo>", | ||
| 76 | + Short: "Edit visibility, description, or default branch", | ||
| 77 | + Args: cobra.ExactArgs(1), | ||
| 78 | + RunE: runRepoEdit, | ||
| 79 | + } | ||
| 80 | + editCmd.Flags().StringVar(&repoEditVis, "visibility", "", "public | private") | ||
| 81 | + editCmd.Flags().StringVarP(&repoEditDesc, "description", "d", "", "new description") | ||
| 82 | + editCmd.Flags().StringVar(&repoEditBranch, "default-branch", "", "new default branch") | ||
| 83 | + | ||
| 84 | + // delete | ||
| 85 | + deleteCmd := &cobra.Command{ | ||
| 86 | + Use: "delete <owner/repo>", | ||
| 87 | + Short: "Delete a repository and all of its contents", | ||
| 88 | + Args: cobra.ExactArgs(1), | ||
| 89 | + RunE: runRepoDelete, | ||
| 90 | + } | ||
| 91 | + deleteCmd.Flags().BoolVar(&repoDeleteYes, "yes", false, "skip the confirmation prompt") | ||
| 92 | + | ||
| 93 | + // clone | ||
| 94 | + cloneCmd := &cobra.Command{ | ||
| 95 | + Use: "clone <owner/repo> [dir] [-- git-args…]", | ||
| 96 | + Short: "Clone a repository with git", | ||
| 97 | + Long: `Clone a repository by shelling out to git. | ||
| 98 | + | ||
| 99 | +The clone URL is derived from the configured host as <host>/<owner>/<repo>.git. | ||
| 100 | +Extra arguments after -- are passed through to git clone.`, | ||
| 101 | + Args: cobra.MinimumNArgs(1), | ||
| 102 | + RunE: runRepoClone, | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + // files (list dir) | ||
| 106 | + filesCmd := &cobra.Command{ | ||
| 107 | + Use: "files <owner/repo> [path]", | ||
| 108 | + Short: "List a directory in a repository", | ||
| 109 | + Args: cobra.RangeArgs(1, 2), | ||
| 110 | + RunE: runRepoFiles, | ||
| 111 | + } | ||
| 112 | + filesCmd.Flags().StringVar(&repoContentsRef, "ref", "", "branch, tag, or SHA (default: default branch)") | ||
| 113 | + | ||
| 114 | + // cat (file content) | ||
| 115 | + catCmd := &cobra.Command{ | ||
| 116 | + Use: "cat <owner/repo> <path>", | ||
| 117 | + Short: "Print a file's contents", | ||
| 118 | + Args: cobra.ExactArgs(2), | ||
| 119 | + RunE: runRepoCat, | ||
| 120 | + } | ||
| 121 | + catCmd.Flags().StringVar(&repoContentsRef, "ref", "", "branch, tag, or SHA (default: default branch)") | ||
| 122 | + | ||
| 123 | + // commits | ||
| 124 | + commitsCmd := &cobra.Command{ | ||
| 125 | + Use: "commits <owner/repo> [ref]", | ||
| 126 | + Short: "List commit history reachable from a ref", | ||
| 127 | + Args: cobra.RangeArgs(1, 2), | ||
| 128 | + RunE: runRepoCommits, | ||
| 129 | + } | ||
| 130 | + addPaging(commitsCmd) | ||
| 131 | + | ||
| 132 | + // compare | ||
| 133 | + compareCmd := &cobra.Command{ | ||
| 134 | + Use: "compare <owner/repo> <base...head>", | ||
| 135 | + Short: "Compare two refs (base...head)", | ||
| 136 | + Args: cobra.ExactArgs(2), | ||
| 137 | + RunE: runRepoCompare, | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + repoCmd.AddCommand(createCmd, listCmd, viewCmd, editCmd, deleteCmd, cloneCmd, filesCmd, catCmd, commitsCmd, compareCmd, collaboratorCmd()) | ||
| 141 | + rootCmd.AddCommand(repoCmd) | ||
| 142 | +} | ||
| 143 | + | ||
| 144 | +func addPaging(c *cobra.Command) { | ||
| 145 | + c.Flags().IntVar(&pageFlag, "page", 0, "page number (1-based)") | ||
| 146 | + c.Flags().IntVar(&perPageFlag, "per-page", 0, "results per page (max 100)") | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +func runRepoCreate(cmd *cobra.Command, args []string) error { | ||
| 150 | + client, err := newClient() | ||
| 151 | + if err != nil { | ||
| 152 | + return err | ||
| 153 | + } | ||
| 154 | + if repoCreatePub && repoCreatePriv { | ||
| 155 | + return fmt.Errorf("--public and --private are mutually exclusive") | ||
| 156 | + } | ||
| 157 | + vis := "" | ||
| 158 | + if repoCreatePub { | ||
| 159 | + vis = "public" | ||
| 160 | + } else if repoCreatePriv { | ||
| 161 | + vis = "private" | ||
| 162 | + } | ||
| 163 | + r, err := client.CreateRepo(cmd.Context(), api.RepoCreate{ | ||
| 164 | + Owner: repoCreateOrg, | ||
| 165 | + Name: args[0], | ||
| 166 | + Visibility: vis, | ||
| 167 | + Description: repoCreateDesc, | ||
| 168 | + }) | ||
| 169 | + if err != nil { | ||
| 170 | + return err | ||
| 171 | + } | ||
| 172 | + if flagJSON { | ||
| 173 | + return printJSON(cmd.OutOrStdout(), r) | ||
| 174 | + } | ||
| 175 | + fmt.Fprintf(cmd.OutOrStdout(), "Created %s (%s)\n", r.FullName, r.Visibility) | ||
| 176 | + return nil | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +func runRepoList(cmd *cobra.Command, _ []string) error { | ||
| 180 | + client, err := newClient() | ||
| 181 | + if err != nil { | ||
| 182 | + return err | ||
| 183 | + } | ||
| 184 | + if repoListUser != "" && repoListOrg != "" { | ||
| 185 | + return fmt.Errorf("--user and --org are mutually exclusive") | ||
| 186 | + } | ||
| 187 | + var page *api.RepoPage | ||
| 188 | + switch { | ||
| 189 | + case repoListOrg != "": | ||
| 190 | + page, err = client.ListOrgRepos(cmd.Context(), repoListOrg, pageFlag, perPageFlag) | ||
| 191 | + case repoListUser != "": | ||
| 192 | + page, err = client.ListUserRepos(cmd.Context(), repoListUser, pageFlag, perPageFlag) | ||
| 193 | + default: | ||
| 194 | + // Default to the authenticated user's repos. | ||
| 195 | + u, uerr := client.GetUser(cmd.Context()) | ||
| 196 | + if uerr != nil { | ||
| 197 | + return uerr | ||
| 198 | + } | ||
| 199 | + page, err = client.ListUserRepos(cmd.Context(), u.Handle, pageFlag, perPageFlag) | ||
| 200 | + } | ||
| 201 | + if err != nil { | ||
| 202 | + return err | ||
| 203 | + } | ||
| 204 | + if flagJSON { | ||
| 205 | + return printJSON(cmd.OutOrStdout(), page) | ||
| 206 | + } | ||
| 207 | + if len(page.Items) == 0 { | ||
| 208 | + fmt.Fprintln(cmd.OutOrStdout(), "No repositories found.") | ||
| 209 | + return nil | ||
| 210 | + } | ||
| 211 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 212 | + fmt.Fprintln(tw, "NAME\tVISIBILITY\tDESCRIPTION") | ||
| 213 | + for _, r := range page.Items { | ||
| 214 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", r.FullName, r.Visibility, dash(r.Description)) | ||
| 215 | + } | ||
| 216 | + tw.Flush() | ||
| 217 | + printPageFooter(cmd, page.Page) | ||
| 218 | + return nil | ||
| 219 | +} | ||
| 220 | + | ||
| 221 | +func runRepoView(cmd *cobra.Command, args []string) error { | ||
| 222 | + client, err := newClient() | ||
| 223 | + if err != nil { | ||
| 224 | + return err | ||
| 225 | + } | ||
| 226 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 227 | + if err != nil { | ||
| 228 | + return err | ||
| 229 | + } | ||
| 230 | + r, err := client.GetRepo(cmd.Context(), owner, repo) | ||
| 231 | + if err != nil { | ||
| 232 | + return err | ||
| 233 | + } | ||
| 234 | + if flagJSON { | ||
| 235 | + return printJSON(cmd.OutOrStdout(), r) | ||
| 236 | + } | ||
| 237 | + out := cmd.OutOrStdout() | ||
| 238 | + fmt.Fprintf(out, "%s\n", r.FullName) | ||
| 239 | + fmt.Fprintf(out, "Visibility: %s\n", r.Visibility) | ||
| 240 | + fmt.Fprintf(out, "Default branch: %s\n", dash(r.DefaultBranch)) | ||
| 241 | + fmt.Fprintf(out, "Description: %s\n", dash(r.Description)) | ||
| 242 | + if r.Fork { | ||
| 243 | + fmt.Fprintf(out, "Fork of: %s/%s\n", r.ForkedFromOwner, r.ForkedFromName) | ||
| 244 | + } | ||
| 245 | + fmt.Fprintf(out, "Created: %s\n", humanTime(r.CreatedAt)) | ||
| 246 | + return nil | ||
| 247 | +} | ||
| 248 | + | ||
| 249 | +func runRepoEdit(cmd *cobra.Command, args []string) error { | ||
| 250 | + client, err := newClient() | ||
| 251 | + if err != nil { | ||
| 252 | + return err | ||
| 253 | + } | ||
| 254 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 255 | + if err != nil { | ||
| 256 | + return err | ||
| 257 | + } | ||
| 258 | + var in api.RepoUpdate | ||
| 259 | + if cmd.Flags().Changed("visibility") { | ||
| 260 | + in.Visibility = &repoEditVis | ||
| 261 | + } | ||
| 262 | + if cmd.Flags().Changed("description") { | ||
| 263 | + in.Description = &repoEditDesc | ||
| 264 | + } | ||
| 265 | + if cmd.Flags().Changed("default-branch") { | ||
| 266 | + in.DefaultBranch = &repoEditBranch | ||
| 267 | + } | ||
| 268 | + if in.Visibility == nil && in.Description == nil && in.DefaultBranch == nil { | ||
| 269 | + return fmt.Errorf("nothing to edit: pass --visibility, --description, or --default-branch") | ||
| 270 | + } | ||
| 271 | + r, err := client.UpdateRepo(cmd.Context(), owner, repo, in) | ||
| 272 | + if err != nil { | ||
| 273 | + return err | ||
| 274 | + } | ||
| 275 | + if flagJSON { | ||
| 276 | + return printJSON(cmd.OutOrStdout(), r) | ||
| 277 | + } | ||
| 278 | + fmt.Fprintf(cmd.OutOrStdout(), "Updated %s\n", r.FullName) | ||
| 279 | + return nil | ||
| 280 | +} | ||
| 281 | + | ||
| 282 | +func runRepoDelete(cmd *cobra.Command, args []string) error { | ||
| 283 | + client, err := newClient() | ||
| 284 | + if err != nil { | ||
| 285 | + return err | ||
| 286 | + } | ||
| 287 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 288 | + if err != nil { | ||
| 289 | + return err | ||
| 290 | + } | ||
| 291 | + if !repoDeleteYes { | ||
| 292 | + ans := prompt(cmd, fmt.Sprintf("Delete %s/%s and its storage? This cannot be undone. Type the repo name to confirm: ", owner, repo)) | ||
| 293 | + if ans != repo { | ||
| 294 | + return fmt.Errorf("confirmation did not match; aborted") | ||
| 295 | + } | ||
| 296 | + } | ||
| 297 | + if err := client.DeleteRepo(cmd.Context(), owner, repo); err != nil { | ||
| 298 | + return err | ||
| 299 | + } | ||
| 300 | + fmt.Fprintf(cmd.OutOrStdout(), "Deleted %s/%s\n", owner, repo) | ||
| 301 | + return nil | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +func runRepoClone(cmd *cobra.Command, args []string) error { | ||
| 305 | + cfg, err := loadConfig() | ||
| 306 | + if err != nil { | ||
| 307 | + return err | ||
| 308 | + } | ||
| 309 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 310 | + if err != nil { | ||
| 311 | + return err | ||
| 312 | + } | ||
| 313 | + host := hostFor(cfg) | ||
| 314 | + cloneURL := fmt.Sprintf("%s/%s/%s.git", host, owner, repo) | ||
| 315 | + gitArgs := []string{"clone", cloneURL} | ||
| 316 | + gitArgs = append(gitArgs, args[1:]...) | ||
| 317 | + fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s…\n", cloneURL) | ||
| 318 | + g := exec.Command("git", gitArgs...) | ||
| 319 | + g.Stdout = cmd.OutOrStdout() | ||
| 320 | + g.Stderr = cmd.ErrOrStderr() | ||
| 321 | + g.Stdin = cmd.InOrStdin() | ||
| 322 | + return g.Run() | ||
| 323 | +} | ||
| 324 | + | ||
| 325 | +func runRepoFiles(cmd *cobra.Command, args []string) error { | ||
| 326 | + client, err := newClient() | ||
| 327 | + if err != nil { | ||
| 328 | + return err | ||
| 329 | + } | ||
| 330 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 331 | + if err != nil { | ||
| 332 | + return err | ||
| 333 | + } | ||
| 334 | + path := "" | ||
| 335 | + if len(args) == 2 { | ||
| 336 | + path = args[1] | ||
| 337 | + } | ||
| 338 | + ref := repoContentsRef | ||
| 339 | + if ref == "" { | ||
| 340 | + ref, err = defaultBranch(cmd, client, owner, repo) | ||
| 341 | + if err != nil { | ||
| 342 | + return err | ||
| 343 | + } | ||
| 344 | + } | ||
| 345 | + c, err := client.GetContents(cmd.Context(), owner, repo, ref, path) | ||
| 346 | + if err != nil { | ||
| 347 | + return err | ||
| 348 | + } | ||
| 349 | + if flagJSON { | ||
| 350 | + return printJSON(cmd.OutOrStdout(), c) | ||
| 351 | + } | ||
| 352 | + if c.Type == "file" { | ||
| 353 | + fmt.Fprintf(cmd.OutOrStdout(), "%s is a file (%d bytes); use `rickub repo cat`.\n", c.Path, c.File.Size) | ||
| 354 | + return nil | ||
| 355 | + } | ||
| 356 | + if len(c.Entries) == 0 { | ||
| 357 | + fmt.Fprintln(cmd.OutOrStdout(), "(empty)") | ||
| 358 | + return nil | ||
| 359 | + } | ||
| 360 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 361 | + fmt.Fprintln(tw, "TYPE\tNAME\tSIZE") | ||
| 362 | + for _, e := range c.Entries { | ||
| 363 | + kind := "dir" | ||
| 364 | + size := "-" | ||
| 365 | + if e.Type == "blob" { | ||
| 366 | + kind = "file" | ||
| 367 | + size = fmt.Sprintf("%d", e.Size) | ||
| 368 | + } | ||
| 369 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", kind, e.Name, size) | ||
| 370 | + } | ||
| 371 | + tw.Flush() | ||
| 372 | + return nil | ||
| 373 | +} | ||
| 374 | + | ||
| 375 | +func runRepoCat(cmd *cobra.Command, args []string) error { | ||
| 376 | + client, err := newClient() | ||
| 377 | + if err != nil { | ||
| 378 | + return err | ||
| 379 | + } | ||
| 380 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 381 | + if err != nil { | ||
| 382 | + return err | ||
| 383 | + } | ||
| 384 | + ref := repoContentsRef | ||
| 385 | + if ref == "" { | ||
| 386 | + ref, err = defaultBranch(cmd, client, owner, repo) | ||
| 387 | + if err != nil { | ||
| 388 | + return err | ||
| 389 | + } | ||
| 390 | + } | ||
| 391 | + c, err := client.GetContents(cmd.Context(), owner, repo, ref, args[1]) | ||
| 392 | + if err != nil { | ||
| 393 | + return err | ||
| 394 | + } | ||
| 395 | + if c.Type != "file" || c.File == nil { | ||
| 396 | + return fmt.Errorf("%s is not a file", args[1]) | ||
| 397 | + } | ||
| 398 | + if flagJSON { | ||
| 399 | + return printJSON(cmd.OutOrStdout(), c) | ||
| 400 | + } | ||
| 401 | + out := cmd.OutOrStdout() | ||
| 402 | + if c.File.IsBinary { | ||
| 403 | + raw, derr := base64.StdEncoding.DecodeString(c.File.Content) | ||
| 404 | + if derr != nil { | ||
| 405 | + return fmt.Errorf("decode binary content: %w", derr) | ||
| 406 | + } | ||
| 407 | + _, err = out.Write(raw) | ||
| 408 | + return err | ||
| 409 | + } | ||
| 410 | + _, err = io.WriteString(out, c.File.Content) | ||
| 411 | + if err == nil && !strings.HasSuffix(c.File.Content, "\n") { | ||
| 412 | + fmt.Fprintln(out) | ||
| 413 | + } | ||
| 414 | + if c.File.Truncated { | ||
| 415 | + fmt.Fprintln(cmd.ErrOrStderr(), "(file truncated by the server)") | ||
| 416 | + } | ||
| 417 | + return err | ||
| 418 | +} | ||
| 419 | + | ||
| 420 | +func runRepoCommits(cmd *cobra.Command, args []string) error { | ||
| 421 | + client, err := newClient() | ||
| 422 | + if err != nil { | ||
| 423 | + return err | ||
| 424 | + } | ||
| 425 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 426 | + if err != nil { | ||
| 427 | + return err | ||
| 428 | + } | ||
| 429 | + ref := "" | ||
| 430 | + if len(args) == 2 { | ||
| 431 | + ref = args[1] | ||
| 432 | + } | ||
| 433 | + if ref == "" { | ||
| 434 | + ref, err = defaultBranch(cmd, client, owner, repo) | ||
| 435 | + if err != nil { | ||
| 436 | + return err | ||
| 437 | + } | ||
| 438 | + } | ||
| 439 | + page, err := client.GetCommits(cmd.Context(), owner, repo, ref, pageFlag, perPageFlag) | ||
| 440 | + if err != nil { | ||
| 441 | + return err | ||
| 442 | + } | ||
| 443 | + if flagJSON { | ||
| 444 | + return printJSON(cmd.OutOrStdout(), page) | ||
| 445 | + } | ||
| 446 | + if len(page.Items) == 0 { | ||
| 447 | + fmt.Fprintln(cmd.OutOrStdout(), "No commits.") | ||
| 448 | + return nil | ||
| 449 | + } | ||
| 450 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 451 | + fmt.Fprintln(tw, "SHA\tAUTHOR\tDATE\tSUBJECT") | ||
| 452 | + for _, c := range page.Items { | ||
| 453 | + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", c.Short, dash(c.Author), dash(c.Date), c.Subject) | ||
| 454 | + } | ||
| 455 | + tw.Flush() | ||
| 456 | + printPageFooter(cmd, page.Page) | ||
| 457 | + return nil | ||
| 458 | +} | ||
| 459 | + | ||
| 460 | +func runRepoCompare(cmd *cobra.Command, args []string) error { | ||
| 461 | + client, err := newClient() | ||
| 462 | + if err != nil { | ||
| 463 | + return err | ||
| 464 | + } | ||
| 465 | + owner, repo, err := parseOwnerRepo(args[0]) | ||
| 466 | + if err != nil { | ||
| 467 | + return err | ||
| 468 | + } | ||
| 469 | + cmp, err := client.Compare(cmd.Context(), owner, repo, args[1]) | ||
| 470 | + if err != nil { | ||
| 471 | + return err | ||
| 472 | + } | ||
| 473 | + if flagJSON { | ||
| 474 | + return printJSON(cmd.OutOrStdout(), cmp) | ||
| 475 | + } | ||
| 476 | + out := cmd.OutOrStdout() | ||
| 477 | + fmt.Fprintf(out, "merge-base: %s\n", cmp.MergeBase) | ||
| 478 | + fmt.Fprintf(out, "ahead by %d, behind by %d\n\n", cmp.AheadBy, cmp.BehindBy) | ||
| 479 | + if len(cmp.Commits) > 0 { | ||
| 480 | + tw := newTabw(out) | ||
| 481 | + fmt.Fprintln(tw, "SHA\tAUTHOR\tSUBJECT") | ||
| 482 | + for _, c := range cmp.Commits { | ||
| 483 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Short, dash(c.Author), c.Subject) | ||
| 484 | + } | ||
| 485 | + tw.Flush() | ||
| 486 | + } | ||
| 487 | + if len(cmp.Files) > 0 { | ||
| 488 | + fmt.Fprintf(out, "\n%d file(s) changed:\n", len(cmp.Files)) | ||
| 489 | + for _, f := range cmp.Files { | ||
| 490 | + fmt.Fprintf(out, " %s +%d -%d (%s)\n", f.Path, f.Additions, f.Deletions, f.Status) | ||
| 491 | + } | ||
| 492 | + } | ||
| 493 | + return nil | ||
| 494 | +} | ||
| 495 | + | ||
| 496 | +// defaultBranch resolves the repo's default branch via the refs endpoint, | ||
| 497 | +// falling back to the repo record. | ||
| 498 | +func defaultBranch(cmd *cobra.Command, client *api.Client, owner, repo string) (string, error) { | ||
| 499 | + refs, err := client.GetRefs(cmd.Context(), owner, repo) | ||
| 500 | + if err == nil && refs.DefaultBranch != "" { | ||
| 501 | + return refs.DefaultBranch, nil | ||
| 502 | + } | ||
| 503 | + r, rerr := client.GetRepo(cmd.Context(), owner, repo) | ||
| 504 | + if rerr != nil { | ||
| 505 | + if err != nil { | ||
| 506 | + return "", err | ||
| 507 | + } | ||
| 508 | + return "", rerr | ||
| 509 | + } | ||
| 510 | + if r.DefaultBranch == "" { | ||
| 511 | + return "", fmt.Errorf("could not determine default branch; pass --ref") | ||
| 512 | + } | ||
| 513 | + return r.DefaultBranch, nil | ||
| 514 | +} | ||
| 515 | + | ||
| 516 | +// hostFor returns the effective host from flags/env/config. | ||
| 517 | +func hostFor(cfg *config.Config) string { | ||
| 518 | + return config.ResolveHost(flagHost, cfg) | ||
| 519 | +} | ||
added
cmd/repo_test.go +203 -0 | new file mode 100644 | ||
| @@ -0,0 +1,203 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "encoding/json" | |
| 6 | + "net/http" | |
| 7 | + "net/http/httptest" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// execute runs the root command with args against a fresh output buffer, after | |
| 13 | +// resetting the shared flag globals so tests don't leak state into each other. | |
| 14 | +func execute(t *testing.T, args ...string) (string, string, error) { | |
| 15 | + t.Helper() | |
| 16 | + // Reset globals touched by these tests. | |
| 17 | + flagJSON = false | |
| 18 | + flagHost = "" | |
| 19 | + flagToken = "" | |
| 20 | + repoListUser = "" | |
| 21 | + repoListOrg = "" | |
| 22 | + pageFlag = 0 | |
| 23 | + perPageFlag = 0 | |
| 24 | + issueTitle = "" | |
| 25 | + issueBody = "" | |
| 26 | + issueRepo = "" | |
| 27 | + issueState = "open" | |
| 28 | + milestoneRepo = "" | |
| 29 | + milestoneState = "open" | |
| 30 | + milestoneTitle = "" | |
| 31 | + milestoneDue = "" | |
| 32 | + milestoneDescription = "" | |
| 33 | + runRepo = "" | |
| 34 | + runWatchInterval = "2s" | |
| 35 | + runWatchTimeout = "30m" | |
| 36 | + runWatchLogs = false | |
| 37 | + authLoginScope = "all" | |
| 38 | + authLoginNoBrowser = false | |
| 39 | + | |
| 40 | + var out, errBuf bytes.Buffer | |
| 41 | + rootCmd.SetOut(&out) | |
| 42 | + rootCmd.SetErr(&errBuf) | |
| 43 | + rootCmd.SetArgs(args) | |
| 44 | + err := rootCmd.Execute() | |
| 45 | + return out.String(), errBuf.String(), err | |
| 46 | +} | |
| 47 | + | |
| 48 | +func repoListServer(t *testing.T) *httptest.Server { | |
| 49 | + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 50 | + if r.URL.Path != "/api/v1/users/ricktester/repos" { | |
| 51 | + w.WriteHeader(http.StatusNotFound) | |
| 52 | + w.Write([]byte(`{"error":{"code":"not_found","message":"no"}}`)) | |
| 53 | + return | |
| 54 | + } | |
| 55 | + json.NewEncoder(w).Encode(map[string]any{ | |
| 56 | + "page": 1, "per_page": 30, "has_next": false, | |
| 57 | + "items": []map[string]any{ | |
| 58 | + {"full_name": "ricktester/alpha", "visibility": "public", "description": "first"}, | |
| 59 | + {"full_name": "ricktester/beta", "visibility": "private", "description": ""}, | |
| 60 | + }, | |
| 61 | + }) | |
| 62 | + })) | |
| 63 | +} | |
| 64 | + | |
| 65 | +func TestRepoListTable(t *testing.T) { | |
| 66 | + srv := repoListServer(t) | |
| 67 | + defer srv.Close() | |
| 68 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 69 | + t.Setenv("RICKUB_HOST", srv.URL) | |
| 70 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | |
| 71 | + | |
| 72 | + out, _, err := execute(t, "repo", "list", "--user", "ricktester") | |
| 73 | + if err != nil { | |
| 74 | + t.Fatalf("execute: %v", err) | |
| 75 | + } | |
| 76 | + if !strings.Contains(out, "NAME") || !strings.Contains(out, "VISIBILITY") { | |
| 77 | + t.Errorf("missing table header:\n%s", out) | |
| 78 | + } | |
| 79 | + if !strings.Contains(out, "ricktester/alpha") || !strings.Contains(out, "ricktester/beta") { | |
| 80 | + t.Errorf("missing rows:\n%s", out) | |
| 81 | + } | |
| 82 | + // Empty description rendered as a dash. | |
| 83 | + if !strings.Contains(out, "-") { | |
| 84 | + t.Errorf("expected dash for empty description:\n%s", out) | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +func TestRepoListJSONPassthrough(t *testing.T) { | |
| 89 | + srv := repoListServer(t) | |
| 90 | + defer srv.Close() | |
| 91 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 92 | + t.Setenv("RICKUB_HOST", srv.URL) | |
| 93 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | |
| 94 | + | |
| 95 | + out, _, err := execute(t, "repo", "list", "--user", "ricktester", "--json") | |
| 96 | + if err != nil { | |
| 97 | + t.Fatalf("execute: %v", err) | |
| 98 | + } | |
| 99 | + var decoded struct { | |
| 100 | + Items []struct { | |
| 101 | + FullName string `json:"full_name"` | |
| 102 | + } `json:"items"` | |
| 103 | + } | |
| 104 | + if err := json.Unmarshal([]byte(out), &decoded); err != nil { | |
| 105 | + t.Fatalf("output is not JSON: %v\n%s", err, out) | |
| 106 | + } | |
| 107 | + if len(decoded.Items) != 2 || decoded.Items[0].FullName != "ricktester/alpha" { | |
| 108 | + t.Errorf("unexpected JSON items: %+v", decoded.Items) | |
| 109 | + } | |
| 110 | +} | |
| 111 | + | |
| 112 | +func TestRepoViewNotFoundError(t *testing.T) { | |
| 113 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 114 | + w.WriteHeader(http.StatusNotFound) | |
| 115 | + w.Write([]byte(`{"error":{"code":"not_found","message":"repository not found"}}`)) | |
| 116 | + })) | |
| 117 | + defer srv.Close() | |
| 118 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 119 | + t.Setenv("RICKUB_HOST", srv.URL) | |
| 120 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | |
| 121 | + | |
| 122 | + _, _, err := execute(t, "repo", "view", "ghost/repo") | |
| 123 | + if err == nil { | |
| 124 | + t.Fatal("expected error for 404") | |
| 125 | + } | |
| 126 | + if !strings.Contains(err.Error(), "not_found") { | |
| 127 | + t.Errorf("error should surface code: %v", err) | |
| 128 | + } | |
| 129 | +} | |
| 130 | + | |
| 131 | +func TestHumanTime(t *testing.T) { | |
| 132 | + cases := map[string]string{ | |
| 133 | + "": "-", // empty -> dash | |
| 134 | + "2026-07-21T00:07:53.58474Z": "2026-07-21 00:07 UTC", // sub-second RFC3339 | |
| 135 | + "2026-07-21T00:07:53Z": "2026-07-21 00:07 UTC", // whole-second RFC3339 | |
| 136 | + "2026-07-21T02:07:53+02:00": "2026-07-21 00:07 UTC", // offset normalized to UTC | |
| 137 | + "not-a-timestamp": "not-a-timestamp", // unparseable -> unchanged | |
| 138 | + } | |
| 139 | + for in, want := range cases { | |
| 140 | + if got := humanTime(in); got != want { | |
| 141 | + t.Errorf("humanTime(%q) = %q, want %q", in, got, want) | |
| 142 | + } | |
| 143 | + } | |
| 144 | +} | |
| 145 | + | |
| 146 | +// TestRepoViewHumanizesTimestamp verifies the default output humanizes created_at | |
| 147 | +// while --json passes the raw timestamp through unchanged. | |
| 148 | +func TestRepoViewHumanizesTimestamp(t *testing.T) { | |
| 149 | + const raw = "2026-07-21T00:07:53.58474Z" | |
| 150 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 151 | + w.Header().Set("Content-Type", "application/json") | |
| 152 | + _, _ = w.Write([]byte(`{"owner":"ricktester","name":"demo","full_name":"ricktester/demo","visibility":"public","default_branch":"main","created_at":"` + raw + `"}`)) | |
| 153 | + })) | |
| 154 | + defer srv.Close() | |
| 155 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 156 | + t.Setenv("RICKUB_HOST", srv.URL) | |
| 157 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | |
| 158 | + | |
| 159 | + // Default (human) output: humanized, and NOT the raw nanosecond string. | |
| 160 | + out, _, err := execute(t, "repo", "view", "ricktester/demo") | |
| 161 | + if err != nil { | |
| 162 | + t.Fatalf("execute: %v", err) | |
| 163 | + } | |
| 164 | + if !strings.Contains(out, "2026-07-21 00:07 UTC") { | |
| 165 | + t.Errorf("human output not humanized:\n%s", out) | |
| 166 | + } | |
| 167 | + if strings.Contains(out, raw) { | |
| 168 | + t.Errorf("human output still contains the raw timestamp:\n%s", out) | |
| 169 | + } | |
| 170 | + | |
| 171 | + // --json output: raw timestamp preserved verbatim. | |
| 172 | + jsonOut, _, err := execute(t, "repo", "view", "ricktester/demo", "--json") | |
| 173 | + if err != nil { | |
| 174 | + t.Fatalf("execute --json: %v", err) | |
| 175 | + } | |
| 176 | + if !strings.Contains(jsonOut, raw) { | |
| 177 | + t.Errorf("--json output should keep the raw timestamp:\n%s", jsonOut) | |
| 178 | + } | |
| 179 | +} | |
| 180 | + | |
| 181 | +func TestParseOwnerRepo(t *testing.T) { | |
| 182 | + o, r, err := parseOwnerRepo("ricktester/demo.git") | |
| 183 | + if err != nil || o != "ricktester" || r != "demo" { | |
| 184 | + t.Errorf("got %q/%q err=%v", o, r, err) | |
| 185 | + } | |
| 186 | + if _, _, err := parseOwnerRepo("nope"); err == nil { | |
| 187 | + t.Error("expected error for missing slash") | |
| 188 | + } | |
| 189 | +} | |
| 190 | + | |
| 191 | +func TestOwnerRepoFromRemote(t *testing.T) { | |
| 192 | + cases := map[string][2]string{ | |
| 193 | + "http://localhost:8080/ricktester/demo.git": {"ricktester", "demo"}, | |
| 194 | + "ssh://git@localhost:2222/ricktester/demo": {"ricktester", "demo"}, | |
| 195 | + "git@rickub.com:acme/api.git": {"acme", "api"}, | |
| 196 | + } | |
| 197 | + for url, want := range cases { | |
| 198 | + o, r, err := ownerRepoFromRemote(url) | |
| 199 | + if err != nil || o != want[0] || r != want[1] { | |
| 200 | + t.Errorf("%s => %q/%q err=%v, want %v", url, o, r, err, want) | |
| 201 | + } | |
| 202 | + } | |
| 203 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,203 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "encoding/json" | ||
| 6 | + "net/http" | ||
| 7 | + "net/http/httptest" | ||
| 8 | + "strings" | ||
| 9 | + "testing" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +// execute runs the root command with args against a fresh output buffer, after | ||
| 13 | +// resetting the shared flag globals so tests don't leak state into each other. | ||
| 14 | +func execute(t *testing.T, args ...string) (string, string, error) { | ||
| 15 | + t.Helper() | ||
| 16 | + // Reset globals touched by these tests. | ||
| 17 | + flagJSON = false | ||
| 18 | + flagHost = "" | ||
| 19 | + flagToken = "" | ||
| 20 | + repoListUser = "" | ||
| 21 | + repoListOrg = "" | ||
| 22 | + pageFlag = 0 | ||
| 23 | + perPageFlag = 0 | ||
| 24 | + issueTitle = "" | ||
| 25 | + issueBody = "" | ||
| 26 | + issueRepo = "" | ||
| 27 | + issueState = "open" | ||
| 28 | + milestoneRepo = "" | ||
| 29 | + milestoneState = "open" | ||
| 30 | + milestoneTitle = "" | ||
| 31 | + milestoneDue = "" | ||
| 32 | + milestoneDescription = "" | ||
| 33 | + runRepo = "" | ||
| 34 | + runWatchInterval = "2s" | ||
| 35 | + runWatchTimeout = "30m" | ||
| 36 | + runWatchLogs = false | ||
| 37 | + authLoginScope = "all" | ||
| 38 | + authLoginNoBrowser = false | ||
| 39 | + | ||
| 40 | + var out, errBuf bytes.Buffer | ||
| 41 | + rootCmd.SetOut(&out) | ||
| 42 | + rootCmd.SetErr(&errBuf) | ||
| 43 | + rootCmd.SetArgs(args) | ||
| 44 | + err := rootCmd.Execute() | ||
| 45 | + return out.String(), errBuf.String(), err | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +func repoListServer(t *testing.T) *httptest.Server { | ||
| 49 | + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 50 | + if r.URL.Path != "/api/v1/users/ricktester/repos" { | ||
| 51 | + w.WriteHeader(http.StatusNotFound) | ||
| 52 | + w.Write([]byte(`{"error":{"code":"not_found","message":"no"}}`)) | ||
| 53 | + return | ||
| 54 | + } | ||
| 55 | + json.NewEncoder(w).Encode(map[string]any{ | ||
| 56 | + "page": 1, "per_page": 30, "has_next": false, | ||
| 57 | + "items": []map[string]any{ | ||
| 58 | + {"full_name": "ricktester/alpha", "visibility": "public", "description": "first"}, | ||
| 59 | + {"full_name": "ricktester/beta", "visibility": "private", "description": ""}, | ||
| 60 | + }, | ||
| 61 | + }) | ||
| 62 | + })) | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +func TestRepoListTable(t *testing.T) { | ||
| 66 | + srv := repoListServer(t) | ||
| 67 | + defer srv.Close() | ||
| 68 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 69 | + t.Setenv("RICKUB_HOST", srv.URL) | ||
| 70 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | ||
| 71 | + | ||
| 72 | + out, _, err := execute(t, "repo", "list", "--user", "ricktester") | ||
| 73 | + if err != nil { | ||
| 74 | + t.Fatalf("execute: %v", err) | ||
| 75 | + } | ||
| 76 | + if !strings.Contains(out, "NAME") || !strings.Contains(out, "VISIBILITY") { | ||
| 77 | + t.Errorf("missing table header:\n%s", out) | ||
| 78 | + } | ||
| 79 | + if !strings.Contains(out, "ricktester/alpha") || !strings.Contains(out, "ricktester/beta") { | ||
| 80 | + t.Errorf("missing rows:\n%s", out) | ||
| 81 | + } | ||
| 82 | + // Empty description rendered as a dash. | ||
| 83 | + if !strings.Contains(out, "-") { | ||
| 84 | + t.Errorf("expected dash for empty description:\n%s", out) | ||
| 85 | + } | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +func TestRepoListJSONPassthrough(t *testing.T) { | ||
| 89 | + srv := repoListServer(t) | ||
| 90 | + defer srv.Close() | ||
| 91 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 92 | + t.Setenv("RICKUB_HOST", srv.URL) | ||
| 93 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | ||
| 94 | + | ||
| 95 | + out, _, err := execute(t, "repo", "list", "--user", "ricktester", "--json") | ||
| 96 | + if err != nil { | ||
| 97 | + t.Fatalf("execute: %v", err) | ||
| 98 | + } | ||
| 99 | + var decoded struct { | ||
| 100 | + Items []struct { | ||
| 101 | + FullName string `json:"full_name"` | ||
| 102 | + } `json:"items"` | ||
| 103 | + } | ||
| 104 | + if err := json.Unmarshal([]byte(out), &decoded); err != nil { | ||
| 105 | + t.Fatalf("output is not JSON: %v\n%s", err, out) | ||
| 106 | + } | ||
| 107 | + if len(decoded.Items) != 2 || decoded.Items[0].FullName != "ricktester/alpha" { | ||
| 108 | + t.Errorf("unexpected JSON items: %+v", decoded.Items) | ||
| 109 | + } | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +func TestRepoViewNotFoundError(t *testing.T) { | ||
| 113 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 114 | + w.WriteHeader(http.StatusNotFound) | ||
| 115 | + w.Write([]byte(`{"error":{"code":"not_found","message":"repository not found"}}`)) | ||
| 116 | + })) | ||
| 117 | + defer srv.Close() | ||
| 118 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 119 | + t.Setenv("RICKUB_HOST", srv.URL) | ||
| 120 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | ||
| 121 | + | ||
| 122 | + _, _, err := execute(t, "repo", "view", "ghost/repo") | ||
| 123 | + if err == nil { | ||
| 124 | + t.Fatal("expected error for 404") | ||
| 125 | + } | ||
| 126 | + if !strings.Contains(err.Error(), "not_found") { | ||
| 127 | + t.Errorf("error should surface code: %v", err) | ||
| 128 | + } | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +func TestHumanTime(t *testing.T) { | ||
| 132 | + cases := map[string]string{ | ||
| 133 | + "": "-", // empty -> dash | ||
| 134 | + "2026-07-21T00:07:53.58474Z": "2026-07-21 00:07 UTC", // sub-second RFC3339 | ||
| 135 | + "2026-07-21T00:07:53Z": "2026-07-21 00:07 UTC", // whole-second RFC3339 | ||
| 136 | + "2026-07-21T02:07:53+02:00": "2026-07-21 00:07 UTC", // offset normalized to UTC | ||
| 137 | + "not-a-timestamp": "not-a-timestamp", // unparseable -> unchanged | ||
| 138 | + } | ||
| 139 | + for in, want := range cases { | ||
| 140 | + if got := humanTime(in); got != want { | ||
| 141 | + t.Errorf("humanTime(%q) = %q, want %q", in, got, want) | ||
| 142 | + } | ||
| 143 | + } | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +// TestRepoViewHumanizesTimestamp verifies the default output humanizes created_at | ||
| 147 | +// while --json passes the raw timestamp through unchanged. | ||
| 148 | +func TestRepoViewHumanizesTimestamp(t *testing.T) { | ||
| 149 | + const raw = "2026-07-21T00:07:53.58474Z" | ||
| 150 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 151 | + w.Header().Set("Content-Type", "application/json") | ||
| 152 | + _, _ = w.Write([]byte(`{"owner":"ricktester","name":"demo","full_name":"ricktester/demo","visibility":"public","default_branch":"main","created_at":"` + raw + `"}`)) | ||
| 153 | + })) | ||
| 154 | + defer srv.Close() | ||
| 155 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 156 | + t.Setenv("RICKUB_HOST", srv.URL) | ||
| 157 | + t.Setenv("RICKUB_TOKEN", "rickub_pat_test") | ||
| 158 | + | ||
| 159 | + // Default (human) output: humanized, and NOT the raw nanosecond string. | ||
| 160 | + out, _, err := execute(t, "repo", "view", "ricktester/demo") | ||
| 161 | + if err != nil { | ||
| 162 | + t.Fatalf("execute: %v", err) | ||
| 163 | + } | ||
| 164 | + if !strings.Contains(out, "2026-07-21 00:07 UTC") { | ||
| 165 | + t.Errorf("human output not humanized:\n%s", out) | ||
| 166 | + } | ||
| 167 | + if strings.Contains(out, raw) { | ||
| 168 | + t.Errorf("human output still contains the raw timestamp:\n%s", out) | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + // --json output: raw timestamp preserved verbatim. | ||
| 172 | + jsonOut, _, err := execute(t, "repo", "view", "ricktester/demo", "--json") | ||
| 173 | + if err != nil { | ||
| 174 | + t.Fatalf("execute --json: %v", err) | ||
| 175 | + } | ||
| 176 | + if !strings.Contains(jsonOut, raw) { | ||
| 177 | + t.Errorf("--json output should keep the raw timestamp:\n%s", jsonOut) | ||
| 178 | + } | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +func TestParseOwnerRepo(t *testing.T) { | ||
| 182 | + o, r, err := parseOwnerRepo("ricktester/demo.git") | ||
| 183 | + if err != nil || o != "ricktester" || r != "demo" { | ||
| 184 | + t.Errorf("got %q/%q err=%v", o, r, err) | ||
| 185 | + } | ||
| 186 | + if _, _, err := parseOwnerRepo("nope"); err == nil { | ||
| 187 | + t.Error("expected error for missing slash") | ||
| 188 | + } | ||
| 189 | +} | ||
| 190 | + | ||
| 191 | +func TestOwnerRepoFromRemote(t *testing.T) { | ||
| 192 | + cases := map[string][2]string{ | ||
| 193 | + "http://localhost:8080/ricktester/demo.git": {"ricktester", "demo"}, | ||
| 194 | + "ssh://git@localhost:2222/ricktester/demo": {"ricktester", "demo"}, | ||
| 195 | + "git@rickub.com:acme/api.git": {"acme", "api"}, | ||
| 196 | + } | ||
| 197 | + for url, want := range cases { | ||
| 198 | + o, r, err := ownerRepoFromRemote(url) | ||
| 199 | + if err != nil || o != want[0] || r != want[1] { | ||
| 200 | + t.Errorf("%s => %q/%q err=%v, want %v", url, o, r, err, want) | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | +} | ||
added
cmd/repospec.go +57 -0 | new file mode 100644 | ||
| @@ -0,0 +1,57 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "os/exec" | |
| 6 | + "regexp" | |
| 7 | + "strings" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// parseOwnerRepo splits "owner/repo" into its parts. | |
| 11 | +func parseOwnerRepo(s string) (owner, repo string, err error) { | |
| 12 | + s = strings.TrimSuffix(strings.TrimSpace(s), ".git") | |
| 13 | + parts := strings.Split(s, "/") | |
| 14 | + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { | |
| 15 | + return "", "", fmt.Errorf("expected owner/repo, got %q", s) | |
| 16 | + } | |
| 17 | + return parts[0], parts[1], nil | |
| 18 | +} | |
| 19 | + | |
| 20 | +// resolveRepo determines the target repo: an explicit --repo flag ("owner/repo") | |
| 21 | +// wins; otherwise it infers "owner/repo" from the git "origin" remote of the | |
| 22 | +// current directory. | |
| 23 | +func resolveRepo(flagRepo string) (owner, repo string, err error) { | |
| 24 | + if flagRepo != "" { | |
| 25 | + return parseOwnerRepo(flagRepo) | |
| 26 | + } | |
| 27 | + url, err := gitOriginURL() | |
| 28 | + if err != nil { | |
| 29 | + return "", "", fmt.Errorf("no --repo given and could not infer from git remote: %w", err) | |
| 30 | + } | |
| 31 | + o, r, err := ownerRepoFromRemote(url) | |
| 32 | + if err != nil { | |
| 33 | + return "", "", fmt.Errorf("could not parse owner/repo from remote %q: %w", url, err) | |
| 34 | + } | |
| 35 | + return o, r, nil | |
| 36 | +} | |
| 37 | + | |
| 38 | +func gitOriginURL() (string, error) { | |
| 39 | + out, err := exec.Command("git", "remote", "get-url", "origin").Output() | |
| 40 | + if err != nil { | |
| 41 | + return "", err | |
| 42 | + } | |
| 43 | + return strings.TrimSpace(string(out)), nil | |
| 44 | +} | |
| 45 | + | |
| 46 | +// remotePathRe captures the trailing owner/repo of a git remote URL, whether | |
| 47 | +// http(s)://host/owner/repo(.git), ssh://git@host:port/owner/repo(.git), or | |
| 48 | +// git@host:owner/repo(.git). | |
| 49 | +var remotePathRe = regexp.MustCompile(`[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$`) | |
| 50 | + | |
| 51 | +func ownerRepoFromRemote(url string) (owner, repo string, err error) { | |
| 52 | + m := remotePathRe.FindStringSubmatch(url) | |
| 53 | + if m == nil { | |
| 54 | + return "", "", fmt.Errorf("unrecognized remote URL") | |
| 55 | + } | |
| 56 | + return m[1], m[2], nil | |
| 57 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,57 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "os/exec" | ||
| 6 | + "regexp" | ||
| 7 | + "strings" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// parseOwnerRepo splits "owner/repo" into its parts. | ||
| 11 | +func parseOwnerRepo(s string) (owner, repo string, err error) { | ||
| 12 | + s = strings.TrimSuffix(strings.TrimSpace(s), ".git") | ||
| 13 | + parts := strings.Split(s, "/") | ||
| 14 | + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { | ||
| 15 | + return "", "", fmt.Errorf("expected owner/repo, got %q", s) | ||
| 16 | + } | ||
| 17 | + return parts[0], parts[1], nil | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +// resolveRepo determines the target repo: an explicit --repo flag ("owner/repo") | ||
| 21 | +// wins; otherwise it infers "owner/repo" from the git "origin" remote of the | ||
| 22 | +// current directory. | ||
| 23 | +func resolveRepo(flagRepo string) (owner, repo string, err error) { | ||
| 24 | + if flagRepo != "" { | ||
| 25 | + return parseOwnerRepo(flagRepo) | ||
| 26 | + } | ||
| 27 | + url, err := gitOriginURL() | ||
| 28 | + if err != nil { | ||
| 29 | + return "", "", fmt.Errorf("no --repo given and could not infer from git remote: %w", err) | ||
| 30 | + } | ||
| 31 | + o, r, err := ownerRepoFromRemote(url) | ||
| 32 | + if err != nil { | ||
| 33 | + return "", "", fmt.Errorf("could not parse owner/repo from remote %q: %w", url, err) | ||
| 34 | + } | ||
| 35 | + return o, r, nil | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +func gitOriginURL() (string, error) { | ||
| 39 | + out, err := exec.Command("git", "remote", "get-url", "origin").Output() | ||
| 40 | + if err != nil { | ||
| 41 | + return "", err | ||
| 42 | + } | ||
| 43 | + return strings.TrimSpace(string(out)), nil | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +// remotePathRe captures the trailing owner/repo of a git remote URL, whether | ||
| 47 | +// http(s)://host/owner/repo(.git), ssh://git@host:port/owner/repo(.git), or | ||
| 48 | +// git@host:owner/repo(.git). | ||
| 49 | +var remotePathRe = regexp.MustCompile(`[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$`) | ||
| 50 | + | ||
| 51 | +func ownerRepoFromRemote(url string) (owner, repo string, err error) { | ||
| 52 | + m := remotePathRe.FindStringSubmatch(url) | ||
| 53 | + if m == nil { | ||
| 54 | + return "", "", fmt.Errorf("unrecognized remote URL") | ||
| 55 | + } | ||
| 56 | + return m[1], m[2], nil | ||
| 57 | +} | ||
added
cmd/root.go +134 -0 | new file mode 100644 | ||
| @@ -0,0 +1,134 @@ | ||
| 1 | +// Package cmd defines the rickub CLI command tree (built on Cobra). | |
| 2 | +package cmd | |
| 3 | + | |
| 4 | +import ( | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "fmt" | |
| 8 | + "io" | |
| 9 | + "os" | |
| 10 | + "text/tabwriter" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "rickub.com/rickub/cli/internal/api" | |
| 14 | + "rickub.com/rickub/cli/internal/config" | |
| 15 | + | |
| 16 | + "github.com/spf13/cobra" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// Global flags, bound on the root command. | |
| 20 | +var ( | |
| 21 | + flagHost string | |
| 22 | + flagToken string | |
| 23 | + flagJSON bool | |
| 24 | +) | |
| 25 | + | |
| 26 | +// Version is stamped by main (overridable at build time). | |
| 27 | +var Version = "dev" | |
| 28 | + | |
| 29 | +// rootCmd is the base `rickub` command. | |
| 30 | +var rootCmd = &cobra.Command{ | |
| 31 | + Use: "rickub", | |
| 32 | + Short: "rickub — the smartest git in the universe, on the command line", | |
| 33 | + Long: `rickub is the command-line interface to a rickub git host. | |
| 34 | + | |
| 35 | +It talks to the rickub JSON API (/api/v1) with a personal access token. | |
| 36 | +Authenticate once with "rickub auth login", then drive repos, merge requests, | |
| 37 | +CI runs, orgs, and code browsing from your terminal.`, | |
| 38 | + SilenceUsage: true, | |
| 39 | + SilenceErrors: true, | |
| 40 | +} | |
| 41 | + | |
| 42 | +// Execute runs the root command. main() maps the returned error to an exit code. | |
| 43 | +func Execute() error { | |
| 44 | + return rootCmd.Execute() | |
| 45 | +} | |
| 46 | + | |
| 47 | +func init() { | |
| 48 | + pf := rootCmd.PersistentFlags() | |
| 49 | + pf.StringVar(&flagHost, "host", "", "API host (default https://rickub.com; or RICKUB_HOST)") | |
| 50 | + pf.StringVar(&flagToken, "token", "", "personal access token (or RICKUB_TOKEN)") | |
| 51 | + pf.BoolVar(&flagJSON, "json", false, "output raw JSON instead of a table") | |
| 52 | +} | |
| 53 | + | |
| 54 | +// loadConfig loads the persisted config (empty if none). | |
| 55 | +func loadConfig() (*config.Config, error) { | |
| 56 | + return config.Load() | |
| 57 | +} | |
| 58 | + | |
| 59 | +// newClient builds an authenticated API client from flags/env/config. It errors | |
| 60 | +// with a friendly message when no token is resolvable. | |
| 61 | +// | |
| 62 | +// The stored token is bound to the host it was minted for, so an unexpected | |
| 63 | +// --host / RICKUB_HOST does not get the production credential; when that is why | |
| 64 | +// no token was found, the error says so instead of claiming none is configured. | |
| 65 | +func newClient() (*api.Client, error) { | |
| 66 | + cfg, err := loadConfig() | |
| 67 | + if err != nil { | |
| 68 | + return nil, err | |
| 69 | + } | |
| 70 | + host := config.ResolveHost(flagHost, cfg) | |
| 71 | + token := config.ResolveToken(flagToken, cfg, host) | |
| 72 | + if token == "" { | |
| 73 | + if cfg.Host != "" && cfg.TokenFor(cfg.Host) != "" { | |
| 74 | + return nil, fmt.Errorf("no token stored for %s (the stored token belongs to %s); run `rickub auth login --host %s` or pass --token / set RICKUB_TOKEN", host, cfg.Host, host) | |
| 75 | + } | |
| 76 | + return nil, fmt.Errorf("no token configured; run `rickub auth login` or pass --token / set RICKUB_TOKEN") | |
| 77 | + } | |
| 78 | + config.WarnIfInsecure(os.Stderr, host) | |
| 79 | + return api.New(host, token), nil | |
| 80 | +} | |
| 81 | + | |
| 82 | +// ctx returns a background context (a place to add cancellation later). | |
| 83 | +func ctx() context.Context { return context.Background() } | |
| 84 | + | |
| 85 | +// ---- output helpers ---- | |
| 86 | + | |
| 87 | +// printJSON marshals v as indented JSON to the given writer. | |
| 88 | +func printJSON(w io.Writer, v any) error { | |
| 89 | + enc := json.NewEncoder(w) | |
| 90 | + enc.SetIndent("", " ") | |
| 91 | + enc.SetEscapeHTML(false) | |
| 92 | + return enc.Encode(v) | |
| 93 | +} | |
| 94 | + | |
| 95 | +// newTabw returns a tabwriter over the given writer configured for the CLI's | |
| 96 | +// two-space-padded column style. | |
| 97 | +func newTabw(w io.Writer) *tabwriter.Writer { | |
| 98 | + return tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | |
| 99 | +} | |
| 100 | + | |
| 101 | +// printPageFooter prints a pagination hint when more pages are available. | |
| 102 | +func printPageFooter(cmd *cobra.Command, p api.Page) { | |
| 103 | + if p.HasNext { | |
| 104 | + next := p.Page + 1 | |
| 105 | + if next < 2 { | |
| 106 | + next = 2 | |
| 107 | + } | |
| 108 | + fmt.Fprintf(cmd.ErrOrStderr(), "(more results — pass --page %d)\n", next) | |
| 109 | + } | |
| 110 | +} | |
| 111 | + | |
| 112 | +// dash renders empty strings as a dash for table cells. | |
| 113 | +func dash(s string) string { | |
| 114 | + if s == "" { | |
| 115 | + return "-" | |
| 116 | + } | |
| 117 | + return s | |
| 118 | +} | |
| 119 | + | |
| 120 | +// humanTime renders an API RFC3339 timestamp (often with sub-second precision, | |
| 121 | +// e.g. "2026-07-21T00:07:53.58474Z") in a friendlier form for the default, | |
| 122 | +// human-readable output: "2006-01-02 15:04 UTC". An empty value renders as a | |
| 123 | +// dash; an unparseable value is returned unchanged so we never hide data. This is | |
| 124 | +// used ONLY for human output — the --json path prints the raw struct untouched. | |
| 125 | +func humanTime(s string) string { | |
| 126 | + if s == "" { | |
| 127 | + return "-" | |
| 128 | + } | |
| 129 | + t, err := time.Parse(time.RFC3339, s) | |
| 130 | + if err != nil { | |
| 131 | + return s | |
| 132 | + } | |
| 133 | + return t.UTC().Format("2006-01-02 15:04 UTC") | |
| 134 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,134 @@ | |||
| 1 | +// Package cmd defines the rickub CLI command tree (built on Cobra). | ||
| 2 | +package cmd | ||
| 3 | + | ||
| 4 | +import ( | ||
| 5 | + "context" | ||
| 6 | + "encoding/json" | ||
| 7 | + "fmt" | ||
| 8 | + "io" | ||
| 9 | + "os" | ||
| 10 | + "text/tabwriter" | ||
| 11 | + "time" | ||
| 12 | + | ||
| 13 | + "rickub.com/rickub/cli/internal/api" | ||
| 14 | + "rickub.com/rickub/cli/internal/config" | ||
| 15 | + | ||
| 16 | + "github.com/spf13/cobra" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// Global flags, bound on the root command. | ||
| 20 | +var ( | ||
| 21 | + flagHost string | ||
| 22 | + flagToken string | ||
| 23 | + flagJSON bool | ||
| 24 | +) | ||
| 25 | + | ||
| 26 | +// Version is stamped by main (overridable at build time). | ||
| 27 | +var Version = "dev" | ||
| 28 | + | ||
| 29 | +// rootCmd is the base `rickub` command. | ||
| 30 | +var rootCmd = &cobra.Command{ | ||
| 31 | + Use: "rickub", | ||
| 32 | + Short: "rickub — the smartest git in the universe, on the command line", | ||
| 33 | + Long: `rickub is the command-line interface to a rickub git host. | ||
| 34 | + | ||
| 35 | +It talks to the rickub JSON API (/api/v1) with a personal access token. | ||
| 36 | +Authenticate once with "rickub auth login", then drive repos, merge requests, | ||
| 37 | +CI runs, orgs, and code browsing from your terminal.`, | ||
| 38 | + SilenceUsage: true, | ||
| 39 | + SilenceErrors: true, | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +// Execute runs the root command. main() maps the returned error to an exit code. | ||
| 43 | +func Execute() error { | ||
| 44 | + return rootCmd.Execute() | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +func init() { | ||
| 48 | + pf := rootCmd.PersistentFlags() | ||
| 49 | + pf.StringVar(&flagHost, "host", "", "API host (default https://rickub.com; or RICKUB_HOST)") | ||
| 50 | + pf.StringVar(&flagToken, "token", "", "personal access token (or RICKUB_TOKEN)") | ||
| 51 | + pf.BoolVar(&flagJSON, "json", false, "output raw JSON instead of a table") | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +// loadConfig loads the persisted config (empty if none). | ||
| 55 | +func loadConfig() (*config.Config, error) { | ||
| 56 | + return config.Load() | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// newClient builds an authenticated API client from flags/env/config. It errors | ||
| 60 | +// with a friendly message when no token is resolvable. | ||
| 61 | +// | ||
| 62 | +// The stored token is bound to the host it was minted for, so an unexpected | ||
| 63 | +// --host / RICKUB_HOST does not get the production credential; when that is why | ||
| 64 | +// no token was found, the error says so instead of claiming none is configured. | ||
| 65 | +func newClient() (*api.Client, error) { | ||
| 66 | + cfg, err := loadConfig() | ||
| 67 | + if err != nil { | ||
| 68 | + return nil, err | ||
| 69 | + } | ||
| 70 | + host := config.ResolveHost(flagHost, cfg) | ||
| 71 | + token := config.ResolveToken(flagToken, cfg, host) | ||
| 72 | + if token == "" { | ||
| 73 | + if cfg.Host != "" && cfg.TokenFor(cfg.Host) != "" { | ||
| 74 | + return nil, fmt.Errorf("no token stored for %s (the stored token belongs to %s); run `rickub auth login --host %s` or pass --token / set RICKUB_TOKEN", host, cfg.Host, host) | ||
| 75 | + } | ||
| 76 | + return nil, fmt.Errorf("no token configured; run `rickub auth login` or pass --token / set RICKUB_TOKEN") | ||
| 77 | + } | ||
| 78 | + config.WarnIfInsecure(os.Stderr, host) | ||
| 79 | + return api.New(host, token), nil | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +// ctx returns a background context (a place to add cancellation later). | ||
| 83 | +func ctx() context.Context { return context.Background() } | ||
| 84 | + | ||
| 85 | +// ---- output helpers ---- | ||
| 86 | + | ||
| 87 | +// printJSON marshals v as indented JSON to the given writer. | ||
| 88 | +func printJSON(w io.Writer, v any) error { | ||
| 89 | + enc := json.NewEncoder(w) | ||
| 90 | + enc.SetIndent("", " ") | ||
| 91 | + enc.SetEscapeHTML(false) | ||
| 92 | + return enc.Encode(v) | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +// newTabw returns a tabwriter over the given writer configured for the CLI's | ||
| 96 | +// two-space-padded column style. | ||
| 97 | +func newTabw(w io.Writer) *tabwriter.Writer { | ||
| 98 | + return tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +// printPageFooter prints a pagination hint when more pages are available. | ||
| 102 | +func printPageFooter(cmd *cobra.Command, p api.Page) { | ||
| 103 | + if p.HasNext { | ||
| 104 | + next := p.Page + 1 | ||
| 105 | + if next < 2 { | ||
| 106 | + next = 2 | ||
| 107 | + } | ||
| 108 | + fmt.Fprintf(cmd.ErrOrStderr(), "(more results — pass --page %d)\n", next) | ||
| 109 | + } | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +// dash renders empty strings as a dash for table cells. | ||
| 113 | +func dash(s string) string { | ||
| 114 | + if s == "" { | ||
| 115 | + return "-" | ||
| 116 | + } | ||
| 117 | + return s | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +// humanTime renders an API RFC3339 timestamp (often with sub-second precision, | ||
| 121 | +// e.g. "2026-07-21T00:07:53.58474Z") in a friendlier form for the default, | ||
| 122 | +// human-readable output: "2006-01-02 15:04 UTC". An empty value renders as a | ||
| 123 | +// dash; an unparseable value is returned unchanged so we never hide data. This is | ||
| 124 | +// used ONLY for human output — the --json path prints the raw struct untouched. | ||
| 125 | +func humanTime(s string) string { | ||
| 126 | + if s == "" { | ||
| 127 | + return "-" | ||
| 128 | + } | ||
| 129 | + t, err := time.Parse(time.RFC3339, s) | ||
| 130 | + if err != nil { | ||
| 131 | + return s | ||
| 132 | + } | ||
| 133 | + return t.UTC().Format("2006-01-02 15:04 UTC") | ||
| 134 | +} | ||
added
cmd/run.go +356 -0 | new file mode 100644 | ||
| @@ -0,0 +1,356 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strconv" | |
| 6 | + "strings" | |
| 7 | + "time" | |
| 8 | + | |
| 9 | + "rickub.com/rickub/cli/internal/api" | |
| 10 | + | |
| 11 | + "github.com/spf13/cobra" | |
| 12 | +) | |
| 13 | + | |
| 14 | +var ( | |
| 15 | + runRepo string | |
| 16 | + runDispatchRef string | |
| 17 | + runWatchInterval string | |
| 18 | + runWatchTimeout string | |
| 19 | + runWatchLogs bool | |
| 20 | +) | |
| 21 | + | |
| 22 | +func init() { | |
| 23 | + runCmd := &cobra.Command{ | |
| 24 | + Use: "run", | |
| 25 | + Short: "Work with CI (Actions) workflow runs", | |
| 26 | + } | |
| 27 | + runCmd.PersistentFlags().StringVarP(&runRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") | |
| 28 | + | |
| 29 | + listCmd := &cobra.Command{ | |
| 30 | + Use: "list", | |
| 31 | + Short: "List workflow runs", | |
| 32 | + Args: cobra.NoArgs, | |
| 33 | + RunE: runRunList, | |
| 34 | + } | |
| 35 | + addPaging(listCmd) | |
| 36 | + | |
| 37 | + viewCmd := &cobra.Command{ | |
| 38 | + Use: "view <number>", | |
| 39 | + Short: "Show a run with its jobs and steps", | |
| 40 | + Args: cobra.ExactArgs(1), | |
| 41 | + RunE: runRunView, | |
| 42 | + } | |
| 43 | + | |
| 44 | + logsCmd := &cobra.Command{ | |
| 45 | + Use: "logs <number>", | |
| 46 | + Short: "Print a run's accumulated logs", | |
| 47 | + Args: cobra.ExactArgs(1), | |
| 48 | + RunE: runRunLogs, | |
| 49 | + } | |
| 50 | + | |
| 51 | + rerunCmd := &cobra.Command{ | |
| 52 | + Use: "rerun <number>", | |
| 53 | + Short: "Re-run a finished run at the same commit", | |
| 54 | + Args: cobra.ExactArgs(1), | |
| 55 | + RunE: runRunRerun, | |
| 56 | + } | |
| 57 | + | |
| 58 | + cancelCmd := &cobra.Command{ | |
| 59 | + Use: "cancel <number>", | |
| 60 | + Short: "Cancel an in-flight run", | |
| 61 | + Args: cobra.ExactArgs(1), | |
| 62 | + RunE: runRunCancel, | |
| 63 | + } | |
| 64 | + | |
| 65 | + dispatchCmd := &cobra.Command{ | |
| 66 | + Use: "dispatch", | |
| 67 | + Short: "Trigger workflow_dispatch workflows", | |
| 68 | + Args: cobra.NoArgs, | |
| 69 | + RunE: runRunDispatch, | |
| 70 | + } | |
| 71 | + dispatchCmd.Flags().StringVar(&runDispatchRef, "ref", "", "branch to dispatch on (default: default branch)") | |
| 72 | + | |
| 73 | + watchCmd := &cobra.Command{ | |
| 74 | + Use: "watch <number>", | |
| 75 | + Short: "Follow a run until it finishes (exit 0 on success, 1 otherwise)", | |
| 76 | + Args: cobra.ExactArgs(1), | |
| 77 | + RunE: runRunWatch, | |
| 78 | + } | |
| 79 | + watchCmd.Flags().StringVar(&runWatchInterval, "interval", "2s", "poll interval (e.g. 2s, 5s)") | |
| 80 | + watchCmd.Flags().StringVar(&runWatchTimeout, "timeout", "30m", "give up after this duration") | |
| 81 | + watchCmd.Flags().BoolVar(&runWatchLogs, "logs", false, "print the accumulated logs when the run finishes") | |
| 82 | + | |
| 83 | + runCmd.AddCommand(listCmd, viewCmd, logsCmd, rerunCmd, cancelCmd, dispatchCmd, watchCmd) | |
| 84 | + rootCmd.AddCommand(runCmd) | |
| 85 | +} | |
| 86 | + | |
| 87 | +func runNumber(arg string) (int, error) { | |
| 88 | + n, err := strconv.Atoi(arg) | |
| 89 | + if err != nil || n <= 0 { | |
| 90 | + return 0, fmt.Errorf("invalid run number %q", arg) | |
| 91 | + } | |
| 92 | + return n, nil | |
| 93 | +} | |
| 94 | + | |
| 95 | +func runRunList(cmd *cobra.Command, _ []string) error { | |
| 96 | + client, err := newClient() | |
| 97 | + if err != nil { | |
| 98 | + return err | |
| 99 | + } | |
| 100 | + owner, repo, err := resolveRepo(runRepo) | |
| 101 | + if err != nil { | |
| 102 | + return err | |
| 103 | + } | |
| 104 | + page, err := client.ListRuns(cmd.Context(), owner, repo, pageFlag, perPageFlag) | |
| 105 | + if err != nil { | |
| 106 | + return err | |
| 107 | + } | |
| 108 | + if flagJSON { | |
| 109 | + return printJSON(cmd.OutOrStdout(), page) | |
| 110 | + } | |
| 111 | + if len(page.Items) == 0 { | |
| 112 | + fmt.Fprintln(cmd.OutOrStdout(), "No workflow runs.") | |
| 113 | + return nil | |
| 114 | + } | |
| 115 | + tw := newTabw(cmd.OutOrStdout()) | |
| 116 | + fmt.Fprintln(tw, "#\tWORKFLOW\tEVENT\tBRANCH\tSTATUS\tCOMMIT") | |
| 117 | + for _, r := range page.Items { | |
| 118 | + fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n", r.Number, dash(r.Workflow), dash(r.Event), dash(r.Branch), r.Status, short(r.HeadSHA)) | |
| 119 | + } | |
| 120 | + tw.Flush() | |
| 121 | + printPageFooter(cmd, page.Page) | |
| 122 | + return nil | |
| 123 | +} | |
| 124 | + | |
| 125 | +func short(sha string) string { | |
| 126 | + if len(sha) > 8 { | |
| 127 | + return sha[:8] | |
| 128 | + } | |
| 129 | + return dash(sha) | |
| 130 | +} | |
| 131 | + | |
| 132 | +func runRunView(cmd *cobra.Command, args []string) error { | |
| 133 | + client, err := newClient() | |
| 134 | + if err != nil { | |
| 135 | + return err | |
| 136 | + } | |
| 137 | + owner, repo, err := resolveRepo(runRepo) | |
| 138 | + if err != nil { | |
| 139 | + return err | |
| 140 | + } | |
| 141 | + n, err := runNumber(args[0]) | |
| 142 | + if err != nil { | |
| 143 | + return err | |
| 144 | + } | |
| 145 | + r, err := client.GetRun(cmd.Context(), owner, repo, n) | |
| 146 | + if err != nil { | |
| 147 | + return err | |
| 148 | + } | |
| 149 | + if flagJSON { | |
| 150 | + return printJSON(cmd.OutOrStdout(), r) | |
| 151 | + } | |
| 152 | + out := cmd.OutOrStdout() | |
| 153 | + fmt.Fprintf(out, "Run #%d %s [%s]\n", r.Number, dash(r.Workflow), r.Status) | |
| 154 | + fmt.Fprintf(out, "event: %s branch: %s commit: %s\n", dash(r.Event), dash(r.Branch), short(r.HeadSHA)) | |
| 155 | + if r.SecretsWithheld { | |
| 156 | + fmt.Fprintln(out, "note: secrets were withheld from this run") | |
| 157 | + } | |
| 158 | + for _, j := range r.Jobs { | |
| 159 | + fmt.Fprintf(out, "\nJob %s [%s]\n", j.Name, j.Status) | |
| 160 | + for _, s := range j.Steps { | |
| 161 | + fmt.Fprintf(out, " %2d. %-30s %s\n", s.Ordinal, s.Name, s.Status) | |
| 162 | + } | |
| 163 | + } | |
| 164 | + return nil | |
| 165 | +} | |
| 166 | + | |
| 167 | +func runRunLogs(cmd *cobra.Command, args []string) error { | |
| 168 | + client, err := newClient() | |
| 169 | + if err != nil { | |
| 170 | + return err | |
| 171 | + } | |
| 172 | + owner, repo, err := resolveRepo(runRepo) | |
| 173 | + if err != nil { | |
| 174 | + return err | |
| 175 | + } | |
| 176 | + n, err := runNumber(args[0]) | |
| 177 | + if err != nil { | |
| 178 | + return err | |
| 179 | + } | |
| 180 | + logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n) | |
| 181 | + if err != nil { | |
| 182 | + return err | |
| 183 | + } | |
| 184 | + if flagJSON { | |
| 185 | + return printJSON(cmd.OutOrStdout(), logs) | |
| 186 | + } | |
| 187 | + out := cmd.OutOrStdout() | |
| 188 | + fmt.Fprintf(out, "Run #%d [%s]\n", logs.Number, logs.Status) | |
| 189 | + for _, j := range logs.Jobs { | |
| 190 | + fmt.Fprintf(out, "\n===== job: %s [%s] =====\n", j.Name, j.Status) | |
| 191 | + fmt.Fprintln(out, j.Log) | |
| 192 | + } | |
| 193 | + return nil | |
| 194 | +} | |
| 195 | + | |
| 196 | +func runRunRerun(cmd *cobra.Command, args []string) error { | |
| 197 | + client, err := newClient() | |
| 198 | + if err != nil { | |
| 199 | + return err | |
| 200 | + } | |
| 201 | + owner, repo, err := resolveRepo(runRepo) | |
| 202 | + if err != nil { | |
| 203 | + return err | |
| 204 | + } | |
| 205 | + n, err := runNumber(args[0]) | |
| 206 | + if err != nil { | |
| 207 | + return err | |
| 208 | + } | |
| 209 | + r, err := client.RerunRun(cmd.Context(), owner, repo, n) | |
| 210 | + if err != nil { | |
| 211 | + return err | |
| 212 | + } | |
| 213 | + if flagJSON { | |
| 214 | + return printJSON(cmd.OutOrStdout(), r) | |
| 215 | + } | |
| 216 | + fmt.Fprintf(cmd.OutOrStdout(), "Re-ran; new run #%d [%s]\n", r.Number, r.Status) | |
| 217 | + return nil | |
| 218 | +} | |
| 219 | + | |
| 220 | +func runRunCancel(cmd *cobra.Command, args []string) error { | |
| 221 | + client, err := newClient() | |
| 222 | + if err != nil { | |
| 223 | + return err | |
| 224 | + } | |
| 225 | + owner, repo, err := resolveRepo(runRepo) | |
| 226 | + if err != nil { | |
| 227 | + return err | |
| 228 | + } | |
| 229 | + n, err := runNumber(args[0]) | |
| 230 | + if err != nil { | |
| 231 | + return err | |
| 232 | + } | |
| 233 | + r, err := client.CancelRun(cmd.Context(), owner, repo, n) | |
| 234 | + if err != nil { | |
| 235 | + return err | |
| 236 | + } | |
| 237 | + if flagJSON { | |
| 238 | + return printJSON(cmd.OutOrStdout(), r) | |
| 239 | + } | |
| 240 | + fmt.Fprintf(cmd.OutOrStdout(), "Cancelled run #%d [%s]\n", r.Number, r.Status) | |
| 241 | + return nil | |
| 242 | +} | |
| 243 | + | |
| 244 | +func runRunDispatch(cmd *cobra.Command, _ []string) error { | |
| 245 | + client, err := newClient() | |
| 246 | + if err != nil { | |
| 247 | + return err | |
| 248 | + } | |
| 249 | + owner, repo, err := resolveRepo(runRepo) | |
| 250 | + if err != nil { | |
| 251 | + return err | |
| 252 | + } | |
| 253 | + res, err := client.Dispatch(cmd.Context(), owner, repo, runDispatchRef) | |
| 254 | + if err != nil { | |
| 255 | + return err | |
| 256 | + } | |
| 257 | + if flagJSON { | |
| 258 | + return printJSON(cmd.OutOrStdout(), res) | |
| 259 | + } | |
| 260 | + fmt.Fprintf(cmd.OutOrStdout(), "Dispatched %d run(s).\n", res.Dispatched) | |
| 261 | + for _, r := range res.Runs { | |
| 262 | + fmt.Fprintf(cmd.OutOrStdout(), " #%d %s [%s]\n", r.Number, dash(r.Workflow), r.Status) | |
| 263 | + } | |
| 264 | + return nil | |
| 265 | +} | |
| 266 | + | |
| 267 | +// ---- watch ------------------------------------------------------------------- | |
| 268 | + | |
| 269 | +// runTerminalStatuses are the statuses a run never leaves. | |
| 270 | +var runTerminalStatuses = map[string]bool{"success": true, "failure": true, "cancelled": true} | |
| 271 | + | |
| 272 | +// runWatchExitError is returned (non-zero exit) when the watched run ends in a | |
| 273 | +// state other than success — CI-friendly for scripting. | |
| 274 | +type runWatchExitError struct{ status string } | |
| 275 | + | |
| 276 | +func (e *runWatchExitError) Error() string { return "run finished with status " + e.status } | |
| 277 | + | |
| 278 | +func runRunWatch(cmd *cobra.Command, args []string) error { | |
| 279 | + client, err := newClient() | |
| 280 | + if err != nil { | |
| 281 | + return err | |
| 282 | + } | |
| 283 | + owner, repo, err := resolveRepo(runRepo) | |
| 284 | + if err != nil { | |
| 285 | + return err | |
| 286 | + } | |
| 287 | + n, err := runNumber(args[0]) | |
| 288 | + if err != nil { | |
| 289 | + return err | |
| 290 | + } | |
| 291 | + interval, err := time.ParseDuration(runWatchInterval) | |
| 292 | + if err != nil || interval <= 0 { | |
| 293 | + return fmt.Errorf("invalid --interval %q", runWatchInterval) | |
| 294 | + } | |
| 295 | + timeout, err := time.ParseDuration(runWatchTimeout) | |
| 296 | + if err != nil || timeout <= 0 { | |
| 297 | + return fmt.Errorf("invalid --timeout %q", runWatchTimeout) | |
| 298 | + } | |
| 299 | + | |
| 300 | + out := cmd.OutOrStdout() | |
| 301 | + deadline := time.Now().Add(timeout) | |
| 302 | + var last string | |
| 303 | + tick := time.NewTicker(interval) | |
| 304 | + defer tick.Stop() | |
| 305 | + for { | |
| 306 | + r, err := client.GetRun(cmd.Context(), owner, repo, n) | |
| 307 | + if err != nil { | |
| 308 | + return err | |
| 309 | + } | |
| 310 | + if snap := runWatchSnapshot(r); snap != last { | |
| 311 | + last = snap | |
| 312 | + fmt.Fprintln(out, snap) | |
| 313 | + } | |
| 314 | + if runTerminalStatuses[r.Status] { | |
| 315 | + if runWatchLogs { | |
| 316 | + logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n) | |
| 317 | + if err != nil { | |
| 318 | + return err | |
| 319 | + } | |
| 320 | + for _, j := range logs.Jobs { | |
| 321 | + fmt.Fprintf(out, "\n===== job: %s [%s] =====\n%s\n", j.Name, j.Status, j.Log) | |
| 322 | + } | |
| 323 | + } | |
| 324 | + if flagJSON { | |
| 325 | + return printJSON(out, r) | |
| 326 | + } | |
| 327 | + fmt.Fprintf(out, "Run #%d finished: %s\n", r.Number, r.Status) | |
| 328 | + if r.Status != "success" { | |
| 329 | + return &runWatchExitError{status: r.Status} | |
| 330 | + } | |
| 331 | + return nil | |
| 332 | + } | |
| 333 | + if time.Now().After(deadline) { | |
| 334 | + return fmt.Errorf("timed out after %s; run #%d is still %s", runWatchTimeout, r.Number, r.Status) | |
| 335 | + } | |
| 336 | + select { | |
| 337 | + case <-cmd.Context().Done(): | |
| 338 | + return cmd.Context().Err() | |
| 339 | + case <-tick.C: | |
| 340 | + } | |
| 341 | + } | |
| 342 | +} | |
| 343 | + | |
| 344 | +// runWatchSnapshot renders one compact status line per poll, only when | |
| 345 | +// something changed: "#12 CI [running] · build:success · test:running". | |
| 346 | +func runWatchSnapshot(r *api.Run) string { | |
| 347 | + parts := make([]string, 0, len(r.Jobs)) | |
| 348 | + for _, j := range r.Jobs { | |
| 349 | + parts = append(parts, j.Name+":"+j.Status) | |
| 350 | + } | |
| 351 | + snap := fmt.Sprintf("#%d %s [%s]", r.Number, dash(r.Workflow), r.Status) | |
| 352 | + if len(parts) > 0 { | |
| 353 | + snap += " · " + strings.Join(parts, " · ") | |
| 354 | + } | |
| 355 | + return snap | |
| 356 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,356 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "strconv" | ||
| 6 | + "strings" | ||
| 7 | + "time" | ||
| 8 | + | ||
| 9 | + "rickub.com/rickub/cli/internal/api" | ||
| 10 | + | ||
| 11 | + "github.com/spf13/cobra" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +var ( | ||
| 15 | + runRepo string | ||
| 16 | + runDispatchRef string | ||
| 17 | + runWatchInterval string | ||
| 18 | + runWatchTimeout string | ||
| 19 | + runWatchLogs bool | ||
| 20 | +) | ||
| 21 | + | ||
| 22 | +func init() { | ||
| 23 | + runCmd := &cobra.Command{ | ||
| 24 | + Use: "run", | ||
| 25 | + Short: "Work with CI (Actions) workflow runs", | ||
| 26 | + } | ||
| 27 | + runCmd.PersistentFlags().StringVarP(&runRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") | ||
| 28 | + | ||
| 29 | + listCmd := &cobra.Command{ | ||
| 30 | + Use: "list", | ||
| 31 | + Short: "List workflow runs", | ||
| 32 | + Args: cobra.NoArgs, | ||
| 33 | + RunE: runRunList, | ||
| 34 | + } | ||
| 35 | + addPaging(listCmd) | ||
| 36 | + | ||
| 37 | + viewCmd := &cobra.Command{ | ||
| 38 | + Use: "view <number>", | ||
| 39 | + Short: "Show a run with its jobs and steps", | ||
| 40 | + Args: cobra.ExactArgs(1), | ||
| 41 | + RunE: runRunView, | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + logsCmd := &cobra.Command{ | ||
| 45 | + Use: "logs <number>", | ||
| 46 | + Short: "Print a run's accumulated logs", | ||
| 47 | + Args: cobra.ExactArgs(1), | ||
| 48 | + RunE: runRunLogs, | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + rerunCmd := &cobra.Command{ | ||
| 52 | + Use: "rerun <number>", | ||
| 53 | + Short: "Re-run a finished run at the same commit", | ||
| 54 | + Args: cobra.ExactArgs(1), | ||
| 55 | + RunE: runRunRerun, | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + cancelCmd := &cobra.Command{ | ||
| 59 | + Use: "cancel <number>", | ||
| 60 | + Short: "Cancel an in-flight run", | ||
| 61 | + Args: cobra.ExactArgs(1), | ||
| 62 | + RunE: runRunCancel, | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + dispatchCmd := &cobra.Command{ | ||
| 66 | + Use: "dispatch", | ||
| 67 | + Short: "Trigger workflow_dispatch workflows", | ||
| 68 | + Args: cobra.NoArgs, | ||
| 69 | + RunE: runRunDispatch, | ||
| 70 | + } | ||
| 71 | + dispatchCmd.Flags().StringVar(&runDispatchRef, "ref", "", "branch to dispatch on (default: default branch)") | ||
| 72 | + | ||
| 73 | + watchCmd := &cobra.Command{ | ||
| 74 | + Use: "watch <number>", | ||
| 75 | + Short: "Follow a run until it finishes (exit 0 on success, 1 otherwise)", | ||
| 76 | + Args: cobra.ExactArgs(1), | ||
| 77 | + RunE: runRunWatch, | ||
| 78 | + } | ||
| 79 | + watchCmd.Flags().StringVar(&runWatchInterval, "interval", "2s", "poll interval (e.g. 2s, 5s)") | ||
| 80 | + watchCmd.Flags().StringVar(&runWatchTimeout, "timeout", "30m", "give up after this duration") | ||
| 81 | + watchCmd.Flags().BoolVar(&runWatchLogs, "logs", false, "print the accumulated logs when the run finishes") | ||
| 82 | + | ||
| 83 | + runCmd.AddCommand(listCmd, viewCmd, logsCmd, rerunCmd, cancelCmd, dispatchCmd, watchCmd) | ||
| 84 | + rootCmd.AddCommand(runCmd) | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +func runNumber(arg string) (int, error) { | ||
| 88 | + n, err := strconv.Atoi(arg) | ||
| 89 | + if err != nil || n <= 0 { | ||
| 90 | + return 0, fmt.Errorf("invalid run number %q", arg) | ||
| 91 | + } | ||
| 92 | + return n, nil | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +func runRunList(cmd *cobra.Command, _ []string) error { | ||
| 96 | + client, err := newClient() | ||
| 97 | + if err != nil { | ||
| 98 | + return err | ||
| 99 | + } | ||
| 100 | + owner, repo, err := resolveRepo(runRepo) | ||
| 101 | + if err != nil { | ||
| 102 | + return err | ||
| 103 | + } | ||
| 104 | + page, err := client.ListRuns(cmd.Context(), owner, repo, pageFlag, perPageFlag) | ||
| 105 | + if err != nil { | ||
| 106 | + return err | ||
| 107 | + } | ||
| 108 | + if flagJSON { | ||
| 109 | + return printJSON(cmd.OutOrStdout(), page) | ||
| 110 | + } | ||
| 111 | + if len(page.Items) == 0 { | ||
| 112 | + fmt.Fprintln(cmd.OutOrStdout(), "No workflow runs.") | ||
| 113 | + return nil | ||
| 114 | + } | ||
| 115 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 116 | + fmt.Fprintln(tw, "#\tWORKFLOW\tEVENT\tBRANCH\tSTATUS\tCOMMIT") | ||
| 117 | + for _, r := range page.Items { | ||
| 118 | + fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n", r.Number, dash(r.Workflow), dash(r.Event), dash(r.Branch), r.Status, short(r.HeadSHA)) | ||
| 119 | + } | ||
| 120 | + tw.Flush() | ||
| 121 | + printPageFooter(cmd, page.Page) | ||
| 122 | + return nil | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +func short(sha string) string { | ||
| 126 | + if len(sha) > 8 { | ||
| 127 | + return sha[:8] | ||
| 128 | + } | ||
| 129 | + return dash(sha) | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +func runRunView(cmd *cobra.Command, args []string) error { | ||
| 133 | + client, err := newClient() | ||
| 134 | + if err != nil { | ||
| 135 | + return err | ||
| 136 | + } | ||
| 137 | + owner, repo, err := resolveRepo(runRepo) | ||
| 138 | + if err != nil { | ||
| 139 | + return err | ||
| 140 | + } | ||
| 141 | + n, err := runNumber(args[0]) | ||
| 142 | + if err != nil { | ||
| 143 | + return err | ||
| 144 | + } | ||
| 145 | + r, err := client.GetRun(cmd.Context(), owner, repo, n) | ||
| 146 | + if err != nil { | ||
| 147 | + return err | ||
| 148 | + } | ||
| 149 | + if flagJSON { | ||
| 150 | + return printJSON(cmd.OutOrStdout(), r) | ||
| 151 | + } | ||
| 152 | + out := cmd.OutOrStdout() | ||
| 153 | + fmt.Fprintf(out, "Run #%d %s [%s]\n", r.Number, dash(r.Workflow), r.Status) | ||
| 154 | + fmt.Fprintf(out, "event: %s branch: %s commit: %s\n", dash(r.Event), dash(r.Branch), short(r.HeadSHA)) | ||
| 155 | + if r.SecretsWithheld { | ||
| 156 | + fmt.Fprintln(out, "note: secrets were withheld from this run") | ||
| 157 | + } | ||
| 158 | + for _, j := range r.Jobs { | ||
| 159 | + fmt.Fprintf(out, "\nJob %s [%s]\n", j.Name, j.Status) | ||
| 160 | + for _, s := range j.Steps { | ||
| 161 | + fmt.Fprintf(out, " %2d. %-30s %s\n", s.Ordinal, s.Name, s.Status) | ||
| 162 | + } | ||
| 163 | + } | ||
| 164 | + return nil | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +func runRunLogs(cmd *cobra.Command, args []string) error { | ||
| 168 | + client, err := newClient() | ||
| 169 | + if err != nil { | ||
| 170 | + return err | ||
| 171 | + } | ||
| 172 | + owner, repo, err := resolveRepo(runRepo) | ||
| 173 | + if err != nil { | ||
| 174 | + return err | ||
| 175 | + } | ||
| 176 | + n, err := runNumber(args[0]) | ||
| 177 | + if err != nil { | ||
| 178 | + return err | ||
| 179 | + } | ||
| 180 | + logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n) | ||
| 181 | + if err != nil { | ||
| 182 | + return err | ||
| 183 | + } | ||
| 184 | + if flagJSON { | ||
| 185 | + return printJSON(cmd.OutOrStdout(), logs) | ||
| 186 | + } | ||
| 187 | + out := cmd.OutOrStdout() | ||
| 188 | + fmt.Fprintf(out, "Run #%d [%s]\n", logs.Number, logs.Status) | ||
| 189 | + for _, j := range logs.Jobs { | ||
| 190 | + fmt.Fprintf(out, "\n===== job: %s [%s] =====\n", j.Name, j.Status) | ||
| 191 | + fmt.Fprintln(out, j.Log) | ||
| 192 | + } | ||
| 193 | + return nil | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +func runRunRerun(cmd *cobra.Command, args []string) error { | ||
| 197 | + client, err := newClient() | ||
| 198 | + if err != nil { | ||
| 199 | + return err | ||
| 200 | + } | ||
| 201 | + owner, repo, err := resolveRepo(runRepo) | ||
| 202 | + if err != nil { | ||
| 203 | + return err | ||
| 204 | + } | ||
| 205 | + n, err := runNumber(args[0]) | ||
| 206 | + if err != nil { | ||
| 207 | + return err | ||
| 208 | + } | ||
| 209 | + r, err := client.RerunRun(cmd.Context(), owner, repo, n) | ||
| 210 | + if err != nil { | ||
| 211 | + return err | ||
| 212 | + } | ||
| 213 | + if flagJSON { | ||
| 214 | + return printJSON(cmd.OutOrStdout(), r) | ||
| 215 | + } | ||
| 216 | + fmt.Fprintf(cmd.OutOrStdout(), "Re-ran; new run #%d [%s]\n", r.Number, r.Status) | ||
| 217 | + return nil | ||
| 218 | +} | ||
| 219 | + | ||
| 220 | +func runRunCancel(cmd *cobra.Command, args []string) error { | ||
| 221 | + client, err := newClient() | ||
| 222 | + if err != nil { | ||
| 223 | + return err | ||
| 224 | + } | ||
| 225 | + owner, repo, err := resolveRepo(runRepo) | ||
| 226 | + if err != nil { | ||
| 227 | + return err | ||
| 228 | + } | ||
| 229 | + n, err := runNumber(args[0]) | ||
| 230 | + if err != nil { | ||
| 231 | + return err | ||
| 232 | + } | ||
| 233 | + r, err := client.CancelRun(cmd.Context(), owner, repo, n) | ||
| 234 | + if err != nil { | ||
| 235 | + return err | ||
| 236 | + } | ||
| 237 | + if flagJSON { | ||
| 238 | + return printJSON(cmd.OutOrStdout(), r) | ||
| 239 | + } | ||
| 240 | + fmt.Fprintf(cmd.OutOrStdout(), "Cancelled run #%d [%s]\n", r.Number, r.Status) | ||
| 241 | + return nil | ||
| 242 | +} | ||
| 243 | + | ||
| 244 | +func runRunDispatch(cmd *cobra.Command, _ []string) error { | ||
| 245 | + client, err := newClient() | ||
| 246 | + if err != nil { | ||
| 247 | + return err | ||
| 248 | + } | ||
| 249 | + owner, repo, err := resolveRepo(runRepo) | ||
| 250 | + if err != nil { | ||
| 251 | + return err | ||
| 252 | + } | ||
| 253 | + res, err := client.Dispatch(cmd.Context(), owner, repo, runDispatchRef) | ||
| 254 | + if err != nil { | ||
| 255 | + return err | ||
| 256 | + } | ||
| 257 | + if flagJSON { | ||
| 258 | + return printJSON(cmd.OutOrStdout(), res) | ||
| 259 | + } | ||
| 260 | + fmt.Fprintf(cmd.OutOrStdout(), "Dispatched %d run(s).\n", res.Dispatched) | ||
| 261 | + for _, r := range res.Runs { | ||
| 262 | + fmt.Fprintf(cmd.OutOrStdout(), " #%d %s [%s]\n", r.Number, dash(r.Workflow), r.Status) | ||
| 263 | + } | ||
| 264 | + return nil | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +// ---- watch ------------------------------------------------------------------- | ||
| 268 | + | ||
| 269 | +// runTerminalStatuses are the statuses a run never leaves. | ||
| 270 | +var runTerminalStatuses = map[string]bool{"success": true, "failure": true, "cancelled": true} | ||
| 271 | + | ||
| 272 | +// runWatchExitError is returned (non-zero exit) when the watched run ends in a | ||
| 273 | +// state other than success — CI-friendly for scripting. | ||
| 274 | +type runWatchExitError struct{ status string } | ||
| 275 | + | ||
| 276 | +func (e *runWatchExitError) Error() string { return "run finished with status " + e.status } | ||
| 277 | + | ||
| 278 | +func runRunWatch(cmd *cobra.Command, args []string) error { | ||
| 279 | + client, err := newClient() | ||
| 280 | + if err != nil { | ||
| 281 | + return err | ||
| 282 | + } | ||
| 283 | + owner, repo, err := resolveRepo(runRepo) | ||
| 284 | + if err != nil { | ||
| 285 | + return err | ||
| 286 | + } | ||
| 287 | + n, err := runNumber(args[0]) | ||
| 288 | + if err != nil { | ||
| 289 | + return err | ||
| 290 | + } | ||
| 291 | + interval, err := time.ParseDuration(runWatchInterval) | ||
| 292 | + if err != nil || interval <= 0 { | ||
| 293 | + return fmt.Errorf("invalid --interval %q", runWatchInterval) | ||
| 294 | + } | ||
| 295 | + timeout, err := time.ParseDuration(runWatchTimeout) | ||
| 296 | + if err != nil || timeout <= 0 { | ||
| 297 | + return fmt.Errorf("invalid --timeout %q", runWatchTimeout) | ||
| 298 | + } | ||
| 299 | + | ||
| 300 | + out := cmd.OutOrStdout() | ||
| 301 | + deadline := time.Now().Add(timeout) | ||
| 302 | + var last string | ||
| 303 | + tick := time.NewTicker(interval) | ||
| 304 | + defer tick.Stop() | ||
| 305 | + for { | ||
| 306 | + r, err := client.GetRun(cmd.Context(), owner, repo, n) | ||
| 307 | + if err != nil { | ||
| 308 | + return err | ||
| 309 | + } | ||
| 310 | + if snap := runWatchSnapshot(r); snap != last { | ||
| 311 | + last = snap | ||
| 312 | + fmt.Fprintln(out, snap) | ||
| 313 | + } | ||
| 314 | + if runTerminalStatuses[r.Status] { | ||
| 315 | + if runWatchLogs { | ||
| 316 | + logs, err := client.GetRunLogs(cmd.Context(), owner, repo, n) | ||
| 317 | + if err != nil { | ||
| 318 | + return err | ||
| 319 | + } | ||
| 320 | + for _, j := range logs.Jobs { | ||
| 321 | + fmt.Fprintf(out, "\n===== job: %s [%s] =====\n%s\n", j.Name, j.Status, j.Log) | ||
| 322 | + } | ||
| 323 | + } | ||
| 324 | + if flagJSON { | ||
| 325 | + return printJSON(out, r) | ||
| 326 | + } | ||
| 327 | + fmt.Fprintf(out, "Run #%d finished: %s\n", r.Number, r.Status) | ||
| 328 | + if r.Status != "success" { | ||
| 329 | + return &runWatchExitError{status: r.Status} | ||
| 330 | + } | ||
| 331 | + return nil | ||
| 332 | + } | ||
| 333 | + if time.Now().After(deadline) { | ||
| 334 | + return fmt.Errorf("timed out after %s; run #%d is still %s", runWatchTimeout, r.Number, r.Status) | ||
| 335 | + } | ||
| 336 | + select { | ||
| 337 | + case <-cmd.Context().Done(): | ||
| 338 | + return cmd.Context().Err() | ||
| 339 | + case <-tick.C: | ||
| 340 | + } | ||
| 341 | + } | ||
| 342 | +} | ||
| 343 | + | ||
| 344 | +// runWatchSnapshot renders one compact status line per poll, only when | ||
| 345 | +// something changed: "#12 CI [running] · build:success · test:running". | ||
| 346 | +func runWatchSnapshot(r *api.Run) string { | ||
| 347 | + parts := make([]string, 0, len(r.Jobs)) | ||
| 348 | + for _, j := range r.Jobs { | ||
| 349 | + parts = append(parts, j.Name+":"+j.Status) | ||
| 350 | + } | ||
| 351 | + snap := fmt.Sprintf("#%d %s [%s]", r.Number, dash(r.Workflow), r.Status) | ||
| 352 | + if len(parts) > 0 { | ||
| 353 | + snap += " · " + strings.Join(parts, " · ") | ||
| 354 | + } | ||
| 355 | + return snap | ||
| 356 | +} | ||
added
cmd/search.go +53 -0 | new file mode 100644 | ||
| @@ -0,0 +1,53 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strings" | |
| 6 | + | |
| 7 | + "github.com/spf13/cobra" | |
| 8 | +) | |
| 9 | + | |
| 10 | +func init() { | |
| 11 | + searchCmd := &cobra.Command{ | |
| 12 | + Use: "search", | |
| 13 | + Short: "Search rickub", | |
| 14 | + } | |
| 15 | + | |
| 16 | + reposCmd := &cobra.Command{ | |
| 17 | + Use: "repos <query>", | |
| 18 | + Short: "Search repositories", | |
| 19 | + Args: cobra.MinimumNArgs(1), | |
| 20 | + RunE: runSearchRepos, | |
| 21 | + } | |
| 22 | + addPaging(reposCmd) | |
| 23 | + | |
| 24 | + searchCmd.AddCommand(reposCmd) | |
| 25 | + rootCmd.AddCommand(searchCmd) | |
| 26 | +} | |
| 27 | + | |
| 28 | +func runSearchRepos(cmd *cobra.Command, args []string) error { | |
| 29 | + client, err := newClient() | |
| 30 | + if err != nil { | |
| 31 | + return err | |
| 32 | + } | |
| 33 | + q := strings.Join(args, " ") | |
| 34 | + page, err := client.SearchRepos(cmd.Context(), q, pageFlag, perPageFlag) | |
| 35 | + if err != nil { | |
| 36 | + return err | |
| 37 | + } | |
| 38 | + if flagJSON { | |
| 39 | + return printJSON(cmd.OutOrStdout(), page) | |
| 40 | + } | |
| 41 | + if len(page.Items) == 0 { | |
| 42 | + fmt.Fprintln(cmd.OutOrStdout(), "No repositories matched.") | |
| 43 | + return nil | |
| 44 | + } | |
| 45 | + tw := newTabw(cmd.OutOrStdout()) | |
| 46 | + fmt.Fprintln(tw, "NAME\tVISIBILITY\tDESCRIPTION") | |
| 47 | + for _, r := range page.Items { | |
| 48 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", r.FullName, r.Visibility, dash(r.Description)) | |
| 49 | + } | |
| 50 | + tw.Flush() | |
| 51 | + printPageFooter(cmd, page.Page) | |
| 52 | + return nil | |
| 53 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "strings" | ||
| 6 | + | ||
| 7 | + "github.com/spf13/cobra" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +func init() { | ||
| 11 | + searchCmd := &cobra.Command{ | ||
| 12 | + Use: "search", | ||
| 13 | + Short: "Search rickub", | ||
| 14 | + } | ||
| 15 | + | ||
| 16 | + reposCmd := &cobra.Command{ | ||
| 17 | + Use: "repos <query>", | ||
| 18 | + Short: "Search repositories", | ||
| 19 | + Args: cobra.MinimumNArgs(1), | ||
| 20 | + RunE: runSearchRepos, | ||
| 21 | + } | ||
| 22 | + addPaging(reposCmd) | ||
| 23 | + | ||
| 24 | + searchCmd.AddCommand(reposCmd) | ||
| 25 | + rootCmd.AddCommand(searchCmd) | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +func runSearchRepos(cmd *cobra.Command, args []string) error { | ||
| 29 | + client, err := newClient() | ||
| 30 | + if err != nil { | ||
| 31 | + return err | ||
| 32 | + } | ||
| 33 | + q := strings.Join(args, " ") | ||
| 34 | + page, err := client.SearchRepos(cmd.Context(), q, pageFlag, perPageFlag) | ||
| 35 | + if err != nil { | ||
| 36 | + return err | ||
| 37 | + } | ||
| 38 | + if flagJSON { | ||
| 39 | + return printJSON(cmd.OutOrStdout(), page) | ||
| 40 | + } | ||
| 41 | + if len(page.Items) == 0 { | ||
| 42 | + fmt.Fprintln(cmd.OutOrStdout(), "No repositories matched.") | ||
| 43 | + return nil | ||
| 44 | + } | ||
| 45 | + tw := newTabw(cmd.OutOrStdout()) | ||
| 46 | + fmt.Fprintln(tw, "NAME\tVISIBILITY\tDESCRIPTION") | ||
| 47 | + for _, r := range page.Items { | ||
| 48 | + fmt.Fprintf(tw, "%s\t%s\t%s\n", r.FullName, r.Visibility, dash(r.Description)) | ||
| 49 | + } | ||
| 50 | + tw.Flush() | ||
| 51 | + printPageFooter(cmd, page.Page) | ||
| 52 | + return nil | ||
| 53 | +} | ||
added
cmd/version.go +21 -0 | new file mode 100644 | ||
| @@ -0,0 +1,21 @@ | ||
| 1 | +package cmd | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "runtime" | |
| 6 | + | |
| 7 | + "github.com/spf13/cobra" | |
| 8 | +) | |
| 9 | + | |
| 10 | +func init() { | |
| 11 | + versionCmd := &cobra.Command{ | |
| 12 | + Use: "version", | |
| 13 | + Short: "Print the rickub CLI version", | |
| 14 | + Args: cobra.NoArgs, | |
| 15 | + Run: func(cmd *cobra.Command, _ []string) { | |
| 16 | + fmt.Fprintf(cmd.OutOrStdout(), "rickub %s (%s/%s)\n", Version, runtime.GOOS, runtime.GOARCH) | |
| 17 | + }, | |
| 18 | + } | |
| 19 | + rootCmd.AddCommand(versionCmd) | |
| 20 | + rootCmd.Version = Version | |
| 21 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | +package cmd | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "runtime" | ||
| 6 | + | ||
| 7 | + "github.com/spf13/cobra" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +func init() { | ||
| 11 | + versionCmd := &cobra.Command{ | ||
| 12 | + Use: "version", | ||
| 13 | + Short: "Print the rickub CLI version", | ||
| 14 | + Args: cobra.NoArgs, | ||
| 15 | + Run: func(cmd *cobra.Command, _ []string) { | ||
| 16 | + fmt.Fprintf(cmd.OutOrStdout(), "rickub %s (%s/%s)\n", Version, runtime.GOOS, runtime.GOARCH) | ||
| 17 | + }, | ||
| 18 | + } | ||
| 19 | + rootCmd.AddCommand(versionCmd) | ||
| 20 | + rootCmd.Version = Version | ||
| 21 | +} | ||
added
go.mod +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +module rickub.com/rickub/cli | |
| 2 | + | |
| 3 | +go 1.26 | |
| 4 | + | |
| 5 | +require ( | |
| 6 | + github.com/spf13/cobra v1.10.2 | |
| 7 | + gopkg.in/yaml.v3 v3.0.1 | |
| 8 | +) | |
| 9 | + | |
| 10 | +require ( | |
| 11 | + github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 12 | + github.com/spf13/pflag v1.0.9 // indirect | |
| 13 | +) | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +module rickub.com/rickub/cli | ||
| 2 | + | ||
| 3 | +go 1.26 | ||
| 4 | + | ||
| 5 | +require ( | ||
| 6 | + github.com/spf13/cobra v1.10.2 | ||
| 7 | + gopkg.in/yaml.v3 v3.0.1 | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +require ( | ||
| 11 | + github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 12 | + github.com/spf13/pflag v1.0.9 // indirect | ||
| 13 | +) | ||
added
go.sum +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | |
| 2 | +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | |
| 3 | +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | |
| 4 | +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | |
| 5 | +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= | |
| 6 | +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= | |
| 7 | +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= | |
| 8 | +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | |
| 9 | +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | |
| 10 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | |
| 11 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | |
| 12 | +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | |
| 13 | +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | ||
| 2 | +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | ||
| 3 | +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | ||
| 4 | +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | ||
| 5 | +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= | ||
| 6 | +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= | ||
| 7 | +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= | ||
| 8 | +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||
| 9 | +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | ||
| 10 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | ||
| 11 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
| 12 | +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
| 13 | +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||
added
internal/api/client.go +182 -0 | new file mode 100644 | ||
| @@ -0,0 +1,182 @@ | ||
| 1 | +// Package api is a thin, standalone HTTP client for the rickub JSON API | |
| 2 | +// (`/api/v1`), hand-written against the API's published OpenAPI description. It | |
| 3 | +// imports none of the server's packages: the CLI is a pure client with a clean | |
| 4 | +// dependency boundary. | |
| 5 | +package api | |
| 6 | + | |
| 7 | +import ( | |
| 8 | + "bytes" | |
| 9 | + "context" | |
| 10 | + "encoding/json" | |
| 11 | + "fmt" | |
| 12 | + "io" | |
| 13 | + "net/http" | |
| 14 | + "net/url" | |
| 15 | + "strconv" | |
| 16 | + "strings" | |
| 17 | + "time" | |
| 18 | +) | |
| 19 | + | |
| 20 | +// Client talks to a rickub API host with a bearer PAT. | |
| 21 | +type Client struct { | |
| 22 | + Host string // e.g. https://rickub.com (no trailing slash) | |
| 23 | + Token string // rickub_pat_… | |
| 24 | + HTTPClient *http.Client | |
| 25 | + // UserAgent is sent on every request. | |
| 26 | + UserAgent string | |
| 27 | +} | |
| 28 | + | |
| 29 | +// New builds a Client with a sane default HTTP client. | |
| 30 | +func New(host, token string) *Client { | |
| 31 | + return &Client{ | |
| 32 | + Host: strings.TrimRight(host, "/"), | |
| 33 | + Token: token, | |
| 34 | + HTTPClient: &http.Client{Timeout: 30 * time.Second}, | |
| 35 | + UserAgent: "rickub-cli", | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +// APIError is the parsed `{error:{code,message}}` envelope plus HTTP status. | |
| 40 | +type APIError struct { | |
| 41 | + Status int | |
| 42 | + Code string | |
| 43 | + Message string | |
| 44 | +} | |
| 45 | + | |
| 46 | +func (e *APIError) Error() string { | |
| 47 | + if e.Code != "" { | |
| 48 | + return fmt.Sprintf("%s (%s)", e.Message, e.Code) | |
| 49 | + } | |
| 50 | + if e.Message != "" { | |
| 51 | + return e.Message | |
| 52 | + } | |
| 53 | + return fmt.Sprintf("HTTP %d", e.Status) | |
| 54 | +} | |
| 55 | + | |
| 56 | +type errorEnvelope struct { | |
| 57 | + Error struct { | |
| 58 | + Code string `json:"code"` | |
| 59 | + Message string `json:"message"` | |
| 60 | + } `json:"error"` | |
| 61 | +} | |
| 62 | + | |
| 63 | +// Request is a low-level typed request description. | |
| 64 | +type Request struct { | |
| 65 | + Method string | |
| 66 | + // Path is the API path WITHOUT the /api/v1 prefix, e.g. "/repos/o/r". | |
| 67 | + // Each segment must already be escaped by the caller where needed. | |
| 68 | + Path string | |
| 69 | + Query url.Values | |
| 70 | + // Body, if non-nil, is JSON-encoded. | |
| 71 | + Body any | |
| 72 | + // Accept overrides the Accept header (default application/json). | |
| 73 | + Accept string | |
| 74 | +} | |
| 75 | + | |
| 76 | +// raw performs the request and returns status, body bytes, and content-type. | |
| 77 | +// A non-2xx response is decoded into an *APIError. | |
| 78 | +func (c *Client) raw(ctx context.Context, r Request) (int, []byte, string, error) { | |
| 79 | + u := c.Host + "/api/v1" + r.Path | |
| 80 | + if len(r.Query) > 0 { | |
| 81 | + u += "?" + r.Query.Encode() | |
| 82 | + } | |
| 83 | + | |
| 84 | + var body io.Reader | |
| 85 | + if r.Body != nil { | |
| 86 | + b, err := json.Marshal(r.Body) | |
| 87 | + if err != nil { | |
| 88 | + return 0, nil, "", err | |
| 89 | + } | |
| 90 | + body = bytes.NewReader(b) | |
| 91 | + } | |
| 92 | + | |
| 93 | + req, err := http.NewRequestWithContext(ctx, r.Method, u, body) | |
| 94 | + if err != nil { | |
| 95 | + return 0, nil, "", err | |
| 96 | + } | |
| 97 | + if c.Token != "" { | |
| 98 | + req.Header.Set("Authorization", "Bearer "+c.Token) | |
| 99 | + } | |
| 100 | + accept := r.Accept | |
| 101 | + if accept == "" { | |
| 102 | + accept = "application/json" | |
| 103 | + } | |
| 104 | + req.Header.Set("Accept", accept) | |
| 105 | + if r.Body != nil { | |
| 106 | + req.Header.Set("Content-Type", "application/json") | |
| 107 | + } | |
| 108 | + req.Header.Set("User-Agent", c.UserAgent) | |
| 109 | + | |
| 110 | + resp, err := c.HTTPClient.Do(req) | |
| 111 | + if err != nil { | |
| 112 | + return 0, nil, "", err | |
| 113 | + } | |
| 114 | + defer resp.Body.Close() | |
| 115 | + | |
| 116 | + data, err := io.ReadAll(resp.Body) | |
| 117 | + if err != nil { | |
| 118 | + return resp.StatusCode, nil, resp.Header.Get("Content-Type"), err | |
| 119 | + } | |
| 120 | + | |
| 121 | + if resp.StatusCode >= 200 && resp.StatusCode < 300 { | |
| 122 | + return resp.StatusCode, data, resp.Header.Get("Content-Type"), nil | |
| 123 | + } | |
| 124 | + | |
| 125 | + // Attempt to decode the standard error envelope. | |
| 126 | + apiErr := &APIError{Status: resp.StatusCode} | |
| 127 | + var env errorEnvelope | |
| 128 | + if json.Unmarshal(data, &env) == nil && env.Error.Code != "" { | |
| 129 | + apiErr.Code = env.Error.Code | |
| 130 | + apiErr.Message = env.Error.Message | |
| 131 | + } else { | |
| 132 | + apiErr.Message = strings.TrimSpace(string(data)) | |
| 133 | + if apiErr.Message == "" { | |
| 134 | + apiErr.Message = http.StatusText(resp.StatusCode) | |
| 135 | + } | |
| 136 | + } | |
| 137 | + return resp.StatusCode, data, resp.Header.Get("Content-Type"), apiErr | |
| 138 | +} | |
| 139 | + | |
| 140 | +// do performs the request and unmarshals a JSON success body into out (which | |
| 141 | +// may be nil for empty 204 responses). | |
| 142 | +func (c *Client) do(ctx context.Context, r Request, out any) error { | |
| 143 | + _, data, _, err := c.raw(ctx, r) | |
| 144 | + if err != nil { | |
| 145 | + return err | |
| 146 | + } | |
| 147 | + if out == nil || len(bytes.TrimSpace(data)) == 0 { | |
| 148 | + return nil | |
| 149 | + } | |
| 150 | + return json.Unmarshal(data, out) | |
| 151 | +} | |
| 152 | + | |
| 153 | +// RawJSON performs a request and returns the raw (success) response body. Used | |
| 154 | +// by the `rickub api` escape hatch and `--json` passthrough. | |
| 155 | +func (c *Client) RawJSON(ctx context.Context, method, path string, query url.Values, body any) ([]byte, string, error) { | |
| 156 | + _, data, ct, err := c.raw(ctx, Request{Method: method, Path: path, Query: query, Body: body}) | |
| 157 | + return data, ct, err | |
| 158 | +} | |
| 159 | + | |
| 160 | +// RawText performs a GET requesting text/plain and returns the decoded body. | |
| 161 | +func (c *Client) RawText(ctx context.Context, path string, query url.Values) ([]byte, error) { | |
| 162 | + _, data, _, err := c.raw(ctx, Request{Method: http.MethodGet, Path: path, Query: query, Accept: "text/plain"}) | |
| 163 | + return data, err | |
| 164 | +} | |
| 165 | + | |
| 166 | +// itoa is a small helper for building integer path segments. | |
| 167 | +func itoa(n int) string { return strconv.Itoa(n) } | |
| 168 | + | |
| 169 | +// pageQuery builds a ?page/?per_page query, omitting zero values. | |
| 170 | +func pageQuery(page, perPage int, extra url.Values) url.Values { | |
| 171 | + q := url.Values{} | |
| 172 | + for k, v := range extra { | |
| 173 | + q[k] = v | |
| 174 | + } | |
| 175 | + if page > 0 { | |
| 176 | + q.Set("page", strconv.Itoa(page)) | |
| 177 | + } | |
| 178 | + if perPage > 0 { | |
| 179 | + q.Set("per_page", strconv.Itoa(perPage)) | |
| 180 | + } | |
| 181 | + return q | |
| 182 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,182 @@ | |||
| 1 | +// Package api is a thin, standalone HTTP client for the rickub JSON API | ||
| 2 | +// (`/api/v1`), hand-written against the API's published OpenAPI description. It | ||
| 3 | +// imports none of the server's packages: the CLI is a pure client with a clean | ||
| 4 | +// dependency boundary. | ||
| 5 | +package api | ||
| 6 | + | ||
| 7 | +import ( | ||
| 8 | + "bytes" | ||
| 9 | + "context" | ||
| 10 | + "encoding/json" | ||
| 11 | + "fmt" | ||
| 12 | + "io" | ||
| 13 | + "net/http" | ||
| 14 | + "net/url" | ||
| 15 | + "strconv" | ||
| 16 | + "strings" | ||
| 17 | + "time" | ||
| 18 | +) | ||
| 19 | + | ||
| 20 | +// Client talks to a rickub API host with a bearer PAT. | ||
| 21 | +type Client struct { | ||
| 22 | + Host string // e.g. https://rickub.com (no trailing slash) | ||
| 23 | + Token string // rickub_pat_… | ||
| 24 | + HTTPClient *http.Client | ||
| 25 | + // UserAgent is sent on every request. | ||
| 26 | + UserAgent string | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +// New builds a Client with a sane default HTTP client. | ||
| 30 | +func New(host, token string) *Client { | ||
| 31 | + return &Client{ | ||
| 32 | + Host: strings.TrimRight(host, "/"), | ||
| 33 | + Token: token, | ||
| 34 | + HTTPClient: &http.Client{Timeout: 30 * time.Second}, | ||
| 35 | + UserAgent: "rickub-cli", | ||
| 36 | + } | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +// APIError is the parsed `{error:{code,message}}` envelope plus HTTP status. | ||
| 40 | +type APIError struct { | ||
| 41 | + Status int | ||
| 42 | + Code string | ||
| 43 | + Message string | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +func (e *APIError) Error() string { | ||
| 47 | + if e.Code != "" { | ||
| 48 | + return fmt.Sprintf("%s (%s)", e.Message, e.Code) | ||
| 49 | + } | ||
| 50 | + if e.Message != "" { | ||
| 51 | + return e.Message | ||
| 52 | + } | ||
| 53 | + return fmt.Sprintf("HTTP %d", e.Status) | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +type errorEnvelope struct { | ||
| 57 | + Error struct { | ||
| 58 | + Code string `json:"code"` | ||
| 59 | + Message string `json:"message"` | ||
| 60 | + } `json:"error"` | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +// Request is a low-level typed request description. | ||
| 64 | +type Request struct { | ||
| 65 | + Method string | ||
| 66 | + // Path is the API path WITHOUT the /api/v1 prefix, e.g. "/repos/o/r". | ||
| 67 | + // Each segment must already be escaped by the caller where needed. | ||
| 68 | + Path string | ||
| 69 | + Query url.Values | ||
| 70 | + // Body, if non-nil, is JSON-encoded. | ||
| 71 | + Body any | ||
| 72 | + // Accept overrides the Accept header (default application/json). | ||
| 73 | + Accept string | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +// raw performs the request and returns status, body bytes, and content-type. | ||
| 77 | +// A non-2xx response is decoded into an *APIError. | ||
| 78 | +func (c *Client) raw(ctx context.Context, r Request) (int, []byte, string, error) { | ||
| 79 | + u := c.Host + "/api/v1" + r.Path | ||
| 80 | + if len(r.Query) > 0 { | ||
| 81 | + u += "?" + r.Query.Encode() | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + var body io.Reader | ||
| 85 | + if r.Body != nil { | ||
| 86 | + b, err := json.Marshal(r.Body) | ||
| 87 | + if err != nil { | ||
| 88 | + return 0, nil, "", err | ||
| 89 | + } | ||
| 90 | + body = bytes.NewReader(b) | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + req, err := http.NewRequestWithContext(ctx, r.Method, u, body) | ||
| 94 | + if err != nil { | ||
| 95 | + return 0, nil, "", err | ||
| 96 | + } | ||
| 97 | + if c.Token != "" { | ||
| 98 | + req.Header.Set("Authorization", "Bearer "+c.Token) | ||
| 99 | + } | ||
| 100 | + accept := r.Accept | ||
| 101 | + if accept == "" { | ||
| 102 | + accept = "application/json" | ||
| 103 | + } | ||
| 104 | + req.Header.Set("Accept", accept) | ||
| 105 | + if r.Body != nil { | ||
| 106 | + req.Header.Set("Content-Type", "application/json") | ||
| 107 | + } | ||
| 108 | + req.Header.Set("User-Agent", c.UserAgent) | ||
| 109 | + | ||
| 110 | + resp, err := c.HTTPClient.Do(req) | ||
| 111 | + if err != nil { | ||
| 112 | + return 0, nil, "", err | ||
| 113 | + } | ||
| 114 | + defer resp.Body.Close() | ||
| 115 | + | ||
| 116 | + data, err := io.ReadAll(resp.Body) | ||
| 117 | + if err != nil { | ||
| 118 | + return resp.StatusCode, nil, resp.Header.Get("Content-Type"), err | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + if resp.StatusCode >= 200 && resp.StatusCode < 300 { | ||
| 122 | + return resp.StatusCode, data, resp.Header.Get("Content-Type"), nil | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + // Attempt to decode the standard error envelope. | ||
| 126 | + apiErr := &APIError{Status: resp.StatusCode} | ||
| 127 | + var env errorEnvelope | ||
| 128 | + if json.Unmarshal(data, &env) == nil && env.Error.Code != "" { | ||
| 129 | + apiErr.Code = env.Error.Code | ||
| 130 | + apiErr.Message = env.Error.Message | ||
| 131 | + } else { | ||
| 132 | + apiErr.Message = strings.TrimSpace(string(data)) | ||
| 133 | + if apiErr.Message == "" { | ||
| 134 | + apiErr.Message = http.StatusText(resp.StatusCode) | ||
| 135 | + } | ||
| 136 | + } | ||
| 137 | + return resp.StatusCode, data, resp.Header.Get("Content-Type"), apiErr | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +// do performs the request and unmarshals a JSON success body into out (which | ||
| 141 | +// may be nil for empty 204 responses). | ||
| 142 | +func (c *Client) do(ctx context.Context, r Request, out any) error { | ||
| 143 | + _, data, _, err := c.raw(ctx, r) | ||
| 144 | + if err != nil { | ||
| 145 | + return err | ||
| 146 | + } | ||
| 147 | + if out == nil || len(bytes.TrimSpace(data)) == 0 { | ||
| 148 | + return nil | ||
| 149 | + } | ||
| 150 | + return json.Unmarshal(data, out) | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +// RawJSON performs a request and returns the raw (success) response body. Used | ||
| 154 | +// by the `rickub api` escape hatch and `--json` passthrough. | ||
| 155 | +func (c *Client) RawJSON(ctx context.Context, method, path string, query url.Values, body any) ([]byte, string, error) { | ||
| 156 | + _, data, ct, err := c.raw(ctx, Request{Method: method, Path: path, Query: query, Body: body}) | ||
| 157 | + return data, ct, err | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +// RawText performs a GET requesting text/plain and returns the decoded body. | ||
| 161 | +func (c *Client) RawText(ctx context.Context, path string, query url.Values) ([]byte, error) { | ||
| 162 | + _, data, _, err := c.raw(ctx, Request{Method: http.MethodGet, Path: path, Query: query, Accept: "text/plain"}) | ||
| 163 | + return data, err | ||
| 164 | +} | ||
| 165 | + | ||
| 166 | +// itoa is a small helper for building integer path segments. | ||
| 167 | +func itoa(n int) string { return strconv.Itoa(n) } | ||
| 168 | + | ||
| 169 | +// pageQuery builds a ?page/?per_page query, omitting zero values. | ||
| 170 | +func pageQuery(page, perPage int, extra url.Values) url.Values { | ||
| 171 | + q := url.Values{} | ||
| 172 | + for k, v := range extra { | ||
| 173 | + q[k] = v | ||
| 174 | + } | ||
| 175 | + if page > 0 { | ||
| 176 | + q.Set("page", strconv.Itoa(page)) | ||
| 177 | + } | ||
| 178 | + if perPage > 0 { | ||
| 179 | + q.Set("per_page", strconv.Itoa(perPage)) | ||
| 180 | + } | ||
| 181 | + return q | ||
| 182 | +} | ||
added
internal/api/client_test.go +221 -0 | new file mode 100644 | ||
| @@ -0,0 +1,221 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "errors" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 9 | + "testing" | |
| 10 | +) | |
| 11 | + | |
| 12 | +func TestAuthHeaderAndPath(t *testing.T) { | |
| 13 | + var gotAuth, gotPath, gotAccept string | |
| 14 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 15 | + gotAuth = r.Header.Get("Authorization") | |
| 16 | + gotPath = r.URL.Path | |
| 17 | + gotAccept = r.Header.Get("Accept") | |
| 18 | + json.NewEncoder(w).Encode(User{Handle: "ricktester"}) | |
| 19 | + })) | |
| 20 | + defer srv.Close() | |
| 21 | + | |
| 22 | + c := New(srv.URL, "rickub_pat_secret") | |
| 23 | + u, err := c.GetUser(context.Background()) | |
| 24 | + if err != nil { | |
| 25 | + t.Fatalf("GetUser: %v", err) | |
| 26 | + } | |
| 27 | + if u.Handle != "ricktester" { | |
| 28 | + t.Errorf("handle = %q", u.Handle) | |
| 29 | + } | |
| 30 | + if gotAuth != "Bearer rickub_pat_secret" { | |
| 31 | + t.Errorf("auth header = %q", gotAuth) | |
| 32 | + } | |
| 33 | + if gotPath != "/api/v1/user" { | |
| 34 | + t.Errorf("path = %q", gotPath) | |
| 35 | + } | |
| 36 | + if gotAccept != "application/json" { | |
| 37 | + t.Errorf("accept = %q", gotAccept) | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +func TestErrorEnvelopeParsed(t *testing.T) { | |
| 42 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 43 | + w.WriteHeader(http.StatusNotFound) | |
| 44 | + w.Write([]byte(`{"error":{"code":"not_found","message":"repository not found"}}`)) | |
| 45 | + })) | |
| 46 | + defer srv.Close() | |
| 47 | + | |
| 48 | + c := New(srv.URL, "t") | |
| 49 | + _, err := c.GetRepo(context.Background(), "who", "what") | |
| 50 | + if err == nil { | |
| 51 | + t.Fatal("expected error") | |
| 52 | + } | |
| 53 | + var apiErr *APIError | |
| 54 | + if !errors.As(err, &apiErr) { | |
| 55 | + t.Fatalf("expected *APIError, got %T", err) | |
| 56 | + } | |
| 57 | + if apiErr.Status != http.StatusNotFound { | |
| 58 | + t.Errorf("status = %d", apiErr.Status) | |
| 59 | + } | |
| 60 | + if apiErr.Code != "not_found" { | |
| 61 | + t.Errorf("code = %q", apiErr.Code) | |
| 62 | + } | |
| 63 | + if apiErr.Message != "repository not found" { | |
| 64 | + t.Errorf("message = %q", apiErr.Message) | |
| 65 | + } | |
| 66 | + if apiErr.Error() != "repository not found (not_found)" { | |
| 67 | + t.Errorf("Error() = %q", apiErr.Error()) | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +func TestNonJSONErrorFallback(t *testing.T) { | |
| 72 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 73 | + w.WriteHeader(http.StatusBadGateway) | |
| 74 | + w.Write([]byte("upstream boom")) | |
| 75 | + })) | |
| 76 | + defer srv.Close() | |
| 77 | + | |
| 78 | + c := New(srv.URL, "t") | |
| 79 | + _, err := c.GetUser(context.Background()) | |
| 80 | + var apiErr *APIError | |
| 81 | + if !errors.As(err, &apiErr) { | |
| 82 | + t.Fatalf("expected *APIError, got %v", err) | |
| 83 | + } | |
| 84 | + if apiErr.Message != "upstream boom" { | |
| 85 | + t.Errorf("message = %q", apiErr.Message) | |
| 86 | + } | |
| 87 | +} | |
| 88 | + | |
| 89 | +func TestPaginationQueryAndDecode(t *testing.T) { | |
| 90 | + var gotQuery string | |
| 91 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 92 | + gotQuery = r.URL.RawQuery | |
| 93 | + json.NewEncoder(w).Encode(map[string]any{ | |
| 94 | + "page": 2, | |
| 95 | + "per_page": 5, | |
| 96 | + "has_next": true, | |
| 97 | + "items": []map[string]any{ | |
| 98 | + {"owner": "ricktester", "name": "demo", "full_name": "ricktester/demo", "visibility": "public"}, | |
| 99 | + }, | |
| 100 | + }) | |
| 101 | + })) | |
| 102 | + defer srv.Close() | |
| 103 | + | |
| 104 | + c := New(srv.URL, "t") | |
| 105 | + page, err := c.ListUserRepos(context.Background(), "ricktester", 2, 5) | |
| 106 | + if err != nil { | |
| 107 | + t.Fatalf("ListUserRepos: %v", err) | |
| 108 | + } | |
| 109 | + if gotQuery != "page=2&per_page=5" { | |
| 110 | + t.Errorf("query = %q", gotQuery) | |
| 111 | + } | |
| 112 | + if page.Page.Page != 2 || page.PerPage != 5 || !page.HasNext { | |
| 113 | + t.Errorf("page envelope = %+v", page.Page) | |
| 114 | + } | |
| 115 | + if len(page.Items) != 1 || page.Items[0].FullName != "ricktester/demo" { | |
| 116 | + t.Errorf("items = %+v", page.Items) | |
| 117 | + } | |
| 118 | +} | |
| 119 | + | |
| 120 | +func TestSearchQueryEncoding(t *testing.T) { | |
| 121 | + var gotQuery string | |
| 122 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 123 | + gotQuery = r.URL.RawQuery | |
| 124 | + json.NewEncoder(w).Encode(RepoPage{}) | |
| 125 | + })) | |
| 126 | + defer srv.Close() | |
| 127 | + | |
| 128 | + c := New(srv.URL, "t") | |
| 129 | + if _, err := c.SearchRepos(context.Background(), "hello world", 0, 0); err != nil { | |
| 130 | + t.Fatalf("SearchRepos: %v", err) | |
| 131 | + } | |
| 132 | + if gotQuery != "q=hello+world" { | |
| 133 | + t.Errorf("query = %q", gotQuery) | |
| 134 | + } | |
| 135 | +} | |
| 136 | + | |
| 137 | +func TestCreateRepoSendsBody(t *testing.T) { | |
| 138 | + var gotMethod string | |
| 139 | + var body RepoCreate | |
| 140 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 141 | + gotMethod = r.Method | |
| 142 | + json.NewDecoder(r.Body).Decode(&body) | |
| 143 | + if ct := r.Header.Get("Content-Type"); ct != "application/json" { | |
| 144 | + t.Errorf("content-type = %q", ct) | |
| 145 | + } | |
| 146 | + w.WriteHeader(http.StatusCreated) | |
| 147 | + json.NewEncoder(w).Encode(Repo{FullName: "ricktester/demo", Visibility: "public"}) | |
| 148 | + })) | |
| 149 | + defer srv.Close() | |
| 150 | + | |
| 151 | + c := New(srv.URL, "t") | |
| 152 | + r, err := c.CreateRepo(context.Background(), RepoCreate{Name: "demo", Visibility: "public"}) | |
| 153 | + if err != nil { | |
| 154 | + t.Fatalf("CreateRepo: %v", err) | |
| 155 | + } | |
| 156 | + if gotMethod != http.MethodPost { | |
| 157 | + t.Errorf("method = %s", gotMethod) | |
| 158 | + } | |
| 159 | + if body.Name != "demo" || body.Visibility != "public" { | |
| 160 | + t.Errorf("body = %+v", body) | |
| 161 | + } | |
| 162 | + if r.FullName != "ricktester/demo" { | |
| 163 | + t.Errorf("full_name = %q", r.FullName) | |
| 164 | + } | |
| 165 | +} | |
| 166 | + | |
| 167 | +func TestDeleteRepoNoContent(t *testing.T) { | |
| 168 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 169 | + if r.Method != http.MethodDelete { | |
| 170 | + t.Errorf("method = %s", r.Method) | |
| 171 | + } | |
| 172 | + w.WriteHeader(http.StatusNoContent) | |
| 173 | + })) | |
| 174 | + defer srv.Close() | |
| 175 | + | |
| 176 | + c := New(srv.URL, "t") | |
| 177 | + if err := c.DeleteRepo(context.Background(), "o", "r"); err != nil { | |
| 178 | + t.Fatalf("DeleteRepo: %v", err) | |
| 179 | + } | |
| 180 | +} | |
| 181 | + | |
| 182 | +func TestRawTextAcceptHeader(t *testing.T) { | |
| 183 | + var gotAccept string | |
| 184 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 185 | + gotAccept = r.Header.Get("Accept") | |
| 186 | + w.Header().Set("Content-Type", "text/plain") | |
| 187 | + w.Write([]byte("hello\n")) | |
| 188 | + })) | |
| 189 | + defer srv.Close() | |
| 190 | + | |
| 191 | + c := New(srv.URL, "t") | |
| 192 | + data, err := c.GetRaw(context.Background(), "o", "r", "main", "README.md") | |
| 193 | + if err != nil { | |
| 194 | + t.Fatalf("GetRaw: %v", err) | |
| 195 | + } | |
| 196 | + if gotAccept != "text/plain" { | |
| 197 | + t.Errorf("accept = %q", gotAccept) | |
| 198 | + } | |
| 199 | + if string(data) != "hello\n" { | |
| 200 | + t.Errorf("data = %q", data) | |
| 201 | + } | |
| 202 | +} | |
| 203 | + | |
| 204 | +func TestContentsPathEscaping(t *testing.T) { | |
| 205 | + var gotPath string | |
| 206 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 207 | + gotPath = r.URL.EscapedPath() | |
| 208 | + json.NewEncoder(w).Encode(Contents{Type: "file", File: &Blob{Path: "a/b.txt", Content: "hi"}}) | |
| 209 | + })) | |
| 210 | + defer srv.Close() | |
| 211 | + | |
| 212 | + c := New(srv.URL, "t") | |
| 213 | + if _, err := c.GetContents(context.Background(), "o", "r", "main", "dir/sub/file name.txt"); err != nil { | |
| 214 | + t.Fatalf("GetContents: %v", err) | |
| 215 | + } | |
| 216 | + // Each segment escaped, slashes preserved. | |
| 217 | + want := "/api/v1/repos/o/r/contents/main/dir/sub/file%20name.txt" | |
| 218 | + if gotPath != want { | |
| 219 | + t.Errorf("path = %q, want %q", gotPath, want) | |
| 220 | + } | |
| 221 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,221 @@ | |||
| 1 | +package api | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "errors" | ||
| 7 | + "net/http" | ||
| 8 | + "net/http/httptest" | ||
| 9 | + "testing" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +func TestAuthHeaderAndPath(t *testing.T) { | ||
| 13 | + var gotAuth, gotPath, gotAccept string | ||
| 14 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 15 | + gotAuth = r.Header.Get("Authorization") | ||
| 16 | + gotPath = r.URL.Path | ||
| 17 | + gotAccept = r.Header.Get("Accept") | ||
| 18 | + json.NewEncoder(w).Encode(User{Handle: "ricktester"}) | ||
| 19 | + })) | ||
| 20 | + defer srv.Close() | ||
| 21 | + | ||
| 22 | + c := New(srv.URL, "rickub_pat_secret") | ||
| 23 | + u, err := c.GetUser(context.Background()) | ||
| 24 | + if err != nil { | ||
| 25 | + t.Fatalf("GetUser: %v", err) | ||
| 26 | + } | ||
| 27 | + if u.Handle != "ricktester" { | ||
| 28 | + t.Errorf("handle = %q", u.Handle) | ||
| 29 | + } | ||
| 30 | + if gotAuth != "Bearer rickub_pat_secret" { | ||
| 31 | + t.Errorf("auth header = %q", gotAuth) | ||
| 32 | + } | ||
| 33 | + if gotPath != "/api/v1/user" { | ||
| 34 | + t.Errorf("path = %q", gotPath) | ||
| 35 | + } | ||
| 36 | + if gotAccept != "application/json" { | ||
| 37 | + t.Errorf("accept = %q", gotAccept) | ||
| 38 | + } | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +func TestErrorEnvelopeParsed(t *testing.T) { | ||
| 42 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 43 | + w.WriteHeader(http.StatusNotFound) | ||
| 44 | + w.Write([]byte(`{"error":{"code":"not_found","message":"repository not found"}}`)) | ||
| 45 | + })) | ||
| 46 | + defer srv.Close() | ||
| 47 | + | ||
| 48 | + c := New(srv.URL, "t") | ||
| 49 | + _, err := c.GetRepo(context.Background(), "who", "what") | ||
| 50 | + if err == nil { | ||
| 51 | + t.Fatal("expected error") | ||
| 52 | + } | ||
| 53 | + var apiErr *APIError | ||
| 54 | + if !errors.As(err, &apiErr) { | ||
| 55 | + t.Fatalf("expected *APIError, got %T", err) | ||
| 56 | + } | ||
| 57 | + if apiErr.Status != http.StatusNotFound { | ||
| 58 | + t.Errorf("status = %d", apiErr.Status) | ||
| 59 | + } | ||
| 60 | + if apiErr.Code != "not_found" { | ||
| 61 | + t.Errorf("code = %q", apiErr.Code) | ||
| 62 | + } | ||
| 63 | + if apiErr.Message != "repository not found" { | ||
| 64 | + t.Errorf("message = %q", apiErr.Message) | ||
| 65 | + } | ||
| 66 | + if apiErr.Error() != "repository not found (not_found)" { | ||
| 67 | + t.Errorf("Error() = %q", apiErr.Error()) | ||
| 68 | + } | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +func TestNonJSONErrorFallback(t *testing.T) { | ||
| 72 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 73 | + w.WriteHeader(http.StatusBadGateway) | ||
| 74 | + w.Write([]byte("upstream boom")) | ||
| 75 | + })) | ||
| 76 | + defer srv.Close() | ||
| 77 | + | ||
| 78 | + c := New(srv.URL, "t") | ||
| 79 | + _, err := c.GetUser(context.Background()) | ||
| 80 | + var apiErr *APIError | ||
| 81 | + if !errors.As(err, &apiErr) { | ||
| 82 | + t.Fatalf("expected *APIError, got %v", err) | ||
| 83 | + } | ||
| 84 | + if apiErr.Message != "upstream boom" { | ||
| 85 | + t.Errorf("message = %q", apiErr.Message) | ||
| 86 | + } | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +func TestPaginationQueryAndDecode(t *testing.T) { | ||
| 90 | + var gotQuery string | ||
| 91 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 92 | + gotQuery = r.URL.RawQuery | ||
| 93 | + json.NewEncoder(w).Encode(map[string]any{ | ||
| 94 | + "page": 2, | ||
| 95 | + "per_page": 5, | ||
| 96 | + "has_next": true, | ||
| 97 | + "items": []map[string]any{ | ||
| 98 | + {"owner": "ricktester", "name": "demo", "full_name": "ricktester/demo", "visibility": "public"}, | ||
| 99 | + }, | ||
| 100 | + }) | ||
| 101 | + })) | ||
| 102 | + defer srv.Close() | ||
| 103 | + | ||
| 104 | + c := New(srv.URL, "t") | ||
| 105 | + page, err := c.ListUserRepos(context.Background(), "ricktester", 2, 5) | ||
| 106 | + if err != nil { | ||
| 107 | + t.Fatalf("ListUserRepos: %v", err) | ||
| 108 | + } | ||
| 109 | + if gotQuery != "page=2&per_page=5" { | ||
| 110 | + t.Errorf("query = %q", gotQuery) | ||
| 111 | + } | ||
| 112 | + if page.Page.Page != 2 || page.PerPage != 5 || !page.HasNext { | ||
| 113 | + t.Errorf("page envelope = %+v", page.Page) | ||
| 114 | + } | ||
| 115 | + if len(page.Items) != 1 || page.Items[0].FullName != "ricktester/demo" { | ||
| 116 | + t.Errorf("items = %+v", page.Items) | ||
| 117 | + } | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +func TestSearchQueryEncoding(t *testing.T) { | ||
| 121 | + var gotQuery string | ||
| 122 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 123 | + gotQuery = r.URL.RawQuery | ||
| 124 | + json.NewEncoder(w).Encode(RepoPage{}) | ||
| 125 | + })) | ||
| 126 | + defer srv.Close() | ||
| 127 | + | ||
| 128 | + c := New(srv.URL, "t") | ||
| 129 | + if _, err := c.SearchRepos(context.Background(), "hello world", 0, 0); err != nil { | ||
| 130 | + t.Fatalf("SearchRepos: %v", err) | ||
| 131 | + } | ||
| 132 | + if gotQuery != "q=hello+world" { | ||
| 133 | + t.Errorf("query = %q", gotQuery) | ||
| 134 | + } | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +func TestCreateRepoSendsBody(t *testing.T) { | ||
| 138 | + var gotMethod string | ||
| 139 | + var body RepoCreate | ||
| 140 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 141 | + gotMethod = r.Method | ||
| 142 | + json.NewDecoder(r.Body).Decode(&body) | ||
| 143 | + if ct := r.Header.Get("Content-Type"); ct != "application/json" { | ||
| 144 | + t.Errorf("content-type = %q", ct) | ||
| 145 | + } | ||
| 146 | + w.WriteHeader(http.StatusCreated) | ||
| 147 | + json.NewEncoder(w).Encode(Repo{FullName: "ricktester/demo", Visibility: "public"}) | ||
| 148 | + })) | ||
| 149 | + defer srv.Close() | ||
| 150 | + | ||
| 151 | + c := New(srv.URL, "t") | ||
| 152 | + r, err := c.CreateRepo(context.Background(), RepoCreate{Name: "demo", Visibility: "public"}) | ||
| 153 | + if err != nil { | ||
| 154 | + t.Fatalf("CreateRepo: %v", err) | ||
| 155 | + } | ||
| 156 | + if gotMethod != http.MethodPost { | ||
| 157 | + t.Errorf("method = %s", gotMethod) | ||
| 158 | + } | ||
| 159 | + if body.Name != "demo" || body.Visibility != "public" { | ||
| 160 | + t.Errorf("body = %+v", body) | ||
| 161 | + } | ||
| 162 | + if r.FullName != "ricktester/demo" { | ||
| 163 | + t.Errorf("full_name = %q", r.FullName) | ||
| 164 | + } | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +func TestDeleteRepoNoContent(t *testing.T) { | ||
| 168 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 169 | + if r.Method != http.MethodDelete { | ||
| 170 | + t.Errorf("method = %s", r.Method) | ||
| 171 | + } | ||
| 172 | + w.WriteHeader(http.StatusNoContent) | ||
| 173 | + })) | ||
| 174 | + defer srv.Close() | ||
| 175 | + | ||
| 176 | + c := New(srv.URL, "t") | ||
| 177 | + if err := c.DeleteRepo(context.Background(), "o", "r"); err != nil { | ||
| 178 | + t.Fatalf("DeleteRepo: %v", err) | ||
| 179 | + } | ||
| 180 | +} | ||
| 181 | + | ||
| 182 | +func TestRawTextAcceptHeader(t *testing.T) { | ||
| 183 | + var gotAccept string | ||
| 184 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 185 | + gotAccept = r.Header.Get("Accept") | ||
| 186 | + w.Header().Set("Content-Type", "text/plain") | ||
| 187 | + w.Write([]byte("hello\n")) | ||
| 188 | + })) | ||
| 189 | + defer srv.Close() | ||
| 190 | + | ||
| 191 | + c := New(srv.URL, "t") | ||
| 192 | + data, err := c.GetRaw(context.Background(), "o", "r", "main", "README.md") | ||
| 193 | + if err != nil { | ||
| 194 | + t.Fatalf("GetRaw: %v", err) | ||
| 195 | + } | ||
| 196 | + if gotAccept != "text/plain" { | ||
| 197 | + t.Errorf("accept = %q", gotAccept) | ||
| 198 | + } | ||
| 199 | + if string(data) != "hello\n" { | ||
| 200 | + t.Errorf("data = %q", data) | ||
| 201 | + } | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +func TestContentsPathEscaping(t *testing.T) { | ||
| 205 | + var gotPath string | ||
| 206 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 207 | + gotPath = r.URL.EscapedPath() | ||
| 208 | + json.NewEncoder(w).Encode(Contents{Type: "file", File: &Blob{Path: "a/b.txt", Content: "hi"}}) | ||
| 209 | + })) | ||
| 210 | + defer srv.Close() | ||
| 211 | + | ||
| 212 | + c := New(srv.URL, "t") | ||
| 213 | + if _, err := c.GetContents(context.Background(), "o", "r", "main", "dir/sub/file name.txt"); err != nil { | ||
| 214 | + t.Fatalf("GetContents: %v", err) | ||
| 215 | + } | ||
| 216 | + // Each segment escaped, slashes preserved. | ||
| 217 | + want := "/api/v1/repos/o/r/contents/main/dir/sub/file%20name.txt" | ||
| 218 | + if gotPath != want { | ||
| 219 | + t.Errorf("path = %q, want %q", gotPath, want) | ||
| 220 | + } | ||
| 221 | +} | ||
added
internal/api/endpoints.go +415 -0 | new file mode 100644 | ||
| @@ -0,0 +1,415 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "net/http" | |
| 6 | + "net/url" | |
| 7 | + "strings" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// seg escapes a single path segment. | |
| 11 | +func seg(s string) string { return url.PathEscape(s) } | |
| 12 | + | |
| 13 | +// pathSegs escapes a possibly-multi-segment path, preserving slashes. | |
| 14 | +func pathSegs(p string) string { | |
| 15 | + p = strings.Trim(p, "/") | |
| 16 | + if p == "" { | |
| 17 | + return "" | |
| 18 | + } | |
| 19 | + parts := strings.Split(p, "/") | |
| 20 | + for i, part := range parts { | |
| 21 | + parts[i] = url.PathEscape(part) | |
| 22 | + } | |
| 23 | + return strings.Join(parts, "/") | |
| 24 | +} | |
| 25 | + | |
| 26 | +// ---- identity ---- | |
| 27 | + | |
| 28 | +// GetUser returns the identity that owns the presented PAT. | |
| 29 | +func (c *Client) GetUser(ctx context.Context) (*User, error) { | |
| 30 | + var u User | |
| 31 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/user"}, &u) | |
| 32 | + return &u, err | |
| 33 | +} | |
| 34 | + | |
| 35 | +// ---- repositories ---- | |
| 36 | + | |
| 37 | +// CreateRepo creates a repository. | |
| 38 | +func (c *Client) CreateRepo(ctx context.Context, in RepoCreate) (*Repo, error) { | |
| 39 | + var r Repo | |
| 40 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos", Body: in}, &r) | |
| 41 | + return &r, err | |
| 42 | +} | |
| 43 | + | |
| 44 | +// GetRepo fetches a repository. | |
| 45 | +func (c *Client) GetRepo(ctx context.Context, owner, repo string) (*Repo, error) { | |
| 46 | + var r Repo | |
| 47 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo)}, &r) | |
| 48 | + return &r, err | |
| 49 | +} | |
| 50 | + | |
| 51 | +// UpdateRepo patches a repository. | |
| 52 | +func (c *Client) UpdateRepo(ctx context.Context, owner, repo string, in RepoUpdate) (*Repo, error) { | |
| 53 | + var r Repo | |
| 54 | + err := c.do(ctx, Request{Method: http.MethodPatch, Path: "/repos/" + seg(owner) + "/" + seg(repo), Body: in}, &r) | |
| 55 | + return &r, err | |
| 56 | +} | |
| 57 | + | |
| 58 | +// DeleteRepo deletes a repository. | |
| 59 | +func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error { | |
| 60 | + return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo)}, nil) | |
| 61 | +} | |
| 62 | + | |
| 63 | +// ListUserRepos lists a user's repos visible to the caller. | |
| 64 | +func (c *Client) ListUserRepos(ctx context.Context, handle string, page, perPage int) (*RepoPage, error) { | |
| 65 | + var p RepoPage | |
| 66 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/users/" + seg(handle) + "/repos", Query: pageQuery(page, perPage, nil)}, &p) | |
| 67 | + return &p, err | |
| 68 | +} | |
| 69 | + | |
| 70 | +// ListOrgRepos lists an org's repos visible to the caller. | |
| 71 | +func (c *Client) ListOrgRepos(ctx context.Context, handle string, page, perPage int) (*RepoPage, error) { | |
| 72 | + var p RepoPage | |
| 73 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle) + "/repos", Query: pageQuery(page, perPage, nil)}, &p) | |
| 74 | + return &p, err | |
| 75 | +} | |
| 76 | + | |
| 77 | +// SearchRepos searches repositories. | |
| 78 | +func (c *Client) SearchRepos(ctx context.Context, q string, page, perPage int) (*RepoPage, error) { | |
| 79 | + extra := url.Values{} | |
| 80 | + if q != "" { | |
| 81 | + extra.Set("q", q) | |
| 82 | + } | |
| 83 | + var p RepoPage | |
| 84 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/search/repos", Query: pageQuery(page, perPage, extra)}, &p) | |
| 85 | + return &p, err | |
| 86 | +} | |
| 87 | + | |
| 88 | +// ---- collaborators ---- | |
| 89 | + | |
| 90 | +// ListCollaborators lists repo collaborators (admin only). | |
| 91 | +func (c *Client) ListCollaborators(ctx context.Context, owner, repo string) ([]Collaborator, error) { | |
| 92 | + var cs []Collaborator | |
| 93 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/collaborators"}, &cs) | |
| 94 | + return cs, err | |
| 95 | +} | |
| 96 | + | |
| 97 | +// PutCollaborator adds or updates a collaborator. | |
| 98 | +func (c *Client) PutCollaborator(ctx context.Context, owner, repo, user, permission string) (*Collaborator, error) { | |
| 99 | + body := map[string]string{} | |
| 100 | + if permission != "" { | |
| 101 | + body["permission"] = permission | |
| 102 | + } | |
| 103 | + var col Collaborator | |
| 104 | + err := c.do(ctx, Request{Method: http.MethodPut, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/collaborators/" + seg(user), Body: body}, &col) | |
| 105 | + return &col, err | |
| 106 | +} | |
| 107 | + | |
| 108 | +// DeleteCollaborator removes a collaborator. | |
| 109 | +func (c *Client) DeleteCollaborator(ctx context.Context, owner, repo, user string) error { | |
| 110 | + return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/collaborators/" + seg(user)}, nil) | |
| 111 | +} | |
| 112 | + | |
| 113 | +// ---- merge requests ---- | |
| 114 | + | |
| 115 | +// ListMergeRequests lists merge requests. | |
| 116 | +func (c *Client) ListMergeRequests(ctx context.Context, owner, repo, state string, page, perPage int) (*MergeRequestPage, error) { | |
| 117 | + extra := url.Values{} | |
| 118 | + if state != "" { | |
| 119 | + extra.Set("state", state) | |
| 120 | + } | |
| 121 | + var p MergeRequestPage | |
| 122 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests", Query: pageQuery(page, perPage, extra)}, &p) | |
| 123 | + return &p, err | |
| 124 | +} | |
| 125 | + | |
| 126 | +// CreateMergeRequest opens a merge request. | |
| 127 | +func (c *Client) CreateMergeRequest(ctx context.Context, owner, repo string, in MergeRequestCreate) (*MergeRequest, error) { | |
| 128 | + var mr MergeRequest | |
| 129 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests", Body: in}, &mr) | |
| 130 | + return &mr, err | |
| 131 | +} | |
| 132 | + | |
| 133 | +// GetMergeRequest fetches a merge request with its detail. | |
| 134 | +func (c *Client) GetMergeRequest(ctx context.Context, owner, repo string, number int) (*MergeRequestDetail, error) { | |
| 135 | + var mr MergeRequestDetail | |
| 136 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number)}, &mr) | |
| 137 | + return &mr, err | |
| 138 | +} | |
| 139 | + | |
| 140 | +// MergeMergeRequest merges a merge request. | |
| 141 | +func (c *Client) MergeMergeRequest(ctx context.Context, owner, repo string, number int, method string) (*MergeRequest, error) { | |
| 142 | + body := map[string]string{} | |
| 143 | + if method != "" { | |
| 144 | + body["method"] = method | |
| 145 | + } | |
| 146 | + var mr MergeRequest | |
| 147 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/merge", Body: body}, &mr) | |
| 148 | + return &mr, err | |
| 149 | +} | |
| 150 | + | |
| 151 | +// CloseMergeRequest closes a merge request. | |
| 152 | +func (c *Client) CloseMergeRequest(ctx context.Context, owner, repo string, number int) (*MergeRequest, error) { | |
| 153 | + var mr MergeRequest | |
| 154 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/close"}, &mr) | |
| 155 | + return &mr, err | |
| 156 | +} | |
| 157 | + | |
| 158 | +// ReopenMergeRequest reopens a closed merge request. | |
| 159 | +func (c *Client) ReopenMergeRequest(ctx context.Context, owner, repo string, number int) (*MergeRequest, error) { | |
| 160 | + var mr MergeRequest | |
| 161 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/reopen"}, &mr) | |
| 162 | + return &mr, err | |
| 163 | +} | |
| 164 | + | |
| 165 | +// CommentMergeRequest adds a comment to a merge request. | |
| 166 | +func (c *Client) CommentMergeRequest(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) { | |
| 167 | + var cm Comment | |
| 168 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/comments", Body: map[string]string{"body": body}}, &cm) | |
| 169 | + return &cm, err | |
| 170 | +} | |
| 171 | + | |
| 172 | +// ReviewMergeRequest records a review verdict. | |
| 173 | +func (c *Client) ReviewMergeRequest(ctx context.Context, owner, repo string, number int, verdict string) error { | |
| 174 | + return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/reviews", Body: map[string]string{"verdict": verdict}}, nil) | |
| 175 | +} | |
| 176 | + | |
| 177 | +// ---- actions ---- | |
| 178 | + | |
| 179 | +// ListRuns lists workflow runs. | |
| 180 | +func (c *Client) ListRuns(ctx context.Context, owner, repo string, page, perPage int) (*RunPage, error) { | |
| 181 | + var p RunPage | |
| 182 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs", Query: pageQuery(page, perPage, nil)}, &p) | |
| 183 | + return &p, err | |
| 184 | +} | |
| 185 | + | |
| 186 | +// GetRun fetches a run with jobs + step statuses. | |
| 187 | +func (c *Client) GetRun(ctx context.Context, owner, repo string, number int) (*Run, error) { | |
| 188 | + var r Run | |
| 189 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number)}, &r) | |
| 190 | + return &r, err | |
| 191 | +} | |
| 192 | + | |
| 193 | +// GetRunLogs fetches a run's accumulated logs (JSON form). | |
| 194 | +func (c *Client) GetRunLogs(ctx context.Context, owner, repo string, number int) (*RunLogs, error) { | |
| 195 | + var l RunLogs | |
| 196 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number) + "/logs"}, &l) | |
| 197 | + return &l, err | |
| 198 | +} | |
| 199 | + | |
| 200 | +// RerunRun re-runs a finished run. | |
| 201 | +func (c *Client) RerunRun(ctx context.Context, owner, repo string, number int) (*Run, error) { | |
| 202 | + var r Run | |
| 203 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number) + "/rerun"}, &r) | |
| 204 | + return &r, err | |
| 205 | +} | |
| 206 | + | |
| 207 | +// CancelRun cancels an in-flight run. | |
| 208 | +func (c *Client) CancelRun(ctx context.Context, owner, repo string, number int) (*Run, error) { | |
| 209 | + var r Run | |
| 210 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number) + "/cancel"}, &r) | |
| 211 | + return &r, err | |
| 212 | +} | |
| 213 | + | |
| 214 | +// Dispatch triggers workflow_dispatch workflows. | |
| 215 | +func (c *Client) Dispatch(ctx context.Context, owner, repo, ref string) (*DispatchResult, error) { | |
| 216 | + body := map[string]string{} | |
| 217 | + if ref != "" { | |
| 218 | + body["ref"] = ref | |
| 219 | + } | |
| 220 | + var d DispatchResult | |
| 221 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/dispatch", Body: body}, &d) | |
| 222 | + return &d, err | |
| 223 | +} | |
| 224 | + | |
| 225 | +// ---- device flow (pre-auth: the device code is the credential) ---- | |
| 226 | + | |
| 227 | +// StartDeviceLogin requests a device code the user approves in their browser | |
| 228 | +// (the URL is in the response). Works on a tokenless client. | |
| 229 | +func (c *Client) StartDeviceLogin(ctx context.Context, scope, clientName string) (*DeviceCodeStart, error) { | |
| 230 | + body := map[string]string{} | |
| 231 | + if clientName != "" { | |
| 232 | + body["client_name"] = clientName | |
| 233 | + } | |
| 234 | + if scope != "" { | |
| 235 | + body["scope"] = scope | |
| 236 | + } | |
| 237 | + var d DeviceCodeStart | |
| 238 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/device/code", Body: body}, &d) | |
| 239 | + return &d, err | |
| 240 | +} | |
| 241 | + | |
| 242 | +// PollDeviceToken exchanges an approved device code for a PAT. A pending | |
| 243 | +// approval returns an *APIError with Code "authorization_pending" (keep | |
| 244 | +// polling); "access_denied", "expired_token", and "invalid_grant" are terminal. | |
| 245 | +func (c *Client) PollDeviceToken(ctx context.Context, deviceCode string) (*DeviceToken, error) { | |
| 246 | + var d DeviceToken | |
| 247 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/device/token", Body: map[string]string{"device_code": deviceCode}}, &d) | |
| 248 | + return &d, err | |
| 249 | +} | |
| 250 | + | |
| 251 | +// ---- issues / labels / milestones ---- | |
| 252 | + | |
| 253 | +// ListIssues lists a repo's issues (state: open default, closed, all). | |
| 254 | +func (c *Client) ListIssues(ctx context.Context, owner, repo, state string, page, perPage int) (*IssuePage, error) { | |
| 255 | + extra := url.Values{} | |
| 256 | + if state != "" { | |
| 257 | + extra.Set("state", state) | |
| 258 | + } | |
| 259 | + var p IssuePage | |
| 260 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues", Query: pageQuery(page, perPage, extra)}, &p) | |
| 261 | + return &p, err | |
| 262 | +} | |
| 263 | + | |
| 264 | +// GetIssue fetches an issue with body, comments, labels, milestone, assignees. | |
| 265 | +func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*IssueDetail, error) { | |
| 266 | + var i IssueDetail | |
| 267 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number)}, &i) | |
| 268 | + return &i, err | |
| 269 | +} | |
| 270 | + | |
| 271 | +// CreateIssue opens an issue. | |
| 272 | +func (c *Client) CreateIssue(ctx context.Context, owner, repo, title, body string) (*IssueDetail, error) { | |
| 273 | + var i IssueDetail | |
| 274 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues", Body: map[string]string{"title": title, "body": body}}, &i) | |
| 275 | + return &i, err | |
| 276 | +} | |
| 277 | + | |
| 278 | +// SetIssueState closes ("closed") or reopens ("open") an issue. | |
| 279 | +func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*IssueDetail, error) { | |
| 280 | + var i IssueDetail | |
| 281 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/state", Body: map[string]string{"state": state}}, &i) | |
| 282 | + return &i, err | |
| 283 | +} | |
| 284 | + | |
| 285 | +// CommentIssue adds a comment. | |
| 286 | +func (c *Client) CommentIssue(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) { | |
| 287 | + var cm Comment | |
| 288 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/comments", Body: map[string]string{"body": body}}, &cm) | |
| 289 | + return &cm, err | |
| 290 | +} | |
| 291 | + | |
| 292 | +// SetIssueLabels replaces an issue's labels by name (empty clears all). | |
| 293 | +func (c *Client) SetIssueLabels(ctx context.Context, owner, repo string, number int, labels []string) error { | |
| 294 | + if labels == nil { | |
| 295 | + labels = []string{} | |
| 296 | + } | |
| 297 | + return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/labels", Body: map[string]any{"labels": labels}}, nil) | |
| 298 | +} | |
| 299 | + | |
| 300 | +// SetIssueMilestone assigns (id or title) or clears ("") an issue's milestone. | |
| 301 | +func (c *Client) SetIssueMilestone(ctx context.Context, owner, repo string, number int, milestone string) error { | |
| 302 | + return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/milestone", Body: map[string]string{"milestone": milestone}}, nil) | |
| 303 | +} | |
| 304 | + | |
| 305 | +// SetIssueAssignee adds (op "add") or removes (op "remove") an assignee. | |
| 306 | +func (c *Client) SetIssueAssignee(ctx context.Context, owner, repo string, number int, op, user string) error { | |
| 307 | + return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/assignees", Body: map[string]string{"op": op, "user": user}}, nil) | |
| 308 | +} | |
| 309 | + | |
| 310 | +// ListLabels lists a repo's labels. | |
| 311 | +func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) { | |
| 312 | + var l []Label | |
| 313 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/labels"}, &l) | |
| 314 | + return l, err | |
| 315 | +} | |
| 316 | + | |
| 317 | +// ListMilestones lists a repo's milestones (state: open default, closed, all). | |
| 318 | +func (c *Client) ListMilestones(ctx context.Context, owner, repo, state string) ([]Milestone, error) { | |
| 319 | + extra := url.Values{} | |
| 320 | + if state != "" { | |
| 321 | + extra.Set("state", state) | |
| 322 | + } | |
| 323 | + var m []Milestone | |
| 324 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones", Query: extra}, &m) | |
| 325 | + return m, err | |
| 326 | +} | |
| 327 | + | |
| 328 | +// CreateMilestone creates a milestone (dueOn is YYYY-MM-DD or empty). | |
| 329 | +func (c *Client) CreateMilestone(ctx context.Context, owner, repo, title, description, dueOn string) (*Milestone, error) { | |
| 330 | + var m Milestone | |
| 331 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones", Body: map[string]string{"title": title, "description": description, "due_on": dueOn}}, &m) | |
| 332 | + return &m, err | |
| 333 | +} | |
| 334 | + | |
| 335 | +// UpdateMilestone patches a milestone; nil fields keep their values. | |
| 336 | +func (c *Client) UpdateMilestone(ctx context.Context, owner, repo, id string, in map[string]any) (*Milestone, error) { | |
| 337 | + var m Milestone | |
| 338 | + err := c.do(ctx, Request{Method: http.MethodPatch, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones/" + seg(id), Body: in}, &m) | |
| 339 | + return &m, err | |
| 340 | +} | |
| 341 | + | |
| 342 | +// DeleteMilestone removes a milestone. | |
| 343 | +func (c *Client) DeleteMilestone(ctx context.Context, owner, repo, id string) error { | |
| 344 | + return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones/" + seg(id)}, nil) | |
| 345 | +} | |
| 346 | + | |
| 347 | +// ---- organizations ---- | |
| 348 | + | |
| 349 | +// GetOrg fetches basic org info. | |
| 350 | +func (c *Client) GetOrg(ctx context.Context, handle string) (*Org, error) { | |
| 351 | + var o Org | |
| 352 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle)}, &o) | |
| 353 | + return &o, err | |
| 354 | +} | |
| 355 | + | |
| 356 | +// ListOrgMembers lists org members. | |
| 357 | +func (c *Client) ListOrgMembers(ctx context.Context, handle string) ([]OrgMember, error) { | |
| 358 | + var m []OrgMember | |
| 359 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle) + "/members"}, &m) | |
| 360 | + return m, err | |
| 361 | +} | |
| 362 | + | |
| 363 | +// ListOrgTeams lists org teams. | |
| 364 | +func (c *Client) ListOrgTeams(ctx context.Context, handle string) ([]Team, error) { | |
| 365 | + var t []Team | |
| 366 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle) + "/teams"}, &t) | |
| 367 | + return t, err | |
| 368 | +} | |
| 369 | + | |
| 370 | +// ---- code browsing ---- | |
| 371 | + | |
| 372 | +// GetRefs returns branches, tags, and the default branch. | |
| 373 | +func (c *Client) GetRefs(ctx context.Context, owner, repo string) (*Refs, error) { | |
| 374 | + var r Refs | |
| 375 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/refs"}, &r) | |
| 376 | + return &r, err | |
| 377 | +} | |
| 378 | + | |
| 379 | +// GetContents returns a directory listing or file content. | |
| 380 | +func (c *Client) GetContents(ctx context.Context, owner, repo, ref, path string) (*Contents, error) { | |
| 381 | + p := "/repos/" + seg(owner) + "/" + seg(repo) + "/contents/" + seg(ref) | |
| 382 | + if sp := pathSegs(path); sp != "" { | |
| 383 | + p += "/" + sp | |
| 384 | + } | |
| 385 | + var ct Contents | |
| 386 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: p}, &ct) | |
| 387 | + return &ct, err | |
| 388 | +} | |
| 389 | + | |
| 390 | +// GetRaw returns raw file bytes. | |
| 391 | +func (c *Client) GetRaw(ctx context.Context, owner, repo, ref, path string) ([]byte, error) { | |
| 392 | + p := "/repos/" + seg(owner) + "/" + seg(repo) + "/raw/" + seg(ref) + "/" + pathSegs(path) | |
| 393 | + return c.RawText(ctx, p, nil) | |
| 394 | +} | |
| 395 | + | |
| 396 | +// GetCommits returns commit history reachable from a ref. | |
| 397 | +func (c *Client) GetCommits(ctx context.Context, owner, repo, ref string, page, perPage int) (*CommitPage, error) { | |
| 398 | + var p CommitPage | |
| 399 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/commits/" + seg(ref), Query: pageQuery(page, perPage, nil)}, &p) | |
| 400 | + return &p, err | |
| 401 | +} | |
| 402 | + | |
| 403 | +// GetCommit returns a single commit's detail. | |
| 404 | +func (c *Client) GetCommit(ctx context.Context, owner, repo, sha string) (*CommitDetail, error) { | |
| 405 | + var cd CommitDetail | |
| 406 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/commit/" + seg(sha)}, &cd) | |
| 407 | + return &cd, err | |
| 408 | +} | |
| 409 | + | |
| 410 | +// Compare returns a base...head comparison. | |
| 411 | +func (c *Client) Compare(ctx context.Context, owner, repo, spec string) (*Comparison, error) { | |
| 412 | + var cmp Comparison | |
| 413 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/compare/" + seg(spec)}, &cmp) | |
| 414 | + return &cmp, err | |
| 415 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,415 @@ | |||
| 1 | +package api | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "net/http" | ||
| 6 | + "net/url" | ||
| 7 | + "strings" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// seg escapes a single path segment. | ||
| 11 | +func seg(s string) string { return url.PathEscape(s) } | ||
| 12 | + | ||
| 13 | +// pathSegs escapes a possibly-multi-segment path, preserving slashes. | ||
| 14 | +func pathSegs(p string) string { | ||
| 15 | + p = strings.Trim(p, "/") | ||
| 16 | + if p == "" { | ||
| 17 | + return "" | ||
| 18 | + } | ||
| 19 | + parts := strings.Split(p, "/") | ||
| 20 | + for i, part := range parts { | ||
| 21 | + parts[i] = url.PathEscape(part) | ||
| 22 | + } | ||
| 23 | + return strings.Join(parts, "/") | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +// ---- identity ---- | ||
| 27 | + | ||
| 28 | +// GetUser returns the identity that owns the presented PAT. | ||
| 29 | +func (c *Client) GetUser(ctx context.Context) (*User, error) { | ||
| 30 | + var u User | ||
| 31 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/user"}, &u) | ||
| 32 | + return &u, err | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +// ---- repositories ---- | ||
| 36 | + | ||
| 37 | +// CreateRepo creates a repository. | ||
| 38 | +func (c *Client) CreateRepo(ctx context.Context, in RepoCreate) (*Repo, error) { | ||
| 39 | + var r Repo | ||
| 40 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos", Body: in}, &r) | ||
| 41 | + return &r, err | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +// GetRepo fetches a repository. | ||
| 45 | +func (c *Client) GetRepo(ctx context.Context, owner, repo string) (*Repo, error) { | ||
| 46 | + var r Repo | ||
| 47 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo)}, &r) | ||
| 48 | + return &r, err | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +// UpdateRepo patches a repository. | ||
| 52 | +func (c *Client) UpdateRepo(ctx context.Context, owner, repo string, in RepoUpdate) (*Repo, error) { | ||
| 53 | + var r Repo | ||
| 54 | + err := c.do(ctx, Request{Method: http.MethodPatch, Path: "/repos/" + seg(owner) + "/" + seg(repo), Body: in}, &r) | ||
| 55 | + return &r, err | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +// DeleteRepo deletes a repository. | ||
| 59 | +func (c *Client) DeleteRepo(ctx context.Context, owner, repo string) error { | ||
| 60 | + return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo)}, nil) | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +// ListUserRepos lists a user's repos visible to the caller. | ||
| 64 | +func (c *Client) ListUserRepos(ctx context.Context, handle string, page, perPage int) (*RepoPage, error) { | ||
| 65 | + var p RepoPage | ||
| 66 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/users/" + seg(handle) + "/repos", Query: pageQuery(page, perPage, nil)}, &p) | ||
| 67 | + return &p, err | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +// ListOrgRepos lists an org's repos visible to the caller. | ||
| 71 | +func (c *Client) ListOrgRepos(ctx context.Context, handle string, page, perPage int) (*RepoPage, error) { | ||
| 72 | + var p RepoPage | ||
| 73 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle) + "/repos", Query: pageQuery(page, perPage, nil)}, &p) | ||
| 74 | + return &p, err | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +// SearchRepos searches repositories. | ||
| 78 | +func (c *Client) SearchRepos(ctx context.Context, q string, page, perPage int) (*RepoPage, error) { | ||
| 79 | + extra := url.Values{} | ||
| 80 | + if q != "" { | ||
| 81 | + extra.Set("q", q) | ||
| 82 | + } | ||
| 83 | + var p RepoPage | ||
| 84 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/search/repos", Query: pageQuery(page, perPage, extra)}, &p) | ||
| 85 | + return &p, err | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +// ---- collaborators ---- | ||
| 89 | + | ||
| 90 | +// ListCollaborators lists repo collaborators (admin only). | ||
| 91 | +func (c *Client) ListCollaborators(ctx context.Context, owner, repo string) ([]Collaborator, error) { | ||
| 92 | + var cs []Collaborator | ||
| 93 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/collaborators"}, &cs) | ||
| 94 | + return cs, err | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +// PutCollaborator adds or updates a collaborator. | ||
| 98 | +func (c *Client) PutCollaborator(ctx context.Context, owner, repo, user, permission string) (*Collaborator, error) { | ||
| 99 | + body := map[string]string{} | ||
| 100 | + if permission != "" { | ||
| 101 | + body["permission"] = permission | ||
| 102 | + } | ||
| 103 | + var col Collaborator | ||
| 104 | + err := c.do(ctx, Request{Method: http.MethodPut, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/collaborators/" + seg(user), Body: body}, &col) | ||
| 105 | + return &col, err | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +// DeleteCollaborator removes a collaborator. | ||
| 109 | +func (c *Client) DeleteCollaborator(ctx context.Context, owner, repo, user string) error { | ||
| 110 | + return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/collaborators/" + seg(user)}, nil) | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +// ---- merge requests ---- | ||
| 114 | + | ||
| 115 | +// ListMergeRequests lists merge requests. | ||
| 116 | +func (c *Client) ListMergeRequests(ctx context.Context, owner, repo, state string, page, perPage int) (*MergeRequestPage, error) { | ||
| 117 | + extra := url.Values{} | ||
| 118 | + if state != "" { | ||
| 119 | + extra.Set("state", state) | ||
| 120 | + } | ||
| 121 | + var p MergeRequestPage | ||
| 122 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests", Query: pageQuery(page, perPage, extra)}, &p) | ||
| 123 | + return &p, err | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +// CreateMergeRequest opens a merge request. | ||
| 127 | +func (c *Client) CreateMergeRequest(ctx context.Context, owner, repo string, in MergeRequestCreate) (*MergeRequest, error) { | ||
| 128 | + var mr MergeRequest | ||
| 129 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests", Body: in}, &mr) | ||
| 130 | + return &mr, err | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +// GetMergeRequest fetches a merge request with its detail. | ||
| 134 | +func (c *Client) GetMergeRequest(ctx context.Context, owner, repo string, number int) (*MergeRequestDetail, error) { | ||
| 135 | + var mr MergeRequestDetail | ||
| 136 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number)}, &mr) | ||
| 137 | + return &mr, err | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +// MergeMergeRequest merges a merge request. | ||
| 141 | +func (c *Client) MergeMergeRequest(ctx context.Context, owner, repo string, number int, method string) (*MergeRequest, error) { | ||
| 142 | + body := map[string]string{} | ||
| 143 | + if method != "" { | ||
| 144 | + body["method"] = method | ||
| 145 | + } | ||
| 146 | + var mr MergeRequest | ||
| 147 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/merge", Body: body}, &mr) | ||
| 148 | + return &mr, err | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +// CloseMergeRequest closes a merge request. | ||
| 152 | +func (c *Client) CloseMergeRequest(ctx context.Context, owner, repo string, number int) (*MergeRequest, error) { | ||
| 153 | + var mr MergeRequest | ||
| 154 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/close"}, &mr) | ||
| 155 | + return &mr, err | ||
| 156 | +} | ||
| 157 | + | ||
| 158 | +// ReopenMergeRequest reopens a closed merge request. | ||
| 159 | +func (c *Client) ReopenMergeRequest(ctx context.Context, owner, repo string, number int) (*MergeRequest, error) { | ||
| 160 | + var mr MergeRequest | ||
| 161 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/reopen"}, &mr) | ||
| 162 | + return &mr, err | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +// CommentMergeRequest adds a comment to a merge request. | ||
| 166 | +func (c *Client) CommentMergeRequest(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) { | ||
| 167 | + var cm Comment | ||
| 168 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/comments", Body: map[string]string{"body": body}}, &cm) | ||
| 169 | + return &cm, err | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +// ReviewMergeRequest records a review verdict. | ||
| 173 | +func (c *Client) ReviewMergeRequest(ctx context.Context, owner, repo string, number int, verdict string) error { | ||
| 174 | + return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/reviews", Body: map[string]string{"verdict": verdict}}, nil) | ||
| 175 | +} | ||
| 176 | + | ||
| 177 | +// ---- actions ---- | ||
| 178 | + | ||
| 179 | +// ListRuns lists workflow runs. | ||
| 180 | +func (c *Client) ListRuns(ctx context.Context, owner, repo string, page, perPage int) (*RunPage, error) { | ||
| 181 | + var p RunPage | ||
| 182 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs", Query: pageQuery(page, perPage, nil)}, &p) | ||
| 183 | + return &p, err | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +// GetRun fetches a run with jobs + step statuses. | ||
| 187 | +func (c *Client) GetRun(ctx context.Context, owner, repo string, number int) (*Run, error) { | ||
| 188 | + var r Run | ||
| 189 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number)}, &r) | ||
| 190 | + return &r, err | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +// GetRunLogs fetches a run's accumulated logs (JSON form). | ||
| 194 | +func (c *Client) GetRunLogs(ctx context.Context, owner, repo string, number int) (*RunLogs, error) { | ||
| 195 | + var l RunLogs | ||
| 196 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number) + "/logs"}, &l) | ||
| 197 | + return &l, err | ||
| 198 | +} | ||
| 199 | + | ||
| 200 | +// RerunRun re-runs a finished run. | ||
| 201 | +func (c *Client) RerunRun(ctx context.Context, owner, repo string, number int) (*Run, error) { | ||
| 202 | + var r Run | ||
| 203 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number) + "/rerun"}, &r) | ||
| 204 | + return &r, err | ||
| 205 | +} | ||
| 206 | + | ||
| 207 | +// CancelRun cancels an in-flight run. | ||
| 208 | +func (c *Client) CancelRun(ctx context.Context, owner, repo string, number int) (*Run, error) { | ||
| 209 | + var r Run | ||
| 210 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/runs/" + itoa(number) + "/cancel"}, &r) | ||
| 211 | + return &r, err | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | +// Dispatch triggers workflow_dispatch workflows. | ||
| 215 | +func (c *Client) Dispatch(ctx context.Context, owner, repo, ref string) (*DispatchResult, error) { | ||
| 216 | + body := map[string]string{} | ||
| 217 | + if ref != "" { | ||
| 218 | + body["ref"] = ref | ||
| 219 | + } | ||
| 220 | + var d DispatchResult | ||
| 221 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/actions/dispatch", Body: body}, &d) | ||
| 222 | + return &d, err | ||
| 223 | +} | ||
| 224 | + | ||
| 225 | +// ---- device flow (pre-auth: the device code is the credential) ---- | ||
| 226 | + | ||
| 227 | +// StartDeviceLogin requests a device code the user approves in their browser | ||
| 228 | +// (the URL is in the response). Works on a tokenless client. | ||
| 229 | +func (c *Client) StartDeviceLogin(ctx context.Context, scope, clientName string) (*DeviceCodeStart, error) { | ||
| 230 | + body := map[string]string{} | ||
| 231 | + if clientName != "" { | ||
| 232 | + body["client_name"] = clientName | ||
| 233 | + } | ||
| 234 | + if scope != "" { | ||
| 235 | + body["scope"] = scope | ||
| 236 | + } | ||
| 237 | + var d DeviceCodeStart | ||
| 238 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/device/code", Body: body}, &d) | ||
| 239 | + return &d, err | ||
| 240 | +} | ||
| 241 | + | ||
| 242 | +// PollDeviceToken exchanges an approved device code for a PAT. A pending | ||
| 243 | +// approval returns an *APIError with Code "authorization_pending" (keep | ||
| 244 | +// polling); "access_denied", "expired_token", and "invalid_grant" are terminal. | ||
| 245 | +func (c *Client) PollDeviceToken(ctx context.Context, deviceCode string) (*DeviceToken, error) { | ||
| 246 | + var d DeviceToken | ||
| 247 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/device/token", Body: map[string]string{"device_code": deviceCode}}, &d) | ||
| 248 | + return &d, err | ||
| 249 | +} | ||
| 250 | + | ||
| 251 | +// ---- issues / labels / milestones ---- | ||
| 252 | + | ||
| 253 | +// ListIssues lists a repo's issues (state: open default, closed, all). | ||
| 254 | +func (c *Client) ListIssues(ctx context.Context, owner, repo, state string, page, perPage int) (*IssuePage, error) { | ||
| 255 | + extra := url.Values{} | ||
| 256 | + if state != "" { | ||
| 257 | + extra.Set("state", state) | ||
| 258 | + } | ||
| 259 | + var p IssuePage | ||
| 260 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues", Query: pageQuery(page, perPage, extra)}, &p) | ||
| 261 | + return &p, err | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | +// GetIssue fetches an issue with body, comments, labels, milestone, assignees. | ||
| 265 | +func (c *Client) GetIssue(ctx context.Context, owner, repo string, number int) (*IssueDetail, error) { | ||
| 266 | + var i IssueDetail | ||
| 267 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number)}, &i) | ||
| 268 | + return &i, err | ||
| 269 | +} | ||
| 270 | + | ||
| 271 | +// CreateIssue opens an issue. | ||
| 272 | +func (c *Client) CreateIssue(ctx context.Context, owner, repo, title, body string) (*IssueDetail, error) { | ||
| 273 | + var i IssueDetail | ||
| 274 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues", Body: map[string]string{"title": title, "body": body}}, &i) | ||
| 275 | + return &i, err | ||
| 276 | +} | ||
| 277 | + | ||
| 278 | +// SetIssueState closes ("closed") or reopens ("open") an issue. | ||
| 279 | +func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*IssueDetail, error) { | ||
| 280 | + var i IssueDetail | ||
| 281 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/state", Body: map[string]string{"state": state}}, &i) | ||
| 282 | + return &i, err | ||
| 283 | +} | ||
| 284 | + | ||
| 285 | +// CommentIssue adds a comment. | ||
| 286 | +func (c *Client) CommentIssue(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) { | ||
| 287 | + var cm Comment | ||
| 288 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/comments", Body: map[string]string{"body": body}}, &cm) | ||
| 289 | + return &cm, err | ||
| 290 | +} | ||
| 291 | + | ||
| 292 | +// SetIssueLabels replaces an issue's labels by name (empty clears all). | ||
| 293 | +func (c *Client) SetIssueLabels(ctx context.Context, owner, repo string, number int, labels []string) error { | ||
| 294 | + if labels == nil { | ||
| 295 | + labels = []string{} | ||
| 296 | + } | ||
| 297 | + return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/labels", Body: map[string]any{"labels": labels}}, nil) | ||
| 298 | +} | ||
| 299 | + | ||
| 300 | +// SetIssueMilestone assigns (id or title) or clears ("") an issue's milestone. | ||
| 301 | +func (c *Client) SetIssueMilestone(ctx context.Context, owner, repo string, number int, milestone string) error { | ||
| 302 | + return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/milestone", Body: map[string]string{"milestone": milestone}}, nil) | ||
| 303 | +} | ||
| 304 | + | ||
| 305 | +// SetIssueAssignee adds (op "add") or removes (op "remove") an assignee. | ||
| 306 | +func (c *Client) SetIssueAssignee(ctx context.Context, owner, repo string, number int, op, user string) error { | ||
| 307 | + return c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/issues/" + itoa(number) + "/assignees", Body: map[string]string{"op": op, "user": user}}, nil) | ||
| 308 | +} | ||
| 309 | + | ||
| 310 | +// ListLabels lists a repo's labels. | ||
| 311 | +func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) { | ||
| 312 | + var l []Label | ||
| 313 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/labels"}, &l) | ||
| 314 | + return l, err | ||
| 315 | +} | ||
| 316 | + | ||
| 317 | +// ListMilestones lists a repo's milestones (state: open default, closed, all). | ||
| 318 | +func (c *Client) ListMilestones(ctx context.Context, owner, repo, state string) ([]Milestone, error) { | ||
| 319 | + extra := url.Values{} | ||
| 320 | + if state != "" { | ||
| 321 | + extra.Set("state", state) | ||
| 322 | + } | ||
| 323 | + var m []Milestone | ||
| 324 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones", Query: extra}, &m) | ||
| 325 | + return m, err | ||
| 326 | +} | ||
| 327 | + | ||
| 328 | +// CreateMilestone creates a milestone (dueOn is YYYY-MM-DD or empty). | ||
| 329 | +func (c *Client) CreateMilestone(ctx context.Context, owner, repo, title, description, dueOn string) (*Milestone, error) { | ||
| 330 | + var m Milestone | ||
| 331 | + err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones", Body: map[string]string{"title": title, "description": description, "due_on": dueOn}}, &m) | ||
| 332 | + return &m, err | ||
| 333 | +} | ||
| 334 | + | ||
| 335 | +// UpdateMilestone patches a milestone; nil fields keep their values. | ||
| 336 | +func (c *Client) UpdateMilestone(ctx context.Context, owner, repo, id string, in map[string]any) (*Milestone, error) { | ||
| 337 | + var m Milestone | ||
| 338 | + err := c.do(ctx, Request{Method: http.MethodPatch, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones/" + seg(id), Body: in}, &m) | ||
| 339 | + return &m, err | ||
| 340 | +} | ||
| 341 | + | ||
| 342 | +// DeleteMilestone removes a milestone. | ||
| 343 | +func (c *Client) DeleteMilestone(ctx context.Context, owner, repo, id string) error { | ||
| 344 | + return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/milestones/" + seg(id)}, nil) | ||
| 345 | +} | ||
| 346 | + | ||
| 347 | +// ---- organizations ---- | ||
| 348 | + | ||
| 349 | +// GetOrg fetches basic org info. | ||
| 350 | +func (c *Client) GetOrg(ctx context.Context, handle string) (*Org, error) { | ||
| 351 | + var o Org | ||
| 352 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle)}, &o) | ||
| 353 | + return &o, err | ||
| 354 | +} | ||
| 355 | + | ||
| 356 | +// ListOrgMembers lists org members. | ||
| 357 | +func (c *Client) ListOrgMembers(ctx context.Context, handle string) ([]OrgMember, error) { | ||
| 358 | + var m []OrgMember | ||
| 359 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle) + "/members"}, &m) | ||
| 360 | + return m, err | ||
| 361 | +} | ||
| 362 | + | ||
| 363 | +// ListOrgTeams lists org teams. | ||
| 364 | +func (c *Client) ListOrgTeams(ctx context.Context, handle string) ([]Team, error) { | ||
| 365 | + var t []Team | ||
| 366 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/orgs/" + seg(handle) + "/teams"}, &t) | ||
| 367 | + return t, err | ||
| 368 | +} | ||
| 369 | + | ||
| 370 | +// ---- code browsing ---- | ||
| 371 | + | ||
| 372 | +// GetRefs returns branches, tags, and the default branch. | ||
| 373 | +func (c *Client) GetRefs(ctx context.Context, owner, repo string) (*Refs, error) { | ||
| 374 | + var r Refs | ||
| 375 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/refs"}, &r) | ||
| 376 | + return &r, err | ||
| 377 | +} | ||
| 378 | + | ||
| 379 | +// GetContents returns a directory listing or file content. | ||
| 380 | +func (c *Client) GetContents(ctx context.Context, owner, repo, ref, path string) (*Contents, error) { | ||
| 381 | + p := "/repos/" + seg(owner) + "/" + seg(repo) + "/contents/" + seg(ref) | ||
| 382 | + if sp := pathSegs(path); sp != "" { | ||
| 383 | + p += "/" + sp | ||
| 384 | + } | ||
| 385 | + var ct Contents | ||
| 386 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: p}, &ct) | ||
| 387 | + return &ct, err | ||
| 388 | +} | ||
| 389 | + | ||
| 390 | +// GetRaw returns raw file bytes. | ||
| 391 | +func (c *Client) GetRaw(ctx context.Context, owner, repo, ref, path string) ([]byte, error) { | ||
| 392 | + p := "/repos/" + seg(owner) + "/" + seg(repo) + "/raw/" + seg(ref) + "/" + pathSegs(path) | ||
| 393 | + return c.RawText(ctx, p, nil) | ||
| 394 | +} | ||
| 395 | + | ||
| 396 | +// GetCommits returns commit history reachable from a ref. | ||
| 397 | +func (c *Client) GetCommits(ctx context.Context, owner, repo, ref string, page, perPage int) (*CommitPage, error) { | ||
| 398 | + var p CommitPage | ||
| 399 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/commits/" + seg(ref), Query: pageQuery(page, perPage, nil)}, &p) | ||
| 400 | + return &p, err | ||
| 401 | +} | ||
| 402 | + | ||
| 403 | +// GetCommit returns a single commit's detail. | ||
| 404 | +func (c *Client) GetCommit(ctx context.Context, owner, repo, sha string) (*CommitDetail, error) { | ||
| 405 | + var cd CommitDetail | ||
| 406 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/commit/" + seg(sha)}, &cd) | ||
| 407 | + return &cd, err | ||
| 408 | +} | ||
| 409 | + | ||
| 410 | +// Compare returns a base...head comparison. | ||
| 411 | +func (c *Client) Compare(ctx context.Context, owner, repo, spec string) (*Comparison, error) { | ||
| 412 | + var cmp Comparison | ||
| 413 | + err := c.do(ctx, Request{Method: http.MethodGet, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/compare/" + seg(spec)}, &cmp) | ||
| 414 | + return &cmp, err | ||
| 415 | +} | ||
added
internal/api/endpoints_issues_test.go +182 -0 | new file mode 100644 | ||
| @@ -0,0 +1,182 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "errors" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 9 | + "testing" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// issuesServer stubs the issue/milestone/device surface, recording every | |
| 13 | +// request (method+path+body) and replying from a scripted map. | |
| 14 | +type recordedRequest struct { | |
| 15 | + Method string | |
| 16 | + Path string | |
| 17 | + Body string | |
| 18 | +} | |
| 19 | + | |
| 20 | +func newIssuesServer(t *testing.T) (*httptest.Server, *[]recordedRequest, map[string]string) { | |
| 21 | + t.Helper() | |
| 22 | + var calls []recordedRequest | |
| 23 | + responses := map[string]string{} | |
| 24 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 25 | + var body string | |
| 26 | + if r.Body != nil { | |
| 27 | + buf := make([]byte, 4096) | |
| 28 | + n, _ := r.Body.Read(buf) | |
| 29 | + body = string(buf[:n]) | |
| 30 | + } | |
| 31 | + calls = append(calls, recordedRequest{Method: r.Method, Path: r.URL.Path, Body: body}) | |
| 32 | + resp, ok := responses[r.Method+" "+r.URL.Path] | |
| 33 | + if !ok { | |
| 34 | + w.WriteHeader(http.StatusNotFound) | |
| 35 | + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": "not_found", "message": "no"}}) | |
| 36 | + return | |
| 37 | + } | |
| 38 | + w.Header().Set("Content-Type", "application/json") | |
| 39 | + _, _ = w.Write([]byte(resp)) | |
| 40 | + })) | |
| 41 | + t.Cleanup(srv.Close) | |
| 42 | + return srv, &calls, responses | |
| 43 | +} | |
| 44 | + | |
| 45 | +func TestIssueEndpointsRoundTrip(t *testing.T) { | |
| 46 | + srv, calls, responses := newIssuesServer(t) | |
| 47 | + responses["GET /api/v1/repos/o/r/issues"] = `{"items":[{"number":7,"title":"bug","state":"open","author":"rick","labels":[{"id":"1","name":"bug","color":"d73a4a"}],"milestone":{"id":"m1","title":"v1.0","state":"open","open_issues":2,"closed_issues":1}}],"page":1,"per_page":30,"has_next":false}` | |
| 48 | + responses["POST /api/v1/repos/o/r/issues"] = `{"number":8,"title":"new","state":"open","author":"rick","body":"the body","labels":[],"comments":[]}` | |
| 49 | + responses["GET /api/v1/repos/o/r/issues/7"] = `{"number":7,"title":"bug","state":"open","author":"rick","body":"spicy","labels":[],"comments":[{"author":"rick","body":"first","createdAt":"2026-01-01T00:00:00Z"}],"assignees":[{"handle":"rick"}]}` | |
| 50 | + responses["POST /api/v1/repos/o/r/issues/7/state"] = `{"number":7,"state":"closed","title":"bug"}` | |
| 51 | + responses["POST /api/v1/repos/o/r/issues/7/comments"] = `{"author":"rick","body":"hi"}` | |
| 52 | + responses["POST /api/v1/repos/o/r/issues/7/labels"] = `` | |
| 53 | + responses["POST /api/v1/repos/o/r/issues/7/milestone"] = `` | |
| 54 | + responses["POST /api/v1/repos/o/r/issues/7/assignees"] = `` | |
| 55 | + responses["DELETE /api/v1/repos/o/r/milestones/m1"] = `` | |
| 56 | + responses["GET /api/v1/repos/o/r/labels"] = `[{"id":"1","name":"bug","color":"d73a4a"}]` | |
| 57 | + responses["GET /api/v1/repos/o/r/milestones"] = `[{"id":"m1","title":"v1.0","state":"open","open_issues":2,"closed_issues":1}]` | |
| 58 | + responses["POST /api/v1/repos/o/r/milestones"] = `{"id":"m1","title":"v1.0","state":"open"}` | |
| 59 | + responses["PATCH /api/v1/repos/o/r/milestones/m1"] = `{"id":"m1","title":"v1.0","state":"closed"}` | |
| 60 | + c := New(srv.URL, "rickub_pat_x") | |
| 61 | + ctx := context.Background() | |
| 62 | + | |
| 63 | + page, err := c.ListIssues(ctx, "o", "r", "open", 0, 0) | |
| 64 | + if err != nil || len(page.Items) != 1 || page.Items[0].Milestone.Title != "v1.0" || page.Items[0].Labels[0].Name != "bug" { | |
| 65 | + t.Fatalf("ListIssues: %+v err=%v", page, err) | |
| 66 | + } | |
| 67 | + | |
| 68 | + created, err := c.CreateIssue(ctx, "o", "r", "new", "the body") | |
| 69 | + if err != nil || created.Number != 8 || created.Body != "the body" { | |
| 70 | + t.Fatalf("CreateIssue: %+v err=%v", created, err) | |
| 71 | + } | |
| 72 | + | |
| 73 | + detail, err := c.GetIssue(ctx, "o", "r", 7) | |
| 74 | + if err != nil || len(detail.Comments) != 1 || len(detail.Assignees) != 1 || detail.Assignees[0].Handle != "rick" { | |
| 75 | + t.Fatalf("GetIssue: %+v err=%v", detail, err) | |
| 76 | + } | |
| 77 | + | |
| 78 | + closed, err := c.SetIssueState(ctx, "o", "r", 7, "closed") | |
| 79 | + if err != nil || closed.State != "closed" { | |
| 80 | + t.Fatalf("SetIssueState: %+v err=%v", closed, err) | |
| 81 | + } | |
| 82 | + | |
| 83 | + if _, err := c.CommentIssue(ctx, "o", "r", 7, "hi"); err != nil { | |
| 84 | + t.Fatalf("CommentIssue: %v", err) | |
| 85 | + } | |
| 86 | + if err := c.SetIssueLabels(ctx, "o", "r", 7, []string{"bug"}); err != nil { | |
| 87 | + t.Fatalf("SetIssueLabels: %v", err) | |
| 88 | + } | |
| 89 | + if err := c.SetIssueMilestone(ctx, "o", "r", 7, "v1.0"); err != nil { | |
| 90 | + t.Fatalf("SetIssueMilestone: %v", err) | |
| 91 | + } | |
| 92 | + if err := c.SetIssueAssignee(ctx, "o", "r", 7, "add", "rick"); err != nil { | |
| 93 | + t.Fatalf("SetIssueAssignee: %v", err) | |
| 94 | + } | |
| 95 | + | |
| 96 | + labels, err := c.ListLabels(ctx, "o", "r") | |
| 97 | + if err != nil || len(labels) != 1 || labels[0].Color != "d73a4a" { | |
| 98 | + t.Fatalf("ListLabels: %+v err=%v", labels, err) | |
| 99 | + } | |
| 100 | + | |
| 101 | + mses, err := c.ListMilestones(ctx, "o", "r", "open") | |
| 102 | + if err != nil || len(mses) != 1 || mses[0].OpenCount != 2 || mses[0].ClosedCount != 1 { | |
| 103 | + t.Fatalf("ListMilestones: %+v err=%v", mses, err) | |
| 104 | + } | |
| 105 | + | |
| 106 | + if _, err := c.CreateMilestone(ctx, "o", "r", "v1.0", "", "2026-12-31"); err != nil { | |
| 107 | + t.Fatalf("CreateMilestone: %v", err) | |
| 108 | + } | |
| 109 | + updated, err := c.UpdateMilestone(ctx, "o", "r", "m1", map[string]any{"state": "closed"}) | |
| 110 | + if err != nil || updated.State != "closed" { | |
| 111 | + t.Fatalf("UpdateMilestone: %+v err=%v", updated, err) | |
| 112 | + } | |
| 113 | + | |
| 114 | + // The mutation bodies carry what the server expects. | |
| 115 | + want := map[string]recordedRequest{ | |
| 116 | + "POST /api/v1/repos/o/r/issues": {Method: "POST", Path: "/api/v1/repos/o/r/issues", Body: `{"body":"the body","title":"new"}`}, | |
| 117 | + "POST /api/v1/repos/o/r/issues/7/state": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/state", Body: `{"state":"closed"}`}, | |
| 118 | + "POST /api/v1/repos/o/r/issues/7/labels": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/labels", Body: `{"labels":["bug"]}`}, | |
| 119 | + "POST /api/v1/repos/o/r/issues/7/milestone": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/milestone", Body: `{"milestone":"v1.0"}`}, | |
| 120 | + "POST /api/v1/repos/o/r/issues/7/assignees": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/assignees", Body: `{"op":"add","user":"rick"}`}, | |
| 121 | + "PATCH /api/v1/repos/o/r/milestones/m1": {Method: "PATCH", Path: "/api/v1/repos/o/r/milestones/m1", Body: `{"state":"closed"}`}, | |
| 122 | + } | |
| 123 | + byKey := map[string]recordedRequest{} | |
| 124 | + for _, c := range *calls { | |
| 125 | + byKey[c.Method+" "+c.Path] = c | |
| 126 | + } | |
| 127 | + for key, w := range want { | |
| 128 | + if got := byKey[key]; got.Body != w.Body { | |
| 129 | + t.Errorf("%s body = %s, want %s", key, got.Body, w.Body) | |
| 130 | + } | |
| 131 | + } | |
| 132 | +} | |
| 133 | + | |
| 134 | +func TestDeviceFlowClientRoundTrip(t *testing.T) { | |
| 135 | + var sawAuth bool | |
| 136 | + var tokenPathCalls int | |
| 137 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 138 | + if h := r.Header.Get("Authorization"); h != "" { | |
| 139 | + sawAuth = true | |
| 140 | + } | |
| 141 | + switch { | |
| 142 | + case r.Method == "POST" && r.URL.Path == "/api/v1/device/code": | |
| 143 | + _, _ = w.Write([]byte(`{"device_code":"dc","user_code":"ABCD-EFGH","verification_url":"https://x/login/device","verification_uri_complete":"https://x/login/device?user_code=ABCD-EFGH","expires_in":600,"interval":1}`)) | |
| 144 | + case r.Method == "POST" && r.URL.Path == "/api/v1/device/token": | |
| 145 | + tokenPathCalls++ | |
| 146 | + if tokenPathCalls == 1 { | |
| 147 | + w.WriteHeader(http.StatusBadRequest) | |
| 148 | + _, _ = w.Write([]byte(`{"error":{"code":"authorization_pending","message":"keep polling"}}`)) | |
| 149 | + return | |
| 150 | + } | |
| 151 | + _, _ = w.Write([]byte(`{"access_token":"rickub_pat_minted","token_type":"bearer","scope":"all"}`)) | |
| 152 | + default: | |
| 153 | + w.WriteHeader(http.StatusNotFound) | |
| 154 | + } | |
| 155 | + })) | |
| 156 | + defer srv.Close() | |
| 157 | + | |
| 158 | + c := New(srv.URL, "") // tokenless: the device code is the credential | |
| 159 | + start, err := c.StartDeviceLogin(context.Background(), "all", "test client") | |
| 160 | + if err != nil || start.UserCode != "ABCD-EFGH" || start.Interval != 1 { | |
| 161 | + t.Fatalf("StartDeviceLogin: %+v err=%v", start, err) | |
| 162 | + } | |
| 163 | + | |
| 164 | + // First poll: pending → *APIError with the machine code. | |
| 165 | + if _, err := c.PollDeviceToken(context.Background(), start.DeviceCode); err == nil { | |
| 166 | + t.Fatal("first poll should error") | |
| 167 | + } else { | |
| 168 | + var apiErr *APIError | |
| 169 | + if !errors.As(err, &apiErr) || apiErr.Code != "authorization_pending" { | |
| 170 | + t.Fatalf("pending error: %v", err) | |
| 171 | + } | |
| 172 | + } | |
| 173 | + | |
| 174 | + // Second poll: minted token. | |
| 175 | + tok, err := c.PollDeviceToken(context.Background(), start.DeviceCode) | |
| 176 | + if err != nil || tok.AccessToken != "rickub_pat_minted" { | |
| 177 | + t.Fatalf("PollDeviceToken: %+v err=%v", tok, err) | |
| 178 | + } | |
| 179 | + if sawAuth { | |
| 180 | + t.Error("device endpoints must not send an Authorization header on a tokenless client") | |
| 181 | + } | |
| 182 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,182 @@ | |||
| 1 | +package api | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "errors" | ||
| 7 | + "net/http" | ||
| 8 | + "net/http/httptest" | ||
| 9 | + "testing" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +// issuesServer stubs the issue/milestone/device surface, recording every | ||
| 13 | +// request (method+path+body) and replying from a scripted map. | ||
| 14 | +type recordedRequest struct { | ||
| 15 | + Method string | ||
| 16 | + Path string | ||
| 17 | + Body string | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +func newIssuesServer(t *testing.T) (*httptest.Server, *[]recordedRequest, map[string]string) { | ||
| 21 | + t.Helper() | ||
| 22 | + var calls []recordedRequest | ||
| 23 | + responses := map[string]string{} | ||
| 24 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 25 | + var body string | ||
| 26 | + if r.Body != nil { | ||
| 27 | + buf := make([]byte, 4096) | ||
| 28 | + n, _ := r.Body.Read(buf) | ||
| 29 | + body = string(buf[:n]) | ||
| 30 | + } | ||
| 31 | + calls = append(calls, recordedRequest{Method: r.Method, Path: r.URL.Path, Body: body}) | ||
| 32 | + resp, ok := responses[r.Method+" "+r.URL.Path] | ||
| 33 | + if !ok { | ||
| 34 | + w.WriteHeader(http.StatusNotFound) | ||
| 35 | + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": "not_found", "message": "no"}}) | ||
| 36 | + return | ||
| 37 | + } | ||
| 38 | + w.Header().Set("Content-Type", "application/json") | ||
| 39 | + _, _ = w.Write([]byte(resp)) | ||
| 40 | + })) | ||
| 41 | + t.Cleanup(srv.Close) | ||
| 42 | + return srv, &calls, responses | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +func TestIssueEndpointsRoundTrip(t *testing.T) { | ||
| 46 | + srv, calls, responses := newIssuesServer(t) | ||
| 47 | + responses["GET /api/v1/repos/o/r/issues"] = `{"items":[{"number":7,"title":"bug","state":"open","author":"rick","labels":[{"id":"1","name":"bug","color":"d73a4a"}],"milestone":{"id":"m1","title":"v1.0","state":"open","open_issues":2,"closed_issues":1}}],"page":1,"per_page":30,"has_next":false}` | ||
| 48 | + responses["POST /api/v1/repos/o/r/issues"] = `{"number":8,"title":"new","state":"open","author":"rick","body":"the body","labels":[],"comments":[]}` | ||
| 49 | + responses["GET /api/v1/repos/o/r/issues/7"] = `{"number":7,"title":"bug","state":"open","author":"rick","body":"spicy","labels":[],"comments":[{"author":"rick","body":"first","createdAt":"2026-01-01T00:00:00Z"}],"assignees":[{"handle":"rick"}]}` | ||
| 50 | + responses["POST /api/v1/repos/o/r/issues/7/state"] = `{"number":7,"state":"closed","title":"bug"}` | ||
| 51 | + responses["POST /api/v1/repos/o/r/issues/7/comments"] = `{"author":"rick","body":"hi"}` | ||
| 52 | + responses["POST /api/v1/repos/o/r/issues/7/labels"] = `` | ||
| 53 | + responses["POST /api/v1/repos/o/r/issues/7/milestone"] = `` | ||
| 54 | + responses["POST /api/v1/repos/o/r/issues/7/assignees"] = `` | ||
| 55 | + responses["DELETE /api/v1/repos/o/r/milestones/m1"] = `` | ||
| 56 | + responses["GET /api/v1/repos/o/r/labels"] = `[{"id":"1","name":"bug","color":"d73a4a"}]` | ||
| 57 | + responses["GET /api/v1/repos/o/r/milestones"] = `[{"id":"m1","title":"v1.0","state":"open","open_issues":2,"closed_issues":1}]` | ||
| 58 | + responses["POST /api/v1/repos/o/r/milestones"] = `{"id":"m1","title":"v1.0","state":"open"}` | ||
| 59 | + responses["PATCH /api/v1/repos/o/r/milestones/m1"] = `{"id":"m1","title":"v1.0","state":"closed"}` | ||
| 60 | + c := New(srv.URL, "rickub_pat_x") | ||
| 61 | + ctx := context.Background() | ||
| 62 | + | ||
| 63 | + page, err := c.ListIssues(ctx, "o", "r", "open", 0, 0) | ||
| 64 | + if err != nil || len(page.Items) != 1 || page.Items[0].Milestone.Title != "v1.0" || page.Items[0].Labels[0].Name != "bug" { | ||
| 65 | + t.Fatalf("ListIssues: %+v err=%v", page, err) | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + created, err := c.CreateIssue(ctx, "o", "r", "new", "the body") | ||
| 69 | + if err != nil || created.Number != 8 || created.Body != "the body" { | ||
| 70 | + t.Fatalf("CreateIssue: %+v err=%v", created, err) | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + detail, err := c.GetIssue(ctx, "o", "r", 7) | ||
| 74 | + if err != nil || len(detail.Comments) != 1 || len(detail.Assignees) != 1 || detail.Assignees[0].Handle != "rick" { | ||
| 75 | + t.Fatalf("GetIssue: %+v err=%v", detail, err) | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + closed, err := c.SetIssueState(ctx, "o", "r", 7, "closed") | ||
| 79 | + if err != nil || closed.State != "closed" { | ||
| 80 | + t.Fatalf("SetIssueState: %+v err=%v", closed, err) | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + if _, err := c.CommentIssue(ctx, "o", "r", 7, "hi"); err != nil { | ||
| 84 | + t.Fatalf("CommentIssue: %v", err) | ||
| 85 | + } | ||
| 86 | + if err := c.SetIssueLabels(ctx, "o", "r", 7, []string{"bug"}); err != nil { | ||
| 87 | + t.Fatalf("SetIssueLabels: %v", err) | ||
| 88 | + } | ||
| 89 | + if err := c.SetIssueMilestone(ctx, "o", "r", 7, "v1.0"); err != nil { | ||
| 90 | + t.Fatalf("SetIssueMilestone: %v", err) | ||
| 91 | + } | ||
| 92 | + if err := c.SetIssueAssignee(ctx, "o", "r", 7, "add", "rick"); err != nil { | ||
| 93 | + t.Fatalf("SetIssueAssignee: %v", err) | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + labels, err := c.ListLabels(ctx, "o", "r") | ||
| 97 | + if err != nil || len(labels) != 1 || labels[0].Color != "d73a4a" { | ||
| 98 | + t.Fatalf("ListLabels: %+v err=%v", labels, err) | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + mses, err := c.ListMilestones(ctx, "o", "r", "open") | ||
| 102 | + if err != nil || len(mses) != 1 || mses[0].OpenCount != 2 || mses[0].ClosedCount != 1 { | ||
| 103 | + t.Fatalf("ListMilestones: %+v err=%v", mses, err) | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + if _, err := c.CreateMilestone(ctx, "o", "r", "v1.0", "", "2026-12-31"); err != nil { | ||
| 107 | + t.Fatalf("CreateMilestone: %v", err) | ||
| 108 | + } | ||
| 109 | + updated, err := c.UpdateMilestone(ctx, "o", "r", "m1", map[string]any{"state": "closed"}) | ||
| 110 | + if err != nil || updated.State != "closed" { | ||
| 111 | + t.Fatalf("UpdateMilestone: %+v err=%v", updated, err) | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + // The mutation bodies carry what the server expects. | ||
| 115 | + want := map[string]recordedRequest{ | ||
| 116 | + "POST /api/v1/repos/o/r/issues": {Method: "POST", Path: "/api/v1/repos/o/r/issues", Body: `{"body":"the body","title":"new"}`}, | ||
| 117 | + "POST /api/v1/repos/o/r/issues/7/state": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/state", Body: `{"state":"closed"}`}, | ||
| 118 | + "POST /api/v1/repos/o/r/issues/7/labels": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/labels", Body: `{"labels":["bug"]}`}, | ||
| 119 | + "POST /api/v1/repos/o/r/issues/7/milestone": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/milestone", Body: `{"milestone":"v1.0"}`}, | ||
| 120 | + "POST /api/v1/repos/o/r/issues/7/assignees": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/assignees", Body: `{"op":"add","user":"rick"}`}, | ||
| 121 | + "PATCH /api/v1/repos/o/r/milestones/m1": {Method: "PATCH", Path: "/api/v1/repos/o/r/milestones/m1", Body: `{"state":"closed"}`}, | ||
| 122 | + } | ||
| 123 | + byKey := map[string]recordedRequest{} | ||
| 124 | + for _, c := range *calls { | ||
| 125 | + byKey[c.Method+" "+c.Path] = c | ||
| 126 | + } | ||
| 127 | + for key, w := range want { | ||
| 128 | + if got := byKey[key]; got.Body != w.Body { | ||
| 129 | + t.Errorf("%s body = %s, want %s", key, got.Body, w.Body) | ||
| 130 | + } | ||
| 131 | + } | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +func TestDeviceFlowClientRoundTrip(t *testing.T) { | ||
| 135 | + var sawAuth bool | ||
| 136 | + var tokenPathCalls int | ||
| 137 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 138 | + if h := r.Header.Get("Authorization"); h != "" { | ||
| 139 | + sawAuth = true | ||
| 140 | + } | ||
| 141 | + switch { | ||
| 142 | + case r.Method == "POST" && r.URL.Path == "/api/v1/device/code": | ||
| 143 | + _, _ = w.Write([]byte(`{"device_code":"dc","user_code":"ABCD-EFGH","verification_url":"https://x/login/device","verification_uri_complete":"https://x/login/device?user_code=ABCD-EFGH","expires_in":600,"interval":1}`)) | ||
| 144 | + case r.Method == "POST" && r.URL.Path == "/api/v1/device/token": | ||
| 145 | + tokenPathCalls++ | ||
| 146 | + if tokenPathCalls == 1 { | ||
| 147 | + w.WriteHeader(http.StatusBadRequest) | ||
| 148 | + _, _ = w.Write([]byte(`{"error":{"code":"authorization_pending","message":"keep polling"}}`)) | ||
| 149 | + return | ||
| 150 | + } | ||
| 151 | + _, _ = w.Write([]byte(`{"access_token":"rickub_pat_minted","token_type":"bearer","scope":"all"}`)) | ||
| 152 | + default: | ||
| 153 | + w.WriteHeader(http.StatusNotFound) | ||
| 154 | + } | ||
| 155 | + })) | ||
| 156 | + defer srv.Close() | ||
| 157 | + | ||
| 158 | + c := New(srv.URL, "") // tokenless: the device code is the credential | ||
| 159 | + start, err := c.StartDeviceLogin(context.Background(), "all", "test client") | ||
| 160 | + if err != nil || start.UserCode != "ABCD-EFGH" || start.Interval != 1 { | ||
| 161 | + t.Fatalf("StartDeviceLogin: %+v err=%v", start, err) | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + // First poll: pending → *APIError with the machine code. | ||
| 165 | + if _, err := c.PollDeviceToken(context.Background(), start.DeviceCode); err == nil { | ||
| 166 | + t.Fatal("first poll should error") | ||
| 167 | + } else { | ||
| 168 | + var apiErr *APIError | ||
| 169 | + if !errors.As(err, &apiErr) || apiErr.Code != "authorization_pending" { | ||
| 170 | + t.Fatalf("pending error: %v", err) | ||
| 171 | + } | ||
| 172 | + } | ||
| 173 | + | ||
| 174 | + // Second poll: minted token. | ||
| 175 | + tok, err := c.PollDeviceToken(context.Background(), start.DeviceCode) | ||
| 176 | + if err != nil || tok.AccessToken != "rickub_pat_minted" { | ||
| 177 | + t.Fatalf("PollDeviceToken: %+v err=%v", tok, err) | ||
| 178 | + } | ||
| 179 | + if sawAuth { | ||
| 180 | + t.Error("device endpoints must not send an Authorization header on a tokenless client") | ||
| 181 | + } | ||
| 182 | +} | ||
added
internal/api/models.go +367 -0 | new file mode 100644 | ||
| @@ -0,0 +1,367 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +// Types mirror the schemas of the rickub JSON API (`/api/v1`). Clients must | |
| 4 | +// ignore unknown JSON fields (additive changes may appear within v1), which | |
| 5 | +// json unmarshalling does by default. | |
| 6 | + | |
| 7 | +// User is the identity that owns the presented PAT. | |
| 8 | +type User struct { | |
| 9 | + Handle string `json:"handle"` | |
| 10 | + DisplayName string `json:"display_name"` | |
| 11 | + Email string `json:"email"` | |
| 12 | + EmailVerified bool `json:"email_verified"` | |
| 13 | + Superadmin bool `json:"superadmin"` | |
| 14 | +} | |
| 15 | + | |
| 16 | +// Repo is a repository. | |
| 17 | +type Repo struct { | |
| 18 | + Owner string `json:"owner"` | |
| 19 | + Name string `json:"name"` | |
| 20 | + FullName string `json:"full_name"` | |
| 21 | + Visibility string `json:"visibility"` | |
| 22 | + Description string `json:"description"` | |
| 23 | + DefaultBranch string `json:"default_branch"` | |
| 24 | + Fork bool `json:"fork"` | |
| 25 | + ForkedFromOwner string `json:"forked_from_owner"` | |
| 26 | + ForkedFromName string `json:"forked_from_name"` | |
| 27 | + CreatedAt string `json:"created_at"` | |
| 28 | + URL string `json:"url"` | |
| 29 | +} | |
| 30 | + | |
| 31 | +// RepoCreate is the create-repo request body. | |
| 32 | +type RepoCreate struct { | |
| 33 | + Owner string `json:"owner,omitempty"` | |
| 34 | + Name string `json:"name"` | |
| 35 | + Visibility string `json:"visibility,omitempty"` | |
| 36 | + Description string `json:"description,omitempty"` | |
| 37 | +} | |
| 38 | + | |
| 39 | +// RepoUpdate is the patch-repo request body. | |
| 40 | +type RepoUpdate struct { | |
| 41 | + Visibility *string `json:"visibility,omitempty"` | |
| 42 | + Description *string `json:"description,omitempty"` | |
| 43 | + DefaultBranch *string `json:"default_branch,omitempty"` | |
| 44 | +} | |
| 45 | + | |
| 46 | +// Collaborator is a repo collaborator grant. | |
| 47 | +type Collaborator struct { | |
| 48 | + Handle string `json:"handle"` | |
| 49 | + DisplayName string `json:"display_name"` | |
| 50 | + Permission string `json:"permission"` | |
| 51 | +} | |
| 52 | + | |
| 53 | +// MergeRequest is a merge request (a.k.a. pull request). | |
| 54 | +type MergeRequest struct { | |
| 55 | + Number int `json:"number"` | |
| 56 | + Title string `json:"title"` | |
| 57 | + Body string `json:"body"` | |
| 58 | + State string `json:"state"` | |
| 59 | + Author string `json:"author"` | |
| 60 | + BaseBranch string `json:"base_branch"` | |
| 61 | + HeadBranch string `json:"head_branch"` | |
| 62 | + HeadOwner string `json:"head_owner"` | |
| 63 | + HeadRepo string `json:"head_repo"` | |
| 64 | + CrossRepo bool `json:"cross_repo"` | |
| 65 | + MergeSHA string `json:"merge_sha"` | |
| 66 | + CreatedAt string `json:"created_at"` | |
| 67 | + MergedAt string `json:"merged_at"` | |
| 68 | + URL string `json:"url"` | |
| 69 | +} | |
| 70 | + | |
| 71 | +// MergeRequestDetail adds comments/reviewers/assignees/reviews. | |
| 72 | +type MergeRequestDetail struct { | |
| 73 | + MergeRequest | |
| 74 | + Comments []Comment `json:"comments"` | |
| 75 | + Reviewers []Subject `json:"reviewers"` | |
| 76 | + Assignees []Subject `json:"assignees"` | |
| 77 | + Reviews []Review `json:"reviews"` | |
| 78 | +} | |
| 79 | + | |
| 80 | +// MergeRequestCreate is the open-MR request body. | |
| 81 | +type MergeRequestCreate struct { | |
| 82 | + Base string `json:"base"` | |
| 83 | + Head string `json:"head"` | |
| 84 | + Title string `json:"title"` | |
| 85 | + Body string `json:"body,omitempty"` | |
| 86 | + HeadOwner string `json:"head_owner,omitempty"` | |
| 87 | + HeadRepo string `json:"head_repo,omitempty"` | |
| 88 | +} | |
| 89 | + | |
| 90 | +// Comment is an MR comment. | |
| 91 | +type Comment struct { | |
| 92 | + Author string `json:"author"` | |
| 93 | + Body string `json:"body"` | |
| 94 | + CreatedAt string `json:"created_at"` | |
| 95 | +} | |
| 96 | + | |
| 97 | +// Subject is a reviewer/assignee (user or team). | |
| 98 | +type Subject struct { | |
| 99 | + Type string `json:"type"` | |
| 100 | + Name string `json:"name"` | |
| 101 | + Label string `json:"label"` | |
| 102 | +} | |
| 103 | + | |
| 104 | +// Review is a review verdict. | |
| 105 | +type Review struct { | |
| 106 | + Reviewer string `json:"reviewer"` | |
| 107 | + Verdict string `json:"verdict"` | |
| 108 | + UpdatedAt string `json:"updated_at"` | |
| 109 | +} | |
| 110 | + | |
| 111 | +// RunStep is a single step's status. | |
| 112 | +type RunStep struct { | |
| 113 | + Ordinal int `json:"ordinal"` | |
| 114 | + Name string `json:"name"` | |
| 115 | + Status string `json:"status"` | |
| 116 | +} | |
| 117 | + | |
| 118 | +// RunJob is a job with its steps. | |
| 119 | +type RunJob struct { | |
| 120 | + Name string `json:"name"` | |
| 121 | + Status string `json:"status"` | |
| 122 | + Steps []RunStep `json:"steps"` | |
| 123 | +} | |
| 124 | + | |
| 125 | +// Run is a workflow run. | |
| 126 | +type Run struct { | |
| 127 | + Number int `json:"number"` | |
| 128 | + Workflow string `json:"workflow"` | |
| 129 | + Event string `json:"event"` | |
| 130 | + Branch string `json:"branch"` | |
| 131 | + Status string `json:"status"` | |
| 132 | + HeadSHA string `json:"head_sha"` | |
| 133 | + SecretsWithheld bool `json:"secrets_withheld"` | |
| 134 | + CreatedAt string `json:"created_at"` | |
| 135 | + UpdatedAt string `json:"updated_at"` | |
| 136 | + URL string `json:"url"` | |
| 137 | + Jobs []RunJob `json:"jobs"` | |
| 138 | +} | |
| 139 | + | |
| 140 | +// RunLogsJob is one job's accumulated log. | |
| 141 | +type RunLogsJob struct { | |
| 142 | + Name string `json:"name"` | |
| 143 | + Status string `json:"status"` | |
| 144 | + Log string `json:"log"` | |
| 145 | +} | |
| 146 | + | |
| 147 | +// RunLogs is the accumulated logs for a run. | |
| 148 | +type RunLogs struct { | |
| 149 | + Number int `json:"number"` | |
| 150 | + Status string `json:"status"` | |
| 151 | + Jobs []RunLogsJob `json:"jobs"` | |
| 152 | +} | |
| 153 | + | |
| 154 | +// DispatchResult is the response of an actions dispatch. | |
| 155 | +type DispatchResult struct { | |
| 156 | + Dispatched int `json:"dispatched"` | |
| 157 | + Runs []Run `json:"runs"` | |
| 158 | +} | |
| 159 | + | |
| 160 | +// Org is basic org info. | |
| 161 | +type Org struct { | |
| 162 | + Handle string `json:"handle"` | |
| 163 | + DisplayName string `json:"display_name"` | |
| 164 | + CreatedAt string `json:"created_at"` | |
| 165 | +} | |
| 166 | + | |
| 167 | +// OrgMember is an org membership. | |
| 168 | +type OrgMember struct { | |
| 169 | + Handle string `json:"handle"` | |
| 170 | + DisplayName string `json:"display_name"` | |
| 171 | + Role string `json:"role"` | |
| 172 | +} | |
| 173 | + | |
| 174 | +// Team is an org team. | |
| 175 | +type Team struct { | |
| 176 | + Slug string `json:"slug"` | |
| 177 | + Name string `json:"name"` | |
| 178 | + Description string `json:"description"` | |
| 179 | + SubTeam bool `json:"sub_team"` | |
| 180 | +} | |
| 181 | + | |
| 182 | +// Ref is a branch or tag. | |
| 183 | +type Ref struct { | |
| 184 | + Name string `json:"name"` | |
| 185 | + SHA string `json:"sha"` | |
| 186 | +} | |
| 187 | + | |
| 188 | +// Refs is the ref inventory. | |
| 189 | +type Refs struct { | |
| 190 | + Branches []Ref `json:"branches"` | |
| 191 | + Tags []Ref `json:"tags"` | |
| 192 | + DefaultBranch string `json:"default_branch"` | |
| 193 | +} | |
| 194 | + | |
| 195 | +// TreeEntry is one entry in a directory listing. | |
| 196 | +type TreeEntry struct { | |
| 197 | + Name string `json:"name"` | |
| 198 | + Type string `json:"type"` // blob | tree | |
| 199 | + Mode string `json:"mode"` | |
| 200 | + SHA string `json:"sha"` | |
| 201 | + Size int `json:"size"` | |
| 202 | +} | |
| 203 | + | |
| 204 | +// Blob is a file's content. | |
| 205 | +type Blob struct { | |
| 206 | + Path string `json:"path"` | |
| 207 | + Size int `json:"size"` | |
| 208 | + IsBinary bool `json:"is_binary"` | |
| 209 | + Truncated bool `json:"truncated"` | |
| 210 | + Content string `json:"content"` | |
| 211 | +} | |
| 212 | + | |
| 213 | +// Contents is a dir listing or file. | |
| 214 | +type Contents struct { | |
| 215 | + Type string `json:"type"` // dir | file | |
| 216 | + Ref string `json:"ref"` | |
| 217 | + Path string `json:"path"` | |
| 218 | + Entries []TreeEntry `json:"entries"` | |
| 219 | + File *Blob `json:"file"` | |
| 220 | +} | |
| 221 | + | |
| 222 | +// Commit is a commit summary. | |
| 223 | +type Commit struct { | |
| 224 | + SHA string `json:"sha"` | |
| 225 | + Short string `json:"short"` | |
| 226 | + Author string `json:"author"` | |
| 227 | + Date string `json:"date"` | |
| 228 | + Subject string `json:"subject"` | |
| 229 | +} | |
| 230 | + | |
| 231 | +// DiffFile is one file's diff. | |
| 232 | +type DiffFile struct { | |
| 233 | + Path string `json:"path"` | |
| 234 | + Status string `json:"status"` | |
| 235 | + Additions int `json:"additions"` | |
| 236 | + Deletions int `json:"deletions"` | |
| 237 | + Patch string `json:"patch"` | |
| 238 | +} | |
| 239 | + | |
| 240 | +// CommitDetail is a single commit's metadata + diff. | |
| 241 | +type CommitDetail struct { | |
| 242 | + SHA string `json:"sha"` | |
| 243 | + Short string `json:"short"` | |
| 244 | + AuthorName string `json:"author_name"` | |
| 245 | + AuthorEmail string `json:"author_email"` | |
| 246 | + AuthorDate string `json:"author_date"` | |
| 247 | + Subject string `json:"subject"` | |
| 248 | + Body string `json:"body"` | |
| 249 | + Parents []string `json:"parents"` | |
| 250 | + Files []DiffFile `json:"files"` | |
| 251 | + Truncated bool `json:"truncated"` | |
| 252 | +} | |
| 253 | + | |
| 254 | +// Comparison is a base...head comparison. | |
| 255 | +type Comparison struct { | |
| 256 | + MergeBase string `json:"merge_base"` | |
| 257 | + AheadBy int `json:"ahead_by"` | |
| 258 | + BehindBy int `json:"behind_by"` | |
| 259 | + Commits []Commit `json:"commits"` | |
| 260 | + Files []DiffFile `json:"files"` | |
| 261 | + Truncated bool `json:"truncated"` | |
| 262 | +} | |
| 263 | + | |
| 264 | +// Page is the pagination envelope shared by list responses. | |
| 265 | +type Page struct { | |
| 266 | + Page int `json:"page"` | |
| 267 | + PerPage int `json:"per_page"` | |
| 268 | + HasNext bool `json:"has_next"` | |
| 269 | +} | |
| 270 | + | |
| 271 | +// RepoPage is a page of repositories. | |
| 272 | +type RepoPage struct { | |
| 273 | + Page | |
| 274 | + Items []Repo `json:"items"` | |
| 275 | +} | |
| 276 | + | |
| 277 | +// MergeRequestPage is a page of merge requests. | |
| 278 | +type MergeRequestPage struct { | |
| 279 | + Page | |
| 280 | + Items []MergeRequest `json:"items"` | |
| 281 | +} | |
| 282 | + | |
| 283 | +// RunPage is a page of runs. | |
| 284 | +type RunPage struct { | |
| 285 | + Page | |
| 286 | + Items []Run `json:"items"` | |
| 287 | +} | |
| 288 | + | |
| 289 | +// CommitPage is a page of commits. | |
| 290 | +type CommitPage struct { | |
| 291 | + Page | |
| 292 | + Items []Commit `json:"items"` | |
| 293 | +} | |
| 294 | + | |
| 295 | +// --- issues / labels / milestones --------------------------------------------- | |
| 296 | + | |
| 297 | +// Label is a repo label. | |
| 298 | +type Label struct { | |
| 299 | + ID string `json:"id"` | |
| 300 | + Name string `json:"name"` | |
| 301 | + Color string `json:"color"` | |
| 302 | +} | |
| 303 | + | |
| 304 | +// Milestone is a repo milestone with progress counts. | |
| 305 | +type Milestone struct { | |
| 306 | + ID string `json:"id"` | |
| 307 | + Title string `json:"title"` | |
| 308 | + Description string `json:"description"` | |
| 309 | + State string `json:"state"` | |
| 310 | + DueOn *string `json:"due_on,omitempty"` | |
| 311 | + CreatedAt string `json:"created_at"` | |
| 312 | + OpenCount int `json:"open_issues"` | |
| 313 | + ClosedCount int `json:"closed_issues"` | |
| 314 | +} | |
| 315 | + | |
| 316 | +// Issue is an issue list row. | |
| 317 | +type Issue struct { | |
| 318 | + Number int `json:"number"` | |
| 319 | + Title string `json:"title"` | |
| 320 | + State string `json:"state"` | |
| 321 | + Author string `json:"author"` | |
| 322 | + Labels []Label `json:"labels"` | |
| 323 | + Milestone *Milestone `json:"milestone,omitempty"` | |
| 324 | + CreatedAt string `json:"created_at"` | |
| 325 | + ClosedAt *string `json:"closed_at,omitempty"` | |
| 326 | + URL string `json:"url"` | |
| 327 | +} | |
| 328 | + | |
| 329 | +// Assignee is a user assigned to an issue. | |
| 330 | +type Assignee struct { | |
| 331 | + Handle string `json:"handle"` | |
| 332 | + DisplayName string `json:"display_name,omitempty"` | |
| 333 | +} | |
| 334 | + | |
| 335 | +// IssueDetail adds body, comments, and assignees. | |
| 336 | +type IssueDetail struct { | |
| 337 | + Issue | |
| 338 | + Body string `json:"body"` | |
| 339 | + Comments []Comment `json:"comments"` | |
| 340 | + Assignees []Assignee `json:"assignees"` | |
| 341 | +} | |
| 342 | + | |
| 343 | +// IssuePage is a page of issues. | |
| 344 | +type IssuePage struct { | |
| 345 | + Page | |
| 346 | + Items []Issue `json:"items"` | |
| 347 | +} | |
| 348 | + | |
| 349 | +// --- device flow (web login) -------------------------------------------------- | |
| 350 | + | |
| 351 | +// DeviceCodeStart is the /device/code response: where to approve and how often | |
| 352 | +// to poll. | |
| 353 | +type DeviceCodeStart struct { | |
| 354 | + DeviceCode string `json:"device_code"` | |
| 355 | + UserCode string `json:"user_code"` | |
| 356 | + VerificationURL string `json:"verification_url"` | |
| 357 | + VerificationURIComplete string `json:"verification_uri_complete"` | |
| 358 | + ExpiresIn int `json:"expires_in"` | |
| 359 | + Interval int `json:"interval"` | |
| 360 | +} | |
| 361 | + | |
| 362 | +// DeviceToken is the successful /device/token poll: a freshly minted PAT. | |
| 363 | +type DeviceToken struct { | |
| 364 | + AccessToken string `json:"access_token"` | |
| 365 | + TokenType string `json:"token_type"` | |
| 366 | + Scope string `json:"scope"` | |
| 367 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,367 @@ | |||
| 1 | +package api | ||
| 2 | + | ||
| 3 | +// Types mirror the schemas of the rickub JSON API (`/api/v1`). Clients must | ||
| 4 | +// ignore unknown JSON fields (additive changes may appear within v1), which | ||
| 5 | +// json unmarshalling does by default. | ||
| 6 | + | ||
| 7 | +// User is the identity that owns the presented PAT. | ||
| 8 | +type User struct { | ||
| 9 | + Handle string `json:"handle"` | ||
| 10 | + DisplayName string `json:"display_name"` | ||
| 11 | + Email string `json:"email"` | ||
| 12 | + EmailVerified bool `json:"email_verified"` | ||
| 13 | + Superadmin bool `json:"superadmin"` | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +// Repo is a repository. | ||
| 17 | +type Repo struct { | ||
| 18 | + Owner string `json:"owner"` | ||
| 19 | + Name string `json:"name"` | ||
| 20 | + FullName string `json:"full_name"` | ||
| 21 | + Visibility string `json:"visibility"` | ||
| 22 | + Description string `json:"description"` | ||
| 23 | + DefaultBranch string `json:"default_branch"` | ||
| 24 | + Fork bool `json:"fork"` | ||
| 25 | + ForkedFromOwner string `json:"forked_from_owner"` | ||
| 26 | + ForkedFromName string `json:"forked_from_name"` | ||
| 27 | + CreatedAt string `json:"created_at"` | ||
| 28 | + URL string `json:"url"` | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +// RepoCreate is the create-repo request body. | ||
| 32 | +type RepoCreate struct { | ||
| 33 | + Owner string `json:"owner,omitempty"` | ||
| 34 | + Name string `json:"name"` | ||
| 35 | + Visibility string `json:"visibility,omitempty"` | ||
| 36 | + Description string `json:"description,omitempty"` | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +// RepoUpdate is the patch-repo request body. | ||
| 40 | +type RepoUpdate struct { | ||
| 41 | + Visibility *string `json:"visibility,omitempty"` | ||
| 42 | + Description *string `json:"description,omitempty"` | ||
| 43 | + DefaultBranch *string `json:"default_branch,omitempty"` | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +// Collaborator is a repo collaborator grant. | ||
| 47 | +type Collaborator struct { | ||
| 48 | + Handle string `json:"handle"` | ||
| 49 | + DisplayName string `json:"display_name"` | ||
| 50 | + Permission string `json:"permission"` | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +// MergeRequest is a merge request (a.k.a. pull request). | ||
| 54 | +type MergeRequest struct { | ||
| 55 | + Number int `json:"number"` | ||
| 56 | + Title string `json:"title"` | ||
| 57 | + Body string `json:"body"` | ||
| 58 | + State string `json:"state"` | ||
| 59 | + Author string `json:"author"` | ||
| 60 | + BaseBranch string `json:"base_branch"` | ||
| 61 | + HeadBranch string `json:"head_branch"` | ||
| 62 | + HeadOwner string `json:"head_owner"` | ||
| 63 | + HeadRepo string `json:"head_repo"` | ||
| 64 | + CrossRepo bool `json:"cross_repo"` | ||
| 65 | + MergeSHA string `json:"merge_sha"` | ||
| 66 | + CreatedAt string `json:"created_at"` | ||
| 67 | + MergedAt string `json:"merged_at"` | ||
| 68 | + URL string `json:"url"` | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +// MergeRequestDetail adds comments/reviewers/assignees/reviews. | ||
| 72 | +type MergeRequestDetail struct { | ||
| 73 | + MergeRequest | ||
| 74 | + Comments []Comment `json:"comments"` | ||
| 75 | + Reviewers []Subject `json:"reviewers"` | ||
| 76 | + Assignees []Subject `json:"assignees"` | ||
| 77 | + Reviews []Review `json:"reviews"` | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +// MergeRequestCreate is the open-MR request body. | ||
| 81 | +type MergeRequestCreate struct { | ||
| 82 | + Base string `json:"base"` | ||
| 83 | + Head string `json:"head"` | ||
| 84 | + Title string `json:"title"` | ||
| 85 | + Body string `json:"body,omitempty"` | ||
| 86 | + HeadOwner string `json:"head_owner,omitempty"` | ||
| 87 | + HeadRepo string `json:"head_repo,omitempty"` | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +// Comment is an MR comment. | ||
| 91 | +type Comment struct { | ||
| 92 | + Author string `json:"author"` | ||
| 93 | + Body string `json:"body"` | ||
| 94 | + CreatedAt string `json:"created_at"` | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +// Subject is a reviewer/assignee (user or team). | ||
| 98 | +type Subject struct { | ||
| 99 | + Type string `json:"type"` | ||
| 100 | + Name string `json:"name"` | ||
| 101 | + Label string `json:"label"` | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +// Review is a review verdict. | ||
| 105 | +type Review struct { | ||
| 106 | + Reviewer string `json:"reviewer"` | ||
| 107 | + Verdict string `json:"verdict"` | ||
| 108 | + UpdatedAt string `json:"updated_at"` | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +// RunStep is a single step's status. | ||
| 112 | +type RunStep struct { | ||
| 113 | + Ordinal int `json:"ordinal"` | ||
| 114 | + Name string `json:"name"` | ||
| 115 | + Status string `json:"status"` | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +// RunJob is a job with its steps. | ||
| 119 | +type RunJob struct { | ||
| 120 | + Name string `json:"name"` | ||
| 121 | + Status string `json:"status"` | ||
| 122 | + Steps []RunStep `json:"steps"` | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +// Run is a workflow run. | ||
| 126 | +type Run struct { | ||
| 127 | + Number int `json:"number"` | ||
| 128 | + Workflow string `json:"workflow"` | ||
| 129 | + Event string `json:"event"` | ||
| 130 | + Branch string `json:"branch"` | ||
| 131 | + Status string `json:"status"` | ||
| 132 | + HeadSHA string `json:"head_sha"` | ||
| 133 | + SecretsWithheld bool `json:"secrets_withheld"` | ||
| 134 | + CreatedAt string `json:"created_at"` | ||
| 135 | + UpdatedAt string `json:"updated_at"` | ||
| 136 | + URL string `json:"url"` | ||
| 137 | + Jobs []RunJob `json:"jobs"` | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +// RunLogsJob is one job's accumulated log. | ||
| 141 | +type RunLogsJob struct { | ||
| 142 | + Name string `json:"name"` | ||
| 143 | + Status string `json:"status"` | ||
| 144 | + Log string `json:"log"` | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +// RunLogs is the accumulated logs for a run. | ||
| 148 | +type RunLogs struct { | ||
| 149 | + Number int `json:"number"` | ||
| 150 | + Status string `json:"status"` | ||
| 151 | + Jobs []RunLogsJob `json:"jobs"` | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +// DispatchResult is the response of an actions dispatch. | ||
| 155 | +type DispatchResult struct { | ||
| 156 | + Dispatched int `json:"dispatched"` | ||
| 157 | + Runs []Run `json:"runs"` | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +// Org is basic org info. | ||
| 161 | +type Org struct { | ||
| 162 | + Handle string `json:"handle"` | ||
| 163 | + DisplayName string `json:"display_name"` | ||
| 164 | + CreatedAt string `json:"created_at"` | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +// OrgMember is an org membership. | ||
| 168 | +type OrgMember struct { | ||
| 169 | + Handle string `json:"handle"` | ||
| 170 | + DisplayName string `json:"display_name"` | ||
| 171 | + Role string `json:"role"` | ||
| 172 | +} | ||
| 173 | + | ||
| 174 | +// Team is an org team. | ||
| 175 | +type Team struct { | ||
| 176 | + Slug string `json:"slug"` | ||
| 177 | + Name string `json:"name"` | ||
| 178 | + Description string `json:"description"` | ||
| 179 | + SubTeam bool `json:"sub_team"` | ||
| 180 | +} | ||
| 181 | + | ||
| 182 | +// Ref is a branch or tag. | ||
| 183 | +type Ref struct { | ||
| 184 | + Name string `json:"name"` | ||
| 185 | + SHA string `json:"sha"` | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +// Refs is the ref inventory. | ||
| 189 | +type Refs struct { | ||
| 190 | + Branches []Ref `json:"branches"` | ||
| 191 | + Tags []Ref `json:"tags"` | ||
| 192 | + DefaultBranch string `json:"default_branch"` | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +// TreeEntry is one entry in a directory listing. | ||
| 196 | +type TreeEntry struct { | ||
| 197 | + Name string `json:"name"` | ||
| 198 | + Type string `json:"type"` // blob | tree | ||
| 199 | + Mode string `json:"mode"` | ||
| 200 | + SHA string `json:"sha"` | ||
| 201 | + Size int `json:"size"` | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +// Blob is a file's content. | ||
| 205 | +type Blob struct { | ||
| 206 | + Path string `json:"path"` | ||
| 207 | + Size int `json:"size"` | ||
| 208 | + IsBinary bool `json:"is_binary"` | ||
| 209 | + Truncated bool `json:"truncated"` | ||
| 210 | + Content string `json:"content"` | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +// Contents is a dir listing or file. | ||
| 214 | +type Contents struct { | ||
| 215 | + Type string `json:"type"` // dir | file | ||
| 216 | + Ref string `json:"ref"` | ||
| 217 | + Path string `json:"path"` | ||
| 218 | + Entries []TreeEntry `json:"entries"` | ||
| 219 | + File *Blob `json:"file"` | ||
| 220 | +} | ||
| 221 | + | ||
| 222 | +// Commit is a commit summary. | ||
| 223 | +type Commit struct { | ||
| 224 | + SHA string `json:"sha"` | ||
| 225 | + Short string `json:"short"` | ||
| 226 | + Author string `json:"author"` | ||
| 227 | + Date string `json:"date"` | ||
| 228 | + Subject string `json:"subject"` | ||
| 229 | +} | ||
| 230 | + | ||
| 231 | +// DiffFile is one file's diff. | ||
| 232 | +type DiffFile struct { | ||
| 233 | + Path string `json:"path"` | ||
| 234 | + Status string `json:"status"` | ||
| 235 | + Additions int `json:"additions"` | ||
| 236 | + Deletions int `json:"deletions"` | ||
| 237 | + Patch string `json:"patch"` | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +// CommitDetail is a single commit's metadata + diff. | ||
| 241 | +type CommitDetail struct { | ||
| 242 | + SHA string `json:"sha"` | ||
| 243 | + Short string `json:"short"` | ||
| 244 | + AuthorName string `json:"author_name"` | ||
| 245 | + AuthorEmail string `json:"author_email"` | ||
| 246 | + AuthorDate string `json:"author_date"` | ||
| 247 | + Subject string `json:"subject"` | ||
| 248 | + Body string `json:"body"` | ||
| 249 | + Parents []string `json:"parents"` | ||
| 250 | + Files []DiffFile `json:"files"` | ||
| 251 | + Truncated bool `json:"truncated"` | ||
| 252 | +} | ||
| 253 | + | ||
| 254 | +// Comparison is a base...head comparison. | ||
| 255 | +type Comparison struct { | ||
| 256 | + MergeBase string `json:"merge_base"` | ||
| 257 | + AheadBy int `json:"ahead_by"` | ||
| 258 | + BehindBy int `json:"behind_by"` | ||
| 259 | + Commits []Commit `json:"commits"` | ||
| 260 | + Files []DiffFile `json:"files"` | ||
| 261 | + Truncated bool `json:"truncated"` | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | +// Page is the pagination envelope shared by list responses. | ||
| 265 | +type Page struct { | ||
| 266 | + Page int `json:"page"` | ||
| 267 | + PerPage int `json:"per_page"` | ||
| 268 | + HasNext bool `json:"has_next"` | ||
| 269 | +} | ||
| 270 | + | ||
| 271 | +// RepoPage is a page of repositories. | ||
| 272 | +type RepoPage struct { | ||
| 273 | + Page | ||
| 274 | + Items []Repo `json:"items"` | ||
| 275 | +} | ||
| 276 | + | ||
| 277 | +// MergeRequestPage is a page of merge requests. | ||
| 278 | +type MergeRequestPage struct { | ||
| 279 | + Page | ||
| 280 | + Items []MergeRequest `json:"items"` | ||
| 281 | +} | ||
| 282 | + | ||
| 283 | +// RunPage is a page of runs. | ||
| 284 | +type RunPage struct { | ||
| 285 | + Page | ||
| 286 | + Items []Run `json:"items"` | ||
| 287 | +} | ||
| 288 | + | ||
| 289 | +// CommitPage is a page of commits. | ||
| 290 | +type CommitPage struct { | ||
| 291 | + Page | ||
| 292 | + Items []Commit `json:"items"` | ||
| 293 | +} | ||
| 294 | + | ||
| 295 | +// --- issues / labels / milestones --------------------------------------------- | ||
| 296 | + | ||
| 297 | +// Label is a repo label. | ||
| 298 | +type Label struct { | ||
| 299 | + ID string `json:"id"` | ||
| 300 | + Name string `json:"name"` | ||
| 301 | + Color string `json:"color"` | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +// Milestone is a repo milestone with progress counts. | ||
| 305 | +type Milestone struct { | ||
| 306 | + ID string `json:"id"` | ||
| 307 | + Title string `json:"title"` | ||
| 308 | + Description string `json:"description"` | ||
| 309 | + State string `json:"state"` | ||
| 310 | + DueOn *string `json:"due_on,omitempty"` | ||
| 311 | + CreatedAt string `json:"created_at"` | ||
| 312 | + OpenCount int `json:"open_issues"` | ||
| 313 | + ClosedCount int `json:"closed_issues"` | ||
| 314 | +} | ||
| 315 | + | ||
| 316 | +// Issue is an issue list row. | ||
| 317 | +type Issue struct { | ||
| 318 | + Number int `json:"number"` | ||
| 319 | + Title string `json:"title"` | ||
| 320 | + State string `json:"state"` | ||
| 321 | + Author string `json:"author"` | ||
| 322 | + Labels []Label `json:"labels"` | ||
| 323 | + Milestone *Milestone `json:"milestone,omitempty"` | ||
| 324 | + CreatedAt string `json:"created_at"` | ||
| 325 | + ClosedAt *string `json:"closed_at,omitempty"` | ||
| 326 | + URL string `json:"url"` | ||
| 327 | +} | ||
| 328 | + | ||
| 329 | +// Assignee is a user assigned to an issue. | ||
| 330 | +type Assignee struct { | ||
| 331 | + Handle string `json:"handle"` | ||
| 332 | + DisplayName string `json:"display_name,omitempty"` | ||
| 333 | +} | ||
| 334 | + | ||
| 335 | +// IssueDetail adds body, comments, and assignees. | ||
| 336 | +type IssueDetail struct { | ||
| 337 | + Issue | ||
| 338 | + Body string `json:"body"` | ||
| 339 | + Comments []Comment `json:"comments"` | ||
| 340 | + Assignees []Assignee `json:"assignees"` | ||
| 341 | +} | ||
| 342 | + | ||
| 343 | +// IssuePage is a page of issues. | ||
| 344 | +type IssuePage struct { | ||
| 345 | + Page | ||
| 346 | + Items []Issue `json:"items"` | ||
| 347 | +} | ||
| 348 | + | ||
| 349 | +// --- device flow (web login) -------------------------------------------------- | ||
| 350 | + | ||
| 351 | +// DeviceCodeStart is the /device/code response: where to approve and how often | ||
| 352 | +// to poll. | ||
| 353 | +type DeviceCodeStart struct { | ||
| 354 | + DeviceCode string `json:"device_code"` | ||
| 355 | + UserCode string `json:"user_code"` | ||
| 356 | + VerificationURL string `json:"verification_url"` | ||
| 357 | + VerificationURIComplete string `json:"verification_uri_complete"` | ||
| 358 | + ExpiresIn int `json:"expires_in"` | ||
| 359 | + Interval int `json:"interval"` | ||
| 360 | +} | ||
| 361 | + | ||
| 362 | +// DeviceToken is the successful /device/token poll: a freshly minted PAT. | ||
| 363 | +type DeviceToken struct { | ||
| 364 | + AccessToken string `json:"access_token"` | ||
| 365 | + TokenType string `json:"token_type"` | ||
| 366 | + Scope string `json:"scope"` | ||
| 367 | +} | ||
added
internal/config/config.go +252 -0 | new file mode 100644 | ||
| @@ -0,0 +1,252 @@ | ||
| 1 | +// Package config handles rickub CLI configuration and token storage. | |
| 2 | +// | |
| 3 | +// Configuration is persisted to ~/.config/rickub/config.yaml (mode 0600) and | |
| 4 | +// holds the active API host plus, per host, the personal access token minted | |
| 5 | +// for it. Tokens are bound to the host they were issued against: a token saved | |
| 6 | +// for https://rickub.com is never sent to some other host that a --host flag or | |
| 7 | +// RICKUB_HOST happens to name. At runtime the effective host and token are | |
| 8 | +// resolved with a fixed precedence so a flag or environment variable can always | |
| 9 | +// override the stored config: | |
| 10 | +// | |
| 11 | +// host: --host flag → RICKUB_HOST env → config file → DefaultHost | |
| 12 | +// token: --token flag → RICKUB_TOKEN env → config file entry for that host | |
| 13 | +// | |
| 14 | +// A token supplied explicitly (flag or env) is honoured for whatever host is in | |
| 15 | +// effect — the caller asked for it. Only the stored token is host-bound. | |
| 16 | +package config | |
| 17 | + | |
| 18 | +import ( | |
| 19 | + "errors" | |
| 20 | + "fmt" | |
| 21 | + "io" | |
| 22 | + "net" | |
| 23 | + "net/url" | |
| 24 | + "os" | |
| 25 | + "path/filepath" | |
| 26 | + "strings" | |
| 27 | + | |
| 28 | + "gopkg.in/yaml.v3" | |
| 29 | +) | |
| 30 | + | |
| 31 | +// DefaultHost is the production API host used when nothing else is configured. | |
| 32 | +const DefaultHost = "https://rickub.com" | |
| 33 | + | |
| 34 | +// Environment variables consulted when resolving host/token. | |
| 35 | +const ( | |
| 36 | + EnvToken = "RICKUB_TOKEN" | |
| 37 | + EnvHost = "RICKUB_HOST" | |
| 38 | +) | |
| 39 | + | |
| 40 | +// HostConfig is the per-host state stored in the config file. | |
| 41 | +type HostConfig struct { | |
| 42 | + Token string `yaml:"token,omitempty"` | |
| 43 | +} | |
| 44 | + | |
| 45 | +// Config is the persisted CLI configuration. | |
| 46 | +type Config struct { | |
| 47 | + // Host is the active host, used when neither --host nor RICKUB_HOST is set. | |
| 48 | + Host string `yaml:"host,omitempty"` | |
| 49 | + // Hosts maps a normalized host URL to the credentials minted for it. | |
| 50 | + Hosts map[string]HostConfig `yaml:"hosts,omitempty"` | |
| 51 | +} | |
| 52 | + | |
| 53 | +// NormalizeHost canonicalizes a host URL for use as a config key and for | |
| 54 | +// comparing the effective host against the host a token was saved for. It trims | |
| 55 | +// surrounding space and trailing slashes and lowercases the scheme and | |
| 56 | +// authority (which are case-insensitive per RFC 3986); anything it cannot parse | |
| 57 | +// is returned trimmed but otherwise untouched. | |
| 58 | +func NormalizeHost(host string) string { | |
| 59 | + host = strings.TrimRight(strings.TrimSpace(host), "/") | |
| 60 | + if host == "" { | |
| 61 | + return "" | |
| 62 | + } | |
| 63 | + u, err := url.Parse(host) | |
| 64 | + if err != nil || u.Host == "" { | |
| 65 | + return host | |
| 66 | + } | |
| 67 | + u.Scheme = strings.ToLower(u.Scheme) | |
| 68 | + u.Host = strings.ToLower(u.Host) | |
| 69 | + return strings.TrimRight(u.String(), "/") | |
| 70 | +} | |
| 71 | + | |
| 72 | +// Path returns the config file path, honouring XDG_CONFIG_HOME. | |
| 73 | +func Path() (string, error) { | |
| 74 | + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { | |
| 75 | + return filepath.Join(xdg, "rickub", "config.yaml"), nil | |
| 76 | + } | |
| 77 | + home, err := os.UserHomeDir() | |
| 78 | + if err != nil { | |
| 79 | + return "", err | |
| 80 | + } | |
| 81 | + return filepath.Join(home, ".config", "rickub", "config.yaml"), nil | |
| 82 | +} | |
| 83 | + | |
| 84 | +// Load reads the config file. A missing file is not an error: it returns an | |
| 85 | +// empty Config so callers can rely on flag/env resolution alone. | |
| 86 | +func Load() (*Config, error) { | |
| 87 | + path, err := Path() | |
| 88 | + if err != nil { | |
| 89 | + return nil, err | |
| 90 | + } | |
| 91 | + return LoadFrom(path) | |
| 92 | +} | |
| 93 | + | |
| 94 | +// LoadFrom reads config from an explicit path (used by tests). | |
| 95 | +func LoadFrom(path string) (*Config, error) { | |
| 96 | + data, err := os.ReadFile(path) | |
| 97 | + if err != nil { | |
| 98 | + if errors.Is(err, os.ErrNotExist) { | |
| 99 | + return &Config{}, nil | |
| 100 | + } | |
| 101 | + return nil, err | |
| 102 | + } | |
| 103 | + var c Config | |
| 104 | + if err := yaml.Unmarshal(data, &c); err != nil { | |
| 105 | + return nil, fmt.Errorf("parse %s: %w", path, err) | |
| 106 | + } | |
| 107 | + return &c, nil | |
| 108 | +} | |
| 109 | + | |
| 110 | +// Save writes the config file (mode 0600), creating parent dirs as needed. | |
| 111 | +func (c *Config) Save() error { | |
| 112 | + path, err := Path() | |
| 113 | + if err != nil { | |
| 114 | + return err | |
| 115 | + } | |
| 116 | + return c.SaveTo(path) | |
| 117 | +} | |
| 118 | + | |
| 119 | +// SaveTo writes config to an explicit path (used by tests). | |
| 120 | +func (c *Config) SaveTo(path string) error { | |
| 121 | + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 122 | + return err | |
| 123 | + } | |
| 124 | + data, err := yaml.Marshal(c) | |
| 125 | + if err != nil { | |
| 126 | + return err | |
| 127 | + } | |
| 128 | + // Write 0600 so the token is not world-readable. | |
| 129 | + return os.WriteFile(path, data, 0o600) | |
| 130 | +} | |
| 131 | + | |
| 132 | +// TokenFor returns the stored token minted for host, or "" if none is stored | |
| 133 | +// for exactly that host. | |
| 134 | +func (c *Config) TokenFor(host string) string { | |
| 135 | + if c == nil { | |
| 136 | + return "" | |
| 137 | + } | |
| 138 | + return c.Hosts[NormalizeHost(host)].Token | |
| 139 | +} | |
| 140 | + | |
| 141 | +// SetToken stores token as the credential for host and makes host active. | |
| 142 | +func (c *Config) SetToken(host, token string) { | |
| 143 | + host = NormalizeHost(host) | |
| 144 | + if host == "" { | |
| 145 | + return | |
| 146 | + } | |
| 147 | + if c.Hosts == nil { | |
| 148 | + c.Hosts = make(map[string]HostConfig) | |
| 149 | + } | |
| 150 | + entry := c.Hosts[host] | |
| 151 | + entry.Token = token | |
| 152 | + c.Hosts[host] = entry | |
| 153 | + c.Host = host | |
| 154 | +} | |
| 155 | + | |
| 156 | +// ClearToken removes the stored token for host. It reports whether one was | |
| 157 | +// removed. | |
| 158 | +func (c *Config) ClearToken(host string) bool { | |
| 159 | + host = NormalizeHost(host) | |
| 160 | + if c == nil || c.Hosts == nil { | |
| 161 | + return false | |
| 162 | + } | |
| 163 | + entry, ok := c.Hosts[host] | |
| 164 | + if !ok || entry.Token == "" { | |
| 165 | + return false | |
| 166 | + } | |
| 167 | + entry.Token = "" | |
| 168 | + if entry == (HostConfig{}) { | |
| 169 | + delete(c.Hosts, host) | |
| 170 | + } else { | |
| 171 | + c.Hosts[host] = entry | |
| 172 | + } | |
| 173 | + return true | |
| 174 | +} | |
| 175 | + | |
| 176 | +// ResolveHost applies the host precedence: flag → env → config → default. | |
| 177 | +// The returned host is normalized and never has a trailing slash. | |
| 178 | +func ResolveHost(flagHost string, cfg *Config) string { | |
| 179 | + host := DefaultHost | |
| 180 | + if cfg != nil && cfg.Host != "" { | |
| 181 | + host = cfg.Host | |
| 182 | + } | |
| 183 | + if env := os.Getenv(EnvHost); env != "" { | |
| 184 | + host = env | |
| 185 | + } | |
| 186 | + if flagHost != "" { | |
| 187 | + host = flagHost | |
| 188 | + } | |
| 189 | + return NormalizeHost(host) | |
| 190 | +} | |
| 191 | + | |
| 192 | +// ResolveToken applies the token precedence: flag → env → stored token for | |
| 193 | +// host. An explicit flag or environment token is honoured for any host; the | |
| 194 | +// stored token is returned only when host matches the host it was saved for, so | |
| 195 | +// pointing --host / RICKUB_HOST at another server cannot leak it. | |
| 196 | +func ResolveToken(flagToken string, cfg *Config, host string) string { | |
| 197 | + if flagToken != "" { | |
| 198 | + return flagToken | |
| 199 | + } | |
| 200 | + if env := os.Getenv(EnvToken); env != "" { | |
| 201 | + return env | |
| 202 | + } | |
| 203 | + return cfg.TokenFor(host) | |
| 204 | +} | |
| 205 | + | |
| 206 | +// IsLoopbackHost reports whether host addresses the local machine, where | |
| 207 | +// sending a token over plain HTTP does not put it on the wire. | |
| 208 | +func IsLoopbackHost(host string) bool { | |
| 209 | + h := NormalizeHost(host) | |
| 210 | + u, err := url.Parse(h) | |
| 211 | + if err != nil { | |
| 212 | + return false | |
| 213 | + } | |
| 214 | + hostname := u.Hostname() | |
| 215 | + if hostname == "" { | |
| 216 | + hostname = h | |
| 217 | + } | |
| 218 | + if strings.EqualFold(hostname, "localhost") { | |
| 219 | + return true | |
| 220 | + } | |
| 221 | + if ip := net.ParseIP(hostname); ip != nil { | |
| 222 | + return ip.IsLoopback() | |
| 223 | + } | |
| 224 | + return false | |
| 225 | +} | |
| 226 | + | |
| 227 | +// IsInsecureHost reports whether sending a token to host would put it on the | |
| 228 | +// wire in the clear: a non-https scheme on a non-loopback address. | |
| 229 | +func IsInsecureHost(host string) bool { | |
| 230 | + h := NormalizeHost(host) | |
| 231 | + if h == "" { | |
| 232 | + return false | |
| 233 | + } | |
| 234 | + u, err := url.Parse(h) | |
| 235 | + if err != nil { | |
| 236 | + return false | |
| 237 | + } | |
| 238 | + if strings.EqualFold(u.Scheme, "https") { | |
| 239 | + return false | |
| 240 | + } | |
| 241 | + return !IsLoopbackHost(h) | |
| 242 | +} | |
| 243 | + | |
| 244 | +// WarnIfInsecure prints a warning to w when a token is about to be sent to host | |
| 245 | +// over an unencrypted connection. It reports whether it warned. | |
| 246 | +func WarnIfInsecure(w io.Writer, host string) bool { | |
| 247 | + if !IsInsecureHost(host) { | |
| 248 | + return false | |
| 249 | + } | |
| 250 | + fmt.Fprintf(w, "warning: sending your token to %s over an unencrypted connection; anyone on the network path can read it\n", NormalizeHost(host)) | |
| 251 | + return true | |
| 252 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,252 @@ | |||
| 1 | +// Package config handles rickub CLI configuration and token storage. | ||
| 2 | +// | ||
| 3 | +// Configuration is persisted to ~/.config/rickub/config.yaml (mode 0600) and | ||
| 4 | +// holds the active API host plus, per host, the personal access token minted | ||
| 5 | +// for it. Tokens are bound to the host they were issued against: a token saved | ||
| 6 | +// for https://rickub.com is never sent to some other host that a --host flag or | ||
| 7 | +// RICKUB_HOST happens to name. At runtime the effective host and token are | ||
| 8 | +// resolved with a fixed precedence so a flag or environment variable can always | ||
| 9 | +// override the stored config: | ||
| 10 | +// | ||
| 11 | +// host: --host flag → RICKUB_HOST env → config file → DefaultHost | ||
| 12 | +// token: --token flag → RICKUB_TOKEN env → config file entry for that host | ||
| 13 | +// | ||
| 14 | +// A token supplied explicitly (flag or env) is honoured for whatever host is in | ||
| 15 | +// effect — the caller asked for it. Only the stored token is host-bound. | ||
| 16 | +package config | ||
| 17 | + | ||
| 18 | +import ( | ||
| 19 | + "errors" | ||
| 20 | + "fmt" | ||
| 21 | + "io" | ||
| 22 | + "net" | ||
| 23 | + "net/url" | ||
| 24 | + "os" | ||
| 25 | + "path/filepath" | ||
| 26 | + "strings" | ||
| 27 | + | ||
| 28 | + "gopkg.in/yaml.v3" | ||
| 29 | +) | ||
| 30 | + | ||
| 31 | +// DefaultHost is the production API host used when nothing else is configured. | ||
| 32 | +const DefaultHost = "https://rickub.com" | ||
| 33 | + | ||
| 34 | +// Environment variables consulted when resolving host/token. | ||
| 35 | +const ( | ||
| 36 | + EnvToken = "RICKUB_TOKEN" | ||
| 37 | + EnvHost = "RICKUB_HOST" | ||
| 38 | +) | ||
| 39 | + | ||
| 40 | +// HostConfig is the per-host state stored in the config file. | ||
| 41 | +type HostConfig struct { | ||
| 42 | + Token string `yaml:"token,omitempty"` | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +// Config is the persisted CLI configuration. | ||
| 46 | +type Config struct { | ||
| 47 | + // Host is the active host, used when neither --host nor RICKUB_HOST is set. | ||
| 48 | + Host string `yaml:"host,omitempty"` | ||
| 49 | + // Hosts maps a normalized host URL to the credentials minted for it. | ||
| 50 | + Hosts map[string]HostConfig `yaml:"hosts,omitempty"` | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +// NormalizeHost canonicalizes a host URL for use as a config key and for | ||
| 54 | +// comparing the effective host against the host a token was saved for. It trims | ||
| 55 | +// surrounding space and trailing slashes and lowercases the scheme and | ||
| 56 | +// authority (which are case-insensitive per RFC 3986); anything it cannot parse | ||
| 57 | +// is returned trimmed but otherwise untouched. | ||
| 58 | +func NormalizeHost(host string) string { | ||
| 59 | + host = strings.TrimRight(strings.TrimSpace(host), "/") | ||
| 60 | + if host == "" { | ||
| 61 | + return "" | ||
| 62 | + } | ||
| 63 | + u, err := url.Parse(host) | ||
| 64 | + if err != nil || u.Host == "" { | ||
| 65 | + return host | ||
| 66 | + } | ||
| 67 | + u.Scheme = strings.ToLower(u.Scheme) | ||
| 68 | + u.Host = strings.ToLower(u.Host) | ||
| 69 | + return strings.TrimRight(u.String(), "/") | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +// Path returns the config file path, honouring XDG_CONFIG_HOME. | ||
| 73 | +func Path() (string, error) { | ||
| 74 | + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { | ||
| 75 | + return filepath.Join(xdg, "rickub", "config.yaml"), nil | ||
| 76 | + } | ||
| 77 | + home, err := os.UserHomeDir() | ||
| 78 | + if err != nil { | ||
| 79 | + return "", err | ||
| 80 | + } | ||
| 81 | + return filepath.Join(home, ".config", "rickub", "config.yaml"), nil | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +// Load reads the config file. A missing file is not an error: it returns an | ||
| 85 | +// empty Config so callers can rely on flag/env resolution alone. | ||
| 86 | +func Load() (*Config, error) { | ||
| 87 | + path, err := Path() | ||
| 88 | + if err != nil { | ||
| 89 | + return nil, err | ||
| 90 | + } | ||
| 91 | + return LoadFrom(path) | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +// LoadFrom reads config from an explicit path (used by tests). | ||
| 95 | +func LoadFrom(path string) (*Config, error) { | ||
| 96 | + data, err := os.ReadFile(path) | ||
| 97 | + if err != nil { | ||
| 98 | + if errors.Is(err, os.ErrNotExist) { | ||
| 99 | + return &Config{}, nil | ||
| 100 | + } | ||
| 101 | + return nil, err | ||
| 102 | + } | ||
| 103 | + var c Config | ||
| 104 | + if err := yaml.Unmarshal(data, &c); err != nil { | ||
| 105 | + return nil, fmt.Errorf("parse %s: %w", path, err) | ||
| 106 | + } | ||
| 107 | + return &c, nil | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +// Save writes the config file (mode 0600), creating parent dirs as needed. | ||
| 111 | +func (c *Config) Save() error { | ||
| 112 | + path, err := Path() | ||
| 113 | + if err != nil { | ||
| 114 | + return err | ||
| 115 | + } | ||
| 116 | + return c.SaveTo(path) | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +// SaveTo writes config to an explicit path (used by tests). | ||
| 120 | +func (c *Config) SaveTo(path string) error { | ||
| 121 | + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | ||
| 122 | + return err | ||
| 123 | + } | ||
| 124 | + data, err := yaml.Marshal(c) | ||
| 125 | + if err != nil { | ||
| 126 | + return err | ||
| 127 | + } | ||
| 128 | + // Write 0600 so the token is not world-readable. | ||
| 129 | + return os.WriteFile(path, data, 0o600) | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +// TokenFor returns the stored token minted for host, or "" if none is stored | ||
| 133 | +// for exactly that host. | ||
| 134 | +func (c *Config) TokenFor(host string) string { | ||
| 135 | + if c == nil { | ||
| 136 | + return "" | ||
| 137 | + } | ||
| 138 | + return c.Hosts[NormalizeHost(host)].Token | ||
| 139 | +} | ||
| 140 | + | ||
| 141 | +// SetToken stores token as the credential for host and makes host active. | ||
| 142 | +func (c *Config) SetToken(host, token string) { | ||
| 143 | + host = NormalizeHost(host) | ||
| 144 | + if host == "" { | ||
| 145 | + return | ||
| 146 | + } | ||
| 147 | + if c.Hosts == nil { | ||
| 148 | + c.Hosts = make(map[string]HostConfig) | ||
| 149 | + } | ||
| 150 | + entry := c.Hosts[host] | ||
| 151 | + entry.Token = token | ||
| 152 | + c.Hosts[host] = entry | ||
| 153 | + c.Host = host | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +// ClearToken removes the stored token for host. It reports whether one was | ||
| 157 | +// removed. | ||
| 158 | +func (c *Config) ClearToken(host string) bool { | ||
| 159 | + host = NormalizeHost(host) | ||
| 160 | + if c == nil || c.Hosts == nil { | ||
| 161 | + return false | ||
| 162 | + } | ||
| 163 | + entry, ok := c.Hosts[host] | ||
| 164 | + if !ok || entry.Token == "" { | ||
| 165 | + return false | ||
| 166 | + } | ||
| 167 | + entry.Token = "" | ||
| 168 | + if entry == (HostConfig{}) { | ||
| 169 | + delete(c.Hosts, host) | ||
| 170 | + } else { | ||
| 171 | + c.Hosts[host] = entry | ||
| 172 | + } | ||
| 173 | + return true | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +// ResolveHost applies the host precedence: flag → env → config → default. | ||
| 177 | +// The returned host is normalized and never has a trailing slash. | ||
| 178 | +func ResolveHost(flagHost string, cfg *Config) string { | ||
| 179 | + host := DefaultHost | ||
| 180 | + if cfg != nil && cfg.Host != "" { | ||
| 181 | + host = cfg.Host | ||
| 182 | + } | ||
| 183 | + if env := os.Getenv(EnvHost); env != "" { | ||
| 184 | + host = env | ||
| 185 | + } | ||
| 186 | + if flagHost != "" { | ||
| 187 | + host = flagHost | ||
| 188 | + } | ||
| 189 | + return NormalizeHost(host) | ||
| 190 | +} | ||
| 191 | + | ||
| 192 | +// ResolveToken applies the token precedence: flag → env → stored token for | ||
| 193 | +// host. An explicit flag or environment token is honoured for any host; the | ||
| 194 | +// stored token is returned only when host matches the host it was saved for, so | ||
| 195 | +// pointing --host / RICKUB_HOST at another server cannot leak it. | ||
| 196 | +func ResolveToken(flagToken string, cfg *Config, host string) string { | ||
| 197 | + if flagToken != "" { | ||
| 198 | + return flagToken | ||
| 199 | + } | ||
| 200 | + if env := os.Getenv(EnvToken); env != "" { | ||
| 201 | + return env | ||
| 202 | + } | ||
| 203 | + return cfg.TokenFor(host) | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +// IsLoopbackHost reports whether host addresses the local machine, where | ||
| 207 | +// sending a token over plain HTTP does not put it on the wire. | ||
| 208 | +func IsLoopbackHost(host string) bool { | ||
| 209 | + h := NormalizeHost(host) | ||
| 210 | + u, err := url.Parse(h) | ||
| 211 | + if err != nil { | ||
| 212 | + return false | ||
| 213 | + } | ||
| 214 | + hostname := u.Hostname() | ||
| 215 | + if hostname == "" { | ||
| 216 | + hostname = h | ||
| 217 | + } | ||
| 218 | + if strings.EqualFold(hostname, "localhost") { | ||
| 219 | + return true | ||
| 220 | + } | ||
| 221 | + if ip := net.ParseIP(hostname); ip != nil { | ||
| 222 | + return ip.IsLoopback() | ||
| 223 | + } | ||
| 224 | + return false | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +// IsInsecureHost reports whether sending a token to host would put it on the | ||
| 228 | +// wire in the clear: a non-https scheme on a non-loopback address. | ||
| 229 | +func IsInsecureHost(host string) bool { | ||
| 230 | + h := NormalizeHost(host) | ||
| 231 | + if h == "" { | ||
| 232 | + return false | ||
| 233 | + } | ||
| 234 | + u, err := url.Parse(h) | ||
| 235 | + if err != nil { | ||
| 236 | + return false | ||
| 237 | + } | ||
| 238 | + if strings.EqualFold(u.Scheme, "https") { | ||
| 239 | + return false | ||
| 240 | + } | ||
| 241 | + return !IsLoopbackHost(h) | ||
| 242 | +} | ||
| 243 | + | ||
| 244 | +// WarnIfInsecure prints a warning to w when a token is about to be sent to host | ||
| 245 | +// over an unencrypted connection. It reports whether it warned. | ||
| 246 | +func WarnIfInsecure(w io.Writer, host string) bool { | ||
| 247 | + if !IsInsecureHost(host) { | ||
| 248 | + return false | ||
| 249 | + } | ||
| 250 | + fmt.Fprintf(w, "warning: sending your token to %s over an unencrypted connection; anyone on the network path can read it\n", NormalizeHost(host)) | ||
| 251 | + return true | ||
| 252 | +} | ||
added
internal/config/config_test.go +203 -0 | new file mode 100644 | ||
| @@ -0,0 +1,203 @@ | ||
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | +) | |
| 10 | + | |
| 11 | +func TestSaveLoadRoundTrip(t *testing.T) { | |
| 12 | + dir := t.TempDir() | |
| 13 | + path := filepath.Join(dir, "config.yaml") | |
| 14 | + | |
| 15 | + in := &Config{} | |
| 16 | + in.SetToken("http://localhost:3998", "rickub_pat_abc") | |
| 17 | + if err := in.SaveTo(path); err != nil { | |
| 18 | + t.Fatalf("SaveTo: %v", err) | |
| 19 | + } | |
| 20 | + | |
| 21 | + // File must be 0600. | |
| 22 | + info, err := os.Stat(path) | |
| 23 | + if err != nil { | |
| 24 | + t.Fatalf("stat: %v", err) | |
| 25 | + } | |
| 26 | + if perm := info.Mode().Perm(); perm != 0o600 { | |
| 27 | + t.Errorf("perm = %o, want 600", perm) | |
| 28 | + } | |
| 29 | + | |
| 30 | + out, err := LoadFrom(path) | |
| 31 | + if err != nil { | |
| 32 | + t.Fatalf("LoadFrom: %v", err) | |
| 33 | + } | |
| 34 | + if out.Host != "http://localhost:3998" { | |
| 35 | + t.Errorf("host round-trip: got %q", out.Host) | |
| 36 | + } | |
| 37 | + if got := out.TokenFor("http://localhost:3998"); got != "rickub_pat_abc" { | |
| 38 | + t.Errorf("token round-trip: got %q", got) | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +func TestLoadMissingIsEmpty(t *testing.T) { | |
| 43 | + out, err := LoadFrom(filepath.Join(t.TempDir(), "nope.yaml")) | |
| 44 | + if err != nil { | |
| 45 | + t.Fatalf("LoadFrom missing: %v", err) | |
| 46 | + } | |
| 47 | + if out.Host != "" || len(out.Hosts) != 0 { | |
| 48 | + t.Errorf("expected empty config, got %+v", out) | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +func TestResolveHostPrecedence(t *testing.T) { | |
| 53 | + cfg := &Config{Host: "http://config-host"} | |
| 54 | + | |
| 55 | + // config only | |
| 56 | + t.Setenv(EnvHost, "") | |
| 57 | + if got := ResolveHost("", cfg); got != "http://config-host" { | |
| 58 | + t.Errorf("config host: got %q", got) | |
| 59 | + } | |
| 60 | + // env overrides config | |
| 61 | + t.Setenv(EnvHost, "http://env-host/") | |
| 62 | + if got := ResolveHost("", cfg); got != "http://env-host" { | |
| 63 | + t.Errorf("env host (trailing slash trimmed): got %q", got) | |
| 64 | + } | |
| 65 | + // flag overrides env | |
| 66 | + if got := ResolveHost("http://flag-host", cfg); got != "http://flag-host" { | |
| 67 | + t.Errorf("flag host: got %q", got) | |
| 68 | + } | |
| 69 | + // default when nothing set | |
| 70 | + t.Setenv(EnvHost, "") | |
| 71 | + if got := ResolveHost("", &Config{}); got != DefaultHost { | |
| 72 | + t.Errorf("default host: got %q", got) | |
| 73 | + } | |
| 74 | +} | |
| 75 | + | |
| 76 | +func TestResolveTokenPrecedence(t *testing.T) { | |
| 77 | + const host = "https://rickub.com" | |
| 78 | + cfg := &Config{} | |
| 79 | + cfg.SetToken(host, "cfg-token") | |
| 80 | + | |
| 81 | + t.Setenv(EnvToken, "") | |
| 82 | + if got := ResolveToken("", cfg, host); got != "cfg-token" { | |
| 83 | + t.Errorf("config token: got %q", got) | |
| 84 | + } | |
| 85 | + t.Setenv(EnvToken, "env-token") | |
| 86 | + if got := ResolveToken("", cfg, host); got != "env-token" { | |
| 87 | + t.Errorf("env token: got %q", got) | |
| 88 | + } | |
| 89 | + if got := ResolveToken("flag-token", cfg, host); got != "flag-token" { | |
| 90 | + t.Errorf("flag token: got %q", got) | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +// The stored token must never follow the host around: this is the leak the | |
| 95 | +// per-host binding exists to prevent. | |
| 96 | +func TestResolveTokenIsBoundToItsHost(t *testing.T) { | |
| 97 | + t.Setenv(EnvToken, "") | |
| 98 | + cfg := &Config{} | |
| 99 | + cfg.SetToken("https://rickub.com", "rickub_pat_prod") | |
| 100 | + | |
| 101 | + if got := ResolveToken("", cfg, "https://evil.example"); got != "" { | |
| 102 | + t.Errorf("stored token leaked to another host: got %q", got) | |
| 103 | + } | |
| 104 | + if got := ResolveToken("", cfg, "http://localhost:3000"); got != "" { | |
| 105 | + t.Errorf("stored token leaked to a local host: got %q", got) | |
| 106 | + } | |
| 107 | + if got := ResolveToken("", cfg, "https://rickub.com"); got != "rickub_pat_prod" { | |
| 108 | + t.Errorf("token not returned for its own host: got %q", got) | |
| 109 | + } | |
| 110 | + // An explicit token is the caller's own choice and works anywhere. | |
| 111 | + if got := ResolveToken("explicit", cfg, "https://evil.example"); got != "explicit" { | |
| 112 | + t.Errorf("explicit token: got %q", got) | |
| 113 | + } | |
| 114 | + t.Setenv(EnvToken, "env-token") | |
| 115 | + if got := ResolveToken("", cfg, "https://evil.example"); got != "env-token" { | |
| 116 | + t.Errorf("env token: got %q", got) | |
| 117 | + } | |
| 118 | +} | |
| 119 | + | |
| 120 | +func TestMultipleHostsKeepSeparateTokens(t *testing.T) { | |
| 121 | + cfg := &Config{} | |
| 122 | + cfg.SetToken("https://rickub.com", "prod") | |
| 123 | + cfg.SetToken("http://localhost:3000/", "dev") | |
| 124 | + | |
| 125 | + if got := cfg.TokenFor("https://rickub.com"); got != "prod" { | |
| 126 | + t.Errorf("prod token: got %q", got) | |
| 127 | + } | |
| 128 | + if got := cfg.TokenFor("http://localhost:3000"); got != "dev" { | |
| 129 | + t.Errorf("dev token: got %q", got) | |
| 130 | + } | |
| 131 | + // SetToken makes the host it saved active. | |
| 132 | + if cfg.Host != "http://localhost:3000" { | |
| 133 | + t.Errorf("active host: got %q", cfg.Host) | |
| 134 | + } | |
| 135 | + | |
| 136 | + // Logout only affects the named host. | |
| 137 | + if !cfg.ClearToken("http://localhost:3000") { | |
| 138 | + t.Error("ClearToken reported nothing removed") | |
| 139 | + } | |
| 140 | + if got := cfg.TokenFor("http://localhost:3000"); got != "" { | |
| 141 | + t.Errorf("dev token survived logout: got %q", got) | |
| 142 | + } | |
| 143 | + if got := cfg.TokenFor("https://rickub.com"); got != "prod" { | |
| 144 | + t.Errorf("prod token removed by dev logout: got %q", got) | |
| 145 | + } | |
| 146 | + if cfg.ClearToken("http://localhost:3000") { | |
| 147 | + t.Error("ClearToken reported a removal on an empty host") | |
| 148 | + } | |
| 149 | +} | |
| 150 | + | |
| 151 | +func TestNormalizeHost(t *testing.T) { | |
| 152 | + cases := map[string]string{ | |
| 153 | + "https://RickUB.com/": "https://rickub.com", | |
| 154 | + "HTTPS://rickub.com": "https://rickub.com", | |
| 155 | + " https://rickub.com ": "https://rickub.com", | |
| 156 | + "http://localhost:3000": "http://localhost:3000", | |
| 157 | + "": "", | |
| 158 | + } | |
| 159 | + for in, want := range cases { | |
| 160 | + if got := NormalizeHost(in); got != want { | |
| 161 | + t.Errorf("NormalizeHost(%q) = %q, want %q", in, got, want) | |
| 162 | + } | |
| 163 | + } | |
| 164 | +} | |
| 165 | + | |
| 166 | +func TestInsecureHostDetection(t *testing.T) { | |
| 167 | + insecure := []string{"http://evil.example", "http://192.0.2.10:3000", "HTTP://Evil.Example/"} | |
| 168 | + for _, h := range insecure { | |
| 169 | + if !IsInsecureHost(h) { | |
| 170 | + t.Errorf("IsInsecureHost(%q) = false, want true", h) | |
| 171 | + } | |
| 172 | + } | |
| 173 | + secure := []string{ | |
| 174 | + "https://rickub.com", | |
| 175 | + "http://localhost:3000", | |
| 176 | + "http://127.0.0.1:3000", | |
| 177 | + "http://[::1]:3000", | |
| 178 | + "", | |
| 179 | + } | |
| 180 | + for _, h := range secure { | |
| 181 | + if IsInsecureHost(h) { | |
| 182 | + t.Errorf("IsInsecureHost(%q) = true, want false", h) | |
| 183 | + } | |
| 184 | + } | |
| 185 | +} | |
| 186 | + | |
| 187 | +func TestWarnIfInsecure(t *testing.T) { | |
| 188 | + var buf bytes.Buffer | |
| 189 | + if !WarnIfInsecure(&buf, "http://evil.example") { | |
| 190 | + t.Fatal("expected a warning for a plain-HTTP remote host") | |
| 191 | + } | |
| 192 | + if !strings.Contains(buf.String(), "evil.example") { | |
| 193 | + t.Errorf("warning does not name the host: %q", buf.String()) | |
| 194 | + } | |
| 195 | + | |
| 196 | + buf.Reset() | |
| 197 | + if WarnIfInsecure(&buf, "http://localhost:3000") { | |
| 198 | + t.Error("warned about loopback") | |
| 199 | + } | |
| 200 | + if buf.Len() != 0 { | |
| 201 | + t.Errorf("unexpected output: %q", buf.String()) | |
| 202 | + } | |
| 203 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,203 @@ | |||
| 1 | +package config | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "os" | ||
| 6 | + "path/filepath" | ||
| 7 | + "strings" | ||
| 8 | + "testing" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +func TestSaveLoadRoundTrip(t *testing.T) { | ||
| 12 | + dir := t.TempDir() | ||
| 13 | + path := filepath.Join(dir, "config.yaml") | ||
| 14 | + | ||
| 15 | + in := &Config{} | ||
| 16 | + in.SetToken("http://localhost:3998", "rickub_pat_abc") | ||
| 17 | + if err := in.SaveTo(path); err != nil { | ||
| 18 | + t.Fatalf("SaveTo: %v", err) | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + // File must be 0600. | ||
| 22 | + info, err := os.Stat(path) | ||
| 23 | + if err != nil { | ||
| 24 | + t.Fatalf("stat: %v", err) | ||
| 25 | + } | ||
| 26 | + if perm := info.Mode().Perm(); perm != 0o600 { | ||
| 27 | + t.Errorf("perm = %o, want 600", perm) | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + out, err := LoadFrom(path) | ||
| 31 | + if err != nil { | ||
| 32 | + t.Fatalf("LoadFrom: %v", err) | ||
| 33 | + } | ||
| 34 | + if out.Host != "http://localhost:3998" { | ||
| 35 | + t.Errorf("host round-trip: got %q", out.Host) | ||
| 36 | + } | ||
| 37 | + if got := out.TokenFor("http://localhost:3998"); got != "rickub_pat_abc" { | ||
| 38 | + t.Errorf("token round-trip: got %q", got) | ||
| 39 | + } | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +func TestLoadMissingIsEmpty(t *testing.T) { | ||
| 43 | + out, err := LoadFrom(filepath.Join(t.TempDir(), "nope.yaml")) | ||
| 44 | + if err != nil { | ||
| 45 | + t.Fatalf("LoadFrom missing: %v", err) | ||
| 46 | + } | ||
| 47 | + if out.Host != "" || len(out.Hosts) != 0 { | ||
| 48 | + t.Errorf("expected empty config, got %+v", out) | ||
| 49 | + } | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +func TestResolveHostPrecedence(t *testing.T) { | ||
| 53 | + cfg := &Config{Host: "http://config-host"} | ||
| 54 | + | ||
| 55 | + // config only | ||
| 56 | + t.Setenv(EnvHost, "") | ||
| 57 | + if got := ResolveHost("", cfg); got != "http://config-host" { | ||
| 58 | + t.Errorf("config host: got %q", got) | ||
| 59 | + } | ||
| 60 | + // env overrides config | ||
| 61 | + t.Setenv(EnvHost, "http://env-host/") | ||
| 62 | + if got := ResolveHost("", cfg); got != "http://env-host" { | ||
| 63 | + t.Errorf("env host (trailing slash trimmed): got %q", got) | ||
| 64 | + } | ||
| 65 | + // flag overrides env | ||
| 66 | + if got := ResolveHost("http://flag-host", cfg); got != "http://flag-host" { | ||
| 67 | + t.Errorf("flag host: got %q", got) | ||
| 68 | + } | ||
| 69 | + // default when nothing set | ||
| 70 | + t.Setenv(EnvHost, "") | ||
| 71 | + if got := ResolveHost("", &Config{}); got != DefaultHost { | ||
| 72 | + t.Errorf("default host: got %q", got) | ||
| 73 | + } | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +func TestResolveTokenPrecedence(t *testing.T) { | ||
| 77 | + const host = "https://rickub.com" | ||
| 78 | + cfg := &Config{} | ||
| 79 | + cfg.SetToken(host, "cfg-token") | ||
| 80 | + | ||
| 81 | + t.Setenv(EnvToken, "") | ||
| 82 | + if got := ResolveToken("", cfg, host); got != "cfg-token" { | ||
| 83 | + t.Errorf("config token: got %q", got) | ||
| 84 | + } | ||
| 85 | + t.Setenv(EnvToken, "env-token") | ||
| 86 | + if got := ResolveToken("", cfg, host); got != "env-token" { | ||
| 87 | + t.Errorf("env token: got %q", got) | ||
| 88 | + } | ||
| 89 | + if got := ResolveToken("flag-token", cfg, host); got != "flag-token" { | ||
| 90 | + t.Errorf("flag token: got %q", got) | ||
| 91 | + } | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +// The stored token must never follow the host around: this is the leak the | ||
| 95 | +// per-host binding exists to prevent. | ||
| 96 | +func TestResolveTokenIsBoundToItsHost(t *testing.T) { | ||
| 97 | + t.Setenv(EnvToken, "") | ||
| 98 | + cfg := &Config{} | ||
| 99 | + cfg.SetToken("https://rickub.com", "rickub_pat_prod") | ||
| 100 | + | ||
| 101 | + if got := ResolveToken("", cfg, "https://evil.example"); got != "" { | ||
| 102 | + t.Errorf("stored token leaked to another host: got %q", got) | ||
| 103 | + } | ||
| 104 | + if got := ResolveToken("", cfg, "http://localhost:3000"); got != "" { | ||
| 105 | + t.Errorf("stored token leaked to a local host: got %q", got) | ||
| 106 | + } | ||
| 107 | + if got := ResolveToken("", cfg, "https://rickub.com"); got != "rickub_pat_prod" { | ||
| 108 | + t.Errorf("token not returned for its own host: got %q", got) | ||
| 109 | + } | ||
| 110 | + // An explicit token is the caller's own choice and works anywhere. | ||
| 111 | + if got := ResolveToken("explicit", cfg, "https://evil.example"); got != "explicit" { | ||
| 112 | + t.Errorf("explicit token: got %q", got) | ||
| 113 | + } | ||
| 114 | + t.Setenv(EnvToken, "env-token") | ||
| 115 | + if got := ResolveToken("", cfg, "https://evil.example"); got != "env-token" { | ||
| 116 | + t.Errorf("env token: got %q", got) | ||
| 117 | + } | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +func TestMultipleHostsKeepSeparateTokens(t *testing.T) { | ||
| 121 | + cfg := &Config{} | ||
| 122 | + cfg.SetToken("https://rickub.com", "prod") | ||
| 123 | + cfg.SetToken("http://localhost:3000/", "dev") | ||
| 124 | + | ||
| 125 | + if got := cfg.TokenFor("https://rickub.com"); got != "prod" { | ||
| 126 | + t.Errorf("prod token: got %q", got) | ||
| 127 | + } | ||
| 128 | + if got := cfg.TokenFor("http://localhost:3000"); got != "dev" { | ||
| 129 | + t.Errorf("dev token: got %q", got) | ||
| 130 | + } | ||
| 131 | + // SetToken makes the host it saved active. | ||
| 132 | + if cfg.Host != "http://localhost:3000" { | ||
| 133 | + t.Errorf("active host: got %q", cfg.Host) | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + // Logout only affects the named host. | ||
| 137 | + if !cfg.ClearToken("http://localhost:3000") { | ||
| 138 | + t.Error("ClearToken reported nothing removed") | ||
| 139 | + } | ||
| 140 | + if got := cfg.TokenFor("http://localhost:3000"); got != "" { | ||
| 141 | + t.Errorf("dev token survived logout: got %q", got) | ||
| 142 | + } | ||
| 143 | + if got := cfg.TokenFor("https://rickub.com"); got != "prod" { | ||
| 144 | + t.Errorf("prod token removed by dev logout: got %q", got) | ||
| 145 | + } | ||
| 146 | + if cfg.ClearToken("http://localhost:3000") { | ||
| 147 | + t.Error("ClearToken reported a removal on an empty host") | ||
| 148 | + } | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +func TestNormalizeHost(t *testing.T) { | ||
| 152 | + cases := map[string]string{ | ||
| 153 | + "https://RickUB.com/": "https://rickub.com", | ||
| 154 | + "HTTPS://rickub.com": "https://rickub.com", | ||
| 155 | + " https://rickub.com ": "https://rickub.com", | ||
| 156 | + "http://localhost:3000": "http://localhost:3000", | ||
| 157 | + "": "", | ||
| 158 | + } | ||
| 159 | + for in, want := range cases { | ||
| 160 | + if got := NormalizeHost(in); got != want { | ||
| 161 | + t.Errorf("NormalizeHost(%q) = %q, want %q", in, got, want) | ||
| 162 | + } | ||
| 163 | + } | ||
| 164 | +} | ||
| 165 | + | ||
| 166 | +func TestInsecureHostDetection(t *testing.T) { | ||
| 167 | + insecure := []string{"http://evil.example", "http://192.0.2.10:3000", "HTTP://Evil.Example/"} | ||
| 168 | + for _, h := range insecure { | ||
| 169 | + if !IsInsecureHost(h) { | ||
| 170 | + t.Errorf("IsInsecureHost(%q) = false, want true", h) | ||
| 171 | + } | ||
| 172 | + } | ||
| 173 | + secure := []string{ | ||
| 174 | + "https://rickub.com", | ||
| 175 | + "http://localhost:3000", | ||
| 176 | + "http://127.0.0.1:3000", | ||
| 177 | + "http://[::1]:3000", | ||
| 178 | + "", | ||
| 179 | + } | ||
| 180 | + for _, h := range secure { | ||
| 181 | + if IsInsecureHost(h) { | ||
| 182 | + t.Errorf("IsInsecureHost(%q) = true, want false", h) | ||
| 183 | + } | ||
| 184 | + } | ||
| 185 | +} | ||
| 186 | + | ||
| 187 | +func TestWarnIfInsecure(t *testing.T) { | ||
| 188 | + var buf bytes.Buffer | ||
| 189 | + if !WarnIfInsecure(&buf, "http://evil.example") { | ||
| 190 | + t.Fatal("expected a warning for a plain-HTTP remote host") | ||
| 191 | + } | ||
| 192 | + if !strings.Contains(buf.String(), "evil.example") { | ||
| 193 | + t.Errorf("warning does not name the host: %q", buf.String()) | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + buf.Reset() | ||
| 197 | + if WarnIfInsecure(&buf, "http://localhost:3000") { | ||
| 198 | + t.Error("warned about loopback") | ||
| 199 | + } | ||
| 200 | + if buf.Len() != 0 { | ||
| 201 | + t.Errorf("unexpected output: %q", buf.String()) | ||
| 202 | + } | ||
| 203 | +} | ||
added
main.go +24 -0 | new file mode 100644 | ||
| @@ -0,0 +1,24 @@ | ||
| 1 | +// Command rickub is the command-line interface to a rickub git host. | |
| 2 | +package main | |
| 3 | + | |
| 4 | +import ( | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "os" | |
| 8 | + | |
| 9 | + "rickub.com/rickub/cli/cmd" | |
| 10 | + "rickub.com/rickub/cli/internal/api" | |
| 11 | +) | |
| 12 | + | |
| 13 | +func main() { | |
| 14 | + if err := cmd.Execute(); err != nil { | |
| 15 | + // Surface the API error envelope clearly on stderr. | |
| 16 | + var apiErr *api.APIError | |
| 17 | + if errors.As(err, &apiErr) { | |
| 18 | + fmt.Fprintf(os.Stderr, "rickub: %s\n", apiErr.Error()) | |
| 19 | + } else { | |
| 20 | + fmt.Fprintf(os.Stderr, "rickub: %s\n", err) | |
| 21 | + } | |
| 22 | + os.Exit(1) | |
| 23 | + } | |
| 24 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,24 @@ | |||
| 1 | +// Command rickub is the command-line interface to a rickub git host. | ||
| 2 | +package main | ||
| 3 | + | ||
| 4 | +import ( | ||
| 5 | + "errors" | ||
| 6 | + "fmt" | ||
| 7 | + "os" | ||
| 8 | + | ||
| 9 | + "rickub.com/rickub/cli/cmd" | ||
| 10 | + "rickub.com/rickub/cli/internal/api" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +func main() { | ||
| 14 | + if err := cmd.Execute(); err != nil { | ||
| 15 | + // Surface the API error envelope clearly on stderr. | ||
| 16 | + var apiErr *api.APIError | ||
| 17 | + if errors.As(err, &apiErr) { | ||
| 18 | + fmt.Fprintf(os.Stderr, "rickub: %s\n", apiErr.Error()) | ||
| 19 | + } else { | ||
| 20 | + fmt.Fprintf(os.Stderr, "rickub: %s\n", err) | ||
| 21 | + } | ||
| 22 | + os.Exit(1) | ||
| 23 | + } | ||
| 24 | +} | ||
added
scripts/build-dist.sh +100 -0 | new file mode 100755 | ||
| @@ -0,0 +1,100 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# | |
| 3 | +# build-dist.sh — cross-compile the rickub CLI and package release archives. | |
| 4 | +# | |
| 5 | +# Usage: scripts/build-dist.sh <version> | |
| 6 | +# VERSION=1.2.3 scripts/build-dist.sh | |
| 7 | +# | |
| 8 | +# <version> is the bare version WITHOUT a leading "v" (e.g. 1.2.3 or 0.1.0-rc.42). | |
| 9 | +# It is stamped into the binary and used in the archive file names. | |
| 10 | +# | |
| 11 | +# Output (in ./dist): | |
| 12 | +# rickub_<version>_<os>_<arch>.tar.gz for each target | |
| 13 | +# SHA256SUMS checksums of the archives | |
| 14 | +# | |
| 15 | +# Everything is built with CGO_ENABLED=0 so a single Linux amd64 runner can | |
| 16 | +# produce all four targets (the rickub CI fleet is Linux/amd64 only). | |
| 17 | + | |
| 18 | +set -euo pipefail | |
| 19 | + | |
| 20 | +MODULE="rickub.com/rickub/cli" | |
| 21 | +BINARY="rickub" | |
| 22 | + | |
| 23 | +# Targets: rickub CI runners are linux/amd64 only, so every artifact is a | |
| 24 | +# cross-compile. Pure Go + CGO_ENABLED=0 makes that safe. | |
| 25 | +TARGETS=( | |
| 26 | + "linux/amd64" | |
| 27 | + "linux/arm64" | |
| 28 | + "darwin/amd64" | |
| 29 | + "darwin/arm64" | |
| 30 | +) | |
| 31 | + | |
| 32 | +VERSION="${1:-${VERSION:-}}" | |
| 33 | +if [ -z "${VERSION}" ]; then | |
| 34 | + echo "build-dist.sh: no version given (pass as \$1 or set \$VERSION)" >&2 | |
| 35 | + exit 2 | |
| 36 | +fi | |
| 37 | +VERSION="${VERSION#v}" | |
| 38 | + | |
| 39 | +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | |
| 40 | +cd "${REPO_ROOT}" | |
| 41 | + | |
| 42 | +DIST="${REPO_ROOT}/dist" | |
| 43 | +rm -rf "${DIST}" | |
| 44 | +mkdir -p "${DIST}" | |
| 45 | + | |
| 46 | +# Stage outside the work tree so nothing untracked is left behind. | |
| 47 | +STAGE="$(mktemp -d "${TMPDIR:-/tmp}/rickub-dist.XXXXXX")" | |
| 48 | +trap 'rm -rf "${STAGE}"' EXIT | |
| 49 | + | |
| 50 | +# sha256sum (GNU/Linux) vs shasum (macOS dev boxes). | |
| 51 | +sha256() { | |
| 52 | + if command -v sha256sum >/dev/null 2>&1; then | |
| 53 | + sha256sum "$@" | |
| 54 | + else | |
| 55 | + shasum -a 256 "$@" | |
| 56 | + fi | |
| 57 | +} | |
| 58 | + | |
| 59 | +LDFLAGS="-s -w -X ${MODULE}/cmd.Version=${VERSION}" | |
| 60 | + | |
| 61 | +echo "==> building ${BINARY} ${VERSION}" | |
| 62 | +echo " ldflags: ${LDFLAGS}" | |
| 63 | +go version | |
| 64 | + | |
| 65 | +for target in "${TARGETS[@]}"; do | |
| 66 | + GOOS="${target%%/*}" | |
| 67 | + GOARCH="${target##*/}" | |
| 68 | + name="${BINARY}_${VERSION}_${GOOS}_${GOARCH}" | |
| 69 | + out="${STAGE}/${name}" | |
| 70 | + mkdir -p "${out}" | |
| 71 | + | |
| 72 | + echo "==> ${GOOS}/${GOARCH}" | |
| 73 | + CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \ | |
| 74 | + go build -trimpath -ldflags "${LDFLAGS}" -o "${out}/${BINARY}" . | |
| 75 | + | |
| 76 | + # Ship docs alongside the binary when they exist. LICENSE does not exist in | |
| 77 | + # this repo yet; it is picked up automatically once someone adds one. | |
| 78 | + contents=("${BINARY}") | |
| 79 | + for extra in README.md LICENSE LICENSE.md LICENSE.txt; do | |
| 80 | + if [ -f "${REPO_ROOT}/${extra}" ]; then | |
| 81 | + cp "${REPO_ROOT}/${extra}" "${out}/${extra}" | |
| 82 | + contents+=("${extra}") | |
| 83 | + fi | |
| 84 | + done | |
| 85 | + | |
| 86 | + tar -czf "${DIST}/${name}.tar.gz" -C "${out}" "${contents[@]}" | |
| 87 | + echo " -> dist/${name}.tar.gz" | |
| 88 | +done | |
| 89 | + | |
| 90 | +echo "==> SHA256SUMS" | |
| 91 | +( | |
| 92 | + cd "${DIST}" | |
| 93 | + # Deterministic ordering, names only (no ./ prefix) so `sha256sum -c` works | |
| 94 | + # from inside an unpacked download. | |
| 95 | + # shellcheck disable=SC2035 | |
| 96 | + sha256 *.tar.gz > SHA256SUMS | |
| 97 | +) | |
| 98 | +cat "${DIST}/SHA256SUMS" | |
| 99 | + | |
| 100 | +echo "==> done: $(ls -1 "${DIST}" | wc -l | tr -d ' ') files in dist/" | |
| new file mode 100755 | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# | ||
| 3 | +# build-dist.sh — cross-compile the rickub CLI and package release archives. | ||
| 4 | +# | ||
| 5 | +# Usage: scripts/build-dist.sh <version> | ||
| 6 | +# VERSION=1.2.3 scripts/build-dist.sh | ||
| 7 | +# | ||
| 8 | +# <version> is the bare version WITHOUT a leading "v" (e.g. 1.2.3 or 0.1.0-rc.42). | ||
| 9 | +# It is stamped into the binary and used in the archive file names. | ||
| 10 | +# | ||
| 11 | +# Output (in ./dist): | ||
| 12 | +# rickub_<version>_<os>_<arch>.tar.gz for each target | ||
| 13 | +# SHA256SUMS checksums of the archives | ||
| 14 | +# | ||
| 15 | +# Everything is built with CGO_ENABLED=0 so a single Linux amd64 runner can | ||
| 16 | +# produce all four targets (the rickub CI fleet is Linux/amd64 only). | ||
| 17 | + | ||
| 18 | +set -euo pipefail | ||
| 19 | + | ||
| 20 | +MODULE="rickub.com/rickub/cli" | ||
| 21 | +BINARY="rickub" | ||
| 22 | + | ||
| 23 | +# Targets: rickub CI runners are linux/amd64 only, so every artifact is a | ||
| 24 | +# cross-compile. Pure Go + CGO_ENABLED=0 makes that safe. | ||
| 25 | +TARGETS=( | ||
| 26 | + "linux/amd64" | ||
| 27 | + "linux/arm64" | ||
| 28 | + "darwin/amd64" | ||
| 29 | + "darwin/arm64" | ||
| 30 | +) | ||
| 31 | + | ||
| 32 | +VERSION="${1:-${VERSION:-}}" | ||
| 33 | +if [ -z "${VERSION}" ]; then | ||
| 34 | + echo "build-dist.sh: no version given (pass as \$1 or set \$VERSION)" >&2 | ||
| 35 | + exit 2 | ||
| 36 | +fi | ||
| 37 | +VERSION="${VERSION#v}" | ||
| 38 | + | ||
| 39 | +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | ||
| 40 | +cd "${REPO_ROOT}" | ||
| 41 | + | ||
| 42 | +DIST="${REPO_ROOT}/dist" | ||
| 43 | +rm -rf "${DIST}" | ||
| 44 | +mkdir -p "${DIST}" | ||
| 45 | + | ||
| 46 | +# Stage outside the work tree so nothing untracked is left behind. | ||
| 47 | +STAGE="$(mktemp -d "${TMPDIR:-/tmp}/rickub-dist.XXXXXX")" | ||
| 48 | +trap 'rm -rf "${STAGE}"' EXIT | ||
| 49 | + | ||
| 50 | +# sha256sum (GNU/Linux) vs shasum (macOS dev boxes). | ||
| 51 | +sha256() { | ||
| 52 | + if command -v sha256sum >/dev/null 2>&1; then | ||
| 53 | + sha256sum "$@" | ||
| 54 | + else | ||
| 55 | + shasum -a 256 "$@" | ||
| 56 | + fi | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +LDFLAGS="-s -w -X ${MODULE}/cmd.Version=${VERSION}" | ||
| 60 | + | ||
| 61 | +echo "==> building ${BINARY} ${VERSION}" | ||
| 62 | +echo " ldflags: ${LDFLAGS}" | ||
| 63 | +go version | ||
| 64 | + | ||
| 65 | +for target in "${TARGETS[@]}"; do | ||
| 66 | + GOOS="${target%%/*}" | ||
| 67 | + GOARCH="${target##*/}" | ||
| 68 | + name="${BINARY}_${VERSION}_${GOOS}_${GOARCH}" | ||
| 69 | + out="${STAGE}/${name}" | ||
| 70 | + mkdir -p "${out}" | ||
| 71 | + | ||
| 72 | + echo "==> ${GOOS}/${GOARCH}" | ||
| 73 | + CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \ | ||
| 74 | + go build -trimpath -ldflags "${LDFLAGS}" -o "${out}/${BINARY}" . | ||
| 75 | + | ||
| 76 | + # Ship docs alongside the binary when they exist. LICENSE does not exist in | ||
| 77 | + # this repo yet; it is picked up automatically once someone adds one. | ||
| 78 | + contents=("${BINARY}") | ||
| 79 | + for extra in README.md LICENSE LICENSE.md LICENSE.txt; do | ||
| 80 | + if [ -f "${REPO_ROOT}/${extra}" ]; then | ||
| 81 | + cp "${REPO_ROOT}/${extra}" "${out}/${extra}" | ||
| 82 | + contents+=("${extra}") | ||
| 83 | + fi | ||
| 84 | + done | ||
| 85 | + | ||
| 86 | + tar -czf "${DIST}/${name}.tar.gz" -C "${out}" "${contents[@]}" | ||
| 87 | + echo " -> dist/${name}.tar.gz" | ||
| 88 | +done | ||
| 89 | + | ||
| 90 | +echo "==> SHA256SUMS" | ||
| 91 | +( | ||
| 92 | + cd "${DIST}" | ||
| 93 | + # Deterministic ordering, names only (no ./ prefix) so `sha256sum -c` works | ||
| 94 | + # from inside an unpacked download. | ||
| 95 | + # shellcheck disable=SC2035 | ||
| 96 | + sha256 *.tar.gz > SHA256SUMS | ||
| 97 | +) | ||
| 98 | +cat "${DIST}/SHA256SUMS" | ||
| 99 | + | ||
| 100 | +echo "==> done: $(ls -1 "${DIST}" | wc -l | tr -d ' ') files in dist/" | ||