rickub/clipublic Fork 0
1a1d430
Commits
Clone
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git

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
Olivier Girardot committed 2026-09-01T15:02:44+02:00 Browse files
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/"