Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 157 additions & 6 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -611,14 +611,11 @@ runs:
MODEL: ${{ inputs.model }}
AGENT_MODEL_INPUT: ${{ inputs.agent_model }}
PARSING_MODEL_INPUT: ${{ inputs.parsing_model }}
# A setup PR's run goes on to preview other pull requests; the credentials stay until
# those finish and the step after them removes them.
KEEP_AUTH: ${{ github.event_name == 'pull_request' && github.head_ref == 'codeboarding/setup-action' }}
run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/review-analyze-head.sh"'

# The head step removes the credentials; this covers a run that failed before it.
- name: Remove analysis credentials
if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review'
shell: bash
run: '"$GITHUB_ACTION_PATH/scripts/action/clear-auth.sh"'

# Published before the review is rendered: the analysis is the expensive part,
# so a later rendering or posting failure must not throw it away. Only the
# next run reads this, hence the short retention.
Expand Down Expand Up @@ -724,6 +721,160 @@ runs:
GITHUB_TOKEN: ${{ inputs.github_token }}
path: ${{ steps.review_body.outputs.path }}

# Before a setup PR merges, its own run is the only one GitHub starts for us, so this is
# where a new user's open pull requests get their first review: the ones the app listed
# in the setup PR's description, each applied to the commit this run just analysed and
# analysed incrementally from it (see review-preview-pr.sh). Nothing is posted on them.
# The app finds each as codeboarding-preview-<run>-<attempt>-pr<number>-<head sha>.
# Not codeboarding-review-<run>-...: the app takes the newest artifact with that prefix as
# the setup PR's own review, so a preview named that way would replace the setup PR's map.
# All best effort: the setup PR's map is already uploaded and must not fail because of them.
- name: Resolve pull requests to preview
id: preview_list
if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_comment.outcome == 'success' && github.event_name == 'pull_request' && github.head_ref == 'codeboarding/setup-action'
continue-on-error: true
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
GH_HOST: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ steps.guard.outputs.pr_number }}
HEAD_REF: ${{ github.head_ref }}
BASE_REF: ${{ steps.guard.outputs.base_ref }}
MAX_PREVIEWS: '3'
run: "$GITHUB_ACTION_PATH/scripts/action/resolve-preview-prs.sh"

- name: Preview pull request 1 for the setup PR
id: preview_1
if: steps.guard.outputs.mode == 'review' && steps.preview_list.outputs.count >= 1
continue-on-error: true
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target
GIT_TOKEN: ${{ inputs.github_token }}
REPOSITORY: ${{ github.repository }}
SETUP_PR: ${{ steps.guard.outputs.pr_number }}
REVIEW_BASE_SHA: ${{ steps.guard.outputs.merge_base_sha }}
BASE_ANALYSIS_PATH: ${{ steps.review_baseline.outputs.base_analysis_path }}
# The setup PR's own review artifact already records where the base graph lives.
REVIEW_ARTIFACT_DIR: ${{ steps.review_artifact.outputs.artifact_dir }}
ENGINE_VERSION: ${{ steps.state.outputs.engine_version }}
CFG_HASH: ${{ steps.state.outputs.cfg_hash }}
DEPTH_CAP: ${{ inputs.depth_cap }}
MODEL: ${{ inputs.model }}
AGENT_MODEL_INPUT: ${{ inputs.agent_model }}
PARSING_MODEL_INPUT: ${{ inputs.parsing_model }}
KEEP_AUTH: 'true'
run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/review-preview-pr.sh" 1'

- name: Upload preview 1
id: upload_preview_1
if: steps.preview_1.outputs.artifact_dir != '' && github.server_url == 'https://github.com'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: codeboarding-preview-${{ github.run_id }}-${{ github.run_attempt }}-pr${{ steps.preview_1.outputs.pr_number }}-${{ steps.preview_1.outputs.head_sha }}
path: ${{ steps.preview_1.outputs.artifact_dir }}
if-no-files-found: error
retention-days: 14

# The sticky review comment is an edit by now, and edits send no notification. One new
# comment on a setup PR emails its author: after the first preview when there is one,
# so the email links a coloured pull request, otherwise as soon as the map is posted.
- name: Tell the setup PR's author the map is ready
if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_comment.outcome == 'success' && github.event_name == 'pull_request' && github.head_ref == 'codeboarding/setup-action'
continue-on-error: true
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
GH_HOST: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ steps.guard.outputs.pr_number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
HEAD_REF: ${{ github.head_ref }}
BASE_REF: ${{ steps.guard.outputs.base_ref }}
FIRST_PR: ${{ steps.upload_preview_1.outcome == 'success' && steps.preview_1.outputs.pr_number || '' }}
FIRST_TITLE: ${{ steps.preview_1.outputs.title }}
PREVIEW_PRS: ${{ steps.preview_list.outputs.prs }}
run: "$GITHUB_ACTION_PATH/scripts/action/notify-setup-ready.sh"

