Skip to content

Instantly share code, notes, and snippets.

@starius
Created April 10, 2026 03:20
Show Gist options
  • Select an option

  • Save starius/80925275c7d352e8c5ebd06d558659ba to your computer and use it in GitHub Desktop.

Select an option

Save starius/80925275c7d352e8c5ebd06d558659ba to your computer and use it in GitHub Desktop.
Save all issues and discussions from GitHub repo using gh tool
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: gh-discussions-save.sh OWNER/REPO
Fetch all discussions, top-level comments, and threaded replies with GitHub
CLI and save one Markdown file per discussion in the current directory.
Examples:
./gh-discussions-save.sh cli/cli
./gh-discussions-save.sh https://github.com/cli/cli
Requirements:
- gh
- jq
- an authenticated gh session (`gh auth login`)
EOF
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
normalize_repo() {
local repo="$1"
repo="${repo#https://github.com/}"
repo="${repo#http://github.com/}"
repo="${repo#github.com/}"
repo="${repo%/}"
case "$repo" in
*/*)
printf '%s\n' "$repo"
;;
*)
return 1
;;
esac
}
slugify() {
local input="$1"
local slug
slug="$(
printf '%s' "$input" |
tr '[:upper:]' '[:lower:]' |
sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g'
)"
slug="${slug:0:80}"
if [[ -z "$slug" ]]; then
slug="untitled"
fi
printf '%s\n' "$slug"
}
safe_token() {
local input="$1"
local token
token="$(
printf '%s' "$input" |
sed -E 's/[^A-Za-z0-9._-]+/-/g; s/^-+//; s/-+$//; s/-+/-/g'
)"
token="${token:0:120}"
if [[ -z "$token" ]]; then
token="item"
fi
printf '%s\n' "$token"
}
render_list_line() {
local label="$1"
local value="$2"
if [[ -n "$value" ]]; then
printf -- '- %s: %s\n' "$label" "$value"
fi
}
DISCUSSIONS_QUERY="$(cat <<'EOF'
query($owner: String!, $repo: String!, $endCursor: String) {
repository(owner: $owner, name: $repo) {
discussions(first: 100, after: $endCursor, orderBy: {field: CREATED_AT, direction: ASC}) {
nodes {
id
number
title
body
url
createdAt
updatedAt
isAnswered
answerChosenAt
locked
author {
login
}
authorAssociation
category {
name
isAnswerable
}
comments {
totalCount
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
EOF
)"
COMMENTS_QUERY="$(cat <<'EOF'
query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {
repository(owner: $owner, name: $repo) {
discussion(number: $number) {
comments(first: 100, after: $endCursor) {
nodes {
id
databaseId
url
body
createdAt
updatedAt
isAnswer
author {
login
}
authorAssociation
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
EOF
)"
REPLIES_QUERY="$(cat <<'EOF'
query($id: ID!, $endCursor: String) {
node(id: $id) {
... on DiscussionComment {
replies(first: 100, after: $endCursor) {
nodes {
id
url
body
createdAt
updatedAt
isAnswer
author {
login
}
authorAssociation
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
EOF
)"
if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then
usage
exit 0
fi
if [[ $# -ne 1 ]]; then
usage >&2
exit 1
fi
need_cmd gh
need_cmd jq
repo="$(normalize_repo "$1")" || die "Repository must look like OWNER/REPO or https://github.com/OWNER/REPO"
owner="${repo%%/*}"
repo_name="${repo#*/}"
gh auth status >/dev/null 2>&1 || die "gh is not authenticated. Run: gh auth login"
workdir="$(pwd)"
tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/gh-discussions-save.XXXXXX")"
trap 'rm -rf "$tmpdir"' EXIT
discussions_pages="$tmpdir/discussions-pages.json"
discussions_file="$tmpdir/discussions.json"
comments_dir="$tmpdir/comments-by-discussion"
replies_dir="$tmpdir/replies-by-comment"
mkdir -p "$comments_dir" "$replies_dir"
printf 'Fetching discussions from %s...\n' "$repo" >&2
gh api graphql --paginate --slurp \
-F owner="$owner" \
-F repo="$repo_name" \
-f query="$DISCUSSIONS_QUERY" > "$discussions_pages"
jq '[.[].data.repository.discussions.nodes[]?] | sort_by(.number)' \
"$discussions_pages" > "$discussions_file"
discussion_count="$(jq 'length' "$discussions_file")"
printf 'Found %s discussions. Fetching threads...\n' "$discussion_count" >&2
total_comments=0
total_replies=0
while IFS= read -r discussion_json; do
number="$(jq -r '.number' <<<"$discussion_json")"
comments_pages="$tmpdir/discussion-$number-comments-pages.json"
comments_file="$comments_dir/$number.json"
printf 'Fetching comments for discussion #%s...\n' "$number" >&2
gh api graphql --paginate --slurp \
-F owner="$owner" \
-F repo="$repo_name" \
-F number="$number" \
-f query="$COMMENTS_QUERY" > "$comments_pages"
jq '[.[].data.repository.discussion.comments.nodes[]?] | sort_by(.createdAt)' \
"$comments_pages" > "$comments_file"
comments_in_discussion="$(jq 'length' "$comments_file")"
total_comments="$((total_comments + comments_in_discussion))"
while IFS= read -r comment_json; do
comment_id="$(jq -r '.id' <<<"$comment_json")"
comment_dbid="$(jq -r '.databaseId // empty' <<<"$comment_json")"
comment_key="$comment_dbid"
if [[ -z "$comment_key" ]]; then
comment_key="$(safe_token "$comment_id")"
fi
reply_pages="$tmpdir/${number}-${comment_key}-replies-pages.json"
reply_file="$replies_dir/$comment_key.json"
gh api graphql --paginate --slurp \
-F id="$comment_id" \
-f query="$REPLIES_QUERY" > "$reply_pages"
jq '[.[].data.node.replies.nodes[]?] | sort_by(.createdAt)' \
"$reply_pages" > "$reply_file"
replies_in_comment="$(jq 'length' "$reply_file")"
total_replies="$((total_replies + replies_in_comment))"
done < <(jq -c '.[]' "$comments_file")
done < <(jq -c '.[]' "$discussions_file")
printf 'Writing %s discussion files to %s...\n' "$discussion_count" "$workdir" >&2
while IFS= read -r discussion_json; do
number="$(jq -r '.number' <<<"$discussion_json")"
title="$(jq -r '.title' <<<"$discussion_json")"
body="$(jq -r '.body // empty' <<<"$discussion_json")"
url="$(jq -r '.url // empty' <<<"$discussion_json")"
created_at="$(jq -r '.createdAt // empty' <<<"$discussion_json")"
updated_at="$(jq -r '.updatedAt // empty' <<<"$discussion_json")"
author="$(jq -r '.author.login // "unknown"' <<<"$discussion_json")"
author_association="$(jq -r '.authorAssociation // empty' <<<"$discussion_json")"
category="$(jq -r '.category.name // empty' <<<"$discussion_json")"
answered="$(jq -r '.isAnswered' <<<"$discussion_json")"
answer_chosen_at="$(jq -r '.answerChosenAt // empty' <<<"$discussion_json")"
locked="$(jq -r '.locked' <<<"$discussion_json")"
comments_total="$(jq -r '.comments.totalCount // 0' <<<"$discussion_json")"
comments_file="$comments_dir/$number.json"
file_name="discussion-${number}-$(slugify "$title").md"
{
printf '# %s\n\n' "$title"
printf '> Exported from `%s`.\n\n' "$repo"
printf -- '- Discussion: #%s\n' "$number"
render_list_line "Category" "$category"
render_list_line "Author" "$author"
render_list_line "Author association" "$author_association"
render_list_line "Created" "$created_at"
render_list_line "Updated" "$updated_at"
render_list_line "URL" "$url"
printf -- '- Answered: %s\n' "$answered"
if [[ -n "$answer_chosen_at" ]]; then
render_list_line "Answer chosen" "$answer_chosen_at"
fi
printf -- '- Locked: %s\n' "$locked"
printf -- '- Comments: %s\n' "$comments_total"
printf '\n## Body\n\n'
if [[ -n "$body" ]]; then
printf '%s\n' "$body"
else
printf '_No body._\n'
fi
printf '\n## Comments\n\n'
if [[ ! -f "$comments_file" ]] || [[ "$(jq 'length' "$comments_file")" -eq 0 ]]; then
printf '_No comments._\n'
else
comment_index=0
while IFS= read -r comment_json; do
comment_index="$((comment_index + 1))"
comment_id="$(jq -r '.id' <<<"$comment_json")"
comment_dbid="$(jq -r '.databaseId // empty' <<<"$comment_json")"
comment_key="$comment_dbid"
if [[ -z "$comment_key" ]]; then
comment_key="$(safe_token "$comment_id")"
fi
comment_author="$(jq -r '.author.login // "unknown"' <<<"$comment_json")"
comment_assoc="$(jq -r '.authorAssociation // empty' <<<"$comment_json")"
comment_created="$(jq -r '.createdAt // empty' <<<"$comment_json")"
comment_updated="$(jq -r '.updatedAt // empty' <<<"$comment_json")"
comment_url="$(jq -r '.url // empty' <<<"$comment_json")"
comment_body="$(jq -r '.body // empty' <<<"$comment_json")"
is_answer="$(jq -r '.isAnswer' <<<"$comment_json")"
reply_file="$replies_dir/$comment_key.json"
printf '### Comment %s by %s\n\n' "$comment_index" "$comment_author"
render_list_line "Created" "$comment_created"
if [[ -n "$comment_updated" && "$comment_updated" != "$comment_created" ]]; then
render_list_line "Updated" "$comment_updated"
fi
render_list_line "Author association" "$comment_assoc"
render_list_line "URL" "$comment_url"
if [[ "$is_answer" == "true" ]]; then
printf -- '- Marked as answer: true\n'
fi
printf '\n'
if [[ -n "$comment_body" ]]; then
printf '%s\n' "$comment_body"
else
printf '_No body._\n'
fi
if [[ -f "$reply_file" ]] && [[ "$(jq 'length' "$reply_file")" -gt 0 ]]; then
printf '\n#### Replies\n\n'
reply_index=0
while IFS= read -r reply_json; do
reply_index="$((reply_index + 1))"
reply_author="$(jq -r '.author.login // "unknown"' <<<"$reply_json")"
reply_assoc="$(jq -r '.authorAssociation // empty' <<<"$reply_json")"
reply_created="$(jq -r '.createdAt // empty' <<<"$reply_json")"
reply_updated="$(jq -r '.updatedAt // empty' <<<"$reply_json")"
reply_url="$(jq -r '.url // empty' <<<"$reply_json")"
reply_body="$(jq -r '.body // empty' <<<"$reply_json")"
reply_is_answer="$(jq -r '.isAnswer' <<<"$reply_json")"
printf '##### Reply %s by %s\n\n' "$reply_index" "$reply_author"
render_list_line "Created" "$reply_created"
if [[ -n "$reply_updated" && "$reply_updated" != "$reply_created" ]]; then
render_list_line "Updated" "$reply_updated"
fi
render_list_line "Author association" "$reply_assoc"
render_list_line "URL" "$reply_url"
if [[ "$reply_is_answer" == "true" ]]; then
printf -- '- Marked as answer: true\n'
fi
printf '\n'
if [[ -n "$reply_body" ]]; then
printf '%s\n' "$reply_body"
else
printf '_No body._\n'
fi
printf '\n'
done < <(jq -c '.[]' "$reply_file")
fi
printf '\n'
done < <(jq -c '.[]' "$comments_file")
fi
} > "$workdir/$file_name"
done < <(jq -c '.[]' "$discussions_file")
printf 'Done. Wrote %s Markdown files with %s comments and %s replies into %s.\n' \
"$discussion_count" "$total_comments" "$total_replies" "$workdir" >&2
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: gh-issues-save.sh OWNER/REPO
Fetch all non-PR issues and their comments with GitHub CLI and save one
Markdown file per issue in the current directory.
Examples:
./gh-issues-save.sh cli/cli
./gh-issues-save.sh https://github.com/cli/cli
Requirements:
- gh
- jq
- an authenticated gh session (`gh auth login`)
EOF
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
normalize_repo() {
local repo="$1"
repo="${repo#https://github.com/}"
repo="${repo#http://github.com/}"
repo="${repo#github.com/}"
repo="${repo%/}"
case "$repo" in
*/*)
printf '%s\n' "$repo"
;;
*)
return 1
;;
esac
}
slugify() {
local input="$1"
local slug
slug="$(
printf '%s' "$input" |
tr '[:upper:]' '[:lower:]' |
sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g'
)"
slug="${slug:0:80}"
if [[ -z "$slug" ]]; then
slug="untitled"
fi
printf '%s\n' "$slug"
}
render_list_line() {
local label="$1"
local value="$2"
if [[ -n "$value" ]]; then
printf -- '- %s: %s\n' "$label" "$value"
fi
}
if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then
usage
exit 0
fi
if [[ $# -ne 1 ]]; then
usage >&2
exit 1
fi
need_cmd gh
need_cmd jq
repo="$(normalize_repo "$1")" || die "Repository must look like OWNER/REPO or https://github.com/OWNER/REPO"
gh auth status >/dev/null 2>&1 || die "gh is not authenticated. Run: gh auth login"
workdir="$(pwd)"
tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/gh-issues-save.XXXXXX")"
trap 'rm -rf "$tmpdir"' EXIT
issues_pages="$tmpdir/issues-pages.json"
issues_file="$tmpdir/issues.json"
comments_pages="$tmpdir/comments-pages.json"
comments_file="$tmpdir/comments.json"
comments_dir="$tmpdir/comments-by-issue"
mkdir -p "$comments_dir"
printf 'Fetching issues from %s...\n' "$repo" >&2
gh api --paginate --slurp \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"repos/$repo/issues?state=all&per_page=100" > "$issues_pages"
jq '[.[][]? | select(.pull_request | not)] | sort_by(.number)' \
"$issues_pages" > "$issues_file"
printf 'Fetching issue comments from %s...\n' "$repo" >&2
gh api --paginate --slurp \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"repos/$repo/issues/comments?per_page=100" > "$comments_pages"
jq '[.[][]?] | sort_by(.issue_url, .created_at)' \
"$comments_pages" > "$comments_file"
while IFS= read -r comment_group; do
issue_url="$(jq -r '.[0].issue_url' <<<"$comment_group")"
issue_number="${issue_url##*/}"
printf '%s\n' "$comment_group" > "$comments_dir/$issue_number.json"
done < <(jq -c 'group_by(.issue_url)[]?' "$comments_file")
issue_count="$(jq 'length' "$issues_file")"
comment_count="$(jq 'length' "$comments_file")"
printf 'Writing %s issue files to %s...\n' "$issue_count" "$workdir" >&2
while IFS= read -r issue_json; do
number="$(jq -r '.number' <<<"$issue_json")"
title="$(jq -r '.title' <<<"$issue_json")"
state="$(jq -r '.state' <<<"$issue_json")"
state_reason="$(jq -r '.state_reason // empty' <<<"$issue_json")"
author="$(jq -r '.user.login // "unknown"' <<<"$issue_json")"
author_association="$(jq -r '.author_association // empty' <<<"$issue_json")"
created_at="$(jq -r '.created_at // empty' <<<"$issue_json")"
updated_at="$(jq -r '.updated_at // empty' <<<"$issue_json")"
closed_at="$(jq -r '.closed_at // empty' <<<"$issue_json")"
url="$(jq -r '.html_url // empty' <<<"$issue_json")"
labels="$(jq -r '[.labels[]?.name] | join(", ")' <<<"$issue_json")"
assignees="$(jq -r '[.assignees[]?.login] | join(", ")' <<<"$issue_json")"
milestone="$(jq -r '.milestone.title // empty' <<<"$issue_json")"
body="$(jq -r '.body // empty' <<<"$issue_json")"
comments_total="$(jq -r '.comments // 0' <<<"$issue_json")"
file_name="issue-${number}-$(slugify "$title").md"
comment_file="$comments_dir/$number.json"
{
printf '# %s\n\n' "$title"
printf '> Exported from `%s`.\n\n' "$repo"
printf -- '- Issue: #%s\n' "$number"
printf -- '- State: %s\n' "$state"
render_list_line "State reason" "$state_reason"
render_list_line "Author" "$author"
render_list_line "Author association" "$author_association"
render_list_line "Created" "$created_at"
render_list_line "Updated" "$updated_at"
render_list_line "Closed" "$closed_at"
render_list_line "URL" "$url"
render_list_line "Labels" "$labels"
render_list_line "Assignees" "$assignees"
render_list_line "Milestone" "$milestone"
printf -- '- Comments: %s\n' "$comments_total"
printf '\n## Body\n\n'
if [[ -n "$body" ]]; then
printf '%s\n' "$body"
else
printf '_No body._\n'
fi
printf '\n## Comments\n\n'
if [[ ! -f "$comment_file" ]]; then
printf '_No comments._\n'
else
while IFS= read -r comment_json; do
comment_author="$(jq -r '.user.login // "unknown"' <<<"$comment_json")"
comment_assoc="$(jq -r '.author_association // empty' <<<"$comment_json")"
comment_created="$(jq -r '.created_at // empty' <<<"$comment_json")"
comment_updated="$(jq -r '.updated_at // empty' <<<"$comment_json")"
comment_url="$(jq -r '.html_url // empty' <<<"$comment_json")"
comment_body="$(jq -r '.body // empty' <<<"$comment_json")"
printf '### %s\n\n' "$comment_author"
render_list_line "Created" "$comment_created"
if [[ -n "$comment_updated" && "$comment_updated" != "$comment_created" ]]; then
render_list_line "Updated" "$comment_updated"
fi
render_list_line "Author association" "$comment_assoc"
render_list_line "URL" "$comment_url"
printf '\n'
if [[ -n "$comment_body" ]]; then
printf '%s\n' "$comment_body"
else
printf '_No body._\n'
fi
printf '\n'
done < <(jq -c '.[]' "$comment_file")
fi
} > "$workdir/$file_name"
done < <(jq -c '.[]' "$issues_file")
printf 'Done. Wrote %s Markdown files with %s comments into %s.\n' \
"$issue_count" "$comment_count" "$workdir" >&2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment