Skip to content

Commit e0f5db7

Browse files
authored
Add claude GitHub actions 1770170196497 (#4028)
* checkout correct version Signed-off-by: Andy Miller <rhuk@mac.com> * cache-cleanup command Signed-off-by: Andy Miller <rhuk@mac.com> * remove standlone binary Signed-off-by: Andy Miller <rhuk@mac.com> * "Claude PR Assistant workflow" * "Claude Code Review workflow" --------- Signed-off-by: Andy Miller <rhuk@mac.com>
1 parent 07de0cb commit e0f5db7

File tree

4 files changed

+365
-0
lines changed

4 files changed

+365
-0
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Claude Code Review
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, ready_for_review, reopened]
6+
# Optional: Only run on specific file changes
7+
# paths:
8+
# - "src/**/*.ts"
9+
# - "src/**/*.tsx"
10+
# - "src/**/*.js"
11+
# - "src/**/*.jsx"
12+
13+
jobs:
14+
claude-review:
15+
# Optional: Filter by PR author
16+
# if: |
17+
# github.event.pull_request.user.login == 'external-contributor' ||
18+
# github.event.pull_request.user.login == 'new-developer' ||
19+
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
20+
21+
runs-on: ubuntu-latest
22+
permissions:
23+
contents: read
24+
pull-requests: read
25+
issues: read
26+
id-token: write
27+
28+
steps:
29+
- name: Checkout repository
30+
uses: actions/checkout@v4
31+
with:
32+
fetch-depth: 1
33+
34+
- name: Run Claude Code Review
35+
id: claude-review
36+
uses: anthropics/claude-code-action@v1
37+
with:
38+
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39+
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
40+
plugins: 'code-review@claude-code-plugins'
41+
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
42+
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
43+
# or https://code.claude.com/docs/en/cli-reference for available options
44+

.github/workflows/claude.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Claude Code
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
pull_request_review_comment:
7+
types: [created]
8+
issues:
9+
types: [opened, assigned]
10+
pull_request_review:
11+
types: [submitted]
12+
13+
jobs:
14+
claude:
15+
if: |
16+
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
17+
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
18+
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
19+
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
20+
runs-on: ubuntu-latest
21+
permissions:
22+
contents: read
23+
pull-requests: read
24+
issues: read
25+
id-token: write
26+
actions: read # Required for Claude to read CI results on PRs
27+
steps:
28+
- name: Checkout repository
29+
uses: actions/checkout@v4
30+
with:
31+
fetch-depth: 1
32+
33+
- name: Run Claude Code
34+
id: claude
35+
uses: anthropics/claude-code-action@v1
36+
with:
37+
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38+
39+
# This is an optional setting that allows Claude to read CI results on PRs
40+
additional_permissions: |
41+
actions: read
42+
43+
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
44+
# prompt: 'Update the pull request description to include a summary of changes.'
45+
46+
# Optional: Add claude_args to customize behavior and configuration
47+
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
48+
# or https://code.claude.com/docs/en/cli-reference for available options
49+
# claude_args: '--allowed-tools Bash(gh pr:*)'
50+

system/src/Grav/Console/Application/GravApplication.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace Grav\Console\Application;
1111

