feat: add script to export GitHub Projects V2 board to Markdown#167
Merged
joshjohanning merged 3 commits intomainfrom Apr 22, 2026
Merged
feat: add script to export GitHub Projects V2 board to Markdown#167joshjohanning merged 3 commits intomainfrom
joshjohanning merged 3 commits intomainfrom
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a new gh-cli shell script to export a GitHub Projects V2 board into a shareable Markdown document (TOC + per-item sections), and documents the new tool in the gh-cli README.
Changes:
- Introduces
export-project-board-to-markdown.shto fetch Projects V2 items via GraphQL and render them as structured Markdown - Generates a Markdown table of contents plus per-item metadata tables, descriptions, and comments
- Updates
gh-cli/README.mdwith usage and feature notes for the new script
Show a summary per file
| File | Description |
|---|---|
gh-cli/export-project-board-to-markdown.sh |
New exporter script that queries Projects V2 via GraphQL and writes a Markdown report |
gh-cli/README.md |
Adds documentation entry for the new exporter script |
Copilot's findings
Comments suppressed due to low confidence (4)
gh-cli/export-project-board-to-markdown.sh:310
- Markdown table rows are built by directly interpolating field values. If any value contains
|or newlines (common for free-text project fields), the table will render incorrectly. Escape|(e.g.,\|) and convert newlines to<br>(or otherwise sanitize) for all values emitted inside the table.
echo "| Field | Value |"
echo "| --- | --- |"
echo "| Type | $icon |"
if [ -n "$repo_owner" ] && [ -n "$repo_name" ]; then
echo "| Repository | \`$repo_owner/$repo_name\` |"
fi
if [ -n "$number" ] && [ -n "$url" ]; then
echo "| Link | [#$number]($url) |"
elif [ -n "$url" ]; then
echo "| Link | [$url]($url) |"
fi
if [ -n "$state" ]; then
if [ "$type" = "PullRequest" ] && [ "$merged" = "true" ]; then
echo "| State | MERGED |"
else
echo "| State | $state |"
fi
fi
# Pull out Status and Day custom fields up front so they appear in a consistent slot.
status_value=$(echo "$item" | jq -r '[.fieldValues.nodes[]? | select(.field.name == "Status") | (.text // .name // .title // .date // (.number | tostring?))] | first // empty')
day_value=$(echo "$item" | jq -r '[.fieldValues.nodes[]? | select(.field.name == "Day") | (.text // .name // .title // .date // (.number | tostring?))] | first // empty')
if [ -n "$status_value" ]; then
echo "| Status | $status_value |"
fi
if [ -n "$day_value" ]; then
echo "| Day | $day_value |"
fi
if [ -n "$author" ]; then
echo "| Author | @$author |"
fi
if [ -n "$created" ]; then
created_fmt=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$created" "+%Y-%m-%d" 2>/dev/null || echo "$created")
echo "| Created | $created_fmt |"
fi
# Assignees
assignees=$(echo "$item" | jq -r '[.content.assignees.nodes[]?.login] | map("@" + .) | join(", ")')
if [ -n "$assignees" ]; then
echo "| Assignees | $assignees |"
fi
# Labels
labels=$(echo "$item" | jq -r '[.content.labels.nodes[]?.name] | map("`" + . + "`") | join(", ")')
if [ -n "$labels" ]; then
echo "| Labels | $labels |"
fi
# Remaining custom field values (Title/Description/Body excluded; Status/Day already emitted)
echo "$item" | jq -r '
.fieldValues.nodes[]?
| select(.field.name != null
and (.field.name | IN("Title", "Description", "Body", "Status", "Day") | not))
| "| " + .field.name + " | " + (
(.text // .name // .title // .date // (.number | tostring?)) // ""
) + " |"
'
gh-cli/export-project-board-to-markdown.sh:221
- Inside the per-item loop, the script invokes
jqmany times against the same JSON object (title/number/url/repo/state/author/etc.). For boards with many items, repeatedly parsing JSON can make the export noticeably slow. Consider extracting all needed fields in a singlejqcall per item (or emitting the full Markdown via onejqtemplate) to reduce overhead.
item=$(echo "$items_json" | jq -c ".[$i]")
idx=$((i + 1))
type=$(echo "$item" | jq -r '.content.__typename // "ProjectItem"')
# Resolve title
title=$(echo "$item" | jq -r '.content.title // empty')
if [ -z "$title" ]; then
title=$(echo "$item" | jq -r '[.fieldValues.nodes[]? | select(.field.name == "Title") | .text] | first // "Untitled"')
fi
number=$(echo "$item" | jq -r '.content.number // empty')
url=$(echo "$item" | jq -r '.content.url // empty')
repo_owner=$(echo "$item" | jq -r '.content.repository.owner.login // empty')
repo_name=$(echo "$item" | jq -r '.content.repository.name // empty')
state=$(echo "$item" | jq -r '.content.state // empty')
merged=$(echo "$item" | jq -r '.content.merged // empty')
author=$(echo "$item" | jq -r '.content.author.login // .content.creator.login // empty')
created=$(echo "$item" | jq -r '.content.createdAt // empty')
body=$(echo "$item" | jq -r '.content.body // empty')
gh-cli/export-project-board-to-markdown.sh:36
- The GraphQL call is missing the
X-Github-Next-Global-ID: 1header. Add this header to thegh api graphqlrequest so results use the new global node ID format (per GitHub’s GraphQL global ID migration guidance).
response=$(gh api graphql --paginate -f org="$org" -F projectNumber="$project_number" -f query='
query($org: String!, $projectNumber: Int!, $endCursor: String) {
gh-cli/export-project-board-to-markdown.sh:170
jq’sgroup_by(.)requires the input array to be sorted by the grouping key; otherwise identical Status values that aren’t adjacent will be counted in separate groups. Sort the Status list (e.g.,sort/sort_by(.)) beforegroup_by(.)so the status breakdown counts are correct.
status_summary=$(echo "$items_json" | jq -r '
[ .[] | .fieldValues.nodes[]? | select(.field.name == "Status") | .name ]
| group_by(.)
| map({status: .[0], count: length})
| sort_by(-.count)
| map("\(.status): \(.count)")
| join(" - ")
')
- Files reviewed: 2/2 changed files
- Comments generated: 2
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ename behavior Agent-Logs-Url: https://github.com/joshjohanning/github-misc-scripts/sessions/00189b49-0cc0-4114-96f4-9345a93653b7 Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This pull request adds a new script for exporting GitHub Project Boards (Projects V2) to a clean, shareable Markdown file. The script fetches all board items, preserves their Markdown formatting, and generates a well-structured document with a clickable table of contents and detailed metadata. Documentation has also been updated to describe this new tool.