javi-forge 1.26.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ci-local/hooks/commit-msg +7 -0
- package/ci-local/hooks/pre-commit +8 -0
- package/ci-local/hooks/pre-push +8 -0
- package/dist/cli/dispatch/ci.js +1 -1
- package/dist/cli/dispatch/skills-cmd.js +8 -0
- package/dist/commands/ci.js +5 -1
- package/dist/commands/doctor.js +9 -0
- package/dist/commands/init/steps/ghagga.d.ts +3 -4
- package/dist/commands/init/steps/ghagga.js +5 -15
- package/dist/commands/skills/analysis.js +31 -2
- package/dist/commands/skills/benchmark.js +10 -0
- package/dist/commands/skills/constants.d.ts +5 -0
- package/dist/commands/skills/constants.js +5 -0
- package/dist/commands/skills/parsing.d.ts +18 -3
- package/dist/commands/skills/parsing.js +29 -3
- package/dist/commands/skills/scoring.d.ts +7 -6
- package/dist/commands/skills/scoring.js +31 -1
- package/dist/lib/context.d.ts +22 -0
- package/dist/lib/context.js +120 -79
- package/dist/lib/safe-read.d.ts +62 -0
- package/dist/lib/safe-read.js +221 -0
- package/dist/lib/security-analysis.d.ts +19 -2
- package/dist/lib/security-analysis.js +65 -13
- package/dist/lib/skill-scanner.d.ts +22 -1
- package/dist/lib/skill-scanner.js +76 -4
- package/dist/types/index.d.ts +18 -0
- package/dist/ui/Skills.js +12 -7
- package/package.json +1 -1
- package/templates/github/ghagga-review.yml +0 -30
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import fs from "fs-extra";
|
|
10
|
+
import { describeSafeReadFailure, safeReadFile } from "./safe-read.js";
|
|
10
11
|
// =============================================================================
|
|
11
12
|
// Constants
|
|
12
13
|
// =============================================================================
|
|
14
|
+
/**
|
|
15
|
+
* Hard ceiling per scanned file. Source files past this are generated or
|
|
16
|
+
* vendored bundles: running every rule over them costs far more than it finds.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_ANALYSIS_BYTES = 2 * 1024 * 1024;
|
|
13
19
|
const SEVERITY_ORDER = {
|
|
14
20
|
critical: 5,
|
|
15
21
|
high: 4,
|
|
@@ -316,7 +322,7 @@ export function severityAtOrAbove(severity, threshold) {
|
|
|
316
322
|
// =============================================================================
|
|
317
323
|
// Report generation
|
|
318
324
|
// =============================================================================
|
|
319
|
-
export function buildSummary(findings, failThreshold) {
|
|
325
|
+
export function buildSummary(findings, failThreshold, incomplete = false) {
|
|
320
326
|
const bySeverity = {
|
|
321
327
|
critical: 0,
|
|
322
328
|
high: 0,
|
|
@@ -329,23 +335,29 @@ export function buildSummary(findings, failThreshold) {
|
|
|
329
335
|
bySeverity[f.severity]++;
|
|
330
336
|
byCategory[f.category] = (byCategory[f.category] ?? 0) + 1;
|
|
331
337
|
}
|
|
332
|
-
|
|
338
|
+
// Fail closed on an incomplete scan: a file we never fully read could hold the
|
|
339
|
+
// very finding that would have failed the gate. No threshold finding is not
|
|
340
|
+
// the same as "clean" when part of the codebase was invisible to the scan.
|
|
341
|
+
const noThresholdFinding = !findings.some((f) => severityAtOrAbove(f.severity, failThreshold));
|
|
342
|
+
const passed = noThresholdFinding && !incomplete;
|
|
333
343
|
return {
|
|
334
344
|
total: findings.length,
|
|
335
345
|
bySeverity,
|
|
336
346
|
byCategory,
|
|
337
347
|
passed,
|
|
348
|
+
incomplete,
|
|
338
349
|
failThreshold,
|
|
339
350
|
};
|
|
340
351
|
}
|
|
341
|
-
export function buildReport(findings, projectDir, options = {}) {
|
|
352
|
+
export function buildReport(findings, projectDir, options = {}, skipped = []) {
|
|
342
353
|
const failThreshold = options.failThreshold ?? "high";
|
|
343
354
|
return {
|
|
344
355
|
engine: "semgrep",
|
|
345
356
|
timestamp: new Date().toISOString(),
|
|
346
357
|
projectDir,
|
|
347
358
|
findings,
|
|
348
|
-
summary: buildSummary(findings, failThreshold),
|
|
359
|
+
summary: buildSummary(findings, failThreshold, skipped.length > 0),
|
|
360
|
+
...(skipped.length > 0 ? { skipped } : {}),
|
|
349
361
|
};
|
|
350
362
|
}
|
|
351
363
|
// =============================================================================
|
|
@@ -426,16 +438,42 @@ export async function runSecurityAnalysis(projectDir, options = {}) {
|
|
|
426
438
|
}
|
|
427
439
|
// Run pattern matching
|
|
428
440
|
const findings = [];
|
|
441
|
+
const skipped = [];
|
|
429
442
|
for (const filePath of allFiles) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
443
|
+
// Guarded read: a binary blob or a multi-megabyte bundle would otherwise
|
|
444
|
+
// be fed to every regex rule in the set. `maxBytes` is pinned to the same
|
|
445
|
+
// ceiling as the hard reject so the documented 2 MiB limit is the effective
|
|
446
|
+
// scan cap — otherwise the 1 MiB default would silently truncate every file
|
|
447
|
+
// between 1 and 2 MiB and MAX_ANALYSIS_BYTES would be a dead constant.
|
|
448
|
+
const read = await safeReadFile(filePath, {
|
|
449
|
+
maxBytes: MAX_ANALYSIS_BYTES,
|
|
450
|
+
hardRejectOverBytes: MAX_ANALYSIS_BYTES,
|
|
451
|
+
});
|
|
437
452
|
// Use relative paths in findings for readability
|
|
438
453
|
const relativePath = path.relative(projectDir, filePath);
|
|
454
|
+
if (!read.ok) {
|
|
455
|
+
skipped.push({
|
|
456
|
+
file: relativePath,
|
|
457
|
+
reason: describeSafeReadFailure(read),
|
|
458
|
+
});
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const content = read.content;
|
|
462
|
+
if (read.truncated) {
|
|
463
|
+
skipped.push({
|
|
464
|
+
file: relativePath,
|
|
465
|
+
reason: `truncated at ${read.bytesRead} of ${read.totalBytes} bytes — scanned partially`,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
// A clamped long line means the regex pass saw a shortened line — a payload
|
|
469
|
+
// hidden past the clamp would be invisible. Record it like a truncation so
|
|
470
|
+
// the scan is marked incomplete and cannot report a clean pass.
|
|
471
|
+
if (read.longLinesClamped) {
|
|
472
|
+
skipped.push({
|
|
473
|
+
file: relativePath,
|
|
474
|
+
reason: "long line(s) clamped — scanned partially",
|
|
475
|
+
});
|
|
476
|
+
}
|
|
439
477
|
for (const rule of rules) {
|
|
440
478
|
const matches = matchRule(rule, content, filePath);
|
|
441
479
|
// Rewrite file paths to relative
|
|
@@ -452,7 +490,7 @@ export async function runSecurityAnalysis(projectDir, options = {}) {
|
|
|
452
490
|
return sevDiff;
|
|
453
491
|
return a.file.localeCompare(b.file);
|
|
454
492
|
});
|
|
455
|
-
return buildReport(findings, projectDir, options);
|
|
493
|
+
return buildReport(findings, projectDir, options, skipped);
|
|
456
494
|
}
|
|
457
495
|
// =============================================================================
|
|
458
496
|
// Report formatting (for CI output)
|
|
@@ -468,7 +506,14 @@ export function formatReportText(report) {
|
|
|
468
506
|
.map(([sev, count]) => `${count} ${sev}`)
|
|
469
507
|
.join(", ") || "none"}`);
|
|
470
508
|
lines.push(`Pass threshold: ${summary.failThreshold}`);
|
|
471
|
-
lines.push(`Result: ${summary.passed
|
|
509
|
+
lines.push(`Result: ${summary.passed
|
|
510
|
+
? "PASS"
|
|
511
|
+
: summary.incomplete
|
|
512
|
+
? "FAIL (incomplete scan — some files were not fully analysed)"
|
|
513
|
+
: "FAIL"}`);
|
|
514
|
+
if (report.skipped && report.skipped.length > 0) {
|
|
515
|
+
lines.push(`Skipped files: ${report.skipped.length}`);
|
|
516
|
+
}
|
|
472
517
|
lines.push("");
|
|
473
518
|
if (findings.length > 0) {
|
|
474
519
|
lines.push("--- Findings ---");
|
|
@@ -481,6 +526,13 @@ export function formatReportText(report) {
|
|
|
481
526
|
lines.push(` ${f.message}`);
|
|
482
527
|
}
|
|
483
528
|
}
|
|
529
|
+
if (report.skipped && report.skipped.length > 0) {
|
|
530
|
+
lines.push("");
|
|
531
|
+
lines.push("--- Skipped ---");
|
|
532
|
+
for (const s of report.skipped) {
|
|
533
|
+
lines.push(`${s.file}: ${s.reason}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
484
536
|
return lines.join("\n");
|
|
485
537
|
}
|
|
486
538
|
export function formatReportJson(report) {
|
|
@@ -15,13 +15,34 @@ export interface SkillThreat {
|
|
|
15
15
|
context: string;
|
|
16
16
|
message: string;
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* `unscannable` is a fail-closed verdict: the file could not be read in full
|
|
20
|
+
* (binary, oversized, I/O error) or a content-mutating clamp/truncation
|
|
21
|
+
* happened during the read, so we cannot certify it. A gate MUST treat it as a
|
|
22
|
+
* rejection exactly like `block` — see {@link isRejectedVerdict}, the single
|
|
23
|
+
* predicate every install/registry gate should use instead of `=== "block"`.
|
|
24
|
+
*/
|
|
25
|
+
export type SkillScanVerdict = "pass" | "warn" | "block" | "unscannable";
|
|
26
|
+
/**
|
|
27
|
+
* The set of verdicts an install/registry gate rejects on. Fail-closed:
|
|
28
|
+
* `block` (a critical threat was found) and `unscannable` (the file could not
|
|
29
|
+
* be certified because it was not fully scanned) both mean "do not install".
|
|
30
|
+
* Use this everywhere instead of a bare `verdict === "block"` check so a future
|
|
31
|
+
* gate can never let an `unscannable` file slip through.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isRejectedVerdict(verdict: SkillScanVerdict): boolean;
|
|
19
34
|
export interface SkillScanResult {
|
|
20
35
|
skillPath: string;
|
|
21
36
|
skillName: string;
|
|
22
37
|
verdict: SkillScanVerdict;
|
|
23
38
|
threats: SkillThreat[];
|
|
24
39
|
summary: SkillScanSummary;
|
|
40
|
+
/**
|
|
41
|
+
* Scan-level notes — populated when the file could not be fully analysed
|
|
42
|
+
* (binary, oversized, unreadable) so a skipped file is visible in the report
|
|
43
|
+
* instead of masquerading as a clean pass.
|
|
44
|
+
*/
|
|
45
|
+
notes?: string[];
|
|
25
46
|
}
|
|
26
47
|
export interface SkillScanSummary {
|
|
27
48
|
total: number;
|
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import fs from "fs-extra";
|
|
10
|
+
import { describeSafeReadFailure, safeReadFile } from "./safe-read.js";
|
|
11
|
+
/**
|
|
12
|
+
* The set of verdicts an install/registry gate rejects on. Fail-closed:
|
|
13
|
+
* `block` (a critical threat was found) and `unscannable` (the file could not
|
|
14
|
+
* be certified because it was not fully scanned) both mean "do not install".
|
|
15
|
+
* Use this everywhere instead of a bare `verdict === "block"` check so a future
|
|
16
|
+
* gate can never let an `unscannable` file slip through.
|
|
17
|
+
*/
|
|
18
|
+
export function isRejectedVerdict(verdict) {
|
|
19
|
+
return verdict === "block" || verdict === "unscannable";
|
|
20
|
+
}
|
|
10
21
|
/**
|
|
11
22
|
* Ordered by severity (critical first). Each pattern is tested against
|
|
12
23
|
* every non-comment line in the skill file.
|
|
@@ -282,18 +293,66 @@ export function extractSkillName(content, filePath) {
|
|
|
282
293
|
// =============================================================================
|
|
283
294
|
// Main scan function
|
|
284
295
|
// =============================================================================
|
|
296
|
+
/**
|
|
297
|
+
* Hard ceiling for a scanned skill file. Past this it is not a skill document
|
|
298
|
+
* but a dumped log or a vendored bundle: scanning it would run every regex
|
|
299
|
+
* over megabytes of noise, so it is skipped and reported instead.
|
|
300
|
+
*/
|
|
301
|
+
const MAX_SCAN_BYTES = 1024 * 1024;
|
|
285
302
|
export async function scanSkillFile(filePath) {
|
|
286
|
-
|
|
303
|
+
// `maxLineLength: 0` disables the per-line clamp for the scanner's own read:
|
|
304
|
+
// a padded single line hiding `rm -rf ~` past column 10k must reach the regex
|
|
305
|
+
// pass intact, not be silently truncated and then scanned as if complete. The
|
|
306
|
+
// total-byte ceiling still bounds memory (a file past it fails `too-large`).
|
|
307
|
+
const read = await safeReadFile(filePath, {
|
|
308
|
+
hardRejectOverBytes: MAX_SCAN_BYTES,
|
|
309
|
+
maxLineLength: 0,
|
|
310
|
+
});
|
|
311
|
+
// A file we could not read is not a clean file. Never crash the batch, and
|
|
312
|
+
// never report it as a pass: an unscannable file cannot be certified safe, so
|
|
313
|
+
// it fails closed with the strongest verdict a gate rejects on.
|
|
314
|
+
if (!read.ok) {
|
|
315
|
+
return {
|
|
316
|
+
skillPath: filePath,
|
|
317
|
+
skillName: path.basename(path.dirname(filePath)),
|
|
318
|
+
verdict: "unscannable",
|
|
319
|
+
threats: [],
|
|
320
|
+
summary: computeScanSummary([]),
|
|
321
|
+
notes: [
|
|
322
|
+
`scanning incomplete: ${describeSafeReadFailure(read)} — rejected (an unscannable file cannot be certified safe)`,
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const content = read.content;
|
|
287
327
|
const skillName = extractSkillName(content, filePath);
|
|
288
328
|
const threats = scanSkillContent(content, filePath);
|
|
289
|
-
const verdict = computeVerdict(threats);
|
|
290
329
|
const summary = computeScanSummary(threats);
|
|
330
|
+
// A content-mutating read (truncated bytes, or a clamped line) means the
|
|
331
|
+
// regex pass did NOT see the whole file. Even if no threat surfaced in what
|
|
332
|
+
// we did see, we cannot certify the rest — fail closed rather than emit a
|
|
333
|
+
// pass/warn over partial content. With `maxLineLength: 0` and the byte
|
|
334
|
+
// ceiling above these should not fire, but the guard is the safety net.
|
|
335
|
+
const incomplete = read.truncated || read.longLinesClamped;
|
|
336
|
+
const notes = [];
|
|
337
|
+
if (read.truncated) {
|
|
338
|
+
notes.push(`truncated: only the first ${read.bytesRead} of ${read.totalBytes} bytes were scanned`);
|
|
339
|
+
}
|
|
340
|
+
if (read.longLinesClamped) {
|
|
341
|
+
notes.push("clamped: one or more lines exceeded the per-line limit");
|
|
342
|
+
}
|
|
343
|
+
if (incomplete) {
|
|
344
|
+
notes.push("rejected: the file was not fully scanned and cannot be certified safe");
|
|
345
|
+
}
|
|
346
|
+
const verdict = incomplete
|
|
347
|
+
? "unscannable"
|
|
348
|
+
: computeVerdict(threats);
|
|
291
349
|
return {
|
|
292
350
|
skillPath: filePath,
|
|
293
351
|
skillName,
|
|
294
352
|
verdict,
|
|
295
353
|
threats,
|
|
296
354
|
summary,
|
|
355
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
297
356
|
};
|
|
298
357
|
}
|
|
299
358
|
/**
|
|
@@ -346,6 +405,12 @@ export function formatScanReport(result) {
|
|
|
346
405
|
lines.push(`Verdict: ${verdict.toUpperCase()}`);
|
|
347
406
|
lines.push(`Findings: ${summary.total} (${summary.critical} critical, ${summary.high} high, ${summary.moderate} moderate, ${summary.low} low)`);
|
|
348
407
|
lines.push("");
|
|
408
|
+
if (result.notes && result.notes.length > 0) {
|
|
409
|
+
lines.push("--- Notes ---");
|
|
410
|
+
for (const note of result.notes)
|
|
411
|
+
lines.push(` ${note}`);
|
|
412
|
+
lines.push("");
|
|
413
|
+
}
|
|
349
414
|
if (threats.length > 0) {
|
|
350
415
|
lines.push("--- Threats ---");
|
|
351
416
|
for (const t of threats) {
|
|
@@ -354,7 +419,11 @@ export function formatScanReport(result) {
|
|
|
354
419
|
lines.push(` Context: ${t.context}`);
|
|
355
420
|
}
|
|
356
421
|
}
|
|
357
|
-
if (verdict === "
|
|
422
|
+
if (verdict === "unscannable") {
|
|
423
|
+
lines.push("");
|
|
424
|
+
lines.push("REJECTED: File could not be fully scanned, so it cannot be certified safe. Not installed.");
|
|
425
|
+
}
|
|
426
|
+
else if (verdict === "block") {
|
|
358
427
|
lines.push("");
|
|
359
428
|
lines.push("BLOCKED: Critical threats detected. Review and remove before installing.");
|
|
360
429
|
}
|
|
@@ -367,11 +436,14 @@ export function formatScanReport(result) {
|
|
|
367
436
|
export function formatBatchReport(results) {
|
|
368
437
|
const lines = [];
|
|
369
438
|
const blocked = results.filter((r) => r.verdict === "block");
|
|
439
|
+
const unscannable = results.filter((r) => r.verdict === "unscannable");
|
|
370
440
|
const warned = results.filter((r) => r.verdict === "warn");
|
|
371
441
|
const passed = results.filter((r) => r.verdict === "pass");
|
|
372
442
|
lines.push(`=== SkillGuard Batch Scan ===`);
|
|
373
443
|
lines.push(`Scanned: ${results.length} skill(s)`);
|
|
374
|
-
lines.push(`
|
|
444
|
+
lines.push(`Rejected: ${blocked.length + unscannable.length}`);
|
|
445
|
+
lines.push(` Blocked (threats): ${blocked.length}`);
|
|
446
|
+
lines.push(` Unscannable (not certified): ${unscannable.length}`);
|
|
375
447
|
lines.push(`Warned: ${warned.length}`);
|
|
376
448
|
lines.push(`Passed: ${passed.length}`);
|
|
377
449
|
lines.push("");
|
package/dist/types/index.d.ts
CHANGED
|
@@ -226,6 +226,11 @@ export interface SkillBudgetEntry {
|
|
|
226
226
|
skillName: string;
|
|
227
227
|
skillPath: string;
|
|
228
228
|
tokens: number;
|
|
229
|
+
/**
|
|
230
|
+
* Set when the SKILL.md could not be fully read (binary, oversized, I/O
|
|
231
|
+
* error) — `tokens` is then partial or zero and should be read with care.
|
|
232
|
+
*/
|
|
233
|
+
note?: string;
|
|
229
234
|
}
|
|
230
235
|
export interface SkillBudgetSuggestion {
|
|
231
236
|
/** Skills to disable in this suggestion set */
|
|
@@ -271,6 +276,13 @@ export interface SkillScore {
|
|
|
271
276
|
grade: SkillGrade;
|
|
272
277
|
threshold: number;
|
|
273
278
|
passing: boolean;
|
|
279
|
+
/**
|
|
280
|
+
* Set when the SKILL.md could not be read (binary, oversized, I/O error). The
|
|
281
|
+
* numeric dimensions are then meaningless placeholders (all `0`, never a
|
|
282
|
+
* computed grade or `safety: 100`); callers must surface the file as unread
|
|
283
|
+
* rather than as a scored skill. `passing` is always `false` in this state.
|
|
284
|
+
*/
|
|
285
|
+
unread?: string;
|
|
274
286
|
}
|
|
275
287
|
export interface SkillRegistryGateResult {
|
|
276
288
|
skillName: string;
|
|
@@ -287,6 +299,12 @@ export interface SkillBenchmarkResult {
|
|
|
287
299
|
skillName: string;
|
|
288
300
|
checks: SkillBenchmarkCheck[];
|
|
289
301
|
passRate: number;
|
|
302
|
+
/**
|
|
303
|
+
* Set when the SKILL.md could not be read (binary, oversized, I/O error). No
|
|
304
|
+
* checks were run (`checks` is empty, `passRate` is `0`); callers must surface
|
|
305
|
+
* the file as unread rather than as a benchmarked skill that failed every check.
|
|
306
|
+
*/
|
|
307
|
+
unread?: string;
|
|
290
308
|
}
|
|
291
309
|
declare const WORKFLOW_FORMAT: {
|
|
292
310
|
readonly DOT: "dot";
|
package/dist/ui/Skills.js
CHANGED
|
@@ -66,13 +66,18 @@ export default function Skills({ mode, budget, deep, skillsDir }) {
|
|
|
66
66
|
result.budget.budget,
|
|
67
67
|
" tokens",
|
|
68
68
|
result.budget.overBudget ? " (OVER BUDGET)" : "")),
|
|
69
|
-
result.budget.entries.map((entry) => (React.createElement(Box, { key: entry.skillName, marginLeft: 4 },
|
|
70
|
-
React.createElement(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
result.budget.entries.map((entry) => (React.createElement(Box, { key: entry.skillName, flexDirection: "column", marginLeft: 4 },
|
|
70
|
+
React.createElement(Box, null,
|
|
71
|
+
React.createElement(Text, { color: entry.note ? theme.warning : theme.muted }, entry.skillName),
|
|
72
|
+
React.createElement(Text, { color: theme.muted, dimColor: true },
|
|
73
|
+
" ",
|
|
74
|
+
"~",
|
|
75
|
+
entry.tokens,
|
|
76
|
+
" tokens")),
|
|
77
|
+
entry.note && (React.createElement(Box, { marginLeft: 2 },
|
|
78
|
+
React.createElement(Text, { color: theme.warning },
|
|
79
|
+
"! ",
|
|
80
|
+
entry.note)))))),
|
|
76
81
|
result.budget.suggestions.map((s, i) => (React.createElement(Box, { key: `suggestion-${i}`, marginLeft: 4 },
|
|
77
82
|
React.createElement(Text, { color: theme.warning },
|
|
78
83
|
"! ",
|
package/package.json
CHANGED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# ===========================================
|
|
2
|
-
# GHAGGA AI Code Review
|
|
3
|
-
# ===========================================
|
|
4
|
-
# Triggers AI code review on pull requests.
|
|
5
|
-
# Requires: GHAGGA GitHub Action (JNZader/ghagga@v1)
|
|
6
|
-
#
|
|
7
|
-
# Modes: simple (default), workflow, consensus
|
|
8
|
-
# ===========================================
|
|
9
|
-
|
|
10
|
-
name: Code Review
|
|
11
|
-
|
|
12
|
-
on:
|
|
13
|
-
pull_request:
|
|
14
|
-
types: [opened, synchronize, reopened]
|
|
15
|
-
|
|
16
|
-
permissions:
|
|
17
|
-
pull-requests: write
|
|
18
|
-
contents: read
|
|
19
|
-
|
|
20
|
-
jobs:
|
|
21
|
-
review:
|
|
22
|
-
runs-on: ubuntu-latest
|
|
23
|
-
steps:
|
|
24
|
-
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
25
|
-
# TODO(pin): ghagga has no v1 tag upstream (releases start at v2) — this
|
|
26
|
-
# ref cannot be SHA-pinned as-is; pick a real released tag and pin it.
|
|
27
|
-
- uses: JNZader/ghagga@v1
|
|
28
|
-
with:
|
|
29
|
-
mode: simple
|
|
30
|
-
provider: github
|