diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6e720e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +testsuite/sshkeys/* text eol=lf + diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml new file mode 100644 index 0000000..55e10f6 --- /dev/null +++ b/.github/secret_scanning.yml @@ -0,0 +1,4 @@ +# These are intentionally committed fixture keys for the local broker +# integration tests. They must never grant access to real infrastructure. +paths-ignore: + - "testsuite/sshkeys/**" diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml deleted file mode 100644 index be75fdb..0000000 --- a/.github/workflows/build-artifacts.yml +++ /dev/null @@ -1,177 +0,0 @@ -name: Build Artifacts - -on: - push: - branches: - - main - - develop - paths: - - "*.go" - - "**/*.go" - - "go.mod" - - "go.sum" - workflow_dispatch: - -permissions: - contents: write - -jobs: - build: - name: ${{ matrix.artifact_name }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: macos-14 - goos: darwin - goarch: arm64 - artifact_name: bgit-mac-arm64 - - os: macos-15-intel - goos: darwin - goarch: amd64 - artifact_name: bgit-mac-amd64 - - os: ubuntu-24.04 - goos: linux - goarch: amd64 - artifact_name: bgit-linux-amd64 - - os: ubuntu-24.04 - goos: linux - goarch: arm64 - artifact_name: bgit-linux-arm64 - - os: windows-2022 - goos: windows - goarch: amd64 - artifact_name: bgit-windows-amd64.exe - - os: windows-2022 - goos: windows - goarch: arm64 - artifact_name: bgit-windows-arm64.exe - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Resolve Version From Changelog - if: github.ref == 'refs/heads/main' - shell: pwsh - run: | - if (!(Test-Path "CHANGELOG.md")) { - throw "Missing CHANGELOG.md" - } - - $lines = Get-Content "CHANGELOG.md" - $line = $lines | Where-Object { $_ -match '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)' } | Select-Object -First 1 - if (-not $line) { - throw "Could not find semantic version heading in CHANGELOG.md" - } - if ($line -notmatch '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)') { - throw "Invalid semantic version heading in CHANGELOG.md" - } - - $version = $Matches[1] - $releaseTag = $version - $start = [Array]::IndexOf($lines, $line) - $notes = New-Object System.Collections.Generic.List[string] - - for ($i = $start + 1; $i -lt $lines.Count; $i++) { - if ($lines[$i] -match '^\s*##\s+v?\d+\.\d+\.\d+(?:\s|$)') { - break - } - $notes.Add($lines[$i]) - } - - $releaseNotes = ($notes -join "`n").Trim() - if ([string]::IsNullOrWhiteSpace($releaseNotes)) { - throw "CHANGELOG.md section for $version has no release notes" - } - - $releaseNotes | Out-File -FilePath "release-notes.md" -Encoding utf8 - - "APP_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "RELEASE_TAG=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "RELEASE_NAME=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "ARTIFACT_FILE=${{ matrix.artifact_name }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Resolve Development Release - if: github.ref == 'refs/heads/develop' - shell: pwsh - run: | - $artifact = "${{ matrix.artifact_name }}" - if ($artifact.EndsWith(".exe")) { - $artifact = $artifact.Substring(0, $artifact.Length - 4) + "-dev.exe" - } else { - $artifact = "$artifact-dev" - } - - "Latest development release" | Out-File -FilePath "release-notes.md" -Encoding utf8 - - "APP_VERSION=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "RELEASE_TAG=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "RELEASE_NAME=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "ARTIFACT_FILE=$artifact" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Test - run: go test ./... - - - name: Build - shell: pwsh - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - CGO_ENABLED: "0" - run: | - New-Item -ItemType Directory -Force -Path "dist" | Out-Null - go build -trimpath -ldflags="-s -w -X main.version=${{ env.APP_VERSION }}" -o "dist/${{ env.ARTIFACT_FILE }}" . - - if (!(Test-Path "dist/${{ env.ARTIFACT_FILE }}")) { - throw "Missing expected artifact: dist/${{ env.ARTIFACT_FILE }}" - } - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ env.ARTIFACT_FILE }} - path: dist/${{ env.ARTIFACT_FILE }} - if-no-files-found: error - - - name: Publish To Release - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - $tag = $env:RELEASE_TAG - $name = $env:RELEASE_NAME - $asset = "dist/${{ env.ARTIFACT_FILE }}" - $repo = "${{ github.repository }}" - $target = "${{ github.ref_name }}" - - if ($tag -eq "dev-latest") { - git tag -f $tag "${{ github.sha }}" - git push origin "refs/tags/$tag" --force - } - - gh release view $tag --repo $repo *> $null - if ($LASTEXITCODE -ne 0) { - if ($tag -eq "dev-latest") { - gh release create $tag --repo $repo --target $target --title $name --notes-file release-notes.md --prerelease - } else { - gh release create $tag --repo $repo --target $target --title $name --notes-file release-notes.md - } - if ($LASTEXITCODE -ne 0) { - gh release view $tag --repo $repo *> $null - if ($LASTEXITCODE -ne 0) { - throw "Could not create or access release $tag" - } - } - } - - gh release edit $tag --repo $repo --title $name --notes-file release-notes.md - gh release upload $tag $asset --repo $repo --clobber diff --git a/.github/workflows/test-and-build-artifacts.yml b/.github/workflows/test-and-build-artifacts.yml new file mode 100644 index 0000000..219f7d4 --- /dev/null +++ b/.github/workflows/test-and-build-artifacts.yml @@ -0,0 +1,380 @@ +name: Test And Build Artifacts + +on: + push: + branches: + - main + - develop + paths: + - "*.go" + - "**/*.go" + - "go.mod" + - "go.sum" + - "CHANGELOG.md" + - "broker/**" + - "www/**" + - "testsuite/**" + - ".github/workflows/test-and-build-artifacts.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: test-and-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Unit Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: + - macos-14 + - macos-15-intel + - ubuntu-24.04 + - windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Unit Tests + run: go test ./... + + build: + name: Build Multiarchitecture Artifacts + needs: + - unit + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Resolve Version From Changelog + if: github.ref == 'refs/heads/main' + shell: pwsh + run: | + if (!(Test-Path "CHANGELOG.md")) { + throw "Missing CHANGELOG.md" + } + + $lines = Get-Content "CHANGELOG.md" + $line = $lines | Where-Object { $_ -match '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)' } | Select-Object -First 1 + if (-not $line) { + throw "Could not find semantic version heading in CHANGELOG.md" + } + if ($line -notmatch '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)') { + throw "Invalid semantic version heading in CHANGELOG.md" + } + + $version = $Matches[1] + $releaseTag = $version + $start = [Array]::IndexOf($lines, $line) + $notes = New-Object System.Collections.Generic.List[string] + + for ($i = $start + 1; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^\s*##\s+v?\d+\.\d+\.\d+(?:\s|$)') { + break + } + $notes.Add($lines[$i]) + } + + $releaseNotes = ($notes -join "`n").Trim() + if ([string]::IsNullOrWhiteSpace($releaseNotes)) { + throw "CHANGELOG.md section for $version has no release notes" + } + + $releaseNotes | Out-File -FilePath "release-notes.md" -Encoding utf8 + + "APP_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_TAG=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_NAME=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Resolve Development Release + if: github.ref == 'refs/heads/develop' + shell: pwsh + run: | + if (!(Test-Path "CHANGELOG.md")) { + throw "Missing CHANGELOG.md" + } + + $lines = Get-Content "CHANGELOG.md" + $line = $lines | Where-Object { $_ -match '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)' } | Select-Object -First 1 + if (-not $line) { + throw "Could not find semantic version heading in CHANGELOG.md" + } + if ($line -notmatch '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)') { + throw "Invalid semantic version heading in CHANGELOG.md" + } + + $version = "$($Matches[1])-dev" + + "Latest development release for $version" | Out-File -FilePath "release-notes.md" -Encoding utf8 + + "APP_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_TAG=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_NAME=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Resolve Manual Development Release + if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' + shell: pwsh + run: | + "Manual development build" | Out-File -FilePath "release-notes.md" -Encoding utf8 + + "APP_VERSION=dev" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_TAG=dev" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_NAME=dev" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Build + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "dist" | Out-Null + + $targets = @( + @{ GOOS = "darwin"; GOARCH = "arm64"; Artifact = "bgit-mac-arm64" }, + @{ GOOS = "darwin"; GOARCH = "amd64"; Artifact = "bgit-mac-amd64" }, + @{ GOOS = "linux"; GOARCH = "amd64"; Artifact = "bgit-linux-amd64" }, + @{ GOOS = "linux"; GOARCH = "arm64"; Artifact = "bgit-linux-arm64" }, + @{ GOOS = "windows"; GOARCH = "amd64"; Artifact = "bgit-windows-amd64.exe" }, + @{ GOOS = "windows"; GOARCH = "arm64"; Artifact = "bgit-windows-arm64.exe" } + ) + + foreach ($target in $targets) { + $artifact = $target.Artifact + if ($env:RELEASE_TAG -eq "dev-latest") { + if ($artifact.EndsWith(".exe")) { + $artifact = $artifact.Substring(0, $artifact.Length - 4) + "-dev.exe" + } else { + $artifact = "$artifact-dev" + } + } + + $env:GOOS = $target.GOOS + $env:GOARCH = $target.GOARCH + $env:CGO_ENABLED = "0" + + go build -trimpath -ldflags="-s -w -X main.version=$env:APP_VERSION" -o "dist/$artifact" . + + if (!(Test-Path "dist/$artifact")) { + throw "Missing expected artifact: dist/$artifact" + } + } + + - name: Upload Artifact + uses: actions/upload-artifact@v7 + with: + name: bgit-artifacts + path: dist/* + if-no-files-found: error + + integration: + name: Integration Tests (${{ matrix.os }}) + needs: + - build + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + binary: bgit-mac-arm64 + - os: macos-15-intel + binary: bgit-mac-amd64 + - os: ubuntu-24.04 + binary: bgit-linux-amd64 + - os: windows-2022 + binary: bgit-windows-amd64.exe + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 25 + package-manager-cache: false + + - name: Download Built Artifacts + uses: actions/download-artifact@v7 + with: + name: bgit-artifacts + path: dist + + - name: Prepare Built Binary + shell: bash + run: | + ls -la dist + binary="${{ matrix.binary }}" + if [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then + if [[ "$binary" == *.exe ]]; then + binary="${binary%.exe}-dev.exe" + else + binary="${binary}-dev" + fi + fi + artifact="dist/$binary" + if [[ ! -f "$artifact" ]]; then + echo "Missing expected artifact $binary" >&2 + exit 1 + fi + if [[ "$binary" == *.exe ]]; then + cp "$artifact" ./bgit.exe + chmod +x ./bgit.exe + printf 'BGIT_PATH=%s/bgit.exe\n' "$GITHUB_WORKSPACE" >> "$GITHUB_ENV" + ./bgit.exe --version + else + cp "$artifact" ./bgit + chmod +x ./bgit + printf 'BGIT_PATH=%s/bgit\n' "$GITHUB_WORKSPACE" >> "$GITHUB_ENV" + ./bgit --version + fi + + - name: Local Broker Integration (GCP runtime) + shell: bash + env: + BGIT_TEST_USE_EXISTING_BINARY: "1" + run: BGIT="$BGIT_PATH" ./testsuite/run-local-broker.sh gcp + + - name: Local Broker Integration (AWS runtime) + shell: bash + env: + BGIT_TEST_USE_EXISTING_BINARY: "1" + run: BGIT="$BGIT_PATH" ./testsuite/run-local-broker.sh aws + + publish: + name: Publish Release + needs: + - integration + runs-on: ubuntu-24.04 + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download Built Artifacts + uses: actions/download-artifact@v7 + with: + name: bgit-artifacts + path: dist + + - name: Resolve Version From Changelog + if: github.ref == 'refs/heads/main' + shell: pwsh + run: | + if (!(Test-Path "CHANGELOG.md")) { + throw "Missing CHANGELOG.md" + } + + $lines = Get-Content "CHANGELOG.md" + $line = $lines | Where-Object { $_ -match '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)' } | Select-Object -First 1 + if (-not $line) { + throw "Could not find semantic version heading in CHANGELOG.md" + } + if ($line -notmatch '^\s*##\s+v?(\d+\.\d+\.\d+)(?:\s|$)') { + throw "Invalid semantic version heading in CHANGELOG.md" + } + + $version = $Matches[1] + $releaseTag = $version + $start = [Array]::IndexOf($lines, $line) + $notes = New-Object System.Collections.Generic.List[string] + + for ($i = $start + 1; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^\s*##\s+v?\d+\.\d+\.\d+(?:\s|$)') { + break + } + $notes.Add($lines[$i]) + } + + $releaseNotes = ($notes -join "`n").Trim() + if ([string]::IsNullOrWhiteSpace($releaseNotes)) { + throw "CHANGELOG.md section for $version has no release notes" + } + + $releaseNotes | Out-File -FilePath "release-notes.md" -Encoding utf8 + + "RELEASE_TAG=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_NAME=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Resolve Development Release + if: github.ref == 'refs/heads/develop' + shell: pwsh + run: | + "Latest development release" | Out-File -FilePath "release-notes.md" -Encoding utf8 + + "RELEASE_TAG=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "RELEASE_NAME=dev-latest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Publish To Release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $tag = $env:RELEASE_TAG + $name = $env:RELEASE_NAME + $repo = "${{ github.repository }}" + $target = "${{ github.ref_name }}" + + if ($tag -eq "dev-latest") { + git tag -f $tag "${{ github.sha }}" + git push origin "refs/tags/$tag" --force + } + + gh release view $tag --repo $repo *> $null + if ($LASTEXITCODE -ne 0) { + if ($tag -eq "dev-latest") { + gh release create $tag --repo $repo --target $target --title $name --notes-file release-notes.md --prerelease + } else { + gh release create $tag --repo $repo --target $target --title $name --notes-file release-notes.md + } + if ($LASTEXITCODE -ne 0) { + gh release view $tag --repo $repo *> $null + if ($LASTEXITCODE -ne 0) { + throw "Could not create or access release $tag" + } + } + } + + gh release edit $tag --repo $repo --title $name --notes-file release-notes.md + Get-ChildItem -Path dist -File | ForEach-Object { + gh release upload $tag $_.FullName --repo $repo --clobber + if ($LASTEXITCODE -ne 0) { + throw "Could not upload release asset $($_.FullName)" + } + } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ccbeac3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,79 @@ +name: Test + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: test-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Unit Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: + - macos-14 + - macos-15-intel + - ubuntu-24.04 + - windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Unit Tests + run: go test ./... + + local-broker-integration: + name: Local Broker Integration (${{ matrix.runtime }} / ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: + - macos-14 + - macos-15-intel + - ubuntu-24.04 + - windows-2022 + runtime: + - gcp + - aws + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 25 + package-manager-cache: false + + - name: Local Broker Integration + shell: bash + run: ./testsuite/run-local-broker.sh "${{ matrix.runtime }}" diff --git a/.gitignore b/.gitignore index b0f2549..02fc5ac 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ bgit coverage.txt .DS_Store attic/ +.broker-test/ +testsuite/local/repo/ +testsuite/gcp/repo/ +testsuite/aws/repo/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d72fdd0..2854191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ All notable changes to `bgit` are documented in this file. This project follows semantic versioning. +## 1.0.0 + +Breaking changes + +- BucketGit is now broker-first. Normal repository operations go through a + broker-backed repo model by default; legacy direct bucket and cloud IAM flows + moved under `bgit direct`. +- `bgit admin` now manages broker-backed repository users, keys, protection, + issues, visibility, and danger-zone repository controls instead of cloud IAM. +- Repository setup and selection now use broker profiles from + `~/.bgit/config.yaml`, including region-qualified profiles. + +Added + +- Broker-first setup and repository initialization, including cloud profile + discovery, owner SSH key import, multi-region broker provisioning, and + `~/.bgit/config.yaml`. +- Broker-issued object-transfer capabilities, logical repo mapping, roles, + branch protection, pull requests, issues, and GitHub SSH key import. +- Repository visibility, read-only mode, logical rename, destructive owner-only + delete controls, owner transfer, member invites, and repo-scoped invite + cancellation. +- `bgit web` as a broker-aware repository browser with embedded assets, + pull-request review flows, issues, settings, capability-aware controls, and + local/remote state indicators. +- `bgit direct` as the explicit low-level object-storage and cloud IAM recovery + path. +- Local broker integration test mode for GCP and AWS runtimes, with coverage for + roles, branch protection, PRs, issues, native Git transport, public/private + access, identity selection, and danger-zone controls. + +Changed + +- Push/fetch/read paths use the broker by default, with region-qualified + profiles and `--profile NAME --region REGION` disambiguation. +- Setup is more guided for GCP/AWS onboarding, project/billing/API checks, and + interactive profile, region, and SSH key selection. +- BucketGit identity is configurable globally or per repo, with a clear prompt + before pushing with the default client identity. + ## 0.4.0 Added diff --git a/README.md b/README.md index 69c018b..ca1bec9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # bgit -`bgit` is a Git CLI for repositories stored directly in object storage. It -keeps a normal `.git` checkout on disk, so developers can use familiar Git -commands locally, while `bgit` syncs Git objects, branches, and tags to a -`gs://` or `s3://` repository. +`bgit` is a Git CLI for repositories stored directly in cloud buckets. It keeps +normal `.git` checkouts on disk, so developers can use familiar local Git +workflows, while BucketGit stores repository objects and refs in GCS or S3 and +coordinates access through a lightweight broker. -Use it when you want a lightweight Git backend in GCS or S3 without running a +Use it when you want Git repositories in cloud object storage without running a Git server. ## Project @@ -37,501 +37,360 @@ Check the installed version: bgit --version ``` -## Build +## How BucketGit Works -```bash -go build -o bgit . -``` +BucketGit has two layers: -## Features - -- Clone, initialize, fetch, pull, and push repositories backed by GCS or S3. -- Store a repository at any `gs://bucket/path/to/repo.git` or - `s3://bucket/path/to/repo.git` prefix. -- Work in a normal Git checkout with a standard `.git` directory. -- Use native local workflows for status, add, commit, checkout, branch, merge, - tag, diff, log, show, reset, restore, stash, revert, grep, blame, - cherry-pick, clean, describe, ls-files, ls-tree, archive, config, rev-parse, - rm, and mv. -- Push branches and tags back to object storage with `bgit push`. -- Configure an origin with `bgit origin` or `bgit remote add origin`. -- Grant read, write, admin, public, or private bucket access with `bgit admin`. -- Create and save gcloud profiles with `bgit create-gcloud-profile`. -- Configure native Git fetch/push over SSH with `bgit ssh setup` and the - serverless broker. -- Browse remote or local repositories with `bgit web`. -- Create the target GCS or S3 bucket automatically when permissions allow it. -- Run direct bucket inspection commands for scripts and automation. +- A normal local Git checkout on your machine. +- A broker-backed repository stored directly in GCS or S3. -## Requirements +The broker handles repository mapping, roles, SSH-key authorization, pull +requests, issues, branch protection, and short-lived object-transfer +capabilities. Developers do not need long-lived bucket credentials for everyday +clone, fetch, pull, push, review, or web browsing flows. -- Go 1.22 or newer to build from source. -- The `git` executable available on `PATH` for repository initialization, - checkout setup, and compatibility config/remote metadata. -- Google Cloud Storage access through `gcloud` or Application Default - Credentials for `gs://` repositories. -- AWS credentials through the AWS SDK credential chain for `s3://` - repositories. +Direct bucket access still exists under `bgit direct` for recovery, migration, +and low-level inspection. It is not the normal user workflow. -By default, `bgit` asks `gcloud` for an OAuth access token and uses that token -for GCS API calls: +![BucketGit serverless architecture](architecture/bucketgit-serverless-architecture.png) -```bash -gcloud auth login -gcloud auth print-access-token -``` +## Quickstart -This follows the active gcloud configuration. To use a named gcloud profile: +Set up BucketGit for one or more cloud profiles: ```bash -bgit --profile test-profile clone gs://my-bucket/repositories/demo.git -bgit --profile test-profile push +bgit setup ``` -Internally, bgit runs `gcloud auth print-access-token`. When a profile is set, -bgit runs that subprocess with `CLOUDSDK_ACTIVE_CONFIG_NAME` set to the profile -name so it matches gcloud's named configuration behavior. -Global flags such as `--profile` and `--auth` can be placed before or after the -command. +`bgit setup` discovers GCP and AWS profiles, lets you choose regions, imports +owner SSH keys, deploys or updates the broker, and writes global configuration to +`~/.bgit/config.yaml`. -### Gcloud Profiles - -Use an existing gcloud configuration for one command: +Create a new repository: ```bash -bgit push --profile test-profile -``` - -You can also save auth defaults in the checkout: +mkdir demo +cd demo +bgit init -```bash -bgit config bucketgit.auth gcloud -bgit config bucketgit.profile test-profile +echo "hello" > README.md +bgit add README.md +bgit commit -m "Initial commit" +bgit push ``` -Check the saved profile: +Clone an existing broker-backed repository: ```bash -bgit config bucketgit.profile +bgit clone https://broker.example.com/team/demo.git ./demo ``` -Use `bucketgit.auth adc` to make that checkout use ADC by default. If no auth -config is set, bgit defaults to `gcloud`; if no profile/configuration is set, -bgit uses the active gcloud configuration. - -To create a new gcloud profile and save it in the current checkout: +Inside an initialized checkout, normal Git commands also work for fetch and push +through the `core.sshCommand` written by `bgit init`: ```bash -bgit create-gcloud-profile my-profile +git fetch +git push ``` -This runs `gcloud config configurations create my-profile`, then -`gcloud auth login --configuration my-profile`. Use `--yes` to skip bgit's -confirmation prompt. The gcloud browser login still runs. +## Common Commands ```bash -bgit create-gcloud-profile --yes my-profile -``` +bgit setup +bgit setup profile create --provider gcp work +bgit setup profile create --provider aws work -For CI, service accounts, or environments where ADC is preferred, opt in -explicitly: +bgit init +bgit init --noninteractive --repo team/demo --profile work.europe-west1 +bgit clone https://broker.example.com/team/demo.git ./demo +bgit web -```bash -bgit --auth adc push -``` +bgit status +bgit add -A +bgit commit -m "Update" +bgit checkout -b feature/docs +bgit diff +bgit log --oneline -When `bgit put` or `bgit --bucket ... init` targets a GCS bucket that does not -exist, `bgit` attempts to create it in the active Google Cloud project. The -project is read from `GOOGLE_CLOUD_PROJECT`, `GCLOUD_PROJECT`, `GCP_PROJECT`, -or `gcloud config get-value project` using the selected configuration. The -environment variables take precedence, which is useful when a gcloud profile has -an account but no project set. +bgit fetch +bgit pull +bgit push +bgit push --tags +bgit push --delete feature/docs +bgit ls-remote -For S3 repositories, `bgit push` creates the bucket when it does not exist and -the selected AWS credentials have permission. Region selection follows -`AWS_REGION`, then `AWS_DEFAULT_REGION`, then `us-east-1`. +bgit pr create --title "Add docs" --source feature/docs --target main +bgit pr list +bgit pr view 1 +bgit pr diff 1 +bgit pr merge 1 -If Google returns an auth error, first check that the selected gcloud -configuration has the expected account and project: +bgit issue create "Bug report" --body "Details" +bgit issue list +bgit issue view 1 -```bash -gcloud config configurations list -CLOUDSDK_ACTIVE_CONFIG_NAME=test-profile gcloud auth print-access-token -CLOUDSDK_ACTIVE_CONFIG_NAME=test-profile gcloud config get-value project +bgit whoami +bgit repos mine ``` -### AWS Profiles - -For `s3://` origins, bgit uses the AWS SDK credential chain. It supports -`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, temporary credentials through -`AWS_SESSION_TOKEN`, IAM roles, SSO-backed profiles, and the credentials/config -files written by the AWS CLI. +## Setup And Profiles -Region selection follows `AWS_REGION`, then `AWS_DEFAULT_REGION`, and defaults -to `us-east-1` when neither is set. +Global configuration is stored in `~/.bgit/config.yaml`. Profiles are +provider- and region-aware, so the same cloud account can have brokers in +multiple regions. -Use an AWS CLI profile for one command: +Examples: ```bash -bgit clone s3://my-bucket/repositories/demo.git --profile work -bgit push --profile work +bgit init --noninteractive --repo app --profile work.europe-west1 +bgit push --profile work --region europe-west1 ``` -Save the profile in a checkout: +If a profile has multiple configured regions, pass the region explicitly: ```bash -bgit config bucketgit.profile work +bgit push --profile work --region eu-west-1 ``` -## Quickstart - -Clone an existing object-storage-backed repository: +or use a region-qualified profile name: ```bash -bgit clone gs://my-bucket/repositories/demo.git ./demo -bgit clone s3://my-bucket/repositories/demo.git ./demo-s3 --profile work -cd demo - -git status -git log --oneline +bgit push --profile work.eu-west-1 ``` -For read-only remote operations such as `clone`, `fetch`, `pull`, and -`ls-remote`, `bgit` first tries an anonymous public read. If the repository is -private, it automatically retries with the configured GCS or AWS credentials. - -Make a change and push it: +`bgit setup` can also create cloud CLI profiles: ```bash -echo "hello" > README.md -bgit add README.md -bgit commit -m "Add README" -bgit push +bgit setup profile create --provider gcp work +bgit setup profile create --provider aws work ``` -Create a new repository from an existing directory: +GCP setup uses `gcloud` configurations. AWS setup reads AWS config/credentials +files and can use the AWS CLI when profile creation is requested. -```bash -mkdir demo -cd demo +## Identity -bgit init -echo "hello" > README.md -bgit add README.md -bgit commit -m "Initial commit" +BucketGit supports a global name and email in `~/.bgit/config.yaml` and per-repo +identity in `.git/config`, matching the way Git users expect identity to work. +The repo-local identity overrides the global one. -bgit origin gs://my-bucket/repositories/demo.git -# or: -bgit origin s3://my-bucket/repositories/demo.git -bgit push -``` +If no identity is configured, BucketGit falls back to a default client identity +and warns before pushing. -## Web UI - -`bgit web` serves a small local repository browser on `127.0.0.1:8042`: - -```bash -bgit web -``` +## Access Control -By default it serves the configured remote repository using the same read path -as `bgit fetch` and `bgit ls-remote`: anonymous public read first, then -authenticated GCS/S3 retry when the repository is private. It also honors -`bucketgit.profile` and `--profile`. +Repository access is broker-backed and SSH-key based. Roles are: -If the checkout is configured with `bucketgit.broker`, `bgit web` can fall back -to broker-mediated read access signed by the user's ssh-agent key. This lets a -user who only has SSH broker access browse the repository without direct cloud -credentials. +- `owner` +- `admin` +- `maintainer` +- `developer` +- `triage` +- `read` -The web UI includes a branch/tag selector, clone command copy buttons, file and -raw blob views, commit author and committer metadata, and per-commit diffs. +Owners cannot be deleted or suspended. Ownership transfer uses a two-step flow: +the current owner creates a transfer command, and the new owner accepts it with +an SSH signature. -Use `--local` to browse the local `.git` object store instead: +Useful admin commands: ```bash -bgit web --local -bgit web --port 9000 -``` +bgit admin keys list +bgit admin keys add --user ada --role developer --key ~/.ssh/ada.pub +bgit admin keys import-github octocat --role triage +bgit admin keys suspend KEY_OR_FINGERPRINT +bgit admin keys remove KEY_OR_FINGERPRINT -## Git SSH Transport +bgit admin invite-user --broker https://broker.example.com --user ada --role developer team/demo.git +bgit admin accept-invite CODE +bgit admin cancel-invite --broker https://broker.example.com --user ada team/demo.git -`bgit ssh setup` configures a checkout so normal Git clients use `bgit` as the -SSH transport: +bgit admin confirm-ownership-transfer --broker https://broker.example.com team/demo.git +bgit admin accept-ownership-transfer CODE +bgit admin cancel-ownership-transfer --broker https://broker.example.com team/demo.git -```bash -bgit ssh setup gs://my-bucket/repositories/demo.git -bgit ssh setup s3://my-bucket/repositories/demo.git --profile work +bgit admin protect add main +bgit admin protect list +bgit admin protect remove main ``` -This writes a Git remote like `git@git.bucketgit.com:bucket/prefix.git` and -sets `core.sshCommand=bgit ssh`. Fresh native Git clones can use the same URL -when `GIT_SSH_COMMAND` points at `bgit ssh`: +A repo can have at most one active pending invite per username. Invite +cancellation is repo-scoped. + +## Repository Settings + +Broker-backed repositories support public/private visibility, read-only mode, +issues, branch protection, logical rename, and owner-only destructive delete. ```bash -GIT_SSH_COMMAND="bgit ssh" git clone git@git.bucketgit.com:my-bucket/repositories/demo.git +bgit admin repo visibility public +bgit admin repo visibility private +bgit admin repo readonly on +bgit admin repo readonly off +bgit admin repo issues on +bgit admin repo issues off +bgit admin repo rename new-name +bgit admin repo delete --yes ``` -SSH Git operations are authorized through the bgit broker. Fetch, clone, and -`ls-remote` require an active key with `read`, `write`, or `admin`. Push -requires `write` or `admin`. Suspended keys are rejected. +Public repositories can be cloned and browsed without an SSH key. Private +repositories require a recognized broker SSH key. -When a broker is configured, both native `git push` through `bgit ssh` and -`bgit push` use the broker for compare-and-swap ref updates before mirroring refs -back to the bucket. This gives AWS and GCP the same concurrent-push behavior: -one writer wins and stale writers are rejected instead of silently overwriting a -ref. +## Pull Requests And Issues -Direct `bgit` commands against `gs://` or `s3://` origins still use the selected -cloud credentials. If the broker is unavailable and an operator needs to bypass -broker coordination, use: +Pull requests and issues are broker metadata, not part of the Git protocol. +BucketGit implements them on top of repository refs and broker-side metadata. ```bash -bgit push --skip-broker -``` +bgit pr create --title "Add docs" --source feature/docs --target main +bgit pr list +bgit pr view 1 +bgit pr diff 1 +bgit pr comment 1 "Looks good" +bgit pr approve 1 "Approved" +bgit pr reject 1 "Please change this" +bgit pr merge 1 --delete-branch +bgit pr close 1 -When no broker is configured, `bgit push` writes refs directly to the bucket and -accepts the usual last-writer-wins risk. +bgit issue create "Missing docs" --body "The setup page needs examples." +bgit issue list +bgit issue comment 1 "I can take this." +bgit issue close 1 +bgit issue reopen 1 +``` -For GCP broker bootstrap, `bgit ssh setup` enables the required APIs and uses a -named Firestore database called `bgit`. If that database does not exist yet, the -caller needs `datastore.databases.create`, for example via -`roles/datastore.owner`. This permission is only needed while creating the -database; later repo/key administration uses the deployed broker and SSH admin -keys. +Branch protection is enforced by the broker. Protected branches can require the +PR merge path, with optional owner/admin override. -Broker-mediated `bgit web` reads use the broker runtime's cloud permissions to -read repository objects. The generated AWS broker role includes S3 read/list -permissions. On GCP, grant the Cloud Run function service account storage -read/list access if the repository bucket is outside the function's default -project permissions. +## Web UI -The broker tracks repositories and SSH keys: +`bgit web` serves a local browser UI on `127.0.0.1:8042`: ```bash -bgit ssh repo add -bgit ssh keys list -bgit ssh keys add --user ada --role read --key ~/.ssh/ada.pub -bgit ssh keys suspend KEY_OR_COMMENT -bgit ssh keys remove KEY_OR_COMMENT +bgit web ``` -## Repository URLs +The web UI uses the configured repository and broker by default. It shows files, +commits, pull requests, issues, repository settings, capability-aware controls, +local dirty/staged/unpushed state, and remote sync status. -Repository URLs use the `gs://` or `s3://` scheme: +Use local-only mode to browse the local `.git` object store without broker +refreshes: -```text -gs://bucket-name/path/to/repo.git -s3://bucket-name/path/to/repo.git +```bash +bgit web --local +bgit web --port 9000 ``` -The bucket is the object-storage bucket name. Everything after the bucket is the -repository prefix. For example, this repository: +The web assets are embedded into the `bgit` binary at build time. -```text -gs://my-bucket/repositories/demo.git -``` +## Native Git Transport -is stored under: +`bgit init` writes a Git remote like: ```text -gs://my-bucket/repositories/demo.git/HEAD -gs://my-bucket/repositories/demo.git/objects/... -gs://my-bucket/repositories/demo.git/refs/... +git@git.bucketgit.com:team/demo.git ``` -The same layout is used for S3: +and configures: ```text -s3://my-bucket/repositories/demo.git/HEAD -s3://my-bucket/repositories/demo.git/objects/... -s3://my-bucket/repositories/demo.git/refs/... +core.sshCommand=bgit ssh ``` -## Common Commands +That lets native Git use BucketGit for fetch and push inside initialized +repositories: ```bash -bgit --version -bgit clone gs://my-bucket/repositories/demo.git [directory] -bgit clone s3://my-bucket/repositories/demo.git [directory] -bgit init [directory] -bgit origin gs://my-bucket/repositories/demo.git -bgit origin s3://my-bucket/repositories/demo.git -bgit ssh setup gs://my-bucket/repositories/demo.git -bgit web - -bgit fetch -bgit pull -bgit push -bgit push --skip-broker -bgit push --tags -bgit push --delete feature -bgit ls-remote -bgit admin grant-write user:dev@example.com - -bgit checkout -b feature -bgit checkout main -bgit branch -bgit merge feature -bgit tag v1.0.0 - -bgit status -bgit add -A -bgit commit -m "Update" -bgit diff -bgit log --oneline -bgit show HEAD -bgit restore README.md -bgit reset --hard HEAD -bgit stash -bgit revert HEAD -bgit config user.name "Ada Lovelace" -bgit rev-parse HEAD +git fetch +git push ``` -Local workflow commands are implemented by `bgit` for the supported subset. -Commands outside that subset return `Unsupported` instead of delegating to the -system `git` binary. +Native Git transport is authorized through the broker. Ref updates use +compare-and-swap checks so stale writers are rejected instead of silently +overwriting refs. -## Origins +## Direct Bucket Mode -`bgit clone` writes the origin into `.git/config` automatically. To attach an -origin to an existing checkout, run: +Direct bucket mode is the low-level escape hatch for recovery, migration, +scripts, and debugging. It uses cloud credentials directly and bypasses the +normal broker-first workflow. ```bash -bgit origin gs://my-bucket/repositories/demo.git -bgit origin s3://my-bucket/repositories/demo.git +bgit direct help +bgit direct clone gs://bucket/repositories/demo.git +bgit direct clone s3://bucket/repositories/demo.git +bgit direct fetch +bgit direct push +bgit --bucket my-bucket --prefix repositories/demo.git direct ls docs/ +bgit --bucket my-bucket --prefix repositories/demo.git direct cat docs/readme.md ``` -You can also use Git-style remote commands: +Cloud IAM and bucket-policy recovery commands also live under direct mode: ```bash -bgit remote add origin gs://my-bucket/repositories/demo.git -bgit remote add origin s3://my-bucket/repositories/demo.git -bgit remote set-url origin gs://my-bucket/repositories/demo.git +bgit direct admin grant-read user:dev@example.com +bgit direct admin grant-write serviceAccount:ci@project.iam.gserviceaccount.com +bgit direct admin grant-admin arn:aws:iam::123456789012:role/Admin ``` -If `bgit push` is run without an origin, it prints a copy-pasteable example: +## Broker Maintenance -```text -No configured push destination. -Either specify the repository from the command-line: - - bgit --bucket bucket-name --prefix path/to/repo.git push - -or configure a bgit origin: - - bgit origin gs://bucket-name/path/to/repo.git - bgit origin s3://bucket-name/path/to/repo.git - -and then push: - - bgit push -``` - -## Access Control - -`bgit admin` grants bucket access using the selected cloud profile. Run it -inside a checkout to infer the bucket and prefix from `.git/config`, or pass -`--bucket` explicitly. - -For GCS repositories: +Broker maintenance commands are intentionally separated from normal user flows: ```bash -bgit admin grant-read user:dev@example.com -bgit admin grant-write serviceAccount:ci@project.iam.gserviceaccount.com -bgit admin --bucket my-bucket grant-admin admin@example.com -bgit admin make-public -bgit admin make-private +bgit janitor members reindex +bgit broker delete --provider gcp --profile work --region europe-west1 --yes +bgit broker delete --provider aws --profile work --region eu-west-1 --yes ``` -GCS `grant-read` grants `roles/storage.objectViewer` and -`roles/storage.legacyBucketReader`. `grant-write` grants -`roles/storage.objectAdmin` and `roles/storage.legacyBucketReader`. -`grant-admin` grants `roles/storage.admin`. `make-public` grants anonymous read -access at bucket level. `make-private` removes `allUsers` and -`allAuthenticatedUsers` from bgit's bucket-level read roles. +Use them for repair, test cleanup, or broker decommissioning. -The caller must already have permission to read and update the bucket IAM -policy, such as `roles/storage.admin` on the bucket. +## Development And Tests -For S3 repositories: +Build from source: ```bash -bgit admin grant-read arn:aws:iam::123456789012:role/Developer -bgit admin --bucket s3://my-bucket/repositories/demo.git grant-write 123456789012 -bgit admin --bucket s3://my-bucket/repositories/demo.git grant-admin arn:aws:iam::123456789012:role/Admin -bgit admin --bucket s3://my-bucket/repositories/demo.git make-public -bgit admin --bucket s3://my-bucket/repositories/demo.git make-private +go build -o bgit . ``` -S3 identities must be IAM or STS ARNs, 12 digit AWS account IDs, or `*`. -`grant-read` grants `s3:ListBucket` for the repository prefix and -`s3:GetObject` for objects under that prefix. `grant-write` adds -`s3:PutObject`, `s3:DeleteObject`, and multipart abort access. `grant-admin` -grants `s3:*` for the bucket and repository prefix. The caller must already have -permission to read and update the bucket policy. - -S3 `make-public` removes bucket-level Block Public Access and adds anonymous -read access for the repository prefix. `make-private` removes bgit's anonymous -statements for that prefix and restores bucket-level Block Public Access. - -## Branches And Tags - -New repositories default to the `main` branch. Use `--branch` when cloning or -using direct GCS mode to target another branch: +Run unit tests: ```bash -bgit --branch develop clone gs://my-bucket/repositories/demo.git -bgit --branch release fetch +go test ./... ``` -Tags are regular Git tags in the object-storage-backed repository: +Run the local broker integration suite: ```bash -bgit tag v1.0.0 -bgit tag -a v1.0.1 -m "Release v1.0.1" -bgit push --tags -bgit ls-remote --tags +./testsuite/run.sh +BGIT_TEST_PROVIDER=gcp ./testsuite/run.sh +BGIT_TEST_PROVIDER=aws ./testsuite/run.sh ``` -## Direct Bucket Mode - -Most developers should use `clone`, `init`, `origin`, and `push`. Direct bucket -mode is available for scripts and one-off inspection without a checkout: - -```bash -bgit --bucket my-bucket --prefix repositories/demo.git ls docs/ -bgit --bucket my-bucket --prefix repositories/demo.git cat docs/readme.md -bgit --bucket my-bucket --prefix repositories/demo.git log --limit 10 -bgit --bucket my-bucket --prefix repositories/demo.git put docs/readme.md --file README.md -m "Add readme" --author "Ada Lovelace" --email ada@example.com -``` +The integration suite uses local SQLite-backed broker runtimes and does not +require cloud credentials or deployed brokers. -## How It Works +## Requirements -`bgit` stores Git objects and refs in an object-storage prefix using the normal Git -repository layout. Remote operations read and write those objects and refs -directly through the GCS or S3 API. +Runtime requirements depend on the command: -Local checkouts remain normal Git worktrees. `bgit` implements the supported -local workflow commands directly, uses the `git` executable only for repository -setup/config compatibility, and uses object-storage-backed remote updates for -collaboration. +- `git` on `PATH` for repository initialization and native Git compatibility. +- `ssh-agent`/`ssh-add` for broker SSH-key signing flows. +- `gcloud` for GCP setup and profile creation. +- AWS config/credentials files and optionally the AWS CLI for AWS setup/profile + creation. +- Go 1.22 or newer to build from source. ## Unsupported Commands -Some Git commands depend on Git's network protocol, server-side hooks, packfile -maintenance, or repository features that `bgit` does not emulate. Unsupported -commands return: +BucketGit implements the supported local workflow commands directly. Commands +outside that subset return an unsupported-command error instead of delegating to +the system Git binary. -```text -Unsupported: '' is not supported by bgit -``` - -Unsupported commands include `rebase`, `daemon`, `submodule`, `lfs`, `gc`, -`fsck`, `repack`, `prune`, `worktree`, credential helpers, server helpers, and -related maintenance commands. Native Git fetch and push are supported inside -repositories configured with `bgit ssh setup`. +Unsupported commands include repository maintenance and server features such as +`daemon`, `submodule`, `lfs`, `gc`, `fsck`, `repack`, `prune`, `worktree`, +credential helpers, and related server helpers. ## Contributing @@ -547,14 +406,3 @@ fork-to-pull-request workflow and the checks to run before opening a PR. `bgit` is provided as-is, without warranty of any kind. You are responsible for testing it against your own repositories, access controls, backup strategy, and operational requirements before relying on it in production. - -## Help - -```bash -bgit help -bgit help push -bgit push --help -bgit --help push -bgit push help -bgit --version -``` diff --git a/architecture/bucketgit-serverless-architecture.png b/architecture/bucketgit-serverless-architecture.png new file mode 100644 index 0000000..a1861a5 Binary files /dev/null and b/architecture/bucketgit-serverless-architecture.png differ diff --git a/auth_capabilities.go b/auth_capabilities.go new file mode 100644 index 0000000..1568154 --- /dev/null +++ b/auth_capabilities.go @@ -0,0 +1,468 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +type brokerAuthStatusRequest struct { + Repo brokerRepo `json:"repo"` +} + +type repoAuthCache struct { + BrokerURL string `json:"broker_url,omitempty"` + Repo string `json:"repo,omitempty"` + KeyFingerprint string `json:"key_fingerprint,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type brokerAuthStatus struct { + BrokerURL string `json:"broker_url,omitempty"` + BrokerVersion string `json:"broker_version,omitempty"` + Repo brokerRepo `json:"repo,omitempty"` + Identity brokerIdentity `json:"identity,omitempty"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + Capabilities map[string]bool `json:"capabilities,omitempty"` + ResolvedAt string `json:"resolved_at,omitempty"` + CachedAt string `json:"cached_at,omitempty"` + Stale bool `json:"stale,omitempty"` + Error string `json:"error,omitempty"` +} + +type brokerIdentity struct { + User string `json:"user,omitempty"` + Source string `json:"source,omitempty"` + KeyFingerprint string `json:"key_fingerprint,omitempty"` + PublicKey string `json:"public_key,omitempty"` +} + +func whoamiCommand(ctx context.Context, cfg config, args []string, stdout io.Writer) error { + jsonOut := false + refresh := false + all := false + for _, arg := range args { + switch arg { + case "--json": + jsonOut = true + case "--refresh": + refresh = true + case "--all": + all = true + default: + return errors.New("usage: bgit whoami [--json] [--refresh] [--all]") + } + } + if all { + return whoamiAllCommand(ctx, cfg, jsonOut, stdout) + } + if cfg.brokerURL == "" || cfg.logicalRepo == "" { + return errors.New("whoami requires a broker-backed BucketGit repository") + } + status, err := brokerWhoami(ctx, cfg, refresh) + if err != nil { + return err + } + if jsonOut { + data, err := json.MarshalIndent(status, "", " ") + if err != nil { + return err + } + fmt.Fprintln(stdout, string(data)) + return nil + } + fmt.Fprintf(stdout, "broker: %s\n", status.BrokerURL) + fmt.Fprintf(stdout, "repo: %s\n", firstNonEmpty(status.Repo.Logical, status.Repo.Prefix)) + fmt.Fprintf(stdout, "user: %s\n", firstNonEmpty(status.User, status.Identity.User, "unknown")) + fmt.Fprintf(stdout, "role: %s\n", firstNonEmpty(status.Role, "none")) + if status.Identity.KeyFingerprint != "" { + fmt.Fprintf(stdout, "key: %s\n", status.Identity.KeyFingerprint) + if cfg.identity != "" { + fmt.Fprintf(stdout, "selected identity: %s\n", cfg.identity) + } + } + if status.BrokerVersion != "" { + fmt.Fprintf(stdout, "broker version: %s\n", status.BrokerVersion) + } + var caps []string + for name, ok := range status.Capabilities { + if ok { + caps = append(caps, name) + } + } + sort.Strings(caps) + if len(caps) > 0 { + fmt.Fprintf(stdout, "capabilities: %s\n", strings.Join(caps, ", ")) + } + return nil +} + +func whoamiAllCommand(ctx context.Context, cfg config, jsonOut bool, stdout io.Writer) error { + if cfg.brokerURL == "" { + return errors.New("whoami --all requires a broker-backed repository or --profile selection") + } + repos, err := brokerReposMineAllKeys(ctx, cfg.brokerURL) + if err != nil { + return err + } + resp := brokerReposMineResponse{Repos: repos} + if jsonOut { + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return err + } + fmt.Fprintln(stdout, string(data)) + return nil + } + if len(resp.Repos) == 0 { + fmt.Fprintln(stdout, "No repositories found for the available SSH keys.") + return nil + } + for _, repo := range resp.Repos { + fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\n", repo.Logical, repo.Role, repo.User, repo.KeyFingerprint) + } + for _, warning := range repoMembershipWarnings(resp.Repos) { + fmt.Fprintf(stdout, "warning: %s\n", warning) + } + return nil +} + +func reposCommand(ctx context.Context, cfg config, args []string, stdout io.Writer) error { + jsonOut := false + if len(args) == 0 || args[0] != "mine" { + return errors.New("usage: bgit repos mine [--json]") + } + for _, arg := range args[1:] { + switch arg { + case "--json": + jsonOut = true + default: + return errors.New("usage: bgit repos mine [--json]") + } + } + return whoamiAllCommand(ctx, cfg, jsonOut, stdout) +} + +func brokerReposMineAllKeys(ctx context.Context, brokerURL string) ([]brokerRepoMembership, error) { + data := []byte(`{}`) + headerSets := brokerSignatureHeaderSetsForBroker(brokerURL, data) + if len(headerSets) == 0 { + return nil, errors.New("no SSH agent keys available") + } + merged := map[string]brokerRepoMembership{} + var lastErr error + for _, headers := range headerSets { + var resp brokerReposMineResponse + if err := brokerPostContextWithHeaders(ctx, brokerURL, "/repos/mine", data, headers, &resp); err != nil { + lastErr = err + continue + } + for _, repo := range resp.Repos { + key := repo.KeyFingerprint + "\x00" + firstNonEmpty(repo.Logical, repo.RepoID, repo.Repo.Logical) + merged[key] = repo + } + } + if len(merged) == 0 && lastErr != nil { + return nil, lastErr + } + var repos []brokerRepoMembership + for _, repo := range merged { + repos = append(repos, repo) + } + sort.Slice(repos, func(i, j int) bool { + a := firstNonEmpty(repos[i].Logical, repos[i].RepoID) + b := firstNonEmpty(repos[j].Logical, repos[j].RepoID) + if a == b { + return repos[i].KeyFingerprint < repos[j].KeyFingerprint + } + return a < b + }) + return repos, nil +} + +func repoMembershipWarnings(repos []brokerRepoMembership) []string { + byRepo := map[string][]brokerRepoMembership{} + for _, repo := range repos { + name := firstNonEmpty(repo.Logical, repo.RepoID, repo.Repo.Logical) + if name == "" { + continue + } + byRepo[name] = append(byRepo[name], repo) + } + var warnings []string + for name, memberships := range byRepo { + if len(memberships) < 2 { + continue + } + users := map[string]struct{}{} + roles := map[string]struct{}{} + for _, membership := range memberships { + users[membership.User] = struct{}{} + roles[membership.Role] = struct{}{} + } + if len(users) > 1 { + warnings = append(warnings, fmt.Sprintf("%s is available through multiple SSH keys with different user labels", name)) + } else if len(roles) > 1 { + warnings = append(warnings, fmt.Sprintf("%s is available through multiple SSH keys with different roles", name)) + } + } + sort.Strings(warnings) + return warnings +} + +func brokerPostContextWithHeaders(ctx context.Context, brokerURL, path string, data []byte, headers map[string]string, resp any) error { + endpoint := strings.TrimRight(brokerURL, "/") + path + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return err + } + httpReq.Header.Set("content-type", "application/json") + for key, value := range headers { + httpReq.Header.Set(key, value) + } + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return err + } + defer httpResp.Body.Close() + body, readErr := io.ReadAll(httpResp.Body) + if readErr != nil { + return readErr + } + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + msg := strings.TrimSpace(string(body)) + if msg == "" { + msg = httpResp.Status + } + return fmt.Errorf("broker %s: %s", path, msg) + } + if resp != nil && len(body) > 0 { + if err := json.Unmarshal(body, resp); err != nil { + return err + } + } + return nil +} + +type brokerReposMineResponse struct { + Repos []brokerRepoMembership `json:"repos"` +} + +type brokerRepoMembership struct { + RepoID string `json:"repo_id,omitempty"` + Logical string `json:"logical,omitempty"` + Repo brokerRepo `json:"repo,omitempty"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + Source string `json:"source,omitempty"` + KeyFingerprint string `json:"key_fingerprint,omitempty"` + Suspended bool `json:"suspended,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +func brokerWhoami(ctx context.Context, cfg config, refresh bool) (brokerAuthStatus, error) { + if !refresh { + if cached, err := readWhoamiCache(cfg.brokerURL); err == nil && cached.BrokerURL != "" { + if firstNonEmpty(cached.Repo.Logical, cached.Repo.Prefix) == firstNonEmpty(cfg.logicalRepo, cfg.prefix) { + return cached, nil + } + } + } + var status brokerAuthStatus + if err := brokerPostContext(ctx, cfg.brokerURL, "/auth/status", brokerAuthStatusRequest{Repo: repoForBroker(cfg)}, &status); err != nil { + return brokerAuthStatus{}, err + } + status.BrokerURL = cfg.brokerURL + if status.Repo.Logical == "" && status.Repo.Prefix == "" { + status.Repo = repoForBroker(cfg) + } + if status.User == "" { + status.User = status.Identity.User + } + if status.CachedAt == "" { + status.CachedAt = time.Now().UTC().Format(time.RFC3339) + } + _ = writeWhoamiCache(cfg.brokerURL, status) + return status, nil +} + +func readWhoamiCache(brokerURL string) (brokerAuthStatus, error) { + path, err := whoamiCachePath(brokerURL) + if err != nil { + return brokerAuthStatus{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return brokerAuthStatus{}, err + } + var status brokerAuthStatus + if err := json.Unmarshal(data, &status); err != nil { + return brokerAuthStatus{}, err + } + return status, nil +} + +func writeWhoamiCache(brokerURL string, status brokerAuthStatus) error { + path, err := whoamiCachePath(brokerURL) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(status, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} + +func preferredBrokerKeyFingerprints(brokerURL string, payload []byte) []string { + var preferred []string + if fp := fingerprintForIdentityPreference(brokerIdentityPreference); fp != "" { + preferred = append(preferred, fp) + } + if cache, err := readRepoAuthCache(brokerURL, payload); err == nil && cache.KeyFingerprint != "" { + preferred = append(preferred, cache.KeyFingerprint) + } + return uniqueNonEmptyStrings(preferred) +} + +func fingerprintForIdentityPreference(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if strings.HasPrefix(value, "SHA256:") { + return value + } + data, err := os.ReadFile(expandHome(value)) + if err != nil { + return "" + } + pub, _, _, _, err := ssh.ParseAuthorizedKey(data) + if err != nil { + return "" + } + return ssh.FingerprintSHA256(pub) +} + +func readRepoAuthCache(brokerURL string, payload []byte) (repoAuthCache, error) { + path, err := repoAuthCachePath() + if err != nil { + return repoAuthCache{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return repoAuthCache{}, err + } + var cache repoAuthCache + if err := json.Unmarshal(data, &cache); err != nil { + return repoAuthCache{}, err + } + if cache.BrokerURL != "" && brokerURL != "" && cache.BrokerURL != brokerURL { + return repoAuthCache{}, errors.New("repo auth cache is for a different broker") + } + if repo := repoNameFromBrokerPayload(payload); repo != "" && cache.Repo != "" && cache.Repo != repo { + return repoAuthCache{}, errors.New("repo auth cache is for a different repo") + } + return cache, nil +} + +func writeRepoAuthCache(brokerURL string, payload []byte, fingerprint string) error { + if strings.TrimSpace(fingerprint) == "" { + return nil + } + path, err := repoAuthCachePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + cache := repoAuthCache{ + BrokerURL: brokerURL, + Repo: repoNameFromBrokerPayload(payload), + KeyFingerprint: fingerprint, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} + +func repoAuthCachePath() (string, error) { + out, err := runGit(".", "rev-parse", "--git-path", "bgit/cache/auth.json") + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func repoNameFromBrokerPayload(payload []byte) string { + var raw struct { + Repo brokerRepo `json:"repo"` + } + if err := json.Unmarshal(payload, &raw); err != nil { + return "" + } + return firstNonEmpty(raw.Repo.Logical, raw.Repo.Prefix, raw.Repo.Origin) +} + +func whoamiCachePath(brokerURL string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".bgit", "cache", safeBrokerCacheName(brokerURL), "whoami.json"), nil +} + +func safeBrokerCacheName(brokerURL string) string { + value := strings.TrimSpace(brokerURL) + if parsed, err := url.Parse(value); err == nil && parsed.Host != "" { + value = parsed.Host + } + value = strings.ToLower(value) + var b strings.Builder + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + return strings.Trim(b.String(), "-") +} + +func uniqueNonEmptyStrings(values []string) []string { + seen := map[string]struct{}{} + var out []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + key := strings.ToLower(value) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/auth_capabilities_test.go b/auth_capabilities_test.go new file mode 100644 index 0000000..cbe246f --- /dev/null +++ b/auth_capabilities_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWhoamiCommandWritesGlobalCache(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/status" { + t.Fatalf("path = %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(brokerAuthStatus{ + BrokerVersion: "1.0.0-dev", + Repo: brokerRepo{Provider: "gcs", Logical: "foo.git"}, + Identity: brokerIdentity{User: "dennis", KeyFingerprint: "SHA256:test"}, + User: "dennis", + Role: "admin", + Capabilities: map[string]bool{"read": true, "push": true, "admin_keys": true}, + ResolvedAt: "2026-05-18T12:00:00Z", + }) + })) + defer server.Close() + + var stdout bytes.Buffer + cfg := config{provider: "gcs", brokerURL: server.URL, logicalRepo: "foo.git", prefix: "foo.git"} + if err := whoamiCommand(nilContext{}, cfg, []string{"--refresh"}, &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "user: dennis") || !strings.Contains(stdout.String(), "role: admin") { + t.Fatalf("stdout = %q", stdout.String()) + } + path, err := whoamiCachePath(server.URL) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte(`"role": "admin"`)) { + t.Fatalf("cache = %s", data) + } + if !strings.HasPrefix(path, filepath.Join(home, ".bgit", "cache")) { + t.Fatalf("cache path = %s", path) + } +} + +func TestPreferredBrokerKeyRankUsesConfiguredThenCachedKeys(t *testing.T) { + preferred := []string{"SHA256:configured", "SHA256:cached"} + if preferredBrokerKeyRank("SHA256:configured", preferred) >= preferredBrokerKeyRank("SHA256:cached", preferred) { + t.Fatal("configured key should rank before cached key") + } + if preferredBrokerKeyRank("SHA256:cached", preferred) >= preferredBrokerKeyRank("SHA256:other", preferred) { + t.Fatal("cached key should rank before unrelated agent keys") + } +} + +func TestRepoMembershipWarningsShowAmbiguousKeys(t *testing.T) { + warnings := repoMembershipWarnings([]brokerRepoMembership{ + {Logical: "foo.git", User: "dennis", Role: "admin", KeyFingerprint: "SHA256:a"}, + {Logical: "foo.git", User: "dennis", Role: "read", KeyFingerprint: "SHA256:b"}, + {Logical: "bar.git", User: "work", Role: "read", KeyFingerprint: "SHA256:c"}, + {Logical: "bar.git", User: "personal", Role: "read", KeyFingerprint: "SHA256:d"}, + }) + got := strings.Join(warnings, "\n") + if !strings.Contains(got, "foo.git is available through multiple SSH keys with different roles") { + t.Fatalf("warnings = %#v", warnings) + } + if !strings.Contains(got, "bar.git is available through multiple SSH keys with different user labels") { + t.Fatalf("warnings = %#v", warnings) + } +} + +func TestExplicitProfileSelectionAppliesToRepositoryDiscovery(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + path := filepath.Join(home, ".bgit", "config.yaml") + if err := writeGlobalConfig(path, globalConfig{ + Version: globalConfigVersion, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + ProjectID: "example-test-123456", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://gcp.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + cfg := config{gcloudConfiguration: "work.europe-west1", gcloudConfigurationExplicit: true} + if err := applyExplicitBrokerProfileSelection(&cfg, "repos"); err != nil { + t.Fatal(err) + } + if cfg.brokerURL != "https://gcp.example.test" || cfg.provider != "gcs" || cfg.region != "europe-west1" { + t.Fatalf("cfg = %#v", cfg) + } +} diff --git a/broker/aws/template.yaml b/broker/aws/template.yaml new file mode 100644 index 0000000..5afee22 --- /dev/null +++ b/broker/aws/template.yaml @@ -0,0 +1,1358 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Minimal bgit SSH broker control-plane endpoint. +Resources: + BrokerTransferRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub bgit-broker-transfer-${AWS::Region} + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + AWS: !Sub arn:aws:iam::${AWS::AccountId}:root + Action: + - sts:AssumeRole + - sts:TagSession + Condition: + ArnEquals: + aws:PrincipalArn: !Sub arn:aws:iam::${AWS::AccountId}:role/bgit-broker-${AWS::Region} + Policies: + - PolicyName: bgit-transfer-session-boundary + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:DeleteObject + - s3:AbortMultipartUpload + Resource: arn:aws:s3:::*/* + - Effect: Allow + Action: + - s3:ListBucket + - s3:CreateBucket + - s3:HeadBucket + - s3:DeleteBucket + Resource: arn:aws:s3:::* + BrokerRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub bgit-broker-${AWS::Region} + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: bgit-broker-table + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:DeleteItem + - dynamodb:Query + - dynamodb:Scan + Resource: + - !GetAtt BrokerTable.Arn + - !GetAtt BrokerPullRequestTable.Arn + - !GetAtt BrokerMemberTable.Arn + - Effect: Allow + Action: + - sts:AssumeRole + - sts:TagSession + Resource: !GetAtt BrokerTransferRole.Arn + - Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:DeleteObject + - s3:ListBucket + - s3:CreateBucket + - s3:HeadBucket + - s3:DeleteBucket + Resource: + - arn:aws:s3:::* + - arn:aws:s3:::*/* + BrokerTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: bgit-broker-repos + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: id + AttributeType: S + KeySchema: + - AttributeName: id + KeyType: HASH + BrokerPullRequestTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: bgit-broker-prs + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: repo_id + AttributeType: S + - AttributeName: pr_id + AttributeType: N + KeySchema: + - AttributeName: repo_id + KeyType: HASH + - AttributeName: pr_id + KeyType: RANGE + BrokerMemberTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: bgit-broker-members + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: fingerprint + AttributeType: S + - AttributeName: repo_id + AttributeType: S + KeySchema: + - AttributeName: fingerprint + KeyType: HASH + - AttributeName: repo_id + KeyType: RANGE + BrokerFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: bgit-broker + Runtime: nodejs22.x + Handler: index.handler + Role: !GetAtt BrokerRole.Arn + Environment: + Variables: + TABLE_NAME: !Ref BrokerTable + PR_TABLE_NAME: !Ref BrokerPullRequestTable + MEMBER_TABLE_NAME: !Ref BrokerMemberTable + TRANSFER_ROLE_ARN: !GetAtt BrokerTransferRole.Arn + BROKER_VERSION: {{BROKER_VERSION}} + Code: + ZipFile: | + const crypto = require("crypto"); + const {DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, ScanCommand, DeleteItemCommand} = require("@aws-sdk/client-dynamodb"); + const {S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadBucketCommand, CreateBucketCommand, DeleteBucketCommand} = require("@aws-sdk/client-s3"); + const {STSClient, AssumeRoleCommand} = require("@aws-sdk/client-sts"); + const db = new DynamoDBClient({}); + const s3 = new S3Client({}); + const sts = new STSClient({}); + const table = process.env.TABLE_NAME; + const prTable = process.env.PR_TABLE_NAME; + const memberTable = process.env.MEMBER_TABLE_NAME; + const transferRoleArn = process.env.TRANSFER_ROLE_ARN; + const brokerVersion = process.env.BROKER_VERSION || "{{BROKER_VERSION}}"; + const zero = "0000000000000000000000000000000000000000"; + function response(statusCode, body) { + return {statusCode, headers: {"content-type": "application/json"}, body: JSON.stringify(body)}; + } + function repoID(repo) { + if (repo && repo.logical) return ["logical", repo.logical].join(":"); + return [repo.provider || "s3", repo.bucket, repo.prefix].join(":"); + } + function docID(repo) { + return Buffer.from(repoID(repo)).toString("base64url"); + } + function cleanName(value) { + return String(value || "repo").toLowerCase().replace(/[^a-z0-9.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "repo"; + } + function randomSuffix() { + return crypto.randomBytes(5).toString("hex"); + } + async function loadRepo(repo) { + if (!repo || (!repo.logical && (!repo.bucket || !repo.prefix))) throw new Error("repo is required"); + const id = docID(repo); + const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); + let data = {repo, keys: [], audit: []}; + if (out.Item) data = JSON.parse(out.Item.data.S || "{}"); + data.repo = data.repo || repo; + data.keys = data.keys || []; + data.audit = data.audit || []; + const owners = await loadOwners(); + for (const owner of owners.data.keys || []) { + if (owner.role === "owner" && !data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(owner.public_key))) data.keys.push(owner); + } + return {id, data}; + } + async function saveRepo(entry) { + await db.send(new PutItemCommand({TableName: table, Item: {id: {S: entry.id}, data: {S: JSON.stringify(entry.data)}}})); + await syncMembershipIndex(entry); + } + async function syncMembershipIndex(entry) { + const repo = entry.data.repo || {}; + if (!repo.logical && (!repo.bucket || !repo.prefix)) return; + const repoIDValue = repoID(repo); + const logical = repo.logical || repo.prefix || repoIDValue; + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + const fingerprint = keyFingerprint(key.public_key); + await db.send(new PutItemCommand({TableName: memberTable, Item: { + fingerprint: {S: fingerprint}, + repo_id: {S: repoIDValue}, + data: {S: JSON.stringify({repo_id: repoIDValue, logical, repo, user: key.user || "", role: key.role || "", source: key.source || "", suspended: !!key.suspended, updated_at: new Date().toISOString()})} + }})); + } + } + async function syncAllMembershipIndexes() { + let count = 0; + let ExclusiveStartKey; + do { + const out = await db.send(new ScanCommand({TableName: table, ExclusiveStartKey})); + for (const item of out.Items || []) { + const id = item.id && item.id.S || ""; + if (id === "_owners") continue; + const data = JSON.parse(item.data && item.data.S || "{}"); + const repo = data.repo || {}; + if (!repo.logical && (!repo.bucket || !repo.prefix)) continue; + count++; + await syncMembershipIndex({id, data}); + } + ExclusiveStartKey = out.LastEvaluatedKey; + } while (ExclusiveStartKey); + return count; + } + function prKey(repoID, id) { + return {repo_id: {S: repoID}, pr_id: {N: String(Number(id))}}; + } + async function savePR(entry, pr) { + await db.send(new PutItemCommand({TableName: prTable, Item: {...prKey(entry.id, pr.id), data: {S: JSON.stringify(pr)}}})); + } + async function loadPR(entry, id) { + const out = await db.send(new GetItemCommand({TableName: prTable, Key: prKey(entry.id, id)})); + if (!out.Item) return null; + return JSON.parse(out.Item.data.S || "{}"); + } + async function listPRs(entry) { + const prs = []; + let startKey = undefined; + do { + const out = await db.send(new QueryCommand({ + TableName: prTable, + KeyConditionExpression: "repo_id = :repo", + ExpressionAttributeValues: {":repo": {S: entry.id}}, + ScanIndexForward: false, + ExclusiveStartKey: startKey + })); + for (const item of out.Items || []) { + const pr = JSON.parse(item.data.S || "{}"); + if (Number(pr.id || 0) > 0 && pr.kind !== "issue") prs.push(pr); + } + startKey = out.LastEvaluatedKey; + } while (startKey); + return prs; + } + async function syncPRRecords(entry, known) { + const knownMap = known && typeof known === "object" ? known : {}; + const prs = await listPRs(entry); + const present = new Set(prs.map((pr) => String(pr.id))); + const deleted = Object.keys(knownMap).filter((id) => !present.has(String(id))).map((id) => Number(id)).filter((id) => Number.isFinite(id)); + return { + prs: prs.filter((pr) => String(pr.version || "") !== String(knownMap[String(pr.id)] || "")), + deleted, + }; + } + async function loadOwners() { + const id = "_owners"; + const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); + if (!out.Item) return {id, data: {keys: [], audit: []}}; + const data = JSON.parse(out.Item.data.S || "{}"); + data.keys = data.keys || []; + data.audit = data.audit || []; + return {id, data}; + } + function audit(entry, event) { + entry.data.audit = (entry.data.audit || []).concat([{...event, at: new Date().toISOString()}]).slice(-500); + } + function readSSHString(buf, offset) { + if (offset + 4 > buf.length) throw new Error("invalid SSH wire string"); + const len = buf.readUInt32BE(offset); + const start = offset + 4; + if (start + len > buf.length) throw new Error("invalid SSH wire string"); + return {value: buf.subarray(start, start + len), offset: start + len}; + } + function header(event, name) { + const headers = event.headers || {}; + return headers[name] || headers[name.toLowerCase()] || ""; + } + function expectedMessage(rawBody) { + const digest = crypto.createHash("sha256").update(Buffer.from(rawBody || "{}")).digest("base64"); + return Buffer.from("bgit-broker-v1\n" + digest).toString("base64"); + } + function normalizeKey(key) { + return String(key || "").trim().split(/\s+/).slice(0, 2).join(" "); + } + function base64URL(buf) { + return Buffer.from(buf).toString("base64url"); + } + function sshMPIntToBuffer(buf) { + let out = Buffer.from(buf); + while (out.length > 1 && out[0] === 0) out = out.subarray(1); + return out; + } + function ecdsaSignatureToDER(blob) { + let parsed = readSSHString(blob, 0); + const r = sshMPIntToBuffer(parsed.value); + parsed = readSSHString(blob, parsed.offset); + const s = sshMPIntToBuffer(parsed.value); + const encodeInt = (value) => { + let out = Buffer.from(value); + if (out.length === 0) out = Buffer.from([0]); + if (out[0] & 0x80) out = Buffer.concat([Buffer.from([0]), out]); + return Buffer.concat([Buffer.from([0x02, out.length]), out]); + }; + const body = Buffer.concat([encodeInt(r), encodeInt(s)]); + if (body.length > 127) throw new Error("ECDSA signature too large"); + return Buffer.concat([Buffer.from([0x30, body.length]), body]); + } + function publicKeyObject(publicKey) { + const parts = normalizeKey(publicKey).split(/\s+/); + if (parts.length < 2) throw new Error("invalid SSH public key"); + const blob = Buffer.from(parts[1], "base64"); + let parsed = readSSHString(blob, 0); + const alg = parsed.value.toString(); + if (alg !== parts[0]) throw new Error("SSH key algorithm mismatch"); + if (alg === "ssh-ed25519") { + parsed = readSSHString(blob, parsed.offset); + return crypto.createPublicKey({key: {kty: "OKP", crv: "Ed25519", x: base64URL(parsed.value)}, format: "jwk"}); + } + if (alg === "ssh-rsa") { + parsed = readSSHString(blob, parsed.offset); + const e = sshMPIntToBuffer(parsed.value); + parsed = readSSHString(blob, parsed.offset); + const n = sshMPIntToBuffer(parsed.value); + return crypto.createPublicKey({key: {kty: "RSA", n: base64URL(n), e: base64URL(e)}, format: "jwk"}); + } + if (alg.startsWith("ecdsa-sha2-")) { + parsed = readSSHString(blob, parsed.offset); + const sshCurve = parsed.value.toString(); + parsed = readSSHString(blob, parsed.offset); + const point = parsed.value; + const curves = {"nistp256": "P-256", "nistp384": "P-384", "nistp521": "P-521"}; + const crv = curves[sshCurve]; + if (!crv || !point.length || point[0] !== 4) throw new Error("unsupported ECDSA SSH key"); + const coordinateLength = Math.ceil(Number(sshCurve.replace("nistp", "")) / 8); + const x = point.subarray(1, 1 + coordinateLength); + const y = point.subarray(1 + coordinateLength, 1 + 2 * coordinateLength); + if (x.length !== coordinateLength || y.length !== coordinateLength) throw new Error("invalid ECDSA SSH key"); + return crypto.createPublicKey({key: {kty: "EC", crv, x: base64URL(x), y: base64URL(y)}, format: "jwk"}); + } + throw new Error("unsupported SSH key algorithm"); + } + function signatureVerifyAlgorithm(alg) { + if (alg === "ssh-ed25519") return null; + if (alg === "ssh-rsa") return "sha1"; + if (alg === "rsa-sha2-256") return "sha256"; + if (alg === "rsa-sha2-512") return "sha512"; + if (alg === "ecdsa-sha2-nistp256") return "sha256"; + if (alg === "ecdsa-sha2-nistp384") return "sha384"; + if (alg === "ecdsa-sha2-nistp521") return "sha512"; + throw new Error("unsupported SSH signature algorithm"); + } + function signatureBlobForVerify(alg, sig) { + if (alg.startsWith("ecdsa-sha2-")) return ecdsaSignatureToDER(sig); + return sig; + } + function verifySSHSignature(publicKey, message, signature) { + const parsed = readSSHString(Buffer.from(signature, "base64"), 0); + const alg = parsed.value.toString(); + const sig = readSSHString(Buffer.from(signature, "base64"), parsed.offset).value; + return crypto.verify(signatureVerifyAlgorithm(alg), Buffer.from(message, "base64"), publicKeyObject(publicKey), signatureBlobForVerify(alg, sig)); + } + function signedKey(event, entry) { + const keys = (entry.data.keys || []).filter((k) => !k.suspended); + const publicKey = normalizeKey(header(event, "x-bgit-key")); + const message = String(header(event, "x-bgit-signature-message")); + const signature = String(header(event, "x-bgit-signature")); + if (!publicKey || !message || !signature || message !== expectedMessage(event.body)) return null; + const key = keys.find((k) => normalizeKey(k.public_key) === publicKey); + if (!key) return null; + if (!verifySSHSignature(publicKey, message, signature)) return null; + return key; + } + function submittedSignedKey(event) { + const publicKey = normalizeKey(header(event, "x-bgit-key")); + const message = String(header(event, "x-bgit-signature-message")); + const signature = String(header(event, "x-bgit-signature")); + if (!publicKey || !message || !signature || message !== expectedMessage(event.body)) return null; + if (!verifySSHSignature(publicKey, message, signature)) return null; + return {public_key: publicKey, fingerprint: keyFingerprint(publicKey)}; + } + function ownershipTransferCode(brokerURL, repo, token) { + const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, repo, token})).toString("base64url"); + return "bgitot_" + payload; + } + function ownershipTransferTokenHash(token) { + return crypto.createHash("sha256").update(String(token || "")).digest("hex"); + } + function ownershipTransferExpired(transfer) { + return !transfer || !transfer.expires_at || Date.parse(transfer.expires_at) <= Date.now(); + } + function memberInviteCode(brokerURL, repo, token) { + const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, repo, token})).toString("base64url"); + return "bgitinv_" + payload; + } + function verifySignature(event, entry) { + const adminKeys = (entry.data.keys || []).filter((k) => (k.role === "admin" || k.role === "owner") && !k.suspended); + if (adminKeys.length === 0) return true; + const key = signedKey(event, entry); + return !!key && (key.role === "admin" || key.role === "owner"); + } + function roleAllows(role, operation) { + if (role === "owner" || role === "admin") return true; + if (operation === "read") return ["read", "triage", "developer", "maintainer"].includes(role); + if (operation === "write") return ["developer", "maintainer"].includes(role); + if (operation === "merge") return ["maintainer"].includes(role); + return false; + } + function keyFingerprint(publicKey) { + const parts = String(publicKey || "").trim().split(/\s+/); + const data = parts.length >= 2 ? Buffer.from(parts[1], "base64") : Buffer.from(normalizeKey(publicKey)); + return "SHA256:" + crypto.createHash("sha256").update(data).digest("base64").replace(/=+$/g, ""); + } + function keyMatches(item, key) { + const value = String(key || "").trim(); + if (!value) return false; + const normalized = normalizeKey(value); + return normalizeKey(item.public_key) === normalized || + item.public_key === value || + item.public_key.includes(value) || + keyFingerprint(item.public_key) === value; + } + function roleCapabilities(role) { + return { + read: roleAllows(role, "read"), + push: roleAllows(role, "write"), + comment: ["owner", "admin", "maintainer", "developer", "triage"].includes(role), + review: ["owner", "admin", "maintainer", "developer", "triage"].includes(role), + approve: ["owner", "admin", "maintainer", "triage"].includes(role), + merge: roleAllows(role, "merge"), + admin_keys: role === "owner" || role === "admin", + manage_protection: role === "owner" || role === "admin", + reopen_pr: ["owner", "admin", "maintainer"].includes(role), + owner_transfer: role === "owner", + broker_upgrade: role === "owner" || role === "admin", + }; + } + function anonymousKey() { + return {user: "anonymous", role: "read", public_key: "", source: "public", anonymous: true}; + } + function repoIsPublic(entry) { + return (entry.data.visibility || "private") === "public"; + } + function repoIsReadOnly(entry) { + return !!entry.data.read_only; + } + function validRole(role) { + return ["owner", "admin", "maintainer", "developer", "triage", "read"].includes(role); + } + function normalizeRole(role) { + return role === "write" ? "developer" : role; + } + function requireAdmin(event, entry) { + if (!verifySignature(event, entry)) throw Object.assign(new Error("admin SSH signature required"), {statusCode: 403}); + } + function requireOperation(event, entry, operation) { + if (operation !== "read" && repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); + const key = signedKey(event, entry); + if (!key && operation === "read" && repoIsPublic(entry)) return anonymousKey(); + if (!key || !roleAllows(key.role, operation)) throw Object.assign(new Error(operation + " SSH signature required"), {statusCode: 403}); + return key; + } + function requireIssueCreate(event, entry) { + if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); + if (repoIsPublic(entry)) return signedKey(event, entry) || anonymousKey(); + return requireOperation(event, entry, "read"); + } + function cleanObjectPath(value) { + const path = String(value || "").replace(/^\/+/, ""); + if (path.includes("\0") || path.includes("..")) throw new Error("invalid object path"); + return path; + } + function objectName(repo, objectPath) { + const prefix = String(repo.prefix || "").replace(/^\/+|\/+$/g, ""); + const path = cleanObjectPath(objectPath); + return prefix ? prefix + "/" + path : path; + } + async function ensurePhysicalRepo(entry) { + const repo = entry.data.repo || {}; + if (repo.bucket && repo.prefix) return repo; + const logical = cleanName(repo.logical || repo.prefix || "repo.git"); + const suffix = entry.data.bucket_suffix || randomSuffix(); + const bucket = `bgit-${logical.replace(/\.git$/, "")}-${suffix}`.slice(0, 63).replace(/\.+$/g, ""); + try { + await s3.send(new HeadBucketCommand({Bucket: bucket})); + } catch (err) { + await s3.send(new CreateBucketCommand({Bucket: bucket})); + } + entry.data.bucket_suffix = suffix; + entry.data.repo = {...repo, provider: "s3", bucket, prefix: "repo.git"}; + await saveRepo(entry); + return entry.data.repo; + } + async function streamToBuffer(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks); + } + async function readObject(repo, objectPath) { + const out = await s3.send(new GetObjectCommand({Bucket: repo.bucket, Key: objectName(repo, objectPath)})); + const data = await streamToBuffer(out.Body); + return data.toString("base64"); + } + async function writeTextObject(repo, objectPath, value) { + await s3.send(new PutObjectCommand({Bucket: repo.bucket, Key: objectName(repo, objectPath), Body: value})); + } + async function deleteObject(repo, objectPath) { + await s3.send(new DeleteObjectCommand({Bucket: repo.bucket, Key: objectName(repo, objectPath)})); + } + async function deletePhysicalRepo(repo) { + if (!repo.bucket) return; + let token = undefined; + do { + const out = await s3.send(new ListObjectsV2Command({Bucket: repo.bucket, ContinuationToken: token})); + for (const item of out.Contents || []) { + await s3.send(new DeleteObjectCommand({Bucket: repo.bucket, Key: item.Key})); + } + token = out.NextContinuationToken; + } while (token); + await s3.send(new DeleteBucketCommand({Bucket: repo.bucket})); + } + async function listObjects(repo, prefix) { + const repoPrefix = String(repo.prefix || "").replace(/^\/+|\/+$/g, ""); + const queryPrefix = objectName(repo, prefix); + const paths = []; + let token = undefined; + do { + const out = await s3.send(new ListObjectsV2Command({Bucket: repo.bucket, Prefix: queryPrefix, ContinuationToken: token})); + for (const item of out.Contents || []) { + const strip = repoPrefix ? repoPrefix + "/" : ""; + paths.push(item.Key.startsWith(strip) ? item.Key.slice(strip.length) : item.Key); + } + token = out.NextContinuationToken; + } while (token); + return paths; + } + function policyFor(repo, objectPath, operation) { + const key = objectName(repo, objectPath); + const actions = operation === "read" ? ["s3:GetObject"] : operation === "delete" ? ["s3:DeleteObject"] : ["s3:PutObject", "s3:AbortMultipartUpload"]; + return JSON.stringify({Version: "2012-10-17", Statement: [ + {Effect: "Allow", Action: actions, Resource: `arn:aws:s3:::${repo.bucket}/${key}`}, + {Effect: "Allow", Action: ["s3:ListBucket"], Resource: `arn:aws:s3:::${repo.bucket}`, Condition: {StringLike: {"s3:prefix": [`${repo.prefix}/*`]}}} + ]}); + } + async function objectCapability(repo, objectPath, operation, key) { + if (process.env.BROKER_TEST_MODE === "sqlite") { + const action = operation === "write" ? "PUT" : operation === "delete" ? "DELETE" : "GET"; + const object = objectName(repo, objectPath); + const url = String(process.env.BROKER_TEST_BASE_URL || "").replace(/\/+$/g, "") + "/_objects/" + encodeURIComponent(repo.bucket) + "/" + Buffer.from(object).toString("base64url") + "?method=" + encodeURIComponent(action); + return {provider: "test", mode: "signed_url", method: action, url, headers: operation === "write" ? {"content-type": "application/octet-stream"} : {}, bucket: repo.bucket, prefix: repo.prefix, object, region: process.env.AWS_REGION, expires_in: 600}; + } + const out = await sts.send(new AssumeRoleCommand({ + RoleArn: transferRoleArn, + RoleSessionName: `bgit-${operation}-${Date.now()}`, + DurationSeconds: 900, + Policy: policyFor(repo, objectPath, operation), + Tags: [ + {Key: "bgit-operation", Value: operation}, + {Key: "bgit-user", Value: String(key.user || "unknown").slice(0, 128)}, + {Key: "bgit-repo", Value: String(repo.prefix || "repo").slice(0, 128)} + ] + })); + return { + provider: "s3", mode: "sts", bucket: repo.bucket, prefix: repo.prefix, + object: objectName(repo, objectPath), region: process.env.AWS_REGION, + expires_in: 900, + credentials: { + access_key_id: out.Credentials.AccessKeyId, + secret_access_key: out.Credentials.SecretAccessKey, + session_token: out.Credentials.SessionToken + } + }; + } + function protectionFor(data, ref) { + return (data.protections || []).find((p) => p.ref === ref); + } + function assertRefAllowed(data, ref, key, opts) { + const protection = protectionFor(data, ref); + if (!protection || !protection.require_pr) return; + if (opts && opts.fromPR) return; + if (protection.allow_overrides && key && (key.role === "owner" || key.role === "admin")) return; + throw Object.assign(new Error(`protected branch ${ref} requires a pull request`), {statusCode: 403}); + } + function nextPRID(data) { + data.next_pr_id = Number(data.next_pr_id || 1); + return data.next_pr_id++; + } + function findPR(data, id) { + return (data.prs || []).find((pr) => Number(pr.id) === Number(id)); + } + function nextPRNoteID(pr) { + pr.next_note_id = Number(pr.next_note_id || 1); + return pr.next_note_id++; + } + function nextPRCommentID(pr) { + pr.next_comment_id = Number(pr.next_comment_id || 1); + return pr.next_comment_id++; + } + function hashLineText(value) { + return crypto.createHash("sha1").update(String(value || "")).digest("hex"); + } + function normalizeReviewComments(pr, comments, key, head) { + if (!Array.isArray(comments)) return []; + const now = new Date().toISOString(); + return comments.map((comment) => { + const body = String(comment.body || "").trim(); + if (!body) return null; + const lineText = String(comment.line_text || ""); + return { + id: nextPRCommentID(pr), + user: key.user, + body, + file: String(comment.file || "").trim(), + kind: String(comment.kind || "line").trim(), + side: String(comment.side || "new").trim(), + hunk: String(comment.hunk || "").trim(), + hunk_index: Number(comment.hunk_index || 0), + old_start: Number(comment.old_start || 0), + new_start: Number(comment.new_start || 0), + offset: Number(comment.offset || 0), + line: Number(comment.line || 0), + line_text: lineText, + line_hash: String(comment.line_hash || hashLineText(lineText)), + head: String(comment.head || head || pr.head || ""), + at: now, + }; + }).filter(Boolean); + } + function findPRComment(comments, id) { + if (!Array.isArray(comments) || !id) return null; + for (const comment of comments) { + if (Number(comment.id) === Number(id)) return comment; + const nested = findPRComment(comment.replies || [], id); + if (nested) return nested; + } + return null; + } + function findPRReplyTarget(pr, noteID, commentID) { + const notes = [...(pr.comments || []), ...(pr.reviews || [])]; + for (const note of notes) { + if (commentID) { + const inline = findPRComment(note.comments || [], commentID); + if (inline) return inline; + const reply = findPRComment(note.replies || [], commentID); + if (reply) return reply; + } + if (noteID && Number(note.id) === Number(noteID)) return note; + } + return null; + } + function bumpPRVersion(data, pr) { + const now = new Date().toISOString(); + data.next_pr_version = Number(data.next_pr_version || 1); + pr.version = `${data.next_pr_version++}-${crypto.randomBytes(4).toString("hex")}`; + pr.updated_at = now; + return pr; + } + function ensurePRVersions(data) { + let changed = false; + for (const pr of data.prs || []) { + if (!pr.version) { + bumpPRVersion(data, pr); + changed = true; + } + } + return changed; + } + function syncPRs(data, known) { + const knownMap = known && typeof known === "object" ? known : {}; + return (data.prs || []).filter((pr) => String(pr.version || "") !== String(knownMap[String(pr.id)] || "")); + } + function countApprovals(pr) { + const latest = new Map(); + for (const review of pr.reviews || []) { + if (review.user) latest.set(review.user, review.state); + } + return Array.from(latest.values()).filter((state) => state === "approved").length; + } + function nextIssueID(data) { + data.next_issue_id = Number(data.next_issue_id || 1); + return data.next_issue_id++; + } + function issueKey(repoID, id) { + return {repo_id: {S: repoID}, pr_id: {N: String(-Number(id))}}; + } + async function saveIssue(entry, issue) { + await db.send(new PutItemCommand({TableName: prTable, Item: {...issueKey(entry.id, issue.id), data: {S: JSON.stringify(issue)}}})); + } + async function loadIssue(entry, id) { + const out = await db.send(new GetItemCommand({TableName: prTable, Key: issueKey(entry.id, id)})); + return out.Item ? JSON.parse(out.Item.data.S || "{}") : null; + } + async function listIssues(entry) { + const out = await db.send(new QueryCommand({ + TableName: prTable, + KeyConditionExpression: "repo_id = :repo_id AND pr_id < :zero", + ExpressionAttributeValues: {":repo_id": {S: entry.id}, ":zero": {N: "0"}}, + ScanIndexForward: false, + })); + return (out.Items || []).map((item) => JSON.parse(item.data.S || "{}")); + } + async function deleteRepoMetadata(entry) { + const out = await db.send(new QueryCommand({ + TableName: prTable, + KeyConditionExpression: "repo_id = :repo_id", + ExpressionAttributeValues: {":repo_id": {S: entry.id}}, + })); + for (const item of out.Items || []) { + await db.send(new DeleteItemCommand({TableName: prTable, Key: {repo_id: item.repo_id, pr_id: item.pr_id}})); + } + const repoIDValue = repoID(entry.data.repo || {}); + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + await db.send(new DeleteItemCommand({TableName: memberTable, Key: {fingerprint: {S: keyFingerprint(key.public_key)}, repo_id: {S: repoIDValue}}})); + } + await db.send(new DeleteItemCommand({TableName: table, Key: {id: {S: entry.id}}})); + } + async function moveRepoRecords(oldID, newID) { + const out = await db.send(new QueryCommand({ + TableName: prTable, + KeyConditionExpression: "repo_id = :repo_id", + ExpressionAttributeValues: {":repo_id": {S: oldID}}, + })); + for (const item of out.Items || []) { + await db.send(new PutItemCommand({TableName: prTable, Item: {...item, repo_id: {S: newID}}})); + await db.send(new DeleteItemCommand({TableName: prTable, Key: {repo_id: item.repo_id, pr_id: item.pr_id}})); + } + } + async function deleteMembershipIndex(entry) { + const repoIDValue = repoID(entry.data.repo || {}); + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + await db.send(new DeleteItemCommand({TableName: memberTable, Key: {fingerprint: {S: keyFingerprint(key.public_key)}, repo_id: {S: repoIDValue}}})); + } + } + async function updateRefCAS(repo, ref, oldHash, newHash, key, opts = {}) { + const id = docID(repo); + const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); + const oldData = out.Item && out.Item.data ? out.Item.data.S : ""; + const data = oldData ? JSON.parse(oldData || "{}") : {repo, keys: [], refs: {}, audit: []}; + data.repo = data.repo || repo; + data.keys = data.keys || []; + data.refs = data.refs || {}; + data.protections = data.protections || []; + assertRefAllowed(data, ref, key, opts); + const current = Object.prototype.hasOwnProperty.call(data.refs, ref) ? data.refs[ref] : oldHash; + if (current !== oldHash) throw Object.assign(new Error("stale ref"), {statusCode: 409}); + if (newHash === zero) delete data.refs[ref]; + else data.refs[ref] = newHash; + audit({data}, {type: "ref_update", ref, old: oldHash, new: newHash}); + const item = {id: {S: id}, data: {S: JSON.stringify(data)}}; + const input = {TableName: table, Item: item}; + if (oldData) { + input.ConditionExpression = "#data = :old"; + input.ExpressionAttributeNames = {"#data": "data"}; + input.ExpressionAttributeValues = {":old": {S: oldData}}; + } else { + input.ConditionExpression = "attribute_not_exists(id)"; + } + try { + await db.send(new PutItemCommand(input)); + } catch (err) { + if (err.name === "ConditionalCheckFailedException") throw Object.assign(new Error("stale ref"), {statusCode: 409}); + throw err; + } + } + exports.handler = async (event) => { + const path = event.rawPath || "/"; + const method = event.requestContext && event.requestContext.http ? event.requestContext.http.method : "GET"; + const body = event.body ? JSON.parse(event.body) : {}; + try { + if (path === "/" || path === "/health") return response(200, {ok: true, service: "bgit-broker", version: brokerVersion}); + if (path === "/owners/upsert" && method === "POST") { + const entry = await loadOwners(); + if (!verifySignature(event, entry)) throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const user = body.user || "owner"; + const role = normalizeRole(body.role || "owner"); + if (role !== "owner") throw new Error("owner bootstrap only accepts owner role"); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); + } + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/repos/upsert" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + entry.data.repo = {...(entry.data.repo || {}), ...(body.repo || {})}; + if (body.repo && body.repo.logical && !entry.data.repo.bucket) await ensurePhysicalRepo(entry); + const user = body.admin_user || "admin"; + const role = normalizeRole(body.role || "admin"); + if (!validRole(role)) throw new Error("invalid role"); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); + } + audit(entry, {type: "repo_upsert", user}); + await saveRepo(entry); + return response(200, {ok: true, repo: entry.data.repo, bucket_suffix: entry.data.bucket_suffix}); + } + if (path === "/repo/info" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + return response(200, { + repo: entry.data.repo || body.repo, + description: entry.data.description || "", + default_branch: entry.data.default_branch || "main", + visibility: entry.data.visibility || "private", + read_only: !!entry.data.read_only, + issues_enabled: entry.data.issues_enabled !== false, + }); + } + if (path === "/repo/update" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + if (Object.prototype.hasOwnProperty.call(body, "description")) entry.data.description = String(body.description || "").trim(); + if (Object.prototype.hasOwnProperty.call(body, "default_branch")) entry.data.default_branch = String(body.default_branch || "").trim() || "main"; + if (Object.prototype.hasOwnProperty.call(body, "visibility")) entry.data.visibility = body.visibility === "public" ? "public" : "private"; + if (Object.prototype.hasOwnProperty.call(body, "read_only")) entry.data.read_only = !!body.read_only; + if (Object.prototype.hasOwnProperty.call(body, "issues_enabled")) entry.data.issues_enabled = body.issues_enabled !== false; + audit(entry, {type: "repo_update"}); + await saveRepo(entry); + return response(200, { + ok: true, + repo: entry.data.repo || body.repo, + description: entry.data.description, + default_branch: entry.data.default_branch, + visibility: entry.data.visibility, + read_only: !!entry.data.read_only, + issues_enabled: entry.data.issues_enabled !== false, + }); + } + if (path === "/repo/rename" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const logical = String(body.logical || "").trim().replace(/^\/+|\/+$/g, ""); + if (!logical) throw new Error("logical repo name is required"); + const newRepo = {...(entry.data.repo || body.repo), logical}; + const newID = docID(newRepo); + if (entry.id !== newID) { + const existing = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: newID}}})); + if (existing.Item) throw Object.assign(new Error("target logical repo already exists"), {statusCode: 409}); + } + entry.data.repo = newRepo; + audit(entry, {type: "repo_rename", logical, user: key.user}); + await db.send(new PutItemCommand({TableName: table, Item: {id: {S: newID}, data: {S: JSON.stringify(entry.data)}}})); + if (entry.id !== newID) { + await moveRepoRecords(entry.id, newID); + await deleteMembershipIndex({data: {...entry.data, repo: body.repo}}); + await db.send(new DeleteItemCommand({TableName: table, Key: {id: {S: entry.id}}})); + } + await syncMembershipIndex({id: newID, data: entry.data}); + return response(200, {ok: true, repo: newRepo}); + } + if (path === "/repo/delete" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const repo = await ensurePhysicalRepo(entry); + await deletePhysicalRepo(repo); + await deleteRepoMetadata(entry); + return response(200, {ok: true}); + } + if (path === "/keys/list" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + return response(200, {keys: entry.data.keys}); + } + if (path === "/keys/add" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + const user = body.user || "admin"; + const role = normalizeRole(body.role || "read"); + if (!validRole(role) || role === "owner") throw new Error("invalid role"); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); + } + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/keys/invite/create" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + const user = String(body.user || "").trim(); + const role = normalizeRole(body.role || "read"); + if (!user) throw new Error("user is required"); + if (!validRole(role) || role === "owner") throw new Error("invalid role"); + const token = crypto.randomBytes(24).toString("base64url"); + const brokerURL = String(body.broker_url || "").trim(); + const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + entry.data.invites = (entry.data.invites || []).filter((invite) => Date.parse(invite.expires_at || "") > Date.now()); + const normalizedUser = user.toLowerCase(); + if (entry.data.invites.some((invite) => String(invite.user || "").trim().toLowerCase() === normalizedUser)) throw Object.assign(new Error("invite already pending for user"), {statusCode: 409}); + entry.data.invites.push({token_hash: ownershipTransferTokenHash(token), user, role, broker_url: brokerURL, expires_at: expires}); + const code = memberInviteCode(brokerURL, entry.data.repo || body.repo, token); + audit(entry, {type: "member_invite_create", user, role}); + await saveRepo(entry); + return response(200, {ok: true, code, accept_command: "bgit admin accept-invite " + code}); + } + if (path === "/keys/invite/accept" && method === "POST") { + const entry = await loadRepo(body.repo); + const signed = submittedSignedKey(event); + if (!signed) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); + const tokenHash = ownershipTransferTokenHash(body.token); + const invites = entry.data.invites || []; + const invite = invites.find((item) => item.token_hash === tokenHash && Date.parse(item.expires_at || "") > Date.now()); + if (!invite) throw Object.assign(new Error("invite is not pending or has expired"), {statusCode: 404}); + const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(signed.public_key)); + if (existing) { + existing.user = invite.user; + existing.role = invite.role; + existing.suspended = false; + } else { + entry.data.keys = entry.data.keys || []; + entry.data.keys.push({user: invite.user, role: invite.role, public_key: signed.public_key, source: "invite", suspended: false}); + } + entry.data.invites = invites.filter((item) => item.token_hash !== tokenHash); + audit(entry, {type: "member_invite_accept", user: invite.user, role: invite.role, fingerprint: signed.fingerprint}); + await saveRepo(entry); + return response(200, {ok: true, user: invite.user, role: invite.role, fingerprint: signed.fingerprint}); + } + if (path === "/keys/invite/cancel" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + const invites = entry.data.invites || []; + const user = String(body.user || "").trim().toLowerCase(); + const tokenHash = body.token ? ownershipTransferTokenHash(body.token) : ""; + const next = invites.filter((item) => { + if (tokenHash) return item.token_hash !== tokenHash; + return String(item.user || "").trim().toLowerCase() !== user; + }); + if (next.length === invites.length) throw Object.assign(new Error("invite is not pending or has expired"), {statusCode: 404}); + entry.data.invites = next; + audit(entry, {type: "member_invite_cancel"}); + await saveRepo(entry); + return response(200, {ok: true}); + } + if ((path === "/keys/remove" || path === "/keys/suspend") && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + const key = String(body.key || "").trim(); + const match = (k) => keyMatches(k, key); + if (entry.data.keys.some((k) => match(k) && k.role === "owner")) throw Object.assign(new Error("owners cannot be removed or suspended"), {statusCode: 403}); + let changed = false; + if (path === "/keys/remove") { + const before = entry.data.keys.length; + entry.data.keys = entry.data.keys.filter((k) => !match(k)); + changed = entry.data.keys.length !== before; + } else { + for (const item of entry.data.keys) { + if (match(item)) { + item.suspended = true; + changed = true; + } + } + } + if (!changed) throw Object.assign(new Error("key not found"), {statusCode: 404}); + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/keys/unsuspend" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + const key = String(body.key || "").trim(); + const match = (k) => keyMatches(k, key); + let changed = false; + for (const item of entry.data.keys || []) { + if (match(item)) { + item.suspended = false; + changed = true; + } + } + if (!changed) throw Object.assign(new Error("key not found"), {statusCode: 404}); + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/owners/transfer/confirm" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + if (entry.data.owner_transfer && !ownershipTransferExpired(entry.data.owner_transfer)) { + throw Object.assign(new Error("ownership transfer already pending; run bgit admin cancel-ownership-transfer to cancel it"), {statusCode: 409}); + } + const token = crypto.randomBytes(24).toString("base64url"); + const brokerURL = String(body.broker_url || "").trim(); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + entry.data.owner_transfer = { + token_hash: ownershipTransferTokenHash(token), + requested_by: key.user || "", + requested_by_fingerprint: keyFingerprint(key.public_key), + broker_url: brokerURL, + expires_at: expires, + }; + const code = ownershipTransferCode(brokerURL, entry.data.repo || body.repo, token); + audit(entry, {type: "owner_transfer_confirm", user: key.user || "", expires_at: expires}); + await saveRepo(entry); + return response(200, {ok: true, code, accept_command: "bgit admin accept-ownership-transfer " + code, cancel_command: "bgit admin cancel-ownership-transfer --broker " + brokerURL + " " + ((entry.data.repo || body.repo).logical || "")}); + } + if (path === "/owners/transfer/cancel" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + delete entry.data.owner_transfer; + audit(entry, {type: "owner_transfer_cancel", user: key.user || ""}); + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/owners/transfer/accept" && method === "POST") { + const entry = await loadRepo(body.repo); + const transfer = entry.data.owner_transfer; + if (!transfer || ownershipTransferExpired(transfer)) throw Object.assign(new Error("ownership transfer is not pending or has expired"), {statusCode: 404}); + if (ownershipTransferTokenHash(body.token) !== transfer.token_hash) throw Object.assign(new Error("ownership transfer code is invalid"), {statusCode: 403}); + const accepted = submittedSignedKey(event); + if (!accepted) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); + const user = String(body.user || "owner").trim() || "owner"; + const ownerFingerprint = transfer.requested_by_fingerprint || ""; + for (const item of entry.data.keys || []) { + if (item.role === "owner" && keyFingerprint(item.public_key) === ownerFingerprint) item.role = "admin"; + } + const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(accepted.public_key)); + if (existing) { + existing.role = "owner"; + existing.user = user; + existing.suspended = false; + } else { + entry.data.keys = entry.data.keys || []; + entry.data.keys.push({user, role: "owner", public_key: accepted.public_key, source: "ownership-transfer", suspended: false}); + } + delete entry.data.owner_transfer; + audit(entry, {type: "owner_transfer_accept", user, fingerprint: accepted.fingerprint}); + await saveRepo(entry); + return response(200, {ok: true, user, fingerprint: accepted.fingerprint}); + } + if (path === "/protection/list" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + return response(200, {protections: entry.data.protections || []}); + } + if (path === "/protection/upsert" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); + entry.data.protections.push({ref: body.ref, require_pr: body.require_pr !== false, allow_overrides: !!body.allow_overrides}); + audit(entry, {type: "protection_upsert", ref: body.ref}); + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/protection/remove" && method === "POST") { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); + audit(entry, {type: "protection_remove", ref: body.ref}); + await saveRepo(entry); + return response(200, {ok: true}); + } + if (path === "/issues/list" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); + const issues = await listIssues(entry); + return response(200, {issues}); + } + if (path === "/issues/view" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error("issue not found"), {statusCode: 404}); + return response(200, {issue}); + } + if (path === "/issues/create" && method === "POST") { + const entry = await loadRepo(body.repo); + if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); + const key = requireIssueCreate(event, entry); + const title = String(body.title || "").trim(); + const issueBody = String(body.body || "").trim(); + if (!title) throw new Error("issue title is required"); + const issue = {id: nextIssueID(entry.data), title, body: issueBody, status: "open", author: key.user || "anonymous", comments: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()}; + await saveRepo(entry); + await saveIssue(entry, issue); + return response(200, {ok: true, issue}); + } + if (path === "/issues/comment" && method === "POST") { + const entry = await loadRepo(body.repo); + if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); + const key = requireIssueCreate(event, entry); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error("issue not found"), {statusCode: 404}); + const comment = String(body.comment || "").trim(); + if (!comment) throw new Error("comment is required"); + issue.comments = issue.comments || []; + issue.comments.push({user: key.user || "anonymous", body: comment, at: new Date().toISOString()}); + issue.updated_at = new Date().toISOString(); + await saveIssue(entry, issue); + return response(200, {ok: true, issue}); + } + if (path === "/issues/close" || path === "/issues/reopen") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "write"); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error("issue not found"), {statusCode: 404}); + issue.status = path === "/issues/reopen" ? "open" : "closed"; + issue.updated_at = new Date().toISOString(); + await saveIssue(entry, issue); + return response(200, {ok: true, issue}); + } + if (path === "/prs/create" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "write"); + entry.data.refs = entry.data.refs || {}; + const pr = {...(body.pr || {})}; + pr.id = nextPRID(entry.data); + pr.status = "open"; + pr.author = key.user; + pr.approvals = pr.approvals || 0; + pr.checks = pr.checks || []; + pr.head = entry.data.refs[pr.source] || ""; + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_create", id: pr.id, source: pr.source, target: pr.target, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {pr}); + } + if (path === "/prs/list" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + return response(200, {prs: await listPRs(entry)}); + } + if (path === "/prs/sync" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + return response(200, await syncPRRecords(entry, body.known || {})); + } + if (path === "/prs/view" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + return response(200, {pr}); + } + if (path === "/prs/close" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "write"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + pr.status = "closed"; + pr.closed_by = key.user; + pr.closed_at = new Date().toISOString(); + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_close", id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } + if (path === "/prs/reopen" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "write"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + pr.status = "open"; + delete pr.closed_by; + delete pr.closed_at; + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_reopen", id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } + if (path === "/prs/comment" && method === "POST") { + const entry = await loadRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); + const key = requireOperation(event, entry, "read"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + const comment = String(body.comment || "").trim(); + if (!comment) throw Object.assign(new Error("comment is required"), {statusCode: 400}); + pr.comments = pr.comments || []; + pr.comments.push({id: nextPRNoteID(pr), user: key.user, body: comment, at: new Date().toISOString()}); + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_comment", id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } + if (path === "/prs/reply" && method === "POST") { + const entry = await loadRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); + const key = requireOperation(event, entry, "read"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + const comment = String(body.comment || "").trim(); + if (!comment) throw Object.assign(new Error("comment is required"), {statusCode: 400}); + const target = findPRReplyTarget(pr, Number(body.target_note_id || 0), Number(body.target_comment_id || 0)); + if (!target) throw Object.assign(new Error("reply target not found"), {statusCode: 404}); + target.replies = target.replies || []; + target.replies.push({id: nextPRCommentID(pr), user: key.user, body: comment, kind: "reply", at: new Date().toISOString()}); + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_reply", id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } + if (path === "/prs/review" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "write"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + const state = String(body.review || "").trim(); + if (!["commented", "approved", "changes_requested"].includes(state)) throw Object.assign(new Error("unsupported review state"), {statusCode: 400}); + pr.reviews = pr.reviews || []; + const comments = normalizeReviewComments(pr, body.comments, key, pr.head); + pr.reviews.push({id: nextPRNoteID(pr), user: key.user, body: String(body.comment || "").trim(), state, comments, head: String(pr.head || ""), at: new Date().toISOString()}); + pr.approvals = countApprovals(pr); + bumpPRVersion(entry.data, pr); + audit(entry, {type: "pr_review", id: pr.id, user: key.user, state}); + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } + if (path === "/prs/merge" && method === "POST") { + const entry = await loadRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); + const key = requireOperation(event, entry, "merge"); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); + if (pr.status !== "open") throw new Error("pull request is not open"); + const newHash = (entry.data.refs || {})[pr.source] || pr.head; + if (!newHash) throw new Error("pull request source ref has no head"); + const oldHash = (entry.data.refs || {})[pr.target] || zero; + await updateRefCAS(body.repo, pr.target, oldHash, newHash, key, {fromPR: true}); + const repo = await ensurePhysicalRepo(entry); + await writeTextObject(repo, pr.target, newHash + "\n"); + entry.data.refs = entry.data.refs || {}; + entry.data.refs[pr.target] = newHash; + pr.status = "merged"; + pr.merged_by = key.user; + pr.merged_at = new Date().toISOString(); + bumpPRVersion(entry.data, pr); + if (body.delete_branch && pr.source && pr.source !== pr.target) { + delete entry.data.refs[pr.source]; + await deleteObject(repo, pr.source); + audit(entry, {type: "branch_delete", ref: pr.source, from_pr: pr.id, user: key.user}); + } + await saveRepo(entry); + await savePR(entry, pr); + return response(200, {ok: true, pr}); + } + if (path === "/auth/check" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry); + const operation = body.operation || ""; + const allowed = (operation === "read" && repoIsPublic(entry)) || (!!key && roleAllows(key.role, operation)); + return response(200, {allowed, user: key && key.user || (allowed ? "anonymous" : ""), role: key && key.role || (allowed ? "read" : "")}); + } + if (path === "/auth/status" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = signedKey(event, entry) || (repoIsPublic(entry) ? anonymousKey() : null); + if (!key) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); + return response(200, { + broker_version: brokerVersion, + repo: entry.data.repo || body.repo, + identity: {user: key.user || "", source: key.source || "", key_fingerprint: key.public_key ? keyFingerprint(key.public_key) : "", public_key: key.public_key || ""}, + user: key.user || "", + role: key.role || "", + capabilities: roleCapabilities(key.role || ""), + resolved_at: new Date().toISOString(), + }); + } + if (path === "/repos/mine" && method === "POST") { + const fingerprint = header(event, "x-bgit-key-fingerprint") || ""; + if (!fingerprint) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); + const out = await db.send(new QueryCommand({ + TableName: memberTable, + KeyConditionExpression: "fingerprint = :fingerprint", + ExpressionAttributeValues: {":fingerprint": {S: fingerprint}} + })); + const repos = []; + for (const item of out.Items || []) { + const data = JSON.parse(item.data.S || "{}"); + if (!data.suspended && data.repo && (data.repo.logical || (data.repo.bucket && data.repo.prefix))) repos.push({...data, key_fingerprint: fingerprint}); + } + repos.sort((a, b) => String(a.logical || a.repo_id || "").localeCompare(String(b.logical || b.repo_id || ""))); + return response(200, {repos}); + } + if (path === "/members/reindex" && method === "POST") { + if (body.repo && (body.repo.logical || body.repo.bucket || body.repo.prefix)) { + const entry = await loadRepo(body.repo); + requireAdmin(event, entry); + await syncMembershipIndex(entry); + return response(200, {ok: true, repositories: 1}); + } + const owners = await loadOwners(); + if (!verifySignature(event, owners)) throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const count = await syncAllMembershipIndexes(); + return response(200, {ok: true, repositories: count}); + } + if (path === "/objects/capability" && method === "POST") { + const entry = await loadRepo(body.repo); + const operation = body.operation || "read"; + const key = requireOperation(event, entry, operation === "read" ? "read" : "write"); + const repo = await ensurePhysicalRepo(entry); + const capability = await objectCapability(repo, body.path, operation, key); + audit(entry, {type: "capability_issued", operation, path: body.path, user: key.user, role: key.role}); + await saveRepo(entry); + return response(200, capability); + } + if (path === "/objects/read" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + const repo = await ensurePhysicalRepo(entry); + return response(200, {data: await readObject(repo, body.path)}); + } + if (path === "/objects/list" && method === "POST") { + const entry = await loadRepo(body.repo); + requireOperation(event, entry, "read"); + const repo = await ensurePhysicalRepo(entry); + return response(200, {paths: await listObjects(repo, body.prefix)}); + } + if (path === "/refs/update" && method === "POST") { + const entry = await loadRepo(body.repo); + const key = requireOperation(event, entry, "write"); + await updateRefCAS(body.repo, body.ref, body.old, body.new, key, {override: !!body.override}); + return response(200, {ok: true}); + } + return response(404, {error: "unknown broker endpoint"}); + } catch (err) { + return response(err.statusCode || 500, {error: err.message || String(err)}); + } + }; + BrokerFunctionUrl: + Type: AWS::Lambda::Url + Properties: + TargetFunctionArn: !Ref BrokerFunction + AuthType: NONE + BrokerFunctionUrlPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref BrokerFunction + Action: lambda:InvokeFunctionUrl + Principal: '*' + FunctionUrlAuthType: NONE + BrokerFunctionInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref BrokerFunction + Action: lambda:InvokeFunction + Principal: '*' + InvokedViaFunctionUrl: true +Outputs: + BrokerUrl: + Value: !GetAtt BrokerFunctionUrl.FunctionUrl diff --git a/broker/gcp/index.js b/broker/gcp/index.js new file mode 100644 index 0000000..f351aeb --- /dev/null +++ b/broker/gcp/index.js @@ -0,0 +1,1307 @@ +'use strict'; + +const crypto = require('crypto'); +const {Firestore} = require('@google-cloud/firestore'); +const {Storage} = require('@google-cloud/storage'); +const {GoogleAuth} = require('google-auth-library'); + +const db = new Firestore({databaseId: process.env.FIRESTORE_DATABASE || 'bgit'}); +const repos = db.collection('bgit_broker_repos'); +const members = db.collection('bgit_broker_members'); +const storage = new Storage(); +const auth = new GoogleAuth({scopes: ['https://www.googleapis.com/auth/cloud-platform']}); +const brokerVersion = process.env.BROKER_VERSION || '{{BROKER_VERSION}}'; +const zero = '0000000000000000000000000000000000000000'; + +function repoID(repo) { + if (repo && repo.logical) return ['logical', repo.logical].join(':'); + return [repo.provider || 'gcs', repo.bucket, repo.prefix].join(':'); +} + +function docID(repo) { + return Buffer.from(repoID(repo)).toString('base64url'); +} + +function cleanName(value) { + return String(value || 'repo').toLowerCase().replace(/[^a-z0-9.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'repo'; +} + +function randomSuffix() { + return crypto.randomBytes(5).toString('hex'); +} + +async function loadRepo(repo) { + const ref = repos.doc(docID(repo)); + const snap = await ref.get(); + if (!snap.exists) return {ref, data: {repo, keys: [], audit: []}}; + const data = snap.data() || {}; + data.repo = data.repo || repo; + data.keys = data.keys || []; + data.audit = data.audit || []; + return {ref, data}; +} + +async function saveRepo(entry) { + await entry.ref.set(entry.data, {merge: false}); + await syncMembershipIndex(entry); +} + +async function syncMembershipIndex(entry) { + const repo = entry.data.repo || {}; + if (!repo.logical && (!repo.bucket || !repo.prefix)) return; + const repoIDValue = repoID(repo); + const logical = repo.logical || repo.prefix || repoIDValue; + const writes = []; + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + const fingerprint = keyFingerprint(key.public_key); + writes.push(members.doc(memberDocID(fingerprint)).collection('repos').doc(docID(repo)).set({ + repo_id: repoIDValue, + logical, + repo, + user: key.user || '', + role: key.role || '', + source: key.source || '', + suspended: !!key.suspended, + updated_at: new Date().toISOString(), + }, {merge: true})); + } + await Promise.all(writes); +} + +async function syncAllMembershipIndexes() { + const snap = await repos.get(); + const writes = []; + let count = 0; + snap.forEach((doc) => { + if (doc.id === '_owners') return; + const data = doc.data() || {}; + const repo = data.repo || {}; + if (!repo.logical && (!repo.bucket || !repo.prefix)) return; + count++; + writes.push(syncMembershipIndex({ref: doc.ref, data})); + }); + await Promise.all(writes); + return count; +} + +function prDoc(entry, id) { + return entry.ref.collection('prs').doc(String(id).padStart(10, '0')); +} + +async function savePR(entry, pr) { + await prDoc(entry, pr.id).set(pr, {merge: false}); +} + +async function loadPR(entry, id) { + const snap = await prDoc(entry, id).get(); + if (!snap.exists) return null; + return snap.data() || null; +} + +async function listPRs(entry) { + const snap = await entry.ref.collection('prs').orderBy('id', 'desc').get(); + return snap.docs.map((doc) => doc.data() || {}); +} + +async function syncPRRecords(entry, known) { + const knownMap = known && typeof known === 'object' ? known : {}; + const prs = await listPRs(entry); + const present = new Set(prs.map((pr) => String(pr.id))); + const deleted = Object.keys(knownMap).filter((id) => !present.has(String(id))).map((id) => Number(id)).filter((id) => Number.isFinite(id)); + return { + prs: prs.filter((pr) => String(pr.version || '') !== String(knownMap[String(pr.id)] || '')), + deleted, + }; +} + +async function loadOwners() { + const ref = repos.doc('_owners'); + const snap = await ref.get(); + if (!snap.exists) return {ref, data: {keys: [], audit: []}}; + const data = snap.data() || {}; + data.keys = data.keys || []; + data.audit = data.audit || []; + return {ref, data}; +} + +function audit(entry, event) { + entry.data.audit = entry.data.audit || []; + entry.data.audit.push({...event, at: new Date().toISOString()}); + if (entry.data.audit.length > 500) entry.data.audit = entry.data.audit.slice(-500); +} + +function readSSHString(buf, offset) { + if (offset + 4 > buf.length) throw new Error('invalid SSH wire string'); + const len = buf.readUInt32BE(offset); + const start = offset + 4; + if (start + len > buf.length) throw new Error('invalid SSH wire string'); + return {value: buf.subarray(start, start + len), offset: start + len}; +} + +function rawBody(req) { + if (req.rawBody) return Buffer.from(req.rawBody); + return Buffer.from(JSON.stringify(req.body || {})); +} + +function expectedMessage(req) { + const digest = crypto.createHash('sha256').update(rawBody(req)).digest('base64'); + return Buffer.from('bgit-broker-v1\n' + digest).toString('base64'); +} + +function normalizeKey(key) { + return String(key || '').trim().split(/\s+/).slice(0, 2).join(' '); +} + +function base64URL(buf) { + return Buffer.from(buf).toString('base64url'); +} + +function sshMPIntToBuffer(buf) { + let out = Buffer.from(buf); + while (out.length > 1 && out[0] === 0) out = out.subarray(1); + return out; +} + +function ecdsaSignatureToDER(blob) { + let parsed = readSSHString(blob, 0); + const r = sshMPIntToBuffer(parsed.value); + parsed = readSSHString(blob, parsed.offset); + const s = sshMPIntToBuffer(parsed.value); + const encodeInt = (value) => { + let out = Buffer.from(value); + if (out.length === 0) out = Buffer.from([0]); + if (out[0] & 0x80) out = Buffer.concat([Buffer.from([0]), out]); + return Buffer.concat([Buffer.from([0x02, out.length]), out]); + }; + const body = Buffer.concat([encodeInt(r), encodeInt(s)]); + if (body.length > 127) throw new Error('ECDSA signature too large'); + return Buffer.concat([Buffer.from([0x30, body.length]), body]); +} + +function publicKeyObject(publicKey) { + const parts = normalizeKey(publicKey).split(/\s+/); + if (parts.length < 2) throw new Error('invalid SSH public key'); + const blob = Buffer.from(parts[1], 'base64'); + let parsed = readSSHString(blob, 0); + const alg = parsed.value.toString(); + if (alg !== parts[0]) throw new Error('SSH key algorithm mismatch'); + if (alg === 'ssh-ed25519') { + parsed = readSSHString(blob, parsed.offset); + return crypto.createPublicKey({key: {kty: 'OKP', crv: 'Ed25519', x: base64URL(parsed.value)}, format: 'jwk'}); + } + if (alg === 'ssh-rsa') { + parsed = readSSHString(blob, parsed.offset); + const e = sshMPIntToBuffer(parsed.value); + parsed = readSSHString(blob, parsed.offset); + const n = sshMPIntToBuffer(parsed.value); + return crypto.createPublicKey({key: {kty: 'RSA', n: base64URL(n), e: base64URL(e)}, format: 'jwk'}); + } + if (alg.startsWith('ecdsa-sha2-')) { + parsed = readSSHString(blob, parsed.offset); + const sshCurve = parsed.value.toString(); + parsed = readSSHString(blob, parsed.offset); + const point = parsed.value; + const curves = {'nistp256': 'P-256', 'nistp384': 'P-384', 'nistp521': 'P-521'}; + const crv = curves[sshCurve]; + if (!crv || !point.length || point[0] !== 4) throw new Error('unsupported ECDSA SSH key'); + const coordinateLength = Math.ceil(Number(sshCurve.replace('nistp', '')) / 8); + const x = point.subarray(1, 1 + coordinateLength); + const y = point.subarray(1 + coordinateLength, 1 + 2 * coordinateLength); + if (x.length !== coordinateLength || y.length !== coordinateLength) throw new Error('invalid ECDSA SSH key'); + return crypto.createPublicKey({key: {kty: 'EC', crv, x: base64URL(x), y: base64URL(y)}, format: 'jwk'}); + } + throw new Error('unsupported SSH key algorithm'); +} + +function signatureVerifyAlgorithm(alg) { + if (alg === 'ssh-ed25519') return null; + if (alg === 'ssh-rsa') return 'sha1'; + if (alg === 'rsa-sha2-256') return 'sha256'; + if (alg === 'rsa-sha2-512') return 'sha512'; + if (alg === 'ecdsa-sha2-nistp256') return 'sha256'; + if (alg === 'ecdsa-sha2-nistp384') return 'sha384'; + if (alg === 'ecdsa-sha2-nistp521') return 'sha512'; + throw new Error('unsupported SSH signature algorithm'); +} + +function signatureBlobForVerify(alg, sig) { + if (alg.startsWith('ecdsa-sha2-')) return ecdsaSignatureToDER(sig); + return sig; +} + +function verifySSHSignature(publicKey, message, signature) { + const parsed = readSSHString(Buffer.from(signature, 'base64'), 0); + const alg = parsed.value.toString(); + const sig = readSSHString(Buffer.from(signature, 'base64'), parsed.offset).value; + return crypto.verify(signatureVerifyAlgorithm(alg), Buffer.from(message, 'base64'), publicKeyObject(publicKey), signatureBlobForVerify(alg, sig)); +} + +function signedKey(req, entry) { + const keys = (entry.data.keys || []).filter((k) => !k.suspended); + const publicKey = normalizeKey(req.get('x-bgit-key')); + const message = String(req.get('x-bgit-signature-message') || ''); + const signature = String(req.get('x-bgit-signature') || ''); + if (!publicKey || !message || !signature || message !== expectedMessage(req)) return null; + const key = keys.find((k) => normalizeKey(k.public_key) === publicKey); + if (!key) return null; + if (!verifySSHSignature(publicKey, message, signature)) return null; + return key; +} + +function submittedSignedKey(req) { + const publicKey = normalizeKey(req.get('x-bgit-key')); + const message = String(req.get('x-bgit-signature-message') || ''); + const signature = String(req.get('x-bgit-signature') || ''); + if (!publicKey || !message || !signature || message !== expectedMessage(req)) return null; + if (!verifySSHSignature(publicKey, message, signature)) return null; + return {public_key: publicKey, fingerprint: keyFingerprint(publicKey)}; +} + +function ownershipTransferCode(brokerURL, repo, token) { + const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, repo, token})).toString('base64url'); + return 'bgitot_' + payload; +} + +function ownershipTransferTokenHash(token) { + return crypto.createHash('sha256').update(String(token || '')).digest('hex'); +} + +function ownershipTransferExpired(transfer) { + return !transfer || !transfer.expires_at || Date.parse(transfer.expires_at) <= Date.now(); +} + +function memberInviteCode(brokerURL, repo, token) { + const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, repo, token})).toString('base64url'); + return 'bgitinv_' + payload; +} + +function verifySignature(req, entry) { + const adminKeys = (entry.data.keys || []).filter((k) => (k.role === 'admin' || k.role === 'owner') && !k.suspended); + if (adminKeys.length === 0) return true; + const key = signedKey(req, entry); + return !!key && (key.role === 'admin' || key.role === 'owner'); +} + +function roleAllows(role, operation) { + if (role === 'owner') return true; + if (role === 'admin') return true; + if (operation === 'read') return ['read', 'triage', 'developer', 'maintainer'].includes(role); + if (operation === 'write') return ['developer', 'maintainer'].includes(role); + if (operation === 'merge') return ['maintainer'].includes(role); + return false; +} + +function keyFingerprint(publicKey) { + const parts = String(publicKey || '').trim().split(/\s+/); + const data = parts.length >= 2 ? Buffer.from(parts[1], 'base64') : Buffer.from(normalizeKey(publicKey)); + return 'SHA256:' + crypto.createHash('sha256').update(data).digest('base64').replace(/=+$/g, ''); +} + +function keyMatches(item, key) { + const value = String(key || '').trim(); + if (!value) return false; + const normalized = normalizeKey(value); + return normalizeKey(item.public_key) === normalized || + item.public_key === value || + item.public_key.includes(value) || + keyFingerprint(item.public_key) === value; +} + +function memberDocID(fingerprint) { + return Buffer.from(String(fingerprint || '')).toString('base64url'); +} + +function roleCapabilities(role) { + return { + read: roleAllows(role, 'read'), + push: roleAllows(role, 'write'), + comment: ['owner', 'admin', 'maintainer', 'developer', 'triage'].includes(role), + review: ['owner', 'admin', 'maintainer', 'developer', 'triage'].includes(role), + approve: ['owner', 'admin', 'maintainer', 'triage'].includes(role), + merge: roleAllows(role, 'merge'), + admin_keys: role === 'owner' || role === 'admin', + manage_protection: role === 'owner' || role === 'admin', + reopen_pr: ['owner', 'admin', 'maintainer'].includes(role), + owner_transfer: role === 'owner', + broker_upgrade: role === 'owner' || role === 'admin', + }; +} + +function anonymousKey() { + return {user: 'anonymous', role: 'read', public_key: '', source: 'public', anonymous: true}; +} + +function repoIsPublic(entry) { + return (entry.data.visibility || 'private') === 'public'; +} + +function repoIsReadOnly(entry) { + return !!entry.data.read_only; +} + +function validRole(role) { + return ['owner', 'admin', 'maintainer', 'developer', 'triage', 'read'].includes(role); +} + +function normalizeRole(role) { + return role === 'write' ? 'developer' : role; +} + +function requireAdmin(req, entry) { + if (!verifySignature(req, entry)) { + const err = new Error('admin SSH signature required'); + err.status = 403; + throw err; + } +} + +function requireRead(req, entry) { + const key = signedKey(req, entry); + if (!key && repoIsPublic(entry)) return anonymousKey(); + if (!key || !roleAllows(key.role, 'read')) { + const err = new Error('read SSH signature required'); + err.status = 403; + throw err; + } + return key; +} + +function requireWrite(req, entry) { + if (repoIsReadOnly(entry)) { + const err = new Error('repository is read-only'); + err.status = 403; + throw err; + } + const key = signedKey(req, entry); + if (!key || !roleAllows(key.role, 'write')) { + const err = new Error('write SSH signature required'); + err.status = 403; + throw err; + } + return key; +} + +function requireIssueCreate(req, entry) { + if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); + if (repoIsPublic(entry)) return signedKey(req, entry) || anonymousKey(); + return requireRead(req, entry); +} + +function cleanObjectPath(value) { + const path = String(value || '').replace(/^\/+/, ''); + if (path.includes('\0') || path.includes('..')) throw new Error('invalid object path'); + return path; +} + +function objectName(repo, objectPath) { + const prefix = String(repo.prefix || '').replace(/^\/+|\/+$/g, ''); + const path = cleanObjectPath(objectPath); + return prefix ? prefix + '/' + path : path; +} + +async function ensurePhysicalRepo(entry) { + const repo = entry.data.repo || {}; + if (repo.bucket && repo.prefix) return repo; + const logical = cleanName(repo.logical || repo.prefix || 'repo.git'); + const suffix = entry.data.bucket_suffix || randomSuffix(); + const bucket = `bgit-${logical.replace(/\.git$/, '')}-${suffix}`.slice(0, 63).replace(/\.+$/g, ''); + const prefix = 'repo.git'; + try { + await storage.bucket(bucket).get({autoCreate: true}); + } catch (err) { + await storage.createBucket(bucket); + } + entry.data.bucket_suffix = suffix; + entry.data.repo = {...repo, provider: 'gcs', bucket, prefix}; + await saveRepo(entry); + return entry.data.repo; +} + +async function readObject(repo, objectPath) { + const [data] = await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).download(); + return data.toString('base64'); +} + +async function writeTextObject(repo, objectPath, value) { + await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).save(value); +} + +async function deleteObject(repo, objectPath) { + await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).delete({ignoreNotFound: true}); +} + +async function deletePhysicalRepo(repo) { + if (!repo.bucket) return; + const bucket = storage.bucket(repo.bucket); + const [files] = await bucket.getFiles(); + await Promise.all(files.map((file) => file.delete({ignoreNotFound: true}))); + try { + await bucket.delete(); + } catch (err) { + if (err && err.code !== 404) throw err; + } +} + +async function listObjects(repo, prefix) { + const repoPrefix = String(repo.prefix || '').replace(/^\/+|\/+$/g, ''); + const queryPrefix = objectName(repo, prefix); + const [files] = await storage.bucket(repo.bucket).getFiles({prefix: queryPrefix}); + const strip = repoPrefix ? repoPrefix + '/' : ''; + return files.map((file) => file.name.startsWith(strip) ? file.name.slice(strip.length) : file.name); +} + +async function serviceAccountEmail() { + if (process.env.BGIT_SIGNING_SERVICE_ACCOUNT) return process.env.BGIT_SIGNING_SERVICE_ACCOUNT; + const client = await auth.getClient(); + const projectId = await auth.getProjectId(); + return `${projectId}@appspot.gserviceaccount.com`; +} + +async function signedURL(repo, objectPath, operation) { + const action = operation === 'write' ? 'write' : operation === 'delete' ? 'delete' : 'read'; + const method = action === 'write' ? 'PUT' : action === 'delete' ? 'DELETE' : 'GET'; + const file = storage.bucket(repo.bucket).file(objectName(repo, objectPath)); + const [url] = await file.getSignedUrl({ + version: 'v4', + action, + expires: Date.now() + 10 * 60 * 1000, + method, + virtualHostedStyle: false, + extensionHeaders: action === 'write' ? {'content-type': 'application/octet-stream'} : undefined, + cname: undefined, + accessibleAt: undefined, + signingEndpoint: 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/' + await serviceAccountEmail() + ':signBlob', + }); + return {provider: 'gcs', mode: 'signed_url', method, url, headers: action === 'write' ? {'content-type': 'application/octet-stream'} : {}, expires_in: 600}; +} + +async function resumableUpload(repo, objectPath) { + const [uri] = await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).createResumableUpload({metadata: {contentType: 'application/octet-stream'}}); + return {provider: 'gcs', mode: 'resumable_upload', method: 'PUT', url: uri, headers: {}, expires_in: 600}; +} + +function protectionFor(data, ref) { + return (data.protections || []).find((p) => p.ref === ref); +} + +function assertRefAllowed(data, ref, key, opts) { + const protection = protectionFor(data, ref); + if (!protection || !protection.require_pr) return; + if (opts && opts.fromPR) return; + if (protection.allow_overrides && key && (key.role === 'owner' || key.role === 'admin')) return; + const err = new Error(`protected branch ${ref} requires a pull request`); + err.status = 403; + throw err; +} + +async function updateRefCAS(repo, ref, oldHash, newHash, key, opts = {}) { + const id = docID(repo); + const refDoc = repos.doc(id); + await db.runTransaction(async (tx) => { + const snap = await tx.get(refDoc); + const data = snap.exists ? (snap.data() || {}) : {repo, keys: [], refs: {}, audit: []}; + data.repo = data.repo || repo; + data.keys = data.keys || []; + data.refs = data.refs || {}; + data.protections = data.protections || []; + assertRefAllowed(data, ref, key, opts); + const current = Object.prototype.hasOwnProperty.call(data.refs, ref) ? data.refs[ref] : oldHash; + if (current !== oldHash) { + const err = new Error('stale ref'); + err.status = 409; + throw err; + } + if (newHash === zero) delete data.refs[ref]; + else data.refs[ref] = newHash; + data.audit = (data.audit || []).concat([{type: 'ref_update', ref, old: oldHash, new: newHash, at: new Date().toISOString()}]).slice(-500); + tx.set(refDoc, data, {merge: true}); + }); +} + +function nextPRID(data) { + data.next_pr_id = Number(data.next_pr_id || 1); + return data.next_pr_id++; +} + +function nextIssueID(data) { + data.next_issue_id = Number(data.next_issue_id || 1); + return data.next_issue_id++; +} + +function issueDoc(entry, id) { + return entry.ref.collection('issues').doc(String(id).padStart(10, '0')); +} + +async function saveIssue(entry, issue) { + await issueDoc(entry, issue.id).set(issue, {merge: false}); +} + +async function loadIssue(entry, id) { + const snap = await issueDoc(entry, id).get(); + if (!snap.exists) return null; + return snap.data() || null; +} + +async function listIssues(entry) { + const snap = await entry.ref.collection('issues').orderBy('id', 'desc').get(); + return snap.docs.map((doc) => doc.data() || {}); +} + +async function deleteRepoMetadata(entry) { + const prSnap = await entry.ref.collection('prs').get(); + const issueSnap = await entry.ref.collection('issues').get(); + const deletes = []; + prSnap.forEach((doc) => deletes.push(doc.ref.delete())); + issueSnap.forEach((doc) => deletes.push(doc.ref.delete())); + const repo = entry.data.repo || {}; + const oldRepoDocID = docID(repo); + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + deletes.push(members.doc(memberDocID(keyFingerprint(key.public_key))).collection('repos').doc(oldRepoDocID).delete()); + } + deletes.push(entry.ref.delete()); + await Promise.all(deletes); +} + +async function moveRepoSubcollections(oldEntry, newRef) { + const copies = []; + for (const collectionName of ['prs', 'issues']) { + const snap = await oldEntry.ref.collection(collectionName).get(); + snap.forEach((doc) => { + copies.push(newRef.collection(collectionName).doc(doc.id).set(doc.data() || {}, {merge: false})); + }); + } + await Promise.all(copies); +} + +async function deleteMembershipIndex(entry) { + const repoDocID = docID(entry.data.repo || {}); + const deletes = []; + for (const key of entry.data.keys || []) { + if (!key.public_key) continue; + deletes.push(members.doc(memberDocID(keyFingerprint(key.public_key))).collection('repos').doc(repoDocID).delete()); + } + await Promise.all(deletes); +} + +function findPR(data, id) { + return (data.prs || []).find((pr) => Number(pr.id) === Number(id)); +} + +function nextPRNoteID(pr) { + pr.next_note_id = Number(pr.next_note_id || 1); + return pr.next_note_id++; +} + +function nextPRCommentID(pr) { + pr.next_comment_id = Number(pr.next_comment_id || 1); + return pr.next_comment_id++; +} + +function hashLineText(value) { + return crypto.createHash('sha1').update(String(value || '')).digest('hex'); +} + +function normalizeReviewComments(pr, comments, key, head) { + if (!Array.isArray(comments)) return []; + const now = new Date().toISOString(); + return comments.map((comment) => { + const body = String(comment.body || '').trim(); + if (!body) return null; + const lineText = String(comment.line_text || ''); + return { + id: nextPRCommentID(pr), + user: key.user, + body, + file: String(comment.file || '').trim(), + kind: String(comment.kind || 'line').trim(), + side: String(comment.side || 'new').trim(), + hunk: String(comment.hunk || '').trim(), + hunk_index: Number(comment.hunk_index || 0), + old_start: Number(comment.old_start || 0), + new_start: Number(comment.new_start || 0), + offset: Number(comment.offset || 0), + line: Number(comment.line || 0), + line_text: lineText, + line_hash: String(comment.line_hash || hashLineText(lineText)), + head: String(comment.head || head || pr.head || ''), + at: now, + }; + }).filter(Boolean); +} + +function findPRComment(comments, id) { + if (!Array.isArray(comments) || !id) return null; + for (const comment of comments) { + if (Number(comment.id) === Number(id)) return comment; + const nested = findPRComment(comment.replies || [], id); + if (nested) return nested; + } + return null; +} + +function findPRReplyTarget(pr, noteID, commentID) { + const notes = [...(pr.comments || []), ...(pr.reviews || [])]; + for (const note of notes) { + if (commentID) { + const inline = findPRComment(note.comments || [], commentID); + if (inline) return inline; + const reply = findPRComment(note.replies || [], commentID); + if (reply) return reply; + } + if (noteID && Number(note.id) === Number(noteID)) return note; + } + return null; +} + +function bumpPRVersion(data, pr) { + const now = new Date().toISOString(); + data.next_pr_version = Number(data.next_pr_version || 1); + pr.version = `${data.next_pr_version++}-${crypto.randomBytes(4).toString('hex')}`; + pr.updated_at = now; + return pr; +} + +function ensurePRVersions(data) { + let changed = false; + for (const pr of data.prs || []) { + if (!pr.version) { + bumpPRVersion(data, pr); + changed = true; + } + } + return changed; +} + +function syncPRs(data, known) { + const knownMap = known && typeof known === 'object' ? known : {}; + return (data.prs || []).filter((pr) => String(pr.version || '') !== String(knownMap[String(pr.id)] || '')); +} + +function countApprovals(pr) { + const latest = new Map(); + for (const review of pr.reviews || []) { + if (review.user) latest.set(review.user, review.state); + } + return Array.from(latest.values()).filter((state) => state === 'approved').length; +} + +async function ensureRepo(repo) { + if (!repo || (!repo.logical && (!repo.bucket || !repo.prefix))) throw new Error('repo is required'); + const entry = await loadRepo(repo); + const owners = await loadOwners(); + for (const owner of owners.data.keys || []) { + if (owner.role === 'owner' && !entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(owner.public_key))) { + entry.data.keys.push(owner); + } + } + return entry; +} + +exports.broker = async (req, res) => { + res.set('content-type', 'application/json'); + if (req.path === '/health' || req.path === '/') { + res.status(200).send(JSON.stringify({ok: true, service: 'bgit-broker', version: brokerVersion})); + return; + } + try { + const body = req.body || {}; + if (req.path === '/owners/upsert' && req.method === 'POST') { + const entry = await loadOwners(); + if (!verifySignature(req, entry)) throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const user = body.user || 'owner'; + const role = normalizeRole(body.role || 'owner'); + if (role !== 'owner') throw new Error('owner bootstrap only accepts owner role'); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); + } + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/repos/upsert' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const user = body.admin_user || 'admin'; + const role = normalizeRole(body.role || 'admin'); + if (!validRole(role)) throw new Error('invalid role'); + entry.data.repo = {...(entry.data.repo || {}), ...(body.repo || {})}; + if (body.repo && body.repo.logical && !entry.data.repo.bucket) await ensurePhysicalRepo(entry); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); + } + audit(entry, {type: 'repo_upsert', user}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, repo: entry.data.repo, bucket_suffix: entry.data.bucket_suffix})); + return; + } + if (req.path === '/repo/info' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + res.status(200).send(JSON.stringify({ + repo: entry.data.repo || body.repo, + description: entry.data.description || '', + default_branch: entry.data.default_branch || 'main', + visibility: entry.data.visibility || 'private', + read_only: !!entry.data.read_only, + issues_enabled: entry.data.issues_enabled !== false, + })); + return; + } + if (req.path === '/repo/update' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + if (Object.prototype.hasOwnProperty.call(body, 'description')) entry.data.description = String(body.description || '').trim(); + if (Object.prototype.hasOwnProperty.call(body, 'default_branch')) entry.data.default_branch = String(body.default_branch || '').trim() || 'main'; + if (Object.prototype.hasOwnProperty.call(body, 'visibility')) entry.data.visibility = body.visibility === 'public' ? 'public' : 'private'; + if (Object.prototype.hasOwnProperty.call(body, 'read_only')) entry.data.read_only = !!body.read_only; + if (Object.prototype.hasOwnProperty.call(body, 'issues_enabled')) entry.data.issues_enabled = body.issues_enabled !== false; + audit(entry, {type: 'repo_update'}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ + ok: true, + repo: entry.data.repo || body.repo, + description: entry.data.description, + default_branch: entry.data.default_branch, + visibility: entry.data.visibility, + read_only: !!entry.data.read_only, + issues_enabled: entry.data.issues_enabled !== false, + })); + return; + } + if (req.path === '/repo/rename' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const logical = String(body.logical || '').trim().replace(/^\/+|\/+$/g, ''); + if (!logical) throw new Error('logical repo name is required'); + const newRepo = {...(entry.data.repo || body.repo), logical}; + const newRef = repos.doc(docID(newRepo)); + const oldID = docID(entry.data.repo || body.repo); + const newID = docID(newRepo); + if (oldID !== newID && (await newRef.get()).exists) throw Object.assign(new Error('target logical repo already exists'), {status: 409}); + entry.data.repo = newRepo; + audit(entry, {type: 'repo_rename', logical, user: key.user}); + await newRef.set(entry.data, {merge: false}); + if (oldID !== newID) { + await moveRepoSubcollections({ref: entry.ref, data: {...entry.data, repo: body.repo}}, newRef); + await deleteMembershipIndex({data: {...entry.data, repo: body.repo}}); + await entry.ref.delete(); + } + await syncMembershipIndex({ref: newRef, data: entry.data}); + res.status(200).send(JSON.stringify({ok: true, repo: newRepo})); + return; + } + if (req.path === '/repo/delete' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const repo = await ensurePhysicalRepo(entry); + await deletePhysicalRepo(repo); + await deleteRepoMetadata(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/keys/list' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + res.status(200).send(JSON.stringify({keys: entry.data.keys})); + return; + } + if (req.path === '/keys/add' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const user = body.user || 'admin'; + const role = normalizeRole(body.role || 'read'); + if (!validRole(role) || role === 'owner') throw new Error('invalid role'); + for (const publicKey of body.public_keys || []) { + if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); + } + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/keys/invite/create' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const user = String(body.user || '').trim(); + const role = normalizeRole(body.role || 'read'); + if (!user) throw new Error('user is required'); + if (!validRole(role) || role === 'owner') throw new Error('invalid role'); + const token = crypto.randomBytes(24).toString('base64url'); + const brokerURL = String(body.broker_url || '').trim(); + const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + entry.data.invites = (entry.data.invites || []).filter((invite) => Date.parse(invite.expires_at || '') > Date.now()); + const normalizedUser = user.toLowerCase(); + if (entry.data.invites.some((invite) => String(invite.user || '').trim().toLowerCase() === normalizedUser)) throw Object.assign(new Error('invite already pending for user'), {status: 409}); + entry.data.invites.push({token_hash: ownershipTransferTokenHash(token), user, role, broker_url: brokerURL, expires_at: expires}); + const code = memberInviteCode(brokerURL, entry.data.repo || body.repo, token); + audit(entry, {type: 'member_invite_create', user, role}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, code, accept_command: 'bgit admin accept-invite ' + code})); + return; + } + if (req.path === '/keys/invite/accept' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const signed = submittedSignedKey(req); + if (!signed) throw Object.assign(new Error('SSH signature required'), {status: 403}); + const tokenHash = ownershipTransferTokenHash(body.token); + const invites = entry.data.invites || []; + const invite = invites.find((item) => item.token_hash === tokenHash && Date.parse(item.expires_at || '') > Date.now()); + if (!invite) throw Object.assign(new Error('invite is not pending or has expired'), {status: 404}); + const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(signed.public_key)); + if (existing) { + existing.user = invite.user; + existing.role = invite.role; + existing.suspended = false; + } else { + entry.data.keys = entry.data.keys || []; + entry.data.keys.push({user: invite.user, role: invite.role, public_key: signed.public_key, source: 'invite', suspended: false}); + } + entry.data.invites = invites.filter((item) => item.token_hash !== tokenHash); + audit(entry, {type: 'member_invite_accept', user: invite.user, role: invite.role, fingerprint: signed.fingerprint}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, user: invite.user, role: invite.role, fingerprint: signed.fingerprint})); + return; + } + if (req.path === '/keys/invite/cancel' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const invites = entry.data.invites || []; + const user = String(body.user || '').trim().toLowerCase(); + const tokenHash = body.token ? ownershipTransferTokenHash(body.token) : ''; + const next = invites.filter((item) => { + if (tokenHash) return item.token_hash !== tokenHash; + return String(item.user || '').trim().toLowerCase() !== user; + }); + if (next.length === invites.length) throw Object.assign(new Error('invite is not pending or has expired'), {status: 404}); + entry.data.invites = next; + audit(entry, {type: 'member_invite_cancel'}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if ((req.path === '/keys/remove' || req.path === '/keys/suspend') && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const key = String(body.key || '').trim(); + const match = (k) => keyMatches(k, key); + if (entry.data.keys.some((k) => match(k) && k.role === 'owner')) throw Object.assign(new Error('owners cannot be removed or suspended'), {status: 403}); + let changed = false; + if (req.path === '/keys/remove') { + const before = entry.data.keys.length; + entry.data.keys = entry.data.keys.filter((k) => !match(k)); + changed = entry.data.keys.length !== before; + } else { + for (const item of entry.data.keys) { + if (match(item)) { + item.suspended = true; + changed = true; + } + } + } + if (!changed) throw Object.assign(new Error('key not found'), {status: 404}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/keys/unsuspend' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + const key = String(body.key || '').trim(); + const match = (k) => keyMatches(k, key); + let changed = false; + for (const item of entry.data.keys || []) { + if (match(item)) { + item.suspended = false; + changed = true; + } + } + if (!changed) throw Object.assign(new Error('key not found'), {status: 404}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/owners/transfer/confirm' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + if (entry.data.owner_transfer && !ownershipTransferExpired(entry.data.owner_transfer)) { + throw Object.assign(new Error('ownership transfer already pending; run bgit admin cancel-ownership-transfer to cancel it'), {status: 409}); + } + const token = crypto.randomBytes(24).toString('base64url'); + const brokerURL = String(body.broker_url || '').trim(); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + entry.data.owner_transfer = { + token_hash: ownershipTransferTokenHash(token), + requested_by: key.user || '', + requested_by_fingerprint: keyFingerprint(key.public_key), + broker_url: brokerURL, + expires_at: expires, + }; + const code = ownershipTransferCode(brokerURL, entry.data.repo || body.repo, token); + audit(entry, {type: 'owner_transfer_confirm', user: key.user || '', expires_at: expires}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, code, accept_command: 'bgit admin accept-ownership-transfer ' + code, cancel_command: 'bgit admin cancel-ownership-transfer --broker ' + brokerURL + ' ' + ((entry.data.repo || body.repo).logical || '')})); + return; + } + if (req.path === '/owners/transfer/cancel' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + delete entry.data.owner_transfer; + audit(entry, {type: 'owner_transfer_cancel', user: key.user || ''}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/owners/transfer/accept' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const transfer = entry.data.owner_transfer; + if (!transfer || ownershipTransferExpired(transfer)) throw Object.assign(new Error('ownership transfer is not pending or has expired'), {status: 404}); + if (ownershipTransferTokenHash(body.token) !== transfer.token_hash) throw Object.assign(new Error('ownership transfer code is invalid'), {status: 403}); + const accepted = submittedSignedKey(req); + if (!accepted) throw Object.assign(new Error('SSH signature required'), {status: 403}); + const user = String(body.user || 'owner').trim() || 'owner'; + const ownerFingerprint = transfer.requested_by_fingerprint || ''; + for (const item of entry.data.keys || []) { + if (item.role === 'owner' && keyFingerprint(item.public_key) === ownerFingerprint) item.role = 'admin'; + } + const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(accepted.public_key)); + if (existing) { + existing.role = 'owner'; + existing.user = user; + existing.suspended = false; + } else { + entry.data.keys = entry.data.keys || []; + entry.data.keys.push({user, role: 'owner', public_key: accepted.public_key, source: 'ownership-transfer', suspended: false}); + } + delete entry.data.owner_transfer; + audit(entry, {type: 'owner_transfer_accept', user, fingerprint: accepted.fingerprint}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, user, fingerprint: accepted.fingerprint})); + return; + } + if (req.path === '/protection/list' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + res.status(200).send(JSON.stringify({protections: entry.data.protections || []})); + return; + } + if (req.path === '/protection/upsert' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); + entry.data.protections.push({ref: body.ref, require_pr: body.require_pr !== false, allow_overrides: !!body.allow_overrides}); + audit(entry, {type: 'protection_upsert', ref: body.ref}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/protection/remove' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); + audit(entry, {type: 'protection_remove', ref: body.ref}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/issues/list' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); + const issues = await listIssues(entry); + res.status(200).send(JSON.stringify({issues})); + return; + } + if (req.path === '/issues/view' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error('issue not found'), {status: 404}); + res.status(200).send(JSON.stringify({issue})); + return; + } + if (req.path === '/issues/create' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); + const key = requireIssueCreate(req, entry); + const title = String(body.title || '').trim(); + const issueBody = String(body.body || '').trim(); + if (!title) throw new Error('issue title is required'); + const issue = {id: nextIssueID(entry.data), title, body: issueBody, status: 'open', author: key.user || 'anonymous', comments: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()}; + await saveRepo(entry); + await saveIssue(entry, issue); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; + } + if (req.path === '/issues/comment' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); + const key = requireIssueCreate(req, entry); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error('issue not found'), {status: 404}); + const comment = String(body.comment || '').trim(); + if (!comment) throw new Error('comment is required'); + issue.comments = issue.comments || []; + issue.comments.push({user: key.user || 'anonymous', body: comment, at: new Date().toISOString()}); + issue.updated_at = new Date().toISOString(); + await saveIssue(entry, issue); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; + } + if (req.path === '/issues/close' || req.path === '/issues/reopen') { + const entry = await ensureRepo(body.repo); + requireWrite(req, entry); + const issue = await loadIssue(entry, body.id); + if (!issue) throw Object.assign(new Error('issue not found'), {status: 404}); + issue.status = req.path === '/issues/reopen' ? 'open' : 'closed'; + issue.updated_at = new Date().toISOString(); + await saveIssue(entry, issue); + res.status(200).send(JSON.stringify({ok: true, issue})); + return; + } + if (req.path === '/prs/create' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireWrite(req, entry); + entry.data.refs = entry.data.refs || {}; + const pr = {...(body.pr || {})}; + pr.id = nextPRID(entry.data); + pr.status = 'open'; + pr.author = key.user; + pr.approvals = pr.approvals || 0; + pr.checks = pr.checks || []; + pr.head = entry.data.refs[pr.source] || ''; + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_create', id: pr.id, source: pr.source, target: pr.target, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({pr})); + return; + } + if (req.path === '/prs/list' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + res.status(200).send(JSON.stringify({prs: await listPRs(entry)})); + return; + } + if (req.path === '/prs/sync' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + res.status(200).send(JSON.stringify(await syncPRRecords(entry, body.known || {}))); + return; + } + if (req.path === '/prs/view' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + res.status(200).send(JSON.stringify({pr})); + return; + } + if (req.path === '/prs/close' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireWrite(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + pr.status = 'closed'; + pr.closed_by = key.user; + pr.closed_at = new Date().toISOString(); + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_close', id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } + if (req.path === '/prs/reopen' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireWrite(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + pr.status = 'open'; + delete pr.closed_by; + delete pr.closed_at; + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_reopen', id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } + if (req.path === '/prs/comment' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); + const key = requireRead(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + const comment = String(body.comment || '').trim(); + if (!comment) throw Object.assign(new Error('comment is required'), {status: 400}); + pr.comments = pr.comments || []; + pr.comments.push({id: nextPRNoteID(pr), user: key.user, body: comment, at: new Date().toISOString()}); + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_comment', id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } + if (req.path === '/prs/reply' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); + const key = requireRead(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + const comment = String(body.comment || '').trim(); + if (!comment) throw Object.assign(new Error('comment is required'), {status: 400}); + const target = findPRReplyTarget(pr, Number(body.target_note_id || 0), Number(body.target_comment_id || 0)); + if (!target) throw Object.assign(new Error('reply target not found'), {status: 404}); + target.replies = target.replies || []; + target.replies.push({id: nextPRCommentID(pr), user: key.user, body: comment, kind: 'reply', at: new Date().toISOString()}); + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_reply', id: pr.id, user: key.user}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } + if (req.path === '/prs/review' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireWrite(req, entry); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + const state = String(body.review || '').trim(); + if (!['commented', 'approved', 'changes_requested'].includes(state)) throw Object.assign(new Error('unsupported review state'), {status: 400}); + pr.reviews = pr.reviews || []; + const comments = normalizeReviewComments(pr, body.comments, key, pr.head); + pr.reviews.push({id: nextPRNoteID(pr), user: key.user, body: String(body.comment || '').trim(), state, comments, head: String(pr.head || ''), at: new Date().toISOString()}); + pr.approvals = countApprovals(pr); + bumpPRVersion(entry.data, pr); + audit(entry, {type: 'pr_review', id: pr.id, user: key.user, state}); + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } + if (req.path === '/prs/merge' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); + const key = signedKey(req, entry); + if (!key || !roleAllows(key.role, 'merge')) throw Object.assign(new Error('merge SSH signature required'), {status: 403}); + const pr = await loadPR(entry, body.id); + if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); + if (pr.status !== 'open') throw new Error('pull request is not open'); + const newHash = (entry.data.refs || {})[pr.source] || pr.head; + if (!newHash) throw new Error('pull request source ref has no head'); + const oldHash = (entry.data.refs || {})[pr.target] || zero; + await updateRefCAS(body.repo, pr.target, oldHash, newHash, key, {fromPR: true}); + const repo = await ensurePhysicalRepo(entry); + await writeTextObject(repo, pr.target, newHash + '\n'); + entry.data.refs = entry.data.refs || {}; + entry.data.refs[pr.target] = newHash; + pr.status = 'merged'; + pr.merged_by = key.user; + pr.merged_at = new Date().toISOString(); + bumpPRVersion(entry.data, pr); + if (body.delete_branch && pr.source && pr.source !== pr.target) { + delete entry.data.refs[pr.source]; + await deleteObject(repo, pr.source); + audit(entry, {type: 'branch_delete', ref: pr.source, from_pr: pr.id, user: key.user}); + } + await saveRepo(entry); + await savePR(entry, pr); + res.status(200).send(JSON.stringify({ok: true, pr})); + return; + } + if (req.path === '/auth/check' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry); + const operation = body.operation || ''; + const allowed = (operation === 'read' && repoIsPublic(entry)) || (!!key && roleAllows(key.role, operation)); + res.status(200).send(JSON.stringify({allowed, user: key && key.user || (allowed ? 'anonymous' : ''), role: key && key.role || (allowed ? 'read' : '')})); + return; + } + if (req.path === '/auth/status' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = signedKey(req, entry) || (repoIsPublic(entry) ? anonymousKey() : null); + if (!key) throw Object.assign(new Error('SSH signature required'), {status: 403}); + res.status(200).send(JSON.stringify({ + broker_version: brokerVersion, + repo: entry.data.repo || body.repo, + identity: {user: key.user || '', source: key.source || '', key_fingerprint: key.public_key ? keyFingerprint(key.public_key) : '', public_key: key.public_key || ''}, + user: key.user || '', + role: key.role || '', + capabilities: roleCapabilities(key.role || ''), + resolved_at: new Date().toISOString(), + })); + return; + } + if (req.path === '/repos/mine' && req.method === 'POST') { + const fingerprint = req.get('x-bgit-key-fingerprint') || ''; + if (!fingerprint) throw Object.assign(new Error('SSH signature required'), {status: 403}); + const snap = await members.doc(memberDocID(fingerprint)).collection('repos').get(); + const out = []; + snap.forEach((doc) => { + const item = doc.data() || {}; + if (!item.suspended && item.repo && (item.repo.logical || (item.repo.bucket && item.repo.prefix))) out.push({...item, key_fingerprint: fingerprint}); + }); + out.sort((a, b) => String(a.logical || a.repo_id || '').localeCompare(String(b.logical || b.repo_id || ''))); + res.status(200).send(JSON.stringify({repos: out})); + return; + } + if (req.path === '/members/reindex' && req.method === 'POST') { + if (body.repo && (body.repo.logical || body.repo.bucket || body.repo.prefix)) { + const entry = await ensureRepo(body.repo); + requireAdmin(req, entry); + await syncMembershipIndex(entry); + res.status(200).send(JSON.stringify({ok: true, repositories: 1})); + return; + } + const owners = await loadOwners(); + if (!verifySignature(req, owners)) throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const count = await syncAllMembershipIndexes(); + res.status(200).send(JSON.stringify({ok: true, repositories: count})); + return; + } + if (req.path === '/objects/capability' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const operation = body.operation || 'read'; + const key = operation === 'read' ? requireRead(req, entry) : requireWrite(req, entry); + const repo = await ensurePhysicalRepo(entry); + const capability = body.resumable ? await resumableUpload(repo, body.path) : await signedURL(repo, body.path, operation); + audit(entry, {type: 'capability_issued', operation, path: body.path, user: key.user, role: key.role}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({...capability, bucket: repo.bucket, prefix: repo.prefix, object: objectName(repo, body.path)})); + return; + } + if (req.path === '/objects/read' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + const repo = await ensurePhysicalRepo(entry); + const data = await readObject(repo, body.path); + res.status(200).send(JSON.stringify({data})); + return; + } + if (req.path === '/objects/list' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + requireRead(req, entry); + const repo = await ensurePhysicalRepo(entry); + const paths = await listObjects(repo, body.prefix); + res.status(200).send(JSON.stringify({paths})); + return; + } + if (req.path === '/refs/update' && req.method === 'POST') { + const entry = await ensureRepo(body.repo); + const key = requireWrite(req, entry); + await updateRefCAS(body.repo, body.ref, body.old, body.new, key, {override: !!body.override}); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + res.status(404).send(JSON.stringify({error: 'unknown broker endpoint'})); + } catch (err) { + res.status(err.status || 500).send(JSON.stringify({error: err.message || String(err)})); + } +}; diff --git a/broker/gcp/package.json b/broker/gcp/package.json new file mode 100644 index 0000000..ce9b776 --- /dev/null +++ b/broker/gcp/package.json @@ -0,0 +1 @@ +{"scripts":{"start":"functions-framework --target=broker"},"dependencies":{"@google-cloud/functions-framework":"^3.4.0","@google-cloud/firestore":"^7.10.0","@google-cloud/storage":"^7.16.0","google-auth-library":"^9.15.1"}} diff --git a/broker/test_support/sqlite_broker.js b/broker/test_support/sqlite_broker.js new file mode 100644 index 0000000..6c98fc1 --- /dev/null +++ b/broker/test_support/sqlite_broker.js @@ -0,0 +1,435 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const {Readable} = require('stream'); +const {DatabaseSync} = require('node:sqlite'); + +function ensureDir(dir) { + fs.mkdirSync(dir, {recursive: true}); +} + +function encodeObjectName(name) { + return Buffer.from(String(name || ''), 'utf8').toString('base64url'); +} + +function decodeObjectName(name) { + return Buffer.from(String(name || ''), 'base64url').toString('utf8'); +} + +class SQLiteStore { + constructor(file) { + ensureDir(path.dirname(file)); + this.db = new DatabaseSync(file); + this.db.exec(` + create table if not exists documents ( + path text primary key, + data text not null + ); + create table if not exists aws_items ( + table_name text not null, + pk text not null, + data text not null, + primary key (table_name, pk) + ); + `); + } + + getDocument(docPath) { + const row = this.db.prepare('select data from documents where path = ?').get(docPath); + return row ? JSON.parse(row.data) : null; + } + + setDocument(docPath, data, merge) { + const current = merge ? this.getDocument(docPath) : null; + const next = current ? {...current, ...data} : data; + this.db.prepare('insert or replace into documents(path, data) values (?, ?)').run(docPath, JSON.stringify(next || {})); + } + + deleteDocument(docPath) { + this.db.prepare('delete from documents where path = ?').run(docPath); + } + + listCollection(collectionPath) { + const prefix = collectionPath.replace(/\/+$/g, '') + '/'; + const rows = this.db.prepare('select path, data from documents where path like ?').all(prefix + '%'); + return rows + .filter((row) => !row.path.slice(prefix.length).includes('/')) + .map((row) => ({id: row.path.slice(prefix.length), path: row.path, data: JSON.parse(row.data)})); + } + + awsGet(tableName, pk) { + const row = this.db.prepare('select data from aws_items where table_name = ? and pk = ?').get(tableName, pk); + return row ? JSON.parse(row.data) : null; + } + + awsPut(tableName, pk, data) { + this.db.prepare('insert or replace into aws_items(table_name, pk, data) values (?, ?, ?)').run(tableName, pk, JSON.stringify(data || {})); + } + + awsDelete(tableName, pk) { + this.db.prepare('delete from aws_items where table_name = ? and pk = ?').run(tableName, pk); + } + + awsScan(tableName) { + return this.db.prepare('select data from aws_items where table_name = ?').all(tableName).map((row) => JSON.parse(row.data)); + } + + awsQuery(tableName, prefix) { + return this.awsScan(tableName).filter((item) => String(item.__pk || '').startsWith(prefix)); + } +} + +class FakeSnapshot { + constructor(ref, value) { + this.ref = ref; + this.id = ref.id; + this.exists = value !== null && value !== undefined; + this._value = value || {}; + } + data() { + return JSON.parse(JSON.stringify(this._value)); + } +} + +class FakeQuerySnapshot { + constructor(docs) { + this.docs = docs; + } + forEach(fn) { + this.docs.forEach(fn); + } +} + +class FakeDocumentRef { + constructor(store, docPath) { + this._store = store; + this.path = docPath; + this.id = docPath.split('/').pop(); + } + async get() { + return new FakeSnapshot(this, this._store.getDocument(this.path)); + } + async set(data, opts = {}) { + this._store.setDocument(this.path, data || {}, !!opts.merge); + } + async delete() { + this._store.deleteDocument(this.path); + } + collection(name) { + return new FakeCollectionRef(this._store, this.path + '/' + name); + } +} + +class FakeCollectionRef { + constructor(store, collectionPath) { + this._store = store; + this.path = collectionPath.replace(/\/+$/g, ''); + this._orderBy = null; + this._direction = 'asc'; + } + doc(id) { + return new FakeDocumentRef(this._store, this.path + '/' + id); + } + orderBy(field, direction) { + const next = new FakeCollectionRef(this._store, this.path); + next._orderBy = field; + next._direction = direction || 'asc'; + return next; + } + async get() { + let docs = this._store.listCollection(this.path).map((row) => new FakeSnapshot(new FakeDocumentRef(this._store, row.path), row.data)); + if (this._orderBy) { + const field = this._orderBy; + const mult = this._direction === 'desc' ? -1 : 1; + docs = docs.sort((a, b) => { + const av = a.data()[field]; + const bv = b.data()[field]; + return av === bv ? 0 : av > bv ? mult : -mult; + }); + } + return new FakeQuerySnapshot(docs); + } +} + +class FakeFirestore { + constructor() { + this._store = new SQLiteStore(process.env.BROKER_TEST_SQLITE || path.join(process.cwd(), 'broker-test.sqlite')); + } + collection(name) { + return new FakeCollectionRef(this._store, name); + } + async runTransaction(fn) { + const tx = { + get: (ref) => ref.get(), + set: (ref, data, opts) => ref.set(data, opts), + }; + return fn(tx); + } +} + +class FakeFile { + constructor(root, bucket, name) { + this.root = root; + this.bucket = bucket; + this.name = name; + } + diskPath() { + return path.join(this.root, this.bucket, this.name); + } + async download() { + return [fs.readFileSync(this.diskPath())]; + } + async save(value) { + ensureDir(path.dirname(this.diskPath())); + fs.writeFileSync(this.diskPath(), value); + } + async delete() { + fs.rmSync(this.diskPath(), {force: true}); + } + async getSignedUrl(opts = {}) { + const method = opts.method || (opts.action === 'write' ? 'PUT' : opts.action === 'delete' ? 'DELETE' : 'GET'); + return [`${process.env.BROKER_TEST_BASE_URL}/_objects/${encodeURIComponent(this.bucket)}/${encodeObjectName(this.name)}?method=${encodeURIComponent(method)}`]; + } + async createResumableUpload() { + return [`${process.env.BROKER_TEST_BASE_URL}/_objects/${encodeURIComponent(this.bucket)}/${encodeObjectName(this.name)}?method=PUT`]; + } +} + +class FakeBucket { + constructor(root, name) { + this.root = root; + this.name = name; + } + file(name) { + return new FakeFile(this.root, this.name, name); + } + async get() { + ensureDir(path.join(this.root, this.name)); + return [this]; + } + async getFiles(opts = {}) { + const bucketRoot = path.join(this.root, this.name); + const prefix = opts.prefix || ''; + if (!fs.existsSync(bucketRoot)) return [[]]; + const out = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else { + const rel = path.relative(bucketRoot, full).split(path.sep).join('/'); + if (rel.startsWith(prefix)) out.push({name: rel, delete: async () => fs.rmSync(full, {force: true})}); + } + } + }; + walk(bucketRoot); + return [out]; + } + async delete() { + fs.rmSync(path.join(this.root, this.name), {recursive: true, force: true}); + } +} + +class FakeStorage { + constructor() { + this.root = process.env.BROKER_TEST_OBJECT_ROOT || path.join(process.cwd(), 'broker-test-objects'); + ensureDir(this.root); + } + bucket(name) { + return new FakeBucket(this.root, name); + } + async createBucket(name) { + const bucket = this.bucket(name); + await bucket.get(); + return [bucket]; + } +} + +class FakeGoogleAuth { + async getClient() { + return {}; + } + async getProjectId() { + return 'bgit-test'; + } +} + +function awsAttrToValue(attr) { + if (!attr) return undefined; + if (Object.prototype.hasOwnProperty.call(attr, 'S')) return attr.S; + if (Object.prototype.hasOwnProperty.call(attr, 'N')) return Number(attr.N); + return undefined; +} + +function awsItemPK(item) { + if (item.id) return item.id.S; + if (item.repo_id && item.pr_id) return item.repo_id.S + '#' + item.pr_id.N; + if (item.fingerprint && item.repo_id) return item.fingerprint.S + '#' + item.repo_id.S; + return JSON.stringify(item); +} + +function makeAWSModules(store, objectRoot) { + class DynamoDBClient { + async send(command) { + const input = command.input || {}; + const name = command.constructor.name; + if (name === 'GetItemCommand') { + const item = store.awsGet(input.TableName, awsItemPK(input.Key || {})); + return item ? {Item: item} : {}; + } + if (name === 'PutItemCommand') { + const pk = awsItemPK(input.Item || {}); + store.awsPut(input.TableName, pk, {...input.Item, __pk: pk}); + return {}; + } + if (name === 'DeleteItemCommand') { + store.awsDelete(input.TableName, awsItemPK(input.Key || {})); + return {}; + } + if (name === 'ScanCommand') { + return {Items: store.awsScan(input.TableName)}; + } + if (name === 'QueryCommand') { + const values = input.ExpressionAttributeValues || {}; + const fingerprint = awsAttrToValue(values[':fingerprint']); + const repoID = awsAttrToValue(values[':repo_id']); + const prefix = fingerprint ? fingerprint + '#' : repoID ? repoID + '#' : ''; + return {Items: store.awsQuery(input.TableName, prefix)}; + } + throw new Error('unsupported fake DynamoDB command ' + name); + } + } + class GetItemCommand { constructor(input) { this.input = input || {}; } } + class PutItemCommand { constructor(input) { this.input = input || {}; } } + class QueryCommand { constructor(input) { this.input = input || {}; } } + class ScanCommand { constructor(input) { this.input = input || {}; } } + class DeleteItemCommand { constructor(input) { this.input = input || {}; } } + class GetObjectCommand { constructor(input) { this.input = input || {}; } } + class PutObjectCommand { constructor(input) { this.input = input || {}; } } + class DeleteObjectCommand { constructor(input) { this.input = input || {}; } } + class ListObjectsV2Command { constructor(input) { this.input = input || {}; } } + class HeadBucketCommand { constructor(input) { this.input = input || {}; } } + class CreateBucketCommand { constructor(input) { this.input = input || {}; } } + class DeleteBucketCommand { constructor(input) { this.input = input || {}; } } + class AssumeRoleCommand { constructor(input) { this.input = input || {}; } } + class S3Client { + async send(command) { + const input = command.input || {}; + const name = command.constructor.name; + const bucketRoot = path.join(objectRoot, input.Bucket || ''); + const filePath = path.join(bucketRoot, input.Key || ''); + if (name === 'HeadBucketCommand' || name === 'CreateBucketCommand') { + ensureDir(bucketRoot); + return {}; + } + if (name === 'PutObjectCommand') { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, Buffer.isBuffer(input.Body) || typeof input.Body === 'string' ? input.Body : Buffer.from(input.Body || '')); + return {}; + } + if (name === 'GetObjectCommand') { + return {Body: Readable.from(fs.readFileSync(filePath))}; + } + if (name === 'DeleteObjectCommand') { + fs.rmSync(filePath, {force: true}); + return {}; + } + if (name === 'DeleteBucketCommand') { + fs.rmSync(bucketRoot, {recursive: true, force: true}); + return {}; + } + if (name === 'ListObjectsV2Command') { + const contents = []; + if (fs.existsSync(bucketRoot)) { + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else { + const key = path.relative(bucketRoot, full).split(path.sep).join('/'); + if (key.startsWith(input.Prefix || '')) contents.push({Key: key}); + } + } + }; + walk(bucketRoot); + } + return {Contents: contents}; + } + throw new Error('unsupported fake S3 command ' + name); + } + } + class STSClient { + async send() { + return {Credentials: {AccessKeyId: 'test', SecretAccessKey: 'test', SessionToken: 'test'}}; + } + } + return { + '@aws-sdk/client-dynamodb': { + DynamoDBClient, + GetItemCommand, + PutItemCommand, + QueryCommand, + ScanCommand, + DeleteItemCommand, + }, + '@aws-sdk/client-s3': { + S3Client, + GetObjectCommand, + PutObjectCommand, + DeleteObjectCommand, + ListObjectsV2Command, + HeadBucketCommand, + CreateBucketCommand, + DeleteBucketCommand, + }, + '@aws-sdk/client-sts': { + STSClient, + AssumeRoleCommand, + }, + }; +} + +async function handleObjectRequest(req, res, objectRoot) { + const parts = req.url.split('?')[0].split('/').filter(Boolean); + if (parts[0] !== '_objects' || parts.length < 3) return false; + const bucket = decodeURIComponent(parts[1]); + const name = decodeObjectName(parts.slice(2).join('/')); + const filePath = path.join(objectRoot, bucket, name); + if (req.method === 'GET') { + if (!fs.existsSync(filePath)) { + res.writeHead(404).end('not found'); + return true; + } + res.writeHead(200, {'content-type': 'application/octet-stream'}); + fs.createReadStream(filePath).pipe(res); + return true; + } + if (req.method === 'PUT') { + ensureDir(path.dirname(filePath)); + const chunks = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + fs.writeFileSync(filePath, Buffer.concat(chunks)); + res.writeHead(200, {'content-type': 'application/json'}).end('{}'); + }); + return true; + } + if (req.method === 'DELETE') { + fs.rmSync(filePath, {force: true}); + res.writeHead(200, {'content-type': 'application/json'}).end('{}'); + return true; + } + res.writeHead(405).end('method not allowed'); + return true; +} + +module.exports = { + SQLiteStore, + FakeFirestore, + FakeStorage, + FakeGoogleAuth, + makeAWSModules, + handleObjectRequest, +}; diff --git a/broker/testserver.js b/broker/testserver.js new file mode 100644 index 0000000..746d2d8 --- /dev/null +++ b/broker/testserver.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const http = require('http'); +const Module = require('module'); +const path = require('path'); +const vm = require('vm'); +const { + SQLiteStore, + FakeFirestore, + FakeStorage, + FakeGoogleAuth, + makeAWSModules, + handleObjectRequest, +} = require('./test_support/sqlite_broker'); + +const runtime = process.argv[2] || process.env.BROKER_TEST_RUNTIME || 'gcp'; +const port = Number(process.env.PORT || process.env.BROKER_TEST_PORT || 19080); +const root = process.env.BROKER_TEST_ROOT || path.join(process.cwd(), '.broker-test'); +const sqlitePath = process.env.BROKER_TEST_SQLITE || path.join(root, runtime + '.sqlite'); +const objectRoot = process.env.BROKER_TEST_OBJECT_ROOT || path.join(root, runtime + '-objects'); + +fs.mkdirSync(root, {recursive: true}); +fs.mkdirSync(objectRoot, {recursive: true}); +process.env.BROKER_TEST_MODE = 'sqlite'; +process.env.BROKER_TEST_SQLITE = sqlitePath; +process.env.BROKER_TEST_OBJECT_ROOT = objectRoot; +process.env.BROKER_VERSION = process.env.BROKER_VERSION || '1.0.0-test'; +process.env.TABLE_NAME = process.env.TABLE_NAME || 'bgit-broker-repos'; +process.env.PR_TABLE_NAME = process.env.PR_TABLE_NAME || 'bgit-broker-prs'; +process.env.MEMBER_TABLE_NAME = process.env.MEMBER_TABLE_NAME || 'bgit-broker-members'; +process.env.TRANSFER_ROLE_ARN = process.env.TRANSFER_ROLE_ARN || 'arn:aws:iam::000000000000:role/bgit-test-transfer'; +process.env.AWS_REGION = process.env.AWS_REGION || 'us-east-1'; + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function installGCPMocks() { + const originalLoad = Module._load; + Module._load = function patchedLoad(request, parent, isMain) { + if (request === '@google-cloud/firestore') return {Firestore: FakeFirestore}; + if (request === '@google-cloud/storage') return {Storage: FakeStorage}; + if (request === 'google-auth-library') return {GoogleAuth: FakeGoogleAuth}; + return originalLoad.call(this, request, parent, isMain); + }; +} + +function loadGCPHandler() { + installGCPMocks(); + return require('./gcp/index.js').broker; +} + +function awsZipFileSource() { + const template = fs.readFileSync(path.join(__dirname, 'aws/template.yaml'), 'utf8').split(/\r?\n/); + const start = template.findIndex((line) => line.includes('ZipFile: |')); + if (start < 0) throw new Error('AWS template ZipFile not found'); + const lines = []; + for (let i = start + 1; i < template.length; i++) { + const line = template[i]; + if (/^ BrokerFunctionUrl:/.test(line)) break; + lines.push(line.replace(/^ /, '')); + } + return lines.join('\n'); +} + +function loadAWSHandler() { + const store = new SQLiteStore(sqlitePath); + const awsModules = makeAWSModules(store, objectRoot); + const sandbox = { + exports: {}, + module: {exports: {}}, + require: (name) => { + if (awsModules[name]) return awsModules[name]; + return require(name); + }, + process, + Buffer, + console, + setTimeout, + clearTimeout, + URL, + }; + sandbox.module.exports = sandbox.exports; + vm.runInNewContext(awsZipFileSource(), sandbox, {filename: 'broker/aws/template.js'}); + return sandbox.exports.handler || sandbox.module.exports.handler; +} + +let server; + +function gcpResponse(res) { + return { + set: (key, value) => res.setHeader(key, value), + status(code) { + res.statusCode = code; + return this; + }, + send(value) { + res.end(value); + }, + }; +} + +async function handleGCP(handler, req, res, raw) { + const bodyText = raw.toString('utf8'); + const gcpReq = { + path: new URL(req.url, process.env.BROKER_TEST_BASE_URL).pathname, + method: req.method, + headers: req.headers, + rawBody: raw, + body: bodyText ? JSON.parse(bodyText) : {}, + get: (name) => req.headers[String(name).toLowerCase()], + }; + await handler(gcpReq, gcpResponse(res)); +} + +async function handleAWS(handler, req, res, raw) { + const event = { + rawPath: new URL(req.url, process.env.BROKER_TEST_BASE_URL).pathname, + requestContext: {http: {method: req.method}}, + headers: req.headers, + body: raw.toString('utf8'), + }; + const out = await handler(event); + res.writeHead(out.statusCode || 200, out.headers || {'content-type': 'application/json'}); + res.end(out.body || ''); +} + +async function main() { + const handler = runtime === 'aws' ? loadAWSHandler() : loadGCPHandler(); + server = http.createServer(async (req, res) => { + try { + if (await handleObjectRequest(req, res, objectRoot)) return; + const raw = await readBody(req); + if (runtime === 'aws') await handleAWS(handler, req, res, raw); + else await handleGCP(handler, req, res, raw); + } catch (err) { + res.writeHead(err.statusCode || err.status || 500, {'content-type': 'application/json'}); + res.end(JSON.stringify({error: err.message || String(err)})); + } + }); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + const address = server.address(); + process.env.BROKER_TEST_BASE_URL = `http://127.0.0.1:${address.port}`; + console.log(`bgit test broker ${runtime} listening on ${process.env.BROKER_TEST_BASE_URL}`); +} + +process.on('SIGTERM', () => server && server.close(() => process.exit(0))); +process.on('SIGINT', () => server && server.close(() => process.exit(130))); + +main().catch((err) => { + console.error(err && err.stack || err); + process.exit(1); +}); + diff --git a/broker_assets.go b/broker_assets.go new file mode 100644 index 0000000..cbf8a94 --- /dev/null +++ b/broker_assets.go @@ -0,0 +1,33 @@ +package main + +import ( + "embed" + "os" + "path/filepath" + "strings" +) + +//go:embed broker/gcp/package.json broker/gcp/index.js broker/aws/template.yaml +var brokerAssets embed.FS + +func writeGCPBrokerSource(dir string) error { + for _, name := range []string{"package.json", "index.js"} { + data, err := brokerAssets.ReadFile("broker/gcp/" + name) + if err != nil { + return err + } + body := strings.ReplaceAll(string(data), "{{BROKER_VERSION}}", brokerVersion) + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + return err + } + } + return nil +} + +func awsBrokerCloudFormationTemplate() string { + data, err := brokerAssets.ReadFile("broker/aws/template.yaml") + if err != nil { + return "" + } + return strings.ReplaceAll(string(data), "{{BROKER_VERSION}}", brokerVersion) +} diff --git a/broker_commands.go b/broker_commands.go new file mode 100644 index 0000000..417ccea --- /dev/null +++ b/broker_commands.go @@ -0,0 +1,2084 @@ +package main + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +type brokerProfile struct { + Provider string + Name string + Region string + QualifiedName string + BrokerURL string +} + +func brokerAdminCommand(cfg config, args []string, stdout io.Writer) error { + return brokerAdminCommandWithInput(cfg, args, os.Stdin, stdout) +} + +func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit admin keys|owner|protect|members|confirm-ownership-transfer|accept-ownership-transfer|cancel-ownership-transfer|invite-user|accept-invite|cancel-invite [args]\n\nCloud IAM administration moved to bgit direct admin.") + } + switch args[0] { + case "keys": + return brokerAdminKeysCommand(cfg, args[1:], stdin, stdout) + case "repo": + return brokerAdminRepoCommand(cfg, args[1:], stdout) + case "owner": + return brokerOwnerCommand(cfg, args[1:], stdout) + case "protect": + return brokerProtectionCommand(cfg, args[1:], stdout) + case "members": + return brokerMembersCommand(cfg, args[1:], stdout) + case "confirm-ownership-transfer", "accept-ownership-transfer", "cancel-ownership-transfer": + return brokerOwnerCommand(cfg, args, stdout) + case "invite-user": + return brokerInviteUserCommand(cfg, args[1:], stdout) + case "accept-invite": + return brokerAcceptInviteCommand(args[1:], stdout) + case "cancel-invite": + return brokerCancelInviteCommand(cfg, args[1:], stdout) + case "grant-read", "grant-write", "grant-admin", "make-public", "make-private": + return errors.New("cloud IAM administration moved to bgit direct admin") + default: + return fmt.Errorf("unknown admin command %q", args[0]) + } +} + +type brokerRepoAdminRequest struct { + Repo brokerRepo `json:"repo"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + Visibility string `json:"visibility,omitempty"` + ReadOnly *bool `json:"read_only,omitempty"` + IssuesEnabled *bool `json:"issues_enabled,omitempty"` + Logical string `json:"logical,omitempty"` +} + +func brokerAdminRepoCommand(cfg config, args []string, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit admin repo visibility|readonly|issues|rename|delete [args]") + } + cfg, err := configForBrokerCommand(cfg) + if err != nil { + return err + } + switch args[0] { + case "visibility": + if len(args) != 2 || (args[1] != "public" && args[1] != "private") { + return errors.New("usage: bgit admin repo visibility public|private") + } + req := brokerRepoAdminRequest{Repo: repoForBroker(cfg), Visibility: args[1]} + if err := brokerPost(cfg.brokerURL, "/repo/update", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "set repository visibility to %s\n", args[1]) + return nil + case "readonly": + if len(args) != 2 || (args[1] != "on" && args[1] != "off") { + return errors.New("usage: bgit admin repo readonly on|off") + } + readOnly := args[1] == "on" + req := brokerRepoAdminRequest{Repo: repoForBroker(cfg), ReadOnly: &readOnly} + if err := brokerPost(cfg.brokerURL, "/repo/update", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "set repository read-only to %t\n", readOnly) + return nil + case "issues": + if len(args) != 2 || (args[1] != "on" && args[1] != "off") { + return errors.New("usage: bgit admin repo issues on|off") + } + issuesEnabled := args[1] == "on" + req := brokerRepoAdminRequest{Repo: repoForBroker(cfg), IssuesEnabled: &issuesEnabled} + if err := brokerPost(cfg.brokerURL, "/repo/update", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "set repository issues to %t\n", issuesEnabled) + return nil + case "rename": + if len(args) != 2 { + return errors.New("usage: bgit admin repo rename NEW_LOGICAL_NAME") + } + logical := logicalRepoWithGit(args[1]) + if err := brokerPost(cfg.brokerURL, "/repo/rename", brokerRepoAdminRequest{Repo: repoForBroker(cfg), Logical: logical}, nil); err != nil { + return err + } + _, _ = runGit(".", "config", "--local", "bucketgit.logicalRepo", logical) + _, _ = runGit(".", "remote", "set-url", "origin", "git@"+defaultSSHHost+":"+logical) + fmt.Fprintf(stdout, "renamed repository to %s\n", logicalRepoDisplayName(logical)) + return nil + case "delete": + if len(args) != 2 || args[1] != "--yes" { + return errors.New("usage: bgit admin repo delete --yes") + } + if err := brokerPost(cfg.brokerURL, "/repo/delete", brokerRepoAdminRequest{Repo: repoForBroker(cfg)}, nil); err != nil { + return err + } + fmt.Fprintln(stdout, "deleted repository") + return nil + default: + return fmt.Errorf("unknown repo admin command %q", args[0]) + } +} + +func brokerMembersCommand(cfg config, args []string, stdout io.Writer) error { + if len(args) != 1 || args[0] != "reindex" { + return errors.New("usage: bgit admin members reindex") + } + return janitorMembersReindex(cfg, stdout) +} + +func janitorCommand(cfg config, args []string, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit janitor members reindex") + } + switch args[0] { + case "members": + if len(args) == 2 && args[1] == "reindex" { + return janitorMembersReindex(cfg, stdout) + } + return errors.New("usage: bgit janitor members reindex") + default: + return fmt.Errorf("unknown janitor command %q", args[0]) + } +} + +func janitorMembersReindex(cfg config, stdout io.Writer) error { + brokerURL := strings.TrimSpace(cfg.brokerURL) + if brokerURL == "" { + var err error + brokerURL, err = brokerURLForCommand(sshSetupOptions{}) + if err != nil { + return err + } + } + if err := brokerPost(brokerURL, "/members/reindex", brokerKeyRequest{Repo: repoForBroker(cfg)}, nil); err != nil { + return err + } + fmt.Fprintln(stdout, "reindexed broker membership") + return nil +} + +func brokerInitCommand(args []string, stdin io.Reader, stdout io.Writer) error { + opts, repoName, err := parseBrokerInitArgs(args) + if err != nil { + return err + } + if !opts.noninteractive { + opts.interactive = true + } + if opts.noninteractive { + if strings.TrimSpace(opts.profile) == "" { + return errors.New("init --noninteractive requires --profile PROFILE") + } + if strings.TrimSpace(repoName) == "" { + return errors.New("init --noninteractive requires --repo NAME") + } + } + global, path, err := loadGlobalConfigForInit(opts.configPath) + if err != nil { + return err + } + profiles := brokerProfilesFromGlobalConfig(global) + if len(profiles) == 0 && opts.interactive { + fmt.Fprint(stdout, "No broker profiles found. Run bgit setup now? [y/N] ") + answer, _ := bufio.NewReader(stdin).ReadString('\n') + if strings.EqualFold(strings.TrimSpace(answer), "y") || strings.EqualFold(strings.TrimSpace(answer), "yes") { + cmd := exec.Command(os.Args[0], "setup", "--config", path) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("run bgit setup: %w", err) + } + global, path, err = loadGlobalConfigForInit(opts.configPath) + if err != nil { + return err + } + profiles = brokerProfilesFromGlobalConfig(global) + } + } + if len(profiles) == 0 { + return errors.New("no broker profiles configured; run bgit setup first") + } + target := "." + if opts.directory != "" { + target = opts.directory + } + identityName := "" + identityEmail := "" + if opts.interactive { + initial := initDialogInitialState(target, global, repoName, opts.profile) + identityName = initial.IdentityName + identityEmail = initial.IdentityEmail + result, err := brokerInitPrompt(stdin, stdout, initial, profiles) + if err != nil { + return err + } + if !result.Changed { + fmt.Fprintln(stdout, "No changes made to the repository configuration.") + return nil + } + if result.IdentityChanged && !result.RepoChanged && !result.ProfileChanged { + if err := writeLocalIdentityConfig(target, result.IdentityName, result.IdentityEmail); err != nil { + return err + } + fmt.Fprintln(stdout, "Updated repository identity.") + return nil + } + repoName = result.RepoName + opts.profile = result.ProfileName + identityName = result.IdentityName + identityEmail = result.IdentityEmail + } + if strings.TrimSpace(repoName) == "" { + wd, err := os.Getwd() + if err != nil { + return err + } + repoName = filepath.Base(wd) + ".git" + } + profile, err := selectBrokerProfileForCommand(profiles, opts.profile, opts.region, "bgit init") + if err != nil { + return err + } + if identityName == "" && identityEmail == "" { + identity := initDialogInitialState(target, global, repoName, opts.profile) + identityName = identity.IdentityName + identityEmail = identity.IdentityEmail + } + return initBrokerWorktree(target, repoName, profile, identityName, identityEmail, stdout) +} + +func brokerCloneCommand(args []string, stdin io.Reader, stdout io.Writer) error { + opts, repoName, err := parseBrokerInitArgs(args) + if err != nil { + return err + } + if opts.brokerURL == "" { + brokerURL, parsedRepo, ok, err := parseBrokerCloneURL(repoName) + if err != nil { + return err + } + if ok { + opts.brokerURL = brokerURL + repoName = parsedRepo + } + } + if strings.TrimSpace(repoName) == "" { + return errors.New("usage: bgit clone [directory] [--profile PROFILE]\n bgit clone https://broker.example.com/team/app.git [directory]\n bgit clone --broker https://broker.example.com team/app.git [directory]") + } + if opts.brokerURL != "" { + profile, err := brokerProfileForCloneURL(opts.brokerURL) + if err != nil { + return err + } + return brokerCloneWithProfile(opts, repoName, profile, stdout) + } + global, _, err := loadGlobalConfigForInit(opts.configPath) + if err != nil { + return err + } + profiles := brokerProfilesFromGlobalConfig(global) + if len(profiles) == 0 { + return errors.New("no broker profiles configured; run bgit setup first") + } + if opts.interactive { + result, err := brokerInitPrompt(stdin, stdout, initDialogInitialState(".", global, repoName, opts.profile), profiles) + if err != nil { + return err + } + repoName = result.RepoName + opts.profile = result.ProfileName + } + profile, err := selectBrokerProfileForCommand(profiles, opts.profile, opts.region, "bgit clone "+repoName) + if err != nil { + return err + } + return brokerCloneWithProfile(opts, repoName, profile, stdout) +} + +func brokerCloneWithProfile(opts brokerInitOptions, repoName string, profile brokerProfile, stdout io.Writer) error { + target := opts.directory + if target == "" { + target = strings.TrimSuffix(filepath.Base(strings.Trim(repoName, "/")), ".git") + } + if err := initBrokerWorktree(target, repoName, profile, "", "", io.Discard); err != nil { + return err + } + if _, err := runGit(target, "fetch", "origin"); err != nil { + return err + } + if _, err := runGit(target, "checkout", "--quiet", "-B", defaultBranch, "origin/"+defaultBranch); err != nil { + _, _ = runGit(target, "checkout", "--quiet", "-B", defaultBranch) + } + fmt.Fprintf(stdout, "Cloned %s into '%s'\n", repoName, target) + return nil +} + +func brokerAdminKeysCommand(cfg config, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit admin keys list|add|remove|suspend|import-github [args]") + } + if args[0] != "import-github" { + return sshKeysCommand(cfg, args, stdout) + } + opts, err := parseImportGitHubKeysArgs(args[1:]) + if err != nil { + return err + } + cfg, err = configForBrokerCommand(cfg) + if err != nil { + return err + } + brokerURL, err := brokerURLForCommand(sshSetupOptions{broker: opts.broker}) + if err != nil { + return err + } + keys, err := fetchGitHubPublicKeys(context.Background(), opts.username) + if err != nil { + return err + } + if len(keys) == 0 { + return fmt.Errorf("github user %s has no public SSH keys", opts.username) + } + if !opts.yes { + fmt.Fprintf(stdout, "Import %d key(s) from github:%s as %s? [y/N] ", len(keys), opts.username, opts.role) + answer, _ := bufio.NewReader(stdin).ReadString('\n') + if !strings.EqualFold(strings.TrimSpace(answer), "y") && !strings.EqualFold(strings.TrimSpace(answer), "yes") { + return errors.New("import cancelled") + } + } + if err := brokerAddKeysWithSource(brokerURL, cfg, opts.username, opts.role, "github:"+opts.username, keys); err != nil { + return err + } + fmt.Fprintf(stdout, "imported %d key(s) from github:%s with role %s\n", len(keys), opts.username, opts.role) + return nil +} + +type importGitHubKeysOptions struct { + username string + role string + broker string + yes bool +} + +func parseImportGitHubKeysArgs(args []string) (importGitHubKeysOptions, error) { + opts := importGitHubKeysOptions{role: "read"} + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--role": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.role = normalizeBrokerRole(value) + case "--broker": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.broker = value + case "--yes", "-y": + opts.yes = true + default: + if strings.HasPrefix(arg, "-") { + return opts, fmt.Errorf("unsupported import-github option %s", arg) + } + if opts.username != "" { + return opts, errors.New("import-github accepts exactly one username") + } + opts.username = strings.TrimPrefix(strings.TrimSpace(arg), "@") + } + } + if opts.username == "" { + return opts, errors.New("usage: bgit admin keys import-github [--role ROLE] [--yes]") + } + if !validBrokerRole(opts.role) || opts.role == "owner" { + return opts, fmt.Errorf("invalid import role %q", opts.role) + } + return opts, nil +} + +func fetchGitHubPublicKeys(ctx context.Context, username string) ([]string, error) { + endpoint := "https://github.com/" + username + ".keys" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("fetch %s: %s", endpoint, resp.Status) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return splitPublicKeyLines(string(data)), nil +} + +func configForBrokerCommand(base config) (config, error) { + cfg := base + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + } + if strings.TrimSpace(cfg.brokerURL) == "" { + if out, err := runGit(".", "config", "--get", "bucketgit.broker"); err == nil { + cfg.brokerURL = strings.TrimSpace(string(out)) + } + } + if strings.TrimSpace(cfg.brokerURL) == "" { + return config{}, errors.New("broker URL is required; run bgit setup/init first") + } + if strings.TrimSpace(cfg.logicalRepo) == "" { + if out, err := runGit(".", "config", "--get", "bucketgit.logicalRepo"); err == nil { + cfg.logicalRepo = strings.Trim(strings.TrimSpace(string(out)), "/") + } + } + if cfg.origin == "" { + cfg.origin = originForConfig(cfg) + } + return cfg, nil +} + +type brokerOwnerTransferRequest struct { + Repo brokerRepo `json:"repo"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + BrokerURL string `json:"broker_url,omitempty"` + Token string `json:"token,omitempty"` +} + +type brokerOwnerTransferResponse struct { + Code string `json:"code"` + AcceptCommand string `json:"accept_command"` + CancelCommand string `json:"cancel_command"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +type ownerTransferCodePayload struct { + BrokerURL string `json:"broker_url"` + Repo brokerRepo `json:"repo"` + Token string `json:"token"` +} + +func brokerOwnerCommand(cfg config, args []string, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit admin confirm-ownership-transfer --broker URL REPO\n bgit admin accept-ownership-transfer CODE\n bgit admin cancel-ownership-transfer [--broker URL REPO]") + } + switch args[0] { + case "transfer": + return errors.New("bgit admin owner transfer was replaced by bgit admin confirm-ownership-transfer") + case "confirm-ownership-transfer": + return brokerConfirmOwnershipTransferCommand(cfg, args[1:], stdout) + case "accept-ownership-transfer": + return brokerAcceptOwnershipTransferCommand(args[1:], stdout) + case "cancel-ownership-transfer": + return brokerCancelOwnershipTransferCommand(cfg, args[1:], stdout) + default: + return fmt.Errorf("unknown owner command %q", args[0]) + } +} + +func brokerConfirmOwnershipTransferCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL, repoName, err := parseOwnershipTransferTarget(cfg, args, true) + if err != nil { + return err + } + repo := brokerRepo{Provider: "gcs", Logical: logicalRepoWithGit(repoName), Origin: "git@" + defaultSSHHost + ":" + logicalRepoWithGit(repoName)} + var resp brokerOwnerTransferResponse + if err := brokerPost(brokerURL, "/owners/transfer/confirm", brokerOwnerTransferRequest{Repo: repo, BrokerURL: brokerURL}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "ownership transfer pending for %s\n\nGive this command to the new owner:\n %s\n\nCancel with:\n %s\n", repo.Logical, resp.AcceptCommand, resp.CancelCommand) + return nil +} + +func brokerAcceptOwnershipTransferCommand(args []string, stdout io.Writer) error { + if len(args) != 1 { + return errors.New("usage: bgit admin accept-ownership-transfer CODE") + } + payload, err := parseOwnershipTransferCode(args[0]) + if err != nil { + return err + } + var resp brokerOwnerTransferResponse + if err := brokerPost(payload.BrokerURL, "/owners/transfer/accept", brokerOwnerTransferRequest{Repo: payload.Repo, Token: payload.Token, User: "owner"}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "accepted ownership for %s with key %s\n", payload.Repo.Logical, resp.Fingerprint) + return nil +} + +func brokerCancelOwnershipTransferCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL, repoName, err := parseOwnershipTransferTarget(cfg, args, false) + if err != nil { + return err + } + repo := brokerRepo{Provider: "gcs", Logical: logicalRepoWithGit(repoName), Origin: "git@" + defaultSSHHost + ":" + logicalRepoWithGit(repoName)} + if err := brokerPost(brokerURL, "/owners/transfer/cancel", brokerOwnerTransferRequest{Repo: repo}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "cancelled pending ownership transfer for %s\n", repo.Logical) + return nil +} + +func parseOwnershipTransferTarget(cfg config, args []string, requireBroker bool) (string, string, error) { + brokerURL := "" + repoName := "" + var err error + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--broker": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return "", "", err + } + brokerURL = strings.TrimSpace(value) + default: + if strings.HasPrefix(arg, "-") { + return "", "", fmt.Errorf("unsupported ownership transfer option %s", arg) + } + if repoName != "" { + return "", "", errors.New("ownership transfer accepts exactly one repository") + } + repoName = strings.TrimSpace(arg) + } + } + if brokerURL == "" && !requireBroker { + if local, err := configForBrokerCommand(cfg); err == nil { + brokerURL = local.brokerURL + if repoName == "" { + repoName = local.logicalRepo + } + } + } + if brokerURL == "" { + return "", "", errors.New("ownership transfer requires --broker URL") + } + if repoName == "" { + return "", "", errors.New("ownership transfer requires a repository name") + } + return brokerURL, repoName, nil +} + +func parseOwnershipTransferCode(code string) (ownerTransferCodePayload, error) { + code = strings.TrimSpace(code) + if !strings.HasPrefix(code, "bgitot_") { + return ownerTransferCodePayload{}, errors.New("invalid ownership transfer code") + } + raw := strings.TrimPrefix(code, "bgitot_") + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return ownerTransferCodePayload{}, errors.New("invalid ownership transfer code") + } + var payload ownerTransferCodePayload + if err := json.Unmarshal(data, &payload); err != nil { + return ownerTransferCodePayload{}, errors.New("invalid ownership transfer code") + } + if strings.TrimSpace(payload.BrokerURL) == "" || strings.TrimSpace(payload.Token) == "" || strings.TrimSpace(payload.Repo.Logical) == "" { + return ownerTransferCodePayload{}, errors.New("invalid ownership transfer code") + } + return payload, nil +} + +func brokerInviteUserCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL := "" + repoName := "" + user := "" + role := "read" + var err error + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--broker": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + brokerURL = strings.TrimSpace(value) + case "--user": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + user = strings.TrimSpace(value) + case "--role": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + role = normalizeBrokerRole(value) + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported invite-user option %s", arg) + } + if repoName != "" { + return errors.New("invite-user accepts exactly one repository") + } + repoName = strings.TrimSpace(arg) + } + } + if brokerURL == "" || repoName == "" || user == "" { + return errors.New("usage: bgit admin invite-user --broker URL --user USER [--role ROLE] REPO") + } + if !validBrokerRole(role) || role == "owner" { + return fmt.Errorf("invalid role %q", role) + } + repo := brokerRepo{Provider: "gcs", Logical: logicalRepoWithGit(repoName), Origin: "git@" + defaultSSHHost + ":" + logicalRepoWithGit(repoName)} + var resp brokerOwnerTransferResponse + if err := brokerPost(brokerURL, "/keys/invite/create", brokerOwnerTransferRequest{Repo: repo, BrokerURL: brokerURL, User: user, Role: role}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "invite pending for %s as %s on %s\n\nGive this command to the user:\n %s\n", user, role, repo.Logical, resp.AcceptCommand) + return nil +} + +func brokerAcceptInviteCommand(args []string, stdout io.Writer) error { + if len(args) != 1 { + return errors.New("usage: bgit admin accept-invite CODE") + } + payload, err := parseInviteCode(args[0]) + if err != nil { + return err + } + var resp brokerOwnerTransferResponse + if err := brokerPost(payload.BrokerURL, "/keys/invite/accept", brokerOwnerTransferRequest{Repo: payload.Repo, Token: payload.Token}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "accepted invite for %s as %s with key %s\n", resp.User, resp.Role, resp.Fingerprint) + return nil +} + +func brokerCancelInviteCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL, repoName, user, err := parseCancelInviteTarget(cfg, args) + if err != nil { + return err + } + repo := brokerRepo{Provider: "gcs", Logical: logicalRepoWithGit(repoName), Origin: "git@" + defaultSSHHost + ":" + logicalRepoWithGit(repoName)} + if err := brokerPost(brokerURL, "/keys/invite/cancel", brokerOwnerTransferRequest{Repo: repo, User: user}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "cancelled invite for %s on %s\n", user, repo.Logical) + return nil +} + +func parseCancelInviteTarget(cfg config, args []string) (string, string, string, error) { + brokerURL := "" + repoName := "" + user := "" + var err error + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--broker": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return "", "", "", err + } + brokerURL = strings.TrimSpace(value) + case "--user": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return "", "", "", err + } + user = strings.TrimSpace(value) + default: + if strings.HasPrefix(arg, "-") { + return "", "", "", fmt.Errorf("unsupported cancel-invite option %s", arg) + } + if repoName != "" { + return "", "", "", errors.New("cancel-invite accepts exactly one repository") + } + repoName = strings.TrimSpace(arg) + } + } + if brokerURL == "" || repoName == "" { + if local, err := configForBrokerCommand(cfg); err == nil { + if brokerURL == "" { + brokerURL = local.brokerURL + } + if repoName == "" { + repoName = local.logicalRepo + } + } + } + if brokerURL == "" || repoName == "" || user == "" { + return "", "", "", errors.New("usage: bgit admin cancel-invite --broker URL --user USER REPO") + } + return brokerURL, repoName, user, nil +} + +func parseInviteCode(code string) (ownerTransferCodePayload, error) { + code = strings.TrimSpace(code) + if !strings.HasPrefix(code, "bgitinv_") { + return ownerTransferCodePayload{}, errors.New("invalid invite code") + } + raw := strings.TrimPrefix(code, "bgitinv_") + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return ownerTransferCodePayload{}, errors.New("invalid invite code") + } + var payload ownerTransferCodePayload + if err := json.Unmarshal(data, &payload); err != nil { + return ownerTransferCodePayload{}, errors.New("invalid invite code") + } + if strings.TrimSpace(payload.BrokerURL) == "" || strings.TrimSpace(payload.Token) == "" || strings.TrimSpace(payload.Repo.Logical) == "" { + return ownerTransferCodePayload{}, errors.New("invalid invite code") + } + return payload, nil +} + +type brokerProtectionRequest struct { + Repo brokerRepo `json:"repo"` + Ref string `json:"ref"` + RequirePR bool `json:"require_pr"` + AllowOverrides bool `json:"allow_overrides"` +} + +func brokerProtectionCommand(cfg config, args []string, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit admin protect add|list|remove [ref]") + } + cfg, err := configForBrokerCommand(cfg) + if err != nil { + return err + } + switch args[0] { + case "list": + var resp struct { + Protections []brokerProtectionRequest `json:"protections"` + } + if err := brokerPost(cfg.brokerURL, "/protection/list", brokerProtectionRequest{Repo: repoForBroker(cfg)}, &resp); err != nil { + return err + } + for _, protection := range resp.Protections { + mode := "pr-required" + if protection.AllowOverrides { + mode += ",owner-admin-override" + } + fmt.Fprintf(stdout, "%s\t%s\n", protection.Ref, mode) + } + return nil + case "add": + ref := "refs/heads/main" + allowOverrides := false + for _, arg := range args[1:] { + switch arg { + case "--allow-owner-admin-override": + allowOverrides = true + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported protect option %s", arg) + } + ref = normalizeDestinationRef(arg) + } + } + req := brokerProtectionRequest{Repo: repoForBroker(cfg), Ref: ref, RequirePR: true, AllowOverrides: allowOverrides} + if err := brokerPost(cfg.brokerURL, "/protection/upsert", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "protected %s\n", ref) + return nil + case "remove": + if len(args) != 2 { + return errors.New("usage: bgit admin protect remove ") + } + req := brokerProtectionRequest{Repo: repoForBroker(cfg), Ref: normalizeDestinationRef(args[1])} + if err := brokerPost(cfg.brokerURL, "/protection/remove", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "removed protection for %s\n", req.Ref) + return nil + default: + return fmt.Errorf("unknown protect command %q", args[0]) + } +} + +type brokerPullRequest struct { + ID int `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Source string `json:"source,omitempty"` + Target string `json:"target,omitempty"` + Status string `json:"status,omitempty"` + Author string `json:"author,omitempty"` + Version string `json:"version,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Approvals int `json:"approvals,omitempty"` + Checks []string `json:"checks,omitempty"` + Head string `json:"head,omitempty"` + Comments []brokerPullRequestNote `json:"comments,omitempty"` + Reviews []brokerPullRequestNote `json:"reviews,omitempty"` + MergedBy string `json:"merged_by,omitempty"` + MergedAt string `json:"merged_at,omitempty"` + ClosedBy string `json:"closed_by,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` +} + +type brokerPullRequestNote struct { + ID int `json:"id,omitempty"` + User string `json:"user,omitempty"` + Body string `json:"body,omitempty"` + State string `json:"state,omitempty"` + Source string `json:"source,omitempty"` + At string `json:"at,omitempty"` + Comments []brokerPullRequestComment `json:"comments,omitempty"` + Replies []brokerPullRequestComment `json:"replies,omitempty"` + Head string `json:"head,omitempty"` +} + +type brokerPullRequestComment struct { + ID int `json:"id,omitempty"` + User string `json:"user,omitempty"` + Body string `json:"body,omitempty"` + File string `json:"file,omitempty"` + Kind string `json:"kind,omitempty"` + Side string `json:"side,omitempty"` + Hunk string `json:"hunk,omitempty"` + HunkIndex int `json:"hunk_index,omitempty"` + OldStart int `json:"old_start,omitempty"` + NewStart int `json:"new_start,omitempty"` + Offset int `json:"offset,omitempty"` + Line int `json:"line,omitempty"` + LineText string `json:"line_text,omitempty"` + LineHash string `json:"line_hash,omitempty"` + Head string `json:"head,omitempty"` + Outdated bool `json:"outdated,omitempty"` + At string `json:"at,omitempty"` + Replies []brokerPullRequestComment `json:"replies,omitempty"` +} + +type brokerPullRequestRequest struct { + Repo brokerRepo `json:"repo"` + ID int `json:"id,omitempty"` + PR brokerPullRequest `json:"pr,omitempty"` + Known map[string]string `json:"known,omitempty"` + Merge bool `json:"merge,omitempty"` + DeleteBranch bool `json:"delete_branch,omitempty"` + Comment string `json:"comment,omitempty"` + Review string `json:"review,omitempty"` + Comments []brokerPullRequestComment `json:"comments,omitempty"` + TargetNoteID int `json:"target_note_id,omitempty"` + TargetCommentID int `json:"target_comment_id,omitempty"` +} + +func issueCommand(args []string, stdin io.Reader, stdout io.Writer) error { + _ = stdin + if len(args) == 0 { + return errors.New("usage: bgit issue list|create|view|comment|close|reopen [args]") + } + cfg, err := configForBrokerCommand(config{}) + if err != nil { + return err + } + switch args[0] { + case "list": + var resp struct { + Issues []brokerIssue `json:"issues"` + } + if err := brokerPost(cfg.brokerURL, "/issues/list", brokerIssueRequest{Repo: repoForBroker(cfg)}, &resp); err != nil { + return err + } + for _, issue := range resp.Issues { + fmt.Fprintf(stdout, "#%d\t%s\t%s\n", issue.ID, firstNonEmpty(issue.Status, "open"), issue.Title) + } + return nil + case "create": + title := "" + body := "" + for i := 1; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--body": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + body = value + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported issue create option %s", arg) + } + if title != "" { + title += " " + } + title += arg + } + } + if strings.TrimSpace(title) == "" { + return errors.New("usage: bgit issue create TITLE [--body BODY]") + } + var resp struct { + Issue brokerIssue `json:"issue"` + } + if err := brokerPost(cfg.brokerURL, "/issues/create", brokerIssueRequest{Repo: repoForBroker(cfg), Title: title, Body: body}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "created issue #%d\n", resp.Issue.ID) + return nil + case "view": + id, err := parseIssueIDArg(args) + if err != nil { + return err + } + var resp struct { + Issue brokerIssue `json:"issue"` + } + if err := brokerPost(cfg.brokerURL, "/issues/view", brokerIssueRequest{Repo: repoForBroker(cfg), ID: id}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "#%d %s\n%s\n\n%s\n", resp.Issue.ID, resp.Issue.Title, firstNonEmpty(resp.Issue.Status, "open"), resp.Issue.Body) + for _, comment := range resp.Issue.Comments { + fmt.Fprintf(stdout, "\n%s commented:\n%s\n", firstNonEmpty(comment.User, "anonymous"), comment.Body) + } + return nil + case "comment": + if len(args) < 3 { + return errors.New("usage: bgit issue comment ID COMMENT") + } + id, err := strconv.Atoi(strings.TrimPrefix(args[1], "#")) + if err != nil || id <= 0 { + return errors.New("issue id is required") + } + comment := strings.Join(args[2:], " ") + if err := brokerPost(cfg.brokerURL, "/issues/comment", brokerIssueRequest{Repo: repoForBroker(cfg), ID: id, Comment: comment}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "commented on issue #%d\n", id) + return nil + case "close", "reopen": + id, err := parseIssueIDArg(args) + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/issues/"+args[0], brokerIssueRequest{Repo: repoForBroker(cfg), ID: id}, nil); err != nil { + return err + } + verb := "closed" + if args[0] == "reopen" { + verb = "reopened" + } + fmt.Fprintf(stdout, "%s issue #%d\n", verb, id) + return nil + default: + return fmt.Errorf("unknown issue command %q", args[0]) + } +} + +func parseIssueIDArg(args []string) (int, error) { + if len(args) != 2 { + return 0, errors.New("issue id is required") + } + id, err := strconv.Atoi(strings.TrimPrefix(args[1], "#")) + if err != nil || id <= 0 { + return 0, errors.New("issue id is required") + } + return id, nil +} + +func prCommand(args []string, stdin io.Reader, stdout io.Writer) error { + _ = stdin + if len(args) == 0 { + return errors.New("usage: bgit pr create|list|view|checkout|diff|merge|close|reopen|comment|approve|reject [args]") + } + cfg, err := configForBrokerCommand(config{}) + if err != nil { + return err + } + switch args[0] { + case "create": + return prCreateCommand(cfg, args[1:], stdout) + case "list": + var resp struct { + PRs []brokerPullRequest `json:"prs"` + } + if err := brokerPost(cfg.brokerURL, "/prs/list", brokerPullRequestRequest{Repo: repoForBroker(cfg)}, &resp); err != nil { + return err + } + for _, pr := range resp.PRs { + fmt.Fprintf(stdout, "#%d\t%s\t%s -> %s\t%s\n", pr.ID, pr.Status, pr.Source, pr.Target, pr.Title) + } + return nil + case "view": + pr, err := brokerGetPullRequest(cfg, args[1:]) + if err != nil { + return err + } + fmt.Fprintf(stdout, "#%d %s\nstatus: %s\nsource: %s\ntarget: %s\napprovals: %d\n", pr.ID, pr.Title, pr.Status, pr.Source, pr.Target, pr.Approvals) + if strings.TrimSpace(pr.Body) != "" { + fmt.Fprintf(stdout, "\n%s\n", pr.Body) + } + return nil + case "close": + id, err := parsePRIDArg(args[1:]) + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/prs/close", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "closed PR #%d\n", id) + return nil + case "reopen": + id, err := parsePRIDArg(args[1:]) + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/prs/reopen", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "reopened PR #%d\n", id) + return nil + case "merge": + deleteBranch := false + var idArgs []string + for _, arg := range args[1:] { + switch arg { + case "--delete-branch": + deleteBranch = true + default: + idArgs = append(idArgs, arg) + } + } + id, err := parsePRIDArg(idArgs) + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/prs/merge", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id, Merge: true, DeleteBranch: deleteBranch}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "merged PR #%d\n", id) + return nil + case "comment": + id, comment, err := parsePRIDAndTextArg(args[1:], "usage: bgit pr comment ID COMMENT") + if err != nil { + return err + } + if err := brokerPost(cfg.brokerURL, "/prs/comment", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id, Comment: comment}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "commented on PR #%d\n", id) + return nil + case "approve", "reject": + id, comment, err := parsePRIDAndOptionalTextArg(args[1:]) + if err != nil { + return err + } + review := "approved" + verb := "approved" + if args[0] == "reject" { + review = "changes_requested" + verb = "requested changes on" + } + if err := brokerPost(cfg.brokerURL, "/prs/review", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id, Review: review, Comment: comment}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "%s PR #%d\n", verb, id) + return nil + case "checkout": + pr, err := brokerGetPullRequest(cfg, args[1:]) + if err != nil { + return err + } + if _, err := runGit(".", "fetch", "origin", pr.Source+":"+pr.Source); err != nil { + return err + } + _, err = runGit(".", "checkout", shortRefName(pr.Source)) + return err + case "diff": + pr, err := brokerGetPullRequest(cfg, args[1:]) + if err != nil { + return err + } + source := shortRefName(pr.Source) + target := shortRefName(pr.Target) + if _, err := runGit(".", "fetch", "origin", pr.Source+":refs/remotes/origin/"+source, pr.Target+":refs/remotes/origin/"+target); err != nil { + return err + } + out, err := runGit(".", "diff", "refs/remotes/origin/"+target+"..."+"refs/remotes/origin/"+source) + if err != nil { + return err + } + _, err = stdout.Write(out) + return err + default: + return fmt.Errorf("unknown pr command %q", args[0]) + } +} + +func parsePRIDAndTextArg(args []string, usage string) (int, string, error) { + if len(args) < 2 { + return 0, "", errors.New(usage) + } + id, err := strconv.Atoi(args[0]) + if err != nil || id <= 0 { + return 0, "", errors.New("pull request id is required") + } + text := strings.TrimSpace(strings.Join(args[1:], " ")) + if text == "" { + return 0, "", errors.New(usage) + } + return id, text, nil +} + +func parsePRIDAndOptionalTextArg(args []string) (int, string, error) { + if len(args) < 1 { + return 0, "", errors.New("pull request id is required") + } + id, err := strconv.Atoi(args[0]) + if err != nil || id <= 0 { + return 0, "", errors.New("pull request id is required") + } + return id, strings.TrimSpace(strings.Join(args[1:], " ")), nil +} + +func prCreateCommand(cfg config, args []string, stdout io.Writer) error { + pr := brokerPullRequest{Target: "refs/heads/main"} + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--title": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + pr.Title = value + case "--body": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + pr.Body = value + case "--source": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + pr.Source = normalizeDestinationRef(value) + case "--target": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + pr.Target = normalizeDestinationRef(value) + default: + return fmt.Errorf("unsupported pr create option %s", arg) + } + } + if pr.Source == "" { + out, err := runGit(".", "branch", "--show-current") + if err != nil { + return err + } + pr.Source = branchRef(strings.TrimSpace(string(out))) + } + if pr.Title == "" { + pr.Title = shortRefName(pr.Source) + " into " + shortRefName(pr.Target) + } + var resp struct { + PR brokerPullRequest `json:"pr"` + } + if err := brokerPost(cfg.brokerURL, "/prs/create", brokerPullRequestRequest{Repo: repoForBroker(cfg), PR: pr}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "created PR #%d %s\n", resp.PR.ID, resp.PR.Title) + return nil +} + +func brokerGetPullRequest(cfg config, args []string) (brokerPullRequest, error) { + id, err := parsePRIDArg(args) + if err != nil { + return brokerPullRequest{}, err + } + var resp struct { + PR brokerPullRequest `json:"pr"` + } + if err := brokerPost(cfg.brokerURL, "/prs/view", brokerPullRequestRequest{Repo: repoForBroker(cfg), ID: id}, &resp); err != nil { + return brokerPullRequest{}, err + } + return resp.PR, nil +} + +func parsePRIDArg(args []string) (int, error) { + if len(args) != 1 { + return 0, errors.New("PR command requires exactly one PR id") + } + id := parsePositiveInt(strings.TrimPrefix(args[0], "#")) + if id <= 0 { + return 0, fmt.Errorf("invalid PR id %q", args[0]) + } + return id, nil +} + +type brokerInitOptions struct { + interactive bool + noninteractive bool + profile string + region string + repo string + brokerURL string + configPath string + directory string +} + +func parseBrokerInitArgs(args []string) (brokerInitOptions, string, error) { + var opts brokerInitOptions + var rest []string + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--interactive": + opts.interactive = true + case "--noninteractive": + opts.noninteractive = true + case "--profile": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.profile = value + case "--region": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.region = value + case "--repo": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.repo = value + case "--broker": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.brokerURL = value + case "--config": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.configPath = expandHome(value) + default: + if strings.HasPrefix(arg, "-") { + return opts, "", fmt.Errorf("unsupported init option %s", arg) + } + rest = append(rest, arg) + } + } + if opts.interactive && opts.noninteractive { + return opts, "", errors.New("init accepts either --interactive or --noninteractive, not both") + } + if opts.repo != "" { + switch len(rest) { + case 0: + return opts, opts.repo, nil + case 1: + opts.directory = rest[0] + return opts, opts.repo, nil + default: + return opts, "", errors.New("init accepts at most one directory when --repo is set") + } + } + switch len(rest) { + case 0: + return opts, opts.repo, nil + case 1: + return opts, firstNonEmpty(opts.repo, rest[0]), nil + case 2: + opts.directory = rest[1] + return opts, firstNonEmpty(opts.repo, rest[0]), nil + default: + return opts, "", errors.New("init accepts at most repository name and optional directory") + } +} + +func parseBrokerCloneURL(raw string) (string, string, bool, error) { + raw = strings.TrimSpace(raw) + if raw == "" || (!strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://")) { + return "", "", false, nil + } + parsed, err := url.Parse(raw) + if err != nil { + return "", "", true, fmt.Errorf("parse broker clone URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", "", true, fmt.Errorf("unsupported broker clone URL scheme %q", parsed.Scheme) + } + if parsed.Host == "" { + return "", "", true, errors.New("broker clone URL must include a host") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", "", true, errors.New("broker clone URL must not include query parameters or a fragment") + } + repoName := strings.Trim(parsed.Path, "/") + if repoName == "" { + return "", "", true, errors.New("broker clone URL must include a logical repository path") + } + return parsed.Scheme + "://" + parsed.Host, repoName, true, nil +} + +func brokerProfileForCloneURL(brokerURL string) (brokerProfile, error) { + brokerURL = strings.TrimRight(strings.TrimSpace(brokerURL), "/") + if brokerURL == "" { + return brokerProfile{}, errors.New("--broker requires a broker URL") + } + parsed, err := url.Parse(brokerURL) + if err != nil { + return brokerProfile{}, fmt.Errorf("parse broker URL: %w", err) + } + if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return brokerProfile{}, errors.New("--broker must be an http(s) URL") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return brokerProfile{}, errors.New("--broker must not include query parameters or a fragment") + } + return brokerProfile{ + Provider: "gcs", + Name: parsed.Host, + QualifiedName: "broker:" + brokerURL, + BrokerURL: brokerURL, + }, nil +} + +func loadGlobalConfigForInit(path string) (globalConfig, string, error) { + var err error + if path == "" { + path, err = defaultGlobalConfigPath() + if err != nil { + return globalConfig{}, "", err + } + } + cfg, err := readGlobalConfig(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return globalConfig{Version: globalConfigVersion}, path, nil + } + return globalConfig{}, path, err + } + return cfg, path, nil +} + +func brokerProfilesFromGlobalConfig(cfg globalConfig) []brokerProfile { + var profiles []brokerProfile + for _, profile := range cfg.GCPProfiles { + for _, region := range profile.Regions { + if strings.TrimSpace(region.BrokerURL) == "" { + continue + } + name := "gcp:" + profile.Name + "/" + region.Name + profiles = append(profiles, brokerProfile{Provider: "gcs", Name: profile.Name, Region: region.Name, QualifiedName: name, BrokerURL: region.BrokerURL}) + } + } + for _, profile := range cfg.AWSProfiles { + for _, region := range profile.Regions { + if strings.TrimSpace(region.BrokerURL) == "" { + continue + } + name := "aws:" + profile.Name + "/" + region.Name + profiles = append(profiles, brokerProfile{Provider: "s3", Name: profile.Name, Region: region.Name, QualifiedName: name, BrokerURL: region.BrokerURL}) + } + } + return profiles +} + +func selectBrokerProfile(profiles []brokerProfile, name string) (brokerProfile, error) { + return selectBrokerProfileForCommand(profiles, name, "", "bgit") +} + +func selectBrokerProfileForCommand(profiles []brokerProfile, name, region, command string) (brokerProfile, error) { + if strings.TrimSpace(name) == "" { + if len(profiles) == 1 { + return profiles[0], nil + } + return brokerProfile{}, errors.New("multiple broker profiles configured; pass --profile") + } + name = strings.TrimSpace(name) + region = strings.TrimSpace(region) + var matches []brokerProfile + for _, profile := range profiles { + if region != "" && profile.Region != region { + continue + } + if brokerProfileNameMatches(profile, name) { + matches = append(matches, profile) + } + } + if len(matches) == 1 { + return matches[0], nil + } + if len(matches) > 1 { + return brokerProfile{}, ambiguousBrokerProfileError(name, command, matches) + } + return brokerProfile{}, fmt.Errorf("broker profile %q not found", name) +} + +func brokerProfileNameMatches(profile brokerProfile, name string) bool { + providerName := providerProfileName(profile.Provider) + candidates := []string{ + profile.QualifiedName, + profile.Name, + profile.Name + "." + profile.Region, + providerName + ":" + profile.Name, + providerName + ":" + profile.Name + "." + profile.Region, + providerName + ":" + profile.Name + "/" + profile.Region, + } + for _, candidate := range candidates { + if name == candidate { + return true + } + } + return false +} + +func ambiguousBrokerProfileError(name, command string, matches []brokerProfile) error { + var b strings.Builder + fmt.Fprintf(&b, "broker profile %q is ambiguous.\nSpecify the region you want to use:\n", name) + for _, profile := range matches { + fmt.Fprintf(&b, " %s --profile %s.%s\n", command, profile.Name, profile.Region) + } + return errors.New(strings.TrimRight(b.String(), "\n")) +} + +func providerProfileName(provider string) string { + if provider == "s3" { + return "aws" + } + return "gcp" +} + +type initDialogConfig struct { + RepoName string + ProfileName string + IdentityName string + IdentityEmail string + Existing bool +} + +type initDialogResult struct { + RepoName string + ProfileName string + IdentityName string + IdentityEmail string + Changed bool + RepoChanged bool + ProfileChanged bool + IdentityChanged bool +} + +func brokerInitPrompt(stdin io.Reader, stdout io.Writer, initial initDialogConfig, profiles []brokerProfile) (initDialogResult, error) { + reader, ok := stdin.(*bufio.Reader) + if !ok { + reader = bufio.NewReader(stdin) + } + return runInitDialogWithRaw(reader, stdin, stdout, initial, profiles) +} + +type initDialogState struct { + repoName string + initialRepoName string + profileName string + initialProfileName string + identityName string + initialIdentityName string + identityEmail string + initialIdentityEmail string + existing bool + profiles []brokerProfile + selectedProfile int + initialProfile int + cursor int + button int + editingField int + editOriginal string + message string +} + +func runInitDialogWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, initial initDialogConfig, profiles []brokerProfile) (initDialogResult, error) { + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return initDialogResult{}, err + } + defer restore() + selectedProfile := initDialogSelectedProfile(profiles, initial.ProfileName) + state := initDialogState{ + repoName: firstNonEmpty(strings.TrimSpace(initial.RepoName), defaultInitRepoName()), + initialRepoName: firstNonEmpty(strings.TrimSpace(initial.RepoName), defaultInitRepoName()), + profileName: strings.TrimSpace(initial.ProfileName), + initialProfileName: strings.TrimSpace(initial.ProfileName), + identityName: firstNonEmpty(strings.TrimSpace(initial.IdentityName), defaultBucketGitIdentityName), + initialIdentityName: firstNonEmpty(strings.TrimSpace(initial.IdentityName), defaultBucketGitIdentityName), + identityEmail: firstNonEmpty(strings.TrimSpace(initial.IdentityEmail), defaultBucketGitIdentityEmail()), + initialIdentityEmail: firstNonEmpty(strings.TrimSpace(initial.IdentityEmail), defaultBucketGitIdentityEmail()), + existing: initial.Existing, + profiles: profiles, + selectedProfile: selectedProfile, + initialProfile: selectedProfile, + button: -1, + editingField: -1, + } + for { + fmt.Fprint(stdout, renderInitDialogFrame(state, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return initDialogResult{}, errors.New("init canceled") + } + return initDialogResult{}, err + } + switch b { + case 0x03: + return initDialogResult{}, errors.New("init canceled") + case 0x04: + if state.editingField >= 0 { + state.editingField = -1 + state.editOriginal = "" + continue + } + if result, ok := state.deploy(); ok { + return result, nil + } + case '\r', '\n': + if state.editingField >= 0 { + state.editingField = -1 + state.editOriginal = "" + state.message = "" + continue + } + if result, ok := state.activate(); ok { + return result, nil + } else if state.button == 1 { + return initDialogResult{}, errors.New("init canceled") + } + case ' ': + if state.editingField >= 0 { + state.appendFieldByte(b) + } else if result, ok := state.activate(); ok { + return result, nil + } + case '\t': + if state.editingField >= 0 { + state.editingField = -1 + state.editOriginal = "" + } + state.tab() + case 0x7f, 0x08: + if state.editingField >= 0 { + state.backspaceField() + } + case 0x1b: + if state.editingField >= 0 { + state.setFieldValue(state.editingField, state.editOriginal) + state.editingField = -1 + state.editOriginal = "" + state.message = "" + continue + } + next, err := reader.ReadByte() + if err != nil { + return initDialogResult{}, errors.New("init canceled") + } + if next == '[' { + last, err := reader.ReadByte() + if err != nil { + return initDialogResult{}, errors.New("init canceled") + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + } + continue + } + return initDialogResult{}, errors.New("init canceled") + default: + if state.editingField >= 0 && b >= 32 && b <= 126 { + state.appendFieldByte(b) + } + } + } +} + +func defaultInitRepoName() string { + wd, err := os.Getwd() + if err != nil { + return "repo.git" + } + name := strings.TrimSpace(filepath.Base(wd)) + if name == "" || name == "." || name == string(filepath.Separator) { + return "repo.git" + } + if !strings.HasSuffix(name, ".git") { + name += ".git" + } + return name +} + +func logicalRepoWithGit(name string) string { + name = strings.Trim(strings.TrimSpace(name), "/") + if name == "" { + return "repo.git" + } + if !strings.HasSuffix(name, ".git") { + name += ".git" + } + return name +} + +func logicalRepoDisplayName(name string) string { + return strings.TrimSuffix(strings.Trim(strings.TrimSpace(name), "/"), ".git") +} + +func initDialogInitialState(target string, global globalConfig, repoName, profileName string) initDialogConfig { + initial := initDialogConfig{ + RepoName: firstNonEmpty(strings.TrimSpace(repoName), defaultInitRepoName()), + ProfileName: strings.TrimSpace(profileName), + IdentityName: firstNonEmpty(strings.TrimSpace(global.Identity.Name), defaultBucketGitIdentityName), + IdentityEmail: firstNonEmpty(strings.TrimSpace(global.Identity.Email), defaultBucketGitIdentityEmail()), + } + gitDir := filepath.Join(target, ".git") + configPath := filepath.Join(gitDir, "config") + if _, err := os.Stat(configPath); err == nil { + initial.Existing = true + } + cfg, err := readLocalConfigFile(configPath) + if err != nil { + return initial + } + if value, ok := cfg.get("bucketgit.logicalRepo"); ok && strings.TrimSpace(repoName) == "" { + initial.RepoName = strings.TrimSpace(value) + } + if value, ok := cfg.get("bucketgit.profile"); ok && strings.TrimSpace(profileName) == "" { + initial.ProfileName = strings.TrimSpace(value) + } + if value, ok := cfg.get("user.name"); ok && strings.TrimSpace(value) != "" { + initial.IdentityName = strings.TrimSpace(value) + } + if value, ok := cfg.get("user.email"); ok && strings.TrimSpace(value) != "" { + initial.IdentityEmail = strings.TrimSpace(value) + } + return initial +} + +func initDialogSelectedProfile(profiles []brokerProfile, profileName string) int { + if strings.TrimSpace(profileName) == "" { + if len(profiles) == 1 { + return 0 + } + return -1 + } + for i, profile := range profiles { + if brokerProfileNameMatches(profile, strings.TrimSpace(profileName)) { + return i + } + } + return -1 +} + +func (s initDialogState) rows() int { + return 3 + len(s.profiles) +} + +func (s *initDialogState) up() { + if s.editingField >= 0 { + return + } + s.button = -1 + s.message = "" + if s.rows() == 0 { + return + } + if s.cursor == 0 { + s.cursor = s.rows() - 1 + return + } + s.cursor-- +} + +func (s *initDialogState) down() { + if s.editingField >= 0 { + return + } + s.button = -1 + s.message = "" + if s.rows() == 0 { + return + } + s.cursor = (s.cursor + 1) % s.rows() +} + +func (s *initDialogState) tab() { + if s.editingField >= 0 { + s.editingField = -1 + s.editOriginal = "" + } + s.message = "" + if s.button == 1 { + s.button = -1 + s.cursor = 0 + return + } + if s.button < 0 { + s.button = 0 + return + } + s.button = (s.button + 1) % 2 +} + +func (s *initDialogState) activate() (initDialogResult, bool) { + if s.button == 0 { + return s.deploy() + } + if s.button == 1 { + return initDialogResult{}, false + } + if s.cursor >= 0 && s.cursor <= 2 { + s.editingField = s.cursor + s.editOriginal = s.fieldValue(s.cursor) + s.message = "" + return initDialogResult{}, false + } + idx := s.cursor - 3 + if idx >= 0 && idx < len(s.profiles) { + s.selectedProfile = idx + s.profileName = s.profiles[idx].QualifiedName + } + return initDialogResult{}, false +} + +func (s *initDialogState) deploy() (initDialogResult, bool) { + repo := strings.TrimSpace(s.repoName) + if repo == "" { + s.message = "Enter a repository name before OK." + return initDialogResult{}, false + } + if email := strings.TrimSpace(s.identityEmail); email != "" && !identityEmailPattern.MatchString(email) { + s.message = "Email address looks invalid." + return initDialogResult{}, false + } + result := s.result() + if !result.Changed { + return result, true + } + if (result.RepoChanged || result.ProfileChanged) && (s.selectedProfile < 0 || s.selectedProfile >= len(s.profiles)) { + s.message = "Select a profile before OK." + return initDialogResult{}, false + } + return result, true +} + +func (s initDialogState) result() initDialogResult { + profileName := strings.TrimSpace(s.profileName) + if s.selectedProfile >= 0 && s.selectedProfile < len(s.profiles) { + profileName = s.profiles[s.selectedProfile].QualifiedName + } + result := initDialogResult{ + RepoName: strings.TrimSpace(s.repoName), + ProfileName: profileName, + IdentityName: strings.TrimSpace(s.identityName), + IdentityEmail: strings.TrimSpace(s.identityEmail), + } + result.RepoChanged = result.RepoName != strings.TrimSpace(s.initialRepoName) + result.ProfileChanged = result.ProfileName != strings.TrimSpace(s.initialProfileName) + result.IdentityChanged = result.IdentityName != strings.TrimSpace(s.initialIdentityName) || + result.IdentityEmail != strings.TrimSpace(s.initialIdentityEmail) + result.Changed = result.RepoChanged || result.ProfileChanged || result.IdentityChanged + if !s.existing && result.ProfileName != "" { + result.Changed = true + } + return result +} + +func (s initDialogState) fieldValue(row int) string { + switch row { + case 1: + return s.identityName + case 2: + return s.identityEmail + default: + return s.repoName + } +} + +func (s *initDialogState) setFieldValue(row int, value string) { + switch row { + case 1: + s.identityName = value + case 2: + s.identityEmail = value + default: + s.repoName = value + } +} + +func (s *initDialogState) appendFieldByte(b byte) { + s.message = "" + value := s.fieldValue(s.editingField) + if len(value) >= 80 { + return + } + s.setFieldValue(s.editingField, value+string(b)) +} + +func (s *initDialogState) backspaceField() { + s.message = "" + value := s.fieldValue(s.editingField) + if len(value) == 0 { + return + } + s.setFieldValue(s.editingField, value[:len(value)-1]) +} + +func renderInitDialogFrame(state initDialogState, rawMode bool) string { + rendered := renderInitDialogWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderInitDialogWithStyle(state initDialogState, style bool) string { + var lines []string + lines = append(lines, + "+------------------------------------------------------------+", + "| BUCKETGIT INIT |", + "+------------------------------------------------------------+", + setupDialogRow("Configure repository"), + "| |", + ) + inputActive := state.editingField == 0 + inputStyle := setupDialogSectionStyle(style, state.button < 0 && state.cursor == 0) + if style && inputActive { + inputStyle += "\x1b[44;97m" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s Repository [%s]", initDialogMarker(state, 0), initDialogInputValue(state.repoName, 38, inputActive, style)), inputStyle)) + lines = append(lines, setupDialogRow("")) + lines = append(lines, setupDialogRowStyled("Identity", setupDialogSectionStyle(style, state.button < 0 && (state.cursor == 1 || state.cursor == 2)))) + nameActive := state.editingField == 1 + nameStyle := setupDialogSectionStyle(style, state.button < 0 && state.cursor == 1) + if style && nameActive { + nameStyle += "\x1b[44;97m" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s Name [%s]", initDialogMarker(state, 1), initDialogInputValue(state.identityName, 43, nameActive, style)), nameStyle)) + emailActive := state.editingField == 2 + emailStyle := setupDialogSectionStyle(style, state.button < 0 && state.cursor == 2) + if style && emailActive { + emailStyle += "\x1b[44;97m" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s Email [%s]", initDialogMarker(state, 2), initDialogInputValue(state.identityEmail, 43, emailActive, style)), emailStyle)) + if state.usesDefaultIdentity() { + lines = append(lines, setupDialogRowStyled(" Configure name/email with bgit setup or bgit config.", setupDialogANSI(style, "33"))) + } + lines = append(lines, setupDialogRow("")) + lines = append(lines, setupDialogRowStyled("Profiles", setupDialogSectionStyle(style, state.button < 0 && state.cursor > 2))) + for i, profile := range state.profiles { + cursor := i + 3 + marker := initDialogMarker(state, cursor) + checked := " " + if state.selectedProfile == i { + checked = "x" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s [%s] %-50s", marker, checked, profile.QualifiedName), setupDialogSectionStyle(style, state.button < 0 && state.cursor > 2))) + } + if len(state.profiles) == 0 { + lines = append(lines, setupDialogRowStyled(" no profiles configured; run bgit setup", setupDialogSectionStyle(style, state.button < 0 && state.cursor > 2))) + } + if state.message != "" { + lines = append(lines, setupDialogRowStyled(state.message, setupDialogANSI(style, "33"))) + } + okStyle := "" + cancelStyle := "" + if style && state.button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.button == 1 { + cancelStyle = "\x1b[44;97m" + } + lines = append(lines, + "| |", + "+------------------------------------------------------------+", + setupDialogRow(setupDialogButton("[ OK ]", okStyle)+" "+setupDialogButton("[ Cancel ]", cancelStyle)), + setupDialogRow("Enter edits/saves field Space selects profile"), + setupDialogRow("Tab fields/buttons Ctrl-D OK Esc cancel"), + "+------------------------------------------------------------+", + ) + return strings.Join(lines, "\n") + "\n" +} + +func initDialogMarker(state initDialogState, row int) string { + if state.button < 0 && state.cursor == row { + return ">" + } + return " " +} + +func (s initDialogState) usesDefaultIdentity() bool { + return strings.TrimSpace(s.identityName) == defaultBucketGitIdentityName || + strings.TrimSpace(s.identityEmail) == defaultBucketGitIdentityEmail() +} + +func initDialogInputValue(value string, width int, active, style bool) string { + _ = style + if active { + if len(value) >= width { + return value[len(value)-width+1:] + "|" + } + value += "|" + } + if len(value) > width { + return value[len(value)-width:] + } + return value + strings.Repeat(" ", width-len(value)) +} + +func parsePositiveInt(value string) int { + n := 0 + for _, ch := range value { + if ch < '0' || ch > '9' { + return 0 + } + n = n*10 + int(ch-'0') + } + return n +} + +func initBrokerWorktree(target, repoName string, profile brokerProfile, identityName, identityEmail string, stdout io.Writer) error { + absTarget, err := filepath.Abs(target) + if err != nil { + return err + } + if err := os.MkdirAll(absTarget, 0o755); err != nil { + return err + } + if _, err := runGit(absTarget, "init", "--initial-branch", defaultBranch); err != nil { + if _, fallbackErr := runGit(absTarget, "init"); fallbackErr != nil { + return err + } + } + repoName = strings.Trim(repoName, "/") + if !strings.HasSuffix(repoName, ".git") { + repoName += ".git" + } + remoteURL := fmt.Sprintf("git@%s:%s", defaultSSHHost, repoName) + sshCommand := gitSSHCommandForExecutable() + pairs := [][]string{ + {"bucketgit.broker", profile.BrokerURL}, + {"bucketgit.profile", profile.QualifiedName}, + {"bucketgit.region", profile.Region}, + {"bucketgit.provider", profile.Provider}, + {"bucketgit.logicalRepo", repoName}, + {"core.sshCommand", sshCommand}, + } + if strings.TrimSpace(identityName) != "" { + pairs = append(pairs, []string{"user.name", strings.TrimSpace(identityName)}) + } + if strings.TrimSpace(identityEmail) != "" { + pairs = append(pairs, []string{"user.email", strings.TrimSpace(identityEmail)}) + } + for _, pair := range pairs { + if _, err := runGit(absTarget, "config", "--local", pair[0], pair[1]); err != nil { + return err + } + } + if err := configureBucketGitLineEndings(absTarget); err != nil { + return err + } + if err := setGitOrigin(absTarget, remoteURL); err != nil { + return err + } + if err := setGitBranchTracking(absTarget, defaultBranch, "origin"); err != nil { + return err + } + if err := brokerUpsertLogicalRepo(profile.BrokerURL, profile.Provider, repoName); err != nil { + fmt.Fprintf(stdout, "broker repo registration skipped: %v\n", err) + } + fmt.Fprintf(stdout, "Initialized broker-backed BucketGit repository in %s/\n", filepath.Join(absTarget, ".git")) + fmt.Fprintf(stdout, "configured origin %s\n", remoteURL) + return nil +} + +func gitSSHCommandForExecutable() string { + exe, err := os.Executable() + if err != nil || strings.TrimSpace(exe) == "" { + return "bgit ssh" + } + return shellQuoteForGitSSHCommand(exe) + " ssh" +} + +func shellQuoteForGitSSHCommand(value string) string { + if value == "" { + return "''" + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func writeLocalIdentityConfig(target, name, email string) error { + absTarget, err := filepath.Abs(target) + if err != nil { + return err + } + if _, err := os.Stat(filepath.Join(absTarget, ".git")); errors.Is(err, os.ErrNotExist) { + if _, err := runGit(absTarget, "init", "--initial-branch", defaultBranch); err != nil { + if _, fallbackErr := runGit(absTarget, "init"); fallbackErr != nil { + return err + } + } + } + if strings.TrimSpace(name) != "" { + if _, err := runGit(absTarget, "config", "--local", "user.name", strings.TrimSpace(name)); err != nil { + return err + } + } + if strings.TrimSpace(email) != "" { + if _, err := runGit(absTarget, "config", "--local", "user.email", strings.TrimSpace(email)); err != nil { + return err + } + } + return nil +} diff --git a/broker_commands_test.go b/broker_commands_test.go new file mode 100644 index 0000000..9d9a76d --- /dev/null +++ b/broker_commands_test.go @@ -0,0 +1,758 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBrokerInitWritesBrokerGitConfig(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, ".bgit", "config") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + ProjectID: "project-id", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://broker.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "app") + var stdout bytes.Buffer + err := brokerInitCommand([]string{"--noninteractive", "--repo", "team/app", target, "--profile", "gcp:work/europe-west1", "--config", configPath}, strings.NewReader(""), &stdout) + if err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "bucketgit.broker": "https://broker.example.test", + "bucketgit.profile": "gcp:work/europe-west1", + "bucketgit.region": "europe-west1", + "bucketgit.logicalRepo": "team/app.git", + "branch.main.remote": "origin", + "branch.main.merge": "refs/heads/main", + "core.autocrlf": "false", + "core.eol": "lf", + } { + out, err := runGit(target, "config", "--get", key) + if err != nil { + t.Fatalf("%s: %v", key, err) + } + if strings.TrimSpace(string(out)) != want { + t.Fatalf("%s = %q, want %q", key, strings.TrimSpace(string(out)), want) + } + } + out, err := runGit(target, "config", "--get", "core.sshCommand") + if err != nil { + t.Fatalf("core.sshCommand: %v", err) + } + if got := strings.TrimSpace(string(out)); !strings.HasSuffix(got, " ssh") { + t.Fatalf("core.sshCommand = %q", got) + } + if got := strings.TrimSpace(string(out)); !strings.HasPrefix(got, "'") { + t.Fatalf("core.sshCommand should quote executable path, got %q", got) + } + remote, err := runGit(target, "remote", "get-url", "origin") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(remote)) != "git@git.bucketgit.com:team/app.git" { + t.Fatalf("origin = %q", strings.TrimSpace(string(remote))) + } +} + +func TestShellQuoteForGitSSHCommand(t *testing.T) { + got := shellQuoteForGitSSHCommand(`D:\a\bgit\bgit\bgit.exe`) + if got != `'D:\a\bgit\bgit\bgit.exe'` { + t.Fatalf("quoted windows path = %q", got) + } + got = shellQuoteForGitSSHCommand(`/tmp/BucketGit Test/bin/bgit`) + if got != `'/tmp/BucketGit Test/bin/bgit'` { + t.Fatalf("quoted unix path = %q", got) + } + got = shellQuoteForGitSSHCommand(`/tmp/dennis' test/bgit`) + if got != `'/tmp/dennis'\'' test/bgit'` { + t.Fatalf("quoted apostrophe path = %q", got) + } +} + +func TestInitBrokerWorktreeOmitsIdentityWhenUnset(t *testing.T) { + target := filepath.Join(t.TempDir(), "app") + err := initBrokerWorktree(target, "team/app", brokerProfile{ + Provider: "gcs", + QualifiedName: "broker:https://broker.example.test", + BrokerURL: "", + }, "", "", io.Discard) + if err != nil { + t.Fatal(err) + } + if out, err := runGit(target, "config", "--local", "--get", "user.name"); err == nil { + t.Fatalf("user.name should not be set, got %q", strings.TrimSpace(string(out))) + } + if out, err := runGit(target, "config", "--local", "--get", "user.email"); err == nil { + t.Fatalf("user.email should not be set, got %q", strings.TrimSpace(string(out))) + } +} + +func TestBrokerInitNoninteractiveRequiresProfileAndRepo(t *testing.T) { + err := brokerInitCommand([]string{"--noninteractive", "--repo", "team/app"}, strings.NewReader(""), ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "requires --profile") { + t.Fatalf("err = %v", err) + } + err = brokerInitCommand([]string{"--noninteractive", "--profile", "work"}, strings.NewReader(""), ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "requires --repo") { + t.Fatalf("err = %v", err) + } +} + +func TestAdminKeysListUsesLogicalBrokerRepo(t *testing.T) { + target, server, _ := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/keys/list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + var req brokerRepoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + if req.Repo.Logical != "team/app.git" { + t.Fatalf("logical repo = %q", req.Repo.Logical) + } + _, _ = w.Write([]byte(`{"keys":[{"user":"owner","role":"owner","public_key":"ssh-ed25519 AAAA owner"}]}`)) + }) + defer server.Close() + + var stdout bytes.Buffer + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + defer os.Chdir(previous) + + if err := brokerAdminKeysCommand(config{}, []string{"list"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "owner\towner\tactive") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestTopLevelBrokerInitForwardsGlobalProfile(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, ".bgit", "config") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + ProjectID: "project-id", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://broker.example.test", + }}, + }}, + AWSProfiles: []globalAWSProfile{{ + Name: "prod", + AccountID: "123456789012", + Regions: []globalProfileRegion{{ + Name: "eu-west-1", + BrokerURL: "https://aws-broker.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "app") + var stdout bytes.Buffer + err := run([]string{"init", "--noninteractive", "--repo", "team/app", target, "--config", configPath, "--profile", "gcp:work/europe-west1"}, strings.NewReader(""), &stdout, ioDiscard{}) + if err != nil { + t.Fatal(err) + } + out, err := runGit(target, "config", "--get", "bucketgit.profile") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(out)) != "gcp:work/europe-west1" { + t.Fatalf("profile = %q", strings.TrimSpace(string(out))) + } +} + +func TestBrokerProfileAmbiguousBareNameSuggestsRegionQualifiedNames(t *testing.T) { + profiles := []brokerProfile{{ + Provider: "gcs", + Name: "work", + Region: "us-central1", + QualifiedName: "gcp:work/us-central1", + BrokerURL: "https://us.example.test", + }, { + Provider: "gcs", + Name: "work", + Region: "europe-west1", + QualifiedName: "gcp:work/europe-west1", + BrokerURL: "https://eu.example.test", + }} + _, err := selectBrokerProfileForCommand(profiles, "work", "", "bgit push") + if err == nil { + t.Fatal("expected ambiguous profile error") + } + if !strings.Contains(err.Error(), `broker profile "work" is ambiguous`) || + !strings.Contains(err.Error(), "bgit push --profile work.us-central1") || + !strings.Contains(err.Error(), "bgit push --profile work.europe-west1") { + t.Fatalf("err = %v", err) + } +} + +func TestBrokerProfileBareNameWithRegionSelectsProfile(t *testing.T) { + profiles := []brokerProfile{{ + Provider: "gcs", + Name: "work", + Region: "us-central1", + QualifiedName: "gcp:work/us-central1", + BrokerURL: "https://us.example.test", + }, { + Provider: "gcs", + Name: "work", + Region: "europe-west1", + QualifiedName: "gcp:work/europe-west1", + BrokerURL: "https://eu.example.test", + }} + got, err := selectBrokerProfileForCommand(profiles, "work", "europe-west1", "bgit push") + if err != nil { + t.Fatal(err) + } + if got.Region != "europe-west1" || got.BrokerURL != "https://eu.example.test" { + t.Fatalf("profile = %#v", got) + } +} + +func TestExplicitBrokerProfileSelectionUsesRegionForDataPlaneCommand(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + configPath := filepath.Join(home, ".bgit", "config.yaml") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + Regions: []globalProfileRegion{{ + Name: "us-central1", + BrokerURL: "https://us.example.test", + }, { + Name: "europe-west1", + BrokerURL: "https://eu.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + cfg := config{gcloudConfiguration: "work", gcloudConfigurationExplicit: true, region: "europe-west1"} + if err := applyExplicitBrokerProfileSelection(&cfg, "push"); err != nil { + t.Fatal(err) + } + if cfg.brokerURL != "https://eu.example.test" || cfg.gcloudConfiguration != "gcp:work/europe-west1" { + t.Fatalf("cfg = %#v", cfg) + } +} + +func TestExplicitBrokerProfileSelectionRejectsAmbiguousDataPlaneProfile(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + configPath := filepath.Join(home, ".bgit", "config.yaml") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + Regions: []globalProfileRegion{{ + Name: "us-central1", + BrokerURL: "https://us.example.test", + }, { + Name: "europe-west1", + BrokerURL: "https://eu.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + cfg := config{gcloudConfiguration: "work", gcloudConfigurationExplicit: true} + err := applyExplicitBrokerProfileSelection(&cfg, "push") + if err == nil { + t.Fatal("expected ambiguous profile error") + } + if !strings.Contains(err.Error(), "bgit push --profile work.us-central1") || + !strings.Contains(err.Error(), "bgit push --profile work.europe-west1") { + t.Fatalf("err = %v", err) + } +} + +func TestBrokerProfileDotRegionSelectsProfile(t *testing.T) { + profiles := []brokerProfile{{ + Provider: "s3", + Name: "prod", + Region: "us-east-1", + QualifiedName: "aws:prod/us-east-1", + BrokerURL: "https://us.example.test", + }, { + Provider: "s3", + Name: "prod", + Region: "eu-west-1", + QualifiedName: "aws:prod/eu-west-1", + BrokerURL: "https://eu.example.test", + }} + got, err := selectBrokerProfileForCommand(profiles, "prod.eu-west-1", "", "bgit push") + if err != nil { + t.Fatal(err) + } + if got.Region != "eu-west-1" { + t.Fatalf("profile = %#v", got) + } +} + +func TestParseBrokerCloneURL(t *testing.T) { + brokerURL, repo, ok, err := parseBrokerCloneURL("https://broker.example.test/team/app.git") + if err != nil { + t.Fatal(err) + } + if !ok || brokerURL != "https://broker.example.test" || repo != "team/app.git" { + t.Fatalf("brokerURL=%q repo=%q ok=%v", brokerURL, repo, ok) + } +} + +func TestBrokerProfileForCloneURL(t *testing.T) { + profile, err := brokerProfileForCloneURL("https://broker.example.test/") + if err != nil { + t.Fatal(err) + } + if profile.BrokerURL != "https://broker.example.test" || + profile.QualifiedName != "broker:https://broker.example.test" || + profile.Provider != "gcs" { + t.Fatalf("profile = %#v", profile) + } +} + +func TestBrokerInitInteractivePromptsForRepoAndProfile(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, ".bgit", "config") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + AWSProfiles: []globalAWSProfile{{ + Name: "prod", + AccountID: "123456789012", + Regions: []globalProfileRegion{{ + Name: "eu-west-1", + BrokerURL: "https://broker.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "repo") + var stdout bytes.Buffer + err := brokerInitCommand([]string{"--config", configPath, "--profile", "aws:prod/eu-west-1", "ignored", target}, strings.NewReader("\x04"), &stdout) + if err != nil { + t.Fatal(err) + } + out, err := runGit(target, "config", "--get", "bucketgit.profile") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(out)) != "aws:prod/eu-west-1" { + t.Fatalf("profile = %q", strings.TrimSpace(string(out))) + } +} + +func TestBrokerInitInteractiveIdentityOnlyUpdatesRepoConfig(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, ".bgit", "config") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + Identity: globalIdentityConfig{Name: "Global User", Email: "global@example.com"}, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://broker.example.test", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "repo") + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "bucketgit.logicalRepo", "team/app.git"}, + {"config", "bucketgit.profile", "gcp:work/europe-west1"}, + {"config", "user.name", "Repo User"}, + {"config", "user.email", "old@example.com"}, + } { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + var stdout bytes.Buffer + input := strings.NewReader("\x1b[B\x1b[B\n" + strings.Repeat("\x7f", len("old@example.com")) + "new@example.com\n\x04") + err := brokerInitCommand([]string{"--config", configPath, "--repo", "team/app.git", target}, input, &stdout) + if err != nil { + t.Fatal(err) + } + out, err := runGit(target, "config", "--get", "user.email") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(out)) != "new@example.com" { + t.Fatalf("user.email = %q", strings.TrimSpace(string(out))) + } + if !strings.Contains(stdout.String(), "Updated repository identity.") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestInitDialogRendersRepoInputAndProfiles(t *testing.T) { + rendered := renderInitDialogWithStyle(initDialogState{ + repoName: "app.git", + profiles: []brokerProfile{{ + Provider: "gcs", + Name: "work", + Region: "europe-west1", + QualifiedName: "gcp:work/europe-west1", + BrokerURL: "https://broker.example.test", + }}, + selectedProfile: 0, + }, false) + for _, want := range []string{"BUCKETGIT INIT", "Repository [app.git", "[x] gcp:work/europe-west1", "[ OK ]"} { + if !strings.Contains(rendered, want) { + t.Fatalf("rendered missing %q:\n%s", want, rendered) + } + } +} + +func TestInitDialogRendersIdentityAndDefaultWarning(t *testing.T) { + rendered := renderInitDialogWithStyle(initDialogState{ + repoName: "app.git", + identityName: defaultBucketGitIdentityName, + identityEmail: defaultBucketGitIdentityEmail(), + initialIdentityName: defaultBucketGitIdentityName, + initialIdentityEmail: defaultBucketGitIdentityEmail(), + profiles: nil, + selectedProfile: -1, + initialProfile: -1, + editingField: -1, + }, false) + for _, want := range []string{"Identity", "Name [BucketGit Client", "Email [", "Configure name/email with bgit setup or bgit config."} { + if !strings.Contains(rendered, want) { + t.Fatalf("rendered missing %q:\n%s", want, rendered) + } + } +} + +func TestInitDialogExistingNoChangesReturnsUnchanged(t *testing.T) { + state := initDialogState{ + repoName: "app.git", + initialRepoName: "app.git", + profileName: "gcp:work/europe-west1", + initialProfileName: "gcp:work/europe-west1", + identityName: "Dennis Example", + initialIdentityName: "Dennis Example", + identityEmail: "dennis@example.com", + initialIdentityEmail: "dennis@example.com", + existing: true, + profiles: []brokerProfile{{QualifiedName: "gcp:work/europe-west1"}}, + selectedProfile: 0, + initialProfile: 0, + } + result, ok := state.deploy() + if !ok { + t.Fatal("deploy rejected unchanged existing config") + } + if result.Changed { + t.Fatalf("changed = true: %#v", result) + } +} + +func TestInitDialogFreshNoProfileNoChangesReturnsUnchanged(t *testing.T) { + state := initDialogState{ + repoName: "foo.git", + initialRepoName: "foo.git", + identityName: "Dennis Vink", + initialIdentityName: "Dennis Vink", + identityEmail: "hi@bucketgit.com", + initialIdentityEmail: "hi@bucketgit.com", + existing: false, + profiles: []brokerProfile{{QualifiedName: "gcp:work/europe-west1"}}, + selectedProfile: -1, + initialProfile: -1, + } + result, ok := state.deploy() + if !ok { + t.Fatalf("deploy rejected unchanged fresh dialog: %q", state.message) + } + if result.Changed { + t.Fatalf("changed = true: %#v", result) + } +} + +func TestInitDialogExistingIdentityOnlyChangeDoesNotRequireProfile(t *testing.T) { + state := initDialogState{ + repoName: "app.git", + initialRepoName: "app.git", + identityName: "Dennis Example", + initialIdentityName: "Dennis Example", + identityEmail: "new@example.com", + initialIdentityEmail: "old@example.com", + existing: true, + selectedProfile: -1, + initialProfile: -1, + } + result, ok := state.deploy() + if !ok { + t.Fatal("deploy rejected identity-only change") + } + if !result.IdentityChanged || result.ProfileChanged || result.RepoChanged { + t.Fatalf("result = %#v", result) + } +} + +func TestInitDialogInitialStateUsesRepoThenGlobalIdentity(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "repo") + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "bucketgit.logicalRepo", "team/app.git"}, + {"config", "bucketgit.profile", "gcp:work/europe-west1"}, + {"config", "user.name", "Repo User"}, + {"config", "user.email", "repo@example.com"}, + } { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + initial := initDialogInitialState(target, globalConfig{Identity: globalIdentityConfig{Name: "Global User", Email: "global@example.com"}}, "", "") + if !initial.Existing || initial.RepoName != "team/app.git" || initial.ProfileName != "gcp:work/europe-west1" || + initial.IdentityName != "Repo User" || initial.IdentityEmail != "repo@example.com" { + t.Fatalf("initial = %#v", initial) + } + fresh := initDialogInitialState(filepath.Join(root, "fresh"), globalConfig{Identity: globalIdentityConfig{Name: "Global User", Email: "global@example.com"}}, "new.git", "") + if fresh.Existing || fresh.IdentityName != "Global User" || fresh.IdentityEmail != "global@example.com" { + t.Fatalf("fresh = %#v", fresh) + } +} + +func TestInitDialogEditsRepoAndSelectsProfile(t *testing.T) { + var stdout bytes.Buffer + result, err := runInitDialogWithRaw( + bufio.NewReader(strings.NewReader("\nbar\n\x1b[B\x1b[B\x1b[B \x04")), + strings.NewReader(""), + &stdout, + initDialogConfig{RepoName: "foo"}, + []brokerProfile{{ + Provider: "gcs", + Name: "work", + Region: "europe-west1", + QualifiedName: "gcp:work/europe-west1", + BrokerURL: "https://broker.example.test", + }}, + ) + if err != nil { + t.Fatal(err) + } + if result.RepoName != "foobar" || result.ProfileName != "gcp:work/europe-west1" { + t.Fatalf("repo=%q profile=%q", result.RepoName, result.ProfileName) + } + if !strings.Contains(stdout.String(), "BUCKETGIT INIT") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestInitDialogEscapeRevertsRepoEdit(t *testing.T) { + var stdout bytes.Buffer + result, err := runInitDialogWithRaw( + bufio.NewReader(strings.NewReader("\nbar\x1b\x1b[B\x1b[B\x1b[B \x04")), + strings.NewReader(""), + &stdout, + initDialogConfig{RepoName: "foo"}, + []brokerProfile{{ + Provider: "gcs", + Name: "work", + Region: "europe-west1", + QualifiedName: "gcp:work/europe-west1", + BrokerURL: "https://broker.example.test", + }}, + ) + if err != nil { + t.Fatal(err) + } + if result.RepoName != "foo" || result.ProfileName != "gcp:work/europe-west1" { + t.Fatalf("repo=%q profile=%q", result.RepoName, result.ProfileName) + } +} + +func TestTopLevelPushWithoutBrokerOrBucketShowsOriginHint(t *testing.T) { + err := run([]string{"push"}, strings.NewReader(""), &bytes.Buffer{}, ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "No configured push destination") { + t.Fatalf("err = %v", err) + } +} + +func TestAdminCloudIAMMovedToDirect(t *testing.T) { + err := brokerAdminCommand(config{}, []string{"grant-read", "user@example.com"}, ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "bgit direct admin") { + t.Fatalf("err = %v", err) + } +} + +func TestBrokerAdminProtectAndPRCommandsUseBroker(t *testing.T) { + target, server, requests := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + switch r.URL.Path { + case "/protection/upsert", "/prs/merge", "/prs/close": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/protection/list": + _, _ = w.Write([]byte(`{"protections":[{"ref":"refs/heads/main","require_pr":true,"allow_overrides":true}]}`)) + case "/prs/create": + var req brokerPullRequestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + if req.PR.Source != "refs/heads/main" || req.PR.Target != "refs/heads/main" { + t.Fatalf("create PR req = %#v", req.PR) + } + _, _ = w.Write([]byte(`{"pr":{"id":7,"title":"demo","source":"refs/heads/main","target":"refs/heads/main","status":"open"}}`)) + case "/prs/list": + _, _ = w.Write([]byte(`{"prs":[{"id":7,"title":"demo","source":"refs/heads/main","target":"refs/heads/main","status":"open"}]}`)) + case "/prs/view": + _, _ = w.Write([]byte(`{"pr":{"id":7,"title":"demo","source":"refs/heads/main","target":"refs/heads/main","status":"open","approvals":0}}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + }) + defer server.Close() + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + if err := brokerAdminCommand(config{}, []string{"protect", "add", "main", "--allow-owner-admin-override"}, &stdout); err != nil { + t.Fatal(err) + } + if err := brokerAdminCommand(config{}, []string{"protect", "list"}, &stdout); err != nil { + t.Fatal(err) + } + if err := prCommand([]string{"create", "--title", "demo"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if err := prCommand([]string{"list"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if err := prCommand([]string{"view", "7"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if err := prCommand([]string{"merge", "7"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if err := prCommand([]string{"close", "7"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + want := "/protection/upsert,/protection/list,/prs/create,/prs/list,/prs/view,/prs/merge,/prs/close" + if strings.Join(*requests, ",") != want { + t.Fatalf("requests = %#v", *requests) + } + if !strings.Contains(stdout.String(), "created PR #7") || !strings.Contains(stdout.String(), "refs/heads/main") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestImportGitHubKeysConfirmsAndStoresSource(t *testing.T) { + var addReq brokerKeyRequest + target, server, _ := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + if r.URL.Path != "/keys/add" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&addReq); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + defer server.Close() + + oldTransport := http.DefaultClient.Transport + http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "github.com" { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("ssh-ed25519 AAAAGH octocat@github\n")), + Request: req, + }, nil + } + return http.DefaultTransport.RoundTrip(req) + }) + defer func() { http.DefaultClient.Transport = oldTransport }() + + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if err := brokerAdminKeysCommand(config{}, []string{"import-github", "octocat", "--role", "triage"}, strings.NewReader("y\n"), &stdout); err != nil { + t.Fatal(err) + } + if addReq.User != "octocat" || addReq.Role != "triage" || addReq.Source != "github:octocat" || len(addReq.PublicKeys) != 1 { + t.Fatalf("add req = %#v", addReq) + } +} + +func setupBrokerCommandTestRepo(t *testing.T, handler http.HandlerFunc) (string, *httptest.Server, *[]string) { + t.Helper() + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + requests := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.Path) + handler(w, r) + })) + for key, value := range map[string]string{ + "bucketgit.broker": server.URL, + "bucketgit.logicalRepo": "team/app.git", + "bucketgit.provider": "gcs", + "bucketgit.branch": "main", + } { + if _, err := runGit(target, "config", "--local", key, value); err != nil { + server.Close() + t.Fatal(err) + } + } + return target, server, &requests +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/broker_data_path.go b/broker_data_path.go new file mode 100644 index 0000000..3bfb357 --- /dev/null +++ b/broker_data_path.go @@ -0,0 +1,208 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type brokerObjectCapabilityRequest struct { + Repo brokerRepo `json:"repo"` + Path string `json:"path"` + Operation string `json:"operation"` + Size int64 `json:"size,omitempty"` + Resumable bool `json:"resumable,omitempty"` +} + +type brokerObjectCapabilityResponse struct { + Provider string `json:"provider"` + Mode string `json:"mode"` + Method string `json:"method,omitempty"` + URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Bucket string `json:"bucket,omitempty"` + Prefix string `json:"prefix,omitempty"` + Object string `json:"object,omitempty"` + Region string `json:"region,omitempty"` + Credentials brokerObjectAWSCredentials `json:"credentials,omitempty"` +} + +type brokerObjectAWSCredentials struct { + AccessKeyID string `json:"access_key_id"` + SecretAccessKey string `json:"secret_access_key"` + SessionToken string `json:"session_token"` +} + +func (s *brokerGitStore) write(ctx context.Context, objectPath string, data []byte) error { + capability, err := s.objectCapability(ctx, objectPath, "write", int64(len(data))) + if err != nil { + return err + } + return s.writeWithCapability(ctx, capability, data) +} + +func (s *brokerGitStore) delete(ctx context.Context, objectPath string) error { + capability, err := s.objectCapability(ctx, objectPath, "delete", 0) + if err != nil { + return err + } + return s.deleteWithCapability(ctx, capability) +} + +func (s *brokerGitStore) readWithCapability(ctx context.Context, objectPath string) ([]byte, bool, error) { + capability, err := s.objectCapability(ctx, objectPath, "read", 0) + if err != nil { + if isBrokerNotFoundError(err) { + return nil, true, fs.ErrNotExist + } + if isBrokerCapabilityUnsupported(err) { + return nil, false, nil + } + return nil, true, err + } + data, err := s.getWithCapability(ctx, capability) + if errors.Is(err, fs.ErrNotExist) { + return nil, true, fs.ErrNotExist + } + return data, true, err +} + +func (s *brokerGitStore) objectCapability(ctx context.Context, objectPath, operation string, size int64) (brokerObjectCapabilityResponse, error) { + var resp brokerObjectCapabilityResponse + req := brokerObjectCapabilityRequest{ + Repo: repoForBroker(s.cfg), + Path: strings.TrimPrefix(objectPath, "/"), + Operation: operation, + Size: size, + Resumable: s.cfg.provider == "gcs" && operation == "write" && size > 32*1024*1024, + } + err := brokerPostContext(ctx, s.brokerURL, "/objects/capability", req, &resp) + return resp, err +} + +func (s *brokerGitStore) getWithCapability(ctx context.Context, capability brokerObjectCapabilityResponse) ([]byte, error) { + if capability.Mode == "sts" || capability.Provider == "s3" { + client := s3ClientForBrokerCapability(capability) + out, err := client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(capability.Bucket), + Key: aws.String(capability.Object), + }) + if err != nil { + if isS3NotFound(err) { + return nil, fs.ErrNotExist + } + return nil, err + } + defer out.Body.Close() + return io.ReadAll(out.Body) + } + httpReq, err := http.NewRequestWithContext(ctx, firstNonEmpty(capability.Method, http.MethodGet), capability.URL, nil) + if err != nil { + return nil, err + } + for key, value := range capability.Headers { + httpReq.Header.Set(key, value) + } + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + if httpResp.StatusCode == http.StatusNotFound { + return nil, fs.ErrNotExist + } + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + body, _ := io.ReadAll(httpResp.Body) + return nil, fmt.Errorf("broker object GET: %s %s", httpResp.Status, strings.TrimSpace(string(body))) + } + return io.ReadAll(httpResp.Body) +} + +func (s *brokerGitStore) writeWithCapability(ctx context.Context, capability brokerObjectCapabilityResponse, data []byte) error { + if capability.Mode == "sts" || capability.Provider == "s3" { + client := s3ClientForBrokerCapability(capability) + _, err := client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(capability.Bucket), + Key: aws.String(capability.Object), + Body: bytes.NewReader(data), + }) + return err + } + method := firstNonEmpty(capability.Method, http.MethodPut) + httpReq, err := http.NewRequestWithContext(ctx, method, capability.URL, bytes.NewReader(data)) + if err != nil { + return err + } + for key, value := range capability.Headers { + httpReq.Header.Set(key, value) + } + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return err + } + defer httpResp.Body.Close() + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + body, _ := io.ReadAll(httpResp.Body) + return fmt.Errorf("broker object %s: %s %s", method, httpResp.Status, strings.TrimSpace(string(body))) + } + return nil +} + +func (s *brokerGitStore) deleteWithCapability(ctx context.Context, capability brokerObjectCapabilityResponse) error { + if capability.Mode == "sts" || capability.Provider == "s3" { + client := s3ClientForBrokerCapability(capability) + _, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(capability.Bucket), + Key: aws.String(capability.Object), + }) + return err + } + httpReq, err := http.NewRequestWithContext(ctx, firstNonEmpty(capability.Method, http.MethodDelete), capability.URL, nil) + if err != nil { + return err + } + for key, value := range capability.Headers { + httpReq.Header.Set(key, value) + } + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return err + } + defer httpResp.Body.Close() + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + body, _ := io.ReadAll(httpResp.Body) + return fmt.Errorf("broker object DELETE: %s %s", httpResp.Status, strings.TrimSpace(string(body))) + } + return nil +} + +func s3ClientForBrokerCapability(capability brokerObjectCapabilityResponse) *s3.Client { + region := firstNonEmpty(capability.Region, defaultAWSRegion()) + creds := credentials.NewStaticCredentialsProvider( + capability.Credentials.AccessKeyID, + capability.Credentials.SecretAccessKey, + capability.Credentials.SessionToken, + ) + return s3.New(s3.Options{ + Region: region, + Credentials: aws.NewCredentialsCache(creds), + }) +} + +func isBrokerCapabilityUnsupported(err error) bool { + if err == nil { + return false + } + message := err.Error() + return strings.Contains(message, "unknown broker endpoint") || + strings.Contains(message, "404") +} diff --git a/broker_lifecycle.go b/broker_lifecycle.go new file mode 100644 index 0000000..71eaa7f --- /dev/null +++ b/broker_lifecycle.go @@ -0,0 +1,247 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strings" +) + +type brokerDeleteOptions struct { + provider string + profile string + region string + configPath string + deleteData bool + yes bool + firestoreDatabase string +} + +func brokerCommand(ctx context.Context, base config, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: bgit broker delete [--provider gcp|aws] [--profile NAME] [--region REGION] [--data] --yes") + } + switch args[0] { + case "delete", "decommission": + return brokerDeleteCommand(ctx, base, args[1:], stdin, stdout) + default: + return fmt.Errorf("unknown broker command %q", args[0]) + } +} + +func brokerDeleteCommand(ctx context.Context, base config, args []string, stdin io.Reader, stdout io.Writer) error { + opts, err := parseBrokerDeleteArgs(args) + if err != nil { + return err + } + if !opts.yes { + fmt.Fprint(stdout, "Delete bgit broker infrastructure? [y/N] ") + answer, _ := bufioReadLine(stdin) + if !strings.EqualFold(strings.TrimSpace(answer), "y") && !strings.EqualFold(strings.TrimSpace(answer), "yes") { + return errors.New("broker delete cancelled") + } + } + provider := firstNonEmpty(opts.provider, normalizeSetupProvider(firstNonEmpty(base.provider, "gcp"))) + if provider == "" { + return errors.New("broker delete requires --provider gcp|aws") + } + cfg := base + cfg.provider = mapSetupProviderToConfig(provider) + if opts.profile != "" { + cfg.gcloudConfiguration = opts.profile + cfg.gcloudConfigurationExplicit = true + } + switch provider { + case "gcs": + if err := deleteGCPBroker(ctx, cfg, opts, stdout); err != nil { + return err + } + case "s3": + if err := deleteAWSBroker(ctx, cfg, opts, stdout); err != nil { + return err + } + default: + return fmt.Errorf("unsupported broker provider %q", provider) + } + if err := removeDeletedBrokerFromGlobalConfig(opts, provider, cfg.gcloudConfiguration); err != nil { + return err + } + return nil +} + +func parseBrokerDeleteArgs(args []string) (brokerDeleteOptions, error) { + var opts brokerDeleteOptions + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--provider": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.provider = normalizeSetupProvider(value) + if opts.provider == "" { + return opts, fmt.Errorf("unsupported broker provider %q", value) + } + case "--profile": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.profile = value + case "--region": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.region = value + case "--config": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.configPath = expandHome(value) + case "--firestore-database": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + opts.firestoreDatabase = value + case "--data": + opts.deleteData = true + case "--yes", "-y": + opts.yes = true + default: + return opts, fmt.Errorf("unsupported broker delete option %s", arg) + } + } + return opts, nil +} + +func deleteGCPBroker(ctx context.Context, cfg config, opts brokerDeleteOptions, stdout io.Writer) error { + region := firstNonEmpty(strings.TrimSpace(opts.region), defaultGCPRegion(cfg)) + fmt.Fprintf(stdout, "deleting GCP bgit broker function in %s\n", region) + cmd := gcloudCommand(cfg.gcloudConfiguration, "functions", "delete", "bgit-broker", "--gen2", "--region", region, "--quiet") + if out, err := cmd.CombinedOutput(); err != nil { + if !brokerDeleteMissing(string(out), err) { + return fmt.Errorf("delete GCP bgit broker function: %w\n%s", err, strings.TrimSpace(string(out))) + } + fmt.Fprintf(stdout, "GCP function bgit-broker was already absent\n") + } + runDelete := gcloudCommand(cfg.gcloudConfiguration, "run", "services", "delete", "bgit-broker", "--region", region, "--quiet") + if out, err := runDelete.CombinedOutput(); err != nil && !brokerDeleteMissing(string(out), err) { + return fmt.Errorf("delete GCP bgit broker Cloud Run service: %w\n%s", err, strings.TrimSpace(string(out))) + } + if opts.deleteData { + database := firstNonEmpty(strings.TrimSpace(opts.firestoreDatabase), os.Getenv("BGIT_FIRESTORE_DATABASE"), "bgit") + fmt.Fprintf(stdout, "deleting GCP Firestore database %s\n", database) + deleteDB := gcloudCommand(cfg.gcloudConfiguration, "firestore", "databases", "delete", "--database="+database, "--quiet") + if out, err := deleteDB.CombinedOutput(); err != nil && !brokerDeleteMissing(string(out), err) { + return fmt.Errorf("delete GCP Firestore database %s: %w\n%s", database, err, strings.TrimSpace(string(out))) + } + } + fmt.Fprintf(stdout, "deleted GCP bgit broker\n") + return nil +} + +func deleteAWSBroker(ctx context.Context, cfg config, opts brokerDeleteOptions, stdout io.Writer) error { + region := firstNonEmpty(strings.TrimSpace(opts.region), defaultAWSRegion()) + profile := strings.TrimSpace(firstNonEmpty(opts.profile, cfg.gcloudConfiguration)) + fmt.Fprintf(stdout, "deleting AWS CloudFormation stack bgit-broker in %s\n", region) + args := []string{"cloudformation", "delete-stack", "--stack-name", "bgit-broker", "--region", region} + if out, err := awsCommand(ctx, profile, args...).CombinedOutput(); err != nil { + if !brokerDeleteMissing(string(out), err) { + return fmt.Errorf("delete AWS bgit broker stack: %w\n%s", err, strings.TrimSpace(string(out))) + } + fmt.Fprintf(stdout, "AWS stack bgit-broker was already absent\n") + return nil + } + waitArgs := []string{"cloudformation", "wait", "stack-delete-complete", "--stack-name", "bgit-broker", "--region", region} + if out, err := awsCommand(ctx, profile, waitArgs...).CombinedOutput(); err != nil { + return fmt.Errorf("wait for AWS bgit broker stack deletion: %w\n%s", err, strings.TrimSpace(string(out))) + } + fmt.Fprintf(stdout, "deleted AWS bgit broker\n") + return nil +} + +func brokerDeleteMissing(out string, err error) bool { + message := strings.ToLower(out + "\n" + err.Error()) + return strings.Contains(message, "not found") || + strings.Contains(message, "not exist") || + strings.Contains(message, "does not exist") || + strings.Contains(message, "could not be found") || + strings.Contains(message, "resource not found") || + strings.Contains(message, "stack with id bgit-broker does not exist") +} + +func removeDeletedBrokerFromGlobalConfig(opts brokerDeleteOptions, provider, profile string) error { + path := opts.configPath + var err error + if path == "" { + path, err = defaultGlobalConfigPath() + if err != nil { + return err + } + } + global, err := readGlobalConfig(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + switch provider { + case "gcs": + for i := range global.GCPProfiles { + if profile == "" || global.GCPProfiles[i].Name == profile { + global.GCPProfiles[i].Regions = clearGlobalProfileRegion(global.GCPProfiles[i].Regions, opts.region) + } + } + case "s3": + for i := range global.AWSProfiles { + if profile == "" || global.AWSProfiles[i].Name == profile { + global.AWSProfiles[i].Regions = clearGlobalProfileRegion(global.AWSProfiles[i].Regions, opts.region) + } + } + } + return writeGlobalConfig(path, global) +} + +func clearGlobalProfileRegion(regions []globalProfileRegion, region string) []globalProfileRegion { + region = strings.TrimSpace(region) + if region == "" { + return nil + } + var out []globalProfileRegion + for _, entry := range regions { + if entry.Name != region { + out = append(out, entry) + } + } + return out +} + +func mapSetupProviderToConfig(provider string) string { + switch provider { + case "gcs": + return "gcs" + case "s3": + return "s3" + default: + return provider + } +} + +func bufioReadLine(stdin io.Reader) (string, error) { + reader := bufio.NewReader(stdin) + return reader.ReadString('\n') +} diff --git a/git_receive.go b/git_receive.go index 4cc839d..feb61e7 100644 --- a/git_receive.go +++ b/git_receive.go @@ -276,6 +276,9 @@ func applyReceivePackCommands(ctx context.Context, repo *nativeGitRepo, store wr } } else { delete(refs, cmd.ref) + if strings.HasPrefix(cmd.ref, "refs/heads/") { + _ = unsetGitBranchTracking(".", strings.TrimPrefix(cmd.ref, "refs/heads/")) + } } case "noop": default: diff --git a/global_config.go b/global_config.go new file mode 100644 index 0000000..af8e366 --- /dev/null +++ b/global_config.go @@ -0,0 +1,315 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +const globalConfigVersion = 1 + +type globalConfig struct { + Version int + Identity globalIdentityConfig + GCPProfiles []globalGCPProfile + AWSProfiles []globalAWSProfile + Repos []globalRepoConfig +} + +type globalIdentityConfig struct { + Name string + Email string +} + +type globalGCPProfile struct { + Name string + ProjectID string + Account string + Region string + ServiceAccount string + BrokerURL string + BrokerVersion string + LastSetupAt string + Regions []globalProfileRegion +} + +type globalAWSProfile struct { + Name string + AccountID string + ARN string + Region string + BrokerURL string + BrokerVersion string + LastSetupAt string + Regions []globalProfileRegion +} + +type globalProfileRegion struct { + Name string + BrokerURL string + BrokerVersion string + LastSetupAt string +} + +type globalRepoConfig struct { + Name string + Profile string + BrokerURL string +} + +func defaultGlobalConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".bgit", "config.yaml"), nil +} + +func readGlobalConfig(path string) (globalConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return globalConfig{}, err + } + cfg, err := parseGlobalConfigYAML(data) + if err != nil { + return cfg, err + } + normalizeGlobalConfigProfileRegions(&cfg) + return cfg, nil +} + +type globalConfigYAML struct { + Version int `yaml:"version"` + Identity globalIdentityYAML `yaml:"identity,omitempty"` + GCP globalGCPConfigYAML `yaml:"gcp,omitempty"` + AWS globalAWSConfigYAML `yaml:"aws,omitempty"` + Repos map[string]globalRepoYAML `yaml:"repos,omitempty"` +} + +type globalIdentityYAML struct { + Name string `yaml:"name,omitempty"` + Email string `yaml:"email,omitempty"` +} + +type globalGCPConfigYAML struct { + Profiles map[string]globalGCPProfileYAML `yaml:"profiles,omitempty"` +} + +type globalAWSConfigYAML struct { + Profiles map[string]globalAWSProfileYAML `yaml:"profiles,omitempty"` +} + +type globalGCPProfileYAML struct { + ProjectID string `yaml:"project_id,omitempty"` + Account string `yaml:"account,omitempty"` + ServiceAccount string `yaml:"service_account,omitempty"` + Regions map[string]globalProfileRegionYAML `yaml:"regions,omitempty"` +} + +type globalAWSProfileYAML struct { + AccountID string `yaml:"account_id,omitempty"` + ARN string `yaml:"arn,omitempty"` + Regions map[string]globalProfileRegionYAML `yaml:"regions,omitempty"` +} + +type globalProfileRegionYAML struct { + BrokerURL string `yaml:"broker_url,omitempty"` + BrokerVersion string `yaml:"broker_version,omitempty"` + LastSetupAt string `yaml:"last_setup_at,omitempty"` +} + +type globalRepoYAML struct { + Profile string `yaml:"profile,omitempty"` + BrokerURL string `yaml:"broker_url,omitempty"` +} + +func parseGlobalConfigYAML(data []byte) (globalConfig, error) { + var raw globalConfigYAML + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&raw); err != nil { + return globalConfig{}, err + } + return globalConfigFromYAML(raw), nil +} + +func globalConfigFromYAML(raw globalConfigYAML) globalConfig { + cfg := globalConfig{ + Version: raw.Version, + Identity: globalIdentityConfig{ + Name: raw.Identity.Name, + Email: raw.Identity.Email, + }, + } + if cfg.Version == 0 { + cfg.Version = globalConfigVersion + } + for name, profile := range raw.GCP.Profiles { + next := globalGCPProfile{ + Name: name, + ProjectID: profile.ProjectID, + Account: profile.Account, + ServiceAccount: profile.ServiceAccount, + } + for regionName, region := range profile.Regions { + next.Regions = append(next.Regions, globalProfileRegion{ + Name: regionName, + BrokerURL: region.BrokerURL, + BrokerVersion: region.BrokerVersion, + LastSetupAt: region.LastSetupAt, + }) + } + sortGlobalProfileRegions(next.Regions) + cfg.GCPProfiles = append(cfg.GCPProfiles, next) + } + for name, profile := range raw.AWS.Profiles { + next := globalAWSProfile{ + Name: name, + AccountID: profile.AccountID, + ARN: profile.ARN, + } + for regionName, region := range profile.Regions { + next.Regions = append(next.Regions, globalProfileRegion{ + Name: regionName, + BrokerURL: region.BrokerURL, + BrokerVersion: region.BrokerVersion, + LastSetupAt: region.LastSetupAt, + }) + } + sortGlobalProfileRegions(next.Regions) + cfg.AWSProfiles = append(cfg.AWSProfiles, next) + } + for name, repo := range raw.Repos { + cfg.Repos = append(cfg.Repos, globalRepoConfig{Name: name, Profile: repo.Profile, BrokerURL: repo.BrokerURL}) + } + sortGlobalConfig(&cfg) + return cfg +} + +func globalConfigToYAML(cfg globalConfig) globalConfigYAML { + normalizeGlobalConfigProfileRegions(&cfg) + sortGlobalConfig(&cfg) + out := globalConfigYAML{ + Version: cfg.Version, + Identity: globalIdentityYAML{ + Name: cfg.Identity.Name, + Email: cfg.Identity.Email, + }, + GCP: globalGCPConfigYAML{Profiles: map[string]globalGCPProfileYAML{}}, + AWS: globalAWSConfigYAML{Profiles: map[string]globalAWSProfileYAML{}}, + Repos: map[string]globalRepoYAML{}, + } + if out.Version == 0 { + out.Version = globalConfigVersion + } + for _, profile := range cfg.GCPProfiles { + next := globalGCPProfileYAML{ + ProjectID: profile.ProjectID, + Account: profile.Account, + ServiceAccount: profile.ServiceAccount, + Regions: map[string]globalProfileRegionYAML{}, + } + for _, region := range profile.Regions { + next.Regions[region.Name] = globalProfileRegionYAML{ + BrokerURL: region.BrokerURL, + BrokerVersion: region.BrokerVersion, + LastSetupAt: region.LastSetupAt, + } + } + out.GCP.Profiles[profile.Name] = next + } + for _, profile := range cfg.AWSProfiles { + next := globalAWSProfileYAML{ + AccountID: profile.AccountID, + ARN: profile.ARN, + Regions: map[string]globalProfileRegionYAML{}, + } + for _, region := range profile.Regions { + next.Regions[region.Name] = globalProfileRegionYAML{ + BrokerURL: region.BrokerURL, + BrokerVersion: region.BrokerVersion, + LastSetupAt: region.LastSetupAt, + } + } + out.AWS.Profiles[profile.Name] = next + } + for _, repo := range cfg.Repos { + out.Repos[repo.Name] = globalRepoYAML{Profile: repo.Profile, BrokerURL: repo.BrokerURL} + } + return out +} + +func sortGlobalConfig(cfg *globalConfig) { + sort.Slice(cfg.GCPProfiles, func(i, j int) bool { + return cfg.GCPProfiles[i].Name < cfg.GCPProfiles[j].Name + }) + for i := range cfg.GCPProfiles { + sortGlobalProfileRegions(cfg.GCPProfiles[i].Regions) + } + sort.Slice(cfg.AWSProfiles, func(i, j int) bool { + return cfg.AWSProfiles[i].Name < cfg.AWSProfiles[j].Name + }) + for i := range cfg.AWSProfiles { + sortGlobalProfileRegions(cfg.AWSProfiles[i].Regions) + } + sort.Slice(cfg.Repos, func(i, j int) bool { + return cfg.Repos[i].Name < cfg.Repos[j].Name + }) +} + +func sortGlobalProfileRegions(regions []globalProfileRegion) { + sort.Slice(regions, func(i, j int) bool { + return regions[i].Name < regions[j].Name + }) +} + +func writeGlobalConfig(path string, cfg globalConfig) error { + if cfg.Version == 0 { + cfg.Version = globalConfigVersion + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := yaml.Marshal(globalConfigToYAML(cfg)) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func normalizeGlobalConfigProfileRegions(cfg *globalConfig) { + for i := range cfg.GCPProfiles { + profile := &cfg.GCPProfiles[i] + if len(profile.Regions) == 0 && strings.TrimSpace(profile.BrokerURL) != "" { + profile.Regions = append(profile.Regions, globalProfileRegion{ + Name: firstNonEmpty(profile.Region, "us-central1"), + BrokerURL: profile.BrokerURL, + BrokerVersion: profile.BrokerVersion, + LastSetupAt: profile.LastSetupAt, + }) + } + profile.Region = "" + profile.BrokerURL = "" + profile.BrokerVersion = "" + profile.LastSetupAt = "" + } + for i := range cfg.AWSProfiles { + profile := &cfg.AWSProfiles[i] + if len(profile.Regions) == 0 && strings.TrimSpace(profile.BrokerURL) != "" { + profile.Regions = append(profile.Regions, globalProfileRegion{ + Name: firstNonEmpty(profile.Region, "us-east-1"), + BrokerURL: profile.BrokerURL, + BrokerVersion: profile.BrokerVersion, + LastSetupAt: profile.LastSetupAt, + }) + } + profile.Region = "" + profile.BrokerURL = "" + profile.BrokerVersion = "" + profile.LastSetupAt = "" + } +} diff --git a/global_config_test.go b/global_config_test.go new file mode 100644 index 0000000..711b306 --- /dev/null +++ b/global_config_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestGlobalConfigRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), ".bgit", "config.yaml") + want := globalConfig{ + Version: globalConfigVersion, + Identity: globalIdentityConfig{ + Name: "Dennis Example", + Email: "dennis@example.com", + }, + GCPProfiles: []globalGCPProfile{{ + Name: "work", + ProjectID: "example-test-123456", + Account: "dennis@example.com", + ServiceAccount: "bgit-broker@example-test-123456.iam.gserviceaccount.com", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://gcp.example.test", + BrokerVersion: brokerVersion, + LastSetupAt: "2026-05-16T10:00:00Z", + }}, + }}, + AWSProfiles: []globalAWSProfile{{ + Name: "work", + AccountID: "123456789012", + ARN: "arn:aws:iam::123456789012:user/dennis", + Regions: []globalProfileRegion{{ + Name: "us-east-1", + BrokerURL: "https://aws.example.test", + BrokerVersion: brokerVersion, + LastSetupAt: "2026-05-16T10:00:00Z", + }}, + }}, + Repos: []globalRepoConfig{{ + Name: "team/app.git", + Profile: "gcp:work", + BrokerURL: "https://gcp.example.test", + }}, + } + if err := writeGlobalConfig(path, want); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "gcp:") || + !strings.Contains(text, "identity:") || + !strings.Contains(text, "Dennis Example") || + !strings.Contains(text, "aws:") || + !strings.Contains(text, "profiles:") || + !strings.Contains(text, "work:") || + !strings.Contains(text, "regions:") || + !strings.Contains(text, "europe-west1:") || + !strings.Contains(text, "us-east-1:") { + t.Fatalf("config format =\n%s", string(data)) + } + got, err := readGlobalConfig(path) + if err != nil { + t.Fatal(err) + } + if got.Version != want.Version || + len(got.GCPProfiles) != 1 || + len(got.AWSProfiles) != 1 || + len(got.Repos) != 1 || + got.Identity != want.Identity { + t.Fatalf("cfg = %#v", got) + } + if !reflect.DeepEqual(got.GCPProfiles[0], want.GCPProfiles[0]) { + t.Fatalf("gcp profile = %#v", got.GCPProfiles[0]) + } + if !reflect.DeepEqual(got.AWSProfiles[0], want.AWSProfiles[0]) { + t.Fatalf("aws profile = %#v", got.AWSProfiles[0]) + } + if got.Repos[0] != want.Repos[0] { + t.Fatalf("repo = %#v", got.Repos[0]) + } +} + +func TestDefaultGlobalConfigPathUsesYAML(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + got, err := defaultGlobalConfigPath() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".bgit", "config.yaml") + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } +} + +func TestReadGlobalConfigDoesNotFallBackToLegacyConfig(t *testing.T) { + dir := filepath.Join(t.TempDir(), ".bgit") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + legacyPath := filepath.Join(dir, "config") + data := `version = 1 + +[[gcp.profiles]] +name = "work" +region = "europe-west1" +broker_url = "https://gcp.example.test" +` + if err := os.WriteFile(legacyPath, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + _, err := readGlobalConfig(filepath.Join(dir, "config.yaml")) + if err == nil { + t.Fatal("expected missing config.yaml error") + } +} + +func TestGlobalConfigRejectsUnknownKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + data := "version: 1\ngcp:\n unknown: value\n" + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + _, err := readGlobalConfig(path) + if err == nil || !strings.Contains(err.Error(), "field unknown not found") { + t.Fatalf("err = %v", err) + } +} diff --git a/go.mod b/go.mod index b7b68ff..4277fb4 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,9 @@ require ( cloud.google.com/go/iam v1.1.12 cloud.google.com/go/storage v1.43.0 golang.org/x/oauth2 v0.22.0 + golang.org/x/term v0.22.0 google.golang.org/api v0.191.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -14,6 +16,7 @@ require ( cloud.google.com/go/auth v0.8.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect cloud.google.com/go/compute/metadata v0.5.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect diff --git a/go.sum b/go.sum index 9eafada..50084f0 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ cloud.google.com/go/longrunning v0.5.11/go.mod h1:rDn7//lmlfWV1Dx6IB4RatCPenTwwm cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs= cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= @@ -157,6 +159,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= diff --git a/identity.go b/identity.go new file mode 100644 index 0000000..68d0ccb --- /dev/null +++ b/identity.go @@ -0,0 +1,167 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "regexp" + "strings" +) + +const defaultBucketGitIdentityName = "BucketGit Client" + +var identityEmailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + +type identityConfig struct { + Name string + Email string + UsesDefault bool +} + +func defaultBucketGitIdentityEmail() string { + username := firstNonEmpty(os.Getenv("USER"), os.Getenv("USERNAME"), "username") + username = strings.ToLower(strings.TrimSpace(username)) + var clean strings.Builder + for _, r := range username { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' { + clean.WriteRune(r) + } + } + if clean.Len() == 0 { + return "username@bucketgit.com" + } + return clean.String() + "@bucketgit.com" +} + +func readGlobalIdentity() globalIdentityConfig { + path, err := defaultGlobalConfigPath() + if err != nil { + return globalIdentityConfig{} + } + cfg, err := readGlobalConfig(path) + if err != nil { + return globalIdentityConfig{} + } + return cfg.Identity +} + +func effectiveRepositoryIdentity(repo *localRepository) identityConfig { + name := firstNonEmpty(os.Getenv("GIT_AUTHOR_NAME"), repo.configValue("user.name")) + email := firstNonEmpty(os.Getenv("GIT_AUTHOR_EMAIL"), repo.configValue("user.email")) + if name == "" || email == "" { + global := readGlobalIdentity() + name = firstNonEmpty(name, global.Name) + email = firstNonEmpty(email, global.Email) + } + defaultEmail := defaultBucketGitIdentityEmail() + name = firstNonEmpty(name, defaultBucketGitIdentityName) + email = firstNonEmpty(email, defaultEmail) + return identityConfig{ + Name: name, + Email: email, + UsesDefault: name == defaultBucketGitIdentityName || email == defaultEmail, + } +} + +func (r *localRepository) identityValue(key string) string { + if value := r.configValue(key); value != "" { + return value + } + global := readGlobalIdentity() + switch key { + case "user.name": + return global.Name + case "user.email": + return global.Email + default: + return "" + } +} + +func maybeConfigureIdentityBeforePush(stdin io.Reader, stdout io.Writer) error { + repo, err := openLocalRepository(".") + if err != nil { + return nil + } + identity := effectiveRepositoryIdentity(repo) + if !identity.UsesDefault { + return nil + } + fmt.Fprintf(stdout, "BucketGit is using the default identity %s <%s>.\n", identity.Name, identity.Email) + fmt.Fprintln(stdout, "You have not configured your name and email address yet.") + fmt.Fprintf(stdout, "Configure a global BucketGit identity now? [Y/n] ") + reader := bufio.NewReader(stdin) + answer, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return err + } + answer = strings.ToLower(strings.TrimSpace(answer)) + if errors.Is(err, io.EOF) && answer == "" { + fmt.Fprintf(stdout, "Continuing as %s <%s>.\n", identity.Name, identity.Email) + return nil + } + if answer == "n" || answer == "no" { + fmt.Fprintf(stdout, "Continuing as %s <%s>.\n", identity.Name, identity.Email) + return nil + } + name, email, err := readIdentityFields(reader, stdout, "", "") + if err != nil { + return err + } + if name == "" || email == "" { + fmt.Fprintf(stdout, "Continuing as %s <%s>.\n", identity.Name, identity.Email) + return nil + } + return writeGlobalIdentity(name, email) +} + +func readIdentityFields(reader *bufio.Reader, stdout io.Writer, currentName, currentEmail string) (string, string, error) { + fmt.Fprintf(stdout, "Name") + if currentName != "" { + fmt.Fprintf(stdout, " [%s]", currentName) + } + fmt.Fprint(stdout, ": ") + name, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", "", err + } + name = strings.TrimSpace(name) + if name == "" { + name = strings.TrimSpace(currentName) + } + fmt.Fprintf(stdout, "Email") + if currentEmail != "" { + fmt.Fprintf(stdout, " [%s]", currentEmail) + } + fmt.Fprint(stdout, ": ") + email, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", "", err + } + email = strings.TrimSpace(email) + if email == "" { + email = strings.TrimSpace(currentEmail) + } + if email != "" && !identityEmailPattern.MatchString(email) { + return "", "", fmt.Errorf("email address %q looks invalid", email) + } + return name, email, nil +} + +func writeGlobalIdentity(name, email string) error { + path, err := defaultGlobalConfigPath() + if err != nil { + return err + } + cfg, err := readGlobalConfig(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + cfg = globalConfig{Version: globalConfigVersion} + } + cfg.Identity = globalIdentityConfig{Name: strings.TrimSpace(name), Email: strings.TrimSpace(email)} + return writeGlobalConfig(path, cfg) +} diff --git a/local_config.go b/local_config.go index 8745d7c..a31917f 100644 --- a/local_config.go +++ b/local_config.go @@ -99,6 +99,91 @@ func parseConfigArgs(args []string) (configOptions, error) { return opts, nil } +func configArgsAreGlobal(args []string) bool { + for _, arg := range args { + if arg == "--global" { + return true + } + } + return false +} + +func globalConfigCommand(args []string, stdout io.Writer) error { + opts, err := parseGlobalConfigArgs(args) + if err != nil { + return err + } + path, err := defaultGlobalConfigPath() + if err != nil { + return err + } + cfg, err := readGlobalConfig(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + cfg = globalConfig{Version: globalConfigVersion} + } + if opts.list { + if cfg.Identity.Name != "" { + fmt.Fprintf(stdout, "user.name=%s\n", cfg.Identity.Name) + } + if cfg.Identity.Email != "" { + fmt.Fprintf(stdout, "user.email=%s\n", cfg.Identity.Email) + } + return nil + } + if opts.unset { + switch opts.key { + case "user.name": + cfg.Identity.Name = "" + case "user.email": + cfg.Identity.Email = "" + default: + return fmt.Errorf("unsupported global config key %s", opts.key) + } + return writeGlobalConfig(path, cfg) + } + if opts.value == nil { + switch opts.key { + case "user.name": + if cfg.Identity.Name != "" { + fmt.Fprintln(stdout, cfg.Identity.Name) + } + case "user.email": + if cfg.Identity.Email != "" { + fmt.Fprintln(stdout, cfg.Identity.Email) + } + default: + return fmt.Errorf("unsupported global config key %s", opts.key) + } + return nil + } + switch opts.key { + case "user.name": + cfg.Identity.Name = *opts.value + case "user.email": + if !identityEmailPattern.MatchString(*opts.value) { + return fmt.Errorf("email address %q looks invalid", *opts.value) + } + cfg.Identity.Email = *opts.value + default: + return fmt.Errorf("unsupported global config key %s", opts.key) + } + return writeGlobalConfig(path, cfg) +} + +func parseGlobalConfigArgs(args []string) (configOptions, error) { + var filtered []string + for _, arg := range args { + if arg == "--global" { + continue + } + filtered = append(filtered, arg) + } + return parseConfigArgs(filtered) +} + func readLocalConfigFile(path string) (localConfigFile, error) { cfg := localConfigFile{sections: map[string]map[string]string{}} data, err := os.ReadFile(path) diff --git a/local_extra.go b/local_extra.go index 74c287f..bf062fb 100644 --- a/local_extra.go +++ b/local_extra.go @@ -37,6 +37,7 @@ type renameChange struct { func (r *localRepository) diff(args []string, stdout io.Writer) error { cached := false + var revisions []string for _, arg := range args { switch arg { case "--cached", "--staged": @@ -45,14 +46,23 @@ func (r *localRepository) diff(args []string, stdout io.Writer) error { if strings.HasPrefix(arg, "-") { return fmt.Errorf("unsupported diff option %s", arg) } + revisions = append(revisions, arg) } } + if len(revisions) > 2 { + return errors.New("diff accepts at most two revisions") + } idx, err := r.readIndex() if err != nil { return err } var left map[string]string - if cached { + if len(revisions) > 0 { + left, err = r.revisionTreeFiles(revisions[0]) + if err != nil { + return err + } + } else if cached { left, err = r.headTreeFiles() if err != nil { return err @@ -64,25 +74,53 @@ func (r *localRepository) diff(args []string, stdout io.Writer) error { } } right := map[string]string{} - if cached { + if len(revisions) == 2 { + right, err = r.revisionTreeFiles(revisions[1]) + if err != nil { + return err + } + } else if cached { for _, entry := range idx.entries { right[entry.path] = entry.hash } } else { + paths := map[string]struct{}{} + for path := range left { + paths[path] = struct{}{} + } for _, entry := range idx.entries { - hash, err := r.writeBlobFromWorktree(entry.path) + paths[entry.path] = struct{}{} + } + for path := range paths { + hash, err := r.writeBlobFromWorktree(path) if errors.Is(err, fs.ErrNotExist) { continue } if err != nil { return err } - right[entry.path] = hash + right[path] = hash } } return r.printDiff(left, right, stdout) } +func (r *localRepository) revisionTreeFiles(revision string) (map[string]string, error) { + hash, err := r.resolveRevision(revision) + if err != nil { + return nil, fmt.Errorf("unknown revision %q", revision) + } + commit, err := r.commitObject(hash) + if err != nil { + return nil, err + } + files := map[string]string{} + if err := r.collectTreeFiles(commit.tree, "", files); err != nil { + return nil, err + } + return files, nil +} + func (r *localRepository) headTreeFiles() (map[string]string, error) { files := map[string]string{} if head, err := r.resolveRevision("HEAD"); err == nil { @@ -1178,6 +1216,131 @@ func simpleLineDiff(left, right string) []string { if left == right { return nil } + if len(a)*len(b) > 900000 { + return simpleWholeFileDiff(a, b) + } + ops := simpleLineDiffOps(a, b) + hunks := simpleLineDiffHunks(ops, 3) + var out []string + for _, hunk := range hunks { + oldStart, oldCount, newStart, newCount := simpleDiffHunkRange(ops[hunk.start:hunk.end]) + out = append(out, fmt.Sprintf("@@ %s %s @@", hunkRangeFrom(oldStart, oldCount, "-"), hunkRangeFrom(newStart, newCount, "+"))) + for _, op := range ops[hunk.start:hunk.end] { + out = append(out, string(op.kind)+op.text) + } + } + return out +} + +type simpleDiffOp struct { + kind byte + text string + oldLine int + newLine int +} + +type simpleDiffHunk struct { + start int + end int +} + +func simpleLineDiffOps(a, b []string) []simpleDiffOp { + rows := make([][]int, len(a)+1) + for i := range rows { + rows[i] = make([]int, len(b)+1) + } + for i := len(a) - 1; i >= 0; i-- { + for j := len(b) - 1; j >= 0; j-- { + if a[i] == b[j] { + rows[i][j] = rows[i+1][j+1] + 1 + } else if rows[i+1][j] >= rows[i][j+1] { + rows[i][j] = rows[i+1][j] + } else { + rows[i][j] = rows[i][j+1] + } + } + } + i, j := 0, 0 + var ops []simpleDiffOp + for i < len(a) || j < len(b) { + switch { + case i < len(a) && j < len(b) && a[i] == b[j]: + ops = append(ops, simpleDiffOp{kind: ' ', text: a[i], oldLine: i + 1, newLine: j + 1}) + i++ + j++ + case i < len(a) && (j == len(b) || rows[i+1][j] >= rows[i][j+1]): + ops = append(ops, simpleDiffOp{kind: '-', text: a[i], oldLine: i + 1, newLine: j + 1}) + i++ + case j < len(b): + ops = append(ops, simpleDiffOp{kind: '+', text: b[j], oldLine: i + 1, newLine: j + 1}) + j++ + } + } + return ops +} + +func simpleLineDiffHunks(ops []simpleDiffOp, context int) []simpleDiffHunk { + var hunks []simpleDiffHunk + for i, op := range ops { + if op.kind == ' ' { + continue + } + start := i - context + if start < 0 { + start = 0 + } + end := i + context + 1 + if end > len(ops) { + end = len(ops) + } + if len(hunks) > 0 && start <= hunks[len(hunks)-1].end { + if end > hunks[len(hunks)-1].end { + hunks[len(hunks)-1].end = end + } + continue + } + hunks = append(hunks, simpleDiffHunk{start: start, end: end}) + } + return hunks +} + +func simpleDiffHunkRange(ops []simpleDiffOp) (int, int, int, int) { + oldStart, newStart := 0, 0 + oldCount, newCount := 0, 0 + for _, op := range ops { + if op.kind != '+' { + if oldStart == 0 { + oldStart = op.oldLine + } + oldCount++ + } + if op.kind != '-' { + if newStart == 0 { + newStart = op.newLine + } + newCount++ + } + } + if oldStart == 0 && len(ops) > 0 { + oldStart = ops[0].oldLine - 1 + } + if newStart == 0 && len(ops) > 0 { + newStart = ops[0].newLine - 1 + } + return oldStart, oldCount, newStart, newCount +} + +func hunkRangeFrom(start, count int, prefix string) string { + if count == 0 { + return prefix + strconv.Itoa(start) + ",0" + } + if count == 1 { + return prefix + strconv.Itoa(start) + } + return prefix + strconv.Itoa(start) + "," + strconv.Itoa(count) +} + +func simpleWholeFileDiff(a, b []string) []string { var out []string out = append(out, fmt.Sprintf("@@ %s %s @@", hunkRange("-", len(a)), hunkRange("+", len(b)))) for _, line := range a { @@ -1264,8 +1427,8 @@ func (r *localRepository) findPathInTree(treeHash, path string) (string, error) } func (r *localRepository) commitWithParents(treeHash string, parents []string, message string) (string, error) { - authorName := firstNonEmpty(os.Getenv("GIT_AUTHOR_NAME"), r.configValue("user.name"), "bgit") - authorEmail := firstNonEmpty(os.Getenv("GIT_AUTHOR_EMAIL"), r.configValue("user.email"), "bgit@example.com") + authorName := firstNonEmpty(os.Getenv("GIT_AUTHOR_NAME"), r.identityValue("user.name"), defaultBucketGitIdentityName) + authorEmail := firstNonEmpty(os.Getenv("GIT_AUTHOR_EMAIL"), r.identityValue("user.email"), defaultBucketGitIdentityEmail()) committerName := firstNonEmpty(os.Getenv("GIT_COMMITTER_NAME"), authorName) committerEmail := firstNonEmpty(os.Getenv("GIT_COMMITTER_EMAIL"), authorEmail) now := time.Now() diff --git a/local_native.go b/local_native.go index 8263fbf..b40bed0 100644 --- a/local_native.go +++ b/local_native.go @@ -555,8 +555,8 @@ func (r *localRepository) commit(args []string, stdout io.Writer) error { } } after := idx.treeFiles() - authorName := firstNonEmpty(os.Getenv("GIT_AUTHOR_NAME"), r.configValue("user.name"), "bgit") - authorEmail := firstNonEmpty(os.Getenv("GIT_AUTHOR_EMAIL"), r.configValue("user.email"), "bgit@example.com") + authorName := firstNonEmpty(os.Getenv("GIT_AUTHOR_NAME"), r.identityValue("user.name"), defaultBucketGitIdentityName) + authorEmail := firstNonEmpty(os.Getenv("GIT_AUTHOR_EMAIL"), r.identityValue("user.email"), defaultBucketGitIdentityEmail()) committerName := firstNonEmpty(os.Getenv("GIT_COMMITTER_NAME"), authorName) committerEmail := firstNonEmpty(os.Getenv("GIT_COMMITTER_EMAIL"), authorEmail) now := time.Now() @@ -620,30 +620,42 @@ func (r *localRepository) checkout(cmd string, args []string, stdout io.Writer) if err := r.writeRef(branchRef(opts.target), hash); err != nil { return err } + if err := setGitBranchTrackingIfOrigin(r.worktree, opts.target); err != nil { + return err + } + if err := r.checkoutCommit(hash); err != nil { + return err + } if err := r.setHEADSymbolic(branchRef(opts.target)); err != nil { return err } fmt.Fprintf(stdout, "Switched to a new branch '%s'\n", opts.target) - return r.checkoutCommit(hash) + return nil } if hash, err := r.resolveRevision(branchRef(opts.target)); err == nil { + if err := r.checkoutCommit(hash); err != nil { + return err + } if err := r.setHEADSymbolic(branchRef(opts.target)); err != nil { return err } fmt.Fprintf(stdout, "Switched to branch '%s'\n", opts.target) - return r.checkoutCommit(hash) + return nil } hash, err := r.resolveRevision(opts.target) if err != nil { return err } + if err := r.checkoutCommit(hash); err != nil { + return err + } if err := os.WriteFile(filepath.Join(r.gitDir, "HEAD"), []byte(hash+"\n"), 0o644); err != nil { return err } if commit, err := r.commitObject(hash); err == nil { fmt.Fprintf(stdout, "HEAD is now at %s %s\n", hash[:7], commit.subject) } - return r.checkoutCommit(hash) + return nil } func (r *localRepository) pathExistsInHead(path string) bool { @@ -714,7 +726,7 @@ func (r *localRepository) checkoutPaths(source string, paths []string) error { } func (r *localRepository) branch(args []string, stdout io.Writer) error { - if len(args) == 0 { + if len(args) == 0 || (len(args) == 1 && args[0] == "-a") { branches, err := r.listRefs("refs/heads") if err != nil { return err @@ -728,6 +740,15 @@ func (r *localRepository) branch(args []string, stdout io.Writer) error { } fmt.Fprintln(stdout, prefix+short) } + if len(args) == 1 { + remotes, err := r.listRefs("refs/remotes") + if err != nil { + return err + } + for _, name := range remotes { + fmt.Fprintln(stdout, " "+strings.TrimPrefix(name, "refs/")) + } + } return nil } if args[0] == "-d" || args[0] == "-D" { @@ -739,6 +760,9 @@ func (r *localRepository) branch(args []string, stdout io.Writer) error { if err := r.deleteRef(ref); err != nil { return err } + if err := unsetGitBranchTracking(r.worktree, args[1]); err != nil { + return err + } short := hash if len(short) > 7 { short = short[:7] @@ -757,7 +781,10 @@ func (r *localRepository) branch(args []string, stdout io.Writer) error { if err != nil { return err } - return r.writeRef(branchRef(args[0]), hash) + if err := r.writeRef(branchRef(args[0]), hash); err != nil { + return err + } + return setGitBranchTrackingIfOrigin(r.worktree, args[0]) } func (r *localRepository) tag(args []string, stdout io.Writer) error { @@ -814,8 +841,8 @@ func (r *localRepository) tag(args []string, stdout io.Writer) error { return errors.New("annotated tags require -m") } now := time.Now() - name := firstNonEmpty(r.configValue("user.name"), "bgit") - email := firstNonEmpty(r.configValue("user.email"), "bgit@example.com") + name := firstNonEmpty(r.identityValue("user.name"), defaultBucketGitIdentityName) + email := firstNonEmpty(r.identityValue("user.email"), defaultBucketGitIdentityEmail()) var buf bytes.Buffer fmt.Fprintf(&buf, "object %s\n", hash) fmt.Fprintf(&buf, "type commit\n") @@ -1150,40 +1177,76 @@ func (r *localRepository) checkoutCommit(hash string) error { if err != nil { return err } - files := map[string]string{} - if err := r.collectTreeFiles(commit.tree, "", files); err != nil { + files := map[string]treeFile{} + if err := r.collectTreeFileEntries(commit.tree, "", files); err != nil { return err } current, err := r.readIndex() if err != nil { return err } + dirty, err := r.dirtyTrackedFiles(current) + if err != nil { + return err + } + var conflicts []string for _, entry := range current.entries { - if _, ok := files[entry.path]; !ok { + target, ok := files[entry.path] + _, isDirty := dirty[entry.path] + if isDirty && (!ok || target.hash != entry.hash) { + conflicts = append(conflicts, entry.path) + continue + } + if !ok && !isDirty { err := os.Remove(filepath.Join(r.worktree, filepath.FromSlash(entry.path))) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } } } + if len(conflicts) > 0 { + sort.Strings(conflicts) + return checkoutLocalChangesError(conflicts) + } idx := gitIndex{} - for path, blobHash := range files { - obj, err := r.storeObject(blobHash) - if err != nil { - return err + for path, meta := range files { + if _, isDirty := dirty[path]; !isDirty { + if err := r.writeBlobToWorktree(meta.hash, path); err != nil { + return err + } } - target := filepath.Join(r.worktree, filepath.FromSlash(path)) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err + idx.entries = append(idx.entries, indexEntry{path: path, hash: meta.hash, mode: meta.mode}) + } + idx.sort() + return r.writeIndex(idx) +} + +func (r *localRepository) dirtyTrackedFiles(idx gitIndex) (map[string]string, error) { + dirty := map[string]string{} + for _, entry := range idx.entries { + hash, err := r.writeBlobFromWorktree(entry.path) + if errors.Is(err, fs.ErrNotExist) { + dirty[entry.path] = "" + continue } - if err := os.WriteFile(target, obj.data, 0o644); err != nil { - return err + if err != nil { + return nil, err } - if err := r.addPathToIndex(&idx, path); err != nil { - return err + if hash != entry.hash { + dirty[entry.path] = hash } } - return r.writeIndex(idx) + return dirty, nil +} + +func checkoutLocalChangesError(paths []string) error { + var b strings.Builder + b.WriteString("Your local changes to the following files would be overwritten by checkout:\n") + for _, path := range paths { + fmt.Fprintf(&b, "\t%s\n", path) + } + b.WriteString("Please commit your changes or stash them before you switch branches.") + return errors.New(b.String()) } func (r *localRepository) commitObject(hash string) (commitObject, error) { diff --git a/main.go b/main.go index 2991687..a971d42 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( const defaultBranch = "main" const defaultAuthMode = "gcloud" +const brokerVersion = "1.0.0-dev" var version = "dev" @@ -30,8 +31,13 @@ type config struct { prefix string branch string origin string + brokerURL string + logicalRepo string + region string auth string gcloudConfiguration string + identity string + direct bool authExplicit bool gcloudConfigurationExplicit bool versionRequested bool @@ -55,6 +61,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } return usage(stderr) } + setBrokerIdentityPreference(cfg.identity) if cfg.versionRequested { return versionCommand(stdout) } @@ -77,48 +84,127 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { if isUnsupportedCommand(cmd) && !(cmd == "show" && explicitBucket) { return unsupportedCommand(cmd) } - if cmd == "origin" { - return originCommand(cmdArgs, stdout) + if cmd == "origin" || cmd == "remote" { + return fmt.Errorf("bgit %s is direct bucket configuration; use bgit direct %s", cmd, strings.Join(append([]string{cmd}, cmdArgs...), " ")) } - if cmd == "remote" { - return remoteCommand(cmdArgs, stdout) + if cmd == "direct" { + cfg.direct = true + return directCommand(context.Background(), cfg, cmdArgs, stdin, stdout) } if cmd == "admin" { - return adminCommand(cfg, cmdArgs, stdout) + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) + } + if cfg.gcloudConfigurationExplicit { + if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { + return err + } + } + return brokerAdminCommandWithInput(cfg, cmdArgs, stdin, stdout) + } + if cmd == "janitor" { + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) + } + if cfg.gcloudConfigurationExplicit { + if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { + return err + } + } + return janitorCommand(cfg, cmdArgs, stdout) } if cmd == "ssh" { return sshCommand(cfg, cmdArgs, stdout, stderr) } + if cmd == "import-gh-user" || cmd == "create-gcloud-profile" || cmd == "create-aws-profile" { + return fmt.Errorf("unknown command %q", cmd) + } + if cmd == "setup" { + return setupCommand(context.Background(), cfg, cmdArgs, stdin, stdout) + } + if cmd == "broker" { + return brokerCommand(context.Background(), cfg, cmdArgs, stdin, stdout) + } + if cmd == "repos" { + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) + } + if cfg.gcloudConfigurationExplicit { + if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { + return err + } + } + return reposCommand(context.Background(), cfg, cmdArgs, stdout) + } + if cmd == "pr" { + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) + } + return prCommand(cmdArgs, stdin, stdout) + } + if cmd == "issue" || cmd == "issues" { + if localCfg, err := readLocalConfig("."); err == nil { + cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) + } + return issueCommand(cmdArgs, stdin, stdout) + } if cmd == "web" { return webCommand(context.Background(), cfg, cmdArgs, stdout) } - if cmd == "create-gcloud-profile" { - return createGcloudProfileCommand(cmdArgs, stdin, stdout) + if cmd == "config" && configArgsAreGlobal(cmdArgs) { + return globalConfigCommand(cmdArgs, stdout) } if isLocalGitCommand(cmd) || (!explicitBucket && isPreferLocalGitCommand(cmd)) { return nativeLocalCommand(cmd, cmdArgs, stdout) } + if cfg.authExplicit { + return errors.New("--auth is only supported with bgit direct") + } + if explicitBucket { + return errors.New("direct bucket operations require bgit direct; run bgit direct help") + } - ctx := context.Background() if cmd == "clone" { - return cloneCommand(ctx, cfg, cmdArgs, stdout) + cmdArgs = mergeBrokerSelectionArgs(cmdArgs, cfg) + return brokerCloneCommand(cmdArgs, stdin, stdout) } - if cmd == "init" && cfg.bucket == "" { - return initEmptyWorktree(cmdArgs, stdout) + if cmd == "init" { + cmdArgs = mergeBrokerSelectionArgs(cmdArgs, cfg) + return brokerInitCommand(cmdArgs, stdin, stdout) } if cfg.bucket == "" { localCfg, err := readLocalConfig(".") if err == nil { cfg = mergeConfig(cfg, localCfg) + setBrokerIdentityPreference(cfg.identity) } } - if cfg.bucket == "" { + if cmd == "whoami" { + if cfg.gcloudConfigurationExplicit { + if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { + return err + } + } + return whoamiCommand(context.Background(), cfg, cmdArgs, stdout) + } + if !cfg.direct && cfg.gcloudConfigurationExplicit && isNativeRemoteCommand(cmd) { + if err := applyExplicitBrokerProfileSelection(&cfg, cmd); err != nil { + return err + } + } + if cfg.bucket == "" && cfg.brokerURL == "" { if cmd == "push" { return missingOriginError() } return errors.New("--bucket is required outside a bucketgit checkout") } + ctx := context.Background() store, closeStore, err := newRemoteStore(ctx, cfg, isReadOnlyRemoteCommand(cmd)) if err != nil { return fmt.Errorf("create remote store: %w", err) @@ -126,7 +212,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { defer closeStore() if isNativeRemoteCommand(cmd) { - if commandCreatesBucket(cmd) || cmd == "push" { + if cfg.brokerURL == "" && (commandCreatesBucket(cmd) || cmd == "push") { if err := ensureBucket(ctx, cfg); err != nil { return err } @@ -140,6 +226,9 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { case "pull": return repo.pull(ctx, cmdArgs, stdout) case "push": + if err := maybeConfigureIdentityBeforePush(stdin, stdout); err != nil { + return err + } return repo.push(ctx, cmdArgs, stdout) case "ls-remote": return repo.lsRemote(ctx, cmdArgs, stdout) @@ -157,6 +246,136 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return fmt.Errorf("unknown command %q", cmd) } +func applyExplicitBrokerProfileSelection(cfg *config, cmd string) error { + path, err := defaultGlobalConfigPath() + if err != nil { + return err + } + global, err := readGlobalConfig(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + profiles := brokerProfilesFromGlobalConfig(global) + if len(profiles) == 0 { + return nil + } + profile, err := selectBrokerProfileForCommand(profiles, cfg.gcloudConfiguration, cfg.region, "bgit "+cmd) + if err != nil { + return err + } + cfg.provider = profile.Provider + cfg.brokerURL = profile.BrokerURL + cfg.region = profile.Region + cfg.gcloudConfiguration = profile.QualifiedName + if cfg.logicalRepo == "" && cfg.prefix != "" { + cfg.logicalRepo = strings.Trim(cfg.prefix, "/") + } + return nil +} + +func mergeBrokerProfileArg(args []string, cfg config) []string { + return mergeBrokerSelectionArgs(args, cfg) +} + +func mergeBrokerSelectionArgs(args []string, cfg config) []string { + merged := append([]string{}, args...) + for i := 0; i < len(args); i++ { + arg := args[i] + name, _, _ := strings.Cut(arg, "=") + switch name { + case "--profile": + cfg.gcloudConfigurationExplicit = false + case "--region": + cfg.region = "" + } + } + if cfg.gcloudConfigurationExplicit && strings.TrimSpace(cfg.gcloudConfiguration) != "" { + merged = append(merged, "--profile", cfg.gcloudConfiguration) + } + if strings.TrimSpace(cfg.region) != "" { + merged = append(merged, "--region", cfg.region) + } + return merged +} + +func directCommand(ctx context.Context, cfg config, args []string, stdin io.Reader, stdout io.Writer) error { + cfg.direct = true + if len(args) == 0 { + return errors.New("usage: bgit direct clone|init|fetch|pull|push|ls-remote|ls|cat|show|log|put|admin [args]") + } + cmd := args[0] + cmdArgs := args[1:] + if cmd == "help" || cmd == "-h" || cmd == "--help" { + return commandHelp("direct", stdout) + } + if cmd == "admin" { + return adminCommand(cfg, cmdArgs, stdout) + } + if cmd == "origin" { + return originCommand(cmdArgs, stdout) + } + if cmd == "remote" { + return remoteCommand(cmdArgs, stdout) + } + if cmd == "clone" { + return cloneCommand(ctx, cfg, cmdArgs, stdout) + } + if cmd == "init" && cfg.bucket == "" { + return initEmptyWorktree(cmdArgs, stdout) + } + if cfg.bucket == "" { + localCfg, err := readLocalConfig(".") + if err == nil { + cfg = mergeConfig(cfg, localCfg) + } + } + if cfg.bucket == "" { + if cmd == "push" { + return missingOriginError() + } + return errors.New("--bucket is required outside a bucketgit checkout") + } + store, closeStore, err := newRemoteStore(ctx, cfg, isReadOnlyRemoteCommand(cmd)) + if err != nil { + return fmt.Errorf("create remote store: %w", err) + } + defer closeStore() + if !isNativeRemoteCommand(cmd) { + return fmt.Errorf("unknown direct command %q", cmd) + } + if commandCreatesBucket(cmd) || cmd == "push" { + if err := ensureBucket(ctx, cfg); err != nil { + return err + } + } + repo := openNativeGitRepo(store, cfg) + switch cmd { + case "init": + return repo.initWorktree(ctx, cmdArgs, stdout) + case "fetch": + return repo.fetch(ctx, cmdArgs, stdout) + case "pull": + return repo.pull(ctx, cmdArgs, stdout) + case "push": + return repo.push(ctx, cmdArgs, stdout) + case "ls-remote": + return repo.lsRemote(ctx, cmdArgs, stdout) + case "ls", "list": + return repo.listFiles(ctx, cmdArgs, stdout) + case "cat", "show": + return repo.catFile(ctx, cmdArgs, stdout) + case "log": + return repo.log(ctx, cmdArgs, stdout) + case "put": + return repo.putFile(ctx, cmdArgs, stdin, stdout) + default: + return fmt.Errorf("unknown direct command %q", cmd) + } +} + func isNativeRemoteCommand(cmd string) bool { switch cmd { case "init", "fetch", "pull", "push", "ls-remote", "ls", "list", "cat", "show", "log", "put": @@ -222,6 +441,15 @@ func extractGlobalFlags(args []string, cfg *config) ([]string, error) { value = args[i] } cfg.branch = value + case "--region": + if !hasValue { + i++ + if i >= len(args) { + return nil, errors.New("--region requires a value") + } + value = args[i] + } + cfg.region = value case "--auth": if !hasValue { i++ @@ -242,6 +470,15 @@ func extractGlobalFlags(args []string, cfg *config) ([]string, error) { } cfg.gcloudConfiguration = value cfg.gcloudConfigurationExplicit = true + case "--identity": + if !hasValue { + i++ + if i >= len(args) { + return nil, errors.New("--identity requires a value") + } + value = args[i] + } + cfg.identity = value case "--version", "-v": cfg.versionRequested = true default: @@ -276,6 +513,15 @@ func mergeConfig(primary, fallback config) config { if primary.prefix == "" { primary.prefix = fallback.prefix } + if primary.brokerURL == "" { + primary.brokerURL = fallback.brokerURL + } + if primary.logicalRepo == "" { + primary.logicalRepo = fallback.logicalRepo + } + if primary.region == "" { + primary.region = fallback.region + } if primary.branch == "" || primary.branch == defaultBranch { primary.branch = fallback.branch } @@ -291,6 +537,9 @@ func mergeConfig(primary, fallback config) config { if !primary.gcloudConfigurationExplicit && fallback.gcloudConfiguration != "" { primary.gcloudConfiguration = fallback.gcloudConfiguration } + if primary.identity == "" { + primary.identity = fallback.identity + } return primary } @@ -337,6 +586,38 @@ func readLocalConfig(dir string) (config, error) { branch = defaultBranch } localAuth := defaultBranchAuth(dir) + brokerURL := "" + if brokerOut, brokerErr := runGit(dir, "config", "--get", "bucketgit.broker"); brokerErr == nil { + brokerURL = strings.TrimSpace(string(brokerOut)) + } + logicalRepo := "" + if logicalOut, logicalErr := runGit(dir, "config", "--get", "bucketgit.logicalRepo"); logicalErr == nil { + logicalRepo = strings.Trim(strings.TrimSpace(string(logicalOut)), "/") + } + localRegion := "" + if regionOut, regionErr := runGit(dir, "config", "--get", "bucketgit.region"); regionErr == nil { + localRegion = strings.TrimSpace(string(regionOut)) + } + localProvider := "" + if providerOut, providerErr := runGit(dir, "config", "--get", "bucketgit.provider"); providerErr == nil { + localProvider = strings.TrimSpace(string(providerOut)) + } + if brokerURL != "" && logicalRepo != "" { + identity := localIdentityPreference(dir) + provider := firstNonEmpty(localProvider, "gcs") + return config{ + provider: provider, + prefix: logicalRepo, + branch: branch, + origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, logicalRepo), + brokerURL: brokerURL, + logicalRepo: logicalRepo, + region: localRegion, + identity: identity, + auth: localAuth.auth, + gcloudConfiguration: localAuth.gcloudConfiguration, + }, nil + } originOut, originErr := runGit(dir, "config", "--get", "bucketgit.origin") if originErr == nil { @@ -344,6 +625,7 @@ func readLocalConfig(dir string) (config, error) { cfg, _, err := parseRepoURI(origin) if err == nil { cfg.branch = branch + cfg.region = localRegion cfg.auth = localAuth.auth cfg.gcloudConfiguration = localAuth.gcloudConfiguration return cfg, nil @@ -358,6 +640,7 @@ func readLocalConfig(dir string) (config, error) { cfg, _, parseErr := parseRepoURI(origin) if parseErr == nil { cfg.branch = branch + cfg.region = localRegion cfg.auth = localAuth.auth cfg.gcloudConfiguration = localAuth.gcloudConfiguration return cfg, nil @@ -371,23 +654,33 @@ func readLocalConfig(dir string) (config, error) { } bucket := strings.TrimSpace(string(bucketOut)) prefix := strings.Trim(strings.TrimSpace(string(prefixOut)), "/") - provider := "gcs" - if providerOut, err := runGit(dir, "config", "--get", "bucketgit.provider"); err == nil { - if value := strings.TrimSpace(string(providerOut)); value != "" { - provider = value - } - } + provider := firstNonEmpty(localProvider, "gcs") return config{ provider: provider, bucket: bucket, prefix: prefix, branch: branch, origin: originForConfig(config{provider: provider, bucket: bucket, prefix: prefix}), + brokerURL: brokerURL, + logicalRepo: logicalRepo, + region: localRegion, auth: localAuth.auth, gcloudConfiguration: localAuth.gcloudConfiguration, }, nil } +func localIdentityPreference(dir string) string { + for _, key := range []string{"bucketgit.sshKeyFingerprint", "bucketgit.sshKey", "bucketgit.identity"} { + out, err := runGit(dir, "config", "--get", key) + if err == nil { + if value := strings.TrimSpace(string(out)); value != "" { + return value + } + } + } + return "" +} + func defaultBranchAuth(dir string) config { cfg := config{auth: defaultAuthMode} if out, err := runGit(dir, "config", "--get", "bucketgit.auth"); err == nil { @@ -411,16 +704,16 @@ func missingOriginError() error { return errors.New(`No configured push destination. Either specify the repository from the command-line: - bgit --bucket bucket-name --prefix path/to/repo.git push + bgit --bucket bucket-name --prefix path/to/repo.git direct push -or configure a bgit origin: +or configure a direct bgit origin: - bgit origin gs://bucket-name/path/to/repo.git - bgit origin s3://bucket-name/path/to/repo.git + bgit direct origin gs://bucket-name/path/to/repo.git + bgit direct origin s3://bucket-name/path/to/repo.git and then push: - bgit push`) + bgit direct push`) } func newStorageClient(ctx context.Context, cfg config) (*storage.Client, error) { @@ -455,6 +748,21 @@ func isReadOnlyRemoteCommand(cmd string) bool { } func newRemoteStore(ctx context.Context, cfg config, publicFallback bool) (gitRemoteStore, func(), error) { + if !cfg.direct { + if cfg.brokerURL == "" { + if out, err := runGit(".", "config", "--get", "bucketgit.broker"); err == nil { + cfg.brokerURL = strings.TrimSpace(string(out)) + } + } + if cfg.logicalRepo == "" { + if out, err := runGit(".", "config", "--get", "bucketgit.logicalRepo"); err == nil { + cfg.logicalRepo = strings.Trim(strings.TrimSpace(string(out)), "/") + } + } + if cfg.brokerURL != "" { + return &brokerGitStore{brokerURL: cfg.brokerURL, cfg: cfg}, func() {}, nil + } + } provider := cfg.provider if provider == "" { provider = "gcs" @@ -549,32 +857,57 @@ func gcloudCommand(configuration string, args ...string) *exec.Cmd { func usage(w io.Writer) error { _, err := fmt.Fprint(w, `usage: bgit [args] -common commands: - clone gs://bucket/prefix.git [directory] - clone s3://bucket/prefix.git [directory] - init [directory] - origin gs://bucket/prefix.git - origin s3://bucket/prefix.git - ssh setup [gs://bucket/prefix.git|s3://bucket/prefix.git] - web [--addr 127.0.0.1] [--port 8042] [--local] - admin grant-read|grant-write|grant-admin IDENTITY - create-gcloud-profile NAME - fetch | pull | push | ls-remote - status | add | commit | checkout | branch | merge | tag - diff | log | show | reset | restore | stash | revert - grep | blame | cherry-pick | clean | describe - ls-files | ls-tree | archive | config | rev-parse | rm | mv - -direct GCS mode: - bgit --bucket BUCKET --prefix PREFIX ls [prefix] - bgit --bucket BUCKET --prefix PREFIX cat [--commit SHA] path - bgit --bucket BUCKET --prefix PREFIX log [--limit N] [--skip N] [--path PATH] - put path [--file FILE] -m MSG --author NAME --email EMAIL +These are common BucketGit commands: + +start a repository + setup Connect a cloud account and deploy or update BucketGit + init Create a local Git repository backed by BucketGit + clone Clone a BucketGit repository into a new directory + +work on the current change + add Add file contents to the index + mv Move or rename a file, directory, or symlink + restore Restore working tree files + rm Remove files from the working tree and index + +examine history and state + diff Show changes between commits, commit and working tree, etc + grep Print lines matching a pattern + log Show commit logs + show Show objects + status Show the working tree status + +grow, mark, and tweak history + branch List, create, or delete branches + checkout Switch branches or restore paths + commit Record changes to the repository + merge Join development histories together + reset Reset HEAD, index, or working tree state + tag Create, list, delete, or verify tags + +collaborate + fetch Download objects and refs from BucketGit + pull Fetch and integrate with the current branch + push Update remote refs and upload objects + ls-remote List remote refs + pr Create, review, merge, and close pull requests + issue Create, comment on, close, and reopen issues + +administer + whoami Show broker identity, role, and capabilities for this repo + repos List repositories visible to local SSH keys + admin Manage broker-backed users, keys, owners, and protection + janitor Run broker maintenance and repair tasks + broker Delete or decommission deployed broker infrastructure + web Browse a repository locally global options: --profile NAME - --auth gcloud|adc + --identity KEY_OR_FINGERPRINT --version + +Legacy direct bucket operations are under "bgit direct". +Run "bgit help " or "bgit direct help" for details. `) return err } @@ -619,101 +952,176 @@ func commandHelp(cmd string, stdout io.Writer) error { func helpPages() map[string]string { return map[string]string{ - "clone": `usage: bgit clone gs://bucket/prefix.git [directory] - bgit clone s3://bucket/prefix.git [directory] + "clone": `usage: + bgit clone [directory] + bgit clone https://broker.example.com/team/app.git [directory] + bgit clone --broker https://broker.example.com team/app.git [directory] -Clone a bucketgit repository from object storage into a local worktree. -The origin is stored in .git/config so later bgit fetch, pull, and push -commands can infer it. +Clone a BucketGit repository by logical repo name. Passing a broker URL makes +the checkout self-contained and does not require a local profile. Direct +object-storage clone moved to bgit direct clone. examples: - bgit clone gs://my-bucket/repositories/app.git - bgit clone s3://my-bucket/repositories/app.git --profile aws-profile - bgit clone gs://my-bucket/repositories/app.git ./app - bgit --branch develop clone gs://my-bucket/repositories/app.git + bgit clone team/app.git + bgit clone https://bgit-broker.example.com/team/app.git + bgit direct clone gs://my-bucket/repositories/app.git + bgit direct clone s3://my-bucket/repositories/app.git --profile aws-profile `, - "init": `usage: bgit init [directory] + "init": `usage: + bgit init + bgit init --noninteractive --repo NAME --profile PROFILE[.REGION] [--region REGION] [directory] -Create a local Git repository. This does not require an origin. Configure one -later with bgit origin before pushing. +Create a local Git repository and attach it to a BucketGit repository from +~/.bgit/config.yaml. Without --noninteractive, init prompts for missing repo, +profile, and region choices. examples: bgit init - bgit init ./app - bgit origin gs://my-bucket/repositories/app.git - bgit push + bgit init --noninteractive --repo app --profile gcp:work.europe-west1 + bgit init --noninteractive --repo app --profile work --region europe-west1 +`, + "setup": `usage: + bgit setup + bgit setup --yes [--provider gcp|aws] [--profile NAME] [--key PATH] [--region REGION] + bgit setup profile create --provider gcp|aws NAME + +Discover cloud profiles, deploy or update a bgit broker, import owner SSH keys, +and write the global BucketGit config at ~/.bgit/config.yaml. + +GCP profiles are discovered from gcloud configurations. AWS profiles are +discovered from AWS config/credentials files and aws configure list-profiles +when the AWS CLI is available. + +examples: + bgit setup + bgit setup profile create --provider gcp work + bgit setup --yes --provider gcp --profile work --key ~/.ssh/id_ed25519.pub + bgit setup --yes --provider aws --profile production --region us-east-1 +`, + "broker": `usage: + bgit broker delete --provider gcp --profile NAME [--region REGION] [--data] --yes + bgit broker delete --provider aws --profile NAME [--region REGION] --yes + +Delete deployed bgit broker infrastructure for a selected cloud profile. +AWS deletes the CloudFormation stack and waits for deletion. GCP deletes the +Gen2 function/Cloud Run service; pass --data to also delete the Firestore +broker database. `, "origin": `usage: - bgit origin gs://bucket/prefix.git - bgit origin s3://bucket/prefix.git + bgit direct origin gs://bucket/prefix.git + bgit direct origin s3://bucket/prefix.git -Set the bucketgit origin for the current local Git repository. This also +Set a direct bucketgit origin for the current local Git repository. This also sets the regular Git remote named origin to the same URL for visibility. examples: - bgit origin gs://my-bucket/repositories/app.git - bgit origin s3://my-bucket/repositories/app.git --profile aws-profile + bgit direct origin gs://my-bucket/repositories/app.git + bgit direct origin s3://my-bucket/repositories/app.git --profile aws-profile git remote -v `, "remote": `usage: - bgit remote add origin gs://bucket/prefix.git - bgit remote add origin s3://bucket/prefix.git - bgit remote set-url origin gs://bucket/prefix.git + bgit direct remote add origin gs://bucket/prefix.git + bgit direct remote add origin s3://bucket/prefix.git + bgit direct remote set-url origin gs://bucket/prefix.git -Configure the bucketgit origin using Git remote syntax. +Configure a direct bucketgit origin using Git remote syntax. `, "admin": `usage: - bgit admin grant-read IDENTITY - bgit admin grant-write IDENTITY - bgit admin grant-admin IDENTITY - bgit admin make-public - bgit admin make-private - bgit admin --bucket BUCKET grant-write IDENTITY + bgit admin keys list|add|remove|suspend|import-github [args] + bgit admin invite-user --broker URL --user USER [--role ROLE] REPO + bgit admin accept-invite CODE + bgit admin cancel-invite --broker URL --user USER REPO + bgit admin confirm-ownership-transfer --broker URL REPO + bgit admin accept-ownership-transfer CODE + bgit admin cancel-ownership-transfer [--broker URL REPO] + bgit admin protect add|list|remove [ref] + bgit admin repo visibility public|private + bgit admin repo readonly on|off + bgit admin repo issues on|off + bgit admin repo rename NEW_LOGICAL_NAME + bgit admin repo delete --yes + +Broker-backed repository administration. Cloud IAM and bucket-policy +administration moved to bgit direct admin. -Grant bucket access or toggle public read access for GCS or S3 repositories. -Run inside a bgit checkout to infer the bucket and prefix, or pass --bucket -explicitly. - -For GCS, IDENTITY may be user@example.com, user:user@example.com, -serviceAccount:name@project.iam.gserviceaccount.com, group:team@example.com, -allUsers, or allAuthenticatedUsers. +examples: + bgit admin keys list + bgit admin keys add --user ada --role developer --key ~/.ssh/ada.pub + bgit admin keys import-github octocat --role read + bgit admin invite-user --broker https://broker.example.com --user ada --role developer app + bgit admin protect add main + bgit admin repo visibility public + bgit direct admin grant-read user:dev@example.com +`, + "issue": `usage: + bgit issue list + bgit issue create TITLE [--body BODY] + bgit issue view ID + bgit issue comment ID COMMENT + bgit issue close ID + bgit issue reopen ID + +Broker-backed repository issues. Public repositories allow anonymous issue +creation; private repositories require membership. +`, + "pr": `usage: + bgit pr create [--title TITLE] [--body BODY] [--source BRANCH] [--target BRANCH] + bgit pr list + bgit pr view ID + bgit pr checkout ID + bgit pr diff ID + bgit pr merge ID [--delete-branch] + bgit pr close ID + bgit pr comment ID COMMENT + bgit pr approve ID [COMMENT] + bgit pr reject ID [COMMENT] + +Broker-backed pull request metadata and merge/ref protection workflow. +Pull requests are stored in the broker control plane, not in Git itself. +`, + "whoami": `usage: bgit whoami [--json] [--refresh] [--all] -For S3, IDENTITY must be an IAM/STS ARN, a 12 digit AWS account ID, or *. +Show the SSH identity, repo role, and broker capabilities for the current +broker-backed repository. Results are cached under ~/.bgit/cache//. +Use --all to list repositories visible to the SSH keys currently loaded in +ssh-agent. +`, + "repos": `usage: bgit repos mine [--json] -grant-read grants object read access plus bucket/prefix listing. -grant-write grants object read/write/delete access plus bucket/prefix listing. -grant-admin grants storage admin access on the bucket or repository prefix. -make-public grants anonymous read access. -make-private removes bgit-managed anonymous read access. +List repositories visible to the SSH keys currently loaded in ssh-agent using +the broker membership index. +`, + "janitor": `usage: bgit janitor members reindex -examples: - bgit admin grant-read user:dev@example.com - bgit admin grant-write serviceAccount:ci@project.iam.gserviceaccount.com - bgit admin --bucket my-bucket grant-admin admin@example.com - bgit admin make-public - bgit admin make-private - bgit admin --bucket s3://my-bucket/repositories/app.git grant-read arn:aws:iam::123456789012:role/Developer +Broker maintenance and repair commands. These commands rebuild derived broker +metadata from authoritative repo state and are not needed for normal use. +`, + "direct": `usage: + bgit direct help + bgit direct clone gs://bucket/prefix.git [directory] + bgit direct clone s3://bucket/prefix.git [directory] + bgit direct origin gs://bucket/prefix.git + bgit direct remote add origin s3://bucket/prefix.git + bgit direct fetch|pull|push|ls-remote + bgit direct ls|cat|show|log|put [args] + bgit direct admin grant-read|grant-write|grant-admin IDENTITY + +Low-level object-storage and cloud IAM escape hatch for legacy direct bucket +operations, recovery, migration, and debugging. Normal BucketGit workflows +should use setup, init, git transport, and admin commands. + +Direct mode also owns --bucket/--prefix and --auth gcloud|adc. `, "ssh": `usage: - bgit ssh setup [--broker URL] [--region REGION] [--firestore-database NAME] [--firestore-location LOCATION] [--key PATH] [--no-agent] [gs://bucket/prefix.git|s3://bucket/prefix.git] - bgit ssh scaffold [--broker URL] [gs://bucket/prefix.git|s3://bucket/prefix.git] - bgit ssh repo add [--broker URL] [--key PATH] [repo] - bgit ssh keys list|add|remove|suspend [--broker URL] [--key PATH] [repo] - -Configure the current repository so normal git fetch/push uses bgit as the SSH -transport command. The setup command also records public keys from ssh-agent or ---key for a future broker-backed authorization flow. When --broker is omitted, -setup looks for an existing bgit-broker endpoint in the selected cloud account -and region. Setup also upserts the repository into the broker with discovered -SSH identities under an admin user. + bgit ssh git-upload-pack + bgit ssh git-receive-pack + +Internal SSH transport used by Git for BucketGit remotes. Most users should not +run this command directly; bgit init writes the required core.sshCommand config. examples: - bgit ssh setup gs://my-bucket/repositories/app.git - bgit ssh setup s3://my-bucket/repositories/app.git --profile aws-profile --key ~/.ssh/id_ed25519.pub - bgit ssh repo add --key ~/.ssh/id_ed25519.pub - bgit ssh keys add --user ada --role write --key ~/.ssh/ada.pub - bgit ssh keys list - bgit ssh scaffold + git fetch origin + git push origin main `, "web": `usage: bgit web [--addr ADDR] [--port PORT] [--local] @@ -727,15 +1135,6 @@ examples: bgit web bgit web --port 8042 bgit web --local -`, - "create-gcloud-profile": `usage: bgit create-gcloud-profile [--yes] NAME - -Create a gcloud configuration, run gcloud auth login for that configuration, -and save it as bucketgit.profile in the current checkout when run inside one. - -examples: - bgit create-gcloud-profile my-profile - bgit create-gcloud-profile --yes my-profile `, "fetch": `usage: bgit fetch @@ -766,21 +1165,21 @@ examples: List refs from the configured object-storage repository. `, - "ls": `usage: bgit --bucket BUCKET --prefix PREFIX ls [path-prefix] + "ls": `usage: bgit --bucket BUCKET --prefix PREFIX direct ls [path-prefix] -Direct GCS mode: list files at the configured branch without a checkout. +Direct bucket mode: list files at the configured branch without a checkout. `, - "list": `usage: bgit --bucket BUCKET --prefix PREFIX list [path-prefix] + "list": `usage: bgit --bucket BUCKET --prefix PREFIX direct list [path-prefix] -Direct GCS mode: list files at the configured branch without a checkout. +Direct bucket mode: list files at the configured branch without a checkout. `, - "cat": `usage: bgit --bucket BUCKET --prefix PREFIX cat [--commit SHA] path + "cat": `usage: bgit --bucket BUCKET --prefix PREFIX direct cat [--commit SHA] path -Direct GCS mode: print one file from the configured branch or commit. +Direct bucket mode: print one file from the configured branch or commit. `, - "put": `usage: bgit --bucket BUCKET --prefix PREFIX put path [--file FILE] -m MSG --author NAME --email EMAIL + "put": `usage: bgit --bucket BUCKET --prefix PREFIX direct put path [--file FILE] -m MSG --author NAME --email EMAIL -Direct GCS mode: write one file and commit it to the GCS-backed repository. +Direct bucket mode: write one file and commit it to the bucket-backed repository. Use --file - or omit --file to read content from stdin. `, } @@ -827,16 +1226,16 @@ func createGcloudProfileCommand(args []string, stdin io.Reader, stdout io.Writer yes = true default: if strings.HasPrefix(arg, "-") { - return fmt.Errorf("unsupported create-gcloud-profile option %s", arg) + return fmt.Errorf("unsupported setup profile create option %s", arg) } if profile != "" { - return errors.New("create-gcloud-profile accepts exactly one profile name") + return errors.New("setup profile create accepts exactly one profile name") } profile = arg } } if profile == "" { - return errors.New("create-gcloud-profile requires a profile name") + return errors.New("setup profile create requires a profile name") } if !yes { fmt.Fprintf(stdout, "Create gcloud configuration %q, run browser login, and save it in this checkout if possible? [y/N] ", profile) @@ -864,20 +1263,90 @@ func createGcloudProfileCommand(args []string, stdin io.Reader, stdout io.Writer return nil } +func createAWSProfileCommand(args []string, stdin io.Reader, stdout io.Writer) error { + yes := false + var profile string + for _, arg := range args { + switch arg { + case "-y", "--yes": + yes = true + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported setup profile create option %s", arg) + } + if profile != "" { + return errors.New("setup profile create accepts exactly one profile name") + } + profile = arg + } + } + if profile == "" { + return errors.New("setup profile create requires a profile name") + } + if !yes { + fmt.Fprintf(stdout, "Create or update AWS profile %q with aws configure? [y/N] ", profile) + var answer string + _, _ = fmt.Fscanln(stdin, &answer) + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer != "y" && answer != "yes" { + return errors.New("aborted") + } + } + if err := runAWSProfileCommand(stdout, "configure", "--profile", profile); err != nil { + return err + } + fmt.Fprintf(stdout, "created AWS profile %s\n", profile) + return nil +} + +func createAWSProfileConfigured(profile, accessKey, secretKey, region string, stdout io.Writer) error { + profile = strings.TrimSpace(profile) + accessKey = strings.TrimSpace(accessKey) + secretKey = strings.TrimSpace(secretKey) + region = strings.TrimSpace(region) + if profile == "" { + return errors.New("setup profile create requires a profile name") + } + if accessKey == "" { + return errors.New("AWS access key ID is required") + } + if secretKey == "" { + return errors.New("AWS secret access key is required") + } + fmt.Fprintf(stdout, "configuring AWS profile %s\n", profile) + if err := runAWSProfileCommand(stdout, "configure", "set", "aws_access_key_id", accessKey, "--profile", profile); err != nil { + return err + } + if err := runAWSProfileCommand(stdout, "configure", "set", "aws_secret_access_key", secretKey, "--profile", profile); err != nil { + return err + } + if region != "" { + if err := runAWSProfileCommand(stdout, "configure", "set", "region", region, "--profile", profile); err != nil { + return err + } + } + fmt.Fprintf(stdout, "created AWS profile %s\n", profile) + return nil +} + func runGcloudProfileCommand(stdout io.Writer, args ...string) error { cmd := exec.Command("gcloud", args...) cmd.Stdin = os.Stdin - var out bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &out + cmd.Stdout = stdout + cmd.Stderr = stdout if err := cmd.Run(); err != nil { - if out.Len() > 0 { - _, _ = stdout.Write(out.Bytes()) - } return fmt.Errorf("gcloud %s: %w", strings.Join(args, " "), err) } - if out.Len() > 0 { - _, _ = stdout.Write(out.Bytes()) + return nil +} + +func runAWSProfileCommand(stdout io.Writer, args ...string) error { + cmd := exec.Command("aws", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("aws %s failed: %w", strings.Join(args, " "), err) } return nil } @@ -1007,6 +1476,9 @@ func initEmptyWorktree(args []string, stdout io.Writer) error { if _, err := runGit(absTarget, "config", "--local", "bucketgit.branch", *branch); err != nil { return err } + if err := configureBucketGitLineEndings(absTarget); err != nil { + return err + } fmt.Fprintf(stdout, "Initialized empty Git repository in %s/\n", filepath.Join(absTarget, ".git")) return nil } @@ -1022,6 +1494,28 @@ func writeBucketGitConfig(worktree string, cfg config) error { {"bucketgit.prefix", cfg.prefix}, {"bucketgit.branch", cfg.branch}, } + if strings.TrimSpace(cfg.auth) != "" && cfg.auth != defaultAuthMode { + pairs = append(pairs, []string{"bucketgit.auth", cfg.auth}) + } + if strings.TrimSpace(cfg.gcloudConfiguration) != "" { + pairs = append(pairs, []string{"bucketgit.profile", cfg.gcloudConfiguration}) + } + for _, pair := range pairs { + if _, err := runGit(worktree, "config", "--local", pair[0], pair[1]); err != nil { + return err + } + } + if err := configureBucketGitLineEndings(worktree); err != nil { + return err + } + return nil +} + +func configureBucketGitLineEndings(worktree string) error { + pairs := [][]string{ + {"core.autocrlf", "false"}, + {"core.eol", "lf"}, + } for _, pair := range pairs { if _, err := runGit(worktree, "config", "--local", pair[0], pair[1]); err != nil { return err @@ -1042,6 +1536,41 @@ func setGitOrigin(worktree string, origin string) error { return err } +func setGitBranchTracking(worktree, branch, remote string) error { + branch = shortBranchName(firstNonEmpty(branch, defaultBranch)) + remote = firstNonEmpty(strings.TrimSpace(remote), "origin") + pairs := [][]string{ + {"branch." + branch + ".remote", remote}, + {"branch." + branch + ".merge", branchRef(branch)}, + } + for _, pair := range pairs { + if _, err := runGit(worktree, "config", "--local", pair[0], pair[1]); err != nil { + return err + } + } + return nil +} + +func setGitBranchTrackingIfOrigin(worktree, branch string) error { + if _, err := runGit(worktree, "remote", "get-url", "origin"); err != nil { + return nil + } + return setGitBranchTracking(worktree, branch, "origin") +} + +func unsetGitBranchTracking(worktree, branch string) error { + branch = shortBranchName(strings.TrimSpace(branch)) + if branch == "" { + return nil + } + for _, key := range []string{"branch." + branch + ".remote", "branch." + branch + ".merge"} { + if _, err := runGit(worktree, "config", "--local", "--unset-all", key); err != nil { + continue + } + } + return nil +} + func originForConfig(cfg config) string { if cfg.origin != "" { return cfg.origin @@ -1129,6 +1658,7 @@ type pushOptions struct { force bool delete bool skipBroker bool + remote string refs []string } @@ -1142,6 +1672,10 @@ func parsePushArgs(args []string) (pushOptions, error) { opts.force = true case "--delete", "-d": opts.delete = true + case "--set-upstream", "-u": + // bgit records the configured remote in the worktree. Accept Git's + // common upstream flag for CLI compatibility, but there is nothing + // extra to persist in the object-store ref update path. case "--skip-broker": opts.skipBroker = true default: @@ -1154,6 +1688,10 @@ func parsePushArgs(args []string) (pushOptions, error) { } } } + if len(opts.refs) > 0 && opts.refs[0] == "origin" { + opts.remote = opts.refs[0] + opts.refs = opts.refs[1:] + } if opts.delete && len(opts.refs) == 0 { return opts, errors.New("push --delete requires at least one branch or ref") } diff --git a/main_test.go b/main_test.go index dd34037..d571dd2 100644 --- a/main_test.go +++ b/main_test.go @@ -3,7 +3,6 @@ package main import ( "bytes" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -53,6 +52,18 @@ func TestParseGlobalFlags(t *testing.T) { } } +func setTestHome(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", home) + if volume := filepath.VolumeName(home); volume != "" { + t.Setenv("HOMEDRIVE", volume) + t.Setenv("HOMEPATH", strings.TrimPrefix(home, volume)) + } + } +} + func TestParseGlobalBucketURLInfersProviderAndPrefix(t *testing.T) { cfg, rest, err := parseGlobalFlags([]string{ "admin", @@ -186,7 +197,7 @@ func TestHelpCommandPages(t *testing.T) { if err := run([]string{"help", "clone"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "usage: bgit clone gs://bucket/prefix.git") { + if !strings.Contains(stdout.String(), "bgit clone [directory]") { t.Fatalf("clone help = %q", stdout.String()) } @@ -194,7 +205,7 @@ func TestHelpCommandPages(t *testing.T) { if err := run([]string{"clone", "help"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "usage: bgit clone gs://bucket/prefix.git") { + if !strings.Contains(stdout.String(), "bgit clone [directory]") { t.Fatalf("clone help alias = %q", stdout.String()) } @@ -202,7 +213,7 @@ func TestHelpCommandPages(t *testing.T) { if err := run([]string{"--help", "clone"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "usage: bgit clone gs://bucket/prefix.git") { + if !strings.Contains(stdout.String(), "bgit clone [directory]") { t.Fatalf("--help clone = %q", stdout.String()) } @@ -210,16 +221,16 @@ func TestHelpCommandPages(t *testing.T) { if err := run([]string{"clone", "--help"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "usage: bgit clone gs://bucket/prefix.git") { + if !strings.Contains(stdout.String(), "bgit clone [directory]") { t.Fatalf("clone --help = %q", stdout.String()) } stdout.Reset() - if err := run([]string{"help", "create-gcloud-profile"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { + if err := run([]string{"help", "setup"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "usage: bgit create-gcloud-profile") { - t.Fatalf("create-gcloud-profile help = %q", stdout.String()) + if !strings.Contains(stdout.String(), "bgit setup profile create") { + t.Fatalf("setup help = %q", stdout.String()) } } @@ -234,6 +245,17 @@ func TestCreateGcloudProfileCommandRequiresConfirmation(t *testing.T) { } } +func TestCreateAWSProfileCommandRequiresConfirmation(t *testing.T) { + var stdout bytes.Buffer + err := createAWSProfileCommand([]string{"default"}, strings.NewReader("n\n"), &stdout) + if err == nil || !strings.Contains(err.Error(), "aborted") { + t.Fatalf("expected aborted, got %v", err) + } + if !strings.Contains(stdout.String(), "Create or update AWS profile") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + func TestConfigHelp(t *testing.T) { var stdout bytes.Buffer if err := run([]string{"help", "config"}, strings.NewReader(""), &stdout, ioDiscard{}); err != nil { @@ -523,6 +545,27 @@ func TestParsePushArgs(t *testing.T) { } } +func TestParsePushArgsAcceptsGitRemoteShape(t *testing.T) { + opts, err := parsePushArgs([]string{"-u", "origin", "feature/protection-check"}) + if err != nil { + t.Fatal(err) + } + if opts.remote != "origin" { + t.Fatalf("remote = %q", opts.remote) + } + if len(opts.refs) != 1 || opts.refs[0] != "feature/protection-check" { + t.Fatalf("refs = %#v", opts.refs) + } + + opts, err = parsePushArgs([]string{"--set-upstream", "origin"}) + if err != nil { + t.Fatal(err) + } + if opts.remote != "origin" || len(opts.refs) != 0 { + t.Fatalf("opts = %#v", opts) + } +} + func TestNoRefsErrorDetection(t *testing.T) { err := errors.New("git --git-dir /tmp/repo.git show-ref --tags: exit status 1") if !isNoRefs(err) { @@ -623,6 +666,18 @@ func TestInitWorktreeCreatesGitCheckout(t *testing.T) { if strings.TrimSpace(string(originOut)) != "gs://bucket/repos/demo.git" { t.Fatalf("origin = %q", string(originOut)) } + for key, want := range map[string]string{ + "branch.master.remote": "origin", + "branch.master.merge": "refs/heads/master", + } { + out, err := runGit(target, "config", "--local", "--get", key) + if err != nil { + t.Fatalf("%s: %v", key, err) + } + if got := strings.TrimSpace(string(out)); got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } + } } func TestOriginCommandWritesLocalConfigAndGitRemote(t *testing.T) { @@ -695,114 +750,7 @@ func TestOriginCommandWritesS3Provider(t *testing.T) { } } -func TestSSHSetupWritesLocalConfigAndGitRemote(t *testing.T) { - target := t.TempDir() - if _, err := runGit("", "init", target); err != nil { - t.Fatal(err) - } - keyPath := filepath.Join(t.TempDir(), "id_ed25519.pub") - if err := os.WriteFile(keyPath, []byte("ssh-ed25519 AAAATESTKEY ada@example.com\n"), 0o644); err != nil { - t.Fatal(err) - } - oldDir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - defer os.Chdir(oldDir) - if err := os.Chdir(target); err != nil { - t.Fatal(err) - } - var upsert brokerRepoRequest - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/repos/upsert" { - t.Fatalf("unexpected broker path %s", r.URL.Path) - } - if err := json.NewDecoder(r.Body).Decode(&upsert); err != nil { - t.Fatal(err) - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - var stdout bytes.Buffer - err = sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{ - "setup", - "gs://bucket-name/path/repo.git", - "--no-agent", - "--key", keyPath, - "--broker", server.URL, - }, &stdout, ioDiscard{}) - if err != nil { - t.Fatal(err) - } - remoteOut, err := runGit(target, "remote", "get-url", "origin") - if err != nil { - t.Fatal(err) - } - if got := strings.TrimSpace(string(remoteOut)); got != "git@git.bucketgit.com:bucket-name/path/repo.git" { - t.Fatalf("remote origin = %q", got) - } - for key, want := range map[string]string{ - "core.sshCommand": "bgit ssh", - "bucketgit.origin": "gs://bucket-name/path/repo.git", - "bucketgit.sshHost": "git.bucketgit.com", - "bucketgit.broker": server.URL, - "bucketgit.sshkey1": "ssh-ed25519 AAAATESTKEY ada@example.com", - } { - out, err := runGit(target, "config", "--local", key) - if err != nil { - t.Fatalf("read %s: %v", key, err) - } - if got := strings.TrimSpace(string(out)); got != want { - t.Fatalf("%s = %q, want %q", key, got, want) - } - } - if !strings.Contains(stdout.String(), "configured SSH origin git@git.bucketgit.com:bucket-name/path/repo.git") { - t.Fatalf("stdout = %q", stdout.String()) - } - if upsert.Repo.Origin != "gs://bucket-name/path/repo.git" || upsert.AdminUser != "admin" || upsert.Role != "admin" { - t.Fatalf("upsert = %#v", upsert) - } - if len(upsert.PublicKeys) != 1 || upsert.PublicKeys[0] != "ssh-ed25519 AAAATESTKEY ada@example.com" { - t.Fatalf("upsert keys = %#v", upsert.PublicKeys) - } -} - -func TestSSHScaffoldInfersExistingOrigin(t *testing.T) { - target := t.TempDir() - if _, err := runGit("", "init", target); err != nil { - t.Fatal(err) - } - oldDir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - defer os.Chdir(oldDir) - if err := os.Chdir(target); err != nil { - t.Fatal(err) - } - if err := originCommand([]string{"s3://bucket-name/path/repo.git"}, ioDiscard{}); err != nil { - t.Fatal(err) - } - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"scaffold"}, ioDiscard{}, ioDiscard{}); err != nil { - t.Fatal(err) - } - remoteOut, err := runGit(target, "remote", "get-url", "origin") - if err != nil { - t.Fatal(err) - } - if got := strings.TrimSpace(string(remoteOut)); got != "git@git.bucketgit.com:bucket-name/path/repo.git" { - t.Fatalf("remote origin = %q", got) - } - providerOut, err := runGit(target, "config", "--local", "bucketgit.provider") - if err != nil { - t.Fatal(err) - } - if got := strings.TrimSpace(string(providerOut)); got != "s3" { - t.Fatalf("provider = %q", got) - } -} - -func TestSSHRepoAddAndKeysCommandsUseBroker(t *testing.T) { +func TestSSHKeysCommandsUseBroker(t *testing.T) { target := t.TempDir() if _, err := runGit("", "init", target); err != nil { t.Fatal(err) @@ -816,7 +764,7 @@ func TestSSHRepoAddAndKeysCommandsUseBroker(t *testing.T) { requests = append(requests, r.URL.Path) w.Header().Set("content-type", "application/json") switch r.URL.Path { - case "/repos/upsert", "/keys/add", "/keys/remove", "/keys/suspend": + case "/keys/add", "/keys/remove", "/keys/suspend": w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) case "/keys/list": @@ -841,26 +789,23 @@ func TestSSHRepoAddAndKeysCommandsUseBroker(t *testing.T) { if _, err := runGit(target, "config", "--local", "bucketgit.broker", server.URL); err != nil { t.Fatal(err) } - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"repo", "add", "--no-agent", "--key", keyPath}, ioDiscard{}, ioDiscard{}); err != nil { - t.Fatal(err) - } - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"keys", "add", "--no-agent", "--key", keyPath, "--user", "ada", "--role", "write"}, ioDiscard{}, ioDiscard{}); err != nil { + if err := brokerAdminKeysCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"add", "--no-agent", "--key", keyPath, "--user", "ada", "--role", "write"}, strings.NewReader(""), ioDiscard{}); err != nil { t.Fatal(err) } var stdout bytes.Buffer - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"keys", "list"}, &stdout, ioDiscard{}); err != nil { + if err := brokerAdminKeysCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"list"}, strings.NewReader(""), &stdout); err != nil { t.Fatal(err) } if !strings.Contains(stdout.String(), "admin\tadmin\tactive\tssh-ed25519 AAAAADMIN admin@example.com") { t.Fatalf("keys list stdout = %q", stdout.String()) } - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"keys", "suspend", "AAAAADMIN"}, ioDiscard{}, ioDiscard{}); err != nil { + if err := brokerAdminKeysCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"suspend", "AAAAADMIN"}, strings.NewReader(""), ioDiscard{}); err != nil { t.Fatal(err) } - if err := sshCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"keys", "remove", "AAAAADMIN"}, ioDiscard{}, ioDiscard{}); err != nil { + if err := brokerAdminKeysCommand(config{auth: "gcloud", branch: defaultBranch}, []string{"remove", "AAAAADMIN"}, strings.NewReader(""), ioDiscard{}); err != nil { t.Fatal(err) } - want := []string{"/repos/upsert", "/keys/add", "/keys/list", "/keys/suspend", "/keys/remove"} + want := []string{"/keys/add", "/keys/list", "/keys/suspend", "/keys/remove"} if strings.Join(requests, ",") != strings.Join(want, ",") { t.Fatalf("requests = %#v", requests) } @@ -1067,11 +1012,18 @@ func TestProvisionGCPBrokerURLDeploysThenDiscoversFunction(t *testing.T) { bin := t.TempDir() marker := filepath.Join(t.TempDir(), "deployed") writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ - {match: "functions describe bgit-broker", stdout: "https://bgit-broker-provisioned.example.test", requireFile: marker, exitCode: 1}, + {match: "functions describe bgit-broker --gen2 --region europe-west1 --format=value(serviceConfig.uri)", stdout: "https://bgit-broker-provisioned.example.test", requireFile: marker, exitCode: 1}, {match: "services enable"}, + {match: "services list --enabled", stdout: "serviceusage.googleapis.com cloudresourcemanager.googleapis.com cloudfunctions.googleapis.com run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com firestore.googleapis.com iamcredentials.googleapis.com"}, {match: "firestore databases describe", exitCode: 1}, {match: "firestore databases create"}, - {match: "functions deploy bgit-broker", touch: marker}, + {match: "config get-value project", stdout: "project-id"}, + {match: "config get-value account", stdout: "ada@example.com"}, + {match: "iam service-accounts describe bgit-broker@project-id.iam.gserviceaccount.com", exitCode: 1}, + {match: "iam service-accounts create bgit-broker"}, + {match: "projects add-iam-policy-binding project-id --member=serviceAccount:bgit-broker@project-id.iam.gserviceaccount.com"}, + {match: "--service-account bgit-broker@project-id.iam.gserviceaccount.com", touch: marker}, + {match: "iam service-accounts add-iam-policy-binding bgit-broker@project-id.iam.gserviceaccount.com"}, }) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) var stdout bytes.Buffer @@ -1112,6 +1064,9 @@ func TestProvisionAWSBrokerURLDeploysThenDiscoversStackOutput(t *testing.T) { bin := t.TempDir() marker := filepath.Join(t.TempDir(), "deployed") writeFakeCLI(t, bin, "aws", []fakeCLIAction{ + {match: "sts get-caller-identity", stdout: `{"Account":"123456789012","Arn":"arn:aws:iam::123456789012:user/dennis"}`}, + {match: "s3api head-bucket --bucket bgit-broker-artifacts-123456789012-eu-west-1", exitCode: 1}, + {match: "s3api create-bucket --bucket bgit-broker-artifacts-123456789012-eu-west-1"}, {match: "cloudformation describe-stacks", stdout: "https://bgit-broker-provisioned-aws.example.test", requireFile: marker, exitCode: 1}, {match: "cloudformation deploy", touch: marker}, }) @@ -1130,86 +1085,140 @@ func TestProvisionAWSBrokerURLDeploysThenDiscoversStackOutput(t *testing.T) { } type fakeCLIAction struct { - match string - stdout string - exitCode int - touch string - requireFile string + match string + stdout string + missingStdout string + exitCode int + touch string + requireFile string + onlyIfFile string +} + +type fakeCLIActionJSON struct { + Match string `json:"match,omitempty"` + Stdout string `json:"stdout,omitempty"` + MissingStdout string `json:"missing_stdout,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + Touch string `json:"touch,omitempty"` + RequireFile string `json:"require_file,omitempty"` + OnlyIfFile string `json:"only_if_file,omitempty"` +} + +func (a fakeCLIAction) MarshalJSON() ([]byte, error) { + return json.Marshal(fakeCLIActionJSON{ + Match: a.match, + Stdout: a.stdout, + MissingStdout: a.missingStdout, + ExitCode: a.exitCode, + Touch: a.touch, + RequireFile: a.requireFile, + OnlyIfFile: a.onlyIfFile, + }) +} + +func (a *fakeCLIAction) UnmarshalJSON(data []byte) error { + var raw fakeCLIActionJSON + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *a = fakeCLIAction{ + match: raw.Match, + stdout: raw.Stdout, + missingStdout: raw.MissingStdout, + exitCode: raw.ExitCode, + touch: raw.Touch, + requireFile: raw.RequireFile, + onlyIfFile: raw.OnlyIfFile, + } + return nil } func writeFakeCLI(t *testing.T, dir, name string, actions []fakeCLIAction) { t.Helper() path := filepath.Join(dir, name) if runtime.GOOS == "windows" { - path += ".bat" + path += ".exe" } - var script strings.Builder - if runtime.GOOS == "windows" { - script.WriteString("@echo off\r\n") - script.WriteString("set ARGS=%*\r\n") - for _, action := range actions { - finalExitCode := fakeCLIFinalExitCode(action) - script.WriteString("echo %ARGS% | findstr /C:\"") - script.WriteString(escapeBatch(action.match)) - script.WriteString("\" >nul\r\n") - script.WriteString("if not errorlevel 1 (\r\n") - if action.requireFile != "" { - script.WriteString(" if not exist \"") - script.WriteString(escapeBatch(action.requireFile)) - script.WriteString("\" exit /b ") - script.WriteString(strconv.Itoa(firstNonZeroInt(action.exitCode, 1))) - script.WriteString("\r\n") - } - if action.touch != "" { - script.WriteString(" type nul > \"") - script.WriteString(escapeBatch(action.touch)) - script.WriteString("\"\r\n") - } - if action.stdout != "" { - script.WriteString(" echo ") - script.WriteString(action.stdout) - script.WriteString("\r\n") - } - script.WriteString(" exit /b ") - script.WriteString(strconv.Itoa(finalExitCode)) - script.WriteString("\r\n)\r\n") - } - script.WriteString("exit /b 1\r\n") - } else { - script.WriteString("#!/bin/sh\n") - script.WriteString("case \"$*\" in\n") - for _, action := range actions { - finalExitCode := fakeCLIFinalExitCode(action) - script.WriteString(" *\"") - script.WriteString(strings.ReplaceAll(action.match, `"`, `\"`)) - script.WriteString("\"*) ") - if action.requireFile != "" { - script.WriteString("[ -f '") - script.WriteString(strings.ReplaceAll(action.requireFile, `'`, `'\''`)) - script.WriteString("' ] || exit ") - script.WriteString(strconv.Itoa(firstNonZeroInt(action.exitCode, 1))) - script.WriteString(" ; ") + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o755); err != nil { + t.Fatal(err) + } + actionsData, err := json.Marshal(actions) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+".json", actionsData, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestMain(m *testing.M) { + if path, ok := fakeCLIActionPath(); ok { + os.Exit(runFakeCLI(path, os.Args[1:])) + } + os.Exit(m.Run()) +} + +func fakeCLIActionPath() (string, bool) { + exe, err := os.Executable() + if err != nil { + return "", false + } + path := exe + ".json" + if _, err := os.Stat(path); err == nil { + return path, true + } + return "", false +} + +func runFakeCLI(path string, args []string) int { + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + var actions []fakeCLIAction + if err := json.Unmarshal(data, &actions); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + joined := strings.Join(args, " ") + for _, action := range actions { + if !strings.Contains(joined, action.match) { + continue + } + if action.onlyIfFile != "" { + if _, err := os.Stat(action.onlyIfFile); err != nil { + continue } - if action.touch != "" { - script.WriteString("touch '") - script.WriteString(strings.ReplaceAll(action.touch, `'`, `'\''`)) - script.WriteString("' ; ") + } + if action.requireFile != "" { + if _, err := os.Stat(action.requireFile); err != nil { + if action.missingStdout != "" { + fmt.Fprintln(os.Stdout, action.missingStdout) + } + return firstNonZeroInt(action.exitCode, 1) } - if action.stdout != "" { - script.WriteString("echo ") - script.WriteString(action.stdout) - script.WriteString(" ; ") + } + if action.touch != "" { + if err := os.WriteFile(action.touch, nil, 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 } - script.WriteString("exit ") - script.WriteString(strconv.Itoa(finalExitCode)) - script.WriteString(" ;;\n") } - script.WriteString(" *) exit 1 ;;\n") - script.WriteString("esac\n") - } - if err := os.WriteFile(path, []byte(script.String()), 0o755); err != nil { - t.Fatal(err) + if action.stdout != "" { + fmt.Fprintln(os.Stdout, action.stdout) + } + return fakeCLIFinalExitCode(action) } + return 1 } func fakeCLIFinalExitCode(action fakeCLIAction) int { @@ -1228,12 +1237,6 @@ func firstNonZeroInt(values ...int) int { return 0 } -func escapeBatch(value string) string { - value = strings.ReplaceAll(value, `\`, `\\`) - value = strings.ReplaceAll(value, `"`, `\"`) - return value -} - func TestAWSBrokerCloudFormationTemplateHasBrokerOutput(t *testing.T) { template := awsBrokerCloudFormationTemplate() for _, want := range []string{ @@ -1251,6 +1254,8 @@ func TestAWSBrokerCloudFormationTemplateHasBrokerOutput(t *testing.T) { "/refs/update", "roleAllows", "ConditionalCheckFailedException", + "BROKER_VERSION: " + brokerVersion, + `version: brokerVersion`, } { if !strings.Contains(template, want) { t.Fatalf("template missing %q:\n%s", want, template) @@ -1280,6 +1285,8 @@ func TestGCPBrokerSourceUsesFirestoreAndSignatureHeaders(t *testing.T) { "/refs/update", "roleAllows", "runTransaction", + "process.env.BROKER_VERSION", + "version: brokerVersion", } { if !strings.Contains(string(index), want) { t.Fatalf("GCP broker source missing %q:\n%s", want, string(index)) @@ -1302,6 +1309,29 @@ func TestBrokerSignatureMessageIsStable(t *testing.T) { } } +func TestBrokerForbiddenAllowsSignatureRetryOnlyForAuthFailures(t *testing.T) { + for _, msg := range []string{ + `{"error":"write SSH signature required"}`, + `{"error":"owner SSH signature required"}`, + `admin SSH signature required`, + } { + if !brokerForbiddenAllowsSignatureRetry(msg) { + t.Fatalf("expected auth retry for %q", msg) + } + } + for _, msg := range []string{ + `{"error":"protected branch refs/heads/main requires a pull request"}`, + `{"error":"repository is read-only"}`, + `{"error":"owners cannot be removed or suspended"}`, + `forbidden`, + ``, + } { + if brokerForbiddenAllowsSignatureRetry(msg) { + t.Fatalf("did not expect auth retry for %q", msg) + } + } +} + func TestMergeConfigUsesRepoAuthUnlessExplicit(t *testing.T) { local := config{auth: "adc", gcloudConfiguration: "test-profile"} merged := mergeConfig(config{auth: "gcloud"}, local) @@ -1314,6 +1344,18 @@ func TestMergeConfigUsesRepoAuthUnlessExplicit(t *testing.T) { } } +func TestMergeConfigUsesRepoRegion(t *testing.T) { + local := config{region: "eu-west-1"} + merged := mergeConfig(config{}, local) + if merged.region != "eu-west-1" { + t.Fatalf("merged region = %q", merged.region) + } + merged = mergeConfig(config{region: "us-west-2"}, local) + if merged.region != "us-west-2" { + t.Fatalf("explicit region = %q", merged.region) + } +} + func TestDefaultAWSRegion(t *testing.T) { t.Setenv("AWS_REGION", "") t.Setenv("AWS_DEFAULT_REGION", "") @@ -1330,6 +1372,27 @@ func TestDefaultAWSRegion(t *testing.T) { } } +func TestAWSRegionPrefersExplicitConfig(t *testing.T) { + t.Setenv("AWS_REGION", "eu-central-1") + t.Setenv("AWS_DEFAULT_REGION", "eu-west-1") + if got := awsRegion(config{region: "ap-southeast-2"}); got != "ap-southeast-2" { + t.Fatalf("explicit region = %q", got) + } + if got := awsRegion(config{}); got != "eu-central-1" { + t.Fatalf("fallback region = %q", got) + } +} + +func TestAnonymousS3ClientUsesExplicitRegion(t *testing.T) { + client, err := newS3Client(context.Background(), config{region: "ap-southeast-2"}, true) + if err != nil { + t.Fatal(err) + } + if got := client.Options().Region; got != "ap-southeast-2" { + t.Fatalf("client region = %q", got) + } +} + func TestInitEmptyWorktreeDoesNotRequireOrigin(t *testing.T) { target := filepath.Join(t.TempDir(), "repo") var stdout bytes.Buffer @@ -1369,6 +1432,31 @@ func TestReadLocalConfigFallsBackToGCSRemoteOrigin(t *testing.T) { } } +func TestWriteBucketGitConfigPersistsSelectedAuthDefaults(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", target); err != nil { + t.Fatal(err) + } + cfg := config{ + provider: "gcs", + bucket: "bucket-name", + prefix: "path/repo.git", + branch: defaultBranch, + auth: "adc", + gcloudConfiguration: "work", + } + if err := writeBucketGitConfig(target, cfg); err != nil { + t.Fatal(err) + } + got, err := readLocalConfig(target) + if err != nil { + t.Fatal(err) + } + if got.gcloudConfiguration != "work" || got.auth != "adc" { + t.Fatalf("cfg = %#v", got) + } +} + func TestMissingOriginErrorIncludesCopyPasteCommands(t *testing.T) { err := missingOriginError() if err == nil { @@ -1377,9 +1465,9 @@ func TestMissingOriginErrorIncludesCopyPasteCommands(t *testing.T) { text := err.Error() for _, want := range []string{ "No configured push destination.", - "bgit origin gs://bucket-name/path/to/repo.git", - "bgit push", - "bgit --bucket bucket-name --prefix path/to/repo.git push", + "bgit direct origin gs://bucket-name/path/to/repo.git", + "bgit direct push", + "bgit --bucket bucket-name --prefix path/to/repo.git direct push", } { if !strings.Contains(text, want) { t.Fatalf("missing %q in:\n%s", want, text) @@ -1400,7 +1488,7 @@ func TestPushWithoutOriginReportsSetupBeforeGCSClient(t *testing.T) { if err := os.Chdir(target); err != nil { t.Fatal(err) } - err = run([]string{"push"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}) + err = run([]string{"direct", "push"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}) if err == nil { t.Fatal("expected missing origin error") } @@ -2052,28 +2140,29 @@ func TestCommitCheckoutBranchAndLogWorkWithoutOrigin(t *testing.T) { } } -func TestExpandedNativePorcelainCommands(t *testing.T) { +func TestLocalBranchLifecycleMaintainsOriginTracking(t *testing.T) { target := t.TempDir() if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { t.Fatal(err) } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } for _, args := range [][]string{ {"config", "user.name", "Ada"}, {"config", "user.email", "ada@example.com"}, + {"remote", "add", "origin", "git@git.bucketgit.com:team/app.git"}, } { if _, err := runGit(target, args...); err != nil { t.Fatal(err) } } - if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatal(err) - } - oldDir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - defer os.Chdir(oldDir) - if err := os.Chdir(target); err != nil { + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("main\n"), 0o644); err != nil { t.Fatal(err) } if err := localGitCommand("add", []string{"README.md"}, ioDiscard{}); err != nil { @@ -2082,59 +2171,222 @@ func TestExpandedNativePorcelainCommands(t *testing.T) { if err := localGitCommand("commit", []string{"-m", "Initial"}, ioDiscard{}); err != nil { t.Fatal(err) } - if err := localGitCommand("tag", []string{"v1.0.0"}, ioDiscard{}); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil { + if err := localGitCommand("checkout", []string{"-b", "feature/web"}, ioDiscard{}); err != nil { t.Fatal(err) } - - checks := []struct { - cmd string - args []string - want string - }{ - {"diff", nil, "+world"}, - {"grep", []string{"world"}, "README.md:2:world"}, - {"ls-files", nil, "README.md"}, - {"ls-tree", []string{"-r", "HEAD"}, "README.md"}, - {"show", []string{"HEAD:README.md"}, "hello"}, - {"describe", nil, "v1.0.0"}, - {"blame", []string{"README.md"}, "Ada"}, - } - for _, check := range checks { - var stdout bytes.Buffer - if err := localGitCommand(check.cmd, check.args, &stdout); err != nil { - t.Fatalf("%s: %v", check.cmd, err) + for key, want := range map[string]string{ + "branch.feature/web.remote": "origin", + "branch.feature/web.merge": "refs/heads/feature/web", + } { + out, err := runGit(target, "config", "--local", "--get", key) + if err != nil { + t.Fatalf("%s: %v", key, err) } - if !strings.Contains(stdout.String(), check.want) { - t.Fatalf("%s output missing %q:\n%s", check.cmd, check.want, stdout.String()) + if got := strings.TrimSpace(string(out)); got != want { + t.Fatalf("%s = %q, want %q", key, got, want) } } - - if err := localGitCommand("restore", []string{"README.md"}, ioDiscard{}); err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(filepath.Join(target, "README.md")) - if err != nil { + if err := localGitCommand("checkout", []string{"main"}, ioDiscard{}); err != nil { t.Fatal(err) } - if string(data) != "hello\n" { - t.Fatalf("restore data = %q", string(data)) - } - if err := os.WriteFile(filepath.Join(target, "temp.txt"), []byte("temp\n"), 0o644); err != nil { + if err := localGitCommand("branch", []string{"-D", "feature/web"}, ioDiscard{}); err != nil { t.Fatal(err) } - var stdout bytes.Buffer - if err := localGitCommand("clean", []string{"-f"}, &stdout); err != nil { - t.Fatal(err) + if _, err := runGit(target, "config", "--local", "--get", "branch.feature/web.remote"); err == nil { + t.Fatal("branch.feature/web.remote still configured after branch delete") } - if _, err := os.Stat(filepath.Join(target, "temp.txt")); !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("temp.txt still exists: %v", err) + if _, err := runGit(target, "config", "--local", "--get", "branch.feature/web.merge"); err == nil { + t.Fatal("branch.feature/web.merge still configured after branch delete") } } -func TestCherryPickRevertBranchTagAndStashOutput(t *testing.T) { +func TestCheckoutCarriesCompatibleLocalChanges(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"config", "user.name", "Ada"}, {"config", "user.email", "ada@example.com"}} { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("TEST\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "add", "README.md"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "commit", "-m", "Initial"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "checkout", "-b", "barfoo"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("TEST\nTEST2\n"), 0o644); err != nil { + t.Fatal(err) + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if err := localGitCommand("checkout", []string{"main"}, &stdout); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(target, "README.md")) + if err != nil { + t.Fatal(err) + } + if string(data) != "TEST\nTEST2\n" { + t.Fatalf("README.md = %q", string(data)) + } + if !strings.Contains(stdout.String(), "Switched to branch 'main'") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestCheckoutRejectsOverwrittenLocalChanges(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"config", "user.name", "Ada"}, {"config", "user.email", "ada@example.com"}} { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("main\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "add", "README.md"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "commit", "-m", "main"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "checkout", "-b", "barfoo"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("branch\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "commit", "-am", "branch"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("dirty\n"), 0o644); err != nil { + t.Fatal(err) + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + err = localGitCommand("checkout", []string{"main"}, ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "would be overwritten by checkout") { + t.Fatalf("err = %v", err) + } + if branch, _ := runGit(target, "branch", "--show-current"); strings.TrimSpace(string(branch)) != "barfoo" { + t.Fatalf("branch = %q", string(branch)) + } + data, err := os.ReadFile(filepath.Join(target, "README.md")) + if err != nil { + t.Fatal(err) + } + if string(data) != "dirty\n" { + t.Fatalf("README.md = %q", string(data)) + } +} + +func TestExpandedNativePorcelainCommands(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "user.name", "Ada"}, + {"config", "user.email", "ada@example.com"}, + } { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + if err := localGitCommand("add", []string{"README.md"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if err := localGitCommand("commit", []string{"-m", "Initial"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if err := localGitCommand("tag", []string{"v1.0.0"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil { + t.Fatal(err) + } + + checks := []struct { + cmd string + args []string + want string + }{ + {"diff", nil, "+world"}, + {"grep", []string{"world"}, "README.md:2:world"}, + {"ls-files", nil, "README.md"}, + {"ls-tree", []string{"-r", "HEAD"}, "README.md"}, + {"show", []string{"HEAD:README.md"}, "hello"}, + {"describe", nil, "v1.0.0"}, + {"blame", []string{"README.md"}, "Ada"}, + } + for _, check := range checks { + var stdout bytes.Buffer + if err := localGitCommand(check.cmd, check.args, &stdout); err != nil { + t.Fatalf("%s: %v", check.cmd, err) + } + if !strings.Contains(stdout.String(), check.want) { + t.Fatalf("%s output missing %q:\n%s", check.cmd, check.want, stdout.String()) + } + } + + if err := localGitCommand("restore", []string{"README.md"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(target, "README.md")) + if err != nil { + t.Fatal(err) + } + if string(data) != "hello\n" { + t.Fatalf("restore data = %q", string(data)) + } + if err := os.WriteFile(filepath.Join(target, "temp.txt"), []byte("temp\n"), 0o644); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if err := localGitCommand("clean", []string{"-f"}, &stdout); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(target, "temp.txt")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("temp.txt still exists: %v", err) + } +} + +func TestCherryPickRevertBranchTagAndStashOutput(t *testing.T) { target := t.TempDir() if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { t.Fatal(err) @@ -2288,6 +2540,31 @@ func TestNativeConfigCommand(t *testing.T) { } } +func TestGlobalIdentityConfigCommand(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + if err := globalConfigCommand([]string{"--global", "user.name", "Dennis Example"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if err := globalConfigCommand([]string{"--global", "user.email", "dennis@example.com"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if err := globalConfigCommand([]string{"--global", "--get", "user.name"}, &stdout); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(stdout.String()) != "Dennis Example" { + t.Fatalf("user.name = %q", stdout.String()) + } + cfg, err := readGlobalConfig(filepath.Join(home, ".bgit", "config.yaml")) + if err != nil { + t.Fatal(err) + } + if cfg.Identity.Name != "Dennis Example" || cfg.Identity.Email != "dennis@example.com" { + t.Fatalf("identity = %#v", cfg.Identity) + } +} + func TestPushUpdatesBareRepo(t *testing.T) { root := t.TempDir() bare := filepath.Join(root, "repo.git") @@ -2336,6 +2613,60 @@ func TestPushUpdatesBareRepo(t *testing.T) { } } +func TestNativeDiffSupportsRevisionOperands(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "config", "user.name", "Ada"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "config", "user.email", "ada@example.com"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("main\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "add", "README.md"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "commit", "-m", "main"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "checkout", "-b", "barfoo"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "README.md"), []byte("main\nbarfoo\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "commit", "-am", "barfoo"); err != nil { + t.Fatal(err) + } + if _, err := runGit(target, "checkout", "main"); err != nil { + t.Fatal(err) + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if err := localGitCommand("diff", []string{"barfoo"}, &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "-barfoo") { + t.Fatalf("diff barfoo = %q", stdout.String()) + } + stdout.Reset() + err = localGitCommand("diff", []string{"foobar"}, &stdout) + if err == nil || !strings.Contains(err.Error(), `unknown revision "foobar"`) { + t.Fatalf("err = %v stdout=%q", err, stdout.String()) + } +} + func TestNativeResetHardSupportsHeadAncestorAndRemovesFiles(t *testing.T) { target := t.TempDir() if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { @@ -2508,6 +2839,14 @@ func TestNativeGitRepoPushWritesObjectsAndRefsWithoutBareSync(t *testing.T) { if strings.ReplaceAll(stdout.String(), "\r\n", "\n") != "# Demo\n" { t.Fatalf("remote cat = %q", stdout.String()) } + + stdout.Reset() + if err := repo.push(context.Background(), nil, &stdout); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(stdout.String()) != "Everything up-to-date" { + t.Fatalf("second push stdout = %q", stdout.String()) + } } func TestNativeGitRepoPushTagsDoesNotMoveConfiguredBranch(t *testing.T) { @@ -2608,6 +2947,64 @@ func TestNativeGitRepoFetchCopiesObjectsAndRemoteRefs(t *testing.T) { } } +func TestNativeGitRepoPushDefaultsToCurrentBranch(t *testing.T) { + root := t.TempDir() + remoteRoot := filepath.Join(root, "remote.git") + worktree := filepath.Join(root, "worktree") + if err := os.MkdirAll(remoteRoot, 0o755); err != nil { + t.Fatal(err) + } + if _, err := runGit("", "init", "--initial-branch", "main", worktree); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "config", "user.name", "Ada"); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "config", "user.email", "ada@example.com"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(worktree, "README.md"), []byte("# Demo\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "add", "README.md"); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "commit", "-m", "Initial commit"); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "checkout", "-b", "barfoo"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(worktree, "README.md"), []byte("# Demo\n\nbarfoo\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(worktree, "commit", "-am", "Barfoo"); err != nil { + t.Fatal(err) + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(worktree); err != nil { + t.Fatal(err) + } + repo := newNativeGitRepoForStore(config{branch: "main", origin: "gs://bucket/repo.git"}, &localGitStore{root: remoteRoot}) + var stdout bytes.Buffer + if err := repo.push(context.Background(), nil, &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "barfoo -> barfoo") { + t.Fatalf("stdout = %q", stdout.String()) + } + if _, err := os.Stat(filepath.Join(remoteRoot, "refs", "heads", "barfoo")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(remoteRoot, "refs", "heads", "main")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("main ref err = %v", err) + } +} + func TestNativeGitRepoPutFileCommitsAndPushesWithoutBareSync(t *testing.T) { root := t.TempDir() remoteRoot := filepath.Join(root, "remote.git") @@ -2750,6 +3147,256 @@ func TestWebHandlerRendersBranchSelector(t *testing.T) { } } +func TestWebHandlerServesJSONAPI(t *testing.T) { + bare := createBareFixture(t) + repo := newNativeGitRepoForStore(config{branch: "main", origin: "gs://bucket/repo.git"}, &localGitStore{root: bare}) + handler := newWebHandler(repo, config{branch: "main", origin: "gs://bucket/repo.git"}) + + for _, tc := range []struct { + path string + want []string + }{ + {path: "/api/refs", want: []string{`"full_name":"refs/heads/main"`}}, + {path: "/api/tree?path=docs", want: []string{`"path":"docs/guide.md"`, `"kind":"file"`}}, + {path: "/api/blob?path=README.md", want: []string{`"encoding":"utf-8"`, `"content":"# Demo\n"`}}, + {path: "/api/commits", want: []string{`"subject":"Initial commit"`}}, + } { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s status = %d body=%s", tc.path, rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { + t.Fatalf("%s content-type = %q", tc.path, got) + } + for _, want := range tc.want { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("%s body missing %q:\n%s", tc.path, want, rec.Body.String()) + } + } + } +} + +func TestOpenWebRepositoryUsesBrokerFromRepoConfig(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "bucketgit.broker", "https://broker.example.test"}, + {"config", "bucketgit.logicalRepo", "team/app.git"}, + {"config", "bucketgit.provider", "gcs"}, + } { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + repo, apiRepo, closeStore, cfg, err := openWebRepository(context.Background(), config{}, false) + if err != nil { + t.Fatal(err) + } + defer closeStore() + if _, ok := repo.store.(*localGitStore); !ok { + t.Fatalf("seed store = %T, want *localGitStore", repo.store) + } + store, ok := apiRepo.store.(*brokerGitStore) + if !ok { + t.Fatalf("api store = %T, want *brokerGitStore", apiRepo.store) + } + if store.brokerURL != "https://broker.example.test" || cfg.brokerURL != "https://broker.example.test" || cfg.logicalRepo != "team/app.git" { + t.Fatalf("store=%#v cfg=%#v", store, cfg) + } +} + +func TestOpenWebRepositoryLocalBypassesBroker(t *testing.T) { + target := t.TempDir() + if _, err := runGit("", "init", "--initial-branch", "main", target); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "bucketgit.broker", "https://broker.example.test"}, + {"config", "bucketgit.logicalRepo", "team/app.git"}, + } { + if _, err := runGit(target, args...); err != nil { + t.Fatal(err) + } + } + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(target); err != nil { + t.Fatal(err) + } + repo, apiRepo, closeStore, _, err := openWebRepository(context.Background(), config{}, true) + if err != nil { + t.Fatal(err) + } + defer closeStore() + if _, ok := repo.store.(*localGitStore); !ok { + t.Fatalf("store = %T, want *localGitStore", repo.store) + } + if apiRepo != repo { + t.Fatalf("api repo should be local repo in --local mode") + } +} + +func TestWebClonePanelShowsBrokerCloneCommand(t *testing.T) { + server := &webServer{cfg: config{ + brokerURL: "https://broker.example.test/", + logicalRepo: "team/app.git", + origin: "git@git.bucketgit.com:team/app.git", + }} + html := server.clonePanelHTML() + if !strings.Contains(html, "team/app.git") { + t.Fatalf("clone panel missing repo: %s", html) + } + if !strings.Contains(html, "bgit clone https://broker.example.test/team/app.git") { + t.Fatalf("clone panel missing broker clone command: %s", html) + } + if !strings.Contains(html, "git@git.bucketgit.com:team/app.git") { + t.Fatalf("clone panel missing ssh origin: %s", html) + } +} + +func TestWebRepoHeaderUsesShortTitleAndBrokerLocationBadge(t *testing.T) { + cfg := config{ + brokerURL: "https://broker.example.test/", + logicalRepo: "team/app.git", + } + title := webRepoTitle(cfg) + if title != "team/app.git" { + t.Fatalf("title = %q", title) + } + server := &webServer{cfg: cfg, title: title} + if badge := server.repoLocationBadge(); badge != "broker.example.test/team/app.git" { + t.Fatalf("badge = %q", badge) + } + header := server.headerHTML("refs/heads/main", "") + if strings.Contains(header, "bucketgit repository") { + t.Fatalf("header should not include repository label: %s", header) + } + if !strings.Contains(header, `data-theme-toggle`) { + t.Fatalf("header missing theme toggle: %s", header) + } +} + +func TestWebHandlerCanRenderSeedThenRemote(t *testing.T) { + localRoot := t.TempDir() + remoteRoot := t.TempDir() + for _, root := range []string{localRoot, remoteRoot} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + localSource := filepath.Join(localRoot, "source") + remoteSource := filepath.Join(remoteRoot, "source") + localBare := filepath.Join(localRoot, "repo.git") + remoteBare := filepath.Join(remoteRoot, "repo.git") + for _, bare := range []string{localBare, remoteBare} { + if _, err := runGit("", "init", "--bare", bare); err != nil { + t.Fatal(err) + } + } + for _, item := range []struct { + worktree string + bare string + text string + message string + }{ + {localSource, localBare, "# Local\n", "Local commit"}, + {remoteSource, remoteBare, "# Remote\n", "Remote commit"}, + } { + if _, err := runGit("", "init", "--initial-branch", "main", item.worktree); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(item.worktree, "README.md"), []byte(item.text), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runGit(item.worktree, "add", "README.md"); err != nil { + t.Fatal(err) + } + env := append(os.Environ(), "GIT_AUTHOR_NAME=Ada", "GIT_AUTHOR_EMAIL=ada@example.com", "GIT_COMMITTER_NAME=Ada", "GIT_COMMITTER_EMAIL=ada@example.com") + if _, err := runGitEnv(item.worktree, env, "commit", "-m", item.message); err != nil { + t.Fatal(err) + } + if _, err := runGit(item.worktree, "push", item.bare, "HEAD:refs/heads/main"); err != nil { + t.Fatal(err) + } + } + seed := newNativeGitRepoForStore(config{branch: "main"}, &localGitStore{root: localBare}) + remote := newNativeGitRepoForStore(config{branch: "main"}, &localGitStore{root: remoteBare}) + handler := newWebHandlerWithAPI(seed, remote, config{branch: "main", origin: "gs://bucket/repo.git"}) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if !strings.Contains(rec.Body.String(), "# Local") || strings.Contains(rec.Body.String(), "# Remote") { + t.Fatalf("seed body = %s", rec.Body.String()) + } + req = httptest.NewRequest(http.MethodGet, "/?_remote=1", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if !strings.Contains(rec.Body.String(), "# Remote") || strings.Contains(rec.Body.String(), "# Local") { + t.Fatalf("remote body = %s", rec.Body.String()) + } + req = httptest.NewRequest(http.MethodGet, "/api/tree", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if !strings.Contains(rec.Body.String(), `"subject":"Remote commit"`) { + t.Fatalf("api body = %s", rec.Body.String()) + } +} + +func TestWebPullRequestCacheRendersPRTabAndPage(t *testing.T) { + bare := filepath.Join(t.TempDir(), "repo.git") + if _, err := runGit("", "init", "--bare", bare); err != nil { + t.Fatal(err) + } + handler := newWebHandlerWithAPI( + newNativeGitRepoForStore(config{branch: "main"}, &localGitStore{root: bare}), + nil, + config{branch: "main", brokerURL: "https://broker.example.test", logicalRepo: "team/app.git", provider: "gcs"}, + ) + if err := handler.writePullRequestCache([]brokerPullRequest{{ + ID: 7, + Title: "Add docs", + Source: "refs/heads/docs", + Target: "refs/heads/main", + Status: "open", + Approvals: 1, + }}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/prs", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Add docs") || !strings.Contains(rec.Body.String(), `data-pr-tab`) { + t.Fatalf("body = %s", rec.Body.String()) + } + data, err := os.ReadFile(filepath.Join(bare, "bucketgit", "cache", "prs.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"title": "Add docs"`) { + t.Fatalf("cache = %s", string(data)) + } +} + func TestParseWebArgs(t *testing.T) { opts, err := parseWebArgs([]string{"--local", "--addr", "0.0.0.0", "--port", "9000"}) if err != nil { @@ -2811,15 +3458,17 @@ func TestBrokerGitStoreReadsAndListsThroughBroker(t *testing.T) { paths = append(paths, r.URL.Path) w.Header().Set("content-type", "application/json") switch r.URL.Path { - case "/objects/read": - var req brokerObjectRequest + case "/objects/capability": + var req brokerObjectCapabilityRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatal(err) } - if req.Repo.Bucket != "bucket" || req.Path != "objects/aa/bb" { + if req.Repo.Bucket != "bucket" || req.Path != "objects/aa/bb" || req.Operation != "read" { t.Fatalf("read req = %#v", req) } - _, _ = fmt.Fprintf(w, `{"data":%q}`, base64.StdEncoding.EncodeToString([]byte("object data"))) + _, _ = fmt.Fprintf(w, `{"provider":"gcs","mode":"signed_url","method":"GET","url":%q}`, "http://"+r.Host+"/object") + case "/object": + _, _ = w.Write([]byte("object data")) case "/objects/list": var req brokerObjectRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -2850,7 +3499,7 @@ func TestBrokerGitStoreReadsAndListsThroughBroker(t *testing.T) { if strings.Join(listed, ",") != "refs/heads/main" { t.Fatalf("listed = %#v", listed) } - if strings.Join(paths, ",") != "/objects/read,/objects/list" { + if strings.Join(paths, ",") != "/objects/capability,/object,/objects/list" { t.Fatalf("paths = %#v", paths) } } diff --git a/native_git.go b/native_git.go index d0b3015..69e9c04 100644 --- a/native_git.go +++ b/native_git.go @@ -284,6 +284,9 @@ func (r *nativeGitRepo) initWorktree(ctx context.Context, args []string, stdout if err := setGitOrigin(absTarget, originForConfig(cfg)); err != nil { return err } + if err := setGitBranchTracking(absTarget, cfg.branch, "origin"); err != nil { + return err + } cloneRepo := *r cloneRepo.cfg = cfg if err := cloneRepo.fetchIntoWorktree(ctx, absTarget, true, io.Discard); err != nil { @@ -337,30 +340,39 @@ func (r *nativeGitRepo) fetchIntoWorktree(ctx context.Context, worktree string, names = append(names, name) } sort.Strings(names) + var updates []string for _, name := range names { hash := refs[name] switch { case strings.HasPrefix(name, "refs/heads/"): localRef := filepath.Join(gitDir, filepath.FromSlash("refs/remotes/bucketgit/"+strings.TrimPrefix(name, "refs/heads/"))) + oldHash := readRefFile(localRef) if err := writeRefFile(localRef, hash); err != nil { return err } + short := strings.TrimPrefix(name, "refs/heads/") + switch { + case oldHash == "": + updates = append(updates, fmt.Sprintf(" * [new branch] %s -> bucketgit/%s", short, short)) + case oldHash != hash: + updates = append(updates, fmt.Sprintf(" %s..%s %s -> bucketgit/%s", shortHash(oldHash), shortHash(hash), short, short)) + } case tags && strings.HasPrefix(name, "refs/tags/"): localRef := filepath.Join(gitDir, filepath.FromSlash(name)) + oldHash := readRefFile(localRef) if err := writeRefFile(localRef, hash); err != nil { return err } + if oldHash == "" { + short := strings.TrimPrefix(name, "refs/tags/") + updates = append(updates, fmt.Sprintf(" * [new tag] %s -> %s", short, short)) + } } } - fmt.Fprintf(stdout, "From %s\n", originForConfig(r.cfg)) - for _, name := range names { - switch { - case strings.HasPrefix(name, "refs/heads/"): - short := strings.TrimPrefix(name, "refs/heads/") - fmt.Fprintf(stdout, " * [new branch] %s -> bucketgit/%s\n", short, short) - case tags && strings.HasPrefix(name, "refs/tags/"): - short := strings.TrimPrefix(name, "refs/tags/") - fmt.Fprintf(stdout, " * [new tag] %s -> %s\n", short, short) + if len(updates) > 0 { + fmt.Fprintf(stdout, "From %s\n", originForConfig(r.cfg)) + for _, update := range updates { + fmt.Fprintln(stdout, update) } } return nil @@ -388,7 +400,7 @@ func (r *nativeGitRepo) pull(ctx context.Context, args []string, stdout io.Write if *rebase { return unsupportedCommand("rebase") } - if err := r.fetchIntoWorktree(ctx, worktree, true, io.Discard); err != nil { + if err := r.fetchIntoWorktree(ctx, worktree, true, stdout); err != nil { return err } localRepo, err := openLocalRepository(worktree) @@ -427,6 +439,18 @@ func (r *nativeGitRepo) pull(ctx context.Context, args []string, stdout io.Write return nil } +func readRefFile(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + hash := strings.TrimSpace(string(data)) + if !isHexHash(hash) { + return "" + } + return hash +} + func (r *nativeGitRepo) push(ctx context.Context, args []string, stdout io.Writer) error { opts, err := parsePushArgs(args) if err != nil { @@ -448,6 +472,10 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts if err != nil { return err } + localRepo, err := openLocalRepository(worktree) + if err != nil { + return err + } if err := uploadLocalObjects(ctx, store, gitDir); err != nil { return err } @@ -461,7 +489,7 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts } updateRef := func(ref, oldHash, newHash string) error { if brokerURL != "" { - if err := brokerUpdateRef(brokerURL, r.cfg, ref, oldHash, newHash); err != nil { + if err := brokerUpdateRefWithOverride(brokerURL, r.cfg, ref, oldHash, newHash, opts.force); err != nil { return brokerPushError(err) } } @@ -476,6 +504,14 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts if err := updateRef(normalized, firstNonEmpty(refs[normalized], zeroObjectID()), zeroObjectID()); err != nil { return err } + if err := updateLocalRemoteTrackingRef(gitDir, normalized, zeroObjectID()); err != nil { + return err + } + if strings.HasPrefix(normalized, "refs/heads/") { + if err := unsetGitBranchTracking(worktree, strings.TrimPrefix(normalized, "refs/heads/")); err != nil { + return err + } + } } fmt.Fprintf(stdout, "To %s\n", originForConfig(r.cfg)) for _, ref := range opts.refs { @@ -489,11 +525,21 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts if err != nil { return err } - ref := branchRef(r.cfg.branch) - if err := updateRef(ref, firstNonEmpty(refs[ref], zeroObjectID()), hash); err != nil { - return err + branch := firstNonEmpty(localRepo.currentBranch(), r.cfg.branch, defaultBranch) + ref := branchRef(branch) + oldHash := pushOldHash(gitDir, refs, ref) + if oldHash != hash { + if err := updateRef(ref, oldHash, hash); err != nil { + return err + } + if err := updateLocalRemoteTrackingRef(gitDir, ref, hash); err != nil { + return err + } + if err := setGitBranchTrackingIfOrigin(worktree, branch); err != nil { + return err + } + updates = append(updates, pushUpdateLine(oldHash, hash, branch, branch)) } - updates = append(updates, fmt.Sprintf(" * [new branch] %s -> %s", r.cfg.branch, r.cfg.branch)) } else { for _, refspec := range opts.refs { src, dst, ok := strings.Cut(refspec, ":") @@ -506,10 +552,22 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts return err } ref := normalizeDestinationRef(dst) - if err := updateRef(ref, firstNonEmpty(refs[ref], zeroObjectID()), hash); err != nil { + oldHash := pushOldHash(gitDir, refs, ref) + if oldHash == hash { + continue + } + if err := updateRef(ref, oldHash, hash); err != nil { + return err + } + if err := updateLocalRemoteTrackingRef(gitDir, ref, hash); err != nil { return err } - updates = append(updates, fmt.Sprintf(" * [new branch] %s -> %s", shortRefName(src), shortRefName(normalizeDestinationRef(dst)))) + if strings.HasPrefix(ref, "refs/heads/") { + if err := setGitBranchTrackingIfOrigin(worktree, strings.TrimPrefix(ref, "refs/heads/")); err != nil { + return err + } + } + updates = append(updates, pushUpdateLine(oldHash, hash, shortRefName(src), shortRefName(normalizeDestinationRef(dst)))) } } if opts.tags { @@ -518,12 +576,24 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts return err } for ref, hash := range tags { - if err := updateRef(ref, firstNonEmpty(refs[ref], zeroObjectID()), hash); err != nil { + oldHash := firstNonEmpty(refs[ref], zeroObjectID()) + if oldHash == hash { + continue + } + if err := updateRef(ref, oldHash, hash); err != nil { return err } - updates = append(updates, fmt.Sprintf(" * [new tag] %s -> %s", shortRefName(ref), shortRefName(ref))) + if oldHash == zeroObjectID() { + updates = append(updates, fmt.Sprintf(" * [new tag] %s -> %s", shortRefName(ref), shortRefName(ref))) + } else { + updates = append(updates, fmt.Sprintf(" %s..%s %s -> %s", shortHash(oldHash), shortHash(hash), shortRefName(ref), shortRefName(ref))) + } } } + if len(updates) == 0 { + fmt.Fprintln(stdout, "Everything up-to-date") + return nil + } fmt.Fprintf(stdout, "To %s\n", originForConfig(r.cfg)) for _, line := range updates { fmt.Fprintln(stdout, line) @@ -531,6 +601,53 @@ func (r *nativeGitRepo) pushWorktree(ctx context.Context, worktree string, opts return nil } +func pushOldHash(gitDir string, refs map[string]string, ref string) string { + if hash := refs[ref]; hash != "" { + return hash + } + if hash := localRemoteTrackingHash(gitDir, ref); hash != "" { + return hash + } + return zeroObjectID() +} + +func pushUpdateLine(oldHash, newHash, src, dst string) string { + if oldHash == zeroObjectID() { + return fmt.Sprintf(" * [new branch] %s -> %s", src, dst) + } + return fmt.Sprintf(" %s..%s %s -> %s", shortHash(oldHash), shortHash(newHash), src, dst) +} + +func localRemoteTrackingHash(gitDir, ref string) string { + if !strings.HasPrefix(ref, "refs/heads/") { + return "" + } + path := filepath.Join(gitDir, filepath.FromSlash("refs/remotes/bucketgit/"+strings.TrimPrefix(ref, "refs/heads/"))) + data, err := os.ReadFile(path) + if err != nil { + return "" + } + hash := strings.TrimSpace(string(data)) + if !isHexHash(hash) { + return "" + } + return hash +} + +func updateLocalRemoteTrackingRef(gitDir, ref, hash string) error { + if !strings.HasPrefix(ref, "refs/heads/") { + return nil + } + path := filepath.Join(gitDir, filepath.FromSlash("refs/remotes/bucketgit/"+strings.TrimPrefix(ref, "refs/heads/"))) + if hash == zeroObjectID() { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil + } + return writeRefFile(path, hash) +} + func (r *nativeGitRepo) putFile(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer) error { opts, err := parsePutArgs(args) if err != nil { diff --git a/s3_store.go b/s3_store.go index 4e346cb..58c332e 100644 --- a/s3_store.go +++ b/s3_store.go @@ -24,14 +24,18 @@ type s3GitStore struct { } func newS3Client(ctx context.Context, cfg config, anonymous bool) (*s3.Client, error) { + region := awsRegion(cfg) if anonymous { return s3.New(s3.Options{ - Region: defaultAWSRegion(), + Region: region, Credentials: aws.AnonymousCredentials{}, }), nil } opts := []func(*awsconfig.LoadOptions) error{ - awsconfig.WithDefaultRegion(defaultAWSRegion()), + awsconfig.WithDefaultRegion(region), + } + if strings.TrimSpace(cfg.region) != "" { + opts = append(opts, awsconfig.WithRegion(region)) } if strings.TrimSpace(cfg.gcloudConfiguration) != "" { opts = append(opts, awsconfig.WithSharedConfigProfile(strings.TrimSpace(cfg.gcloudConfiguration))) @@ -43,6 +47,13 @@ func newS3Client(ctx context.Context, cfg config, anonymous bool) (*s3.Client, e return s3.NewFromConfig(awsCfg), nil } +func awsRegion(cfg config) string { + if value := strings.TrimSpace(cfg.region); value != "" { + return value + } + return defaultAWSRegion() +} + func defaultAWSRegion() string { if value := strings.TrimSpace(os.Getenv("AWS_REGION")); value != "" { return value @@ -130,7 +141,7 @@ func ensureS3Bucket(ctx context.Context, cfg config) error { return fmt.Errorf("check bucket s3://%s: %w", cfg.bucket, err) } input := &s3.CreateBucketInput{Bucket: aws.String(cfg.bucket)} - region := defaultAWSRegion() + region := awsRegion(cfg) if region != "" && region != "us-east-1" { input.CreateBucketConfiguration = &types.CreateBucketConfiguration{ LocationConstraint: types.BucketLocationConstraint(region), diff --git a/setup.go b/setup.go new file mode 100644 index 0000000..65a72c0 --- /dev/null +++ b/setup.go @@ -0,0 +1,3065 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" + + "golang.org/x/term" +) + +const setupProbeTimeout = 10 * time.Second +const setupDialogProfilesPerProvider = 10 +const setupRegionDialogItemsPerPage = 10 + +var errSetupBack = errors.New("setup back") +var setupProfileNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@+-]+$`) +var setupAWSAccessKeyPattern = regexp.MustCompile(`^(A3T[A-Z0-9]|AKIA|ASIA)[A-Z0-9]{16}$`) +var setupAWSRegionPattern = regexp.MustCompile(`^[a-z]{2}(-gov)?-[a-z]+-[0-9]+$`) + +type setupOptions struct { + yes bool + provider string + profiles []string + configPath string + region string + keys []string + noAgent bool +} + +type setupProfile struct { + Provider string + Name string + Active bool + Existing bool + Account string + ProjectID string + AccountID string + ARN string + Region string + ConfiguredRegions []string +} + +type setupSSHKey struct { + PublicKey string + Source string + Comment string +} + +type setupSelection struct { + Profiles []setupProfile + Keys []setupSSHKey + IdentityName string + IdentityEmail string + Action string + CreateProvider string + CreateName string + CreateAccessKey string + CreateSecretKey string + CreateRegion string + DefaultCreate string + DefaultCreateByProvider map[string]string +} + +type brokerOwnerRequest struct { + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + PublicKeys []string `json:"public_keys,omitempty"` +} + +func setupCommand(ctx context.Context, base config, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) >= 2 && args[0] == "profile" && args[1] == "create" { + return setupProfileCreateCommand(args[2:], stdin, stdout) + } + opts, err := parseSetupArgs(args) + if err != nil { + return err + } + if len(opts.profiles) == 0 && base.gcloudConfigurationExplicit && strings.TrimSpace(base.gcloudConfiguration) != "" { + opts.profiles = append(opts.profiles, strings.TrimSpace(base.gcloudConfiguration)) + } + interactiveReader := bufio.NewReader(stdin) + path := opts.configPath + if path == "" { + path, err = defaultGlobalConfigPath() + if err != nil { + return err + } + } + fmt.Fprintln(stdout, "discovering cloud profiles...") + profiles, err := discoverSetupProfiles(ctx) + if err != nil { + return err + } + global, err := readGlobalConfig(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + global = globalConfig{Version: globalConfigVersion} + } + if global.Version == 0 { + global.Version = globalConfigVersion + } + profiles = markConfiguredSetupProfiles(profiles, global) + profiles = filterSetupProfiles(profiles, opts.provider, opts.profiles, opts.region) + if len(profiles) == 0 { + if opts.yes { + return errors.New("no cloud profiles found; install/configure gcloud or AWS CLI profiles first") + } + if len(setupAvailableCreateProviders()) == 0 { + return errors.New("no cloud profiles found; install/configure gcloud or AWS CLI profiles first") + } + } + keys, err := discoverSetupSSHKeys(setupSSHKeyOptions{Paths: opts.keys, NoAgent: opts.noAgent}) + if err != nil { + return err + } +selectAgain: + for { + selection := setupSelection{ + Profiles: profiles, + Keys: keys, + IdentityName: global.Identity.Name, + IdentityEmail: global.Identity.Email, + DefaultCreate: firstSetupRequestedProfile(opts), + DefaultCreateByProvider: setupCreateProfileDefaults(profiles, opts), + } + if !opts.yes { + selected, err := runSetupDialogWithRaw(interactiveReader, stdin, stdout, selection) + if err != nil { + return err + } + selection = selected + } + if selection.Action == "create-profile" { + if err := setupInteractiveCreateProfile(selection, stdout); err != nil { + return err + } + fmt.Fprintln(stdout, "rediscovering cloud profiles...") + profiles, err = discoverSetupProfiles(ctx) + if err != nil { + return err + } + profiles = markConfiguredSetupProfiles(profiles, global) + profiles = filterSetupProfiles(profiles, opts.provider, opts.profiles, opts.region) + continue selectAgain + } + identityChanged := setupSelectionIdentityChanged(selection, global) + if len(selection.Profiles) == 0 && !identityChanged { + return errors.New("setup requires at least one selected cloud profile") + } + if strings.TrimSpace(selection.IdentityEmail) != "" && !identityEmailPattern.MatchString(strings.TrimSpace(selection.IdentityEmail)) { + return fmt.Errorf("email address %q looks invalid", strings.TrimSpace(selection.IdentityEmail)) + } + if strings.TrimSpace(selection.IdentityName) != "" || strings.TrimSpace(selection.IdentityEmail) != "" { + global.Identity = globalIdentityConfig{ + Name: strings.TrimSpace(selection.IdentityName), + Email: strings.TrimSpace(selection.IdentityEmail), + } + } + if len(selection.Profiles) == 0 { + if err := writeGlobalConfig(path, global); err != nil { + return err + } + fmt.Fprintf(stdout, "wrote BucketGit config %s\n", path) + return nil + } + var publicKeys []string + for _, key := range selection.Keys { + publicKeys = append(publicKeys, key.PublicKey) + } + publicKeys = uniqueStrings(publicKeys) + now := time.Now().UTC().Format(time.RFC3339) + for _, profile := range selection.Profiles { + if profile.Provider == "s3" && (profile.AccountID == "" || profile.ARN == "") { + accountID, arn := awsCallerIdentity(ctx, profile.Name) + profile.AccountID = firstNonEmpty(profile.AccountID, accountID) + profile.ARN = firstNonEmpty(profile.ARN, arn) + } + cfg := base + cfg.provider = profile.Provider + cfg.gcloudConfiguration = profile.Name + cfg.gcloudConfigurationExplicit = profile.Name != "" + if profile.Provider == "gcs" { + if err := requireSetupCLI("gcloud", "GCP"); err != nil { + return err + } + if err := ensureGcloudSetupAuth(ctx, cfg, !opts.yes, stdin, stdout); err != nil { + return err + } + if err := ensureGcloudSetupProjectAccess(ctx, cfg, !opts.yes, stdin, stdout); err != nil { + return err + } + if project := gcloudConfigValue(ctx, cfg.gcloudConfiguration, "project"); project != "" { + profile.ProjectID = project + } + if err := ensureGcloudSetupBilling(ctx, cfg, profile.ProjectID, !opts.yes, stdin, stdout); err != nil { + return err + } + regions, err := resolveGCPSetupRegionsWithRaw(profile, opts.region, !opts.yes, interactiveReader, stdin, stdout) + if err != nil { + if errors.Is(err, errSetupBack) && !opts.yes { + continue selectAgain + } + return err + } + if len(regions) == 0 { + return fmt.Errorf("GCP profile %s requires at least one selected region", profile.Name) + } + for _, region := range regions { + profile.Region = region + if err := setupProvisionSelectedProfile(base, path, now, profile, opts, publicKeys, &global, stdout); err != nil { + return err + } + } + continue + } else if profile.Provider == "s3" { + if err := requireSetupCLI("aws", "AWS"); err != nil { + return err + } + regions, err := resolveAWSSetupRegionsWithRaw(ctx, profile, opts.region, !opts.yes, interactiveReader, stdin, stdout) + if err != nil { + if errors.Is(err, errSetupBack) && !opts.yes { + continue selectAgain + } + return err + } + if len(regions) == 0 { + return fmt.Errorf("AWS profile %s requires at least one selected region", profile.Name) + } + for _, region := range regions { + profile.Region = region + if err := setupProvisionSelectedProfile(base, path, now, profile, opts, publicKeys, &global, stdout); err != nil { + return err + } + } + continue + } + } + if err := writeGlobalConfig(path, global); err != nil { + return err + } + fmt.Fprintf(stdout, "wrote BucketGit config %s\n", path) + fmt.Fprintln(stdout) + fmt.Fprintln(stdout, "Next steps:") + fmt.Fprintln(stdout, " bgit init") + fmt.Fprintln(stdout, " bgit init --noninteractive --repo my-repo --profile PROFILE") + fmt.Fprintln(stdout, " git push -u origin main") + return nil + } +} + +func setupProfileCreateCommand(args []string, stdin io.Reader, stdout io.Writer) error { + provider := "" + var rest []string + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--provider": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + provider = normalizeSetupProvider(value) + if provider == "" { + return fmt.Errorf("unsupported setup profile provider %q", value) + } + case "gcp", "gcs", "aws", "s3": + if provider == "" { + provider = normalizeSetupProvider(arg) + continue + } + rest = append(rest, arg) + default: + rest = append(rest, arg) + } + } + if provider == "" { + return errors.New("usage: bgit setup profile create --provider gcp|aws NAME") + } + switch provider { + case "gcs": + return createGcloudProfileCommand(rest, stdin, stdout) + case "s3": + return createAWSProfileCommand(rest, stdin, stdout) + default: + return errors.New("usage: bgit setup profile create --provider gcp|aws NAME") + } +} + +func setupInteractiveCreateProfile(selection setupSelection, stdout io.Writer) error { + provider := selection.CreateProvider + name := strings.TrimSpace(selection.CreateName) + if name == "" { + name = "default" + } + switch provider { + case "gcs": + if _, err := exec.LookPath("gcloud"); err != nil { + return errors.New("gcloud is not installed") + } + return createGcloudProfileCommand([]string{"--yes", name}, strings.NewReader(""), stdout) + case "s3": + if _, err := exec.LookPath("aws"); err != nil { + return errors.New("AWS CLI is not installed") + } + return createAWSProfileConfigured(name, selection.CreateAccessKey, selection.CreateSecretKey, selection.CreateRegion, stdout) + default: + return errors.New("unknown setup profile provider") + } +} + +func setupSelectionIdentityChanged(selection setupSelection, cfg globalConfig) bool { + return strings.TrimSpace(selection.IdentityName) != strings.TrimSpace(cfg.Identity.Name) || + strings.TrimSpace(selection.IdentityEmail) != strings.TrimSpace(cfg.Identity.Email) +} + +func firstSetupRequestedProfile(opts setupOptions) string { + if len(opts.profiles) > 0 { + return opts.profiles[0] + } + return "" +} + +func setupCreateProfileDefaults(profiles []setupProfile, opts setupOptions) map[string]string { + defaults := map[string]string{} + if requested := firstSetupRequestedProfile(opts); requested != "" { + if opts.provider == "" || opts.provider == "gcs" { + defaults["gcs"] = requested + } + if opts.provider == "" || opts.provider == "s3" { + defaults["s3"] = requested + } + return defaults + } + hasDefault := map[string]bool{} + for _, profile := range profiles { + if profile.Name == "default" { + hasDefault[profile.Provider] = true + } + } + if !hasDefault["gcs"] { + defaults["gcs"] = "default" + } + if !hasDefault["s3"] { + defaults["s3"] = "default" + } + return defaults +} + +func setupProvisionSelectedProfile(base config, path, now string, profile setupProfile, opts setupOptions, publicKeys []string, global *globalConfig, stdout io.Writer) error { + _ = path + cfg := base + cfg.provider = profile.Provider + cfg.gcloudConfiguration = profile.Name + cfg.gcloudConfigurationExplicit = profile.Name != "" + brokerURL, err := provisionBrokerURL(cfg, sshSetupOptions{region: firstNonEmpty(opts.region, profile.Region)}, stdout) + if err != nil { + return err + } + if len(publicKeys) > 0 { + if err := brokerUpsertOwners(brokerURL, publicKeys); err != nil { + return err + } + fmt.Fprintf(stdout, "imported %d owner key(s) into broker %s\n", len(publicKeys), brokerURL) + } + switch profile.Provider { + case "gcs": + serviceAccount := "" + if strings.TrimSpace(profile.ProjectID) != "" { + serviceAccount = gcpBrokerServiceAccountEmail(profile.ProjectID) + } + *global = upsertGlobalGCPProfile(*global, globalGCPProfile{ + Name: profile.Name, + ProjectID: profile.ProjectID, + Account: profile.Account, + ServiceAccount: serviceAccount, + Regions: []globalProfileRegion{{ + Name: profile.Region, + BrokerURL: brokerURL, + BrokerVersion: brokerVersion, + LastSetupAt: now, + }}, + }) + case "s3": + *global = upsertGlobalAWSProfile(*global, globalAWSProfile{ + Name: profile.Name, + AccountID: profile.AccountID, + ARN: profile.ARN, + Regions: []globalProfileRegion{{ + Name: profile.Region, + BrokerURL: brokerURL, + BrokerVersion: brokerVersion, + LastSetupAt: now, + }}, + }) + } + return nil +} + +func offerSetupProfileBootstrap(opts setupOptions, reader *bufio.Reader, stdout io.Writer) error { + provider := opts.provider + if provider == "" { + provider = promptSetupProvider(reader, stdout) + } + switch provider { + case "gcs": + if _, err := exec.LookPath("gcloud"); err != nil { + return errors.New("gcloud is not installed") + } + fmt.Fprint(stdout, "No usable gcloud profiles found. Create one now? [y/N] ") + if !readSetupYes(reader) { + return errors.New("setup requires a cloud profile") + } + fmt.Fprint(stdout, "GCP profile name [default]: ") + name := readSetupLine(reader) + if name == "" && len(opts.profiles) > 0 { + name = opts.profiles[0] + } + if name == "" { + name = "default" + } + if err := runGcloudProfileCommand(stdout, "config", "configurations", "create", name); err != nil { + return err + } + return runGcloudProfileCommand(stdout, "auth", "login", "--configuration", name) + case "s3": + if _, err := exec.LookPath("aws"); err != nil { + return errors.New("AWS CLI is not installed") + } + fmt.Fprint(stdout, "No usable AWS profiles found. Run aws configure now? [y/N] ") + if !readSetupYes(reader) { + return errors.New("setup requires a cloud profile") + } + fmt.Fprint(stdout, "AWS profile name [default]: ") + name := readSetupLine(reader) + if name == "" && len(opts.profiles) > 0 { + name = opts.profiles[0] + } + if name == "" { + name = "default" + } + return runAWSProfileCommand(stdout, "configure", "--profile", name) + default: + return errors.New("setup requires --provider gcp or --provider aws to create a profile") + } +} + +func promptSetupProvider(reader *bufio.Reader, stdout io.Writer) string { + gcloudOK := false + awsOK := false + if _, err := exec.LookPath("gcloud"); err == nil { + gcloudOK = true + } + if _, err := exec.LookPath("aws"); err == nil { + awsOK = true + } + if gcloudOK && !awsOK { + return "gcs" + } + if awsOK && !gcloudOK { + return "s3" + } + if !gcloudOK && !awsOK { + return "" + } + fmt.Fprint(stdout, "Create profile for provider [gcp/aws]: ") + switch strings.ToLower(readSetupLine(reader)) { + case "gcp", "gcs": + return "gcs" + case "aws", "s3": + return "s3" + default: + return "" + } +} + +func readSetupYes(reader *bufio.Reader) bool { + answer := strings.ToLower(readSetupLine(reader)) + return answer == "y" || answer == "yes" +} + +func readSetupLine(reader *bufio.Reader) string { + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "" + } + return strings.TrimSpace(line) +} + +type gcloudProjectOption struct { + ID string + Name string +} + +type gcloudBillingAccountOption struct { + Name string + DisplayName string + Open bool +} + +var defaultGCPSetupRegions = []string{ + "us-central1", + "us-east1", + "us-east4", + "us-west1", + "us-west2", + "us-west3", + "us-west4", + "northamerica-northeast1", + "southamerica-east1", + "europe-west1", + "europe-west2", + "europe-west3", + "europe-west4", + "europe-west6", + "europe-central2", + "asia-east1", + "asia-east2", + "asia-northeast1", + "asia-northeast2", + "asia-northeast3", + "asia-south1", + "asia-southeast1", + "asia-southeast2", + "australia-southeast1", +} + +var awsSetupRegions = []string{ + "us-east-1", + "eu-west-1", + "eu-central-1", + "us-west-2", + "ap-southeast-1", + "ap-northeast-1", + "ap-south-1", + "sa-east-1", + "ca-central-1", + "af-south-1", + "ap-east-1", + "ap-east-2", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-2", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", + "ca-west-1", + "eu-central-2", + "eu-north-1", + "eu-south-1", + "eu-south-2", + "eu-west-2", + "eu-west-3", + "il-central-1", + "me-central-1", + "me-south-1", + "mx-central-1", + "us-east-2", + "us-west-1", +} + +func parseSetupArgs(args []string) (setupOptions, error) { + var opts setupOptions + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--yes", "-y": + opts.yes = true + case "--provider": + value, next, err := optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + i = next + opts.provider = normalizeSetupProvider(value) + if opts.provider == "" { + return opts, fmt.Errorf("unsupported setup provider %q", value) + } + case "--profile": + value, next, err := optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + i = next + opts.profiles = append(opts.profiles, value) + case "--config": + value, next, err := optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + i = next + opts.configPath = expandHome(value) + case "--region": + value, next, err := optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + i = next + opts.region = value + case "--key": + value, next, err := optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, err + } + i = next + opts.keys = append(opts.keys, value) + case "--no-agent": + opts.noAgent = true + default: + return opts, fmt.Errorf("unsupported setup option %s", arg) + } + } + return opts, nil +} + +func discoverSetupProfiles(ctx context.Context) ([]setupProfile, error) { + var profiles []setupProfile + gcp, err := discoverGCPSetupProfiles(ctx) + if err != nil { + return nil, err + } + profiles = append(profiles, gcp...) + aws, err := discoverAWSSetupProfiles(ctx) + if err != nil { + return nil, err + } + profiles = append(profiles, aws...) + return profiles, nil +} + +func discoverGCPSetupProfiles(ctx context.Context) ([]setupProfile, error) { + if _, err := exec.LookPath("gcloud"); err != nil { + return nil, nil + } + probeCtx, cancel := context.WithTimeout(ctx, setupProbeTimeout) + defer cancel() + out, err := exec.CommandContext(probeCtx, "gcloud", "config", "configurations", "list", "--format=value(name,is_active)").Output() + if err != nil { + return nil, nil + } + var profiles []setupProfile + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) == 0 { + continue + } + name := fields[0] + active := len(fields) > 1 && strings.EqualFold(fields[1], "true") + profile := setupProfile{Provider: "gcs", Name: name, Active: active} + profile.Account = gcloudConfigValue(ctx, name, "account") + profile.ProjectID = gcloudConfigValue(ctx, name, "project") + profile.Region = firstNonEmpty(gcloudConfigValue(ctx, name, "run/region"), gcloudConfigValue(ctx, name, "functions/region"), "us-central1") + profiles = append(profiles, profile) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return profiles, nil +} + +func requireSetupCLI(binary, provider string) error { + if _, err := exec.LookPath(binary); err != nil { + return fmt.Errorf("%s CLI is not installed; install `%s` or deselect the %s profile", provider, binary, provider) + } + return nil +} + +func gcloudConfigValue(ctx context.Context, profile, key string) string { + probeCtx, cancel := context.WithTimeout(ctx, setupProbeTimeout) + defer cancel() + cmd := exec.CommandContext(probeCtx, "gcloud", "--configuration", profile, "config", "get-value", key, "--quiet") + out, err := cmd.Output() + if err != nil { + return "" + } + value := strings.TrimSpace(string(out)) + if value == "(unset)" { + return "" + } + return value +} + +func discoverAWSSetupProfiles(ctx context.Context) ([]setupProfile, error) { + _ = ctx + names := map[string]struct{}{} + for _, name := range awsProfilesFromFiles() { + names[name] = struct{}{} + } + var sorted []string + for name := range names { + sorted = append(sorted, name) + } + sort.Strings(sorted) + var profiles []setupProfile + for _, name := range sorted { + profile := setupProfile{Provider: "s3", Name: name, Region: configuredAWSProfileRegion(name)} + profiles = append(profiles, profile) + } + return profiles, nil +} + +func awsProfilesFromFiles() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + names := map[string]struct{}{} + for _, path := range []string{filepath.Join(home, ".aws", "config"), filepath.Join(home, ".aws", "credentials")} { + for _, name := range parseAWSProfileFile(path) { + names[name] = struct{}{} + } + } + var sorted []string + for name := range names { + sorted = append(sorted, name) + } + sort.Strings(sorted) + return sorted +} + +func parseAWSProfileFile(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + names := map[string]struct{}{} + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(line, "[") || !strings.HasSuffix(line, "]") { + continue + } + name := strings.TrimSuffix(strings.TrimPrefix(line, "["), "]") + name = strings.TrimSpace(strings.TrimPrefix(name, "profile ")) + if name != "" { + names[name] = struct{}{} + } + } + var sorted []string + for name := range names { + sorted = append(sorted, name) + } + sort.Strings(sorted) + return sorted +} + +func configuredAWSProfileRegion(profile string) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + for _, path := range []string{filepath.Join(home, ".aws", "config"), filepath.Join(home, ".aws", "credentials")} { + if region := awsProfileFileValue(path, profile, "region"); region != "" { + return region + } + } + return "" +} + +func awsProfileFileValue(path, profile, key string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + section := "" + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")) + section = strings.TrimSpace(strings.TrimPrefix(section, "profile ")) + continue + } + if section != profile { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(k) != key { + continue + } + return strings.TrimSpace(v) + } + return "" +} + +func awsCallerIdentity(ctx context.Context, profile string) (string, string) { + if _, err := exec.LookPath("aws"); err != nil { + return "", "" + } + args := []string{"sts", "get-caller-identity", "--output", "json"} + if profile != "" { + args = append(args, "--profile", profile) + } + probeCtx, cancel := context.WithTimeout(ctx, setupProbeTimeout) + defer cancel() + out, err := exec.CommandContext(probeCtx, "aws", args...).Output() + if err != nil { + return "", "" + } + var resp struct { + Account string `json:"Account"` + ARN string `json:"Arn"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return "", "" + } + return resp.Account, resp.ARN +} + +func resolveGCPSetupRegion(profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, stdout io.Writer) (string, error) { + regions, err := resolveGCPSetupRegionsWithRaw(profile, explicitRegion, interactive, stdin, stdin, stdout) + if err != nil || len(regions) == 0 { + return "", err + } + return regions[0], nil +} + +func resolveGCPSetupRegionWithRaw(profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + regions, err := resolveGCPSetupRegionsWithRaw(profile, explicitRegion, interactive, stdin, rawInput, stdout) + if err != nil || len(regions) == 0 { + return "", err + } + return regions[0], nil +} + +func resolveGCPSetupRegionsWithRaw(profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, rawInput io.Reader, stdout io.Writer) ([]string, error) { + if strings.TrimSpace(explicitRegion) != "" { + return []string{strings.TrimSpace(explicitRegion)}, nil + } + defaultRegion := firstNonEmpty(strings.TrimSpace(profile.Region), "us-central1") + if !interactive { + return []string{defaultRegion}, nil + } + regions := gcpSetupRegions(defaultRegion) + reader, ok := stdin.(*bufio.Reader) + if !ok { + reader = bufio.NewReader(stdin) + } + return runSetupRegionDialogWithRaw(reader, rawInput, stdout, "GCP", profile.Name, regions, setupRegionInitialSelection(profile)) +} + +func gcpSetupRegions(defaultRegion string) []string { + seen := map[string]struct{}{} + var regions []string + add := func(region string) { + region = strings.TrimSpace(region) + if region == "" { + return + } + if _, ok := seen[region]; ok { + return + } + seen[region] = struct{}{} + regions = append(regions, region) + } + add(defaultRegion) + for _, region := range defaultGCPSetupRegions { + add(region) + } + return regions +} + +func resolveAWSSetupRegion(ctx context.Context, profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, stdout io.Writer) (string, error) { + regions, err := resolveAWSSetupRegionsWithRaw(ctx, profile, explicitRegion, interactive, stdin, stdin, stdout) + if err != nil || len(regions) == 0 { + return "", err + } + return regions[0], nil +} + +func resolveAWSSetupRegionWithRaw(ctx context.Context, profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + regions, err := resolveAWSSetupRegionsWithRaw(ctx, profile, explicitRegion, interactive, stdin, rawInput, stdout) + if err != nil || len(regions) == 0 { + return "", err + } + return regions[0], nil +} + +func resolveAWSSetupRegionsWithRaw(ctx context.Context, profile setupProfile, explicitRegion string, interactive bool, stdin io.Reader, rawInput io.Reader, stdout io.Writer) ([]string, error) { + if err := requireSetupCLI("aws", "AWS"); err != nil { + return nil, err + } + if strings.TrimSpace(explicitRegion) != "" { + return []string{strings.TrimSpace(explicitRegion)}, nil + } + if !interactive { + if strings.TrimSpace(profile.Region) != "" { + return []string{strings.TrimSpace(profile.Region)}, nil + } + return nil, fmt.Errorf("AWS profile %s has no configured region; pass --region REGION or set aws_region/region in ~/.aws/config", profile.Name) + } + _ = ctx + if len(awsSetupRegions) == 0 { + return nil, fmt.Errorf("AWS profile %s has no enabled regions visible; pass --region REGION", profile.Name) + } + reader, ok := stdin.(*bufio.Reader) + if !ok { + reader = bufio.NewReader(stdin) + } + return runSetupRegionDialogWithRaw(reader, rawInput, stdout, "AWS", profile.Name, awsSetupRegions, setupRegionInitialSelection(profile)) +} + +func markConfiguredSetupProfiles(profiles []setupProfile, cfg globalConfig) []setupProfile { + configured := map[string][]string{} + for _, profile := range cfg.GCPProfiles { + if regions := configuredSetupProfileRegions(profile.Regions); len(regions) > 0 { + configured["gcs:"+profile.Name] = regions + } + } + for _, profile := range cfg.AWSProfiles { + if regions := configuredSetupProfileRegions(profile.Regions); len(regions) > 0 { + configured["s3:"+profile.Name] = regions + } + } + if len(configured) == 0 { + return profiles + } + var out []setupProfile + for _, profile := range profiles { + regions, ok := configured[profile.Provider+":"+profile.Name] + if !ok { + out = append(out, profile) + continue + } + for _, region := range regions { + next := profile + next.Existing = true + next.Region = region + next.ConfiguredRegions = append([]string{}, regions...) + out = append(out, next) + } + } + return out +} + +func configuredSetupProfileRegions(regions []globalProfileRegion) []string { + var out []string + for _, region := range regions { + if strings.TrimSpace(region.BrokerURL) == "" { + continue + } + if name := strings.TrimSpace(region.Name); name != "" { + out = append(out, name) + } + } + if len(out) > 0 { + return uniqueStrings(out) + } + for _, region := range regions { + if name := strings.TrimSpace(region.Name); name != "" { + out = append(out, name) + } + } + return uniqueStrings(out) +} + +func setupRegionInitialSelection(profile setupProfile) []string { + if len(profile.ConfiguredRegions) > 0 { + return append([]string{}, profile.ConfiguredRegions...) + } + if profile.Existing && strings.TrimSpace(profile.Region) != "" { + return []string{strings.TrimSpace(profile.Region)} + } + return nil +} + +func filterSetupProfiles(profiles []setupProfile, provider string, names []string, explicitRegion string) []setupProfile { + nameSet := map[string]struct{}{} + for _, name := range names { + nameSet[name] = struct{}{} + } + var out []setupProfile + for _, profile := range profiles { + if provider != "" && profile.Provider != provider { + continue + } + if len(nameSet) > 0 { + if !setupProfileNameSelected(profile, nameSet) { + continue + } + } + if strings.TrimSpace(explicitRegion) != "" { + profile.Region = strings.TrimSpace(explicitRegion) + } + out = append(out, profile) + } + if strings.TrimSpace(explicitRegion) != "" { + out = dedupeSetupProfiles(out) + } + return out +} + +func setupProfileNameSelected(profile setupProfile, names map[string]struct{}) bool { + candidates := []string{ + profile.Name, + profile.Name + "." + profile.Region, + providerProfileName(profile.Provider) + ":" + profile.Name, + providerProfileName(profile.Provider) + ":" + profile.Name + "." + profile.Region, + providerProfileName(profile.Provider) + ":" + profile.Name + "/" + profile.Region, + } + for _, candidate := range candidates { + if _, ok := names[candidate]; ok { + return true + } + } + return false +} + +func dedupeSetupProfiles(profiles []setupProfile) []setupProfile { + seen := map[string]struct{}{} + var out []setupProfile + for _, profile := range profiles { + key := profile.Provider + ":" + profile.Name + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, profile) + } + return out +} + +func normalizeSetupProvider(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "gcp", "gcs", "google": + return "gcs" + case "aws", "s3": + return "s3" + default: + return "" + } +} + +type setupSSHKeyOptions struct { + Paths []string + NoAgent bool +} + +func discoverSetupSSHKeys(opts setupSSHKeyOptions) ([]setupSSHKey, error) { + var keys []setupSSHKey + for _, path := range opts.Paths { + data, err := os.ReadFile(expandHome(path)) + if err != nil { + return nil, err + } + for _, key := range parseSetupSSHKeys(string(data), path) { + keys = append(keys, key) + } + } + if !opts.NoAgent { + agentKeys, err := sshAgentPublicKeys() + if err == nil { + for _, key := range agentKeys { + keys = append(keys, setupSSHKey{PublicKey: key, Source: "ssh-agent", Comment: sshKeyComment(key)}) + } + } + } + fileKeys, err := discoverSSHKeyFiles() + if err != nil { + return nil, err + } + keys = append(keys, fileKeys...) + return dedupeSetupSSHKeys(keys), nil +} + +func discoverSSHKeyFiles() ([]setupSSHKey, error) { + var dirs []string + home, err := os.UserHomeDir() + if err == nil { + dirs = append(dirs, filepath.Join(home, ".ssh")) + if runtime.GOOS == "windows" { + dirs = append(dirs, filepath.Join(home, "ssh")) + } + } + var keys []setupSSHKey + for _, dir := range dirs { + matches, err := filepath.Glob(filepath.Join(dir, "*.pub")) + if err != nil { + return nil, err + } + sort.Strings(matches) + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + continue + } + for _, key := range parseSetupSSHKeys(string(data), path) { + keys = append(keys, key) + } + } + } + return keys, nil +} + +func parseSetupSSHKeys(data, source string) []setupSSHKey { + var keys []setupSSHKey + for _, line := range splitPublicKeyLines(data) { + keys = append(keys, setupSSHKey{PublicKey: line, Source: source, Comment: sshKeyComment(line)}) + } + return keys +} + +func dedupeSetupSSHKeys(keys []setupSSHKey) []setupSSHKey { + seen := map[string]struct{}{} + var out []setupSSHKey + for _, key := range keys { + normalized := normalizeSSHPublicKey(key.PublicKey) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + key.PublicKey = normalized + out = append(out, key) + } + return out +} + +func normalizeSSHPublicKey(key string) string { + return strings.Join(strings.Fields(strings.TrimSpace(key))[:min(2, len(strings.Fields(strings.TrimSpace(key))))], " ") +} + +func sshKeyComment(key string) string { + fields := strings.Fields(strings.TrimSpace(key)) + if len(fields) <= 2 { + return "" + } + return strings.Join(fields[2:], " ") +} + +func ensureGcloudSetupAuth(ctx context.Context, cfg config, interactive bool, stdin io.Reader, stdout io.Writer) error { + if _, err := gcloudSetupAccessToken(ctx, cfg); err == nil { + return nil + } else if !gcloudAuthNeedsLogin(err.Error()) { + return err + } + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + loginCommand := fmt.Sprintf("gcloud auth login --configuration %s --no-launch-browser", profile) + if !interactive { + return fmt.Errorf("gcloud profile %s needs authentication; run `%s`", profile, loginCommand) + } + fmt.Fprintf(stdout, "gcloud profile %s needs authentication.\n", profile) + fmt.Fprintf(stdout, "Starting `%s`.\n", loginCommand) + fmt.Fprintln(stdout, "Open the URL printed by gcloud, finish the OAuth flow, then paste the code if prompted.") + cmd := exec.CommandContext(ctx, "gcloud", "auth", "login", "--configuration", profile, "--no-launch-browser") + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("gcloud auth login failed: %w", err) + } + if _, err := gcloudSetupAccessToken(ctx, cfg); err != nil { + return fmt.Errorf("gcloud profile %s is still not authenticated after login: %w", profile, err) + } + return nil +} + +func ensureGcloudSetupProjectAccess(ctx context.Context, cfg config, interactive bool, stdin io.Reader, stdout io.Writer) error { + project := gcloudConfigValue(ctx, cfg.gcloudConfiguration, "project") + if project == "" { + var err error + project, err = ensureGcloudSetupProjectSelected(ctx, cfg, interactive, stdin, stdout) + if err != nil { + return err + } + } + if err := gcloudSetupProjectAccess(ctx, cfg, project); err == nil { + return nil + } else if enabled, enableErr := maybeEnableGcloudSetupProjectAPIs(ctx, cfg, project, err, stdout); enableErr != nil { + return enableErr + } else if enabled { + return nil + } else if repaired, repairErr := maybeRepairGcloudQuotaProject(ctx, cfg, project, err, interactive, stdin, stdout); repairErr != nil { + return repairErr + } else if repaired { + return nil + } else if !gcloudAuthNeedsLogin(err.Error()) { + return err + } + if err := runGcloudSetupLogin(ctx, cfg, interactive, stdin, stdout); err != nil { + return err + } + if err := gcloudSetupProjectAccess(ctx, cfg, project); err == nil { + return nil + } else if enabled, enableErr := maybeEnableGcloudSetupProjectAPIs(ctx, cfg, project, err, stdout); enableErr != nil { + return enableErr + } else if enabled { + return nil + } else if repaired, repairErr := maybeRepairGcloudQuotaProject(ctx, cfg, project, err, interactive, stdin, stdout); repairErr != nil { + return repairErr + } else if !repaired { + return fmt.Errorf("gcloud profile %s still cannot access project %s after login: %w", cfg.gcloudConfiguration, project, err) + } + return nil +} + +func ensureGcloudSetupProjectSelected(ctx context.Context, cfg config, interactive bool, stdin io.Reader, stdout io.Writer) (string, error) { + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + if !interactive { + return "", errors.New("gcloud project is unset; run `gcloud config set project PROJECT --configuration " + profile + "`") + } + reader := bufio.NewReader(stdin) + account := gcloudConfigValue(ctx, cfg.gcloudConfiguration, "account") + fmt.Fprintf(stdout, "gcloud profile %s", profile) + if account != "" { + fmt.Fprintf(stdout, " uses account %s", account) + } + fmt.Fprintln(stdout, " but has no project configured.") + projects, _ := listGcloudSetupProjects(ctx, cfg) + if len(projects) > 0 { + fmt.Fprintln(stdout, "Visible projects:") + for i, project := range projects { + label := project.ID + if strings.TrimSpace(project.Name) != "" { + label += " - " + project.Name + } + fmt.Fprintf(stdout, " %d. %s\n", i+1, label) + } + } + fmt.Fprintln(stdout, "Choose a project number, type an existing project ID, type `create`, or leave blank to cancel.") + fmt.Fprint(stdout, "Project: ") + choice := readSetupLine(reader) + if choice == "" { + return "", errors.New("setup requires a gcloud project") + } + projectID := "" + if n := parsePositiveInt(choice); n > 0 && n <= len(projects) { + projectID = projects[n-1].ID + } else if strings.EqualFold(choice, "create") { + created, err := createGcloudSetupProject(ctx, cfg, reader, stdout) + if err != nil { + return "", err + } + projectID = created + } else { + projectID = choice + } + if err := setGcloudSetupProject(ctx, cfg, projectID, stdout); err != nil { + return "", err + } + return projectID, nil +} + +func listGcloudSetupProjects(ctx context.Context, cfg config) ([]gcloudProjectOption, error) { + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, "gcloud", "projects", "list", "--configuration", cfg.gcloudConfiguration, "--format=value(projectId,name)") + out, err := cmd.Output() + if err != nil { + return nil, err + } + var projects []gcloudProjectOption + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + project := gcloudProjectOption{ID: fields[0]} + if len(fields) > 1 { + project.Name = strings.Join(fields[1:], " ") + } + projects = append(projects, project) + } + return projects, scanner.Err() +} + +func createGcloudSetupProject(ctx context.Context, cfg config, reader *bufio.Reader, stdout io.Writer) (string, error) { + fmt.Fprint(stdout, "New project ID: ") + projectBase := readSetupLine(reader) + if projectBase == "" { + return "", errors.New("project ID is required") + } + fmt.Fprintf(stdout, "Project display name [%s]: ", projectBase) + name := readSetupLine(reader) + if name == "" { + name = projectBase + } + projectID, err := gcloudSetupInitialProjectID(projectBase) + if err != nil { + return "", err + } + if projectID != projectBase { + fmt.Fprintf(stdout, "Project ID %s is not a valid GCP project ID; trying %s.\n", projectBase, projectID) + } + if err := runGcloudSetupProjectCreate(ctx, cfg, projectID, name, stdout); err == nil { + return projectID, nil + } else if !gcloudSetupProjectIDAlreadyExists(err) { + return "", fmt.Errorf("create gcloud project %s: %w", projectID, err) + } + projectID, err = gcloudSetupProjectIDWithRandomSuffix(projectBase) + if err != nil { + return "", err + } + fmt.Fprintf(stdout, "Project ID %s is already in use; trying %s.\n", projectBase, projectID) + if err := runGcloudSetupProjectCreate(ctx, cfg, projectID, name, stdout); err != nil { + return "", fmt.Errorf("create gcloud project %s: %w", projectID, err) + } + return projectID, nil +} + +func runGcloudSetupProjectCreate(ctx context.Context, cfg config, projectID, name string, stdout io.Writer) error { + cmd := exec.CommandContext(ctx, "gcloud", "projects", "create", projectID, "--configuration", cfg.gcloudConfiguration, "--name", name) + out, err := cmd.CombinedOutput() + if len(out) > 0 { + _, _ = stdout.Write(out) + } + if err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func gcloudSetupProjectIDAlreadyExists(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "already in use") || strings.Contains(msg, "project id") && strings.Contains(msg, "in use") +} + +func gcloudSetupInitialProjectID(base string) (string, error) { + base = gcloudSetupProjectIDBase(base) + if base == "" { + return "", errors.New("project ID must contain at least one lowercase letter or digit") + } + if len(base) < 6 { + return gcloudSetupProjectIDWithRandomSuffix(base) + } + if len(base) > 30 { + base = strings.TrimRight(base[:30], "-") + } + if !gcloudSetupProjectIDValid(base) { + return "", fmt.Errorf("invalid project ID %q", base) + } + return base, nil +} + +func gcloudSetupProjectIDBase(base string) string { + base = strings.ToLower(strings.TrimSpace(base)) + var b strings.Builder + lastHyphen := false + for _, ch := range base { + valid := ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' + if valid { + b.WriteRune(ch) + lastHyphen = false + continue + } + if ch == '-' || ch == '_' || ch == ' ' || ch == '.' { + if !lastHyphen { + b.WriteByte('-') + lastHyphen = true + } + } + } + base = strings.Trim(b.String(), "-") + if base == "" { + return "" + } + if base[0] < 'a' || base[0] > 'z' { + base = "bgit-" + base + } + return base +} + +func gcloudSetupProjectIDValid(id string) bool { + if len(id) < 6 || len(id) > 30 { + return false + } + if id[0] < 'a' || id[0] > 'z' { + return false + } + last := id[len(id)-1] + if last == '-' { + return false + } + for _, ch := range id { + if ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' || ch == '-' { + continue + } + return false + } + return true +} + +func gcloudSetupProjectIDWithRandomSuffix(base string) (string, error) { + n, err := rand.Int(rand.Reader, big.NewInt(10000000)) + if err != nil { + return "", fmt.Errorf("generate project ID suffix: %w", err) + } + return gcloudSetupProjectIDWithSuffix(base, fmt.Sprintf("%07d", n.Int64())), nil +} + +func gcloudSetupProjectIDWithSuffix(base, suffix string) string { + base = gcloudSetupProjectIDBase(base) + if len(base) > 22 { + base = strings.TrimRight(base[:22], "-") + } + return base + "-" + suffix +} + +func setGcloudSetupProject(ctx context.Context, cfg config, projectID string, stdout io.Writer) error { + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + for _, args := range [][]string{ + {"config", "set", "project", projectID, "--configuration", profile}, + {"config", "set", "billing/quota_project", projectID, "--configuration", profile}, + } { + cmd := exec.CommandContext(ctx, "gcloud", args...) + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("gcloud %s failed: %w", strings.Join(args, " "), err) + } + } + return nil +} + +func ensureGcloudSetupBilling(ctx context.Context, cfg config, project string, interactive bool, stdin io.Reader, stdout io.Writer) error { + project = strings.TrimSpace(project) + if project == "" { + project = gcloudConfigValue(ctx, cfg.gcloudConfiguration, "project") + } + if project == "" { + return errors.New("GCP project is not configured") + } + enabled, err := gcloudSetupBillingEnabled(ctx, cfg, project) + if err == nil && enabled { + return nil + } + if err != nil { + if gcloudProjectServiceDisabled(err.Error()) { + if enableErr := enableGcloudSetupProjectServices(ctx, cfg, project, []string{"cloudbilling.googleapis.com"}, stdout, "GCP Cloud Billing API"); enableErr != nil { + return enableErr + } + enabled, err = gcloudSetupBillingEnabled(ctx, cfg, project) + if err == nil && enabled { + return nil + } + } + if err != nil { + return fmt.Errorf("check GCP billing for project %s: %w", project, err) + } + } + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + if !interactive { + return fmt.Errorf("GCP project %s does not have billing enabled; run `gcloud billing projects link %s --billing-account BILLING_ACCOUNT --configuration %s`", project, project, profile) + } + accounts, err := listGcloudSetupBillingAccounts(ctx, cfg) + if err != nil { + return fmt.Errorf("list GCP billing accounts: %w", err) + } + var openAccounts []gcloudBillingAccountOption + for _, account := range accounts { + if account.Open { + openAccounts = append(openAccounts, account) + } + } + if len(openAccounts) == 0 { + return fmt.Errorf("GCP project %s does not have billing enabled and no open billing accounts are visible to profile %s", project, profile) + } + reader := bufio.NewReader(stdin) + fmt.Fprintf(stdout, "GCP project %s does not have billing enabled.\n", project) + fmt.Fprintln(stdout, "Visible billing accounts:") + for i, account := range openAccounts { + label := account.Name + if strings.TrimSpace(account.DisplayName) != "" { + label += " - " + account.DisplayName + } + fmt.Fprintf(stdout, " %d. %s\n", i+1, label) + } + fmt.Fprintln(stdout, "Choose a billing account number, type a billing account ID, or leave blank to cancel.") + fmt.Fprint(stdout, "Billing account: ") + choice := readSetupLine(reader) + if choice == "" { + return errors.New("setup requires billing to deploy the GCP broker") + } + billingAccount := choice + if n := parsePositiveInt(choice); n > 0 && n <= len(openAccounts) { + billingAccount = openAccounts[n-1].Name + } + if err := linkGcloudSetupBillingAccount(ctx, cfg, project, billingAccount, stdout); err != nil { + return err + } + if err := waitForGcloudSetupBilling(ctx, cfg, project); err != nil { + return err + } + return nil +} + +func gcloudSetupBillingEnabled(ctx context.Context, cfg config, project string) (bool, error) { + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, + "gcloud", "billing", "projects", "describe", project, + "--configuration", cfg.gcloudConfiguration, + "--quiet", + "--format=value(billingEnabled)", + ) + out, err := cmd.CombinedOutput() + if err != nil { + if strings.EqualFold(strings.TrimSpace(string(out)), "false") { + return false, nil + } + return false, fmt.Errorf("%w\n%s", err, strings.TrimSpace(string(out))) + } + switch strings.ToLower(strings.TrimSpace(string(out))) { + case "true", "yes", "1": + return true, nil + default: + return false, nil + } +} + +func listGcloudSetupBillingAccounts(ctx context.Context, cfg config) ([]gcloudBillingAccountOption, error) { + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, + "gcloud", "billing", "accounts", "list", + "--configuration", cfg.gcloudConfiguration, + "--quiet", + "--format=value(name,displayName,open)", + ) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%w\n%s", err, strings.TrimSpace(string(out))) + } + var accounts []gcloudBillingAccountOption + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + fields := strings.Fields(strings.TrimSpace(scanner.Text())) + if len(fields) == 0 { + continue + } + account := gcloudBillingAccountOption{Name: fields[0], Open: true} + if len(fields) > 1 { + last := strings.ToLower(fields[len(fields)-1]) + if last == "true" || last == "false" { + account.Open = last == "true" + account.DisplayName = strings.Join(fields[1:len(fields)-1], " ") + } else { + account.DisplayName = strings.Join(fields[1:], " ") + } + } + accounts = append(accounts, account) + } + return accounts, scanner.Err() +} + +func linkGcloudSetupBillingAccount(ctx context.Context, cfg config, project, billingAccount string, stdout io.Writer) error { + fmt.Fprintf(stdout, "linking GCP project %s to billing account %s\n", project, billingAccount) + cmd := exec.CommandContext(ctx, + "gcloud", "billing", "projects", "link", project, + "--billing-account", billingAccount, + "--configuration", cfg.gcloudConfiguration, + "--quiet", + ) + out, err := cmd.CombinedOutput() + if len(out) > 0 { + _, _ = stdout.Write(out) + } + if err != nil { + if gcloudSetupBillingQuotaExceeded(string(out)) { + return fmt.Errorf("link GCP project %s to billing account %s: billing quota exceeded; request a quota increase at https://support.google.com/code/contact/billing_quota_increase or choose a different billing account", project, billingAccount) + } + return fmt.Errorf("link GCP project %s to billing account %s: %w\n%s", project, billingAccount, err, strings.TrimSpace(string(out))) + } + return nil +} + +func gcloudSetupBillingQuotaExceeded(message string) bool { + msg := strings.ToLower(message) + return strings.Contains(msg, "billing quota exceeded") || + strings.Contains(msg, "billing_quota_increase") +} + +func waitForGcloudSetupBilling(ctx context.Context, cfg config, project string) error { + for i := 0; i < 12; i++ { + enabled, err := gcloudSetupBillingEnabled(ctx, cfg, project) + if err == nil && enabled { + return nil + } + time.Sleep(5 * time.Second) + } + return fmt.Errorf("GCP project %s billing was not visible as enabled before timeout", project) +} + +func maybeRepairGcloudQuotaProject(ctx context.Context, cfg config, project string, accessErr error, interactive bool, stdin io.Reader, stdout io.Writer) (bool, error) { + if !gcloudAuthNeedsLogin(accessErr.Error()) { + return false, nil + } + quotaProject := gcloudConfigValue(ctx, cfg.gcloudConfiguration, "billing/quota_project") + if quotaProject == "" || quotaProject == project { + return false, nil + } + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + command := fmt.Sprintf("gcloud config set billing/quota_project %s --configuration %s", project, profile) + if !interactive { + return false, fmt.Errorf("gcloud profile %s uses quota project %s while target project is %s; run `%s`", profile, quotaProject, project, command) + } + fmt.Fprintf(stdout, "gcloud profile %s uses quota project %s while target project is %s.\n", profile, quotaProject, project) + fmt.Fprintf(stdout, "Set quota project to %s now? [Y/n] ", project) + answer := "" + _, _ = fmt.Fscanln(stdin, &answer) + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer == "n" || answer == "no" { + return false, fmt.Errorf("gcloud profile %s cannot use quota project %s; run `%s`", profile, quotaProject, command) + } + cmd := exec.CommandContext(ctx, "gcloud", "config", "set", "billing/quota_project", project, "--configuration", profile) + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("set gcloud quota project: %w", err) + } + if err := gcloudSetupProjectAccess(ctx, cfg, project); err != nil { + return false, fmt.Errorf("gcloud profile %s still cannot access project %s after setting quota project: %w", profile, project, err) + } + return true, nil +} + +func maybeEnableGcloudSetupProjectAPIs(ctx context.Context, cfg config, project string, accessErr error, stdout io.Writer) (bool, error) { + if !gcloudProjectServiceDisabled(accessErr.Error()) { + return false, nil + } + services := []string{ + "serviceusage.googleapis.com", + "cloudresourcemanager.googleapis.com", + } + if err := enableGcloudSetupProjectServices(ctx, cfg, project, services, stdout, "GCP project APIs"); err != nil { + return false, err + } + for i := 0; i < 12; i++ { + if err := gcloudSetupProjectAccess(ctx, cfg, project); err == nil { + return true, nil + } else if !gcloudProjectServiceDisabled(err.Error()) { + return false, err + } + time.Sleep(5 * time.Second) + } + if err := gcloudSetupProjectAccess(ctx, cfg, project); err != nil { + return false, fmt.Errorf("gcloud project %s is still not ready after enabling required APIs: %w", project, err) + } + return true, nil +} + +func enableGcloudSetupProjectServices(ctx context.Context, cfg config, project string, services []string, stdout io.Writer, label string) error { + fmt.Fprintf(stdout, "enabling required GCP project APIs for %s\n", project) + enableCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + args := append([]string{"services", "enable"}, services...) + args = append(args, + "--project", project, + "--configuration", cfg.gcloudConfiguration, + "--quiet", + ) + cmd := exec.CommandContext(enableCtx, "gcloud", args...) + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("enable %s for %s: %w", label, project, err) + } + return waitForGCPServicesEnabled(cfg, project, services, stdout, label) +} + +func gcloudSetupProjectAccess(ctx context.Context, cfg config, project string) error { + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, "gcloud", "projects", "describe", project, "--configuration", cfg.gcloudConfiguration, "--format=value(projectId)") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("gcloud project access check failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + if strings.TrimSpace(string(out)) == "" { + return fmt.Errorf("gcloud project access check returned no project for %s", project) + } + return nil +} + +func gcloudProjectServiceDisabled(message string) bool { + message = strings.ToLower(message) + return strings.Contains(message, "service_disabled") || + strings.Contains(message, "api has not been used") || + strings.Contains(message, "it is disabled") +} + +func runGcloudSetupLogin(ctx context.Context, cfg config, interactive bool, stdin io.Reader, stdout io.Writer) error { + profile := strings.TrimSpace(cfg.gcloudConfiguration) + if profile == "" { + profile = "default" + } + loginCommand := fmt.Sprintf("gcloud auth login --configuration %s --no-launch-browser", profile) + if !interactive { + return fmt.Errorf("gcloud profile %s needs authentication; run `%s`", profile, loginCommand) + } + fmt.Fprintf(stdout, "gcloud profile %s needs authentication or a different account.\n", profile) + fmt.Fprintf(stdout, "Starting `%s`.\n", loginCommand) + fmt.Fprintln(stdout, "Open the URL printed by gcloud, finish the OAuth flow, then paste the code if prompted.") + cmd := exec.CommandContext(ctx, "gcloud", "auth", "login", "--configuration", profile, "--no-launch-browser") + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("gcloud auth login failed: %w", err) + } + return nil +} + +func gcloudSetupAccessToken(ctx context.Context, cfg config) (string, error) { + probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cmd := exec.CommandContext(probeCtx, "gcloud", "auth", "print-access-token", "--configuration", cfg.gcloudConfiguration) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("gcloud auth print-access-token failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + token := strings.TrimSpace(string(out)) + if token == "" { + return "", errors.New("gcloud auth print-access-token returned an empty token") + } + return token, nil +} + +func gcloudAuthNeedsLogin(message string) bool { + message = strings.ToLower(message) + return strings.Contains(message, "reauthentication failed") || + strings.Contains(message, "gcloud auth login") || + strings.Contains(message, "no credential") || + strings.Contains(message, "invalid_grant") || + strings.Contains(message, "login required") || + strings.Contains(message, "user_project_denied") || + strings.Contains(message, "caller does not have required permission") || + strings.Contains(message, "does not have permission to access projects") +} + +type setupRegionDialogState struct { + Provider string + Profile string + Regions []string + Selected map[string]bool + Cursor int + Page int + Button int + Message string +} + +func runSetupRegionDialogWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, provider, profile string, regions []string, selected []string) ([]string, error) { + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return nil, err + } + defer restore() + state := setupRegionDialogState{Provider: provider, Profile: profile, Regions: regions, Selected: map[string]bool{}, Button: -1} + for _, region := range selected { + if region = strings.TrimSpace(region); region != "" { + state.Selected[region] = true + } + } + for { + fmt.Fprint(stdout, renderSetupRegionDialogFrame(state, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return nil, errSetupBack + } + return nil, err + } + switch b { + case 0x03: + return nil, errors.New("setup canceled") + case 0x04: + if regions, ok := state.deploy(); ok { + return regions, nil + } + case '\r', '\n', ' ', 'x', 'X': + if regions, ok := state.activate(); ok { + return regions, nil + } + if state.Button == 1 { + return nil, errSetupBack + } + case '\t': + state.tab() + case 'q', 'Q': + return nil, errSetupBack + case 0x1b: + next, err := reader.ReadByte() + if err != nil { + return nil, errSetupBack + } + if next == '[' { + last, err := reader.ReadByte() + if err != nil { + return nil, errSetupBack + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + } + continue + } + return nil, errSetupBack + } + } +} + +func (s setupRegionDialogState) visibleRegions() []string { + if len(s.Regions) == 0 { + return nil + } + start := s.Page * setupRegionDialogItemsPerPage + if start >= len(s.Regions) { + start = 0 + } + end := minSetupDialogInt(start+setupRegionDialogItemsPerPage, len(s.Regions)) + return s.Regions[start:end] +} + +func (s *setupRegionDialogState) rows() int { + rows := len(s.visibleRegions()) + if len(s.Regions) > setupRegionDialogItemsPerPage { + rows++ + } + return rows +} + +func (s *setupRegionDialogState) up() { + if s.rows() == 0 { + return + } + s.Button = -1 + s.Message = "" + if s.Cursor == 0 { + s.Cursor = s.rows() - 1 + return + } + s.Cursor-- +} + +func (s *setupRegionDialogState) down() { + if s.rows() == 0 { + return + } + s.Button = -1 + s.Message = "" + s.Cursor = (s.Cursor + 1) % s.rows() +} + +func (s *setupRegionDialogState) tab() { + s.Message = "" + if s.Button == 1 { + s.Button = -1 + s.Cursor = 0 + return + } + if s.Button < 0 { + s.Button = 0 + return + } + s.Button = (s.Button + 1) % 2 +} + +func (s *setupRegionDialogState) activate() ([]string, bool) { + if s.Button == 0 { + return s.deploy() + } + if s.Button == 1 { + return nil, false + } + visible := s.visibleRegions() + if s.Cursor < len(visible) { + region := visible[s.Cursor] + s.Selected[region] = !s.Selected[region] + return nil, false + } + if len(s.Regions) > setupRegionDialogItemsPerPage && s.Cursor == len(visible) { + pages := (len(s.Regions) + setupRegionDialogItemsPerPage - 1) / setupRegionDialogItemsPerPage + s.Page = (s.Page + 1) % pages + s.Cursor = 0 + } + return nil, false +} + +func (s *setupRegionDialogState) deploy() ([]string, bool) { + var selected []string + for _, region := range s.Regions { + if s.Selected[region] { + selected = append(selected, region) + } + } + if len(selected) > 0 { + return selected, true + } + s.Message = "Select at least one region before deploy." + return nil, false +} + +func renderSetupRegionDialogFrame(state setupRegionDialogState, rawMode bool) string { + rendered := renderSetupRegionDialogWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderSetupRegionDialogWithStyle(state setupRegionDialogState, style bool) string { + var lines []string + lines = append(lines, + "+------------------------------------------------------------+", + "| BUCKETGIT SETUP |", + "+------------------------------------------------------------+", + setupDialogRow(fmt.Sprintf("%s profile %s regions", state.Provider, state.Profile)), + "| |", + ) + visible := state.visibleRegions() + for i, region := range visible { + marker := " " + if state.Button < 0 && state.Cursor == i { + marker = ">" + } + checked := " " + if state.Selected[region] { + checked = "x" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s [%s] %s", marker, checked, region), setupDialogSectionStyle(style, state.Button < 0))) + } + if len(state.Regions) > setupRegionDialogItemsPerPage { + marker := " " + if state.Button < 0 && state.Cursor == len(visible) { + marker = ">" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s show next regions", marker), setupDialogSectionStyle(style, state.Button < 0))) + } + if state.Message != "" { + lines = append(lines, setupDialogRowStyled(state.Message, setupDialogANSI(style, "33"))) + } + okStyle := "" + cancelStyle := "" + if style && state.Button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.Button == 1 { + cancelStyle = "\x1b[44;97m" + } + lines = append(lines, + "| |", + "+------------------------------------------------------------+", + setupDialogRow(setupDialogButton("[ OK ]", okStyle)+" "+setupDialogButton("[ Cancel ]", cancelStyle)), + setupDialogRow("Controls: arrows move Space/Enter select Ctrl-D deploy"), + setupDialogRow("Tab rows/buttons Esc back Ctrl-C cancel"), + "+------------------------------------------------------------+", + ) + return strings.Join(lines, "\n") + "\n" +} + +func runSetupDialog(stdin io.Reader, stdout io.Writer, initial setupSelection) (setupSelection, error) { + reader, ok := stdin.(*bufio.Reader) + if !ok { + reader = bufio.NewReader(stdin) + } + return runSetupDialogWithRaw(reader, stdin, stdout, initial) +} + +func runSetupDialogWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, initial setupSelection) (setupSelection, error) { + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return setupSelection{}, err + } + defer restore() + + state := setupDialogState{ + profiles: initial.Profiles, + keys: initial.Keys, + identityName: initial.IdentityName, + identityEmail: initial.IdentityEmail, + initialIdentityName: initial.IdentityName, + initialIdentityEmail: initial.IdentityEmail, + createProviders: setupAvailableCreateProviders(), + defaultCreate: initial.DefaultCreate, + defaultCreateByProvider: initial.DefaultCreateByProvider, + selectedProfiles: make([]bool, len(initial.Profiles)), + selectedKeys: make([]bool, len(initial.Keys)), + providerPages: map[string]int{}, + button: -1, + } + for i := range initial.Keys { + state.selectedKeys[i] = true + } + for { + fmt.Fprint(stdout, renderSetupDialogFrame(state, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return setupSelection{}, errors.New("setup canceled") + } + return setupSelection{}, err + } + if state.discardingCreatePaste || state.discardingIdentityPaste { + if b == '\r' || b == '\n' || (b >= 32 && b <= 126) { + continue + } + state.discardingCreatePaste = false + state.discardingIdentityPaste = false + } + switch b { + case 0x03: + return setupSelection{}, errors.New("setup canceled") + case 0x04: + if state.editingCreate { + state.editingCreate = false + state.editOriginal = "" + continue + } + if state.editingIdentity { + state.editingIdentity = false + state.editOriginal = "" + continue + } + if state.createProvider != "" { + if selected, ok := state.deployCreateProfile(); ok { + return selected, nil + } + continue + } + if selected, ok := state.deploy(); ok { + return selected, nil + } + case '\r', '\n': + if state.editingCreate { + state.editingCreate = false + state.editOriginal = "" + state.message = "" + continue + } + if state.editingIdentity { + state.editingIdentity = false + state.editOriginal = "" + state.message = "" + continue + } + if selected, ok := state.activate(); ok { + return selected, nil + } else if state.button == 1 { + return setupSelection{}, errors.New("setup canceled") + } + case ' ', 'x', 'X': + if state.editingCreate { + state.appendCreateByte(b) + continue + } + if state.editingIdentity { + state.appendIdentityByte(b) + continue + } + if selected, ok := state.activate(); ok { + return selected, nil + } else if state.button == 1 { + return setupSelection{}, errors.New("setup canceled") + } + case '\t': + state.discardingCreatePaste = false + state.tab() + case 'q', 'Q': + return setupSelection{}, errors.New("setup canceled") + case 0x7f, 0x08: + state.discardingCreatePaste = false + if state.editingCreate { + state.backspaceCreate() + } + if state.editingIdentity { + state.backspaceIdentity() + } + case 0x1b: + state.discardingCreatePaste = false + state.discardingIdentityPaste = false + if state.editingCreate { + state.setCreateFieldValue(state.editOriginal) + state.editingCreate = false + state.editOriginal = "" + state.message = "" + continue + } + if state.editingIdentity { + state.setIdentityFieldValue(state.editOriginal) + state.editingIdentity = false + state.editOriginal = "" + state.message = "" + continue + } + next, err := reader.ReadByte() + if err != nil { + if state.createProvider != "" { + state.cancelCreateProfile() + continue + } + return setupSelection{}, errors.New("setup canceled") + } + if next == '[' { + last, err := reader.ReadByte() + if err != nil { + return setupSelection{}, errors.New("setup canceled") + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + } + continue + } + if state.createProvider != "" { + state.cancelCreateProfile() + continue + } + return setupSelection{}, errors.New("setup canceled") + default: + if state.editingCreate && b >= 32 && b <= 126 { + state.appendCreateByte(b) + } + if state.editingIdentity && b >= 32 && b <= 126 { + state.appendIdentityByte(b) + } + } + } +} + +func setupDialogRawMode(stdin io.Reader) (bool, func(), error) { + file, ok := stdin.(*os.File) + if !ok { + return false, func() {}, nil + } + fd := int(file.Fd()) + if !term.IsTerminal(fd) { + return false, func() {}, nil + } + state, err := term.MakeRaw(fd) + if err != nil { + return false, nil, err + } + return true, func() { + _ = term.Restore(fd, state) + fmt.Fprint(os.Stdout, "\x1b[?25h") + }, nil +} + +func renderSetupDialogFrame(state setupDialogState, rawMode bool) string { + rendered := renderSetupDialogWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +type setupDialogState struct { + profiles []setupProfile + keys []setupSSHKey + createProviders []string + selectedProfiles []bool + selectedKeys []bool + providerPages map[string]int + identityName string + identityEmail string + initialIdentityName string + initialIdentityEmail string + cursor int + button int + message string + createProvider string + createName string + createAccessKey string + createSecretKey string + createRegion string + defaultCreate string + defaultCreateByProvider map[string]string + editingCreate bool + discardingCreatePaste bool + editingIdentity bool + discardingIdentityPaste bool + editOriginal string +} + +type setupDialogVisibleItem struct { + Kind string + Provider string + ProfileIndex int + KeyIndex int + Label string +} + +func setupAvailableCreateProviders() []string { + var providers []string + if _, err := exec.LookPath("gcloud"); err == nil { + providers = append(providers, "gcs") + } + if _, err := exec.LookPath("aws"); err == nil { + providers = append(providers, "s3") + } + return providers +} + +func (s setupDialogState) selection() setupSelection { + var profiles []setupProfile + for i, profile := range s.profiles { + if i < len(s.selectedProfiles) && s.selectedProfiles[i] { + profiles = append(profiles, profile) + } + } + var keys []setupSSHKey + for i, key := range s.keys { + if i < len(s.selectedKeys) && s.selectedKeys[i] { + keys = append(keys, key) + } + } + return setupSelection{ + Profiles: profiles, + Keys: keys, + IdentityName: strings.TrimSpace(s.identityName), + IdentityEmail: strings.TrimSpace(s.identityEmail), + } +} + +func (s *setupDialogState) rows() int { + if s.createProvider != "" { + return len(s.createFields()) + } + return len(s.visibleItems()) +} + +func (s *setupDialogState) up() { + if s.editingCreate || s.editingIdentity { + return + } + if s.rows() == 0 { + return + } + s.button = -1 + s.message = "" + if s.cursor == 0 { + s.cursor = s.rows() - 1 + return + } + s.cursor-- +} + +func (s *setupDialogState) down() { + if s.editingCreate || s.editingIdentity { + return + } + if s.rows() == 0 { + return + } + s.button = -1 + s.message = "" + s.cursor = (s.cursor + 1) % s.rows() +} + +func (s *setupDialogState) activate() (setupSelection, bool) { + if s.createProvider != "" { + if s.button == 0 { + return s.deployCreateProfile() + } + if s.button == 1 { + s.cancelCreateProfile() + return setupSelection{}, false + } + s.editingCreate = true + s.editOriginal = s.createFieldValue() + s.message = "" + return setupSelection{}, false + } + if s.button == 0 { + return s.deploy() + } + if s.button == 1 { + return setupSelection{}, false + } + items := s.visibleItems() + if s.cursor < 0 || s.cursor >= len(items) { + return setupSelection{}, false + } + s.message = "" + item := items[s.cursor] + switch item.Kind { + case "identity-name", "identity-email": + s.editingIdentity = true + s.editOriginal = s.identityFieldValue() + s.message = "" + case "create-profile": + s.createProvider = item.Provider + s.createName = firstNonEmpty(s.defaultCreateByProvider[item.Provider], s.defaultCreate) + s.editingCreate = true + s.editOriginal = s.createFieldValue() + s.cursor = 0 + s.button = -1 + return setupSelection{}, false + case "profile": + s.selectedProfiles[item.ProfileIndex] = !s.selectedProfiles[item.ProfileIndex] + case "more": + s.nextProviderPage(item.Provider) + case "key": + if item.KeyIndex >= 0 && item.KeyIndex < len(s.keys) { + s.selectedKeys[item.KeyIndex] = !s.selectedKeys[item.KeyIndex] + } + } + return setupSelection{}, false +} + +func (s *setupDialogState) deploy() (setupSelection, bool) { + selected := s.selection() + if len(selected.Profiles) == 0 && !s.identityChanged() { + s.message = "Select a cloud profile or change identity before deploy." + return setupSelection{}, false + } + return selected, true +} + +func (s setupDialogState) identityChanged() bool { + return strings.TrimSpace(s.identityName) != strings.TrimSpace(s.initialIdentityName) || + strings.TrimSpace(s.identityEmail) != strings.TrimSpace(s.initialIdentityEmail) +} + +func (s *setupDialogState) deployCreateProfile() (setupSelection, bool) { + name := strings.TrimSpace(s.createName) + if name == "" { + s.message = "Enter a profile name before OK." + return setupSelection{}, false + } + if !setupProfileNamePattern.MatchString(name) { + s.message = "Profile name can use letters, numbers, dot, dash, underscore, @, and +." + return setupSelection{}, false + } + if s.createProvider == "s3" { + accessKey := strings.TrimSpace(s.createAccessKey) + secretKey := strings.TrimSpace(s.createSecretKey) + region := strings.TrimSpace(s.createRegion) + if accessKey == "" { + s.message = "Enter an AWS access key ID before OK." + return setupSelection{}, false + } + if !setupAWSAccessKeyPattern.MatchString(accessKey) { + s.message = "AWS access key ID format looks invalid." + return setupSelection{}, false + } + if secretKey == "" { + s.message = "Enter an AWS secret access key before OK." + return setupSelection{}, false + } + if strings.ContainsAny(secretKey, " \t\r\n") || len(secretKey) < 20 { + s.message = "AWS secret access key format looks invalid." + return setupSelection{}, false + } + if region != "" && !setupAWSRegionPattern.MatchString(region) { + s.message = "AWS region format looks invalid." + return setupSelection{}, false + } + } + return setupSelection{ + Action: "create-profile", + CreateProvider: s.createProvider, + CreateName: name, + CreateAccessKey: strings.TrimSpace(s.createAccessKey), + CreateSecretKey: strings.TrimSpace(s.createSecretKey), + CreateRegion: strings.TrimSpace(s.createRegion), + }, true +} + +func (s *setupDialogState) cancelCreateProfile() { + s.createProvider = "" + s.createName = "" + s.createAccessKey = "" + s.createSecretKey = "" + s.createRegion = "" + s.editingCreate = false + s.discardingCreatePaste = false + s.editOriginal = "" + s.button = -1 + s.cursor = 0 + s.message = "" +} + +func (s *setupDialogState) appendCreateByte(b byte) { + if s.discardingCreatePaste { + if b == '\r' || b == '\n' || (b >= 32 && b <= 126) { + return + } + s.discardingCreatePaste = false + } + s.message = "" + if b == '\r' || b == '\n' { + s.editingCreate = false + s.editOriginal = "" + s.discardingCreatePaste = true + return + } + value := s.createFieldValue() + if len(value) >= 48 { + return + } + if b < 32 || b > 126 { + return + } + s.setCreateFieldValue(value + string(b)) +} + +func (s *setupDialogState) backspaceCreate() { + s.message = "" + value := s.createFieldValue() + if len(value) == 0 { + return + } + s.setCreateFieldValue(value[:len(value)-1]) +} + +func (s setupDialogState) createFields() []string { + if s.createProvider == "s3" { + return []string{"Profile name", "AWS Access Key ID", "AWS Secret Access Key", "Default region name"} + } + return []string{"Profile name"} +} + +func (s setupDialogState) createFieldValue() string { + return s.createFieldValueForRow(s.cursor) +} + +func (s setupDialogState) createFieldValueForRow(row int) string { + switch row { + case 1: + return s.createAccessKey + case 2: + return s.createSecretKey + case 3: + return s.createRegion + default: + return s.createName + } +} + +func (s *setupDialogState) setCreateFieldValue(value string) { + switch s.cursor { + case 1: + s.createAccessKey = value + case 2: + s.createSecretKey = value + case 3: + s.createRegion = value + default: + s.createName = value + } +} + +func (s *setupDialogState) tab() { + if s.editingCreate { + s.editingCreate = false + s.editOriginal = "" + } + if s.editingIdentity { + s.editingIdentity = false + s.editOriginal = "" + } + s.message = "" + if s.createProvider != "" { + if s.button == 1 { + s.button = -1 + return + } + if s.button < 0 { + s.button = 0 + return + } + s.button = (s.button + 1) % 2 + return + } + if s.button == 1 { + s.button = -1 + s.cursor = 0 + return + } + items := s.visibleItems() + if len(items) == 0 { + s.button = (s.button + 1) % 2 + return + } + currentProvider := "" + if s.cursor >= 0 && s.cursor < len(items) { + currentProvider = items[s.cursor].Provider + } + for _, provider := range setupDialogProviderOrder(s.profiles, s.createProviders) { + if currentProvider == "" || provider > currentProvider { + if idx := firstSetupDialogProviderItem(items, provider); idx >= 0 { + s.cursor = idx + s.button = 0 + return + } + } + } + if currentProvider != "ssh" { + if currentProvider != "identity" { + if idx := firstSetupDialogProviderItem(items, "identity"); idx >= 0 { + s.cursor = idx + s.button = -1 + return + } + } + if idx := firstSetupDialogProviderItem(items, "ssh"); idx >= 0 { + s.cursor = idx + s.button = -1 + return + } + } + if s.button < 0 { + s.button = 0 + } else { + s.button = (s.button + 1) % 2 + } +} + +func firstSetupDialogProviderItem(items []setupDialogVisibleItem, provider string) int { + for i, item := range items { + if item.Provider == provider { + return i + } + } + return -1 +} + +func (s *setupDialogState) nextProviderPage(provider string) { + indices := s.profileIndicesByProvider()[provider] + if len(indices) <= setupDialogProfilesPerProvider { + return + } + pages := (len(indices) + setupDialogProfilesPerProvider - 1) / setupDialogProfilesPerProvider + s.ensureProviderPages() + s.providerPages[provider] = (s.providerPages[provider] + 1) % pages + items := s.visibleItems() + if idx := firstSetupDialogProviderItem(items, provider); idx >= 0 { + s.cursor = idx + } +} + +func (s *setupDialogState) ensureProviderPages() { + if s.providerPages == nil { + s.providerPages = map[string]int{} + } +} + +func (s setupDialogState) visibleItems() []setupDialogVisibleItem { + var items []setupDialogVisibleItem + byProvider := s.profileIndicesByProvider() + for _, provider := range setupDialogProviderOrder(s.profiles, s.createProviders) { + indices := byProvider[provider] + page := 0 + if s.providerPages != nil { + page = s.providerPages[provider] + } + start := page * setupDialogProfilesPerProvider + if start >= len(indices) { + start = 0 + } + end := start + setupDialogProfilesPerProvider + if end > len(indices) { + end = len(indices) + } + for _, profileIndex := range indices[start:end] { + items = append(items, setupDialogVisibleItem{Kind: "profile", Provider: provider, ProfileIndex: profileIndex}) + } + if len(indices) > setupDialogProfilesPerProvider { + nextEnd := minSetupDialogInt(end+setupDialogProfilesPerProvider, len(indices)) + nextStart := end + 1 + if end >= len(indices) { + nextStart = 1 + nextEnd = minSetupDialogInt(setupDialogProfilesPerProvider, len(indices)) + } + items = append(items, setupDialogVisibleItem{ + Kind: "more", + Provider: provider, + Label: fmt.Sprintf("show next %s profiles (%d-%d of %d)", setupProviderLabel(provider), nextStart, nextEnd, len(indices)), + }) + } + if setupDialogCanCreateProvider(s.createProviders, provider) { + items = append(items, setupDialogVisibleItem{ + Kind: "create-profile", + Provider: provider, + Label: fmt.Sprintf("create new %s profile", setupProviderLabel(provider)), + }) + } + } + items = append(items, setupDialogVisibleItem{Kind: "identity-name", Provider: "identity", Label: "Name"}) + items = append(items, setupDialogVisibleItem{Kind: "identity-email", Provider: "identity", Label: "Email"}) + for i := range s.keys { + items = append(items, setupDialogVisibleItem{Kind: "key", Provider: "ssh", KeyIndex: i}) + } + return items +} + +func setupDialogCanCreateProvider(providers []string, provider string) bool { + for _, item := range providers { + if item == provider { + return true + } + } + return false +} + +func (s setupDialogState) profileIndicesByProvider() map[string][]int { + byProvider := map[string][]int{} + for i, profile := range s.profiles { + byProvider[profile.Provider] = append(byProvider[profile.Provider], i) + } + return byProvider +} + +func setupDialogProviderOrder(profiles []setupProfile, createProviders []string) []string { + seen := map[string]struct{}{} + for _, profile := range profiles { + seen[profile.Provider] = struct{}{} + } + for _, provider := range createProviders { + seen[provider] = struct{}{} + } + var order []string + for _, provider := range []string{"gcs", "s3"} { + if _, ok := seen[provider]; ok { + order = append(order, provider) + delete(seen, provider) + } + } + var rest []string + for provider := range seen { + rest = append(rest, provider) + } + sort.Strings(rest) + return append(order, rest...) +} + +func minSetupDialogInt(a, b int) int { + if a < b { + return a + } + return b +} + +func renderSetupDialog(state setupDialogState) string { + return renderSetupDialogWithStyle(state, false) +} + +func renderSetupDialogWithStyle(state setupDialogState, style bool) string { + if state.createProvider != "" { + return renderSetupCreateProfileDialogWithStyle(state, style) + } + var lines []string + lines = append(lines, + "+------------------------------------------------------------+", + "| BUCKETGIT SETUP |", + "+------------------------------------------------------------+", + "| Select cloud profiles to configure |", + "| |", + ) + activeSection := state.activeSection() + itemIndex := 0 + byProvider := state.profileIndicesByProvider() + for _, provider := range setupDialogProviderOrder(state.profiles, state.createProviders) { + lines = append(lines, setupDialogRowStyled(setupProviderLabel(provider)+" profiles", setupDialogSectionStyle(style, activeSection == provider))) + providerItems := setupDialogProviderVisibleItems(state, provider) + for _, item := range providerItems { + marker := " " + if state.cursor == itemIndex { + marker = ">" + } + switch item.Kind { + case "create-profile": + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s %s", marker, item.Label), setupDialogSectionStyle(style, activeSection == provider))) + case "profile": + profile := state.profiles[item.ProfileIndex] + checked := " " + if item.ProfileIndex < len(state.selectedProfiles) && state.selectedProfiles[item.ProfileIndex] { + checked = "x" + } + detail := firstNonEmpty(profile.ProjectID, profile.AccountID, profile.Account, profile.ARN) + rowStyle := setupDialogSectionStyle(style, activeSection == provider) + if profile.Existing && style { + rowStyle += "\x1b[1;97m" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s [%s] %s:%-12s %-34s", marker, checked, setupProviderLabel(profile.Provider), setupProfileDisplayName(profile), detail), rowStyle)) + case "more": + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s %s", marker, item.Label), setupDialogSectionStyle(style, activeSection == provider))) + } + itemIndex++ + } + if len(byProvider[provider]) == 0 { + lines = append(lines, setupDialogRowStyled(" none", setupDialogSectionStyle(style, activeSection == provider))) + } + } + lines = append(lines, setupDialogRow("")) + lines = append(lines, setupDialogRowStyled("Identity", setupDialogSectionStyle(style, activeSection == "identity"))) + for _, field := range []struct { + kind string + label string + value string + }{ + {kind: "identity-name", label: "Name", value: state.identityName}, + {kind: "identity-email", label: "Email", value: state.identityEmail}, + } { + marker := " " + if state.cursor == itemIndex { + marker = ">" + } + active := activeSection == "identity" && state.button < 0 && state.cursor == itemIndex + inputStyle := setupDialogSectionStyle(style, activeSection == "identity") + if style && state.editingIdentity && active { + inputStyle += "\x1b[44;97m" + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s %-5s [%s]", marker, field.label, initDialogInputValue(field.value, 48, state.editingIdentity && active, style)), inputStyle)) + itemIndex++ + } + lines = append(lines, setupDialogRow("")) + lines = append(lines, setupDialogRowStyled("Owner SSH keys", setupDialogSectionStyle(style, activeSection == "ssh"))) + for i, key := range state.keys { + marker := " " + if state.cursor == itemIndex { + marker = ">" + } + checked := " " + if i < len(state.selectedKeys) && state.selectedKeys[i] { + checked = "x" + } + label := firstNonEmpty(key.Comment, key.Source, shortSetupKey(key.PublicKey)) + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s [%s] %-54s", marker, checked, label), setupDialogSectionStyle(style, activeSection == "ssh"))) + itemIndex++ + } + if len(state.keys) == 0 { + lines = append(lines, setupDialogRowStyled(" no SSH public keys found", setupDialogSectionStyle(style, activeSection == "ssh"))) + } + ok := "[ OK ]" + exit := "[ Exit ]" + okStyle := "" + exitStyle := "" + if style && state.button == 0 { + okStyle = "\x1b[44;97m" + } + if state.button == 1 { + ok = " OK " + exit = "[ EXIT ]" + if style { + exitStyle = "\x1b[44;97m" + } + } + if state.message != "" { + lines = append(lines, setupDialogRowStyled(state.message, setupDialogANSI(style, "33"))) + } + lines = append(lines, + "| |", + "+------------------------------------------------------------+", + setupDialogRow(setupDialogButton(ok, okStyle)+" "+setupDialogButton(exit, exitStyle)), + setupDialogRow("Controls: arrows move Space/Enter select/edit Ctrl-D deploy"), + setupDialogRow("Tab sections/buttons Esc cancel/revert"), + "+------------------------------------------------------------+", + ) + return strings.Join(lines, "\n") + "\n" +} + +func renderSetupCreateProfileDialogWithStyle(state setupDialogState, style bool) string { + provider := strings.ToUpper(setupProviderLabel(state.createProvider)) + var lines []string + lines = append(lines, + "+------------------------------------------------------------+", + "| BUCKETGIT SETUP |", + "+------------------------------------------------------------+", + setupDialogRow(fmt.Sprintf("Create %s profile", provider)), + "| |", + ) + for i, label := range state.createFields() { + active := state.button < 0 && state.cursor == i + inputStyle := setupDialogSectionStyle(style, active) + if style && state.editingCreate && active { + inputStyle += "\x1b[44;97m" + } + marker := " " + if active { + marker = ">" + } + value := state.createFieldValueForRow(i) + if state.createProvider == "s3" && i == 2 { + value = strings.Repeat("*", len(value)) + } + lines = append(lines, setupDialogRowStyled(fmt.Sprintf("%s %-21s [%s]", marker, label, initDialogInputValue(value, 28, state.editingCreate && active, style)), inputStyle)) + } + if state.message != "" { + lines = append(lines, setupDialogRowStyled(state.message, setupDialogANSI(style, "33"))) + } + okStyle := "" + cancelStyle := "" + if style && state.button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.button == 1 { + cancelStyle = "\x1b[44;97m" + } + lines = append(lines, + "| |", + "+------------------------------------------------------------+", + setupDialogRow(setupDialogButton("[ OK ]", okStyle)+" "+setupDialogButton("[ Cancel ]", cancelStyle)), + setupDialogRow("Enter edits/saves profile Ctrl-D OK"), + setupDialogRow("Tab field/buttons Esc back Ctrl-C cancel"), + "+------------------------------------------------------------+", + ) + return strings.Join(lines, "\n") + "\n" +} + +func (s setupDialogState) activeSection() string { + if s.createProvider != "" { + return s.createProvider + } + if s.button >= 0 { + return "buttons" + } + items := s.visibleItems() + if s.cursor >= 0 && s.cursor < len(items) { + return items[s.cursor].Provider + } + return "" +} + +func (s setupDialogState) identityFieldValue() string { + items := s.visibleItems() + if s.cursor < 0 || s.cursor >= len(items) { + return "" + } + switch items[s.cursor].Kind { + case "identity-email": + return s.identityEmail + default: + return s.identityName + } +} + +func (s *setupDialogState) setIdentityFieldValue(value string) { + items := s.visibleItems() + if s.cursor < 0 || s.cursor >= len(items) { + return + } + switch items[s.cursor].Kind { + case "identity-email": + s.identityEmail = value + default: + s.identityName = value + } +} + +func (s *setupDialogState) appendIdentityByte(b byte) { + if s.discardingIdentityPaste { + if b == '\r' || b == '\n' || (b >= 32 && b <= 126) { + return + } + s.discardingIdentityPaste = false + } + s.message = "" + if b == '\r' || b == '\n' { + s.editingIdentity = false + s.editOriginal = "" + s.discardingIdentityPaste = true + return + } + value := s.identityFieldValue() + if len(value) >= 80 { + return + } + if b < 32 || b > 126 { + return + } + s.setIdentityFieldValue(value + string(b)) +} + +func (s *setupDialogState) backspaceIdentity() { + s.message = "" + value := s.identityFieldValue() + if len(value) == 0 { + return + } + s.setIdentityFieldValue(value[:len(value)-1]) +} + +func setupDialogSectionStyle(style, active bool) string { + if !style || !active { + return "" + } + return "\x1b[48;5;236m" +} + +func setupDialogANSI(style bool, code string) string { + if !style { + return "" + } + return "\x1b[" + code + "m" +} + +func setupDialogButton(text, style string) string { + if style == "" { + return text + } + return style + text + "\x1b[0m" +} + +func stripSetupANSI(value string) string { + var b strings.Builder + for i := 0; i < len(value); i++ { + if value[i] == 0x1b && i+1 < len(value) && value[i+1] == '[' { + i += 2 + for i < len(value) && (value[i] < '@' || value[i] > '~') { + i++ + } + continue + } + b.WriteByte(value[i]) + } + return b.String() +} + +func setupDialogProviderVisibleItems(state setupDialogState, provider string) []setupDialogVisibleItem { + var items []setupDialogVisibleItem + for _, item := range state.visibleItems() { + if item.Provider == provider { + items = append(items, item) + } + } + return items +} + +func setupDialogRow(text string) string { + visible := stripSetupANSI(text) + if len(visible) > 58 { + text = visible[:58] + visible = text + } + return "| " + text + strings.Repeat(" ", 58-len(visible)) + " |" +} + +func setupDialogRowStyled(text, style string) string { + visible := text + if len(visible) > 58 { + visible = visible[:58] + text = visible + } + if style != "" { + text = style + text + "\x1b[0m" + } + return "| " + text + strings.Repeat(" ", 58-len(visible)) + " |" +} + +func setupProviderLabel(provider string) string { + if provider == "s3" { + return "aws" + } + return "gcp" +} + +func setupProfileDisplayName(profile setupProfile) string { + if profile.Existing && strings.TrimSpace(profile.Region) != "" { + return profile.Name + "." + profile.Region + } + return profile.Name +} + +func shortSetupKey(key string) string { + fields := strings.Fields(key) + if len(fields) < 2 { + return key + } + part := fields[1] + if len(part) > 16 { + part = part[:16] + } + return fields[0] + " " + part +} + +func brokerUpsertOwners(brokerURL string, publicKeys []string) error { + return brokerPost(brokerURL, "/owners/upsert", brokerOwnerRequest{User: "owner", Role: "owner", PublicKeys: publicKeys}, nil) +} + +func upsertGlobalGCPProfile(cfg globalConfig, profile globalGCPProfile) globalConfig { + for i, existing := range cfg.GCPProfiles { + if existing.Name == profile.Name { + profile.Regions = mergeGlobalProfileRegions(existing.Regions, profile.Regions) + cfg.GCPProfiles[i] = profile + return cfg + } + } + cfg.GCPProfiles = append(cfg.GCPProfiles, profile) + return cfg +} + +func upsertGlobalAWSProfile(cfg globalConfig, profile globalAWSProfile) globalConfig { + for i, existing := range cfg.AWSProfiles { + if existing.Name == profile.Name { + profile.Regions = mergeGlobalProfileRegions(existing.Regions, profile.Regions) + cfg.AWSProfiles[i] = profile + return cfg + } + } + cfg.AWSProfiles = append(cfg.AWSProfiles, profile) + return cfg +} + +func mergeGlobalProfileRegions(existing, incoming []globalProfileRegion) []globalProfileRegion { + out := append([]globalProfileRegion{}, existing...) + for _, next := range incoming { + matched := false + for i := range out { + if out[i].Name == next.Name { + out[i] = next + matched = true + break + } + } + if !matched { + out = append(out, next) + } + } + return out +} diff --git a/setup_test.go b/setup_test.go new file mode 100644 index 0000000..85c3ab2 --- /dev/null +++ b/setup_test.go @@ -0,0 +1,878 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseAWSProfileFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + data := `[default] +region = us-east-1 + +[profile work] +region = eu-west-1 +` + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + got := parseAWSProfileFile(path) + if strings.Join(got, ",") != "default,work" { + t.Fatalf("profiles = %#v", got) + } + if region := awsProfileFileValue(path, "work", "region"); region != "eu-west-1" { + t.Fatalf("region = %q", region) + } +} + +func TestResolveAWSSetupRegionUsesConfiguredRegion(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{}) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + got, err := resolveAWSSetupRegion(context.Background(), setupProfile{Name: "prod", Region: "eu-west-1"}, "", false, strings.NewReader(""), ioDiscard{}) + if err != nil { + t.Fatal(err) + } + if got != "eu-west-1" { + t.Fatalf("region = %q", got) + } +} + +func TestResolveAWSSetupRegionPromptsFromEnabledRegions(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{}) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + got, err := resolveAWSSetupRegion(context.Background(), setupProfile{Name: "prod"}, "", true, strings.NewReader("\x1b[B \x04"), &stdout) + if err != nil { + t.Fatal(err) + } + if got != "eu-west-1" { + t.Fatalf("region = %q", got) + } + if !strings.Contains(stdout.String(), "AWS profile prod regions") || + !strings.Contains(stdout.String(), "us-east-1") || + !strings.Contains(stdout.String(), "eu-west-1") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestResolveAWSSetupRegionYesModeRequiresRegion(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{}) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + errRegion, err := resolveAWSSetupRegion(context.Background(), setupProfile{Name: "prod"}, "", false, strings.NewReader(""), ioDiscard{}) + if err == nil || errRegion != "" || !strings.Contains(err.Error(), "pass --region REGION") { + t.Fatalf("region=%q err=%v", errRegion, err) + } +} + +func TestResolveAWSSetupRegionRequiresAWSCLI(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + region, err := resolveAWSSetupRegion(context.Background(), setupProfile{Name: "prod", Region: "eu-west-1"}, "", true, strings.NewReader(""), ioDiscard{}) + if err == nil || region != "" || !strings.Contains(err.Error(), "AWS CLI is not installed") { + t.Fatalf("region=%q err=%v", region, err) + } +} + +func TestResolveGCPSetupRegionUsesDialog(t *testing.T) { + var stdout bytes.Buffer + got, err := resolveGCPSetupRegion(setupProfile{Name: "work", Region: "us-central1"}, "", true, strings.NewReader("\x1b[B \x04"), &stdout) + if err != nil { + t.Fatal(err) + } + if got != "us-east1" { + t.Fatalf("region = %q", got) + } + if !strings.Contains(stdout.String(), "GCP profile work regions") || + !strings.Contains(stdout.String(), "us-central1") || + !strings.Contains(stdout.String(), "us-east1") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestResolveGCPSetupRegionUsesExistingConfiguredRegion(t *testing.T) { + var stdout bytes.Buffer + got, err := resolveGCPSetupRegion(setupProfile{Name: "work", Region: "europe-west1", Existing: true}, "", true, strings.NewReader("\x04"), &stdout) + if err != nil { + t.Fatal(err) + } + if got != "europe-west1" { + t.Fatalf("region = %q", got) + } + if !strings.Contains(stdout.String(), "[x] europe-west1") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestConfiguredSetupProfilesCarryConfiguredRegion(t *testing.T) { + got := markConfiguredSetupProfiles([]setupProfile{{ + Provider: "gcs", + Name: "work", + Region: "us-central1", + }}, globalConfig{GCPProfiles: []globalGCPProfile{{ + Name: "work", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://broker.example.test", + }}, + }}}) + if len(got) != 1 { + t.Fatalf("profiles = %#v", got) + } + if !got[0].Existing || got[0].Region != "europe-west1" { + t.Fatalf("profile = %#v", got[0]) + } +} + +func TestConfiguredSetupProfilesExpandConfiguredRegions(t *testing.T) { + got := markConfiguredSetupProfiles([]setupProfile{{ + Provider: "gcs", + Name: "work", + }}, globalConfig{GCPProfiles: []globalGCPProfile{{ + Name: "work", + Regions: []globalProfileRegion{{ + Name: "us-central1", + BrokerURL: "https://us.example.test", + }, { + Name: "europe-west1", + BrokerURL: "https://eu.example.test", + }}, + }}}) + if len(got) != 2 { + t.Fatalf("profiles = %#v", got) + } + if got[0].Name != "work" || got[0].Region != "us-central1" || !got[0].Existing { + t.Fatalf("first profile = %#v", got[0]) + } + if got[1].Name != "work" || got[1].Region != "europe-west1" || !got[1].Existing { + t.Fatalf("second profile = %#v", got[1]) + } +} + +func TestSetupDialogRendersCheckboxesAndKeys(t *testing.T) { + rendered := renderSetupDialog(setupDialogState{ + profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + ProjectID: "example-test-123456", + }}, + keys: []setupSSHKey{{ + PublicKey: "ssh-ed25519 AAAATEST", + Source: "ssh-agent", + Comment: "dennis", + }}, + selectedProfiles: []bool{true}, + selectedKeys: []bool{true}, + }) + for _, want := range []string{"BUCKETGIT SETUP", "> [x] gcp:work", "Owner SSH keys", "[x] dennis", "[ OK ]", "[ Exit ]"} { + if !strings.Contains(rendered, want) { + t.Fatalf("dialog missing %q:\n%s", want, rendered) + } + } +} + +func TestSetupDialogPaginatesProfilesPerProvider(t *testing.T) { + var profiles []setupProfile + for i := 0; i < 12; i++ { + profiles = append(profiles, setupProfile{Provider: "gcs", Name: "gcp" + string(rune('a'+i))}) + } + for i := 0; i < 11; i++ { + profiles = append(profiles, setupProfile{Provider: "s3", Name: "aws" + string(rune('a'+i))}) + } + rendered := renderSetupDialog(setupDialogState{ + profiles: profiles, + selectedProfiles: make([]bool, len(profiles)), + providerPages: map[string]int{}, + }) + if strings.Count(rendered, "gcp:") != setupDialogProfilesPerProvider { + t.Fatalf("expected first GCP page only:\n%s", rendered) + } + if strings.Count(rendered, "aws:") != setupDialogProfilesPerProvider { + t.Fatalf("expected first AWS page only:\n%s", rendered) + } + for _, want := range []string{"show next gcp profiles", "show next aws profiles"} { + if !strings.Contains(rendered, want) { + t.Fatalf("dialog missing %q:\n%s", want, rendered) + } + } +} + +func TestSetupDialogTabJumpsBetweenProviders(t *testing.T) { + state := setupDialogState{ + profiles: []setupProfile{ + {Provider: "gcs", Name: "work"}, + {Provider: "s3", Name: "prod"}, + }, + selectedProfiles: make([]bool, 2), + providerPages: map[string]int{}, + } + state.tab() + items := state.visibleItems() + if state.cursor >= len(items) || items[state.cursor].Provider != "s3" { + t.Fatalf("tab should jump to AWS provider, cursor=%d items=%#v", state.cursor, items) + } +} + +func TestSetupDialogHandlesKeyboardSelection(t *testing.T) { + var stdout bytes.Buffer + selected, err := runSetupDialog(strings.NewReader(" \x04"), &stdout, setupSelection{ + Profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + Active: true, + }}, + Keys: []setupSSHKey{{ + PublicKey: "ssh-ed25519 AAAATEST", + Comment: "dennis", + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(selected.Profiles) != 1 { + t.Fatalf("profiles = %#v", selected.Profiles) + } + if len(selected.Keys) != 1 { + t.Fatalf("keys = %#v", selected.Keys) + } +} + +func TestSetupDialogPreselectsSSHKeys(t *testing.T) { + var stdout bytes.Buffer + selected, err := runSetupDialog(strings.NewReader(" \x04"), &stdout, setupSelection{ + Profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + }}, + Keys: []setupSSHKey{{ + PublicKey: "ssh-ed25519 AAAATEST", + Comment: "dennis", + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(selected.Keys) != 1 { + t.Fatalf("keys = %#v", selected.Keys) + } +} + +func TestSetupDialogCreatesAWSProfileInApp(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{}) + t.Setenv("PATH", bin) + var stdout bytes.Buffer + input := " demo\n\x1b[B\nAKIA1234567890ABCDEF\n\x1b[B\nsecretkeyvalue1234567890\n\x1b[B\nus-east-1\n\x04" + selected, err := runSetupDialog(strings.NewReader(input), &stdout, setupSelection{}) + if err != nil { + t.Fatalf("%v\n%s", err, stdout.String()) + } + if selected.Action != "create-profile" || selected.CreateProvider != "s3" { + t.Fatalf("selection = %#v", selected) + } + if selected.CreateName != "demo" || selected.CreateAccessKey != "AKIA1234567890ABCDEF" || selected.CreateSecretKey != "secretkeyvalue1234567890" || selected.CreateRegion != "us-east-1" { + t.Fatalf("selection = %#v", selected) + } + if !strings.Contains(stdout.String(), "Create AWS profile") || strings.Contains(stdout.String(), "AWS Access Key ID [None]") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupCreateProfileDefaultsAvoidExistingDefault(t *testing.T) { + defaults := setupCreateProfileDefaults([]setupProfile{{Provider: "s3", Name: "default"}}, setupOptions{}) + if defaults["s3"] != "" { + t.Fatalf("aws default = %q", defaults["s3"]) + } + if defaults["gcs"] != "default" { + t.Fatalf("gcp default = %q", defaults["gcs"]) + } +} + +func TestSetupCreateProfileValidationAndSingleLinePaste(t *testing.T) { + state := setupDialogState{createProvider: "s3", createName: "default", createAccessKey: "bad", createSecretKey: "secretkeyvalue1234567890"} + if _, ok := state.deployCreateProfile(); ok || !strings.Contains(state.message, "access key") { + t.Fatalf("message = %q ok=%v", state.message, ok) + } + state = setupDialogState{createProvider: "s3", editingCreate: true} + for _, b := range []byte("one\ntwo") { + state.appendCreateByte(b) + } + if state.createName != "one" { + t.Fatalf("createName = %q", state.createName) + } +} + +func TestCreateAWSProfileConfiguredUsesAWSConfigureSet(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{ + {match: "configure set aws_access_key_id AKIA1234567890ABCDEF --profile demo"}, + {match: "configure set aws_secret_access_key secretkeyvalue1234567890 --profile demo"}, + {match: "configure set region eu-west-1 --profile demo"}, + }) + t.Setenv("PATH", bin) + var stdout bytes.Buffer + if err := createAWSProfileConfigured("demo", "AKIA1234567890ABCDEF", "secretkeyvalue1234567890", "eu-west-1", &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "created AWS profile demo") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupDialogDoesNotDeployWithoutProfile(t *testing.T) { + var stdout bytes.Buffer + _, err := runSetupDialog(strings.NewReader("\x04\x03"), &stdout, setupSelection{ + Profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + Active: true, + }}, + Keys: []setupSSHKey{{ + PublicKey: "ssh-ed25519 AAAATEST", + Comment: "dennis", + }}, + }) + if err == nil || !strings.Contains(err.Error(), "setup canceled") { + t.Fatalf("err = %v", err) + } + if !strings.Contains(stdout.String(), "Select a cloud profile or change identity") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupDialogDeploysIdentityOnlyWhenChanged(t *testing.T) { + state := setupDialogState{ + identityName: "Dennis Example", + identityEmail: "dennis@example.com", + initialIdentityName: "BucketGit Client", + initialIdentityEmail: "dennis@bucketgit.com", + } + selected, ok := state.deploy() + if !ok { + t.Fatalf("deploy rejected identity-only change: %q", state.message) + } + if len(selected.Profiles) != 0 { + t.Fatalf("profiles = %#v", selected.Profiles) + } + if selected.IdentityName != "Dennis Example" || selected.IdentityEmail != "dennis@example.com" { + t.Fatalf("identity = %q <%s>", selected.IdentityName, selected.IdentityEmail) + } +} + +func TestSetupDialogEOFCancels(t *testing.T) { + var stdout bytes.Buffer + _, err := runSetupDialog(strings.NewReader(""), &stdout, setupSelection{ + Profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + Active: true, + }}, + }) + if err == nil || !strings.Contains(err.Error(), "setup canceled") { + t.Fatalf("err = %v", err) + } +} + +func TestSetupDialogCtrlCCancels(t *testing.T) { + var stdout bytes.Buffer + _, err := runSetupDialog(strings.NewReader("\x03"), &stdout, setupSelection{ + Profiles: []setupProfile{{ + Provider: "gcs", + Name: "work", + Active: true, + }}, + }) + if err == nil || !strings.Contains(err.Error(), "setup canceled") { + t.Fatalf("err = %v", err) + } +} + +func TestSetupSSHKeyDiscoveryDedupesKeys(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + sshDir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(sshDir, 0o755); err != nil { + t.Fatal(err) + } + key := "ssh-ed25519 AAAATEST dennis@example" + if err := os.WriteFile(filepath.Join(sshDir, "id_ed25519.pub"), []byte(key+"\n"), 0o644); err != nil { + t.Fatal(err) + } + explicit := filepath.Join(home, "explicit.pub") + if err := os.WriteFile(explicit, []byte(key+"\n"), 0o644); err != nil { + t.Fatal(err) + } + keys, err := discoverSetupSSHKeys(setupSSHKeyOptions{Paths: []string{explicit}, NoAgent: true}) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 { + t.Fatalf("keys = %#v", keys) + } + if keys[0].PublicKey != "ssh-ed25519 AAAATEST" || keys[0].Comment != "dennis@example" { + t.Fatalf("key = %#v", keys[0]) + } +} + +func TestSetupCommandProvisionsGCPAndWritesGlobalConfig(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + pubKey := filepath.Join(home, "owner.pub") + if err := os.WriteFile(pubKey, []byte("ssh-ed25519 AAAAOWNER owner@example\n"), 0o644); err != nil { + t.Fatal(err) + } + var ownerReq brokerOwnerRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/owners/upsert" { + t.Fatalf("unexpected broker path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&ownerReq); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + bin := t.TempDir() + marker := filepath.Join(t.TempDir(), "deployed") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config configurations list", stdout: "work True"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "config get-value project", stdout: "example-test-123456"}, + {match: "auth print-access-token", stdout: "token"}, + {match: "billing projects describe example-test-123456", stdout: "True"}, + {match: "projects describe example-test-123456", stdout: "example-test-123456"}, + {match: "functions describe bgit-broker --gen2 --region europe-west1 --format=value(serviceConfig.uri)", stdout: server.URL, requireFile: marker, exitCode: 1}, + {match: "services enable"}, + {match: "services list --enabled", stdout: "serviceusage.googleapis.com cloudresourcemanager.googleapis.com cloudfunctions.googleapis.com run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com firestore.googleapis.com iamcredentials.googleapis.com"}, + {match: "firestore databases describe", exitCode: 1}, + {match: "firestore databases create"}, + {match: "iam service-accounts describe bgit-broker@example-test-123456.iam.gserviceaccount.com", exitCode: 1}, + {match: "iam service-accounts create bgit-broker"}, + {match: "projects add-iam-policy-binding example-test-123456 --member=serviceAccount:bgit-broker@example-test-123456.iam.gserviceaccount.com"}, + {match: "--service-account bgit-broker@example-test-123456.iam.gserviceaccount.com", touch: marker}, + {match: "iam service-accounts add-iam-policy-binding bgit-broker@example-test-123456.iam.gserviceaccount.com"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + configPath := filepath.Join(home, ".bgit", "config") + var stdout bytes.Buffer + err := setupCommand(nilContext{}, config{}, []string{"--yes", "--provider", "gcp", "--profile", "work", "--config", configPath, "--key", pubKey, "--no-agent", "--region", "europe-west1"}, strings.NewReader(""), &stdout) + if err != nil { + t.Fatal(err) + } + if len(ownerReq.PublicKeys) != 1 || ownerReq.Role != "owner" { + t.Fatalf("owner request = %#v", ownerReq) + } + cfg, err := readGlobalConfig(configPath) + if err != nil { + t.Fatal(err) + } + if len(cfg.GCPProfiles) != 1 { + t.Fatalf("cfg = %#v", cfg) + } + profile := cfg.GCPProfiles[0] + if profile.Name != "work" || profile.ProjectID != "example-test-123456" || + len(profile.Regions) != 1 || profile.Regions[0].Name != "europe-west1" || + profile.Regions[0].BrokerURL != server.URL || profile.Regions[0].BrokerVersion != brokerVersion { + t.Fatalf("profile = %#v", profile) + } + if !strings.Contains(stdout.String(), "Next steps:") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupCommandOffersGCPProfileCreationWhenNoneExist(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + pubKey := filepath.Join(home, "owner.pub") + if err := os.WriteFile(pubKey, []byte("ssh-ed25519 AAAAOWNER owner@example\n"), 0o644); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + bin := t.TempDir() + profileMarker := filepath.Join(t.TempDir(), "profile") + deployMarker := filepath.Join(t.TempDir(), "deployed") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config configurations list", stdout: "new True", requireFile: profileMarker, exitCode: 1}, + {match: "config configurations create new", touch: profileMarker}, + {match: "auth login --configuration new"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "config get-value project", stdout: "example-test-123456"}, + {match: "auth print-access-token", stdout: "token"}, + {match: "billing projects describe example-test-123456", stdout: "True"}, + {match: "projects describe example-test-123456", stdout: "example-test-123456"}, + {match: "functions describe bgit-broker --gen2 --region europe-west1 --format=value(serviceConfig.uri)", stdout: server.URL, requireFile: deployMarker, exitCode: 1}, + {match: "services enable"}, + {match: "services list --enabled", stdout: "serviceusage.googleapis.com cloudresourcemanager.googleapis.com cloudfunctions.googleapis.com run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com firestore.googleapis.com iamcredentials.googleapis.com"}, + {match: "firestore databases describe", exitCode: 1}, + {match: "firestore databases create"}, + {match: "iam service-accounts describe bgit-broker@example-test-123456.iam.gserviceaccount.com", exitCode: 1}, + {match: "iam service-accounts create bgit-broker"}, + {match: "projects add-iam-policy-binding example-test-123456 --member=serviceAccount:bgit-broker@example-test-123456.iam.gserviceaccount.com"}, + {match: "--service-account bgit-broker@example-test-123456.iam.gserviceaccount.com", touch: deployMarker}, + {match: "iam service-accounts add-iam-policy-binding bgit-broker@example-test-123456.iam.gserviceaccount.com"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + configPath := filepath.Join(home, ".bgit", "config") + var stdout bytes.Buffer + err := setupCommand(nilContext{}, config{}, []string{"--provider", "gcp", "--profile", "new", "--config", configPath, "--key", pubKey, "--no-agent", "--region", "europe-west1"}, strings.NewReader("\n\n\x04 \x04"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "create new gcp profile") || !strings.Contains(stdout.String(), "Profile name") || !strings.Contains(stdout.String(), "[new") { + t.Fatalf("stdout = %q", stdout.String()) + } + cfg, err := readGlobalConfig(configPath) + if err != nil { + t.Fatal(err) + } + if len(cfg.GCPProfiles) != 1 || cfg.GCPProfiles[0].Name != "new" { + t.Fatalf("cfg = %#v", cfg) + } +} + +func TestEnsureGcloudSetupAuthRunsLoginOnReauth(t *testing.T) { + bin := t.TempDir() + authMarker := filepath.Join(t.TempDir(), "authed") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "auth print-access-token", stdout: "token", missingStdout: "ERROR: Reauthentication failed. Please run: gcloud auth login", requireFile: authMarker, exitCode: 1}, + {match: "auth login --configuration work --no-launch-browser", stdout: "https://example.test/oauth", touch: authMarker}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupAuth(context.Background(), config{gcloudConfiguration: "work"}, true, strings.NewReader("code\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "Open the URL printed by gcloud") || + !strings.Contains(stdout.String(), "https://example.test/oauth") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupAuthYesModeDoesNotLaunchLogin(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "auth print-access-token", stdout: "ERROR: Reauthentication failed. Please run: gcloud auth login", exitCode: 1}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + err := ensureGcloudSetupAuth(context.Background(), config{gcloudConfiguration: "work"}, false, strings.NewReader(""), ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "gcloud auth login --configuration work --no-launch-browser") { + t.Fatalf("err = %v", err) + } +} + +func TestEnsureGcloudSetupProjectAccessRunsLoginOnUserProjectDenied(t *testing.T) { + bin := t.TempDir() + authMarker := filepath.Join(t.TempDir(), "authed") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project", stdout: "hurozo"}, + {match: "projects describe hurozo", stdout: "hurozo", missingStdout: "ERROR: USER_PROJECT_DENIED Caller does not have required permission", requireFile: authMarker, exitCode: 1}, + {match: "auth login --configuration default --no-launch-browser", stdout: "https://example.test/oauth", touch: authMarker}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "default"}, true, strings.NewReader("code\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "needs authentication or a different account") || + !strings.Contains(stdout.String(), "https://example.test/oauth") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessRepairsQuotaProject(t *testing.T) { + bin := t.TempDir() + quotaMarker := filepath.Join(t.TempDir(), "quota") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project", stdout: "hurozo"}, + {match: "projects describe hurozo", stdout: "hurozo", missingStdout: "ERROR: USER_PROJECT_DENIED Caller does not have required permission to use project aafje-490407", requireFile: quotaMarker, exitCode: 1}, + {match: "config get-value billing/quota_project", stdout: "aafje-490407"}, + {match: "config set billing/quota_project hurozo", touch: quotaMarker}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "default"}, true, strings.NewReader("\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "uses quota project aafje-490407") || + !strings.Contains(stdout.String(), "Set quota project to hurozo now?") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessSelectsExistingProjectWhenUnset(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "projects list", stdout: "hurozo Hurozo"}, + {match: "config set project hurozo"}, + {match: "config set billing/quota_project hurozo"}, + {match: "projects describe hurozo", stdout: "hurozo"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "dennis"}, true, strings.NewReader("1\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "has no project configured") || + !strings.Contains(stdout.String(), "1. hurozo - Hurozo") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessCreatesProjectWhenUnset(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "projects list"}, + {match: "projects create bgit-test"}, + {match: "config set project bgit-test"}, + {match: "config set billing/quota_project bgit-test"}, + {match: "projects describe bgit-test", stdout: "bgit-test"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "dennis"}, true, strings.NewReader("create\nbgit-test\nBucketGit Test\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "New project ID:") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessEnablesAPIsForFreshProject(t *testing.T) { + bin := t.TempDir() + apiMarker := filepath.Join(t.TempDir(), "apis") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "projects list"}, + {match: "projects create bgittest"}, + {match: "config set project bgittest"}, + {match: "config set billing/quota_project bgittest"}, + {match: "projects describe bgittest", stdout: "bgittest", missingStdout: "ERROR: SERVICE_DISABLED Cloud Resource Manager API has not been used in project bgittest before or it is disabled.", requireFile: apiMarker, exitCode: 1}, + {match: "services enable serviceusage.googleapis.com cloudresourcemanager.googleapis.com --project bgittest", touch: apiMarker}, + {match: "services list --enabled --project=bgittest", stdout: "serviceusage.googleapis.com cloudresourcemanager.googleapis.com"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "dennis"}, true, strings.NewReader("create\nbgittest\n\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "enabling required GCP project APIs for bgittest") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessCreatesProjectWithSuffixOnCollision(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "projects list"}, + {match: "projects create bgit-test ", stdout: "ERROR: project ID already in use", exitCode: 1}, + {match: "projects create bgit-test-"}, + {match: "config set project bgit-test-"}, + {match: "config set billing/quota_project bgit-test-"}, + {match: "projects describe bgit-test-", stdout: "bgit-test"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "dennis"}, true, strings.NewReader("create\nbgit-test\n\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "Project display name [bgit-test]:") || + !strings.Contains(stdout.String(), "Project ID bgit-test is already in use; trying bgit-test-") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupProjectAccessCreatesShortProjectWithSuffix(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "config get-value project"}, + {match: "config get-value account", stdout: "dennis@example.com"}, + {match: "projects list"}, + {match: "projects create demo-"}, + {match: "config set project demo-"}, + {match: "config set billing/quota_project demo-"}, + {match: "projects describe demo-", stdout: "demo"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupProjectAccess(context.Background(), config{gcloudConfiguration: "dennis"}, true, strings.NewReader("create\ndemo\n\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "Project display name [demo]:") || + !strings.Contains(stdout.String(), "Project ID demo is not a valid GCP project ID; trying demo-") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupBillingLinksSelectedAccount(t *testing.T) { + bin := t.TempDir() + billingMarker := filepath.Join(t.TempDir(), "billing") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "billing projects describe bgittest", stdout: "True", missingStdout: "False", requireFile: billingMarker, exitCode: 1}, + {match: "billing accounts list", stdout: "billingAccounts/123 Hurozo Billing true"}, + {match: "billing projects link bgittest --billing-account billingAccounts/123", touch: billingMarker}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupBilling(context.Background(), config{gcloudConfiguration: "dennis"}, "bgittest", true, strings.NewReader("1\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "does not have billing enabled") || + !strings.Contains(stdout.String(), "linking GCP project bgittest to billing account billingAccounts/123") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupBillingEnablesCloudBillingAPI(t *testing.T) { + bin := t.TempDir() + apiMarker := filepath.Join(t.TempDir(), "billing-api") + billingMarker := filepath.Join(t.TempDir(), "billing") + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "billing projects describe bgittest --configuration dennis --quiet --format=value(billingEnabled)", stdout: "True", onlyIfFile: billingMarker}, + {match: "billing projects describe bgittest --configuration dennis --quiet --format=value(billingEnabled)", stdout: "False", missingStdout: "ERROR: SERVICE_DISABLED Cloud Billing API has not been used in project bgittest before or it is disabled.", requireFile: apiMarker, exitCode: 1}, + {match: "services enable cloudbilling.googleapis.com --project bgittest", touch: apiMarker}, + {match: "services list --enabled --project=bgittest", stdout: "cloudbilling.googleapis.com"}, + {match: "billing accounts list --configuration dennis --quiet", stdout: "billingAccounts/123 Hurozo Billing true"}, + {match: "billing projects link bgittest --billing-account billingAccounts/123", touch: billingMarker}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := ensureGcloudSetupBilling(context.Background(), config{gcloudConfiguration: "dennis"}, "bgittest", true, strings.NewReader("1\n"), &stdout) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "enabling required GCP project APIs for bgittest") || + !strings.Contains(stdout.String(), "linking GCP project bgittest to billing account billingAccounts/123") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestEnsureGcloudSetupBillingYesModeReturnsLinkCommand(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "billing projects describe bgittest", stdout: "False"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + err := ensureGcloudSetupBilling(context.Background(), config{gcloudConfiguration: "dennis"}, "bgittest", false, strings.NewReader(""), ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "gcloud billing projects link bgittest --billing-account BILLING_ACCOUNT --configuration dennis") { + t.Fatalf("err = %v", err) + } +} + +func TestLinkGcloudSetupBillingAccountReportsQuotaExceeded(t *testing.T) { + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "billing projects link bgittest --billing-account billingAccounts/123", stdout: "ERROR: Cloud billing quota exceeded: https://support.google.com/code/contact/billing_quota_increase", exitCode: 1}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + err := linkGcloudSetupBillingAccount(context.Background(), config{gcloudConfiguration: "dennis"}, "bgittest", "billingAccounts/123", &stdout) + if err == nil || !strings.Contains(err.Error(), "billing quota exceeded") || + !strings.Contains(err.Error(), "choose a different billing account") { + t.Fatalf("err = %v", err) + } +} + +func TestGcloudSetupProjectIDWithSuffixTruncatesBase(t *testing.T) { + got := gcloudSetupProjectIDWithSuffix("very-long-project-id-base", "1234567") + if got != "very-long-project-id-b-1234567" { + t.Fatalf("project ID = %q", got) + } +} + +func TestGcloudIAMBindingRetryableDetectsServiceAccountPropagation(t *testing.T) { + if !gcloudIAMBindingRetryable("INVALID_ARGUMENT: Service account bgit-broker@project.iam.gserviceaccount.com does not exist.", errors.New("exit status 1")) { + t.Fatal("service account propagation error should be retryable") + } + if gcloudIAMBindingRetryable("PERMISSION_DENIED: permission denied", errors.New("exit status 1")) { + t.Fatal("permission denied should not be retryable") + } +} + +func TestBrokerDeleteAWSDeletesStackAndClearsConfig(t *testing.T) { + home := t.TempDir() + configPath := filepath.Join(home, ".bgit", "config") + if err := writeGlobalConfig(configPath, globalConfig{ + Version: globalConfigVersion, + AWSProfiles: []globalAWSProfile{{ + Name: "prod", + AccountID: "123456789012", + Regions: []globalProfileRegion{{ + Name: "eu-west-1", + BrokerURL: "https://broker.example.test", + BrokerVersion: brokerVersion, + }}, + }}, + }); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + writeFakeCLI(t, bin, "aws", []fakeCLIAction{ + {match: "cloudformation delete-stack --stack-name bgit-broker --region eu-west-1"}, + {match: "cloudformation wait stack-delete-complete --stack-name bgit-broker --region eu-west-1"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + if err := brokerCommand(nilContext{}, config{}, []string{"delete", "--provider", "aws", "--profile", "prod", "--region", "eu-west-1", "--config", configPath, "--yes"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + cfg, err := readGlobalConfig(configPath) + if err != nil { + t.Fatal(err) + } + if len(cfg.AWSProfiles[0].Regions) != 0 { + t.Fatalf("profile not cleared = %#v", cfg.AWSProfiles[0]) + } + if !strings.Contains(stdout.String(), "deleted AWS bgit broker") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestBrokerDeleteGCPDeletesFunctionAndOptionalData(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + configPath := filepath.Join(home, ".bgit", "config") + bin := t.TempDir() + writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ + {match: "functions delete bgit-broker --gen2 --region europe-west1 --quiet"}, + {match: "run services delete bgit-broker --region europe-west1 --quiet"}, + {match: "firestore databases delete --database=bgit --quiet"}, + }) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout bytes.Buffer + if err := brokerCommand(nilContext{}, config{}, []string{"delete", "--provider", "gcp", "--profile", "work", "--region", "europe-west1", "--data", "--config", configPath, "--yes"}, strings.NewReader(""), &stdout); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "deleted GCP bgit broker") { + t.Fatalf("stdout = %q", stdout.String()) + } +} diff --git a/ssh.go b/ssh.go index 2774510..e2b78f6 100644 --- a/ssh.go +++ b/ssh.go @@ -4,25 +4,32 @@ import ( "bufio" "bytes" "context" + "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" - "net" "net/http" "os" "os/exec" "path/filepath" + "sort" "strings" + "time" "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/agent" ) const defaultSSHHost = "git.bucketgit.com" +var brokerIdentityPreference string + +func setBrokerIdentityPreference(value string) { + brokerIdentityPreference = strings.TrimSpace(value) +} + type sshSetupOptions struct { broker string region string @@ -34,17 +41,9 @@ type sshSetupOptions struct { func sshCommand(base config, args []string, stdout, stderr io.Writer) error { if len(args) == 0 { - return errors.New("usage: bgit ssh setup|scaffold [args]") + return errors.New("usage: bgit ssh git-upload-pack|git-receive-pack [args]") } switch args[0] { - case "setup": - return sshSetupCommand(base, args[1:], stdout, true) - case "scaffold": - return sshSetupCommand(base, args[1:], stdout, false) - case "repo": - return sshRepoCommand(base, args[1:], stdout) - case "keys": - return sshKeysCommand(base, args[1:], stdout) case "git-upload-pack", "git-receive-pack", "git-upload-archive": return sshGitServiceCommand(args, stdout) default: @@ -107,6 +106,11 @@ func configForSSHRepo(repo string) (config, error) { if repo == "" { return config{}, errors.New("missing repository path") } + if localCfg, err := readLocalConfig("."); err == nil && localCfg.logicalRepo != "" { + if strings.Trim(localCfg.logicalRepo, "/") == strings.Trim(repo, "/") { + return mergeSSHRepoAuth(localCfg), nil + } + } if strings.Contains(repo, "://") { cfg, _, err := parseRepoURI(repo) if err != nil { @@ -214,89 +218,6 @@ func mergeSSHRepoAuth(cfg config) config { return cfg } -func sshSetupCommand(base config, args []string, stdout io.Writer, includeKeys bool) error { - opts, repoArg, err := parseSSHSetupArgs(args) - if err != nil { - return err - } - cfg, err := sshSetupConfig(base, repoArg) - if err != nil { - return err - } - worktree, err := requireWorktree(".") - if err != nil { - return err - } - if err := writeBucketGitConfig(worktree, cfg); err != nil { - return err - } - sshURL := sshRemoteURL(cfg) - if err := setGitOrigin(worktree, sshURL); err != nil { - return err - } - for _, pair := range [][]string{ - {"core.sshCommand", "bgit ssh"}, - {"bucketgit.sshHost", defaultSSHHost}, - {"bucketgit.sshRemote", sshURL}, - } { - if _, err := runGit(worktree, "config", "--local", pair[0], pair[1]); err != nil { - return err - } - } - brokerURL := "" - if strings.TrimSpace(opts.broker) != "" { - brokerURL = strings.TrimSpace(opts.broker) - if err := writeBrokerConfig(worktree, strings.TrimSpace(opts.broker), stdout); err != nil { - return err - } - } else if includeKeys { - discovered, err := discoverBrokerURL(cfg, opts) - if err != nil { - fmt.Fprintf(stdout, "broker not found; provisioning bgit-broker\n") - discovered, err = provisionBrokerURL(cfg, opts, stdout) - if err != nil { - return err - } - } - brokerURL = discovered - if err := writeBrokerConfig(worktree, brokerURL, stdout); err != nil { - return err - } - } - - fmt.Fprintf(stdout, "configured SSH origin %s\n", sshURL) - fmt.Fprintf(stdout, "configured core.sshCommand=bgit ssh\n") - if includeKeys { - keys, err := collectSSHPublicKeys(opts) - if err != nil { - return err - } - if len(keys) == 0 { - fmt.Fprintf(stdout, "no public keys found; add one later with bgit ssh setup --key PATH\n") - return nil - } - if err := writeSSHKeyDefaults(worktree, keys); err != nil { - return err - } - fmt.Fprintf(stdout, "recorded %d SSH public key default(s) for broker setup\n", len(keys)) - if brokerURL != "" { - if firstNonEmpty(cfg.provider, "gcs") == "gcs" && strings.TrimSpace(opts.broker) == "" { - if err := ensureGCPBrokerServices(cfg, stdout); err != nil { - return err - } - if err := ensureGCPBrokerFirestoreDatabase(cfg, opts, stdout); err != nil { - return err - } - } - if err := brokerUpsertRepo(brokerURL, cfg, "admin", keys); err != nil { - return err - } - fmt.Fprintf(stdout, "upserted repo %s with admin user admin\n", cfg.origin) - } - } - return nil -} - func parseSSHSetupArgs(args []string) (sshSetupOptions, string, error) { var opts sshSetupOptions var repoArg string @@ -353,10 +274,10 @@ func parseSSHSetupArgs(args []string) (sshSetupOptions, string, error) { opts.noAgent = true default: if strings.HasPrefix(arg, "-") { - return opts, "", fmt.Errorf("unsupported ssh setup option %s", arg) + return opts, "", fmt.Errorf("unsupported ssh option %s", arg) } if repoArg != "" { - return opts, "", errors.New("ssh setup accepts at most one repository URI") + return opts, "", errors.New("ssh commands accept at most one repository URI") } repoArg = arg } @@ -377,15 +298,24 @@ func sshSetupConfig(base config, repoArg string) (config, error) { return cfg, nil } cfg := base - if cfg.bucket == "" { + if cfg.bucket == "" && cfg.logicalRepo == "" { localCfg, err := readLocalConfig(".") if err != nil { - return config{}, errors.New("ssh setup requires a repository URI or an existing bgit origin") + return config{}, errors.New("ssh command requires a repository URI or an existing bgit origin") } cfg = mergeConfig(cfg, localCfg) } + if cfg.brokerURL != "" && cfg.logicalRepo != "" { + if cfg.branch == "" { + cfg.branch = defaultBranch + } + if cfg.origin == "" { + cfg.origin = fmt.Sprintf("git@%s:%s", defaultSSHHost, strings.Trim(cfg.logicalRepo, "/")) + } + return cfg, nil + } if cfg.bucket == "" || cfg.prefix == "" { - return config{}, errors.New("ssh setup requires a repository URI or an existing bgit origin") + return config{}, errors.New("ssh command requires a repository URI or an existing bgit origin") } if cfg.branch == "" { cfg.branch = defaultBranch @@ -396,39 +326,9 @@ func sshSetupConfig(base config, repoArg string) (config, error) { return cfg, nil } -func sshRepoCommand(base config, args []string, stdout io.Writer) error { - if len(args) == 0 || args[0] != "add" { - return errors.New("usage: bgit ssh repo add [--broker URL] [--key PATH] [--no-agent] [repo]") - } - opts, repoArg, err := parseSSHSetupArgs(args[1:]) - if err != nil { - return err - } - cfg, err := sshSetupConfig(base, repoArg) - if err != nil { - return err - } - brokerURL, err := brokerURLForCommand(opts) - if err != nil { - return err - } - keys, err := collectSSHPublicKeys(opts) - if err != nil { - return err - } - if err := brokerUpsertRepo(brokerURL, cfg, "admin", keys); err != nil { - return err - } - fmt.Fprintf(stdout, "upserted repo %s in broker %s\n", cfg.origin, brokerURL) - if len(keys) > 0 { - fmt.Fprintf(stdout, "added %d admin key(s) for user admin\n", len(keys)) - } - return nil -} - func sshKeysCommand(base config, args []string, stdout io.Writer) error { if len(args) == 0 { - return errors.New("usage: bgit ssh keys list|add|remove|suspend [args]") + return errors.New("usage: bgit admin keys list|add|remove|suspend [args]") } action := args[0] opts, repoArg, err := parseSSHKeyArgs(args[1:]) @@ -463,7 +363,7 @@ func sshKeysCommand(base config, args []string, stdout io.Writer) error { return err } if len(keys) == 0 { - return errors.New("ssh keys add requires --key or a key loaded in ssh-agent") + return errors.New("admin keys add requires --key or a key loaded in ssh-agent") } if err := brokerAddKeys(brokerURL, cfg, opts.user, opts.role, keys); err != nil { return err @@ -491,7 +391,7 @@ func sshKeysCommand(base config, args []string, stdout io.Writer) error { fmt.Fprintf(stdout, "suspended key %s\n", identity) return nil default: - return fmt.Errorf("unknown ssh keys command %q", action) + return fmt.Errorf("unknown admin keys command %q", action) } } @@ -556,7 +456,7 @@ func parseSSHKeyArgs(args []string) (sshKeyOptions, string, error) { opts.fingerprint = value default: if strings.HasPrefix(arg, "-") { - return opts, "", fmt.Errorf("unsupported ssh keys option %s", arg) + return opts, "", fmt.Errorf("unsupported admin keys option %s", arg) } if repoArg == "" && strings.Contains(arg, "://") { repoArg = arg @@ -570,7 +470,7 @@ func parseSSHKeyArgs(args []string) (sshKeyOptions, string, error) { repoArg = arg continue } - return opts, "", errors.New("too many ssh keys arguments") + return opts, "", errors.New("too many admin keys arguments") } } return opts, repoArg, nil @@ -613,7 +513,7 @@ func brokerURLForCommand(opts sshSetupOptions) (string, error) { return value, nil } } - return "", errors.New("broker URL is required; run bgit ssh setup or pass --broker URL") + return "", errors.New("broker URL is required; run bgit setup/init or pass --broker URL") } func sshRemoteURL(cfg config) string { @@ -637,12 +537,14 @@ type brokerRepo struct { Bucket string `json:"bucket"` Prefix string `json:"prefix"` Origin string `json:"origin"` + Logical string `json:"logical,omitempty"` } type brokerKey struct { User string `json:"user"` Role string `json:"role"` PublicKey string `json:"public_key"` + Source string `json:"source,omitempty"` Suspended bool `json:"suspended,omitempty"` } @@ -659,6 +561,7 @@ type brokerKeyRequest struct { Role string `json:"role,omitempty"` PublicKeys []string `json:"public_keys,omitempty"` Key string `json:"key,omitempty"` + Source string `json:"source,omitempty"` } type brokerAuthRequest struct { @@ -673,23 +576,25 @@ type brokerAuthResponse struct { } type brokerRefUpdateRequest struct { - Repo brokerRepo `json:"repo"` - Ref string `json:"ref"` - Old string `json:"old"` - New string `json:"new"` + Repo brokerRepo `json:"repo"` + Ref string `json:"ref"` + Old string `json:"old"` + New string `json:"new"` + Override bool `json:"override,omitempty"` } type brokerKeysResponse struct { Keys []brokerKey `json:"keys"` } -func brokerUpsertRepo(brokerURL string, cfg config, adminUser string, publicKeys []string) error { - req := brokerRepoRequest{ - Repo: repoForBroker(cfg), - AdminUser: adminUser, - PublicKeys: publicKeys, - Role: "admin", +func brokerUpsertLogicalRepo(brokerURL, provider, logicalRepo string) error { + cfg := config{ + provider: provider, + prefix: strings.Trim(logicalRepo, "/"), + logicalRepo: strings.Trim(logicalRepo, "/"), + origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, strings.Trim(logicalRepo, "/")), } + req := brokerRepoRequest{Repo: repoForBroker(cfg)} return brokerPost(brokerURL, "/repos/upsert", req, nil) } @@ -702,11 +607,20 @@ func brokerListKeys(brokerURL string, cfg config) ([]brokerKey, error) { } func brokerAddKeys(brokerURL string, cfg config, user, role string, publicKeys []string) error { + return brokerAddKeysWithSource(brokerURL, cfg, user, role, "", publicKeys) +} + +func brokerAddKeysWithSource(brokerURL string, cfg config, user, role, source string, publicKeys []string) error { + role = normalizeBrokerRole(role) + if !validBrokerRole(role) { + return fmt.Errorf("invalid broker role %q", role) + } req := brokerKeyRequest{ Repo: repoForBroker(cfg), User: user, Role: role, PublicKeys: publicKeys, + Source: source, } return brokerPost(brokerURL, "/keys/add", req, nil) } @@ -715,12 +629,35 @@ func brokerMutateKey(brokerURL, path string, cfg config, key string) error { return brokerPost(brokerURL, path, brokerKeyRequest{Repo: repoForBroker(cfg), Key: key}, nil) } +func validBrokerRole(role string) bool { + switch strings.TrimSpace(role) { + case "owner", "admin", "maintainer", "developer", "triage", "read": + return true + default: + return false + } +} + +func normalizeBrokerRole(role string) string { + switch strings.TrimSpace(role) { + case "write": + return "developer" + default: + return strings.TrimSpace(role) + } +} + func brokerUpdateRef(brokerURL string, cfg config, ref, oldHash, newHash string) error { + return brokerUpdateRefWithOverride(brokerURL, cfg, ref, oldHash, newHash, false) +} + +func brokerUpdateRefWithOverride(brokerURL string, cfg config, ref, oldHash, newHash string, override bool) error { req := brokerRefUpdateRequest{ - Repo: repoForBroker(cfg), - Ref: ref, - Old: firstNonEmpty(strings.TrimSpace(oldHash), zeroObjectID()), - New: firstNonEmpty(strings.TrimSpace(newHash), zeroObjectID()), + Repo: repoForBroker(cfg), + Ref: ref, + Old: firstNonEmpty(strings.TrimSpace(oldHash), zeroObjectID()), + New: firstNonEmpty(strings.TrimSpace(newHash), zeroObjectID()), + Override: override, } return brokerPost(brokerURL, "/refs/update", req, nil) } @@ -776,7 +713,7 @@ func brokerURLForSSHService(cfg config) (string, error) { } url, err := discoverBrokerURL(cfg, sshSetupOptions{}) if err != nil { - return "", fmt.Errorf("broker URL is required for SSH Git access; run bgit ssh setup: %w", err) + return "", fmt.Errorf("broker URL is required for SSH Git access; run bgit init: %w", err) } return url, nil } @@ -785,11 +722,13 @@ func repoForBroker(cfg config) brokerRepo { if cfg.origin == "" { cfg.origin = originForConfig(cfg) } + logical := strings.Trim(firstNonEmpty(cfg.logicalRepo, cfg.prefix), "/") return brokerRepo{ Provider: firstNonEmpty(cfg.provider, "gcs"), Bucket: cfg.bucket, Prefix: strings.Trim(cfg.prefix, "/"), Origin: cfg.origin, + Logical: logical, } } @@ -803,62 +742,156 @@ func brokerPostContext(ctx context.Context, brokerURL, path string, req any, res if err != nil { return err } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) - if err != nil { - return err + headerSets := brokerSignatureHeaderSetsForBroker(brokerURL, data) + if len(headerSets) == 0 { + headerSets = []map[string]string{{}} + } else { + headerSets = append(headerSets, map[string]string{}) } - httpReq.Header.Set("content-type", "application/json") - for key, value := range brokerSignatureHeaders(data) { - httpReq.Header.Set(key, value) - } - httpResp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return err - } - defer httpResp.Body.Close() - body, readErr := io.ReadAll(httpResp.Body) - if readErr != nil { - return readErr - } - if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + var lastErr error + for i, headers := range headerSets { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return err + } + httpReq.Header.Set("content-type", "application/json") + for key, value := range headers { + httpReq.Header.Set(key, value) + } + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return err + } + body, readErr := io.ReadAll(httpResp.Body) + _ = httpResp.Body.Close() + if readErr != nil { + return readErr + } + if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 { + if fingerprint := headers["X-Bgit-Key-Fingerprint"]; fingerprint != "" { + _ = writeRepoAuthCache(brokerURL, data, fingerprint) + } + if resp != nil && len(body) > 0 { + if err := json.Unmarshal(body, resp); err != nil { + return err + } + } + return nil + } msg := strings.TrimSpace(string(body)) if msg == "" { msg = httpResp.Status } - return fmt.Errorf("broker %s: %s", path, msg) - } - if resp != nil && len(body) > 0 { - if err := json.Unmarshal(body, resp); err != nil { - return err + lastErr = fmt.Errorf("broker %s: %s", path, msg) + if httpResp.StatusCode != http.StatusForbidden || i == len(headerSets)-1 || !brokerForbiddenAllowsSignatureRetry(msg) { + return lastErr } } - return nil + return lastErr +} + +func brokerForbiddenAllowsSignatureRetry(msg string) bool { + msg = strings.ToLower(strings.TrimSpace(msg)) + if msg == "" { + return false + } + return strings.Contains(msg, "ssh signature required") } func brokerSignatureHeaders(payload []byte) map[string]string { - headers := map[string]string{} - sock := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK")) - if sock == "" { - return headers + sets := brokerSignatureHeaderSetsForBroker("", payload) + if len(sets) == 0 { + return map[string]string{} } - conn, err := net.Dial("unix", sock) - if err != nil { - return headers + return sets[0] +} + +func brokerSignatureHeaderSets(payload []byte) []map[string]string { + return brokerSignatureHeaderSetsForBroker("", payload) +} + +func brokerSignatureHeaderSetsForBroker(brokerURL string, payload []byte) []map[string]string { + signers := explicitBrokerSigners() + agentSigners, cleanup, err := sshAgentSigners() + if err == nil && len(agentSigners) > 0 { + signers = append(signers, agentSigners...) } - defer conn.Close() - signers, err := agent.NewClient(conn).Signers() - if err != nil || len(signers) == 0 { - return headers + if len(signers) == 0 { + return nil + } + if cleanup != nil { + defer cleanup() } message := brokerSignatureMessage(payload) - sig, err := signers[0].Sign(nil, message) - if err != nil { - return headers + preferred := preferredBrokerKeyFingerprints(brokerURL, payload) + type signedHeaders struct { + fingerprint string + headers map[string]string + } + var signed []signedHeaders + for _, signer := range signers { + sig, err := signer.Sign(rand.Reader, message) + if err != nil { + continue + } + fingerprint := ssh.FingerprintSHA256(signer.PublicKey()) + signed = append(signed, signedHeaders{fingerprint: fingerprint, headers: map[string]string{ + "X-Bgit-Key": strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))), + "X-Bgit-Key-Fingerprint": fingerprint, + "X-Bgit-Signature": base64.StdEncoding.EncodeToString(ssh.Marshal(sig)), + "X-Bgit-Signature-Message": base64.StdEncoding.EncodeToString(message), + }}) + } + sort.SliceStable(signed, func(i, j int) bool { + return preferredBrokerKeyRank(signed[i].fingerprint, preferred) < preferredBrokerKeyRank(signed[j].fingerprint, preferred) + }) + var sets []map[string]string + for _, item := range signed { + sets = append(sets, item.headers) + } + return sets +} + +func explicitBrokerSigners() []ssh.Signer { + var paths []string + for _, envName := range []string{"BGIT_SSH_KEY", "BGIT_SSH_KEYS"} { + for _, value := range filepath.SplitList(os.Getenv(envName)) { + if value = strings.TrimSpace(value); value != "" { + paths = append(paths, value) + } + } + } + if value := strings.TrimSpace(brokerIdentityPreference); value != "" && !strings.HasPrefix(value, "SHA256:") { + paths = append(paths, value) } - headers["X-Bgit-Key"] = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signers[0].PublicKey()))) - headers["X-Bgit-Signature"] = base64.StdEncoding.EncodeToString(ssh.Marshal(sig)) - headers["X-Bgit-Signature-Message"] = base64.StdEncoding.EncodeToString(message) - return headers + seen := map[string]struct{}{} + var signers []ssh.Signer + for _, path := range paths { + path = expandHome(path) + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + data, err := os.ReadFile(path) + if err != nil { + continue + } + signer, err := ssh.ParsePrivateKey(data) + if err != nil { + continue + } + signers = append(signers, signer) + } + return signers +} + +func preferredBrokerKeyRank(fingerprint string, preferred []string) int { + for i, value := range preferred { + if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(fingerprint)) { + return i + } + } + return len(preferred) + 1 } func brokerSignatureMessage(payload []byte) []byte { @@ -915,15 +948,13 @@ func discoverAWSBrokerURL(cfg config, opts sshSetupOptions) (string, error) { region := firstNonEmpty(strings.TrimSpace(opts.region), defaultAWSRegion()) profile := strings.TrimSpace(cfg.gcloudConfiguration) args := []string{"cloudformation", "describe-stacks", "--stack-name", "bgit-broker", "--region", region, "--query", "Stacks[0].Outputs[?OutputKey=='BrokerUrl'].OutputValue | [0]", "--output", "text"} - args = appendAWSProfile(args, profile) - if out, err := exec.Command("aws", args...).Output(); err == nil { + if out, err := awsCommand(context.Background(), profile, args...).Output(); err == nil { if url := cleanBrokerURL(string(out)); url != "" { return url, nil } } args = []string{"ssm", "get-parameter", "--name", "/bgit/broker/default/url", "--region", region, "--query", "Parameter.Value", "--output", "text"} - args = appendAWSProfile(args, profile) - if out, err := exec.Command("aws", args...).Output(); err == nil { + if out, err := awsCommand(context.Background(), profile, args...).Output(); err == nil { if url := cleanBrokerURL(string(out)); url != "" { return url, nil } @@ -950,6 +981,16 @@ func provisionGCPBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) ( if err := ensureGCPBrokerFirestoreDatabase(cfg, opts, stdout); err != nil { return "", err } + serviceAccount, err := ensureGCPBrokerServiceAccount(cfg, stdout) + if err != nil { + return "", err + } + if err := ensureGCPBrokerRuntimePermissions(cfg, serviceAccount, stdout); err != nil { + return "", err + } + if err := ensureGCPBrokerDeployerPermission(cfg, serviceAccount, stdout); err != nil { + return "", err + } sourceDir, err := os.MkdirTemp("", "bgit-gcp-broker-*") if err != nil { return "", err @@ -968,340 +1009,343 @@ func provisionGCPBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) ( "--entry-point", "broker", "--trigger-http", "--allow-unauthenticated", - "--set-env-vars", "FIRESTORE_DATABASE="+gcpBrokerFirestoreDatabase(opts), + "--service-account", serviceAccount, + "--set-env-vars", "FIRESTORE_DATABASE="+gcpBrokerFirestoreDatabase(opts)+",BROKER_VERSION="+brokerVersion+",BGIT_SIGNING_SERVICE_ACCOUNT="+serviceAccount, "--quiet", ) out, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("deploy GCP bgit broker: %w\n%s", err, strings.TrimSpace(string(out))) } + if err := ensureGCPBrokerSigningPermission(cfg, serviceAccount, stdout); err != nil { + return "", err + } return discoverGCPBrokerURL(cfg, opts) } func ensureGCPBrokerServices(cfg config, stdout io.Writer) error { + project := gcloudProject(cfg) + if project == "" { + return errors.New("GCP project is not configured") + } services := []string{ + "serviceusage.googleapis.com", + "cloudresourcemanager.googleapis.com", "cloudfunctions.googleapis.com", "run.googleapis.com", "cloudbuild.googleapis.com", "artifactregistry.googleapis.com", "firestore.googleapis.com", + "iamcredentials.googleapis.com", } fmt.Fprintf(stdout, "ensuring GCP broker APIs are enabled\n") args := append([]string{"services", "enable"}, services...) + args = append(args, "--project="+project, "--quiet") cmd := gcloudCommand(cfg.gcloudConfiguration, args...) out, err := cmd.CombinedOutput() if err != nil { + if gcpBrokerServicesNeedBilling(string(out)) { + return fmt.Errorf("enable GCP broker APIs: project %s does not have billing enabled; link a billing account with `gcloud billing projects link %s --billing-account BILLING_ACCOUNT` and rerun setup\n%s", project, project, strings.TrimSpace(string(out))) + } return fmt.Errorf("enable GCP broker APIs: %w\n%s", err, strings.TrimSpace(string(out))) } + if err := waitForGCPBrokerServices(cfg, project, services, stdout); err != nil { + return err + } return nil } -func ensureGCPBrokerFirestoreDatabase(cfg config, opts sshSetupOptions, stdout io.Writer) error { - database := gcpBrokerFirestoreDatabase(opts) - region := firstNonEmpty(strings.TrimSpace(opts.region), defaultGCPRegion(cfg)) - location := firstNonEmpty(strings.TrimSpace(opts.firestoreLocation), os.Getenv("BGIT_FIRESTORE_LOCATION"), region) +func gcpBrokerServicesNeedBilling(message string) bool { + message = strings.ToLower(message) + return strings.Contains(message, "billing account") && strings.Contains(message, "not found") || + strings.Contains(message, "billing must be enabled") || + strings.Contains(message, "ureq_project_billing_not_found") || + strings.Contains(message, "billing-enabled") +} + +func waitForGCPBrokerServices(cfg config, project string, services []string, stdout io.Writer) error { + return waitForGCPServicesEnabled(cfg, project, services, stdout, "GCP broker APIs") +} + +func waitForGCPServicesEnabled(cfg config, project string, services []string, stdout io.Writer, label string) error { + want := map[string]struct{}{} + for _, service := range services { + want[service] = struct{}{} + } + var lastMissing []string + for i := 0; i < 24; i++ { + enabled, err := gcpEnabledServices(cfg, project) + if err == nil { + missing := missingGCPServices(want, enabled) + if len(missing) == 0 { + return nil + } + lastMissing = missing + } + if i == 0 { + fmt.Fprintf(stdout, "waiting for %s to become enabled\n", label) + } + time.Sleep(5 * time.Second) + } + if len(lastMissing) == 0 { + return fmt.Errorf("%s were not visible as enabled before timeout", label) + } + return fmt.Errorf("%s were not visible as enabled before timeout: %s", label, strings.Join(lastMissing, ", ")) +} + +func gcpEnabledServices(cfg config, project string) (map[string]struct{}, error) { + cmd := gcloudCommand(cfg.gcloudConfiguration, + "services", "list", + "--enabled", + "--project="+project, + "--format=value(config.name)", + ) + out, err := cmd.Output() + if err != nil { + return nil, err + } + enabled := map[string]struct{}{} + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + for _, service := range strings.Fields(scanner.Text()) { + enabled[service] = struct{}{} + } + } + return enabled, scanner.Err() +} + +func missingGCPServices(want map[string]struct{}, enabled map[string]struct{}) []string { + var missing []string + for service := range want { + if _, ok := enabled[service]; !ok { + missing = append(missing, service) + } + } + sort.Strings(missing) + return missing +} + +func ensureGCPBrokerServiceAccount(cfg config, stdout io.Writer) (string, error) { + project := gcloudProject(cfg) + if project == "" { + return "", errors.New("GCP project is not configured") + } + email := gcpBrokerServiceAccountEmail(project) describe := gcloudCommand(cfg.gcloudConfiguration, - "firestore", "databases", "describe", - "--database="+database, - "--format=value(name)", + "iam", "service-accounts", "describe", email, + "--format=value(email)", ) if out, err := describe.Output(); err == nil && strings.TrimSpace(string(out)) != "" { - return nil + fmt.Fprintf(stdout, "using GCP broker service account %s\n", email) + return email, nil } - fmt.Fprintf(stdout, "creating Firestore database %s in %s\n", database, location) + fmt.Fprintf(stdout, "creating GCP broker service account %s\n", email) create := gcloudCommand(cfg.gcloudConfiguration, - "firestore", "databases", "create", - "--database="+database, - "--location="+location, - "--type=firestore-native", + "iam", "service-accounts", "create", "bgit-broker", + "--display-name=BucketGit Broker", + "--project="+project, "--quiet", ) out, err := create.CombinedOutput() if err != nil { - return fmt.Errorf("create GCP Firestore database %s in %s: %w\n%s", database, location, err, strings.TrimSpace(string(out))) + return "", fmt.Errorf("create GCP broker service account %s: %w\n%s", email, err, strings.TrimSpace(string(out))) } - return nil + return email, nil } -func gcpBrokerFirestoreDatabase(opts sshSetupOptions) string { - return firstNonEmpty(strings.TrimSpace(opts.firestoreDatabase), os.Getenv("BGIT_FIRESTORE_DATABASE"), "bgit") +func gcpBrokerServiceAccountEmail(project string) string { + return "bgit-broker@" + project + ".iam.gserviceaccount.com" } -func writeGCPBrokerSource(dir string) error { - files := map[string]string{ - "package.json": `{"scripts":{"start":"functions-framework --target=broker"},"dependencies":{"@google-cloud/functions-framework":"^3.4.0","@google-cloud/firestore":"^7.10.0","@google-cloud/storage":"^7.16.0"}} -`, - "index.js": `'use strict'; - -const crypto = require('crypto'); -const {Firestore} = require('@google-cloud/firestore'); -const {Storage} = require('@google-cloud/storage'); -const db = new Firestore({databaseId: process.env.FIRESTORE_DATABASE || 'bgit'}); -const repos = db.collection('bgit_broker_repos'); -const storage = new Storage(); - -function repoID(repo) { - return [repo.provider || 'gcs', repo.bucket, repo.prefix].join(':'); +func ensureGCPBrokerRuntimePermissions(cfg config, serviceAccount string, stdout io.Writer) error { + project := gcloudProject(cfg) + if project == "" { + return errors.New("GCP project is not configured") + } + for _, role := range []string{"roles/datastore.user", "roles/storage.admin"} { + fmt.Fprintf(stdout, "granting GCP broker %s to %s\n", role, serviceAccount) + cmd := gcloudCommand(cfg.gcloudConfiguration, + "projects", "add-iam-policy-binding", project, + "--member=serviceAccount:"+serviceAccount, + "--role="+role, + "--quiet", + ) + out, err := runGcloudIAMBindingWithRetry(cmd) + if err != nil { + return fmt.Errorf("grant GCP broker %s: %w\n%s", role, err, strings.TrimSpace(string(out))) + } + } + return nil } -function docID(repo) { - return Buffer.from(repoID(repo)).toString('base64url'); +func ensureGCPBrokerDeployerPermission(cfg config, serviceAccount string, stdout io.Writer) error { + account := gcloudAccount(cfg) + if account == "" { + return nil + } + member := "user:" + account + if strings.HasSuffix(account, ".gserviceaccount.com") { + member = "serviceAccount:" + account + } + fmt.Fprintf(stdout, "granting GCP broker deploy permission to %s\n", member) + cmd := gcloudCommand(cfg.gcloudConfiguration, + "iam", "service-accounts", "add-iam-policy-binding", serviceAccount, + "--member="+member, + "--role=roles/iam.serviceAccountUser", + "--quiet", + ) + out, err := runGcloudIAMBindingWithRetry(cmd) + if err != nil { + if fallbackErr := ensureGCPBrokerProjectDeployerPermission(cfg, member); fallbackErr != nil { + return fmt.Errorf("grant GCP broker deploy permission: %w\n%s", err, strings.TrimSpace(string(out))) + } + } + return nil } -async function loadRepo(repo) { - const ref = repos.doc(docID(repo)); - const snap = await ref.get(); - if (!snap.exists) return {ref, data: {repo, keys: []}}; - const data = snap.data() || {}; - data.repo = data.repo || repo; - data.keys = data.keys || []; - return {ref, data}; +func ensureGCPBrokerProjectDeployerPermission(cfg config, member string) error { + project := gcloudProject(cfg) + if project == "" { + return errors.New("GCP project is not configured") + } + cmd := gcloudCommand(cfg.gcloudConfiguration, + "projects", "add-iam-policy-binding", project, + "--member="+member, + "--role=roles/iam.serviceAccountUser", + "--quiet", + ) + out, err := runGcloudIAMBindingWithRetry(cmd) + if err != nil { + return fmt.Errorf("grant project-level deploy permission: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil } -async function saveRepo(entry) { - await entry.ref.set(entry.data, {merge: true}); +func ensureGCPBrokerSigningPermission(cfg config, serviceAccount string, stdout io.Writer) error { + fmt.Fprintf(stdout, "granting GCP broker signBlob permission to %s\n", serviceAccount) + args := []string{ + "iam", "service-accounts", "add-iam-policy-binding", serviceAccount, + "--member=serviceAccount:" + serviceAccount, + "--role=roles/iam.serviceAccountTokenCreator", + "--quiet", + } + if project := gcloudProject(cfg); project != "" { + args = append(args, "--project="+project) + } + cmd := gcloudCommand(cfg.gcloudConfiguration, args...) + bindOut, bindErr := runGcloudIAMBindingWithRetry(cmd) + if bindErr != nil { + if err := ensureGCPBrokerProjectSigningPermission(cfg, serviceAccount); err != nil { + return fmt.Errorf("grant GCP broker signBlob permission: %w\n%s", bindErr, strings.TrimSpace(string(bindOut))) + } + } + return nil } -function readSSHString(buf, offset) { - const len = buf.readUInt32BE(offset); - const start = offset + 4; - return {value: buf.subarray(start, start + len), offset: start + len}; +func ensureGCPBrokerProjectSigningPermission(cfg config, serviceAccount string) error { + project := gcloudProject(cfg) + if project == "" { + return errors.New("GCP project is not configured") + } + cmd := gcloudCommand(cfg.gcloudConfiguration, + "projects", "add-iam-policy-binding", project, + "--member=serviceAccount:"+serviceAccount, + "--role=roles/iam.serviceAccountTokenCreator", + "--quiet", + ) + out, err := runGcloudIAMBindingWithRetry(cmd) + if err != nil { + return fmt.Errorf("grant project-level signBlob permission: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil } -function rawBody(req) { - if (req.rawBody) return Buffer.from(req.rawBody); - return Buffer.from(JSON.stringify(req.body || {})); +func runGcloudIAMBindingWithRetry(cmd *exec.Cmd) ([]byte, error) { + out, err := cmd.CombinedOutput() + if err == nil || !gcloudIAMBindingRetryable(string(out), err) { + return out, err + } + var lastOut []byte + var lastErr error + for attempt := 0; attempt < 8; attempt++ { + time.Sleep(time.Duration(attempt+1) * time.Second) + retry := exec.Command(cmd.Path, cmd.Args[1:]...) + retry.Env = cmd.Env + retry.Dir = cmd.Dir + lastOut, lastErr = retry.CombinedOutput() + if lastErr == nil || !gcloudIAMBindingRetryable(string(lastOut), lastErr) { + return lastOut, lastErr + } + } + return lastOut, lastErr } -function expectedMessage(req) { - const digest = crypto.createHash('sha256').update(rawBody(req)).digest('base64'); - return Buffer.from('bgit-broker-v1\n' + digest).toString('base64'); -} - -function normalizeKey(key) { - return String(key || '').trim().split(/\s+/).slice(0, 2).join(' '); +func gcloudIAMBindingRetryable(out string, err error) bool { + if err == nil { + return false + } + message := strings.ToLower(out + "\n" + err.Error()) + return strings.Contains(message, "service account") && + (strings.Contains(message, "does not exist") || + strings.Contains(message, "not found") || + strings.Contains(message, "principal") && strings.Contains(message, "not found")) } -function publicKeyObject(publicKey) { - const parts = normalizeKey(publicKey).split(/\s+/); - if (parts[0] !== 'ssh-ed25519') return crypto.createPublicKey(publicKey); - const blob = Buffer.from(parts[1], 'base64'); - let parsed = readSSHString(blob, 0); - const alg = parsed.value.toString(); - if (alg !== 'ssh-ed25519') throw new Error('unsupported SSH key algorithm'); - parsed = readSSHString(blob, parsed.offset); - const derPrefix = Buffer.from('302a300506032b6570032100', 'hex'); - return crypto.createPublicKey({key: Buffer.concat([derPrefix, parsed.value]), format: 'der', type: 'spki'}); +func gcloudAccount(cfg config) string { + out, err := gcloudCommand(cfg.gcloudConfiguration, "config", "get-value", "account", "--quiet").Output() + if err != nil { + return "" + } + value := strings.TrimSpace(string(out)) + if value == "(unset)" { + return "" + } + return value } -function verifySignature(req, entry) { - const adminKeys = (entry.data.keys || []).filter((k) => k.role === 'admin' && !k.suspended); - if (adminKeys.length === 0) return true; - const key = signedKey(req, entry); - return !!key && key.role === 'admin'; +func gcloudProject(cfg config) string { + out, err := gcloudCommand(cfg.gcloudConfiguration, "config", "get-value", "project", "--quiet").Output() + if err != nil { + return "" + } + value := strings.TrimSpace(string(out)) + if value == "(unset)" { + return "" + } + return value } -function signedKey(req, entry) { - const keys = (entry.data.keys || []).filter((k) => !k.suspended); - const publicKey = normalizeKey(req.get('x-bgit-key')); - const message = String(req.get('x-bgit-signature-message') || ''); - const signature = String(req.get('x-bgit-signature') || ''); - if (!publicKey || !message || !signature || message !== expectedMessage(req)) return null; - const key = keys.find((k) => normalizeKey(k.public_key) === publicKey); - if (!key) return null; - const parsed = readSSHString(Buffer.from(signature, 'base64'), 0); - const alg = parsed.value.toString(); - const sig = readSSHString(Buffer.from(signature, 'base64'), parsed.offset).value; - const verifyAlg = alg === 'ssh-ed25519' ? null : 'sha256'; - if (!crypto.verify(verifyAlg, Buffer.from(message, 'base64'), publicKeyObject(publicKey), sig)) return null; - return key; -} - -function roleAllows(role, operation) { - if (role === 'admin') return true; - if (operation === 'read') return role === 'read' || role === 'write'; - if (operation === 'write') return role === 'write'; - return false; -} - -function cleanObjectPath(value) { - const path = String(value || '').replace(/^\/+/, ''); - if (path.includes('\0')) throw new Error('invalid object path'); - return path; -} - -function objectName(repo, objectPath) { - const prefix = String(repo.prefix || '').replace(/^\/+|\/+$/g, ''); - const path = cleanObjectPath(objectPath); - return prefix ? prefix + '/' + path : path; -} - -function requireRead(req, entry) { - const key = signedKey(req, entry); - if (!key || !roleAllows(key.role, 'read')) { - const err = new Error('read SSH signature required'); - err.status = 403; - throw err; - } -} - -async function readObject(repo, objectPath) { - const [data] = await storage.bucket(repo.bucket).file(objectName(repo, objectPath)).download(); - return data.toString('base64'); -} - -async function listObjects(repo, prefix) { - const repoPrefix = String(repo.prefix || '').replace(/^\/+|\/+$/g, ''); - const queryPrefix = objectName(repo, prefix); - const [files] = await storage.bucket(repo.bucket).getFiles({prefix: queryPrefix}); - const strip = repoPrefix ? repoPrefix + '/' : ''; - return files.map((file) => file.name.startsWith(strip) ? file.name.slice(strip.length) : file.name); -} - -async function updateRefCAS(repo, ref, oldHash, newHash) { - const id = docID(repo); - const refDoc = repos.doc(id); - await db.runTransaction(async (tx) => { - const snap = await tx.get(refDoc); - const data = snap.exists ? (snap.data() || {}) : {repo, keys: [], refs: {}}; - data.repo = data.repo || repo; - data.keys = data.keys || []; - data.refs = data.refs || {}; - const zero = '0000000000000000000000000000000000000000'; - const current = Object.prototype.hasOwnProperty.call(data.refs, ref) ? data.refs[ref] : oldHash; - if (current !== oldHash) { - const err = new Error('stale ref'); - err.status = 409; - throw err; - } - if (newHash === zero) { - delete data.refs[ref]; - } else { - data.refs[ref] = newHash; - } - tx.set(refDoc, data, {merge: true}); - }); -} - -async function ensureRepo(repo) { - const id = repoID(repo); - if (!repo || !repo.bucket || !repo.prefix) throw new Error('repo is required'); - return loadRepo(repo); -} - -function requireAdmin(req, entry) { - if (!verifySignature(req, entry)) { - const err = new Error('admin SSH signature required'); - err.status = 403; - throw err; - } -} - -exports.broker = async (req, res) => { - res.set('content-type', 'application/json'); - if (req.path === '/health' || req.path === '/') { - res.status(200).send(JSON.stringify({ok: true, service: 'bgit-broker'})); - return; - } - try { - const body = req.body || {}; - if (req.path === '/repos/upsert' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); - const user = body.admin_user || 'admin'; - const role = body.role || 'admin'; - for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) { - entry.data.keys.push({user, role, public_key: publicKey, suspended: false}); - } - } - await saveRepo(entry); - res.status(200).send(JSON.stringify({ok: true})); - return; - } - if (req.path === '/keys/list' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); - res.status(200).send(JSON.stringify({keys: entry.data.keys})); - return; - } - if (req.path === '/keys/add' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); - const user = body.user || 'admin'; - const role = body.role || 'read'; - for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) { - entry.data.keys.push({user, role, public_key: publicKey, suspended: false}); - } - } - await saveRepo(entry); - res.status(200).send(JSON.stringify({ok: true})); - return; - } - if ((req.path === '/keys/remove' || req.path === '/keys/suspend') && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); - const key = String(body.key || '').trim(); - const normalized = normalizeKey(key); - const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(key); - if (req.path === '/keys/remove') { - entry.data.keys = entry.data.keys.filter((k) => !match(k)); - } else { - for (const item of entry.data.keys) if (match(item)) item.suspended = true; - } - await saveRepo(entry); - res.status(200).send(JSON.stringify({ok: true})); - return; - } - if (req.path === '/auth/check' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); - const operation = body.operation || ''; - const allowed = !!key && roleAllows(key.role, operation); - res.status(200).send(JSON.stringify({allowed, user: key && key.user, role: key && key.role})); - return; - } - if (req.path === '/objects/read' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireRead(req, entry); - const data = await readObject(body.repo, body.path); - res.status(200).send(JSON.stringify({data})); - return; - } - if (req.path === '/objects/list' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireRead(req, entry); - const paths = await listObjects(body.repo, body.prefix); - res.status(200).send(JSON.stringify({paths})); - return; - } - if (req.path === '/refs/update' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); - if (!key || !roleAllows(key.role, 'write')) { - res.status(403).send(JSON.stringify({error: 'write SSH signature required'})); - return; - } - await updateRefCAS(body.repo, body.ref, body.old, body.new); - res.status(200).send(JSON.stringify({ok: true})); - return; - } - res.status(404).send(JSON.stringify({error: 'unknown broker endpoint'})); - } catch (err) { - res.status(err.status || 500).send(JSON.stringify({error: err.message || String(err)})); - } -}; -`, - } - for name, body := range files { - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { - return err - } +func ensureGCPBrokerFirestoreDatabase(cfg config, opts sshSetupOptions, stdout io.Writer) error { + database := gcpBrokerFirestoreDatabase(opts) + region := firstNonEmpty(strings.TrimSpace(opts.region), defaultGCPRegion(cfg)) + location := firstNonEmpty(strings.TrimSpace(opts.firestoreLocation), os.Getenv("BGIT_FIRESTORE_LOCATION"), region) + describe := gcloudCommand(cfg.gcloudConfiguration, + "firestore", "databases", "describe", + "--database="+database, + "--format=value(name)", + ) + if out, err := describe.Output(); err == nil && strings.TrimSpace(string(out)) != "" { + return nil + } + fmt.Fprintf(stdout, "creating Firestore database %s in %s\n", database, location) + create := gcloudCommand(cfg.gcloudConfiguration, + "firestore", "databases", "create", + "--database="+database, + "--location="+location, + "--type=firestore-native", + "--quiet", + ) + out, err := create.CombinedOutput() + if err != nil { + return fmt.Errorf("create GCP Firestore database %s in %s: %w\n%s", database, location, err, strings.TrimSpace(string(out))) } return nil } +func gcpBrokerFirestoreDatabase(opts sshSetupOptions) string { + return firstNonEmpty(strings.TrimSpace(opts.firestoreDatabase), os.Getenv("BGIT_FIRESTORE_DATABASE"), "bgit") +} + func provisionAWSBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) (string, error) { region := firstNonEmpty(strings.TrimSpace(opts.region), defaultAWSRegion()) template, err := os.CreateTemp("", "bgit-aws-broker-*.yaml") @@ -1317,354 +1361,54 @@ func provisionAWSBrokerURL(cfg config, opts sshSetupOptions, stdout io.Writer) ( if err := template.Close(); err != nil { return "", err } - fmt.Fprintf(stdout, "deploying AWS CloudFormation stack bgit-broker in %s\n", region) + fmt.Fprintf(stdout, "deploying AWS CloudFormation stack bgit-broker in %s", region) + if strings.TrimSpace(cfg.gcloudConfiguration) != "" { + fmt.Fprintf(stdout, " with profile %s", strings.TrimSpace(cfg.gcloudConfiguration)) + } + fmt.Fprintln(stdout) + s3Bucket, err := ensureAWSBrokerDeploymentBucket(cfg, region, stdout) + if err != nil { + return "", err + } args := []string{ "cloudformation", "deploy", "--stack-name", "bgit-broker", "--template-file", templatePath, + "--s3-bucket", s3Bucket, "--capabilities", "CAPABILITY_NAMED_IAM", "--region", region, } - args = appendAWSProfile(args, strings.TrimSpace(cfg.gcloudConfiguration)) - out, err := exec.Command("aws", args...).CombinedOutput() + out, err := awsCommand(context.Background(), strings.TrimSpace(cfg.gcloudConfiguration), args...).CombinedOutput() if err != nil { return "", fmt.Errorf("deploy AWS bgit broker: %w\n%s", err, strings.TrimSpace(string(out))) } return discoverAWSBrokerURL(cfg, opts) } -func awsBrokerCloudFormationTemplate() string { - return `AWSTemplateFormatVersion: '2010-09-09' -Description: Minimal bgit SSH broker control-plane endpoint. -Resources: - BrokerRole: - Type: AWS::IAM::Role - Properties: - RoleName: !Sub bgit-broker-${AWS::Region} - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: lambda.amazonaws.com - Action: sts:AssumeRole - ManagedPolicyArns: - - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - Policies: - - PolicyName: bgit-broker-table - PolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Action: - - dynamodb:GetItem - - dynamodb:PutItem - Resource: !GetAtt BrokerTable.Arn - - Effect: Allow - Action: - - s3:GetObject - Resource: arn:aws:s3:::*/* - - Effect: Allow - Action: - - s3:ListBucket - Resource: arn:aws:s3:::* - BrokerTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: bgit-broker-repos - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: id - AttributeType: S - KeySchema: - - AttributeName: id - KeyType: HASH - BrokerFunction: - Type: AWS::Lambda::Function - Properties: - FunctionName: bgit-broker - Runtime: nodejs22.x - Handler: index.handler - Role: !GetAtt BrokerRole.Arn - Environment: - Variables: - TABLE_NAME: !Ref BrokerTable - Code: - ZipFile: | - const crypto = require("crypto"); - const {DynamoDBClient, GetItemCommand, PutItemCommand} = require("@aws-sdk/client-dynamodb"); - const {S3Client, GetObjectCommand, ListObjectsV2Command} = require("@aws-sdk/client-s3"); - const db = new DynamoDBClient({}); - const s3 = new S3Client({}); - const table = process.env.TABLE_NAME; - function repoID(repo) { - return [repo.provider || "s3", repo.bucket, repo.prefix].join(":"); - } - function docID(repo) { - return Buffer.from(repoID(repo)).toString("base64url"); - } - async function loadRepo(repo) { - if (!repo || !repo.bucket || !repo.prefix) throw new Error("repo is required"); - const id = docID(repo); - const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); - if (!out.Item) return {id, data: {repo, keys: []}}; - const data = JSON.parse(out.Item.data.S || "{}"); - data.repo = data.repo || repo; - data.keys = data.keys || []; - return {id, data}; - } - async function saveRepo(entry) { - await db.send(new PutItemCommand({TableName: table, Item: {id: {S: entry.id}, data: {S: JSON.stringify(entry.data)}}})); - } - function readSSHString(buf, offset) { - const len = buf.readUInt32BE(offset); - const start = offset + 4; - return {value: buf.subarray(start, start + len), offset: start + len}; - } - function expectedMessage(rawBody) { - const digest = crypto.createHash("sha256").update(Buffer.from(rawBody || "{}")).digest("base64"); - return Buffer.from("bgit-broker-v1\n" + digest).toString("base64"); - } - function normalizeKey(key) { - return String(key || "").trim().split(/\s+/).slice(0, 2).join(" "); - } - function publicKeyObject(publicKey) { - const parts = normalizeKey(publicKey).split(/\s+/); - if (parts[0] !== "ssh-ed25519") return crypto.createPublicKey(publicKey); - const blob = Buffer.from(parts[1], "base64"); - let parsed = readSSHString(blob, 0); - const alg = parsed.value.toString(); - if (alg !== "ssh-ed25519") throw new Error("unsupported SSH key algorithm"); - parsed = readSSHString(blob, parsed.offset); - const derPrefix = Buffer.from("302a300506032b6570032100", "hex"); - return crypto.createPublicKey({key: Buffer.concat([derPrefix, parsed.value]), format: "der", type: "spki"}); - } - function header(event, name) { - const headers = event.headers || {}; - return headers[name] || headers[name.toLowerCase()] || ""; - } - function verifySignature(event, entry) { - const adminKeys = (entry.data.keys || []).filter((k) => k.role === "admin" && !k.suspended); - if (adminKeys.length === 0) return true; - const key = signedKey(event, entry); - return !!key && key.role === "admin"; - } - function signedKey(event, entry) { - const keys = (entry.data.keys || []).filter((k) => !k.suspended); - const publicKey = normalizeKey(header(event, "x-bgit-key")); - const message = String(header(event, "x-bgit-signature-message")); - const signature = String(header(event, "x-bgit-signature")); - if (!publicKey || !message || !signature || message !== expectedMessage(event.body)) return null; - const key = keys.find((k) => normalizeKey(k.public_key) === publicKey); - if (!key) return null; - const parsed = readSSHString(Buffer.from(signature, "base64"), 0); - const alg = parsed.value.toString(); - const sig = readSSHString(Buffer.from(signature, "base64"), parsed.offset).value; - const verifyAlg = alg === "ssh-ed25519" ? null : "sha256"; - if (!crypto.verify(verifyAlg, Buffer.from(message, "base64"), publicKeyObject(publicKey), sig)) return null; - return key; - } - function roleAllows(role, operation) { - if (role === "admin") return true; - if (operation === "read") return role === "read" || role === "write"; - if (operation === "write") return role === "write"; - return false; - } - function cleanObjectPath(value) { - const path = String(value || "").replace(/^\/+/, ""); - if (path.includes("\0")) throw new Error("invalid object path"); - return path; - } - function objectName(repo, objectPath) { - const prefix = String(repo.prefix || "").replace(/^\/+|\/+$/g, ""); - const path = cleanObjectPath(objectPath); - return prefix ? prefix + "/" + path : path; - } - function requireRead(event, entry) { - const key = signedKey(event, entry); - if (!key || !roleAllows(key.role, "read")) { - const err = new Error("read SSH signature required"); - err.statusCode = 403; - throw err; - } - } - async function streamToBuffer(stream) { - const chunks = []; - for await (const chunk of stream) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks); - } - async function readObject(repo, objectPath) { - const out = await s3.send(new GetObjectCommand({Bucket: repo.bucket, Key: objectName(repo, objectPath)})); - const data = await streamToBuffer(out.Body); - return data.toString("base64"); - } - async function listObjects(repo, prefix) { - const repoPrefix = String(repo.prefix || "").replace(/^\/+|\/+$/g, ""); - const queryPrefix = objectName(repo, prefix); - const paths = []; - let token = undefined; - do { - const out = await s3.send(new ListObjectsV2Command({Bucket: repo.bucket, Prefix: queryPrefix, ContinuationToken: token})); - for (const item of out.Contents || []) { - const strip = repoPrefix ? repoPrefix + "/" : ""; - paths.push(item.Key.startsWith(strip) ? item.Key.slice(strip.length) : item.Key); - } - token = out.NextContinuationToken; - } while (token); - return paths; - } - async function updateRefCAS(repo, ref, oldHash, newHash) { - const id = docID(repo); - const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); - const oldData = out.Item && out.Item.data ? out.Item.data.S : ""; - const data = oldData ? JSON.parse(oldData || "{}") : {repo, keys: [], refs: {}}; - data.repo = data.repo || repo; - data.keys = data.keys || []; - data.refs = data.refs || {}; - const zero = "0000000000000000000000000000000000000000"; - const current = Object.prototype.hasOwnProperty.call(data.refs, ref) ? data.refs[ref] : oldHash; - if (current !== oldHash) { - const err = new Error("stale ref"); - err.statusCode = 409; - throw err; - } - if (newHash === zero) { - delete data.refs[ref]; - } else { - data.refs[ref] = newHash; - } - const item = {id: {S: id}, data: {S: JSON.stringify(data)}}; - const input = {TableName: table, Item: item}; - if (oldData) { - input.ConditionExpression = "#data = :old"; - input.ExpressionAttributeNames = {"#data": "data"}; - input.ExpressionAttributeValues = {":old": {S: oldData}}; - } else { - input.ConditionExpression = "attribute_not_exists(id)"; - } - try { - await db.send(new PutItemCommand(input)); - } catch (err) { - if (err.name === "ConditionalCheckFailedException") { - const stale = new Error("stale ref"); - stale.statusCode = 409; - throw stale; - } - throw err; - } - } - function requireAdmin(event, entry) { - if (!verifySignature(event, entry)) { - const err = new Error("admin SSH signature required"); - err.statusCode = 403; - throw err; - } - } - exports.handler = async (event) => { - const path = event.rawPath || "/"; - const method = event.requestContext && event.requestContext.http ? event.requestContext.http.method : "GET"; - const body = event.body ? JSON.parse(event.body) : {}; - try { - if (path === "/" || path === "/health") { - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({ok: true, service: "bgit-broker"}) }; - } - if (path === "/repos/upsert" && method === "POST") { - const entry = await loadRepo(body.repo); - requireAdmin(event, entry); - const user = body.admin_user || "admin"; - const role = body.role || "admin"; - for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, suspended: false}); - } - await saveRepo(entry); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({ok: true}) }; - } - if (path === "/keys/list" && method === "POST") { - const entry = await loadRepo(body.repo); - requireAdmin(event, entry); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({keys: entry.data.keys}) }; - } - if (path === "/keys/add" && method === "POST") { - const entry = await loadRepo(body.repo); - requireAdmin(event, entry); - const user = body.user || "admin"; - const role = body.role || "read"; - for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, suspended: false}); - } - await saveRepo(entry); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({ok: true}) }; - } - if ((path === "/keys/remove" || path === "/keys/suspend") && method === "POST") { - const entry = await loadRepo(body.repo); - requireAdmin(event, entry); - const key = String(body.key || "").trim(); - const normalized = normalizeKey(key); - const match = (k) => normalizeKey(k.public_key) === normalized || k.public_key.includes(key); - if (path === "/keys/remove") { - entry.data.keys = entry.data.keys.filter((k) => !match(k)); - } else { - for (const item of entry.data.keys) if (match(item)) item.suspended = true; - } - await saveRepo(entry); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({ok: true}) }; - } - if (path === "/auth/check" && method === "POST") { - const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); - const operation = body.operation || ""; - const allowed = !!key && roleAllows(key.role, operation); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({allowed, user: key && key.user, role: key && key.role}) }; - } - if (path === "/objects/read" && method === "POST") { - const entry = await loadRepo(body.repo); - requireRead(event, entry); - const data = await readObject(body.repo, body.path); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({data}) }; - } - if (path === "/objects/list" && method === "POST") { - const entry = await loadRepo(body.repo); - requireRead(event, entry); - const paths = await listObjects(body.repo, body.prefix); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({paths}) }; - } - if (path === "/refs/update" && method === "POST") { - const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); - if (!key || !roleAllows(key.role, "write")) { - return { statusCode: 403, headers: {"content-type": "application/json"}, body: JSON.stringify({error: "write SSH signature required"}) }; - } - await updateRefCAS(body.repo, body.ref, body.old, body.new); - return { statusCode: 200, headers: {"content-type": "application/json"}, body: JSON.stringify({ok: true}) }; - } - return { statusCode: 404, headers: {"content-type": "application/json"}, body: JSON.stringify({error: "unknown broker endpoint"}) }; - } catch (err) { - return { statusCode: err.statusCode || 500, headers: {"content-type": "application/json"}, body: JSON.stringify({error: err.message || String(err)}) }; - } - }; - BrokerFunctionUrl: - Type: AWS::Lambda::Url - Properties: - TargetFunctionArn: !Ref BrokerFunction - AuthType: NONE - BrokerFunctionUrlPermission: - Type: AWS::Lambda::Permission - Properties: - FunctionName: !Ref BrokerFunction - Action: lambda:InvokeFunctionUrl - Principal: '*' - FunctionUrlAuthType: NONE - BrokerFunctionInvokePermission: - Type: AWS::Lambda::Permission - Properties: - FunctionName: !Ref BrokerFunction - Action: lambda:InvokeFunction - Principal: '*' - InvokedViaFunctionUrl: true -Outputs: - BrokerUrl: - Value: !GetAtt BrokerFunctionUrl.FunctionUrl -` +func ensureAWSBrokerDeploymentBucket(cfg config, region string, stdout io.Writer) (string, error) { + accountID, _ := awsCallerIdentity(context.Background(), strings.TrimSpace(cfg.gcloudConfiguration)) + if accountID == "" { + return "", errors.New("discover AWS account id for broker deployment bucket") + } + bucket := fmt.Sprintf("bgit-broker-artifacts-%s-%s", accountID, region) + headArgs := []string{"s3api", "head-bucket", "--bucket", bucket, "--region", region} + if err := awsCommand(context.Background(), strings.TrimSpace(cfg.gcloudConfiguration), headArgs...).Run(); err == nil { + return bucket, nil + } + fmt.Fprintf(stdout, "creating AWS broker deployment bucket %s in %s\n", bucket, region) + createArgs := []string{"s3api", "create-bucket", "--bucket", bucket, "--region", region} + if region != "us-east-1" { + createArgs = append(createArgs, "--create-bucket-configuration", "LocationConstraint="+region) + } + out, err := awsCommand(context.Background(), strings.TrimSpace(cfg.gcloudConfiguration), createArgs...).CombinedOutput() + if err != nil { + text := strings.TrimSpace(string(out)) + if strings.Contains(text, "BucketAlreadyOwnedByYou") || strings.Contains(text, "BucketAlreadyExists") { + return bucket, nil + } + return "", fmt.Errorf("create AWS broker deployment bucket %s: %w\n%s", bucket, err, text) + } + return bucket, nil } func appendAWSProfile(args []string, profile string) []string { @@ -1674,6 +1418,17 @@ func appendAWSProfile(args []string, profile string) []string { return append(args, "--profile", strings.TrimSpace(profile)) } +func awsCommand(ctx context.Context, profile string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "aws", args...) + if strings.TrimSpace(profile) != "" { + cmd.Env = append(os.Environ(), + "AWS_PROFILE="+strings.TrimSpace(profile), + "AWS_SDK_LOAD_CONFIG=1", + ) + } + return cmd +} + func cleanBrokerURL(out string) string { value := strings.TrimSpace(out) if value == "" || value == "None" || value == "null" { diff --git a/ssh_agent_unix.go b/ssh_agent_unix.go new file mode 100644 index 0000000..b7a0436 --- /dev/null +++ b/ssh_agent_unix.go @@ -0,0 +1,29 @@ +//go:build !windows + +package main + +import ( + "net" + "os" + "strings" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +func sshAgentSigners() ([]ssh.Signer, func(), error) { + sock := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK")) + if sock == "" { + return nil, nil, nil + } + conn, err := net.Dial("unix", sock) + if err != nil { + return nil, nil, err + } + signers, err := agent.NewClient(conn).Signers() + if err != nil { + _ = conn.Close() + return nil, nil, err + } + return signers, func() { _ = conn.Close() }, nil +} diff --git a/ssh_agent_windows.go b/ssh_agent_windows.go new file mode 100644 index 0000000..2a9e064 --- /dev/null +++ b/ssh_agent_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package main + +import ( + "net" + "os" + "strings" + "time" + + "github.com/Microsoft/go-winio" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +const windowsOpenSSHAgentPipe = `\\.\pipe\openssh-ssh-agent` + +func sshAgentSigners() ([]ssh.Signer, func(), error) { + sock := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK")) + var conn net.Conn + var err error + timeout := 5 * time.Second + if strings.HasPrefix(sock, `\\.\pipe\`) { + conn, err = winio.DialPipe(sock, &timeout) + } else { + conn, err = winio.DialPipe(windowsOpenSSHAgentPipe, &timeout) + } + if err != nil { + return nil, nil, err + } + signers, err := agent.NewClient(conn).Signers() + if err != nil { + _ = conn.Close() + return nil, nil, err + } + return signers, func() { _ = conn.Close() }, nil +} diff --git a/testsuite/README.md b/testsuite/README.md new file mode 100644 index 0000000..3c784b3 --- /dev/null +++ b/testsuite/README.md @@ -0,0 +1,50 @@ +# BucketGit Integration Test Suite + +This suite exercises the built `bgit` binary against real local repositories and +local SQLite-backed broker runtimes. It intentionally lives outside the Go unit +tests because it starts broker servers, creates repositories, and runs full CLI +flows. + +Run everything locally: + +```bash +./testsuite/run.sh +``` + +Run one provider locally: + +```bash +BGIT_TEST_PROVIDER=gcp ./testsuite/run.sh +BGIT_TEST_PROVIDER=aws ./testsuite/run.sh +``` + +Run a broker runtime directly: + +```bash +./testsuite/run-local-broker.sh gcp +./testsuite/run-local-broker.sh aws +``` + +The local broker runner executes the real GCP Cloud Functions broker module or +the real AWS Lambda broker code extracted from the CloudFormation template. It +uses SQLite for broker metadata and local HTTP object capability URLs for Git +objects, so it does not require cloud credentials and never touches deployed +brokers. + +Useful overrides: + +```bash +BGIT_TEST_PROVIDER=gcp|aws|all +BGIT_TEST_RUN_ID=20260519092710 +BGIT_TEST_LOCAL_BROKER_ROOT=/tmp/bgit-local-broker +BGIT_TEST_KEEP_ARTIFACTS=1 +``` + +The suite creates throwaway worktrees under `testsuite/gcp/repo/`, +`testsuite/aws/repo/`, and `testsuite/local/`. Test SSH identities are under +`testsuite/sshkeys/`; these are generated fixtures only and must never be used +outside the test suite. + +On success, the local broker runner removes its temporary broker root and the +worktrees for the provider it ran. On failure it keeps them for debugging. Set +`BGIT_TEST_KEEP_ARTIFACTS=1` to keep artifacts after successful runs too. diff --git a/testsuite/aws/admin_keys_invites.sh b/testsuite/aws/admin_keys_invites.sh new file mode 100755 index 0000000..21d1414 --- /dev/null +++ b/testsuite/aws/admin_keys_invites.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws invite)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" +repo="$(git -C "$dir" config --get bucketgit.logicalRepo)" +out="$(run_in "$dir" admin keys list)" +assert_contains "$out" "owner" +out="$(run_in "$dir" admin invite-user --broker "$broker" --user developer --role developer "$repo")" +assert_contains "$out" "bgit admin accept-invite" +out="$(cd "$dir" && expect_failure "$BGIT" admin invite-user --broker "$broker" --user bad --role owner "$repo")" +assert_contains "$out" "invalid role" diff --git a/testsuite/aws/admin_repo.sh b/testsuite/aws/admin_repo.sh new file mode 100755 index 0000000..a68426e --- /dev/null +++ b/testsuite/aws/admin_repo.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws adminrepo)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +run_in "$dir" admin repo visibility private >/dev/null +run_in "$dir" admin repo issues on >/dev/null +run_in "$dir" admin repo readonly off >/dev/null +out="$(cd "$dir" && expect_failure "$BGIT" admin repo readonly maybe)" +assert_contains "$out" "usage: bgit admin repo readonly on|off" +new_name="$(new_repo_name aws adminrepo-renamed)" +run_in "$dir" admin repo rename "$new_name" >/dev/null +assert_contains "$(git -C "$dir" config --get bucketgit.logicalRepo)" "$new_name.git" diff --git a/testsuite/aws/branch_protection.sh b/testsuite/aws/branch_protection.sh new file mode 100644 index 0000000..9e3bb6c --- /dev/null +++ b/testsuite/aws/branch_protection.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/branch_protection.sh" aws diff --git a/testsuite/aws/danger_zone.sh b/testsuite/aws/danger_zone.sh new file mode 100644 index 0000000..17fd43e --- /dev/null +++ b/testsuite/aws/danger_zone.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/danger_zone.sh" aws diff --git a/testsuite/aws/identity_selection.sh b/testsuite/aws/identity_selection.sh new file mode 100644 index 0000000..d9143f8 --- /dev/null +++ b/testsuite/aws/identity_selection.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/identity_selection.sh" aws diff --git a/testsuite/aws/init.sh b/testsuite/aws/init.sh new file mode 100755 index 0000000..dec7066 --- /dev/null +++ b/testsuite/aws/init.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws init)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +assert_file_exists "$dir/.git/config" +out="$(git -C "$dir" config --get bucketgit.logicalRepo)" +assert_contains "$out" "$repo" +out="$(git -C "$dir" config --get bucketgit.broker)" +assert_contains "$out" "http" diff --git a/testsuite/aws/invites_ownership.sh b/testsuite/aws/invites_ownership.sh new file mode 100644 index 0000000..5104639 --- /dev/null +++ b/testsuite/aws/invites_ownership.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/invites_ownership.sh" aws diff --git a/testsuite/aws/issues.sh b/testsuite/aws/issues.sh new file mode 100755 index 0000000..21fc579 --- /dev/null +++ b/testsuite/aws/issues.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws issues)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +run_in "$dir" admin repo issues on >/dev/null +out="$(run_in "$dir" issue create "GCP test issue" --body "created by testsuite")" +assert_contains "$out" "created issue #" +id="$(printf '%s' "$out" | sed -n 's/.*#\([0-9][0-9]*\).*/\1/p')" +assert_contains "$(run_in "$dir" issue list)" "GCP test issue" +run_in "$dir" issue comment "$id" "comment from testsuite" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "comment from testsuite" +run_in "$dir" issue close "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "closed" +run_in "$dir" issue reopen "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "open" diff --git a/testsuite/aws/issues_permissions.sh b/testsuite/aws/issues_permissions.sh new file mode 100644 index 0000000..16042fb --- /dev/null +++ b/testsuite/aws/issues_permissions.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/issues_permissions.sh" aws diff --git a/testsuite/aws/native_git_transport.sh b/testsuite/aws/native_git_transport.sh new file mode 100644 index 0000000..6217135 --- /dev/null +++ b/testsuite/aws/native_git_transport.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/native_git_transport.sh" aws diff --git a/testsuite/aws/pr.sh b/testsuite/aws/pr.sh new file mode 100755 index 0000000..b1fcd3d --- /dev/null +++ b/testsuite/aws/pr.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws pr)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +commit_file "$dir" README.md "gcp pr" "initial" +run_in "$dir" push -u origin main >/dev/null +run_in "$dir" checkout -b feature/testsuite >/dev/null +commit_file "$dir" FEATURE.md "feature" "feature commit" +run_in "$dir" push -u origin feature/testsuite >/dev/null +out="$(run_in "$dir" pr create --title "GCP testsuite PR" --body "body" --source feature/testsuite --target main)" +assert_contains "$out" "created PR #" +assert_contains "$(run_in "$dir" pr list)" "GCP testsuite PR" +id="$(run_in "$dir" pr list | sed -n 's/^#\([0-9][0-9]*\).*/\1/p' | head -1)" +assert_contains "$(run_in "$dir" pr view "$id")" "GCP testsuite PR" +assert_contains "$(run_in "$dir" pr diff "$id")" "FEATURE.md" +out="$(run_in "$dir" pr close "$id")" +assert_contains "$out" "closed" diff --git a/testsuite/aws/pr_depth.sh b/testsuite/aws/pr_depth.sh new file mode 100644 index 0000000..b618213 --- /dev/null +++ b/testsuite/aws/pr_depth.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/pr_depth.sh" aws diff --git a/testsuite/aws/public_private_access.sh b/testsuite/aws/public_private_access.sh new file mode 100644 index 0000000..3e9c50c --- /dev/null +++ b/testsuite/aws/public_private_access.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/public_private_access.sh" aws diff --git a/testsuite/aws/push_fetch_pull_lsremote.sh b/testsuite/aws/push_fetch_pull_lsremote.sh new file mode 100755 index 0000000..ef1de95 --- /dev/null +++ b/testsuite/aws/push_fetch_pull_lsremote.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws remote)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +commit_file "$dir" README.md "gcp remote" "initial" +out="$(run_in "$dir" push -u origin main)" +assert_contains "$out" "main -> main" +out="$(run_in "$dir" ls-remote)" +assert_contains "$out" "refs/heads/main" +clone="$SUITE_ROOT/aws/repo/remote-clone-$RUN_ID" +rm -rf "$clone" +expect_success "$BGIT" clone "$(git -C "$dir" config --get bucketgit.broker)/$(git -C "$dir" config --get bucketgit.logicalRepo)" "$clone" >/dev/null +init_local_git_identity "$clone" +assert_file_exists "$clone/README.md" +printf 'gcp pull\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "gcp pull source" >/dev/null +run_in "$dir" push >/dev/null +out="$(run_in "$clone" pull)" +assert_contains "$out" "README.md" diff --git a/testsuite/aws/roles_permissions.sh b/testsuite/aws/roles_permissions.sh new file mode 100644 index 0000000..e5873b6 --- /dev/null +++ b/testsuite/aws/roles_permissions.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/roles_permissions.sh" aws diff --git a/testsuite/aws/ssh_key_types.sh b/testsuite/aws/ssh_key_types.sh new file mode 100755 index 0000000..2ed6abf --- /dev/null +++ b/testsuite/aws/ssh_key_types.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws keytypes)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" +repo="$(git -C "$dir" config --get bucketgit.logicalRepo)" + +accept_with_key() { + local label="$1" + local key_path="$2" + local out code + out="$(run_in "$dir" admin invite-user --broker "$broker" --user "$label" --role read "$repo")" + code="$(printf '%s\n' "$out" | awk '/accept-invite/ {print $NF; exit}')" + [[ "$code" == bgitinv_* ]] || fail "invite code not found in output: $out" + ( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + add_test_key "$key_path" + export BGIT_SSH_KEY="$(native_path "$key_path")" + cd "$dir" + "$BGIT" admin accept-invite "$code" >/dev/null + ) +} + +accept_with_key ed25519 "$SUITE_ROOT/sshkeys/developer" +accept_with_key rsa "$SUITE_ROOT/sshkeys/rsa_owner" +accept_with_key ecdsa "$SUITE_ROOT/sshkeys/ecdsa_owner" + +out="$(run_in "$dir" admin keys list)" +assert_contains "$out" "ed25519" +assert_contains "$out" "rsa" +assert_contains "$out" "ecdsa" diff --git a/testsuite/aws/whoami_repos.sh b/testsuite/aws/whoami_repos.sh new file mode 100755 index 0000000..61800ea --- /dev/null +++ b/testsuite/aws/whoami_repos.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo aws whoami)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +out="$(run_in "$dir" whoami)" +assert_contains "$out" "role" +out="$(run_in "$dir" repos mine)" +assert_contains "$out" "$repo" diff --git a/testsuite/gcp/admin_keys_invites.sh b/testsuite/gcp/admin_keys_invites.sh new file mode 100755 index 0000000..334771d --- /dev/null +++ b/testsuite/gcp/admin_keys_invites.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp invite)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" +repo="$(git -C "$dir" config --get bucketgit.logicalRepo)" +out="$(run_in "$dir" admin keys list)" +assert_contains "$out" "owner" +out="$(run_in "$dir" admin invite-user --broker "$broker" --user developer --role developer "$repo")" +assert_contains "$out" "bgit admin accept-invite" +out="$(cd "$dir" && expect_failure "$BGIT" admin invite-user --broker "$broker" --user bad --role owner "$repo")" +assert_contains "$out" "invalid role" diff --git a/testsuite/gcp/admin_repo.sh b/testsuite/gcp/admin_repo.sh new file mode 100755 index 0000000..0fe45b7 --- /dev/null +++ b/testsuite/gcp/admin_repo.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp adminrepo)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +run_in "$dir" admin repo visibility private >/dev/null +run_in "$dir" admin repo issues on >/dev/null +run_in "$dir" admin repo readonly off >/dev/null +out="$(cd "$dir" && expect_failure "$BGIT" admin repo readonly maybe)" +assert_contains "$out" "usage: bgit admin repo readonly on|off" +new_name="$(new_repo_name gcp adminrepo-renamed)" +run_in "$dir" admin repo rename "$new_name" >/dev/null +assert_contains "$(git -C "$dir" config --get bucketgit.logicalRepo)" "$new_name.git" diff --git a/testsuite/gcp/branch_protection.sh b/testsuite/gcp/branch_protection.sh new file mode 100644 index 0000000..e28f2b9 --- /dev/null +++ b/testsuite/gcp/branch_protection.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/branch_protection.sh" gcp diff --git a/testsuite/gcp/danger_zone.sh b/testsuite/gcp/danger_zone.sh new file mode 100644 index 0000000..8b09f98 --- /dev/null +++ b/testsuite/gcp/danger_zone.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/danger_zone.sh" gcp diff --git a/testsuite/gcp/identity_selection.sh b/testsuite/gcp/identity_selection.sh new file mode 100644 index 0000000..bda4170 --- /dev/null +++ b/testsuite/gcp/identity_selection.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/identity_selection.sh" gcp diff --git a/testsuite/gcp/init.sh b/testsuite/gcp/init.sh new file mode 100755 index 0000000..d264dda --- /dev/null +++ b/testsuite/gcp/init.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp init)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +assert_file_exists "$dir/.git/config" +out="$(git -C "$dir" config --get bucketgit.logicalRepo)" +assert_contains "$out" "$repo" +out="$(git -C "$dir" config --get bucketgit.broker)" +assert_contains "$out" "http" diff --git a/testsuite/gcp/invites_ownership.sh b/testsuite/gcp/invites_ownership.sh new file mode 100644 index 0000000..116cd5d --- /dev/null +++ b/testsuite/gcp/invites_ownership.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/invites_ownership.sh" gcp diff --git a/testsuite/gcp/issues.sh b/testsuite/gcp/issues.sh new file mode 100755 index 0000000..ba15ac2 --- /dev/null +++ b/testsuite/gcp/issues.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp issues)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +run_in "$dir" admin repo issues on >/dev/null +out="$(run_in "$dir" issue create "GCP test issue" --body "created by testsuite")" +assert_contains "$out" "created issue #" +id="$(printf '%s' "$out" | sed -n 's/.*#\([0-9][0-9]*\).*/\1/p')" +assert_contains "$(run_in "$dir" issue list)" "GCP test issue" +run_in "$dir" issue comment "$id" "comment from testsuite" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "comment from testsuite" +run_in "$dir" issue close "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "closed" +run_in "$dir" issue reopen "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "open" diff --git a/testsuite/gcp/issues_permissions.sh b/testsuite/gcp/issues_permissions.sh new file mode 100644 index 0000000..5c64b48 --- /dev/null +++ b/testsuite/gcp/issues_permissions.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/issues_permissions.sh" gcp diff --git a/testsuite/gcp/native_git_transport.sh b/testsuite/gcp/native_git_transport.sh new file mode 100644 index 0000000..2f326bf --- /dev/null +++ b/testsuite/gcp/native_git_transport.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/native_git_transport.sh" gcp diff --git a/testsuite/gcp/pr.sh b/testsuite/gcp/pr.sh new file mode 100755 index 0000000..52b18ab --- /dev/null +++ b/testsuite/gcp/pr.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp pr)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +commit_file "$dir" README.md "gcp pr" "initial" +run_in "$dir" push -u origin main >/dev/null +run_in "$dir" checkout -b feature/testsuite >/dev/null +commit_file "$dir" FEATURE.md "feature" "feature commit" +run_in "$dir" push -u origin feature/testsuite >/dev/null +out="$(run_in "$dir" pr create --title "GCP testsuite PR" --body "body" --source feature/testsuite --target main)" +assert_contains "$out" "created PR #" +assert_contains "$(run_in "$dir" pr list)" "GCP testsuite PR" +id="$(run_in "$dir" pr list | sed -n 's/^#\([0-9][0-9]*\).*/\1/p' | head -1)" +assert_contains "$(run_in "$dir" pr view "$id")" "GCP testsuite PR" +assert_contains "$(run_in "$dir" pr diff "$id")" "FEATURE.md" +out="$(run_in "$dir" pr close "$id")" +assert_contains "$out" "closed" diff --git a/testsuite/gcp/pr_depth.sh b/testsuite/gcp/pr_depth.sh new file mode 100644 index 0000000..235e6fd --- /dev/null +++ b/testsuite/gcp/pr_depth.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/pr_depth.sh" gcp diff --git a/testsuite/gcp/public_private_access.sh b/testsuite/gcp/public_private_access.sh new file mode 100644 index 0000000..4e3fe51 --- /dev/null +++ b/testsuite/gcp/public_private_access.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/public_private_access.sh" gcp diff --git a/testsuite/gcp/push_fetch_pull_lsremote.sh b/testsuite/gcp/push_fetch_pull_lsremote.sh new file mode 100755 index 0000000..d63cce0 --- /dev/null +++ b/testsuite/gcp/push_fetch_pull_lsremote.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp remote)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +commit_file "$dir" README.md "gcp remote" "initial" +out="$(run_in "$dir" push -u origin main)" +assert_contains "$out" "main -> main" +out="$(run_in "$dir" ls-remote)" +assert_contains "$out" "refs/heads/main" +clone="$SUITE_ROOT/gcp/repo/remote-clone-$RUN_ID" +rm -rf "$clone" +expect_success "$BGIT" clone "$(git -C "$dir" config --get bucketgit.broker)/$(git -C "$dir" config --get bucketgit.logicalRepo)" "$clone" >/dev/null +init_local_git_identity "$clone" +assert_file_exists "$clone/README.md" +printf 'gcp pull\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "gcp pull source" >/dev/null +run_in "$dir" push >/dev/null +out="$(run_in "$clone" pull)" +assert_contains "$out" "README.md" diff --git a/testsuite/gcp/roles_permissions.sh b/testsuite/gcp/roles_permissions.sh new file mode 100644 index 0000000..9c195eb --- /dev/null +++ b/testsuite/gcp/roles_permissions.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/roles_permissions.sh" gcp diff --git a/testsuite/gcp/ssh_key_types.sh b/testsuite/gcp/ssh_key_types.sh new file mode 100755 index 0000000..9bfcc69 --- /dev/null +++ b/testsuite/gcp/ssh_key_types.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp keytypes)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" +repo="$(git -C "$dir" config --get bucketgit.logicalRepo)" + +accept_with_key() { + local label="$1" + local key_path="$2" + local out code + out="$(run_in "$dir" admin invite-user --broker "$broker" --user "$label" --role read "$repo")" + code="$(printf '%s\n' "$out" | awk '/accept-invite/ {print $NF; exit}')" + [[ "$code" == bgitinv_* ]] || fail "invite code not found in output: $out" + ( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + add_test_key "$key_path" + export BGIT_SSH_KEY="$(native_path "$key_path")" + cd "$dir" + "$BGIT" admin accept-invite "$code" >/dev/null + ) +} + +accept_with_key ed25519 "$SUITE_ROOT/sshkeys/developer" +accept_with_key rsa "$SUITE_ROOT/sshkeys/rsa_owner" +accept_with_key ecdsa "$SUITE_ROOT/sshkeys/ecdsa_owner" + +out="$(run_in "$dir" admin keys list)" +assert_contains "$out" "ed25519" +assert_contains "$out" "rsa" +assert_contains "$out" "ecdsa" diff --git a/testsuite/gcp/whoami_repos.sh b/testsuite/gcp/whoami_repos.sh new file mode 100755 index 0000000..e2fac39 --- /dev/null +++ b/testsuite/gcp/whoami_repos.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +init_output="$(init_bgit_repo gcp whoami)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +out="$(run_in "$dir" whoami)" +assert_contains "$out" "role" +out="$(run_in "$dir" repos mine)" +assert_contains "$out" "$repo" diff --git a/testsuite/lib/cases/branch_protection.sh b/testsuite/lib/cases/branch_protection.sh new file mode 100644 index 0000000..fe97fb1 --- /dev/null +++ b/testsuite/lib/cases/branch_protection.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" protection)" +commit_file "$dir" README.md "base" "initial" +run_in "$dir" push -u origin main >/dev/null + +run_in "$dir" admin protect add main >/dev/null +out="$(run_in "$dir" admin protect list)" +assert_contains "$out" "refs/heads/main" +assert_contains "$out" "pr-required" + +printf 'blocked\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "blocked direct push" >/dev/null +out="$(cd "$dir" && expect_failure "$BGIT" push)" +assert_contains "$out" "protected branch refs/heads/main requires a pull request" + +run_in "$dir" checkout -b feature/protected >/dev/null +printf 'via pr\n' > "$dir/feature.txt" +run_in "$dir" add feature.txt >/dev/null +run_in "$dir" commit -m "feature protected" >/dev/null +run_in "$dir" push -u origin feature/protected >/dev/null +out="$(run_in "$dir" pr create --title "Protected merge" --source feature/protected --target main)" +assert_contains "$out" "created PR #" +id="$(run_in "$dir" pr list | sed -n 's/^#\([0-9][0-9]*\).*/\1/p' | head -1)" +out="$(run_in_as maintainer "$dir" pr merge "$id")" +assert_contains "$out" "merged PR #$id" + +run_in "$dir" admin protect remove main >/dev/null +out="$(run_in "$dir" admin protect list)" +assert_not_contains "$out" "refs/heads/main" + +run_in "$dir" checkout main >/dev/null +run_in "$dir" pull >/dev/null +run_in "$dir" admin protect add main --allow-owner-admin-override >/dev/null +printf 'owner override\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "owner override" >/dev/null +out="$(run_in "$dir" push --force)" +assert_contains "$out" "main -> main" +run_in "$dir" admin protect remove main >/dev/null + +run_in "$dir" checkout main >/dev/null +printf 'readonly\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "readonly check" >/dev/null +run_in "$dir" admin repo readonly on >/dev/null +out="$(expect_failure_in_as developer "$dir" push)" +assert_contains "$out" "repository is read-only" +run_in "$dir" admin repo readonly off >/dev/null + +run_in "$dir" checkout -b delete/me >/dev/null +printf 'delete\n' > "$dir/delete.txt" +run_in "$dir" add delete.txt >/dev/null +run_in "$dir" commit -m "delete branch" >/dev/null +run_in "$dir" push -u origin delete/me >/dev/null +out="$(run_in "$dir" push --delete delete/me)" +assert_contains "$out" "deleted" +out="$(run_in "$dir" ls-remote --heads)" +assert_not_contains "$out" "refs/heads/delete/me" diff --git a/testsuite/lib/cases/danger_zone.sh b/testsuite/lib/cases/danger_zone.sh new file mode 100644 index 0000000..b10df54 --- /dev/null +++ b/testsuite/lib/cases/danger_zone.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" danger)" +repo="$(git -C "$dir" config --get bucketgit.logicalRepo)" +broker="$(git -C "$dir" config --get bucketgit.broker)" +commit_file "$dir" README.md "danger zone" "initial" +run_in "$dir" push -u origin main >/dev/null + +new_name="$(new_repo_name "$provider" danger-renamed)" +out="$(expect_failure_in_as admin "$dir" admin repo rename "$new_name")" +assert_contains "$out" "owner SSH signature required" +out="$(run_in "$dir" admin repo rename "$new_name")" +assert_contains "$out" "renamed repository to $new_name" +renamed_repo="$new_name.git" +assert_contains "$(git -C "$dir" config --get bucketgit.logicalRepo)" "$renamed_repo" +assert_contains "$(git -C "$dir" remote get-url origin)" "$renamed_repo" + +out="$(expect_failure_in_as admin "$dir" admin repo delete --yes)" +assert_contains "$out" "owner SSH signature required" +out="$(cd "$dir" && expect_failure "$BGIT" admin repo delete)" +assert_contains "$out" "usage: bgit admin repo delete --yes" + +out="$(run_in "$dir" repos mine)" +assert_contains "$out" "$renamed_repo" +out="$(run_in "$dir" admin repo delete --yes)" +assert_contains "$out" "deleted repository" +out="$(run_in "$dir" repos mine)" +assert_not_contains "$out" "$renamed_repo" +assert_not_contains "$out" "$repo" diff --git a/testsuite/lib/cases/identity_selection.sh b/testsuite/lib/cases/identity_selection.sh new file mode 100644 index 0000000..bd28a80 --- /dev/null +++ b/testsuite/lib/cases/identity_selection.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" identity)" +commit_file "$dir" README.md "identity" "initial" +run_in "$dir" push -u origin main >/dev/null + +developer_fp="$(key_fingerprint developer)" +read_fp="$(key_fingerprint read)" +outsider_fp="$(key_fingerprint outsider)" + +out="$(with_agent_key developer bash -c 'cd "$1" && "$2" --identity "$3" whoami --refresh' _ "$dir" "$BGIT" "$developer_fp")" +assert_contains "$out" "role: developer" +assert_contains "$out" "selected identity: $developer_fp" + +out="$( + with_agent_keys outsider,developer bash -c 'cd "$1" && "$2" --identity "$3" whoami --refresh' _ "$dir" "$BGIT" "$developer_fp" +)" +assert_contains "$out" "role: developer" + +out="$(expect_failure_in_as outsider "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" +out="$(with_agent_key read bash -c 'cd "$1" && "$2" --identity "$3" whoami --refresh' _ "$dir" "$BGIT" "$read_fp")" +assert_contains "$out" "role: read" + +out="$(with_agent_key developer bash -c 'cd "$1" && "$2" whoami --json --refresh' _ "$dir" "$BGIT")" +assert_contains "$out" '"role": "developer"' +assert_contains "$out" '"capabilities"' +out="$(with_agent_key developer bash -c 'cd "$1" && "$2" whoami --json' _ "$dir" "$BGIT")" +assert_contains "$out" '"role": "developer"' + +out="$( + with_agent_keys developer,read bash -c 'cd "$1" && "$2" whoami --all' _ "$dir" "$BGIT" +)" +assert_contains "$out" "developer" +assert_contains "$out" "reader" +assert_contains "$out" "warning:" +out="$( + with_agent_key developer bash -c 'cd "$1" && "$2" repos mine --json' _ "$dir" "$BGIT" +)" +assert_contains "$out" '"repos"' +assert_contains "$out" '"role": "developer"' + +out="$(with_agent_key outsider bash -c 'cd "$1" && "$2" --identity "$3" whoami --refresh' _ "$dir" "$BGIT" "$outsider_fp" 2>&1 || true)" +assert_contains "$out" "SSH signature required" diff --git a/testsuite/lib/cases/invites_ownership.sh b/testsuite/lib/cases/invites_ownership.sh new file mode 100644 index 0000000..fd02e05 --- /dev/null +++ b/testsuite/lib/cases/invites_ownership.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +init_output="$(init_bgit_repo "$provider" invites-ownership)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" + +out="$(run_in "$dir" admin invite-user --broker "$broker" --user invited-dev --role developer "$repo")" +assert_contains "$out" "bgit admin accept-invite" +invite_code="$(printf '%s\n' "$out" | awk '/accept-invite/ {print $NF; exit}')" +[[ "$invite_code" == bgitinv_* ]] || fail "invite code not found in output: $out" +out="$(expect_failure "$BGIT" admin invite-user --broker "$broker" --user invited-dev --role read "$repo")" +assert_contains "$out" "invite already pending for user" +cancel_out="$(run_in "$dir" admin invite-user --broker "$broker" --user cancelled-dev --role developer "$repo")" +cancel_code="$(printf '%s\n' "$cancel_out" | awk '/accept-invite/ {print $NF; exit}')" +out="$(run_in "$dir" admin cancel-invite --user cancelled-dev)" +assert_contains "$out" "cancelled invite for cancelled-dev" +out="$(with_agent_key maintainer expect_failure "$BGIT" admin accept-invite "$cancel_code")" +assert_contains "$out" "invite is not pending or has expired" +cancel_out="$(run_in "$dir" admin invite-user --broker "$broker" --user code-cancelled --role read "$repo")" +cancel_code="$(printf '%s\n' "$cancel_out" | awk '/accept-invite/ {print $NF; exit}')" +out="$(expect_failure "$BGIT" admin cancel-invite "$cancel_code")" +assert_contains "$out" "usage: bgit admin cancel-invite --broker URL --user USER REPO" +out="$(run_in "$dir" admin cancel-invite --user code-cancelled)" +assert_contains "$out" "cancelled invite for code-cancelled" +out="$(with_agent_key maintainer expect_failure "$BGIT" admin accept-invite "$cancel_code")" +assert_contains "$out" "invite is not pending or has expired" +out="$(without_ssh_identity expect_failure "$BGIT" admin accept-invite "$invite_code")" +assert_contains "$out" "SSH signature required" +out="$(with_agent_key outsider "$BGIT" admin accept-invite "$invite_code")" +assert_contains "$out" "accepted invite for invited-dev as developer" +out="$(expect_failure_in_as outsider "$dir" admin accept-invite "$invite_code")" +assert_contains "$out" "invite is not pending or has expired" +out="$(run_in_as outsider "$dir" whoami --refresh)" +assert_contains "$out" "user: invited-dev" +assert_contains "$out" "role: developer" + +out="$(run_in "$dir" admin confirm-ownership-transfer --broker "$broker" "$repo")" +assert_contains "$out" "ownership transfer pending" +transfer_code="$(printf '%s\n' "$out" | awk '/accept-ownership-transfer/ {print $NF; exit}')" +[[ "$transfer_code" == bgitot_* ]] || fail "ownership transfer code not found in output: $out" +out="$(expect_failure "$BGIT" admin confirm-ownership-transfer --broker "$broker" "$repo")" +assert_contains "$out" "ownership transfer already pending" +out="$(run_in "$dir" admin cancel-ownership-transfer --broker "$broker" "$repo")" +assert_contains "$out" "cancelled pending ownership transfer" +out="$(with_agent_key outsider expect_failure "$BGIT" admin accept-ownership-transfer "$transfer_code")" +assert_contains "$out" "ownership transfer is not pending or has expired" + +out="$(run_in "$dir" admin confirm-ownership-transfer --broker "$broker" "$repo")" +transfer_code="$(printf '%s\n' "$out" | awk '/accept-ownership-transfer/ {print $NF; exit}')" +out="$(with_agent_key outsider "$BGIT" admin accept-ownership-transfer "$transfer_code")" +assert_contains "$out" "accepted ownership for $repo" +out="$(run_in_as outsider "$dir" whoami --refresh)" +assert_contains "$out" "role: owner" +out="$(run_in "$dir" whoami --refresh)" +assert_contains "$out" "role: admin" +out="$(expect_failure "$BGIT" admin confirm-ownership-transfer --broker "$broker" "$repo")" +assert_contains "$out" "owner SSH signature required" diff --git a/testsuite/lib/cases/issues_permissions.sh b/testsuite/lib/cases/issues_permissions.sh new file mode 100644 index 0000000..d43094d --- /dev/null +++ b/testsuite/lib/cases/issues_permissions.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" issue-perms)" +commit_file "$dir" README.md "issues" "initial" +run_in "$dir" push -u origin main >/dev/null + +run_in "$dir" admin repo issues off >/dev/null +out="$(expect_failure_in_as triage "$dir" issue create "disabled")" +assert_contains "$out" "issues are disabled" +run_in "$dir" admin repo issues on >/dev/null + +out="$(expect_failure_in_as outsider "$dir" issue create "private outsider")" +assert_contains "$out" "read SSH signature required" +out="$(run_in_as read "$dir" issue create "read issue" --body "private member")" +assert_contains "$out" "created issue #" +id="$(printf '%s' "$out" | sed -n 's/.*#\([0-9][0-9]*\).*/\1/p')" +run_in_as triage "$dir" issue comment "$id" "triage comment" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "triage comment" +out="$(expect_failure_in_as read "$dir" issue close "$id")" +assert_contains "$out" "write SSH signature required" +run_in_as developer "$dir" issue close "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "closed" +run_in_as developer "$dir" issue reopen "$id" >/dev/null +assert_contains "$(run_in "$dir" issue view "$id")" "open" +out="$(expect_failure_in_as developer "$dir" issue view 99999)" +assert_contains "$out" "issue not found" + +run_in "$dir" admin repo visibility public >/dev/null +out="$(run_in_no_agent "$dir" issue create "anonymous public issue" --body "anon")" +assert_contains "$out" "created issue #" +anon_id="$(printf '%s' "$out" | sed -n 's/.*#\([0-9][0-9]*\).*/\1/p')" +run_in_no_agent "$dir" issue comment "$anon_id" "anonymous comment" >/dev/null +assert_contains "$(run_in "$dir" issue view "$anon_id")" "anonymous comment" + diff --git a/testsuite/lib/cases/local_more_porcelain.sh b/testsuite/lib/cases/local_more_porcelain.sh new file mode 100644 index 0000000..8a60ca7 --- /dev/null +++ b/testsuite/lib/cases/local_more_porcelain.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../testlib.sh" + +dir="$(new_workdir local porcelain-more)" +"$BGIT" init --noninteractive --repo local-porcelain-more --profile "$GCP_PROFILE" "${CONFIG_ARGS[@]}" "$dir" >/dev/null +init_local_git_identity "$dir" +commit_file "$dir" README.md "line one" "initial" + +run_in "$dir" switch -c feature >/dev/null +printf 'feature\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "feature change" >/dev/null +run_in "$dir" switch main >/dev/null +run_in "$dir" cherry-pick feature >/dev/null +assert_contains "$(run_in "$dir" log --oneline)" "feature change" +run_in "$dir" revert HEAD >/dev/null +assert_contains "$(run_in "$dir" log --oneline)" "Revert" + +printf 'stash\n' >> "$dir/README.md" +run_in "$dir" stash push -m "stash test" >/dev/null +assert_contains "$(run_in "$dir" stash list)" "stash@{0}" +run_in "$dir" stash pop >/dev/null 2>&1 || true +assert_contains "$(run_in "$dir" status)" "modified:" +run_in "$dir" reset --hard HEAD >/dev/null + +mkdir -p "$dir/tmp" +printf 'tmp\n' > "$dir/tmp/generated.txt" +run_in "$dir" clean -f -d >/dev/null +assert_file_not_exists "$dir/tmp/generated.txt" + +run_in "$dir" tag v-local >/dev/null +assert_contains "$(run_in "$dir" describe)" "v-local" +run_in "$dir" tag -v v-local >/dev/null 2>&1 || true +run_in "$dir" tag -d v-local >/dev/null +assert_not_contains "$(run_in "$dir" tag)" "v-local" + +assert_contains "$(run_in "$dir" blame README.md)" "line one" +assert_contains "$(run_in "$dir" ls-tree HEAD)" "README.md" +run_in "$dir" archive --format=tar HEAD >/tmp/bgit-archive-$$.tar +test -s /tmp/bgit-archive-$$.tar || fail "archive output was empty" +rm -f /tmp/bgit-archive-$$.tar +run_in "$dir" config bucketgit.test value >/dev/null +assert_contains "$(run_in "$dir" config --get bucketgit.test)" "value" + +run_in "$dir" branch delete-test >/dev/null +run_in "$dir" branch -d delete-test >/dev/null +assert_not_contains "$(run_in "$dir" branch)" "delete-test" + +printf 'unsafe main\n' >> "$dir/README.md" +out="$(cd "$dir" && expect_failure "$BGIT" clean --bad-option)" +assert_contains "$out" "unsupported clean option" diff --git a/testsuite/lib/cases/native_git_transport.sh b/testsuite/lib/cases/native_git_transport.sh new file mode 100644 index 0000000..e71ab30 --- /dev/null +++ b/testsuite/lib/cases/native_git_transport.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +init_output="$(init_bgit_repo "$provider" native)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +commit_file "$dir" README.md "native" "initial" +(cd "$dir" && git push -u origin main >/dev/null) +assert_contains "$(run_in "$dir" ls-remote --heads)" "refs/heads/main" + +clone="$SUITE_ROOT/$provider/repo/native-clone-$RUN_ID" +rm -rf "$clone" +"$BGIT" clone "$(git -C "$dir" config --get bucketgit.broker)/$repo" "$clone" >/dev/null +init_local_git_identity "$clone" +assert_file_exists "$clone/README.md" +(cd "$clone" && git fetch origin >/dev/null) +(cd "$clone" && git ls-remote origin >/tmp/bgit-native-lsremote.$$) +assert_contains "$(cat /tmp/bgit-native-lsremote.$$)" "refs/heads/main" +rm -f /tmp/bgit-native-lsremote.$$ + +(cd "$clone" && git checkout -b native/feature >/dev/null) +printf 'feature\n' > "$clone/native.txt" +(cd "$clone" && git add native.txt && git commit -m "native feature" >/dev/null && git push -u origin native/feature >/dev/null) +assert_contains "$(run_in "$dir" ls-remote --heads)" "refs/heads/native/feature" +branch_remote="$(git -C "$clone" config --get branch.native/feature.remote)" +assert_contains "$branch_remote" "origin" + +(cd "$clone" && git tag native-v1 && git push origin native-v1 >/dev/null) +assert_contains "$(run_in "$dir" ls-remote --tags)" "refs/tags/native-v1" + +(cd "$clone" && git push origin --delete native/feature >/dev/null) +assert_not_contains "$(run_in "$dir" ls-remote --heads)" "refs/heads/native/feature" +if git -C "$clone" config --get branch.native/feature.remote >/dev/null 2>&1; then + fail "branch tracking should be removed after native branch delete" +fi + +printf 'remote update\n' >> "$dir/README.md" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m "remote update" >/dev/null +run_in "$dir" push >/dev/null +if [[ -n "$(git -C "$clone" status --porcelain)" ]]; then + git -C "$clone" status --porcelain >&2 + fail "native clone should be clean before pull" +fi +(cd "$clone" && git checkout main >/dev/null && git pull >/dev/null) +assert_contains "$(cat "$clone/README.md")" "remote update" diff --git a/testsuite/lib/cases/pr_depth.sh b/testsuite/lib/cases/pr_depth.sh new file mode 100644 index 0000000..0eaabf9 --- /dev/null +++ b/testsuite/lib/cases/pr_depth.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" pr-depth)" +commit_file "$dir" README.md "base" "initial" +run_in "$dir" push -u origin main >/dev/null + +run_in "$dir" checkout -b feature/pr-depth >/dev/null +printf 'change\n' >> "$dir/README.md" +printf 'new\n' > "$dir/NEW.md" +run_in "$dir" add README.md NEW.md >/dev/null +run_in "$dir" commit -m "pr depth change" >/dev/null +run_in "$dir" push -u origin feature/pr-depth >/dev/null +out="$(run_in_as developer "$dir" pr create --title "PR depth" --body "body" --source feature/pr-depth --target main)" +assert_contains "$out" "created PR #" +id="$(run_in "$dir" pr list | sed -n 's/^#\([0-9][0-9]*\).*/\1/p' | head -1)" +assert_contains "$(run_in "$dir" pr view "$id")" "status: open" +assert_contains "$(run_in "$dir" pr diff "$id")" "NEW.md" + +run_in "$dir" pr close "$id" >/dev/null +assert_contains "$(run_in "$dir" pr view "$id")" "status: closed" +run_in_as maintainer "$dir" pr reopen "$id" >/dev/null +assert_contains "$(run_in "$dir" pr view "$id")" "status: open" + +out="$(expect_failure_in_as triage "$dir" pr approve "$id" "looks good")" +assert_contains "$out" "write SSH signature required" +run_in_as triage "$dir" pr comment "$id" "triage comment" >/dev/null +run_in_as maintainer "$dir" pr approve "$id" "looks good" >/dev/null +out="$(run_in "$dir" pr view "$id")" +assert_contains "$out" "approvals: 1" +run_in_as developer "$dir" pr reject "$id" "needs work" >/dev/null +out="$(run_in "$dir" pr view "$id")" +assert_contains "$out" "approvals: 1" +run_in_as read "$dir" pr comment "$id" "reader comment" >/dev/null +out="$(run_in "$dir" pr view "$id")" +assert_contains "$out" "PR depth" + +out="$(run_in_as maintainer "$dir" pr merge "$id" --delete-branch)" +assert_contains "$out" "merged PR #$id" +assert_contains "$(run_in "$dir" pr view "$id")" "status: merged" +out="$(run_in "$dir" ls-remote --heads)" +assert_not_contains "$out" "refs/heads/feature/pr-depth" +out="$(expect_failure_in_as maintainer "$dir" pr merge "$id")" +assert_contains "$out" "pull request is not open" diff --git a/testsuite/lib/cases/public_private_access.sh b/testsuite/lib/cases/public_private_access.sh new file mode 100644 index 0000000..0bec654 --- /dev/null +++ b/testsuite/lib/cases/public_private_access.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +init_output="$(init_bgit_repo "$provider" public-private)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" +repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +broker="$(git -C "$dir" config --get bucketgit.broker)" +clone_url="${broker%/}/$repo" +commit_file "$dir" README.md "public private access" "initial" +run_in "$dir" push -u origin main >/dev/null + +private_no_key="$SUITE_ROOT/$provider/repo/public-private-no-key-private-$RUN_ID" +private_unknown="$SUITE_ROOT/$provider/repo/public-private-unknown-private-$RUN_ID" +out="$(without_ssh_identity expect_failure "$BGIT" clone "$clone_url" "$private_no_key")" +assert_contains "$out" "broker denied read access" +out="$(with_agent_key outsider expect_failure "$BGIT" clone "$clone_url" "$private_unknown")" +assert_contains "$out" "broker denied read access" + +run_in "$dir" admin repo visibility public >/dev/null + +public_no_key="$SUITE_ROOT/$provider/repo/public-private-no-key-public-$RUN_ID" +public_unknown="$SUITE_ROOT/$provider/repo/public-private-unknown-public-$RUN_ID" +without_ssh_identity expect_success "$BGIT" clone "$clone_url" "$public_no_key" >/dev/null +assert_file_exists "$public_no_key/README.md" +assert_contains "$(cat "$public_no_key/README.md")" "public private access" +with_agent_key outsider expect_success "$BGIT" clone "$clone_url" "$public_unknown" >/dev/null +assert_file_exists "$public_unknown/README.md" +assert_contains "$(cat "$public_unknown/README.md")" "public private access" + +run_in "$dir" admin repo visibility private >/dev/null +out="$(cd "$public_no_key" && without_ssh_identity expect_failure "$BGIT" ls-remote)" +assert_contains "$out" "read SSH signature required" diff --git a/testsuite/lib/cases/roles_permissions.sh b/testsuite/lib/cases/roles_permissions.sh new file mode 100644 index 0000000..dffd967 --- /dev/null +++ b/testsuite/lib/cases/roles_permissions.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail +provider="$1" +source "$(dirname "$0")/../testlib.sh" + +dir="$(setup_role_repo "$provider" roles)" +commit_file "$dir" README.md "roles" "initial" +run_in "$dir" push -u origin main >/dev/null + +out="$(run_in_as read "$dir" whoami --refresh)" +assert_contains "$out" "role: read" +out="$(expect_failure_in_as read "$dir" push)" +assert_contains "$out" "write SSH signature required" + +out="$(run_in_as triage "$dir" issue create "triage can create issue" --body "triage")" +assert_contains "$out" "created issue #" +out="$(expect_failure_in_as triage "$dir" pr create --title "triage cannot pr" --source main --target main)" +assert_contains "$out" "write SSH signature required" + +run_in_as developer "$dir" checkout -b developer/branch >/dev/null +printf 'developer\n' > "$dir/developer.txt" +run_in_as developer "$dir" add developer.txt >/dev/null +run_in_as developer "$dir" commit -m "developer branch" >/dev/null +out="$(run_in_as developer "$dir" push -u origin developer/branch)" +assert_contains "$out" "developer/branch -> developer/branch" + +out="$(expect_failure_in_as maintainer "$dir" admin keys list)" +assert_contains "$out" "admin SSH signature required" +out="$(run_in_as admin "$dir" admin keys list)" +assert_contains "$out" "developer" + +owner_fp="$(key_fingerprint owner)" +out="$(expect_failure_in_as admin "$dir" admin keys remove "$owner_fp")" +assert_contains "$out" "owners cannot be removed or suspended" +out="$(expect_failure_in_as admin "$dir" admin keys suspend "$owner_fp")" +assert_contains "$out" "owners cannot be removed or suspended" + +developer_fp="$(key_fingerprint developer)" +run_in_as admin "$dir" admin keys suspend "$developer_fp" >/dev/null +out="$(expect_failure_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" +run_in_as admin "$dir" admin keys remove "$developer_fp" >/dev/null +out="$(expect_failure_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" + +out="$(expect_failure_in_as outsider "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" + diff --git a/testsuite/lib/testlib.sh b/testsuite/lib/testlib.sh new file mode 100755 index 0000000..e8e1ecd --- /dev/null +++ b/testsuite/lib/testlib.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BGIT="${BGIT:-$ROOT/bgit}" +SUITE_ROOT="$ROOT/testsuite" +RUN_ID="${BGIT_TEST_RUN_ID:-$(date +%Y%m%d%H%M%S)}" +GCP_PROFILE="${BGIT_TEST_GCP_PROFILE:-gcp:local/test}" +AWS_PROFILE="${BGIT_TEST_AWS_PROFILE:-aws:local/test}" +CONFIG_ARGS=() +if [[ -n "${BGIT_TEST_CONFIG:-}" ]]; then + CONFIG_ARGS=(--config "$BGIT_TEST_CONFIG") +fi + +log() { printf '[%s] %s\n' "$(basename "$0")" "$*" >&2; } +fail() { printf '[%s] FAIL: %s\n' "$(basename "$0")" "$*" >&2; exit 1; } + +assert_contains() { + local haystack="$1" + local needle="$2" + [[ "$haystack" == *"$needle"* ]] || fail "expected output to contain '$needle'; got: $haystack" +} + +assert_not_contains() { + local haystack="$1" + local needle="$2" + [[ "$haystack" != *"$needle"* ]] || fail "expected output not to contain '$needle'; got: $haystack" +} + +assert_file_exists() { + [[ -e "$1" ]] || fail "expected file to exist: $1" +} + +assert_file_not_exists() { + [[ ! -e "$1" ]] || fail "expected file not to exist: $1" +} + +expect_success() { + local out + if ! out="$("$@" 2>&1)"; then + printf '%s\n' "$out" >&2 + fail "command failed: $*" + fi + printf '%s' "$out" +} + +expect_failure() { + local out + if out="$("$@" 2>&1)"; then + printf '%s\n' "$out" >&2 + fail "command unexpectedly succeeded: $*" + fi + printf '%s' "$out" +} + +provider_profile() { + case "$1" in + gcp) printf '%s' "$GCP_PROFILE" ;; + aws) printf '%s' "$AWS_PROFILE" ;; + *) fail "unknown provider $1" ;; + esac +} + +provider_dir() { + printf '%s/%s/repo' "$SUITE_ROOT" "$1" +} + +new_repo_name() { + printf 'bgit-it-%s-%s-%s' "$1" "$RUN_ID" "${2:-repo}" +} + +new_workdir() { + local provider="$1" + local name="$2" + local dir + dir="$(provider_dir "$provider")/$name" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s' "$dir" +} + +init_local_git_identity() { + git -C "$1" config user.name "BucketGit Tests" + git -C "$1" config user.email "tests@bucketgit.local" +} + +init_bgit_repo() { + local provider="$1" + local suffix="$2" + local profile repo dir + profile="$(provider_profile "$provider")" + repo="$(new_repo_name "$provider" "$suffix")" + dir="$(new_workdir "$provider" "$suffix")" + expect_success "$BGIT" init --noninteractive --repo "$repo" --profile "$profile" "${CONFIG_ARGS[@]}" "$dir" >/dev/null + init_local_git_identity "$dir" + printf '%s\n%s\n' "$dir" "$repo.git" +} + +commit_file() { + local dir="$1" + local file="$2" + local body="$3" + local msg="$4" + printf '%s\n' "$body" > "$dir/$file" + (cd "$dir" && "$BGIT" add "$file" && "$BGIT" commit -m "$msg" >/dev/null) +} + +run_in() { + local dir="$1" + shift + (cd "$dir" && "$BGIT" "$@") +} + +key_path() { + printf '%s/sshkeys/%s' "$SUITE_ROOT" "$1" +} + +key_fingerprint() { + ssh-keygen -lf "$(key_path "$1.pub")" | awk '{print $2}' +} + +native_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" + else + printf '%s' "$1" + fi +} + +path_list_separator() { + if command -v cygpath >/dev/null 2>&1; then + printf ';' + else + printf ':' + fi +} + +add_test_key() { + local key="$1" + chmod 600 "$key" >/dev/null 2>&1 || true + ssh-add "$key" >/dev/null +} + +with_agent_key() { + local key="$1" + shift + ( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + local path + path="$(key_path "$key")" + add_test_key "$path" + export BGIT_SSH_KEY="$(native_path "$path")" + unset BGIT_SSH_KEYS + "$@" + ) +} + +with_agent_keys() { + local keys_csv="$1" + shift + ( + eval "$(ssh-agent -s)" >/dev/null + trap 'ssh-agent -k >/dev/null 2>&1 || true' EXIT + unset BGIT_SSH_KEY + local sep paths key path + sep="$(path_list_separator)" + paths="" + IFS=',' read -r -a keys <<< "$keys_csv" + for key in "${keys[@]}"; do + path="$(key_path "$key")" + add_test_key "$path" + if [[ -n "$paths" ]]; then + paths="${paths}${sep}" + fi + paths="${paths}$(native_path "$path")" + done + export BGIT_SSH_KEYS="$paths" + "$@" + ) +} + +without_ssh_identity() { + ( + unset SSH_AUTH_SOCK + unset BGIT_SSH_KEY + unset BGIT_SSH_KEYS + "$@" + ) +} + +run_in_as() { + local key="$1" + local dir="$2" + shift 2 + with_agent_key "$key" bash -c 'cd "$1" && shift && "$@"' _ "$dir" "$BGIT" "$@" +} + +run_in_no_agent() { + local dir="$1" + shift + (cd "$dir" && without_ssh_identity "$BGIT" "$@") +} + +expect_failure_no_agent() { + local dir="$1" + shift + (cd "$dir" && without_ssh_identity expect_failure "$BGIT" "$@") +} + +expect_failure_in_as() { + local key="$1" + local dir="$2" + shift 2 + with_agent_key "$key" bash -c ' + dir="$1" + shift + cd "$dir" + if out="$("$@" 2>&1)"; then + printf "%s\n" "$out" >&2 + exit 99 + fi + printf "%s" "$out" + ' _ "$dir" "$BGIT" "$@" +} + +add_key_to_repo() { + local dir="$1" + local user="$2" + local role="$3" + local key="$4" + run_in "$dir" admin keys add --no-agent --key "$(key_path "$key.pub")" --user "$user" --role "$role" >/dev/null +} + +setup_role_repo() { + local provider="$1" + local suffix="$2" + local init_output dir + init_output="$(init_bgit_repo "$provider" "$suffix")" + dir="$(printf "%s\n" "$init_output" | sed -n "1p")" + add_key_to_repo "$dir" admin admin admin + add_key_to_repo "$dir" maintainer maintainer maintainer + add_key_to_repo "$dir" developer developer developer + add_key_to_repo "$dir" triage triage triage + add_key_to_repo "$dir" reader read read + printf '%s\n' "$dir" +} diff --git a/testsuite/local/add.sh b/testsuite/local/add.sh new file mode 100755 index 0000000..83191cd --- /dev/null +++ b/testsuite/local/add.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +dir="$(new_workdir local add)" +"$BGIT" init --noninteractive --repo local-add "$dir" --profile "$GCP_PROFILE" >/dev/null +init_local_git_identity "$dir" +printf 'hello\n' > "$dir/README.md" +out="$(run_in "$dir" status)" +assert_contains "$out" "Untracked files" +run_in "$dir" add README.md >/dev/null +out="$(run_in "$dir" status)" +assert_contains "$out" "Changes to be committed" diff --git a/testsuite/local/branch_checkout_merge.sh b/testsuite/local/branch_checkout_merge.sh new file mode 100755 index 0000000..9da7cff --- /dev/null +++ b/testsuite/local/branch_checkout_merge.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +dir="$(new_workdir local branch)" +"$BGIT" init --noninteractive --repo local-branch "$dir" --profile "$GCP_PROFILE" >/dev/null +init_local_git_identity "$dir" +commit_file "$dir" README.md base "base" +run_in "$dir" checkout -b feature >/dev/null +commit_file "$dir" feature.txt feature "feature" +run_in "$dir" checkout main >/dev/null +out="$(run_in "$dir" merge feature)" +assert_contains "$out" "feature.txt" diff --git a/testsuite/local/commit.sh b/testsuite/local/commit.sh new file mode 100755 index 0000000..4d858bd --- /dev/null +++ b/testsuite/local/commit.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +dir="$(new_workdir local commit)" +"$BGIT" init --noninteractive --repo local-commit "$dir" --profile "$GCP_PROFILE" >/dev/null +init_local_git_identity "$dir" +printf 'commit\n' > "$dir/README.md" +run_in "$dir" add README.md >/dev/null +out="$(run_in "$dir" commit -m "local commit")" +assert_contains "$out" "local commit" +out="$(run_in "$dir" log --oneline)" +assert_contains "$out" "local commit" diff --git a/testsuite/local/diff_log_show_status.sh b/testsuite/local/diff_log_show_status.sh new file mode 100755 index 0000000..cc1ea61 --- /dev/null +++ b/testsuite/local/diff_log_show_status.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +dir="$(new_workdir local inspect)" +"$BGIT" init --noninteractive --repo local-inspect "$dir" --profile "$GCP_PROFILE" >/dev/null +init_local_git_identity "$dir" +commit_file "$dir" README.md one "one" +printf 'two\n' >> "$dir/README.md" +out="$(run_in "$dir" diff)" +assert_contains "$out" "+two" +run_in "$dir" add README.md >/dev/null +run_in "$dir" commit -m two >/dev/null +assert_contains "$(run_in "$dir" log --oneline)" "two" +assert_contains "$(run_in "$dir" show HEAD)" "two" +assert_contains "$(run_in "$dir" status)" "working tree clean" diff --git a/testsuite/local/more_porcelain.sh b/testsuite/local/more_porcelain.sh new file mode 100644 index 0000000..ef9b0a7 --- /dev/null +++ b/testsuite/local/more_porcelain.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/local_more_porcelain.sh" diff --git a/testsuite/local/porcelain_misc.sh b/testsuite/local/porcelain_misc.sh new file mode 100755 index 0000000..474fd6e --- /dev/null +++ b/testsuite/local/porcelain_misc.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../lib/testlib.sh" +dir="$(new_workdir local porcelain)" +"$BGIT" init --noninteractive --repo local-porcelain "$dir" --profile "$GCP_PROFILE" >/dev/null +init_local_git_identity "$dir" +commit_file "$dir" README.md "alpha" "initial" +run_in "$dir" tag v1 >/dev/null +assert_contains "$(run_in "$dir" tag)" "v1" +assert_contains "$(run_in "$dir" grep alpha)" "README.md" +run_in "$dir" mv README.md MOVED.md >/dev/null +assert_contains "$(run_in "$dir" status)" "renamed:" +run_in "$dir" restore --staged MOVED.md >/dev/null || true +run_in "$dir" reset --hard HEAD >/dev/null +assert_file_exists "$dir/README.md" +run_in "$dir" rm README.md >/dev/null +assert_contains "$(run_in "$dir" status)" "deleted:" +run_in "$dir" reset --hard HEAD >/dev/null +assert_file_exists "$dir/README.md" +head_hash="$(run_in "$dir" rev-parse HEAD)" +[[ "${#head_hash}" -ge 40 ]] || fail "expected rev-parse HEAD to return a commit hash" +assert_contains "$(run_in "$dir" ls-files)" "README.md" diff --git a/testsuite/run-local-broker.sh b/testsuite/run-local-broker.sh new file mode 100755 index 0000000..86098d8 --- /dev/null +++ b/testsuite/run-local-broker.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +runtime="${1:-${BGIT_TEST_BROKER_RUNTIME:-gcp}}" +case "$runtime" in + gcp) provider="gcp"; port="${BGIT_TEST_BROKER_PORT:-19190}" ;; + aws) provider="aws"; port="${BGIT_TEST_BROKER_PORT:-19191}" ;; + *) printf 'usage: ./testsuite/run-local-broker.sh gcp|aws\n' >&2; exit 2 ;; +esac + +export GOCACHE="${GOCACHE:-$(go env GOCACHE 2>/dev/null || printf '/tmp/bgit-gocache')}" +export GOMODCACHE="${GOMODCACHE:-$(go env GOMODCACHE 2>/dev/null || printf '/tmp/bgit-gomodcache')}" +if [[ "${BGIT_TEST_USE_EXISTING_BINARY:-}" != "1" ]]; then + go build -o bgit . +fi + +native_path() { + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$1" + else + printf '%s' "$1" + fi +} + +remove_test_artifacts_best_effort() { + local path="$1" + if [[ -e "$path" ]]; then + rm -rf "$path" >/dev/null 2>&1 || true + fi +} + +for key in "$ROOT"/testsuite/sshkeys/*; do + tmp="${key}.tmp" + tr -d '\r' < "$key" > "$tmp" + mv "$tmp" "$key" +done + +run_id="${BGIT_TEST_RUN_ID:-$(date +%Y%m%d%H%M%S)}" +tmp_root="${TMPDIR:-${TMP:-/tmp}}" +test_root="${BGIT_TEST_LOCAL_BROKER_ROOT:-${tmp_root%/}/bgit-local-broker-${runtime}-${run_id}}" +broker_url="http://127.0.0.1:${port}" +config_path="${test_root}/home/.bgit/config.yaml" +remove_test_artifacts_best_effort "$test_root" +remove_test_artifacts_best_effort "$ROOT/testsuite/local/repo" +remove_test_artifacts_best_effort "$ROOT/testsuite/${provider}/repo" +mkdir -p "$(dirname "$config_path")" +export HOME="${test_root}/home" +export USERPROFILE="$(native_path "$HOME")" + +cat > "$config_path" < "${test_root}/broker.log" 2>&1 & +broker_pid=$! +status=0 +cleanup() { + status=$? + kill "$broker_pid" >/dev/null 2>&1 || true + wait "$broker_pid" >/dev/null 2>&1 || true + if [[ -n "${SSH_AGENT_PID:-}" ]]; then ssh-agent -k >/dev/null 2>&1 || true; fi + printf 'kept test artifacts in %s and %s\n' "$test_root" "$ROOT/testsuite/${provider}/repo" >&2 + exit "$status" +} +trap cleanup EXIT + +for _ in $(seq 1 100); do + if curl -sS "${broker_url}/health" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +curl -sS "${broker_url}/health" >/dev/null + +eval "$(ssh-agent -s)" >/dev/null +chmod 600 "$ROOT"/testsuite/sshkeys/* >/dev/null 2>&1 || true +ssh-add "$ROOT/testsuite/sshkeys/owner" >/dev/null +export BGIT_SSH_KEY="$(native_path "$ROOT/testsuite/sshkeys/owner")" + +owner_key="$(cat "$ROOT/testsuite/sshkeys/owner.pub")" +curl -sS -X POST "${broker_url}/owners/upsert" \ + -H 'content-type: application/json' \ + --data "{\"user\":\"owner\",\"role\":\"owner\",\"public_keys\":[\"${owner_key}\"]}" >/dev/null + +export BGIT="${BGIT:-$ROOT/bgit}" +export BGIT_TEST_USE_EXISTING_BINARY=1 +export BGIT_TEST_RUN_ID="$run_id" +export BGIT_TEST_PROVIDER="$provider" +export BGIT_TEST_CONFIG="$(native_path "$config_path")" +export BGIT_TEST_GCP_PROFILE="gcp:local/test" +export BGIT_TEST_AWS_PROFILE="aws:local/test" +export BGIT_TEST_IN_LOCAL_BROKER=1 + +printf 'Running %s tests against local %s broker at %s\n' "$provider" "$runtime" "$broker_url" +"$ROOT/testsuite/run.sh" diff --git a/testsuite/run.sh b/testsuite/run.sh new file mode 100755 index 0000000..e22dc00 --- /dev/null +++ b/testsuite/run.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ "${BGIT_TEST_IN_LOCAL_BROKER:-}" != "1" ]]; then + runtimes=() + provider_filter="${BGIT_TEST_PROVIDER:-all}" + if [[ "$provider_filter" == "all" || "$provider_filter" == "gcp" ]]; then + runtimes+=(gcp) + fi + if [[ "$provider_filter" == "all" || "$provider_filter" == "aws" ]]; then + runtimes+=(aws) + fi + for runtime in "${runtimes[@]}"; do + "$ROOT/testsuite/run-local-broker.sh" "$runtime" + done + exit 0 +fi + +export GOCACHE="${GOCACHE:-$(go env GOCACHE 2>/dev/null || printf '/tmp/bgit-gocache')}" +if [[ "${BGIT_TEST_USE_EXISTING_BINARY:-}" != "1" ]]; then + go build -o bgit . +fi + +export BGIT="${BGIT:-$ROOT/bgit}" +export BGIT_TEST_RUN_ID="${BGIT_TEST_RUN_ID:-$(date +%Y%m%d%H%M%S)}" + +provider_filter="${BGIT_TEST_PROVIDER:-all}" + +tests=() +while IFS= read -r -d '' file; do tests+=("$file"); done < <(find testsuite/local -name '*.sh' -type f -print0 | sort -z) +if [[ "$provider_filter" == "all" || "$provider_filter" == "gcp" ]]; then + while IFS= read -r -d '' file; do tests+=("$file"); done < <(find testsuite/gcp -maxdepth 1 -name '*.sh' -type f -print0 | sort -z) +fi +if [[ "$provider_filter" == "all" || "$provider_filter" == "aws" ]]; then + while IFS= read -r -d '' file; do tests+=("$file"); done < <(find testsuite/aws -maxdepth 1 -name '*.sh' -type f -print0 | sort -z) +fi + +printf 'Running %d integration test files with run id %s\n' "${#tests[@]}" "$BGIT_TEST_RUN_ID" +for test in "${tests[@]}"; do + printf '\n==> %s\n' "$test" + bash "$test" +done + +printf '\nIntegration suite passed.\n' diff --git a/testsuite/sshkeys/admin b/testsuite/sshkeys/admin new file mode 100644 index 0000000..a04293c --- /dev/null +++ b/testsuite/sshkeys/admin @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACC5JidYWiSlyNsgB2w1r7+7CuRGbRDeC8fqNT92tLfjOQAAAJjx7md+8e5n +fgAAAAtzc2gtZWQyNTUxOQAAACC5JidYWiSlyNsgB2w1r7+7CuRGbRDeC8fqNT92tLfjOQ +AAAEBZqOWhvPb5qwYIRW2pqJ/wgBCB8IQeS31gRz0J4OAhn7kmJ1haJKXI2yAHbDWvv7sK +5EZtEN4Lx+o1P3a0t+M5AAAAFGJnaXQtdGVzdHN1aXRlLWFkbWluAQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/admin.pub b/testsuite/sshkeys/admin.pub new file mode 100644 index 0000000..fe39942 --- /dev/null +++ b/testsuite/sshkeys/admin.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILkmJ1haJKXI2yAHbDWvv7sK5EZtEN4Lx+o1P3a0t+M5 bgit-testsuite-admin diff --git a/testsuite/sshkeys/developer b/testsuite/sshkeys/developer new file mode 100644 index 0000000..666cdae --- /dev/null +++ b/testsuite/sshkeys/developer @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAH5LlS5hY9FWPmkr98BqLD9iyaTVidooyn+Hin5XulqQAAAKCCkMC0gpDA +tAAAAAtzc2gtZWQyNTUxOQAAACAH5LlS5hY9FWPmkr98BqLD9iyaTVidooyn+Hin5XulqQ +AAAEBMce9totBr4zRh3tzZtJM+QSn2tQjTQ3XgTobQnCtQJgfkuVLmFj0VY+aSv3wGosP2 +LJpNWJ2ijKf4eKfle6WpAAAAGGJnaXQtdGVzdHN1aXRlLWRldmVsb3BlcgECAwQF +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/developer.pub b/testsuite/sshkeys/developer.pub new file mode 100644 index 0000000..3f0aebc --- /dev/null +++ b/testsuite/sshkeys/developer.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfkuVLmFj0VY+aSv3wGosP2LJpNWJ2ijKf4eKfle6Wp bgit-testsuite-developer diff --git a/testsuite/sshkeys/ecdsa_owner b/testsuite/sshkeys/ecdsa_owner new file mode 100644 index 0000000..2eff7da --- /dev/null +++ b/testsuite/sshkeys/ecdsa_owner @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSGwLYxWggAehWvG5Z0iquQBlCkWnv6 +mjFy+dK+rMcWNIir4DIsDQY5R7hivdqdZA37SNajoqTyawwJwNVIxRkNAAAAuIFXKAqBVy +gKAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIbAtjFaCAB6Fa8b +lnSKq5AGUKRae/qaMXL50r6sxxY0iKvgMiwNBjlHuGK92p1kDftI1qOipPJrDAnA1UjFGQ +0AAAAhAJlRPyByzdqxiUqLJuG5ZFk6ZK7siL4cxreqmnoYZMmHAAAAGmJnaXQtdGVzdHN1 +aXRlLWVjZHNhX293bmVyAQIDBAU= +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/ecdsa_owner.pub b/testsuite/sshkeys/ecdsa_owner.pub new file mode 100644 index 0000000..cc0cdfe --- /dev/null +++ b/testsuite/sshkeys/ecdsa_owner.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIbAtjFaCAB6Fa8blnSKq5AGUKRae/qaMXL50r6sxxY0iKvgMiwNBjlHuGK92p1kDftI1qOipPJrDAnA1UjFGQ0= bgit-testsuite-ecdsa_owner diff --git a/testsuite/sshkeys/maintainer b/testsuite/sshkeys/maintainer new file mode 100644 index 0000000..ed0beb5 --- /dev/null +++ b/testsuite/sshkeys/maintainer @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACCzq5ZU79gbaD6GeQqcGqZ+3TJQr/NLz9n9IvBhfxVY3wAAAKCiV/9Wolf/ +VgAAAAtzc2gtZWQyNTUxOQAAACCzq5ZU79gbaD6GeQqcGqZ+3TJQr/NLz9n9IvBhfxVY3w +AAAEDWu9fXA06Gvr+WJpR3cm/RYNxns+Zaid4gHvwr2SrwBbOrllTv2BtoPoZ5Cpwapn7d +MlCv80vP2f0i8GF/FVjfAAAAGWJnaXQtdGVzdHN1aXRlLW1haW50YWluZXIBAgME +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/maintainer.pub b/testsuite/sshkeys/maintainer.pub new file mode 100644 index 0000000..eff86ec --- /dev/null +++ b/testsuite/sshkeys/maintainer.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILOrllTv2BtoPoZ5Cpwapn7dMlCv80vP2f0i8GF/FVjf bgit-testsuite-maintainer diff --git a/testsuite/sshkeys/outsider b/testsuite/sshkeys/outsider new file mode 100644 index 0000000..9746cc7 --- /dev/null +++ b/testsuite/sshkeys/outsider @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAEi3xa43hNEaEnxsu9TIcnnIrJGdUFnZPIDo2wlldt5AAAAKBlmmfNZZpn +zQAAAAtzc2gtZWQyNTUxOQAAACAEi3xa43hNEaEnxsu9TIcnnIrJGdUFnZPIDo2wlldt5A +AAAECHNddXcQfY0vVX2BVv1QvvAZ79hkTGiCqb8zbwYBcFAwSLfFrjeE0RoSfGy71Mhyec +iskZ1QWdk8gOjbCWV23kAAAAF2JnaXQtdGVzdHN1aXRlLW91dHNpZGVyAQIDBAUG +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/outsider.pub b/testsuite/sshkeys/outsider.pub new file mode 100644 index 0000000..7e3dfef --- /dev/null +++ b/testsuite/sshkeys/outsider.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIASLfFrjeE0RoSfGy71MhyeciskZ1QWdk8gOjbCWV23k bgit-testsuite-outsider diff --git a/testsuite/sshkeys/owner b/testsuite/sshkeys/owner new file mode 100644 index 0000000..f0d0158 --- /dev/null +++ b/testsuite/sshkeys/owner @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACD9k6ppi/81E9PbJ4ybh7H8toOwJb1+s3bRI1jHoPMhpQAAAJjL6sLpy+rC +6QAAAAtzc2gtZWQyNTUxOQAAACD9k6ppi/81E9PbJ4ybh7H8toOwJb1+s3bRI1jHoPMhpQ +AAAEBLtQMZ5r06BZTgZT39nmpdRb9KK4QSaQfupy2OMMnpp/2TqmmL/zUT09snjJuHsfy2 +g7AlvX6zdtEjWMeg8yGlAAAAFGJnaXQtdGVzdHN1aXRlLW93bmVyAQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/owner.pub b/testsuite/sshkeys/owner.pub new file mode 100644 index 0000000..5bfb281 --- /dev/null +++ b/testsuite/sshkeys/owner.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP2TqmmL/zUT09snjJuHsfy2g7AlvX6zdtEjWMeg8yGl bgit-testsuite-owner diff --git a/testsuite/sshkeys/read b/testsuite/sshkeys/read new file mode 100644 index 0000000..460b485 --- /dev/null +++ b/testsuite/sshkeys/read @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAIb+Yr0s1MAi4fiB+vCn6rRkfONxsGQbHDZ88SBpcwpwAAAJhTQ7BBU0Ow +QQAAAAtzc2gtZWQyNTUxOQAAACAIb+Yr0s1MAi4fiB+vCn6rRkfONxsGQbHDZ88SBpcwpw +AAAEDvi5R9NXyRt+Xl/wnn2lAY9jYAgQJQUZBZy4pUPw6s/whv5ivSzUwCLh+IH68KfqtG +R843GwZBscNnzxIGlzCnAAAAE2JnaXQtdGVzdHN1aXRlLXJlYWQBAg== +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/read.pub b/testsuite/sshkeys/read.pub new file mode 100644 index 0000000..ffa349c --- /dev/null +++ b/testsuite/sshkeys/read.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAhv5ivSzUwCLh+IH68KfqtGR843GwZBscNnzxIGlzCn bgit-testsuite-read diff --git a/testsuite/sshkeys/rsa_owner b/testsuite/sshkeys/rsa_owner new file mode 100644 index 0000000..34d4f29 --- /dev/null +++ b/testsuite/sshkeys/rsa_owner @@ -0,0 +1,38 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAYEAxEFRBDfRu2/6rQa1+/lHydWBTIVF5qtZQ1+B9jSboJ4p/nhUZPIB +gZkk7XlbqSrwjURQ7qpDwmDY8bMzUCEkrcNteAAkpfYRGjxy8VeCzAwkfhEAOqg7oOhzAb +1fQftSVbna2YWuueNykuW0P2w6UtqG40dJBIT6eTg2UU+gb+sw4bCvZVKRlf3JzF4boSZz +CgDY/pxY2tVEIsTlbpWBbK4wfYUPCyxU5Y1gXrw+/5yilnfZzsyOqWImk3+JDRDdlf+jjm +RFPtqtbxq/YAe7TFPnnNfJb1Q/26aJcZqMb5F09j3RjnkqXjcQ6MOzYX8cY3V+tCscmDlm +ejWMbBuYDBg0iCav1IAQZSqI0m0GrwCfzXVzSRrg0HWdUbHKPmpD6fyEr2+wM9MvwQofBJ +jwZv1apSfOanySvTxaifUE/MNNJBcSOh2g9Oq3ScUECLCJEBmmRE0w7BdfwK+12BHxjtwf +t7nh4V3RSoZgp+0tTVENGdeQmxymsdn4lv3AWjXNAAAFkDQoDQo0KA0KAAAAB3NzaC1yc2 +EAAAGBAMRBUQQ30btv+q0Gtfv5R8nVgUyFRearWUNfgfY0m6CeKf54VGTyAYGZJO15W6kq +8I1EUO6qQ8Jg2PGzM1AhJK3DbXgAJKX2ERo8cvFXgswMJH4RADqoO6DocwG9X0H7UlW52t +mFrrnjcpLltD9sOlLahuNHSQSE+nk4NlFPoG/rMOGwr2VSkZX9ycxeG6EmcwoA2P6cWNrV +RCLE5W6VgWyuMH2FDwssVOWNYF68Pv+copZ32c7MjqliJpN/iQ0Q3ZX/o45kRT7arW8av2 +AHu0xT55zXyW9UP9umiXGajG+RdPY90Y55Kl43EOjDs2F/HGN1frQrHJg5Zno1jGwbmAwY +NIgmr9SAEGUqiNJtBq8An811c0ka4NB1nVGxyj5qQ+n8hK9vsDPTL8EKHwSY8Gb9WqUnzm +p8kr08Won1BPzDTSQXEjodoPTqt0nFBAiwiRAZpkRNMOwXX8CvtdgR8Y7cH7e54eFd0UqG +YKftLU1RDRnXkJscprHZ+Jb9wFo1zQAAAAMBAAEAAAGAaHsFcKNu6sTAxaDO/ahGibM6tM +w23IjYar/L5pE3URki7jCNbXhRSPeI60wyeis8CVkXZRgMHs2EcZifdsdOSZvDCaG54QjR +LhCEeOvH3G2Sd/MBFjk+FXnq0EBLGEt+F9lsI2XCEYB/HKlhfmpV2oowSYtH2joZRrOgZ0 +Vm+m5RhbWUivKcQyfraPuo5fAcSnUNEO+XdlkXfxMnuemqD3vkoM5XpfEh+Vt8tLKvL1Hq +VQTVVf0c7hwswVWiVuxkvKLVSRudi+78cUl/sS4Tbm9GftZpcVbFdEY0NlrFmhmsBzSjpY +Dqwcy4jWxdTkYb/vITqbC8B30kw/BFyyVyF2jkHHdBY+akyWGEt9F0LeJ62aXb17Tn+sjq +v/qE2maYnlCeBCHYAk8xHUv/esiW4+5Gk7eKfAP/xy2gARaiyytM0WGcvHXDH1J4rV+y/e +fOHnmAcQFkRkEwANK6f+zayW/5tI4N7ze718/1AMBgS32i2qc8iKI9GOhDTu1nztVBAAAA +wHggqUm2RBKVmjZStQECIxkdimzbD/nmSmvBIZJs4coV33iniDi26ZtYL6gv0qfhawuAE8 +mUQYjEi2qmTLFV9rTLKdeIWLivg3pd1tCn51qgu5vjnFq8zwdJli2xo1JAuYCEjo8osPal +GHwbQJ8ph+PjHLYw0lvSGIAkYHM4DeBw91l62CspeH/B70KZ/EmdPq+OH/AOuU7wv0ZjSr +lwkusQnEHpl2LeB8cECA3ucpkNxiMlPaiK6OzFBh8fARK0BAAAAMEA5LOhVpwep++7wz/S +DFkp8/tSqk6qLRcEq2QRBor1okc0u3iO6KDZX0RRoMaCT+o6XUbC9fnYEAbT/XCKJ+9hKw +CxZ69fWzsPxzkkQ8ESo5LzulyhLBkijTbjZ8Qk5WPI9sWLQxXHxpsjnYCtikLjp+TdP2t6 +v/x6g2qcyyrf5RuIWO2iiTGvua/nww7PiG+iIuT3mpm2OX+kHIsb5k30m1YC/w+Rveq0Nj +A28TKH17yaigBDj3mbez44wG4KtMXJAAAAwQDbrjoZfJYAyq+xH7M9TgD4w7rHJozWJNJF +fxaE7Nb3yxQ+OpfKEpoz4jiNQvMmxq/UNvYe3czUNC8/yeIAPAZMvPkOr1upecR+nDhcrH +tKnOAQ636PDZLE524NwSzrpOpah7oJqjiyMiNDNh1bV7mgDNWbZm8y8S3oCqfYa+8qEkcZ +kMoOrLKMPKyhhQcKrI4vyaNrGZ7UTe6bNyP0f/N0YHLUG9U2w5TyFln1f08SAzlm3l2OMS +QMlbOqAPajgeUAAAAYYmdpdC10ZXN0c3VpdGUtcnNhX293bmVyAQID +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/rsa_owner.pub b/testsuite/sshkeys/rsa_owner.pub new file mode 100644 index 0000000..f4343e2 --- /dev/null +++ b/testsuite/sshkeys/rsa_owner.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDEQVEEN9G7b/qtBrX7+UfJ1YFMhUXmq1lDX4H2NJugnin+eFRk8gGBmSTteVupKvCNRFDuqkPCYNjxszNQISStw214ACSl9hEaPHLxV4LMDCR+EQA6qDug6HMBvV9B+1JVudrZha6543KS5bQ/bDpS2objR0kEhPp5ODZRT6Bv6zDhsK9lUpGV/cnMXhuhJnMKANj+nFja1UQixOVulYFsrjB9hQ8LLFTljWBevD7/nKKWd9nOzI6pYiaTf4kNEN2V/6OOZEU+2q1vGr9gB7tMU+ec18lvVD/bpolxmoxvkXT2PdGOeSpeNxDow7NhfxxjdX60KxyYOWZ6NYxsG5gMGDSIJq/UgBBlKojSbQavAJ/NdXNJGuDQdZ1Rsco+akPp/ISvb7Az0y/BCh8EmPBm/VqlJ85qfJK9PFqJ9QT8w00kFxI6HaD06rdJxQQIsIkQGaZETTDsF1/Ar7XYEfGO3B+3ueHhXdFKhmCn7S1NUQ0Z15CbHKax2fiW/cBaNc0= bgit-testsuite-rsa_owner diff --git a/testsuite/sshkeys/triage b/testsuite/sshkeys/triage new file mode 100644 index 0000000..1ed3a16 --- /dev/null +++ b/testsuite/sshkeys/triage @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACA9+bBAsCVJS3WSHtgoagQvrk1XR3RIB21Xnl/Yd7ZgVQAAAJihDDsloQw7 +JQAAAAtzc2gtZWQyNTUxOQAAACA9+bBAsCVJS3WSHtgoagQvrk1XR3RIB21Xnl/Yd7ZgVQ +AAAECRMmBUVK+99J8r6wNgkf2DoBb/FX5Gzeyz0y48INPR+j35sECwJUlLdZIe2ChqBC+u +TVdHdEgHbVeeX9h3tmBVAAAAFWJnaXQtdGVzdHN1aXRlLXRyaWFnZQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/testsuite/sshkeys/triage.pub b/testsuite/sshkeys/triage.pub new file mode 100644 index 0000000..09a9aa1 --- /dev/null +++ b/testsuite/sshkeys/triage.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID35sECwJUlLdZIe2ChqBC+uTVdHdEgHbVeeX9h3tmBV bgit-testsuite-triage diff --git a/web.go b/web.go index 489e301..8bcdd2b 100644 --- a/web.go +++ b/web.go @@ -1,9 +1,12 @@ package main import ( + "archive/zip" "bytes" "context" + "embed" "encoding/base64" + "encoding/json" "errors" "flag" "fmt" @@ -14,17 +17,30 @@ import ( "net" "net/http" "net/url" + "os" pathpkg "path" + "path/filepath" "sort" "strconv" "strings" + "sync" "time" "unicode/utf8" + + "golang.org/x/crypto/ssh" ) +//go:embed www/* +var webAssets embed.FS + const ( - defaultWebAddr = "127.0.0.1" - defaultWebPort = 8042 + defaultWebAddr = "127.0.0.1" + defaultWebPort = 8042 + webPageTemplatePath = "www/page.html" + webCSSPath = "www/app.css" + webJSPath = "www/app.js" + webLogoPath = "www/bgit-mark.png" + webFaviconPath = "www/favicon.ico" ) type webOptions struct { @@ -34,9 +50,12 @@ type webOptions struct { } type webServer struct { - repo *nativeGitRepo - cfg config - title string + repo *nativeGitRepo + apiRepo *nativeGitRepo + cfg config + title string + events *webEventHub + localGitDir string } type brokerGitStore struct { @@ -60,6 +79,12 @@ type webTreeFile struct { hash string } +type webFileIndexEntry struct { + Path string `json:"path"` + URL string `json:"url"` + Kind string `json:"kind"` +} + type webChangedFile struct { path string oldHash string @@ -67,9 +92,15 @@ type webChangedFile struct { additions int deletions int diff []webDiffLine + visual []webVisualDiffRow binary bool } +type webDiffRenderOptions struct { + Review bool + PRID int +} + type webRefOption struct { name string fullName string @@ -81,18 +112,95 @@ type webDiffLine struct { text string } +type webAPIRef struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Kind string `json:"kind"` +} + +type webAPICommit struct { + Hash string `json:"hash"` + ShortHash string `json:"short_hash"` + Subject string `json:"subject"` + Body string `json:"body,omitempty"` + Author string `json:"author"` + Email string `json:"email"` + Timestamp int64 `json:"timestamp"` + Parents []string `json:"parents,omitempty"` + Tree string `json:"tree,omitempty"` +} + +type webAPITreeEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Kind string `json:"kind"` + Hash string `json:"hash"` + URL string `json:"url"` +} + +type webAPIState struct { + Branch string `json:"branch"` + LocalHead string `json:"local_head,omitempty"` + RemoteHead string `json:"remote_head,omitempty"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Dirty bool `json:"dirty"` + DirtyFiles []string `json:"dirty_files"` + StagedFiles []string `json:"staged_files"` + UnstagedFiles []string `json:"unstaged_files"` + UntrackedFiles []string `json:"untracked_files"` + UnpushedFiles []string `json:"unpushed_files"` + UnpulledFiles []string `json:"unpulled_files"` + UnpushedCommits []webAPICommit `json:"unpushed_commits"` + UnpulledCommits []webAPICommit `json:"unpulled_commits"` + FetchError string `json:"fetch_error,omitempty"` +} + +type webPullRequestCache struct { + UpdatedAt int64 `json:"updated_at"` + PRs []brokerPullRequest `json:"prs"` +} + +type brokerIssue struct { + ID int `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Status string `json:"status,omitempty"` + Author string `json:"author,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Comments []brokerIssueReply `json:"comments,omitempty"` +} + +type brokerIssueReply struct { + User string `json:"user,omitempty"` + Body string `json:"body,omitempty"` + At string `json:"at,omitempty"` +} + +type brokerIssueRequest struct { + Repo brokerRepo `json:"repo"` + ID int `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Comment string `json:"comment,omitempty"` +} + func webCommand(ctx context.Context, cfg config, args []string, stdout io.Writer) error { opts, err := parseWebArgs(args) if err != nil { return err } - repo, closeStore, cfg, err := openWebRepository(ctx, cfg, opts.local) + repo, apiRepo, closeStore, cfg, err := openWebRepository(ctx, cfg, opts.local) if err != nil { return err } defer closeStore() - handler := newWebHandler(repo, cfg) + handler := newWebHandlerWithAPI(repo, apiRepo, cfg) + liveCtx, cancelLive := context.WithCancel(ctx) + defer cancelLive() + handler.startLiveMonitors(liveCtx) ln, err := listenWeb(opts.addr, opts.port) if err != nil { return err @@ -153,11 +261,11 @@ func parseWebArgs(args []string) (webOptions, error) { return opts, nil } -func openWebRepository(ctx context.Context, cfg config, local bool) (*nativeGitRepo, func(), config, error) { +func openWebRepository(ctx context.Context, cfg config, local bool) (*nativeGitRepo, *nativeGitRepo, func(), config, error) { if local { localRepo, err := openLocalRepository(".") if err != nil { - return nil, nil, cfg, err + return nil, nil, nil, cfg, err } if localCfg, err := readLocalConfig(localRepo.worktree); err == nil { cfg = mergeConfig(cfg, localCfg) @@ -165,43 +273,44 @@ func openWebRepository(ctx context.Context, cfg config, local bool) (*nativeGitR if branch := localRepo.currentBranch(); branch != "" { cfg.branch = branch } - return newNativeGitRepoForStore(cfg, localRepo.store), func() {}, cfg, nil + repo := newNativeGitRepoForStore(cfg, localRepo.store) + return repo, repo, func() {}, cfg, nil } - if cfg.bucket == "" { + var seedRepo *nativeGitRepo + if localRepo, err := openLocalRepository("."); err == nil { + if localCfg, err := readLocalConfig(localRepo.worktree); err == nil { + cfg = mergeConfig(cfg, localCfg) + } + if branch := localRepo.currentBranch(); branch != "" { + cfg.branch = branch + } + seedRepo = newNativeGitRepoForStore(cfg, localRepo.store) + } + if cfg.bucket == "" && cfg.brokerURL == "" { localCfg, err := readLocalConfig(".") if err == nil { cfg = mergeConfig(cfg, localCfg) } } - if cfg.bucket == "" { - return nil, nil, cfg, errors.New("--bucket is required outside a bucketgit checkout") + if cfg.bucket == "" && cfg.brokerURL == "" { + return nil, nil, nil, cfg, errors.New("--bucket is required outside a bucketgit checkout") } - brokerURL := webBrokerURL() store, closeStore, err := newRemoteStore(ctx, cfg, true) if err != nil { - if brokerURL == "" { - return nil, nil, cfg, fmt.Errorf("create remote store: %w", err) - } - return openNativeGitRepo(&brokerGitStore{brokerURL: brokerURL, cfg: cfg}, cfg), func() {}, cfg, nil - } - if brokerURL != "" { - store = &fallbackGitRemoteStore{ - primary: store, - fallback: &brokerGitStore{brokerURL: brokerURL, cfg: cfg}, - } + return nil, nil, nil, cfg, fmt.Errorf("create remote store: %w", err) } - return openNativeGitRepo(store, cfg), closeStore, cfg, nil -} - -func webBrokerURL() string { - if out, err := runGit(".", "config", "--get", "bucketgit.broker"); err == nil { - return strings.TrimSpace(string(out)) + remoteRepo := openNativeGitRepo(store, cfg) + if seedRepo == nil { + seedRepo = remoteRepo } - return "" + return seedRepo, remoteRepo, closeStore, cfg, nil } func (s *brokerGitStore) read(ctx context.Context, objectPath string) ([]byte, error) { + if data, ok, err := s.readWithCapability(ctx, objectPath); ok || err != nil { + return data, err + } var resp brokerObjectResponse err := brokerPostContext(ctx, s.brokerURL, "/objects/read", brokerObjectRequest{ Repo: repoForBroker(s.cfg), @@ -244,11 +353,28 @@ func isBrokerNotFoundError(err error) bool { strings.Contains(message, "not found") } -func newWebHandler(repo *nativeGitRepo, cfg config) http.Handler { - return &webServer{repo: repo, cfg: cfg, title: webRepoTitle(cfg)} +func newWebHandler(repo *nativeGitRepo, cfg config) *webServer { + return newWebHandlerWithAPI(repo, repo, cfg) +} + +func newWebHandlerWithAPI(repo, apiRepo *nativeGitRepo, cfg config) *webServer { + if apiRepo == nil { + apiRepo = repo + } + localGitDir := "" + if repo != nil { + if store, ok := repo.store.(*localGitStore); ok { + localGitDir = store.root + } + } + return &webServer{repo: repo, apiRepo: apiRepo, cfg: cfg, title: webRepoTitle(cfg), events: newWebEventHub(), localGitDir: localGitDir} } func webRepoTitle(cfg config) string { + logicalRepo := strings.Trim(cfg.logicalRepo, "/") + if logicalRepo != "" { + return logicalRepo + } if cfg.origin != "" { return cfg.origin } @@ -259,30 +385,195 @@ func webRepoTitle(cfg config) string { } func (s *webServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } ctx := r.Context() route := strings.TrimPrefix(r.URL.Path, "/") + srv := s.serverForRequest(r, strings.HasPrefix(route, "api/")) switch { + case r.Method != http.MethodGet && r.Method != http.MethodHead && !strings.HasPrefix(route, "api/actions/"): + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + case route == "assets/bgit-mark.png": + s.handleWebAsset(w, webLogoPath) + case route == "favicon.ico": + s.handleWebAsset(w, webFaviconPath) + case route == "events": + s.handleEvents(w, r) + case route == "api/state": + s.handleAPIState(ctx, w, r) + case route == "api/me": + s.handleAPIMe(ctx, w, r) + case route == "api/actions/commit": + s.handleAPIActionCommit(ctx, w, r) + case route == "api/actions/stage": + s.handleAPIActionStage(ctx, w, r) + case route == "api/actions/unstage": + s.handleAPIActionUnstage(ctx, w, r) + case route == "api/actions/discard": + s.handleAPIActionDiscard(ctx, w, r) + case route == "api/actions/uncommit": + s.handleAPIActionUncommit(ctx, w, r) + case route == "api/actions/push": + s.handleAPIActionPush(ctx, w, r) + case route == "api/actions/pull": + s.handleAPIActionPull(ctx, w, r) + case route == "api/actions/pr": + s.handleAPIActionPullRequest(ctx, w, r) + case route == "api/actions/issues": + s.handleAPIActionIssue(ctx, w, r) + case route == "api/actions/settings": + s.handleAPIActionSettings(ctx, w, r) + case route == "api/diff": + s.handleAPIDiff(ctx, w, r) + case route == "api/refs": + srv.handleAPIRefs(ctx, w, r) + case route == "api/tree": + srv.handleAPITree(ctx, w, r) + case route == "api/commits": + srv.handleAPICommits(ctx, w, r) + case route == "api/prs": + s.handleAPIPullRequests(ctx, w, r) + case route == "api/issues": + s.handleAPIIssues(ctx, w, r) + case route == "api/settings": + s.handleAPISettings(ctx, w, r) + case route == "api/blob": + srv.handleAPIBlob(ctx, w, r) + case strings.HasPrefix(route, "api/commit/"): + srv.handleAPICommit(ctx, w, r, strings.TrimPrefix(route, "api/commit/")) case r.URL.Path == "/": - s.handleTree(ctx, w, r, "") + srv.handleTree(ctx, w, r, "") case route == "commits": - s.handleCommits(ctx, w, r) + srv.handleCommits(ctx, w, r) + case route == "prs": + s.handlePullRequests(ctx, w, r) + case strings.HasPrefix(route, "prs/"): + s.handlePullRequest(ctx, w, r, strings.TrimPrefix(route, "prs/")) + case route == "issues": + s.handleIssues(ctx, w, r) + case strings.HasPrefix(route, "issues/"): + s.handleIssue(ctx, w, r, strings.TrimPrefix(route, "issues/")) + case route == "settings": + s.handleSettings(ctx, w, r) + case route == "archive.zip": + srv.handleArchiveZip(ctx, w, r) case strings.HasPrefix(route, "commit/"): - s.handleCommit(ctx, w, r, strings.TrimPrefix(route, "commit/")) + srv.handleCommit(ctx, w, r, strings.TrimPrefix(route, "commit/")) case strings.HasPrefix(route, "tree/"): - s.handleTree(ctx, w, r, strings.TrimPrefix(route, "tree/")) + srv.handleTree(ctx, w, r, strings.TrimPrefix(route, "tree/")) case strings.HasPrefix(route, "blob/"): - s.handleBlob(ctx, w, r, strings.TrimPrefix(route, "blob/"), false) + srv.handleBlob(ctx, w, r, strings.TrimPrefix(route, "blob/"), false) case strings.HasPrefix(route, "raw/"): - s.handleBlob(ctx, w, r, strings.TrimPrefix(route, "raw/"), true) + srv.handleBlob(ctx, w, r, strings.TrimPrefix(route, "raw/"), true) default: http.NotFound(w, r) } } +func (s *webServer) handleWebAsset(w http.ResponseWriter, path string) { + data, err := webAssetBytes(path) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + if typ := mime.TypeByExtension(filepath.Ext(path)); typ != "" { + w.Header().Set("Content-Type", typ) + } + w.Header().Set("Cache-Control", "public, max-age=86400") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func (s *webServer) handleEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + client := s.events.subscribe() + defer s.events.unsubscribe(client) + fmt.Fprint(w, "event: ready\ndata: {}\n\n") + flusher.Flush() + for { + select { + case <-r.Context().Done(): + return + case event := <-client: + fmt.Fprint(w, event) + flusher.Flush() + } + } +} + +func (s *webServer) startLiveMonitors(ctx context.Context) { + if s.events == nil { + return + } + if s.cfg.brokerURL != "" && s.cfg.logicalRepo != "" { + go s.refreshWhoamiForWeb(ctx) + } + if repo, err := openLocalRepository("."); err == nil { + go monitorWebPath(ctx, repo.gitDir, "git", s.events) + } + if dir := webAssetDir(); dir != "" { + go monitorWebPath(ctx, dir, "assets", s.events) + } +} + +func (s *webServer) handleAPIMe(ctx context.Context, w http.ResponseWriter, r *http.Request) { + status, err := s.webWhoami(ctx, r.URL.Query().Get("refresh") == "1") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + s.renderJSON(w, status) +} + +func (s *webServer) cachedWhoamiJSON() string { + if s.cfg.brokerURL == "" { + return "null" + } + status, err := readWhoamiCache(s.cfg.brokerURL) + if err != nil { + return "null" + } + data, err := json.Marshal(status) + if err != nil { + return "null" + } + return string(data) +} + +func (s *webServer) webWhoami(ctx context.Context, refresh bool) (brokerAuthStatus, error) { + if s.cfg.brokerURL == "" || s.cfg.logicalRepo == "" { + return brokerAuthStatus{}, errors.New("whoami requires a broker-backed repository") + } + return brokerWhoami(ctx, s.cfg, refresh) +} + +func (s *webServer) refreshWhoamiForWeb(ctx context.Context) { + status, err := s.webWhoami(ctx, true) + if err != nil { + return + } + if s.events != nil { + s.events.broadcastJSON("whoami", status) + } +} + +func (s *webServer) serverForRequest(r *http.Request, api bool) *webServer { + if s.apiRepo == nil || s.apiRepo == s.repo { + return s + } + if api || r.URL.Query().Get("_remote") == "1" { + next := *s + next.repo = s.apiRepo + return &next + } + return s +} + func (s *webServer) headCommit(ctx context.Context, r *http.Request) (string, commitObject, string, error) { ref := strings.TrimSpace(r.URL.Query().Get("ref")) if ref == "" { @@ -299,34 +590,50 @@ func (s *webServer) headCommit(ctx context.Context, r *http.Request) (string, co return hash, commit, ref, nil } -func (s *webServer) handleTree(ctx context.Context, w http.ResponseWriter, r *http.Request, repoPath string) { +func (s *webServer) handleAPIRefs(ctx context.Context, w http.ResponseWriter, r *http.Request) { + options, err := s.refOptions(ctx) + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + refs := make([]webAPIRef, 0, len(options)) + for _, option := range options { + refs = append(refs, webAPIRef{Name: option.name, FullName: option.fullName, Kind: option.kind}) + } + s.renderJSON(w, map[string]any{ + "refs": refs, + "default_ref": branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)), + }) +} + +func (s *webServer) handleAPITree(ctx context.Context, w http.ResponseWriter, r *http.Request) { _, commit, ref, err := s.headCommit(ctx, r) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } - repoPath = cleanWebPath(repoPath) + repoPath := cleanWebPath(r.URL.Query().Get("path")) treeHash := commit.tree - if repoPath != "" { + if repoPath != "" && repoPath != "commits" && repoPath != "prs" { hash, err := s.repo.findPath(ctx, commit.tree, repoPath) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } obj, err := s.repo.object(ctx, hash) if err != nil { - s.renderError(w, http.StatusInternalServerError, err) + s.renderJSONError(w, http.StatusInternalServerError, err) return } if obj.typ == gitObjectBlob { - http.Redirect(w, r, webURL("blob", repoPath, ref), http.StatusFound) + s.renderJSONError(w, http.StatusBadRequest, errors.New("path is a blob")) return } treeHash = hash } entries, err := s.repo.treeEntries(ctx, treeHash) if err != nil { - s.renderError(w, http.StatusInternalServerError, err) + s.renderJSONError(w, http.StatusInternalServerError, err) return } sort.SliceStable(entries, func(i, j int) bool { @@ -335,68 +642,247 @@ func (s *webServer) handleTree(ctx context.Context, w http.ResponseWriter, r *ht } return entries[i].name < entries[j].name }) - commits, _ := s.repo.walkCommits(ctx, commit.hash, 10, 0, repoPath) - readme := s.readmeHTML(ctx, commit.tree) - - var body strings.Builder - body.WriteString(`
`) - body.WriteString(s.headerHTML(ref, repoPath)) - body.WriteString(s.clonePanelHTML()) - body.WriteString(`
` + html.EscapeString(firstNonEmpty(commit.subject, shortHash(commit.hash))) + `
` + html.EscapeString(commit.author) + ` committed ` + html.EscapeString(relativeTime(commit.timestamp)) + `
` + html.EscapeString(shortHash(commit.hash)) + `
`) - body.WriteString(`
Files
`) - if repoPath != "" { - parent := pathpkg.Dir(repoPath) - if parent == "." { - parent = "" - } - body.WriteString(``) - } + apiEntries := make([]webAPITreeEntry, 0, len(entries)) for _, entry := range entries { - targetPath := pathpkg.Join(repoPath, entry.name) kind := "file" route := "blob" - name := entry.name if entry.typ == gitObjectTree { kind = "dir" route = "tree" - name += "/" } - body.WriteString(``) - } - body.WriteString(`
dir..
` + kind + `` + html.EscapeString(name) + `` + html.EscapeString(shortHash(entry.hash)) + `
`) - if readme != "" && repoPath == "" { - body.WriteString(`
README
` + readme + `
`) + targetPath := pathpkg.Join(repoPath, entry.name) + apiEntries = append(apiEntries, webAPITreeEntry{ + Name: entry.name, + Path: targetPath, + Kind: kind, + Hash: entry.hash, + URL: webURL(route, targetPath, ref), + }) } - body.WriteString(`
Recent commits
`) - body.WriteString(commitListHTML(commits, ref, true)) - body.WriteString(`
`) - s.renderPage(w, webPageTitle(s.title, repoPath), body.String()) + commits, _ := s.repo.walkCommits(ctx, commit.hash, 10, 0, repoPath) + s.renderJSON(w, map[string]any{ + "ref": ref, + "path": repoPath, + "commit": webAPICommitFromCommit(commit), + "entries": apiEntries, + "recent_commits": webAPICommitsFromCommits(commits), + }) } -func (s *webServer) handleCommits(ctx context.Context, w http.ResponseWriter, r *http.Request) { +func (s *webServer) handleAPICommits(ctx context.Context, w http.ResponseWriter, r *http.Request) { _, commit, ref, err := s.headCommit(ctx, r) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } - commits, err := s.repo.walkCommits(ctx, commit.hash, 100, 0, "") + repoPath := cleanWebPath(r.URL.Query().Get("path")) + commits, err := s.repo.walkCommits(ctx, commit.hash, 100, 0, repoPath) if err != nil { - s.renderError(w, http.StatusInternalServerError, err) + s.renderJSONError(w, http.StatusInternalServerError, err) return } - var body strings.Builder - body.WriteString(`
`) - body.WriteString(s.headerHTML(ref, "commits")) - body.WriteString(`
Commits
`) - body.WriteString(commitListHTML(commits, ref, false)) - body.WriteString(`
`) - s.renderPage(w, webPageTitle(s.title, "commits"), body.String()) + s.renderJSON(w, map[string]any{"ref": ref, "path": repoPath, "head": webAPICommitFromCommit(commit), "commits": webAPICommitsFromCommits(commits)}) } -func (s *webServer) handleCommit(ctx context.Context, w http.ResponseWriter, r *http.Request, hash string) { +func (s *webServer) handleAPIPullRequests(ctx context.Context, w http.ResponseWriter, r *http.Request) { + refresh := r.URL.Query().Get("refresh") == "1" + prs := []brokerPullRequest{} + source := "cache" + stale := false + if !refresh { + if cached, err := s.readPullRequestCache(); err == nil { + s.renderJSON(w, map[string]any{ + "prs": webAPIPullRequests(cached.PRs), + "source": source, + "stale": true, + }) + return + } + } + refreshed, err := s.refreshPullRequestCache(ctx) + if err != nil { + cached, cacheErr := s.readPullRequestCache() + if cacheErr != nil { + s.renderJSONError(w, http.StatusForbidden, err) + return + } + prs = cached.PRs + stale = true + } else { + prs = refreshed + source = "broker" + } + s.renderJSON(w, map[string]any{ + "prs": webAPIPullRequests(prs), + "source": source, + "stale": stale, + }) +} + +type webSettingsInfo struct { + Repo brokerRepo `json:"repo"` + Title string `json:"title"` + BrokerURL string `json:"broker_url,omitempty"` + Provider string `json:"provider,omitempty"` + Region string `json:"region,omitempty"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + Visibility string `json:"visibility,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + IssuesEnabled bool `json:"issues_enabled"` + Keys []brokerKey `json:"keys,omitempty"` + Protections []brokerProtectionRequest `json:"protections,omitempty"` + Errors map[string]string `json:"errors,omitempty"` +} + +type brokerRepoInfoRequest struct { + Repo brokerRepo `json:"repo"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + Visibility string `json:"visibility,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + IssuesEnabled bool `json:"issues_enabled"` + Logical string `json:"logical,omitempty"` +} + +type brokerRepoInfoResponse struct { + Repo brokerRepo `json:"repo"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Visibility string `json:"visibility"` + ReadOnly bool `json:"read_only"` + IssuesEnabled bool `json:"issues_enabled"` +} + +func (s *webServer) handleAPISettings(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.renderJSON(w, s.settingsInfo(ctx)) +} + +func (s *webServer) handleAPIIssues(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + issues, err := s.listIssues(ctx) + if err != nil { + s.renderJSONError(w, http.StatusForbidden, err) + return + } + s.renderJSON(w, map[string]any{"issues": issues}) +} + +func (s *webServer) listIssues(ctx context.Context) ([]brokerIssue, error) { + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + return nil, errors.New("broker issues unavailable") + } + var resp struct { + Issues []brokerIssue `json:"issues"` + } + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/issues/list", brokerIssueRequest{Repo: repoForBroker(s.cfg)}, &resp); err != nil { + return nil, err + } + return resp.Issues, nil +} + +func (s *webServer) getIssue(ctx context.Context, id int) (brokerIssue, error) { + var resp struct { + Issue brokerIssue `json:"issue"` + } + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/issues/view", brokerIssueRequest{Repo: repoForBroker(s.cfg), ID: id}, &resp); err != nil { + return brokerIssue{}, err + } + return resp.Issue, nil +} + +func (s *webServer) settingsInfo(ctx context.Context) webSettingsInfo { + info := webSettingsInfo{ + Repo: repoForBroker(s.cfg), + Title: s.title, + BrokerURL: s.cfg.brokerURL, + Provider: s.cfg.provider, + Region: firstNonEmpty(s.cfg.region, globalConfigRegionForBrokerURL(s.cfg.brokerURL)), + DefaultBranch: defaultBranch, + Visibility: "private", + IssuesEnabled: true, + Errors: map[string]string{}, + } + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + return info + } + var repoInfo brokerRepoInfoResponse + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/repo/info", brokerRepoInfoRequest{Repo: repoForBroker(s.cfg)}, &repoInfo); err != nil { + info.Errors["repo"] = err.Error() + } else { + if repoInfo.Repo.Logical != "" || repoInfo.Repo.Bucket != "" { + info.Repo = repoInfo.Repo + } + info.Description = repoInfo.Description + info.DefaultBranch = firstNonEmpty(repoInfo.DefaultBranch, defaultBranch) + info.Visibility = firstNonEmpty(repoInfo.Visibility, "private") + info.ReadOnly = repoInfo.ReadOnly + info.IssuesEnabled = repoInfo.IssuesEnabled + } + if keys, err := brokerListKeys(s.cfg.brokerURL, s.cfg); err != nil { + info.Errors["members"] = err.Error() + } else { + info.Keys = keys + } + var protections struct { + Protections []brokerProtectionRequest `json:"protections"` + } + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/protection/list", brokerProtectionRequest{Repo: repoForBroker(s.cfg)}, &protections); err != nil { + info.Errors["protections"] = err.Error() + } else { + info.Protections = protections.Protections + } + if len(info.Errors) == 0 { + info.Errors = nil + } + return info +} + +func globalConfigRegionForBrokerURL(brokerURL string) string { + want := normalizeBrokerURLForCompare(brokerURL) + if want == "" { + return "" + } + path, err := defaultGlobalConfigPath() + if err != nil { + return "" + } + global, err := readGlobalConfig(path) + if err != nil { + return "" + } + for _, profile := range global.GCPProfiles { + for _, region := range profile.Regions { + if normalizeBrokerURLForCompare(region.BrokerURL) == want { + return region.Name + } + } + } + for _, profile := range global.AWSProfiles { + for _, region := range profile.Regions { + if normalizeBrokerURLForCompare(region.BrokerURL) == want { + return region.Name + } + } + } + return "" +} + +func normalizeBrokerURLForCompare(value string) string { + return strings.TrimRight(strings.TrimSpace(value), "/") +} + +func (s *webServer) handleAPICommit(ctx context.Context, w http.ResponseWriter, r *http.Request, hash string) { hash = strings.TrimSpace(strings.Trim(hash, "/")) if hash == "" { - s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + s.renderJSONError(w, http.StatusNotFound, fs.ErrNotExist) return } commitHash, err := s.repo.resolveRevision(ctx, hash) @@ -405,101 +891,1813 @@ func (s *webServer) handleCommit(ctx context.Context, w http.ResponseWriter, r * } commit, err := s.repo.commit(ctx, commitHash) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } - ref := strings.TrimSpace(r.URL.Query().Get("ref")) files, additions, deletions, err := s.changedFiles(ctx, commit) if err != nil { - s.renderError(w, http.StatusInternalServerError, err) + s.renderJSONError(w, http.StatusInternalServerError, err) return } - var body strings.Builder - body.WriteString(`
`) - body.WriteString(s.headerHTML(firstNonEmpty(ref, commit.hash), "commit/"+shortHash(commit.hash))) - body.WriteString(`
`) - body.WriteString(`

` + html.EscapeString(firstNonEmpty(commit.subject, shortHash(commit.hash))) + `

`) - if commit.body != "" { - body.WriteString(`
` + html.EscapeString(commit.body) + `
`) - } - body.WriteString(`
`) - body.WriteString(`
` + strconv.Itoa(len(files)) + ` changed file` + pluralSuffix(len(files)) + `+` + strconv.Itoa(additions) + `-` + strconv.Itoa(deletions) + `
`) - body.WriteString(``) - for _, file := range files { - body.WriteString(``) - } - body.WriteString(`
` + html.EscapeString(file.path) + `+` + strconv.Itoa(file.additions) + ` -` + strconv.Itoa(file.deletions) + `
`) - for _, file := range files { - body.WriteString(diffFileHTML(file)) - } - body.WriteString(`
`) - s.renderPage(w, webPageTitle(s.title, shortHash(commit.hash)), body.String()) + s.renderJSON(w, map[string]any{ + "commit": webAPICommitFromCommit(commit), + "files": webAPIChangedFiles(files), + "additions": additions, + "deletions": deletions, + }) } -func (s *webServer) handleBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, repoPath string, raw bool) { +func (s *webServer) handleAPIBlob(ctx context.Context, w http.ResponseWriter, r *http.Request) { _, commit, ref, err := s.headCommit(ctx, r) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } - repoPath = cleanWebPath(repoPath) + repoPath := cleanWebPath(r.URL.Query().Get("path")) if repoPath == "" { - s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + s.renderJSONError(w, http.StatusNotFound, fs.ErrNotExist) return } hash, err := s.repo.findPath(ctx, commit.tree, repoPath) if err != nil { - s.renderError(w, http.StatusNotFound, err) + s.renderJSONError(w, http.StatusNotFound, err) return } obj, err := s.repo.object(ctx, hash) if err != nil { - s.renderError(w, http.StatusInternalServerError, err) + s.renderJSONError(w, http.StatusInternalServerError, err) return } if obj.typ == gitObjectTree { - http.Redirect(w, r, webURL("tree", repoPath, ref), http.StatusFound) - return - } - if raw { - contentType := mime.TypeByExtension(pathpkg.Ext(repoPath)) - if contentType == "" { - contentType = http.DetectContentType(obj.data) - } - w.Header().Set("Content-Type", contentType) - w.Write(obj.data) + s.renderJSONError(w, http.StatusBadRequest, errors.New("path is a tree")) return } - var body strings.Builder - body.WriteString(`
`) - body.WriteString(s.headerHTML(ref, repoPath)) - body.WriteString(`
` + html.EscapeString(repoPath) + `
` + strconv.Itoa(len(obj.data)) + ` bytes
Raw
`) - body.WriteString(``) + content := "" + encoding := "base64" if isTextBlob(obj.data) { - body.WriteString(`
` + html.EscapeString(string(obj.data)) + `
`) + content = string(obj.data) + encoding = "utf-8" } else { - body.WriteString(`
Binary file. Use Raw to download the contents.
`) + content = base64.StdEncoding.EncodeToString(obj.data) } - body.WriteString(`
`) - s.renderPage(w, webPageTitle(s.title, repoPath), body.String()) + s.renderJSON(w, map[string]any{ + "ref": ref, + "path": repoPath, + "commit": webAPICommitFromCommit(commit), + "hash": hash, + "size": len(obj.data), + "text": isTextBlob(obj.data), + "encoding": encoding, + "content": content, + "raw_url": webURL("raw", repoPath, ref), + }) } -func (s *webServer) readmeHTML(ctx context.Context, treeHash string) string { - entries, err := s.repo.treeEntries(ctx, treeHash) +func (s *webServer) handleAPIState(ctx context.Context, w http.ResponseWriter, r *http.Request) { + state, err := s.webRepositoryState(ctx, true, r.URL.Query().Get("ref")) if err != nil { - return "" + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, state) +} + +func (s *webServer) handleAPIDiff(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if commit := strings.TrimSpace(r.URL.Query().Get("commit")); commit != "" { + files, additions, deletions, err := s.commitChangedFiles(ctx, commit) + if err == nil { + s.renderJSON(w, map[string]any{"commit": commit, "diff": changedFilesUnifiedDiff(files), "html": diffFilesPanelHTML(files, additions, deletions)}) + return + } + diffHTML, htmlErr := localCommitVisualDiffHTML(commit) + diff, diffErr := localCommitDiff(commit) + if diffErr != nil && htmlErr != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + resp := map[string]any{"commit": commit, "diff": diff} + if htmlErr == nil { + resp["html"] = diffHTML + } + s.renderJSON(w, resp) + return + } + if prID := strings.TrimSpace(r.URL.Query().Get("pr")); prID != "" { + id, err := strconv.Atoi(prID) + if err != nil || id <= 0 { + s.renderJSONError(w, http.StatusBadRequest, errors.New("invalid pull request id")) + return + } + diff, err := s.pullRequestUnifiedDiff(ctx, id) + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + resp := map[string]any{"pr": id, "diff": diff} + if pr, prErr := s.pullRequestByID(ctx, id); prErr == nil { + resp["html"] = s.pullRequestFilesHTML(ctx, pr) + } + s.renderJSON(w, resp) + return + } + repoPath := cleanWebPath(r.URL.Query().Get("path")) + if repoPath == "" || repoPath == "." { + s.renderJSONError(w, http.StatusBadRequest, errors.New("diff requires a path")) + return + } + mode := strings.TrimSpace(r.URL.Query().Get("mode")) + if mode == "" { + mode = "worktree" + } + diff, err := localFileDiff(repoPath, mode) + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + diffHTML, htmlErr := localFileVisualDiffHTML(repoPath, mode) + resp := map[string]any{ + "path": repoPath, + "mode": mode, + "diff": diff, + } + if htmlErr == nil { + resp["html"] = diffHTML + } + s.renderJSON(w, resp) + _ = ctx +} + +func (s *webServer) handleAPIActionCommit(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Message string `json:"message"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + req.Message = strings.TrimSpace(req.Message) + if req.Message == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("commit message is required")) + return + } + repo, err := openLocalRepository(".") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + var out bytes.Buffer + if err := repo.commit([]string{"-m", req.Message}, &out); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err := s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "output": strings.TrimSpace(out.String()), "state": state}) +} + +func (s *webServer) handleAPIActionStage(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + repoPath := cleanWebPath(req.Path) + if repoPath == "" || repoPath == "." { + s.renderJSONError(w, http.StatusBadRequest, errors.New("stage requires a path")) + return + } + repo, err := openLocalRepository(".") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + repoPath = canonicalWorktreePath(repo, repoPath) + if err := repo.add([]string{repoPath}); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err := s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "state": state}) +} + +func (s *webServer) handleAPIActionUnstage(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + repoPath := cleanWebPath(req.Path) + if repoPath == "" || repoPath == "." { + s.renderJSONError(w, http.StatusBadRequest, errors.New("unstage requires a path")) + return + } + repo, err := openLocalRepository(".") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + if err := repo.reset([]string{"--", repoPath}); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err := s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "state": state}) +} + +func (s *webServer) handleAPIActionDiscard(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + repoPath := cleanWebPath(req.Path) + if repoPath == "" || repoPath == "." { + s.renderJSONError(w, http.StatusBadRequest, errors.New("checkout requires a path")) + return + } + repo, err := openLocalRepository(".") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + repoPath = canonicalWorktreePath(repo, repoPath) + state, err := s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + source := firstNonEmpty(state.RemoteHead, state.LocalHead, "HEAD") + if err := repo.checkoutPaths(source, []string{repoPath}); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err = s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "state": state}) +} + +func (s *webServer) handleAPIActionUncommit(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + state, err := s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + if state.RemoteHead == "" || state.Ahead == 0 { + s.renderJSONError(w, http.StatusBadRequest, errors.New("no unpushed commits to uncommit")) + return + } + repo, err := openLocalRepository(".") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + if err := repo.reset([]string{"--soft", state.RemoteHead}); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err = s.webRepositoryState(ctx, false, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "state": state}) +} + +func canonicalWorktreePath(repo *localRepository, repoPath string) string { + files, err := repo.allWorktreeFiles() + if err != nil { + return repoPath + } + for _, file := range files { + if file == repoPath { + return file + } + } + for _, file := range files { + if strings.EqualFold(file, repoPath) { + return file + } + } + return repoPath +} + +func (s *webServer) handleAPIActionPush(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var out bytes.Buffer + if err := run([]string{"push"}, strings.NewReader("n\n"), &out, &out); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err := s.webRepositoryState(ctx, true, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "output": strings.TrimSpace(out.String()), "state": state}) +} + +func (s *webServer) handleAPIActionPull(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var out bytes.Buffer + if err := run([]string{"pull"}, strings.NewReader(""), &out, &out); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + state, err := s.webRepositoryState(ctx, true, "") + if err != nil { + s.renderJSONError(w, http.StatusInternalServerError, err) + return + } + s.renderJSON(w, map[string]any{"ok": true, "output": strings.TrimSpace(out.String()), "state": state}) +} + +func (s *webServer) handleAPIActionPullRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if strings.TrimSpace(s.cfg.brokerURL) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("pull request actions require a broker-backed repository")) + return + } + var req struct { + ID int `json:"id"` + Action string `json:"action"` + Comment string `json:"comment"` + DeleteBranch bool `json:"delete_branch"` + Comments []brokerPullRequestComment `json:"comments"` + TargetNoteID int `json:"target_note_id"` + TargetCommentID int `json:"target_comment_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + if req.ID <= 0 { + s.renderJSONError(w, http.StatusBadRequest, errors.New("pull request id is required")) + return + } + var resp struct { + PR brokerPullRequest `json:"pr"` + } + brokerReq := brokerPullRequestRequest{ + Repo: repoForBroker(s.cfg), + ID: req.ID, + Comment: strings.TrimSpace(req.Comment), + DeleteBranch: req.DeleteBranch, + Comments: req.Comments, + TargetNoteID: req.TargetNoteID, + TargetCommentID: req.TargetCommentID, + } + endpoint := "" + switch strings.TrimSpace(req.Action) { + case "comment": + if brokerReq.Comment == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("comment is required")) + return + } + endpoint = "/prs/comment" + case "approve": + endpoint = "/prs/review" + brokerReq.Review = "approved" + case "reject": + endpoint = "/prs/review" + brokerReq.Review = "changes_requested" + case "review-comment": + endpoint = "/prs/review" + brokerReq.Review = "commented" + case "reply": + if brokerReq.Comment == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("comment is required")) + return + } + endpoint = "/prs/reply" + case "merge": + endpoint = "/prs/merge" + brokerReq.Merge = true + case "close": + endpoint = "/prs/close" + case "reopen": + endpoint = "/prs/reopen" + default: + s.renderJSONError(w, http.StatusBadRequest, fmt.Errorf("unsupported pull request action %q", req.Action)) + return + } + if err := brokerPostContext(ctx, s.cfg.brokerURL, endpoint, brokerReq, &resp); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + prs := s.upsertPullRequestCache(resp.PR) + s.renderJSON(w, map[string]any{"ok": true, "pr": resp.PR, "prs": webAPIPullRequests(prs)}) +} + +func (s *webServer) handleAPIActionSettings(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("settings require a broker-backed repository")) + return + } + var req struct { + Action string `json:"action"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Visibility string `json:"visibility"` + ReadOnly bool `json:"read_only"` + IssuesEnabled bool `json:"issues_enabled"` + Logical string `json:"logical"` + User string `json:"user"` + Role string `json:"role"` + PublicKey string `json:"public_key"` + Key string `json:"key"` + Ref string `json:"ref"` + RequirePR bool `json:"require_pr"` + AllowOverrides bool `json:"allow_overrides"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + endpoint := "" + var payload any + switch strings.TrimSpace(req.Action) { + case "update-repo": + endpoint = "/repo/update" + payload = brokerRepoInfoRequest{ + Repo: repoForBroker(s.cfg), + Description: req.Description, + DefaultBranch: req.DefaultBranch, + Visibility: req.Visibility, + ReadOnly: req.ReadOnly, + IssuesEnabled: req.IssuesEnabled, + } + case "add-member": + user := strings.TrimSpace(req.User) + if user == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("user is required")) + return + } + role := normalizeBrokerRole(req.Role) + if !validBrokerRole(role) || role == "owner" { + s.renderJSONError(w, http.StatusBadRequest, fmt.Errorf("invalid role %q", req.Role)) + return + } + endpoint = "/keys/invite/create" + payload = brokerOwnerTransferRequest{Repo: repoForBroker(s.cfg), User: user, Role: role, BrokerURL: s.cfg.brokerURL} + case "remove-member": + endpoint = "/keys/remove" + payload = brokerKeyRequest{Repo: repoForBroker(s.cfg), Key: strings.TrimSpace(req.Key)} + case "suspend-member": + endpoint = "/keys/suspend" + payload = brokerKeyRequest{Repo: repoForBroker(s.cfg), Key: strings.TrimSpace(req.Key)} + case "unsuspend-member": + endpoint = "/keys/unsuspend" + payload = brokerKeyRequest{Repo: repoForBroker(s.cfg), Key: strings.TrimSpace(req.Key)} + case "transfer-owner": + endpoint = "/owners/transfer/confirm" + payload = brokerOwnerTransferRequest{Repo: repoForBroker(s.cfg), BrokerURL: s.cfg.brokerURL} + case "repo-rename": + endpoint = "/repo/rename" + payload = brokerRepoInfoRequest{Repo: repoForBroker(s.cfg), Logical: logicalRepoWithGit(req.Logical)} + case "repo-delete": + endpoint = "/repo/delete" + payload = brokerRepoInfoRequest{Repo: repoForBroker(s.cfg)} + case "protect-upsert": + ref := normalizeDestinationRef(firstNonEmpty(strings.TrimSpace(req.Ref), defaultBranch)) + endpoint = "/protection/upsert" + payload = brokerProtectionRequest{Repo: repoForBroker(s.cfg), Ref: ref, RequirePR: req.RequirePR, AllowOverrides: req.AllowOverrides} + case "protect-remove": + endpoint = "/protection/remove" + payload = brokerProtectionRequest{Repo: repoForBroker(s.cfg), Ref: normalizeDestinationRef(req.Ref)} + default: + s.renderJSONError(w, http.StatusBadRequest, fmt.Errorf("unsupported settings action %q", req.Action)) + return + } + if endpoint != "/repo/update" { + switch p := payload.(type) { + case brokerKeyRequest: + if strings.TrimSpace(p.Key) == "" && len(p.PublicKeys) == 0 { + s.renderJSONError(w, http.StatusBadRequest, errors.New("member key is required")) + return + } + case brokerOwnerTransferRequest: + if endpoint == "/keys/invite/create" && strings.TrimSpace(p.User) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("member invite requires user")) + return + } + case brokerProtectionRequest: + if strings.TrimSpace(p.Ref) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("branch protection ref is required")) + return + } + case brokerRepoInfoRequest: + if endpoint == "/repo/rename" && strings.TrimSpace(p.Logical) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("logical repo name is required")) + return + } + } + } + var brokerResp map[string]any + if err := brokerPostContext(ctx, s.cfg.brokerURL, endpoint, payload, &brokerResp); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + if endpoint == "/repo/rename" && strings.TrimSpace(req.Logical) != "" { + logical := logicalRepoWithGit(req.Logical) + _, _ = runGit(".", "config", "--local", "bucketgit.logicalRepo", logical) + _, _ = runGit(".", "remote", "set-url", "origin", "git@"+defaultSSHHost+":"+logical) + s.cfg.logicalRepo = logical + } + s.renderJSON(w, map[string]any{"ok": true, "settings": s.settingsInfo(ctx), "broker": brokerResp}) +} + +func (s *webServer) handleAPIActionIssue(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("issues require a broker-backed repository")) + return + } + var req struct { + Action string `json:"action"` + ID int `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + Comment string `json:"comment"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + endpoint := "" + payload := brokerIssueRequest{Repo: repoForBroker(s.cfg), ID: req.ID, Title: strings.TrimSpace(req.Title), Body: strings.TrimSpace(req.Body), Comment: strings.TrimSpace(req.Comment)} + switch strings.TrimSpace(req.Action) { + case "create": + endpoint = "/issues/create" + if payload.Title == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("issue title is required")) + return + } + case "comment": + endpoint = "/issues/comment" + if payload.ID <= 0 || payload.Comment == "" { + s.renderJSONError(w, http.StatusBadRequest, errors.New("issue comment requires an issue and comment")) + return + } + case "close": + endpoint = "/issues/close" + case "reopen": + endpoint = "/issues/reopen" + default: + s.renderJSONError(w, http.StatusBadRequest, fmt.Errorf("unsupported issue action %q", req.Action)) + return + } + var resp map[string]any + if err := brokerPostContext(ctx, s.cfg.brokerURL, endpoint, payload, &resp); err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + s.renderJSON(w, map[string]any{"ok": true}) +} + +func (s *webServer) webRepositoryState(ctx context.Context, refreshRemote bool, selectedRef string) (webAPIState, error) { + localRepo, err := openLocalRepository(".") + if err != nil { + return webAPIState{}, err + } + currentBranch := localRepo.currentBranch() + ref := normalizeWebRef(selectedRef) + if ref == "" { + ref = branchRef(firstNonEmpty(currentBranch, s.cfg.branch, defaultBranch)) + } + branch := shortRefName(ref) + state := webAPIState{Branch: branch} + isBranchRef := strings.HasPrefix(ref, "refs/heads/") + if refreshRemote && isBranchRef && (s.cfg.brokerURL != "" || s.cfg.bucket != "" || s.cfg.origin != "") { + if err := s.fetchWebRemoteTracking(ctx, ref); err != nil { + state.FetchError = err.Error() + } + } + if head, err := localRepo.resolveRevision(ref); err == nil { + state.LocalHead = head + } + if isBranchRef { + remoteRef := "refs/remotes/bucketgit/" + shortBranchName(ref) + if remoteHead, err := localRepo.resolveRevision(remoteRef); err == nil { + state.RemoteHead = remoteHead + } else if remoteHead, err := localRepo.resolveRevision("refs/remotes/origin/" + shortBranchName(ref)); err == nil { + state.RemoteHead = remoteHead + } + } + + if currentBranch != "" && ref == branchRef(currentBranch) { + status := localWorkingTreeStatus() + state.StagedFiles = status.staged + state.UnstagedFiles = status.unstaged + state.UntrackedFiles = status.untracked + state.DirtyFiles = append(state.DirtyFiles, state.StagedFiles...) + state.DirtyFiles = append(state.DirtyFiles, state.UnstagedFiles...) + state.DirtyFiles = append(state.DirtyFiles, state.UntrackedFiles...) + state.DirtyFiles = uniqueSortedStrings(state.DirtyFiles) + state.StagedFiles = uniqueSortedStrings(state.StagedFiles) + state.UnstagedFiles = uniqueSortedStrings(state.UnstagedFiles) + state.UntrackedFiles = uniqueSortedStrings(state.UntrackedFiles) + sort.Strings(state.DirtyFiles) + state.Dirty = len(state.DirtyFiles) > 0 + } + state.UnpushedFiles = localChangedFilesBetween(localRepo, state.RemoteHead, state.LocalHead) + state.UnpulledFiles = localChangedFilesBetween(localRepo, state.LocalHead, state.RemoteHead) + + if state.LocalHead != "" { + commits, err := localCommitRange(localRepo, state.RemoteHead, state.LocalHead, 25) + if err != nil { + return state, err + } + state.UnpushedCommits = webAPICommitsFromCommits(commits) + state.Ahead = len(commits) + } + if state.RemoteHead != "" { + commits, err := localCommitRange(localRepo, state.LocalHead, state.RemoteHead, 25) + if err != nil { + return state, err + } + state.UnpulledCommits = webAPICommitsFromCommits(commits) + state.Behind = len(commits) + } + return state, nil +} + +func (s *webServer) fetchWebRemoteTracking(ctx context.Context, branch string) error { + worktree, err := requireWorktree(".") + if err != nil { + return err + } + localCfg, err := readLocalConfig(worktree) + if err != nil { + return err + } + cfg := mergeConfig(localCfg, s.cfg) + cfg.branch = firstNonEmpty(branch, cfg.branch, defaultBranch) + store, closeStore, err := newRemoteStore(ctx, cfg, true) + if err != nil { + return err + } + defer closeStore() + repo := openNativeGitRepo(store, cfg) + return repo.fetchIntoWorktree(ctx, worktree, true, io.Discard) +} + +type webWorkingTreeStatus struct { + staged []string + unstaged []string + untracked []string +} + +func localWorkingTreeStatus() webWorkingTreeStatus { + out, err := runGit(".", "status", "--porcelain", "--untracked-files=all") + if err != nil { + return webWorkingTreeStatus{} + } + var status webWorkingTreeStatus + for _, line := range strings.Split(strings.TrimRight(string(out), "\r\n"), "\n") { + if len(line) < 4 { + continue + } + indexStatus := line[0] + worktreeStatus := line[1] + path := strings.TrimSpace(line[3:]) + if before, after, ok := strings.Cut(path, " -> "); ok && before != "" { + path = strings.TrimSpace(after) + } + path = strings.Trim(path, `"`) + if path == "" { + continue + } + if indexStatus == '?' && worktreeStatus == '?' { + status.untracked = append(status.untracked, path) + continue + } + if indexStatus != ' ' && indexStatus != '?' { + status.staged = append(status.staged, path) + } + if worktreeStatus != ' ' && worktreeStatus != '?' { + status.unstaged = append(status.unstaged, path) + } + } + return status +} + +func localFileDiff(repoPath, mode string) (string, error) { + repo, err := openLocalRepository(".") + if err != nil { + return "", err + } + repoPath = canonicalWorktreePath(repo, repoPath) + switch mode { + case "staged", "cached": + out, err := runGit(".", "diff", "--cached", "--", repoPath) + if err != nil { + return "", err + } + return string(out), nil + case "worktree", "unstaged", "": + out, err := runGit(".", "diff", "--", repoPath) + if err != nil { + return "", err + } + if len(out) > 0 { + return string(out), nil + } + status := localWorkingTreeStatus() + for _, path := range status.untracked { + if path == repoPath { + return localUntrackedFileDiff(repoPath) + } + } + return "", nil + default: + return "", fmt.Errorf("unsupported diff mode %q", mode) + } +} + +func (s *webServer) commitChangedFiles(ctx context.Context, hash string) ([]webChangedFile, int, int, error) { + commitHash, err := s.repo.resolveRevision(ctx, hash) + if err != nil { + commitHash = hash + } + commit, err := s.repo.commit(ctx, commitHash) + if err != nil { + return nil, 0, 0, err + } + return s.changedFiles(ctx, commit) +} + +func localFileVisualDiffHTML(repoPath, mode string) (string, error) { + repo, err := openLocalRepository(".") + if err != nil { + return "", err + } + repoPath = canonicalWorktreePath(repo, repoPath) + var oldData, newData []byte + switch mode { + case "staged", "cached": + oldData = gitBlobData("HEAD:" + repoPath) + newData = gitBlobData(":" + repoPath) + case "worktree", "unstaged", "": + oldData = gitBlobData(":" + repoPath) + if oldData == nil { + oldData = gitBlobData("HEAD:" + repoPath) + } + if data, err := os.ReadFile(repoPath); err == nil { + newData = data + } + default: + return "", fmt.Errorf("unsupported diff mode %q", mode) + } + file := webChangedFileFromData(repoPath, "", "", oldData, newData) + return diffFileHTML(file), nil +} + +func gitBlobData(revisionPath string) []byte { + out, err := runGit(".", "show", revisionPath) + if err != nil { + return nil + } + return out +} + +func localUntrackedFileDiff(repoPath string) (string, error) { + data, err := os.ReadFile(repoPath) + if err != nil { + return "", err + } + var b strings.Builder + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", repoPath, repoPath) + fmt.Fprintln(&b, "new file mode 100644") + fmt.Fprintln(&b, "index 0000000..0000000 100644") + fmt.Fprintln(&b, "--- /dev/null") + fmt.Fprintf(&b, "+++ b/%s\n", repoPath) + if !isTextBlob(data) { + fmt.Fprintln(&b, "Binary file changed") + return b.String(), nil + } + for _, line := range splitLines(string(data)) { + b.WriteString("+") + b.WriteString(line) + b.WriteString("\n") + } + return b.String(), nil +} + +func localCommitDiff(hash string) (string, error) { + repo, err := openLocalRepository(".") + if err != nil { + return "", err + } + hash, err = repo.resolveRevision(hash) + if err != nil { + return "", err + } + commit, err := repo.commitObject(hash) + if err != nil { + return "", err + } + if len(commit.parents) == 0 { + out, err := runGit(".", "show", "--format=", "--patch", hash) + if err != nil { + return "", err + } + return string(out), nil + } + out, err := runGit(".", "diff", commit.parents[0], hash) + if err != nil { + return "", err + } + return string(out), nil +} + +func localCommitVisualDiffHTML(hash string) (string, error) { + fullHash, err := gitOutputLine("rev-parse", hash) + if err != nil { + return "", err + } + parentLine, _ := gitOutputLine("show", "-s", "--format=%P", fullHash) + parent := "" + if fields := strings.Fields(parentLine); len(fields) > 0 { + parent = fields[0] + } + var namesOut []byte + if parent != "" { + namesOut, err = runGit(".", "diff", "--name-only", parent, fullHash) + } else { + namesOut, err = runGit(".", "show", "--format=", "--name-only", fullHash) + } + if err != nil { + return "", err + } + var files []webChangedFile + totalAdditions := 0 + totalDeletions := 0 + for _, name := range strings.Split(strings.TrimSpace(string(namesOut)), "\n") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + var oldData []byte + if parent != "" { + oldData = gitBlobData(parent + ":" + name) + } + newData := gitBlobData(fullHash + ":" + name) + file := webChangedFileFromData(name, "", "", oldData, newData) + totalAdditions += file.additions + totalDeletions += file.deletions + files = append(files, file) + } + return diffFilesPanelHTML(files, totalAdditions, totalDeletions), nil +} + +func gitOutputLine(args ...string) (string, error) { + out, err := runGit(".", args...) + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func uniqueSortedStrings(values []string) []string { + seen := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + seen[value] = struct{}{} + } + } + files := make([]string, 0, len(seen)) + for value := range seen { + files = append(files, value) + } + sort.Strings(files) + return files +} + +func localCommitRange(repo *localRepository, base, head string, limit int) ([]commitObject, error) { + if head == "" || head == base { + return nil, nil + } + excluded := map[string]struct{}{} + if base != "" { + if err := markCommitAncestors(repo, base, excluded); err != nil { + return nil, err + } + } + seen := map[string]struct{}{} + var commits []commitObject + stack := []string{head} + for len(stack) > 0 { + hash := stack[0] + stack = stack[1:] + if _, ok := seen[hash]; ok { + continue + } + seen[hash] = struct{}{} + if _, ok := excluded[hash]; ok { + continue + } + commit, err := repo.commitObject(hash) + if err != nil { + return nil, err + } + commits = append(commits, commit) + stack = append(stack, commit.parents...) + } + sort.SliceStable(commits, func(i, j int) bool { + return commits[i].timestamp > commits[j].timestamp + }) + if limit > 0 && len(commits) > limit { + commits = commits[:limit] + } + return commits, nil +} + +func markCommitAncestors(repo *localRepository, head string, out map[string]struct{}) error { + stack := []string{head} + for len(stack) > 0 { + hash := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if _, ok := out[hash]; ok { + continue + } + out[hash] = struct{}{} + commit, err := repo.commitObject(hash) + if err != nil { + return err + } + stack = append(stack, commit.parents...) + } + return nil +} + +func localChangedFilesBetween(repo *localRepository, base, head string) []string { + if base == "" || head == "" || base == head { + return nil + } + before, err := repo.treeFilesForCommit(base) + if err != nil { + return nil + } + after, err := repo.treeFilesForCommit(head) + if err != nil { + return nil + } + seen := map[string]struct{}{} + for path, afterFile := range after { + if beforeFile, ok := before[path]; !ok || beforeFile.hash != afterFile.hash || beforeFile.mode != afterFile.mode { + seen[path] = struct{}{} + } + } + for path := range before { + if _, ok := after[path]; !ok { + seen[path] = struct{}{} + } + } + files := make([]string, 0, len(seen)) + for path := range seen { + files = append(files, path) + } + sort.Strings(files) + return files +} + +func (s *webServer) handleTree(ctx context.Context, w http.ResponseWriter, r *http.Request, repoPath string) { + _, commit, ref, err := s.headCommit(ctx, r) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + repoPath = cleanWebPath(repoPath) + treeHash := commit.tree + if repoPath != "" && repoPath != "commits" && repoPath != "prs" { + hash, err := s.repo.findPath(ctx, commit.tree, repoPath) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + obj, err := s.repo.object(ctx, hash) + if err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + if obj.typ == gitObjectBlob { + http.Redirect(w, r, webURL("blob", repoPath, ref), http.StatusFound) + return + } + treeHash = hash + } + entries, err := s.repo.treeEntries(ctx, treeHash) + if err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].typ != entries[j].typ { + return entries[i].typ == gitObjectTree + } + return entries[i].name < entries[j].name + }) + repoCommits, _ := s.repo.walkCommits(ctx, commit.hash, 200, 0, "") + readme := s.readmeHTML(ctx, commit.tree) + + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, repoPath)) + body.WriteString(s.repoToolbarHTML(ref, true)) + body.WriteString(s.fileIndexHTML(ctx, commit.tree, ref)) + body.WriteString(`
`) + body.WriteString(`
` + html.EscapeString(commit.author) + `` + html.EscapeString(displayCommitSubject(commit)) + `` + html.EscapeString(shortHash(commit.hash)) + `` + html.EscapeString(relativeTime(commit.timestamp)) + `
`) + if repoPath != "" && repoPath != "commits" && repoPath != "prs" { + parent := pathpkg.Dir(repoPath) + if parent == "." { + parent = "" + } + body.WriteString(``) + } + for _, entry := range entries { + targetPath := pathpkg.Join(repoPath, entry.name) + kind := "file" + route := "blob" + name := entry.name + if entry.typ == gitObjectTree { + kind = "dir" + route = "tree" + name += "/" + } + body.WriteString(``) + } + body.WriteString(`
dir..
` + kind + `` + html.EscapeString(name) + `` + html.EscapeString(shortHash(entry.hash)) + `
`) + body.WriteString(`
README
`) + if readme != "" && repoPath == "" { + body.WriteString(readme) + } + body.WriteString(`
`) + body.WriteString(`
`) + body.WriteString(s.repoSidePanelHTML(contributorsFromCommits(repoCommits))) + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, repoPath), body.String()) +} + +func (s *webServer) handleCommits(ctx context.Context, w http.ResponseWriter, r *http.Request) { + _, commit, ref, err := s.headCommit(ctx, r) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + selectedHash := strings.TrimSpace(r.URL.Query().Get("commit")) + if selectedHash == "" { + selectedHash = strings.TrimSpace(r.URL.Query().Get("selected")) + } + if selectedHash != "" { + if resolved, err := s.repo.resolveRevision(ctx, selectedHash); err == nil { + selectedHash = resolved + } + } + commits, err := s.repo.walkCommits(ctx, commit.hash, 100, 0, "") + if err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, "commits")) + body.WriteString(`
Commits
`) + body.WriteString(s.commitListHTML(ctx, commits, ref, false, selectedHash)) + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, "commits"), body.String()) +} + +func (s *webServer) handlePullRequests(ctx context.Context, w http.ResponseWriter, r *http.Request) { + prs := []brokerPullRequest{} + source := "cache" + stale := false + if cached, err := s.readPullRequestCache(); err == nil { + prs = cached.PRs + stale = true + } else if refreshed, err := s.refreshPullRequestCache(ctx); err == nil { + prs = refreshed + source = "broker" + } + ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, "prs")) + body.WriteString(`
Pull requests
`) + body.WriteString(pullRequestListHTML(prs)) + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, "pull requests"), body.String()) +} + +func (s *webServer) handleIssues(ctx context.Context, w http.ResponseWriter, r *http.Request) { + issues, err := s.listIssues(ctx) + if err != nil { + issues = nil + } + ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, "issues")) + body.WriteString(`
Issues
`) + body.WriteString(issueListHTML(issues)) + body.WriteString(`

New issue

`) + if err != nil { + body.WriteString(`
` + html.EscapeString(err.Error()) + `
`) + } + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, "issues"), body.String()) +} + +func (s *webServer) handleIssue(ctx context.Context, w http.ResponseWriter, r *http.Request, idPart string) { + id, err := strconv.Atoi(strings.Trim(idPart, "/")) + if err != nil || id <= 0 { + http.NotFound(w, r) + return + } + issue, err := s.getIssue(ctx, id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, "issues")) + body.WriteString(`
`) + body.WriteString(`
` + html.EscapeString(strings.ToUpper(firstNonEmpty(issue.Status, "open"))) + `

` + html.EscapeString(issue.Title) + `

`) + body.WriteString(`
` + html.EscapeString(firstNonEmpty(issue.Author, "anonymous")) + ` opened ` + html.EscapeString(relativeTime(parseTime(issue.CreatedAt))) + `

` + html.EscapeString(issue.Body) + `

`) + for _, comment := range issue.Comments { + body.WriteString(`
` + html.EscapeString(firstNonEmpty(comment.User, "anonymous")) + ` commented ` + html.EscapeString(relativeTime(parseTime(comment.At))) + `

` + html.EscapeString(comment.Body) + `

`) + } + body.WriteString(`
`) + if issue.Status == "closed" { + body.WriteString(``) + } else { + body.WriteString(``) + } + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, "issue"), body.String()) +} + +func (s *webServer) handleSettings(ctx context.Context, w http.ResponseWriter, r *http.Request) { + info := s.settingsInfo(ctx) + ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, "settings")) + body.WriteString(`
`) + body.WriteString(`
Repository settings
`) + if strings.TrimSpace(s.cfg.brokerURL) == "" { + body.WriteString(`
Settings are available for broker-backed repositories.
`) + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, "settings"), body.String()) + return + } + body.WriteString(s.settingsAboutHTML(info)) + body.WriteString(s.settingsAccessHTML(info)) + body.WriteString(s.settingsBranchesHTML(info)) + body.WriteString(s.settingsPullRequestsHTML(info)) + body.WriteString(s.settingsDangerHTML(info)) + if len(info.Errors) > 0 { + body.WriteString(`

Unavailable sections

`) + for name, message := range info.Errors { + body.WriteString(`
` + html.EscapeString(name) + ` ` + html.EscapeString(message) + `
`) + } + body.WriteString(`
`) + } + body.WriteString(``) + s.renderPage(w, webPageTitle(s.title, "settings"), body.String()) +} + +func (s *webServer) settingsAboutHTML(info webSettingsInfo) string { + var b strings.Builder + b.WriteString(`

About

`) + b.WriteString(`
`) + b.WriteString(``) + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(settingsMetaItem("Repository", logicalRepoDisplayName(firstNonEmpty(info.Repo.Logical, info.Title)))) + b.WriteString(settingsMetaItem("Provider", firstNonEmpty(info.Provider, info.Repo.Provider))) + b.WriteString(settingsMetaItem("Region", info.Region)) + b.WriteString(settingsMetaItem("Broker", strings.TrimPrefix(strings.TrimPrefix(info.BrokerURL, "https://"), "http://"))) + b.WriteString(`
`) + return b.String() +} + +func (s *webServer) settingsAccessHTML(info webSettingsInfo) string { + var b strings.Builder + b.WriteString(`

Access

`) + b.WriteString(`
`) + if len(info.Keys) == 0 { + b.WriteString(`
No members found.
`) + } else { + for _, key := range info.Keys { + fingerprint := publicKeyFingerprint(key.PublicKey) + if fingerprint == "" { + fingerprint = key.PublicKey + } + status := "active" + if key.Suspended { + status = "suspended" + } + b.WriteString(`
`) + b.WriteString(`
` + html.EscapeString(firstNonEmpty(key.User, "unknown")) + `` + html.EscapeString(key.Role) + ` · ` + html.EscapeString(status) + `` + html.EscapeString(fingerprint) + ``) + if key.Source != "" { + b.WriteString(`` + html.EscapeString(key.Source) + ``) + } + b.WriteString(`
`) + if key.Role == "owner" { + b.WriteString(`Owner key`) + } else { + if key.Suspended { + b.WriteString(``) + } else { + b.WriteString(``) + } + b.WriteString(``) + } + b.WriteString(`
`) + } + } + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(`

Invite member

`) + b.WriteString(`
`) + b.WriteString(`
`) + return b.String() +} + +func (s *webServer) settingsBranchesHTML(info webSettingsInfo) string { + var b strings.Builder + b.WriteString(`

Branches

`) + if len(info.Protections) == 0 { + b.WriteString(`
No protected branches.
`) + } else { + for _, protection := range info.Protections { + mode := "PR required" + if protection.AllowOverrides { + mode += " · owner/admin override" + } + b.WriteString(`
` + html.EscapeString(shortRefName(protection.Ref)) + `` + html.EscapeString(mode) + `
`) + } + } + b.WriteString(`
`) + b.WriteString(`

Protect branch

`) + b.WriteString(`
`) + return b.String() +} + +func (s *webServer) settingsPullRequestsHTML(info webSettingsInfo) string { + return `

Pull requests

Protected branchesBranches can require pull requests before updates land.
Review metadataApprovals, requested changes, comments, and inline review threads are stored by the broker.
` +} + +func (s *webServer) settingsDangerHTML(info webSettingsInfo) string { + logical := firstNonEmpty(info.Repo.Logical, info.Title) + var b strings.Builder + b.WriteString(`

Danger Zone

Owner-only repository actions live here. These actions can permanently change or delete repository state.
`) + b.WriteString(`
`) + b.WriteString(`

Transfer ownership

Creates a one-time accept command for the new owner. Their SSH signature becomes the new owner key.

`) + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(`

Rename repository

`) + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(`

Delete repository

Deletes broker metadata, bucket contents, and the physical bucket.

`) + b.WriteString(`
`) + return b.String() +} + +func settingsMetaItem(label, value string) string { + if strings.TrimSpace(value) == "" { + value = "not configured" + } + return `
` + html.EscapeString(label) + `` + html.EscapeString(value) + `
` +} + +func publicKeyFingerprint(value string) string { + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(value))) + if err != nil { + return "" + } + return ssh.FingerprintSHA256(pub) +} + +func (s *webServer) handlePullRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, value string) { + parts := strings.Split(strings.Trim(value, "/"), "/") + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { + s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + return + } + id, err := strconv.Atoi(parts[0]) + if err != nil || id <= 0 { + s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + return + } + tab := "conversation" + if len(parts) > 1 && strings.TrimSpace(parts[1]) != "" { + tab = strings.TrimSpace(parts[1]) + } + pr, err := s.pullRequestByID(ctx, id) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, "prs")) + body.WriteString(`
`) + body.WriteString(prHeaderHTML(pr, tab)) + switch tab { + case "files", "files-changed", "diff": + body.WriteString(s.pullRequestFilesHTML(ctx, pr)) + case "commits": + body.WriteString(s.pullRequestCommitsHTML(ctx, pr)) + case "review": + body.WriteString(s.pullRequestReviewHTML(ctx, pr)) + default: + body.WriteString(s.pullRequestConversationHTML(ctx, pr)) + } + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, fmt.Sprintf("PR #%d", pr.ID)), body.String()) +} + +func (s *webServer) pullRequestByID(ctx context.Context, id int) (brokerPullRequest, error) { + if cached, err := s.readPullRequestCache(); err == nil { + for _, pr := range cached.PRs { + if pr.ID == id { + return pr, nil + } + } + } + if refreshed, err := s.refreshPullRequestCache(ctx); err == nil { + for _, pr := range refreshed { + if pr.ID == id { + return pr, nil + } + } + } + return brokerPullRequest{}, errors.New("pull request not found") +} + +func (s *webServer) pullRequestFilesHTML(ctx context.Context, pr brokerPullRequest) string { + files, additions, deletions, err := s.pullRequestChangedFiles(ctx, pr) + if err != nil { + return `
` + html.EscapeString(err.Error()) + `
` + } + return diffFilesPanelHTML(files, additions, deletions) +} + +func (s *webServer) pullRequestReviewHTML(ctx context.Context, pr brokerPullRequest) string { + files, additions, deletions, err := s.pullRequestChangedFiles(ctx, pr) + if err != nil { + return `
` + html.EscapeString(err.Error()) + `
` + } + var b strings.Builder + commentJSON, _ := json.Marshal(prReviewComments(pr)) + b.WriteString(``) + b.WriteString(`
Review changes
Hover a new-side line or file header to add review comments. Submit them together at the bottom.
`) + b.WriteString(`
`) + b.WriteString(diffFilesPanelHTMLWithOptions(files, additions, deletions, webDiffRenderOptions{Review: true, PRID: pr.ID})) + b.WriteString(`
Cancel review
`) + return b.String() +} + +func prReviewComments(pr brokerPullRequest) []brokerPullRequestComment { + var comments []brokerPullRequestComment + for _, review := range pr.Reviews { + comments = append(comments, review.Comments...) + } + return comments +} + +func (s *webServer) pullRequestChangedFiles(ctx context.Context, pr brokerPullRequest) ([]webChangedFile, int, int, error) { + repo := s + targetRef := firstNonEmpty(pr.Target, branchRef(defaultBranch)) + sourceRef := firstNonEmpty(pr.Source, pr.Head) + targetHash, targetErr := repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr := repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + if (targetErr != nil || sourceErr != nil) && s.apiRepo != nil && s.apiRepo != s.repo { + remote := *s + remote.repo = s.apiRepo + repo = &remote + targetHash, targetErr = repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr = repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + } + if targetErr != nil || sourceErr != nil { + return nil, 0, 0, errors.New("pull request refs are not available locally yet. Fetch the source and target branches, then refresh this page") + } + targetCommit, err := repo.repo.commit(ctx, targetHash) + if err != nil { + return nil, 0, 0, err + } + sourceCommit, err := repo.repo.commit(ctx, sourceHash) + if err != nil { + return nil, 0, 0, err + } + return repo.changedFilesBetweenTrees(ctx, targetCommit.tree, sourceCommit.tree) +} + +func (s *webServer) pullRequestUnifiedDiff(ctx context.Context, id int) (string, error) { + pr, err := s.pullRequestByID(ctx, id) + if err != nil { + return "", err + } + repo := s + targetRef := firstNonEmpty(pr.Target, branchRef(defaultBranch)) + sourceRef := firstNonEmpty(pr.Source, pr.Head) + targetHash, targetErr := repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr := repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + if (targetErr != nil || sourceErr != nil) && s.apiRepo != nil && s.apiRepo != s.repo { + remote := *s + remote.repo = s.apiRepo + repo = &remote + targetHash, targetErr = repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr = repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + } + if targetErr != nil || sourceErr != nil { + return "", errors.New("pull request refs are not available") + } + targetCommit, err := repo.repo.commit(ctx, targetHash) + if err != nil { + return "", err + } + sourceCommit, err := repo.repo.commit(ctx, sourceHash) + if err != nil { + return "", err + } + files, _, _, err := repo.changedFilesBetweenTrees(ctx, targetCommit.tree, sourceCommit.tree) + if err != nil { + return "", err + } + return changedFilesUnifiedDiff(files), nil +} + +func changedFilesUnifiedDiff(files []webChangedFile) string { + var b strings.Builder + for _, file := range files { + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", file.path, file.path) + leftShort := "0000000" + rightShort := "0000000" + if file.oldHash != "" { + leftShort = shortHash(file.oldHash) + } + if file.newHash != "" { + rightShort = shortHash(file.newHash) + } + fmt.Fprintf(&b, "index %s..%s 100644\n", leftShort, rightShort) + if file.oldHash == "" { + fmt.Fprintln(&b, "new file mode 100644") + fmt.Fprintln(&b, "--- /dev/null") + fmt.Fprintf(&b, "+++ b/%s\n", file.path) + } else if file.newHash == "" { + fmt.Fprintln(&b, "deleted file mode 100644") + fmt.Fprintf(&b, "--- a/%s\n", file.path) + fmt.Fprintln(&b, "+++ /dev/null") + } else { + fmt.Fprintf(&b, "--- a/%s\n", file.path) + fmt.Fprintf(&b, "+++ b/%s\n", file.path) + } + if file.binary { + fmt.Fprintln(&b, "Binary file changed") + continue + } + for _, line := range file.diff { + fmt.Fprintln(&b, line.text) + } + } + return b.String() +} + +func (s *webServer) pullRequestCommitsHTML(ctx context.Context, pr brokerPullRequest) string { + repo := s + targetRef := firstNonEmpty(pr.Target, branchRef(defaultBranch)) + sourceRef := firstNonEmpty(pr.Source, pr.Head) + targetHash, targetErr := repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr := repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + if (targetErr != nil || sourceErr != nil) && s.apiRepo != nil && s.apiRepo != s.repo { + remote := *s + remote.repo = s.apiRepo + repo = &remote + targetHash, targetErr = repo.resolvePullRequestRevision(ctx, targetRef, "") + sourceHash, sourceErr = repo.resolvePullRequestRevision(ctx, sourceRef, pr.Head) + } + if sourceErr != nil { + return `
Pull request source branch is not available.
` + } + commits, err := repo.commitRange(ctx, targetHash, sourceHash, 50) + if err != nil { + return `
` + html.EscapeString(err.Error()) + `
` + } + return `
Commits
` + s.commitListHTML(ctx, commits, firstNonEmpty(pr.Source, pr.Head), false, "") + `
` +} + +func (s *webServer) resolvePullRequestRevision(ctx context.Context, ref, fallbackHash string) (string, error) { + ref = strings.TrimSpace(ref) + var candidates []string + if ref != "" { + candidates = append(candidates, ref) + short := shortRefName(ref) + if short != "" && short != ref { + candidates = append(candidates, + short, + "refs/remotes/bucketgit/"+short, + "refs/remotes/origin/"+short, + ) + } + } + if fallbackHash != "" { + candidates = append(candidates, fallbackHash) + } + seen := map[string]struct{}{} + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + if hash, err := s.repo.resolveRevision(ctx, candidate); err == nil { + return hash, nil + } + } + return "", fs.ErrNotExist +} + +func (s *webServer) commitRange(ctx context.Context, base, head string, limit int) ([]commitObject, error) { + if head == "" || head == base { + return nil, nil + } + excluded := map[string]struct{}{} + if base != "" { + if err := s.markCommitAncestors(ctx, base, excluded); err != nil { + return nil, err + } + } + seen := map[string]struct{}{} + var commits []commitObject + stack := []string{head} + for len(stack) > 0 { + hash := stack[0] + stack = stack[1:] + if _, ok := seen[hash]; ok { + continue + } + seen[hash] = struct{}{} + if _, ok := excluded[hash]; ok { + continue + } + commit, err := s.repo.commit(ctx, hash) + if err != nil { + return nil, err + } + commits = append(commits, commit) + stack = append(stack, commit.parents...) + } + sort.SliceStable(commits, func(i, j int) bool { + return commits[i].timestamp > commits[j].timestamp + }) + if limit > 0 && len(commits) > limit { + commits = commits[:limit] + } + return commits, nil +} + +func (s *webServer) markCommitAncestors(ctx context.Context, head string, out map[string]struct{}) error { + stack := []string{head} + for len(stack) > 0 { + hash := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if _, ok := out[hash]; ok { + continue + } + out[hash] = struct{}{} + commit, err := s.repo.commit(ctx, hash) + if err != nil { + return err + } + stack = append(stack, commit.parents...) + } + return nil +} + +func (s *webServer) handleCommit(ctx context.Context, w http.ResponseWriter, r *http.Request, hash string) { + hash = strings.TrimSpace(strings.Trim(hash, "/")) + if hash == "" { + s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + return + } + ref := strings.TrimSpace(r.URL.Query().Get("ref")) + http.Redirect(w, r, webCommitURL(hash, ref), http.StatusFound) +} + +func (s *webServer) handleBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, repoPath string, raw bool) { + _, commit, ref, err := s.headCommit(ctx, r) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + repoPath = cleanWebPath(repoPath) + if repoPath == "" { + s.renderError(w, http.StatusNotFound, fs.ErrNotExist) + return + } + hash, err := s.repo.findPath(ctx, commit.tree, repoPath) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + obj, err := s.repo.object(ctx, hash) + if err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + if obj.typ == gitObjectTree { + http.Redirect(w, r, webURL("tree", repoPath, ref), http.StatusFound) + return + } + if raw { + contentType := mime.TypeByExtension(pathpkg.Ext(repoPath)) + if contentType == "" { + contentType = http.DetectContentType(obj.data) + } + w.Header().Set("Content-Type", contentType) + w.Write(obj.data) + return + } + var body strings.Builder + body.WriteString(`
`) + body.WriteString(s.headerHTML(ref, repoPath)) + body.WriteString(`
` + html.EscapeString(repoPath) + `
` + strconv.Itoa(len(obj.data)) + ` bytes
Raw
`) + body.WriteString(``) + if isTextBlob(obj.data) { + body.WriteString(`
` + html.EscapeString(string(obj.data)) + `
`) + } else { + body.WriteString(`
Binary file. Use Raw to download the contents.
`) + } + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, repoPath), body.String()) +} + +func (s *webServer) handleArchiveZip(ctx context.Context, w http.ResponseWriter, r *http.Request) { + _, commit, ref, err := s.headCommit(ctx, r) + if err != nil { + s.renderError(w, http.StatusNotFound, err) + return + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + rootName := archiveRootName(s.title, ref) + if err := s.writeZipTree(ctx, zw, commit.tree, rootName); err != nil { + _ = zw.Close() + s.renderError(w, http.StatusInternalServerError, err) + return + } + if err := zw.Close(); err != nil { + s.renderError(w, http.StatusInternalServerError, err) + return + } + filename := anchorID(rootName) + if filename == "" { + filename = "bucketgit" + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.zip"`) + w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf.Bytes()) +} + +func (s *webServer) writeZipTree(ctx context.Context, zw *zip.Writer, treeHash, prefix string) error { + entries, err := s.repo.treeEntries(ctx, treeHash) + if err != nil { + return err + } + for _, entry := range entries { + target := pathpkg.Join(prefix, entry.name) + if entry.typ == gitObjectTree { + if _, err := zw.Create(target + "/"); err != nil { + return err + } + if err := s.writeZipTree(ctx, zw, entry.hash, target); err != nil { + return err + } + continue + } + obj, err := s.repo.object(ctx, entry.hash) + if err != nil { + return err + } + writer, err := zw.Create(target) + if err != nil { + return err + } + if _, err := writer.Write(obj.data); err != nil { + return err + } + } + return nil +} + +func archiveRootName(title, ref string) string { + name := strings.Trim(strings.TrimSuffix(pathpkg.Base(strings.Trim(title, "/")), ".git"), ".") + if name == "" || name == "." { + name = "bucketgit" + } + refName := displayRef(ref) + if refName == "" { + return name + } + return name + "-" + refName +} + +func (s *webServer) readmeHTML(ctx context.Context, treeHash string) string { + entries, err := s.repo.treeEntries(ctx, treeHash) + if err != nil { + return "" } for _, name := range []string{"README.md", "README", "readme.md", "readme"} { for _, entry := range entries { @@ -516,30 +2714,222 @@ func (s *webServer) readmeHTML(ctx context.Context, treeHash string) string { return "" } -func (s *webServer) headerHTML(ref, repoPath string) string { - var b strings.Builder - b.WriteString(`
bucketgit repository

` + html.EscapeString(s.title) + `

`) - b.WriteString(s.refSelectorHTML(ref)) - b.WriteString(`
`) - b.WriteString(``) - if repoPath != "" { - b.WriteString(`
`) - b.WriteString(`root`) - current := "" - for _, part := range strings.Split(repoPath, "/") { - if part == "" { - continue - } - current = pathpkg.Join(current, part) - b.WriteString(` / ` + html.EscapeString(part) + ``) - } - b.WriteString(`
`) +func (s *webServer) headerHTML(ref, repoPath string) string { + var b strings.Builder + b.WriteString(themeToggleHTML()) + b.WriteString(`
`) + codeActive := ` class="active"` + commitsActive := "" + prsActive := "" + issuesActive := "" + settingsActive := "" + if repoPath == "commits" { + codeActive = "" + commitsActive = ` class="active"` + } else if repoPath == "prs" { + codeActive = "" + prsActive = ` class="active"` + } else if repoPath == "settings" { + codeActive = "" + settingsActive = ` class="active"` + } else if repoPath == "issues" { + codeActive = "" + issuesActive = ` class="active"` + } + b.WriteString(`
`) + codeActions := "" + if codeActive != "" { + codeActions = ` data-code-actions="true"` + } + b.WriteString(`
`) + if location := s.repoLocationBadge(); location != "" { + b.WriteString(`
` + html.EscapeString(location) + `
`) + } + b.WriteString(`
`) + if banner := s.repoPolicyBannerHTML(); banner != "" { + b.WriteString(banner) + } + b.WriteString(`
`) + return b.String() +} + +func (s *webServer) repoPolicyBannerHTML() string { + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + return "" + } + var repoInfo brokerRepoInfoResponse + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/repo/info", brokerRepoInfoRequest{Repo: repoForBroker(s.cfg)}, &repoInfo); err != nil { + return "" + } + if repoInfo.ReadOnly { + return `
This repository has been set to read-only.
` + } + return "" +} + +func (s *webServer) repoToolbarHTML(ref string, includeSearch bool) string { + branchCount, tagCount := s.refCounts(context.Background()) + var b strings.Builder + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(remoteSyncHTML()) + if includeSearch { + b.WriteString(``) + } + b.WriteString(s.codeDropdownHTML(ref)) + b.WriteString(`
`) + return b.String() +} + +type webContributor struct { + Name string +} + +func (s *webServer) repoSidePanelHTML(contributors []webContributor) string { + repoName := strings.TrimSuffix(strings.Trim(s.title, "/"), ".git") + if repoName == "" { + repoName = "this" + } + var b strings.Builder + b.WriteString(``) + return b.String() +} + +func linkedInIconHTML() string { + return `` +} + +func gitHubIconHTML() string { + return `` +} + +func diffIconSVGHTML() string { + return `` +} + +func contributorsFromCommits(commits []commitObject) []webContributor { + indexes := map[string]int{} + contributors := []webContributor{} + for _, commit := range commits { + key := strings.TrimSpace(strings.ToLower(commit.email)) + if key == "" { + key = strings.TrimSpace(strings.ToLower(commit.author)) + } + if key == "" { + continue + } + if _, ok := indexes[key]; ok { + continue + } + name := strings.TrimSpace(commit.author) + if name == "" { + name = strings.TrimSpace(commit.email) + } + indexes[key] = len(contributors) + contributors = append(contributors, webContributor{Name: name}) + } + return contributors +} + +func (s *webServer) fileIndexHTML(ctx context.Context, treeHash, ref string) string { + files := map[string]webTreeFile{} + if err := s.collectTreeFiles(ctx, treeHash, "", files); err != nil { + return "" + } + dirs := map[string]struct{}{} + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + for dir := pathpkg.Dir(path); dir != "." && dir != ""; dir = pathpkg.Dir(dir) { + dirs[dir] = struct{}{} + } + } + sort.Strings(paths) + dirPaths := make([]string, 0, len(dirs)) + for dir := range dirs { + dirPaths = append(dirPaths, dir) + } + sort.Strings(dirPaths) + index := make([]webFileIndexEntry, 0, len(paths)+len(dirPaths)) + for _, dir := range dirPaths { + index = append(index, webFileIndexEntry{Path: dir, URL: webURL("tree", dir, ref), Kind: "dir"}) + } + for _, path := range paths { + index = append(index, webFileIndexEntry{Path: path, URL: webURL("blob", path, ref), Kind: "file"}) + } + data, err := json.Marshal(index) + if err != nil { + return "" + } + return `` +} + +func themeToggleHTML() string { + return `` +} + +func remoteSyncHTML() string { + return `
Synchronising
` +} + +func boolDataAttr(name string, value bool) string { + if !value { + return "" + } + return ` data-` + name + `="true"` +} + +func (s *webServer) repoLocationBadge() string { + brokerURL := strings.TrimSpace(s.cfg.brokerURL) + logicalRepo := strings.Trim(s.cfg.logicalRepo, "/") + if brokerURL == "" || logicalRepo == "" { + return "" } - b.WriteString(`
`) - return b.String() + if parsed, err := url.Parse(brokerURL); err == nil && parsed.Host != "" { + return parsed.Host + "/" + logicalRepo + } + return strings.TrimPrefix(strings.TrimPrefix(strings.TrimRight(brokerURL, "/"), "https://"), "http://") + "/" + logicalRepo } func (s *webServer) refSelectorHTML(ref string) string { + if s.repo == nil { + return `
` + html.EscapeString(displayRef(ref)) + `
` + } options, err := s.refOptions(context.Background()) if err != nil || len(options) == 0 { return `
` + html.EscapeString(displayRef(ref)) + `
` @@ -603,48 +2993,711 @@ func (s *webServer) refOptions(ctx context.Context) ([]webRefOption, error) { return options, nil } +func (s *webServer) refCounts(ctx context.Context) (int, int) { + options, err := s.refOptions(ctx) + if err != nil { + return 0, 0 + } + branches := 0 + tags := 0 + for _, option := range options { + switch option.kind { + case "Branches": + branches++ + case "Tags": + tags++ + } + } + return branches, tags +} + +func (s *webServer) codeDropdownHTML(ref string) string { + widget := s.cloneWidgetHTML(ref) + if widget == "" { + return "" + } + return `
` +} + func (s *webServer) clonePanelHTML() string { + widget := s.cloneWidgetHTML("") + if widget == "" { + return "" + } + return `
` + widget + `
` +} + +func (s *webServer) cloneWidgetHTML(ref string) string { origin := firstNonEmpty(s.cfg.origin, originForConfig(s.cfg)) sshURL := "" - if s.cfg.bucket != "" && s.cfg.prefix != "" { + logicalRepo := strings.Trim(s.cfg.logicalRepo, "/") + if logicalRepo != "" { + sshURL = fmt.Sprintf("git@%s:%s", defaultSSHHost, logicalRepo) + } else if s.cfg.bucket != "" && s.cfg.prefix != "" { sshURL = sshRemoteURL(s.cfg) } - var b strings.Builder - b.WriteString(`
Clone
Use the bucket URL with bgit, or SSH after running bgit ssh setup.
`) - if origin != "" { - b.WriteString(cloneRowHTML("bgit", "bgit clone "+origin)) - b.WriteString(cloneRowHTML("origin", origin)) + options := []cloneOption{} + if s.cfg.brokerURL != "" && logicalRepo != "" { + options = append(options, cloneOption{Label: "BGIT", Value: "bgit clone " + brokerCloneCommandURL(s.cfg.brokerURL, logicalRepo)}) + } else if origin != "" { + options = append(options, cloneOption{Label: "BGIT", Value: "bgit clone " + origin}) } if sshURL != "" { - b.WriteString(cloneRowHTML("ssh", sshURL)) + options = append(options, cloneOption{Label: "SSH", Value: sshURL}) } - b.WriteString(`
`) + if origin != "" && origin != sshURL { + options = append(options, cloneOption{Label: "Origin", Value: origin}) + } + if len(options) == 0 { + return "" + } + return cloneWidgetHTML(options, ref) +} + +type cloneOption struct { + Label string + Value string +} + +func cloneWidgetHTML(options []cloneOption, ref string) string { + var b strings.Builder + b.WriteString(`
Clone
`) + if len(options) > 1 { + b.WriteString(`
`) + for i, option := range options { + active := "" + selected := "false" + if i == 0 { + active = ` class="active"` + selected = "true" + } + id := anchorID("clone-" + option.Label) + b.WriteString(``) + } + b.WriteString(`
`) + } + b.WriteString(`
`) + for i, option := range options { + id := anchorID("clone-" + option.Label) + copyID := "copy-" + anchorID(option.Label+"-"+option.Value) + hidden := "" + if i != 0 { + hidden = ` hidden` + } + b.WriteString(``) + } + b.WriteString(`
`) + if ref != "" { + b.WriteString(``) + } + b.WriteString(`
`) return b.String() } -func cloneRowHTML(label, value string) string { - id := "copy-" + anchorID(label+"-"+value) - return `
` + html.EscapeString(label) + `` + html.EscapeString(value) + `
` +func brokerCloneCommandURL(brokerURL, logicalRepo string) string { + return strings.TrimRight(strings.TrimSpace(brokerURL), "/") + "/" + strings.Trim(strings.TrimSpace(logicalRepo), "/") } func (s *webServer) renderPage(w http.ResponseWriter, title, body string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, ``) - fmt.Fprint(w, html.EscapeString(title)) - fmt.Fprint(w, ``) - fmt.Fprint(w, body) - fmt.Fprint(w, webJS) - fmt.Fprint(w, ``) + page := webAssetString(webPageTemplatePath) + source := "seed" + if s.apiRepo == nil || s.apiRepo == s.repo { + source = "remote" + } + page = strings.ReplaceAll(page, "{{TITLE}}", html.EscapeString(title)) + page = strings.ReplaceAll(page, "{{CSS}}", webAssetString(webCSSPath)) + page = strings.ReplaceAll(page, "{{SOURCE}}", source) + page = strings.ReplaceAll(page, "{{BODY}}", body) + page = strings.ReplaceAll(page, "{{WHOAMI}}", s.cachedWhoamiJSON()) + page = strings.ReplaceAll(page, "{{JS}}", webAssetString(webJSPath)) + fmt.Fprint(w, page) +} + +func (s *webServer) renderError(w http.ResponseWriter, status int, err error) { + w.WriteHeader(status) + s.renderPage(w, fmt.Sprintf("%d", status), `
`+html.EscapeString(err.Error())+`
`) +} + +func (s *webServer) renderJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(value); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func (s *webServer) renderJSONError(w http.ResponseWriter, status int, err error) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if encodeErr := json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}); encodeErr != nil { + http.Error(w, encodeErr.Error(), http.StatusInternalServerError) + } +} + +func webAPICommitFromCommit(commit commitObject) webAPICommit { + return webAPICommit{ + Hash: commit.hash, + ShortHash: shortHash(commit.hash), + Subject: firstNonEmpty(commit.subject, shortHash(commit.hash)), + Body: commit.body, + Author: commit.author, + Email: commit.email, + Timestamp: commit.timestamp, + Parents: commit.parents, + Tree: commit.tree, + } +} + +func webAPICommitsFromCommits(commits []commitObject) []webAPICommit { + out := make([]webAPICommit, 0, len(commits)) + for _, commit := range commits { + out = append(out, webAPICommitFromCommit(commit)) + } + return out +} + +func webAPIChangedFiles(files []webChangedFile) []map[string]any { + out := make([]map[string]any, 0, len(files)) + for _, file := range files { + lines := make([]map[string]string, 0, len(file.diff)) + for _, line := range file.diff { + lines = append(lines, map[string]string{"kind": line.kind, "text": line.text}) + } + out = append(out, map[string]any{ + "path": file.path, + "old_hash": file.oldHash, + "new_hash": file.newHash, + "additions": file.additions, + "deletions": file.deletions, + "binary": file.binary, + "diff": lines, + }) + } + return out +} + +func (s *webServer) pullRequestsAvailable() bool { + if strings.TrimSpace(s.cfg.brokerURL) != "" && strings.TrimSpace(s.cfg.logicalRepo) != "" { + return true + } + if cached, err := s.readPullRequestCache(); err == nil && len(cached.PRs) > 0 { + return true + } + return false +} + +func (s *webServer) pullRequestCachePath() string { + if s.localGitDir == "" { + return "" + } + return filepath.Join(s.localGitDir, "bucketgit", "cache", "prs.json") +} + +func (s *webServer) readPullRequestCache() (webPullRequestCache, error) { + path := s.pullRequestCachePath() + if path == "" { + return webPullRequestCache{}, fs.ErrNotExist + } + data, err := os.ReadFile(path) + if err != nil { + return webPullRequestCache{}, err + } + var cache webPullRequestCache + if err := json.Unmarshal(data, &cache); err != nil { + return webPullRequestCache{}, err + } + if cache.PRs == nil { + cache.PRs = []brokerPullRequest{} + } + return cache, nil +} + +func (s *webServer) writePullRequestCache(prs []brokerPullRequest) error { + path := s.pullRequestCachePath() + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(webPullRequestCache{UpdatedAt: time.Now().Unix(), PRs: prs}, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) { + return nil + } + return os.WriteFile(path, data, 0o644) +} + +func (s *webServer) upsertPullRequestCache(pr brokerPullRequest) []brokerPullRequest { + if pr.ID <= 0 { + if cached, err := s.readPullRequestCache(); err == nil { + return cached.PRs + } + return nil + } + cache, err := s.readPullRequestCache() + if err != nil { + cache.PRs = []brokerPullRequest{} + } + found := false + for i := range cache.PRs { + if cache.PRs[i].ID == pr.ID { + cache.PRs[i] = pr + found = true + break + } + } + if !found { + cache.PRs = append(cache.PRs, pr) + } + sort.SliceStable(cache.PRs, func(i, j int) bool { + return cache.PRs[i].ID > cache.PRs[j].ID + }) + _ = s.writePullRequestCache(cache.PRs) + return cache.PRs +} + +func (s *webServer) refreshPullRequestCache(ctx context.Context) ([]brokerPullRequest, error) { + if strings.TrimSpace(s.cfg.brokerURL) == "" || strings.TrimSpace(s.cfg.logicalRepo) == "" { + return nil, errors.New("broker pull requests unavailable") + } + known := map[string]string{} + cached, cacheErr := s.readPullRequestCache() + if cacheErr == nil { + for _, pr := range cached.PRs { + if pr.ID > 0 && strings.TrimSpace(pr.Version) != "" { + known[strconv.Itoa(pr.ID)] = pr.Version + } + } + } + var resp struct { + PRs []brokerPullRequest `json:"prs"` + Deleted []int `json:"deleted"` + } + if err := brokerPostContext(ctx, s.cfg.brokerURL, "/prs/sync", brokerPullRequestRequest{Repo: repoForBroker(s.cfg), Known: known}, &resp); err != nil { + if !strings.Contains(err.Error(), "unknown broker endpoint") { + return nil, err + } + if listErr := brokerPostContext(ctx, s.cfg.brokerURL, "/prs/list", brokerPullRequestRequest{Repo: repoForBroker(s.cfg)}, &resp); listErr != nil { + return nil, err + } + } + if resp.PRs == nil { + resp.PRs = []brokerPullRequest{} + } + prs := mergePullRequestCache(cached.PRs, resp.PRs, resp.Deleted) + _ = s.writePullRequestCache(prs) + return prs, nil +} + +func mergePullRequestCache(cached, changed []brokerPullRequest, deleted []int) []brokerPullRequest { + byID := map[int]brokerPullRequest{} + for _, pr := range cached { + if pr.ID > 0 { + byID[pr.ID] = pr + } + } + for _, id := range deleted { + delete(byID, id) + } + for _, pr := range changed { + if pr.ID > 0 { + byID[pr.ID] = pr + } + } + prs := make([]brokerPullRequest, 0, len(byID)) + for _, pr := range byID { + prs = append(prs, pr) + } + sort.SliceStable(prs, func(i, j int) bool { + return prs[i].ID > prs[j].ID + }) + return prs +} + +func webAPIPullRequests(prs []brokerPullRequest) []map[string]any { + out := make([]map[string]any, 0, len(prs)) + for _, pr := range prs { + out = append(out, map[string]any{ + "id": pr.ID, + "title": pr.Title, + "body": pr.Body, + "source": pr.Source, + "target": pr.Target, + "status": pr.Status, + "author": pr.Author, + "approvals": pr.Approvals, + "checks": pr.Checks, + "head": pr.Head, + "comments": pr.Comments, + "reviews": pr.Reviews, + }) + } + return out +} + +func pullRequestListHTML(prs []brokerPullRequest) string { + if len(prs) == 0 { + return `
No pull requests found.
` + } + var b strings.Builder + b.WriteString(`
    `) + for _, pr := range prs { + status := firstNonEmpty(pr.Status, "open") + title := firstNonEmpty(pr.Title, "Untitled pull request") + prURL := `/prs/` + strconv.Itoa(pr.ID) + b.WriteString(`
  • ` + html.EscapeString(shortRefName(pr.Source)) + ` → ` + html.EscapeString(shortRefName(pr.Target)) + `
    ` + html.EscapeString(status) + ``) + if pr.Approvals > 0 { + b.WriteString(`` + strconv.Itoa(pr.Approvals) + ` approval`) + if pr.Approvals != 1 { + b.WriteString(`s`) + } + b.WriteString(``) + } + b.WriteString(`
  • `) + } + b.WriteString(`
`) + return b.String() +} + +func issueListHTML(issues []brokerIssue) string { + if len(issues) == 0 { + return `
No issues found.
` + } + var b strings.Builder + b.WriteString(``) + return b.String() +} + +func prHeaderHTML(pr brokerPullRequest, active string) string { + status := firstNonEmpty(pr.Status, "open") + title := firstNonEmpty(pr.Title, "Untitled pull request") + filesActive := "" + conversationActive := ` class="active"` + commitsActive := "" + reviewActive := "" + if active == "files" || active == "files-changed" || active == "diff" { + conversationActive = "" + filesActive = ` class="active"` + } else if active == "commits" { + conversationActive = "" + commitsActive = ` class="active"` + } else if active == "review" { + conversationActive = "" + reviewActive = ` class="active"` + } + id := strconv.Itoa(pr.ID) + var b strings.Builder + b.WriteString(`
`) + b.WriteString(`
` + html.EscapeString(status) + `

#` + id + ` ` + html.EscapeString(title) + `

`) + b.WriteString(`
` + html.EscapeString(shortRefName(pr.Source)) + ` → ` + html.EscapeString(shortRefName(pr.Target))) + if pr.Author != "" { + b.WriteString(` by ` + html.EscapeString(pr.Author)) + } + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(`
`) + return b.String() +} + +func (s *webServer) pullRequestConversationHTML(ctx context.Context, pr brokerPullRequest) string { + contexts := s.prInlineCommentContexts(ctx, pr) + var b strings.Builder + b.WriteString(`
Conversation
`) + if strings.TrimSpace(pr.Body) != "" { + b.WriteString(`
` + html.EscapeString(pr.Body) + `
`) + } else { + b.WriteString(`
No description.
`) + } + b.WriteString(`
`) + for _, comment := range pr.Comments { + b.WriteString(prNoteHTML(comment, "commented", contexts)) + } + for _, review := range pr.Reviews { + label := "reviewed" + if review.State == "approved" { + label = "approved" + } else if review.State == "changes_requested" { + label = "requested changes" + } + b.WriteString(prNoteHTML(review, label, contexts)) + } + if len(pr.Comments) == 0 && len(pr.Reviews) == 0 { + b.WriteString(`
No comments or reviews yet.
`) + } + b.WriteString(`
`) + if firstNonEmpty(pr.Status, "open") == "open" { + b.WriteString(``) + b.WriteString(`
`) + } else { + b.WriteString(`
This pull request is ` + html.EscapeString(firstNonEmpty(pr.Status, "closed")) + `.
`) + } + b.WriteString(`
`) + return b.String() +} + +func (s *webServer) prInlineCommentContexts(ctx context.Context, pr brokerPullRequest) map[string][]webVisualDiffRow { + contexts := map[string][]webVisualDiffRow{} + if len(pr.Comments) == 0 && len(pr.Reviews) == 0 { + return contexts + } + files, _, _, err := s.pullRequestChangedFiles(ctx, pr) + if err != nil { + return contexts + } + filesByPath := map[string]webChangedFile{} + for _, file := range files { + filesByPath[file.path] = file + } + collect := func(note brokerPullRequestNote) { + for _, comment := range note.Comments { + if comment.Kind != "line" || comment.File == "" || comment.Line <= 0 { + continue + } + file, ok := filesByPath[comment.File] + if !ok { + continue + } + rows := file.visual + if len(rows) == 0 { + rows = webVisualDiffRows(file.diff) + } + context := prCommentAfterContextRows(rows, comment) + if len(context) > 0 { + contexts[prInlineCommentKey(comment)] = context + } + } + } + for _, note := range pr.Comments { + collect(note) + } + for _, note := range pr.Reviews { + collect(note) + } + return contexts +} + +func prCommentAfterContextRows(rows []webVisualDiffRow, comment brokerPullRequestComment) []webVisualDiffRow { + target := -1 + targetLine := strconv.Itoa(comment.Line) + for i, row := range rows { + if row.newLine == targetLine { + target = i + break + } + if target < 0 && comment.HunkIndex >= 0 && row.hunkIndex == comment.HunkIndex && row.offset == comment.Offset && row.newLine != "" { + target = i + } + } + if target < 0 { + return nil + } + hunkIndex := rows[target].hunkIndex + var context []webVisualDiffRow + for _, row := range rows { + if row.hidden || row.newLine == "" { + continue + } + if hunkIndex >= 0 && row.hunkIndex != hunkIndex { + continue + } + if row.kind == "hunk" || row.kind == "hunk-top" || row.kind == "hunk-bottom" || row.kind == "note" { + continue + } + context = append(context, row) + } + if len(context) == 0 { + context = append(context, rows[target]) + } + return context +} + +func prNoteHTML(note brokerPullRequestNote, action string, contexts map[string][]webVisualDiffRow) string { + user := firstNonEmpty(note.User, "unknown") + when := note.At + if parsed, err := time.Parse(time.RFC3339, note.At); err == nil { + when = relativeTime(parsed.Unix()) + } + var b strings.Builder + b.WriteString(`
` + html.EscapeString(user) + ` ` + html.EscapeString(action)) + if when != "" { + b.WriteString(` ` + html.EscapeString(when) + ``) + } + b.WriteString(`
`) + if strings.TrimSpace(note.Body) != "" { + b.WriteString(`
` + html.EscapeString(note.Body) + `
`) + } + if len(note.Replies) > 0 { + b.WriteString(prReplyThreadHTML(note.Replies, 1)) + } + if len(note.Comments) > 0 { + b.WriteString(`
`) + for _, comment := range note.Comments { + b.WriteString(prInlineCommentHTML(note.ID, comment, contexts[prInlineCommentKey(comment)])) + } + b.WriteString(`
`) + } + b.WriteString(`
`) + return b.String() +} + +func prInlineCommentKey(comment brokerPullRequestComment) string { + return strings.Join([]string{ + comment.File, + comment.Kind, + strconv.Itoa(comment.Line), + strconv.Itoa(comment.HunkIndex), + strconv.Itoa(comment.Offset), + comment.Head, + comment.Body, + }, "\x00") +} + +func prInlineCommentHTML(noteID int, comment brokerPullRequestComment, context []webVisualDiffRow) string { + file := firstNonEmpty(comment.File, "Changed file") + line := "" + if comment.Kind == "line" && comment.Line > 0 { + line = strconv.Itoa(comment.Line) + } + var b strings.Builder + b.WriteString(`
`) + b.WriteString(`
` + html.EscapeString(file) + ``) + if line != "" { + b.WriteString(`line ` + html.EscapeString(line) + ``) + } + if comment.Outdated { + b.WriteString(`outdated`) + } + b.WriteString(``) + b.WriteString(`
`) + if comment.Kind == "line" { + if len(context) > 0 { + b.WriteString(`
`) + targetRendered := false + for _, row := range context { + target := row.newLine == line + if target { + targetRendered = true + } + b.WriteString(prInlineAfterRowHTML(row, target)) + if target { + b.WriteString(`
` + prInlineCommentBodyHTML(comment, prReplyAttrs(noteID, comment.ID))) + if len(comment.Replies) > 0 { + b.WriteString(`
` + prReplyThreadHTML(comment.Replies, 1)) + } + } + } + if !targetRendered { + b.WriteString(`
` + prInlineCommentBodyHTML(comment, prReplyAttrs(noteID, comment.ID))) + if len(comment.Replies) > 0 { + b.WriteString(`
` + prReplyThreadHTML(comment.Replies, 1)) + } + } + b.WriteString(`
`) + } else { + b.WriteString(`
`) + b.WriteString(`
` + html.EscapeString(line) + `
`) + lineText := comment.LineText + if strings.TrimSpace(lineText) == "" { + lineText = "(line context unavailable)" + } + b.WriteString(`
` + html.EscapeString(lineText) + `
`) + b.WriteString(`
` + prInlineCommentBodyHTML(comment, prReplyAttrs(noteID, comment.ID))) + if len(comment.Replies) > 0 { + b.WriteString(`
` + prReplyThreadHTML(comment.Replies, 1)) + } + b.WriteString(`
`) + } + } else { + b.WriteString(`
File comment
` + prInlineCommentBodyHTML(comment, prReplyAttrs(noteID, comment.ID)) + `
`) + if len(comment.Replies) > 0 { + b.WriteString(prReplyThreadHTML(comment.Replies, 1)) + } + } + b.WriteString(`
`) + return b.String() +} + +func prReplyThreadHTML(replies []brokerPullRequestComment, depth int) string { + if len(replies) == 0 { + return "" + } + if depth > 5 { + depth = 5 + } + var b strings.Builder + b.WriteString(`
`) + for _, reply := range replies { + user := firstNonEmpty(reply.User, "unknown") + when := reply.At + if parsed, err := time.Parse(time.RFC3339, reply.At); err == nil { + when = relativeTime(parsed.Unix()) + } + b.WriteString(`
` + html.EscapeString(user) + ` commented`) + if when != "" { + b.WriteString(` ` + html.EscapeString(when) + ``) + } + b.WriteString(`
`) + b.WriteString(`
` + html.EscapeString(reply.Body) + `
`) + b.WriteString(`
`) + if len(reply.Replies) > 0 { + b.WriteString(prReplyThreadHTML(reply.Replies, depth+1)) + } + } + b.WriteString(`
`) + return b.String() +} + +func prReplyAttrs(noteID, commentID int) string { + attrs := ` data-pr-reply` + if noteID > 0 { + attrs += ` data-target-note-id="` + strconv.Itoa(noteID) + `"` + } + if commentID > 0 { + attrs += ` data-target-comment-id="` + strconv.Itoa(commentID) + `"` + } + return attrs +} + +func prInlineCommentBodyHTML(comment brokerPullRequestComment, replyAttrs string) string { + reply := "" + if replyAttrs != "" { + reply = `` + } + user := firstNonEmpty(comment.User, "unknown") + when := comment.At + if parsed, err := time.Parse(time.RFC3339, comment.At); err == nil { + when = relativeTime(parsed.Unix()) + } + meta := `
` + html.EscapeString(user) + ` commented` + if when != "" { + meta += ` ` + html.EscapeString(when) + `` + } + meta += `
` + return `
` + meta + `
` + html.EscapeString(comment.Body) + `
` + reply + `
` } -func (s *webServer) renderError(w http.ResponseWriter, status int, err error) { - w.WriteHeader(status) - s.renderPage(w, fmt.Sprintf("%d", status), `
`+html.EscapeString(err.Error())+`
`) +func prInlineAfterRowHTML(row webVisualDiffRow, target bool) string { + right := webDiffCellHTML(row.right, false, row.kind == "add") + if row.kind == "change" { + _, right = webInlineChangedHTML(row.left, row.right) + } + targetClass := "" + if target { + targetClass = " pr-inline-target-line" + } + return `
` + html.EscapeString(row.newLine) + `
` + right + `
` } -func commitListHTML(commits []commitObject, ref string, compact bool) string { +func (s *webServer) commitListHTML(ctx context.Context, commits []commitObject, ref string, compact bool, selectedHash string) string { if len(commits) == 0 { return `
No commits.
` } @@ -655,11 +3708,21 @@ func commitListHTML(commits []commitObject, ref string, compact bool) string { if commit.timestamp > 0 { when = time.Unix(commit.timestamp, 0).Format("2006-01-02 15:04") } - b.WriteString(`
  • ` + html.EscapeString(firstNonEmpty(commit.subject, shortHash(commit.hash))) + `
    ` + html.EscapeString(commit.author) + ` authored ` + html.EscapeString(when)) + selected := selectedHash != "" && (commit.hash == selectedHash || strings.HasPrefix(commit.hash, selectedHash) || strings.HasPrefix(selectedHash, commit.hash)) + selectedClass := "" + if selected { + selectedClass = ` class="is-selected-commit"` + } + commitURL := webCommitURL(commit.hash, ref) + b.WriteString(`
    ` + html.EscapeString(displayCommitSubject(commit)) + `
    ` + html.EscapeString(commit.author) + ` authored ` + html.EscapeString(when)) if !compact && commit.committer != "" && (commit.committer != commit.author || commit.committerEmail != commit.email) { b.WriteString(` · committed by ` + html.EscapeString(commit.committer)) } - b.WriteString(`
    ` + html.EscapeString(shortHash(commit.hash)) + `
  • `) + b.WriteString(``) + if selected { + b.WriteString(s.commitInlineDetailHTML(ctx, commit, ref)) + } + b.WriteString(``) } if compact { b.WriteString(``) @@ -669,6 +3732,62 @@ func commitListHTML(commits []commitObject, ref string, compact bool) string { return b.String() } +func (s *webServer) commitInlineDetailHTML(ctx context.Context, commit commitObject, ref string) string { + files, additions, deletions, err := s.changedFiles(ctx, commit) + if err != nil { + return `
    ` + html.EscapeString(err.Error()) + `
    ` + } + var b strings.Builder + b.WriteString(`
    `) + b.WriteString(`
    `) + b.WriteString(`

    ` + html.EscapeString(firstNonEmpty(commit.subject, shortHash(commit.hash))) + `

    `) + if commit.body != "" { + b.WriteString(`
    ` + html.EscapeString(commit.body) + `
    `) + } + b.WriteString(`
    `) + b.WriteString(diffFilesPanelHTML(files, additions, deletions)) + b.WriteString(`
    `) + return b.String() +} + +func displayCommitSubject(commit commitObject) string { + const maxSubjectRunes = 80 + subject := firstNonBlankLine(commit.subject) + if subject == "" { + subject = firstNonBlankLine(commit.body) + } + if subject == "" { + return shortHash(commit.hash) + } + runes := []rune(subject) + if len(runes) <= maxSubjectRunes { + return subject + } + return strings.TrimSpace(string(runes[:maxSubjectRunes-1])) + "…" +} + +func firstNonBlankLine(value string) string { + for _, line := range strings.Split(value, "\n") { + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} + func cleanWebPath(value string) string { value = strings.TrimSpace(strings.Trim(value, "/")) if value == "" { @@ -716,9 +3835,9 @@ func urlQueryEscape(value string) string { } func webCommitURL(hash, ref string) string { - value := "/commit/" + hash + value := "/commits?commit=" + urlQueryEscape(hash) if strings.TrimSpace(ref) != "" { - value += "?ref=" + urlQueryEscape(ref) + value += "&ref=" + urlQueryEscape(ref) } return value } @@ -788,6 +3907,52 @@ func (s *webServer) changedFiles(ctx context.Context, commit commitObject) ([]we return files, totalAdditions, totalDeletions, nil } +func (s *webServer) changedFilesBetweenTrees(ctx context.Context, beforeTree, afterTree string) ([]webChangedFile, int, int, error) { + before := map[string]webTreeFile{} + if beforeTree != "" { + if err := s.collectTreeFiles(ctx, beforeTree, "", before); err != nil { + return nil, 0, 0, err + } + } + after := map[string]webTreeFile{} + if afterTree != "" { + if err := s.collectTreeFiles(ctx, afterTree, "", after); err != nil { + return nil, 0, 0, err + } + } + return s.changedFilesBetweenMaps(ctx, before, after) +} + +func (s *webServer) changedFilesBetweenMaps(ctx context.Context, before, after map[string]webTreeFile) ([]webChangedFile, int, int, error) { + seen := map[string]struct{}{} + for path := range before { + seen[path] = struct{}{} + } + for path := range after { + seen[path] = struct{}{} + } + var paths []string + for path := range seen { + if before[path].hash != after[path].hash { + paths = append(paths, path) + } + } + sort.Strings(paths) + var files []webChangedFile + totalAdditions := 0 + totalDeletions := 0 + for _, path := range paths { + file, err := s.changedFile(ctx, path, before[path].hash, after[path].hash) + if err != nil { + return nil, 0, 0, err + } + totalAdditions += file.additions + totalDeletions += file.deletions + files = append(files, file) + } + return files, totalAdditions, totalDeletions, nil +} + func (s *webServer) collectTreeFiles(ctx context.Context, treeHash, prefix string, out map[string]webTreeFile) error { entries, err := s.repo.treeEntries(ctx, treeHash) if err != nil { @@ -807,26 +3972,31 @@ func (s *webServer) collectTreeFiles(ctx context.Context, treeHash, prefix strin } func (s *webServer) changedFile(ctx context.Context, path, oldHash, newHash string) (webChangedFile, error) { - file := webChangedFile{path: path, oldHash: oldHash, newHash: newHash} var oldData, newData []byte if oldHash != "" { obj, err := s.repo.object(ctx, oldHash) if err != nil { - return file, err + return webChangedFile{path: path, oldHash: oldHash, newHash: newHash}, err } oldData = obj.data } if newHash != "" { obj, err := s.repo.object(ctx, newHash) if err != nil { - return file, err + return webChangedFile{path: path, oldHash: oldHash, newHash: newHash}, err } newData = obj.data } + return webChangedFileFromData(path, oldHash, newHash, oldData, newData), nil +} + +func webChangedFileFromData(path, oldHash, newHash string, oldData, newData []byte) webChangedFile { + file := webChangedFile{path: path, oldHash: oldHash, newHash: newHash} if !isTextBlob(oldData) || !isTextBlob(newData) { file.binary = true - return file, nil + return file } + file.visual = webVisualRowsFromText(string(oldData), string(newData), 3) for _, line := range simpleLineDiff(string(oldData), string(newData)) { diffLine := webDiffLine{text: line} switch { @@ -843,33 +4013,364 @@ func (s *webServer) changedFile(ctx context.Context, path, oldHash, newHash stri } file.diff = append(file.diff, diffLine) } - return file, nil + return file } func diffFileHTML(file webChangedFile) string { + return diffFileHTMLWithOptions(file, webDiffRenderOptions{}) +} + +func diffFileHTMLWithOptions(file webChangedFile, opts webDiffRenderOptions) string { var b strings.Builder - b.WriteString(`
    ` + html.EscapeString(file.path) + `
    `) - if file.oldHash == "" { + reviewAttrs := "" + reviewButton := "" + if opts.Review { + reviewAttrs = ` data-review-file="` + html.EscapeString(file.path) + `"` + reviewButton = `` + } + b.WriteString(`
    ` + html.EscapeString(file.path) + `
    `) + if file.oldHash == "" && file.newHash == "" { + b.WriteString(`local changes`) + } else if file.oldHash == "" { b.WriteString(`added`) } else if file.newHash == "" { b.WriteString(`deleted`) } else { b.WriteString(shortHash(file.oldHash) + ` -> ` + shortHash(file.newHash)) } - b.WriteString(`
    +` + strconv.Itoa(file.additions) + ` -` + strconv.Itoa(file.deletions) + `
    `) + b.WriteString(`
    ` + reviewButton + `+` + strconv.Itoa(file.additions) + ` -` + strconv.Itoa(file.deletions) + `
    `) if file.binary { b.WriteString(`
    Binary file changed.
    `) + } else if len(file.visual) > 0 { + b.WriteString(webVisualDiffGridRowsHTML(file.visual)) } else { - b.WriteString(`
    `)
    -		for _, line := range file.diff {
    -			b.WriteString(`` + html.EscapeString(line.text) + ``)
    -		}
    -		b.WriteString(`
    `) + b.WriteString(webVisualDiffGridHTML(file.diff)) } b.WriteString(`
    `) return b.String() } +type webVisualDiffRow struct { + kind string + left string + right string + oldLine string + newLine string + control string + hunk string + hunkIndex int + oldStart int + newStart int + offset int + hidden bool +} + +type webPendingDelete struct { + text string + line int +} + +func webVisualDiffGridHTML(lines []webDiffLine) string { + return webVisualDiffGridRowsHTML(webVisualDiffRows(lines)) +} + +func webVisualDiffGridRowsHTML(rows []webVisualDiffRow) string { + var b strings.Builder + b.WriteString(`
    Before
    After
    `) + for _, row := range rows { + b.WriteString(webVisualDiffRowHTML(row)) + } + b.WriteString(`
    `) + return b.String() +} + +func webVisualRowsFromText(left, right string, context int) []webVisualDiffRow { + a := splitLines(left) + b := splitLines(right) + ops := simpleLineDiffOps(a, b) + hunks := simpleLineDiffHunks(ops, context) + if len(hunks) == 0 { + return nil + } + hunkStarts := map[int]int{} + hunkEnds := map[int]int{} + visible := map[int]struct{}{} + for i, hunk := range hunks { + hunkStarts[hunk.start] = i + hunkEnds[hunk.end] = i + for i := hunk.start; i < hunk.end; i++ { + visible[i] = struct{}{} + } + } + var rows []webVisualDiffRow + var pending []webPendingDelete + flushDeletes := func(hidden bool) { + for _, deleted := range pending { + rows = append(rows, webVisualDiffRow{kind: "del", left: deleted.text, oldLine: strconv.Itoa(deleted.line), hidden: hidden}) + } + pending = nil + } + for i, op := range ops { + if hunkIndex, ok := hunkStarts[i]; ok { + hunk := hunks[hunkIndex] + flushDeletes(false) + oldStart, oldCount, newStart, newCount := simpleDiffHunkRange(ops[hunk.start:hunk.end]) + control := "" + if hunkIndex > 0 && hunks[hunkIndex-1].end < hunk.start { + control = "up" + } + hunkLabel := "Lines " + webLineRangeLabel(oldStart, oldCount) + " -> " + webLineRangeLabel(newStart, newCount) + rows = append(rows, webVisualDiffRow{kind: "hunk-top", left: hunkLabel, control: control, hunk: hunkLabel, hunkIndex: hunkIndex, oldStart: oldStart, newStart: newStart}) + } + _, isVisible := visible[i] + hidden := !isVisible + hunkIndex := webHunkIndexForOp(hunks, i) + hunkLabel := "" + oldStart, newStart := 0, 0 + if hunkIndex >= 0 { + oldStart, _, newStart, _ = simpleDiffHunkRange(ops[hunks[hunkIndex].start:hunks[hunkIndex].end]) + hunkLabel = "Lines " + webLineRangeLabel(oldStart, 1) + " -> " + webLineRangeLabel(newStart, 1) + } + switch op.kind { + case '-': + pending = append(pending, webPendingDelete{text: op.text, line: op.oldLine}) + case '+': + if len(pending) > 0 { + deleted := pending[0] + pending = pending[1:] + rows = append(rows, webVisualDiffRow{kind: "change", left: deleted.text, right: op.text, oldLine: strconv.Itoa(deleted.line), newLine: strconv.Itoa(op.newLine), hidden: hidden, hunk: hunkLabel, hunkIndex: hunkIndex, oldStart: oldStart, newStart: newStart, offset: op.newLine - newStart}) + } else { + rows = append(rows, webVisualDiffRow{kind: "add", right: op.text, newLine: strconv.Itoa(op.newLine), hidden: hidden, hunk: hunkLabel, hunkIndex: hunkIndex, oldStart: oldStart, newStart: newStart, offset: op.newLine - newStart}) + } + default: + flushDeletes(hidden) + rows = append(rows, webVisualDiffRow{kind: "same", left: op.text, right: op.text, oldLine: strconv.Itoa(op.oldLine), newLine: strconv.Itoa(op.newLine), hidden: hidden, hunk: hunkLabel, hunkIndex: hunkIndex, oldStart: oldStart, newStart: newStart, offset: op.newLine - newStart}) + } + if hunkIndex, ok := hunkEnds[i+1]; ok { + hunk := hunks[hunkIndex] + flushDeletes(false) + control := "" + if hunkIndex < len(hunks)-1 && hunk.end < hunks[hunkIndex+1].start { + control = "down" + } + if control != "" { + rows = append(rows, webVisualDiffRow{kind: "hunk-bottom", control: control}) + } + } + } + flushDeletes(true) + return rows +} + +func webHunkIndexForOp(hunks []simpleDiffHunk, opIndex int) int { + for i, hunk := range hunks { + if opIndex >= hunk.start && opIndex < hunk.end { + return i + } + } + return -1 +} + +func webVisualDiffRows(lines []webDiffLine) []webVisualDiffRow { + oldLine, newLine := 0, 0 + var rows []webVisualDiffRow + var pending []webPendingDelete + flushDeletes := func() { + for _, deleted := range pending { + rows = append(rows, webVisualDiffRow{kind: "del", left: deleted.text, oldLine: strconv.Itoa(deleted.line)}) + } + pending = nil + } + for _, line := range lines { + text := line.text + switch line.kind { + case "hunk": + flushDeletes() + oldLine, newLine = webDiffHunkStarts(text) + rows = append(rows, webVisualDiffRow{kind: "hunk", left: webDiffDividerLabel(text)}) + case "del": + pending = append(pending, webPendingDelete{text: strings.TrimPrefix(text, "-"), line: oldLine}) + oldLine++ + case "add": + added := strings.TrimPrefix(text, "+") + if len(pending) > 0 { + deleted := pending[0] + pending = pending[1:] + rows = append(rows, webVisualDiffRow{kind: "change", left: deleted.text, right: added, oldLine: strconv.Itoa(deleted.line), newLine: strconv.Itoa(newLine)}) + } else { + rows = append(rows, webVisualDiffRow{kind: "add", right: added, newLine: strconv.Itoa(newLine)}) + } + newLine++ + default: + flushDeletes() + value := strings.TrimPrefix(text, " ") + rows = append(rows, webVisualDiffRow{kind: "same", left: value, right: value, oldLine: strconv.Itoa(oldLine), newLine: strconv.Itoa(newLine)}) + oldLine++ + newLine++ + } + } + flushDeletes() + return rows +} + +func webVisualDiffRowHTML(row webVisualDiffRow) string { + if row.kind == "hunk" || row.kind == "hunk-top" || row.kind == "hunk-bottom" || row.kind == "note" { + controls := webDiffContextControlHTML(row.control) + label := html.EscapeString(row.left) + if row.kind == "hunk" { + label = html.EscapeString(webDiffDividerLabel(row.left)) + } + if row.kind == "hunk-bottom" && label == "" { + label = `More context` + } else { + label = `` + label + `` + } + return `
    ` + controls + label + `
    ` + } + left := webDiffCellHTML(row.left, row.kind == "del", false) + right := webDiffCellHTML(row.right, false, row.kind == "add") + if row.kind == "change" { + left, right = webInlineChangedHTML(row.left, row.right) + } + hidden := "" + if row.hidden { + hidden = ` data-hidden-context="true" hidden` + } + attrs := ` data-hunk-index="` + strconv.Itoa(row.hunkIndex) + `" data-hunk="` + html.EscapeString(row.hunk) + `" data-old-start="` + strconv.Itoa(row.oldStart) + `" data-new-start="` + strconv.Itoa(row.newStart) + `" data-offset="` + strconv.Itoa(row.offset) + `"` + return `` +} + +func webDiffContextControlHTML(control string) string { + switch control { + case "up": + return `` + case "down": + return `` + default: + return "" + } +} + +func webDiffCellHTML(text string, deleted, added bool) string { + if text == "" { + return "" + } + value := html.EscapeString(text) + switch { + case deleted: + return `` + value + `` + case added: + return `` + value + `` + default: + return value + } +} + +func webInlineChangedHTML(left, right string) (string, string) { + prefix := 0 + for prefix < len(left) && prefix < len(right) && left[prefix] == right[prefix] { + prefix++ + } + suffix := 0 + for suffix < len(left)-prefix && suffix < len(right)-prefix && left[len(left)-1-suffix] == right[len(right)-1-suffix] { + suffix++ + } + oldEnd := len(left) - suffix + newEnd := len(right) - suffix + oldChanged := left[prefix:oldEnd] + newChanged := right[prefix:newEnd] + if oldChanged == "" { + oldChanged = " " + } + if newChanged == "" { + newChanged = " " + } + oldHTML := html.EscapeString(left[:prefix]) + `` + html.EscapeString(oldChanged) + `` + html.EscapeString(left[oldEnd:]) + newHTML := html.EscapeString(right[:prefix]) + `` + html.EscapeString(newChanged) + `` + html.EscapeString(right[newEnd:]) + return oldHTML, newHTML +} + +func webDiffHunkStarts(line string) (int, int) { + oldStart, _, newStart, _, ok := webDiffHunkRange(line) + if ok { + return oldStart, newStart + } + return 0, 0 +} + +func webDiffDividerLabel(line string) string { + oldStart, oldCount, newStart, newCount, ok := webDiffHunkRange(line) + if ok { + return "Lines " + webLineRangeLabel(oldStart, oldCount) + " -> " + webLineRangeLabel(newStart, newCount) + } + return line +} + +func webDiffHunkRange(line string) (int, int, int, int, bool) { + fields := strings.Fields(line) + if len(fields) < 3 || fields[0] != "@@" { + return 0, 0, 0, 0, false + } + oldStart, oldCount, ok := webParseHunkPart(fields[1], "-") + if !ok { + return 0, 0, 0, 0, false + } + newStart, newCount, ok := webParseHunkPart(fields[2], "+") + if !ok { + return 0, 0, 0, 0, false + } + return oldStart, oldCount, newStart, newCount, true +} + +func webParseHunkPart(part, prefix string) (int, int, bool) { + part = strings.TrimPrefix(part, prefix) + if part == "" { + return 0, 0, false + } + startText, countText, hasCount := strings.Cut(part, ",") + start, err := strconv.Atoi(startText) + if err != nil { + return 0, 0, false + } + count := 1 + if hasCount { + count, err = strconv.Atoi(countText) + if err != nil { + return 0, 0, false + } + } + return start, count, true +} + +func webLineRangeLabel(start, count int) string { + if count <= 1 { + return strconv.Itoa(start) + } + return strconv.Itoa(start) + "-" + strconv.Itoa(start+count-1) +} + +func diffFilesPanelHTML(files []webChangedFile, additions, deletions int) string { + return diffFilesPanelHTMLWithOptions(files, additions, deletions, webDiffRenderOptions{}) +} + +func diffFilesPanelHTMLWithOptions(files []webChangedFile, additions, deletions int, opts webDiffRenderOptions) string { + var b strings.Builder + b.WriteString(`
    ` + strconv.Itoa(len(files)) + ` changed file` + pluralSuffix(len(files)) + `+` + strconv.Itoa(additions) + `-` + strconv.Itoa(deletions) + `
    `) + if len(files) == 0 { + b.WriteString(`
    No file changes.
    `) + return b.String() + } + b.WriteString(``) + for _, file := range files { + b.WriteString(``) + } + b.WriteString(`
    ` + html.EscapeString(file.path) + `+` + strconv.Itoa(file.additions) + ` -` + strconv.Itoa(file.deletions) + `
    `) + for _, file := range files { + b.WriteString(diffFileHTMLWithOptions(file, opts)) + } + return b.String() +} + func metadataItemHTML(label, name, email string, ts int64) string { var b strings.Builder b.WriteString(`
    ` + html.EscapeString(label) + `` + html.EscapeString(name) + ``) @@ -894,7 +4395,55 @@ func relativeTime(ts int64) string { if ts == 0 { return "at an unknown time" } - return time.Unix(ts, 0).Format("2006-01-02 15:04") + then := time.Unix(ts, 0) + diff := time.Since(then) + suffix := "ago" + if diff < 0 { + diff = -diff + suffix = "from now" + } + minute := time.Minute + hour := time.Hour + day := 24 * hour + week := 7 * day + month := 30 * day + year := 365 * day + switch { + case diff < minute: + return "just now" + case diff < hour: + return relativeTimeUnit(int(diff/minute), "minute", suffix) + case diff < day: + return relativeTimeUnit(int(diff/hour), "hour", suffix) + case diff < week: + return relativeTimeUnit(int(diff/day), "day", suffix) + case diff < month: + return relativeTimeUnit(int(diff/week), "week", suffix) + case diff < year: + return relativeTimeUnit(int(diff/month), "month", suffix) + default: + return relativeTimeUnit(int(diff/year), "year", suffix) + } +} + +func parseTime(value string) int64 { + if value == "" { + return 0 + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed.Unix() + } + return 0 +} + +func relativeTimeUnit(count int, unit, suffix string) string { + if count < 1 { + count = 1 + } + if count != 1 { + unit += "s" + } + return strconv.Itoa(count) + " " + unit + " " + suffix } func firstNonZero(values ...int64) int64 { @@ -926,94 +4475,148 @@ func anchorID(value string) string { return strings.Trim(b.String(), "-") } -const webCSS = ` -:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --border: color-mix(in srgb, CanvasText 16%, transparent); --muted: color-mix(in srgb, CanvasText 62%, transparent); --panel: color-mix(in srgb, Canvas 94%, CanvasText 6%); } -* { box-sizing: border-box; } -body { margin: 0; background: color-mix(in srgb, Canvas 96%, CanvasText 4%); color: CanvasText; } -a { color: #0969da; text-decoration: none; } -a:hover { text-decoration: underline; } -code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.layout { max-width: 1180px; margin: 0 auto; padding: 20px 24px 36px; } -.repo-header { border-bottom: 1px solid var(--border); margin-bottom: 16px; padding: 8px 0 0; } -.repo-topline { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } -.repo-kicker { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } -h1 { font-size: 22px; line-height: 1.25; margin: 2px 0 12px; overflow-wrap: anywhere; } -h2 { font-size: 21px; line-height: 1.3; margin: 0 0 12px; } -.ref-pill { border: 1px solid var(--border); border-radius: 999px; padding: 5px 10px; font-size: 13px; background: Canvas; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ref-selector { display: grid; gap: 4px; min-width: 210px; max-width: 320px; } -.ref-selector span { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } -.ref-selector select { min-height: 34px; border: 1px solid var(--border); border-radius: 6px; background: Canvas; color: CanvasText; padding: 0 32px 0 10px; font: inherit; font-size: 13px; max-width: 100%; } -.tabs { display: flex; gap: 6px; margin-top: 4px; } -.tabs a { display: inline-flex; align-items: center; min-height: 36px; padding: 0 12px; border-bottom: 2px solid transparent; color: CanvasText; font-weight: 600; } -.tabs a:first-child { border-bottom-color: #fd8c73; } -.crumbs { margin: 10px 0 12px; color: var(--muted); font-size: 13px; overflow-wrap: anywhere; } -.panel, .clone-panel { background: Canvas; border: 1px solid var(--border); border-radius: 8px; margin: 14px 0; overflow: hidden; } -.panel-title { font-size: 14px; font-weight: 700; padding: 12px 14px; border-bottom: 1px solid var(--border); background: var(--panel); } -.clone-panel { display: grid; grid-template-columns: minmax(180px, 270px) 1fr; gap: 14px; padding: 14px; align-items: start; } -.clone-panel .panel-title { padding: 0; border: 0; background: transparent; } -.clone-grid { display: grid; gap: 8px; min-width: 0; } -.clone-row { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; gap: 8px; align-items: center; } -.clone-row span { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } -.clone-row code { border: 1px solid var(--border); background: var(--panel); min-height: 34px; padding: 8px 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.copy-button, .button-link { border: 1px solid var(--border); background: Canvas; color: CanvasText; border-radius: 6px; min-height: 34px; padding: 0 10px; font: inherit; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; } -.copy-button:hover, .button-link:hover { background: var(--panel); text-decoration: none; } -.commit-strip { display: flex; justify-content: space-between; gap: 14px; align-items: center; padding: 12px 14px; } -.commit-subject { color: CanvasText; font-weight: 700; } -.muted, .meta { color: var(--muted); font-size: 13px; } -.files { border-collapse: collapse; width: 100%; } -.files td { border-top: 1px solid var(--border); padding: 10px 12px; vertical-align: top; } -.files tr:first-child td { border-top: 0; } -.kind { width: 54px; color: var(--muted); text-transform: uppercase; font-size: 11px; font-weight: 700; } -.hash { width: 112px; text-align: right; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } -.readme-panel .panel-title { border-bottom: 1px solid var(--border); } -.blob, .readme pre, .commit-message { margin: 0; padding: 14px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; line-height: 1.5; background: Canvas; } -.blob-toolbar { display: flex; justify-content: space-between; gap: 14px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--border); background: var(--panel); } -.blob-toolbar .panel-title { padding: 0; border: 0; background: transparent; overflow-wrap: anywhere; } -.actions { display: flex; gap: 8px; align-items: center; margin: 10px 14px 14px; font-size: 13px; } -.blob-toolbar .actions { margin: 0; } -.commits { list-style: none; margin: 0; padding: 0; } -.commits li { display: flex; justify-content: space-between; gap: 16px; padding: 12px 14px; border-top: 1px solid var(--border); } -.commits li:first-child { border-top: 0; } -.commit-detail { padding: 16px; } -.commit-detail .commit-message { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 14px; background: var(--panel); } -.metadata-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } -.metadata-grid div { min-width: 0; } -.metadata-grid span { display: block; color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; margin-bottom: 3px; } -.metadata-grid small { display: block; color: var(--muted); overflow-wrap: anywhere; } -.diff-summary { display: flex; gap: 12px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--border); } -.additions { color: #1a7f37; font-weight: 700; } -.deletions { color: #cf222e; font-weight: 700; } -.changed-files .diff-stat { width: 120px; text-align: right; } -.diff-file { scroll-margin-top: 16px; } -.diff-header { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--border); background: var(--panel); overflow-wrap: anywhere; } -.diff { margin: 0; overflow: auto; font-size: 12px; line-height: 1.45; } -.diff-line { display: block; padding: 0 12px; white-space: pre; } -.diff-line.add { background: color-mix(in srgb, #2da44e 16%, Canvas); } -.diff-line.del { background: color-mix(in srgb, #cf222e 14%, Canvas); } -.diff-line.hunk { background: color-mix(in srgb, #0969da 12%, Canvas); color: #0969da; } -.empty { margin: 14px; border: 1px solid var(--border); border-radius: 6px; padding: 14px; color: var(--muted); } -.sr-only { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; } -@media (max-width: 720px) { .layout { padding: 14px; } .repo-topline, .commit-strip, .blob-toolbar, .diff-header { flex-direction: column; align-items: stretch; } .ref-selector { max-width: none; } .clone-panel { grid-template-columns: 1fr; } .clone-row { grid-template-columns: 1fr; } .hash { display: none; } .commits li { flex-direction: column; gap: 6px; } .metadata-grid { grid-template-columns: 1fr; } } -` - -const webJS = `` +type webEventHub struct { + mu sync.Mutex + clients map[chan string]struct{} +} + +func newWebEventHub() *webEventHub { + return &webEventHub{clients: map[chan string]struct{}{}} +} + +func (h *webEventHub) subscribe() chan string { + ch := make(chan string, 8) + h.mu.Lock() + h.clients[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +func (h *webEventHub) unsubscribe(ch chan string) { + h.mu.Lock() + delete(h.clients, ch) + close(ch) + h.mu.Unlock() +} + +func (h *webEventHub) broadcast(name string) { + payload := fmt.Sprintf("event: %s\ndata: {\"time\":%d}\n\n", name, time.Now().UnixMilli()) + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.clients { + select { + case ch <- payload: + default: + } + } +} + +func (h *webEventHub) broadcastJSON(name string, value any) { + data, err := json.Marshal(value) + if err != nil { + return + } + payload := fmt.Sprintf("event: %s\ndata: %s\n\n", name, data) + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.clients { + select { + case ch <- payload: + default: + } + } +} + +func monitorWebPath(ctx context.Context, root, eventName string, hub *webEventHub) { + if root == "" || hub == nil { + return + } + last := webPathFingerprint(root) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + next := webPathFingerprint(root) + if next != "" && next != last { + last = next + hub.broadcast(eventName) + } + } + } +} + +func webPathFingerprint(root string) string { + var newest int64 + var count int + _ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return nil + } + if rel, relErr := filepath.Rel(root, path); relErr == nil { + slashRel := filepath.ToSlash(rel) + if strings.HasPrefix(slashRel, "refs/remotes/bucketgit/") || strings.HasPrefix(slashRel, "refs/tags/") { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + } + name := entry.Name() + if entry.IsDir() { + if name == "objects" || name == "tmp" || name == "bucketgit" { + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(name, ".lock") { + return nil + } + info, err := entry.Info() + if err != nil { + return nil + } + count++ + if mod := info.ModTime().UnixNano(); mod > newest { + newest = mod + } + return nil + }) + return fmt.Sprintf("%d:%d", newest, count) +} + +func webAssetString(path string) string { + data, err := webAssetBytes(path) + if err != nil { + return "" + } + return string(data) +} + +func webAssetBytes(path string) ([]byte, error) { + if diskPath := webAssetDiskPath(path); diskPath != "" { + if data, err := os.ReadFile(diskPath); err == nil { + return data, nil + } + } + return webAssets.ReadFile(path) +} + +func webAssetDiskPath(path string) string { + if _, err := os.Stat(path); err == nil { + return path + } + if exe, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exe), path) + if _, statErr := os.Stat(candidate); statErr == nil { + return candidate + } + } + return "" +} + +func webAssetDir() string { + return filepath.Dir(webAssetDiskPath(webJSPath)) +} diff --git a/www/app.css b/www/app.css new file mode 100644 index 0000000..6a78ef9 --- /dev/null +++ b/www/app.css @@ -0,0 +1,2398 @@ +:root { + --logo-bg: #fdffff; + --ink: #101820; + --muted: #53606b; + --line: #d9e2e8; + --panel: #ffffff; + --green: #0f7f68; + --blue: #255f91; + --navy: #33475b; + --code-bg: #e8f1ff; + --code-ink: #164b8f; + --code-border: #b9d3ff; + --terminal-bg: #384b5f; + --terminal-ink: #f1f7ff; + --heading: #26394d; + --google-blue-soft: #e8f1ff; + --google-blue: var(--blue); + --shadow: 0 24px 70px rgba(16, 24, 32, 0.14); + --bg: #ffffff; + --surface: var(--panel); + --repo-area-bg: #ffffff; + --surface-2: #f6f9fb; + --surface-3: var(--code-bg); + --text: var(--ink); + --border: var(--line); + --border-strong: #b8c8d3; + --repo-border: #9fb1c0; + --repo-rail-width: 100%; + --repo-rail-margin: 0px; + --repo-side-width: 320px; + --repo-gap: 24px; + --repo-pad: 16px; + --repo-side-toolbar-offset: -62px; + --accent: var(--green); + --accent-2: var(--blue); + --accent-soft: var(--google-blue-soft); + --warning: #9a5b00; + --add: #0f7f68; + --del: #d37768; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +:root[data-theme="dark"] { + --logo-bg: #1a2432; + --bg: #1a2432; + --ink: #f1f7ff; + --muted: #a8b5c1; + --line: #405266; + --panel: #1a2432; + --green: #7fb9ab; + --blue: #9dc8ff; + --navy: #dce9f5; + --code-bg: #1f3856; + --code-ink: #cfe4ff; + --code-border: #426c9c; + --terminal-bg: #0b1118; + --terminal-ink: #f1f7ff; + --heading: #f1f7ff; + --google-blue-soft: #182f49; + --shadow: 0 24px 70px rgba(0, 0, 0, 0.36); + --repo-area-bg: var(--panel); + --surface-2: #132032; + --surface-3: #1f3856; + --border-strong: #53697d; + --repo-border: #3d4c5d; + --warning: #d6b85e; + --add: #7fb9ab; + --del: #d37768; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --logo-bg: #1a2432; + --bg: #1a2432; + --ink: #f1f7ff; + --muted: #a8b5c1; + --line: #405266; + --panel: #1a2432; + --green: #7fb9ab; + --blue: #9dc8ff; + --navy: #dce9f5; + --code-bg: #1f3856; + --code-ink: #cfe4ff; + --code-border: #426c9c; + --terminal-bg: #0b1118; + --terminal-ink: #f1f7ff; + --heading: #f1f7ff; + --google-blue-soft: #182f49; + --shadow: 0 24px 70px rgba(0, 0, 0, 0.36); + --repo-area-bg: var(--panel); + --surface-2: #132032; + --surface-3: #1f3856; + --border-strong: #53697d; + --repo-border: #3d4c5d; + --warning: #d6b85e; + --add: #7fb9ab; + --del: #d37768; + } +} + +* { box-sizing: border-box; } +html { min-height: 100%; } +body { + min-height: 100%; + margin: 0; + background: var(--bg); + color: var(--text); + font-size: 14px; + letter-spacing: 0; + line-height: 1.45; +} + +.sync-status { + position: fixed; + left: 50%; + bottom: 18px; + z-index: 20; + display: flex; + min-height: 42px; + width: calc((var(--repo-rail-width) - var(--repo-side-width) - var(--repo-gap) - (2 * var(--repo-pad))) * 0.72); + max-width: calc(100vw - 28px); + align-items: center; + justify-content: center; + padding: 10px 44px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--accent-2) 26%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--panel) 96%, transparent); + box-shadow: 0 18px 48px rgba(16, 24, 32, 0.18); + color: var(--heading); + font-size: 13px; + font-weight: 800; + letter-spacing: 0.01em; + text-align: center; + opacity: 0; + transform: translate(-50%, 6px); + transition: opacity 160ms ease, transform 160ms ease; + pointer-events: none; +} +.sync-status::before { + position: absolute; + left: 18px; + display: block; + width: 7px; + height: 7px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 12%, transparent); + content: ""; +} +.sync-status.is-visible { + opacity: 1; + transform: translate(-50%, 0); +} +.sync-status.is-stale { + border-color: color-mix(in srgb, var(--warning) 42%, var(--border)); + background: color-mix(in srgb, var(--panel) 96%, transparent); + color: var(--warning); +} +.sync-status.is-error { + border-color: color-mix(in srgb, var(--del) 46%, var(--border)); + background: color-mix(in srgb, var(--panel) 96%, transparent); + color: var(--del); +} +.sync-status.is-current { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); + background: color-mix(in srgb, var(--panel) 96%, transparent); + color: var(--accent); +} + +a { color: var(--accent-2); text-decoration: none; } +a:hover { text-decoration: underline; text-underline-offset: 2px; } +a:focus-visible, button:focus-visible, select:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent-2) 70%, transparent); + outline-offset: 2px; +} +code, pre { font-family: var(--mono); } + +.layout { + width: min(100% - 48px, 1920px); + margin: 0 auto; + padding: 20px 0 42px; + display: block; +} +.repo-header { + position: relative; + margin-bottom: 18px; + padding: 18px 0 0; +} +h1 { + max-width: 100%; + margin: 0 0 12px; + color: var(--heading); + font-size: 24px; + font-weight: 800; + line-height: 1.2; + overflow-wrap: anywhere; +} +.repo-controls { + display: flex; + min-width: 0; + align-items: center; + justify-content: end; +} +.tabs-row { + position: relative; + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr) minmax(0, auto); + align-items: stretch; + gap: 12px; + width: var(--repo-rail-width); + margin-right: auto; + margin-left: auto; + border-bottom: 1px solid var(--border); +} +.tabs-row .repo-controls { + min-height: 40px; +} +.repo-policy-banner { + width: var(--repo-rail-width); + box-sizing: border-box; + margin: 10px auto 0; + padding: 9px 12px; + border: 1px solid color-mix(in srgb, var(--del) 35%, var(--repo-border)); + border-radius: 8px; + background: color-mix(in srgb, var(--del) 9%, var(--surface)); + color: var(--heading); + font-size: 13px; + font-weight: 800; +} +.repo-action-control { + display: flex; + min-height: 40px; + align-items: center; + gap: 8px; + justify-content: end; + min-width: 0; +} +.repo-action-button { + display: inline-flex; + min-height: 32px; + align-items: center; + justify-content: center; + padding: 0 15px; + border: 1px solid color-mix(in srgb, var(--del) 72%, var(--border)); + border-radius: 7px; + background: var(--del); + color: #fff; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 900; + line-height: 1; + white-space: nowrap; +} +.repo-action-button[hidden] { + display: none; +} +.repo-action-button:hover { + filter: brightness(0.94); +} +.repo-action-button:disabled, +.inline-action:disabled { + cursor: wait; + filter: grayscale(0.18); + opacity: 0.72; +} +.is-capability-disabled { + cursor: not-allowed; + opacity: 0.55; +} +.repo-action-button.pull { + border-color: color-mix(in srgb, var(--warning) 72%, var(--border)); + background: var(--warning); +} +.repo-action-button.push { + border-color: color-mix(in srgb, var(--blue) 72%, var(--border)); + background: var(--blue); +} +.repo-action-button.uncommit { + border-color: color-mix(in srgb, var(--del) 60%, var(--border)); + background: color-mix(in srgb, var(--del) 86%, #000); +} +.repo-action-button.commit { + border-color: color-mix(in srgb, var(--del) 72%, var(--border)); + background: var(--del); +} +.repo-header-location { + max-width: min(42vw, 640px); + overflow: hidden; + color: var(--muted); + font-family: var(--mono); + font-size: 12px; + line-height: 1.2; + opacity: 0.62; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.theme-toggle { + position: fixed; + top: 18px; + right: 18px; + z-index: 30; + display: inline-flex; + flex: 0 0 auto; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--code-ink); + cursor: pointer; +} +.theme-toggle:hover { + color: var(--blue); + background: color-mix(in srgb, var(--code-bg) 72%, transparent); +} +.theme-symbol { + display: block; + width: 18px; + height: 18px; + fill: currentColor; +} +.theme-auto { + position: absolute; + right: 3px; + bottom: 1px; + display: none; + min-width: 13px; + height: 13px; + align-items: center; + justify-content: center; + border: 1px solid var(--code-border); + background: var(--panel); + color: var(--code-ink); + font-size: 9px; + font-weight: 800; + line-height: 1; +} +.theme-toggle[data-theme-state="auto"] .theme-auto { + display: inline-flex; +} +:root[data-theme="dark"] .theme-toggle:hover { + color: var(--blue); + background: color-mix(in srgb, var(--code-bg) 72%, transparent); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .theme-toggle:hover { + color: var(--blue); + background: color-mix(in srgb, var(--code-bg) 72%, transparent); + } +} +h2 { + margin: 0 0 14px; + font-size: 21px; + color: var(--heading); + font-weight: 800; + line-height: 1.3; +} + +.remote-sync { + display: flex; + align-items: center; + justify-content: end; + gap: 6px; + height: 36px; + min-width: 0; +} +.remote-badge { + display: inline-flex; + width: 142px; + min-height: 24px; + align-items: center; + justify-content: center; + padding: 2px 10px; + overflow: hidden; + border: 1px solid var(--code-border); + border-radius: 999px; + background: var(--code-bg); + color: var(--code-ink); + font-size: 10px; + font-weight: 800; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} +.remote-badge.is-syncing { + border-color: color-mix(in srgb, var(--warning) 58%, var(--border)); + background: color-mix(in srgb, var(--warning) 18%, var(--panel)); + color: var(--warning); +} +.remote-badge.is-current { + border-color: color-mix(in srgb, var(--add) 54%, var(--border)); + background: color-mix(in srgb, var(--add) 15%, var(--panel)); + color: var(--add); +} +.remote-badge.is-error { + border-color: color-mix(in srgb, var(--del) 58%, var(--border)); + background: color-mix(in srgb, var(--del) 14%, var(--panel)); + color: var(--del); +} +.remote-badge.is-ahead, +.remote-badge.is-dirty { + border-color: color-mix(in srgb, var(--del) 58%, var(--border)); + background: color-mix(in srgb, var(--del) 13%, var(--panel)); + color: var(--del); +} +.remote-badge.is-behind { + border-color: color-mix(in srgb, var(--warning) 64%, var(--border)); + background: color-mix(in srgb, var(--warning) 16%, var(--panel)); + color: var(--warning); + cursor: pointer; +} +.remote-refresh { + display: inline-flex; + width: 24px; + height: 36px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 0; + border-radius: 0; + background: transparent; + color: var(--code-ink); + cursor: pointer; + font: inherit; + font-size: 13px; + line-height: 1; +} +.remote-refresh svg { + display: block; + width: 18px; + height: 18px; + fill: currentColor; +} +.remote-refresh.is-current svg { + color: var(--add); +} +.remote-refresh:not(:disabled):hover { + color: var(--blue); +} +.remote-refresh:disabled { + cursor: default; + opacity: 0.72; +} +.remote-refresh.is-spinning { + animation: remote-refresh-spin 900ms linear infinite; +} +@keyframes remote-refresh-spin { + to { transform: rotate(360deg); } +} + +.ref-pill, +.ref-selector select { + min-height: 36px; + border: 1px solid var(--border); + border-radius: 0; + background: var(--logo-bg); + color: var(--text); + box-shadow: none; +} +.ref-pill { + max-width: 320px; + padding: 8px 11px; + overflow: hidden; + font-family: var(--mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.ref-selector { + display: block; + min-width: 180px; + flex: 0 0 180px; +} +.ref-selector span { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} +.ref-selector select { + width: 100%; + padding: 0 34px 0 11px; + font: inherit; + font-size: 13px; +} + +.tabs { + display: flex; + gap: 2px; + min-width: 0; +} +.tabs a { + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 0 13px; + border-bottom: 2px solid transparent; + color: var(--muted); + font-weight: 800; +} +.tabs a.active { + border-bottom-color: var(--blue); + color: var(--heading); +} +.repo-toolbar { + position: relative; + z-index: 0; + display: grid; + grid-template-columns: minmax(0, auto) minmax(260px, 1fr); + align-items: center; + gap: 12px; + width: calc(100% - var(--repo-side-width) - var(--repo-gap) - (2 * var(--repo-pad))); + margin: 14px 0 10px; + margin-left: var(--repo-pad); +} +.repo-toolbar::before { + position: absolute; + z-index: -1; + top: -18px; + left: calc(-1 * var(--repo-pad)); + width: calc(100% + var(--repo-gap) + var(--repo-side-width) + (2 * var(--repo-pad))); + height: calc(100% + 28px); + background: var(--repo-area-bg); + content: ""; +} +.repo-toolbar-left, +.repo-toolbar-right { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.repo-toolbar-right { + justify-content: end; +} +.ref-count { + display: inline-flex; + min-height: 36px; + align-items: center; + gap: 5px; + color: var(--heading); + font-size: 13px; + font-weight: 800; + white-space: nowrap; +} +.file-search { + position: relative; + flex: 0 1 260px; + min-width: 150px; +} +.file-search label { + display: block; +} +.file-search input { + width: 100%; + min-height: 36px; + padding: 0 11px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 13px; +} +.file-search-results { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 30; + width: min(620px, calc(100vw - 32px)); + max-height: min(560px, calc(100vh - 220px)); + overflow: auto; + padding: 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} +.file-search-results a { + display: flex; + min-height: 34px; + align-items: center; + gap: 9px; + padding: 6px 10px; + border-radius: 6px; + color: var(--text); + font-size: 14px; + font-weight: 700; +} +.file-search-results a:hover, +.file-search-results a.active { + background: var(--code-bg); + text-decoration: none; +} +.file-search-icon { + width: 18px; + flex: 0 0 18px; + color: var(--muted); + text-align: center; +} +.code-menu { + position: relative; + flex: 0 0 auto; +} +.code-menu-button { + display: inline-flex; + min-height: 36px; + align-items: center; + gap: 8px; + padding: 0 13px; + border: 1px solid color-mix(in srgb, var(--add) 54%, var(--border)); + border-radius: 6px; + background: var(--add); + color: var(--logo-bg); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 800; +} +.code-menu-popover { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 20; + width: min(440px, calc(100vw - 32px)); + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} +.repo-content { + position: relative; + z-index: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) var(--repo-side-width); + gap: var(--repo-gap); + align-items: start; + width: var(--repo-rail-width); + margin-right: auto; + margin-left: auto; + padding: var(--repo-pad); + background: var(--repo-area-bg); + clear: both; +} +.repo-primary { + display: grid; + gap: 14px; + width: 100%; + padding: 0; + background: var(--repo-area-bg); +} +.repo-primary > .panel { + margin: 0; + box-shadow: none; +} +.repo-content-single { + grid-template-columns: minmax(0, 1fr) var(--repo-side-width); + margin-top: -18px; + padding-top: 0; +} +.repo-content-single .repo-primary { + padding-top: var(--repo-pad); +} +.repo-content-single .repo-primary { + grid-column: 1; +} +.repo-side-panel { + position: relative; + z-index: 1; + display: grid; + gap: 16px; + margin-top: var(--repo-side-toolbar-offset); + width: calc(100% + var(--repo-pad)); + padding: 0; + padding-right: var(--repo-pad); + border: 0; + background: var(--repo-area-bg); + box-shadow: none; + color: var(--muted); +} +.side-panel-section { + padding-bottom: 16px; + border-bottom: 0; +} +.side-panel-section + .side-panel-section { + padding-top: 4px; +} +.side-panel-section:last-child { + padding-bottom: 0; + border-bottom: 0; +} +.side-panel-section h2 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 10px; + color: var(--heading); + font-size: 15px; + font-weight: 800; +} +.side-panel-section p { + margin: 0 0 10px; + font-size: 13px; +} +.side-panel-section p:first-of-type { + text-align: justify; +} +.side-panel-section p a { + color: var(--accent-2); + font-weight: 800; + text-decoration: underline; + text-decoration-color: var(--accent-2); + text-underline-offset: 2px; +} +.side-panel-section p a:visited { + color: var(--accent-2); + text-decoration-color: var(--accent-2); +} +.repo-identity-name { + display: inline; + margin-left: 8px; + color: var(--heading); + font-size: 15px; + font-weight: 800; + line-height: 1.2; + overflow-wrap: anywhere; +} +.repo-identity h2 { + display: inline; +} +.side-links { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} +.side-links .icon-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--accent-2); + font-size: 13px; + font-weight: 800; + text-decoration: none; +} +.side-links .icon-link:hover { + text-decoration: underline; +} +.side-links .icon-link svg { + display: block; + width: 18px; + height: 18px; + fill: currentColor; +} +.count-badge { + display: inline-flex; + min-width: 22px; + height: 22px; + align-items: center; + justify-content: center; + padding: 0 7px; + border: 1px solid var(--border-strong); + border-radius: 999px; + background: var(--surface-2); + color: var(--heading); + font-size: 12px; +} +.contributors-list { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} +.contributors-list li { + display: flex; + min-width: 0; + align-items: center; + font-size: 13px; +} +.contributors-list span { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} +.panel, +.clone-panel { + display: block; + width: 100%; + clear: both; + margin: 14px 0; + overflow: hidden; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} +.panel-title { + padding: 11px 14px; + border-bottom: 1px solid var(--repo-border); + background: var(--surface); + color: var(--heading); + font-size: 13px; + font-weight: 800; +} + +.clone-panel { + padding: 10px 12px 12px; +} +.clone-widget { + padding: 10px 12px 12px; +} +.clone-panel .clone-widget { + padding: 0; +} +.clone-panel .panel-title, +.clone-widget .panel-title { + padding: 0; + border: 0; + background: transparent; + color: var(--heading); + font-size: 17px; +} +.clone-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-bottom: 8px; + border-bottom: 1px solid var(--border); +} +.clone-tabs { + display: flex; + gap: 18px; + min-width: 0; +} +.clone-tabs button { + min-height: 32px; + padding: 0 0 6px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 800; +} +.clone-tabs button.active { + border-bottom-color: var(--blue); + color: var(--heading); +} +.clone-pane { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: stretch; +} +.clone-pane[hidden] { display: none; } +.clone-download { + margin: 12px -12px -12px; + padding: 10px 12px; + border-top: 1px solid var(--border); +} +.clone-download a { + color: var(--heading); + font-weight: 800; +} +.kind { + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} +.clone-pane code { + min-height: 34px; + overflow: hidden; + padding: 7px 10px; + border: 1px solid var(--code-border); + border-radius: 0; + background: var(--code-bg); + color: var(--code-ink); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} +.copy-button, +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 11px; + border: 1px solid var(--code-border); + border-radius: 0; + background: var(--code-bg); + color: var(--code-ink); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 800; +} +.copy-icon-button { + width: 34px; + min-width: 34px; + padding: 0; + font-size: 16px; +} +.copy-icon-button.is-copied { + border-color: color-mix(in srgb, var(--add) 54%, var(--border)); + background: color-mix(in srgb, var(--add) 15%, var(--panel)); + color: var(--add); +} +.copy-button:hover, +.button-link:hover { + border-color: rgba(37, 95, 145, 0.55); + background: #d9e9ff; + text-decoration: none; +} +:root[data-theme="dark"] .copy-button:hover, +:root[data-theme="dark"] .button-link:hover { + border-color: #5f8ec1; + background: #26476c; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .copy-button:hover, + :root:not([data-theme]) .button-link:hover { + border-color: #5f8ec1; + background: #26476c; + } +} + +.commit-strip { + display: flex; + gap: 12px; + align-items: center; + padding: 13px 14px; + border-bottom: 1px solid var(--repo-border); + background: var(--surface-2); +} +.commit-strip code, +.commits code, +.hash { + color: var(--muted); + font-family: var(--mono); + font-size: 12px; +} +.commit-hash-link { + text-decoration: none; +} +.commit-hash-link:hover code { + color: var(--accent-2); + text-decoration: underline; + text-underline-offset: 2px; +} +.commit-subject { + color: var(--heading); + font-weight: 800; + max-width: min(48ch, 45vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.commit-author { + color: var(--heading); + font-weight: 800; + white-space: nowrap; +} +.commit-meta { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + white-space: nowrap; +} +.commit-when { + color: var(--muted); + white-space: nowrap; +} +.muted, +.meta { + color: var(--muted); + font-size: 13px; +} + +.files { + width: 100%; + border-collapse: collapse; +} +.files tr { + transition: background 120ms ease; +} +.files tr:hover { + background: var(--google-blue-soft); +} +.files tr.is-state-dirty, +.files tr.is-state-ahead, +.commits li.is-state-dirty, +.commits li.is-state-ahead { + background: color-mix(in srgb, var(--del) 8%, var(--panel)); +} +.files tr.is-state-behind, +.commits li.is-state-behind { + background: color-mix(in srgb, var(--warning) 10%, var(--panel)); +} +.files tr[hidden] { + display: none; +} +.files td { + padding: 10px 12px; + border-top: 1px solid var(--repo-border); + vertical-align: middle; +} +.files tr:first-child td { border-top: 0; } +.files td:nth-child(2) { + min-width: 0; + overflow-wrap: anywhere; + font-weight: 620; +} +.kind { + width: 56px; + color: var(--muted); +} +.hash { + width: 118px; + text-align: right; +} + +.readme-panel { + margin-top: 0; +} +.readme-panel .panel-title { border-bottom: 1px solid var(--repo-border); } +.readme { + min-height: 110px; +} +.blob, +.readme pre, +.commit-message { + margin: 0; + padding: 15px; + overflow: auto; + background: var(--panel); + font-size: 13px; + line-height: 1.55; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.blob { + background: var(--panel); +} +.blob-toolbar { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: center; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.blob-toolbar .panel-title { + padding: 0; + border: 0; + background: transparent; + overflow-wrap: anywhere; +} +.actions { + display: flex; + gap: 8px; + align-items: center; + margin: 10px 14px 14px; + font-size: 13px; +} +.blob-toolbar .actions { margin: 0; } + +.commits { + margin: 0; + padding: 0; + list-style: none; +} +.commits li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + padding: 8px 10px; + border-top: 1px solid var(--border-strong); +} +.commits li[data-commit-href] { + cursor: pointer; +} +.commits li:first-child { border-top: 0; } +.commits li:hover { background: var(--google-blue-soft); } +.commits li.is-selected-commit { + background: color-mix(in srgb, var(--google-blue-soft) 45%, var(--panel)); +} +.commit-row-main { + min-width: 0; +} +.commit-row-meta { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + margin-left: auto; +} +.commit-row-meta .inline-icon-action { + flex: 0 0 auto; +} +.commit-inline-detail { + grid-column: 1 / -1; + margin-top: 8px; + border: 1px solid var(--repo-border); + border-radius: 8px; + overflow: hidden; + background: var(--panel); +} +.commit-inline-detail > .panel { + margin: 0; + border-right: 0; + border-left: 0; + border-radius: 0; + box-shadow: none; +} +.commit-inline-detail > .panel:first-of-type { + border-top: 0; +} +.commit-inline-detail .visual-diff { + padding: 0; +} + +.state-actions { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; + vertical-align: middle; +} +.state-marker { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; + white-space: nowrap; + vertical-align: middle; +} +.state-marker > span { + display: inline-flex; + align-items: center; + min-height: 19px; + padding: 1px 7px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0; +} +.state-dirty > span, +.state-ahead > span { + border-color: color-mix(in srgb, var(--del) 52%, var(--border)); + background: color-mix(in srgb, var(--del) 12%, var(--panel)); + color: var(--del); +} +.state-behind > span { + border-color: color-mix(in srgb, var(--warning) 58%, var(--border)); + background: color-mix(in srgb, var(--warning) 15%, var(--panel)); + color: var(--warning); +} +.inline-action { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--panel); + color: var(--link); + font: inherit; + font-size: 11px; + font-weight: 800; + cursor: pointer; +} +.inline-action:hover { + border-color: var(--google-blue); + background: var(--google-blue-soft); +} +.inline-icon-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--panel); + color: var(--muted); + cursor: pointer; +} +.inline-icon-action svg { + width: 15px; + height: 15px; +} +.inline-icon-action:hover { + border-color: var(--google-blue); + background: var(--google-blue-soft); + color: var(--google-blue); +} +.inline-icon-action.is-active { + border-color: var(--google-blue); + background: var(--google-blue-soft); + color: var(--google-blue); +} +.inline-icon-action:disabled { + cursor: wait; + opacity: 0.55; +} + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(16, 24, 34, 0.42); +} +.modal-card { + width: min(520px, 100%); + border: 1px solid var(--repo-border); + border-radius: 12px; + background: var(--panel); + box-shadow: var(--shadow); + padding: 20px; +} +.modal-card h2 { + margin: 0 0 8px; + font-size: 18px; +} +.modal-card p { + margin: 0 0 16px; + color: var(--muted); + line-height: 1.45; +} +.modal-field { + display: grid; + gap: 7px; + color: var(--heading); + font-size: 13px; + font-weight: 800; +} +.modal-field input, +.modal-field textarea { + width: 100%; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font: inherit; + padding: 7px 10px; +} +.modal-field input { + min-height: 38px; +} +.modal-field textarea { + min-height: 132px; + resize: vertical; + line-height: 1.45; +} +.modal-error { + margin-top: 8px; + color: var(--del); + font-size: 13px; +} +.modal-file-list { + margin: 0 0 16px; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface-2); +} +.modal-file-list > div { + padding: 9px 11px; + border-bottom: 1px solid var(--repo-border); + color: var(--heading); + font-size: 12px; + font-weight: 900; +} +.modal-file-list ul { + max-height: 180px; + margin: 0; + padding: 6px 0; + overflow: auto; + list-style: none; +} +.modal-file-list li { + padding: 5px 11px; + color: var(--text); + font-family: var(--mono); + font-size: 12px; + overflow-wrap: anywhere; +} +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 18px; +} +.inline-diff-row > td { + padding: 0; + background: var(--panel); +} +.commits li.inline-diff-row { + display: block; + padding: 0; +} +.pr-detail-header + .inline-diff-row { + display: block; + margin-top: 10px; +} +.inline-diff-shell { + padding: 0 0 12px 0; + border-top: 1px solid var(--repo-border); + background: var(--panel); +} +.inline-diff-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + color: var(--heading); + font-size: 12px; +} +.inline-diff-header strong { + font-family: var(--mono); + overflow-wrap: anywhere; +} +.inline-diff-header span { + color: var(--muted); +} +.pr-nav-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 14px; +} +.visual-diff { + padding: 0 12px; + overflow: auto; + background: var(--panel); +} +.visual-diff-file { + border: 1px solid var(--repo-border); + border-radius: 8px; + overflow: hidden; + background: var(--panel); +} +.visual-diff-file + .visual-diff-file { + margin-top: 14px; +} +.visual-diff-title { + padding: 10px 12px; + border-bottom: 1px solid var(--repo-border); + color: var(--heading); + font-family: var(--mono); + font-size: 12px; + font-weight: 800; + overflow-wrap: anywhere; +} +.visual-diff-grid { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) 56px minmax(0, 1fr); + overflow: auto; +} +.visual-diff-heading { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 10px; + border-bottom: 1px solid var(--repo-border); + background: var(--surface-2); + color: var(--heading); + font-size: 12px; + font-weight: 900; +} +.visual-diff-line-heading { + padding: 8px 0; +} +.visual-diff-row { + display: contents; +} +.visual-diff-row[hidden] { + display: none; +} +.visual-diff-divider { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + background: color-mix(in srgb, var(--google-blue) 9%, var(--panel)); + color: var(--link); + font: 800 12px/1.45 var(--mono); +} +.visual-diff-hunk-bottom { + min-height: 28px; +} +.diff-context-controls { + display: inline-flex; + align-items: center; + gap: 4px; +} +.diff-context-controls button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: 1px solid color-mix(in srgb, var(--google-blue) 35%, var(--repo-border)); + border-radius: 5px; + background: var(--panel); + color: var(--link); + font: 800 12px/1 var(--mono); + cursor: pointer; +} +.diff-context-controls button:hover { + background: var(--google-blue-soft); +} +.diff-context-controls button:disabled { + opacity: 0.45; + cursor: default; +} +.diff-context-controls button:disabled:hover { + background: var(--panel); +} +.visual-diff-line-number { + min-height: 28px; + padding: 6px 8px; + background: var(--surface-2); + color: var(--muted); + text-align: right; + user-select: none; + font: 11px/1.45 var(--mono); +} +.visual-diff-row pre { + position: relative; + min-height: 28px; + margin: 0; + padding: 6px 10px; + white-space: pre-wrap; + word-break: break-word; + font: 12px/1.45 var(--mono); +} +.review-comment-target { + padding-right: 38px; +} +.review-comment-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 24px; + border: 1px solid var(--repo-border); + border-radius: 999px; + background: var(--panel); + color: var(--muted); + cursor: pointer; + font-size: 13px; +} +.comment-count { + position: absolute; + top: -7px; + right: -7px; + min-width: 15px; + height: 15px; + padding: 0 4px; + border-radius: 999px; + background: var(--del); + color: #fff; + font-size: 10px; + font-weight: 900; + line-height: 15px; +} +.line-comment { + position: absolute; + right: 6px; + bottom: 3px; + opacity: 0; +} +.review-comment-target:hover .line-comment, +.line-comment:focus-visible { + opacity: 1; +} +.review-draft-row pre, +.review-draft-comment { + background: transparent; +} +.review-thread { + display: grid; + gap: 8px; + margin: 10px 14px; +} +.review-thread-comment { + padding: 10px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface-2); +} +.review-draft-comment { + margin: 10px 14px; + padding: 10px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface-2); +} +.inline-draft-editor { + margin: 10px 14px; +} +.pr-inline-after-context > .inline-draft-editor, +.pr-inline-reply-editor { + grid-column: 2; + min-width: 0; + margin: 9px 10px 10px; +} +.review-draft-editor-row pre { + background: transparent; +} +.inline-draft-box { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + padding: 10px; + border: 1px solid var(--repo-border); + border-left: 3px solid var(--blue); + border-radius: 0 7px 7px 0; + background: var(--surface-2); +} +.inline-draft-box textarea { + width: 100%; + min-height: 92px; + resize: vertical; + padding: 8px 10px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + line-height: 1.45; +} +.inline-draft-actions { + display: flex; + gap: 7px; + align-items: center; +} +.inline-draft-error { + display: none; + grid-column: 1 / -1; + color: var(--del); + font-size: 12px; + font-weight: 800; +} +.inline-draft-editor.has-error .inline-draft-error, +.review-draft-editor-row.has-error .inline-draft-error { + display: block; +} +.visual-diff-note { + color: var(--muted); + font-style: italic; +} +.visual-diff .diff-change { + border-radius: 3px; + padding: 1px 2px; + font-weight: 800; +} +.visual-diff .diff-change.deleted { + background: color-mix(in srgb, var(--del) 24%, transparent); + color: var(--del); + text-decoration: line-through; +} +.visual-diff .diff-change.added { + background: color-mix(in srgb, var(--add) 24%, transparent); + color: var(--add); +} +.button-link.primary { + border-color: var(--blue); + background: var(--blue); + color: #fff; +} + +.pr-list { + margin: 0; + padding: 0; + list-style: none; +} +.pr-item { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border-top: 1px solid var(--border); + cursor: pointer; +} +.pr-item:first-child { border-top: 0; } +.pr-item:hover { background: var(--google-blue-soft); } +.pr-main { + min-width: 0; +} +.pr-title { + color: var(--heading); + text-decoration: none; +} +.pr-title:hover { + color: var(--accent-2); + text-decoration: underline; +} +.pr-main strong { + color: var(--heading); + font-weight: 800; +} +.pr-id { + color: var(--muted); + font-family: var(--mono); + font-size: 12px; +} +.pr-meta { + display: flex; + flex: 0 0 auto; + align-items: flex-start; + gap: 8px; +} +.pr-status, +.pr-approvals { + display: inline-flex; + min-height: 24px; + align-items: center; + padding: 3px 7px; + border: 1px solid var(--code-border); + background: var(--code-bg); + color: var(--code-ink); + font-size: 11px; + font-weight: 800; +} +.pr-detail-header { + padding: 14px 16px 0; +} +.pr-detail-title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.pr-detail-title h2 { + margin: 0; + font-size: 20px; + overflow-wrap: anywhere; +} +.pr-subtabs { + display: flex; + gap: 3px; + margin: 14px -16px 0; + padding: 0 13px; + border-top: 1px solid var(--repo-border); +} +.pr-subtabs a { + display: inline-flex; + min-height: 38px; + align-items: center; + padding: 0 11px; + border-bottom: 2px solid transparent; + color: var(--muted); + font-weight: 800; +} +.pr-subtabs a.active { + border-bottom-color: var(--blue); + color: var(--heading); +} +.pr-body { + padding: 14px; + white-space: pre-wrap; +} +.pr-conversation { + display: grid; + max-height: calc(100vh - 132px); + grid-template-rows: auto auto minmax(0, 1fr) auto; +} +.pr-timeline { + min-height: 0; + overflow: auto; + border-top: 1px solid var(--repo-border); +} +.pr-note { + padding: 13px 14px; + border-bottom: 1px solid var(--repo-border); +} +.pr-note-meta { + display: flex; + align-items: center; + gap: 5px; + color: var(--muted); + font-size: 13px; +} +.pr-note-meta strong { + color: var(--heading); +} +.pr-note-meta span { + margin-left: 1px; +} +.pr-note-body { + margin-top: 8px; + white-space: pre-wrap; + color: var(--text); +} +.pr-inline-comments { + display: grid; + gap: 12px; + margin-top: 12px; +} +.pr-inline-comment { + overflow: hidden; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--panel); +} +.pr-inline-context-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--repo-border); + background: var(--surface-2); + color: var(--heading); + font-family: var(--mono); + font-size: 12px; + font-weight: 800; +} +.pr-reply-button { + width: 26px; + height: 22px; + margin-left: auto; + font-size: 12px; +} +.pr-reply-thread { + display: grid; + gap: 9px; + margin: 10px 10px 10px 34px; +} +.pr-reply-thread .pr-reply-thread { + margin-left: 24px; +} +.pr-reply { + padding: 10px 11px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface-2); +} +.pr-note.is-scroll-target, +.pr-inline-comment.is-scroll-target, +.pr-reply.is-scroll-target { + outline: 2px solid var(--blue); + outline-offset: 3px; +} +.pr-reply-meta { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 5px; + color: var(--muted); + font-size: 12px; +} +.pr-reply-meta strong { + color: var(--heading); +} +.pr-inline-context-line, +.pr-inline-outdated { + color: var(--muted); + font-family: var(--sans); + font-size: 12px; + font-weight: 800; +} +.pr-inline-outdated { + color: var(--del); +} +.pr-inline-after-context { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); +} +.pr-inline-line-number { + min-height: 30px; + padding: 7px 8px; + background: var(--surface-2); + color: var(--muted); + text-align: right; + user-select: none; + font: 11px/1.45 var(--mono); +} +.pr-inline-line-code { + min-height: 30px; + margin: 0; + padding: 7px 10px; + background: var(--panel); + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + font: 12px/1.45 var(--mono); +} +.pr-inline-target-line { + background: color-mix(in srgb, var(--add) 8%, var(--panel)); + box-shadow: inset 3px 0 0 var(--add); +} +.pr-inline-comment-body { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin: 9px 10px 10px; + padding: 10px 11px; + border: 1px solid var(--repo-border); + border-left: 3px solid var(--blue); + border-radius: 0 7px 7px 0; + background: var(--surface-2); + color: var(--text); + white-space: pre-wrap; +} +.pr-inline-body-reply { + flex: 0 0 auto; + margin-left: 8px; +} +.pr-inline-file-comment { + padding: 10px 14px; +} +.pr-inline-file-target { + display: inline-flex; + margin-bottom: 8px; + padding: 3px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--add) 11%, var(--panel)); + color: var(--add); + font-size: 12px; + font-weight: 900; +} +.pr-review-form { + position: sticky; + bottom: 0; + z-index: 3; + padding: 14px; + border-top: 1px solid var(--repo-border); + background: var(--panel); +} +.pr-review-form label { + display: block; + margin-bottom: 7px; + color: var(--heading); + font-size: 12px; + font-weight: 800; +} +.pr-review-form textarea { + width: 100%; + box-sizing: border-box; + resize: vertical; + min-height: 96px; + padding: 10px 11px; + border: 1px solid var(--repo-border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font: inherit; +} +.pr-review-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 10px; +} +.pr-review-actions [data-review-cancel] { + order: 20; + margin-left: auto; +} +.pr-review-actions .pr-delete-branch { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0 8px 0 auto; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} +.pr-closed-note { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 14px; + border-top: 1px solid var(--repo-border); + color: var(--muted); +} + +.settings-primary { + max-width: 980px; +} +.settings-panel { + overflow: visible; +} +.settings-section { + padding: 16px; + border-bottom: 1px solid var(--repo-border); +} +.settings-section:last-child { + border-bottom: 0; +} +.settings-section h2 { + margin: 0 0 12px; + color: var(--heading); + font-size: 15px; +} +.settings-section h3 { + margin: 0 0 10px; + color: var(--heading); + font-size: 13px; +} +.settings-form { + display: grid; + gap: 12px; +} +.settings-member-form, +.settings-protection-form { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--repo-border); +} +.settings-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.settings-form label { + display: grid; + gap: 7px; + color: var(--heading); + font-size: 12px; + font-weight: 800; +} +.settings-form input, +.settings-form select, +.settings-form textarea { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + font-weight: 500; + padding: 8px 10px; +} +.settings-form textarea { + resize: vertical; + line-height: 1.45; +} +.settings-check { + align-content: center; + grid-template-columns: auto 1fr; + gap: 8px; + color: var(--text); +} +.settings-check input { + width: auto; +} +.settings-actions { + display: flex; + justify-content: end; +} +.settings-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} +.settings-meta-grid div, +.settings-info-list div { + min-width: 0; + padding: 11px; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface-2); +} +.settings-meta-grid span, +.settings-info-list span, +.settings-row span, +.settings-row small { + display: block; + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} +.settings-meta-grid strong, +.settings-info-list strong, +.settings-row strong { + display: block; + min-width: 0; + overflow-wrap: anywhere; + color: var(--heading); +} +.settings-table { + display: grid; + border: 1px solid var(--repo-border); + border-radius: 8px; + overflow: hidden; +} +.settings-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 11px 12px; + border-bottom: 1px solid var(--repo-border); + background: var(--surface); +} +.settings-row:last-child { + border-bottom: 0; +} +.settings-row-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + justify-content: end; +} +.settings-note { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} +.settings-danger { + border: 1px solid color-mix(in srgb, var(--del) 45%, var(--repo-border)); + border-radius: 8px; + margin: 16px; +} +.settings-warning { + margin-bottom: 12px; + padding: 10px 12px; + border-radius: 7px; + background: color-mix(in srgb, var(--del) 11%, var(--surface)); + color: var(--heading); + font-size: 13px; + line-height: 1.45; +} +.button-link.danger { + border-color: color-mix(in srgb, var(--del) 55%, var(--repo-border)); + color: var(--del); +} +.settings-info-list { + display: grid; + gap: 10px; +} +.settings-error { + margin-top: 8px; + color: var(--del); + font-size: 13px; +} + +.issue-list { + display: grid; + border: 1px solid var(--repo-border); + border-radius: 8px; + overflow: hidden; +} +.issue-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--repo-border); + color: var(--text); + text-decoration: none; +} +.issue-row:last-child { border-bottom: 0; } +.issue-row strong { color: var(--heading); } +.issue-row small { + display: block; + margin-top: 3px; + color: var(--muted); +} +.issue-state { + align-self: start; + border: 1px solid var(--repo-border); + border-radius: 999px; + padding: 3px 7px; + color: var(--muted); + font-size: 10px; + font-weight: 900; +} +.issue-state.open { + color: var(--add); + border-color: color-mix(in srgb, var(--add) 45%, var(--repo-border)); +} +.issue-state.closed { + color: var(--muted); +} +.issue-form { + display: grid; + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--repo-border); +} +.issue-form label { + display: grid; + gap: 7px; + color: var(--heading); + font-size: 12px; + font-weight: 800; +} +.issue-form input, +.issue-form textarea { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--repo-border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + padding: 8px 10px; +} +.issue-heading { + display: flex; + gap: 10px; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--repo-border); +} +.issue-heading h1 { + margin: 0; + color: var(--heading); + font-size: 20px; +} +.issue-comment { + margin: 14px 16px; + padding: 12px; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface-2); +} +.issue-comment strong { + color: var(--heading); + font-size: 13px; +} +.issue-comment p { + margin: 8px 0 0; + white-space: pre-wrap; +} + +.commit-detail { padding: 17px; } +.commit-detail .commit-message { + margin-bottom: 14px; + border: 1px solid var(--border); + border-radius: 0; + background: var(--code-bg); + color: var(--code-ink); +} +.metadata-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.metadata-grid div { + min-width: 0; + padding: 11px; + border: 1px solid var(--border); + border-radius: 0; + background: var(--surface); +} +.metadata-grid span { + display: block; + margin-bottom: 4px; + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} +.metadata-grid small { + display: block; + color: var(--muted); + overflow-wrap: anywhere; +} + +.diff-summary { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.additions { color: var(--add); font-weight: 800; } +.deletions { color: var(--del); font-weight: 800; } +.changed-files .diff-stat { + width: 120px; + text-align: right; +} +.diff-file { scroll-margin-top: 16px; } +.diff-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface); + overflow-wrap: anywhere; +} +.diff-header-actions { + display: inline-flex; + align-items: center; + gap: 10px; +} +.pr-review-submit { + position: sticky; + bottom: 12px; + z-index: 12; + display: grid; + gap: 8px; + margin-top: 14px; + padding: 12px; + border: 1px solid var(--repo-border); + border-radius: 10px; + background: var(--panel); + box-shadow: var(--shadow); +} +.pr-review-submit textarea { + width: 100%; + border: 1px solid var(--repo-border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font: inherit; + padding: 8px 10px; +} +.review-help { + padding: 12px 14px; +} +.diff { + margin: 0; + overflow: auto; + background: var(--surface); + font-size: 12px; + line-height: 1.5; +} +.diff-line { + display: block; + min-height: 18px; + padding: 0 12px; + white-space: pre; +} +.diff-line.add { background: color-mix(in srgb, var(--add) 15%, transparent); } +.diff-line.del { background: color-mix(in srgb, var(--del) 14%, transparent); } +.diff-line.hunk { + background: var(--code-bg); + color: var(--code-ink); + font-weight: 800; +} + +.empty { + margin: 14px; + padding: 14px; + border: 1px dashed var(--border-strong); + border-radius: 0; + background: var(--surface); + color: var(--muted); +} +.sr-only { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +@media (max-width: 1080px) { + :root { + --repo-side-width: 280px; + --repo-gap: 18px; + } +} + +@media (max-width: 920px) { + .layout { width: min(100vw - 28px, 1180px); padding: 12px 0 28px; } + .sync-status { width: min(70vw, calc(100vw - 28px)); } + .tabs-row { + grid-template-columns: 1fr; + gap: 6px; + } + .tabs, + .tabs-row, + .repo-toolbar, + .repo-content { width: 100%; } + .tabs { + overflow-x: auto; + } + .repo-action-control, + .repo-controls { + align-items: end; + justify-content: start; + flex-wrap: wrap; + } + .repo-toolbar { + display: flex; + align-items: stretch; + flex-direction: column; + margin: 14px 0 10px; + } + .repo-toolbar::before { + left: 0; + width: 100%; + } + .repo-side-panel { + width: 100%; + margin-top: 0; + padding-right: 0; + } + .repo-toolbar-left, + .repo-toolbar-right { flex-wrap: wrap; justify-content: start; } + .file-search { flex: 1 1 100%; width: 100%; } + .file-search-results { left: 0; right: auto; } + .code-menu-popover { left: 0; right: auto; } + .remote-sync { justify-content: start; } + .ref-selector { max-width: none; flex: 1 1 220px; } + .clone-head { display: grid; gap: 8px; } + .clone-tabs { gap: 14px; overflow-x: auto; } + .clone-pane { grid-template-columns: 1fr; } + .blob-toolbar, + .diff-header { + flex-direction: column; + align-items: stretch; + } + .commit-strip { + gap: 4px; + flex-wrap: wrap; + } + .commit-subject { + max-width: 100%; + } + .repo-content { + grid-template-columns: 1fr; + } + .settings-form-grid, + .settings-meta-grid { + grid-template-columns: 1fr; + } + .settings-row { + grid-template-columns: 1fr; + } + .settings-row-actions { + justify-content: start; + } + .pr-item { flex-direction: column; } + .pr-meta { flex-wrap: wrap; } + .hash { display: none; } + .metadata-grid { grid-template-columns: 1fr; } + .files td { padding: 9px 10px; } + .kind { width: 48px; } +} + +@media (max-width: 560px) { + :root { + --repo-pad: 10px; + --repo-gap: 12px; + } + .layout { + width: min(100vw - 16px, 1180px); + } + .tabs a { + padding: 0 10px; + white-space: nowrap; + } + .repo-action-button { + flex: 1 1 auto; + padding: 0 10px; + } + .repo-header-location { + max-width: 100%; + } + .visual-diff-grid { + grid-template-columns: 44px minmax(220px, 1fr) 44px minmax(220px, 1fr); + } +} diff --git a/www/app.js b/www/app.js new file mode 100644 index 0000000..a8be870 --- /dev/null +++ b/www/app.js @@ -0,0 +1,1953 @@ +const reviewDraftComments = []; +let restoringReviewDraft = false; +const prScrollTargetKey = 'bgit.prScrollTarget'; +let currentWhoami = window.BGIT_WHOAMI || null; + +document.addEventListener('click', function (event) { + const contextButton = event.target.closest('[data-diff-context]'); + if (contextButton) { + event.preventDefault(); + revealDiffContext(contextButton); + return; + } + + const fileResult = event.target.closest('[data-file-search-result]'); + if (fileResult) { + event.preventDefault(); + window.location.href = fileResult.getAttribute('data-file-search-result'); + return; + } + + const prItem = event.target.closest('[data-pr-href]'); + if (prItem && !event.target.closest('a, button, input, textarea, select, label')) { + event.preventDefault(); + window.location.href = prItem.getAttribute('data-pr-href'); + return; + } + + const commitRow = event.target.closest('[data-commit-href]'); + if (commitRow && !event.target.closest('a, button, input, textarea, select, label, .commit-inline-detail')) { + event.preventDefault(); + window.location.href = commitRow.getAttribute('data-commit-href'); + return; + } + + const codeToggle = event.target.closest('[data-code-menu-toggle]'); + if (codeToggle) { + const menu = codeToggle.closest('.code-menu'); + const popover = menu ? menu.querySelector('[data-code-menu]') : null; + if (!popover) return; + const open = popover.hidden; + closeCodeMenus(menu); + popover.hidden = !open; + codeToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + return; + } + + if (!event.target.closest('.code-menu')) { + closeCodeMenus(null); + } + if (!event.target.closest('.file-search')) { + closeFileSearchResults(); + } + + const refCount = event.target.closest('[data-focus-ref-selector]'); + if (refCount) { + event.preventDefault(); + const selector = document.querySelector('[data-ref-selector]'); + if (selector) { + selector.focus(); + if (typeof selector.showPicker === 'function') { + try { selector.showPicker(); } catch (_) {} + } + } + return; + } + + const refresh = event.target.closest('[data-remote-refresh]'); + if (refresh) { + refreshRemoteState({manual: true, refreshPullRequests: true}); + return; + } + + const syncBadge = event.target.closest('[data-remote-sync-badge]'); + if (syncBadge && currentWebState && Number(currentWebState.behind || 0) > 0) { + handleWebAction('pull'); + return; + } + + const diffAction = event.target.closest('[data-web-diff]'); + if (diffAction) { + event.preventDefault(); + showInlineDiff(diffAction); + return; + } + + const webAction = event.target.closest('[data-web-action]'); + if (webAction) { + event.preventDefault(); + if (!hasCapability(webAction.getAttribute('data-capability') || '')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + handleWebAction(webAction.getAttribute('data-web-action'), webAction); + return; + } + + const prAction = event.target.closest('[data-pr-action]'); + if (prAction) { + event.preventDefault(); + if (!hasCapability(prAction.getAttribute('data-capability') || '')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + handlePullRequestAction(prAction); + return; + } + + const prReply = event.target.closest('[data-pr-reply]'); + if (prReply) { + event.preventDefault(); + showPullRequestReplyEditor(prReply); + return; + } + + const reviewCommentButton = event.target.closest('[data-review-comment-line], [data-review-comment-file]'); + if (reviewCommentButton) { + event.preventDefault(); + showReviewDraftEditor(reviewCommentButton); + return; + } + + const draftOK = event.target.closest('[data-draft-ok]'); + if (draftOK) { + event.preventDefault(); + submitInlineDraft(draftOK); + return; + } + + const draftCancel = event.target.closest('[data-draft-cancel]'); + if (draftCancel) { + event.preventDefault(); + const editor = draftCancel.closest('[data-draft-editor]'); + if (editor) editor.remove(); + saveReviewDraftState(); + return; + } + + const reviewSubmit = event.target.closest('[data-pr-review-action]'); + if (reviewSubmit) { + event.preventDefault(); + if (!hasCapability(reviewSubmit.getAttribute('data-capability') || '')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + submitReviewDraft(reviewSubmit); + return; + } + + const reviewCancel = event.target.closest('[data-review-cancel]'); + if (reviewCancel) { + clearStoredReviewDraft(currentReviewID()); + return; + } + + const settingsAction = event.target.closest('[data-settings-action]'); + if (settingsAction) { + event.preventDefault(); + handleSettingsAction(settingsAction); + return; + } + + const issueAction = event.target.closest('[data-issue-action]'); + if (issueAction) { + event.preventDefault(); + handleIssueAction(issueAction); + return; + } + + const cloneTab = event.target.closest('[data-clone-tab]'); + if (cloneTab) { + const panel = cloneTab.closest('.clone-panel'); + const target = cloneTab.getAttribute('data-clone-tab'); + if (!panel || !target) return; + for (const tab of panel.querySelectorAll('[data-clone-tab]')) { + const active = tab === cloneTab; + tab.classList.toggle('active', active); + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + } + for (const pane of panel.querySelectorAll('[data-clone-pane]')) { + pane.hidden = pane.getAttribute('data-clone-pane') !== target; + } + return; + } + + const button = event.target.closest('[data-copy-target]'); + if (!button) return; + const target = document.getElementById(button.getAttribute('data-copy-target')); + if (!target) return; + const value = target.value !== undefined ? target.value : target.textContent; + navigator.clipboard.writeText(value).then(function () { + if (button.hasAttribute('data-copy-icon')) { + const oldTitle = button.getAttribute('title') || 'Copy'; + const oldLabel = button.getAttribute('aria-label') || 'Copy'; + button.classList.add('is-copied'); + button.setAttribute('title', 'Copied'); + button.setAttribute('aria-label', 'Copied'); + window.setTimeout(function () { + button.classList.remove('is-copied'); + button.setAttribute('title', oldTitle); + button.setAttribute('aria-label', oldLabel); + }, 1200); + return; + } + const old = button.textContent; + button.textContent = 'Copied'; + window.setTimeout(function () { button.textContent = old; }, 1200); + }); +}); + +document.addEventListener('change', function (event) { + const select = event.target.closest('[data-ref-selector]'); + if (!select) return; + const url = new URL(window.location.href); + url.searchParams.set('ref', select.value); + window.location.href = url.toString(); +}); + +document.addEventListener('submit', function (event) { + const form = event.target.closest('[data-settings-form]'); + if (form) { + event.preventDefault(); + handleSettingsForm(form); + return; + } + const issueForm = event.target.closest('[data-issue-form]'); + if (issueForm) { + event.preventDefault(); + handleIssueForm(issueForm); + } +}); + +document.addEventListener('input', function (event) { + const input = event.target.closest('[data-file-search]'); + if (!input) return; + renderFileSearchResults(input); +}); + +document.addEventListener('input', function (event) { + if (event.target.closest('[data-pr-review-note], [data-draft-editor] [data-draft-text]')) { + saveReviewDraftState(); + } +}); + +document.addEventListener('keydown', function (event) { + const input = event.target.closest('[data-file-search]'); + if (!input || event.key !== 'Enter') return; + const match = findIndexedFile(input.value); + if (!match) return; + event.preventDefault(); + window.location.href = match.url; +}); + +document.addEventListener('DOMContentLoaded', function () { + setupThemeToggle(); + setupReviewDiff(); + restorePullRequestScrollTarget(); + setWhoamiState(currentWhoami); + restoreSettingsStatus(); + connectBgitEvents(); + refreshWhoamiState(); + hydrateRefs(); + refreshRemoteState({refreshPullRequests: false}); + window.setInterval(function () { refreshRemoteState({refreshPullRequests: true}); }, 30000); +}); + +function setupReviewDiff() { + const review = document.querySelector('[data-pr-review-diff]'); + if (!review) return; + const existing = readReviewComments(); + for (const file of review.querySelectorAll('[data-review-file]')) { + const path = file.getAttribute('data-review-file') || ''; + const fileComments = existing.filter((comment) => comment.file === path && comment.kind === 'file'); + fileComments.forEach((comment) => { comment._matched = true; }); + const fileButton = file.querySelector('[data-review-comment-file]'); + if (fileButton && fileComments.length) fileButton.innerHTML = 'đź’¬' + fileComments.length + ''; + if (fileComments.length) { + file.querySelector('.diff-header')?.insertAdjacentHTML('afterend', reviewThreadHTML(fileComments)); + } + for (const row of file.querySelectorAll('.visual-diff-row')) { + const newCell = row.querySelector('pre[data-new-line]'); + const line = newCell ? newCell.getAttribute('data-new-line') : ''; + if (!newCell || !line) continue; + newCell.classList.add('review-comment-target'); + const rowComments = existing.filter((comment) => comment.file === path && comment.kind === 'line' && Number(comment.line || 0) === Number(line)); + rowComments.forEach((comment) => { comment._matched = true; }); + newCell.insertAdjacentHTML('beforeend', ''); + if (rowComments.length) row.insertAdjacentHTML('afterend', '
    ' + reviewThreadHTML(rowComments) + '
    '); + } + const orphaned = existing.filter((comment) => comment.file === path && comment.kind === 'line' && !comment._matched); + if (orphaned.length) { + file.querySelector('.visual-diff-grid')?.insertAdjacentHTML('beforeend', '
    Outdated comments
    ' + reviewThreadHTML(orphaned) + '
    '); + } + } + restoreReviewDraftState(); +} + +function readReviewComments() { + const node = document.getElementById('pr-review-comments'); + if (!node) return []; + try { + const comments = JSON.parse(node.textContent || '[]'); + return Array.isArray(comments) ? comments : []; + } catch (_) { + return []; + } +} + +function reviewThreadHTML(comments) { + return '
    ' + comments.map(function (comment) { + return '
    ' + escapeHTML(comment.user || 'unknown') + ' commented' + (comment.at ? ' ' + escapeHTML(comment.at) + '' : '') + '
    ' + escapeHTML(comment.body || '') + '
    '; + }).join('') + '
    '; +} + +function closeCodeMenus(except) { + for (const menu of document.querySelectorAll('.code-menu')) { + if (except && menu === except) continue; + const popover = menu.querySelector('[data-code-menu]'); + const toggle = menu.querySelector('[data-code-menu-toggle]'); + if (popover) popover.hidden = true; + if (toggle) toggle.setAttribute('aria-expanded', 'false'); + } +} + +function indexedFiles() { + if (indexedFiles.cache) return indexedFiles.cache; + const el = document.getElementById('bgit-file-index'); + if (!el) { + indexedFiles.cache = []; + return indexedFiles.cache; + } + try { + const parsed = JSON.parse(el.textContent || '[]'); + indexedFiles.cache = Array.isArray(parsed) ? parsed : []; + } catch (_) { + indexedFiles.cache = []; + } + return indexedFiles.cache; +} + +function findIndexedFile(value) { + const query = String(value || '').trim().toLowerCase(); + if (!query) return null; + const files = rankedIndexedFiles(query); + return files[0] || null; +} + +function rankedIndexedFiles(query) { + query = String(query || '').trim().toLowerCase(); + if (!query) return []; + const exact = []; + const prefix = []; + const segmentPrefix = []; + for (const file of indexedFiles()) { + const path = String(file.path || ''); + const lower = path.toLowerCase(); + if (lower === query) exact.push(file); + else if (lower.startsWith(query)) prefix.push(file); + else if (lower.split('/').some(function (part) { return part.startsWith(query); })) segmentPrefix.push(file); + } + return exact.concat(prefix, segmentPrefix); +} + +function renderFileSearchResults(input) { + const results = document.querySelector('[data-file-search-results]'); + if (!results) return; + const matches = rankedIndexedFiles(input.value).slice(0, 12); + if (matches.length === 0) { + results.hidden = true; + input.setAttribute('aria-expanded', 'false'); + results.innerHTML = ''; + return; + } + results.innerHTML = matches.map(function (file, index) { + const icon = file.kind === 'dir' ? '▣' : '▯'; + return '' + escapeHTML(file.path || '') + ''; + }).join(''); + results.hidden = false; + input.setAttribute('aria-expanded', 'true'); +} + +function closeFileSearchResults() { + const results = document.querySelector('[data-file-search-results]'); + const input = document.querySelector('[data-file-search]'); + if (results) { + results.hidden = true; + results.innerHTML = ''; + } + if (input) input.setAttribute('aria-expanded', 'false'); +} + +let remoteRefreshInFlight = false; +let remoteSyncInitialized = false; +let currentWebState = null; + +function setupThemeToggle() { + const button = document.querySelector('[data-theme-toggle]'); + if (!button) return; + const storageKey = 'bgit.theme'; + const media = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; + let longPressTimer = 0; + let longPressed = false; + + const storedTheme = function () { + try { + const theme = localStorage.getItem(storageKey); + return theme === 'light' || theme === 'dark' ? theme : ''; + } catch (_) { + return ''; + } + }; + const systemTheme = function () { + return media && media.matches ? 'dark' : 'light'; + }; + const apply = function () { + const theme = storedTheme(); + if (theme) { + document.documentElement.dataset.theme = theme; + button.dataset.themeState = theme; + button.setAttribute('aria-label', 'Switch to ' + (theme === 'dark' ? 'light' : 'dark') + ' theme'); + } else { + delete document.documentElement.dataset.theme; + button.dataset.themeState = 'auto'; + button.setAttribute('aria-label', 'Theme follows system preference'); + } + }; + const setTheme = function (theme) { + try { + localStorage.setItem(storageKey, theme); + } catch (_) {} + apply(); + setSyncStatus('Switched to ' + theme + '. Long-press to reset to system preferences.', 'is-current'); + }; + const clearTheme = function () { + try { + localStorage.removeItem(storageKey); + } catch (_) {} + apply(); + setSyncStatus('Theme follows system', 'is-current'); + }; + + button.addEventListener('click', function () { + if (longPressed) { + longPressed = false; + return; + } + const current = storedTheme() || systemTheme(); + setTheme(current === 'dark' ? 'light' : 'dark'); + }); + button.addEventListener('pointerdown', function () { + longPressed = false; + window.clearTimeout(longPressTimer); + longPressTimer = window.setTimeout(function () { + longPressed = true; + clearTheme(); + }, 650); + }); + for (const eventName of ['pointerup', 'pointercancel', 'pointerleave']) { + button.addEventListener(eventName, function () { + window.clearTimeout(longPressTimer); + }); + } + if (media) { + media.addEventListener('change', apply); + } + apply(); +} + +function connectBgitEvents() { + if (!window.EventSource) return; + let source = null; + let reconnecting = false; + const connect = function () { + source = new EventSource('/events'); + source.onopen = function () { + if (reconnecting) { + reconnecting = false; + clearSyncStatus(); + } + }; + source.addEventListener('git', function () { + refreshRemoteState({refreshPullRequests: true}); + }); + source.addEventListener('whoami', function (event) { + try { + setWhoamiState(JSON.parse(event.data || 'null')); + } catch (_) {} + }); + source.addEventListener('assets', function () { + setSyncStatus('Web assets changed. Reloading…', 'is-stale'); + window.location.reload(); + }); + source.onerror = function () { + reconnecting = true; + setSyncStatus('Lost connection to bgit@' + window.location.host + '... reconnecting.', 'is-error'); + }; + }; + connect(); + window.addEventListener('beforeunload', function () { + if (source) source.close(); + }); +} + +async function refreshWhoamiState() { + try { + setWhoamiState(await fetchJSON('/api/me?refresh=1')); + } catch (_) {} +} + +function setWhoamiState(value) { + currentWhoami = value || null; + document.documentElement.dataset.bgitRole = currentWhoami && currentWhoami.role ? currentWhoami.role : ''; + applyCapabilityUI(); +} + +function hasCapability(name) { + if (!name) return true; + if (!currentWhoami || !currentWhoami.capabilities) return false; + return currentWhoami.capabilities[name] === true; +} + +function applyCapabilityUI() { + for (const el of document.querySelectorAll('[data-capability]')) { + const allowed = hasCapability(el.getAttribute('data-capability') || ''); + const disabledMessage = 'Your current broker role does not allow this action.'; + if (el.matches('button, input, select, textarea')) { + el.disabled = !allowed; + el.title = allowed ? '' : disabledMessage; + } else { + el.classList.toggle('is-capability-disabled', !allowed); + el.title = allowed ? '' : disabledMessage; + for (const control of el.querySelectorAll('button, input, select, textarea')) control.disabled = !allowed; + } + } +} + +async function fetchJSON(path) { + const response = await fetch(path, {headers: {'accept': 'application/json'}}); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} + +async function postJSON(path, body) { + const response = await fetch(path, { + method: 'POST', + headers: {'accept': 'application/json', 'content-type': 'application/json'}, + body: JSON.stringify(body || {}) + }); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} + +let webActionInFlight = false; + +function formValue(form, name) { + const field = form.elements[name]; + return field ? String(field.value || '').trim() : ''; +} + +function formChecked(form, name) { + const field = form.elements[name]; + return !!(field && field.checked); +} + +async function handleSettingsForm(form) { + const action = form.getAttribute('data-settings-form') || ''; + if (!hasCapability(form.getAttribute('data-capability') || '')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + const payload = {action}; + if (action === 'update-repo') { + payload.description = formValue(form, 'description'); + payload.default_branch = formValue(form, 'default_branch'); + payload.visibility = formValue(form, 'visibility') || 'private'; + payload.read_only = formChecked(form, 'read_only'); + payload.issues_enabled = formChecked(form, 'issues_enabled'); + } else if (action === 'add-member') { + payload.user = formValue(form, 'user'); + payload.role = formValue(form, 'role'); + if (!payload.user) { + setSyncStatus('Username is required.', 'is-stale'); + return; + } + } else if (action === 'transfer-owner') { + const ok = await confirmModal({title: 'Transfer ownership?', body: 'This creates a one-time command for the new owner to accept with their own SSH key.', confirm: 'Create command'}); + if (!ok) return; + } else if (action === 'repo-rename') { + payload.logical = formValue(form, 'logical'); + if (!payload.logical) { + setSyncStatus('New logical repository name is required.', 'is-stale'); + return; + } + const ok = await confirmModal({title: 'Rename repository?', body: 'Rename this logical repository to ' + payload.logical + '.', confirm: 'OK'}); + if (!ok) return; + } else if (action === 'repo-delete') { + const expected = form.querySelector('[data-confirm-repo]')?.getAttribute('data-confirm-repo') || ''; + const actual = formValue(form, 'confirm'); + if (!expected || actual !== expected) { + setSyncStatus('Type the repository name exactly to delete it.', 'is-stale'); + return; + } + const ok = await confirmModal({title: 'Delete repository?', body: 'This permanently deletes broker metadata, bucket contents, and the bucket for ' + expected + '.', confirm: 'Delete'}); + if (!ok) return; + } else if (action === 'protect-upsert') { + payload.ref = formValue(form, 'ref'); + payload.require_pr = formChecked(form, 'require_pr'); + payload.allow_overrides = formChecked(form, 'allow_overrides'); + if (!payload.ref) { + setSyncStatus('Branch or ref is required.', 'is-stale'); + return; + } + } + try { + setSettingsBusy(true); + const data = await postJSON('/api/actions/settings', payload); + const command = data && data.broker && (data.broker.accept_command || data.broker.cancel_command); + if (command) { + await confirmModal({title: action === 'add-member' ? 'Invite command' : 'Ownership transfer command', body: command, confirm: 'OK'}); + } + rememberSettingsStatus(settingsSuccessMessage(action, payload, form)); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } finally { + setSettingsBusy(false); + } +} + +async function handleSettingsAction(button) { + const action = button.getAttribute('data-settings-action') || ''; + if (!hasCapability(button.closest('[data-capability]')?.getAttribute('data-capability') || button.getAttribute('data-capability') || '')) { + setSyncStatus('Your current broker role does not allow this action.', 'is-stale'); + return; + } + const member = button.closest('[data-member-key]'); + const protection = button.closest('[data-protection-ref]'); + const payload = {action}; + let subject = ''; + let title = 'Apply settings change?'; + let body = 'This updates broker-managed repository settings.'; + if (member) { + payload.key = member.getAttribute('data-member-key') || ''; + subject = member.querySelector('strong')?.textContent || 'member'; + if (!payload.key) { + setSyncStatus('Member key is missing from the selected row.', 'is-stale'); + return; + } + if (action === 'remove-member') { + title = 'Remove member?'; + body = 'Remove ' + subject + ' from this repository.'; + } else if (action === 'suspend-member') { + title = 'Suspend member?'; + body = 'Suspend ' + subject + ' for this repository without removing the key.'; + } else if (action === 'unsuspend-member') { + title = 'Unsuspend member?'; + body = 'Restore access for ' + subject + ' on this repository.'; + } + } + if (protection) { + payload.ref = protection.getAttribute('data-protection-ref') || ''; + subject = payload.ref; + if (!payload.ref) { + setSyncStatus('Branch protection ref is missing from the selected row.', 'is-stale'); + return; + } + title = 'Remove branch protection?'; + body = 'Remove branch protection for ' + payload.ref + '.'; + } + const ok = await confirmModal({title, body, confirm: 'OK'}); + if (!ok) return; + try { + setSettingsBusy(true); + await postJSON('/api/actions/settings', payload); + rememberSettingsStatus(settingsSuccessMessage(action, Object.assign({}, payload, {subject}))); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } finally { + setSettingsBusy(false); + } +} + +async function handleIssueForm(form) { + const action = form.getAttribute('data-issue-form') || ''; + const panel = form.closest('[data-issue-id]'); + const payload = {action}; + if (panel) payload.id = Number(panel.getAttribute('data-issue-id') || 0); + if (action === 'create') { + payload.title = formValue(form, 'title'); + payload.body = formValue(form, 'body'); + if (!payload.title) { + setSyncStatus('Issue title is required.', 'is-stale'); + return; + } + } else if (action === 'comment') { + payload.comment = formValue(form, 'comment'); + if (!payload.id || !payload.comment) { + setSyncStatus('Issue comment is required.', 'is-stale'); + return; + } + } + try { + await postJSON('/api/actions/issues', payload); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } +} + +async function handleIssueAction(button) { + const panel = button.closest('[data-issue-id]'); + const id = Number(panel?.getAttribute('data-issue-id') || 0); + const action = button.getAttribute('data-issue-action') || ''; + if (!id || !action) return; + try { + await postJSON('/api/actions/issues', {action, id}); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } +} + +function setSettingsBusy(busy) { + for (const el of document.querySelectorAll('[data-settings-root] button, [data-settings-root] input, [data-settings-root] textarea, [data-settings-root] select')) { + el.disabled = !!busy; + } + if (!busy) applyCapabilityUI(); +} + +function settingsSuccessMessage(action, payload, form) { + const subject = payload.subject || payload.user || payload.ref || form?.querySelector('input[name="user"]')?.value || ''; + if (action === 'update-repo') return 'Repository settings saved.'; + if (action === 'add-member') return 'Created invite for ' + subject + '.'; + if (action === 'transfer-owner') return 'Ownership transfer is pending.'; + if (action === 'repo-rename') return 'Repository renamed.'; + if (action === 'repo-delete') return 'Repository deleted.'; + if (action === 'suspend-member') return 'Suspended ' + subject + '.'; + if (action === 'unsuspend-member') return 'Unsuspended ' + subject + '.'; + if (action === 'remove-member') return 'Removed ' + subject + '.'; + if (action === 'protect-upsert') return 'Protected ' + subject + '.'; + if (action === 'protect-remove') return 'Removed protection for ' + subject + '.'; + return 'Settings updated.'; +} + +function rememberSettingsStatus(message) { + try { + window.sessionStorage.setItem('bgit.settingsStatus', message); + } catch (_) {} +} + +function restoreSettingsStatus() { + let message = ''; + try { + message = window.sessionStorage.getItem('bgit.settingsStatus') || ''; + window.sessionStorage.removeItem('bgit.settingsStatus'); + } catch (_) {} + if (message) setSyncStatus(message, 'is-current'); +} + +async function handleWebAction(action, trigger) { + if (webActionInFlight) return; + try { + if (action === 'stage') { + const path = trigger ? trigger.getAttribute('data-path') : ''; + if (!path) return; + setWebActionsBusy(true, 'STAGING'); + setRemoteSyncStatus('syncing', 'Synchronising'); + const data = await postJSON('/api/actions/stage', {path}); + currentWebState = data.state || null; + applyRepositoryState(currentWebState); + reconcileRemoteState(currentWebState); + setSyncStatus('Staged ' + path + '.', 'is-current'); + return; + } + if (action === 'unstage') { + const path = trigger ? trigger.getAttribute('data-path') : ''; + if (!path) return; + setWebActionsBusy(true, 'UNSTAGING'); + const data = await postJSON('/api/actions/unstage', {path}); + currentWebState = data.state || null; + applyRepositoryState(currentWebState); + reconcileRemoteState(currentWebState); + setSyncStatus('Unstaged ' + path + '.', 'is-current'); + return; + } + if (action === 'discard') { + const path = trigger ? trigger.getAttribute('data-path') : ''; + if (!path) return; + const ok = await confirmModal({ + title: 'Checkout file?', + body: 'Discard local changes for ' + path + ' and restore it from the remote branch when available.', + confirm: 'OK' + }); + if (!ok) return; + setWebActionsBusy(true, 'CHECKING OUT'); + const data = await postJSON('/api/actions/discard', {path}); + currentWebState = data.state || null; + applyRepositoryState(currentWebState); + reconcileRemoteState(currentWebState); + setSyncStatus('Checked out ' + path + '.', 'is-current'); + return; + } + if (action === 'commit') { + const stagedFiles = currentWebState && Array.isArray(currentWebState.staged_files) ? currentWebState.staged_files : []; + const message = await promptModal({ + title: 'Commit staged changes', + body: 'Commit the staged changes on the current branch.', + files: stagedFiles, + inputLabel: 'Commit message', + confirm: 'Commit', + required: true + }); + if (!message) return; + setWebActionsBusy(true, 'COMMITTING'); + setRemoteSyncStatus('syncing', 'Synchronising'); + const data = await postJSON('/api/actions/commit', {message}); + currentWebState = data.state || null; + setSyncStatus('Committed local changes.', 'is-current'); + reloadLocalView(); + return; + } + if (action === 'push') { + setWebActionsBusy(true, 'PUSHING'); + setRemoteSyncStatus('syncing', 'Synchronising'); + const data = await postJSON('/api/actions/push', {}); + currentWebState = data.state || null; + applyRepositoryState(currentWebState); + if (currentWebState && Number(currentWebState.ahead || 0) > 0) { + throw new Error('Push did not complete; local branch is still ahead of remote.'); + } + reconcileRemoteState(currentWebState); + setSyncStatus('Push confirmed.', 'is-current'); + return; + } + if (action === 'uncommit') { + const ok = await confirmModal({ + title: 'Uncommit local commits?', + body: 'Move unpushed commits back into staged changes. Nothing will be changed on the remote.', + confirm: 'Uncommit' + }); + if (!ok) return; + setWebActionsBusy(true, 'UNCOMMITTING'); + setRemoteSyncStatus('syncing', 'Synchronising'); + const data = await postJSON('/api/actions/uncommit', {}); + currentWebState = data.state || null; + applyRepositoryState(currentWebState); + reconcileRemoteState(currentWebState); + setSyncStatus('Uncommitted local commits.', 'is-current'); + return; + } + if (action === 'pull') { + const ok = await confirmModal({ + title: 'Pull remote changes?', + body: 'Remote has commits that are not in your local branch. Pull them into this working tree?', + confirm: 'Pull' + }); + if (!ok) return; + setWebActionsBusy(true, 'PULLING'); + setRemoteSyncStatus('syncing', 'Synchronising'); + const data = await postJSON('/api/actions/pull', {}); + currentWebState = data.state || null; + setSyncStatus('Pulled remote changes.', 'is-current'); + reloadLocalView(); + } + } catch (err) { + setRemoteSyncStatus('error', compactError(err)); + setSyncStatus(compactError(err), 'is-stale'); + } finally { + setWebActionsBusy(false); + } +} + +function confirmModal(options) { + return modalDialog(options).then(function (value) { return value === true; }); +} + +function promptModal(options) { + return modalDialog(Object.assign({}, options, {prompt: true})); +} + +async function showInlineDiff(trigger) { + const path = trigger.getAttribute('data-path') || ''; + const mode = trigger.getAttribute('data-mode') || 'worktree'; + const diffURL = trigger.getAttribute('data-diff-url') || (path ? '/api/diff?path=' + encodeURIComponent(path) + '&mode=' + encodeURIComponent(mode) : ''); + if (!diffURL) return; + const anchor = trigger.closest('[data-file-row]') || trigger.closest('[data-commit-row]') || trigger.closest('.pr-detail-header'); + if (!anchor) return; + const existing = anchor.nextElementSibling && anchor.nextElementSibling.matches('[data-inline-diff-row]') ? anchor.nextElementSibling : null; + if (existing) { + existing.remove(); + trigger.classList.remove('is-active'); + trigger.setAttribute('aria-expanded', 'false'); + return; + } + for (const open of document.querySelectorAll('[data-inline-diff-row]')) open.remove(); + for (const active of document.querySelectorAll('[data-web-diff].is-active')) { + active.classList.remove('is-active'); + active.setAttribute('aria-expanded', 'false'); + } + trigger.disabled = true; + try { + const data = await fetchJSON(diffURL); + const title = trigger.getAttribute('data-diff-title') || path || 'Diff'; + const subtitle = trigger.getAttribute('data-diff-subtitle') || (mode === 'staged' ? 'Staged changes' : 'Unstaged changes'); + const diffRow = inlineDiffElement(anchor, title, subtitle, data.html || visualDiffHTML(data.diff || ''), !!data.html); + anchor.insertAdjacentElement('afterend', diffRow); + trigger.classList.add('is-active'); + trigger.setAttribute('aria-expanded', 'true'); + } catch (err) { + setRemoteSyncStatus('error', compactError(err)); + setSyncStatus(compactError(err), 'is-stale'); + } finally { + trigger.disabled = false; + } +} + +function inlineDiffElement(anchor, title, subtitle, content, isHTML) { + let el; + let inner; + if (anchor.tagName === 'TR') { + el = document.createElement('tr'); + inner = '' + inlineDiffShellHTML(title, subtitle, content, isHTML) + ''; + } else if (anchor.tagName === 'LI') { + el = document.createElement('li'); + inner = inlineDiffShellHTML(title, subtitle, content, isHTML); + } else { + el = document.createElement('section'); + inner = inlineDiffShellHTML(title, subtitle, content, isHTML); + } + el.className = 'inline-diff-row'; + el.setAttribute('data-inline-diff-row', ''); + el.innerHTML = inner; + return el; +} + +function inlineDiffShellHTML(title, subtitle, content, isHTML) { + const body = isHTML ? String(content || '') : visualDiffHTML(content || ''); + return '
    ' + escapeHTML(title) + '' + escapeHTML(subtitle || '') + '
    ' + body + '
    '; +} + +function visualDiffHTML(diff) { + const files = parseUnifiedDiff(diff || ''); + if (!files.length) return '
    No diff available.
    '; + return '
    ' + files.map(function (file) { + return '
    ' + escapeHTML(file.path || 'Changed file') + '
    Before
    After
    ' + file.rows.map(diffRowHTML).join('') + '
    '; + }).join('') + '
    '; +} + +function parseUnifiedDiff(diff) { + const files = []; + let current = null; + let pendingDeletes = []; + let oldLine = 0; + let newLine = 0; + function flushDeletes() { + if (!current || !pendingDeletes.length) return; + for (const line of pendingDeletes) current.rows.push({kind: 'del', left: line.text, right: '', oldLine: line.oldLine, newLine: ''}); + pendingDeletes = []; + } + for (const raw of String(diff || '').split(/\r?\n/)) { + if (raw.startsWith('diff --git ')) { + flushDeletes(); + const match = raw.match(/^diff --git a\/(.+?) b\/(.+)$/); + current = {path: match ? match[2] : 'Changed file', rows: []}; + files.push(current); + continue; + } + if (!current && raw !== '') { + current = {path: 'Changed file', rows: []}; + files.push(current); + } + if (!current) continue; + if (raw.startsWith('+++ ') || raw.startsWith('--- ') || raw.startsWith('index ') || raw.startsWith('new file mode') || raw.startsWith('deleted file mode')) continue; + if (raw.startsWith('@@')) { + flushDeletes(); + const hunk = parseHunkStart(raw); + oldLine = hunk.oldLine; + newLine = hunk.newLine; + current.rows.push({kind: 'hunk', left: raw, right: raw, oldLine: '', newLine: ''}); + continue; + } + if (raw.startsWith('-')) { + pendingDeletes.push({text: raw.slice(1), oldLine: oldLine}); + oldLine += 1; + continue; + } + if (raw.startsWith('+')) { + const added = raw.slice(1); + if (pendingDeletes.length) { + const deleted = pendingDeletes.shift(); + current.rows.push({kind: 'change', left: deleted.text, right: added, oldLine: deleted.oldLine, newLine: newLine}); + } else { + current.rows.push({kind: 'add', left: '', right: added, oldLine: '', newLine: newLine}); + } + newLine += 1; + continue; + } + flushDeletes(); + if (raw === '\\ No newline at end of file') { + current.rows.push({kind: 'note', left: raw, right: raw, oldLine: '', newLine: ''}); + } else { + const text = raw.startsWith(' ') ? raw.slice(1) : raw; + current.rows.push({kind: 'same', left: text, right: text, oldLine: oldLine, newLine: newLine}); + oldLine += 1; + newLine += 1; + } + } + flushDeletes(); + for (const file of files) { + if (!file.rows.length) file.rows.push({kind: 'note', left: 'No textual changes.', right: 'No textual changes.', oldLine: '', newLine: ''}); + } + return files; +} + +function parseHunkStart(line) { + const match = String(line || '').match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + return { + oldLine: match ? Number(match[1]) : 0, + newLine: match ? Number(match[2]) : 0 + }; +} + +function diffRowHTML(row) { + if (row.kind === 'hunk' || row.kind === 'note') { + return '
    ' + escapeHTML(formatDiffDivider(row.left)) + '
    '; + } + if (row.kind === 'change') { + const segments = inlineChangedSegments(row.left, row.right); + return '
    ' + escapeHTML(row.oldLine || '') + '
    ' + segments.left + '
    ' + escapeHTML(row.newLine || '') + '
    ' + segments.right + '
    '; + } + return '
    ' + escapeHTML(row.oldLine || '') + '
    ' + diffCellHTML(row.left, row.kind === 'del' ? 'deleted' : 'same') + '
    ' + escapeHTML(row.newLine || '') + '
    ' + diffCellHTML(row.right, row.kind === 'add' ? 'added' : 'same') + '
    '; +} + +function revealDiffContext(button) { + const divider = button.closest('.visual-diff-divider'); + if (!divider) return; + const direction = button.getAttribute('data-diff-context'); + const hiddenRows = hiddenContextRowsForDivider(divider, direction); + const rows = direction === 'up' ? hiddenRows.slice(-20) : hiddenRows.slice(0, 20); + for (const row of rows) { + row.hidden = false; + row.removeAttribute('data-hidden-context'); + } + if (rows.length > 0) { + if (direction === 'up') { + rows[0].before(divider); + } else { + rows[rows.length - 1].after(divider); + } + } + if (hiddenContextRowsForDivider(divider, direction).length === 0) { + button.disabled = true; + button.setAttribute('aria-disabled', 'true'); + } +} + +function hiddenContextRowsForDivider(divider, direction) { + const rows = []; + if (direction === 'up') { + let node = divider.previousElementSibling; + while (node && !node.classList.contains('visual-diff-divider')) { + if (node.hasAttribute('data-hidden-context')) rows.push(node); + node = node.previousElementSibling; + } + rows.reverse(); + return rows; + } + let node = divider.nextElementSibling; + while (node && !node.classList.contains('visual-diff-divider')) { + if (node.hasAttribute('data-hidden-context')) rows.push(node); + node = node.nextElementSibling; + } + return rows; +} + +function formatDiffDivider(line) { + const match = String(line || '').match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); + if (!match) return line || ''; + const oldStart = Number(match[1]); + const oldCount = Number(match[2] || 1); + const newStart = Number(match[3]); + const newCount = Number(match[4] || 1); + return 'Lines ' + lineRangeLabel(oldStart, oldCount) + ' -> ' + lineRangeLabel(newStart, newCount); +} + +function lineRangeLabel(start, count) { + if (!count || count <= 1) return String(start); + return String(start) + '-' + String(start + count - 1); +} + +function diffCellHTML(text, kind) { + if (!text) return ''; + const value = escapeHTML(text); + if (kind === 'deleted' || kind === 'added') return '' + value + ''; + return value; +} + +function inlineChangedSegments(left, right) { + left = String(left || ''); + right = String(right || ''); + let prefix = 0; + while (prefix < left.length && prefix < right.length && left[prefix] === right[prefix]) prefix += 1; + let suffix = 0; + while ( + suffix < left.length - prefix && + suffix < right.length - prefix && + left[left.length - 1 - suffix] === right[right.length - 1 - suffix] + ) { + suffix += 1; + } + const oldEnd = left.length - suffix; + const newEnd = right.length - suffix; + return { + left: escapeHTML(left.slice(0, prefix)) + '' + escapeHTML(left.slice(prefix, oldEnd) || ' ') + '' + escapeHTML(left.slice(oldEnd)), + right: escapeHTML(right.slice(0, prefix)) + '' + escapeHTML(right.slice(prefix, newEnd) || ' ') + '' + escapeHTML(right.slice(newEnd)) + }; +} + +async function handlePullRequestAction(trigger) { + const panel = trigger.closest('[data-pr-id]'); + if (!panel) return; + const id = Number(panel.getAttribute('data-pr-id') || 0); + const action = trigger.getAttribute('data-pr-action') || ''; + const textarea = panel.querySelector('[data-pr-comment]'); + const deleteBranch = panel.querySelector('[data-pr-delete-branch]'); + const comment = textarea ? textarea.value.trim() : ''; + try { + let confirmed = true; + if (action === 'merge') { + confirmed = await confirmModal({ + title: 'Merge pull request?', + body: deleteBranch && deleteBranch.checked ? 'Merge this pull request and delete the source branch afterwards.' : 'Merge this pull request into the target branch.', + confirm: 'Merge' + }); + } else if (action === 'reject') { + confirmed = await confirmModal({ + title: 'Request changes?', + body: comment ? 'Submit this review as changes requested.' : 'Submit a changes requested review without a note.', + confirm: 'Request changes' + }); + } else if (action === 'close') { + confirmed = await confirmModal({ + title: 'Close pull request?', + body: 'Close this pull request without merging it.', + confirm: 'Close PR' + }); + } else if (action === 'reopen') { + confirmed = await confirmModal({ + title: 'Reopen pull request?', + body: 'Reopen this pull request so it can be reviewed and merged again.', + confirm: 'Reopen PR' + }); + } + if (!confirmed) return; + setPullRequestActionsBusy(panel, true, action); + const data = await postJSON('/api/actions/pr', { + id, + action, + comment, + delete_branch: !!(deleteBranch && deleteBranch.checked) + }); + if (Array.isArray(data.prs)) updatePullRequestUI(data.prs); + setSyncStatus('Pull request updated.', 'is-current'); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } finally { + setPullRequestActionsBusy(panel, false); + } +} + +function showPullRequestReplyEditor(trigger) { + const panel = trigger.closest('[data-pr-id]'); + if (!panel) return; + const host = trigger.closest('.pr-inline-comment-body') || trigger.closest('.pr-reply') || trigger.closest('.pr-note'); + if (!host) return; + const existing = host.parentElement ? host.parentElement.querySelector('[data-draft-editor][data-draft-kind="reply"]') : null; + if (existing) existing.remove(); + const editor = document.createElement('div'); + editor.className = 'inline-draft-editor'; + if (host.parentElement && host.parentElement.classList.contains('pr-inline-after-context')) { + editor.classList.add('pr-inline-reply-editor'); + } + editor.setAttribute('data-draft-editor', ''); + editor.setAttribute('data-draft-kind', 'reply'); + editor.setAttribute('data-pr-id', panel.getAttribute('data-pr-id') || ''); + editor.setAttribute('data-target-note-id', trigger.getAttribute('data-target-note-id') || ''); + editor.setAttribute('data-target-comment-id', trigger.getAttribute('data-target-comment-id') || ''); + editor.innerHTML = inlineDraftEditorHTML('Reply'); + host.insertAdjacentElement('afterend', editor); + focusInlineDraft(editor); +} + +async function submitPullRequestReply(editor) { + const id = Number(editor.getAttribute('data-pr-id') || 0); + const textarea = editor.querySelector('[data-draft-text]'); + const text = textarea ? textarea.value.trim() : ''; + if (!text) return; + const targetNoteID = Number(editor.getAttribute('data-target-note-id') || 0); + const targetCommentID = Number(editor.getAttribute('data-target-comment-id') || 0); + try { + setInlineDraftBusy(editor, true); + const data = await postJSON('/api/actions/pr', { + id, + action: 'reply', + comment: text, + target_note_id: targetNoteID, + target_comment_id: targetCommentID + }); + if (Array.isArray(data.prs)) updatePullRequestUI(data.prs); + rememberPullRequestScrollTarget(findSubmittedReplyTarget(data, id, text) || fallbackReplyTarget(targetNoteID, targetCommentID)); + setSyncStatus('Reply added.', 'is-current'); + window.location.reload(); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } finally { + setInlineDraftBusy(editor, false); + } +} + +function fallbackReplyTarget(noteID, commentID) { + if (commentID) return 'pr-comment-' + commentID; + if (noteID) return 'pr-note-' + noteID; + return ''; +} + +function rememberPullRequestScrollTarget(targetID) { + if (!targetID) return; + try { + window.sessionStorage.setItem(prScrollTargetKey, targetID); + } catch (_) {} +} + +function restorePullRequestScrollTarget() { + let targetID = ''; + try { + targetID = window.sessionStorage.getItem(prScrollTargetKey) || ''; + if (targetID) window.sessionStorage.removeItem(prScrollTargetKey); + } catch (_) {} + if (!targetID) return; + window.setTimeout(function () { + const target = document.getElementById(targetID); + if (!target) return; + target.scrollIntoView({block: 'center', behavior: 'smooth'}); + target.classList.add('is-scroll-target'); + window.setTimeout(function () { target.classList.remove('is-scroll-target'); }, 1800); + }, 80); +} + +function findSubmittedReplyTarget(data, prID, body) { + const prs = Array.isArray(data && data.prs) ? data.prs : []; + const pr = prs.find(function (item) { return Number(item.id || 0) === Number(prID || 0); }); + if (!pr) return ''; + const normalizedBody = String(body || '').trim(); + let found = null; + for (const note of [].concat(pr.comments || [], pr.reviews || [])) { + for (const comment of collectPullRequestComments(note)) { + if (String(comment.body || '').trim() === normalizedBody) { + if (!found || Number(comment.id || 0) > Number(found.id || 0)) found = comment; + } + } + } + return found && found.id ? 'pr-comment-' + found.id : ''; +} + +function collectPullRequestComments(noteOrComment) { + const out = []; + function visit(comment) { + if (!comment) return; + out.push(comment); + for (const reply of comment.replies || []) visit(reply); + } + for (const comment of noteOrComment.comments || []) visit(comment); + for (const reply of noteOrComment.replies || []) visit(reply); + return out; +} + +function setPullRequestActionsBusy(panel, busy, activeAction) { + for (const button of panel.querySelectorAll('[data-pr-action]')) { + button.disabled = busy; + if (!button.dataset.label) button.dataset.label = button.textContent; + if (busy && button.getAttribute('data-pr-action') === activeAction) { + button.textContent = 'Working...'; + } else { + button.textContent = button.dataset.label; + } + } +} + +function showReviewDraftEditor(button) { + const filePanel = button.closest('[data-review-file]'); + const row = button.closest('.visual-diff-row'); + const host = row || filePanel; + if (!host) return; + const existing = host.parentElement ? host.parentElement.querySelector('[data-draft-editor][data-draft-kind="review-comment"]') : null; + if (existing) existing.remove(); + const editor = document.createElement('div'); + editor.className = row ? 'visual-diff-row review-draft-editor-row' : 'inline-draft-editor'; + editor.setAttribute('data-draft-editor', ''); + editor.setAttribute('data-draft-kind', 'review-comment'); + const target = reviewDraftTargetFromButton(button); + editor.setAttribute('data-review-kind', target.kind); + editor.setAttribute('data-review-file', target.file); + editor.setAttribute('data-review-line', String(target.line || 0)); + editor.innerHTML = row ? '
    ' + inlineDraftEditorHTML('Comment') + '
    ' : inlineDraftEditorHTML('Comment'); + if (row) row.insertAdjacentElement('afterend', editor); + else filePanel.querySelector('.diff-header')?.insertAdjacentElement('afterend', editor); + editor._reviewTrigger = button; + focusInlineDraft(editor); + if (!restoringReviewDraft) saveReviewDraftState(); +} + +function addReviewDraftComment(button, text) { + const comment = reviewCommentFromButton(button, text); + reviewDraftComments.push(comment); + renderReviewDraftComment(button, comment); + updateReviewDraftState(); + saveReviewDraftState(); +} + +function reviewCommentFromButton(button, text) { + const filePanel = button.closest('[data-review-file]'); + const row = button.closest('.visual-diff-row'); + const file = button.getAttribute('data-file') || (filePanel ? filePanel.getAttribute('data-review-file') : ''); + const line = Number(button.getAttribute('data-line') || (row ? row.querySelector('[data-new-line]')?.getAttribute('data-new-line') : 0) || 0); + return { + body: text, + file, + kind: button.hasAttribute('data-review-comment-file') ? 'file' : 'line', + side: 'new', + hunk: row ? row.getAttribute('data-hunk') || '' : '', + hunk_index: Number(row ? row.getAttribute('data-hunk-index') || 0 : 0), + old_start: Number(row ? row.getAttribute('data-old-start') || 0 : 0), + new_start: Number(row ? row.getAttribute('data-new-start') || 0 : 0), + offset: Number(row ? row.getAttribute('data-offset') || 0 : 0), + line, + line_text: row ? (row.querySelector('pre[data-new-line]')?.innerText || '').replace(/đź’¬\s*$/, '').trimEnd() : '' + }; +} + +function submitInlineDraft(button) { + const editor = button.closest('[data-draft-editor]'); + if (!editor) return; + const textarea = editor.querySelector('[data-draft-text]'); + const text = textarea ? textarea.value.trim() : ''; + if (!text) { + editor.classList.add('has-error'); + if (textarea) textarea.focus(); + return; + } + editor.classList.remove('has-error'); + const kind = editor.getAttribute('data-draft-kind') || ''; + if (kind === 'reply') { + submitPullRequestReply(editor); + return; + } + if (kind === 'review-comment') { + const trigger = editor._reviewTrigger || findReviewDraftButton({ + kind: editor.getAttribute('data-review-kind') || 'line', + file: editor.getAttribute('data-review-file') || '', + line: Number(editor.getAttribute('data-review-line') || 0) + }); + if (!trigger) return; + addReviewDraftComment(trigger, text); + editor.remove(); + saveReviewDraftState(); + } +} + +function inlineDraftEditorHTML(label) { + return '
    Comment is required.
    '; +} + +function focusInlineDraft(editor) { + window.setTimeout(function () { + const textarea = editor.querySelector('[data-draft-text]'); + if (textarea) textarea.focus(); + }, 0); +} + +function setInlineDraftBusy(editor, busy) { + for (const button of editor.querySelectorAll('button')) button.disabled = busy; + const textarea = editor.querySelector('[data-draft-text]'); + if (textarea) textarea.disabled = busy; +} + +function renderReviewDraftComment(button, comment) { + const row = button.closest('.visual-diff-row'); + const host = row || button.closest('[data-review-file]'); + if (!host) return; + const html = '
    You commented' + (comment.kind === 'line' && comment.line ? ' line ' + escapeHTML(comment.line) + '' : '') + '
    ' + escapeHTML(comment.body) + '
    '; + if (row) row.insertAdjacentHTML('afterend', '
    ' + html + '
    '); + else host.querySelector('.diff-header')?.insertAdjacentHTML('afterend', html); +} + +function updateReviewDraftState() { + const form = document.querySelector('[data-pr-review-submit]'); + if (!form) return; + form.classList.toggle('has-drafts', reviewDraftComments.length > 0); +} + +async function submitReviewDraft(button) { + const form = button.closest('[data-pr-review-submit]'); + if (!form) return; + const id = Number(form.getAttribute('data-pr-id') || 0); + const note = form.querySelector('[data-pr-review-note]'); + const action = button.getAttribute('data-pr-review-action') || 'comment'; + const mapped = action === 'approve' ? 'approve' : action === 'reject' ? 'reject' : 'review-comment'; + if (!reviewDraftComments.length && !String(note ? note.value : '').trim() && mapped === 'review-comment') { + setSyncStatus('Add a review note or at least one inline comment.', 'is-stale'); + return; + } + button.disabled = true; + try { + const data = await postJSON('/api/actions/pr', { + id, + action: mapped, + comment: note ? note.value.trim() : '', + comments: reviewDraftComments + }); + reviewDraftComments.splice(0, reviewDraftComments.length); + clearStoredReviewDraft(id); + updatePullRequestUI(data.prs || []); + window.location.href = '/prs/' + encodeURIComponent(String(id)); + } catch (err) { + setSyncStatus(compactError(err), 'is-stale'); + } finally { + button.disabled = false; + } +} + +function currentReviewID() { + const form = document.querySelector('[data-pr-review-submit]'); + if (form) return Number(form.getAttribute('data-pr-id') || 0); + const review = document.querySelector('[data-pr-review-diff]'); + return review ? Number(review.getAttribute('data-pr-id') || 0) : 0; +} + +function reviewDraftStorageKey(id) { + return id ? 'bgit.reviewDraft.' + id : ''; +} + +function saveReviewDraftState() { + if (restoringReviewDraft) return; + const id = currentReviewID(); + const key = reviewDraftStorageKey(id); + if (!key) return; + const note = document.querySelector('[data-pr-review-note]'); + const editors = Array.from(document.querySelectorAll('[data-draft-editor][data-draft-kind="review-comment"]')).map(function (editor) { + const textarea = editor.querySelector('[data-draft-text]'); + return { + kind: editor.getAttribute('data-review-kind') || 'line', + file: editor.getAttribute('data-review-file') || '', + line: Number(editor.getAttribute('data-review-line') || 0), + text: textarea ? textarea.value : '' + }; + }).filter(function (editor) { return editor.file || editor.text; }); + const state = { + note: note ? note.value : '', + comments: reviewDraftComments, + editors + }; + if (!state.note && !state.comments.length && !state.editors.length) { + window.localStorage.removeItem(key); + return; + } + window.localStorage.setItem(key, JSON.stringify(state)); +} + +function restoreReviewDraftState() { + const id = currentReviewID(); + const key = reviewDraftStorageKey(id); + if (!key) return; + let state = null; + try { + state = JSON.parse(window.localStorage.getItem(key) || 'null'); + } catch (_) { + state = null; + } + if (!state) return; + restoringReviewDraft = true; + const note = document.querySelector('[data-pr-review-note]'); + if (note && typeof state.note === 'string') note.value = state.note; + for (const comment of Array.isArray(state.comments) ? state.comments : []) { + const button = findReviewDraftButton(comment); + if (!button) continue; + reviewDraftComments.push(comment); + renderReviewDraftComment(button, comment); + } + for (const draft of Array.isArray(state.editors) ? state.editors : []) { + const button = findReviewDraftButton(draft); + if (!button) continue; + showReviewDraftEditor(button); + const editor = document.querySelector('[data-draft-editor][data-review-file="' + cssEscape(draft.file || '') + '"][data-review-line="' + String(Number(draft.line || 0)) + '"]'); + const textarea = editor ? editor.querySelector('[data-draft-text]') : null; + if (textarea) textarea.value = draft.text || ''; + } + restoringReviewDraft = false; + updateReviewDraftState(); + saveReviewDraftState(); +} + +function clearStoredReviewDraft(id) { + const key = reviewDraftStorageKey(id); + if (key) window.localStorage.removeItem(key); +} + +function reviewDraftTargetFromButton(button) { + const filePanel = button.closest('[data-review-file]'); + return { + kind: button.hasAttribute('data-review-comment-file') ? 'file' : 'line', + file: button.getAttribute('data-file') || (filePanel ? filePanel.getAttribute('data-review-file') : ''), + line: Number(button.getAttribute('data-line') || 0) + }; +} + +function findReviewDraftButton(target) { + const file = target.file || ''; + const line = Number(target.line || 0); + if ((target.kind || '') === 'file') { + return document.querySelector('[data-review-comment-file="' + cssEscape(file) + '"]'); + } + return document.querySelector('[data-review-comment-line][data-file="' + cssEscape(file) + '"][data-line="' + String(line) + '"]'); +} + +function cssEscape(value) { + if (window.CSS && window.CSS.escape) return window.CSS.escape(String(value)); + return String(value).replace(/["\\]/g, '\\$&'); +} + +function modalDialog(options) { + return new Promise(function (resolve) { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + const files = Array.isArray(options.files) ? options.files : []; + const fileListHTML = files.length ? '' : ''; + const fieldHTML = options.multiline ? '' : ''; + const inputHTML = options.prompt ? '' : ''; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + const input = overlay.querySelector('[data-modal-input]'); + const error = overlay.querySelector('[data-modal-error]'); + const close = function (value) { + overlay.remove(); + resolve(value); + }; + overlay.querySelector('[data-modal-cancel]').addEventListener('click', function () { close(false); }); + overlay.querySelector('[data-modal-confirm]').addEventListener('click', function () { + if (!input) { + close(true); + return; + } + const value = input.value.trim(); + if (options.required && !value) { + if (error) { + error.textContent = (options.inputLabel || 'Value') + ' is required.'; + error.hidden = false; + } + input.focus(); + return; + } + close(value); + }); + overlay.addEventListener('click', function (event) { + if (event.target === overlay) close(false); + }); + overlay.addEventListener('keydown', function (event) { + if (event.key === 'Escape') close(false); + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + overlay.querySelector('[data-modal-confirm]').click(); + } + }); + window.setTimeout(function () { + (input || overlay.querySelector('[data-modal-confirm]')).focus(); + }, 0); + }); +} + +async function hydrateRefs() { + const select = document.querySelector('[data-ref-selector]'); + if (!select) return; + const current = new URL(window.location.href).searchParams.get('ref') || select.value; + try { + const data = await fetchJSON('/api/refs'); + if (!Array.isArray(data.refs) || data.refs.length === 0) return; + select.textContent = ''; + let currentGroup = ''; + let group = null; + for (const ref of data.refs) { + if (ref.kind !== currentGroup) { + currentGroup = ref.kind; + group = document.createElement('optgroup'); + group.label = currentGroup; + select.appendChild(group); + } + const option = document.createElement('option'); + option.value = ref.full_name; + option.textContent = ref.name; + if (ref.full_name === current) option.selected = true; + group.appendChild(option); + } + } catch (_) { + // Server-rendered options remain usable if the JSON API is unavailable. + } +} + +async function refreshRemoteState(options) { + options = options || {}; + if (remoteRefreshInFlight) return; + remoteRefreshInFlight = true; + setRemoteRefreshSpinning(true); + if (!remoteSyncInitialized) { + setRemoteSyncStatus('syncing', 'Synchronising'); + } + try { + const ref = currentSelectedRef(); + const data = await fetchJSON('/api/state' + (ref ? '?ref=' + encodeURIComponent(ref) : '')); + currentWebState = data; + applyRepositoryState(data); + await refreshPullRequests(!!options.refreshPullRequests); + reconcileRemoteState(data); + } catch (err) { + remoteSyncInitialized = true; + setRemoteSyncStatus('error', compactError(err)); + } finally { + remoteRefreshInFlight = false; + setRemoteRefreshSpinning(false); + } +} + +async function refreshPullRequests(refresh) { + const tab = document.querySelector('[data-pr-tab]'); + const list = document.querySelector('[data-pr-list]'); + if (!tab && !list) return; + try { + const data = await fetchJSON('/api/prs' + (refresh ? '?refresh=1' : '')); + updatePullRequestUI(Array.isArray(data.prs) ? data.prs : []); + } catch (_) { + // Lack of PR visibility should not affect repository freshness. + } +} + +function currentSelectedRef() { + const urlRef = new URL(window.location.href).searchParams.get('ref'); + if (urlRef) return urlRef; + const selector = document.querySelector('[data-ref-selector]'); + return selector ? selector.value : ''; +} + +function updatePullRequestUI(prs) { + const tab = document.querySelector('[data-pr-tab]'); + if (tab) tab.hidden = prs.length === 0; + const count = document.querySelector('[data-pr-tab-count]'); + if (count) { + count.textContent = String(prs.length); + } + const list = document.querySelector('[data-pr-list]'); + if (list) { + list.innerHTML = pullRequestListHTML(prs); + } +} + +function pullRequestListHTML(prs) { + if (!prs.length) return '
    No pull requests found.
    '; + return '
      ' + prs.map(function (pr) { + const approvals = Number(pr.approvals || 0); + const approvalText = approvals > 0 ? '' + approvals + ' approval' + (approvals === 1 ? '' : 's') + '' : ''; + const id = escapeHTML(String(pr.id || '')); + const url = '/prs/' + id; + return '
    • ' + escapeHTML(shortRefName(pr.source || '')) + ' → ' + escapeHTML(shortRefName(pr.target || '')) + '
      ' + escapeHTML(pr.status || 'open') + '' + approvalText + '
    • '; + }).join('') + '
    '; +} + +function shortRefName(ref) { + return String(ref || '').replace(/^refs\/heads\//, '').replace(/^refs\/tags\//, ''); +} + +function escapeHTML(value) { + return String(value).replace(/[&<>"']/g, function (ch) { + return {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[ch]; + }); +} + +function reconcileRemoteState(data) { + remoteSyncInitialized = true; + if (data && data.fetch_error) { + setRemoteSyncStatus('error', compactError({message: data.fetch_error})); + return; + } + if (data && Number(data.behind || 0) > 0) { + setRemoteSyncStatus('behind', 'NOT PULLED'); + return; + } + if (data && Number(data.ahead || 0) > 0) { + setRemoteSyncStatus('ahead', 'NOT PUSHED'); + return; + } + if (data && Array.isArray(data.unstaged_files) && data.unstaged_files.length > 0) { + setRemoteSyncStatus('dirty', 'UNSTAGED'); + return; + } + if (data && Array.isArray(data.untracked_files) && data.untracked_files.length > 0) { + setRemoteSyncStatus('dirty', 'UNTRACKED'); + return; + } + if (data && data.dirty) { + setRemoteSyncStatus('dirty', 'UNCOMMITTED'); + return; + } + setRemoteSyncStatus('current', 'SYNCHED'); +} + +function applyRepositoryState(state) { + clearStateMarkers(); + if (!state) return; + const staged = new Set((state.staged_files || []).map(pathKey)); + const unstaged = new Set((state.unstaged_files || []).map(pathKey)); + const untracked = new Set((state.untracked_files || []).map(pathKey)); + const unpushed = new Set((state.unpushed_files || []).map(pathKey)); + const unpulled = new Set((state.unpulled_files || []).map(pathKey)); + for (const row of document.querySelectorAll('[data-file-row]')) { + const path = pathKey(row.getAttribute('data-file-path') || ''); + if (!path || path === '..') continue; + if (matchesStatePath(path, untracked)) addFileState(row, 'UNTRACKED', 'dirty', 'stage', path); + if (matchesStatePath(path, unstaged)) addFileState(row, 'UNSTAGED', 'dirty', 'stage', path); + if (matchesStatePath(path, staged)) addFileState(row, 'UNCOMMITTED', 'dirty', 'unstage', path); + if (matchesStatePath(path, unpushed)) addFileState(row, 'NOT PUSHED', 'ahead'); + if (matchesStatePath(path, unpulled)) addFileState(row, 'NOT PULLED', 'behind'); + } + addSyntheticFileRows(untracked, 'UNTRACKED', 'dirty'); + addSyntheticFileRows(staged, 'UNCOMMITTED', 'dirty'); + updateRepoActionButtons(state); + markCommits(state.unpushed_commits || [], 'NOT PUSHED', 'ahead'); + markCommits(state.unpulled_commits || [], 'NOT PULLED', 'behind'); +} + +function clearStateMarkers() { + for (const el of document.querySelectorAll('[data-state-marker]')) el.remove(); + for (const row of document.querySelectorAll('.is-state-dirty,.is-state-ahead,.is-state-behind')) { + row.classList.remove('is-state-dirty', 'is-state-ahead', 'is-state-behind'); + } + updateRepoActionButtons(null); +} + +function pathKey(path) { + return String(path || '').replace(/^\/+/, ''); +} + +function matchesStatePath(rowPath, statePaths) { + if (statePaths.has(rowPath)) return true; + return Array.from(statePaths).some(function (path) { + return path.startsWith(rowPath + '/'); + }); +} + +function addSyntheticFileRows(paths, label, kind) { + const table = document.querySelector('[data-file-list]'); + if (!table) return; + const current = currentTreePath(); + const existing = new Set(Array.from(document.querySelectorAll('[data-file-row]')).map(function (row) { + return pathKey(row.getAttribute('data-file-path') || ''); + })); + for (const path of paths) { + if (!path || existing.has(path)) continue; + const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; + if (parent !== current) continue; + const name = path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path; + const row = document.createElement('tr'); + row.className = 'is-state-' + kind; + row.setAttribute('data-state-marker', 'true'); + row.setAttribute('data-file-row', ''); + row.setAttribute('data-file-name', name.toLowerCase()); + row.setAttribute('data-file-path', path); + row.innerHTML = 'file' + escapeHTML(name) + '' + stateMarkerHTML(label, kind, stateActionForKind(kind, path)) + 'local'; + table.appendChild(row); + } +} + +function currentTreePath() { + const path = window.location.pathname; + if (!path.startsWith('/tree/')) return ''; + return pathKey(decodeURIComponent(path.slice('/tree/'.length))); +} + +function addFileState(row, label, kind, actionKind, path) { + row.classList.add('is-state-' + kind); + const target = row.querySelector('[data-file-state]') || row.children[1]; + if (!target) return; + const actions = actionKind ? stateActionForKind(actionKind, path) : stateActionForKind(kind); + target.insertAdjacentHTML('beforeend', stateMarkerHTML(label, kind, actions)); +} + +function markCommits(commits, label, kind) { + const hashes = new Map(); + for (const commit of commits) { + if (commit.hash) hashes.set(commit.hash, commit); + } + for (const row of document.querySelectorAll('[data-commit-row]')) { + const hash = row.getAttribute('data-commit-hash') || ''; + if (!hashes.has(hash)) continue; + row.classList.add('is-state-' + kind); + const target = row.querySelector('[data-commit-state]') || row.firstElementChild; + if (target) target.insertAdjacentHTML('beforeend', stateMarkerHTML(label, kind, '')); + hashes.delete(hash); + } + const list = document.querySelector('.commits'); + if (!list || kind !== 'behind') return; + const missing = Array.from(hashes.values()).reverse(); + for (const commit of missing) { + const li = document.createElement('li'); + li.className = 'is-state-behind'; + li.setAttribute('data-state-marker', 'true'); + const commitURL = '/commits?commit=' + encodeURIComponent(commit.hash || ''); + li.setAttribute('data-commit-row', 'true'); + li.setAttribute('data-commit-hash', commit.hash || ''); + li.setAttribute('data-commit-href', commitURL); + li.innerHTML = '
    ' + escapeHTML(commit.subject || commit.short_hash || '') + '' + stateMarkerHTML(label, kind, '') + '
    ' + escapeHTML(commit.author || '') + ' authored remotely
    '; + list.insertBefore(li, list.firstChild); + } +} + +function stateActionForKind(kind, path) { + if (kind === 'stage') return diffActionHTML(path, 'worktree') + ''; + if (kind === 'unstage') return diffActionHTML(path, 'staged') + ''; + if (kind === 'commit') return ''; + return ''; +} + +function diffActionHTML(path, mode) { + return ''; +} + +function diffIconSVG() { + return ''; +} + +function updateRepoActionButtons(state) { + const control = document.querySelector('.repo-action-control'); + if (!control || control.getAttribute('data-code-actions') !== 'true') { + setRepoActionButton('[data-repo-commit]', 0, 'COMMIT'); + setRepoActionButton('[data-repo-push]', 0, 'PUSH'); + setRepoActionButton('[data-repo-pull]', 0, 'PULL'); + setRepoActionButton('[data-repo-uncommit]', 0, 'UNCOMMIT'); + return; + } + const stagedCount = state && Array.isArray(state.staged_files) ? state.staged_files.length : 0; + const aheadCount = state ? Number(state.ahead || 0) : 0; + const behindCount = state ? Number(state.behind || 0) : 0; + setRepoActionButton('[data-repo-commit]', stagedCount, 'COMMIT'); + setRepoActionButton('[data-repo-push]', aheadCount, 'PUSH'); + setRepoActionButton('[data-repo-pull]', behindCount, 'PULL'); + setRepoActionButton('[data-repo-uncommit]', aheadCount, 'UNCOMMIT'); + applyCapabilityUI(); +} + +function setRepoActionButton(selector, count, label) { + const button = document.querySelector(selector); + if (!button) return; + button.hidden = Number(count || 0) <= 0; + button.dataset.actionLabel = label; + button.textContent = label; +} + +function setWebActionsBusy(busy, activeLabel) { + webActionInFlight = busy; + for (const button of document.querySelectorAll('[data-web-action]')) { + button.disabled = busy; + if (button.matches('.repo-action-button')) { + if (busy && activeLabel && !button.hidden) { + button.textContent = activeLabel; + } else if (!busy && button.dataset.actionLabel) { + button.textContent = button.dataset.actionLabel; + } + } + } + applyCapabilityUI(); +} + +function stateMarkerHTML(label, kind, action) { + return '' + escapeHTML(label) + '' + (action || '') + ''; +} + +function setRemoteSyncStatus(state, text) { + const badge = document.querySelector('[data-remote-sync-badge]'); + const button = document.querySelector('[data-remote-refresh]'); + if (!badge || !button) return; + badge.textContent = text; + badge.title = text; + badge.className = 'remote-badge is-' + state; + const syncing = state === 'syncing'; + button.disabled = syncing; + button.classList.toggle('is-spinning', syncing); + button.classList.toggle('is-current', state === 'current'); +} + +function setRemoteRefreshSpinning(spinning) { + const button = document.querySelector('[data-remote-refresh]'); + if (!button) return; + button.disabled = spinning; + button.classList.toggle('is-spinning', spinning); + if (spinning) button.classList.remove('is-current'); +} + +function compactError(err) { + let text = err && err.message ? err.message : 'Remote check failed'; + text = text.replace(/\s+/g, ' ').trim(); + if (!text) text = 'Remote check failed'; + if (text.length > 80) text = text.slice(0, 77) + '...'; + return text; +} + +function reloadLocalView() { + const url = new URL(window.location.href); + url.searchParams.delete('_remote'); + url.searchParams.set('_ts', String(Date.now())); + window.location.replace(url.toString()); +} + +function remoteHeadHash(data) { + if (data.commit && data.commit.hash) return data.commit.hash; + if (data.head && data.head.hash) return data.head.hash; + if (Array.isArray(data.commits) && data.commits[0] && data.commits[0].hash) return data.commits[0].hash; + return ''; +} + +function setSyncStatus(text, cls) { + const el = document.querySelector('[data-sync-status]'); + if (!el) return; + window.clearTimeout(setSyncStatus.timer); + el.textContent = text; + el.className = 'sync-status is-visible ' + (cls || ''); + if (cls === 'is-current') { + setSyncStatus.timer = window.setTimeout(function () { + el.classList.remove('is-visible'); + }, 1800); + } +} + +function clearSyncStatus() { + const el = document.querySelector('[data-sync-status]'); + if (!el) return; + window.clearTimeout(setSyncStatus.timer); + el.classList.remove('is-visible'); +} + +function query(values) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value) params.set(key, value); + } + const text = params.toString(); + return text ? '?' + text : ''; +} diff --git a/www/bgit-mark.png b/www/bgit-mark.png new file mode 100644 index 0000000..5d887ea Binary files /dev/null and b/www/bgit-mark.png differ diff --git a/www/favicon.ico b/www/favicon.ico new file mode 100644 index 0000000..4be2463 Binary files /dev/null and b/www/favicon.ico differ diff --git a/www/page.html b/www/page.html new file mode 100644 index 0000000..ddf6887 --- /dev/null +++ b/www/page.html @@ -0,0 +1,24 @@ + + + + + + {{TITLE}} + + + + + +
    Checking remote…
    +{{BODY}} + + + +