- name: Preview pull request 2 for the setup PR
id: preview_2
if: steps.guard.outputs.mode == 'review' && steps.preview_list.outputs.count >= 2
continue-on-error: true
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target
GIT_TOKEN: ${{ inputs.github_token }}
REPOSITORY: ${{ github.repository }}
SETUP_PR: ${{ steps.guard.outputs.pr_number }}
REVIEW_BASE_SHA: ${{ steps.guard.outputs.merge_base_sha }}
BASE_ANALYSIS_PATH: ${{ steps.review_baseline.outputs.base_analysis_path }}
# The setup PR's own review artifact already records where the base graph lives.
REVIEW_ARTIFACT_DIR: ${{ steps.review_artifact.outputs.artifact_dir }}
ENGINE_VERSION: ${{ steps.state.outputs.engine_version }}
CFG_HASH: ${{ steps.state.outputs.cfg_hash }}
DEPTH_CAP: ${{ inputs.depth_cap }}
MODEL: ${{ inputs.model }}
AGENT_MODEL_INPUT: ${{ inputs.agent_model }}
PARSING_MODEL_INPUT: ${{ inputs.parsing_model }}
KEEP_AUTH: 'true'
run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/review-preview-pr.sh" 2'

- name: Upload preview 2
id: upload_preview_2
if: steps.preview_2.outputs.artifact_dir != '' && github.server_url == 'https://github.com'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: codeboarding-preview-${{ github.run_id }}-${{ github.run_attempt }}-pr${{ steps.preview_2.outputs.pr_number }}-${{ steps.preview_2.outputs.head_sha }}
path: ${{ steps.preview_2.outputs.artifact_dir }}
if-no-files-found: error
retention-days: 14

- name: Preview pull request 3 for the setup PR
id: preview_3
if: steps.guard.outputs.mode == 'review' && steps.preview_list.outputs.count >= 3
continue-on-error: true
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target
GIT_TOKEN: ${{ inputs.github_token }}
REPOSITORY: ${{ github.repository }}
SETUP_PR: ${{ steps.guard.outputs.pr_number }}
REVIEW_BASE_SHA: ${{ steps.guard.outputs.merge_base_sha }}
BASE_ANALYSIS_PATH: ${{ steps.review_baseline.outputs.base_analysis_path }}
# The setup PR's own review artifact already records where the base graph lives.
REVIEW_ARTIFACT_DIR: ${{ steps.review_artifact.outputs.artifact_dir }}
ENGINE_VERSION: ${{ steps.state.outputs.engine_version }}
CFG_HASH: ${{ steps.state.outputs.cfg_hash }}
DEPTH_CAP: ${{ inputs.depth_cap }}
MODEL: ${{ inputs.model }}
AGENT_MODEL_INPUT: ${{ inputs.agent_model }}
PARSING_MODEL_INPUT: ${{ inputs.parsing_model }}
run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/review-preview-pr.sh" 3'

- name: Upload preview 3
id: upload_preview_3
if: steps.preview_3.outputs.artifact_dir != '' && github.server_url == 'https://github.com'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: codeboarding-preview-${{ github.run_id }}-${{ github.run_attempt }}-pr${{ steps.preview_3.outputs.pr_number }}-${{ steps.preview_3.outputs.head_sha }}
path: ${{ steps.preview_3.outputs.artifact_dir }}
if-no-files-found: error
retention-days: 14

# The head and preview steps remove the credentials themselves; this covers a run that
# failed before them, and a setup PR's run, which keeps them for its previews.
- name: Remove analysis credentials
if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review'
shell: bash
run: '"$GITHUB_ACTION_PATH/scripts/action/clear-auth.sh"'

# The same body, on the run page. Job summaries and logs are explicitly excluded from
# the artifact storage allowance, and the cap is 1MiB per step against a diagram
# measured in single-digit kilobytes, so this is free in both senses. It earns its
Expand Down
21 changes: 14 additions & 7 deletions scripts/action/build-review-artifact.sh
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
#!/usr/bin/env bash
# Packages both analyses and PR metadata, then outputs the artifact upload directory.
set -euo pipefail
mkdir -p "${RUNNER_TEMP}/cb-review-artifact"
cp "$ANALYSIS_PATH" "${RUNNER_TEMP}/cb-review-artifact/analysis.json"
# A setup PR's previews of other pull requests are packaged the same way, each into its own directory.
dir="${ARTIFACT_DIR:-${RUNNER_TEMP}/cb-review-artifact}"
mkdir -p "$dir"
cp "$ANALYSIS_PATH" "$dir/analysis.json"
# The base graph is normally published separately, named for the commit it
# describes, so ten runs on one pull request store it once rather than ten times.
# A fork review publishes nothing another run could read, so when it had to
# compute the base itself it carries the graph here instead: otherwise the
# artifact would name a base that does not exist and no reader could reproduce
# the comparison.
if [ "${INLINE_BASE:-false}" = true ]; then
cp "$BASE_ANALYSIS_PATH" "${RUNNER_TEMP}/cb-review-artifact/base_analysis.json"
cp "$BASE_ANALYSIS_PATH" "$dir/base_analysis.json"
BASE_ARTIFACT_NAME=""
fi
# The engine writes this next to the analysis it came from. Ship it when present
# so a pull request's warnings are readable without rerunning the analysis.
HEALTH_REPORT="$(dirname "$ANALYSIS_PATH")/health/health_report.json"
[ ! -f "$HEALTH_REPORT" ] || cp "$HEALTH_REPORT" "${RUNNER_TEMP}/cb-review-artifact/health_report.json"
[ ! -f "$HEALTH_REPORT" ] || cp "$HEALTH_REPORT" "$dir/health_report.json"

# basis=preview marks a review made inside a setup PR's run: the pull request's own changes
# laid over the commit that run analysed, not over its real merge base. setup_pr names that run's PR.
# base_sha stays the event's base branch tip for consumers that key on it;
# merge_base_sha records the commit the diagram actually compared against.
# pr_base_sha carries the same value under the name the webview already reads:
Expand All @@ -37,10 +41,13 @@ jq -n \
--arg base_artifact "$BASE_ARTIFACT_NAME" \
--arg base_artifact_id "$BASE_ARTIFACT_ID" \
--arg analysed_files_changed "${ANALYSED_FILES_CHANGED:-unknown}" \
--arg basis "${BASIS:-}" \
--arg setup_pr "${SETUP_PR:-}" \
'{kind: $kind, mode: $mode, base_sha: $base_sha, merge_base_sha: $merge_base_sha, pr_base_sha: $merge_base_sha,
merge_base_resolved: $merge_base_resolved, head_sha: $head_sha,
pr_number: $pr_number, seed_source: $seed_source, chain_depth: $chain_depth,
base_artifact: $base_artifact, base_artifact_id: $base_artifact_id,
analysed_files_changed: $analysed_files_changed}' \
> "${RUNNER_TEMP}/cb-review-artifact/metadata.json"
echo "artifact_dir=${RUNNER_TEMP}/cb-review-artifact" >> "$GITHUB_OUTPUT"
analysed_files_changed: $analysed_files_changed}
+ (if $basis == "" then {} else {basis: $basis, setup_pr: $setup_pr} end)' \
> "$dir/metadata.json"
echo "artifact_dir=$dir" >> "$GITHUB_OUTPUT"
56 changes: 56 additions & 0 deletions scripts/action/notify-setup-ready.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Tells the author of a CodeBoarding setup PR that its preview map is ready.
#
# The review comment is sticky: it is created when the run starts and edited when it
# ends, and GitHub sends no notification for an edit. A new comment does notify the
# PR's author, who for a setup PR is the person who asked for the map (the app opens
# it with their token), so this one comment is what turns "you can close this tab"
# into an email from GitHub. Failed runs need nothing here: GitHub already emails the
# person who triggered a failed run.
#
# When the setup PR asked for previews of other pull requests, this runs after the first
# one is ready (FIRST_PR) and links it, since a coloured pull request is the point; the
# rest of PREVIEW_PRS are named as still running. Posted once per PR, found again by its
# marker, so pushing new settings to the setup PR does not email again. Best effort throughout.
set -euo pipefail
[ "${HEAD_REF:-}" = "codeboarding/setup-action" ] || exit 0
[ -n "${PR_NUMBER:-}" ] && [ -n "${REPOSITORY:-}" ] || exit 0
export GH_HOST="${GH_HOST:-github.com}"
GH_HOST="${GH_HOST#*://}"

marker="<!-- codeboarding-setup-ready -->"
existing="$(gh api --paginate "repos/$REPOSITORY/issues/$PR_NUMBER/comments?per_page=100" \
--jq ".[] | select((.body // \"\") | contains(\"$marker\")) | .id")"
[ -z "$existing" ] || exit 0

# A mention notifies even an author who unsubscribed from the thread. Bots are not mentioned.
greeting="Your"
case "${PR_AUTHOR:-}" in
"" | *"[bot]") ;;
*) greeting="@$PR_AUTHOR your" ;;
esac
if [ -n "${BASE_REF:-}" ]; then
branch="\`$BASE_REF\`"
else
branch="your default branch"
fi
link() { printf 'https://app.codeboarding.org/%s/pull/%s?utm_source=github&utm_medium=setup_ready&utm_campaign=gh_action' "$REPOSITORY" "$1"; }
if [ -n "${FIRST_PR:-}" ]; then
headline="$greeting CodeBoarding map of $branch is ready, and #$FIRST_PR is reviewed on it."
cta="**[Open #$FIRST_PR in CodeBoarding]($(link "$FIRST_PR"))**${FIRST_TITLE:+ · $FIRST_TITLE}"
rest=""
IFS=',' read -r -a all <<< "${PREVIEW_PRS:-}"
for pr in "${all[@]}"; do
if [ -n "$pr" ] && [ "$pr" != "$FIRST_PR" ]; then rest+="${rest:+, }#$pr"; fi
done
[ -z "$rest" ] || cta+=$'\n\n'"$rest follow in a few minutes. [Your map]($(link "$PR_NUMBER")) is open already."
else
headline="$greeting CodeBoarding map of $branch is ready."
cta="**[Open it in CodeBoarding]($(link "$PR_NUMBER"))**"
fi
body="$(printf '%s\n\n%s\n\n%s\n\n%s' \
"$headline" \
"$cta" \
"This is a preview. Merge this PR to keep the map up to date and review every pull request." \
"$marker")"
gh api -X POST "repos/$REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
57 changes: 57 additions & 0 deletions scripts/action/resolve-preview-prs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Reads which pull requests a CodeBoarding setup PR should preview, from a line the app
# writes into its description:
#
# <!-- codeboarding:preview 412,409,401 -->
#
# Before the setup PR merges, its own run is the only one GitHub starts for us, so this is
# where a new user's open pull requests get their first review. The description is read
# now, after the map is built, not from the event payload, so a list changed while the map
# was building still counts. Each entry must be an open pull request from this repository
# into the same branch; anything else is skipped with a notice. At most MAX_PREVIEWS.
#
# Writes one JSON file per preview to $RUNNER_TEMP/codeboarding-preview/<n>.json and
# outputs count= and prs= (comma-separated, in order). Best effort throughout.
set -euo pipefail
out="${RUNNER_TEMP:?}/codeboarding-preview"
rm -rf "$out"
mkdir -p "$out"
count=0 prs=""
finish() { printf 'count=%s\nprs=%s\n' "$count" "$prs" >> "${GITHUB_OUTPUT:-/dev/null}"; exit 0; }
[ "${HEAD_REF:-}" = "codeboarding/setup-action" ] || finish
[ -n "${PR_NUMBER:-}" ] && [ -n "${REPOSITORY:-}" ] && [ -n "${BASE_REF:-}" ] || finish
export GH_HOST="${GH_HOST:-github.com}"
GH_HOST="${GH_HOST#*://}"
max="${MAX_PREVIEWS:-3}"

body="$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" --jq '.body // ""' 2>/dev/null || true)"
line="$(grep -oE '<!-- codeboarding:preview [0-9, ]+ -->' <<< "$body" | head -n 1 || true)"
[ -n "$line" ] || finish

skip() { echo "::notice::Not previewing #$1: $2"; }
seen=" "
for pr in $(grep -oE '[0-9]+' <<< "${line#*preview}"); do
[ "$count" -lt "$max" ] || { skip "$pr" "at most $max pull requests are previewed."; continue; }
[ "$pr" != "$PR_NUMBER" ] || continue
case "$seen" in *" $pr "*) continue ;; esac
seen+="$pr "
if ! json="$(gh api "repos/$REPOSITORY/pulls/$pr" 2>/dev/null)"; then
skip "$pr" "it could not be read."
continue
fi
IFS=$'\t' read -r state head_repo base_ref base_sha head_sha < <(jq -r \
'[.state, (.head.repo.full_name // ""), .base.ref, .base.sha, .head.sha] | @tsv' <<< "$json")
[ "$state" = open ] || { skip "$pr" "it is not open."; continue; }
[ "$head_repo" = "$REPOSITORY" ] || { skip "$pr" "it comes from a fork."; continue; }
[ "$base_ref" = "$BASE_REF" ] || { skip "$pr" "it targets $base_ref, not $BASE_REF."; continue; }
# The pull request's own changes are what it adds since its merge base, the same
# comparison GitHub's Files tab makes.
merge_base="$(gh api "repos/$REPOSITORY/compare/$base_sha...$head_sha" --jq '.merge_base_commit.sha // ""' 2>/dev/null || true)"
[ -n "$merge_base" ] || { skip "$pr" "its merge base could not be resolved."; continue; }
count=$(( count + 1 ))
jq -n --arg pr "$pr" --arg head_sha "$head_sha" --arg merge_base "$merge_base" \
--arg title "$(jq -r '.title // ""' <<< "$json")" \
'{pr_number: $pr, head_sha: $head_sha, merge_base_sha: $merge_base, title: $title}' > "$out/$count.json"
prs="${prs:+$prs,}$pr"
done
finish
Loading
Loading