|
| 1 | +# Template created by https://github.com/XAOSTECH/dev-control |
| 2 | +# See templates folder documentation for details. |
| 3 | + |
| 4 | +# ============================================================================ |
| 5 | +# Comment & Prose Humanisation Workflow Template (humanise.yml) |
| 6 | +# ============================================================================ |
| 7 | +# |
| 8 | +# ORIGIN: https://github.com/XAOSTECH/dev-control |
| 9 | +# This workflow template is part of the dev-control toolkit. |
| 10 | +# See: https://github.com/XAOSTECH/dev-control/blob/main/workflows-templates/ |
| 11 | +# |
| 12 | +# ============================================================================ |
| 13 | +# |
| 14 | +# Rejoins editor/AI hard-wrapped comment and prose paragraphs onto single logical lines, removing the ~70-column wrap imposed on generated text. |
| 15 | +# A wrapped continuation is long, ends mid-sentence (no terminal punctuation) and continues in lower case; deliberate short lines, bullets, headings, tables, code blocks, SPDX headers and shebangs are left untouched. |
| 16 | +# The rule set is self-contained (embedded Perl) so it works even when only the workflow is copied into a target repository. |
| 17 | +# |
| 18 | +# TRIGGERS: |
| 19 | +# - Manual dispatch (workflow_dispatch) |
| 20 | +# - Monthly schedule |
| 21 | +# |
| 22 | +# ============================================================================ |
| 23 | + |
| 24 | +name: Humanise Comments and Prose |
| 25 | + |
| 26 | +on: |
| 27 | + schedule: |
| 28 | + - cron: '0 3 15 * *' |
| 29 | + workflow_dispatch: |
| 30 | + inputs: |
| 31 | + extensions: |
| 32 | + description: 'Comma-separated file extensions to scan' |
| 33 | + required: false |
| 34 | + type: string |
| 35 | + default: 'sh,bash,zsh,md,markdown,txt,yml,yaml,py,rb,pl,toml,ts,tsx,js,jsx,go,rs,c,cc,cpp,h,hpp,java' |
| 36 | + min_wrap: |
| 37 | + description: 'Minimum line length treated as a hard wrap' |
| 38 | + required: false |
| 39 | + type: number |
| 40 | + default: 48 |
| 41 | + dry_run: |
| 42 | + description: 'Report changes without opening a pull request' |
| 43 | + required: false |
| 44 | + type: boolean |
| 45 | + default: false |
| 46 | + |
| 47 | +permissions: |
| 48 | + contents: write |
| 49 | + pull-requests: write |
| 50 | + |
| 51 | +env: |
| 52 | + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true |
| 53 | + |
| 54 | +# One humanise run per ref at a time so overlapping runs cannot race. |
| 55 | +concurrency: |
| 56 | + group: humanise-${{ github.ref }} |
| 57 | + cancel-in-progress: true |
| 58 | + |
| 59 | +jobs: |
| 60 | + humanise: |
| 61 | + name: Humanise Comments and Prose |
| 62 | + runs-on: ubuntu-latest |
| 63 | + |
| 64 | + steps: |
| 65 | + - name: Generate App Token |
| 66 | + id: app_token |
| 67 | + uses: actions/create-github-app-token@v2 |
| 68 | + with: |
| 69 | + app-id: ${{ secrets['XB_AI'] }} |
| 70 | + private-key: ${{ secrets['XB_PK'] }} |
| 71 | + |
| 72 | + - name: Checkout code |
| 73 | + uses: actions/checkout@v6 |
| 74 | + with: |
| 75 | + fetch-depth: 0 |
| 76 | + token: ${{ steps.app_token.outputs.token }} |
| 77 | + |
| 78 | + - name: Humanise comments and prose |
| 79 | + id: humanise |
| 80 | + env: |
| 81 | + EXTENSIONS: ${{ inputs.extensions || 'sh,bash,zsh,md,markdown,txt,yml,yaml,py,rb,pl,toml,ts,tsx,js,jsx,go,rs,c,cc,cpp,h,hpp,java' }} |
| 82 | + HUMANISE_MIN_WRAP: ${{ inputs.min_wrap || '48' }} |
| 83 | + DRY_RUN: ${{ inputs.dry_run || 'false' }} |
| 84 | + run: | |
| 85 | + # Embed the reflow engine so the workflow is self-contained (no dependency on repo scripts). |
| 86 | + cat > /tmp/humanise.pl <<'HUMANISE_PL' |
| 87 | + #!/usr/bin/env perl |
| 88 | + # Reflow hard-wrapped paragraphs (comments and prose) onto single logical lines. |
| 89 | + # Editors/AI wrap comment and prose text at a fixed column (~70) mid-sentence; |
| 90 | + # we rejoin each wrapped paragraph. A wrapped continuation is long, ends |
| 91 | + # mid-sentence (no terminal punctuation) and continues in lower case; deliberate |
| 92 | + # short lines and all structure (bullets, headings, tables, code, SPDX, shebangs) |
| 93 | + # are emitted verbatim. |
| 94 | + use strict; |
| 95 | + use warnings; |
| 96 | +
|
| 97 | + my $MIN = $ENV{HUMANISE_MIN_WRAP} // 48; |
| 98 | + my $file = shift @ARGV or die "usage: humanise.pl FILE\n"; |
| 99 | +
|
| 100 | + open my $in, '<', $file or die "open $file: $!\n"; |
| 101 | + my @l = <$in>; |
| 102 | + close $in; |
| 103 | + my $final_nl = (@l && $l[-1] =~ /\n\z/) ? 1 : 0; |
| 104 | + chomp @l; |
| 105 | +
|
| 106 | + my $mode; |
| 107 | + if ($file =~ /\.(?:md|markdown|mdown|txt|text)\z/i) { $mode = 'md'; } |
| 108 | + elsif ($file =~ /\.(?:sh|bash|zsh|ya?ml|py|rb|pl|pm|toml|cfg|conf|ini|jl|mk)\z/i |
| 109 | + || $file =~ m{(?:^|/)(?:Dockerfile|Makefile)[^/]*\z}) { $mode = 'hash'; } |
| 110 | + elsif ($file =~ /\.(?:ts|tsx|js|jsx|mjs|cjs|c|cc|cpp|cxx|h|hpp|hh|go|rs|java|kt|kts|swift|scala)\z/i) { $mode = 'slash'; } |
| 111 | + else { exit 0; } |
| 112 | +
|
| 113 | + my $mk = $mode eq 'hash' ? '#' : $mode eq 'slash' ? '//' : ''; |
| 114 | +
|
| 115 | + sub is_url { return $_[0] =~ m{https?://|ftp://|file://}; } |
| 116 | + sub is_spdx { return $_[0] =~ /SPDX-|Copyright|\(c\)\s|\x{00A9}/i; } |
| 117 | + sub filled { |
| 118 | + my ($s) = @_; |
| 119 | + return 0 if length($s) < $MIN; |
| 120 | + return 0 if $s =~ /\\\z/ || $s =~ / \z/; |
| 121 | + return 0 if is_url($s); |
| 122 | + (my $t = $s) =~ s/\s+\z//; |
| 123 | + return 0 if $t =~ /[.!?:;]\z/; |
| 124 | + return 1; |
| 125 | + } |
| 126 | + sub cont_lc { return $_[0] =~ /^[a-z]/; } |
| 127 | +
|
| 128 | + sub comment_parts { |
| 129 | + my ($line, $mk) = @_; |
| 130 | + my $q = quotemeta $mk; |
| 131 | + return () unless $line =~ /^(\s*)$q\s?(\S.*)\z/; |
| 132 | + my ($ind, $txt) = ($1, $2); |
| 133 | + return () if $txt =~ /^!/; |
| 134 | + return () if $txt =~ /^[-=*#_~>|+]/; |
| 135 | + return () if $txt =~ /^\d+[.)]\s/; |
| 136 | + return () if $txt =~ /^(?:shellcheck|noqa|type:|pylint|eslint|prettier|TODO|FIXME|NOTE|HACK|XXX|@)/i; |
| 137 | + return () if is_spdx($txt) || is_url($line); |
| 138 | + return ($ind, $txt); |
| 139 | + } |
| 140 | +
|
| 141 | + sub reflow_comments { |
| 142 | + my ($lines, $mk) = @_; |
| 143 | + my @out; |
| 144 | + my $i = 0; |
| 145 | + while ($i <= $#$lines) { |
| 146 | + my $line = $lines->[$i]; |
| 147 | + my ($ind, $txt) = comment_parts($line, $mk); |
| 148 | + if (!defined $ind) { push @out, $line; $i++; next; } |
| 149 | + my @para = ($txt); |
| 150 | + my @src = ($line); |
| 151 | + my $j = $i + 1; |
| 152 | + while ($j <= $#$lines) { |
| 153 | + my ($ind2, $txt2) = comment_parts($lines->[$j], $mk); |
| 154 | + last if !defined $ind2 || $ind2 ne $ind; |
| 155 | + push @para, $txt2; |
| 156 | + push @src, $lines->[$j]; |
| 157 | + $j++; |
| 158 | + } |
| 159 | + my @merged = ({ txt => $para[0], src => $src[0], hit => 0 }); |
| 160 | + for (my $k = 1; $k < @para; $k++) { |
| 161 | + if (filled($src[$k-1]) && cont_lc($para[$k])) { |
| 162 | + $merged[-1]{txt} .= ' ' . $para[$k]; |
| 163 | + $merged[-1]{hit} = 1; |
| 164 | + } else { |
| 165 | + push @merged, { txt => $para[$k], src => $src[$k], hit => 0 }; |
| 166 | + } |
| 167 | + } |
| 168 | + push @out, map { $_->{hit} ? "$ind$mk " . $_->{txt} : $_->{src} } @merged; |
| 169 | + $i = $j; |
| 170 | + } |
| 171 | + return @out; |
| 172 | + } |
| 173 | +
|
| 174 | + sub structural_md { |
| 175 | + my ($s) = @_; |
| 176 | + return ($s =~ /^\s*\z/ |
| 177 | + || $s =~ /^\s*\x60{3}/ |
| 178 | + || $s =~ /^\s{0,3}#{1,6}\s/ |
| 179 | + || $s =~ /^\s*[-*+]\s/ |
| 180 | + || $s =~ /^\s*\d+[.)]\s/ |
| 181 | + || $s =~ /^\s*>/ |
| 182 | + || $s =~ /^\s*\|/ |
| 183 | + || $s =~ /^\s*<[^>]/ |
| 184 | + || $s =~ /^\s{4,}\S/ |
| 185 | + || $s =~ /^\s*(?:[-=_*]\s*){3,}\z/); |
| 186 | + } |
| 187 | +
|
| 188 | + sub reflow_md { |
| 189 | + my ($lines) = @_; |
| 190 | + my @out; |
| 191 | + my $fence = 0; |
| 192 | + my $i = 0; |
| 193 | + if (@$lines && $lines->[0] =~ /^---\s*\z/) { |
| 194 | + push @out, $lines->[0]; $i = 1; |
| 195 | + while ($i <= $#$lines) { push @out, $lines->[$i]; my $done = ($lines->[$i] =~ /^---\s*\z/); $i++; last if $done; } |
| 196 | + } |
| 197 | + while ($i <= $#$lines) { |
| 198 | + my $line = $lines->[$i]; |
| 199 | + if ($line =~ /^\s*\x60{3}/) { $fence = !$fence; push @out, $line; $i++; next; } |
| 200 | + if ($fence || structural_md($line)) { push @out, $line; $i++; next; } |
| 201 | + my @para = ($line); |
| 202 | + my $j = $i + 1; |
| 203 | + while ($j <= $#$lines && !structural_md($lines->[$j]) && $lines->[$j] !~ /^\s*\x60{3}/) { |
| 204 | + push @para, $lines->[$j]; |
| 205 | + $j++; |
| 206 | + } |
| 207 | + my ($indent) = ($para[0] =~ /^(\s*)/); |
| 208 | + my @merged = ({ txt => ($para[0] =~ s/^\s+//r), src => $para[0], hit => 0 }); |
| 209 | + for (my $k = 1; $k < @para; $k++) { |
| 210 | + (my $t = $para[$k]) =~ s/^\s+//; |
| 211 | + if (filled($para[$k-1]) && cont_lc($t)) { |
| 212 | + $merged[-1]{txt} .= ' ' . $t; |
| 213 | + $merged[-1]{hit} = 1; |
| 214 | + } else { |
| 215 | + push @merged, { txt => $t, src => $para[$k], hit => 0 }; |
| 216 | + } |
| 217 | + } |
| 218 | + push @out, map { $_->{hit} ? $indent . $_->{txt} : $_->{src} } @merged; |
| 219 | + $i = $j; |
| 220 | + } |
| 221 | + return @out; |
| 222 | + } |
| 223 | +
|
| 224 | + my @out = $mode eq 'md' ? reflow_md(\@l) : reflow_comments(\@l, $mk); |
| 225 | + open my $o, '>', $file or die "write $file: $!\n"; |
| 226 | + print $o join("\n", @out); |
| 227 | + print $o "\n" if $final_nl; |
| 228 | + close $o; |
| 229 | + HUMANISE_PL |
| 230 | +
|
| 231 | + # Candidate files: tracked, filtered by extension, excluding generated/vendor/self paths. |
| 232 | + IFS=',' read -ra EXTS <<< "$EXTENSIONS" |
| 233 | + FILES=() |
| 234 | + while IFS= read -r f; do |
| 235 | + case "$f" in |
| 236 | + .git/*|node_modules/*|*/node_modules/*|vendor/*|*/vendor/*) continue ;; |
| 237 | + test-repo/*|licence-templates/*|licences-templates/*|.github/static/*) continue ;; |
| 238 | + .github/workflows/humanise.yml|workflows-templates/humanise.yml) continue ;; |
| 239 | + LICENCE|LICENSE|*/LICENCE|*/LICENSE) continue ;; |
| 240 | + esac |
| 241 | + ext="${f##*.}" |
| 242 | + match=0 |
| 243 | + for e in "${EXTS[@]}"; do |
| 244 | + [[ "$ext" == "$e" ]] && { match=1; break; } |
| 245 | + done |
| 246 | + [[ "$match" == 1 ]] && FILES+=("$f") |
| 247 | + done < <(git ls-files) |
| 248 | +
|
| 249 | + echo "Scanning ${#FILES[@]} files for hard-wrapped paragraphs..." |
| 250 | + if [[ ${#FILES[@]} -gt 0 ]]; then |
| 251 | + for f in "${FILES[@]}"; do |
| 252 | + perl /tmp/humanise.pl "$f" || echo "skip: $f" |
| 253 | + done |
| 254 | + fi |
| 255 | +
|
| 256 | + echo "## Humanise" >> "$GITHUB_STEP_SUMMARY" |
| 257 | + if git diff --quiet; then |
| 258 | + echo "needs_fix=false" >> "$GITHUB_OUTPUT" |
| 259 | + echo "No hard-wrapped comments or prose found." | tee -a "$GITHUB_STEP_SUMMARY" |
| 260 | + exit 0 |
| 261 | + fi |
| 262 | +
|
| 263 | + COUNT=$(git diff --name-only | wc -l) |
| 264 | + echo "Reflowed hard-wrapped text in ${COUNT} file(s):" >> "$GITHUB_STEP_SUMMARY" |
| 265 | + git diff --name-only | sed 's/^/- /' >> "$GITHUB_STEP_SUMMARY" |
| 266 | +
|
| 267 | + if [[ "$DRY_RUN" == "true" ]]; then |
| 268 | + git checkout -- . |
| 269 | + echo "needs_fix=false" >> "$GITHUB_OUTPUT" |
| 270 | + echo "" >> "$GITHUB_STEP_SUMMARY" |
| 271 | + echo "_Dry run - no pull request opened._" >> "$GITHUB_STEP_SUMMARY" |
| 272 | + else |
| 273 | + echo "needs_fix=true" >> "$GITHUB_OUTPUT" |
| 274 | + fi |
| 275 | +
|
| 276 | + - name: Setup bot identity |
| 277 | + if: steps.humanise.outputs.needs_fix == 'true' |
| 278 | + uses: XAOSTECH/dev-control/.github/actions/identity@b067f72ca7f849b734a45634f23581a739d5146f # v2.0.0 |
| 279 | + with: |
| 280 | + gpg-private-key: ${{ secrets['XB_GK'] }} |
| 281 | + gpg-passphrase: ${{ secrets['XB_GP'] }} |
| 282 | + user-token: ${{ secrets['XB_UT'] }} |
| 283 | + bot-name: ${{ vars.BOT_NAME || 'xaos-bot' }} |
| 284 | + |
| 285 | + - name: Ensure automerge label exists |
| 286 | + if: steps.humanise.outputs.needs_fix == 'true' |
| 287 | + env: |
| 288 | + GH_TOKEN: ${{ steps.app_token.outputs.token }} |
| 289 | + run: | |
| 290 | + gh label create automerge --description "Automatically merge this PR" --color "3BEF67" 2>/dev/null || true |
| 291 | +
|
| 292 | + - name: Create Pull Request |
| 293 | + if: steps.humanise.outputs.needs_fix == 'true' |
| 294 | + env: |
| 295 | + GH_TOKEN: ${{ steps.app_token.outputs.token }} |
| 296 | + run: | |
| 297 | + git add -A |
| 298 | + if git diff --cached --quiet; then |
| 299 | + echo "No changes to commit" |
| 300 | + exit 0 |
| 301 | + fi |
| 302 | + BRANCH_NAME="humanise/$(date +%Y%m%d-%H%M%S)" |
| 303 | + git checkout -b "$BRANCH_NAME" |
| 304 | + git commit -m "chore: humanise hard-wrapped comments and prose" -m "Rejoin editor/AI hard-wrapped comment and prose paragraphs onto single logical lines; structure, bullets, code, SPDX headers and deliberate short lines are preserved." |
| 305 | + git push origin "$BRANCH_NAME" |
| 306 | + gh pr create --title "chore: Humanise hard-wrapped comments and prose" --body "Automated reflow of editor/AI hard-wrapped comment and prose paragraphs onto single logical lines. Bullets, headings, tables, code blocks, SPDX headers, shebangs and deliberate short lines are preserved. Please review for any unintended joins. Generated by the Humanise workflow (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." --base "${{ github.ref_name }}" --head "$BRANCH_NAME" --label "automerge" |
| 307 | + echo "Pull request created." |
0 commit comments