diff --git a/.agents/pattern-agents.json b/.agents/pattern-agents.json new file mode 100644 index 0000000..9397a26 --- /dev/null +++ b/.agents/pattern-agents.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "github": { + "repo": "patterninc/blurb" + }, + "slack_channel": "#sre-comm", + "datadog": { + "service": "blurb", + "env": "production" + }, + "skills": { + "plugins": [ + "ai-sdlc", + "terraform", + ".claude/skills/change-github-workflows", + ".claude/skills/change-and-verify" + ] + } +} diff --git a/.claude/skills/change-and-verify/SKILL.md b/.claude/skills/change-and-verify/SKILL.md new file mode 100644 index 0000000..f253140 --- /dev/null +++ b/.claude/skills/change-and-verify/SKILL.md @@ -0,0 +1,23 @@ +--- +name: change-and-verify +description: Use when changing Ruby code in blurb (lib/blurb/**) and deciding how to verify the change before a PR, or when cutting a gem release. +--- + +# Changing code in blurb + +1. Read `AGENTS.md` (layout, what must not change) and `docs/adr/` before touching `lib/blurb/request.rb`, `lib/blurb/base_class.rb` or `lib/blurb/request_collection*.rb`. +2. Make the change. Every HTTP call goes through `Blurb::Request` (ADR 0002); don't call `RestClient` elsewhere. +3. Add or update a hermetic spec in `spec/unit/` for the behaviour you changed. Stub HTTP with WebMock (`stub_request`) and keep payloads in `spec/fixtures/`. Never put real credentials, profile IDs or refresh tokens in fixtures. +4. `make all` must pass: RuboCop, every pre-commit hook, unit specs and the SimpleCov gate — exactly what CI runs. If coverage rises, raise `minimum_coverage` in `spec/spec_helper.rb`. +5. If the change alters the public API (method names, key casing, exception classes), it is breaking: say so in the PR and in `CHANGELOG.md`. +6. Open the PR with a Conventional Commit title (`feat: …`, `fix: …`) and add a line under `## [Unreleased]` in `CHANGELOG.md`. + +## Live API check (optional, before a release) + +`make test-live` runs `spec/blurb/` against the real Amazon Advertising API. It needs a `.env` with `BLURB_CLIENT_ID`, `BLURB_CLIENT_SECRET`, `BLURB_REFRESH_TOKEN`, `BLURB_REGION` and `BLURB_PROFILE_ID`, and it creates campaigns, keywords and reports. Only run it when the user asks, against a test/sandbox profile. + +## Releasing a version (only when asked) + +1. Bump `spec.version` in `blurb.gemspec` (semver; breaking → minor while < 1.0). +2. Move the `[Unreleased]` entries in `CHANGELOG.md` under the new version and date; update the compare links. +3. Merge via PR. Then, from an up-to-date default branch, `bundle exec rake release` tags `vX.Y.Z`, pushes the tag and publishes to RubyGems. It needs the owner's RubyGems credentials; don't run it without the user. diff --git a/.claude/skills/change-github-workflows/SKILL.md b/.claude/skills/change-github-workflows/SKILL.md new file mode 100644 index 0000000..a209ab4 --- /dev/null +++ b/.claude/skills/change-github-workflows/SKILL.md @@ -0,0 +1,13 @@ +--- +name: change-github-workflows +description: Use when adding or changing a GitHub Actions workflow in blurb (.github/workflows/*.yml), for example CI, terraform plan/apply, Docker build, ECS or Lambda deploy. +--- + +# Changing GitHub workflows in blurb + +1. Load the `terraform:github-reusable-workflows` skill before you edit anything under `.github/workflows/`. +2. Check whether a reusable workflow in `patterninc/pattern-reusable-workflow` already covers the change: `terraform-ci-cd`, `ecs-deployment`, `docker-build-and-push`, `lambda-deploy`, `db-migrate`, `amplify-deployment` or `frontend-ci-checks`. If one does, call it rather than writing the steps inline. +3. Pin `uses:` to the latest release tag (`gh api repos/patterninc/pattern-reusable-workflow/tags --jq '.[0].name'`), never `@main`. Check the inputs and secrets at that tag, and pass secrets in an explicit `secrets:` block, never `secrets: inherit`. +4. AWS access goes through OIDC (`use-oidc: true` and the account's `aws-account-id`), not static keys. +5. If no reusable workflow fits, say so in the PR body and name the one you checked. +6. `make all` must pass. Its `check-yaml` hook validates the YAML. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..79bfc13 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,10 @@ +{ + "name": "blurb", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/python:1": { "version": "3.12" }, + "ghcr.io/devcontainers/features/ruby:1": { "version": "2.7.8" } + }, + "postCreateCommand": "make bootstrap" +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..5bac40e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Review routing for every path (item 9). Owner comes from backstage.yaml. +* @patterninc/sre diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0239263..1660462 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,4 +24,3 @@ jobs: env: BUNDLE_GITHUB__COM: ${{ secrets.RUBY_GEM_BUNDLE_TOKEN }} BUNDLE_ENTERPRISE__CONTRIBSYS__COM: ${{ secrets.SIDEKIQ_ENTERPRISE_TOKEN }} - \ No newline at end of file diff --git a/.github/workflows/pr-hygiene.yml b/.github/workflows/pr-hygiene.yml new file mode 100644 index 0000000..d129c3c --- /dev/null +++ b/.github/workflows/pr-hygiene.yml @@ -0,0 +1,20 @@ +# PR title = squash-commit message (item 14). Added by closing-ai-readiness-gaps. +# Make "PR Hygiene" a required status check (item 16). +name: 'PR Hygiene' + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + hygiene: + name: 'PR Hygiene' + runs-on: ubuntu-latest + steps: + - name: Conventional Commit PR title + uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml new file mode 100644 index 0000000..89e0c40 --- /dev/null +++ b/.github/workflows/static-checks.yml @@ -0,0 +1,56 @@ +# Credential-free PR checks (items 10, 11, 13, 23): the same hooks and tests as `make all`. +# Added by closing-ai-readiness-gaps. Make "Static Checks" a required status check (item 16). +name: 'Static Checks' + +on: + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + static: + name: 'Static Checks' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + + # Ruby version from .ruby-version; installs Gemfile.lock into vendor/bundle. + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Pre-commit hooks (format, lint, validate) + run: | + pip install pre-commit + pre-commit run --all-files --show-diff-on-failure + + - name: Unit tests + run: make test + + - name: License scan of installed gems (item 18) + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: rootfs + scan-ref: vendor/bundle + scanners: license + severity: HIGH,CRITICAL + exit-code: '1' + trivyignores: .trivyignore.yaml + + - name: Upload test results (item 33) + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: '**/*-junit.xml' + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index b1e8a84..0d89b29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ /.bundle/ /.yardoc -/Gemfile.lock /_yardoc/ /coverage/ /doc/ @@ -12,3 +11,7 @@ .rspec_status .env .byebug_history + +# test output (Makefile `test` target) +rspec-junit.xml +/vendor/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f145335 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,40 @@ +# Added by closing-ai-readiness-gaps (items 10, 11, 12, 13, 14, 17). `make lint` runs every hook; CI runs the same. +default_install_hook_types: [pre-commit, commit-msg] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-merge-conflict + - id: check-yaml + args: [--allow-multiple-documents] + - id: check-added-large-files + - id: detect-private-key + - id: end-of-file-fixer + exclude: \.md$ + - id: trailing-whitespace + exclude: \.md$ + + # RuboCop: lint, layout and complexity cops (items 10, 11, 21). Uses the Gemfile.lock version. + - repo: local + hooks: + - id: rubocop + name: rubocop + entry: bundle exec rubocop --force-exclusion + language: system + types: [ruby] + # Type check lib/ against sig/*.rbs (item 12). Same as `make typecheck`. + - id: steep + name: steep check + entry: bundle exec steep check + language: system + pass_filenames: false + files: ^(lib/.*\.rb|sig/.*\.rbs|Steepfile|Gemfile\.lock)$ + + # Conventional Commits (item 14), enforced on commit-msg + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v4.4.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [--strict, feat, fix, docs, chore, refactor, test, ci, build, perf, revert, style] diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..628a400 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,27 @@ +# Lint, layout (formatter) and complexity cops (audit items 10, 11, 21). +# Offenses that predate RuboCop are frozen in .rubocop_todo.yml; fix them in their +# own PRs and delete the matching todo entry. Don't regenerate the todo to hide new ones. +inherit_from: .rubocop_todo.yml +inherit_mode: + merge: + - Exclude + +AllCops: + TargetRubyVersion: 2.7 + NewCops: enable + SuggestExtensions: false + Exclude: + - 'vendor/**/*' + - 'coverage/**/*' + +# Complexity limits (item 21): RuboCop defaults, enforced on new code. +Metrics/CyclomaticComplexity: + Enabled: true +Metrics/PerceivedComplexity: + Enabled: true +Metrics/MethodLength: + Enabled: true +Metrics/BlockLength: + Exclude: + - 'spec/**/*' + - '*.gemspec' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..31a8db8 --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,613 @@ +# This configuration was generated by +# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit` +# on 2026-10-06 06:57:12 UTC using RuboCop version 1.50.2. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 2 +# Configuration parameters: EnforcedStyle, AllowedGems, Include. +# SupportedStyles: Gemfile, gems.rb, gemspec +# Include: **/*.gemspec, **/Gemfile, **/gems.rb +Gemspec/DevelopmentDependencies: + Exclude: + - 'blurb.gemspec' + +# Offense count: 6 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. +# Include: **/*.gemspec +Gemspec/OrderedDependencies: + Exclude: + - 'blurb.gemspec' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Severity, Include. +# Include: **/*.gemspec +Gemspec/RequireMFA: + Exclude: + - 'blurb.gemspec' + +# Offense count: 1 +# Configuration parameters: Severity, Include. +# Include: **/*.gemspec +Gemspec/RequiredRubyVersion: + Exclude: + - 'blurb.gemspec' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: with_first_argument, with_fixed_indentation +Layout/ArgumentAlignment: + Exclude: + - 'lib/blurb/account.rb' + +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentOneStep, IndentationWidth. +# SupportedStyles: case, end +Layout/CaseIndentation: + Exclude: + - 'lib/blurb/request.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Layout/EmptyLineAfterGuardClause: + Exclude: + - 'lib/blurb/request.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Layout/EmptyLineAfterMagicComment: + Exclude: + - 'blurb.gemspec' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: around, only_before +Layout/EmptyLinesAroundAccessModifier: + Exclude: + - 'lib/blurb/request.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: empty_lines, no_empty_lines +Layout/EmptyLinesAroundBlockBody: + Exclude: + - 'blurb.gemspec' + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines, beginning_only, ending_only +Layout/EmptyLinesAroundClassBody: + Exclude: + - 'lib/blurb/profile.rb' + - 'lib/blurb/request.rb' + - 'lib/blurb/request_collection.rb' + - 'lib/blurb/request_collection_with_campaign_type.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleAlignWith, Severity. +# SupportedStylesAlignWith: keyword, variable, start_of_line +Layout/EndAlignment: + Exclude: + - 'lib/blurb/request.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowForAlignment, AllowBeforeTrailingComments, ForceEqualSignAlignment. +Layout/ExtraSpacing: + Exclude: + - 'blurb.gemspec' + +# Offense count: 13 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: normal, indented_internal_methods +Layout/IndentationConsistency: + Exclude: + - 'lib/blurb/account.rb' + - 'lib/blurb/campaign_requests.rb' + - 'lib/blurb/request.rb' + - 'lib/blurb/request_collection.rb' + +# Offense count: 13 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Width, AllowedPatterns. +Layout/IndentationWidth: + Exclude: + - 'lib/blurb/account.rb' + - 'lib/blurb/campaign_requests.rb' + - 'lib/blurb/request.rb' + - 'lib/blurb/request_collection.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: aligned, indented +Layout/LineEndStringConcatenationIndentation: + Exclude: + - 'blurb.gemspec' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: symmetrical, new_line, same_line +Layout/MultilineMethodCallBraceLayout: + Exclude: + - 'lib/blurb/account.rb' + - 'spec/blurb/history_request_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Layout/SpaceAfterComma: + Exclude: + - 'lib/blurb/profile.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleInsidePipes. +# SupportedStylesInsidePipes: space, no_space +Layout/SpaceAroundBlockParameters: + Exclude: + - 'spec/blurb/account_spec.rb' + +# Offense count: 6 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: space, no_space +Layout/SpaceAroundEqualsInParameterDefault: + Exclude: + - 'lib/blurb/account.rb' + - 'lib/blurb/request_collection.rb' + - 'lib/blurb/snapshot_requests.rb' + - 'lib/blurb/suggested_keyword_requests.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowForAlignment, EnforcedStyleForExponentOperator. +# SupportedStylesForExponentOperator: space, no_space +Layout/SpaceAroundOperators: + Exclude: + - 'blurb.gemspec' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces. +# SupportedStyles: space, no_space +# SupportedStylesForEmptyBraces: space, no_space +Layout/SpaceBeforeBlockBraces: + Exclude: + - 'lib/blurb/account.rb' + - 'lib/blurb/campaign_requests.rb' + - 'lib/blurb/report_requests.rb' + +# Offense count: 22 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters. +# SupportedStyles: space, no_space +# SupportedStylesForEmptyBraces: space, no_space +Layout/SpaceInsideBlockBraces: + Exclude: + - 'spec/blurb/report_requests_spec.rb' + - 'spec/blurb/snapshot_requests_spec.rb' + +# Offense count: 6 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces. +# SupportedStyles: space, no_space, compact +# SupportedStylesForEmptyBraces: space, no_space +Layout/SpaceInsideHashLiteralBraces: + Exclude: + - 'lib/blurb/snapshot_requests.rb' + - 'lib/blurb/suggested_keyword_requests.rb' + +# Offense count: 9 +Lint/MissingSuper: + Exclude: + - 'lib/blurb/account.rb' + - 'lib/blurb/client.rb' + - 'lib/blurb/history_request.rb' + - 'lib/blurb/profile.rb' + - 'lib/blurb/report_requests.rb' + - 'lib/blurb/request.rb' + - 'lib/blurb/request_collection.rb' + - 'lib/blurb/request_collection_with_campaign_type.rb' + - 'lib/blurb/snapshot_requests.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +Lint/NonDeterministicRequireOrder: + Exclude: + - 'spec/spec_helper.rb' + +# Offense count: 2 +Lint/UselessAssignment: + Exclude: + - 'lib/blurb/snapshot_requests.rb' + - 'lib/blurb/suggested_keyword_requests.rb' + +# Offense count: 3 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max. +Metrics/AbcSize: + Exclude: + - 'lib/blurb/profile.rb' + - 'lib/blurb/report_requests.rb' + - 'lib/blurb/request.rb' + +# Offense count: 2 +# Configuration parameters: CountComments, Max, CountAsOne. +Metrics/ClassLength: + Exclude: + - 'lib/blurb/profile.rb' + - 'lib/blurb/report_requests.rb' + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns, Max. +Metrics/CyclomaticComplexity: + Exclude: + - 'lib/blurb/report_requests.rb' + +# Offense count: 8 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: + Exclude: + - 'spec/**/*' + - 'lib/blurb/account.rb' + - 'lib/blurb/history_request.rb' + - 'lib/blurb/profile.rb' + - 'lib/blurb/report_requests.rb' + - 'lib/blurb/request.rb' + +# Offense count: 1 +# Configuration parameters: Max, CountKeywordArgs, MaxOptionalParameters. +Metrics/ParameterLists: + Exclude: + - 'lib/blurb/history_request.rb' + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns, Max. +Metrics/PerceivedComplexity: + Exclude: + - 'lib/blurb/report_requests.rb' + +# Offense count: 1 +Naming/AccessorMethodName: + Exclude: + - 'lib/blurb/account.rb' + +# Offense count: 2 +# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. +# AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to +Naming/MethodParameterName: + Exclude: + - 'lib/blurb/request.rb' + +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: PreferredName. +Naming/RescuedExceptionsVariableName: + Exclude: + - 'lib/blurb/request.rb' + +# Offense count: 12 +# Configuration parameters: AllowedConstants. +Style/Documentation: + Exclude: + - 'spec/**/*' + - 'test/**/*' + - 'lib/blurb.rb' + - 'lib/blurb/account.rb' + - 'lib/blurb/campaign_requests.rb' + - 'lib/blurb/client.rb' + - 'lib/blurb/history_request.rb' + - 'lib/blurb/profile.rb' + - 'lib/blurb/report_requests.rb' + - 'lib/blurb/request.rb' + - 'lib/blurb/request_collection.rb' + - 'lib/blurb/request_collection_with_campaign_type.rb' + - 'lib/blurb/snapshot_requests.rb' + - 'lib/blurb/suggested_keyword_requests.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/Encoding: + Exclude: + - 'blurb.gemspec' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/ExpandPathArguments: + Exclude: + - 'blurb.gemspec' + +# Offense count: 7 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowedVars. +Style/FetchEnvVar: + Exclude: + - 'lib/blurb.rb' + - 'spec/blurb/account_spec.rb' + - 'spec/blurb/profile_spec.rb' + +# Offense count: 39 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: always, always_true, never +Style/FrozenStringLiteralComment: + Exclude: + - 'Gemfile' + - 'Rakefile' + - 'bin/console' + - 'blurb.gemspec' + - 'lib/blurb.rb' + - 'lib/blurb/account.rb' + - 'lib/blurb/base_class.rb' + - 'lib/blurb/campaign_requests.rb' + - 'lib/blurb/client.rb' + - 'lib/blurb/errors/base_exception.rb' + - 'lib/blurb/errors/failed_request.rb' + - 'lib/blurb/errors/invalid_report_request.rb' + - 'lib/blurb/errors/request_throttled.rb' + - 'lib/blurb/history_request.rb' + - 'lib/blurb/profile.rb' + - 'lib/blurb/report_requests.rb' + - 'lib/blurb/request.rb' + - 'lib/blurb/request_collection.rb' + - 'lib/blurb/request_collection_with_campaign_type.rb' + - 'lib/blurb/snapshot_requests.rb' + - 'lib/blurb/suggested_keyword_requests.rb' + - 'spec/blurb/account_spec.rb' + - 'spec/blurb/ad_group_requests_spec.rb' + - 'spec/blurb/campaign_negative_keywords_spec.rb' + - 'spec/blurb/history_request_spec.rb' + - 'spec/blurb/portfolios_spec.rb' + - 'spec/blurb/product_ad_requests_spec.rb' + - 'spec/blurb/profile_spec.rb' + - 'spec/blurb/report_requests_spec.rb' + - 'spec/blurb/sb_keywords_requests_spec.rb' + - 'spec/blurb/sb_negative_keywords_spec.rb' + - 'spec/blurb/sd_ad_group_requests_spec.rb' + - 'spec/blurb/sd_product_ad_requests_spec.rb' + - 'spec/blurb/snapshot_requests_spec.rb' + - 'spec/blurb/sp_keywords_requests_spec.rb' + - 'spec/blurb/sp_negative_keywords_spec.rb' + - 'spec/blurb/suggested_keywords_spec.rb' + - 'spec/spec_helper.rb' + - 'spec/support/request_collection_examples.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. +Style/GuardClause: + Exclude: + - 'lib/blurb/request.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowSplatArgument. +Style/HashConversion: + Exclude: + - 'lib/blurb/request.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, EnforcedShorthandSyntax, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols. +# SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys +# SupportedShorthandSyntax: always, never, either, consistent +Style/HashSyntax: + Exclude: + - 'Rakefile' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/IfUnlessModifier: + Exclude: + - 'lib/blurb/history_request.rb' + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +Style/KeywordParametersOrder: + Exclude: + - 'lib/blurb/history_request.rb' + - 'lib/blurb/request.rb' + - 'lib/blurb/request_collection.rb' + - 'lib/blurb/suggested_keyword_requests.rb' + +# Offense count: 26 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowedMethods, AllowedPatterns. +Style/MethodCallWithoutArgsParentheses: + Exclude: + - 'lib/blurb/account.rb' + - 'lib/blurb/profile.rb' + - 'lib/blurb/request.rb' + - 'spec/blurb/account_spec.rb' + - 'spec/blurb/ad_group_requests_spec.rb' + - 'spec/blurb/campaign_negative_keywords_spec.rb' + - 'spec/blurb/history_request_spec.rb' + - 'spec/blurb/portfolios_spec.rb' + - 'spec/blurb/product_ad_requests_spec.rb' + - 'spec/blurb/profile_spec.rb' + - 'spec/blurb/report_requests_spec.rb' + - 'spec/blurb/sb_keywords_requests_spec.rb' + - 'spec/blurb/sb_negative_keywords_spec.rb' + - 'spec/blurb/sd_ad_group_requests_spec.rb' + - 'spec/blurb/sd_product_ad_requests_spec.rb' + - 'spec/blurb/snapshot_requests_spec.rb' + - 'spec/blurb/sp_keywords_requests_spec.rb' + - 'spec/blurb/sp_negative_keywords_spec.rb' + - 'spec/blurb/suggested_keywords_spec.rb' + - 'spec/support/request_collection_examples.rb' + +# Offense count: 14 +# This cop supports safe autocorrection (--autocorrect). +Style/MultilineIfModifier: + Exclude: + - 'lib/blurb/report_requests.rb' + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowMethodComparison. +Style/MultipleComparison: + Exclude: + - 'lib/blurb/profile.rb' + +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: literals, strict +Style/MutableConstant: + Exclude: + - 'lib/blurb/account.rb' + - 'lib/blurb/history_request.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: PreferredDelimiters. +Style/PercentLiteralDelimiters: + Exclude: + - 'blurb.gemspec' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: short, verbose +Style/PreferredHashMethods: + Exclude: + - 'spec/blurb/history_request_spec.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowedCompactTypes. +# SupportedStyles: compact, exploded +Style/RaiseArgs: + EnforcedStyle: compact + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Style/RedundantFreeze: + Exclude: + - 'lib/blurb/history_request.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Style/RedundantPercentQ: + Exclude: + - 'blurb.gemspec' + +# Offense count: 7 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowMultipleReturnValues. +Style/RedundantReturn: + Exclude: + - 'lib/blurb/account.rb' + - 'lib/blurb/profile.rb' + - 'lib/blurb/request.rb' + +# Offense count: 541 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. +# SupportedStyles: single_quotes, double_quotes +Style/StringLiterals: + Exclude: + - 'Rakefile' + - 'bin/console' + - 'blurb.gemspec' + - 'lib/blurb.rb' + - 'lib/blurb/account.rb' + - 'lib/blurb/campaign_requests.rb' + - 'lib/blurb/client.rb' + - 'lib/blurb/errors/failed_request.rb' + - 'lib/blurb/errors/invalid_report_request.rb' + - 'lib/blurb/errors/request_throttled.rb' + - 'lib/blurb/history_request.rb' + - 'lib/blurb/profile.rb' + - 'lib/blurb/report_requests.rb' + - 'lib/blurb/request.rb' + - 'lib/blurb/request_collection.rb' + - 'lib/blurb/snapshot_requests.rb' + - 'lib/blurb/suggested_keyword_requests.rb' + - 'spec/blurb/account_spec.rb' + - 'spec/blurb/ad_group_requests_spec.rb' + - 'spec/blurb/campaign_negative_keywords_spec.rb' + - 'spec/blurb/history_request_spec.rb' + - 'spec/blurb/product_ad_requests_spec.rb' + - 'spec/blurb/profile_spec.rb' + - 'spec/blurb/report_requests_spec.rb' + - 'spec/blurb/sb_keywords_requests_spec.rb' + - 'spec/blurb/sb_negative_keywords_spec.rb' + - 'spec/blurb/sd_ad_group_requests_spec.rb' + - 'spec/blurb/sd_product_ad_requests_spec.rb' + - 'spec/blurb/snapshot_requests_spec.rb' + - 'spec/blurb/sp_keywords_requests_spec.rb' + - 'spec/blurb/sp_negative_keywords_spec.rb' + - 'spec/blurb/suggested_keywords_spec.rb' + - 'spec/spec_helper.rb' + - 'spec/support/request_collection_examples.rb' + +# Offense count: 7 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: MinSize. +# SupportedStyles: percent, brackets +Style/SymbolArray: + EnforcedStyle: brackets + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleForMultiline. +# SupportedStylesForMultiline: comma, consistent_comma, no_comma +Style/TrailingCommaInArguments: + Exclude: + - 'lib/blurb/report_requests.rb' + - 'lib/blurb/request_collection.rb' + - 'lib/blurb/snapshot_requests.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleForMultiline. +# SupportedStylesForMultiline: comma, consistent_comma, no_comma +Style/TrailingCommaInArrayLiteral: + Exclude: + - 'lib/blurb/report_requests.rb' + +# Offense count: 12 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleForMultiline. +# SupportedStylesForMultiline: comma, consistent_comma, no_comma +Style/TrailingCommaInHashLiteral: + Exclude: + - 'spec/blurb/campaign_negative_keywords_spec.rb' + - 'spec/blurb/portfolios_spec.rb' + - 'spec/blurb/product_ad_requests_spec.rb' + - 'spec/blurb/sb_keywords_requests_spec.rb' + - 'spec/blurb/sb_negative_keywords_spec.rb' + - 'spec/blurb/sd_product_ad_requests_spec.rb' + - 'spec/blurb/sp_keywords_requests_spec.rb' + - 'spec/blurb/sp_negative_keywords_spec.rb' + +# Offense count: 27 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: MinSize, WordRegex. +# SupportedStyles: percent, brackets +Style/WordArray: + EnforcedStyle: brackets + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. +# URISchemes: http, https +Layout/LineLength: + Exclude: + - 'blurb.gemspec' + - 'lib/blurb/history_request.rb' + - 'lib/blurb/request.rb' + - 'spec/blurb/snapshot_requests_spec.rb' + - 'spec/blurb/sp_keywords_requests_spec.rb' diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..6a81b4c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.7.8 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..59511e1 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 2.7.8 diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 0000000..15d7235 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,8 @@ +# License scan (audit item 18): `trivy rootfs --scanners license vendor/bundle` in Static Checks. +licenses: + # diff-lcs (rspec dev dependency) is triple-licensed MIT / Artistic-2.0 / GPL-2.0-or-later; + # we use it under MIT, and it is never shipped with the gem. + - id: GPL-2.0-or-later + paths: + - "ruby/*/specifications/diff-lcs-*.gemspec" + statement: diff-lcs is also MIT-licensed; dev-only (rspec) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e49a26d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# blurb + +Ruby gem (`blurb`, version in `blurb.gemspec`, currently 0.5.9) that wraps the Amazon Advertising API v2 (Sponsored Products, Sponsored Brands, Sponsored Display). It deploys nothing and has no AWS footprint; the artifact is the published gem, consumed by other Pattern Ruby applications that pass it Amazon Advertising credentials. Owner: SRE (`sre-comm` in `backstage.yaml`). + +## Layout + +- `lib/blurb.rb` — entry point. `Blurb.new(client_id:, client_secret:, refresh_token:, region:, profile_id:)`; every argument defaults to a `BLURB_*` env var. +- `lib/blurb/account.rb` — OAuth refresh-token exchange (`retrieve_token`, refreshed after 1 hour) and the region → API host map (`API_URLS`: `TEST`, `NA`, `EU`, `FE`). +- `lib/blurb/profile.rb` — one advertising profile; builds the `RequestCollection*` objects for campaigns, ad groups, keywords, reports, snapshots, etc. +- `lib/blurb/request.rb` — the single HTTP path (`rest-client`). Camel-cases request keys, snake-cases response keys, maps HTTP errors to `lib/blurb/errors/*`. See `docs/adr/0002-*`. +- `lib/blurb/request_collection*.rb` — generic list/retrieve/create/update/delete over a resource URL; `RequestCollectionWithCampaignType` adds the `sp`/`hsa`/`sd` path segment. See `docs/adr/0003-*`. +- `lib/blurb/*_requests.rb` — endpoint-specific collections (reports, snapshots, history, suggested keywords). +- `lib/blurb/errors/` — `FailedRequest`, `RequestThrottled`, `InvalidReportRequest` (all inherit `BaseException`). +- `spec/unit/` — hermetic unit specs; HTTP stubbed with WebMock, payloads in `spec/fixtures/`. Run by `make test` and CI. +- `spec/blurb/` — live-API integration specs (tagged `:live`); they call the real Amazon Advertising API and are excluded unless `BLURB_LIVE=1`. +- `sig/blurb.rbs` — RBS signatures for the public API, checked by Steep (`Steepfile`); `sig/vendor.rbs` stubs rest-client, oauth2 and ActiveSupport. A new or changed public method needs its signature updated. +- `bin/setup`, `bin/console` — `bundle install`; an IRB session with the gem loaded. +- `backstage.yaml` — Backstage catalog entry (owner, cost center). +- `.travis.yml` — stale, Travis no longer runs; `.github/workflows/ci.yml` is the legacy push workflow (installs gems only). + +## Commands + +| Command | What it does | +|---|---| +| `make bootstrap` | `bundle install` plus git hooks (Ruby version in `.ruby-version` / `.tool-versions`) | +| `make lint` | Every pre-commit hook on every file, including RuboCop — what CI runs | +| `make typecheck` | `steep check` of `lib/` against `sig/blurb.rbs` (also a pre-commit hook, so `make lint` and CI run it) | +| `make test` | Hermetic unit specs and golden payload files (`spec/fixtures/golden/`, regenerate with `UPDATE_GOLDEN=1 make test`) with coverage gate; writes `rspec-junit.xml` | +| `make test-live` | Live-API specs; needs a `.env` with `BLURB_CLIENT_ID`, `BLURB_CLIENT_SECRET`, `BLURB_REFRESH_TOKEN`, `BLURB_REGION`, `BLURB_PROFILE_ID` | +| `make all` | Everything CI runs | + +## Do not touch + +- Never commit `.env` or any Amazon Advertising credential, refresh token or profile ID. `.env` is gitignored. +- Don't run `make test-live` against a production advertising profile: the live specs create campaigns, keywords and reports. Use the `TEST` region or a sandbox profile. +- The public API (`Blurb`, `Blurb::Profile` collections, error classes) is consumed by other apps. Renaming a method, changing key casing or an exception class is a breaking change: bump the minor version and note it in `CHANGELOG.md`. +- `bundle exec rake release` publishes to RubyGems; only run it when asked. +- RuboCop offenses that predate the linter are listed in `.rubocop_todo.yml`; fix them in their own PR, don't regenerate the file to hide new ones. + +## Agent tooling + +- Decisions that must not be "simplified away" are in `docs/adr/`. Read them before refactoring. +- Repo skills live in `.claude/skills/`; dispatch metadata in `.agents/pattern-agents.json`. +- Commits and PR titles follow Conventional Commits (hook + `PR Hygiene` check). +- Owner: `#sre-comm` on Slack. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..26e38e8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,83 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Conventional Commit +types map to sections: `feat` → Added, `fix` → Fixed, `refactor`/`perf` → Changed, +removals → Removed, `!`/`BREAKING CHANGE` → called out under Changed. + +Entries before 0.5.9 were reconstructed from git tags and commit messages; older +releases (0.1.0–0.4.x) are summarised only by the upgrade note below. + +## [Unreleased] + +### Added + +- Development tooling (no change to the gem's runtime behaviour beyond the require fix below): `AGENTS.md`, ADRs, RuboCop, pre-commit, hermetic unit specs with WebMock, SimpleCov gate, committed `Gemfile.lock`, `Makefile`, PR CI (`Static Checks`, `PR Hygiene`). +- RBS signatures for the public API in `sig/` (shipped with the gem), type-checked with Steep (`make typecheck`, pre-commit and CI). `lib/` gains only `# @type` comments. +- Golden-file specs for report and history request payloads (`spec/fixtures/golden/`). + +### Fixed + +- `lib/blurb/request.rb` now requires `active_support` and `active_support/core_ext/object/blank` itself, so the gem loads outside Rails with ActiveSupport >= 7.1 and `present?` is defined. Require-only change; no effect where Rails already loaded them. + +### Changed + +- The default `rspec` run now executes only the hermetic specs in `spec/unit/`. The live Amazon Advertising API specs in `spec/blurb/` are tagged `:live` and run with `BLURB_LIVE=1` (`make test-live`). + +## [0.5.9] - 2021-03-04 + +### Fixed + +- `Blurb::Request#make_request` re-raises the original `RestClient` error when an error response has no body, instead of failing with `NoMethodError`. + +## [0.5.8] - 2021-02-16 + +### Fixed + +- Corrected the Faraday SSL version parameter passed by the OAuth client. + +## [0.5.7] - 2021-02-11 + +### Fixed + +- OAuth token requests specify the SSL version explicitly (`ssl: { version: :TLSv1 }` in `Blurb::Account`). + +## [0.5.6] - 2021-01-05 + +### Added + +- Request/response logging to stdout when `BLURB_LOGGING` is set. + +## [0.5.5] - 2020-10-23 + +### Fixed + +- Removed a stray `byebug` call. + +## [0.5.4] - 2020-09-09 + +### Fixed + +- Error classes load `blurb/errors/base_exception` (commit "fix: inherit base exeption"). Note: `FailedRequest`, `RequestThrottled` and `InvalidReportRequest` still subclass `StandardError`, not `Blurb::BaseException`. + +## [0.5.2] - 2020-08-07 + +### Added + +- Sponsored Brands search-term report. + +## Upgrading to 0.2.0 + +0.2.0 moved the gem to v2.0 of the Amazon Advertising API. Request paths, payload +fields and responses follow the v2 API; code written against 0.1.x needs to be +checked against the v2 reference. + +[Unreleased]: https://github.com/patterninc/blurb/compare/v0.5.9...HEAD +[0.5.9]: https://github.com/patterninc/blurb/compare/v0.5.8...v0.5.9 +[0.5.8]: https://github.com/patterninc/blurb/compare/v0.5.7...v0.5.8 +[0.5.7]: https://github.com/patterninc/blurb/compare/v0.5.6...v0.5.7 +[0.5.6]: https://github.com/patterninc/blurb/compare/v0.5.5...v0.5.6 +[0.5.5]: https://github.com/patterninc/blurb/compare/v0.5.4...v0.5.5 +[0.5.4]: https://github.com/patterninc/blurb/compare/v0.5.3...v0.5.4 +[0.5.2]: https://github.com/patterninc/blurb/compare/v0.5.1...v0.5.2 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..d493e9d --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,188 @@ +PATH + remote: . + specs: + blurb (0.5.9) + activesupport + oauth2 (~> 1.4.0) + rest-client (~> 2.0) + +GEM + remote: https://rubygems.org/ + specs: + activesupport (7.1.6) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) + tzinfo (~> 2.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.3) + byebug (11.1.3) + concurrent-ruby (1.3.8) + connection_pool (2.5.5) + crack (1.0.1) + bigdecimal + rexml + csv (3.3.6) + diff-lcs (1.6.2) + docile (1.4.1) + domain_name (0.6.20260921) + dotenv (2.8.1) + drb (2.2.3) + faker (2.1.2) + i18n (>= 0.8) + faraday (2.8.1) + base64 + faraday-net_http (>= 2.0, < 3.1) + ruby2_keywords (>= 0.0.4) + faraday-net_http (3.0.2) + ffi (1.17.4) + fileutils (1.8.0) + hashdiff (1.2.1) + http-accept (1.7.0) + http-cookie (1.1.6) + domain_name (~> 0.5) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.21.2) + jwt (2.10.3) + base64 + language_server-protocol (3.17.0.6) + listen (3.10.1) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.7.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.0929) + minitest (5.26.1) + multi_json (1.15.0) + multi_xml (0.6.0) + mutex_m (0.3.0) + netrc (0.11.0) + oauth2 (1.4.11) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_json (~> 1.3) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + parallel (1.28.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + prism (1.9.0) + public_suffix (5.1.1) + racc (1.8.1) + rack (3.2.7) + rainbow (3.1.1) + rake (10.5.0) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rbs (3.1.3) + regexp_parser (2.13.1) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rspec_junit_formatter (0.3.0) + rspec-core (>= 2, < 4, != 2.12.0) + rubocop (1.50.2) + json (~> 2.3) + parallel (~> 1.10) + parser (>= 3.2.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + securerandom (0.3.2) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + steep (1.4.0) + activesupport (>= 5.1) + concurrent-ruby (>= 1.2.2) + csv (>= 3.0.9) + fileutils (>= 1.1.0) + json (>= 2.1.0) + language_server-protocol (>= 3.15, < 4.0) + listen (~> 3.0) + logger (>= 1.3.0) + parser (>= 3.1) + rainbow (>= 2.2.2, < 4.0) + rbs (>= 2.8.0) + securerandom (>= 0.1) + strscan (>= 1.0.0) + terminal-table (>= 2, < 4) + strscan (3.1.8) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.6.0) + webmock (3.26.4) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + aarch64-linux + arm64-darwin + arm64-darwin-23 + x86_64-darwin + x86_64-linux + +DEPENDENCIES + blurb! + bundler (~> 2.0) + byebug + dotenv + faker (~> 2.1.0) + rake (~> 10.0) + rspec (~> 3.0) + rspec_junit_formatter (~> 0.3.0) + rubocop (~> 1.50.2) + simplecov (~> 0.22) + steep (~> 1.4.0) + webmock (~> 3.18) + +BUNDLED WITH + 2.3.6 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ebc1a40 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +# One-command setup and checks (item 37). Added by closing-ai-readiness-gaps. +# `make all` is what CI runs. None of these targets touch AWS. + +.PHONY: help bootstrap fmt lint typecheck validate test test-live all + +help: ## List targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-12s\033[0m %s\n", $$1, $$2}' + +bootstrap: ## Install gems and git hooks (Ruby version: .ruby-version / .tool-versions) + bundle install + @command -v pre-commit >/dev/null || pipx install pre-commit 2>/dev/null || python3 -m pip install --user pre-commit + pre-commit install --install-hooks + +fmt: ## Apply formatters to every file (RuboCop layout cops) + bundle exec rubocop --autocorrect --only Layout + +lint: ## Run every pre-commit hook on every file (format check, lint, validate) + pre-commit run --all-files --show-diff-on-failure + +typecheck: ## Type-check lib/ against sig/*.rbs with Steep (also a pre-commit hook) + bundle exec steep check + +test: ## Hermetic unit specs (WebMock, no credentials) with coverage gate; writes rspec-junit.xml + bundle exec rspec --format progress --format RspecJunitFormatter --out rspec-junit.xml + +test-live: ## Live Amazon Advertising API specs (needs .env credentials; creates real resources) + BLURB_LIVE=1 bundle exec rspec + +all: lint test ## Everything CI runs diff --git a/Steepfile b/Steepfile new file mode 100644 index 0000000..3fb328f --- /dev/null +++ b/Steepfile @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Type checking (audit item 12): `make typecheck` runs `steep check` against sig/. +D = Steep::Diagnostic + +target :lib do + signature 'sig' + check 'lib' + library 'json', 'date', 'uri' + configure_code_diagnostics(D::Ruby.default) do |hash| + # RequestCollection#execute_bulk_request passes its keyword hash positionally + # (lib/blurb/request_collection.rb). Ruby 2.7 accepts it; Ruby 3 does not. It is a + # known follow-up (runtime change), so report it without failing the check. + hash[D::Ruby::UnexpectedPositionalArgument] = :information + hash[D::Ruby::InsufficientKeywordArguments] = :information + end +end diff --git a/blurb.gemspec b/blurb.gemspec index b02b869..c3d09a1 100644 --- a/blurb.gemspec +++ b/blurb.gemspec @@ -36,6 +36,10 @@ Gem::Specification.new do |spec| spec.add_development_dependency "dotenv" spec.add_development_dependency "byebug" spec.add_development_dependency "faker", "~> 2.1.0" + spec.add_development_dependency "webmock", "~> 3.18" + spec.add_development_dependency "simplecov", "~> 0.22" + spec.add_development_dependency "rubocop", "~> 1.50.2" + spec.add_development_dependency "steep", "~> 1.4.0" spec.add_runtime_dependency "rest-client", "~> 2.0" spec.add_runtime_dependency "oauth2", "~> 1.4.0" diff --git a/docs/adr/0000-template.md b/docs/adr/0000-template.md new file mode 100644 index 0000000..1c6a23a --- /dev/null +++ b/docs/adr/0000-template.md @@ -0,0 +1,16 @@ +# NNNN. Title in imperative mood + +- **Status:** Accepted | Superseded by NNNN +- **Date:** YYYY-MM-DD + +## Context + +What forces are at play? What would a new contributor (or agent) otherwise get wrong? + +## Decision + +The decision, stated so it can be checked against code (name the files, resources, variables). + +## Consequences + +What becomes easier, what becomes harder, and what must never be "cleaned up" because of this. diff --git a/docs/adr/0001-record-architecture-decisions.md b/docs/adr/0001-record-architecture-decisions.md new file mode 100644 index 0000000..5064455 --- /dev/null +++ b/docs/adr/0001-record-architecture-decisions.md @@ -0,0 +1,18 @@ +# 0001. Record architecture decisions + +- **Status:** Accepted +- **Date:** 2026-10-06 + +## Context + +Load-bearing choices in this repo lived only in PR history and people's heads, so contributors and +agents could "simplify" them away. + +## Decision + +Record each such choice as a numbered ADR in `docs/adr/`, from `0000-template.md`. An accepted ADR +is not edited; a reversal is a new ADR that supersedes it. + +## Consequences + +Reviewers and agents cite an ADR number instead of relitigating a decision. AGENTS.md links here. diff --git a/docs/adr/0002-single-request-path-with-key-case-conversion.md b/docs/adr/0002-single-request-path-with-key-case-conversion.md new file mode 100644 index 0000000..053f0d0 --- /dev/null +++ b/docs/adr/0002-single-request-path-with-key-case-conversion.md @@ -0,0 +1,25 @@ +# 0002. Route every API call through `Blurb::Request`, which owns key casing and error mapping + +- **Status:** Proposed — owner to confirm +- **Date:** 2026-10-06 + +## Context + +The Amazon Advertising API speaks camelCase JSON (`campaignId`, `startDate`) and signals throttling, report-not-ready and redirect-to-download through HTTP status codes. Ruby callers want snake_case symbols and typed exceptions. Without one place that does this, each collection would convert keys and rescue `RestClient` errors its own way, and consumers would see inconsistent hashes and exceptions. + +## Decision + +`lib/blurb/request.rb` is the only code that talks HTTP: + +- Request payloads and query params are camel-cased (`camelcase_keys`), except keys that are already all upper case, which pass through. `Date`/`Time`/`ActiveSupport::TimeWithZone` values are formatted as `YYYYMMDD`. +- Responses are JSON-parsed and every key becomes a snake_case **symbol** (`underscore_keys`). +- `429` raises `Blurb::RequestThrottled`; `406` on a report URL raises `Blurb::InvalidReportRequest`; any other error response raises `Blurb::FailedRequest` with the parsed body. A `307` is followed with a plain `GET` and returned **unparsed** — that is how report and snapshot downloads work. +- `GET` requests set `max_redirects: 0` so the `307` reaches the rescue above. + +`RequestCollection`, `Account` and the `*_requests.rb` classes only build URLs, headers and payloads and call `Request#make_request`. + +## Consequences + +- Consumers rely on symbol keys and on these exception classes; changing either is a breaking change (minor version bump plus a `CHANGELOG.md` note). +- Don't "simplify" by removing `max_redirects: 0` or the `TemporaryRedirect` rescue: report downloads would then be JSON-parsed and fail. +- Unit specs in `spec/unit/request_spec.rb` pin this behaviour with WebMock stubs. diff --git a/docs/adr/0003-campaign-type-codes-in-resource-urls.md b/docs/adr/0003-campaign-type-codes-in-resource-urls.md new file mode 100644 index 0000000..c7f00b8 --- /dev/null +++ b/docs/adr/0003-campaign-type-codes-in-resource-urls.md @@ -0,0 +1,20 @@ +# 0003. Map campaign types to Amazon's URL codes in one table; Sponsored Display has no `/v2` prefix + +- **Status:** Proposed — owner to confirm +- **Date:** 2026-10-06 + +## Context + +`Blurb::Profile` (`lib/blurb/profile.rb`) picks campaign types by the keys `:sp`, `:sb` and `:sd`, but Amazon's URLs use `sp`, `hsa` (the legacy name for Sponsored Brands, "headline search ads") and `sd`. Sponsored Products and Brands live under `/v2//`, while Sponsored Display lives at `/sd/` with no version segment. Report list calls for Sponsored Display drop the `sd/` segment entirely. + +## Decision + +- `Blurb::BaseClass::CAMPAIGN_TYPE_CODES` (`lib/blurb/base_class.rb`) is the single map from caller names to URL codes; `sb` maps to `hsa`. +- `RequestCollectionWithCampaignType` (`lib/blurb/request_collection_with_campaign_type.rb`) builds `"#{base_url}/v2/#{type}/#{resource}"`, except for `sd`, which gets `"#{base_url}/sd/#{resource}"`. +- `RequestCollection#execute_request` removes `/sd/` from un-parameterised `GET` calls on `sd/reports` URLs. + +## Consequences + +- Don't "normalise" `hsa` to `sb` or add `/v2` for `sd`: both would send requests to URLs Amazon doesn't serve. +- New campaign types are added to `CAMPAIGN_TYPE_CODES` and, if their URL shape differs, to `RequestCollectionWithCampaignType`. +- `spec/unit/request_collection_spec.rb` pins the URL shapes. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..e7ee892 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,7 @@ +# Architecture Decision Records + +| # | Decision | Status | +|---|----------|--------| +| [0001](0001-record-architecture-decisions.md) | Record architecture decisions | Accepted | +| [0002](0002-single-request-path-with-key-case-conversion.md) | Route every API call through `Blurb::Request` (key casing, error mapping) | Proposed | +| [0003](0003-campaign-type-codes-in-resource-urls.md) | Campaign-type URL codes; Sponsored Display has no `/v2` | Proposed | diff --git a/docs/engineering-best-practices-audit.md b/docs/engineering-best-practices-audit.md index be2dcdf..18de577 100644 --- a/docs/engineering-best-practices-audit.md +++ b/docs/engineering-best-practices-audit.md @@ -2,145 +2,144 @@ | | | |---|---| -| **Audit date** | 2026-09-10 | +| **Audit date** | 2026-10-06 | | **Auditor** | Claude — gauge-repo skill | | **Rubric version** | `item-credit-v1` — 2026-09-04 (`references/best-practices.md`) | +| **Audited ref** | `chore/ai-readiness-common-gaps` (PR #29) | + +## Changes since last audit (2026-09-10, 30.3%) + +- **Moved to Met:** 1 Skills, 2 AGENTS.md, 3 ADRs, 7 Changelog, 9 CODEOWNERS, 10 Linters, 11 Formatters, 12 Type checking, 13 Pre-commit, 14 Commit conventions, 18 License scanning, 21 Complexity, 23 Unit tests, 24 Integration tests, 25 Golden files, 29 Coverage, 32 Flaky quarantine, 33 Structured CI output, 34 Deterministic fixtures, 36 Devcontainer, 48 Lockfiles. +- **Runbooks (4) moved from N/A to Met:** the release runbook the last audit called standard `bundler/gem_tasks` now exists (`docs/runbooks/release-gem.md`), so the item is scored rather than declined. +- **Gap → Partial:** 16 Required CI checks (PR checks exist and pass, not yet required), 49 Agent-dispatch manifest (present; `clickup_list_id` unset). +- **Still Gap:** 30 Mutation testing, 43 Structured logging. ## Repo profile -`blurb` is a Ruby gem (library/SDK, published to RubyGems, currently `0.5.9` per `blurb.gemspec`) wrapping the Amazon Advertising API v2. It has no deployed service, no database, no browser or terminal UI, and no AWS footprint of its own — the artifact is a distributed package consumed by other applications. The codebase is small (~17 files under `lib/`) and flat, with an RSpec suite under `spec/` that exercises the **live** Amazon Advertising API using credentials supplied via `dotenv`/environment variables (`spec/spec_helper.rb` sleeps 1s between examples to avoid API throttling). CI is a single GitHub Actions workflow (`.github/workflows/ci.yml`) that checks out the repo and installs gems but contains **no test or lint step**; a stale `.travis.yml` also remains from the pre-Actions era. Local git history shows a single squashed commit by `patterninc-gha-runner`; the gemspec lists three historical authors and the repo is effectively dormant/maintenance-mode. A `backstage.yaml` catalog entry registers the repo in Backstage (CostCenter INFRA, owner `sre-comm`). The GitHub owner was verified as `patterninc` (`gh repo view` → `patterninc/blurb`), so Pattern's inherited Wiz and Toolsmith controls apply. An active org-level ruleset (`require-pr-review`) protects the default branch. +`blurb` is a Ruby gem (library/SDK, published to RubyGems, `0.5.9` per `blurb.gemspec`) wrapping the Amazon Advertising API v2. It has no deployed service, no database, no browser or terminal UI, and no AWS footprint of its own: the artifact is a distributed package consumed by other applications. The codebase is small (~17 files under `lib/`, one namespace). Ruby is pinned to 2.7.8 (`.ruby-version`, `.tool-versions`, `.devcontainer/`). Hermetic unit specs live in `spec/unit/` (WebMock, JSON fixtures, golden payload files); live-API specs in `spec/blurb/` are tagged `:live` and need credentials. PR CI is `Static Checks` (pre-commit incl. RuboCop and Steep, rspec with SimpleCov, Trivy license scan, JUnit upload) and `PR Hygiene` (Conventional Commit titles); the legacy push workflow `.github/workflows/ci.yml` still only installs gems. History shows ~10 contributors over time; the repo is in maintenance mode and owned by SRE (`backstage.yaml` Owner `sre-comm`). The GitHub owner was verified as `patterninc` (`gh repo view` → `patterninc/blurb`, public), so Pattern's inherited Wiz and Toolsmith controls apply. The org ruleset `require-pr-review` protects the default branch `master`. ## Scorecard | Metric | Value | |--------|-------| | **Critical gates** | **RED** | -| **Adjusted compliance** | **30.3%** | +| **Adjusted compliance** | **91.2%** | -Critical gates are RED: AGENTS.md (item 2) and required CI checks (item 16) are Gaps, and unit tests (23), integration tests (24), and reproducible builds (48) are Partial. Adjusted compliance is calculated independently: +Critical gates are RED only because required CI checks (item 16) is Partial: `Static Checks` and `PR Hygiene` run and pass on PRs, but no ruleset marks them required on `master`. Every other applicable gate (2, 6, 15, 19, 20, 23, 24, 40, 48) is Met. Adjusted compliance is calculated independently: -`(8 Met + 0.5 × 4 Partial) / (49 total − 16 justified N/A) = 10 / 33 = 30.3%` +`(30 Met + 0.5 × 2 Partial) / (49 total − 15 justified N/A) = 31 / 34 = 91.2%` ### Status totals | Status | Items | |--------|------:| -| Met | 8 | -| Partial | 4 | -| Gap | 21 | -| N/A | 16 | +| Met | 30 | +| Partial | 2 | +| Gap | 2 | +| N/A | 15 | | **Total** | **49** | ### Per-category breakdown | Category | Met | Partial | Gap | N/A | |----------|----:|--------:|----:|----:| -| Documentation & Context | 1 | 0 | 5 | 3 | -| Guardrails & Enforcement | 3 | 0 | 8 | 2 | -| Testing & Feedback Loops | 0 | 3 | 5 | 5 | -| Environment & Tooling | 4 | 1 | 2 | 6 | -| Agent dispatch | 0 | 0 | 1 | 0 | -| **Total** | **8** | **4** | **21** | **16** | +| Documentation & Context | 7 | 0 | 0 | 2 | +| Guardrails & Enforcement | 10 | 1 | 0 | 2 | +| Testing & Feedback Loops | 7 | 0 | 1 | 5 | +| Environment & Tooling | 6 | 0 | 1 | 6 | +| Agent dispatch | 0 | 1 | 0 | 0 | +| **Total** | **30** | **2** | **2** | **15** | ## Documentation & Context | # | Practice | Status | Evidence | Recommendation / rationale | |---|----------|--------|----------|----------------------------| -| 1 | Skills / reusable prompt workflows | **Gap** | No `.claude/skills/`, `.claude/commands/`, or equivalent | Add a skill for the one recurring workflow this repo has (cutting and publishing a gem release). Low priority given dormancy. | -| 2 | AGENTS.md | **Gap** | No `AGENTS.md`, `CLAUDE.md`, or `.cursorrules` | Add `AGENTS.md` covering: Ruby version, `bin/setup`, how to run specs (and that they require live Amazon Advertising credentials via `.env`), release process, and code layout under `lib/blurb/`. | -| 3 | Architecture decision records | **Gap** | No `docs/adr/` or equivalent | Capture the few structural decisions (request/retry semantics in `lib/blurb/request.rb`, campaign-type code mapping in `lib/blurb/base_class.rb`) as short ADRs. Low priority. | -| 4 | Runbooks | **Not applicable** | — | Library with no operational surface; the only recurring task is gem release, which is standard `bundler/gem_tasks` (`Rakefile`). | -| 5 | API contract docs (OpenAPI / protobuf) | **Not applicable** | — | The repo consumes Amazon's third-party API; it owns no wire contract. Its public surface is Ruby classes documented in `README.md`. | -| 6 | README with setup & run instructions | **Met** | `README.md` — installation, credential acquisition walkthrough, refresh-token flow, and per-resource usage examples | — | -| 7 | Changelog with migration notes | **Gap** | No `CHANGELOG.md`; version history only visible in `blurb.gemspec` bumps | Published gem with breaking API-version changes (README notes the v2.0 migration) — add a changelog with upgrade notes per release. | +| 1 | Skills / reusable prompt workflows | **Met** | `.claude/skills/change-and-verify/SKILL.md` (change, verify, release flow), `.claude/skills/change-github-workflows/SKILL.md` | — | +| 2 | AGENTS.md | **Met** | `AGENTS.md` (layout, commands, do-not-touch, agent tooling); `CLAUDE.md` is a symlink to it | — | +| 3 | Architecture decision records | **Met** | `docs/adr/0001`–`0003` (request path and key casing, campaign-type URL codes) with index `docs/adr/README.md` | ADRs 0002/0003 are `Proposed — owner to confirm`; the owner should accept or correct them. | +| 4 | Runbooks | **Met** | `docs/runbooks/release-gem.md` (release, verify, yank/rollback), indexed in `docs/runbooks/README.md` | — | +| 5 | API contract docs (OpenAPI / protobuf) | **Not applicable** | — | The repo consumes Amazon's third-party API; it owns no wire contract. Its public surface is Ruby classes, now typed in `sig/blurb.rbs`. | +| 6 | README with setup & run instructions | **Met** | `README.md` — installation, credential walkthrough, refresh-token flow, per-resource usage | The Travis badge, `iserve-products` links and `.env` variable names in "Development" are stale; fix them after PR #26 lands. | +| 7 | Changelog with migration notes | **Met** | `CHANGELOG.md` (Keep a Changelog, v0.5.2–v0.5.9 plus `Unreleased`, upgrade notes) | — | | 8 | On-call playbooks | **Not applicable** | — | Library; nothing is paged. Incidents surface in consuming applications. | -| 9 | CODEOWNERS | **Gap** | No `.github/CODEOWNERS`; `backstage.yaml` names owner `sre-comm` | The org ruleset already requires PR review; add a one-line CODEOWNERS pointing at the owning team so reviews auto-route. | +| 9 | CODEOWNERS | **Met** | `.github/CODEOWNERS`: `* @patterninc/sre` (backstage Owner `sre-comm`) | — | ## Guardrails & Enforcement | # | Practice | Status | Evidence | Recommendation / rationale | |---|----------|--------|----------|----------------------------| -| 10 | Linters | **Gap** | No `.rubocop.yml` or lint step anywhere | Add RuboCop with a minimal config and wire it into CI. | -| 11 | Formatters | **Gap** | No formatter config | Cover via RuboCop's layout cops (same change as item 10). | -| 12 | Type checking | **Gap** | No Sorbet/RBS | Optional RBS signatures for the public API (`Blurb`, `Client`, `Account`) would help agents and consumers; low priority for a dormant wrapper. | -| 13 | Pre-commit hooks | **Gap** | No `.pre-commit-config.yaml` or `.githooks/` | Add a pre-commit hook running RuboCop once it exists. Low priority. | -| 14 | Commit message conventions | **Gap** | No commitlint config; local history is a single squashed commit | Adopt Conventional Commits if changelog automation (item 7) is added; otherwise skip. Low priority. | -| 15 | Branch protection rules | **Met** | Org ruleset `require-pr-review` (active, `~DEFAULT_BRANCH`): `pull_request`, `non_fast_forward`, `deletion` rules | — | -| 16 | Required CI checks before merge | **Gap** | Ruleset has no `required_status_checks` rule, and `.github/workflows/ci.yml` installs gems but never runs the test suite | First make CI actually run tests (see items 23/25), then mark the job required on the default branch. | -| 17 | Dependency allow-lists / deny-lists | **Not applicable** | — | Three runtime dependencies (`rest-client`, `oauth2`, `activesupport`) on a dormant gem; a package allow-list is ceremony beyond this repo's scale. | -| 18 | License compliance scanning | **Gap** | No license-check job | Published MIT gem redistributing dependencies; a small CI license check is cheap insurance. Low priority. | -| 19 | Secret scanning | **Met** | Inherited Pattern Wiz policy (owner verified `patterninc`) | — | -| 20 | SAST / static analysis gates | **Met** | Inherited Pattern Wiz policy (owner verified `patterninc`) | — | -| 21 | Max complexity limits | **Gap** | No complexity enforcement | Enable RuboCop metrics cops with the same change as item 10. | -| 22 | Import boundary enforcement | **Not applicable** | — | Flat single-gem codebase (~17 files, one namespace); there are no architectural layers to police. | +| 10 | Linters | **Met** | `.rubocop.yml` (RuboCop 1.50, `NewCops: enable`), run by the `rubocop` pre-commit hook in `Static Checks`; existing offenses frozen per file in `.rubocop_todo.yml` | Pay the todo down in small PRs. | +| 11 | Formatters | **Met** | RuboCop layout cops (`make fmt`), `end-of-file-fixer` / `trailing-whitespace` hooks | — | +| 12 | Type checking | **Met** | `sig/blurb.rbs` (public API), `sig/vendor.rbs` (dependency stubs), `Steepfile`; `steep check` runs as a pre-commit hook in `Static Checks` and as `make typecheck` | Two diagnostics are downgraded to information for the known positional-hash call in `RequestCollection#execute_bulk_request`; restore them when that is fixed. | +| 13 | Pre-commit hooks | **Met** | `.pre-commit-config.yaml` (hygiene, RuboCop, Steep, Conventional Commits); `make bootstrap` installs them | — | +| 14 | Commit message conventions | **Met** | `conventional-pre-commit` on `commit-msg`; `.github/workflows/pr-hygiene.yml` checks PR titles | — | +| 15 | Branch protection rules | **Met** | Org ruleset `require-pr-review` on `~DEFAULT_BRANCH`: `pull_request`, `non_fast_forward`, `deletion` | — | +| 16 | Required CI checks before merge | **Partial** | `Static Checks` and `PR Hygiene` run on every PR and pass, but no ruleset has a `required_status_checks` rule for `master` | An admin applies a repo ruleset requiring `Static Checks` and `PR Hygiene` on `master` (`set-branch-rules.sh` dry run is ready). | +| 17 | Dependency allow-lists / deny-lists | **Not applicable** | — | Three runtime dependencies on a maintenance-mode gem; a package allow-list is ceremony beyond this repo's scale. | +| 18 | License compliance scanning | **Met** | Trivy `--scanners license` over `vendor/bundle` in `Static Checks`; one documented ignore in `.trivyignore.yaml` (diff-lcs, dev-only, also MIT) | — | +| 19 | Secret scanning | **Met** | Inherited Pattern Wiz policy (owner verified `patterninc`); `Wiz Secret Scanner` reports on PRs | — | +| 20 | SAST / static analysis gates | **Met** | Inherited Pattern Wiz policy (owner verified `patterninc`); `Wiz SAST Scanner` reports on PRs | — | +| 21 | Max complexity limits | **Met** | `Metrics/CyclomaticComplexity`, `PerceivedComplexity`, `MethodLength` enabled at defaults in `.rubocop.yml`; existing violators listed in `.rubocop_todo.yml` | — | +| 22 | Import boundary enforcement | **Not applicable** | — | Flat single-gem codebase (~17 files, one namespace); there are no layers to police. | ## Testing & Feedback Loops | # | Practice | Status | Evidence | Recommendation / rationale | |---|----------|--------|----------|----------------------------| -| 23 | Unit tests | **Partial** | 16 spec files under `spec/blurb/`, but every example calls the live Amazon API with real credentials (`spec/spec_helper.rb` sleeps 1s per example to dodge throttling) — there are no isolated unit tests, and nothing runs in CI | Stub HTTP with WebMock/VCR so the suite runs hermetically without credentials, then run it in CI. | -| 24 | Integration tests | **Partial** | The existing specs *are* integration tests against the real API, but they require a live advertising account/`.env` and are never executed in CI | Keep a credential-gated, tagged live-API suite for pre-release verification; document how to run it. | -| 25 | Snapshot / golden-file tests | **Gap** | No `testdata`/cassette fixtures | VCR cassettes double as golden files for request/response shapes — same work as item 23. | -| 26 | Contract tests (Pact) | **Not applicable** | — | The wire contract is owned by Amazon, a third party; consumer-driven contract testing has no counterpart to verify against. Recorded cassettes (item 25) pin the observed contract. | +| 23 | Unit tests | **Met** | `spec/unit/` (24 examples): request key casing, error mapping, 307 downloads, URL shapes, bulk splitting, account token flow; WebMock blocks real HTTP; run by `make test` in `Static Checks` | — | +| 24 | Integration tests | **Met** | Live-API specs in `spec/blurb/` tagged `:live`, run with `make test-live` (`BLURB_LIVE=1`); documented in `AGENTS.md` and `docs/runbooks/release-gem.md` as the pre-release check | Credential-gated by design; never run in PR CI. | +| 25 | Snapshot / golden-file tests | **Met** | `spec/unit/golden_payloads_spec.rb` compares the exact JSON sent for sp/hsa/sd report creates and history retrieve with `spec/fixtures/golden/*.json`; `UPDATE_GOLDEN=1` regenerates | — | +| 26 | Contract tests (Pact) | **Not applicable** | — | The wire contract is owned by Amazon; consumer-driven contract testing has no provider to verify against. Golden payloads (item 25) pin the observed contract. | | 27 | End-to-end tests (Playwright) | **Not applicable** | — | No UI of any kind; headless client library. | | 28 | Visual regression tests | **Not applicable** | — | No visual surface. | -| 29 | Test coverage thresholds | **Gap** | No SimpleCov or CI coverage gate | Add SimpleCov with a modest threshold once the suite runs in CI. | -| 30 | Mutation testing | **Gap** | None | Only worthwhile after the suite is hermetic and CI-run; low priority. | +| 29 | Test coverage thresholds | **Met** | SimpleCov `minimum_coverage 82` in `spec/spec_helper.rb` (current 82.66%), enforced by `make test` in CI | — | +| 30 | Mutation testing | **Gap** | None | Add `mutant-rspec` scoped to `Blurb::Request` after the Ruby 3 upgrade: current mutant needs Ruby >= 3.0, and the last 2.7-compatible release (0.11.25) needs `parser ~> 3.2.2`, which conflicts with RuboCop 1.50's `rubocop-ast`. | | 31 | Load / performance benchmarks | **Not applicable** | — | Client for a rate-limited third-party API; throughput is bounded by Amazon throttling, not this code. | -| 32 | Flaky test quarantine | **Gap** | No tagging/quarantine mechanism; live-API tests are inherently flaky | Tag live-API specs (e.g. `:live`) and exclude them from the default run — falls out of the item 23/24 split. | -| 33 | Structured CI output | **Partial** | `rspec_junit_formatter` is declared in `blurb.gemspec`, but CI has no test step so no JUnit output is ever produced | Emit JUnit XML from the CI rspec run once one exists. | -| 34 | Deterministic test fixtures | **Gap** | Specs depend on live account state and unseeded Faker data | VCR cassettes (item 23) make inputs and outputs fixed. | +| 32 | Flaky test quarantine | **Met** | Live, inherently flaky specs are tagged `:live` by path in `spec/spec_helper.rb` and excluded from the default and CI run | — | +| 33 | Structured CI output | **Met** | `make test` writes `rspec-junit.xml` (`RspecJunitFormatter`); `Static Checks` uploads `**/*-junit.xml` | — | +| 34 | Deterministic test fixtures | **Met** | `spec/fixtures/*.json` payloads and fixed dates/IDs in unit specs; no unit spec reads live state or unseeded Faker | — | | 35 | Smoke tests for deploys | **Not applicable** | — | Nothing is deployed; the artifact is a published gem. | ## Environment & Tooling | # | Practice | Status | Evidence | Recommendation / rationale | |---|----------|--------|----------|----------------------------| -| 36 | Devcontainer config | **Gap** | No `.devcontainer/`; Ruby version disagrees across configs (CI 2.7.2, stale Travis 2.6.2, no `.ruby-version`) | Add a `.ruby-version` at minimum; a devcontainer is optional at this scale. | -| 37 | One-command setup | **Met** | `bin/setup` (bundle install); `bin/console` for an interactive session | — | +| 36 | Devcontainer config | **Met** | `.devcontainer/devcontainer.json` (Ruby 2.7.8, Python, gh; `make bootstrap`); `.ruby-version`, `.tool-versions` | — | +| 37 | One-command setup | **Met** | `make bootstrap` (gems + hooks); `make all` runs everything CI runs; `bin/setup`, `bin/console` | — | | 38 | Seed scripts for local databases | **Not applicable** | — | No database. | | 39 | MCP servers for external tools | **Met** | Toolsmith-managed MCP access (owner verified `patterninc`) | — | -| 40 | Scoped secrets per environment | **Met** | Amazon credentials enter only via env vars/`dotenv` (`.env` gitignored per `.gitignore`); CI tokens held as GitHub Actions secrets (`ci.yml`); no deployment, so no per-environment split is needed | — | +| 40 | Scoped secrets per environment | **Met** | Amazon credentials only via `BLURB_*` env vars / gitignored `.env`; PR workflows use no secrets beyond `GITHUB_TOKEN`; nothing is deployed | The legacy `ci.yml` passes unrelated `RUBY_GEM_BUNDLE_TOKEN` / `SIDEKIQ_ENTERPRISE_TOKEN`; drop them when that workflow is retired. | | 41 | Preview environments per PR | **Not applicable** | — | Nothing to deploy. | -| 42 | Hot-reload / watch mode | **Not applicable** | — | Library with no runnable app; `bin/console` provides the interactive feedback loop appropriate to a gem this size. | -| 43 | Structured logging (JSON) | **Gap** | `lib/blurb/request.rb` logs via bare `puts` (`log` helper, lines 113–118) | Route through an injectable `Logger` so consuming apps control format/level; JSON structure is the consumer's concern, but `puts` pollutes consumers' stdout. | -| 44 | Observable traces and metrics | **Not applicable** | — | Instrumentation of production behavior belongs to consuming applications; the gem has no runtime of its own. | +| 42 | Hot-reload / watch mode | **Not applicable** | — | Library with no runnable app; `bin/console` is the interactive loop for a gem this size. | +| 43 | Structured logging (JSON) | **Gap** | `lib/blurb/request.rb` `log` helper prints with `puts` when `BLURB_LOGGING` is set | Replace it with an injectable `Logger` so consuming apps control format and level. Runtime behaviour change: needs the owner. | +| 44 | Observable traces and metrics | **Not applicable** | — | Instrumentation belongs to consuming applications; the gem has no runtime of its own. | | 45 | Feature flags with local overrides | **Not applicable** | — | Library; no runtime features to toggle. | | 46 | Database migration tooling | **Not applicable** | — | No database. | | 47 | Dependency update automation | **Met** | Org-wide Wiz for verified Pattern repos | — | -| 48 | Reproducible builds (lockfiles) | **Partial** | `Gemfile.lock` is gitignored; gemspec constraints are loose — `activesupport` is fully unpinned, so CI (`bundler-cache: true`) resolves a different graph over time | Commit `Gemfile.lock` (modern Bundler guidance, even for gems) and add an upper bound on `activesupport`. | +| 48 | Reproducible builds (lockfiles) | **Met** | `Gemfile.lock` committed for linux/darwin x86_64 and arm64; Ruby pinned in `.ruby-version`; CI installs from the lock (`bundler-cache`) | An upper bound on the runtime `activesupport` dependency would protect consumers; it changes their resolution, so it is an owner decision. | ## Agent dispatch | # | Practice | Status | Evidence | Recommendation / rationale | |---|----------|--------|----------|----------------------------| -| 49 | Agent-dispatch manifest | **Gap** | No `.agents/pattern-agents.json` | Add the manifest with `schema_version`, `github.repo` (`patterninc/blurb`), ClickUp list, Slack channel, and skills metadata. No `aws[]` needed — the repo has no AWS footprint. | +| 49 | Agent-dispatch manifest | **Partial** | `.agents/pattern-agents.json` has `schema_version`, `github.repo`, `slack_channel`, `datadog`, `skills.plugins`; no `aws[]` needed (no AWS footprint). `clickup_list_id` is not set | The owner names the ClickUp list for this repo; add it as `clickup_list_id`. | ## Prioritized recommendations -1. **[M] Gap — required CI checks (16):** Add an `rspec` (and later RuboCop) step to `.github/workflows/ci.yml`, then make the job a required status check on the default branch via the ruleset. Blocked on recommendation 3 for the test step to be meaningful. -2. **[S] Gap — AGENTS.md (2):** Write `AGENTS.md` covering setup (`bin/setup`), the credential-dependent test suite and how to run it, Ruby version, release process, and layout of `lib/blurb/`. -3. **[M] Partial — unit tests (23):** Stub HTTP with WebMock/VCR so the spec suite runs hermetically without live Amazon credentials; remove the 1s inter-test sleep for stubbed runs. -4. **[M] Partial — integration tests (24):** Split live-API specs behind a `:live` tag gated on credentials; document the pre-release live run. (Also resolves flaky quarantine, item 32.) -5. **[S] Partial — reproducible builds (48):** Commit `Gemfile.lock`, add an upper bound to the `activesupport` dependency, and add a `.ruby-version` file (also narrows item 36). -6. **[S] Gap — agent-dispatch manifest (49):** Add `.agents/pattern-agents.json` with GitHub, ClickUp, Slack, and skills metadata. -7. **[S] Gap — changelog (7):** Add `CHANGELOG.md` with per-release upgrade notes, backfilling the v2.0 API migration note from the README. -8. **[M] Gap — linters/formatter/complexity (10, 11, 21):** Add RuboCop (lint + layout + metrics cops) and run it in CI. -9. **[S] Gap — CODEOWNERS (9):** Map the repo to `sre-comm` (per `backstage.yaml`) so the ruleset's required reviews auto-route. -10. **[S] Gap — coverage threshold (29):** Add SimpleCov with a starter threshold once CI runs the suite. -11. **[S] Gap — structured logging (43):** Replace the `puts`-based `log` helper in `lib/blurb/request.rb` with an injectable `Logger`. -12. **[S] Cleanup:** Delete the stale `.travis.yml` and the dead TravisCI badge/`iserve-products` homepage links in `README.md`/`blurb.gemspec`. +1. **[S] Partial — required CI checks (16):** An admin adds a repo ruleset requiring `Static Checks` and `PR Hygiene` on `master` (both have reported on PR #29). +2. **[S] Gap — structured logging (43):** Replace the `puts`-based `log` in `lib/blurb/request.rb` with an injectable `Logger` (minor version bump, `CHANGELOG.md` note). +3. **[M] Gap — mutation testing (30):** After moving the gem to Ruby 3.x, add `mutant-rspec` scoped to `Blurb::Request` and run it incrementally (`--since master`) in CI. +4. **[S] Partial — agent-dispatch manifest (49):** Set `clickup_list_id` in `.agents/pattern-agents.json` once the owner names the list; confirm or drop the default `datadog` fields. ## Declined practices | # | Practice | Rationale | |---|----------|-----------| -| 4 | Runbooks | No operational surface; gem release is standard `bundler/gem_tasks`. | | 5 | API contract docs | Consumes Amazon's third-party API; owns no wire contract. | | 8 | On-call playbooks | Library — nothing is paged; incidents surface in consuming apps. | -| 17 | Dependency allow/deny lists | Three runtime dependencies on a dormant gem; policy ceremony beyond repo scale. | +| 17 | Dependency allow/deny lists | Three runtime dependencies on a maintenance-mode gem; policy ceremony beyond repo scale. | | 22 | Import boundary enforcement | Flat ~17-file single namespace; no layers to police. | -| 26 | Contract tests | Contract is owned by a third party (Amazon); cassette fixtures pin the observed shape instead. | +| 26 | Contract tests | Contract is owned by a third party (Amazon); golden payload files pin the observed shape instead. | | 27 | End-to-end tests | No UI; headless client library. | | 28 | Visual regression tests | No visual surface. | | 31 | Load/perf benchmarks | Throughput bounded by Amazon API throttling, not this code. | @@ -154,7 +153,8 @@ Critical gates are RED: AGENTS.md (item 2) and required CI checks (item 16) are ## Beyond the checklist -- `backstage.yaml` registers the repo in the Backstage catalog with cost-center, environment, and ownership labels — machine-readable ownership metadata most repos this size lack. -- Standard Bundler gem scaffold is intact and idiomatic: `bin/setup`, `bin/console`, `Rakefile` with `spec` as the default task, `.rspec` with documentation format. -- `rspec_junit_formatter` and `dotenv` are already declared as dev dependencies — the plumbing for structured CI output and credential hygiene exists even though CI doesn't use it yet. -- `.gitignore` proactively excludes `.env` and `.byebug_history`, keeping local credentials and debug artifacts out of history. +- `backstage.yaml` registers the repo in the Backstage catalog with cost-center, environment and ownership labels. +- The RBS signatures in `sig/` ship with the gem, so consuming apps that use Steep get types for `Blurb` without a separate collection entry. +- `.rubocop_todo.yml` is exclude-only (no raised `Max`), so new code meets RuboCop defaults while old offenses are paid down file by file. +- `.trivyignore.yaml` records why its one license exception is acceptable, next to the rule. +- The unit specs found two real load-order bugs (ActiveSupport >= 7.1 and `present?` outside Rails), fixed by require-only changes in `lib/blurb/request.rb`. diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md new file mode 100644 index 0000000..5580f31 --- /dev/null +++ b/docs/runbooks/README.md @@ -0,0 +1,5 @@ +# Runbooks + +| Runbook | When | +|---|---| +| [Release a new gem version](release-gem.md) | A merged change has to reach consuming applications | diff --git a/docs/runbooks/release-gem.md b/docs/runbooks/release-gem.md new file mode 100644 index 0000000..a9f11e6 --- /dev/null +++ b/docs/runbooks/release-gem.md @@ -0,0 +1,29 @@ +# Release a new blurb gem version + +Repo: `blurb`. Owner: `#sre-comm` on Slack + +## When this is needed + +A merged change on `master` has to reach consuming applications, which install `blurb` from RubyGems (`gem 'blurb'`). + +## Before you start + +- Access: push access to `patterninc/blurb` (tags) and owner rights on the `blurb` gem at rubygems.org (`gem signin`). No AWS access is involved. +- Impact: consumers with an unpinned or `~>` constraint pick the version up on their next `bundle update`. Breaking changes need a minor bump and a `CHANGELOG.md` note. +- New resources: none; this repo has no infrastructure. + +## Steps + +1. Open a PR that bumps `spec.version` in `blurb.gemspec` and moves the `## [Unreleased]` entries in `CHANGELOG.md` under `## [X.Y.Z] - YYYY-MM-DD`; merge it once `Static Checks` passes. +2. Optional, against a test/sandbox profile only: `make test-live` with a `.env` holding `BLURB_CLIENT_ID`, `BLURB_CLIENT_SECRET`, `BLURB_REFRESH_TOKEN`, `BLURB_REGION`, `BLURB_PROFILE_ID`. +3. `git checkout master && git pull` +4. `bundle exec rake release` (from `bundler/gem_tasks` in `Rakefile`): builds `pkg/blurb-X.Y.Z.gem`, creates and pushes tag `vX.Y.Z`, and pushes the gem to rubygems.org. + +## Verify + +- `gem list blurb --remote --exact` shows `blurb (X.Y.Z)`. +- `git ls-remote --tags origin vX.Y.Z` returns the tag. + +## Rollback + +- RubyGems versions can't be replaced. Yank a broken version with `gem yank blurb -v X.Y.Z`, then release a fixed `X.Y.Z+1`. diff --git a/lib/blurb/base_class.rb b/lib/blurb/base_class.rb index 79819de..7488a70 100644 --- a/lib/blurb/base_class.rb +++ b/lib/blurb/base_class.rb @@ -6,4 +6,4 @@ class BaseClass sd: 'sd' }.freeze end -end +end diff --git a/lib/blurb/errors/request_throttled.rb b/lib/blurb/errors/request_throttled.rb index 29b4fde..c9c1ff8 100644 --- a/lib/blurb/errors/request_throttled.rb +++ b/lib/blurb/errors/request_throttled.rb @@ -1,4 +1,4 @@ require "blurb/errors/base_exception" class Blurb class RequestThrottled < StandardError; end -end +end diff --git a/lib/blurb/history_request.rb b/lib/blurb/history_request.rb index bde7ebe..4db7e25 100644 --- a/lib/blurb/history_request.rb +++ b/lib/blurb/history_request.rb @@ -26,6 +26,7 @@ def retrieve( count = MIN_COUNT if count < MIN_COUNT count = MAX_COUNT if count > MAX_COUNT + # @type var payload: Hash[Symbol, untyped] payload = { sort: { key: 'DATE', diff --git a/lib/blurb/report_requests.rb b/lib/blurb/report_requests.rb index df1da8d..5c304a1 100644 --- a/lib/blurb/report_requests.rb +++ b/lib/blurb/report_requests.rb @@ -18,6 +18,7 @@ def create( ) # create payload metrics = get_default_metrics(record_type.to_s.underscore.to_sym, segment) if metrics.nil? + # @type var payload: Hash[Symbol, untyped] payload = { metrics: metrics.map{ |m| m.to_s.camelize(:lower) }.join(","), report_date: report_date diff --git a/lib/blurb/request.rb b/lib/blurb/request.rb index 4f17ae8..73ec3f0 100644 --- a/lib/blurb/request.rb +++ b/lib/blurb/request.rb @@ -1,3 +1,6 @@ +# ActiveSupport >= 7.1 must be loaded before any core_ext file; present? needs object/blank. +require 'active_support' +require 'active_support/core_ext/object/blank' require 'active_support/core_ext/string' require "rest-client" require "blurb/base_class" @@ -16,6 +19,7 @@ def initialize(url:, request_type:, payload: nil, headers:, url_params: nil) end def request_config + # @type var request_config: Hash[Symbol, untyped] request_config = { method: @request_type, url: @url, @@ -42,7 +46,7 @@ def make_request rescue RestClient::TooManyRequests => err raise RequestThrottled.new(JSON.parse(err.response.body)) rescue RestClient::TemporaryRedirect => err - return RestClient.get(err.response.headers[:location]) # If this happens, then we are downloading a report from the api, so we can simply download the location + return RestClient.get(err.response.headers[:location]) # If this happens, then we are downloading a report from the api, so we can simply download the location rescue RestClient::NotAcceptable => err if @url.include?("report") raise InvalidReportRequest.new(JSON.parse(err.response.body)) @@ -52,7 +56,7 @@ def make_request rescue RestClient::ExceptionWithResponse => err if err.response.present? raise FailedRequest.new(JSON.parse(err.response.body)) - else + else raise err end end diff --git a/sig/blurb.rbs b/sig/blurb.rbs new file mode 100644 index 0000000..a8be60b --- /dev/null +++ b/sig/blurb.rbs @@ -0,0 +1,208 @@ +# Type signatures for the gem's public API (audit item 12). `make typecheck` (part of +# `make lint` and CI) runs `steep check` of lib/ against these. Keep them in step with +# lib/: a new public method needs a signature here. +class Blurb + type campaign_type_key = :sp | :sb | :hsa | :sd + type headers = Hash[String, untyped] + # Responses are parsed JSON with snake_case Symbol keys (docs/adr/0002). + type response = untyped + + attr_accessor client: Client + attr_accessor account: Account + + def initialize: (?client_id: String?, ?client_secret: String?, ?refresh_token: String?, ?region: String?, ?profile_id: (String | Integer)?) -> void + def profiles: () -> Array[Profile] + def active_profile: () -> Profile? + + class BaseClass + CAMPAIGN_TYPE_CODES: Hash[Symbol, String] + end + + class BaseException < StandardError + end + + class FailedRequest < StandardError + end + + class InvalidReportRequest < StandardError + end + + class RequestThrottled < StandardError + end + + class Client < BaseClass + attr_accessor client_id: String? + attr_accessor client_secret: String? + + def initialize: (client_id: String?, client_secret: String?) -> void + end + + class Account < BaseClass + # Keyed by region: "TEST", "NA", "EU", "FE". + API_URLS: Hash[String?, String] + + attr_accessor refresh_token: String? + attr_accessor api_url: String? + attr_accessor client: Client + attr_accessor profiles: Array[Profile] + attr_accessor active_profile: Profile? + + @token_refreshed_at: Float + @authorization_token: String? + + def initialize: (refresh_token: String?, region: String?, client: Client, ?profile_id: (String | Integer)?) -> void + def initialize_profiles: (?(String | Integer)? profile_id) -> Profile? + def set_active_profile: (String | Integer profile_id) -> Profile? + def get_profile: (String | Integer profile_id) -> Profile? + def profile_list: () -> response + def retrieve_profile: (String | Integer profile_id) -> response + def retrieve_token: () -> String? + + private + + def profile_request: (String api_path) -> response + def authorization_client: () -> untyped + end + + class Profile < BaseClass + attr_accessor account: Account + attr_accessor ad_groups: RequestCollection + attr_accessor sd_ad_groups: RequestCollection + attr_accessor campaign_negative_keywords: RequestCollection + attr_accessor portfolios: RequestCollection + attr_accessor product_ads: RequestCollection + attr_accessor sd_product_ads: RequestCollection + attr_accessor profile_id: String | Integer + attr_accessor suggested_keywords: SuggestedKeywordRequests + attr_accessor targets: RequestCollection + attr_accessor history: HistoryRequest + + @base_url: String? + @sp_campaigns: CampaignRequests + @sb_campaigns: CampaignRequests + @sd_campaigns: CampaignRequests + @sp_keywords: RequestCollectionWithCampaignType + @sb_keywords: RequestCollectionWithCampaignType + @sp_negative_keywords: RequestCollectionWithCampaignType + @sb_negative_keywords: RequestCollectionWithCampaignType + @sp_snapshots: SnapshotRequests + @sb_snapshots: SnapshotRequests + @sp_reports: ReportRequests + @sb_reports: ReportRequests + @sd_reports: ReportRequests + + def initialize: (profile_id: String | Integer, account: Account) -> void + def campaigns: (campaign_type_key campaign_type) -> CampaignRequests? + def keywords: (campaign_type_key campaign_type) -> RequestCollectionWithCampaignType? + def negative_keywords: (campaign_type_key campaign_type) -> RequestCollectionWithCampaignType? + def snapshots: (campaign_type_key campaign_type) -> SnapshotRequests? + def reports: (campaign_type_key campaign_type) -> ReportRequests? + def request: (?api_path: String, ?request_type: Symbol, ?payload: untyped, ?url_params: Hash[untyped, untyped]?, ?headers: headers) -> response + def profile_details: () -> response + def headers_hash: (?Hash[Symbol, untyped] opts) -> headers + end + + class Request < BaseClass + @url: String + @payload: String? + @headers: headers + @request_type: Symbol + + def initialize: (url: String, request_type: Symbol, headers: headers, ?payload: untyped, ?url_params: Hash[untyped, untyped]?) -> void + def request_config: () -> Hash[Symbol, untyped] + def make_request: () -> response + + private + + def setup_url: (String url, Hash[untyped, untyped]? url_params) -> String + def convert_payload: (untyped payload) -> String? + def convert_response: (untyped resp) -> response + def camelcase_keys: (untyped value) -> untyped + def camelcase_key: (untyped k) -> untyped + def underscore_keys: (untyped value) -> untyped + def underscore_key: (untyped k) -> Symbol + def log: (String header, untyped message) -> void + end + + class RequestCollection < BaseClass + # nil when Account was built with an unknown region (no API_URLS entry). + @base_url: String? + @headers: headers + @api_limit: Integer + + def initialize: (headers: headers, base_url: String, ?bulk_api_limit: Integer) -> void + def list: (?Hash[untyped, untyped]? url_params) -> response + def list_extended: (?Hash[untyped, untyped]? url_params) -> response + def retrieve: (untyped resource_id) -> response + def retrieve_extended: (untyped resource_id) -> response + def create: (**untyped create_params) -> response + def create_bulk: (Array[Hash[Symbol, untyped]] create_array) -> Array[response] + def update: (**untyped update_params) -> response + def update_bulk: (Array[Hash[Symbol, untyped]] update_array) -> Array[response] + def delete: (untyped resource_id) -> response + + private + + def execute_request: (request_type: Symbol, ?api_path: String, ?payload: untyped, ?url_params: Hash[untyped, untyped]?) -> response + def execute_bulk_request: (**untyped execute_request_params) -> Array[response] + end + + class RequestCollectionWithCampaignType < RequestCollection + @campaign_type: String + + def initialize: (campaign_type: String, resource: String, base_url: String?, headers: headers, ?bulk_api_limit: Integer) -> void + end + + class CampaignRequests < RequestCollectionWithCampaignType + private + + def map_campaign_payload: (Array[Hash[Symbol, untyped]] payload) -> Array[Hash[Symbol, untyped]] + end + + class SnapshotRequests < RequestCollectionWithCampaignType + def initialize: (campaign_type: String, base_url: String?, headers: headers) -> void + def create: (Symbol | String record_type, ?String state_filter) -> response + def retrieve: (untyped snapshot_id) -> response + def download: (untyped snapshot_id) -> response + end + + class ReportRequests < RequestCollectionWithCampaignType + SD_TACTIC: String + + def initialize: (campaign_type: String, base_url: String?, headers: headers) -> void + def create: (record_type: Symbol | String, ?report_date: Date | Time, ?metrics: Array[String | Symbol]?, ?segment: String?) -> response + def retrieve: (untyped report_id) -> response + def download: (untyped report_id) -> response + + private + + def get_default_metrics: (Symbol record_type, ?String? segment) -> untyped + end + + class HistoryRequest < RequestCollection + FROM_DATE: String + TO_DATE: String + MAX_COUNT: Integer + MIN_COUNT: Integer + FILTERS: Array[String] + PARENT_CAMPAIGN_ID: nil + + def initialize: (base_url: String?, headers: headers) -> void + def retrieve: (campaign_ids: Array[untyped], ?from_date: String | Integer, ?to_date: String | Integer, ?filters: Array[String], ?parent_campaign_id: untyped, ?count: Integer) -> response + end + + class SuggestedKeywordRequests + @base_url: String + @headers: headers + + def initialize: (base_url: String, headers: headers) -> void + def ad_group_retrieve: (untyped ad_group_id) -> response + def ad_group_retrieve_extended: (untyped ad_group_id) -> response + def asin_retrieve: (String asin_value, ?Integer max_num_suggestions) -> response + def asin_list: (Array[String] asin_list, ?Integer max_num_suggestions) -> response + + private + + def execute_request: (request_type: Symbol, ?api_path: String, ?payload: untyped, ?url_params: Hash[untyped, untyped]?) -> response + end +end diff --git a/sig/vendor.rbs b/sig/vendor.rbs new file mode 100644 index 0000000..aa9199d --- /dev/null +++ b/sig/vendor.rbs @@ -0,0 +1,44 @@ +# Minimal stubs for runtime dependencies that ship no RBS (rest-client, oauth2, +# activesupport). Only what lib/ references; everything is untyped. +module RestClient + def self.get: (*untyped) -> untyped + + class Request + def self.execute: (untyped) -> untyped + end + + class ExceptionWithResponse < StandardError + def response: () -> untyped + end + + class TooManyRequests < ExceptionWithResponse + end + + class TemporaryRedirect < ExceptionWithResponse + end + + class NotAcceptable < ExceptionWithResponse + end +end + +module OAuth2 + class Client + def initialize: (*untyped) -> void + def request: (*untyped) -> untyped + end +end + +module ActiveSupport + class TimeWithZone + end +end + +class Object + def present?: () -> bool + def blank?: () -> bool +end + +class String + def camelize: (?Symbol first_letter) -> String + def underscore: () -> String +end diff --git a/spec/blurb/product_ad_requests_spec.rb b/spec/blurb/product_ad_requests_spec.rb index d883635..006986a 100644 --- a/spec/blurb/product_ad_requests_spec.rb +++ b/spec/blurb/product_ad_requests_spec.rb @@ -18,4 +18,3 @@ include_examples "request collection" end - diff --git a/spec/fixtures/create_response.json b/spec/fixtures/create_response.json new file mode 100644 index 0000000..d58d1e3 --- /dev/null +++ b/spec/fixtures/create_response.json @@ -0,0 +1 @@ +[{"code": "SUCCESS", "campaignId": 123456789}] diff --git a/spec/fixtures/error_bad_request.json b/spec/fixtures/error_bad_request.json new file mode 100644 index 0000000..4051b8b --- /dev/null +++ b/spec/fixtures/error_bad_request.json @@ -0,0 +1 @@ +{"code": "INVALID_ARGUMENT", "details": "dailyBudget must be at least 1.0"} diff --git a/spec/fixtures/error_invalid_report.json b/spec/fixtures/error_invalid_report.json new file mode 100644 index 0000000..e4829d6 --- /dev/null +++ b/spec/fixtures/error_invalid_report.json @@ -0,0 +1 @@ +{"code": "406", "details": "Report is not ready"} diff --git a/spec/fixtures/error_throttled.json b/spec/fixtures/error_throttled.json new file mode 100644 index 0000000..24988fe --- /dev/null +++ b/spec/fixtures/error_throttled.json @@ -0,0 +1 @@ +{"code": "429", "details": "Too Many Requests"} diff --git a/spec/fixtures/golden/history_retrieve.json b/spec/fixtures/golden/history_retrieve.json new file mode 100644 index 0000000..e303a61 --- /dev/null +++ b/spec/fixtures/golden/history_retrieve.json @@ -0,0 +1,25 @@ +{ + "sort": { + "key": "DATE", + "direction": "ASC" + }, + "fromDate": 1704067200000, + "toDate": 1706659200000, + "eventTypes": { + "CAMPAIGN": { + "eventTypeIds": [ + 1, + 2 + ], + "filters": [ + "BUDGET_AMOUNT" + ], + "parents": [ + { + "campaignId": 3 + } + ] + } + }, + "count": 50 +} diff --git a/spec/fixtures/golden/report_hsa_campaigns.json b/spec/fixtures/golden/report_hsa_campaigns.json new file mode 100644 index 0000000..70034e1 --- /dev/null +++ b/spec/fixtures/golden/report_hsa_campaigns.json @@ -0,0 +1,4 @@ +{ + "metrics": "campaignId,impressions,clicks,cost,attributedSales14d,attributedSales14dSameSKU,attributedConversions14d,attributedConversions14dSameSKU", + "reportDate": "20240131" +} diff --git a/spec/fixtures/golden/report_sd_campaigns.json b/spec/fixtures/golden/report_sd_campaigns.json new file mode 100644 index 0000000..2091a96 --- /dev/null +++ b/spec/fixtures/golden/report_sd_campaigns.json @@ -0,0 +1,5 @@ +{ + "metrics": "campaignId,impressions,clicks,cost,currency,attributedConversions1d,attributedConversions7d,attributedConversions14d,attributedConversions30d,attributedConversions1dSameSKU,attributedConversions7dSameSKU,attributedConversions14dSameSKU,attributedConversions30dSameSKU,attributedUnitsOrdered1d,attributedUnitsOrdered7d,attributedUnitsOrdered14d,attributedUnitsOrdered30d,attributedSales1d,attributedSales7d,attributedSales14d,attributedSales30d,attributedSales1dSameSKU,attributedSales7dSameSKU,attributedSales14dSameSKU,attributedSales30dSameSKU", + "reportDate": "20240131", + "tactic": "T00020" +} diff --git a/spec/fixtures/golden/report_sp_keywords.json b/spec/fixtures/golden/report_sp_keywords.json new file mode 100644 index 0000000..f1ba84d --- /dev/null +++ b/spec/fixtures/golden/report_sp_keywords.json @@ -0,0 +1,4 @@ +{ + "metrics": "campaignId,keywordId,impressions,clicks,cost,attributedConversions1d,attributedConversions7d,attributedConversions14d,attributedConversions30d,attributedConversions1dSameSKU,attributedConversions7dSameSKU,attributedConversions14dSameSKU,attributedConversions30dSameSKU,attributedUnitsOrdered1d,attributedUnitsOrdered7d,attributedUnitsOrdered14d,attributedUnitsOrdered30d,attributedSales1d,attributedSales7d,attributedSales14d,attributedSales30d,attributedSales1dSameSKU,attributedSales7dSameSKU,attributedSales14dSameSKU,attributedSales30dSameSKU", + "reportDate": "20240131" +} diff --git a/spec/fixtures/golden/report_sp_keywords_query_segment.json b/spec/fixtures/golden/report_sp_keywords_query_segment.json new file mode 100644 index 0000000..a2c1d98 --- /dev/null +++ b/spec/fixtures/golden/report_sp_keywords_query_segment.json @@ -0,0 +1,5 @@ +{ + "metrics": "campaignId,keywordId,impressions,clicks,cost,attributedConversions1d,attributedConversions7d,attributedConversions14d,attributedConversions30d,attributedConversions1dSameSKU,attributedConversions7dSameSKU,attributedConversions14dSameSKU,attributedConversions30dSameSKU,attributedUnitsOrdered1d,attributedUnitsOrdered7d,attributedUnitsOrdered14d,attributedUnitsOrdered30d,attributedSales1d,attributedSales7d,attributedSales14d,attributedSales30d,attributedSales1dSameSKU,attributedSales7dSameSKU,attributedSales14dSameSKU,attributedSales30dSameSKU", + "reportDate": "20240131", + "segment": "query" +} diff --git a/spec/fixtures/profiles.json b/spec/fixtures/profiles.json new file mode 100644 index 0000000..496b616 --- /dev/null +++ b/spec/fixtures/profiles.json @@ -0,0 +1,4 @@ +[ + {"profileId": 1111111111, "countryCode": "US", "currencyCode": "USD", "timezone": "America/Los_Angeles", "accountInfo": {"marketplaceStringId": "ATVPDKIKX0DER", "id": "A1FAKEACCOUNT", "type": "seller"}}, + {"profileId": 2222222222, "countryCode": "CA", "currencyCode": "CAD", "timezone": "America/Los_Angeles", "accountInfo": {"marketplaceStringId": "A2EUQ1WTGCTBG2", "id": "A1FAKEACCOUNT", "type": "seller"}} +] diff --git a/spec/fixtures/sp_campaigns.json b/spec/fixtures/sp_campaigns.json new file mode 100644 index 0000000..7b97ac3 --- /dev/null +++ b/spec/fixtures/sp_campaigns.json @@ -0,0 +1,3 @@ +[ + {"campaignId": 123456789, "name": "Fixture campaign", "campaignType": "sponsoredProducts", "targetingType": "manual", "state": "enabled", "dailyBudget": 10.0, "startDate": "20240101", "premiumBidAdjustment": false} +] diff --git a/spec/fixtures/token.json b/spec/fixtures/token.json new file mode 100644 index 0000000..9c0bad6 --- /dev/null +++ b/spec/fixtures/token.json @@ -0,0 +1 @@ +{"access_token": "Atza|fake-access-token", "refresh_token": "Atzr|fake-refresh-token", "token_type": "bearer", "expires_in": 3600} diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3a08c50..f552036 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,8 +1,25 @@ +# Two suites share this helper: +# - default (`make test`, CI): hermetic specs; HTTP is blocked by WebMock and every +# example tagged :live (everything under spec/blurb/) is excluded. +# - BLURB_LIVE=1 (`make test-live`): only the :live specs, against the real Amazon +# Advertising API, with credentials from .env. +LIVE = ENV["BLURB_LIVE"] == "1" + +unless LIVE + require "simplecov" + SimpleCov.start do + add_filter "/spec/" + # Current hermetic coverage, rounded down. Raise it as unit specs are added. + minimum_coverage 82 + end +end + require "bundler/setup" require "blurb" require 'dotenv/load' require 'byebug' require 'faker' +require "webmock/rspec" Dir.glob("#{File.dirname __FILE__}/support/*.rb").each { |f| require f } @@ -19,6 +36,17 @@ # Include support files config.include RequestCollectionExamples - # Sleep half a second between tests to avoid amazon advertising api throttling - config.before(:each) { sleep(1) } + # Everything under spec/blurb/ calls the live API. + config.define_derived_metadata(file_path: %r{/spec/blurb/}) do |metadata| + metadata[:live] = true + end + + if LIVE + config.filter_run_including :live + WebMock.allow_net_connect! + # Sleep between live examples to avoid Amazon Advertising API throttling + config.before(:each) { sleep(1) } + else + config.filter_run_excluding :live + end end diff --git a/spec/support/fixtures.rb b/spec/support/fixtures.rb new file mode 100644 index 0000000..d194891 --- /dev/null +++ b/spec/support/fixtures.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Fixtures + DIR = File.expand_path('../fixtures', __dir__) + + def fixture(name) + File.read(File.join(DIR, name)) + end +end + +RSpec.configure { |config| config.include Fixtures } diff --git a/spec/unit/account_spec.rb b/spec/unit/account_spec.rb new file mode 100644 index 0000000..57e82ee --- /dev/null +++ b/spec/unit/account_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Blurb do + let(:token_url) { 'https://api.amazon.com/auth/o2/token' } + let(:api_url) { 'https://advertising-api.amazon.com' } + + before do + stub_request(:post, token_url).to_return( + status: 200, body: fixture('token.json'), headers: { 'Content-Type' => 'application/json' } + ) + end + + def blurb(**opts) + described_class.new(client_id: 'cid', client_secret: 'secret', refresh_token: 'Atzr|fake', region: 'NA', **opts) + end + + it 'exchanges the refresh token for an access token' do + blurb(profile_id: '1111111111') + + expect(WebMock).to have_requested(:post, token_url) + .with(body: hash_including('grant_type' => 'refresh_token', 'refresh_token' => 'Atzr|fake', 'client_id' => 'cid')) + end + + it 'uses the given profile without listing profiles' do + b = blurb(profile_id: '1111111111') + + expect(b.active_profile.profile_id).to eq('1111111111') + expect(WebMock).not_to have_requested(:get, "#{api_url}/v2/profiles") + end + + it 'lists profiles and activates the first when no profile_id is given' do + stub_request(:get, "#{api_url}/v2/profiles").to_return(body: fixture('profiles.json')) + + b = blurb + + expect(b.profiles.map(&:profile_id)).to eq([1_111_111_111, 2_222_222_222]) + expect(b.active_profile.profile_id).to eq(1_111_111_111) + end + + it 'maps each region to its API host' do + expect(Blurb::Account::API_URLS).to include( + 'NA' => 'https://advertising-api.amazon.com', + 'EU' => 'https://advertising-api-eu.amazon.com', + 'FE' => 'https://advertising-api-fe.amazon.com' + ) + end + + it 'sends the bearer token and client id on API calls' do + stub = stub_request(:get, "#{api_url}/v2/profiles/1111111111") + .with(headers: { 'Authorization' => 'Bearer Atza|fake-access-token', + 'Amazon-Advertising-API-ClientId' => 'cid' }) + .to_return(body: '{"profileId": 1111111111}') + + blurb(profile_id: '1111111111').account.retrieve_profile('1111111111') + + expect(stub).to have_been_requested + end +end diff --git a/spec/unit/golden_payloads_spec.rb b/spec/unit/golden_payloads_spec.rb new file mode 100644 index 0000000..8d94dde --- /dev/null +++ b/spec/unit/golden_payloads_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Golden-file tests: the exact JSON body each builder sends to Amazon is stored in +# spec/fixtures/golden/.json. A diff means the wire payload changed. If the +# change is intended, regenerate with `UPDATE_GOLDEN=1 make test` and review the diff. +RSpec.describe 'request payload golden files' do + let(:base_url) { 'https://advertising-api.amazon.com' } + let(:headers) { { 'Content-Type' => 'application/json' } } + let(:golden_dir) { File.join(Fixtures::DIR, 'golden') } + + def capture_body(method, url) + body = nil + stub_request(method, url).to_return do |req| + body = req.body + { status: 200, body: '{}' } + end + yield + JSON.pretty_generate(JSON.parse(body)) + end + + def expect_golden(name, actual) + path = File.join(golden_dir, "#{name}.json") + File.write(path, "#{actual}\n") if ENV['UPDATE_GOLDEN'] == '1' + expect(actual).to eq(File.read(path).chomp), "#{path} differs; UPDATE_GOLDEN=1 regenerates it" + end + + def reports(type) + Blurb::ReportRequests.new(campaign_type: type, base_url: base_url, headers: headers) + end + + { + 'report_sp_keywords' => ['sp', :keywords, nil], + 'report_sp_keywords_query_segment' => ['sp', :keywords, 'query'], + 'report_hsa_campaigns' => ['hsa', :campaigns, nil], + 'report_sd_campaigns' => ['sd', :campaigns, nil] + }.each do |name, (type, record_type, segment)| + it "matches #{name}" do + url = "#{base_url}/v2/#{type}/#{record_type}/report" + body = capture_body(:post, url) do + reports(type).create(record_type: record_type, report_date: Date.new(2024, 1, 31), segment: segment) + end + + expect_golden(name, body) + end + end + + it 'matches history_retrieve' do + history = Blurb::HistoryRequest.new(base_url: base_url, headers: headers) + body = capture_body(:post, "#{base_url}/history") do + history.retrieve(from_date: 1_704_067_200_000, to_date: 1_706_659_200_000, campaign_ids: [1, 2], + filters: ['BUDGET_AMOUNT'], parent_campaign_id: 3, count: 10) + end + + expect_golden('history_retrieve', body) + end +end diff --git a/spec/unit/request_collection_spec.rb b/spec/unit/request_collection_spec.rb new file mode 100644 index 0000000..dacb9ac --- /dev/null +++ b/spec/unit/request_collection_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Pins the URL shapes recorded in docs/adr/0003. +RSpec.describe Blurb::RequestCollectionWithCampaignType do + let(:base_url) { 'https://advertising-api.amazon.com' } + let(:headers) { { 'Content-Type' => 'application/json' } } + + def collection(type, resource: 'campaigns', limit: 100) + described_class.new(campaign_type: type, resource: resource, base_url: base_url, headers: headers, + bulk_api_limit: limit) + end + + it 'maps Sponsored Brands to the legacy hsa code' do + expect(Blurb::BaseClass::CAMPAIGN_TYPE_CODES).to eq(sp: 'sp', sb: 'hsa', sd: 'sd') + end + + it 'puts sp and hsa resources under /v2' do + sp = stub_request(:get, "#{base_url}/v2/sp/campaigns/1").to_return(body: '{}') + hsa = stub_request(:get, "#{base_url}/v2/hsa/campaigns/1").to_return(body: '{}') + + collection('sp').retrieve(1) + collection('hsa').retrieve(1) + + expect(sp).to have_been_requested + expect(hsa).to have_been_requested + end + + it 'puts sd resources at the root, without /v2' do + stub = stub_request(:get, "#{base_url}/sd/campaigns/extended/1").to_return(body: '{}') + + collection('sd').retrieve_extended(1) + + expect(stub).to have_been_requested + end + + it 'drops the sd segment for un-parameterised sd report GETs' do + stub = stub_request(:get, "#{base_url}/reports").to_return(body: '[]') + + collection('sd', resource: 'reports').list + + expect(stub).to have_been_requested + end + + it 'splits bulk creates into requests of at most bulk_api_limit items' do + stub = stub_request(:post, "#{base_url}/v2/hsa/campaigns").to_return(body: fixture('create_response.json')) + + result = collection('hsa', limit: 2).create_bulk([{ name: 'a' }, { name: 'b' }, { name: 'c' }]) + + expect(stub).to have_been_requested.twice + expect(result.length).to eq(2) + end + + it 'sends delete to the resource id' do + stub = stub_request(:delete, "#{base_url}/v2/sp/campaigns/9").to_return(body: '{}') + + collection('sp').delete(9) + + expect(stub).to have_been_requested + end +end diff --git a/spec/unit/request_spec.rb b/spec/unit/request_spec.rb new file mode 100644 index 0000000..293274a --- /dev/null +++ b/spec/unit/request_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Pins the behaviour recorded in docs/adr/0002. +RSpec.describe Blurb::Request do + let(:url) { 'https://advertising-api.amazon.com/v2/sp/campaigns' } + let(:headers) { { 'Authorization' => 'Bearer token', 'Content-Type' => 'application/json' } } + + def request(**opts) + described_class.new(url: url, headers: headers, request_type: :get, **opts) + end + + describe '#make_request' do + it 'snake_cases response keys into symbols' do + stub_request(:get, url).to_return(status: 200, body: fixture('sp_campaigns.json')) + + result = request.make_request + + expect(result.first).to include(campaign_id: 123_456_789, daily_budget: 10.0, premium_bid_adjustment: false) + expect(result.first.keys).to all(be_a(Symbol)) + end + + it 'camelCases query params, formats dates and keeps upper-case keys' do + stub = stub_request(:get, "#{url}?campaignIdFilter=1,2&startDate=20240131&SKU=abc") + .to_return(status: 200, body: '[]') + + request(url_params: { campaign_id_filter: '1,2', start_date: Date.new(2024, 1, 31), SKU: 'abc' }).make_request + + expect(stub).to have_been_requested + end + + it 'camelCases nested POST payload keys' do + stub = stub_request(:post, url) + .with(body: [{ 'name' => 'x', 'dailyBudget' => 5, 'bidding' => { 'adjustmentStrategy' => 'up' } }].to_json) + .to_return(status: 207, body: fixture('create_response.json')) + + result = described_class.new( + url: url, headers: headers, request_type: :post, + payload: [{ name: 'x', daily_budget: 5, bidding: { adjustment_strategy: 'up' } }] + ).make_request + + expect(stub).to have_been_requested + expect(result).to eq([{ code: 'SUCCESS', campaign_id: 123_456_789 }]) + end + + it 'raises RequestThrottled on 429' do + stub_request(:get, url).to_return(status: 429, body: fixture('error_throttled.json')) + + expect { request.make_request }.to raise_error(Blurb::RequestThrottled) + end + + it 'raises InvalidReportRequest on 406 for a report URL' do + report_url = 'https://advertising-api.amazon.com/v2/reports/abc' + stub_request(:get, report_url).to_return(status: 406, body: fixture('error_invalid_report.json')) + + expect { described_class.new(url: report_url, headers: headers, request_type: :get).make_request } + .to raise_error(Blurb::InvalidReportRequest) + end + + it 're-raises RestClient::NotAcceptable on 406 for a non-report URL' do + stub_request(:get, url).to_return(status: 406, body: '{}') + + expect { request.make_request }.to raise_error(RestClient::NotAcceptable) + end + + it 'raises FailedRequest with the body on other error responses' do + stub_request(:get, url).to_return(status: 400, body: fixture('error_bad_request.json')) + + expect { request.make_request }.to raise_error(Blurb::FailedRequest, /INVALID_ARGUMENT/) + end + + it 'follows a 307 and returns the downloaded body unparsed (report downloads)' do + download = 'https://offline-report-storage.example.com/report.json.gz' + stub_request(:get, url).to_return(status: 307, headers: { 'Location' => download }) + stub_request(:get, download).to_return(status: 200, body: 'raw-gzip-bytes') + + expect(request.make_request.body).to eq('raw-gzip-bytes') + end + end +end