Skip to main content

Declarative project plans, materialized as GitHub Projects (and other backends).

Project description

plynth

CI License: Apache 2.0 Python 3.9+

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 (or --token). On github.com, a fine-grained PAT with repository Issues: Read and write + organization Projects: Read and write is the recommended shape. Classic PATs with repo + project scopes work end-to-end on either target. Full breakdown in SECURITY.md. GHES_TOKEN is accepted for one release with a deprecation warning.

Confirmed: GHES 3.19 + FGPAT (2026-05). See SECURITY.md for the permission set that worked.

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.

  1. GraphQL-first, REST for milestones only. createMilestone is not in the GHES 3.19 GraphQL mutation reference. Everything else uses GraphQL. (github.com also requires REST for milestone creation today.)

  2. Issue creation via GraphQL createIssue. Supports milestoneId at creation time. Do NOT use projectIds on CreateIssueInput -- Project V2 requires addProjectV2ItemById then updateProjectV2ItemFieldValue as separate calls.

  3. Dependencies via addBlockedBy / removeBlockedBy. NOT addIssueDependency (doesn't exist). Normalization: blocked_by: XaddBlockedBy(issueId=self, blockingIssueId=X). blocks: YaddBlockedBy(issueId=Y, blockingIssueId=self). Requires repository issues, not drafts.

  4. Views are NOT automatable. No createProjectV2View or updateProjectV2View mutations on GHES 3.19. Views section in template YAML is declarative documentation only.

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

  6. Serial writes, 1-second delay between mutations. GHES GraphQL rate limits are disabled by default but the engine honors Retry-After defensively. Delay is configurable.

  7. Items cannot be added and updated in the same call. Must addProjectV2ItemById first, then updateProjectV2ItemFieldValue separately.

  8. updateProjectV2ItemFieldValue cannot set Assignees, Labels, Milestone, or Repository. Those are issue-level properties handled via createIssue or updateIssue.

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.

  1. 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_sha256 guard on state file
    • Include body_sha256 per issue in state for re-resolve detection
  2. 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
  3. GraphQL client (engine/graphql_client.py)

    • Single requests.Session with 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
  4. REST client (engine/rest_client.py)

    • Milestone creation only
    • Same session/auth pattern as GraphQL client
    • Same write delay and retry logic
  5. 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
  6. CLI (cli.py)

    • plynth create --template <path> --instance <path> [--dry-run [--format text|json]]
    • plynth resolve --state <path> (re-run Phase 5 only)
    • Auth via PLYNTH_TOKEN env var or --token flag
    • JSON dry-run: stable schema for piping plans into other tools. See docs/dry-run-json.md.

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); rich field options, repo creation, GHES version detection, and JSON dry-run output (v0.4.0); drift detection and a GitHub Action wrapper remain on the post-0.4 list.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

plynth-0.4.0.tar.gz (118.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

plynth-0.4.0-py3-none-any.whl (45.8 kB view details)

Uploaded Python 3

File details

Details for the file plynth-0.4.0.tar.gz.

File metadata

  • Download URL: plynth-0.4.0.tar.gz
  • Upload date:
  • Size: 118.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for plynth-0.4.0.tar.gz
Algorithm Hash digest
SHA256 2d551874116f26a5a7b2875f2c1ad5bd290f57390698bcfb2c0b1568755a413d
MD5 ab1ccfc47846834166018b3a91d343d3
BLAKE2b-256 f7926ddef8e3bb5c7e949ed4b0a20b96e4aa0df1282a7bea794ab58f68b4765d

See more details on using hashes here.

Provenance

The following attestation bundles were made for plynth-0.4.0.tar.gz:

Publisher: release.yml on Declaratus/plynth

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file plynth-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: plynth-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 45.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for plynth-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 75c1b7448b5c0491ef03cf41c7dabe65ae7c2867507c7b7cd9e8bb93ab17c161
MD5 d781958182937f9a65ef40ecac747d16
BLAKE2b-256 380ed9dd8482ad0ded75a3e5300d0e6b66fc38bd5400228f5cedf6e9931290f7

See more details on using hashes here.

Provenance

The following attestation bundles were made for plynth-0.4.0-py3-none-any.whl:

Publisher: release.yml on Declaratus/plynth

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page