Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
140 commits
Select commit Hold shift + click to select a range
284696e
build: start the 2.0 line on Ruby 3.4
mantas Aug 27, 2026
bb0b771
refactor!: rewrite the client internals
mantas Aug 27, 2026
b4125cd
feat: add RBS signatures and Steep type checking
mantas Aug 27, 2026
0d8c059
ci: check types and specs on a Ruby matrix, release via trusted publi…
mantas Aug 27, 2026
db0083b
docs: document the 2.0 API and the upgrade path
mantas Aug 27, 2026
d776673
feat: pattern matching, derived clients and pattern-matched shape checks
mantas Aug 27, 2026
9b116f1
ci: boot Zammad and drive it with this gem end to end
mantas Aug 27, 2026
fb6c683
ci: fix the Zammad boot and unblock the Ruby head job
mantas Aug 27, 2026
229948b
fix: detect an already-configured Zammad by authenticating
mantas Aug 27, 2026
9a6adc5
ci: drop Ruby head from the matrix
mantas Aug 27, 2026
d8466bf
ci: test only released Rubies
mantas Aug 27, 2026
8401f89
ci: test on Ruby 4.0 as well
mantas Aug 27, 2026
907e94e
build: add Mantas Masalskis as a gem author
mantas Aug 27, 2026
e01212e
docs: add runnable examples for the 2.0 API
mantas Aug 27, 2026
b42c11a
docs: add a pagination example
mantas Aug 27, 2026
204b7e3
docs: add an example of driving pagination in batches by hand
mantas Aug 27, 2026
2d60cb1
fix: decode the reload response before replacing attributes
mantas Sep 10, 2026
48e3a8e
fix: close two gaps in the credential-redaction contract
mantas Sep 10, 2026
8ce5029
fix: keep the original value as the baseline in changes
mantas Sep 10, 2026
4ee31b2
test: make two vacuous examples able to fail
mantas Sep 10, 2026
dedfdd2
docs: harden the examples against hostile data
mantas Sep 10, 2026
8a2e421
feat!: chain collection pagination instead of passing keywords
mantas Sep 10, 2026
aa58220
docs: correct the documented type of ResponseError#response
mantas Sep 10, 2026
ef4c354
feat: reach any endpoint with client.get/post/put/delete
mantas Sep 10, 2026
4f0e41c
feat!: return false from save on a validation failure
mantas Sep 10, 2026
2cd4509
fix!: stop handing out the mutable attribute hash
mantas Sep 10, 2026
65a780d
feat: open the Faraday stack with adapter and middleware
mantas Sep 10, 2026
8ed7ac4
feat: assign and save a hash of attributes in one call
mantas Sep 10, 2026
7e3e3f0
feat: add find_by, exists? and pluck
mantas Sep 10, 2026
1c282a6
feat: add Client.from_env, #me and #version
mantas Sep 10, 2026
d0c2d12
feat: reach related records through record.related
mantas Sep 10, 2026
cfc9ffd
feat: enumerate a resource proxy without naming all
mantas Sep 10, 2026
ce08783
feat: ship a test kit for code that calls this client
mantas Sep 10, 2026
755a285
test: assert the raising save in the integration specs
mantas Sep 10, 2026
91e60a6
docs: record this round's changes in the upgrade path
mantas Sep 10, 2026
359574a
feat: compare records by the Zammad record they came from
mantas Sep 10, 2026
a389969
feat: serialize a record as its attributes
mantas Sep 10, 2026
db45a04
feat: ask a collection for its size and whether it is empty
mantas Sep 10, 2026
205a941
docs: document how records compare and serialize
mantas Sep 10, 2026
e1cf29d
docs: document the collection size and empty? readers
mantas Sep 10, 2026
70db1f0
feat!: size the call that reads instead of chaining per
mantas Sep 10, 2026
63e00eb
docs: rewrite the examples around the client's defaults
mantas Sep 11, 2026
bd0a532
docs: ask for feedback on 2.0 while it can still change
mantas Sep 11, 2026
33400fb
fix: reject a logger that cannot log
mantas Sep 11, 2026
b66f5ea
docs: sort the migration guide by what it costs to miss
mantas Sep 11, 2026
d7eabdb
fix: stop reporting a working paginator as stuck
mantas Sep 11, 2026
bdc4ce5
fix: raise on a nil query value instead of dropping it
mantas Sep 11, 2026
f30116b
fix: record test-kit queries the way the transport sends them
mantas Sep 11, 2026
c3a84a4
refactor: replace a record's state in one place
mantas Sep 11, 2026
de960a4
fix: clear the previous error before a save attempt
mantas Sep 11, 2026
a22a373
feat: tell a destroyed record apart from a live one
mantas Sep 11, 2026
5fbd704
fix: stop claiming a writer that read-only records refuse
mantas Sep 11, 2026
c635e9f
refactor: record whether a response was JSON
mantas Sep 11, 2026
dbb9136
perf: build the test kit's client once
mantas Sep 11, 2026
fc6fddf
perf: download the smoke attachment once
mantas Sep 11, 2026
ca4a7e3
fix: escape a record id instead of pasting it into the path
mantas Sep 14, 2026
e34f300
fix: redact credentials carried in the instance URL
mantas Sep 14, 2026
0e574e5
fix: send the From header as a string
mantas Sep 14, 2026
5b0467b
fix: keep url credentials out of transport error messages
mantas Sep 14, 2026
8ed8a1e
fix: map bare socket errors to the gem's transport errors
mantas Sep 14, 2026
cd0e245
fix: keep a derived client on the transport it was built with
mantas Sep 14, 2026
3574567
fix: drop the association memo when its foreign key changes
mantas Sep 14, 2026
7ebb30b
fix: check for a repeated page before yielding it
mantas Sep 14, 2026
1836a1c
fix: keep a query-scoped stub answering after the first request
mantas Sep 14, 2026
937f7c0
fix: reject a search term passed to where
mantas Sep 14, 2026
d0a58a7
fix!: reject filters an index endpoint cannot honour
mantas Sep 14, 2026
203e644
fix: escape every record id that reaches a path
mantas Sep 14, 2026
a4a0a6a
fix: redact a credential that carries an unencoded @
mantas Sep 14, 2026
0b6367e
fix: keep a derived transport on the class it was built from
mantas Sep 14, 2026
6c849fd
fix: report the status a locally raised error is the name for
mantas Sep 14, 2026
1a793f6
fix: send nothing when a save has nothing to send
mantas Sep 14, 2026
998e13c
fix!: raise an unstubbed request outside the gem's error hierarchy
mantas Sep 14, 2026
7a9ea39
fix: answer an array-valued query stub
mantas Sep 14, 2026
f3b1045
perf: bound the search find_by scans
mantas Sep 14, 2026
1125896
perf: hold one page at a time while walking
mantas Sep 14, 2026
6098714
fix: walk to the end of an endpoint that pages smaller than the cap
mantas Sep 14, 2026
c9be490
fix!: refuse to re-size a collection already limited to a page
mantas Sep 14, 2026
80da816
test: say what is missing when a lifecycle spec runs alone
mantas Sep 14, 2026
1dbcb5e
docs: record this round's fixes
mantas Sep 14, 2026
1c6d40e
fix!: refuse a record id that is a dot segment
mantas Sep 14, 2026
809f826
fix!: refuse a page larger than the endpoint serves
mantas Sep 14, 2026
b83393b
fix: search find_by on the values Zammad can actually match
mantas Sep 14, 2026
18b660c
fix: refuse a search Zammad routes no endpoint for
mantas Sep 14, 2026
89e8ee6
fix: send a nested query parameter as a structure
mantas Sep 14, 2026
2c8d24c
fix: keep Faraday out of the signatures the gem publishes
mantas Sep 14, 2026
df85ef8
fix: retry the socket failures it says it retries
mantas Sep 14, 2026
f37ac76
fix: read a string key in where as the parameter it names
mantas Sep 14, 2026
cfe48da
fix: raise ParseError for a list that is not made of records
mantas Sep 14, 2026
b651449
fix: drop the state a destroyed record can no longer act on
mantas Sep 14, 2026
bde5122
fix: redact only an @ that actually marks a credential
mantas Sep 14, 2026
5911a8e
refactor: hold the member-path rule in one place
mantas Sep 14, 2026
c41df79
perf: build the test kit's client without a Faraday stack
mantas Sep 14, 2026
264af9f
test: register the integration metadata in one block
mantas Sep 14, 2026
a351a42
docs: record this round's fixes
mantas Sep 14, 2026
2053ba2
refactor: derive the me path from the user resource
mantas Sep 15, 2026
c8bdbce
refactor: drop the transport seam nothing uses
mantas Sep 15, 2026
0f71247
fix: tighten what Config validates and what it redacts
mantas Sep 15, 2026
7d99d94
fix: report every failed connection build as a config error
mantas Sep 15, 2026
2eb07d4
fix: redact the credential key spellings the log let through
mantas Sep 15, 2026
8d2bd28
fix: keep a destroyed record destroyed
mantas Sep 15, 2026
e4f46fd
fix: refuse a fetch arity that Hash#fetch refuses
mantas Sep 15, 2026
26a0c57
fix: quote a find_by value that carries search syntax
mantas Sep 15, 2026
aabe7b3
perf: stop a walk on the total the endpoint reports
mantas Sep 15, 2026
54521f3
fix: record a request body by value in the test kit
mantas Sep 15, 2026
82f864a
fix: read the test kit's request count under its monitor
mantas Sep 15, 2026
9ec5ff9
docs: record this review round's fixes
mantas Sep 15, 2026
ae1d56f
fix: stop a walk only on a total the page corroborates
mantas Sep 15, 2026
3075df0
fix: mark a record created before decoding its body
mantas Sep 15, 2026
1782c23
fix: declare what a resource's endpoint does, and inherit it
mantas Sep 15, 2026
b312ce5
fix: check the options that escaped the config contract
mantas Sep 15, 2026
fa50d70
fix: keep a TLS error and a proxy password in contract
mantas Sep 15, 2026
e2638ec
refactor: hold the deep-copy walk in one place
mantas Sep 15, 2026
8bcbd93
fix: page and scope the test kit like a real endpoint
mantas Sep 15, 2026
7f29d1a
perf: hand out one resource proxy per resource
mantas Sep 15, 2026
3d36ea8
docs: record this review round's fixes
mantas Sep 15, 2026
f0e0c18
fix: refuse a record the create response left without an id
mantas Sep 15, 2026
ac9516a
fix: send a write of an attribute the record does not carry
mantas Sep 15, 2026
a25feb4
fix: refuse an update on a destroyed record before it stages
mantas Sep 15, 2026
03ea768
fix: refuse an article on a ticket that has no id
mantas Sep 15, 2026
09ce522
fix: refuse a has_many list the endpoint served part of
mantas Sep 15, 2026
423d9a5
fix: keep a client immutable once it is built
mantas Sep 15, 2026
5d23543
test: report an unreachable Zammad as a setup failure
mantas Sep 15, 2026
53cca0d
refactor: drop a header spelling a response cannot carry
mantas Sep 15, 2026
e23c647
refactor: forward the collection shorthands by name
mantas Sep 15, 2026
21a882a
docs: size the paging examples the way the endpoints do
mantas Sep 15, 2026
590a1ba
docs: record this review round's fixes
mantas Sep 15, 2026
b1195a5
fix: search a find_by value the way both backends read it
mantas Sep 15, 2026
62d45aa
fix: search find_by on one value, not all of them joined
mantas Sep 15, 2026
2047154
fix: corroborate a total against the page an endpoint serves
mantas Sep 15, 2026
bccb626
fix: refuse a destroyed record's readers instead of dropping a memo
mantas Sep 15, 2026
4edeb4c
docs: say why the deep-copy walk is one walk
mantas Sep 15, 2026
703e4df
fix: give each stubbed response its own frozen headers
mantas Sep 15, 2026
7b9fb52
fix: redact a config value the error quoted rather than interpolated
mantas Sep 15, 2026
0d7dce9
ci: check the published signatures before publishing the gem
mantas Sep 15, 2026
61d30b4
fix: refuse a Numeric that cannot be compared
mantas Sep 15, 2026
c71cf38
fix: say when a block supersedes the fetch default
mantas Sep 15, 2026
8702d57
refactor: drop the string-key branches no decode path reaches
mantas Sep 15, 2026
1b75eb1
fix: populate the class-level memos before a caller can race for them
mantas Sep 15, 2026
fa258aa
fix: close what five rounds of review turned up
mantas Sep 16, 2026
3178b67
fix: close what three more rounds of review turned up
mantas Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ updates:
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "weekly"
groups:
# Lint and type tooling churns often and never affects the shipped gem.
development:
dependency-type: "development"
patterns: ["*"]
runtime:
dependency-type: "production"
patterns: ["*"]
171 changes: 150 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,93 @@
name: CI

