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.
- stint-0.1.0/LICENSE +21 -0
- stint-0.1.0/PKG-INFO +328 -0
- stint-0.1.0/README.md +296 -0
- stint-0.1.0/pyproject.toml +78 -0
- stint-0.1.0/setup.cfg +4 -0
- stint-0.1.0/stint/__init__.py +110 -0
- stint-0.1.0/stint/autogen/__init__.py +25 -0
- stint-0.1.0/stint/autogen/desired.py +351 -0
- stint-0.1.0/stint/autogen/diff.py +1001 -0
- stint-0.1.0/stint/autogen/emit.py +578 -0
- stint-0.1.0/stint/autogen/loader.py +55 -0
- stint-0.1.0/stint/autogen/stamp.py +299 -0
- stint-0.1.0/stint/cli/__init__.py +5 -0
- stint-0.1.0/stint/cli/app.py +39 -0
- stint-0.1.0/stint/cli/cmd_reflect.py +123 -0
- stint-0.1.0/stint/cli/cmd_revision.py +272 -0
- stint-0.1.0/stint/cli/cmd_stamp.py +89 -0
- stint-0.1.0/stint/cli/cmd_upgrade.py +165 -0
- stint-0.1.0/stint/cli/cmd_validate.py +48 -0
- stint-0.1.0/stint/cli/env_config.py +164 -0
- stint-0.1.0/stint/cli/main.py +28 -0
- stint-0.1.0/stint/client/__init__.py +6 -0
- stint-0.1.0/stint/client/auth.py +67 -0
- stint-0.1.0/stint/client/http.py +131 -0
- stint-0.1.0/stint/dialects/__init__.py +8 -0
- stint-0.1.0/stint/dialects/base.py +142 -0
- stint-0.1.0/stint/dialects/jira/__init__.py +5 -0
- stint-0.1.0/stint/dialects/jira/_base.py +985 -0
- stint-0.1.0/stint/dialects/jira/cloud.py +180 -0
- stint-0.1.0/stint/dialects/jira/common.py +247 -0
- stint-0.1.0/stint/engine.py +89 -0
- stint-0.1.0/stint/exceptions.py +90 -0
- stint-0.1.0/stint/fields.py +83 -0
- stint-0.1.0/stint/migrations/__init__.py +40 -0
- stint-0.1.0/stint/migrations/base.py +148 -0
- stint-0.1.0/stint/migrations/context.py +52 -0
- stint-0.1.0/stint/migrations/exceptions.py +24 -0
- stint-0.1.0/stint/migrations/loader.py +62 -0
- stint-0.1.0/stint/migrations/op.py +898 -0
- stint-0.1.0/stint/migrations/runner.py +170 -0
- stint-0.1.0/stint/py.typed +0 -0
- stint-0.1.0/stint/query/__init__.py +27 -0
- stint-0.1.0/stint/query/adf.py +28 -0
- stint-0.1.0/stint/query/columns.py +47 -0
- stint-0.1.0/stint/query/expr.py +198 -0
- stint-0.1.0/stint/query/hydrate.py +145 -0
- stint-0.1.0/stint/query/payload.py +206 -0
- stint-0.1.0/stint/query/select.py +63 -0
- stint-0.1.0/stint/query/session.py +284 -0
- stint-0.1.0/stint/query/sync.py +102 -0
- stint-0.1.0/stint/registry.py +81 -0
- stint-0.1.0/stint/schema/__init__.py +18 -0
- stint-0.1.0/stint/schema/_meta.py +49 -0
- stint-0.1.0/stint/schema/field_config.py +29 -0
- stint-0.1.0/stint/schema/issuetype.py +193 -0
- stint-0.1.0/stint/schema/project.py +97 -0
- stint-0.1.0/stint/schema/screen.py +53 -0
- stint-0.1.0/stint/state/__init__.py +6 -0
- stint-0.1.0/stint/state/file.py +187 -0
- stint-0.1.0/stint/state/lock.py +82 -0
- stint-0.1.0/stint/state/snapshot.py +168 -0
- stint-0.1.0/stint/validate.py +73 -0
- stint-0.1.0/stint.egg-info/PKG-INFO +328 -0
- stint-0.1.0/stint.egg-info/SOURCES.txt +88 -0
- stint-0.1.0/stint.egg-info/dependency_links.txt +1 -0
- stint-0.1.0/stint.egg-info/entry_points.txt +2 -0
- stint-0.1.0/stint.egg-info/requires.txt +9 -0
- stint-0.1.0/stint.egg-info/top_level.txt +1 -0
- stint-0.1.0/tests/test_m0_spike.py +427 -0
- stint-0.1.0/tests/test_m1_cli.py +193 -0
- stint-0.1.0/tests/test_m1_cloud.py +244 -0
- stint-0.1.0/tests/test_m1_dialect.py +553 -0
- stint-0.1.0/tests/test_m1_engine.py +78 -0
- stint-0.1.0/tests/test_m1_state.py +122 -0
- stint-0.1.0/tests/test_m2_downgrade.py +239 -0
- stint-0.1.0/tests/test_m2_migrations.py +276 -0
- stint-0.1.0/tests/test_m2_op_surface.py +937 -0
- stint-0.1.0/tests/test_m2_polish.py +528 -0
- stint-0.1.0/tests/test_m3_autogen.py +581 -0
- stint-0.1.0/tests/test_m3_autogenerate_cli.py +285 -0
- stint-0.1.0/tests/test_m3_multi_parent_graph.py +90 -0
- stint-0.1.0/tests/test_m3_revision_cli.py +151 -0
- stint-0.1.0/tests/test_m3_stamp.py +357 -0
- stint-0.1.0/tests/test_m4_polish.py +832 -0
- stint-0.1.0/tests/test_m5_query.py +425 -0
- stint-0.1.0/tests/test_m6_writes.py +459 -0
- stint-0.1.0/tests/test_m7_tmp.py +517 -0
- stint-0.1.0/tests/test_m8_sync_session.py +260 -0
- stint-0.1.0/tests/test_m8_validate_cli.py +155 -0
- 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
|
+

|
|
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
|
+

|
|
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).
|