Repository: nikomatsakis/skill-tree
Branch: master
Commit: 233d26ab09ea
Files: 39
Total size: 76.7 KB
Directory structure:
gitextract_h1tuqbnh/
├── .github/
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── .skill-tree.toml
├── CODE_OF_CONDUCT.md
├── Cargo.toml
├── README.md
├── RELEASES.md
├── book.toml
├── md/
│ ├── contributing/
│ │ ├── 01-module-structure.md
│ │ ├── 02-important-flows.md
│ │ ├── 03-common-issues.md
│ │ └── 04-running-tests.md
│ ├── design/
│ │ ├── 01-config.md
│ │ └── 02-github-client.md
│ ├── introduction.md
│ ├── summary.md
│ └── welcome.md
├── mermaid-init.js
├── skill-tree-testlib/
│ ├── Cargo.toml
│ └── src/
│ ├── github.rs
│ └── lib.rs
├── src/
│ ├── cli/
│ │ ├── mod.rs
│ │ ├── render.rs
│ │ ├── unblocked.rs
│ │ └── validate.rs
│ ├── config.rs
│ ├── error/
│ │ ├── config.rs
│ │ └── github.rs
│ ├── error.rs
│ ├── github/
│ │ ├── issues.rs
│ │ ├── mod.rs
│ │ └── projects.rs
│ ├── graph/
│ │ ├── mod.rs
│ │ └── validate.rs
│ ├── lib.rs
│ ├── main.rs
│ └── render/
│ └── mod.rs
└── tests/
├── github_client.rs
└── integration.rs
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches: [master]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
check:
name: Check compilation
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Check
run: cargo check
fmt:
name: Check formatting
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install rustfmt
run: rustup component add rustfmt
- name: Check formatting
run: cargo fmt -- --check
test:
name: Run tests (${{ matrix.name }})
strategy:
matrix:
include:
- name: ubuntu
os: ubuntu-latest
- name: macos
os: macos-latest
- name: musl
os: ubuntu-latest
target: x86_64-unknown-linux-musl
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target || '' }}
- name: Install musl tools
if: matrix.target == 'x86_64-unknown-linux-musl'
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ matrix.name }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ matrix.name }}-cargo-
- name: Disable agent tests
run: echo 'test-agents = []' > test-agents.toml
- name: Test
run: cargo test ${{ matrix.target && format('--target {0}', matrix.target) || '' }}
================================================
FILE: .gitignore
================================================
/target
.claude/
================================================
FILE: .skill-tree.toml
================================================
[github]
owner = "rust-lang"
project = 42
[[field]]
display-name = "status"
github-name = "Status"
[[field]]
display-name = "priority"
github-name = "Priority"
[[field]]
display-name = "assignee"
github-name = "Assignee"
[colors]
github-name = "Status"
[colors.values]
"In Progress" = "#4a90d9"
"Blocked" = "#e05252"
"Complete" = "#57a85a"
"Not Started" = "#888888"
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Code of Conduct
## Conduct
- We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other similar characteristic.
- Please avoid using overtly sexual aliases or other nicknames that might detract from a friendly, safe and welcoming environment for all.
- Please be kind and courteous. There's no need to be mean or rude.
- Respect that people have differences of opinion and that every design or implementation choice carries a trade-off and numerous costs. There is seldom a right answer.
- Please keep unstructured critique to a minimum. If you have solid ideas you want to experiment with, make a fork and see how it works.
- We will exclude you from interaction if you insult, demean or harass anyone. That is not welcome behavior. We interpret the term "harassment" as including the definition in the [Citizen Code of Conduct](https://github.com/stumpsyn/policies/blob/master/citizen_code_of_conduct.md); if you have any lack of clarity about what might be included in that concept, please read their definition. In particular, we don't tolerate behavior that excludes people in socially marginalized groups.
- Private harassment is also unacceptable. No matter who you are, if you feel you have been or are being harassed or made uncomfortable by a community member, please contact one of the project maintainers immediately. Whether you're a regular contributor or a newcomer, we care about making this community a safe place for you and we've got your back.
- Likewise any spamming, trolling, flaming, baiting or other attention-stealing behavior is not welcome.
## Reporting
If you need to report conduct issues, please reach out [Niko Matsakis](mailto:rust@nikomatsakis.com)
================================================
FILE: Cargo.toml
================================================
[package]
name = "skill-tree"
version = "3.2.1"
authors = ["Niko Matsakis <niko@alum.mit.edu>"]
edition = "2024"
description = "generate graphviz files to show roadmaps"
license = "MIT"
repository = "https://github.com/nikomatsakis/skill-tree"
homepage = "https://github.com/nikomatsakis/skill-tree"
[dependencies]
rand = "0.10.1"
reqwest = { version = "0.13.3", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0.18"
tokio = { version = "1.52.2", features = ["time"] }
toml = "1.1.2"
[workspace]
members = ["./", "./skill-tree-testlib"]
[dev-dependencies]
indoc = "2.0.7"
skill-tree-testlib = { path = "./skill-tree-testlib" }
tempfile = "3.27.0"
tokio = { version = "1.52.2", features = ["macros", "rt"] }
================================================
FILE: README.md
================================================
# skill-tree
skill-tree fetches a GitHub Project and renders it as a directed dependency graph.
## What is a skill tree?
A "skill tree" is a way to map out the roadmap for a project. The term is
borrowed from video games, but it was first applied to project planning in
this [blog post about WebAssembly's post-MVP future][wasm] — at least, that
was the first time it was used that way.
[wasm]: https://hacks.mozilla.org/2018/10/webassemblys-post-mvp-future/
The idea: work items have dependencies, just like skills in a game. You cannot
unlock the next thing until the current thing is done. Mapping those
dependencies visually shows you the shape of a roadmap at a glance.
## How it works
skill-tree reads a GitHub Project — issues, their blocking relationships, and
their custom field values — and renders the result as a Graphviz DOT file or
SVG. Each node is a GitHub issue. Each edge is a blocking relationship. Node
color is driven by a custom field in GitHub Projects.
GitHub is the source of truth. There is no separate file to maintain.
## Usage
```bash
# Render the dependency graph as SVG
skill-tree render --format svg --output graph.svg
# List open issues with no incoming blocking edges
skill-tree unblocked
# Check for cycles and dangling references
skill-tree validate
```
## Configuration
Create a `.skill-tree.toml` in your project root:
```toml
[github]
owner = "rust-lang"
project = 42
[[field]]
display-name = "status"
github-name = "Status"
[colors]
github-name = "Status"
[colors.values]
"In Progress" = "#4a90d9"
"Blocked" = "#e05252"
"Complete" = "#57a85a"
"Not Started" = "#888888"
```
`owner` is the GitHub organization or user that owns the project.
`project` is the project number from the GitHub Projects URL.
skill-tree fetches all custom fields GitHub returns automatically. `[[field]]`
entries are display declarations only — they give a field a friendly
`display-name` for CLI output. Fields not declared in `[[field]]` are still
fetched and stored on each node.
`[colors]` specifies which GitHub field drives node color (`github-name`)
and maps that field's option values to hex colors (`[colors.values]`).
The entire section is optional — if omitted, all nodes render gray.
## Installation
```bash
cargo install skill-tree
```
Rendering SVG requires Graphviz:
```bash
# macOS
brew install graphviz
# Ubuntu
apt install graphviz
```
## Authentication
skill-tree reads your GitHub token from the `GITHUB_TOKEN` environment
variable:
```bash
export GITHUB_TOKEN=<your token>
```
The token requires `read:project` and `repo` scopes.
## Documentation
For architecture, design decisions, and contribution guide, see the
[skill-tree design book](https://nikomatsakis.github.io/skill-tree/).
## Status
⚠️ **Early development** — expect frequent changes.
## Community
skill-tree is open source. We welcome contributors and maintain a
[code of conduct](./CODE_OF_CONDUCT.md).
================================================
FILE: RELEASES.md
================================================
# 1.3.2
* Remove outdated dependencies
# 1.3.0
* Fix bug around underline
* Add ability to have `href` on tree nodes
# 1.2.0 and before
I wasn't taking notes =)
================================================
FILE: book.toml
================================================
[book]
authors = ["Niko Matsakis", "James Muriuki"]
language = "en"
src = "md"
title = "skill-tree"
[preprocessor.mermaid]
command = "mdbook-mermaid"
[output]
[output.html]
additional-js = ["mermaid.min.js", "mermaid-init.js"]
additional-css = ["theme/custom.css"]
fold.enable = true
fold.level = 0
git-repository-url = "https://github.com/nikomatsakis/skill-tree"
edit-url-template = "https://github.com/nikomatsakis/skill-tree/edit/main/md/{path}"
================================================
FILE: md/contributing/01-module-structure.md
================================================
# Key modules
skill-tree is organized as a three-stage pipeline: fetch data from GitHub, model it as a graph, render it as output.
## `config.rs` — configuration
Reads `.skill-tree.toml` and provides the `SkillTree` application context. Two constructors:
- `SkillTree::from_dir()` — load from a directory (production and tests)
- `SkillTree::from_path()` — load from an explicit file path
Provides query methods to access config data:
- `color_for_value()` — map a field option to a hex color
- `field_by_display_name()` — look up a field by its display name
- `color_field_github_name()` — which field drives node color
## `error/` — error types
All errors in skill-tree organized by origin:
- `error/github.rs` — GitHub API errors (`GitHubError`, `NetworkErrorKind`, `ErrorContext`)
- `error/config.rs` — configuration file errors (`ConfigError`)
Each error type implements `.exit_code()` to map to process exit codes (1, 3, or 4).
## `github/` — GitHub GraphQL client
The only module that talks to GitHub. Implements the fetch stage of the pipeline.
- `github/mod.rs` — `GitHubClient` with retry, rate limit, and timeout handling
- `github/projects.rs` — fetch GitHub Projects V2 items (stub)
- `github/issues.rs` — fetch issues and blocking relationships (stub)
The client automatically retries transient errors (exponential backoff), waits on rate limits, and fails if the operation exceeds the configured timeout. All errors carry `ErrorContext` (query name, owner, project) for debugging.
## `graph/` — graph model
The platform-agnostic data model: nodes (issues), edges (blocking relationships), and algorithms.
- `graph/mod.rs` — `Graph`, `Node`, `Edge` types
- `graph/validate.rs` — cycle detection and dangling edge detection
## `render/` — rendering
Turns a `Graph` into Graphviz DOT format and optionally renders it to SVG using the system `dot` binary.
- `render/mod.rs` — `render()` function, DOT generation
================================================
FILE: md/contributing/02-important-flows.md
================================================
# Important flows
skill-tree is a three-stage pipeline. These are the major paths through the code.
## `render` command
Fetch GitHub Project → model as graph → render as DOT/SVG.
**Flow:**
1. Load config from `.skill-tree.toml`
2. Construct GitHub client (read token from `--token` or `GITHUB_TOKEN` env var)
3. Fetch project items and issues from GitHub GraphQL API
4. Build graph: create nodes for each issue, edges for blocking relationships
5. Render graph to Graphviz DOT format
6. If `--format svg` specified, invoke system `dot` binary to render SVG
7. Write output to file or stdout
## `validate` command
Load graph → check for cycles and dangling edges.
**Flow:**
1. Load config from `.skill-tree.toml`
2. Fetch and build graph (same as render command, steps 2-4)
3. Run cycle detection: depth-first search from each unvisited node
4. Check for dangling edges: edges that reference issues not in the project
5. Exit with code 0 if valid, 2 if cycles found, 3 if GitHub error, 4 if config error
## `unblocked` command
Load graph → find issues with no incoming blocking edges.
**Flow:**
1. Load config from `.skill-tree.toml`
2. Fetch and build graph (same as render command, steps 2-4)
3. Filter to issues with no incoming edges
4. Sort by issue number for deterministic output
5. Print each unblocked issue (or JSON if `--json` specified)
================================================
FILE: md/contributing/03-common-issues.md
================================================
# Common issues
## Known v1 limitations
### Pagination not yet implemented
The GitHub client has the structure for automatic pagination (following `hasNextPage` cursors), but the actual implementation is stubbed. For projects with more than ~100 items, fetching will be incomplete.
**Status:** Ready to implement after `projects.rs` is written. The client already handles the retry/rate limit/timeout logic that pagination needs.
### PR node support deferred
Pull requests are not included in the graph. Only issues and their blocking relationships are fetched.
**Why:** PRs don't form dependency nodes in the skill tree model. A PR either resolves an issue (removes it) or doesn't. Blocking relationships are only tracked between issues.
**Status:** Deferred to v2 if users request it.
### Edge source: GitHub blocking only
The only blocking relationships fetched are those tracked natively in GitHub Projects (the "blocks" field). Relationships encoded in issue bodies (e.g., "blocked by #123") are not parsed.
**Why:** Text parsing is fragile and loses the explicit structure GitHub provides. Native blocking is more reliable.
**Status:** v2 may add opt-in body parsing if requested.
### Single color field in v1
Only one GitHub field can drive node color. Multiple color rules (e.g., Status drives fill, Priority drives border) are deferred.
**Why:** Keeping v1 simple. The infrastructure is designed to support multiple rules in v2.
**Status:** v2 will add `[[color-rule]]` with `attribute` field.
### Deterministic output only by issue number
Node and edge order in DOT output is deterministic (sorted by issue number) but not configurable. Custom sort orders are deferred.
**Status:** v2 may add sort options.
## Potential gotchas for contributors
### `ErrorContext` is metadata, not an error source
The `ErrorContext` struct carries debugging information (query name, owner, project) but is not part of the error chain. Don't use `#[source]` on ErrorContext fields.
### Config filename has a hyphen
The config file is `.skill-tree.toml` (hyphen), not `.skill_tree.toml` (underscore). This matters in tests and error messages.
### GitHub returns 200 for GraphQL errors
When GitHub encounters a GraphQL error, it returns HTTP 200 with an `errors` field in the JSON body. Always check `errors` even on successful HTTP status.
### Network errors during JSON parsing
When `response.json()` fails (bad JSON from GitHub), it returns a `reqwest::Error`, not `serde_json::Error`. Classify it as a network error, not a JSON error.
================================================
FILE: md/contributing/04-running-tests.md
================================================
# Running tests
## Unit tests
```bash
cargo test
```
Runs all unit tests inside the `skill-tree` crate. No network access required.
No `GITHUB_TOKEN` required.
## Integration tests
```bash
cargo test --test integration
```
Runs the integration test suite in `tests/integration.rs`. Uses
`skill-tree-testlib` fixture builders exclusively. No network access required.
No `GITHUB_TOKEN` required.
## All tests
```bash
cargo test --workspace
```
Runs unit tests and integration tests across all crates in the workspace.
## Checking DOT output determinism
```bash
cargo test dot_output_is_valid_digraph
cargo test dot_output_contains_all_nodes
```
These tests assert byte-level properties of the DOT output. If you change
anything in `render/mod.rs`, run these first.
================================================
FILE: md/design/01-config.md
================================================
# Configuration
skill-tree is configured via a `.skill-tree.toml` file in the current
directory. This file tells skill-tree which GitHub Project to read and
how to display what it finds.
Configuration is read once at startup. Changes to `.skill-tree.toml` take
effect on the next invocation.
## Field auto-discovery
skill-tree fetches **all** custom fields GitHub returns for every project
item, regardless of what is declared in `[[field]]`. You do not need to
declare a field to have it fetched.
`[[field]]` entries are display declarations only — they give a field a
friendly `display-name` for CLI output. Fields not declared in `[[field]]`
are still fetched and stored on each node. Adding a new `[[field]]` entry
or a new value in `[colors.values]` later does not require changing what
gets fetched.
## File format
```toml
[github]
owner = "rust-lang"
project = 42
[[field]]
display-name = "status"
github-name = "Status"
[[field]]
display-name = "priority"
github-name = "Priority"
[colors]
github-name = "Status"
[colors.values]
"In Progress" = "#4a90d9"
"Blocked" = "#e05252"
"Complete" = "#57a85a"
"Not Started" = "#888888"
```
## Sections
### `[github]`
Identifies the GitHub Project to fetch data from.
| Field | Type | Required | Description |
|---|---|---|---|
| `owner` | string | yes | GitHub organization or user that owns the project |
| `project` | integer | yes | Project number from the GitHub Projects URL |
For `github.com/orgs/rust-lang/projects/42`, `owner` is `"rust-lang"`
and `project` is `42`. For a user project at
`github.com/users/your-username/projects/1`, `owner` is `"your-username"`.
### `[[field]]`
Gives a GitHub Projects custom field a friendly display name for CLI
output. Optional — skill-tree fetches all fields regardless.
| Field | Type | Description |
|---|---|---|
| `display-name` | string | How skill-tree refers to this field in CLI output |
| `github-name` | string | Exact field name as it appears in GitHub Projects |
`github-name` is case-sensitive and must match the field name in GitHub
Projects character for character. Unknown keys are rejected at parse time.
### `[colors]`
Controls node color in the rendered graph. The entire section is optional.
If omitted, all nodes render with the default gray.
| Field | Type | Description |
|---|---|---|
| `github-name` | string | Which GitHub field drives node color |
| `values` | table | Maps field option values to hex colors |
`github-name` does not need to match a declared `[[field]]` entry —
it refers directly to the GitHub field name. The keys in `[colors.values]`
must match the option names in that field's single-select options in GitHub
Projects exactly, including case and spacing.
Nodes whose field value does not appear in `[colors.values]` render with
the default gray (`#dddddd`).
## The `SkillTree` application context
The parsed `Config` is wrapped in a `SkillTree` struct that also carries
the directory containing the config file. The rest of the pipeline takes
`&SkillTree` rather than `&Config` directly — this keeps configuration
threading explicit and avoids global state. Constructors:
- `SkillTree::from_dir(dir)` — load from a directory (production and tests)
- `SkillTree::from_path(path)` — load from an explicit file path
## Validation
After parsing, skill-tree runs validation on the config and fails with
exit code 4 if any value in `[colors.values]` is not a valid hex color
(`#rgb` or `#rrggbb`).
Other failures happen at parse time, not validation time:
- Missing `[github]` or its required keys
- A `[[field]]` entry with unknown keys
- Type mismatches on any field
## Example: minimal config
The smallest valid config — no field declarations, no colors:
```toml
[github]
owner = "your-org"
project = 1
```
skill-tree fetches all fields from the board and renders nodes in the
default gray. Add `[colors]` when you are ready to add color.
## Example: colors only, no field declarations
```toml
[github]
owner = "rust-lang"
project = 42
[colors]
github-name = "Status"
[colors.values]
"In Progress" = "#4a90d9"
"Blocked" = "#e05252"
"Complete" = "#57a85a"
```
No `[[field]]` declarations needed. skill-tree fetches the Status field
automatically along with everything else on the board.
## Example: full config with display names
```toml
[github]
owner = "rust-lang"
project = 42
[[field]]
display-name = "status"
github-name = "Status"
[[field]]
display-name = "priority"
github-name = "Priority"
[[field]]
display-name = "assignee"
github-name = "Assignee"
[colors]
github-name = "Status"
[colors.values]
"In Progress" = "#4a90d9"
"Blocked" = "#e05252"
"Complete" = "#57a85a"
"Not Started" = "#888888"
```
## Common pitfalls
- **Case mismatch on `github-name`.** `"status"` and `"Status"` are different
fields. The value must match GitHub's field header character for character.
- **Forgetting the `#` on a hex color.** `"4a90d9"` is rejected. The leading
`#` is required.
- **Quoting numeric values.** `project = "42"` is rejected — the field is
an integer, not a string.
- **Mixing case in `[colors.values]` keys.** `"in progress"` does not match
`"In Progress"`. Match GitHub's option names exactly.
================================================
FILE: md/design/02-github-client.md
================================================
# GitHub GraphQL Client
The `github` module owns all communication with the GitHub API. Other modules
import typed structs from `github/projects.rs` and `github/issues.rs` and never
construct URLs, never handle HTTP errors, never parse JSON directly.
This module is the exclusive gateway to GitHub. Everything flows through it.
## Responsibilities
Three things, three things only:
- **Authentication** — read the token from CLI or environment, fail fast if missing
- **Transport** — send GraphQL requests over HTTP, handle retries and rate limits
- **Error translation** — turn network failures and GitHub errors into structured Rust types
The actual GraphQL queries (fields, shapes, variables) live in `projects.rs` and
`issues.rs`. Those modules call back into this module for transport.
## Public API
```rust
pub struct GitHubClient { ... }
impl GitHubClient {
/// Construct a client. Reads the token from the parameter or the
/// `GITHUB_TOKEN` env var. Synchronous; does no I/O.
pub fn new(token: Option<String>, timeout: Duration) -> Result<Self, GitHubError>;
/// Send a single GraphQL request. Handles retries and rate limits.
/// Returns the typed `data` field of the response.
///
/// Pagination is the caller's responsibility — see the Pagination section.
pub async fn query<V: Serialize, T: DeserializeOwned>(
&self,
query: &str,
variables: V,
) -> Result<T, GitHubError>;
}
```
Callers construct the client once at startup. The client:
- Owns the HTTP connection pool
- Stores the authentication token
- Stores the timeout duration
- Implements retry logic and rate limit backoff
Errors do not carry caller context (which project, which query name).
Callers wrap errors at the call site if they need it; this keeps the
transport layer focused on transport.
## Authentication
The token comes from two sources in order of priority:
1. `--token` CLI flag — explicit, takes precedence
2. `GITHUB_TOKEN` environment variable — standard convention
If neither is present, `GitHubClient::new()` returns `GitHubError::MissingToken`
before any network I/O occurs. The error message tells the user how to set the
token.
Required scopes:
- `read:project` — read GitHub Projects V2 data
- `repo` — read issue content and blocking relationships on private repositories
For fully public repositories `public_repo` is sufficient.
## Transport
The client uses `reqwest` for HTTP and `tokio` for async runtime. Every GraphQL
query:
1. Serialize variables to JSON
2. POST to `https://api.github.com/graphql` with the Authorization header
3. Parse the response JSON
4. Check HTTP status (4xx/5xx is an error)
5. Check for `errors` field in the response (non-empty is an error)
6. Return `data` on success
The timeout applies to the entire request including retry backoff. If the request
plus retries exceed the timeout, the client returns `GitHubError::Timeout`.
## Retry strategy
Transient errors are retried with exponential backoff and jitter:
- Network failures (timeout, connection refused, DNS, TLS)
- HTTP 5xx (GitHub service errors)
- HTTP 429 (rate limited; see [Rate limiting](#rate-limiting) for the policy)
Retry policy:
- Up to 3 attempts
- Exponential backoff: ~1s, ~2s, ~4s between attempts
- Jitter: ±20% to avoid thundering herd
- Does not exceed the overall request timeout
Non-transient errors (4xx except 429, GraphQL validation errors, auth failures)
fail immediately without retry.
## Rate limiting
GitHub allows 5000 requests per hour for authenticated tokens. When the client
hits the rate limit (HTTP 429):
1. Parse the `X-RateLimit-Reset` header to get the Unix timestamp when the limit resets
2. Calculate seconds to wait
3. Log a message: `"Rate limit exceeded, waiting N seconds before retry"`
4. Sleep until the reset time
5. Retry the request
The client only sleeps if the wait fits within the *remaining* request
timeout. If the reset is further away than the time we have left, it
returns `GitHubError::RateLimited { retry_after }` immediately so the
caller can decide whether to wait or fail. There is no fixed 60-second
threshold — the budget is the timeout.
## Pagination
GitHub's GraphQL API uses cursor-based pagination. The transport does **not**
hide pagination — it sends one request and returns one response. Pagination
loops live in the caller (`projects.rs`, `issues.rs`) where the query and
response shape are known.
Rationale: making pagination transparent in `query()` requires the transport
to know where, in an arbitrary `T`, the `pageInfo` and node list live. That
either forces a `Paginated` trait on every response type or hides query-shape
knowledge inside the transport. Both are worse than a small explicit loop in
the caller, which already owns the query.
The transport provides two reusable types so callers don't redefine them:
```rust
/// Page metadata returned by every GitHub GraphQL connection.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageInfo {
pub has_next_page: bool,
pub end_cursor: Option<String>,
}
/// A connection: a list of `nodes` plus `pageInfo`. Embed in your response
/// type to use the standard pagination loop.
#[derive(Debug, Deserialize)]
pub struct Connection<T> {
pub nodes: Vec<T>,
#[serde(rename = "pageInfo")]
pub page_info: PageInfo,
}
```
A caller-side pagination loop looks like this:
```rust
let mut all = Vec::new();
let mut cursor: Option<String> = None;
loop {
let resp: MyResponse = client
.query(QUERY, MyVars { after: cursor.clone(), .. })
.await?;
let conn = resp.repository.issues; // Connection<Issue>
all.extend(conn.nodes);
if !conn.page_info.has_next_page { break; }
cursor = conn.page_info.end_cursor;
}
```
The query must declare `first: N` for page size and `after: $after` for the
cursor variable. Beyond that, it's the caller's GraphQL.
A generic `paginate(...)` helper in the transport is intentionally not
provided yet — there are only two callers, and the loop pattern is short.
Add a helper if a third caller appears.
## Timeout configuration
Users set a global timeout via:
```bash
skill-tree render --timeout 60
```
Or environment:
```bash
export GITHUB_TIMEOUT=60
```
Default is 30 seconds if neither is set. The timeout applies to the entire
request including retries and rate limit waiting. If the operation exceeds
the timeout, the client returns `GitHubError::Timeout`.
## Error types
```rust
#[derive(Debug, thiserror::Error)]
pub enum GitHubError {
/// No token in --token or GITHUB_TOKEN environment variable.
MissingToken,
/// HTTP client could not be constructed (TLS backend, proxy config, etc.).
ClientInit(String),
/// Network-level failure with a sub-category.
Network { kind: NetworkErrorKind, message: String },
/// HTTP response with error status code (4xx or 5xx).
HttpError { status: u16, body: String },
/// GraphQL response contained errors in the `errors` field.
GraphQLError(String),
/// GitHub returned a body we could not interpret: malformed JSON,
/// or a well-formed envelope with neither `data` nor `errors`.
InvalidResponse(String),
/// Rate limit exceeded; see Rate limiting for when the client waits
/// vs. surfaces this to the caller.
RateLimited { retry_after: u64 },
/// Overall budget (timeout) exceeded across attempts.
Timeout(u64),
}
pub enum NetworkErrorKind { Timeout, Connection, Other(String) }
```
Exit codes (via `GitHubError::exit_code()`):
- 1 — `InvalidResponse` (malformed upstream body; likely a regression)
- 3 — `Network`, `HttpError`, `GraphQLError`, `RateLimited`, `Timeout`
- 4 — `MissingToken`, `ClientInit` (configuration / environment)
Errors do not carry a `context` field. Callers that want to attach which
project or query failed wrap the error at their call site.
## Module structure
```
github/
mod.rs — GitHubClient, transport, auth, retry logic
projects.rs — ProjectV2 types and query builders
issues.rs — Issue types, sub-issues, blocking relationships
```
`projects.rs` and `issues.rs` define the GraphQL queries as `const` strings and
provide typed response structs. They call `client.query()` for transport.
## Example usage
```rust
// Construct the client once at startup. Synchronous, no I/O.
let client = GitHubClient::new(token_from_cli, Duration::from_secs(30))?;
// Single request — no pagination loop:
let project: ProjectMeta = client.query(
FETCH_PROJECT_META_QUERY,
FetchProjectMetaVars { owner: "rust-lang", project: 42 },
).await?;
// Paginated fetch — loop lives here in the caller:
let mut all = Vec::new();
let mut cursor: Option<String> = None;
loop {
let resp: FetchIssuesResponse = client.query(
FETCH_ISSUES_QUERY,
FetchIssuesVars { owner: "rust-lang", project: 42, after: cursor.clone() },
).await?;
all.extend(resp.repository.issues.nodes);
if !resp.repository.issues.page_info.has_next_page { break; }
cursor = resp.repository.issues.page_info.end_cursor;
}
// The client handles, on each call to query():
// - Rate limits (waits and retries when budget allows)
// - Transient errors (retries with backoff)
// - Overall request timeout
```
## What we are not doing (v2 scope)
- GitHub Enterprise Server (always public github.com)
- GitHub App authentication (token only)
- Per-request timeout override (global timeout only)
- Automatic exponential backoff tuning (fixed schedule)
- Connection pool size configuration
================================================
FILE: md/introduction.md
================================================
# skill-tree
skill-tree fetches a GitHub Project and renders it as a directed dependency graph.
Given a `.skill-tree.toml` pointing at a GitHub Project, skill-tree reads every
issue on the board, extracts blocking relationships and sub-issue hierarchy from
GitHub's native features, and produces a Graphviz DOT file or SVG where each
node is a GitHub issue and each edge is a blocking relationship.
```bash
skill-tree render --format svg --output graph.svg
skill-tree unblocked
skill-tree validate
```
- For installation, see [Installing skill-tree](./guide/install.md).
- For configuration, see [Configuration](./guide/configuration.md).
- For subcommand reference, see [Subcommands](./guide/subcommands.md).
## How it works
```mermaid
flowchart LR
A[".skill-tree.toml"] --> B
subgraph B["skill-tree"]
direction LR
C["fetch"] --> D["model"] --> E["render"]
end
B --> F["graph.svg"]
```
The pipeline has three stages. **Fetch** reads project items, status field
values, sub-issues, and blocking relationships from the GitHub GraphQL API.
**Model** builds a directed graph of nodes and edges and validates it for
cycles and dangling references. **Render** writes a deterministic DOT file
and optionally pipes it through the system `dot` binary to produce an SVG
with clickable nodes.
## What the output looks like
```mermaid
graph LR
D["#8: RFC approved"] --> A["#12: Parser rewrite"]
A --> B["#34: New syntax support"]
A --> C["#35: Error messages"]
E["#41: Test harness"]
style D fill:#57a85a,color:#fff
style A fill:#4a90d9,color:#fff
style B fill:#e05252,color:#fff
style C fill:#e05252,color:#fff
style E fill:#888888,color:#fff
```
Node color is driven by the value of a single-select custom field in GitHub
Projects. Colors are configured in `.skill-tree.toml`. Every node in the SVG
links to its GitHub issue.
================================================
FILE: md/summary.md
================================================
# Summary
- [Introduction](./introduction.md)
# User's guide
- [Installing skill-tree](./guide/install.md)
- [Configuration](./guide/configuration.md)
- [Subcommands](./guide/subcommands.md)
# Design
- [Architecture](./design/architecture.md)
- [GitHub as source of truth](./design/github-source-of-truth.md)
- [Edge convention](./design/edge-convention.md)
- [Node model](./design/node-model.md)
- [Roadmap](./design/roadmap.md)
# Contribution guide
- [Key modules](./contributing/module-structure.md)
- [Important flows](./contributing/important-flows.md)
- [Running tests](./contributing/running-tests.md)
- [Common issues](./contributing/common-issues.md)
- [Governance](./contributing/governance.md)
================================================
FILE: md/welcome.md
================================================
# Contributing to skill-tree
Welcome! This section is for people who want to work on skill-tree itself. If you're a user of skill-tree, see the [getting started guide](../introduction.md) instead.
## Building and testing
skill-tree is a standard Cargo project with a library and a binary:
```bash
cargo check # type-check
cargo test # run the test suite
cargo run -- render # run locally: render command
```
Tests use snapshot assertions via the `expect-test` crate. If a snapshot changes, run with `UPDATE_EXPECT=1` to update it:
```bash
UPDATE_EXPECT=1 cargo test
```
## Development setup
You'll need:
- Rust (latest stable)
- `graphviz` package (for the `dot` binary, used by render tests)
On macOS:
```bash
brew install graphviz
```
On Ubuntu:
```bash
apt install graphviz
```
## Logging and debugging
skill-tree uses `eprintln!` for diagnostic output during development. Errors use the `thiserror` crate for structured error types.
To debug a command:
```bash
RUST_BACKTRACE=1 cargo run -- render --verbose
```
For unit tests:
```bash
cargo test -- --nocapture
```
## Code style
Follow Niko's patterns from the codebase:
- Comments explain the "why", not the "what"
- Code is self-documenting; avoid redundant comments
- Each error type knows its own exit code
- Use `match` statements instead of `matches!` when binding multiple variables
- Tests are organized by concern (config tests in config.rs, GitHub client tests in github/mod.rs)
## What to read next
- [Key modules](./01-module-structure.md) — the major pieces of skill-tree
- [Important flows](./02-important-flows.md) — how each command works
- [Common issues](./03-common-issues.md) — known limitations and gotchas
- [Design docs](../design/) — architecture decisions and design philosophy
## Getting started on a task
1. Pick an issue or identify something to improve
2. Read the relevant design doc (e.g., [GitHub client design](../design/02-github-client.md))
3. Check the [key modules](./01-module-structure.md) to understand the code structure
4. Write a failing test first, then implement
5. Run `cargo test` to make sure everything passes
6. Open a PR with a clear description of what changed and why
## What we're building
skill-tree turns a GitHub Project into a visual dependency graph. It's a tool for thinking about project roadmaps—seeing at a glance which issues are blocking others, which are ready to start, and how the work flows together.
The codebase is intentionally kept small and focused. We ship features that work well, not everything that could be imagined.
================================================
FILE: mermaid-init.js
================================================
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
(() => {
const darkThemes = ['ayu', 'navy', 'coal'];
const lightThemes = ['light', 'rust'];
const classList = document.getElementsByTagName('html')[0].classList;
let lastThemeWasLight = true;
for (const cssClass of classList) {
if (darkThemes.includes(cssClass)) {
lastThemeWasLight = false;
break;
}
}
const theme = lastThemeWasLight ? 'default' : 'dark';
mermaid.initialize({ startOnLoad: true, theme });
// Simplest way to make mermaid re-render the diagrams in the new theme is via refreshing the page
for (const darkTheme of darkThemes) {
document.getElementById(darkTheme).addEventListener('click', () => {
if (lastThemeWasLight) {
window.location.reload();
}
});
}
for (const lightTheme of lightThemes) {
document.getElementById(lightTheme).addEventListener('click', () => {
if (!lastThemeWasLight) {
window.location.reload();
}
});
}
})();
================================================
FILE: skill-tree-testlib/Cargo.toml
================================================
[package]
name = "skill-tree-testlib"
version = "0.1.0"
edition = "2024"
[dependencies]
serde_json = "1.0.149"
skill-tree = { path = ".." }
wiremock = "0.6"
================================================
FILE: skill-tree-testlib/src/github.rs
================================================
//! Mock GitHub GraphQL endpoint for integration tests.
//!
//! `MockGitHub` wraps a `wiremock::MockServer` configured to look like
//! `https://api.github.com/graphql`, plus response builders for the
//! shapes `GitHubClient` needs to handle: 200-with-data, 5xx, 429 with
//! `X-RateLimit-Reset`, GraphQL `errors` envelopes, and malformed bodies.
//!
//! Tests only import this module — they should not pull in `wiremock`
//! directly. `Mock` is re-exported so callers can chain `.expect(N)` /
//! `.up_to_n_times(N)` and call `.mount(&gh.server).await` without a
//! `wiremock` dependency line.
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde_json::Value;
use skill_tree::github::GitHubClient;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockBuilder, MockServer, ResponseTemplate};
pub use wiremock::Mock as MockHandle;
/// A wiremock server preconfigured to look like GitHub's GraphQL endpoint.
pub struct MockGitHub {
pub server: MockServer,
}
impl MockGitHub {
pub async fn start() -> Self {
Self {
server: MockServer::start().await,
}
}
/// A `GitHubClient` pointed at this mock with the given timeout.
/// The token is a non-empty placeholder so `with_endpoint` does not
/// fall back to `GITHUB_TOKEN`.
pub fn client(&self, timeout: Duration) -> GitHubClient {
GitHubClient::with_endpoint(
format!("{}/graphql", self.server.uri()),
Some("test-token".into()),
timeout,
)
.expect("token is supplied directly")
}
/// `POST /graphql` matcher base. Useful when a test needs an extra
/// matcher like a header check.
pub fn matcher(&self) -> MockBuilder {
Mock::given(method("POST")).and(path("/graphql"))
}
/// 200 response wrapping `body` in a GraphQL `data` envelope.
pub fn ok_data(&self, body: Value) -> MockHandle {
self.matcher().respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": body })),
)
}
/// Like `ok_data`, but the mock only matches requests carrying every
/// `(name, value)` header pair. Used to assert that the client sent
/// the headers it was supposed to.
pub fn ok_data_with_headers(&self, body: Value, headers: &[(&str, &str)]) -> MockHandle {
let mut builder = self.matcher();
for (name, value) in headers {
builder = builder.and(header(*name, *value));
}
builder.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": body })),
)
}
/// 200 response with a non-empty GraphQL `errors` array.
pub fn graphql_error(&self, message: &str) -> MockHandle {
self.matcher()
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": null,
"errors": [{ "message": message }],
})))
}
/// 200 response with neither `data` nor `errors` — exercises
/// `GitHubError::InvalidResponse`.
pub fn empty_envelope(&self) -> MockHandle {
self.matcher()
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
}
/// Response with the given HTTP status and an empty body.
pub fn status(&self, status: u16) -> MockHandle {
self.matcher().respond_with(ResponseTemplate::new(status))
}
/// Response with the given HTTP status and a string body.
pub fn status_with_body(&self, status: u16, body: &str) -> MockHandle {
self.matcher()
.respond_with(ResponseTemplate::new(status).set_body_string(body))
}
/// 429 response with `X-RateLimit-Reset` set to `now + secs_until_reset`,
/// matching the format GitHub returns.
pub fn rate_limited(&self, secs_until_reset: u64) -> MockHandle {
let reset = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is past UNIX_EPOCH")
.as_secs()
+ secs_until_reset;
self.matcher().respond_with(
ResponseTemplate::new(429)
.insert_header("X-RateLimit-Reset", reset.to_string().as_str()),
)
}
}
================================================
FILE: skill-tree-testlib/src/lib.rs
================================================
//! Test infrastructure for skill-tree integration tests.
//! Always imported as a dev-dependency.
pub mod github;
pub use github::MockGitHub;
================================================
FILE: src/cli/mod.rs
================================================
//! CLI argument definitions using clap.
//! Declares the three subcommands: render, unblocked, validate.
//!
mod render;
mod unblocked;
mod validate;
================================================
FILE: src/cli/render.rs
================================================
//! Implementation of the `skill-tree render` subcommand.
//! Runs the full fetch → model → render pipeline and writes DOT or SVG.
================================================
FILE: src/cli/unblocked.rs
================================================
//! Implementation of the `skill-tree unblocked` subcommand.
//! Prints all open issues with no incoming blocking edges.
================================================
FILE: src/cli/validate.rs
================================================
//! Implementation of the `skill-tree validate` subcommand.
//! Checks for cycles and dangling edges. Produces no rendered output.
================================================
FILE: src/config.rs
================================================
//! Reads and validates .skill-tree.toml.
//!
//! Two types carry configuration through the application:
//!
//! - [`Config`] -- the raw parsed TOML. Just data.
//! - [`SkillTree`] -- the application context. Wraps `Config` with
//! resolved paths and provides the methods the rest of the pipeline calls.
//!
//! ## Field auto-discovery.
//!
//! skill-tree fetches ALL custom fields GitHub returns for every project item.
//! `[[field]]` entries are display declarations only -- they give a field a
//! friendly `display-name` for CLI output.
//! Fields not declared in `[[field]]` are still fetched and stored on each node.
use crate::error::config::ConfigError;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
type Fallible<T> = Result<T, ConfigError>;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
pub github: GithubConfig,
#[serde(default, rename = "field")]
pub fields: Vec<FieldConfig>,
#[serde(default)]
pub colors: ColorsConfig,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct GithubConfig {
/// GitHub organization or user that owns the project.
///
/// For `github.com/orgs/rust-lang/projects/42` -> `rust-lang`.
pub owner: String,
/// Project number from the GitHub Projects URL.
///
/// For `github.com/orgs/rust-lang/projects/42` -> `42`.
pub project: u64,
}
/// Declares one GitHub Project custom field that skill-tree should read.
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct FieldConfig {
#[serde(rename = "display-name")]
pub display_name: String,
/// Exact field name as it appears in GitHub Projects.
///
/// Case-sensitive. Must match the field header in GitHub Projects.
#[serde(rename = "github-name")]
pub github_name: String,
}
/// Controls node color in the rendered graph.
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct ColorsConfig {
/// Which GitHub field drives node color.
#[serde(rename = "github-name", default)]
pub github_name: String,
/// Maps field option values to hex colors.
///
/// Keys are the option names from the GitHub Projects single-select field.
/// Nodes whose value is not in this map render with the default gray.
#[serde(default)]
pub values: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct SkillTree {
/// The parsed configuration.
pub config: Config,
/// Directory containing the config file. Used to resolve relative paths.
config_dir: PathBuf,
}
impl SkillTree {
/// The default filename skill-tree looks for.
pub const CONFIG_FILENAME: &'static str = ".skill-tree.toml";
/// Load config from `.skill-tree.toml` in `dir`.
///
/// If the file does not exist, return an error
pub fn from_dir(dir: impl AsRef<Path>) -> Fallible<Self> {
let dir = dir.as_ref();
Self::from_path(dir.join(Self::CONFIG_FILENAME))
}
/// Load config from an explicit file path.
pub fn from_path(path: impl AsRef<Path>) -> Fallible<Self> {
let path = path.as_ref();
let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path.to_owned(),
source,
})?;
let config: Config = toml::from_str(&content).map_err(|source| ConfigError::Parse {
path: path.to_owned(),
source,
})?;
config.validate()?;
let config_dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
Ok(Self { config, config_dir })
}
/// Directory containing the config file.
pub fn config_dir(&self) -> &Path {
&self.config_dir
}
/// Return the hex color for a field option value.
///
/// Returns `None` if no color is configured for this value -- the
/// renderer falls back to the default gray.
pub fn color_for_value(&self, value: &str) -> Option<&str> {
self.config.colors.values.get(value).map(String::as_str)
}
/// Returns the `github_name` of the field that drives node color.
pub fn color_field_github_name(&self) -> &str {
&self.config.colors.github_name
}
/// Look up fields by its `display-name`.
///
/// Returns `None` if no field with the given display name is found.
pub fn field_by_display_name(&self, display_name: &str) -> Option<&FieldConfig> {
self.config
.fields
.iter()
.find(|fconf| fconf.display_name == display_name)
}
}
impl Config {
fn validate(&self) -> Fallible<()> {
for (key, value) in &self.colors.values {
if !is_valid_hex_color(value) {
return Err(ConfigError::InvalidColor {
key: key.clone(),
value: value.clone(),
});
}
}
Ok(())
}
}
fn is_valid_hex_color(color: &str) -> bool {
let Some(hex) = color.strip_prefix('#') else {
return false;
};
matches!(hex.len(), 3 | 6) && hex.chars().all(|hc| hc.is_ascii_hexdigit())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
use tempfile::tempdir;
fn parse(toml: &str) -> Config {
toml::from_str(toml).expect("test TOML should be valid")
}
fn valid_toml() -> &'static str {
indoc! {"
[github]
owner = \"rust-lang\"
project = 42
[[field]]
display-name = \"status\"
github-name = \"Status\"
[[field]]
display-name = \"priority\"
github-name = \"Priority\"
[colors]
github-name = \"Status\"
[colors.values]
\"In Progress\" = \"#4a90d9\"
\"Blocked\" = \"#e05252\"
\"Complete\" = \"#57a85a\"
"}
}
fn minimal_toml() -> &'static str {
indoc! {"
[github]
owner = \"nikomatsakis\"
project = 1
"}
}
#[test]
fn parses_github_section() {
let config = parse(valid_toml());
assert_eq!(config.github.owner, "rust-lang");
assert_eq!(config.github.project, 42);
}
#[test]
fn parses_multiple_fields() {
let config = parse(valid_toml());
assert_eq!(config.fields.len(), 2);
assert_eq!(config.fields[0].display_name, "status");
assert_eq!(config.fields[0].github_name, "Status");
assert_eq!(config.fields[1].display_name, "priority");
assert_eq!(config.fields[1].github_name, "Priority");
}
#[test]
fn parses_colors_section() {
let config = parse(valid_toml());
assert_eq!(config.colors.github_name, "Status");
assert_eq!(
config.colors.values.get("In Progress").map(String::as_str),
Some("#4a90d9")
);
}
#[test]
fn minimal_config_is_valid() {
// No [[field]] and no [colors] -- both are optional after
// introducing field auto-discovery.
let config = parse(minimal_toml());
assert!(config.validate().is_ok());
assert!(config.fields.is_empty());
assert!(config.colors.github_name.is_empty());
}
#[test]
fn config_without_fields_is_valid() {
// [[field]] is optional -- skill-tree fetches all fields regardless.
let config = parse(indoc! {"
[github]
owner = \"rust-lang\"
project = 42
[colors]
github-name = \"Status\"
"});
assert!(config.validate().is_ok());
}
#[test]
fn validation_passes_on_valid_config() {
let config = parse(valid_toml());
assert!(config.validate().is_ok());
}
#[test]
fn validation_fails_on_invalid_hex_color() {
let config = parse(indoc! {"
[github]
owner = \"rust-lang\"
project = 42
[[field]]
display-name = \"status\"
github-name = \"Status\"
[colors]
github-name = \"Status\"
[colors.values]
\"In Progress\" = \"blue\"
"});
assert!(matches!(
config.validate(),
Err(ConfigError::InvalidColor { .. })
));
}
#[test]
fn from_dir_loads_config_file() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(".skill-tree.toml"), valid_toml()).unwrap();
let st = SkillTree::from_dir(tmp.path()).unwrap();
assert_eq!(st.config.github.owner, "rust-lang");
assert_eq!(st.config_dir(), tmp.path());
}
#[test]
fn from_dir_fails_when_file_missing() {
let tmp = tempdir().unwrap();
assert!(matches!(
SkillTree::from_dir(tmp.path()),
Err(ConfigError::Io { .. })
));
}
#[test]
fn color_for_value_returns_hex() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(".skill-tree.toml"), valid_toml()).unwrap();
let st = SkillTree::from_dir(tmp.path()).unwrap();
assert_eq!(st.color_for_value("In Progress"), Some("#4a90d9"));
assert_eq!(st.color_for_value("Unknown"), None);
}
#[test]
fn color_for_value_returns_none_when_colors_not_configured() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(".skill-tree.toml"), minimal_toml()).unwrap();
let st = SkillTree::from_dir(tmp.path()).unwrap();
assert_eq!(st.color_for_value("In Progress"), None);
}
#[test]
fn field_by_display_name_finds_declared_field() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(".skill-tree.toml"), valid_toml()).unwrap();
let st = SkillTree::from_dir(tmp.path()).unwrap();
let field = st.field_by_display_name("status").unwrap();
assert_eq!(field.github_name, "Status");
assert!(st.field_by_display_name("nonexistent").is_none());
}
#[test]
fn deny_unknown_fields_on_field_config() {
let result: Result<Config, _> = toml::from_str(indoc! {"
[github]
owner = \"rust-lang\"
project = 42
[[field]]
display-name = \"status\"
github-name = \"Status\"
unknown-key = \"oops\"
[colors]
github-name = \"Status\"
"});
assert!(result.is_err());
}
#[test]
fn hex_color_validation() {
assert!(is_valid_hex_color("#4a90d9"));
assert!(is_valid_hex_color("#fff"));
assert!(is_valid_hex_color("#FFF"));
assert!(is_valid_hex_color("#AABBCC"));
assert!(!is_valid_hex_color("blue"));
assert!(!is_valid_hex_color("#12345"));
assert!(!is_valid_hex_color("#gggggg"));
assert!(!is_valid_hex_color(""));
assert!(!is_valid_hex_color("#"));
}
}
================================================
FILE: src/error/config.rs
================================================
//! Configuration file errors.
use std::path::PathBuf;
/// Error returned when loading or validating a config file.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
/// I/O error reading the config file.
#[error("failed to read config file {path}: {source}")]
Io {
/// Path to the config file that failed to read.
path: PathBuf,
/// The underlying I/O error.
#[source]
source: std::io::Error,
},
/// TOML parsing error.
#[error("failed to parse config file {path}: {source}")]
Parse {
/// Path to the config file that failed to parse.
path: PathBuf,
/// The underlying TOML parse error.
#[source]
source: toml::de::Error,
},
/// Invalid hex color in the config.
#[error("invalid hex color in [colors.values]: {key} = {value}")]
InvalidColor {
/// The key that had the invalid color.
key: String,
/// The invalid color value.
value: String,
},
}
impl ConfigError {
/// Return the process exit code for this error.
pub fn exit_code(&self) -> u8 {
4 // config error
}
}
================================================
FILE: src/error/github.rs
================================================
//! GitHub API error types.
//!
//! All failures from GitHub requests are translated into structured
//! `GitHubError` variants. The transport layer does not know about
//! higher-level concerns like which project or owner triggered a call;
//! callers that want that context should wrap these errors at their
//! call site.
use std::fmt;
/// Error returned by the GitHub GraphQL client.
#[derive(Debug, thiserror::Error)]
pub enum GitHubError {
/// No token found in --token flag or GITHUB_TOKEN environment variable.
#[error("no GitHub token found. Set GITHUB_TOKEN or use --token flag")]
MissingToken,
/// HTTP client could not be constructed (TLS backend, proxy config, etc.).
#[error("failed to initialize HTTP client: {0}")]
ClientInit(String),
/// Network-level failure: timeout, DNS, TLS, connection refused, etc.
#[error("network error ({kind}): {message}")]
Network {
/// Category of network failure.
kind: NetworkErrorKind,
/// Human-readable description.
message: String,
},
/// HTTP response with error status code (4xx or 5xx).
#[error("HTTP {status}: {body}")]
HttpError {
/// HTTP status code.
status: u16,
/// Full response body.
body: String,
},
/// GraphQL response contained errors in the `errors` field.
#[error("GraphQL error: {0}")]
GraphQLError(String),
/// GitHub returned a body we could not interpret: malformed JSON, or a
/// well-formed envelope with neither `data` nor `errors`.
#[error("invalid response body: {0}")]
InvalidResponse(String),
/// GitHub rate limit exceeded. Caller should wait before retrying.
#[error("rate limit exceeded, retry after {retry_after}s")]
RateLimited {
/// Seconds to wait before retrying.
retry_after: u64,
},
/// Request exceeded the configured timeout.
#[error("request timeout after {0}s")]
Timeout(u64),
}
/// Category of network-level failure.
#[derive(Debug, Clone)]
pub enum NetworkErrorKind {
/// Request timeout (socket, DNS, or connection timeout).
Timeout,
/// Connection refused, reset, or closed unexpectedly.
Connection,
/// Other network error not categorized above.
Other(String),
}
impl fmt::Display for NetworkErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NetworkErrorKind::Timeout => write!(f, "timeout"),
NetworkErrorKind::Connection => write!(f, "connection refused"),
NetworkErrorKind::Other(s) => write!(f, "{s}"),
}
}
}
impl GitHubError {
/// Return the process exit code for this error.
///
/// - 1: malformed response (likely a bug or upstream regression)
/// - 3: GitHub API errors (network, HTTP, GraphQL, rate limit, timeout)
/// - 4: configuration errors (missing token, client init failure)
pub fn exit_code(&self) -> u8 {
match self {
GitHubError::MissingToken | GitHubError::ClientInit(_) => 4,
GitHubError::Network { .. }
| GitHubError::HttpError { .. }
| GitHubError::GraphQLError(_)
| GitHubError::RateLimited { .. }
| GitHubError::Timeout(_) => 3,
GitHubError::InvalidResponse(_) => 1,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_token_exit_code() {
assert_eq!(GitHubError::MissingToken.exit_code(), 4);
}
#[test]
fn client_init_exit_code() {
assert_eq!(GitHubError::ClientInit("tls".into()).exit_code(), 4);
}
#[test]
fn network_error_exit_code() {
let err = GitHubError::Network {
kind: NetworkErrorKind::Timeout,
message: "timeout waiting for response".to_string(),
};
assert_eq!(err.exit_code(), 3);
}
#[test]
fn http_error_exit_code() {
let err = GitHubError::HttpError {
status: 500,
body: "Internal Server Error".to_string(),
};
assert_eq!(err.exit_code(), 3);
}
#[test]
fn graphql_error_exit_code() {
let err = GitHubError::GraphQLError("Field not found".to_string());
assert_eq!(err.exit_code(), 3);
}
#[test]
fn rate_limited_exit_code() {
let err = GitHubError::RateLimited { retry_after: 3600 };
assert_eq!(err.exit_code(), 3);
}
#[test]
fn timeout_exit_code() {
let err = GitHubError::Timeout(30);
assert_eq!(err.exit_code(), 3);
}
#[test]
fn invalid_response_exit_code() {
let err = GitHubError::InvalidResponse("no data, no errors".into());
assert_eq!(err.exit_code(), 1);
}
#[test]
fn network_error_kind_display() {
assert_eq!(NetworkErrorKind::Timeout.to_string(), "timeout");
assert_eq!(
NetworkErrorKind::Connection.to_string(),
"connection refused"
);
assert_eq!(
NetworkErrorKind::Other("custom error".to_string()).to_string(),
"custom error"
);
}
}
================================================
FILE: src/error.rs
================================================
//! Error types for skill-tree.
//!
//! All errors in skill-tree are organized into modules by origin:
//! - [`github`] — GitHub API errors
//! - [`config`] — configuration file errors
//!
//! Each error type implements `.exit_code()` to map to the appropriate
//! process exit code (1, 3, or 4).
pub mod config;
pub mod github;
pub use config::ConfigError;
pub use github::{GitHubError, NetworkErrorKind};
================================================
FILE: src/github/issues.rs
================================================
//! Issue relationships: sub-issues and blocking dependencies.
//!
//! Placeholder: GraphQL queries and typed response structs land in a
//! follow-up.
================================================
FILE: src/github/mod.rs
================================================
//! GitHub GraphQL API client.
//!
//! This module is the only place in skill-tree that talks to GitHub.
//! Everything else works with the typed structs from [`projects`] and [`issues`].
pub mod issues;
pub mod projects;
use crate::error::{GitHubError, NetworkErrorKind};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
// ---------------------------------------------------------------------------
// GraphQL primitives
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub(crate) struct GraphQLRequest<'a, V: Serialize> {
pub query: &'a str,
pub variables: V,
}
#[derive(Debug, Deserialize)]
pub(crate) struct GraphQLResponse<T> {
pub data: Option<T>,
pub errors: Option<Vec<GraphQLErrorResponse>>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct GraphQLErrorResponse {
pub message: String,
}
// ---------------------------------------------------------------------------
// Pagination types
// ---------------------------------------------------------------------------
//
// GitHub's GraphQL API uses cursor-based pagination on every list ("connection").
// The transport does not paginate — callers loop, using `Connection<T>` in
// their response types and reading `page_info` to drive the loop.
/// Page metadata returned by every GitHub GraphQL connection.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageInfo {
pub has_next_page: bool,
pub end_cursor: Option<String>,
}
/// A list of `nodes` plus its `page_info`. Embed in your response struct
/// to get the standard pagination shape.
#[derive(Debug, Clone, Deserialize)]
pub struct Connection<T> {
pub nodes: Vec<T>,
#[serde(rename = "pageInfo")]
pub page_info: PageInfo,
}
// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------
/// A configured GitHub GraphQL client with built-in retry and rate limit handling.
///
/// Handles network errors, transient failures, rate limiting, and timeouts.
/// Pass `&GitHubClient` to [`projects`] and [`issues`] functions.
pub struct GitHubClient {
client: Client,
endpoint: String,
token: String,
timeout: Duration,
}
impl GitHubClient {
const DEFAULT_ENDPOINT: &'static str = "https://api.github.com/graphql";
const API_VERSION: &'static str = "2022-11-28";
/// Maximum number of HTTP requests per `query()` call: one initial
/// attempt plus `MAX_ATTEMPTS - 1` retries.
const MAX_ATTEMPTS: u32 = 3;
/// Wait used when GitHub returns a 429 with no parseable
/// `X-RateLimit-Reset` header. One minute is GitHub's documented
/// minimum reset window for secondary rate limits.
const RATE_LIMIT_FALLBACK_SECS: u64 = 60;
/// Create a new client targeting `https://api.github.com/graphql`,
/// reading the token from the parameter or the `GITHUB_TOKEN` env var.
///
/// Fails immediately with [`GitHubError::MissingToken`] if neither is present,
/// before any network I/O occurs.
pub fn new(token: Option<String>, timeout: Duration) -> Result<Self, GitHubError> {
Self::with_endpoint(Self::DEFAULT_ENDPOINT.to_string(), token, timeout)
}
/// Like [`Self::new`] but targets the supplied GraphQL endpoint URL.
/// Used by integration tests against a mock server; also the foundation
/// for any future GitHub Enterprise support.
pub fn with_endpoint(
endpoint: String,
token: Option<String>,
timeout: Duration,
) -> Result<Self, GitHubError> {
let token = token
.or_else(|| std::env::var("GITHUB_TOKEN").ok())
.ok_or(GitHubError::MissingToken)?;
// Per-request timeouts are set in `query_once` from the *remaining*
// budget, so a single hung request can't consume the whole timeout.
let client = Client::builder()
.user_agent("skill-tree")
.build()
.map_err(|e| GitHubError::ClientInit(e.to_string()))?;
Ok(Self {
client,
endpoint,
token,
timeout,
})
}
/// Send a GraphQL query with automatic retry and rate limit handling.
///
/// Makes one initial HTTP request plus up to 2 retries on transient
/// failures, with exponential backoff between attempts. Detects rate
/// limits and waits before retrying when the timeout budget allows.
/// Fails with [`GitHubError::Timeout`] if the entire operation exceeds
/// the configured timeout.
pub async fn query<V, T>(&self, query: &str, variables: V) -> Result<T, GitHubError>
where
V: Serialize,
T: DeserializeOwned,
{
let start = Instant::now();
for attempt in 1..=Self::MAX_ATTEMPTS {
if start.elapsed() >= self.timeout {
return Err(GitHubError::Timeout(self.timeout.as_secs()));
}
let err = match self.query_once(query, &variables, start).await {
Ok(response) => return Ok(response),
Err(err) => err,
};
// Last attempt: surface whatever we got, no more retries.
if attempt == Self::MAX_ATTEMPTS {
return Err(err);
}
// Rate limit: wait if the remaining budget covers it, else fail now.
if let GitHubError::RateLimited { retry_after } = &err {
let wait_secs = *retry_after;
let remaining = self
.timeout
.as_secs()
.saturating_sub(start.elapsed().as_secs());
if remaining > wait_secs {
eprintln!("Rate limited, waiting {wait_secs} seconds...");
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
continue;
}
return Err(err);
}
// Transient: back off and retry.
if Self::is_transient(&err) {
let backoff = Self::backoff_duration(attempt);
eprintln!(
"Transient error (attempt {}/{}), retrying in {:?}...",
attempt,
Self::MAX_ATTEMPTS,
backoff
);
tokio::time::sleep(backoff).await;
continue;
}
// Non-transient: fail fast.
return Err(err);
}
// Loop body always returns or `continue`s on attempts < MAX_ATTEMPTS,
// and always returns on attempt == MAX_ATTEMPTS.
unreachable!("retry loop exited without returning")
}
/// Send a single GraphQL request without retry logic. The per-request
/// timeout is the *remaining* budget so a single hung request cannot
/// consume the whole `query()`-level timeout.
async fn query_once<V, T>(
&self,
query: &str,
variables: &V,
start: Instant,
) -> Result<T, GitHubError>
where
V: Serialize,
T: DeserializeOwned,
{
let request = GraphQLRequest { query, variables };
let remaining = self.timeout.saturating_sub(start.elapsed());
let response = self
.client
.post(&self.endpoint)
.bearer_auth(&self.token)
.header("X-GitHub-Api-Version", Self::API_VERSION)
.timeout(remaining)
.json(&request)
.send()
.await
.map_err(Self::classify_reqwest_error)?;
let status = response.status();
if !status.is_success() {
if status.as_u16() == 429 {
let retry_after = response
.headers()
.get("X-RateLimit-Reset")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.and_then(|reset_time| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
Some(reset_time.saturating_sub(now))
});
return Err(GitHubError::RateLimited {
retry_after: retry_after.unwrap_or(Self::RATE_LIMIT_FALLBACK_SECS),
});
}
let body = response
.text()
.await
.unwrap_or_else(|e| format!("<failed to read body: {e}>"));
return Err(GitHubError::HttpError {
status: status.as_u16(),
body,
});
}
let body: GraphQLResponse<T> = response
.json()
.await
.map_err(Self::classify_reqwest_error)?;
// GitHub's GraphQL spec says `errors` must contain at least one entry
// when present. Treat an empty array the same as no errors so we don't
// surface a useless `GraphQLError("")`.
if let Some(errors) = body.errors.filter(|e| !e.is_empty()) {
let message = errors
.into_iter()
.map(|e| e.message)
.collect::<Vec<_>>()
.join("; ");
return Err(GitHubError::GraphQLError(message));
}
body.data.ok_or_else(|| {
GitHubError::InvalidResponse(
"GraphQL response had neither `data` nor `errors`".to_string(),
)
})
}
/// Classify a reqwest error. JSON decode failures are reported as
/// `InvalidResponse`; everything else is a `Network` error.
fn classify_reqwest_error(err: reqwest::Error) -> GitHubError {
if err.is_decode() {
return GitHubError::InvalidResponse(err.to_string());
}
let kind = if err.is_timeout() {
NetworkErrorKind::Timeout
} else if err.is_connect() {
NetworkErrorKind::Connection
} else {
NetworkErrorKind::Other(err.to_string())
};
GitHubError::Network {
kind,
message: err.to_string(),
}
}
/// Check if an error is transient and worth retrying.
fn is_transient(err: &GitHubError) -> bool {
match err {
GitHubError::Network { .. } => true,
GitHubError::HttpError { status, .. } => *status >= 500,
_ => false,
}
}
/// Delay before retry, with ±20% jitter to avoid thundering herd.
/// Called after a failed `attempt` when more retries remain, so for
/// `MAX_ATTEMPTS = 3` the inputs are 1 (~1s) and 2 (~2s).
fn backoff_duration(attempt: u32) -> Duration {
let base_millis = 1000_u64 * 2_u64.pow(attempt - 1);
let jitter_pct = rand::random::<u64>() % 21; // 0..=20
let signed = if rand::random::<bool>() {
base_millis + base_millis * jitter_pct / 100
} else {
base_millis - base_millis * jitter_pct / 100
};
Duration::from_millis(signed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Deserialize)]
struct Issue {
number: u64,
}
#[test]
fn connection_deserializes_from_github_shape() {
let json = r#"{
"nodes": [{"number": 1}, {"number": 2}],
"pageInfo": {
"hasNextPage": true,
"endCursor": "Y3Vyc29yOjEw"
}
}"#;
let conn: Connection<Issue> = serde_json::from_str(json).unwrap();
assert_eq!(conn.nodes.len(), 2);
assert_eq!(conn.nodes[0].number, 1);
assert!(conn.page_info.has_next_page);
assert_eq!(conn.page_info.end_cursor.as_deref(), Some("Y3Vyc29yOjEw"));
}
#[test]
fn page_info_handles_null_end_cursor_on_last_page() {
let json = r#"{"hasNextPage": false, "endCursor": null}"#;
let info: PageInfo = serde_json::from_str(json).unwrap();
assert!(!info.has_next_page);
assert!(info.end_cursor.is_none());
}
}
================================================
FILE: src/github/projects.rs
================================================
//! GitHub Projects V2 queries.
//!
//! Placeholder: GraphQL queries and typed response structs land in a
//! follow-up. Pagination is the caller's responsibility — see the loop
//! pattern documented in `md/design/02-github-client.md`.
================================================
FILE: src/graph/mod.rs
================================================
//! Graph validation: cycle detection via DFS, dangling edge checks,
//! and orphaned node warnings. Reports precise error paths.
mod validate;
================================================
FILE: src/graph/validate.rs
================================================
//! Graph validation: cycle detection via DFS, dangling edge checks,
//! and orphaned node warnings. Reports precise error paths.
================================================
FILE: src/lib.rs
================================================
//! # skill-tree
//!
//! skill-tree turns a GitHub Project into a visual dependency graph.
//!
//! ## Architecture
//!
//! The codebase is organized as a three-stage pipeline:
//!
//! ```text
//! GitHub API ──► graph::Graph ──► rendered output
//! (fetch) (model) (render)
//! ```
//!
//! Each stage has its own module:
//!
//! - [`config`] — reads `.skill-tree.toml`; drives all three stages
//! - [`github`] — fetches data from the GitHub GraphQL API
//! - [`graph`] — the platform-agnostic data model (nodes + edges)
//! - [`render`] — turns a [`graph`] into Graphviz DOT / SVG
//!
pub mod config;
pub mod error;
pub mod github;
pub mod graph;
pub mod render;
================================================
FILE: src/main.rs
================================================
//! skill-tree binary entry point.
//! Parses CLI arguments and dispatches to render, unblocked, or validate.
use skill_tree::config::SkillTree;
fn main() {
println!("Hello world!");
let config = SkillTree::from_dir(".").unwrap();
println!("{:#?}", config);
}
================================================
FILE: src/render/mod.rs
================================================
//! Renders a Graph as Graphviz DOT or SVG.
//! DOT output is deterministic. SVG is produced via the system dot binary.
//! Every node is a clickable link to its GitHub issue.
//!
================================================
FILE: tests/github_client.rs
================================================
//! Integration tests for `GitHubClient` against a mock GraphQL endpoint.
//!
//! Only imports the public API of `skill_tree` and the test infrastructure
//! exposed by `skill_tree_testlib`. The wiremock plumbing lives in the
//! testlib so individual tests stay focused on the scenario.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::json;
use skill_tree::error::GitHubError;
use skill_tree_testlib::MockGitHub;
#[derive(Serialize)]
struct EmptyVars {}
#[derive(Debug, Deserialize, PartialEq)]
struct Hello {
hello: String,
}
#[tokio::test]
async fn retries_5xx_then_succeeds_and_returns_data() {
let gh = MockGitHub::start().await;
gh.status(503).up_to_n_times(1).mount(&gh.server).await;
gh.ok_data(json!({ "hello": "world" }))
.mount(&gh.server)
.await;
let client = gh.client(Duration::from_secs(10));
let resp: Hello = client
.query("query Q { hello }", EmptyVars {})
.await
.unwrap();
assert_eq!(
resp,
Hello {
hello: "world".into()
}
);
}
#[tokio::test]
async fn gives_up_after_max_retries_returning_last_real_error() {
let gh = MockGitHub::start().await;
gh.status_with_body(500, "boom")
.expect(3) // MAX_ATTEMPTS
.mount(&gh.server)
.await;
let client = gh.client(Duration::from_secs(30));
let err = client
.query::<_, Hello>("query Q { hello }", EmptyVars {})
.await
.unwrap_err();
match err {
GitHubError::HttpError { status, body } => {
assert_eq!(status, 500);
assert_eq!(body, "boom");
}
other => panic!("expected HttpError(500), got {other:?}"),
}
}
#[tokio::test]
async fn rate_limit_within_budget_waits_and_retries() {
let gh = MockGitHub::start().await;
gh.rate_limited(1).up_to_n_times(1).mount(&gh.server).await;
gh.ok_data(json!({ "hello": "world" }))
.mount(&gh.server)
.await;
let client = gh.client(Duration::from_secs(10));
let resp: Hello = client
.query("query Q { hello }", EmptyVars {})
.await
.unwrap();
assert_eq!(
resp,
Hello {
hello: "world".into()
}
);
}
#[tokio::test]
async fn rate_limit_outside_budget_surfaces_to_caller() {
let gh = MockGitHub::start().await;
gh.rate_limited(60).mount(&gh.server).await;
// Tight timeout so a 60s wait is outside the budget.
let client = gh.client(Duration::from_secs(2));
let err = client
.query::<_, Hello>("query Q { hello }", EmptyVars {})
.await
.unwrap_err();
match err {
GitHubError::RateLimited { retry_after } => {
assert!(retry_after >= 58, "expected ~60s, got {retry_after}");
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[tokio::test]
async fn graphql_errors_are_returned_without_retry() {
let gh = MockGitHub::start().await;
gh.graphql_error("Field 'oops' not found")
.expect(1) // not retried
.mount(&gh.server)
.await;
let client = gh.client(Duration::from_secs(10));
let err = client
.query::<_, Hello>("query Q { oops }", EmptyVars {})
.await
.unwrap_err();
match err {
GitHubError::GraphQLError(msg) => assert!(msg.contains("oops")),
other => panic!("expected GraphQLError, got {other:?}"),
}
}
#[tokio::test]
async fn invalid_response_when_envelope_has_neither_data_nor_errors() {
let gh = MockGitHub::start().await;
gh.empty_envelope()
.expect(1) // not retried
.mount(&gh.server)
.await;
let client = gh.client(Duration::from_secs(10));
let err = client
.query::<_, Hello>("query Q { hello }", EmptyVars {})
.await
.unwrap_err();
assert!(
matches!(err, GitHubError::InvalidResponse(_)),
"got {err:?}"
);
}
#[tokio::test]
async fn sends_api_version_and_authorization_headers() {
let gh = MockGitHub::start().await;
gh.ok_data_with_headers(
json!({ "hello": "world" }),
&[
("X-GitHub-Api-Version", "2022-11-28"),
("Authorization", "Bearer test-token"),
],
)
.expect(1)
.mount(&gh.server)
.await;
let client = gh.client(Duration::from_secs(10));
let _: Hello = client
.query("query Q { hello }", EmptyVars {})
.await
.unwrap();
// Mock's `.expect(1)` is verified on drop — if headers were wrong, no
// mock would have matched and the assertion would fail there.
}
================================================
FILE: tests/integration.rs
================================================
gitextract_h1tuqbnh/
├── .github/
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── .skill-tree.toml
├── CODE_OF_CONDUCT.md
├── Cargo.toml
├── README.md
├── RELEASES.md
├── book.toml
├── md/
│ ├── contributing/
│ │ ├── 01-module-structure.md
│ │ ├── 02-important-flows.md
│ │ ├── 03-common-issues.md
│ │ └── 04-running-tests.md
│ ├── design/
│ │ ├── 01-config.md
│ │ └── 02-github-client.md
│ ├── introduction.md
│ ├── summary.md
│ └── welcome.md
├── mermaid-init.js
├── skill-tree-testlib/
│ ├── Cargo.toml
│ └── src/
│ ├── github.rs
│ └── lib.rs
├── src/
│ ├── cli/
│ │ ├── mod.rs
│ │ ├── render.rs
│ │ ├── unblocked.rs
│ │ └── validate.rs
│ ├── config.rs
│ ├── error/
│ │ ├── config.rs
│ │ └── github.rs
│ ├── error.rs
│ ├── github/
│ │ ├── issues.rs
│ │ ├── mod.rs
│ │ └── projects.rs
│ ├── graph/
│ │ ├── mod.rs
│ │ └── validate.rs
│ ├── lib.rs
│ ├── main.rs
│ └── render/
│ └── mod.rs
└── tests/
├── github_client.rs
└── integration.rs
SYMBOL INDEX (88 symbols across 7 files)
FILE: skill-tree-testlib/src/github.rs
type MockGitHub (line 23) | pub struct MockGitHub {
method start (line 28) | pub async fn start() -> Self {
method client (line 37) | pub fn client(&self, timeout: Duration) -> GitHubClient {
method matcher (line 48) | pub fn matcher(&self) -> MockBuilder {
method ok_data (line 53) | pub fn ok_data(&self, body: Value) -> MockHandle {
method ok_data_with_headers (line 62) | pub fn ok_data_with_headers(&self, body: Value, headers: &[(&str, &str...
method graphql_error (line 73) | pub fn graphql_error(&self, message: &str) -> MockHandle {
method empty_envelope (line 83) | pub fn empty_envelope(&self) -> MockHandle {
method status (line 89) | pub fn status(&self, status: u16) -> MockHandle {
method status_with_body (line 94) | pub fn status_with_body(&self, status: u16, body: &str) -> MockHandle {
method rate_limited (line 101) | pub fn rate_limited(&self, secs_until_reset: u64) -> MockHandle {
FILE: src/config.rs
type Fallible (line 24) | type Fallible<T> = Result<T, ConfigError>;
type Config (line 27) | pub struct Config {
method validate (line 149) | fn validate(&self) -> Fallible<()> {
type GithubConfig (line 36) | pub struct GithubConfig {
type FieldConfig (line 51) | pub struct FieldConfig {
type ColorsConfig (line 64) | pub struct ColorsConfig {
type SkillTree (line 78) | pub struct SkillTree {
constant CONFIG_FILENAME (line 88) | pub const CONFIG_FILENAME: &'static str = ".skill-tree.toml";
method from_dir (line 93) | pub fn from_dir(dir: impl AsRef<Path>) -> Fallible<Self> {
method from_path (line 99) | pub fn from_path(path: impl AsRef<Path>) -> Fallible<Self> {
method config_dir (line 120) | pub fn config_dir(&self) -> &Path {
method color_for_value (line 128) | pub fn color_for_value(&self, value: &str) -> Option<&str> {
method color_field_github_name (line 133) | pub fn color_field_github_name(&self) -> &str {
method field_by_display_name (line 140) | pub fn field_by_display_name(&self, display_name: &str) -> Option<&Fie...
function is_valid_hex_color (line 163) | fn is_valid_hex_color(color: &str) -> bool {
function parse (line 181) | fn parse(toml: &str) -> Config {
function valid_toml (line 185) | fn valid_toml() -> &'static str {
function minimal_toml (line 209) | fn minimal_toml() -> &'static str {
function parses_github_section (line 218) | fn parses_github_section() {
function parses_multiple_fields (line 225) | fn parses_multiple_fields() {
function parses_colors_section (line 235) | fn parses_colors_section() {
function minimal_config_is_valid (line 245) | fn minimal_config_is_valid() {
function config_without_fields_is_valid (line 255) | fn config_without_fields_is_valid() {
function validation_passes_on_valid_config (line 269) | fn validation_passes_on_valid_config() {
function validation_fails_on_invalid_hex_color (line 275) | fn validation_fails_on_invalid_hex_color() {
function from_dir_loads_config_file (line 298) | fn from_dir_loads_config_file() {
function from_dir_fails_when_file_missing (line 308) | fn from_dir_fails_when_file_missing() {
function color_for_value_returns_hex (line 317) | fn color_for_value_returns_hex() {
function color_for_value_returns_none_when_colors_not_configured (line 327) | fn color_for_value_returns_none_when_colors_not_configured() {
function field_by_display_name_finds_declared_field (line 336) | fn field_by_display_name_finds_declared_field() {
function deny_unknown_fields_on_field_config (line 347) | fn deny_unknown_fields_on_field_config() {
function hex_color_validation (line 365) | fn hex_color_validation() {
FILE: src/error/config.rs
type ConfigError (line 7) | pub enum ConfigError {
method exit_code (line 40) | pub fn exit_code(&self) -> u8 {
FILE: src/error/github.rs
type GitHubError (line 13) | pub enum GitHubError {
method exit_code (line 88) | pub fn exit_code(&self) -> u8 {
type NetworkErrorKind (line 63) | pub enum NetworkErrorKind {
method fmt (line 73) | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
function missing_token_exit_code (line 106) | fn missing_token_exit_code() {
function client_init_exit_code (line 111) | fn client_init_exit_code() {
function network_error_exit_code (line 116) | fn network_error_exit_code() {
function http_error_exit_code (line 125) | fn http_error_exit_code() {
function graphql_error_exit_code (line 134) | fn graphql_error_exit_code() {
function rate_limited_exit_code (line 140) | fn rate_limited_exit_code() {
function timeout_exit_code (line 146) | fn timeout_exit_code() {
function invalid_response_exit_code (line 152) | fn invalid_response_exit_code() {
function network_error_kind_display (line 158) | fn network_error_kind_display() {
FILE: src/github/mod.rs
type GraphQLRequest (line 20) | pub(crate) struct GraphQLRequest<'a, V: Serialize> {
type GraphQLResponse (line 26) | pub(crate) struct GraphQLResponse<T> {
type GraphQLErrorResponse (line 32) | pub(crate) struct GraphQLErrorResponse {
type PageInfo (line 47) | pub struct PageInfo {
type Connection (line 55) | pub struct Connection<T> {
type GitHubClient (line 69) | pub struct GitHubClient {
constant DEFAULT_ENDPOINT (line 77) | const DEFAULT_ENDPOINT: &'static str = "https://api.github.com/graphql";
constant API_VERSION (line 78) | const API_VERSION: &'static str = "2022-11-28";
constant MAX_ATTEMPTS (line 82) | const MAX_ATTEMPTS: u32 = 3;
constant RATE_LIMIT_FALLBACK_SECS (line 87) | const RATE_LIMIT_FALLBACK_SECS: u64 = 60;
method new (line 94) | pub fn new(token: Option<String>, timeout: Duration) -> Result<Self, G...
method with_endpoint (line 101) | pub fn with_endpoint(
method query (line 132) | pub async fn query<V, T>(&self, query: &str, variables: V) -> Result<T...
method query_once (line 195) | async fn query_once<V, T>(
method classify_reqwest_error (line 276) | fn classify_reqwest_error(err: reqwest::Error) -> GitHubError {
method is_transient (line 296) | fn is_transient(err: &GitHubError) -> bool {
method backoff_duration (line 307) | fn backoff_duration(attempt: u32) -> Duration {
type Issue (line 324) | struct Issue {
function connection_deserializes_from_github_shape (line 329) | fn connection_deserializes_from_github_shape() {
function page_info_handles_null_end_cursor_on_last_page (line 346) | fn page_info_handles_null_end_cursor_on_last_page() {
FILE: src/main.rs
function main (line 6) | fn main() {
FILE: tests/github_client.rs
type EmptyVars (line 15) | struct EmptyVars {}
type Hello (line 18) | struct Hello {
function retries_5xx_then_succeeds_and_returns_data (line 23) | async fn retries_5xx_then_succeeds_and_returns_data() {
function gives_up_after_max_retries_returning_last_real_error (line 44) | async fn gives_up_after_max_retries_returning_last_real_error() {
function rate_limit_within_budget_waits_and_retries (line 67) | async fn rate_limit_within_budget_waits_and_retries() {
function rate_limit_outside_budget_surfaces_to_caller (line 88) | async fn rate_limit_outside_budget_surfaces_to_caller() {
function graphql_errors_are_returned_without_retry (line 108) | async fn graphql_errors_are_returned_without_retry() {
function invalid_response_when_envelope_has_neither_data_nor_errors (line 128) | async fn invalid_response_when_envelope_has_neither_data_nor_errors() {
function sends_api_version_and_authorization_headers (line 148) | async fn sends_api_version_and_authorization_headers() {
Condensed preview — 39 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (84K chars).
[
{
"path": ".github/workflows/ci.yml",
"chars": 2355,
"preview": "name: CI\n\non:\n push:\n branches: [master]\n pull_request:\n\nenv:\n CARGO_TERM_COLOR: always\n\njobs:\n check:\n name: "
},
{
"path": ".gitignore",
"chars": 16,
"preview": "/target\n.claude/"
},
{
"path": ".skill-tree.toml",
"chars": 382,
"preview": "[github]\nowner = \"rust-lang\"\nproject = 42\n\n[[field]]\ndisplay-name = \"status\"\ngithub-name = \"Status\"\n\n[[field]]\ndispla"
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 1897,
"preview": "# Code of Conduct\n\n## Conduct\n\n- We are committed to providing a friendly, safe and welcoming environment for all, regar"
},
{
"path": "Cargo.toml",
"chars": 779,
"preview": "[package]\nname = \"skill-tree\"\nversion = \"3.2.1\"\nauthors = [\"Niko Matsakis <niko@alum.mit.edu>\"]\nedition = \"2024\"\ndescrip"
},
{
"path": "README.md",
"chars": 2948,
"preview": "# skill-tree\n\nskill-tree fetches a GitHub Project and renders it as a directed dependency graph.\n\n## What is a skill tre"
},
{
"path": "RELEASES.md",
"chars": 166,
"preview": "# 1.3.2\n\n* Remove outdated dependencies\n\n# 1.3.0\n\n* Fix bug around underline\n* Add ability to have `href` on tree nodes\n"
},
{
"path": "book.toml",
"chars": 452,
"preview": "[book]\nauthors = [\"Niko Matsakis\", \"James Muriuki\"]\nlanguage = \"en\"\nsrc = \"md\"\ntitle = \"skill-tree\"\n\n[preprocessor.merma"
},
{
"path": "md/contributing/01-module-structure.md",
"chars": 1940,
"preview": "# Key modules\n\nskill-tree is organized as a three-stage pipeline: fetch data from GitHub, model it as a graph, render it"
},
{
"path": "md/contributing/02-important-flows.md",
"chars": 1357,
"preview": "# Important flows\n\nskill-tree is a three-stage pipeline. These are the major paths through the code.\n\n## `render` comman"
},
{
"path": "md/contributing/03-common-issues.md",
"chars": 2556,
"preview": "# Common issues\n\n## Known v1 limitations\n\n### Pagination not yet implemented\n\nThe GitHub client has the structure for au"
},
{
"path": "md/contributing/04-running-tests.md",
"chars": 774,
"preview": "# Running tests\n\n## Unit tests\n\n```bash\ncargo test\n```\n\nRuns all unit tests inside the `skill-tree` crate. No network ac"
},
{
"path": "md/design/01-config.md",
"chars": 5234,
"preview": "# Configuration\n\nskill-tree is configured via a `.skill-tree.toml` file in the current\ndirectory. This file tells skill-"
},
{
"path": "md/design/02-github-client.md",
"chars": 9578,
"preview": "# GitHub GraphQL Client\n\nThe `github` module owns all communication with the GitHub API. Other modules\nimport typed stru"
},
{
"path": "md/introduction.md",
"chars": 1897,
"preview": "# skill-tree\n\nskill-tree fetches a GitHub Project and renders it as a directed dependency graph.\n\nGiven a `.skill-tree.t"
},
{
"path": "md/summary.md",
"chars": 711,
"preview": "# Summary\n\n- [Introduction](./introduction.md)\n\n# User's guide\n\n- [Installing skill-tree](./guide/install.md)\n- [Configu"
},
{
"path": "md/welcome.md",
"chars": 2607,
"preview": "# Contributing to skill-tree\n\nWelcome! This section is for people who want to work on skill-tree itself. If you're a use"
},
{
"path": "mermaid-init.js",
"chars": 1262,
"preview": "// This Source Code Form is subject to the terms of the Mozilla Public\n// License, v. 2.0. If a copy of the MPL was not "
},
{
"path": "skill-tree-testlib/Cargo.toml",
"chars": 158,
"preview": "[package]\nname = \"skill-tree-testlib\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde_json = \"1.0.149\"\nskill-tr"
},
{
"path": "skill-tree-testlib/src/github.rs",
"chars": 4240,
"preview": "//! Mock GitHub GraphQL endpoint for integration tests.\n//!\n//! `MockGitHub` wraps a `wiremock::MockServer` configured t"
},
{
"path": "skill-tree-testlib/src/lib.rs",
"chars": 145,
"preview": "//! Test infrastructure for skill-tree integration tests.\n//! Always imported as a dev-dependency.\n\npub mod github;\n\npub"
},
{
"path": "src/cli/mod.rs",
"chars": 151,
"preview": "//! CLI argument definitions using clap.\n//! Declares the three subcommands: render, unblocked, validate.\n//!\nmod render"
},
{
"path": "src/cli/render.rs",
"chars": 131,
"preview": "//! Implementation of the `skill-tree render` subcommand.\n//! Runs the full fetch → model → render pipeline and writes D"
},
{
"path": "src/cli/unblocked.rs",
"chars": 121,
"preview": "//! Implementation of the `skill-tree unblocked` subcommand.\n//! Prints all open issues with no incoming blocking edges."
},
{
"path": "src/cli/validate.rs",
"chars": 131,
"preview": "//! Implementation of the `skill-tree validate` subcommand.\n//! Checks for cycles and dangling edges. Produces no render"
},
{
"path": "src/config.rs",
"chars": 11147,
"preview": "//! Reads and validates .skill-tree.toml.\n//!\n//! Two types carry configuration through the application:\n//!\n//! - [`Con"
},
{
"path": "src/error/config.rs",
"chars": 1161,
"preview": "//! Configuration file errors.\n\nuse std::path::PathBuf;\n\n/// Error returned when loading or validating a config file.\n#["
},
{
"path": "src/error/github.rs",
"chars": 5130,
"preview": "//! GitHub API error types.\n//!\n//! All failures from GitHub requests are translated into structured\n//! `GitHubError` v"
},
{
"path": "src/error.rs",
"chars": 409,
"preview": "//! Error types for skill-tree.\n//!\n//! All errors in skill-tree are organized into modules by origin:\n//! - [`github`] "
},
{
"path": "src/github/issues.rs",
"chars": 152,
"preview": "//! Issue relationships: sub-issues and blocking dependencies.\n//!\n//! Placeholder: GraphQL queries and typed response s"
},
{
"path": "src/github/mod.rs",
"chars": 12250,
"preview": "//! GitHub GraphQL API client.\n//!\n//! This module is the only place in skill-tree that talks to GitHub.\n//! Everything "
},
{
"path": "src/github/projects.rs",
"chars": 237,
"preview": "//! GitHub Projects V2 queries.\n//!\n//! Placeholder: GraphQL queries and typed response structs land in a\n//! follow-up."
},
{
"path": "src/graph/mod.rs",
"chars": 145,
"preview": "//! Graph validation: cycle detection via DFS, dangling edge checks,\n//! and orphaned node warnings. Reports precise err"
},
{
"path": "src/graph/validate.rs",
"chars": 130,
"preview": "//! Graph validation: cycle detection via DFS, dangling edge checks,\n//! and orphaned node warnings. Reports precise err"
},
{
"path": "src/lib.rs",
"chars": 696,
"preview": "//! # skill-tree\n//!\n//! skill-tree turns a GitHub Project into a visual dependency graph.\n//!\n//! ## Architecture\n//!\n/"
},
{
"path": "src/main.rs",
"chars": 276,
"preview": "//! skill-tree binary entry point.\n//! Parses CLI arguments and dispatches to render, unblocked, or validate.\n\nuse skill"
},
{
"path": "src/render/mod.rs",
"chars": 180,
"preview": "//! Renders a Graph as Graphviz DOT or SVG.\n//! DOT output is deterministic. SVG is produced via the system dot binary.\n"
},
{
"path": "tests/github_client.rs",
"chars": 4634,
"preview": "//! Integration tests for `GitHubClient` against a mock GraphQL endpoint.\n//!\n//! Only imports the public API of `skill_"
},
{
"path": "tests/integration.rs",
"chars": 1,
"preview": "\n"
}
]
About this extraction
This page contains the full source code of the nikomatsakis/skill-tree GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 39 files (76.7 KB), approximately 19.6k tokens, and a symbol index with 88 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.