1212
use Grav\Console\Cli\BackupCommand;
13+
use Grav\Console\Cli\CacheCleanupCommand;
1314
use Grav\Console\Cli\CleanCommand;
1415
use Grav\Console\Cli\ClearCacheCommand;
1516
use Grav\Console\Cli\ComposerCommand;
@@ -40,6 +41,7 @@ public function __construct(string $name = 'UNKNOWN', string $version = 'UNKNOWN
4041
new SandboxCommand(),
4142
new CleanCommand(),
4243
new ClearCacheCommand(),
44+
new CacheCleanupCommand(),
4345
new BackupCommand(),
4446
new NewProjectCommand(),
4547
new SchedulerCommand(),
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
<?php
2+
3+
/**
4+
* @package Grav\Console\Cli
5+
*
6+
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
7+
* @license MIT License; see LICENSE file for details.
8+
*/
9+
10+
namespace Grav\Console\Cli;
11+
12+
use DirectoryIterator;
13+
use Exception;
14+
use Grav\Common\Filesystem\Folder;
15+
use Grav\Common\Grav;
16+
use Grav\Console\GravCommand;
17+
use RecursiveDirectoryIterator;
18+
use RecursiveIteratorIterator;
19+
use Symfony\Component\Console\Input\InputOption;
20+
21+
/**
22+
* Class CacheCleanupCommand
23+
* @package Grav\Console\Cli
24+
*/
25+
class CacheCleanupCommand extends GravCommand
26+
{
27+
/**
28+
* @return void
29+
*/
30+
protected function configure(): void
31+
{
32+
$this
33+
->setName('cache-cleanup')
34+
->setAliases(['cleanup'])
35+
->setDescription('Removes orphaned cache directories that are no longer in use')
36+
->addOption('force', 'f', InputOption::VALUE_NONE, 'Actually delete orphaned caches (dry run without this)')
37+
->addOption('max-age', 'd', InputOption::VALUE_REQUIRED, 'Delete orphaned caches older than N days', '1')
38+
->addOption('max-age-weeks', 'w', InputOption::VALUE_REQUIRED, 'Delete orphaned caches older than N weeks')
39+
->addOption('max-age-months', 'm', InputOption::VALUE_REQUIRED, 'Delete orphaned caches older than N months')
40+
->setHelp(<<<'EOF'
41+
The <info>cache-cleanup</info> command removes orphaned cache directories that are no longer in use.
42+
Only keeps the current cache key directory.
43+
44+
<comment>Dry run (shows what would be deleted):</comment>
45+
<info>bin/grav cache-cleanup</info>
46+
47+
<comment>Actually delete orphaned caches:</comment>
48+
<info>bin/grav cache-cleanup --force</info>
49+
50+
<comment>Delete orphaned caches older than 7 days:</comment>
51+
<info>bin/grav cache-cleanup --force --max-age=7</info>
52+
53+
<comment>Delete orphaned caches older than 2 weeks:</comment>
54+
<info>bin/grav cache-cleanup --force --max-age-weeks=2</info>
55+
56+
<comment>Delete orphaned caches older than 1 month:</comment>
57+
<info>bin/grav cache-cleanup --force --max-age-months=1</info>
58+
59+
<comment>Cron example (run daily at 3am):</comment>
60+
<info>0 3 * * * /path/to/grav/bin/grav cache-cleanup --force >> /var/log/grav-cache-cleanup.log 2>&1</info>
61+
EOF
62+
);
63+
}
64+
65+
/**
66+
* @return int
67+
*/
68+
protected function serve(): int
69+
{
70+
$this->initializeGrav();
71+
72+
$input = $this->getInput();
73+
$io = $this->getIO();
74+
75+
$force = $input->getOption('force');
76+
$maxAge = $this->calculateMaxAgeDays();
77+
$maxAgeSeconds = $maxAge * 86400;
78+
79+
$grav = Grav::instance();
80+
$cache = $grav['cache'];
81+
$currentKey = $cache->getKey();
82+
83+
// Extract just the uniqueness part (after the prefix and dash)
84+
$currentUniqueness = substr($currentKey, strpos($currentKey, '-') + 1);
85+
86+
$io->title('Grav Cache Cleanup');
87+
$io->writeln("Current cache key: <info>{$currentKey}</info>");
88+
$io->writeln("Current uniqueness: <info>{$currentUniqueness}</info>");
89+
$io->writeln("Max age for orphaned caches: <info>{$maxAge} day(s)</info>");
90+
$io->writeln('Mode: ' . ($force ? '<red>FORCE (will delete)</red>' : '<yellow>DRY RUN (use --force to delete)</yellow>'));
91+
$io->newLine();
92+
93+
$cacheDir = GRAV_ROOT . '/cache';
94+
95+
if (!is_dir($cacheDir)) {
96+
$io->error("Cache directory not found: {$cacheDir}");
97+
return 1;
98+
}
99+
100+
$now = time();
101+
$totalDeleted = 0;
102+
$totalSize = 0;
103+
$keptCount = 0;
104+
$skippedCount = 0;
105+
106+
// Directories that contain cache key subdirectories (8-char hex)
107+
$cacheKeyDirs = [
108+
$cacheDir . '/doctrine',
109+
$cacheDir . '/grav',
110+
];
111+
112+
foreach ($cacheKeyDirs as $scanDir) {
113+
if (!is_dir($scanDir)) {
114+
if ($io->isVerbose()) {
115+
$io->writeln("Skipping (not found): {$scanDir}");
116+
}
117+
continue;
118+
}
119+
120+
$io->writeln("Scanning: <cyan>{$scanDir}</cyan>");
121+
$iterator = new DirectoryIterator($scanDir);
122+
123+
foreach ($iterator as $file) {
124+
if ($file->isDot() || !$file->isDir()) {
125+
continue;
126+
}
127+
128+
$dirName = $file->getBasename();
129+
$dirPath = $file->getPathname();
130+
131+
// Only process directories that look like cache keys (8-char hex)
132+
if (!preg_match('/^[a-f0-9]{8}$/', $dirName)) {
133+
if ($io->isVerbose()) {
134+
$io->writeln("[SKIP] {$dirName} (not a cache key directory)");
135+
}
136+
continue;
137+
}
138+
139+
$dirAge = $now - $file->getMTime();
140+
$dirAgeDays = round($dirAge / 86400, 1);
141+
142+
// Get directory size
143+
$size = $this->getDirectorySize($dirPath);
144+
$sizeFormatted = $this->formatBytes($size);
145+
146+
if ($dirName === $currentUniqueness) {
147+
$io->writeln("<green>[KEEP]</green> {$dirName} (CURRENT - {$sizeFormatted})");
148+
$keptCount++;
149+
continue;
150+
}
151+
152+
// Check if old enough to delete
153+
if ($dirAge < $maxAgeSeconds) {
154+
$io->writeln("<yellow>[SKIP]</yellow> {$dirName} (only {$dirAgeDays} days old, waiting for {$maxAge} days - {$sizeFormatted})");
155+
$skippedCount++;
156+
continue;
157+
}
158+
159+
$io->writeln("<red>[DELETE]</red> {$dirName} ({$dirAgeDays} days old - {$sizeFormatted})");
160+
161+
if ($force) {
162+
try {
163+
Folder::delete($dirPath);
164+
$totalDeleted++;
165+
$totalSize += $size;
166+
if ($io->isVerbose()) {
167+
$io->writeln(' -> Deleted successfully');
168+
}
169+
} catch (Exception $e) {
170+
$io->writeln(' -> <red>ERROR:</red> ' . $e->getMessage());
171+
}
172+
} else {
173+
$totalDeleted++;
174+
$totalSize += $size;
175+
}
176+
}
177+
}
178+
179+
$io->newLine();
180+
$io->section('Summary');
181+
$io->writeln("Current cache kept: <green>{$keptCount}</green>");
182+
$io->writeln("Orphaned caches skipped (too new): <yellow>{$skippedCount}</yellow>");
183+
184+
if ($force) {
185+
$io->writeln("Orphaned caches deleted: <red>{$totalDeleted}</red>");
186+
$io->writeln('Space freed: <info>' . $this->formatBytes($totalSize) . '</info>');
187+
} else {
188+
$io->writeln("Orphaned caches to delete: <red>{$totalDeleted}</red>");
189+
$io->writeln('Space to free: <info>' . $this->formatBytes($totalSize) . '</info>');
190+
if ($totalDeleted > 0) {
191+
$io->newLine();
192+
$io->note('Run with --force to actually delete these directories.');
193+
}
194+
}
195+
196+
return 0;
197+
}
198+
199+
/**
200+
* Calculate max age in days from the various options
201+
*
202+
* @return int
203+
*/
204+
private function calculateMaxAgeDays(): int
205+
{
206+
$input = $this->getInput();
207+
208+
// Check for months first (highest priority)
209+
$months = $input->getOption('max-age-months');
210+
if ($months !== null) {
211+
return (int)$months * 30;
212+
}
213+
214+
// Check for weeks
215+
$weeks = $input->getOption('max-age-weeks');
216+
if ($weeks !== null) {
217+
return (int)$weeks * 7;
218+
}
219+
220+
// Default to days
221+
return (int)$input->getOption('max-age');
222+
}
223+
224+
/**
225+
* Get directory size recursively
226+
*
227+
* @param string $path
228+
* @return int
229+
*/
230+
private function getDirectorySize(string $path): int
231+
{
232+
$size = 0;
233+
234+
try {
235+
$iterator = new RecursiveIteratorIterator(
236+
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
237+
RecursiveIteratorIterator::LEAVES_ONLY
238+
);
239+
240+
foreach ($iterator as $file) {
241+
if ($file->isFile()) {
242+
$size += $file->getSize();
243+
}
244+
}
245+
} catch (Exception $e) {
246+
// Ignore errors, return what we have
247+
}
248+
249+
return $size;
250+
}
251+
252+
/**
253+
* Format bytes to human readable
254+
*
255+
* @param int $bytes
256+
* @param int $precision
257+
* @return string
258+
*/
259+
private function formatBytes(int $bytes, int $precision = 2): string
260+
{
261+
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
262+
263+
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
264+
$bytes /= 1024;
265+
}
266+
267+
return round($bytes, $precision) . ' ' . $units[$i];
268+
}
269+
}

0 commit comments

Comments
 (0)