stint 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. stint-0.1.0/LICENSE +21 -0
  2. stint-0.1.0/PKG-INFO +328 -0
  3. stint-0.1.0/README.md +296 -0
  4. stint-0.1.0/pyproject.toml +78 -0
  5. stint-0.1.0/setup.cfg +4 -0
  6. stint-0.1.0/stint/__init__.py +110 -0
  7. stint-0.1.0/stint/autogen/__init__.py +25 -0
  8. stint-0.1.0/stint/autogen/desired.py +351 -0
  9. stint-0.1.0/stint/autogen/diff.py +1001 -0
  10. stint-0.1.0/stint/autogen/emit.py +578 -0
  11. stint-0.1.0/stint/autogen/loader.py +55 -0
  12. stint-0.1.0/stint/autogen/stamp.py +299 -0
  13. stint-0.1.0/stint/cli/__init__.py +5 -0
  14. stint-0.1.0/stint/cli/app.py +39 -0
  15. stint-0.1.0/stint/cli/cmd_reflect.py +123 -0
  16. stint-0.1.0/stint/cli/cmd_revision.py +272 -0
  17. stint-0.1.0/stint/cli/cmd_stamp.py +89 -0
  18. stint-0.1.0/stint/cli/cmd_upgrade.py +165 -0
  19. stint-0.1.0/stint/cli/cmd_validate.py +48 -0
  20. stint-0.1.0/stint/cli/env_config.py +164 -0
  21. stint-0.1.0/stint/cli/main.py +28 -0
  22. stint-0.1.0/stint/client/__init__.py +6 -0
  23. stint-0.1.0/stint/client/auth.py +67 -0
  24. stint-0.1.0/stint/client/http.py +131 -0
  25. stint-0.1.0/stint/dialects/__init__.py +8 -0
  26. stint-0.1.0/stint/dialects/base.py +142 -0
  27. stint-0.1.0/stint/dialects/jira/__init__.py +5 -0
  28. stint-0.1.0/stint/dialects/jira/_base.py +985 -0
  29. stint-0.1.0/stint/dialects/jira/cloud.py +180 -0
  30. stint-0.1.0/stint/dialects/jira/common.py +247 -0
  31. stint-0.1.0/stint/engine.py +89 -0
  32. stint-0.1.0/stint/exceptions.py +90 -0
  33. stint-0.1.0/stint/fields.py +83 -0
  34. stint-0.1.0/stint/migrations/__init__.py +40 -0
  35. stint-0.1.0/stint/migrations/base.py +148 -0
  36. stint-0.1.0/stint/migrations/context.py +52 -0
  37. stint-0.1.0/stint/migrations/exceptions.py +24 -0
  38. stint-0.1.0/stint/migrations/loader.py +62 -0
  39. stint-0.1.0/stint/migrations/op.py +898 -0
  40. stint-0.1.0/stint/migrations/runner.py +170 -0
  41. stint-0.1.0/stint/py.typed +0 -0
  42. stint-0.1.0/stint/query/__init__.py +27 -0
  43. stint-0.1.0/stint/query/adf.py +28 -0
  44. stint-0.1.0/stint/query/columns.py +47 -0
  45. stint-0.1.0/stint/query/expr.py +198 -0
  46. stint-0.1.0/stint/query/hydrate.py +145 -0
  47. stint-0.1.0/stint/query/payload.py +206 -0
  48. stint-0.1.0/stint/query/select.py +63 -0
  49. stint-0.1.0/stint/query/session.py +284 -0
  50. stint-0.1.0/stint/query/sync.py +102 -0
  51. stint-0.1.0/stint/registry.py +81 -0
  52. stint-0.1.0/stint/schema/__init__.py +18 -0
  53. stint-0.1.0/stint/schema/_meta.py +49 -0
  54. stint-0.1.0/stint/schema/field_config.py +29 -0
  55. stint-0.1.0/stint/schema/issuetype.py +193 -0
  56. stint-0.1.0/stint/schema/project.py +97 -0
  57. stint-0.1.0/stint/schema/screen.py +53 -0
  58. stint-0.1.0/stint/state/__init__.py +6 -0
  59. stint-0.1.0/stint/state/file.py +187 -0
  60. stint-0.1.0/stint/state/lock.py +82 -0
  61. stint-0.1.0/stint/state/snapshot.py +168 -0
  62. stint-0.1.0/stint/validate.py +73 -0
  63. stint-0.1.0/stint.egg-info/PKG-INFO +328 -0
  64. stint-0.1.0/stint.egg-info/SOURCES.txt +88 -0
  65. stint-0.1.0/stint.egg-info/dependency_links.txt +1 -0
  66. stint-0.1.0/stint.egg-info/entry_points.txt +2 -0
  67. stint-0.1.0/stint.egg-info/requires.txt +9 -0
  68. stint-0.1.0/stint.egg-info/top_level.txt +1 -0
  69. stint-0.1.0/tests/test_m0_spike.py +427 -0
  70. stint-0.1.0/tests/test_m1_cli.py +193 -0
  71. stint-0.1.0/tests/test_m1_cloud.py +244 -0
  72. stint-0.1.0/tests/test_m1_dialect.py +553 -0
  73. stint-0.1.0/tests/test_m1_engine.py +78 -0
  74. stint-0.1.0/tests/test_m1_state.py +122 -0
  75. stint-0.1.0/tests/test_m2_downgrade.py +239 -0
  76. stint-0.1.0/tests/test_m2_migrations.py +276 -0
  77. stint-0.1.0/tests/test_m2_op_surface.py +937 -0
  78. stint-0.1.0/tests/test_m2_polish.py +528 -0
  79. stint-0.1.0/tests/test_m3_autogen.py +581 -0
  80. stint-0.1.0/tests/test_m3_autogenerate_cli.py +285 -0
  81. stint-0.1.0/tests/test_m3_multi_parent_graph.py +90 -0
  82. stint-0.1.0/tests/test_m3_revision_cli.py +151 -0
  83. stint-0.1.0/tests/test_m3_stamp.py +357 -0
  84. stint-0.1.0/tests/test_m4_polish.py +832 -0
  85. stint-0.1.0/tests/test_m5_query.py +425 -0
  86. stint-0.1.0/tests/test_m6_writes.py +459 -0
  87. stint-0.1.0/tests/test_m7_tmp.py +517 -0
  88. stint-0.1.0/tests/test_m8_sync_session.py +260 -0
  89. stint-0.1.0/tests/test_m8_validate_cli.py +155 -0
  90. stint-0.1.0/tests/test_schema_loader.py +30 -0
