generated from eblume/project-template
Initial commit
This commit is contained in:
commit
c991adf34e
39 changed files with 3492 additions and 0 deletions
1
.dagger/.gitattributes
vendored
Normal file
1
.dagger/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/sdk/** linguist-generated
|
||||
4
.dagger/.gitignore
vendored
Normal file
4
.dagger/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/.venv
|
||||
/**/__pycache__
|
||||
/sdk
|
||||
/.env
|
||||
9
.dagger/pyproject.toml
Normal file
9
.dagger/pyproject.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[project]
|
||||
name = "datasette-satisfactory-ci"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["dagger-io"]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.8.4,<0.9.0"]
|
||||
build-backend = "uv_build"
|
||||
4
.dagger/src/project_template_ci/__init__.py
Normal file
4
.dagger/src/project_template_ci/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Datasette-Satisfactory CI — Dagger build functions."""
|
||||
|
||||
# TODO: Rename class to match your project (also rename the src/ directory)
|
||||
from .main import ProjectTemplateCi as ProjectTemplateCi
|
||||
51
.dagger/src/project_template_ci/main.py
Normal file
51
.dagger/src/project_template_ci/main.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import dagger
|
||||
from dagger import dag, function, object_type
|
||||
|
||||
|
||||
# TODO: Rename class to match your project (also rename the src/ directory)
|
||||
@object_type
|
||||
class ProjectTemplateCi:
|
||||
@function
|
||||
async def build_docs(self, src: dagger.Directory, version: str) -> dagger.File:
|
||||
"""Build Quartz docs site. Returns docs tarball."""
|
||||
return await (
|
||||
dag.container()
|
||||
.from_("node:22-slim")
|
||||
.with_exec(["apt-get", "update", "-qq"])
|
||||
.with_exec(["apt-get", "install", "-y", "-qq", "git"])
|
||||
.with_directory("/workspace", src)
|
||||
.with_workdir("/workspace")
|
||||
.with_exec(
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
"--depth=1",
|
||||
"https://github.com/jackyzha0/quartz.git",
|
||||
"/tmp/quartz",
|
||||
]
|
||||
)
|
||||
.with_exec(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"cp -r /tmp/quartz/quartz /tmp/quartz/package*.json "
|
||||
"/tmp/quartz/tsconfig.json .",
|
||||
]
|
||||
)
|
||||
.with_exec(["npm", "ci"])
|
||||
.with_exec(["cp", "docs/quartz.config.ts", "."])
|
||||
.with_exec(["cp", "docs/quartz.layout.ts", "."])
|
||||
.with_exec(["cp", "CHANGELOG.md", "docs/"])
|
||||
.with_exec(["npx", "quartz", "build", "-d", "docs"])
|
||||
.with_exec(
|
||||
[
|
||||
"tar",
|
||||
"-czf",
|
||||
f"/docs-{version}.tar.gz",
|
||||
"-C",
|
||||
"public",
|
||||
".",
|
||||
]
|
||||
)
|
||||
.file(f"/docs-{version}.tar.gz")
|
||||
)
|
||||
44
.forgejo/workflows/build.yaml
Normal file
44
.forgejo/workflows/build.yaml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Build Workflow
|
||||
#
|
||||
# Generic CI validation for template-based repositories.
|
||||
# By default this runs the repo's prek checks, which already cover:
|
||||
# - workflow linting
|
||||
# - docs integrity checks
|
||||
# - formatting/linting hooks configured by the project
|
||||
#
|
||||
# Projects can extend this with an optional executable hook at:
|
||||
# .forgejo/scripts/build
|
||||
#
|
||||
# The optional hook is the place for project-specific validation such as:
|
||||
# - unit/integration tests
|
||||
# - package builds
|
||||
# - schema validation
|
||||
# - language-specific linters
|
||||
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: k8s
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Run repository checks
|
||||
run: |
|
||||
prek run --all-files
|
||||
|
||||
- name: Run project-specific build hook
|
||||
run: |
|
||||
if [ -x .forgejo/scripts/build ]; then
|
||||
echo "Running project-specific build hook..."
|
||||
./.forgejo/scripts/build
|
||||
else
|
||||
echo "No .forgejo/scripts/build hook found; template validation complete."
|
||||
fi
|
||||
277
.forgejo/workflows/release.yaml
Normal file
277
.forgejo/workflows/release.yaml
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# Release Workflow
|
||||
#
|
||||
# Creates a versioned Forgejo release for template-based repositories.
|
||||
# By default this includes:
|
||||
# - Documentation site bundle (Quartz static build via Dagger)
|
||||
# - Changelog section built from towncrier fragments, when present
|
||||
#
|
||||
# Projects can optionally attach additional release artifacts by providing
|
||||
# an executable hook at `.forgejo/scripts/release`. That hook should place
|
||||
# any extra files under `release-assets/` for upload to the release.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Go to Actions > Release > Run workflow
|
||||
# 2. Select version bump type (patch/minor/major) or choose specific version
|
||||
# 3. The workflow creates a release with the docs bundle and optional extras
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: "Version bump type"
|
||||
required: true
|
||||
default: "BUMP_PATCH"
|
||||
type: choice
|
||||
options:
|
||||
- BUMP_PATCH
|
||||
- BUMP_MINOR
|
||||
- BUMP_MAJOR
|
||||
- SPECIFIC_VERSION
|
||||
specific_version:
|
||||
description: "Specific version (only used when version_type is SPECIFIC_VERSION, e.g., v1.2.0)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: k8s
|
||||
steps:
|
||||
- name: Resolve version
|
||||
id: version
|
||||
run: |
|
||||
VERSION_TYPE="${{ inputs.version_type }}"
|
||||
SPECIFIC_VERSION="${{ inputs.specific_version }}"
|
||||
|
||||
FORGE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
echo "Fetching latest release..."
|
||||
LATEST=$(curl -s "${FORGE_URL}/releases/latest" | jq -r '.tag_name // empty' || true)
|
||||
|
||||
if [ -z "$LATEST" ]; then
|
||||
LATEST="v0.0.0"
|
||||
echo "No previous releases found, using base version: $LATEST"
|
||||
else
|
||||
echo "Latest release: $LATEST"
|
||||
fi
|
||||
|
||||
CURRENT="${LATEST#v}"
|
||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
||||
|
||||
case "$VERSION_TYPE" in
|
||||
BUMP_MAJOR)
|
||||
VERSION="v$((MAJOR + 1)).0.0"
|
||||
echo "Bumping major: $LATEST -> $VERSION"
|
||||
;;
|
||||
BUMP_MINOR)
|
||||
VERSION="v${MAJOR}.$((MINOR + 1)).0"
|
||||
echo "Bumping minor: $LATEST -> $VERSION"
|
||||
;;
|
||||
BUMP_PATCH)
|
||||
VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
echo "Bumping patch: $LATEST -> $VERSION"
|
||||
;;
|
||||
SPECIFIC_VERSION)
|
||||
if [ -z "$SPECIFIC_VERSION" ]; then
|
||||
echo "Error: specific_version is required when version_type is SPECIFIC_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$SPECIFIC_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Version must be in format vX.Y.Z (e.g., v1.0.0)"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="$SPECIFIC_VERSION"
|
||||
echo "Using specific version: $VERSION"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown version_type: $VERSION_TYPE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if curl -sf "${FORGE_URL}/releases/tags/$VERSION" > /dev/null 2>&1; then
|
||||
echo "Error: Release $VERSION already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Building release: $VERSION"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build changelog
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
FRAGMENTS=$(find docs/changelog.d -name "*.md" -not -name ".gitkeep" 2>/dev/null | wc -l)
|
||||
|
||||
if [ "$FRAGMENTS" -gt 0 ]; then
|
||||
echo "Found $FRAGMENTS changelog fragments, building changelog..."
|
||||
uvx towncrier build --version "$VERSION" --yes
|
||||
echo "changelog_updated=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
RELEASE_NOTES=$(awk -v ver="$VERSION" '
|
||||
/^## \[/ {
|
||||
if (found) exit
|
||||
if (index($0, "[" ver "]")) found=1
|
||||
}
|
||||
found {print}
|
||||
' CHANGELOG.md | tail -n +2)
|
||||
|
||||
echo "$RELEASE_NOTES" > /tmp/release_notes.md
|
||||
echo "Release notes extracted for $VERSION"
|
||||
else
|
||||
echo "No changelog fragments found, skipping towncrier"
|
||||
echo "changelog_updated=false" >> "$GITHUB_OUTPUT"
|
||||
echo "" > /tmp/release_notes.md
|
||||
fi
|
||||
|
||||
- name: Build docs
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TARBALL="docs-${VERSION}.tar.gz"
|
||||
echo "Building docs via Dagger..."
|
||||
dagger call build-docs --src=. --version="$VERSION" \
|
||||
export --path="./$TARBALL"
|
||||
echo "Build complete!"
|
||||
ls -lh "$TARBALL"
|
||||
|
||||
- name: Build project-specific release artifacts
|
||||
run: |
|
||||
rm -rf release-assets
|
||||
mkdir -p release-assets
|
||||
|
||||
if [ -x .forgejo/scripts/release ]; then
|
||||
echo "Running project-specific release hook..."
|
||||
./.forgejo/scripts/release "${{ steps.version.outputs.version }}"
|
||||
else
|
||||
echo "No .forgejo/scripts/release hook found; docs-only release."
|
||||
fi
|
||||
|
||||
echo "Release asset inventory:"
|
||||
find release-assets -maxdepth 1 -type f -print | sort || true
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TARBALL="docs-${VERSION}.tar.gz"
|
||||
CHANGELOG_UPDATED="${{ steps.changelog.outputs.changelog_updated }}"
|
||||
FORGE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
echo "Creating release $VERSION..."
|
||||
|
||||
{
|
||||
echo "Release $VERSION"
|
||||
echo ""
|
||||
|
||||
if [ "$CHANGELOG_UPDATED" = "true" ] && [ -s /tmp/release_notes.md ]; then
|
||||
echo "## What's Changed"
|
||||
echo ""
|
||||
cat /tmp/release_notes.md
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "## Assets"
|
||||
echo ""
|
||||
echo "- \`$TARBALL\` contains the static docs site."
|
||||
|
||||
EXTRA_ASSET_COUNT=$(find release-assets -maxdepth 1 -type f | wc -l | tr -d ' ')
|
||||
if [ "$EXTRA_ASSET_COUNT" -gt 0 ]; then
|
||||
echo "- Additional project-specific artifacts are attached to this release."
|
||||
fi
|
||||
} > /tmp/release_body.txt
|
||||
|
||||
RELEASE_DATA=$(jq -n \
|
||||
--arg tag "$VERSION" \
|
||||
--arg name "Release $VERSION" \
|
||||
--rawfile body /tmp/release_body.txt \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||
|
||||
RELEASE_RESPONSE=$(curl -s \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
-d "$RELEASE_DATA" \
|
||||
"${FORGE_URL}/releases")
|
||||
|
||||
echo "API Response: $RELEASE_RESPONSE"
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
|
||||
|
||||
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||
echo "Error: Failed to create release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Created release ID: $RELEASE_ID"
|
||||
|
||||
echo "Uploading $TARBALL..."
|
||||
curl -s \
|
||||
-X POST \
|
||||
-H "Content-Type: application/gzip" \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
--data-binary "@$TARBALL" \
|
||||
"${FORGE_URL}/releases/$RELEASE_ID/assets?name=$TARBALL"
|
||||
|
||||
for artifact in release-assets/*; do
|
||||
if [ ! -f "$artifact" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
FILENAME=$(basename "$artifact")
|
||||
echo "Uploading $FILENAME..."
|
||||
curl -s \
|
||||
-X POST \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
--data-binary "@$artifact" \
|
||||
"${FORGE_URL}/releases/$RELEASE_ID/assets?name=$FILENAME"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Release created successfully!"
|
||||
|
||||
- name: Commit changelog changes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
CHANGELOG_UPDATED="${{ steps.changelog.outputs.changelog_updated }}"
|
||||
|
||||
if [ "$CHANGELOG_UPDATED" != "true" ]; then
|
||||
echo "No changelog changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "Forgejo Actions"
|
||||
git config user.email "actions@forge.eblu.me"
|
||||
|
||||
git add CHANGELOG.md docs/changelog.d/
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "Update changelog for $VERSION [skip ci]"
|
||||
git push origin HEAD:main
|
||||
echo "Changelog changes committed and pushed"
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
EXTRA_ASSET_COUNT=$(find release-assets -maxdepth 1 -type f | wc -l | tr -d ' ')
|
||||
|
||||
echo "================================================"
|
||||
echo "Release: $VERSION"
|
||||
echo "================================================"
|
||||
echo "Docs bundle: docs-${VERSION}.tar.gz"
|
||||
echo "Extra project assets: $EXTRA_ASSET_COUNT"
|
||||
9
.gitea/template
Normal file
9
.gitea/template
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
CLAUDE.md
|
||||
docs/index.md
|
||||
dagger.json
|
||||
.dagger/pyproject.toml
|
||||
.dagger/src/**/*.py
|
||||
docs/quartz.config.ts
|
||||
docs/quartz.layout.ts
|
||||
mise-tasks/pr-comments
|
||||
mise-tasks/docs-mikado
|
||||
3
.github/actionlint.yaml
vendored
Normal file
3
.github/actionlint.yaml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
self-hosted-runner:
|
||||
labels:
|
||||
- k8s
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.claude/settings.local.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.venv/
|
||||
|
||||
# Linter caches
|
||||
.ruff_cache/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
27
.yamllint.yaml
Normal file
27
.yamllint.yaml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
line-length:
|
||||
max: 120
|
||||
level: warning
|
||||
truthy:
|
||||
allowed-values: ['true', 'false', 'yes', 'no']
|
||||
comments:
|
||||
min-spaces-from-content: 1
|
||||
braces:
|
||||
min-spaces-inside: 0
|
||||
max-spaces-inside: 1
|
||||
brackets:
|
||||
min-spaces-inside: 0
|
||||
max-spaces-inside: 0
|
||||
indentation:
|
||||
spaces: 2
|
||||
indent-sequences: consistent
|
||||
comments-indentation: false
|
||||
octal-values:
|
||||
forbid-implicit-octal: false
|
||||
forbid-explicit-octal: true
|
||||
|
||||
ignore:
|
||||
- .venv/
|
||||
61
AGENTS.md
Normal file
61
AGENTS.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidance for Claude Code working in this repository. See also [[ai-assistance-guide]].
|
||||
|
||||
## Overview
|
||||
|
||||
**${REPO_NAME}** — ${REPO_DESCRIPTION}
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
This repository is a **Forgejo template**. Most customization points are auto-resolved by Forgejo's template variable expansion when a new repo is created. The remaining manual steps are:
|
||||
|
||||
1. **Set `baseUrl`** in `docs/quartz.config.ts` — this is the hosted docs domain, not the repo URL. localhost if not hosted.
|
||||
2. **Rename `.dagger/src/project_template_ci/`** directory to match your project, and update the class names inside
|
||||
3. **Fill in project structure** in the `AGENTS.md` Project Structure section
|
||||
4. **Fill in license info** in `README.md`
|
||||
5. **When all TODOs are resolved:** delete this "First-Time Setup" section entirely from `AGENTS.md` — it is only needed once
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Always run `mise run ai-docs` at session start**
|
||||
This will refresh your context with important information you will be assumed to know and follow.
|
||||
**Read the full output** — never truncate, pipe to `head`/`tail`, or skip sections.
|
||||
2. **Classify the change as C0/C1/C2 before starting** (see below) — this determines branching and PR requirements
|
||||
3. **Generated repos use feature branches + PRs for C1/C2** — checkout main, pull, create branch, open PR via `tea pr create`. This template source repo usually stays C0/direct-to-main so it remains clean and templatable.
|
||||
4. **Use changelog fragments in generated repos, not as template residue** — `docs/changelog.d/<name>.<type>.md`
|
||||
Types: `feature`, `bugfix`, `infra`, `doc`, `ai`, `misc`
|
||||
- **Generated repos:** add fragments for noteworthy changes
|
||||
- **This template repo:** keep `docs/changelog.d/` empty except for `.gitkeep`
|
||||
5. **Never commit secrets**
|
||||
|
||||
## Change Classification
|
||||
|
||||
Before starting work, classify the change:
|
||||
|
||||
| Class | Name | When to use | Key trait |
|
||||
|-------|------|-------------|-----------|
|
||||
| **C0** | Quick Fix | Small, low-risk, fix-forward safe | Direct to main, no PR |
|
||||
| **C1** | Human Review | Moderate complexity or risk | Feature branch + PR, docs-first |
|
||||
| **C2** | Mikado Chain | Multi-phase, multi-session, high complexity | Mikado Branch Invariant |
|
||||
|
||||
**C0** — commit directly to main. No branch or PR needed. Fix forward if problems arise.
|
||||
|
||||
**C1** — in generated repos, use a feature branch with an early PR. In this template source repo, prefer direct cleanups unless the user explicitly wants branch-based review. Search related docs first, write documentation changes before code. Upgrade to C2 if complexity spirals.
|
||||
|
||||
**C2** — branch `mikado/<chain-stem>` governed by the Mikado Branch Invariant: all card commits first, then code progress, then card closures. Commits use `C2(<chain>): plan/impl/close/finalize` convention. Reset the branch when new prerequisites are discovered. Resume with `mise run docs-mikado --resume`.
|
||||
|
||||
See [[agent-change-process]] for the full methodology.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
./docs/ # Diataxis docs, Quartz config, and release content
|
||||
./docs/changelog.d/ # keep only .gitkeep in the template; generated repos add towncrier fragments here
|
||||
./.dagger/ # Dagger module; rename src/project_template_ci/ when forking
|
||||
./.forgejo/workflows/ # generic build and release workflows for generated repos
|
||||
./.forgejo/scripts/ # optional per-project build/release hooks consumed by the workflows
|
||||
./mise-tasks/ # repo automation via `mise run`
|
||||
```
|
||||
|
||||
Other code paths will be listed via ai-docs. When you encounter wiki-links (`[[like-this]]`) it is referring to docs/ cards.
|
||||
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
title: changelog
|
||||
tags:
|
||||
- meta
|
||||
---
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
<!-- towncrier release notes start -->
|
||||
57
README.md
Normal file
57
README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# project-template
|
||||
|
||||
A personal project template with opinionated infrastructure for documentation, CI, and AI-assisted development.
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Documentation** — [Diataxis](https://diataxis.fr/)-structured docs built with [Quartz](https://quartz.jzhao.xyz/)
|
||||
- **Changelog** — [Towncrier](https://towncrier.readthedocs.io/) fragment-based changelog
|
||||
- **CI/CD** — [Dagger](https://dagger.io/) pipelines + Forgejo `build` and `release` workflows
|
||||
- **Pre-commit hooks** — [prek](https://github.com/dustinblackman/prek) with linting, formatting, secret detection
|
||||
- **AI assistance** — `AGENTS.md` + structured docs for Claude Code (C0/C1/C2 change process, Mikado method)
|
||||
- **Task runner** — [mise](https://mise.jdx.dev/) tasks for docs validation, Mikado chain management, release preview, and runner inspection
|
||||
|
||||
## Forking This Template
|
||||
|
||||
This is a **Forgejo template repository**. When you create a new repo from this template, Forgejo automatically expands variables like `${REPO_NAME}` and `${REPO_OWNER}` in key files — handling most customization automatically.
|
||||
|
||||
After creating your repo, the remaining manual steps are:
|
||||
|
||||
1. Set `baseUrl` in `docs/quartz.config.ts` to your docs site domain
|
||||
2. Rename `.dagger/src/project_template_ci/` directory and update class names to match your project
|
||||
3. Review and tailor the project structure section in `AGENTS.md`
|
||||
4. Add license information to `README.md`
|
||||
5. Remove the "First-Time Setup" section from `AGENTS.md` and this section from `README.md`
|
||||
|
||||
If you use Claude Code, it will prompt you to resolve remaining TODOs at the start of your first session.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Install git hooks
|
||||
prek install && prek install --hook-type commit-msg
|
||||
|
||||
# Run all pre-commit checks
|
||||
prek run --all-files
|
||||
|
||||
# List available tasks
|
||||
mise tasks
|
||||
|
||||
# Build docs (requires Dagger)
|
||||
dagger call build-docs --src=. --version=dev export --path=./docs-dev.tar.gz
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
./docs/ # documentation (Diataxis, Quartz)
|
||||
./docs/changelog.d/ # leave only .gitkeep in the template; generated repos add towncrier fragments here
|
||||
./.dagger/ # Dagger module backing docs builds and releases
|
||||
./.forgejo/workflows/ # generic build/release workflows for generated repos
|
||||
./.forgejo/scripts/ # optional per-project hooks consumed by those workflows
|
||||
./mise-tasks/ # scripts via `mise run`
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
<!-- TODO: Add license information -->
|
||||
8
dagger.json
Normal file
8
dagger.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "datasette-satisfactory-ci",
|
||||
"engineVersion": "v0.20.6",
|
||||
"sdk": {
|
||||
"source": "python"
|
||||
},
|
||||
"source": ".dagger"
|
||||
}
|
||||
0
docs/changelog.d/.gitkeep
Normal file
0
docs/changelog.d/.gitkeep
Normal file
13
docs/explanation/explanation.md
Normal file
13
docs/explanation/explanation.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
title: Explanation
|
||||
modified: 2026-03-03
|
||||
tags:
|
||||
- explanation
|
||||
- meta
|
||||
---
|
||||
|
||||
# Explanation
|
||||
|
||||
Background context and design decisions.
|
||||
|
||||
<!-- TODO: Add explanation entries as the project grows -->
|
||||
277
docs/how-to/agent-change-process.md
Normal file
277
docs/how-to/agent-change-process.md
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
---
|
||||
title: Agent Change Process
|
||||
modified: 2026-04-19
|
||||
tags:
|
||||
- how-to
|
||||
- ai
|
||||
---
|
||||
|
||||
# Agent Change Process
|
||||
|
||||
How to classify and execute changes, especially when working with AI agents that may lose context across sessions.
|
||||
|
||||
## Change Classification
|
||||
|
||||
Before starting work, classify the change:
|
||||
|
||||
| Class | Name | When to use | Key trait |
|
||||
|-------|------|-------------|-----------|
|
||||
| **C0** | Quick Fix | Small, low-risk, fix-forward safe | Direct to main, no PR |
|
||||
| **C1** | Human Review | Moderate complexity or risk | Feature branch + PR, docs-first |
|
||||
| **C2** | Mikado Chain | Multi-phase, multi-session, high complexity | Mikado Branch Invariant |
|
||||
|
||||
When in doubt, start at C1. Upgrade to C2 if complexity spirals or the user requests it.
|
||||
|
||||
## C0 — Quick Fix
|
||||
|
||||
A change where the risk is low enough that problems can be quickly fixed forward.
|
||||
|
||||
1. Run `mise run ai-docs` to load context
|
||||
2. Implement the change directly on main
|
||||
3. Add a changelog fragment if the change is user-visible or noteworthy (`docs/changelog.d/+<descriptive-slug>.<type>.md`)
|
||||
4. Commit and push
|
||||
|
||||
No feature branch or PR required. If something goes wrong, fix forward with another commit.
|
||||
|
||||
Examples: fix a typo, bump a version, add a simple config value, update a doc.
|
||||
|
||||
## C1 — Human Review
|
||||
|
||||
A change with enough complexity or risk that a human should review it, but not so much that a formal multi-phase approach is needed.
|
||||
|
||||
### Process
|
||||
|
||||
1. Run `mise run ai-docs` to load context
|
||||
2. **Search related docs** — read existing documentation and reference cards related to the change area
|
||||
3. **Create a feature branch** and open a PR early (draft is fine)
|
||||
4. **Documentation first** — commit doc changes reflecting the desired end state before writing code. This helps the reviewer understand intent and catches design issues early
|
||||
5. **Implement** — commit code changes, pushing as you go. The PR gets updated along the way and the user can review and comment at any point
|
||||
6. **Add changelog fragment** — `docs/changelog.d/<branch>.<type>.md` for any user-visible or noteworthy changes
|
||||
7. After user review and successful testing, the user merges the PR
|
||||
|
||||
### Upgrading to C2
|
||||
|
||||
Upgrade to C2 if any of these happen during a C1 change:
|
||||
|
||||
- You discover the change requires multiple prerequisite changes that must be sequenced
|
||||
- The change is spiraling in complexity beyond a single session
|
||||
- The user requests it
|
||||
- During planning you realize this is a multi-phase project
|
||||
|
||||
## C2 — Mikado Chain
|
||||
|
||||
A complex, multi-session change managed through the [Mikado method](https://mikadomethod.info/) with a strict branch discipline called the **Mikado Branch Invariant**.
|
||||
|
||||
### Planning and research
|
||||
|
||||
Before writing any code, invest in understanding the problem:
|
||||
|
||||
1. Run `mise run ai-docs` to load context
|
||||
2. Search related docs, reference cards, and existing how-to guides for the change area
|
||||
3. Think through the dependency graph — what prerequisites exist? What could go wrong?
|
||||
4. Create Mikado cards for everything you can anticipate (you'll discover more later — that's the point of the method)
|
||||
|
||||
This planning phase can span multiple sessions. Cards introduced during planning are merged to main and become the foundation for work cycles later.
|
||||
|
||||
### The Mikado Branch Invariant
|
||||
|
||||
The invariant governs how commits are ordered on a C2 feature branch. The branch must always have this structure:
|
||||
|
||||
```
|
||||
main ← [plan commits] ← [impl, close] ← [impl, close] ← ... ← [finalize]
|
||||
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Planning layer Repeating work cycles
|
||||
(cards only) (impl then close, one leaf at a time)
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. The first N commits on the branch (after diverging from main) must ALL be commits that **only introduce or modify Mikado cards** — no code changes
|
||||
2. After the card layer, work proceeds in **cycles**: each cycle is one or more code commits followed by one or more commits closing leaf nodes
|
||||
3. A cycle should target a single leaf node (though closing multiple in one cycle is acceptable if the code supports it)
|
||||
4. Cycles repeat until the chain is complete
|
||||
|
||||
**The one rule:** No Mikado card may be introduced after any code or card-closing commit. New cards require a branch reset (see below).
|
||||
|
||||
**The length-zero case:** It is valid for the "planning layer" to have zero commits on the branch — this happens when all Mikado cards were introduced in earlier sessions and are already in main's history. The invariant is satisfied.
|
||||
|
||||
**Exception — finalize:** The terminal commit of a completed chain rewrites Mikado cards to historical documentation. This is a card modification after code commits, and is the only permitted violation of the one rule (see "Completing a chain" below).
|
||||
|
||||
### Conventions
|
||||
|
||||
#### Branch naming
|
||||
|
||||
C2 branches must be named `mikado/<chain-stem>`, where `<chain-stem>` is the filename stem of the goal card. Example: goal card `deploy-service.md` → branch `mikado/deploy-service`.
|
||||
|
||||
#### Goal card `branch:` frontmatter
|
||||
|
||||
The goal card of a C2 chain must include a `branch:` field once work begins:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Deploy Service
|
||||
status: active
|
||||
branch: mikado/deploy-service
|
||||
requires:
|
||||
- configure-database
|
||||
- setup-auth
|
||||
tags:
|
||||
- how-to
|
||||
---
|
||||
```
|
||||
|
||||
A goal card with `status: active` but no `branch:` field indicates a chain that has been planned but not yet started — the planning-phase cards exist but no implementation branch has been created.
|
||||
|
||||
#### Commit message convention
|
||||
|
||||
All commits on a `mikado/*` branch must use this format:
|
||||
|
||||
```
|
||||
C2(<chain-stem>): <verb> <short description>
|
||||
```
|
||||
|
||||
Verbs and their meanings:
|
||||
|
||||
| Verb | Phase | What it means |
|
||||
|------|-------|---------------|
|
||||
| `plan` | Planning layer | Introduces or modifies a Mikado card (no code changes) |
|
||||
| `impl` | Work cycle | Code progress toward closing a leaf node (no card changes) |
|
||||
| `close` | Work cycle | Closes a leaf node by removing `status: active` |
|
||||
| `finalize` | Terminal | Rewrites cards to historical docs, adds changelog |
|
||||
|
||||
Examples:
|
||||
```
|
||||
C2(deploy-service): plan add database and auth prerequisite cards
|
||||
C2(deploy-service): impl configure secrets for service
|
||||
C2(deploy-service): close configure-database
|
||||
C2(deploy-service): finalize rewrite cards as historical documentation
|
||||
```
|
||||
|
||||
The `mikado-branch-invariant-check` commit-msg hook validates this convention and the invariant ordering.
|
||||
|
||||
### Process
|
||||
|
||||
1. **Goal card:** Create a how-to doc in `docs/how-to/` describing the desired end state
|
||||
- Add `status: active` and `branch: mikado/<chain-stem>` to frontmatter
|
||||
- Create prerequisite cards discovered during planning, each with `status: active`
|
||||
- Commit all cards together (or in a sequence of card-only commits) using `C2(<chain>): plan ...` messages
|
||||
2. **Open a PR** after the first card commits so the user can review the Mikado graph
|
||||
3. **Work leaf nodes** — pick a leaf (a card with `status: active` and no unmet `requires`):
|
||||
- Commit code changes (`C2(<chain>): impl ...`) that progress toward closing it
|
||||
- **Verify the card's own deliverables** before closing. "Works" means the card's stated outputs are correct — not that downstream consumers have integrated them
|
||||
- Commit the card closure (`C2(<chain>): close ...`) — remove `status: active`
|
||||
- Push to origin — this is the save point
|
||||
4. **End the cycle** — after pushing a closed leaf node, prompt the user to review the PR and suggest ending the session. Each closed leaf is a natural stopping point; the chain is designed to be resumed later
|
||||
5. **Repeat** until the chain is complete
|
||||
6. **New agent sessions** pick up state by running `mise run docs-mikado --resume`
|
||||
|
||||
### Discovering new prerequisites or errors
|
||||
|
||||
When you discover a new prerequisite **or encounter an error** during code work, do not fix forward. The Mikado method's power comes from rigorous resets that keep the plan honest. You must restore the Mikado Branch Invariant:
|
||||
|
||||
1. **Stash or note any in-progress work** you want to preserve
|
||||
2. **Identify the reset point** — the last `plan` or `close` commit before your current `impl` commits:
|
||||
```bash
|
||||
git log --oneline mikado/<chain-stem> --not main
|
||||
```
|
||||
3. **Reset the branch** to that commit:
|
||||
```bash
|
||||
git reset --hard <reset-point-sha>
|
||||
```
|
||||
4. **Update the plan** — add a `plan` commit that captures what you learned:
|
||||
- If you discovered a new prerequisite: add a new card and update `requires`
|
||||
- If you hit an error: update the relevant card with what you learned, or introduce a new prerequisite card that addresses the root cause
|
||||
5. **Replay valid work** by cherry-picking commits that still apply:
|
||||
```bash
|
||||
git cherry-pick <sha1> <sha2> ...
|
||||
```
|
||||
6. **Resume the Mikado process** from the new state of the card stack
|
||||
|
||||
**When to reset vs. fix forward:** If an `impl` commit introduces a bug that's a simple typo or one-liner, another `impl` commit is fine. But if the error reveals a gap in understanding, a missing prerequisite, or requires rethinking the approach — reset. The threshold is: "does this error teach us something that should be in the plan?" If yes, reset.
|
||||
|
||||
**Saving work across resets:** It is acceptable to cherry-pick code commits from before the reset back onto the branch after adding the new card. Use `git stash` for uncommitted work. This is a pragmatic exception — use it only when you are confident the saved work is still valid given the new prerequisite. When in doubt, redo the work from scratch.
|
||||
|
||||
### Completing a chain
|
||||
|
||||
When the final leaf node is closed and no `status: active` cards remain:
|
||||
|
||||
1. **Rewrite all Mikado cards** to reflect their nature as historical documentation:
|
||||
- Remove transient technical details that won't matter in the future
|
||||
- Frame the content as "what to do if someone wanted to repeat this process"
|
||||
- Add appropriate context about what was learned
|
||||
- Remove `branch:` from the goal card frontmatter
|
||||
2. **Add changelog information** in `docs/changelog.d/`
|
||||
3. Commit as `C2(<chain>): finalize ...` — this is the one permitted exception to the invariant's "no card changes after code" rule
|
||||
4. The user reviews and merges the PR
|
||||
|
||||
### Cold-start: resuming a chain in a new session
|
||||
|
||||
When starting a new session to continue C2 work:
|
||||
|
||||
1. Run `mise run ai-docs` to load context
|
||||
2. Run `mise run docs-mikado --resume` — this will:
|
||||
- Detect the current branch and match it to an active chain
|
||||
- Show the chain state, ready leaf nodes, and current position in the invariant
|
||||
- Show the PR number and URL if an open PR exists for the branch
|
||||
- Warn about any stashed work in `git stash list`
|
||||
- If on main, list active chains and suggest which to resume
|
||||
3. Check PR comments with `mise run pr-comments <pr_number>` — use the PR number from the `--resume` output above
|
||||
4. Pick the next ready leaf node and continue with a work cycle
|
||||
|
||||
## Card Conventions
|
||||
|
||||
### Frontmatter
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Deploy Service
|
||||
status: active # omit when complete
|
||||
branch: mikado/deploy-service # goal cards only; omit when complete
|
||||
requires: # explicit dependencies
|
||||
- configure-database
|
||||
- setup-auth
|
||||
tags:
|
||||
- how-to
|
||||
---
|
||||
```
|
||||
|
||||
- `status: active` marks in-progress work; remove when done (this is the ONLY way a card is marked complete)
|
||||
- `branch` is set on goal cards only, linking the card to its `mikado/<chain-stem>` branch. Remove `branch` when the chain is finalized.
|
||||
- `requires` lists card stems (filenames without `.md`) that must be completed first. **Keep `requires` permanently** even after prerequisites are done — it documents the dependency graph history
|
||||
- `required-by` is NOT stored — it's computed by `docs-mikado`
|
||||
|
||||
### Writing Cards
|
||||
|
||||
- **Mikado cards are not plans.** Plans are designed upfront; Mikado cards are discovered through failed attempts.
|
||||
- Cards live in a topic subdirectory under `docs/how-to/`
|
||||
- Keep cards brief (<30 seconds to read)
|
||||
- Link to other cards rather than inlining their content
|
||||
- Document what was learned from failures, not just what to do
|
||||
|
||||
### Git Discipline
|
||||
|
||||
- **C0:** Commit directly to main
|
||||
- **C1:** Single feature branch, PR early, push often
|
||||
- **C2:** Branch named `mikado/<chain-stem>`, Mikado Branch Invariant enforced, `C2()` commit convention, PR early, push after every leaf-node closure
|
||||
- **Changelog fragments (all levels):** Add `docs/changelog.d/<name>.<type>.md` for any user-visible or noteworthy change, regardless of change class. C0 uses orphan fragments (`+<descriptive-slug>.<type>.md`). C1/C2 use the branch name (`<branch>.<type>.md`). C0 includes the fragment in the same commit. C1 includes it during the branch work. C2 includes it in the `finalize` commit.
|
||||
- GitOps requires pushing to test — if a pushed commit breaks, revert it promptly
|
||||
|
||||
## Tools
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `mise run changelog-check` | Validate changelog fragment layout |
|
||||
| `mise run docs-check-frontmatter` | Check required doc frontmatter fields |
|
||||
| `mise run docs-mikado` | List all active Mikado chains with branch status |
|
||||
| `mise run docs-mikado <card>` | Show dependency chain for a goal card |
|
||||
| `mise run docs-mikado <card> --all` | Include completed cards in full |
|
||||
| `mise run docs-mikado --resume` | Resume a chain: detect branch, show state and next steps |
|
||||
| `mise run docs-mikado --resume <chain>` | Resume a specific chain with branch consistency check |
|
||||
| `mise run docs-preview <tarball>` | Preview a released docs bundle locally |
|
||||
| `mise run runner-logs [run_number]` | List Forgejo Actions runs or fetch logs for a specific job |
|
||||
|
||||
The `mikado-branch-invariant-check` commit-msg hook runs automatically on `mikado/*` branches, validating commit message conventions and invariant ordering. Requires `prek install --hook-type commit-msg`.
|
||||
|
||||
## Related
|
||||
|
||||
- [[ai-assistance-guide]] — General AI agent conventions
|
||||
15
docs/how-to/how-to.md
Normal file
15
docs/how-to/how-to.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: How-To Guides
|
||||
modified: 2026-03-03
|
||||
tags:
|
||||
- how-to
|
||||
- meta
|
||||
---
|
||||
|
||||
# How-To Guides
|
||||
|
||||
Task-oriented guides for common operations.
|
||||
|
||||
## Knowledge Base
|
||||
|
||||
- [[agent-change-process]] — C0/C1/C2 change classification and Mikado method
|
||||
18
docs/index.md
Normal file
18
docs/index.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
title: Project Documentation
|
||||
modified: 2026-03-03
|
||||
tags:
|
||||
- meta
|
||||
---
|
||||
|
||||
# Project Documentation
|
||||
|
||||
Welcome to the **datasette-satisfactory** documentation.
|
||||
|
||||
## Navigation
|
||||
|
||||
- [[tutorials]] — Getting started and learning guides
|
||||
- [[reference]] — Technical reference material
|
||||
- [[how-to]] — Task-oriented guides
|
||||
- [[explanation]] — Background and design decisions
|
||||
- [[CHANGELOG]] — Release history
|
||||
91
docs/quartz.config.ts
Normal file
91
docs/quartz.config.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { QuartzConfig } from "./quartz/cfg"
|
||||
import * as Plugin from "./quartz/plugins"
|
||||
|
||||
/**
|
||||
* Quartz configuration for project documentation
|
||||
* See https://quartz.jzhao.xyz/configuration
|
||||
*/
|
||||
const config: QuartzConfig = {
|
||||
configuration: {
|
||||
pageTitle: "Datasette-Satisfactory Docs",
|
||||
pageTitleSuffix: "",
|
||||
enableSPA: true,
|
||||
enablePopovers: true,
|
||||
analytics: null,
|
||||
locale: "en-US",
|
||||
baseUrl: "CHANGEME.example.com", // TODO: Update to your docs site URL
|
||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||
defaultDateType: "modified",
|
||||
theme: {
|
||||
fontOrigin: "googleFonts",
|
||||
cdnCaching: true,
|
||||
typography: {
|
||||
header: "Schibsted Grotesk",
|
||||
body: "Source Sans Pro",
|
||||
code: "IBM Plex Mono",
|
||||
},
|
||||
colors: {
|
||||
lightMode: {
|
||||
light: "#faf8f8",
|
||||
lightgray: "#e5e5e5",
|
||||
gray: "#b8b8b8",
|
||||
darkgray: "#4e4e4e",
|
||||
dark: "#2b2b2b",
|
||||
secondary: "#284b63",
|
||||
tertiary: "#84a59d",
|
||||
highlight: "rgba(143, 159, 169, 0.15)",
|
||||
textHighlight: "#fff23688",
|
||||
},
|
||||
darkMode: {
|
||||
light: "#161618",
|
||||
lightgray: "#393639",
|
||||
gray: "#646464",
|
||||
darkgray: "#d4d4d4",
|
||||
dark: "#ebebec",
|
||||
secondary: "#7b97aa",
|
||||
tertiary: "#84a59d",
|
||||
highlight: "rgba(143, 159, 169, 0.15)",
|
||||
textHighlight: "#fff23688",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
transformers: [
|
||||
Plugin.FrontMatter(),
|
||||
Plugin.CreatedModifiedDate({
|
||||
priority: ["frontmatter", "git", "filesystem"],
|
||||
}),
|
||||
Plugin.SyntaxHighlighting({
|
||||
theme: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
keepBackground: false,
|
||||
}),
|
||||
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
||||
Plugin.GitHubFlavoredMarkdown(),
|
||||
Plugin.TableOfContents(),
|
||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||
Plugin.Description(),
|
||||
Plugin.Latex({ renderEngine: "katex" }),
|
||||
],
|
||||
filters: [Plugin.RemoveDrafts()],
|
||||
emitters: [
|
||||
Plugin.AliasRedirects(),
|
||||
Plugin.ComponentResources(),
|
||||
Plugin.ContentPage(),
|
||||
Plugin.FolderPage(),
|
||||
Plugin.TagPage(),
|
||||
Plugin.ContentIndex({
|
||||
enableSiteMap: true,
|
||||
enableRSS: true,
|
||||
}),
|
||||
Plugin.Assets(),
|
||||
Plugin.Static(),
|
||||
Plugin.NotFoundPage(),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
53
docs/quartz.layout.ts
Normal file
53
docs/quartz.layout.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { PageLayout, SharedLayout } from "./quartz/cfg"
|
||||
import * as Component from "./quartz/components"
|
||||
|
||||
/**
|
||||
* Quartz layout configuration for project documentation
|
||||
* See https://quartz.jzhao.xyz/layout
|
||||
*/
|
||||
|
||||
// Components shared across all pages
|
||||
export const sharedPageComponents: SharedLayout = {
|
||||
head: Component.Head(),
|
||||
header: [],
|
||||
afterBody: [],
|
||||
footer: Component.Footer({
|
||||
links: {
|
||||
"Repository": "/eblume/datasette-satisfactory",
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
// Components for pages that list posts (folder pages, tag pages)
|
||||
export const defaultContentPageLayout: PageLayout = {
|
||||
beforeBody: [
|
||||
Component.Breadcrumbs(),
|
||||
Component.ArticleTitle(),
|
||||
Component.ContentMeta(),
|
||||
Component.TagList(),
|
||||
],
|
||||
left: [
|
||||
Component.PageTitle(),
|
||||
Component.MobileOnly(Component.Spacer()),
|
||||
Component.Search(),
|
||||
Component.Darkmode(),
|
||||
Component.DesktopOnly(Component.Explorer()),
|
||||
],
|
||||
right: [
|
||||
Component.Graph(),
|
||||
Component.DesktopOnly(Component.TableOfContents()),
|
||||
Component.Backlinks(),
|
||||
],
|
||||
}
|
||||
|
||||
export const defaultListPageLayout: PageLayout = {
|
||||
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
|
||||
left: [
|
||||
Component.PageTitle(),
|
||||
Component.MobileOnly(Component.Spacer()),
|
||||
Component.Search(),
|
||||
Component.Darkmode(),
|
||||
Component.DesktopOnly(Component.Explorer()),
|
||||
],
|
||||
right: [],
|
||||
}
|
||||
68
docs/reference/reference.md
Normal file
68
docs/reference/reference.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
title: Reference
|
||||
modified: 2026-04-19
|
||||
tags:
|
||||
- reference
|
||||
- meta
|
||||
---
|
||||
|
||||
# Reference
|
||||
|
||||
Technical reference material for the repository tooling that ships with this project.
|
||||
|
||||
## Template Surface Area
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `.dagger/src/project_template_ci/` | Dagger module that builds the Quartz docs tarball used by releases |
|
||||
| `.forgejo/workflows/build.yaml` | Generic CI validation workflow |
|
||||
| `.forgejo/workflows/release.yaml` | Manual release workflow that versions, builds docs, and publishes release assets |
|
||||
| `.forgejo/scripts/` | Optional project-specific hooks consumed by the workflows |
|
||||
| `mise-tasks/` | Helper tasks for docs validation, Mikado chains, PR review, and runner inspection |
|
||||
|
||||
## Forgejo Workflows
|
||||
|
||||
### `build.yaml`
|
||||
|
||||
- Triggers on pushes to `main` and pull requests targeting `main`
|
||||
- Runs `prek run --all-files`
|
||||
- Executes `.forgejo/scripts/build` if that hook exists and is executable
|
||||
- Otherwise exits after generic template validation
|
||||
|
||||
### `release.yaml`
|
||||
|
||||
- Triggered manually via `workflow_dispatch`
|
||||
- Accepts `BUMP_PATCH`, `BUMP_MINOR`, `BUMP_MAJOR`, or `SPECIFIC_VERSION`
|
||||
- Resolves the next version from the latest Forgejo release tag
|
||||
- Builds `CHANGELOG.md` with towncrier when fragment files exist
|
||||
- Builds `docs-<version>.tar.gz` via `dagger call build-docs --src=. --version=<version>`
|
||||
- Executes `.forgejo/scripts/release <version>` if present to stage extra files under `release-assets/`
|
||||
- Creates the Forgejo release and uploads the docs tarball plus any extra assets
|
||||
- Commits generated changelog updates back to `main` when fragments were consumed
|
||||
|
||||
## Mise Tasks
|
||||
|
||||
| Task | Purpose |
|
||||
|------|---------|
|
||||
| `mise run ai-docs` | Print the key docs files AI agents are expected to read first |
|
||||
| `mise run changelog-check` | Validate changelog fragments are flat files under `docs/changelog.d/` |
|
||||
| `mise run docs-check-filenames` | Detect duplicate doc filenames |
|
||||
| `mise run docs-check-frontmatter` | Validate required frontmatter fields |
|
||||
| `mise run docs-check-index` | Ensure each doc is linked from its category index |
|
||||
| `mise run docs-check-links` | Validate wiki-links against existing doc filenames |
|
||||
| `mise run docs-mikado` | Inspect active Mikado chains and resume C2 work |
|
||||
| `mise run docs-preview <tarball>` | Extract and serve a released docs tarball locally |
|
||||
| `mise run mikado-branch-invariant-check` | Validate `mikado/*` branch commit discipline |
|
||||
| `mise run pr-comments <pr_number>` | List unresolved PR comments |
|
||||
| `mise run runner-logs [run_number]` | List Forgejo Actions runs or fetch logs for a job |
|
||||
|
||||
## Changelog Fragments
|
||||
|
||||
- Store towncrier fragments under `docs/changelog.d/`
|
||||
- Use one flat `.md` file per change
|
||||
- The directory may contain only `.gitkeep` until the first real fragment is added
|
||||
|
||||
## TODO After Templating
|
||||
|
||||
- TODO: Set `baseUrl` in `docs/quartz.config.ts` to the hosted docs domain, or `localhost` if the docs are only previewed locally
|
||||
- TODO: Rename `.dagger/src/project_template_ci/` and the exported Dagger class to match the generated project
|
||||
111
docs/tutorials/ai-assistance-guide.md
Normal file
111
docs/tutorials/ai-assistance-guide.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
---
|
||||
title: AI Assistance Guide
|
||||
modified: 2026-04-19
|
||||
tags:
|
||||
- tutorials
|
||||
- ai
|
||||
---
|
||||
|
||||
# AI Assistance Guide
|
||||
|
||||
> **Audiences:** AI, Owner
|
||||
|
||||
This guide provides context for AI agents (like Claude Code) assisting with this project.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
These are non-negotiable for AI agents working in this repo:
|
||||
|
||||
1. **Run `mise run ai-docs` at session start** — Review project documentation
|
||||
2. **Never commit secrets** — The repo may be public
|
||||
3. **Wait for user review before deploying** — Create PRs, don't auto-deploy
|
||||
4. **Never merge PRs without explicit request** — The user merges after review
|
||||
|
||||
Full rules are in the repo's `AGENTS.md`. See [[agent-change-process]] for the C0/C1/C2 change classification methodology — C0 (direct to main), C1 (feature branch + PR), C2 (Mikado Branch Invariant).
|
||||
|
||||
## Workflow Conventions
|
||||
|
||||
### Branching
|
||||
|
||||
Branching depends on change classification (see [[agent-change-process]]):
|
||||
|
||||
- **C0 (Quick Fix):** Commit directly to main — no branch or PR needed
|
||||
- **C1/C2:** Feature branch required:
|
||||
```bash
|
||||
git checkout main && git pull
|
||||
git checkout -b feature/descriptive-name
|
||||
# ... make changes ...
|
||||
git commit -m "Description"
|
||||
```
|
||||
|
||||
### Pull Requests
|
||||
|
||||
Use the forge's `tea` CLI:
|
||||
```bash
|
||||
tea pr create --title "Title" --description "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
## Testing
|
||||
- [ ] Test step
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Changelog Fragments
|
||||
|
||||
Add a fragment for user-visible changes:
|
||||
```bash
|
||||
# C1/C2: use branch name
|
||||
echo "Description" > docs/changelog.d/branch-name.feature.md
|
||||
|
||||
# C0: use orphan prefix (no branch to name after)
|
||||
echo "Description" > docs/changelog.d/+descriptive-slug.feature.md
|
||||
```
|
||||
|
||||
Types (file suffix): `.feature`, `.bugfix`, `.infra`, `.doc`, `.ai`, `.misc`
|
||||
|
||||
### Wiki-Link Formatting
|
||||
|
||||
Use simple wiki-links without alternate text or extra spaces:
|
||||
- Prefer `[[borgmatic]]` over `[[borgmatic|Borgmatic]]`
|
||||
- Only use alternate text when grammatically warranted
|
||||
- No spaces around the pipe: `[[path|Text]]` not `[[ path|Text ]]`
|
||||
|
||||
When editing documentation, rewrite links to follow this convention as you encounter them.
|
||||
|
||||
## Mise Tasks
|
||||
|
||||
Run `mise tasks` to list all available tasks.
|
||||
|
||||
| Task | When to Use |
|
||||
|------|-------------|
|
||||
| `ai-docs` | At session start — review project documentation |
|
||||
| `changelog-check` | Validate that changelog fragments are flat files in `docs/changelog.d/` |
|
||||
| `docs-check-frontmatter` | Check required frontmatter fields across docs |
|
||||
| `docs-preview` | Preview a released docs tarball locally |
|
||||
| `docs-mikado` | View active Mikado dependency chains for C2 changes |
|
||||
| `docs-mikado --resume` | Resume a C2 chain: detect branch, show state and next steps |
|
||||
| `pr-comments` | Check unresolved PR comments during review |
|
||||
| `runner-logs` | Inspect recent Forgejo Actions runs or fetch logs for a job |
|
||||
| `docs-check-links` | Validate wiki-links in documentation (includes orphan detection) |
|
||||
| `docs-check-index` | Check every doc is referenced in its category index |
|
||||
| `docs-check-filenames` | Check for duplicate doc filenames |
|
||||
|
||||
## Repository Tooling
|
||||
|
||||
This project ships two Forgejo workflows:
|
||||
|
||||
- `build.yaml` runs on pushes and pull requests targeting `main`, executes `prek run --all-files`, and then runs an optional project hook at `.forgejo/scripts/build` when present.
|
||||
- `release.yaml` is a manual workflow that computes the next version, optionally builds `CHANGELOG.md` from fragments, packages Quartz docs via Dagger, runs an optional `.forgejo/scripts/release` hook for extra assets, creates the Forgejo release, and pushes changelog updates back to `main` when fragments were consumed.
|
||||
|
||||
- TODO: Rename `.dagger/src/project_template_ci/` and the exported Dagger class during first-time setup.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
| Pitfall | Correct Approach |
|
||||
|---------|------------------|
|
||||
| Deploying without review | Create PR first, wait for user approval |
|
||||
| Re-explaining reference material | Link to reference cards instead |
|
||||
| Committing to main without classifying | Classify as C0/C1/C2 first — only C0 goes to main |
|
||||
15
docs/tutorials/tutorials.md
Normal file
15
docs/tutorials/tutorials.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: Tutorials
|
||||
modified: 2026-03-03
|
||||
tags:
|
||||
- tutorials
|
||||
- meta
|
||||
---
|
||||
|
||||
# Tutorials
|
||||
|
||||
Learning-oriented guides for getting started.
|
||||
|
||||
## Getting Started
|
||||
|
||||
- [[ai-assistance-guide]] — Guide for AI agents working in this repo
|
||||
21
mise-tasks/ai-docs
Executable file
21
mise-tasks/ai-docs
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env bash
|
||||
#MISE description="Prime AI context with key project documentation"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DOCS_DIR="$(cd "$(dirname "$0")/.." && pwd)/docs"
|
||||
|
||||
# Key files for AI context priming, in order of importance
|
||||
FILES=(
|
||||
"$DOCS_DIR/tutorials/ai-assistance-guide.md"
|
||||
"$DOCS_DIR/how-to/agent-change-process.md"
|
||||
"$DOCS_DIR/index.md"
|
||||
"$DOCS_DIR/reference/reference.md"
|
||||
"$DOCS_DIR/how-to/how-to.md"
|
||||
"$DOCS_DIR/explanation/explanation.md"
|
||||
"$DOCS_DIR/tutorials/tutorials.md"
|
||||
)
|
||||
|
||||
# Concatenate files with headers showing paths
|
||||
# Defaults are tuned for AI consumption (plain text, file headers only)
|
||||
bat --style=header --color=never --decorations=always "$@" "${FILES[@]}"
|
||||
26
mise-tasks/changelog-check
Executable file
26
mise-tasks/changelog-check
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
#MISE description="Validate changelog fragments are flat files in docs/changelog.d/"
|
||||
# Ensures no fragments end up in subdirectories (e.g. from branch names like feature/foo).
|
||||
# Towncrier expects flat <name>.<type>.md files, not nested paths.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CHANGELOG_DIR="$(git rev-parse --show-toplevel)/docs/changelog.d"
|
||||
|
||||
errors=0
|
||||
while IFS= read -r -d '' entry; do
|
||||
rel="${entry#"$CHANGELOG_DIR/"}"
|
||||
if [[ "$rel" == */* ]]; then
|
||||
echo "ERROR: changelog fragment in subdirectory: docs/changelog.d/$rel"
|
||||
echo " Move to: docs/changelog.d/$(echo "$rel" | tr '/' '-')"
|
||||
errors=$((errors + 1))
|
||||
fi
|
||||
done < <(find "$CHANGELOG_DIR" -name '*.md' -print0)
|
||||
|
||||
if [ "$errors" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "$errors fragment(s) in subdirectories. Towncrier requires flat files in docs/changelog.d/."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All changelog fragments are correctly placed."
|
||||
85
mise-tasks/docs-check-filenames
Executable file
85
mise-tasks/docs-check-filenames
Executable file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["rich>=13.0.0"]
|
||||
# ///
|
||||
#MISE description="Detect duplicate filenames in documentation"
|
||||
"""Detect duplicate filenames in documentation.
|
||||
|
||||
This script scans all markdown files in the docs/ directory (excluding
|
||||
changelog.d/) and reports any duplicate filenames that could
|
||||
cause wiki-link resolution issues.
|
||||
|
||||
With Quartz, wiki-links like [[filename]] resolve by filename,
|
||||
so filenames must be unique across the documentation.
|
||||
|
||||
Usage: mise run docs-check-filenames
|
||||
"""
|
||||
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
DOCS_DIR = Path(__file__).parent.parent / "docs"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
console = Console()
|
||||
|
||||
# Collect all filenames and their paths
|
||||
# Key: filename (without .md), Value: list of file paths
|
||||
filenames: dict[str, list[str]] = defaultdict(list)
|
||||
|
||||
# Scan all markdown files (excluding changelog.d/)
|
||||
for md_file in sorted(DOCS_DIR.rglob("*.md")):
|
||||
if "changelog.d" in md_file.parts:
|
||||
continue
|
||||
|
||||
rel_path = str(md_file.relative_to(DOCS_DIR))
|
||||
filename = md_file.stem # filename without .md
|
||||
filenames[filename].append(rel_path)
|
||||
|
||||
# Find duplicates
|
||||
duplicates = {name: paths for name, paths in filenames.items() if len(paths) > 1}
|
||||
|
||||
# Print results
|
||||
console.print("[bold]Doc Filename Inventory[/bold]")
|
||||
console.print()
|
||||
console.print("With Quartz, wiki-links like [[filename]] resolve by filename,")
|
||||
console.print("so filenames must be unique across the documentation.")
|
||||
console.print()
|
||||
|
||||
# Duplicates table (if any)
|
||||
if duplicates:
|
||||
console.print("[bold red]Duplicate Filenames Found[/bold red]")
|
||||
dup_table = Table(show_header=True, header_style="bold")
|
||||
dup_table.add_column("Filename")
|
||||
dup_table.add_column("Paths")
|
||||
|
||||
for name in sorted(duplicates.keys()):
|
||||
paths = duplicates[name]
|
||||
dup_table.add_row(name, "\n".join(paths))
|
||||
|
||||
console.print(dup_table)
|
||||
console.print()
|
||||
|
||||
# Summary
|
||||
console.print(f"Total files: {sum(len(p) for p in filenames.values())}")
|
||||
console.print(f"Unique filenames: {len(filenames)}")
|
||||
console.print(f"Duplicate filenames: {len(duplicates)}")
|
||||
|
||||
if duplicates:
|
||||
console.print()
|
||||
console.print("[bold red]Action required:[/bold red] Rename files to ensure unique wiki-link resolution.")
|
||||
return 1
|
||||
|
||||
console.print()
|
||||
console.print("[bold green]All filenames are unique![/bold green]")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
115
mise-tasks/docs-check-frontmatter
Executable file
115
mise-tasks/docs-check-frontmatter
Executable file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["rich>=13.0.0"]
|
||||
# ///
|
||||
#MISE description="Check that all docs have required frontmatter fields"
|
||||
"""Validate that all documentation files have required YAML frontmatter.
|
||||
|
||||
Required fields: title, tags, modified
|
||||
|
||||
Scans all markdown files in docs/ (excluding changelog.d/) and checks
|
||||
that each file has YAML frontmatter containing the required fields.
|
||||
|
||||
Usage: mise run docs-check-frontmatter
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
DOCS_DIR = Path(__file__).parent.parent / "docs"
|
||||
HOWTO_DIR = DOCS_DIR / "how-to"
|
||||
REQUIRED_FIELDS = {"title", "tags", "modified"}
|
||||
# These fields are only permitted in docs/how-to/
|
||||
HOWTO_ONLY_FIELDS = {"status", "requires"}
|
||||
|
||||
# Match YAML frontmatter block
|
||||
FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
|
||||
|
||||
# Match top-level YAML keys (not indented)
|
||||
KEY_PATTERN = re.compile(r"^([a-zA-Z][a-zA-Z0-9_-]*):", re.MULTILINE)
|
||||
|
||||
|
||||
def extract_frontmatter_keys(file_path: Path) -> set[str] | None:
|
||||
"""Extract top-level keys from YAML frontmatter. Returns None if no frontmatter."""
|
||||
content = file_path.read_text()
|
||||
match = FRONTMATTER_PATTERN.match(content)
|
||||
if not match:
|
||||
return None
|
||||
frontmatter = match.group(1)
|
||||
return set(KEY_PATTERN.findall(frontmatter))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
console = Console()
|
||||
console.print("[bold]Frontmatter Validation[/bold]")
|
||||
console.print(f"Required fields: {', '.join(sorted(REQUIRED_FIELDS))}")
|
||||
console.print()
|
||||
|
||||
missing_issues: list[tuple[str, set[str]]] = []
|
||||
misplaced_issues: list[tuple[str, set[str]]] = []
|
||||
|
||||
for md_file in sorted(DOCS_DIR.rglob("*.md")):
|
||||
if "changelog.d" in md_file.parts:
|
||||
continue
|
||||
|
||||
rel_path = str(md_file.relative_to(DOCS_DIR))
|
||||
keys = extract_frontmatter_keys(md_file)
|
||||
|
||||
if keys is None:
|
||||
missing_issues.append((rel_path, REQUIRED_FIELDS))
|
||||
continue
|
||||
|
||||
missing = REQUIRED_FIELDS - keys
|
||||
if missing:
|
||||
missing_issues.append((rel_path, missing))
|
||||
|
||||
# Check that status/requires only appear in how-to docs
|
||||
is_howto = HOWTO_DIR in md_file.parents or md_file.parent == HOWTO_DIR
|
||||
if not is_howto:
|
||||
misplaced = keys & HOWTO_ONLY_FIELDS
|
||||
if misplaced:
|
||||
misplaced_issues.append((rel_path, misplaced))
|
||||
|
||||
has_issues = bool(missing_issues or misplaced_issues)
|
||||
|
||||
if missing_issues:
|
||||
console.print("[bold red]Missing Required Frontmatter[/bold red]")
|
||||
console.print()
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("File")
|
||||
table.add_column("Missing Fields")
|
||||
|
||||
for rel_path, missing in missing_issues:
|
||||
table.add_row(rel_path, ", ".join(sorted(missing)))
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
if misplaced_issues:
|
||||
console.print("[bold red]Misplaced Frontmatter Fields[/bold red]")
|
||||
console.print(f"[dim]These fields are only allowed in {HOWTO_DIR.relative_to(DOCS_DIR)}/[/dim]")
|
||||
console.print()
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("File")
|
||||
table.add_column("Disallowed Fields")
|
||||
|
||||
for rel_path, misplaced in misplaced_issues:
|
||||
table.add_row(rel_path, ", ".join(sorted(misplaced)))
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
if has_issues:
|
||||
return 1
|
||||
|
||||
console.print("[bold green]All docs have required frontmatter![/bold green]")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
117
mise-tasks/docs-check-index
Executable file
117
mise-tasks/docs-check-index
Executable file
|
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["rich>=13.0.0"]
|
||||
# ///
|
||||
#MISE description="Check that every doc is referenced in its category index"
|
||||
"""Check that every doc in a Diataxis category is referenced in its index.
|
||||
|
||||
Each Diataxis category (tutorials, reference, how-to, explanation) has an
|
||||
index file that should wiki-link to every doc in that category directory.
|
||||
|
||||
A doc is considered referenced if its filename stem appears as a wiki-link
|
||||
target (e.g., alloy.md is matched by [[alloy]]) in the category index.
|
||||
|
||||
Index files are excluded from the self-check.
|
||||
|
||||
Usage: mise run docs-check-index
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.table import Table
|
||||
|
||||
DOCS_DIR = Path(__file__).parent.parent / "docs"
|
||||
|
||||
# Category directories and their index files
|
||||
CATEGORIES = {
|
||||
"tutorials": "tutorials/tutorials.md",
|
||||
"reference": "reference/reference.md",
|
||||
"how-to": "how-to/how-to.md",
|
||||
"explanation": "explanation/explanation.md",
|
||||
}
|
||||
|
||||
# Regex to match wiki-links: [[Target]] or [[Target|Display]]
|
||||
WIKILINK_PATTERN = re.compile(r"\[\[([^\]|]+)(\|[^\]]+)?\]\]")
|
||||
|
||||
# Regex to match inline code (backticks)
|
||||
INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
|
||||
|
||||
|
||||
def extract_link_targets(file_path: Path) -> set[str]:
|
||||
"""Extract all wiki-link targets from a file (ignoring inline code)."""
|
||||
content = file_path.read_text()
|
||||
targets: set[str] = set()
|
||||
|
||||
for line in content.splitlines():
|
||||
line_without_code = INLINE_CODE_PATTERN.sub("", line)
|
||||
for match in WIKILINK_PATTERN.finditer(line_without_code):
|
||||
targets.add(match.group(1).strip())
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def main() -> int:
|
||||
console = Console()
|
||||
console.print("[bold]Category Index Validation[/bold]")
|
||||
console.print()
|
||||
|
||||
has_errors = False
|
||||
missing: list[tuple[str, str, str]] = [] # (category, stem, file)
|
||||
|
||||
for category, index_rel in CATEGORIES.items():
|
||||
index_path = DOCS_DIR / index_rel
|
||||
if not index_path.exists():
|
||||
console.print(f"[yellow]Warning: index file not found: {index_rel}[/yellow]")
|
||||
continue
|
||||
|
||||
category_dir = DOCS_DIR / category
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Get all wiki-link targets from the index
|
||||
index_targets = extract_link_targets(index_path)
|
||||
index_stem = index_path.stem
|
||||
|
||||
# Check each doc in the category directory
|
||||
for md_file in sorted(category_dir.rglob("*.md")):
|
||||
if "changelog.d" in md_file.parts:
|
||||
continue
|
||||
stem = md_file.stem
|
||||
# Skip the index file itself
|
||||
if stem == index_stem:
|
||||
continue
|
||||
if stem not in index_targets:
|
||||
rel_path = str(md_file.relative_to(DOCS_DIR))
|
||||
missing.append((category, stem, rel_path))
|
||||
|
||||
if missing:
|
||||
has_errors = True
|
||||
console.print("[bold red]Docs Missing From Category Index[/bold red]")
|
||||
console.print("These docs are not wiki-linked from their category index file.")
|
||||
console.print()
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Category")
|
||||
table.add_column("File")
|
||||
table.add_column("Add To")
|
||||
|
||||
for category, stem, rel_path in missing:
|
||||
table.add_row(category, rel_path, CATEGORIES[category])
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
if has_errors:
|
||||
return 1
|
||||
|
||||
console.print(f"Checked {len(CATEGORIES)} category indexes.")
|
||||
console.print("[bold green]All docs are referenced in their category index![/bold green]")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
255
mise-tasks/docs-check-links
Executable file
255
mise-tasks/docs-check-links
Executable file
|
|
@ -0,0 +1,255 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["rich>=13.0.0"]
|
||||
# ///
|
||||
#MISE description="Validate all wiki-links point to existing doc filenames"
|
||||
"""Validate that all wiki-links in documentation point to existing files.
|
||||
|
||||
This script scans all markdown files in the docs/ directory (excluding
|
||||
changelog.d/), extracts wiki-links, and verifies each link target
|
||||
exists as a unique filename in the documentation.
|
||||
|
||||
Wiki-link formats supported:
|
||||
- [[filename]] - links to filename.md (must be unique across all docs)
|
||||
- [[target|Display Text]] - filename with display text
|
||||
|
||||
Path-based links (containing '/') are NOT supported to ensure all
|
||||
filenames are unique and links work correctly in obsidian.nvim.
|
||||
|
||||
Usage: mise run docs-check-links
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.table import Table
|
||||
|
||||
DOCS_DIR = Path(__file__).parent.parent / "docs"
|
||||
|
||||
# Regex to match wiki-links: [[Target]] or [[Target|Display]]
|
||||
# Captures: group(1) = target (may have spaces), group(2) = full "|Display" part if present
|
||||
WIKILINK_PATTERN = re.compile(r"\[\[([^\]|]+)(\|[^\]]+)?\]\]")
|
||||
|
||||
# Regex to match inline code (backticks)
|
||||
INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
|
||||
|
||||
|
||||
def extract_wikilinks(file_path: Path) -> list[tuple[str, int, bool]]:
|
||||
"""Extract all wiki-link targets from a markdown file with line numbers.
|
||||
|
||||
Returns list of (target, line_num, has_spaces) tuples.
|
||||
has_spaces is True if the target or pipe separator had surrounding spaces.
|
||||
|
||||
Ignores wiki-links inside inline code (backticks) as these are examples.
|
||||
"""
|
||||
content = file_path.read_text()
|
||||
links = []
|
||||
|
||||
for line_num, line in enumerate(content.splitlines(), start=1):
|
||||
# Remove inline code before searching for wiki-links
|
||||
line_without_code = INLINE_CODE_PATTERN.sub("", line)
|
||||
for match in WIKILINK_PATTERN.finditer(line_without_code):
|
||||
raw_target = match.group(1)
|
||||
target = raw_target.strip()
|
||||
pipe_part = match.group(2) # "|Display" or None
|
||||
# Check for spaces: in target, or around the pipe
|
||||
has_spaces = raw_target != target
|
||||
if pipe_part and (raw_target.endswith(" ") or pipe_part.startswith("| ")):
|
||||
has_spaces = True
|
||||
links.append((target, line_num, has_spaces))
|
||||
|
||||
return links
|
||||
|
||||
|
||||
def main() -> int:
|
||||
console = Console()
|
||||
|
||||
# Collect all valid targets (both filenames and paths)
|
||||
valid_targets: set[str] = set()
|
||||
# Track which filenames are ambiguous (appear multiple times)
|
||||
filename_counts: dict[str, list[str]] = {}
|
||||
|
||||
# Scan all markdown files (excluding changelog.d/)
|
||||
for md_file in DOCS_DIR.rglob("*.md"):
|
||||
if "changelog.d" in md_file.parts:
|
||||
continue
|
||||
# Track filename occurrences
|
||||
filename = md_file.stem
|
||||
rel_path_str = str(md_file.relative_to(DOCS_DIR).with_suffix(""))
|
||||
if filename not in filename_counts:
|
||||
filename_counts[filename] = []
|
||||
filename_counts[filename].append(rel_path_str)
|
||||
# Add relative path without extension (e.g., "reference/services/alloy")
|
||||
valid_targets.add(rel_path_str)
|
||||
|
||||
# Only add filenames that are unique (not ambiguous)
|
||||
ambiguous_filenames: set[str] = set()
|
||||
for filename, paths in filename_counts.items():
|
||||
if len(paths) == 1:
|
||||
valid_targets.add(filename)
|
||||
else:
|
||||
ambiguous_filenames.add(filename)
|
||||
|
||||
# Special case: files at repo root that are copied into docs during build
|
||||
# These are valid link targets even though they don't exist in docs/
|
||||
REPO_ROOT = DOCS_DIR.parent
|
||||
BUILD_TIME_DOCS = ["CHANGELOG.md"]
|
||||
for filename in BUILD_TIME_DOCS:
|
||||
if (REPO_ROOT / filename).exists():
|
||||
valid_targets.add(Path(filename).stem)
|
||||
|
||||
# Collect all broken, ambiguous, path-based, and spaced links
|
||||
broken_links: list[tuple[str, int, str]] = []
|
||||
ambiguous_links: list[tuple[str, int, str, list[str]]] = []
|
||||
path_links: list[tuple[str, int, str]] = []
|
||||
spaced_links: list[tuple[str, int, str]] = []
|
||||
|
||||
# Track which doc stems are linked-to from other docs (for orphan detection)
|
||||
all_doc_stems: set[str] = set(filename_counts.keys())
|
||||
linked_stems: set[str] = set()
|
||||
|
||||
# Scan all markdown files for wiki-links (excluding changelog.d/)
|
||||
for md_file in sorted(DOCS_DIR.rglob("*.md")):
|
||||
if "changelog.d" in md_file.parts:
|
||||
continue
|
||||
|
||||
rel_path = str(md_file.relative_to(DOCS_DIR))
|
||||
source_stem = md_file.stem
|
||||
links = extract_wikilinks(md_file)
|
||||
|
||||
for target, line_num, has_spaces in links:
|
||||
if has_spaces:
|
||||
# Links with spaces in target or around pipe are not allowed
|
||||
spaced_links.append((rel_path, line_num, target))
|
||||
continue
|
||||
|
||||
# Handle anchor links: [[#Heading]] or [[file#Heading]]
|
||||
# Strip the #fragment for validation; pure anchors (#Heading) skip file check
|
||||
file_target = target
|
||||
if "#" in target:
|
||||
file_target = target.split("#", 1)[0]
|
||||
if not file_target:
|
||||
# Pure in-page anchor like [[#Break-glass shutoff]] — always valid
|
||||
continue
|
||||
|
||||
if "/" in file_target:
|
||||
# Path-based links are not allowed - use simple filenames only
|
||||
path_links.append((rel_path, line_num, target))
|
||||
elif file_target in ambiguous_filenames:
|
||||
# Link uses an ambiguous filename - needs to be renamed
|
||||
ambiguous_links.append((rel_path, line_num, target, filename_counts[file_target]))
|
||||
elif file_target not in valid_targets:
|
||||
broken_links.append((rel_path, line_num, target))
|
||||
elif file_target != source_stem:
|
||||
# Valid link to a different doc — record it for orphan detection
|
||||
linked_stems.add(file_target)
|
||||
|
||||
# Print results
|
||||
console.print("[bold]Wiki-Link Validation[/bold]")
|
||||
console.print()
|
||||
console.print(f"Found {len(valid_targets)} valid link targets in documentation.")
|
||||
console.print()
|
||||
|
||||
has_errors = False
|
||||
|
||||
if spaced_links:
|
||||
has_errors = True
|
||||
console.print("[bold red]Wiki-Links With Spaces Found[/bold red]")
|
||||
console.print("Wiki-links must not have spaces in the target or around the pipe.")
|
||||
console.print("Use [[target|Display Text]] not [[target | Display Text]].")
|
||||
console.print()
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("File")
|
||||
table.add_column("Line", justify="right")
|
||||
table.add_column("Target")
|
||||
|
||||
for file_path, line_num, target in spaced_links:
|
||||
table.add_row(file_path, str(line_num), escape(f"[[{target}]]"))
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
if path_links:
|
||||
has_errors = True
|
||||
console.print("[bold red]Path-Based Wiki-Links Found[/bold red]")
|
||||
console.print("Wiki-links must use simple filenames only (no '/' paths).")
|
||||
console.print("Rename files to be unique, then use [[filename]] format.")
|
||||
console.print()
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("File")
|
||||
table.add_column("Line", justify="right")
|
||||
table.add_column("Target")
|
||||
|
||||
for file_path, line_num, target in path_links:
|
||||
table.add_row(file_path, str(line_num), escape(f"[[{target}]]"))
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
if ambiguous_links:
|
||||
has_errors = True
|
||||
console.print("[bold red]Ambiguous Wiki-Links Found[/bold red]")
|
||||
console.print("These links use filenames that exist in multiple locations.")
|
||||
console.print("Rename files to be unique across all documentation.")
|
||||
console.print()
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("File")
|
||||
table.add_column("Line", justify="right")
|
||||
table.add_column("Target")
|
||||
table.add_column("Possible Paths")
|
||||
|
||||
for file_path, line_num, target, paths in ambiguous_links:
|
||||
table.add_row(file_path, str(line_num), escape(f"[[{target}]]"), "\n".join(paths))
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
if broken_links:
|
||||
has_errors = True
|
||||
console.print("[bold red]Broken Wiki-Links Found[/bold red]")
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("File")
|
||||
table.add_column("Line", justify="right")
|
||||
table.add_column("Target")
|
||||
|
||||
for file_path, line_num, target in broken_links:
|
||||
table.add_row(file_path, str(line_num), escape(f"[[{target}]]"))
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("Each wiki-link target must match a filename or path in docs/.")
|
||||
console.print()
|
||||
|
||||
# Orphan detection: docs not linked from any other doc
|
||||
ORPHAN_EXCEPTIONS = {"index"}
|
||||
orphan_stems = sorted(all_doc_stems - linked_stems - ORPHAN_EXCEPTIONS)
|
||||
if orphan_stems:
|
||||
has_errors = True
|
||||
console.print("[bold red]Orphan Documents Found[/bold red]")
|
||||
console.print("These docs are not linked from any other document.")
|
||||
console.print()
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("File")
|
||||
table.add_column("Stem")
|
||||
|
||||
for stem in orphan_stems:
|
||||
paths = filename_counts[stem]
|
||||
for path in paths:
|
||||
table.add_row(f"{path}.md", stem)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
if has_errors:
|
||||
return 1
|
||||
|
||||
console.print("[bold green]All wiki-links are valid![/bold green]")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
655
mise-tasks/docs-mikado
Executable file
655
mise-tasks/docs-mikado
Executable file
|
|
@ -0,0 +1,655 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["httpx>=0.28.0", "pyyaml>=6.0", "rich>=13.0.0", "typer>=0.15.0"]
|
||||
# ///
|
||||
#MISE description="View active Mikado dependency chains for C2 changes"
|
||||
#USAGE arg "[card]" help="Card stem to show chain for"
|
||||
#USAGE flag "--all" help="Show all cards in full, including complete ones"
|
||||
#USAGE flag "--resume" help="Resume a chain: detect branch, show state and next steps"
|
||||
"""View active Mikado dependency chains for C2 changes.
|
||||
|
||||
Scans all markdown files in docs/ for YAML frontmatter with ``status: active``
|
||||
and ``requires`` fields, then builds and displays the Mikado dependency graph.
|
||||
|
||||
Usage:
|
||||
mise run docs-mikado # list all active chains
|
||||
mise run docs-mikado deploy-service # show chain for a card
|
||||
mise run docs-mikado deploy-service --all # include complete cards in full
|
||||
mise run docs-mikado --resume # resume: detect branch, show state
|
||||
mise run docs-mikado --resume deploy-service # resume specific chain
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
DOCS_DIR = Path(__file__).parent.parent / "docs"
|
||||
REPO_DIR = Path(__file__).parent.parent
|
||||
|
||||
C2_COMMIT_RE = re.compile(r"^C2\(([^)]+)\):\s+(plan|impl|close|finalize)\s+(.+)$")
|
||||
|
||||
FORGE_API = "https://forge.eblu.me/api/v1"
|
||||
FORGE_REPO = "eblume/datasette-satisfactory"
|
||||
|
||||
|
||||
def extract_frontmatter(file_path: Path) -> dict | None:
|
||||
"""Extract YAML frontmatter from a markdown file."""
|
||||
content = file_path.read_text()
|
||||
if not content.startswith("---"):
|
||||
return None
|
||||
|
||||
end_idx = content.find("---", 3)
|
||||
if end_idx == -1:
|
||||
return None
|
||||
|
||||
frontmatter_text = content[3:end_idx].strip()
|
||||
try:
|
||||
return yaml.safe_load(frontmatter_text) or {}
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
|
||||
|
||||
def build_graph() -> dict[str, dict]:
|
||||
"""Build the dependency graph from all docs."""
|
||||
cards: dict[str, dict] = {}
|
||||
|
||||
for md_file in sorted(DOCS_DIR.rglob("*.md")):
|
||||
if "changelog.d" in md_file.parts:
|
||||
continue
|
||||
|
||||
frontmatter = extract_frontmatter(md_file)
|
||||
if frontmatter is None:
|
||||
continue
|
||||
|
||||
stem = md_file.stem
|
||||
cards[stem] = {
|
||||
"path": md_file,
|
||||
"title": frontmatter.get("title", stem),
|
||||
"status": frontmatter.get("status"),
|
||||
"branch": frontmatter.get("branch"),
|
||||
"requires": frontmatter.get("requires", []) or [],
|
||||
"required_by": [],
|
||||
}
|
||||
|
||||
# Compute inverse relationships
|
||||
for stem, card in cards.items():
|
||||
for req in card["requires"]:
|
||||
if req in cards:
|
||||
cards[req]["required_by"].append(stem)
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
def detect_cycles(cards: dict[str, dict]) -> list[list[str]]:
|
||||
"""Detect circular dependencies in the graph. Returns list of cycles."""
|
||||
cycles: list[list[str]] = []
|
||||
visited: set[str] = set()
|
||||
on_stack: set[str] = set()
|
||||
|
||||
def dfs(stem: str, path: list[str]) -> None:
|
||||
if stem in on_stack:
|
||||
cycle_start = path.index(stem)
|
||||
cycles.append(path[cycle_start:] + [stem])
|
||||
return
|
||||
if stem in visited or stem not in cards:
|
||||
return
|
||||
visited.add(stem)
|
||||
on_stack.add(stem)
|
||||
path.append(stem)
|
||||
for req in cards[stem]["requires"]:
|
||||
dfs(req, path)
|
||||
path.pop()
|
||||
on_stack.discard(stem)
|
||||
|
||||
for stem in cards:
|
||||
dfs(stem, [])
|
||||
|
||||
return cycles
|
||||
|
||||
|
||||
def is_active(card: dict) -> bool:
|
||||
"""Check if a card has status: active."""
|
||||
return card.get("status") == "active"
|
||||
|
||||
|
||||
def find_root_goals(cards: dict[str, dict]) -> list[str]:
|
||||
"""Find active cards that aren't required by another active card."""
|
||||
roots = []
|
||||
for stem, card in cards.items():
|
||||
if not is_active(card):
|
||||
continue
|
||||
# A root goal is not required by any other active card
|
||||
has_active_parent = any(
|
||||
is_active(cards[rb]) for rb in card["required_by"] if rb in cards
|
||||
)
|
||||
if not has_active_parent:
|
||||
roots.append(stem)
|
||||
return sorted(roots)
|
||||
|
||||
|
||||
def find_ready_leaves(cards: dict[str, dict], root: str) -> list[str]:
|
||||
"""Find active leaf nodes ready for work (no unmet active requires)."""
|
||||
leaves = []
|
||||
visited: set[str] = set()
|
||||
|
||||
def walk(stem: str) -> None:
|
||||
if stem in visited or stem not in cards:
|
||||
return
|
||||
visited.add(stem)
|
||||
card = cards[stem]
|
||||
if not is_active(card):
|
||||
return
|
||||
# Check if all requires are met (not active)
|
||||
unmet = [
|
||||
r for r in card["requires"] if r in cards and is_active(cards[r])
|
||||
]
|
||||
if not unmet:
|
||||
leaves.append(stem)
|
||||
for req in card["requires"]:
|
||||
walk(req)
|
||||
|
||||
walk(root)
|
||||
return sorted(leaves)
|
||||
|
||||
|
||||
def get_current_branch() -> str | None:
|
||||
"""Get the current git branch name."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_DIR,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
branch = result.stdout.strip()
|
||||
return branch if branch != "HEAD" else None
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def get_branch_commits(branch: str) -> list[dict]:
|
||||
"""Get commits on a branch since it diverged from main."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", "--format=%H %s", f"main..{branch}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_DIR,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
commits = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
sha, _, subject = line.partition(" ")
|
||||
match = C2_COMMIT_RE.match(subject)
|
||||
commits.append(
|
||||
{
|
||||
"sha": sha,
|
||||
"subject": subject,
|
||||
"chain": match.group(1) if match else None,
|
||||
"verb": match.group(2) if match else None,
|
||||
"description": match.group(3) if match else None,
|
||||
"conventional": match is not None,
|
||||
}
|
||||
)
|
||||
# git log returns newest first; reverse for chronological
|
||||
commits.reverse()
|
||||
return commits
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
|
||||
def classify_branch_position(commits: list[dict]) -> str:
|
||||
"""Determine where in the invariant the branch currently is."""
|
||||
if not commits:
|
||||
return "empty"
|
||||
|
||||
verbs = [c["verb"] for c in commits if c["conventional"]]
|
||||
if not verbs:
|
||||
return "unconventional"
|
||||
|
||||
last_verb = verbs[-1]
|
||||
has_impl = "impl" in verbs
|
||||
has_close = "close" in verbs
|
||||
|
||||
if last_verb == "finalize":
|
||||
return "finalized"
|
||||
if not has_impl and not has_close:
|
||||
return "planning"
|
||||
if last_verb == "close":
|
||||
return "between-cycles"
|
||||
if last_verb == "impl":
|
||||
return "mid-cycle"
|
||||
if last_verb == "plan" and has_impl:
|
||||
# plan after impl — invariant violation
|
||||
return "invariant-violation"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def find_pr_for_branch(branch: str) -> dict | None:
|
||||
"""Find an open PR for the given branch via the Forgejo API."""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{FORGE_API}/repos/{FORGE_REPO}/pulls",
|
||||
params={"state": "open", "limit": 50},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
for pr in resp.json():
|
||||
if pr.get("head", {}).get("ref") == branch:
|
||||
return {
|
||||
"number": pr["number"],
|
||||
"title": pr["title"],
|
||||
"url": pr.get("html_url", ""),
|
||||
}
|
||||
except (httpx.HTTPError, KeyError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_stash_list() -> list[str]:
|
||||
"""Get the list of git stash entries."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "stash", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_DIR,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().split("\n")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def show_resume(
|
||||
cards: dict[str, dict],
|
||||
console: Console,
|
||||
chain_name: str | None,
|
||||
) -> None:
|
||||
"""Show resume information for continuing C2 work."""
|
||||
current_branch = get_current_branch()
|
||||
roots = find_root_goals(cards)
|
||||
|
||||
# Find chain-to-branch mapping from goal cards
|
||||
chain_branches: dict[str, str | None] = {}
|
||||
for stem in roots:
|
||||
card = cards[stem]
|
||||
chain_branches[stem] = card.get("branch")
|
||||
|
||||
if chain_name:
|
||||
# Explicit chain requested — validate
|
||||
if chain_name not in cards:
|
||||
console.print(f"[red]Chain not found: {chain_name}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
if not is_active(cards[chain_name]):
|
||||
console.print(
|
||||
f"[yellow]{chain_name} is not active (no status: active)[/yellow]"
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
expected_branch = cards[chain_name].get("branch")
|
||||
if expected_branch and current_branch != expected_branch:
|
||||
console.print(
|
||||
f"[red]Branch mismatch:[/red] chain {chain_name} expects "
|
||||
f"branch [bold]{expected_branch}[/bold] but you are on "
|
||||
f"[bold]{current_branch}[/bold]"
|
||||
)
|
||||
console.print(
|
||||
f"\n[dim]Run: git checkout {expected_branch}[/dim]"
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
_show_chain_resume(cards, console, chain_name, current_branch)
|
||||
return
|
||||
|
||||
# No explicit chain — try to detect from branch
|
||||
if current_branch and current_branch.startswith("mikado/"):
|
||||
chain_stem = current_branch.removeprefix("mikado/")
|
||||
# Try to match to a goal card
|
||||
matched = None
|
||||
for stem in roots:
|
||||
if stem == chain_stem:
|
||||
matched = stem
|
||||
break
|
||||
if cards[stem].get("branch") == current_branch:
|
||||
matched = stem
|
||||
break
|
||||
|
||||
if matched:
|
||||
_show_chain_resume(cards, console, matched, current_branch)
|
||||
return
|
||||
else:
|
||||
console.print(
|
||||
f"[yellow]On branch {current_branch} but no matching "
|
||||
f"active chain found for stem '{chain_stem}'[/yellow]"
|
||||
)
|
||||
|
||||
# On main or unrecognized branch — list options
|
||||
console.print()
|
||||
if not roots:
|
||||
console.print("[dim]No active Mikado chains to resume.[/dim]")
|
||||
raise typer.Exit()
|
||||
|
||||
console.print("[bold]Active Mikado chains:[/bold]")
|
||||
console.print()
|
||||
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Chain")
|
||||
table.add_column("Title")
|
||||
table.add_column("Branch")
|
||||
table.add_column("Status")
|
||||
table.add_column("Ready Leaves")
|
||||
|
||||
for stem in roots:
|
||||
card = cards[stem]
|
||||
branch = card.get("branch")
|
||||
if branch:
|
||||
branch_display = f"[green]{branch}[/green]"
|
||||
status = "in progress"
|
||||
else:
|
||||
branch_display = "[dim]not started[/dim]"
|
||||
status = "planned"
|
||||
|
||||
leaves = find_ready_leaves(cards, stem)
|
||||
leaves_display = ", ".join(leaves) if leaves else "[dim]none[/dim]"
|
||||
|
||||
table.add_row(stem, card["title"], branch_display, status, leaves_display)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print(
|
||||
"[dim]Run: mise run docs-mikado --resume <chain> to resume a specific chain[/dim]"
|
||||
)
|
||||
|
||||
|
||||
def _show_chain_resume(
|
||||
cards: dict[str, dict],
|
||||
console: Console,
|
||||
chain: str,
|
||||
current_branch: str | None,
|
||||
) -> None:
|
||||
"""Show detailed resume info for a specific chain."""
|
||||
card = cards[chain]
|
||||
branch = card.get("branch") or current_branch
|
||||
|
||||
console.print()
|
||||
|
||||
# Look up PR for this branch
|
||||
pr_info = find_pr_for_branch(branch) if branch else None
|
||||
panel_lines = [
|
||||
f"[bold]{chain}[/bold] — {card['title']}",
|
||||
f"Branch: [green]{branch or 'not set'}[/green]",
|
||||
]
|
||||
if pr_info:
|
||||
panel_lines.append(
|
||||
f"PR: [cyan]#{pr_info['number']}[/cyan] — {pr_info['title']}"
|
||||
)
|
||||
if pr_info["url"]:
|
||||
panel_lines.append(f" {pr_info['url']}")
|
||||
|
||||
console.print(Panel("\n".join(panel_lines), title="Resuming Mikado Chain"))
|
||||
|
||||
if pr_info:
|
||||
console.print(
|
||||
f"[dim]Check PR comments: mise run pr-comments {pr_info['number']}[/dim]"
|
||||
)
|
||||
|
||||
# Show branch position if we can read commits
|
||||
if branch and current_branch == branch:
|
||||
commits = get_branch_commits(branch)
|
||||
position = classify_branch_position(commits)
|
||||
|
||||
position_labels = {
|
||||
"empty": "[dim]No commits on branch yet[/dim]",
|
||||
"planning": "[cyan]Planning phase[/cyan] — still adding cards",
|
||||
"mid-cycle": "[yellow]Mid-cycle[/yellow] — impl in progress, leaf not yet closed",
|
||||
"between-cycles": "[green]Between cycles[/green] — ready for next leaf",
|
||||
"finalized": "[green]Finalized[/green] — chain complete, awaiting merge",
|
||||
"unconventional": "[yellow]Unconventional commits[/yellow] — commits don't follow C2() convention",
|
||||
"invariant-violation": "[red]Invariant violation[/red] — plan commit found after impl",
|
||||
"unknown": "[dim]Unknown position[/dim]",
|
||||
}
|
||||
console.print(f"\nBranch position: {position_labels.get(position, position)}")
|
||||
|
||||
if commits:
|
||||
console.print("\n[bold]Recent commits:[/bold]")
|
||||
# Show last 10 commits
|
||||
for c in commits[-10:]:
|
||||
if c["conventional"]:
|
||||
verb_colors = {
|
||||
"plan": "cyan",
|
||||
"impl": "yellow",
|
||||
"close": "green",
|
||||
"finalize": "magenta",
|
||||
}
|
||||
color = verb_colors.get(c["verb"], "white")
|
||||
console.print(
|
||||
f" {c['sha'][:8]} [{color}]{c['verb']}[/{color}] {c['description']}"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f" {c['sha'][:8]} [dim]{c['subject']}[/dim]"
|
||||
)
|
||||
|
||||
# Show ready leaves
|
||||
leaves = find_ready_leaves(cards, chain)
|
||||
if leaves:
|
||||
console.print("\n[bold]Ready leaf nodes (no unmet dependencies):[/bold]")
|
||||
for leaf in leaves:
|
||||
leaf_card = cards[leaf]
|
||||
console.print(f" [green]→[/green] {leaf} — {leaf_card['title']}")
|
||||
else:
|
||||
console.print("\n[dim]No ready leaf nodes — all leaves have unmet dependencies[/dim]")
|
||||
|
||||
# Check for stashed work
|
||||
stashes = get_stash_list()
|
||||
if stashes:
|
||||
console.print(
|
||||
f"\n[yellow]Note: {len(stashes)} stash entr{'y' if len(stashes) == 1 else 'ies'} found:[/yellow]"
|
||||
)
|
||||
for entry in stashes[:5]:
|
||||
console.print(f" [dim]{entry}[/dim]")
|
||||
if len(stashes) > 5:
|
||||
console.print(f" [dim]... and {len(stashes) - 5} more[/dim]")
|
||||
console.print(
|
||||
"[dim]Review with: git stash list / git stash show[/dim]"
|
||||
)
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
def walk_chain(
|
||||
cards: dict[str, dict],
|
||||
stem: str,
|
||||
console: Console,
|
||||
show_all: bool,
|
||||
visited: set[str] | None = None,
|
||||
depth: int = 0,
|
||||
) -> None:
|
||||
"""Walk the dependency tree depth-first from a card."""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if stem in visited:
|
||||
card = cards.get(stem)
|
||||
if card:
|
||||
status = "[active]" if is_active(card) else "[complete]"
|
||||
console.print(
|
||||
f"{' ' * depth}[dim]↑ {stem} — {card['title']} {status}"
|
||||
f" (shared dep, shown above)[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(f"{' ' * depth}[red](missing: {stem})[/red]")
|
||||
return
|
||||
visited.add(stem)
|
||||
|
||||
card = cards.get(stem)
|
||||
if card is None:
|
||||
console.print(f"{' ' * depth}[red](missing: {stem})[/red]")
|
||||
return
|
||||
|
||||
active = is_active(card)
|
||||
|
||||
if active or show_all:
|
||||
# Print full card content
|
||||
console.print()
|
||||
separator = "=" * 72
|
||||
console.print(f"[bold cyan]{separator}[/bold cyan]")
|
||||
status_tag = "[active]" if active else "[complete]"
|
||||
console.print(
|
||||
f"[bold]{status_tag} {stem}[/bold] — {card['title']}"
|
||||
)
|
||||
console.print(f"[dim]{card['path'].relative_to(DOCS_DIR)}[/dim]")
|
||||
if card.get("branch"):
|
||||
console.print(f"[dim]branch: {card['branch']}[/dim]")
|
||||
if card["requires"]:
|
||||
console.print(f"[dim]requires: {', '.join(card['requires'])}[/dim]")
|
||||
if card["required_by"]:
|
||||
console.print(
|
||||
f"[dim]required-by: {', '.join(card['required_by'])}[/dim]"
|
||||
)
|
||||
console.print(f"[bold cyan]{separator}[/bold cyan]")
|
||||
console.print()
|
||||
content = card["path"].read_text()
|
||||
console.print(content)
|
||||
else:
|
||||
# One-line summary for complete cards
|
||||
console.print(
|
||||
f"{' ' * depth}[green][complete][/green] {stem} — {card['title']}"
|
||||
)
|
||||
|
||||
# Recurse into dependencies
|
||||
for req in card["requires"]:
|
||||
walk_chain(cards, req, console, show_all, visited, depth + 1)
|
||||
|
||||
|
||||
def main(
|
||||
card: Annotated[
|
||||
str | None, typer.Argument(help="Card stem to show chain for")
|
||||
] = None,
|
||||
all: Annotated[
|
||||
bool,
|
||||
typer.Option("--all", help="Show all cards in full, including complete ones"),
|
||||
] = False,
|
||||
resume: Annotated[
|
||||
bool,
|
||||
typer.Option("--resume", help="Resume a chain: detect branch, show state"),
|
||||
] = False,
|
||||
) -> None:
|
||||
console = Console()
|
||||
cards = build_graph()
|
||||
|
||||
# Check for circular dependencies
|
||||
cycles = detect_cycles(cards)
|
||||
if cycles:
|
||||
console.print("[bold red]Circular dependencies detected![/bold red]")
|
||||
for cycle in cycles:
|
||||
console.print(f" [red]{' → '.join(cycle)}[/red]")
|
||||
console.print()
|
||||
|
||||
if resume:
|
||||
show_resume(cards, console, chain_name=card)
|
||||
return
|
||||
|
||||
if card is None:
|
||||
# List all active Mikado chains
|
||||
roots = find_root_goals(cards)
|
||||
|
||||
if not roots:
|
||||
console.print("[dim]No active Mikado chains found.[/dim]")
|
||||
console.print(
|
||||
"[dim]Cards need status: active in frontmatter to appear here.[/dim]"
|
||||
)
|
||||
raise typer.Exit()
|
||||
|
||||
table = Table(
|
||||
title="Active Mikado Chains", show_header=True, header_style="bold"
|
||||
)
|
||||
table.add_column("Goal Card")
|
||||
table.add_column("Title")
|
||||
table.add_column("Branch")
|
||||
table.add_column("Active Deps", justify="right")
|
||||
table.add_column("Total Deps", justify="right")
|
||||
|
||||
for stem in roots:
|
||||
card_data = cards[stem]
|
||||
|
||||
# Count dependencies
|
||||
def count_deps(s: str, seen: set[str] | None = None) -> tuple[int, int]:
|
||||
if seen is None:
|
||||
seen = set()
|
||||
if s in seen:
|
||||
return 0, 0
|
||||
seen.add(s)
|
||||
active_count = 0
|
||||
total_count = 0
|
||||
c = cards.get(s)
|
||||
if c is None:
|
||||
return 0, 0
|
||||
for req in c["requires"]:
|
||||
total_count += 1
|
||||
req_card = cards.get(req)
|
||||
if req_card and is_active(req_card):
|
||||
active_count += 1
|
||||
sub_active, sub_total = count_deps(req, seen)
|
||||
active_count += sub_active
|
||||
total_count += sub_total
|
||||
return active_count, total_count
|
||||
|
||||
active_deps, total_deps = count_deps(stem)
|
||||
branch = card_data.get("branch")
|
||||
if branch:
|
||||
branch_display = branch
|
||||
else:
|
||||
branch_display = "[dim]not started[/dim]"
|
||||
|
||||
table.add_row(
|
||||
f"[bold]{stem}[/bold]",
|
||||
card_data["title"],
|
||||
branch_display,
|
||||
str(active_deps),
|
||||
str(total_deps),
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print(
|
||||
"[dim]Run: mise run docs-mikado <card> to see full chain[/dim]"
|
||||
)
|
||||
console.print(
|
||||
"[dim]Run: mise run docs-mikado --resume to resume work on a chain[/dim]"
|
||||
)
|
||||
else:
|
||||
if card not in cards:
|
||||
console.print(f"[red]Card not found: {card}[/red]")
|
||||
console.print("[dim]Available cards with status: active:[/dim]")
|
||||
for stem, data in sorted(cards.items()):
|
||||
if is_active(data):
|
||||
console.print(f" {stem} — {data['title']}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
walk_chain(cards, card, console, show_all=all)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
typer.run(main)
|
||||
83
mise-tasks/docs-preview
Executable file
83
mise-tasks/docs-preview
Executable file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["rich>=13.0.0", "typer>=0.24.0"]
|
||||
# ///
|
||||
#MISE description="Serve a docs release tarball locally in a browser"
|
||||
#USAGE arg "<tarball>" help="Path to docs tarball (e.g. ~/Downloads/docs-v0.0.2.tar.gz)"
|
||||
#USAGE flag "--port <port>" default="8484" help="Port for preview server (default 8484)"
|
||||
"""Extract a docs release tarball and serve it locally.
|
||||
|
||||
Downloads the docs tarball from a Forgejo release page manually, then
|
||||
point this task at it to preview the site in your browser.
|
||||
|
||||
Usage:
|
||||
mise run docs-preview ~/Downloads/docs-v0.0.2.tar.gz
|
||||
mise run docs-preview ~/Downloads/docs-v0.0.2.tar.gz --port 9090
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def main(
|
||||
tarball: Annotated[Path, typer.Argument(help="Path to docs tarball")],
|
||||
port: Annotated[int, typer.Option(help="Port for preview server")] = 8484,
|
||||
) -> None:
|
||||
tarball = tarball.expanduser().resolve()
|
||||
if not tarball.exists():
|
||||
console.print(f"[bold red]File not found:[/bold red] {tarball}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
docroot = Path(tempfile.mkdtemp(prefix="docs-preview-"))
|
||||
console.print(f"[dim]Extracting {tarball.name} to {docroot}...[/dim]")
|
||||
with tarfile.open(tarball, "r:gz") as tf:
|
||||
tf.extractall(docroot, filter="data")
|
||||
|
||||
url = f"http://localhost:{port}"
|
||||
console.print(f"\n[bold green]Serving docs at {url}[/bold green]")
|
||||
console.print(f"[yellow]Press Ctrl+C to stop.[/yellow]\n")
|
||||
|
||||
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
|
||||
|
||||
class QuartzHandler(http.server.SimpleHTTPRequestHandler):
|
||||
"""Handler that resolves clean URLs to index.html files."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(docroot), **kwargs)
|
||||
|
||||
def do_GET(self):
|
||||
# Quartz outputs foo.html, not foo/index.html, so clean URLs
|
||||
# like /tutorials/tutorials need to resolve to tutorials.html
|
||||
path = self.path.split("?")[0].split("#")[0]
|
||||
file = docroot / path.lstrip("/")
|
||||
if not file.suffix and not file.is_file():
|
||||
html_candidate = file.with_suffix(".html")
|
||||
if html_candidate.is_file():
|
||||
self.path = path + ".html"
|
||||
elif (file / "index.html").is_file():
|
||||
self.path = path.rstrip("/") + "/index.html"
|
||||
super().do_GET()
|
||||
|
||||
handler = QuartzHandler
|
||||
server = http.server.HTTPServer(("localhost", port), handler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
console.print("\n[dim]Stopped.[/dim]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
typer.run(main)
|
||||
301
mise-tasks/mikado-branch-invariant-check
Executable file
301
mise-tasks/mikado-branch-invariant-check
Executable file
|
|
@ -0,0 +1,301 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["rich>=13.0.0"]
|
||||
# ///
|
||||
#MISE description="Validate Mikado Branch Invariant on mikado/* branches"
|
||||
"""Validate the Mikado Branch Invariant for C2 change branches.
|
||||
|
||||
Runs as a commit-msg hook on mikado/* branches. Receives the commit message
|
||||
file as its first argument, classifies the incoming commit, appends it to the
|
||||
existing branch history, and validates the full sequence.
|
||||
|
||||
Checks:
|
||||
1. All commits follow the C2(<chain>): <verb> <description> convention
|
||||
2. The invariant ordering is maintained: plan commits come before impl/close
|
||||
3. No plan commits appear after any impl or close commits
|
||||
4. Close commits don't appear before impl commits in the same cycle
|
||||
5. The chain stem in commit messages matches the branch name
|
||||
6. impl commits don't modify Mikado card files (docs with mikado frontmatter)
|
||||
|
||||
Can also be run standalone (no arguments) to validate existing branch history.
|
||||
|
||||
Exit code 0 if valid (or not on a mikado/* branch), 1 if violations found.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
REPO_DIR = Path(__file__).parent.parent
|
||||
C2_COMMIT_RE = re.compile(r"^C2\(([^)]+)\):\s+(plan|impl|close|finalize)\s+(.+)$")
|
||||
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
|
||||
|
||||
|
||||
def get_current_branch() -> str | None:
|
||||
"""Get the current git branch name."""
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_DIR,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
branch = result.stdout.strip()
|
||||
return branch if branch != "HEAD" else None
|
||||
return None
|
||||
|
||||
|
||||
def get_branch_commits(branch: str) -> list[dict]:
|
||||
"""Get commits on a branch since it diverged from main."""
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", "--format=%H %s", f"main..{branch}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_DIR,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
commits = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
sha, _, subject = line.partition(" ")
|
||||
match = C2_COMMIT_RE.match(subject)
|
||||
commits.append(
|
||||
{
|
||||
"sha": sha,
|
||||
"subject": subject,
|
||||
"chain": match.group(1) if match else None,
|
||||
"verb": match.group(2) if match else None,
|
||||
"description": match.group(3) if match else None,
|
||||
"conventional": match is not None,
|
||||
}
|
||||
)
|
||||
# git log returns newest first; reverse for chronological
|
||||
commits.reverse()
|
||||
return commits
|
||||
|
||||
|
||||
def parse_commit_message(msg_path: str) -> str:
|
||||
"""Read and return the first line of a commit message file."""
|
||||
with open(msg_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
return line
|
||||
return ""
|
||||
|
||||
|
||||
def make_pending_commit(subject: str) -> dict:
|
||||
"""Create a pseudo-commit dict for the incoming (not yet created) commit."""
|
||||
match = C2_COMMIT_RE.match(subject)
|
||||
return {
|
||||
"sha": "(pending)",
|
||||
"subject": subject,
|
||||
"chain": match.group(1) if match else None,
|
||||
"verb": match.group(2) if match else None,
|
||||
"description": match.group(3) if match else None,
|
||||
"conventional": match is not None,
|
||||
}
|
||||
|
||||
|
||||
def is_mikado_card(content: str) -> bool:
|
||||
"""Check if file content has Mikado card frontmatter.
|
||||
|
||||
A file is a Mikado card if its YAML frontmatter contains any of:
|
||||
- requires: (dependency list, kept permanently on cards)
|
||||
- status: (e.g. 'active' for in-progress cards)
|
||||
- branch: mikado/... (goal cards linking to their branch)
|
||||
"""
|
||||
match = FRONTMATTER_RE.match(content)
|
||||
if not match:
|
||||
return False
|
||||
for line in match.group(1).split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("requires:"):
|
||||
return True
|
||||
if stripped.startswith("status:"):
|
||||
return True
|
||||
if stripped.startswith("branch:") and "mikado/" in stripped:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_file_at_ref(ref: str, path: str) -> str | None:
|
||||
"""Get file content at a git ref. Use ':path' for staged, 'sha:path' for commits."""
|
||||
result = subprocess.run(
|
||||
["git", "show", f"{ref}:{path}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_DIR,
|
||||
)
|
||||
return result.stdout if result.returncode == 0 else None
|
||||
|
||||
|
||||
def get_staged_md_files() -> list[str]:
|
||||
"""Get markdown files staged for commit (added, copied, modified, renamed)."""
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_DIR,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return [f for f in result.stdout.strip().split("\n") if f.endswith(".md")]
|
||||
|
||||
|
||||
def get_commit_md_files(sha: str) -> list[str]:
|
||||
"""Get markdown files changed in a specific commit."""
|
||||
result = subprocess.run(
|
||||
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO_DIR,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return [f for f in result.stdout.strip().split("\n") if f.endswith(".md")]
|
||||
|
||||
|
||||
def check_impl_files_staged() -> list[str]:
|
||||
"""Check that staged files for a pending impl commit don't include Mikado cards."""
|
||||
errors = []
|
||||
for path in get_staged_md_files():
|
||||
# Check staged version; fall back to HEAD for deletions
|
||||
content = get_file_at_ref("", path)
|
||||
if content is None:
|
||||
content = get_file_at_ref("HEAD", path)
|
||||
if content and is_mikado_card(content):
|
||||
errors.append(
|
||||
f"(pending): impl commit modifies Mikado card: {path} — "
|
||||
f"use 'plan' for card changes or 'close' to close leaf nodes"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def check_impl_files_historical(sha: str) -> list[str]:
|
||||
"""Check that a historical impl commit didn't modify Mikado cards."""
|
||||
errors = []
|
||||
for path in get_commit_md_files(sha):
|
||||
# Try file at this commit; fall back to parent for deletions
|
||||
content = get_file_at_ref(sha, path)
|
||||
if content is None:
|
||||
content = get_file_at_ref(f"{sha}~1", path)
|
||||
if content and is_mikado_card(content):
|
||||
errors.append(
|
||||
f"{sha[:8]}: impl commit modifies Mikado card: {path} — "
|
||||
f"use 'plan' for card changes or 'close' to close leaf nodes"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def check_invariant(commits: list[dict], chain_stem: str) -> list[str]:
|
||||
"""Check the Mikado Branch Invariant. Returns list of violation messages."""
|
||||
errors: list[str] = []
|
||||
|
||||
if not commits:
|
||||
return errors
|
||||
|
||||
seen_impl = False
|
||||
seen_close = False
|
||||
|
||||
for i, commit in enumerate(commits):
|
||||
sha_short = commit["sha"][:8]
|
||||
|
||||
if not commit["conventional"]:
|
||||
errors.append(
|
||||
f"{sha_short}: commit doesn't follow C2() convention: "
|
||||
f"{commit['subject']}"
|
||||
)
|
||||
continue
|
||||
|
||||
if commit["chain"] != chain_stem:
|
||||
errors.append(
|
||||
f"{sha_short}: chain mismatch — expected C2({chain_stem}) "
|
||||
f"but found C2({commit['chain']})"
|
||||
)
|
||||
|
||||
verb = commit["verb"]
|
||||
|
||||
if verb == "plan":
|
||||
if seen_impl or seen_close:
|
||||
errors.append(
|
||||
f"{sha_short}: plan commit after impl/close — "
|
||||
f"violates the Mikado Branch Invariant. "
|
||||
f"New prerequisites require a branch reset."
|
||||
)
|
||||
elif verb == "impl":
|
||||
seen_impl = True
|
||||
elif verb == "close":
|
||||
seen_close = True
|
||||
if not seen_impl:
|
||||
errors.append(
|
||||
f"{sha_short}: close commit without preceding impl — "
|
||||
f"leaf nodes should be closed after implementation work"
|
||||
)
|
||||
elif verb == "finalize":
|
||||
# finalize is the permitted exception — must be last
|
||||
if i != len(commits) - 1:
|
||||
errors.append(
|
||||
f"{sha_short}: finalize must be the last commit on the branch"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> None:
|
||||
console = Console(stderr=True)
|
||||
branch = get_current_branch()
|
||||
|
||||
if not branch or not branch.startswith("mikado/"):
|
||||
# Not on a mikado branch — nothing to check
|
||||
sys.exit(0)
|
||||
|
||||
chain_stem = branch.removeprefix("mikado/")
|
||||
commits = get_branch_commits(branch)
|
||||
|
||||
# If called with a commit message file (commit-msg hook), include the
|
||||
# pending commit in the validation
|
||||
if len(sys.argv) > 1:
|
||||
subject = parse_commit_message(sys.argv[1])
|
||||
if subject:
|
||||
commits.append(make_pending_commit(subject))
|
||||
|
||||
if not commits:
|
||||
# No commits on branch yet — valid (length-zero case)
|
||||
sys.exit(0)
|
||||
|
||||
errors = check_invariant(commits, chain_stem)
|
||||
|
||||
# Check file discipline: impl commits must not touch Mikado cards
|
||||
for commit in commits:
|
||||
if commit["verb"] != "impl":
|
||||
continue
|
||||
if commit["sha"] == "(pending)":
|
||||
# Pending commit — check staged files
|
||||
errors.extend(check_impl_files_staged())
|
||||
else:
|
||||
# Historical commit — check files in that commit
|
||||
errors.extend(check_impl_files_historical(commit["sha"]))
|
||||
|
||||
if errors:
|
||||
console.print("[bold red]Mikado Branch Invariant violations:[/bold red]")
|
||||
for error in errors:
|
||||
console.print(f" [red]✗[/red] {error}")
|
||||
console.print()
|
||||
console.print(
|
||||
"[dim]See: docs/how-to/agent-change-process.md "
|
||||
"§ The Mikado Branch Invariant[/dim]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
118
mise-tasks/pr-comments
Executable file
118
mise-tasks/pr-comments
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["httpx>=0.28.0", "rich>=13.0.0"]
|
||||
# ///
|
||||
#MISE description="List unresolved comments on a PR"
|
||||
#USAGE arg "<pr_number>" help="Pull request number"
|
||||
"""Fetch and display unresolved review comments on a pull request.
|
||||
|
||||
This script queries the Forge (Gitea) API to find all review comments that
|
||||
have not been resolved on a given PR. A comment is considered unresolved
|
||||
if its 'resolver' field is null.
|
||||
|
||||
Usage: mise run pr-comments <pr_number>
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
FORGE_API_BASE = "https://forge.eblu.me/api/v1"
|
||||
REPO_OWNER = "eblume"
|
||||
REPO_NAME = "datasette-satisfactory"
|
||||
|
||||
|
||||
def get_reviews(client: httpx.Client, pr_number: int) -> list[dict]:
|
||||
"""Get all reviews for a pull request."""
|
||||
response = client.get(
|
||||
f"{FORGE_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}/reviews"
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def get_review_comments(client: httpx.Client, pr_number: int, review_id: int) -> list[dict]:
|
||||
"""Get all comments for a specific review."""
|
||||
response = client.get(
|
||||
f"{FORGE_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}/reviews/{review_id}/comments"
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
console = Console()
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
console.print("[red]Error:[/red] Please provide a PR number")
|
||||
console.print("Usage: mise run pr-comments <pr_number>")
|
||||
return 1
|
||||
|
||||
try:
|
||||
pr_number = int(sys.argv[1])
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] '{sys.argv[1]}' is not a valid PR number")
|
||||
return 1
|
||||
|
||||
unresolved_comments: list[tuple[dict, dict]] = [] # (review, comment) pairs
|
||||
|
||||
with httpx.Client() as client:
|
||||
# Get all reviews
|
||||
try:
|
||||
reviews = get_reviews(client, pr_number)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
console.print(f"[red]Error:[/red] PR #{pr_number} not found")
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] API request failed: {e}")
|
||||
return 1
|
||||
|
||||
# For each review, get comments and filter to unresolved
|
||||
for review in reviews:
|
||||
try:
|
||||
comments = get_review_comments(client, pr_number, review["id"])
|
||||
except httpx.HTTPStatusError:
|
||||
continue # Skip reviews we can't fetch comments for
|
||||
|
||||
for comment in comments:
|
||||
if comment.get("resolver") is None:
|
||||
unresolved_comments.append((review, comment))
|
||||
|
||||
if not unresolved_comments:
|
||||
console.print(f"[green]No unresolved comments on PR #{pr_number}[/green]")
|
||||
return 0
|
||||
|
||||
# Display unresolved comments
|
||||
console.print(f"[bold]Unresolved Comments on PR #{pr_number}[/bold] ({len(unresolved_comments)} comments)")
|
||||
console.print("=" * 60)
|
||||
console.print()
|
||||
|
||||
for review, comment in unresolved_comments:
|
||||
# Header with file path and reviewer
|
||||
header = Text()
|
||||
path = comment.get("path", "unknown file")
|
||||
reviewer = review.get("user", {}).get("login", "unknown")
|
||||
header.append(f"{path}", style="bold cyan")
|
||||
header.append(f" (by {reviewer})")
|
||||
console.print(header)
|
||||
|
||||
# Comment body
|
||||
body = comment.get("body", "").strip()
|
||||
for line in body.split("\n"):
|
||||
console.print(f" {line}")
|
||||
|
||||
# Link to comment
|
||||
html_url = comment.get("html_url", "")
|
||||
if html_url:
|
||||
console.print(f" [dim]{html_url}[/dim]")
|
||||
|
||||
console.print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
283
mise-tasks/runner-logs
Executable file
283
mise-tasks/runner-logs
Executable file
|
|
@ -0,0 +1,283 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"]
|
||||
# ///
|
||||
#MISE description="List recent Forgejo Actions runs or fetch logs for a specific job"
|
||||
#USAGE arg "[run_number]" help="Run number to show jobs for (omit to list recent runs)"
|
||||
#USAGE flag "--job -j <job>" help="Job index (0-based) to fetch logs for"
|
||||
#USAGE flag "--repo <repo>" help="Forge repo (owner/name), default: detected from git remote"
|
||||
#USAGE flag "--limit -n <limit>" help="Max runs to display (0 for all)"
|
||||
#USAGE flag "--token <token>" help="Forgejo API token (default: read from 1Password)"
|
||||
"""List recent Forgejo Actions runs and fetch job logs.
|
||||
|
||||
Usage:
|
||||
mise run runner-logs # list recent runs (default 15)
|
||||
mise run runner-logs -n 0 # list ALL runs
|
||||
mise run runner-logs --repo eblume/hermes # list runs for a different repo
|
||||
mise run runner-logs 474 # show jobs in run 474
|
||||
mise run runner-logs 474 -j 1 # fetch logs for job 1 of run 474
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
FORGE_URL = "https://forge.ops.eblu.me"
|
||||
FORGE_API = f"{FORGE_URL}/api/v1"
|
||||
OP_TOKEN_REF = "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/api-token"
|
||||
|
||||
app = typer.Typer(add_completion=False)
|
||||
|
||||
|
||||
def resolve_token(explicit_token: str | None, console: Console) -> str:
|
||||
"""Resolve Forgejo API token: explicit flag > FORGEJO_TOKEN env > 1Password."""
|
||||
if explicit_token:
|
||||
return explicit_token
|
||||
env_token = os.environ.get("FORGEJO_TOKEN", "").strip()
|
||||
if env_token:
|
||||
return env_token
|
||||
console.print("[dim]Reading Forgejo API token from 1Password...[/dim]")
|
||||
result = subprocess.run(
|
||||
["op", "read", OP_TOKEN_REF],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def detect_repo_from_git() -> str | None:
|
||||
"""Sniff owner/repo from the git remote 'origin' URL."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return None
|
||||
url = result.stdout.strip()
|
||||
# Match SSH (git@host:owner/repo.git) or HTTPS (https://host/owner/repo.git)
|
||||
m = re.search(r"[/:]([^/]+/[^/]+?)(?:\.git)?$", url)
|
||||
if not m:
|
||||
return None
|
||||
candidate = m.group(1)
|
||||
# Only use it if the remote points at our forge
|
||||
if "forge.ops.eblu.me" in url or "forge.eblu.me" in url:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def auth_headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"token {token}"}
|
||||
|
||||
|
||||
def fetch_tasks(repo: str, token: str) -> list[dict]:
|
||||
"""Fetch all tasks from the Forgejo API, paginating if needed."""
|
||||
tasks: list[dict] = []
|
||||
page = 1
|
||||
while True:
|
||||
resp = httpx.get(
|
||||
f"{FORGE_API}/repos/{repo}/actions/tasks",
|
||||
params={"page": page, "limit": 50},
|
||||
headers=auth_headers(token),
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
batch = resp.json().get("workflow_runs", [])
|
||||
if not batch:
|
||||
break
|
||||
tasks.extend(batch)
|
||||
page += 1
|
||||
return tasks
|
||||
|
||||
|
||||
def list_runs(repo: str, limit: int, token: str, console: Console) -> None:
|
||||
"""List recent workflow runs, grouped by run number."""
|
||||
tasks = fetch_tasks(repo, token)
|
||||
|
||||
# Group tasks by run_number
|
||||
runs: dict[int, list[dict]] = {}
|
||||
for t in tasks:
|
||||
rn = t["run_number"]
|
||||
runs.setdefault(rn, []).append(t)
|
||||
|
||||
table = Table(title=f"Recent runs — {repo}")
|
||||
table.add_column("Run #", style="cyan", no_wrap=True)
|
||||
table.add_column("Status")
|
||||
table.add_column("Jobs")
|
||||
table.add_column("Title")
|
||||
table.add_column("Event")
|
||||
|
||||
shown = 0
|
||||
for rn in sorted(runs, reverse=True):
|
||||
if limit > 0 and shown >= limit:
|
||||
break
|
||||
|
||||
jobs = sorted(runs[rn], key=lambda x: x["id"])
|
||||
|
||||
# Aggregate status: worst status wins
|
||||
statuses = [j.get("status", "") for j in jobs]
|
||||
if "failure" in statuses:
|
||||
status, style = "failure", "red"
|
||||
elif "running" in statuses or "waiting" in statuses:
|
||||
status, style = "running", "yellow"
|
||||
elif all(s == "success" for s in statuses):
|
||||
status, style = "success", "green"
|
||||
else:
|
||||
status, style = statuses[0], "yellow"
|
||||
|
||||
job_names = ", ".join(j.get("name", "?")[:30] for j in jobs)
|
||||
title = (jobs[0].get("display_title") or "")[:40]
|
||||
event = jobs[0].get("event", "")
|
||||
|
||||
table.add_row(
|
||||
str(rn),
|
||||
f"[{style}]{status}[/{style}]",
|
||||
job_names,
|
||||
title,
|
||||
event,
|
||||
)
|
||||
shown += 1
|
||||
|
||||
console.print(table)
|
||||
console.print("\n[dim]Use: mise run runner-logs <run#> to see jobs in a run[/dim]")
|
||||
console.print("[dim] mise run runner-logs <run#> -j N to fetch logs for job N[/dim]")
|
||||
|
||||
|
||||
def show_jobs(run_number: int, repo: str, token: str, console: Console) -> None:
|
||||
"""Show the jobs within a specific run."""
|
||||
tasks = fetch_tasks(repo, token)
|
||||
|
||||
jobs = sorted(
|
||||
[t for t in tasks if t["run_number"] == run_number],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
if not jobs:
|
||||
typer.echo(f"Error: No jobs found for run #{run_number}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
table = Table(title=f"Jobs in run #{run_number} — {repo}")
|
||||
table.add_column("Job #", style="cyan", no_wrap=True)
|
||||
table.add_column("Status")
|
||||
table.add_column("Name")
|
||||
table.add_column("Created")
|
||||
|
||||
for i, job in enumerate(jobs):
|
||||
status = job.get("status", "")
|
||||
style = "green" if status == "success" else "red" if status == "failure" else "yellow"
|
||||
table.add_row(
|
||||
str(i),
|
||||
f"[{style}]{status}[/{style}]",
|
||||
job.get("name", ""),
|
||||
job.get("created_at", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\n[dim]Use: mise run runner-logs {run_number} -j N to fetch logs for job N[/dim]")
|
||||
|
||||
|
||||
def fetch_log(run_number: int, job_index: int, repo: str, token: str) -> None:
|
||||
"""Fetch logs for a specific job via SSH to indri.
|
||||
|
||||
Forgejo stores action logs as zstd-compressed files on disk at
|
||||
~/forgejo/data/actions_log/{owner}/{repo}/{hex_prefix}/{task_id}.log.zst
|
||||
regardless of which runner executed the job. The web log endpoint doesn't
|
||||
support API-token auth for private repos, so we read the files directly.
|
||||
"""
|
||||
tasks = fetch_tasks(repo, token)
|
||||
jobs = sorted(
|
||||
[t for t in tasks if t["run_number"] == run_number],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
if not jobs:
|
||||
typer.echo(f"Error: No jobs found for run #{run_number}", err=True)
|
||||
raise typer.Exit(1)
|
||||
if job_index < 0 or job_index >= len(jobs):
|
||||
typer.echo(
|
||||
f"Error: job index {job_index} out of range (run #{run_number} has {len(jobs)} jobs)",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
task_id = jobs[job_index]["id"]
|
||||
hex_prefix = f"{task_id & 0xff:02x}"
|
||||
log_path = f"~/forgejo/data/actions_log/{repo}/{hex_prefix}/{task_id}.log.zst"
|
||||
|
||||
result = subprocess.run(
|
||||
["ssh", "indri", f"zstdcat {log_path}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
typer.echo(
|
||||
f"Error: could not read log for run #{run_number} job {job_index} (task {task_id})",
|
||||
err=True,
|
||||
)
|
||||
typer.echo(f"Path: indri:{log_path}", err=True)
|
||||
if result.stderr.strip():
|
||||
typer.echo(result.stderr.strip(), err=True)
|
||||
raise typer.Exit(1)
|
||||
sys.stdout.write(result.stdout)
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
run_number: Annotated[
|
||||
int | None,
|
||||
typer.Argument(help="Run number to show jobs for (omit to list recent runs)"),
|
||||
] = None,
|
||||
job: Annotated[
|
||||
int | None,
|
||||
typer.Option("--job", "-j", help="Job index (0-based) to fetch logs for"),
|
||||
] = None,
|
||||
repo: Annotated[
|
||||
str | None,
|
||||
typer.Option("--repo", help="Forge repo (owner/name), default: detected from git remote"),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
typer.Option("--limit", "-n", help="Max runs to display (0 for all)"),
|
||||
] = 15,
|
||||
token: Annotated[
|
||||
str | None,
|
||||
typer.Option("--token", help="Forgejo API token (default: read from 1Password)"),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""List recent Forgejo Actions runs or fetch logs for a specific job."""
|
||||
console = Console()
|
||||
|
||||
if repo is None:
|
||||
repo = detect_repo_from_git()
|
||||
if repo is None:
|
||||
typer.echo(
|
||||
"Error: could not detect repo from git remote; use --repo owner/name",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print(f"[dim]Detected repo: {repo}[/dim]")
|
||||
|
||||
resolved_token = resolve_token(token, console)
|
||||
|
||||
if run_number is None:
|
||||
if job is not None:
|
||||
typer.echo("Error: --job requires a run number", err=True)
|
||||
raise typer.Exit(1)
|
||||
list_runs(repo, limit, resolved_token, console)
|
||||
elif job is None:
|
||||
show_jobs(run_number, repo, resolved_token, console)
|
||||
else:
|
||||
fetch_log(run_number, job, repo, resolved_token)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
3
mise.toml
Normal file
3
mise.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[tools]
|
||||
prek = "latest"
|
||||
dagger = "0.20.6"
|
||||
148
prek.toml
Normal file
148
prek.toml
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# prek.toml - Git hooks configuration
|
||||
# Run: prek run --all-files
|
||||
# Install: prek install && prek install --hook-type commit-msg
|
||||
|
||||
# Built-in hooks (fast, Rust-native — no external dependencies)
|
||||
[[repos]]
|
||||
repo = "builtin"
|
||||
hooks = [
|
||||
{ id = "trailing-whitespace" },
|
||||
{ id = "end-of-file-fixer" },
|
||||
{ id = "check-added-large-files", args = [
|
||||
"--maxkb=1000",
|
||||
] },
|
||||
{ id = "check-merge-conflict" },
|
||||
{ id = "check-json" },
|
||||
{ id = "check-toml" },
|
||||
{ id = "check-case-conflict" },
|
||||
{ id = "detect-private-key" },
|
||||
{ id = "check-executables-have-shebangs" },
|
||||
]
|
||||
|
||||
# check-yaml with --unsafe (builtin fast path doesn't support --unsafe yet)
|
||||
[[repos]]
|
||||
repo = "https://github.com/pre-commit/pre-commit-hooks"
|
||||
rev = "v6.0.0"
|
||||
hooks = [{ id = "check-yaml", args = ["--unsafe"] }]
|
||||
|
||||
# Secret detection
|
||||
[[repos]]
|
||||
repo = "https://github.com/trufflesecurity/trufflehog"
|
||||
rev = "v3.93.4"
|
||||
hooks = [
|
||||
{ id = "trufflehog", entry = "trufflehog git file://. --since-commit HEAD --no-verification --fail", stages = [
|
||||
"pre-commit",
|
||||
"pre-push",
|
||||
] },
|
||||
]
|
||||
|
||||
# YAML linting
|
||||
[[repos]]
|
||||
repo = "https://github.com/adrienverge/yamllint"
|
||||
rev = "v1.38.0"
|
||||
hooks = [{ id = "yamllint", args = ["-c", ".yamllint.yaml"] }]
|
||||
|
||||
# Python - ruff for linting and formatting
|
||||
[[repos]]
|
||||
repo = "https://github.com/astral-sh/ruff-pre-commit"
|
||||
rev = "v0.15.2"
|
||||
hooks = [{ id = "ruff", args = ["--fix"] }, { id = "ruff-format" }]
|
||||
|
||||
# Shell scripts - shellcheck and shfmt
|
||||
[[repos]]
|
||||
repo = "https://github.com/shellcheck-py/shellcheck-py"
|
||||
rev = "v0.11.0.1"
|
||||
hooks = [{ id = "shellcheck", args = ["--severity=warning"] }]
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/scop/pre-commit-shfmt"
|
||||
rev = "v3.12.0-2"
|
||||
hooks = [{ id = "shfmt", args = ["-i", "2", "-ci", "-bn"] }]
|
||||
|
||||
# TOML - taplo
|
||||
[[repos]]
|
||||
repo = "https://github.com/ComPWA/taplo-pre-commit"
|
||||
rev = "v0.9.3"
|
||||
hooks = [
|
||||
{ id = "taplo-format" },
|
||||
{ id = "taplo-lint", exclude = '\.dagger/pyproject\.toml$' },
|
||||
]
|
||||
|
||||
# JSON formatting (prettier for consistent style)
|
||||
[[repos]]
|
||||
repo = "https://github.com/rbubley/mirrors-prettier"
|
||||
rev = "v3.8.1"
|
||||
hooks = [{ id = "prettier", types_or = ["json"], args = ["--tab-width", "2"] }]
|
||||
|
||||
# GitHub/Forgejo Actions workflow linting
|
||||
[[repos]]
|
||||
repo = "https://github.com/rhysd/actionlint"
|
||||
rev = "v1.7.11"
|
||||
hooks = [
|
||||
{ id = "actionlint-system", args = [
|
||||
"-config-file",
|
||||
".github/actionlint.yaml",
|
||||
], files = '\.forgejo/workflows/' },
|
||||
]
|
||||
|
||||
# Custom local hooks
|
||||
|
||||
# Changelog fragment validation (no subdirectories)
|
||||
[[repos]]
|
||||
repo = "local"
|
||||
|
||||
[[repos.hooks]]
|
||||
id = "changelog-check"
|
||||
name = "changelog-check"
|
||||
entry = "mise run changelog-check"
|
||||
language = "system"
|
||||
files = '^docs/changelog\.d/'
|
||||
pass_filenames = false
|
||||
|
||||
# Mikado Branch Invariant (C2 changes)
|
||||
[[repos]]
|
||||
repo = "local"
|
||||
|
||||
[[repos.hooks]]
|
||||
id = "mikado-branch-invariant-check"
|
||||
name = "mikado-branch-invariant-check"
|
||||
entry = "mise run mikado-branch-invariant-check"
|
||||
language = "system"
|
||||
always_run = true
|
||||
stages = ["commit-msg"]
|
||||
|
||||
# Documentation validation
|
||||
[[repos]]
|
||||
repo = "local"
|
||||
|
||||
[[repos.hooks]]
|
||||
id = "docs-check-filenames"
|
||||
name = "docs-check-filenames"
|
||||
entry = "mise run docs-check-filenames"
|
||||
language = "system"
|
||||
files = '^docs/.*\.md$'
|
||||
pass_filenames = false
|
||||
|
||||
[[repos.hooks]]
|
||||
id = "docs-check-links"
|
||||
name = "docs-check-links"
|
||||
entry = "mise run docs-check-links"
|
||||
language = "system"
|
||||
files = '^docs/.*\.md$'
|
||||
pass_filenames = false
|
||||
|
||||
[[repos.hooks]]
|
||||
id = "docs-check-index"
|
||||
name = "docs-check-index"
|
||||
entry = "mise run docs-check-index"
|
||||
language = "system"
|
||||
files = '^docs/.*\.md$'
|
||||
pass_filenames = false
|
||||
|
||||
[[repos.hooks]]
|
||||
id = "docs-check-frontmatter"
|
||||
name = "docs-check-frontmatter"
|
||||
entry = "mise run docs-check-frontmatter"
|
||||
language = "system"
|
||||
files = '^docs/.*\.md$'
|
||||
pass_filenames = false
|
||||
40
towncrier.toml
Normal file
40
towncrier.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Towncrier configuration
|
||||
# https://towncrier.readthedocs.io/
|
||||
|
||||
[tool.towncrier]
|
||||
directory = "docs/changelog.d"
|
||||
filename = "CHANGELOG.md"
|
||||
package = ""
|
||||
title_format = "## [{version}] - {project_date}"
|
||||
issue_format = ""
|
||||
underlines = ["", "", ""]
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "feature"
|
||||
name = "Features"
|
||||
showcontent = true
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "bugfix"
|
||||
name = "Bug Fixes"
|
||||
showcontent = true
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "infra"
|
||||
name = "Infrastructure"
|
||||
showcontent = true
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "doc"
|
||||
name = "Documentation"
|
||||
showcontent = true
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "ai"
|
||||
name = "AI Assistance"
|
||||
showcontent = true
|
||||
|
||||
[[tool.towncrier.type]]
|
||||
directory = "misc"
|
||||
name = "Miscellaneous"
|
||||
showcontent = true
|
||||
Loading…
Add table
Add a link
Reference in a new issue