Declarative project plans, materialized as GitHub Projects (and other backends).
Project description
plynth
Declarative project plans, materialized as GitHub Projects (and other backends).
What this project is
A Python CLI tool that bootstraps fully populated GitHub Projects from declarative YAML templates.
Primary target: GitHub.com (Projects v2 + Issues). Supported target: GitHub Enterprise Server (GHES) 3.19.
plynth replaces a manual 30-60 minute process of copying project templates, converting draft issues to real issues, assigning milestones, replacing placeholder tokens, and wiring cross-reference dependencies. It reads a template definition (YAML) and an instance config (YAML), then orchestrates GraphQL/REST mutations to produce a project with real issues, milestones assigned, placeholders resolved, field values set, and dependency relationships wired.
Install
pipx install plynth # recommended for use as a CLI
pip install plynth # for use as a library / one-shot
Releases are published from this repo to PyPI on every v* tag; GitHub
Releases include the matching wheel + sdist.
Configuration: target
Set target in your instance YAML to point plynth at GitHub.com or a GHES
host:
target: github.com # or omit for the same default
target: https://api.github.com # equivalent
target: https://ghes.example.com # GHES — REST under /api/v3
Authenticate with PLYNTH_TOKEN=ghp_... (or --token). GHES_TOKEN is
accepted for one release with a deprecation warning.
Technology stack
- Python 3.9+ (must run on RHEL 8/9)
- PyYAML -- YAML parsing
- Pydantic v2 -- typed config models, validation, JSON Schema generation
- requests -- HTTP client (GraphQL + REST)
- No heavy GQL library -- raw queries via requests against
/api/graphql
Project structure
plynth/
__init__.py
cli.py # CLI entry point (argparse or click)
models/
__init__.py
template.py # Pydantic models for template YAML
instance.py # Pydantic models for instance config YAML
state.py # Pydantic models for state/mapping file
engine/
__init__.py
planner.py # Validates config, builds effective issue list, resolves placeholders
graphql_client.py # GraphQL client (queries + mutations against GHES)
rest_client.py # REST client (milestone creation only)
phases.py # Phase 1-7 orchestration
queries/
__init__.py
mutations.py # GraphQL mutation strings
queries.py # GraphQL query strings
utils/
__init__.py
references.py # Cross-reference resolution ({PREFIX}-### → #number)
README.md
pyproject.toml
Example templates, instance configs, and the full design spec are being prepared in a follow-up sanitization pass and are not yet included in the repository.
Locked constraints (confirmed against GHES 3.19 GraphQL docs; github.com behaves the same except where noted)
These are hard design decisions, not open questions. Do not revisit them.
-
GraphQL-first, REST for milestones only.
createMilestoneis not in the GHES 3.19 GraphQL mutation reference. Everything else uses GraphQL. (github.com also requires REST for milestone creation today.) -
Issue creation via GraphQL
createIssue. SupportsmilestoneIdat creation time. Do NOT useprojectIdsonCreateIssueInput-- Project V2 requiresaddProjectV2ItemByIdthenupdateProjectV2ItemFieldValueas separate calls. -
Dependencies via
addBlockedBy/removeBlockedBy. NOTaddIssueDependency(doesn't exist). Normalization:blocked_by: X→addBlockedBy(issueId=self, blockingIssueId=X).blocks: Y→addBlockedBy(issueId=Y, blockingIssueId=self). Requires repository issues, not drafts. -
Views are NOT automatable. No
createProjectV2VieworupdateProjectV2Viewmutations on GHES 3.19. Views section in template YAML is declarative documentation only. -
Bootstrap-only in v1. No reconciliation, no destructive updates. Re-runs should be safe (idempotent where possible) but the tool does not diff desired vs actual state.
-
Serial writes, 1-second delay between mutations. GHES GraphQL rate limits are disabled by default but the engine honors
Retry-Afterdefensively. Delay is configurable. -
Items cannot be added and updated in the same call. Must
addProjectV2ItemByIdfirst, thenupdateProjectV2ItemFieldValueseparately. -
updateProjectV2ItemFieldValuecannot set Assignees, Labels, Milestone, or Repository. Those are issue-level properties handled viacreateIssueorupdateIssue.
Execution phases (strict order)
Phase 1: Validate config → resolve owner/repo IDs → createProjectV2 → createProjectV2Field → re-query field/option IDs
Phase 2: POST /repos/{owner}/{repo}/milestones (REST) → store number + node_id
Phase 3: createIssue (GraphQL, with milestoneId) → capture issue number + node_id per template_id
Phase 4: addProjectV2ItemById → updateProjectV2ItemFieldValue (per field per issue)
Phase 5: Resolve {PREFIX}-### → #real_number in issue bodies via updateIssue → addBlockedBy per dependency edge
Phase 6: Skip (views not automatable)
Phase 7: Write state file
State file is checkpointed after each phase so partial failures can resume.
Key GraphQL mutations
# Phase 1
mutation CreateProject($ownerId: ID!, $title: String!) {
createProjectV2(input: {ownerId: $ownerId, title: $title}) {
projectV2 { id number url }
}
}
mutation CreateField($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]) {
createProjectV2Field(input: {projectId: $projectId, name: $name, dataType: $dataType, singleSelectOptions: $options}) {
projectV2Field { ... on ProjectV2SingleSelectField { id name options { id name } } }
}
}
# Phase 3
mutation CreateIssue($repositoryId: ID!, $title: String!, $body: String!, $milestoneId: ID) {
createIssue(input: {repositoryId: $repositoryId, title: $title, body: $body, milestoneId: $milestoneId}) {
issue { id number title }
}
}
# Phase 4
mutation AddItemToProject($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item { id }
}
}
mutation SetFieldValue($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: {singleSelectOptionId: $optionId}}) {
projectV2Item { id }
}
}
# Phase 5
mutation UpdateIssueBody($issueId: ID!, $body: String!) {
updateIssue(input: {id: $issueId, body: $body}) {
issue { id }
}
}
mutation AddBlockedBy($issueId: ID!, $blockingIssueId: ID!) {
addBlockedBy(input: {issueId: $issueId, blockingIssueId: $blockingIssueId}) {
issue { id }
blockingIssue { id }
}
}
Key GraphQL queries
# Preflight: resolve org and repo IDs
query GetOrgId($login: String!) {
organization(login: $login) { id }
}
query GetRepoId($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) { id }
}
# After field creation: re-query field and option IDs
query GetProjectFields($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 20) {
nodes {
... on ProjectV2SingleSelectField {
id name options { id name }
}
... on ProjectV2Field {
id name
}
}
}
}
}
}
Implementation order for Phase B
Build in this order. Each step should be testable independently.
-
Pydantic models (
models/template.py,models/instance.py,models/state.py)- Template: placeholders, fields, milestones, issues (with blocked_by/blocks), views, pruning rules
- Instance: values, repo config, skip_milestones, skip_issues, field_overrides, api settings
- State: run metadata, project/field/milestone/issue ID mappings, phase checkpoints, errors
- Include
template_sha256guard on state file - Include
body_sha256per issue in state for re-resolve detection
-
Planner (
engine/planner.py)- Load and validate template + instance config
- Resolve placeholders in titles, bodies, field options
- Build effective issue list (apply skip_milestones, skip_issues, pruning rules)
- Validate dependency graph (warn if blocked_by target was removed)
- Dry-run mode: print execution plan and exit
-
GraphQL client (
engine/graphql_client.py)- Single
requests.Sessionwith auth header - Generic
execute(query, variables)method - Configurable write delay between mutations
Retry-After/ rate-limit header handling- All mutation strings in
queries/mutations.py
- Single
-
REST client (
engine/rest_client.py)- Milestone creation only
- Same session/auth pattern as GraphQL client
- Same write delay and retry logic
-
Phase orchestration (
engine/phases.py)- Phase 1: create project + fields + re-query IDs
- Phase 2: create milestones (REST)
- Phase 3: create issues (GraphQL)
- Phase 4: add to project + set field values
- Phase 5: resolve cross-refs + wire dependencies
- Phase 7: emit state file
- Checkpoint state file after each phase
- Resume logic: check state file phases, skip completed phases
-
CLI (
cli.py)plynth create --template <path> --instance <path> [--dry-run]plynth resolve --state <path>(re-run Phase 5 only)- Auth via
PLYNTH_TOKENenv var or--tokenflag
What NOT to build in Phase B
- View automation (not supported on GHES 3.19)
- Drift detection / reconciliation (Phase E)
- GitHub Action wrapper (Phase D)
- Destructive operations (delete issues, remove fields)
- GitHub App authentication (PAT is sufficient for v1)
Roadmap
Active work and milestones: https://github.com/Declaratus/plynth/milestones
Browse open issues by area:
Recent themes: github.com support and PyPI publishing (v0.3.0); drift detection, JSON dry-run output, and a GitHub Action wrapper (post-0.3).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file plynth-0.3.0rc1.tar.gz.
File metadata
- Download URL: plynth-0.3.0rc1.tar.gz
- Upload date:
- Size: 82.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c5228bf16e04bcab93bf1893986fe3edfe8ce5d0baf6024e4f492654251117a1
|
|
| MD5 |
9ce9f5829a2560e8d151d5d07896c8b5
|
|
| BLAKE2b-256 |
600030562becd5c375f2531d3047776622148af55b5ce19f781d152474eacb46
|
Provenance
The following attestation bundles were made for plynth-0.3.0rc1.tar.gz:
Publisher:
release.yml on Declaratus/plynth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
plynth-0.3.0rc1.tar.gz -
Subject digest:
c5228bf16e04bcab93bf1893986fe3edfe8ce5d0baf6024e4f492654251117a1 - Sigstore transparency entry: 1428796467
- Sigstore integration time:
-
Permalink:
Declaratus/plynth@d4883ceef96b0f82489f9e869de8b87cbceb71db -
Branch / Tag:
refs/tags/v0.3.0-rc1 - Owner: https://github.com/Declaratus
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d4883ceef96b0f82489f9e869de8b87cbceb71db -
Trigger Event:
push
-
Statement type:
File details
Details for the file plynth-0.3.0rc1-py3-none-any.whl.
File metadata
- Download URL: plynth-0.3.0rc1-py3-none-any.whl
- Upload date:
- Size: 34.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2261e7cb77aeb0c899ab6678f410605b28752f1f9785a73e01426aa74d7656ab
|
|
| MD5 |
78d5e1909958e814fdc59a48d950bafa
|
|
| BLAKE2b-256 |
e6271570a4afd6eb8b6947a7b5fd051750cbb27e283a312d866baa1aa73c1c32
|
Provenance
The following attestation bundles were made for plynth-0.3.0rc1-py3-none-any.whl:
Publisher:
release.yml on Declaratus/plynth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
plynth-0.3.0rc1-py3-none-any.whl -
Subject digest:
2261e7cb77aeb0c899ab6678f410605b28752f1f9785a73e01426aa74d7656ab - Sigstore transparency entry: 1428796494
- Sigstore integration time:
-
Permalink:
Declaratus/plynth@d4883ceef96b0f82489f9e869de8b87cbceb71db -
Branch / Tag:
refs/tags/v0.3.0-rc1 - Owner: https://github.com/Declaratus
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d4883ceef96b0f82489f9e869de8b87cbceb71db -
Trigger Event:
push
-
Statement type: