diff --git a/.automation/skills/lts-audit/SKILL.md b/.automation/skills/lts-audit/SKILL.md new file mode 100644 index 00000000000000..f772251e277860 --- /dev/null +++ b/.automation/skills/lts-audit/SKILL.md @@ -0,0 +1,125 @@ +--- +name: lts-audit +description: Weekly audit of edera/6.18-lts for Edera commits from edera/mainline that it lacks, run unattended in CI by .github/workflows/lts-audit.yml. Covers telling a commit that is really missing from one that was ported in another form or does not apply to 6.18, and the triage notes that go into the audit issue. +--- + +# LTS audit triage + +Edera kernel work lands on `edera/mainline` first and flows down to +`edera/6.18-lts`, one backport pull request per mainline pull request. Some +commits still fall through. The workflow has already listed, from git alone, +every Edera commit on mainline that the LTS tree does not seem to have, and +it files that list as an issue whatever you say. Your job is to tell the +maintainers which of those are **really** missing, and what to do about the +rest. + +The list comes from `pending-backports.sh`, which counts a mainline commit as +present on the LTS tree only when the LTS series has a commit with the same +subject (ignoring a trailing version tag such as ` (6.18)`) or the same +patch-id. So a commit can be listed although it is there: ported under a +reworded subject and with a changed diff, folded into another commit, or +already in `linux-6.18.y` upstream. And a commit can be listed because it +does not belong on 6.18 at all. + +This runs unattended. Nobody can answer a question. Everything a maintainer +needs has to end up in the report. + +## Inputs + +The workflow gives you, in the prompt: + +- `LTS_TIP`: the `edera/6.18-lts` commit audited (checked out). Local branch + `lts-upstream` is its upstream, `linux-6.18.y`; the Edera series is + `..LTS_TIP`, where `` is their merge-base. +- `MAINLINE_TIP`: the `edera/mainline` commit audited (fetched). Local branch + `mainline-upstream` is its upstream, Linus' `master`, and `` + their merge-base. +- `PENDING`: the commits listed as missing, one per line as ` TAB + `, oldest first. The shas are mainline commits. +- `STATUS`: what is already happening to them, one per line as ` TAB + `. `` is `#N` for an open pull request into + `edera/6.18-lts` that carries a commit with that subject, or `#N + backport-waiting` / `#N backport-needs-attention` for a merged mainline + pull request whose backport is in that state. It may be empty. +- `REPORT`: the path your report must be written to. + +You change nothing: no commits, no branches, no checkouts, no pushes. Read +with `git log`, `git show`, `git diff`, `git range-diff`, `git grep` and the +like. + +### How to run commands here + +Your shell commands are checked against an allowlist that matches the +command's first words. Shell variables, `$(...)`, `<(...)`, pipes into +commands that are not on the list, and `VAR=x cmd` prefixes will be refused, +so `LTS_TIP`, `` and `<...>` below are placeholders: substitute the +literal value. Work out the merge-bases once with `git merge-base` and reuse +the hashes. + +This is a blobless clone: file contents are fetched from GitHub the first time +a command needs them, so the first `git show` of a commit can be slow. That is +not a hang. + +## 1. Read the list + +```sh +cat PENDING +cat STATUS +git merge-base LTS_TIP lts-upstream +git merge-base MAINLINE_TIP mainline-upstream +``` + +A commit with an open backport pull request in `STATUS` is in flight: note +the pull request and move on, unless something in it looks wrong. + +## 2. Judge each commit + +For every other commit, `git show ` and decide which one of these it is. +Look, in this order: + +1. **Ported in another form.** Search the LTS series for the change: the + files it touches (`git log --format='%h %s' ..LTS_TIP -- + `), distinctive identifiers it adds (`git grep -n + LTS_TIP -- `), and its subject with different wording. If an LTS + commit carries the same change, adapted or not, name it, and say how it + differs. Several mainline commits squashed into one LTS commit count. +2. **Already upstream in 6.18.** Edera fixes are sometimes sent upstream and + land in a stable release: look for the change in `lts-upstream` (`git log + --format='%h %s' lts-upstream -- `, then `git show`). +3. **Not applicable to 6.18.** The commit fixes or extends code that only + mainline has, upstream or Edera, and the LTS tree has nothing for it to + apply to. Say what is absent. +4. **Missing.** None of the above: the LTS tree lacks the change. Say what + it does in one line, which later Edera commits in `PENDING` depend on it, + and whether it looks likely to apply to 6.18 as is, or needs adapting (and + why). + +When you cannot tell, say **UNSURE**, give both readings and what would +settle it. Never call a commit present on a guess: a maintainer will act on +"ported" by adding it to the skip file, and a commit skipped by mistake is +lost from the LTS tree for good. + +## 3. Write the report + +Write `REPORT` as Markdown. It is pasted under a table of the commits, so do +not repeat the table. Start with one short paragraph saying how many are +really missing, how many are in flight, and how many need a maintainer to +skip them. Then one entry per commit, in `PENDING` order: + +```markdown +### `` + +**Missing** | **In flight (#N)** | **Ported as ``** | **Upstream in 6.18 as ``** | **Not applicable** | **UNSURE** + + +``` + +What to do, by verdict: + +- Missing: backport it. If its mainline pull request is in `STATUS` as + waiting or needing attention, point at that pull request. +- Ported, upstream, or not applicable: add its subject, exactly as `PENDING` + has it, to `.automation/backport-skip` on `edera/6.18-lts`, or label its + mainline pull request `no-backport`. + +Keep it factual and short. Do not quote large diffs. diff --git a/.github/workflows/kernel-nightly.yml b/.github/workflows/kernel-nightly.yml index 9c5135045f42c9..6d6b29be5285be 100644 --- a/.github/workflows/kernel-nightly.yml +++ b/.github/workflows/kernel-nightly.yml @@ -8,10 +8,15 @@ name: Nightly kernel maintenance # - with backport_pr set, proposes the backport of that merged # edera/mainline pull request instead (lts-backport.yml). # lts-backport-trigger.yml on edera/mainline dispatches this for every -# pull request that merges there. +# pull request that merges there; +# - with audit set, checks that it has every Edera commit edera/mainline +# has, and keeps an issue open about the ones it lacks, instead +# (lts-audit.yml). lts-audit-trigger.yml on edera/mainline dispatches +# this weekly. # -# Claude does the rebase and the backports. Independent checks decide what -# happens to each result; see those workflows. +# Claude does the rebase and the backports, and triages the audit. +# Independent checks decide what happens to each result; see those +# workflows. # # GitHub runs scheduled workflows only from the default branch, edera/mainline, # so this copy has no schedule of its own. lts-rebase-trigger.yml on @@ -44,6 +49,10 @@ on: description: Instead of the rebase, backport this merged edera/mainline pull request (its number) type: string default: '' + audit: + description: Instead of the rebase, audit this tree for Edera commits from edera/mainline that it lacks + type: boolean + default: false permissions: contents: read @@ -52,13 +61,14 @@ concurrency: # Per tree: groups are repository-wide, and edera/mainline's copy of this # workflow uses plain kernel-nightly. Each backport has its own group, so # they run alongside the rebase and each other; a newer dispatch for the - # same pull request replaces one still queued. - group: kernel-nightly-edera/6.18-lts${{ inputs.backport_pr != '' && format('-backport-{0}', inputs.backport_pr) || '' }} + # same pull request replaces one still queued. The audit only reads, and + # has its own group too. + group: kernel-nightly-edera/6.18-lts${{ inputs.audit && '-audit' || inputs.backport_pr != '' && format('-backport-{0}', inputs.backport_pr) || '' }} cancel-in-progress: false jobs: lts: - if: inputs.backport_pr == '' + if: inputs.backport_pr == '' && !inputs.audit uses: ./.github/workflows/upstream-rebase.yml permissions: contents: read @@ -76,7 +86,7 @@ jobs: # After the rebase, whatever it did. retry: needs: lts - if: always() && inputs.backport_pr == '' + if: always() && inputs.backport_pr == '' && !inputs.audit runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -101,7 +111,7 @@ jobs: run: bash .github/scripts/retry-backports.sh --stale backport: - if: inputs.backport_pr != '' + if: inputs.backport_pr != '' && !inputs.audit uses: ./.github/workflows/lts-backport.yml permissions: contents: read @@ -112,3 +122,13 @@ jobs: with: pr: ${{ inputs.backport_pr }} secrets: inherit + + audit: + if: inputs.audit + uses: ./.github/workflows/lts-audit.yml + permissions: + contents: read + pull-requests: read + issues: write + id-token: write + secrets: inherit diff --git a/.github/workflows/lts-audit.yml b/.github/workflows/lts-audit.yml new file mode 100644 index 00000000000000..182204add9e0a3 --- /dev/null +++ b/.github/workflows/lts-audit.yml @@ -0,0 +1,396 @@ +name: LTS audit + +# Checks that edera/6.18-lts has every Edera commit edera/mainline has, and +# keeps one issue open about the ones it lacks. Called by kernel-nightly.yml +# when it is dispatched with audit: lts-audit-trigger.yml on edera/mainline +# does that weekly. +# +# Every merged edera/mainline pull request is backported on its own +# (lts-backport.yml), but commits can still fall through: one pushed without +# a pull request, a backport that waits or needs attention and is forgotten, +# a rebase or a hand-landed series that drops one. This is the backstop. +# +# What is missing is decided from git alone, by pending-backports.sh, exactly +# as the backports decide it, with the same skip list. Open backport pull +# requests that carry a missing commit, and mainline pull requests whose +# backport is labelled waiting or needs-attention, are matched to it by +# subject. Claude then reads each commit that is not already in flight and +# says whether it really is missing, following +# .automation/skills/lts-audit/SKILL.md. It runs read-only, and its notes +# only annotate the issue: every commit pending-backports.sh lists stays in +# it until the LTS tree has it or the commit is skipped +# (.automation/backport-skip, or a no-backport label on its pull request). +# +# The issue is edited in place each week, and gets a comment only when the +# set of missing commits changes. A week with nothing missing closes it. + +on: + workflow_call: + +permissions: + contents: read + +env: + SOURCE: edera/mainline + SOURCE_UPSTREAM_URL: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git + SOURCE_UPSTREAM_BRANCH: master + TARGET: edera/6.18-lts + TARGET_UPSTREAM_URL: https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git + TARGET_UPSTREAM_BRANCH: linux-6.18.y + ISSUE_TITLE: edera/6.18-lts is missing commits from edera/mainline + +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + pull-requests: read + outputs: + state: ${{ steps.plan.outputs.state }} + old: ${{ steps.plan.outputs.old }} + source: ${{ steps.plan.outputs.source }} + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ env.TARGET }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Trusted tools + run: | + set -euo pipefail + git fetch --no-tags --filter=blob:none origin "$GITHUB_SHA" + mkdir "$RUNNER_TEMP/tools" + git archive "$GITHUB_SHA" .github/scripts .automation | tar -x -C "$RUNNER_TEMP/tools" --strip-components=1 + + - name: Plan + id: plan + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + out=$RUNNER_TEMP/out + mkdir -p "$out" + tools=$RUNNER_TEMP/tools + R=$GITHUB_REPOSITORY + norm() { sed -E 's/[[:space:]]*\((v?[0-9]+\.[0-9]+(\.[0-9y]+)?(-lts)?)\)$//'; } + subjects() { gh api --paginate "repos/$R/pulls/$1/commits" -q '.[].commit.message | split("\n")[0]' | norm; } + + old=$(git rev-parse HEAD) + echo "old=$old" >> "$GITHUB_OUTPUT" + git fetch --no-tags --filter=blob:none origin "$SOURCE" + src=$(git rev-parse FETCH_HEAD) + echo "source=$src" >> "$GITHUB_OUTPUT" + git fetch --no-tags --filter=blob:none "$SOURCE_UPSTREAM_URL" "$SOURCE_UPSTREAM_BRANCH" + src_up=$(git rev-parse FETCH_HEAD) + git fetch --no-tags --filter=blob:none "$TARGET_UPSTREAM_URL" "$TARGET_UPSTREAM_BRANCH" + tgt_up=$(git rev-parse FETCH_HEAD) + printf '%s\n' "$old" >"$out/lts-tip" + printf '%s\n' "$src" >"$out/mainline-tip" + + # The same skip list the backports use (lts-backport.yml). + cp "$tools/backport-skip" "$out/skip" + gh pr list --repo "$R" --base "$SOURCE" --state merged \ + --label no-backport --limit 500 --json number -q '.[].number' | + while read -r n; do + gh api --paginate "repos/$R/pulls/$n/commits" -q '.[].commit.message | split("\n")[0]' + done >>"$out/skip" + + bash "$tools/scripts/pending-backports.sh" "$src" "$src_up" "$old" "$tgt_up" "$out/skip" >"$out/pending" + + # Where each missing commit already stands, by subject: + # " TAB ", where is "#N" for an open pull request + # into the LTS tree, or "#N label" for a merged mainline one whose + # backport carries that label. + : >"$out/status" + if [ -s "$out/pending" ]; then + gh pr list --repo "$R" --base "$TARGET" --state open --limit 200 --json number -q '.[].number' | + while read -r n; do + subjects "$n" | sed "s/^/#$n\t/" + done >>"$out/status" + for label in backport-waiting backport-needs-attention; do + gh pr list --repo "$R" --base "$SOURCE" --state merged --label "$label" --limit 200 --json number -q '.[].number' | + while read -r n; do + subjects "$n" | sed "s/^/#$n $label\t/" + done >>"$out/status" + done + fi + + { + echo "### LTS audit of \`$TARGET\` (\`${old:0:12}\`) against \`$SOURCE\` (\`${src:0:12}\`)" + echo + if [ -s "$out/pending" ]; then + echo "$(wc -l <"$out/pending") Edera commit(s) missing:" + echo + while IFS=$'\t' read -r sha subject; do echo "- \`${sha:0:12}\` $subject"; done <"$out/pending" + else + echo "In sync: no Edera commit on \`$SOURCE\` is missing." + fi + } >> "$GITHUB_STEP_SUMMARY" + if [ -s "$out/pending" ]; then + echo "state=missing" >> "$GITHUB_OUTPUT" + else + echo "state=clean" >> "$GITHUB_OUTPUT" + fi + + - name: Upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: audit-plan + path: ${{ runner.temp }}/out/ + if-no-files-found: error + retention-days: 30 + + triage: + needs: prepare + if: needs.prepare.outputs.state == 'missing' + runs-on: ubuntu-latest + timeout-minutes: 90 + # Read-only on purpose: the model runs here. It reaches the Claude API + # through OIDC federation, and that is the only thing id-token is for. + permissions: + contents: read + id-token: write + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.old }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + # The skill comes from the commit this run started from. The kernel's + # .gitignore ignores dot-directories, so .rebase-tools/ cannot end up in + # a commit. + - name: Trusted tools + env: + SRC: ${{ needs.prepare.outputs.source }} + run: | + set -euo pipefail + git fetch --no-tags --filter=blob:none origin "$GITHUB_SHA" "$SRC" + mkdir .rebase-tools + git archive "$GITHUB_SHA" .github/scripts .automation | tar -x -C .rebase-tools --strip-components=1 + git fetch --no-tags --filter=blob:none "$SOURCE_UPSTREAM_URL" "$SOURCE_UPSTREAM_BRANCH" + git branch mainline-upstream FETCH_HEAD + git fetch --no-tags --filter=blob:none "$TARGET_UPSTREAM_URL" "$TARGET_UPSTREAM_BRANCH" + git branch lts-upstream FETCH_HEAD + + - name: Download the plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: audit-plan + path: ${{ runner.temp }}/in + + - name: Normalize identifiers + id: ids + env: + RULE: ${{ vars.REBASE_FEDERATION_RULE_ID }} + ORG: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + SVC: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} + WS: ${{ vars.ANTHROPIC_WORKSPACE_ID }} + run: | + set -euo pipefail + strip() { printf '%s' "$1" | tr -d '[:space:]'; } + { + echo "rule=$(strip "$RULE")" + echo "org=$(strip "$ORG")" + echo "svc=$(strip "$SVC")" + echo "ws=$(strip "$WS")" + } >> "$GITHUB_OUTPUT" + # An unset variable otherwise surfaces as an opaque API failure a + # second into the model step, with nothing in the log to say why. + missing=0 + for n in REBASE_FEDERATION_RULE_ID:RULE ANTHROPIC_ORGANIZATION_ID:ORG ANTHROPIC_SERVICE_ACCOUNT_ID:SVC ANTHROPIC_WORKSPACE_ID:WS; do + eval "v=\$${n#*:}" + if [ -z "$(strip "$v")" ]; then + echo "::error::vars.${n%%:*} is empty or not visible to this repository." + missing=1 + fi + done + exit "$missing" + + - name: Triage + id: claude + continue-on-error: true + uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245 + with: + # The job token, which is read-only here. Without it the action + # would exchange OIDC for its own app token, which can write. + github_token: ${{ github.token }} + anthropic_federation_rule_id: '${{ steps.ids.outputs.rule }}' + anthropic_organization_id: '${{ steps.ids.outputs.org }}' + anthropic_service_account_id: '${{ steps.ids.outputs.svc }}' + anthropic_workspace_id: '${{ steps.ids.outputs.ws }}' + allowed_bots: 'github-actions' + prompt: | + Audit edera/6.18-lts for Edera commits from edera/mainline that it + lacks, by following .rebase-tools/skills/lts-audit/SKILL.md + exactly. Read it in full before you start. + + LTS_TIP=${{ needs.prepare.outputs.old }} (${{ env.TARGET }}, checked out; its upstream is local branch lts-upstream) + MAINLINE_TIP=${{ needs.prepare.outputs.source }} (${{ env.SOURCE }}; its upstream is local branch mainline-upstream) + PENDING=${{ runner.temp }}/in/pending + STATUS=${{ runner.temp }}/in/status + REPORT=${{ runner.temp }}/audit-report.md + + You change nothing: no commits, no branches, no pushes. The + workflow files the issue from git's own list; your report + annotates it. + claude_args: | + --model claude-opus-5-5 + --max-turns 150 + --allowedTools "Read,Grep,Glob,Write,Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git range-diff:*),Bash(git merge-base:*),Bash(git rev-parse:*),Bash(git patch-id:*),Bash(git grep:*),Bash(git cat-file:*),Bash(diff:*),Bash(comm:*),Bash(sort:*),Bash(grep:*),Bash(sed:*),Bash(awk:*),Bash(cat:*),Bash(head:*),Bash(tail:*),Bash(wc:*),Bash(ls:*),Bash(cut:*)" + + - name: Collect the report + if: always() + env: + REPORT: ${{ runner.temp }}/audit-report.md + OUTCOME: ${{ steps.claude.outcome }} + run: | + set -euo pipefail + out=$RUNNER_TEMP/out + mkdir -p "$out" + if [ -s "$REPORT" ]; then + cp "$REPORT" "$out/report.md" + else + printf 'Claude left no triage notes (triage step: %s); the run log has the cause.\n' "$OUTCOME" >"$out/report.md" + fi + + - name: Upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: audit-triage + path: ${{ runner.temp }}/out/ + if-no-files-found: error + retention-days: 30 + + publish: + needs: [prepare, triage] + # Whatever triage did: the issue rests on prepare's list, not on Claude. + if: always() && needs.prepare.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: lts-audit-issue + cancel-in-progress: false + permissions: + issues: write + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Download the plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: audit-plan + path: ${{ runner.temp }}/in/plan + + - name: Download the triage + if: needs.triage.result != 'skipped' + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: audit-triage + path: ${{ runner.temp }}/in/triage + + - name: Open, update or close the issue + env: + GH_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + in=$RUNNER_TEMP/in + plan=$in/plan + R=$GITHUB_REPOSITORY + old=$(cat "$plan/lts-tip") + src=$(cat "$plan/mainline-tip") + summary() { cat >> "$GITHUB_STEP_SUMMARY"; } + + existing=$(gh issue list --repo "$R" --state open \ + --search "in:title \"$ISSUE_TITLE\"" --json number,title \ + -q ".[] | select(.title == \"$ISSUE_TITLE\") | .number" | head -n1) + + if [ ! -s "$plan/pending" ]; then + if [ -n "$existing" ]; then + gh issue close "$existing" --repo "$R" --comment \ + "Back in sync: \`$TARGET\` (\`${old:0:12}\`) has every Edera commit on \`$SOURCE\` (\`${src:0:12}\`). [Audit run]($RUN_URL)." + echo "In sync; closed #$existing." | summary + else + echo "In sync; no issue to close." | summary + fi + exit 0 + fi + + # Commit hashes change with every nightly rebase, so the set of + # missing commits is recorded by subject, in the issue itself, to + # tell next week whether it changed. + cut -f2 "$plan/pending" | sort >"$RUNNER_TEMP/now" + now=$(base64 -w0 <"$RUNNER_TEMP/now") + : >"$RUNNER_TEMP/before" + if [ -n "$existing" ]; then + gh issue view "$existing" --repo "$R" --json body -q .body | + sed -n 's/.*.*/\1/p' | head -n1 | + base64 -d >"$RUNNER_TEMP/before" 2>/dev/null || : >"$RUNNER_TEMP/before" + fi + + body=$RUNNER_TEMP/issue.md + { + echo "" + echo "The weekly audit found **$(wc -l <"$plan/pending") Edera commit(s)** on \`$SOURCE\` (\`${src:0:12}\`) that \`$TARGET\` (\`${old:0:12}\`) does not have. [Audit run]($RUN_URL)." + echo + echo "| Mainline commit | Subject | Backport status |" + echo "| --- | --- | --- |" + while IFS=$'\t' read -r sha subject; do + where=$(awk -F'\t' -v s="$subject" '$2 == s { print $1 }' "$plan/status" | sort -u | paste -sd, - | sed 's/,/, /g') + printf '| `%s` | %s | %s |\n' "${sha:0:12}" "${subject//|/\\|}" "${where:-none}" + done <"$plan/pending" + echo + echo "Each one is resolved by landing its backport on \`$TARGET\`, or, if it must never go there, by labelling its mainline pull request \`no-backport\` or listing its subject in \`.automation/backport-skip\` on \`$TARGET\`. A commit that was ported under another subject or in another form also goes in the skip file. This issue is updated weekly and closes itself once nothing is missing." + echo + echo "## Claude's triage" + echo + if [ -s "$in/triage/report.md" ]; then cat "$in/triage/report.md"; else echo "Triage did not run; the run log has the cause."; fi + } >"$body" + if [ "$(wc -c <"$body")" -gt 60000 ]; then + head -c 59000 "$body" >"$body.cut" + printf '\n\n_Truncated; the full triage is in the run artifacts._\n' >>"$body.cut" + mv "$body.cut" "$body" + fi + + if [ -z "$existing" ]; then + url=$(gh issue create --repo "$R" --title "$ISSUE_TITLE" --body-file "$body") + echo "Opened $url." | summary + exit 0 + fi + gh issue edit "$existing" --repo "$R" --body-file "$body" + if cmp -s "$RUNNER_TEMP/before" "$RUNNER_TEMP/now"; then + echo "Unchanged since the last audit; refreshed #$existing." | summary + exit 0 + fi + note=$RUNNER_TEMP/comment.md + { + echo "The set of missing commits changed; the description is updated. [Audit run]($RUN_URL)." + echo + comm -13 "$RUNNER_TEMP/before" "$RUNNER_TEMP/now" | sed 's/^/- newly missing: /' + comm -23 "$RUNNER_TEMP/before" "$RUNNER_TEMP/now" | sed 's/^/- no longer missing: /' + } >"$note" + gh issue comment "$existing" --repo "$R" --body-file "$note" + echo "Updated #$existing." | summary