From d0ab8ee7dcac79eaf618c8617553fe73cae4be6c Mon Sep 17 00:00:00 2001 From: liangshuo-1 Date: Thu, 16 Apr 2026 18:16:31 +0800 Subject: [PATCH] ci: consolidate workflows into layered CI pyramid with results gate (#510) * ci: consolidate 6 workflows into layered CI pyramid with results gate Merge tests.yml, lint.yml, coverage.yml, cli-e2e.yml, gitleaks.yml, and license-header.yml into a single ci.yml with fail-fast layering: - L1 fast-gate: build, vet, gofmt, go mod tidy - L2 quality: unit-test, lint, coverage (40% threshold + Codecov), deadcode (incremental) - L3 e2e: dry-run (no secrets) + live (with secrets, fork-skip) - L4 security: gitleaks, govulncheck, go-licenses, license-header Results gate aggregates all jobs as the single required check for branch protection. Also adds: - arch-audit.yml: weekly cron for dead code, complexity, deps, E2E gaps - .golangci.yml: depguard shortcuts-no-raw-http, forbidigo fmt.Print/log.Fatal - AGENTS.md: E2E testing conventions, updated pre-PR checks Change-Id: I2e21067a9e9e12d366d1b1a092227e9f7d60fe41 --- .codecov.yml | 3 + .github/workflows/arch-audit.yml | 116 ++++++++++ .github/workflows/ci.yml | 333 +++++++++++++++++++++++++++ .github/workflows/cli-e2e.yml | 83 ------- .github/workflows/coverage.yml | 58 ----- .github/workflows/gitleaks.yml | 28 --- .github/workflows/license-header.yml | 26 --- .github/workflows/lint.yml | 60 ----- .github/workflows/tests.yml | 43 ---- .golangci.yml | 18 ++ AGENTS.md | 31 ++- cmd/config/init_messages.go | 32 +-- cmd/config/init_messages_test.go | 12 +- cmd/service/service.go | 20 +- internal/util/strings_test.go | 53 +++++ 15 files changed, 583 insertions(+), 333 deletions(-) create mode 100644 .github/workflows/arch-audit.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/cli-e2e.yml delete mode 100644 .github/workflows/coverage.yml delete mode 100644 .github/workflows/gitleaks.yml delete mode 100644 .github/workflows/license-header.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/tests.yml create mode 100644 internal/util/strings_test.go diff --git a/.codecov.yml b/.codecov.yml index 2f0a040f..9c640e6e 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -6,3 +6,6 @@ coverage: patch: default: target: 60% + +github_checks: + annotations: true diff --git a/.github/workflows/arch-audit.yml b/.github/workflows/arch-audit.yml new file mode 100644 index 00000000..3db50f60 --- /dev/null +++ b/.github/workflows/arch-audit.yml @@ -0,0 +1,116 @@ +name: Architecture Audit + +on: + schedule: + - cron: '0 9 * * 1' # Monday 09:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.x' + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + + - name: Dead code detection + run: | + echo "## Dead Code" >> report.md + go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt + count=$(wc -l < deadcode.txt | tr -d ' ') + echo "Found **$count** unreachable functions" >> report.md + echo '```' >> report.md + cat deadcode.txt >> report.md + echo '```' >> report.md + + - name: Package complexity + run: | + echo "## Package Complexity" >> report.md + echo "" >> report.md + echo "Packages exceeding 2 000 lines or 20 files:" >> report.md + echo "" >> report.md + echo "| Package | Files | Lines | Deps |" >> report.md + echo "|---------|-------|-------|------|" >> report.md + found=0 + for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do + dir=$(go list -f '{{.Dir}}' "$pkg") + files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ') + lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ') + deps=$(go list -f '{{len .Imports}}' "$pkg") + if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then + echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md + found=1 + fi + done + if [ "$found" = "0" ]; then + echo "| _(none)_ | | | |" >> report.md + fi + + - name: Dependency freshness + run: | + echo "## Outdated Dependencies" >> report.md + echo '```' >> report.md + go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md + echo '```' >> report.md + + - name: Circular dependency check + run: | + echo "## Circular Dependencies" >> report.md + go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \ + go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt + if [ -s cycles.txt ]; then + echo '```' >> report.md + cat cycles.txt >> report.md + echo '```' >> report.md + else + echo "No circular dependencies detected." >> report.md + fi + + - name: E2E coverage gaps + run: | + echo "## E2E Coverage Gaps" >> report.md + echo "" >> report.md + echo "Shortcut domains without E2E tests:" >> report.md + echo "" >> report.md + found=0 + for domain in $(ls -d shortcuts/*/); do + name=$(basename "$domain") + if [ "$name" = "common" ]; then continue; fi + if [ ! -d "tests/cli_e2e/$name" ]; then + echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md + found=1 + fi + done + if [ "$found" = "0" ]; then + echo "All shortcut domains have E2E test directories." >> report.md + fi + + - name: Coverage + run: | + echo "## Coverage" >> report.md + packages=$(go list ./... | grep -v 'tests/cli_e2e') + go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true + total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}') + echo "Current total coverage: **${total:-n/a}**" >> report.md + + - name: Publish report + run: | + echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + cat report.md >> $GITHUB_STEP_SUMMARY + + - name: Upload report artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: arch-audit-${{ github.run_number }} + path: report.md + retention-days: 90 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..df35f110 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,333 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + actions: read + checks: write + pull-requests: write + +jobs: + # ── Layer 1: Fast Gate ───────────────────────────────────────────── + fast-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.x' + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + - name: Build + run: go build ./... + - name: Vet + run: go vet ./... + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "$unformatted" + echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit" + exit 1 + fi + - name: Check go.mod tidiness + run: | + go mod tidy + if ! git diff --quiet go.mod go.sum; then + echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes." + git diff go.mod go.sum + exit 1 + fi + + # ── Layer 2: Quality Gate ────────────────────────────────────────── + unit-test: + needs: fast-gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.x' + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + - name: Run tests + run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... + + lint: + needs: fast-gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.x' + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + - name: Run golangci-lint + run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main + + coverage: + needs: fast-gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.x' + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + - name: Run tests with coverage + run: | + packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/') + go test -race -coverprofile=coverage.txt -covermode=atomic $packages + - name: Upload coverage to Codecov + uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6 + with: + files: coverage.txt + token: ${{ secrets.CODECOV_TOKEN }} + - name: Check coverage threshold + run: | + total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%') + threshold=40 + echo "Coverage: ${total}% (threshold: ${threshold}%)" + if (( $(echo "$total < $threshold" | bc -l) )); then + echo "::error::Coverage ${total}% is below threshold ${threshold}%" + exit 1 + fi + - name: Coverage summary + if: ${{ !cancelled() }} + run: | + if [ ! -f coverage.txt ]; then + echo "No coverage data available" >> $GITHUB_STEP_SUMMARY + exit 0 + fi + total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') + echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "
Details" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + + deadcode: + needs: fast-gate + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.x' + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + - name: Dead code check (incremental) + run: | + # Analyze current HEAD (strip line:col for stable diff across line shifts) + # Filter "go: downloading ..." lines to avoid false diffs from module cache state + go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \ + grep -v '^go: ' | \ + sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt + + # Analyze base branch via worktree + git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}" + (cd /tmp/dc-base && python3 scripts/fetch_meta.py && \ + go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \ + grep -v '^go: ' | \ + sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || { + echo "::warning::Failed to analyze base branch — skipping incremental dead code check" + git worktree remove -f /tmp/dc-base 2>/dev/null || true + exit 0 + } + git worktree remove -f /tmp/dc-base + + # Only new dead code blocks the PR + comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt + if [ -s /tmp/dc-new.txt ]; then + echo "::group::New dead code" + cat /tmp/dc-new.txt + echo "::endgroup::" + echo "::error::New dead code detected — remove unreachable functions before merging" + exit 1 + fi + echo "No new dead code introduced" + + # ── Layer 3: E2E Gate ────────────────────────────────────────────── + e2e-dry-run: + needs: [unit-test, lint] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.x' + - name: Build lark-cli + run: make build + - name: Run dry-run E2E tests + env: + LARK_CLI_BIN: ${{ github.workspace }}/lark-cli + LARKSUITE_CLI_APP_ID: dry-run + LARKSUITE_CLI_APP_SECRET: dry-run + LARKSUITE_CLI_BRAND: feishu + run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression' + + e2e-live: + needs: [unit-test, lint] + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} + runs-on: ubuntu-latest + env: + TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }} + TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }} + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.x' + - name: Build lark-cli + run: make build + - name: Configure bot credentials + run: | + if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then + echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" + exit 1 + fi + printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin + - name: Run CLI E2E tests + env: + LARK_CLI_BIN: ${{ github.workspace }}/lark-cli + run: | + packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$') + if [ -z "$packages" ]; then + echo "No CLI E2E packages to test after exclusions." + exit 1 + fi + packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -) + go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v + - name: Publish CLI E2E test report + if: ${{ !cancelled() }} + uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 + with: + name: CLI E2E Tests + path: cli-e2e-report.xml + reporter: java-junit + use-actions-summary: true + list-suites: all + list-tests: all + + # ── Layer 4: Security & Compliance (parallel with L2-L3) ────────── + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.x' + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + - name: Gitleaks + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }} + - name: govulncheck + continue-on-error: true + run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + - name: Check dependency licenses + run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown + + license-header: + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Check license headers + uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad + with: + config: .licenserc.yaml + + # ── Results Gate (single required check for branch protection) ───── + results: + if: ${{ always() }} + needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header] + runs-on: ubuntu-latest + steps: + - name: Evaluate results + run: | + echo "## CI Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY + + # Any failure or cancellation in any job blocks the merge. + # Legitimately skipped jobs (deadcode on push, e2e-live on fork, + # license-header on push) are OK. + FAILED=0 + for result in \ + "${{ needs.fast-gate.result }}" \ + "${{ needs.unit-test.result }}" \ + "${{ needs.lint.result }}" \ + "${{ needs.coverage.result }}" \ + "${{ needs.deadcode.result }}" \ + "${{ needs.e2e-dry-run.result }}" \ + "${{ needs.e2e-live.result }}" \ + "${{ needs.security.result }}" \ + "${{ needs.license-header.result }}"; do + if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then + FAILED=1 + fi + done + + if [ "$FAILED" = "1" ]; then + echo "" + echo "::error::One or more CI jobs failed — see table above" + exit 1 + fi diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml deleted file mode 100644 index c0862086..00000000 --- a/.github/workflows/cli-e2e.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: CLI E2E Tests - -on: - push: - branches: [main] - paths: - - "**.go" - - go.mod - - go.sum - - Makefile - - scripts/fetch_meta.py - - tests/cli_e2e/** - - .github/workflows/cli-e2e.yml - pull_request: - branches: [main] - paths: - - "**.go" - - go.mod - - go.sum - - Makefile - - scripts/fetch_meta.py - - tests/cli_e2e/** - - .github/workflows/cli-e2e.yml - workflow_dispatch: - -permissions: - contents: read - actions: read - checks: write - -jobs: - cli-e2e: - # Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN. - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} - runs-on: ubuntu-latest - env: - TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }} - TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }} - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 - with: - go-version-file: go.mod - - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - with: - python-version: '3.x' - - - name: Build lark-cli - run: make build - - - name: Configure bot credentials - run: | - if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then - echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" - exit 1 - fi - printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin - - - name: Run CLI E2E tests - env: - LARK_CLI_BIN: ${{ github.workspace }}/lark-cli - run: | - packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$') - if [ -z "$packages" ]; then - echo "No CLI E2E packages to test after exclusions." - exit 1 - fi - # gotestsum requires --packages when --rerun-fails is combined with go test args after --. - packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -) - go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v - - - name: Publish CLI E2E test report - if: ${{ !cancelled() }} - uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 - with: - name: CLI E2E Tests - path: cli-e2e-report.xml - reporter: java-junit - use-actions-summary: true - list-suites: all - list-tests: all diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 921bf1e8..00000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Coverage - -on: - push: - branches: [main] - paths: - - "**.go" - - "!tests/cli_e2e/**" - - go.mod - - go.sum - - .github/workflows/coverage.yml - pull_request: - branches: [main] - paths: - - "**.go" - - "!tests/cli_e2e/**" - - go.mod - - go.sum - - .github/workflows/coverage.yml - -permissions: - contents: read - -jobs: - coverage: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 - with: - go-version-file: go.mod - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.x' - - - name: Fetch meta data - run: python3 scripts/fetch_meta.py - - - name: Run tests with coverage - run: | - packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/') - go test -race -coverprofile=coverage.txt -covermode=atomic $packages - - - name: Generate coverage report - run: | - total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') - echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "
Details" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml deleted file mode 100644 index d44b5d6a..00000000 --- a/.github/workflows/gitleaks.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Gitleaks - -on: - pull_request: - branches: [main] - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -jobs: - gitleaks: - # Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN. - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - with: - fetch-depth: 0 - - - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 - env: - # GITHUB_TOKEN is provided automatically by GitHub Actions. - # GITLEAKS_KEY must be configured as a repository secret. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }} diff --git a/.github/workflows/license-header.yml b/.github/workflows/license-header.yml deleted file mode 100644 index 8107ee8f..00000000 --- a/.github/workflows/license-header.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: License Header - -on: - pull_request: - branches: [main] - paths: - - "**/*.go" - - "**/*.js" - - "**/*.py" - - .licenserc.yaml - - .github/workflows/license-header.yml - -permissions: - contents: read - pull-requests: write - -jobs: - header-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - - - name: Check license headers - uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad - with: - config: .licenserc.yaml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index cec20a8b..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Lint - -on: - push: - branches: [main] - paths: - - "**.go" - - go.mod - - go.sum - - .golangci.yml - - .github/workflows/lint.yml - pull_request: - branches: [main] - paths: - - "**.go" - - go.mod - - go.sum - - .golangci.yml - - .github/workflows/lint.yml - -permissions: - contents: read - -jobs: - golangci-lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 - with: - go-version-file: go.mod - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.x' - - - name: Fetch meta_data.json - run: python3 scripts/fetch_meta.py - - - name: Ensure go.mod and go.sum are tidy - run: | - go mod tidy - if ! git diff --quiet go.mod go.sum; then - echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes." - git diff go.mod go.sum - exit 1 - fi - - - name: Run golangci-lint - run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main - - - name: Run govulncheck - continue-on-error: true # informational until Go version is upgraded - run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... - - - name: Check dependency licenses - run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 58351696..00000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Tests - -on: - push: - branches: [main] - paths: - - "**.go" - - go.mod - - go.sum - - .github/workflows/tests.yml - pull_request: - branches: [main] - paths: - - "**.go" - - go.mod - - go.sum - - .github/workflows/tests.yml - -permissions: - contents: read - -jobs: - unit-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 - with: - go-version-file: go.mod - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.x' - - - name: Fetch meta data - run: python3 scripts/fetch_meta.py - - - name: Run tests - run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... - - - name: Build - run: go build -v ./... diff --git a/.golangci.yml b/.golangci.yml index faa9ee79..6889caea 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -70,6 +70,14 @@ linters: desc: >- shortcuts must not import internal/vfs/localfileio directly. Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation. + shortcuts-no-raw-http: + files: + - "**/shortcuts/**" + deny: + - pkg: "net/http" + desc: >- + use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http. + The client layer handles auth, headers, and error normalization. forbidigo: forbid: # ── os: already wrapped in internal/vfs ── @@ -100,6 +108,16 @@ linters: msg: >- Do not use os.Exit in shortcuts/. Return an error instead and let the caller (cmd layer) decide how to terminate. + # ── output: shortcuts must use ctx.Out() ── + - pattern: fmt\.Print(f|ln)?\b + msg: >- + use ctx.Out() or ctx.OutFormat() for structured JSON output. + fmt.Print* bypasses the output envelope and breaks --jq/--format. + # ── logging: shortcuts must return errors, not log.Fatal ── + - pattern: log\.(Print|Fatal|Panic)(f|ln)?\b + msg: >- + use structured error return, not log.Fatal/Panic. + Shortcuts must return errors to the framework for proper exit code handling. # ── filepath: functions that access the filesystem ── - pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b msg: >- diff --git a/AGENTS.md b/AGENTS.md index e6ed5f87..b2392065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,9 +18,11 @@ make test # Full: vet + unit + integration ## Pre-PR Checks (match CI gates) 1. `make unit-test` -2. `go mod tidy` — must not change `go.mod`/`go.sum` -3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main` -4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown` +2. `go vet ./...` +3. `gofmt -l .` — must produce no output +4. `go mod tidy` — must not change `go.mod`/`go.sum` +5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main` +6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown` ## Commit & PR @@ -76,3 +78,26 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput - Every behavior change needs a test alongside the change. - `cmdutil.TestFactory(t, config)` for test factories. - `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state. + +### E2E Testing + +**Dry-run E2E (required for every shortcut change)** +- Validates request structure without calling real APIs +- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory +- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params +- No secrets needed — runs on fork PRs +- Explore correct params with `lark-cli --help` and `lark-cli schema` first + +**Live E2E (required for new flows or behavior changes)** +- Validates real API round-trips +- Place in `tests/cli_e2e//` +- Must be self-contained: create -> use -> cleanup +- Needs bot credentials (CI secrets, skipped on fork PRs) +- Reference: `tests/cli_e2e/task/task_status_workflow_test.go` + +| Change | Dry-run E2E | Live E2E | +|--------|:-----------:|:--------:| +| New shortcut | Required | Required | +| Modify shortcut flags/params | Required | If behavior changes | +| Shortcut bug fix | Required | If regression risk | +| Internal refactor (no shortcut impact) | Not needed | Not needed | diff --git a/cmd/config/init_messages.go b/cmd/config/init_messages.go index 4bfb622e..73948ca9 100644 --- a/cmd/config/init_messages.go +++ b/cmd/config/init_messages.go @@ -10,12 +10,12 @@ import ( ) type initMsg struct { - SelectAction string - CreateNewApp string - ConfigExistingApp string - Platform string - SelectPlatform string - Feishu string + SelectAction string + CreateNewApp string + ConfigExistingApp string + Platform string + SelectPlatform string + Feishu string // TTY (interactive) variants ScanQRCode string // header shown above QR code ScanOrOpenLink string // post-QR alt link prompt ("or open...") @@ -29,11 +29,11 @@ type initMsg struct { } var initMsgZh = &initMsg{ - SelectAction: "选择操作", - CreateNewApp: "一键配置应用 (推荐) ", - ConfigExistingApp: "手动输入应用凭证", - Platform: "平台", - SelectPlatform: "选择平台", + SelectAction: "选择操作", + CreateNewApp: "一键配置应用 (推荐) ", + ConfigExistingApp: "手动输入应用凭证", + Platform: "平台", + SelectPlatform: "选择平台", Feishu: "飞书", ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n", ScanOrOpenLink: "\n或打开以下链接完成配置:\n", @@ -46,11 +46,11 @@ var initMsgZh = &initMsg{ } var initMsgEn = &initMsg{ - SelectAction: "Select action", - CreateNewApp: "Set up your app with one click (Recommended)", - ConfigExistingApp: "Enter app credentials yourself", - Platform: "Platform", - SelectPlatform: "Select platform", + SelectAction: "Select action", + CreateNewApp: "Set up your app with one click (Recommended)", + ConfigExistingApp: "Enter app credentials yourself", + Platform: "Platform", + SelectPlatform: "Select platform", Feishu: "Feishu", ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n", ScanOrOpenLink: "\nOr open the link below in your browser:\n", diff --git a/cmd/config/init_messages_test.go b/cmd/config/init_messages_test.go index ecafb0ec..0bdaecf2 100644 --- a/cmd/config/init_messages_test.go +++ b/cmd/config/init_messages_test.go @@ -48,12 +48,12 @@ func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) { func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) { t.Helper() fields := map[string]string{ - "SelectAction": msg.SelectAction, - "CreateNewApp": msg.CreateNewApp, - "ConfigExistingApp": msg.ConfigExistingApp, - "Platform": msg.Platform, - "SelectPlatform": msg.SelectPlatform, - "Feishu": msg.Feishu, + "SelectAction": msg.SelectAction, + "CreateNewApp": msg.CreateNewApp, + "ConfigExistingApp": msg.ConfigExistingApp, + "Platform": msg.Platform, + "SelectPlatform": msg.SelectPlatform, + "Feishu": msg.Feishu, "ScanQRCode": msg.ScanQRCode, "ScanOrOpenLink": msg.ScanOrOpenLink, "WaitingForScan": msg.WaitingForScan, diff --git a/cmd/service/service.go b/cmd/service/service.go index 5639075b..63b6fc6b 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -101,16 +101,16 @@ type ServiceMethodOptions struct { SchemaPath string // Flags - Params string - Data string - As core.Identity - Output string - PageAll bool - PageLimit int - PageDelay int - Format string - JqExpr string - DryRun bool + Params string + Data string + As core.Identity + Output string + PageAll bool + PageLimit int + PageDelay int + Format string + JqExpr string + DryRun bool File string // --file flag value FileFields []string // auto-detected file field names from metadata } diff --git a/internal/util/strings_test.go b/internal/util/strings_test.go new file mode 100644 index 00000000..766a5d39 --- /dev/null +++ b/internal/util/strings_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +import "testing" + +func TestTruncateStr(t *testing.T) { + tests := []struct { + name string + s string + n int + want string + }{ + {"short string", "hello", 10, "hello"}, + {"exact length", "hello", 5, "hello"}, + {"truncate", "hello world", 5, "hello"}, + {"empty", "", 5, ""}, + {"zero limit", "hello", 0, ""}, + {"CJK characters", "你好世界测试", 4, "你好世界"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TruncateStr(tt.s, tt.n); got != tt.want { + t.Errorf("TruncateStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want) + } + }) + } +} + +func TestTruncateStrWithEllipsis(t *testing.T) { + tests := []struct { + name string + s string + n int + want string + }{ + {"short string", "hello", 10, "hello"}, + {"exact length", "hello", 5, "hello"}, + {"truncate with ellipsis", "hello world", 8, "hello..."}, + {"limit less than 3", "hello", 2, "he"}, + {"limit equals 3", "hello world", 3, "..."}, + {"empty", "", 5, ""}, + {"CJK with ellipsis", "你好世界测试", 5, "你好..."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TruncateStrWithEllipsis(tt.s, tt.n); got != tt.want { + t.Errorf("TruncateStrWithEllipsis(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want) + } + }) + } +}