stint-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Curtis Turner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
stint-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,328 @@
1
+ Metadata-Version: 2.4
2
+ Name: stint
3
+ Version: 0.1.0
4
+ Summary: Declarative schema-as-code and ORM for work-management systems. Jira first, dialect protocol for the rest.
5
+ Author: Curtis Turner
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/curtis-turner/stint
8
+ Project-URL: Source, https://github.com/curtis-turner/stint
9
+ Project-URL: Issues, https://github.com/curtis-turner/stint/issues
10
+ Project-URL: Changelog, https://github.com/curtis-turner/stint/blob/main/CHANGELOG.md
11
+ Keywords: jira,orm,migrations,alembic,schema-as-code,atlassian
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Database
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.14
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: pydantic>=2.0
24
+ Requires-Dist: pydantic-settings>=2.0
25
+ Requires-Dist: httpx>=0.25
26
+ Requires-Dist: pyyaml>=6.0
27
+ Requires-Dist: pydantic[email]
28
+ Requires-Dist: cyclopts>=3.0
29
+ Provides-Extra: jira
30
+ Requires-Dist: jira>=3.8; extra == "jira"
31
+ Dynamic: license-file
32
+
33
+ # stint
34
+
35
+ ![stint: Jira admin as code, not clickops](https://raw.githubusercontent.com/curtis-turner/stint/main/assets/readme-hero.png)
36
+
37
+ Declarative schema-as-code and ORM for work-management systems. Jira first.
38
+
39
+ stint is two tools in one, the same way SQLAlchemy + Alembic is.
40
+
41
+ 1. **Schema as code with versioned migrations.** Declare projects,
42
+ issuetypes, custom fields, screens, and schemes as Pydantic classes.
43
+ Autogenerate a migration from the diff between the declaration and the
44
+ live Jira instance. Commit the migration. Apply it across environments.
45
+ 2. **ORM.** Insert and query issues through the same model classes.
46
+ `session.add(Bug(...))`, `session.scalars(select(Bug).where(...))`.
47
+
48
+ ## The problem, in one screen
49
+
50
+ Changing a Jira project today means clicking through the admin web UI. No
51
+ diff. No review. No record of who changed what or why. Staging and
52
+ production drift apart and nobody can reconstruct how.
53
+
54
+ With stint the same change is a commit your teammate can review:
55
+
56
+ ```python
57
+ # migrations/2026_06_25_1500_add_bug_severity.py
58
+ async def upgrade():
59
+ await op.create_custom_field(
60
+ alias="bug_severity",
61
+ name="Severity",
62
+ type=SelectField,
63
+ options=["S1", "S2", "S3", "S4"],
64
+ )
65
+ ```
66
+
67
+ CI applies that file to staging, then production, from the same source. A
68
+ bad change rolls back to a known revision. The schema lives in your repo
69
+ next to the code that depends on it.
70
+
71
+ Status: `0.1.0`, the first release. Validated against a live Jira Cloud
72
+ tenant. The API may still change within `0.x`.
73
+
74
+ **Primary target: Jira Cloud**, covering both company-managed and
75
+ team-managed projects, validated against a real Cloud tenant for `0.1.0`.
76
+
77
+ **Jira Data Center is out of scope, not on the roadmap.** An audit against
78
+ Atlassian's official OpenAPI specs found that ~17 of the admin endpoints
79
+ stint drives exist only on Cloud; DC keeps those objects web-admin-only and
80
+ never added REST. A DC dialect would fail on a large part of the op surface,
81
+ so stint does not target it. Future growth goes to other work-management
82
+ backends, like Linear, through the dialect protocol.
83
+
84
+ ## Install
85
+
86
+ ```bash
87
+ pip install stint
88
+ ```
89
+
90
+ Python 3.14 or newer. stint relies on [PEP 649](https://peps.python.org/pep-0649/)
91
+ deferred annotation evaluation, the default from 3.14, so the schema metaclass
92
+ can inspect `Annotated` field metadata (and catch mistakes like a CustomField
93
+ that shadows its attribute name) without eagerly resolving every type. Runtime
94
+ deps are `pydantic` (with the `email` extra), `pydantic-settings`, `httpx`,
95
+ `pyyaml`, and `cyclopts` for the CLI. `cyclopts` pulls in `rich` for terminal
96
+ output.
97
+
98
+ ## Why
99
+
100
+ Jira admin lives in a web UI. That makes it hard to review changes, hard
101
+ to mirror across environments, and impossible to roll back cleanly.
102
+ stint treats Jira as a database where the schema happens to be
103
+ unusually heavy. The schema is Python you commit. Migrations are Python
104
+ you commit. Two environments get the same shape by running the same
105
+ migration chain.
106
+
107
+ The data plane is a separate concern. Once the schema is under
108
+ management, the same model classes are the ORM. `Bug.severity == "S1"`
109
+ compiles to `cf[10042] = "S1"` (or whatever the per-env id is, resolved
110
+ through the state file).
111
+
112
+ ## A small example
113
+
114
+ Define the schema as Pydantic classes:
115
+
116
+ ```python
117
+ # schemas/platform.py
118
+ from typing import Annotated, Literal
119
+
120
+ from stint import (
121
+ CustomField, IssueType, Project, Screen, ScreenScheme,
122
+ FieldConfiguration, SelectField, TextField,
123
+ )
124
+
125
+ severity_field = CustomField(
126
+ alias="bug_severity",
127
+ name="Severity",
128
+ type=SelectField,
129
+ options=["S1", "S2", "S3", "S4"],
130
+ )
131
+
132
+ bug_create = Screen(alias="bug_create", name="Bug Create",
133
+ fields=["Summary", "Reporter", severity_field])
134
+ bug_edit = Screen(alias="bug_edit", name="Bug Edit",
135
+ fields=["Summary", "Reporter", severity_field])
136
+ bug_view = Screen(alias="bug_view", name="Bug View",
137
+ fields=["Summary", "Reporter", severity_field])
138
+
139
+ bug_screens = ScreenScheme(
140
+ alias="bug_screens", name="Bug Screen Scheme",
141
+ create=bug_create, edit=bug_edit, view=bug_view,
142
+ )
143
+ bug_fields = FieldConfiguration(
144
+ alias="bug_fields", name="Bug Field Config", required=["Summary"],
145
+ )
146
+
147
+ class Bug(IssueType):
148
+ __alias__ = "bug"
149
+ __screen_scheme__ = bug_screens
150
+ __field_configuration__ = bug_fields
151
+
152
+ summary: str
153
+ reporter: str
154
+ severity: Annotated[Literal["S1", "S2", "S3", "S4"], severity_field]
155
+
156
+ class Platform(Project):
157
+ __key__ = "PLAT"
158
+ __lead__ = "cturner@example.com"
159
+ __style__ = "company-managed"
160
+ __issuetypes__ = [Bug]
161
+ ```
162
+
163
+ Validate it without any network call:
164
+
165
+ ```bash
166
+ stint validate --schema schemas/platform.py
167
+ ```
168
+
169
+ Generate a migration from the diff between your schema and the live env:
170
+
171
+ ```bash
172
+ export STINT_USER=you@example.com # Jira Cloud account email
173
+ export STINT_TOKEN=... # Jira Cloud API token
174
+ stint revision --autogenerate \
175
+ --schema schemas.platform \
176
+ --state state/dev.yaml \
177
+ --migrations-dir migrations/ \
178
+ --url jira_cloud+https://you.atlassian.net \
179
+ --auth api-token \
180
+ --env dev \
181
+ -m "add bug severity"
182
+ ```
183
+
184
+ Review the emitted Python file. It looks roughly like:
185
+
186
+ ```python
187
+ # migrations/2026_05_25_1430_add_bug_severity.py
188
+ from stint import op
189
+ from stint.fields import SelectField
190
+
191
+ revision = "abc123def456"
192
+ down_revision = None
193
+
194
+ async def upgrade():
195
+ await op.create_custom_field(
196
+ alias="bug_severity",
197
+ name="Severity",
198
+ type=SelectField,
199
+ options=["S1", "S2", "S3", "S4"],
200
+ )
201
+
202
+ async def downgrade():
203
+ op.unsupported("deleting bug_severity destroys severity data on existing issues")
204
+ ```
205
+
206
+ Apply it:
207
+
208
+ ```bash
209
+ stint upgrade --env dev
210
+ ```
211
+
212
+ Then query and write issues through the same classes:
213
+
214
+ ```python
215
+ from stint import Session, StateFile, APITokenAuth, create_engine, select
216
+ from schemas.platform import Bug
217
+
218
+ state = StateFile.load("state/dev.yaml")
219
+ engine = create_engine(
220
+ "jira_cloud+https://you.atlassian.net",
221
+ auth=APITokenAuth(email="you@example.com", token=token),
222
+ )
223
+
224
+ with Session(engine, state) as session:
225
+ # READ
226
+ s1s = session.scalars(select(Bug).where(Bug.c.severity == "S1"))
227
+
228
+ # WRITE
229
+ bug = Bug(summary="boom", reporter="alice", severity="S2")
230
+ session.add(bug)
231
+ session.commit()
232
+ print(bug.key) # populated from the response
233
+
234
+ # UPDATE
235
+ existing = session.get(Bug, "PLAT-1234")
236
+ existing.severity = "S1"
237
+ session.commit() # only the changed field is sent
238
+ ```
239
+
240
+ The async version is `AsyncSession(engine, state)` with the same surface
241
+ plus `await` on the I/O methods.
242
+
243
+ ## What ships in 0.1
244
+
245
+ - **Jira Cloud (only target in 0.1)**: company-managed and team-managed
246
+ projects, validated against a live Cloud tenant.
247
+ - Schema plane: custom fields, screens, screen schemes, issue type
248
+ screen schemes, field configurations, field configuration schemes,
249
+ issue types, projects.
250
+ - Migrations: autogenerate, multi-head merge, upgrade, downgrade,
251
+ brownfield stamp, idempotent ops, advisory lock, `429`/`503` retry.
252
+ - Data plane: reads and writes, identity map, dirty tracking, ADF
253
+ wrapping for Cloud descriptions.
254
+ - Cloud team-managed projects: project create with style tracking, CMP-only
255
+ ops fail loud with a Jira UI deep link. Per-issuetype inline screen field
256
+ lists deferred until Atlassian exposes them through REST.
257
+
258
+ ## What does not ship in 0.1
259
+
260
+ - Workflows and workflow schemes.
261
+ - Permission and notification schemes.
262
+ - ADF parsing on reads (writes wrap plain text correctly).
263
+ - Backends other than Jira. The dialect protocol is the extension point.
264
+
265
+ ## CLI
266
+
267
+ ```
268
+ stint reflect Reflect a Jira instance into a snapshot and print it.
269
+ stint revision Create a migration (empty, --merge, or --autogenerate).
270
+ stint stamp Brownfield: match aliases against existing Jira, populate state.
271
+ stint upgrade Apply pending migrations to head (or --to <rev>).
272
+ stint downgrade Roll back to a specific revision.
273
+ stint current Show the env's current revision.
274
+ stint history List migrations in revision order.
275
+ stint validate Run schema-level checks on a Python schema module.
276
+ ```
277
+
278
+ ## State files
279
+
280
+ Each env has a state file (`state/<env>.yaml`) mapping schema aliases to Jira
281
+ object IDs, plus the current migration revision. **Commit it.** It is the
282
+ shared source of truth for a tenant, and it is not sensitive: it holds object
283
+ IDs, the tenant URL, and a revision pointer — no credentials, tokens, issue
284
+ data, or PII. Secrets stay in environment variables (`STINT_TOKEN` /
285
+ `STINT_USER`); they never touch the state file. This is unlike Terraform state,
286
+ which can embed secrets and warrants an encrypted backend.
287
+
288
+ - **Rebuildable.** Lose or corrupt a state file and `stint stamp` reflects the
289
+ tenant and reconstructs the ID mapping by name/key. Only the revision pointer
290
+ is not auto-derived; set it with `stamp --revision <rev>`.
291
+ - **The lock is local.** `stint upgrade` takes an advisory `<state>.lock` on
292
+ the local filesystem. It serializes applies on one machine, not across
293
+ teammates or CI on different machines.
294
+ - **Coordinate writers.** Two people running `upgrade` against the same tenant
295
+ at once can double-apply ops and diverge the state file. The simplest safe
296
+ pattern is to **apply from CI on `main`**, so applies serialize and the
297
+ committed state stays canonical. Cross-machine locking is tracked in #12.
298
+
299
+ ## Caveats
300
+
301
+ - **No transactions across or within migrations.** Jira admin REST does
302
+ not provide them. A failed migration leaves Jira in a partial state.
303
+ Re-running picks up where it left off because op functions check the
304
+ alias-to-id map first.
305
+ - **Partial commits in the data plane raise `PartialCommitError`.** Some
306
+ inserts can succeed and later ones fail; the exception carries the
307
+ per-instance breakdown so callers can decide what to do.
308
+ - **Drift is not auto-reverted.** UI edits land in the live instance and
309
+ stay there until you reflect, diff, and absorb them into the schema.
310
+ - **Project lead resolution needs user-search access.** `__lead__` takes an
311
+ email, which stint resolves to a backend user id (DC username, Cloud
312
+ accountId) via the user-search API at apply time. That call requires the
313
+ "Browse users and groups" global permission. Without it the create/update
314
+ fails with guidance; set `__lead__` to an already-resolved username or
315
+ accountId to skip resolution.
316
+ - **Built-in issue types are adopted, not recreated.** Every Jira tenant ships
317
+ Bug, Task, Story, Epic, and Subtask. A schema declaring one of those names
318
+ (the quickstart's `Bug` does) adopts the existing type into state on
319
+ `upgrade` rather than failing on a duplicate-name create. Names are matched
320
+ exactly, so `Bug` and `bug` are distinct.
321
+ - **Autogenerate needs a clean migration head.** `revision --autogenerate`
322
+ diffs against the state file, which only advances on `upgrade`/`stamp`. With
323
+ unapplied migrations pending it refuses rather than restating them; apply
324
+ first, or pass `--force`.
325
+
326
+ ## License
327
+
328
+ MIT. See [LICENSE](LICENSE).
stint-0.1.0/README.md ADDED
@@ -0,0 +1,296 @@
1
+ # stint
2
+
3
+ ![stint: Jira admin as code, not clickops](https://raw.githubusercontent.com/curtis-turner/stint/main/assets/readme-hero.png)
4
+
5
+ Declarative schema-as-code and ORM for work-management systems. Jira first.
6
+
7
+ stint is two tools in one, the same way SQLAlchemy + Alembic is.
8
+
9
+ 1. **Schema as code with versioned migrations.** Declare projects,
10
+ issuetypes, custom fields, screens, and schemes as Pydantic classes.
11
+ Autogenerate a migration from the diff between the declaration and the
12
+ live Jira instance. Commit the migration. Apply it across environments.
13
+ 2. **ORM.** Insert and query issues through the same model classes.
14
+ `session.add(Bug(...))`, `session.scalars(select(Bug).where(...))`.
15
+
16
+ ## The problem, in one screen
17
+
18
+ Changing a Jira project today means clicking through the admin web UI. No
19
+ diff. No review. No record of who changed what or why. Staging and
20
+ production drift apart and nobody can reconstruct how.
21
+
22
+ With stint the same change is a commit your teammate can review:
23
+
24
+ ```python
25
+ # migrations/2026_06_25_1500_add_bug_severity.py
26
+ async def upgrade():
27
+ await op.create_custom_field(
28
+ alias="bug_severity",
29
+ name="Severity",
30
+ type=SelectField,
31
+ options=["S1", "S2", "S3", "S4"],
32
+ )
33
+ ```
34
+
35
+ CI applies that file to staging, then production, from the same source. A
36
+ bad change rolls back to a known revision. The schema lives in your repo
37
+ next to the code that depends on it.
38
+
39
+ Status: `0.1.0`, the first release. Validated against a live Jira Cloud
40
+ tenant. The API may still change within `0.x`.
41
+
42
+ **Primary target: Jira Cloud**, covering both company-managed and
43
+ team-managed projects, validated against a real Cloud tenant for `0.1.0`.
44
+
45
+ **Jira Data Center is out of scope, not on the roadmap.** An audit against
46
+ Atlassian's official OpenAPI specs found that ~17 of the admin endpoints
47
+ stint drives exist only on Cloud; DC keeps those objects web-admin-only and
48
+ never added REST. A DC dialect would fail on a large part of the op surface,
49
+ so stint does not target it. Future growth goes to other work-management
50
+ backends, like Linear, through the dialect protocol.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install stint
56
+ ```
57
+
58
+ Python 3.14 or newer. stint relies on [PEP 649](https://peps.python.org/pep-0649/)
59
+ deferred annotation evaluation, the default from 3.14, so the schema metaclass
60
+ can inspect `Annotated` field metadata (and catch mistakes like a CustomField
61
+ that shadows its attribute name) without eagerly resolving every type. Runtime
62
+ deps are `pydantic` (with the `email` extra), `pydantic-settings`, `httpx`,
63
+ `pyyaml`, and `cyclopts` for the CLI. `cyclopts` pulls in `rich` for terminal
64
+ output.
65
+
66
+ ## Why
67
+
68
+ Jira admin lives in a web UI. That makes it hard to review changes, hard
69
+ to mirror across environments, and impossible to roll back cleanly.
70
+ stint treats Jira as a database where the schema happens to be
71
+ unusually heavy. The schema is Python you commit. Migrations are Python
72
+ you commit. Two environments get the same shape by running the same
73
+ migration chain.
74
+
75
+ The data plane is a separate concern. Once the schema is under
76
+ management, the same model classes are the ORM. `Bug.severity == "S1"`
77
+ compiles to `cf[10042] = "S1"` (or whatever the per-env id is, resolved
78
+ through the state file).
79
+
80
+ ## A small example
81
+
82
+ Define the schema as Pydantic classes:
83
+
84
+ ```python
85
+ # schemas/platform.py
86
+ from typing import Annotated, Literal
87
+
88
+ from stint import (
89
+ CustomField, IssueType, Project, Screen, ScreenScheme,
90
+ FieldConfiguration, SelectField, TextField,
91
+ )
92
+
93
+ severity_field = CustomField(
94
+ alias="bug_severity",
95
+ name="Severity",
96
+ type=SelectField,
97
+ options=["S1", "S2", "S3", "S4"],
98
+ )
99
+
100
+ bug_create = Screen(alias="bug_create", name="Bug Create",
101
+ fields=["Summary", "Reporter", severity_field])
102
+ bug_edit = Screen(alias="bug_edit", name="Bug Edit",
103
+ fields=["Summary", "Reporter", severity_field])
104
+ bug_view = Screen(alias="bug_view", name="Bug View",
105
+ fields=["Summary", "Reporter", severity_field])
106
+
107
+ bug_screens = ScreenScheme(
108
+ alias="bug_screens", name="Bug Screen Scheme",
109
+ create=bug_create, edit=bug_edit, view=bug_view,
110
+ )
111
+ bug_fields = FieldConfiguration(
112
+ alias="bug_fields", name="Bug Field Config", required=["Summary"],
113
+ )
114
+
115
+ class Bug(IssueType):
116
+ __alias__ = "bug"
117
+ __screen_scheme__ = bug_screens
118
+ __field_configuration__ = bug_fields
119
+
120
+ summary: str
121
+ reporter: str
122
+ severity: Annotated[Literal["S1", "S2", "S3", "S4"], severity_field]
123
+
124
+ class Platform(Project):
125
+ __key__ = "PLAT"
126
+ __lead__ = "cturner@example.com"
127
+ __style__ = "company-managed"
128
+ __issuetypes__ = [Bug]
129
+ ```
130
+
131
+ Validate it without any network call:
132
+
133
+ ```bash
134
+ stint validate --schema schemas/platform.py
135
+ ```
136
+
137
+ Generate a migration from the diff between your schema and the live env:
138
+
139
+ ```bash
140
+ export STINT_USER=you@example.com # Jira Cloud account email
141
+ export STINT_TOKEN=... # Jira Cloud API token
142
+ stint revision --autogenerate \
143
+ --schema schemas.platform \
144
+ --state state/dev.yaml \
145
+ --migrations-dir migrations/ \
146
+ --url jira_cloud+https://you.atlassian.net \
147
+ --auth api-token \
148
+ --env dev \
149
+ -m "add bug severity"
150
+ ```
151
+
152
+ Review the emitted Python file. It looks roughly like:
153
+
154
+ ```python
155
+ # migrations/2026_05_25_1430_add_bug_severity.py
156
+ from stint import op
157
+ from stint.fields import SelectField
158
+
159
+ revision = "abc123def456"
160
+ down_revision = None
161
+
162
+ async def upgrade():
163
+ await op.create_custom_field(
164
+ alias="bug_severity",
165
+ name="Severity",
166
+ type=SelectField,
167
+ options=["S1", "S2", "S3", "S4"],
168
+ )
169
+
170
+ async def downgrade():
171
+ op.unsupported("deleting bug_severity destroys severity data on existing issues")
172
+ ```
173
+
174
+ Apply it:
175
+
176
+ ```bash
177
+ stint upgrade --env dev
178
+ ```
179
+
180
+ Then query and write issues through the same classes:
181
+
182
+ ```python
183
+ from stint import Session, StateFile, APITokenAuth, create_engine, select
184
+ from schemas.platform import Bug
185
+
186
+ state = StateFile.load("state/dev.yaml")
187
+ engine = create_engine(
188
+ "jira_cloud+https://you.atlassian.net",
189
+ auth=APITokenAuth(email="you@example.com", token=token),
190
+ )
191
+
192
+ with Session(engine, state) as session:
193
+ # READ
194
+ s1s = session.scalars(select(Bug).where(Bug.c.severity == "S1"))
195
+
196
+ # WRITE
197
+ bug = Bug(summary="boom", reporter="alice", severity="S2")
198
+ session.add(bug)
199
+ session.commit()
200
+ print(bug.key) # populated from the response
201
+
202
+ # UPDATE
203
+ existing = session.get(Bug, "PLAT-1234")
204
+ existing.severity = "S1"
205
+ session.commit() # only the changed field is sent
206
+ ```
207
+
208
+ The async version is `AsyncSession(engine, state)` with the same surface
209
+ plus `await` on the I/O methods.
210
+
211
+ ## What ships in 0.1
212
+
213
+ - **Jira Cloud (only target in 0.1)**: company-managed and team-managed
214
+ projects, validated against a live Cloud tenant.
215
+ - Schema plane: custom fields, screens, screen schemes, issue type
216
+ screen schemes, field configurations, field configuration schemes,
217
+ issue types, projects.
218
+ - Migrations: autogenerate, multi-head merge, upgrade, downgrade,
219
+ brownfield stamp, idempotent ops, advisory lock, `429`/`503` retry.
220
+ - Data plane: reads and writes, identity map, dirty tracking, ADF
221
+ wrapping for Cloud descriptions.
222
+ - Cloud team-managed projects: project create with style tracking, CMP-only
223
+ ops fail loud with a Jira UI deep link. Per-issuetype inline screen field
224
+ lists deferred until Atlassian exposes them through REST.
225
+
226
+ ## What does not ship in 0.1
227
+
228
+ - Workflows and workflow schemes.
229
+ - Permission and notification schemes.
230
+ - ADF parsing on reads (writes wrap plain text correctly).
231
+ - Backends other than Jira. The dialect protocol is the extension point.
232
+
233
+ ## CLI
234
+
235
+ ```
236
+ stint reflect Reflect a Jira instance into a snapshot and print it.
237
+ stint revision Create a migration (empty, --merge, or --autogenerate).
238
+ stint stamp Brownfield: match aliases against existing Jira, populate state.
239
+ stint upgrade Apply pending migrations to head (or --to <rev>).
240
+ stint downgrade Roll back to a specific revision.
241
+ stint current Show the env's current revision.
242
+ stint history List migrations in revision order.
243
+ stint validate Run schema-level checks on a Python schema module.
244
+ ```
245
+
246
+ ## State files
247
+
248
+ Each env has a state file (`state/<env>.yaml`) mapping schema aliases to Jira
249
+ object IDs, plus the current migration revision. **Commit it.** It is the
250
+ shared source of truth for a tenant, and it is not sensitive: it holds object
251
+ IDs, the tenant URL, and a revision pointer — no credentials, tokens, issue
252
+ data, or PII. Secrets stay in environment variables (`STINT_TOKEN` /
253
+ `STINT_USER`); they never touch the state file. This is unlike Terraform state,
254
+ which can embed secrets and warrants an encrypted backend.
255
+
256
+ - **Rebuildable.** Lose or corrupt a state file and `stint stamp` reflects the
257
+ tenant and reconstructs the ID mapping by name/key. Only the revision pointer
258
+ is not auto-derived; set it with `stamp --revision <rev>`.
259
+ - **The lock is local.** `stint upgrade` takes an advisory `<state>.lock` on
260
+ the local filesystem. It serializes applies on one machine, not across
261
+ teammates or CI on different machines.
262
+ - **Coordinate writers.** Two people running `upgrade` against the same tenant
263
+ at once can double-apply ops and diverge the state file. The simplest safe
264
+ pattern is to **apply from CI on `main`**, so applies serialize and the
265
+ committed state stays canonical. Cross-machine locking is tracked in #12.
266
+
267
+ ## Caveats
268
+
269
+ - **No transactions across or within migrations.** Jira admin REST does
270
+ not provide them. A failed migration leaves Jira in a partial state.
271
+ Re-running picks up where it left off because op functions check the
272
+ alias-to-id map first.
273
+ - **Partial commits in the data plane raise `PartialCommitError`.** Some
274
+ inserts can succeed and later ones fail; the exception carries the
275
+ per-instance breakdown so callers can decide what to do.
276
+ - **Drift is not auto-reverted.** UI edits land in the live instance and
277
+ stay there until you reflect, diff, and absorb them into the schema.
278
+ - **Project lead resolution needs user-search access.** `__lead__` takes an
279
+ email, which stint resolves to a backend user id (DC username, Cloud
280
+ accountId) via the user-search API at apply time. That call requires the
281
+ "Browse users and groups" global permission. Without it the create/update
282
+ fails with guidance; set `__lead__` to an already-resolved username or
283
+ accountId to skip resolution.
284
+ - **Built-in issue types are adopted, not recreated.** Every Jira tenant ships
285
+ Bug, Task, Story, Epic, and Subtask. A schema declaring one of those names
286
+ (the quickstart's `Bug` does) adopts the existing type into state on
287
+ `upgrade` rather than failing on a duplicate-name create. Names are matched
288
+ exactly, so `Bug` and `bug` are distinct.
289
+ - **Autogenerate needs a clean migration head.** `revision --autogenerate`
290
+ diffs against the state file, which only advances on `upgrade`/`stamp`. With
291
+ unapplied migrations pending it refuses rather than restating them; apply
292
+ first, or pass `--force`.
293
+
294
+ ## License
295
+
296
+ MIT. See [LICENSE](LICENSE).