on:
push:
branches: [master]
pull_request:
schedule:
# Run every on Friday to ensure everything works as expected.
- cron: '0 6 * * 5'
# Weekly, to catch breakage from new Ruby or Zammad releases.
- cron: '0 6 * * 5'
workflow_dispatch:
inputs:
zammad_ref:
description: 'Zammad git ref to run the integration specs against'
default: 'develop'
type: string

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
lint:
name: RuboCop
runs-on: ubuntu-latest
container:
image: zammad/zammad-ci:latest
steps:
- uses: actions/checkout@v7
- name: Run lint actions
shell: bash
run: |
source /etc/profile.d/rvm.sh # ensure RVM is loaded
bundle update --bundler
bundle install -j $(nproc)
bundle exec rubocop
test:
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
- run: bundle exec rubocop --format github

types:
name: Steep
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
- run: bundle exec steep check
# sig/vendor is always on the load path here but never in the gem, so
# the published set is checked on its own as well.
- run: bundle exec rake rbs_published

unit:
name: Unit specs (Ruby ${{ matrix.ruby }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Stable releases only, from required_ruby_version up to the current
# stable line. There is no 3.5: that line was abandoned after
# 3.5.0-preview1 and became 4.0.
#
# 'head' is absent because it cannot install at all, for two reasons
# outside this gem. With Gemfile.lock present, bundler honours
# `BUNDLED WITH 2.6.9`, self-downgrades from head's 4.1.0.dev and dies
# with NameError on the removed Pathname::SEPARATOR_PAT. Without the
# lockfile, a fresh resolution pulls steep -> listen -> rb-inotify ->
# ffi, which requires Ruby < 4.1.dev.
ruby: ['3.4', '4.0']
env:
# The unit specs do not need the type-checking toolchain, and skipping it
# keeps this job fast. The `types` job installs it separately.
BUNDLE_WITHOUT: development
steps:
- uses: actions/checkout@v7
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Run unit specs
run: bundle exec rake spec:unit
env:
COVERAGE: 'true'

integration:
name: Integration specs (live Zammad)
runs-on: ubuntu-latest
# Booting Zammad costs far more runner time than the unit suite, so there
# is no point paying for it when the unit specs are already failing.
needs: unit
# A full Zammad boot takes many minutes; without a cap a hung boot would
# occupy a runner until the six hour default expires.
timeout-minutes: 45
container:
image: zammad/zammad-ci:latest
services:
Expand All @@ -32,26 +98,89 @@ jobs:
POSTGRES_PASSWORD: zammad
redis:
image: redis:7
env:
ZAMMAD_REF: ${{ inputs.zammad_ref || 'develop' }}
TEST_USER: admin@example.com
TEST_PASSWORD: test
steps:
- uses: actions/checkout@v7
- name: Set up Zammad

- name: Report the toolchain
shell: bash
run: |
git clone --depth 1 https://github.com/zammad/zammad.git
source /etc/profile.d/rvm.sh
# The gem requires Ruby >= 3.4; fail here with a clear message rather
# than inside a confusing bundler resolution error.
ruby -v
ruby -e 'abort "zammad-ci image ships Ruby #{RUBY_VERSION}, this gem needs >= 3.4" if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")'

- name: Boot Zammad
shell: bash
run: |
# No `set -u`: /etc/profile.d/rvm.sh reads unset variables and aborts
# under nounset.
set -eo pipefail
git clone --depth 1 --branch "$ZAMMAD_REF" https://github.com/zammad/zammad.git
cd zammad
source /etc/profile.d/rvm.sh # ensure RVM is loaded
source /etc/profile.d/rvm.sh
bundle config set --local frozen 'true'
bundle config set --local path 'vendor'
bundle install -j $(nproc)
bundle install -j "$(nproc)"
bundle exec ruby .gitlab/configure_environment.rb
# Each workflow step runs in its own shell, so Zammad's generated
# environment has to be promoted to the job environment to survive.
sed -E 's/^export +//' .gitlab/environment.env \
| grep -E '^[A-Za-z_][A-Za-z0-9_]*=' >> "$GITHUB_ENV"
source .gitlab/environment.env
RAILS_ENV=test bundle exec rake db:create
cp contrib/auto_wizard_test.json auto_wizard.json
bundle exec rake zammad:ci:test:start
- name: Run Ruby API integration tests
echo "TEST_URL=http://localhost:${RAILS_PORT:-3000}/" >> "$GITHUB_ENV"

- name: Wait for Zammad to answer
shell: bash
run: |
probe="${TEST_URL%/}/api/v1/getting_started"
for attempt in $(seq 1 60); do
if curl -sSf --max-time 5 "$probe" >/dev/null 2>&1; then
echo "Zammad answered at $TEST_URL after ${attempt} attempt(s)"
exit 0
fi
sleep 5
done
echo "::error::Zammad never answered at $probe"
exit 1

- name: Install the gem's dependencies
shell: bash
run: |
source /etc/profile.d/rvm.sh
bundle install -j "$(nproc)"

- name: Drive Zammad with this gem
shell: bash
run: |
source /etc/profile.d/rvm.sh
bundle exec ruby script/check_connection.rb

- name: Run the integration specs
shell: bash
run: |
source /etc/profile.d/rvm.sh
bundle exec rake spec:integration

- name: Collect Zammad logs on failure
if: failure()
shell: bash
run: |
source /etc/profile.d/rvm.sh # ensure RVM is loaded
bundle update --bundler
bundle install -j $(nproc)
bundle exec rspec
echo '--- zammad/log ---'
tail -n 200 zammad/log/*.log 2>/dev/null || echo 'no logs found'

- name: Upload Zammad logs
if: failure()
uses: actions/upload-artifact@v5
with:
name: zammad-logs
path: zammad/log/
if-no-files-found: ignore
retention-days: 7
45 changes: 45 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Release

# Publishes to RubyGems via trusted publishing (OIDC), so no API key is
# stored in this repository. Configure the trusted publisher once at
# https://rubygems.org/gems/zammad_api/trusted_publishers
on:
push:
tags: ['v*']

permissions:
contents: read

jobs:
release:
name: Build and publish
runs-on: ubuntu-latest
environment: rubygems
permissions:
contents: write # create the GitHub release and push the tag commit
id-token: write # request the OIDC token for trusted publishing
steps:
- uses: actions/checkout@v7
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
# The tag is what triggers this workflow, and nothing downstream reads
# it: the gem is built from the gemspec, so a tag that disagrees with
# ZammadAPI::VERSION publishes a gem under a version nobody tagged and
# leaves a GitHub release pointing at one that does not exist.
- name: Check the tag against the gem version and the changelog
run: |
tagged="${GITHUB_REF_NAME#v}"
declared="$(ruby -Ilib -rzammad_api/version -e 'print ZammadAPI::VERSION')"
if [ "$tagged" != "$declared" ]; then
echo "::error::tag ${GITHUB_REF_NAME} does not match ZammadAPI::VERSION ($declared)"
exit 1
fi
if ! grep -q "^## \[${declared}\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no '## [${declared}]' heading"
exit 1
fi
- name: Verify the release candidate
run: bundle exec rake spec:unit rubocop steep rbs_published
- uses: rubygems/release-gem@v1
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/.bundle/
/.ruby-version
/.yardoc
/.steep/
/.yardoc/
/_yardoc/
/coverage/
/doc/
Expand Down
50 changes: 20 additions & 30 deletions .overcommit.yml
Original file line number Diff line number Diff line change
@@ -1,33 +1,23 @@
# Use this file to configure the Overcommit hooks you wish to use. This will
# extend the default configuration defined in:
# https://github.com/sds/overcommit/blob/master/config/default.yml
# See https://github.com/sds/overcommit#configuration
#
# At the topmost level of this YAML file is a key representing type of hook
# being run (e.g. pre-commit, commit-msg, etc.). Within each type you can
# customize each hook, such as whether to only run it on certain files (via
# `include`), whether to only display output if it fails (via `quiet`), etc.
#
# For a complete list of hooks, see:
# https://github.com/sds/overcommit/tree/master/lib/overcommit/hook
#
# For a complete list of options that you can use to customize hooks, see:
# https://github.com/sds/overcommit#configuration
#
# Uncomment the following lines to make the configuration take effect.
# Install with: bundle exec overcommit --install

PreCommit:
RuboCop:
enabled: true
on_warn: fail # Treat all warnings as failures
#
# TrailingWhitespace:
# enabled: true
# exclude:
# - '**/db/structure.sql' # Ignore trailing whitespace in generated files
#
#PostCheckout:
# ALL: # Special hook name that customizes all hooks of this type
# quiet: true # Change all post-checkout hooks to only display output on failure
#
# IndexTags:
# enabled: true # Generate a tags file with `ctags` each time HEAD changes
RuboCop:
enabled: true
on_warn: fail # Treat all warnings as failures
command: ['bundle', 'exec', 'rubocop']

TrailingWhitespace:
enabled: true

YamlSyntax:
enabled: true

BundleCheck:
enabled: true

RSpec:
enabled: true
description: 'Run the unit specs'
command: ['bundle', 'exec', 'rspec', 'spec/unit']
1 change: 1 addition & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
--require spec_helper
--format documentation
--color
Loading
Loading