@usepipr/runtime 0.3.1 → 0.3.3

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.
@@ -768,13 +768,11 @@ export default definePipr((pipr) => {
768
768
  const result = await ctx.pi.run(changelog, { manifest });
769
769
  await ctx.comment(
770
770
  [
771
- "## Changelog Draft",
772
- "",
773
771
  \`**Category:** \${result.category}\`,
774
772
  "",
775
773
  result.entry,
776
774
  "",
777
- "### Rationale",
775
+ "## Rationale",
778
776
  result.rationale,
779
777
  ].join("\\n"),
780
778
  );
@@ -869,23 +867,36 @@ export default definePipr((pipr) => {
869
867
  Return only actionable findings that target valid diff ranges.
870
868
  \`,
871
869
  timeout: "10m",
872
- comment: (result, context) => ({
873
- main:
874
- context.platform.id === "local"
875
- ? [
876
- "## Summary",
877
- "",
878
- result.summary.body,
879
- "",
880
- "## Inline Findings",
881
- "",
882
- result.inlineFindings.length === 0
883
- ? "No inline findings."
884
- : result.inlineFindings.map((finding) => \`- \${finding.body}\`).join("\\n"),
885
- ].join("\\n")
886
- : result.summary.body,
887
- inlineFindings: result.inlineFindings,
888
- }),
870
+ comment: (result, context) => {
871
+ const inlineFindingSummary =
872
+ result.inlineFindings.length === 0
873
+ ? "No inline findings."
874
+ : "See inline comments in the diff.";
875
+ const localInlineFindingSummary = [
876
+ "## Inline Findings",
877
+ "",
878
+ result.inlineFindings.length === 0
879
+ ? "No inline findings."
880
+ : result.inlineFindings.map((finding) => \`- \${finding.body}\`).join("\\n"),
881
+ ].join("\\n");
882
+
883
+ return {
884
+ main: [
885
+ "## Summary",
886
+ "",
887
+ result.summary.body,
888
+ "",
889
+ "## Review Result",
890
+ "",
891
+ "| Signal | Result |",
892
+ "| --- | ---: |",
893
+ \`| Inline findings | \${result.inlineFindings.length} |\`,
894
+ "",
895
+ context.platform.id === "local" ? localInlineFindingSummary : inlineFindingSummary,
896
+ ].join("\\n"),
897
+ inlineFindings: result.inlineFindings,
898
+ };
899
+ },
889
900
  });
890
901
  });
891
902
  `
@@ -948,20 +959,18 @@ export default definePipr((pipr) => {
948
959
  };
949
960
  const manifest = await ctx.change.diffManifest({ compressed: true, paths });
950
961
  if (manifest.files.length === 0) {
951
- await ctx.comment("## Dependency Risk\\n\\nNo dependency files changed.");
962
+ await ctx.comment("No dependency files changed.");
952
963
  return;
953
964
  }
954
965
  const result = await ctx.pi.run(dependencyReviewer, { manifest }, { paths });
955
966
  await ctx.comment(
956
967
  [
957
- "## Dependency Risk",
958
- "",
959
968
  result.summary,
960
969
  "",
961
- "### Risks",
970
+ "## Risks",
962
971
  ...result.risks.map((risk) => \`- \${risk}\`),
963
972
  "",
964
- "### Follow-ups",
973
+ "## Follow-ups",
965
974
  ...result.followUps.map((followUp) => \`- \${followUp}\`),
966
975
  ].join("\\n"),
967
976
  );
@@ -1033,7 +1042,7 @@ export default definePipr((pipr) => {
1033
1042
  ...(diagnostic.suggestedFix ? { suggestedFix: diagnostic.suggestedFix } : {}),
1034
1043
  }));
1035
1044
  await ctx.comment({
1036
- main: \`## Diff Diagnostics\\n\\n\${result.summary}\`,
1045
+ main: result.summary,
1037
1046
  inlineFindings,
1038
1047
  });
1039
1048
  },
@@ -1045,6 +1054,268 @@ export default definePipr((pipr) => {
1045
1054
  `
1046
1055
  };
1047
1056
  //#endregion
1057
+ //#region src/config/recipes/fix-suggestions.ts
1058
+ const fixSuggestionsRecipe = {
1059
+ id: "fix-suggestions",
1060
+ title: "Fix Suggestions",
1061
+ description: "Command-first exact suggested fixes for actionable review improvements.",
1062
+ sourceTools: [
1063
+ "Qodo Merge /improve",
1064
+ "GitHub Copilot code review",
1065
+ "Cursor Bugbot"
1066
+ ],
1067
+ configTs: `import { definePipr, z } from "@usepipr/sdk";
1068
+ import type { CommentableRange, DiffManifest, ReviewFinding } from "@usepipr/sdk";
1069
+
1070
+ export default definePipr((pipr) => {
1071
+ const model = pipr.model({
1072
+ provider: "deepseek",
1073
+ model: "deepseek-v4-pro",
1074
+ apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
1075
+ options: { thinking: "high" },
1076
+ });
1077
+
1078
+ pipr.config({ publication: { maxInlineComments: 6 } });
1079
+
1080
+ const fixSuggestionSchema = z.strictObject({
1081
+ title: z.string(),
1082
+ category: z.enum(["correctness", "tests", "maintainability", "typing", "documentation"]),
1083
+ body: z.string(),
1084
+ path: z.string(),
1085
+ rangeId: z.string(),
1086
+ side: z.enum(["RIGHT", "LEFT"]),
1087
+ startLine: z.number().int().positive(),
1088
+ endLine: z.number().int().positive(),
1089
+ suggestedFix: z.string().min(1),
1090
+ });
1091
+
1092
+ type FixSuggestion = z.infer<typeof fixSuggestionSchema>;
1093
+
1094
+ const fixSuggestionOutput = pipr.schema({
1095
+ id: "review/fix-suggestions",
1096
+ schema: z.strictObject({
1097
+ summary: z.string(),
1098
+ suggestions: z.array(fixSuggestionSchema),
1099
+ }),
1100
+ });
1101
+
1102
+ const fixer = pipr.agent({
1103
+ name: "fix-suggestions",
1104
+ model,
1105
+ instructions: \`
1106
+ Find exact suggested changes for this pull request. Return a suggestion only
1107
+ when suggestedFix is a precise replacement for the selected diff range and
1108
+ the reviewer can apply it directly. Prioritize correctness, missing tests,
1109
+ type safety, and small maintainability improvements. Do not report broad
1110
+ refactors, style preferences, or issues without an exact patch.
1111
+ \`,
1112
+ output: fixSuggestionOutput,
1113
+ tools: pipr.tools.readOnly,
1114
+ retry: { invalidOutput: 1, transientFailure: 1 },
1115
+ timeout: "7m",
1116
+ prompt: () => "Find exact suggested changes for this pull request.",
1117
+ });
1118
+
1119
+ const task = pipr.task({
1120
+ name: "fix-suggestions",
1121
+ async run(ctx) {
1122
+ if (!ctx.command) {
1123
+ throw new Error("fix-suggestions is a command-only task");
1124
+ }
1125
+ const manifest = await ctx.change.diffManifest({ compressed: true });
1126
+ const result = await ctx.pi.run(fixer, { manifest });
1127
+ const publishableSuggestions = result.suggestions.filter(
1128
+ (suggestion) => isPublishableSuggestion(suggestion, manifest),
1129
+ );
1130
+ const inlineFindings: ReviewFinding[] = publishableSuggestions.map((suggestion) => {
1131
+ const category = suggestion.category
1132
+ .replaceAll("-", " ")
1133
+ .replace(/^./, (char) => char.toUpperCase());
1134
+ return {
1135
+ body: \`**\${category}:** \${suggestion.title}. \${suggestion.body}\`,
1136
+ path: suggestion.path,
1137
+ rangeId: suggestion.rangeId,
1138
+ side: suggestion.side,
1139
+ startLine: suggestion.startLine,
1140
+ endLine: suggestion.endLine,
1141
+ suggestedFix: suggestion.suggestedFix,
1142
+ };
1143
+ });
1144
+ await ctx.comment({
1145
+ main: [
1146
+ result.summary,
1147
+ "",
1148
+ "## Exact Suggested Changes",
1149
+ "",
1150
+ suggestionsTable(publishableSuggestions),
1151
+ ].join("\\n"),
1152
+ inlineFindings,
1153
+ });
1154
+ },
1155
+ });
1156
+
1157
+ pipr.command({
1158
+ pattern: "@pipr improve",
1159
+ permission: "write",
1160
+ description: "Find exact suggested fixes for this pull request.",
1161
+ task,
1162
+ });
1163
+ });
1164
+
1165
+ type FindingAnchor = Pick<ReviewFinding, "path" | "rangeId" | "side" | "startLine" | "endLine">;
1166
+
1167
+ function isPublishableSuggestion(suggestion: FixSuggestion, manifest: DiffManifest): boolean {
1168
+ if (suggestion.suggestedFix.trim().length === 0) {
1169
+ return false;
1170
+ }
1171
+ const range = commentableRangeForFinding(suggestion, manifest);
1172
+ return Boolean(
1173
+ range &&
1174
+ isPublishableSuggestedFixSelection({
1175
+ side: suggestion.side,
1176
+ kind: range.kind,
1177
+ rangeStartLine: range.startLine,
1178
+ startLine: suggestion.startLine,
1179
+ endLine: suggestion.endLine,
1180
+ preview: range.preview,
1181
+ suggestedFix: suggestion.suggestedFix,
1182
+ }),
1183
+ );
1184
+ }
1185
+
1186
+ function commentableRangeForFinding(
1187
+ finding: FindingAnchor,
1188
+ manifest: DiffManifest,
1189
+ ): CommentableRange | undefined {
1190
+ for (const file of manifest.files) {
1191
+ const range = file.commentableRanges.find((candidate) => candidate.id === finding.rangeId);
1192
+ if (!range) {
1193
+ continue;
1194
+ }
1195
+ return finding.rangeId === range.id &&
1196
+ finding.path === range.path &&
1197
+ finding.side === range.side &&
1198
+ finding.startLine <= finding.endLine &&
1199
+ finding.startLine >= range.startLine &&
1200
+ finding.endLine <= range.endLine
1201
+ ? range
1202
+ : undefined;
1203
+ }
1204
+ return undefined;
1205
+ }
1206
+
1207
+ function isPublishableSuggestedFixSelection(selection: {
1208
+ side: "RIGHT" | "LEFT";
1209
+ kind: "added" | "deleted" | "context" | "mixed";
1210
+ rangeStartLine: number;
1211
+ startLine: number;
1212
+ endLine: number;
1213
+ preview?: string;
1214
+ suggestedFix: string;
1215
+ }): boolean {
1216
+ const suggestedLines = normalizedSuggestedFixLines(selection.suggestedFix);
1217
+ const selectedLineCount = selection.endLine - selection.startLine + 1;
1218
+ if (
1219
+ selection.side !== "RIGHT" ||
1220
+ selection.kind === "deleted" ||
1221
+ selectedLineCount > 12 ||
1222
+ suggestedLines.length > 20
1223
+ ) {
1224
+ return false;
1225
+ }
1226
+
1227
+ const originalLines = selectedPreviewLines(selection, selectedLineCount);
1228
+ return Boolean(
1229
+ originalLines &&
1230
+ !hasUnchangedSelectionEdge(originalLines, suggestedLines) &&
1231
+ !suggestionIncludesUnselectedContext(selection, selectedLineCount, suggestedLines),
1232
+ );
1233
+ }
1234
+
1235
+ function normalizedSuggestedFixLines(value: string): string[] {
1236
+ const normalized = value.replace(/\\r\\n/g, "\\n").replace(/\\r/g, "\\n");
1237
+ const withoutFinalNewline = normalized.endsWith("\\n") ? normalized.slice(0, -1) : normalized;
1238
+ return withoutFinalNewline.length === 0 ? [] : withoutFinalNewline.split("\\n");
1239
+ }
1240
+
1241
+ function selectedPreviewLines(
1242
+ selection: {
1243
+ rangeStartLine: number;
1244
+ startLine: number;
1245
+ preview?: string;
1246
+ },
1247
+ selectedLineCount: number,
1248
+ ): string[] | undefined {
1249
+ if (!selection.preview) {
1250
+ return undefined;
1251
+ }
1252
+ const offset = selection.startLine - selection.rangeStartLine;
1253
+ if (offset < 0) {
1254
+ return undefined;
1255
+ }
1256
+ const previewLines = selection.preview.replace(/\\r\\n/g, "\\n").replace(/\\r/g, "\\n").split("\\n");
1257
+ if (offset + selectedLineCount > previewLines.length) {
1258
+ return undefined;
1259
+ }
1260
+ return previewLines.slice(offset, offset + selectedLineCount);
1261
+ }
1262
+
1263
+ function suggestionIncludesUnselectedContext(
1264
+ selection: {
1265
+ rangeStartLine: number;
1266
+ startLine: number;
1267
+ preview?: string;
1268
+ },
1269
+ selectedLineCount: number,
1270
+ suggestedLines: string[],
1271
+ ): boolean {
1272
+ if (!selection.preview || suggestedLines.length <= selectedLineCount) {
1273
+ return false;
1274
+ }
1275
+ const offset = selection.startLine - selection.rangeStartLine;
1276
+ if (offset < 0) {
1277
+ return false;
1278
+ }
1279
+ const previewLines = selection.preview.replace(/\\r\\n/g, "\\n").replace(/\\r/g, "\\n").split("\\n");
1280
+ const contextLines = [
1281
+ offset > 0 ? previewLines[offset - 1] : undefined,
1282
+ previewLines[offset + selectedLineCount],
1283
+ ].filter((line): line is string => Boolean(line?.trim()));
1284
+ return contextLines.some((line) => suggestedLines.includes(line));
1285
+ }
1286
+
1287
+ function hasUnchangedSelectionEdge(originalLines: string[], suggestedLines: string[]): boolean {
1288
+ const firstLineUnchanged = originalLines[0] === suggestedLines[0];
1289
+ const lastLineUnchanged = originalLines.at(-1) === suggestedLines.at(-1);
1290
+ if (originalLines.length === suggestedLines.length || originalLines.length === 1) {
1291
+ return firstLineUnchanged || lastLineUnchanged;
1292
+ }
1293
+ return firstLineUnchanged && lastLineUnchanged;
1294
+ }
1295
+
1296
+ function suggestionsTable(suggestions: FixSuggestion[]): string {
1297
+ if (suggestions.length === 0) {
1298
+ return [
1299
+ "| Category | Title |",
1300
+ "| --- | --- |",
1301
+ "| - | No exact suggested fixes found. |",
1302
+ ].join("\\n");
1303
+ }
1304
+ return [
1305
+ "| Category | Title |",
1306
+ "| --- | --- |",
1307
+ ...suggestions.map((suggestion) => {
1308
+ const category = suggestion.category
1309
+ .replaceAll("-", " ")
1310
+ .replace(/^./, (char) => char.toUpperCase());
1311
+ const title = suggestion.title.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
1312
+ return \`| \${category} | \${title} |\`;
1313
+ }),
1314
+ ].join("\\n");
1315
+ }
1316
+ `
1317
+ };
1318
+ //#endregion
1048
1319
  //#region src/config/recipes/interactive-ask.ts
1049
1320
  const interactiveAskRecipe = {
1050
1321
  id: "interactive-ask",
@@ -1483,7 +1754,7 @@ const prBriefingRecipe = {
1483
1754
  title: "PR Briefing",
1484
1755
  description: "PR-Agent describe-style overview, risk summary, and walkthrough.",
1485
1756
  sourceTools: ["PR-Agent /describe", "CodeRabbit PR summaries"],
1486
- configTs: `import { definePipr } from "@usepipr/sdk";
1757
+ configTs: `import { definePipr, z } from "@usepipr/sdk";
1487
1758
 
1488
1759
  export default definePipr((pipr) => {
1489
1760
  const model = pipr.model({
@@ -1495,33 +1766,178 @@ export default definePipr((pipr) => {
1495
1766
 
1496
1767
  pipr.config({ publication: { maxInlineComments: 0 } });
1497
1768
 
1498
- pipr.review({
1499
- id: "pr-briefing",
1769
+ const briefingSchema = z.strictObject({
1770
+ summary: z.string(),
1771
+ prType: z.enum(["feature", "bugfix", "refactor", "docs", "tests", "dependency", "infra", "mixed"]),
1772
+ riskLevel: z.enum(["low", "medium", "high"]),
1773
+ riskSummary: z.string(),
1774
+ changeMap: z.array(z.strictObject({
1775
+ area: z.string(),
1776
+ files: z.array(z.string()).max(6),
1777
+ change: z.string(),
1778
+ })).max(8),
1779
+ reviewerFocus: z.array(z.string()).max(6),
1780
+ notableFiles: z.array(z.strictObject({
1781
+ path: z.string(),
1782
+ reason: z.string(),
1783
+ })).max(8),
1784
+ walkthrough: z.array(z.string()).max(8),
1785
+ diagramMermaid: z.string().optional(),
1786
+ });
1787
+
1788
+ type Briefing = z.infer<typeof briefingSchema>;
1789
+
1790
+ const briefingOutput = pipr.schema({
1791
+ id: "briefing/pr-reviewer",
1792
+ schema: briefingSchema,
1793
+ });
1794
+
1795
+ const briefing = pipr.agent({
1796
+ name: "pr-briefing",
1500
1797
  model,
1501
1798
  instructions: \`
1502
1799
  Produce a maintainer briefing instead of a defect hunt. Summarize what changed,
1503
- classify the PR type, explain review risk, and include a concise file walkthrough.
1504
- Return no inline findings unless there is a concrete blocker.
1800
+ classify the PR type, explain review risk, list notable files, and include
1801
+ a concise reviewer walkthrough. Use reviewerFocus for what humans should
1802
+ inspect first. Use diagramMermaid only when a small flowchart clarifies
1803
+ multi-step control flow, data flow, or package boundaries; omit it for
1804
+ straightforward changes. Do not report inline findings.
1505
1805
  \`,
1506
- comment: (result, context) => [
1507
- "## PR Briefing",
1508
- "",
1509
- \`**Change:** \${context.change.title}\`,
1510
- "",
1511
- result.summary.body,
1512
- "",
1513
- "\`\`\`mermaid",
1514
- "flowchart LR",
1515
- " A[Changed files] --> B[Review focus]",
1516
- " B --> C[Merge decision]",
1517
- "\`\`\`",
1518
- ].join("\\n"),
1519
- entrypoints: {
1520
- changeRequest: ["opened", "updated", "reopened", "ready"],
1521
- command: { pattern: "@pipr describe", permission: "read" },
1806
+ output: briefingOutput,
1807
+ tools: pipr.tools.readOnly,
1808
+ retry: { invalidOutput: 1, transientFailure: 1 },
1809
+ timeout: "7m",
1810
+ prompt: () => "Prepare a maintainer briefing for this pull request.",
1811
+ });
1812
+
1813
+ const task = pipr.task({
1814
+ name: "pr-briefing",
1815
+ async run(ctx) {
1816
+ const manifest = await ctx.change.diffManifest({ compressed: true });
1817
+ const result = await ctx.pi.run(briefing, { manifest, change: ctx.change });
1818
+ const reviewerFocus =
1819
+ result.reviewerFocus.length === 0
1820
+ ? "No special reviewer focus called out."
1821
+ : result.reviewerFocus.map((item) => \`- \${item}\`).join("\\n");
1822
+ const walkthrough =
1823
+ result.walkthrough.length === 0
1824
+ ? "No walkthrough notes."
1825
+ : result.walkthrough.map((item) => \`- \${item}\`).join("\\n");
1826
+ await ctx.comment([
1827
+ overviewTable(result, ctx.change.title),
1828
+ "",
1829
+ "## Summary",
1830
+ "",
1831
+ result.summary,
1832
+ "",
1833
+ "## Change Map",
1834
+ "",
1835
+ changeMapTable(result.changeMap),
1836
+ "",
1837
+ "## Reviewer Focus",
1838
+ "",
1839
+ reviewerFocus,
1840
+ "",
1841
+ "## Notable Files",
1842
+ "",
1843
+ notableFilesTable(result.notableFiles),
1844
+ "",
1845
+ "## Walkthrough",
1846
+ "",
1847
+ walkthrough,
1848
+ "",
1849
+ diagramBlock(result.diagramMermaid),
1850
+ ].filter(Boolean).join("\\n"));
1522
1851
  },
1523
1852
  });
1853
+
1854
+ pipr.on.changeRequest({ actions: ["opened", "updated", "reopened", "ready"], task });
1855
+ pipr.command({
1856
+ pattern: "@pipr describe",
1857
+ permission: "read",
1858
+ description: "Generate a reviewer briefing for this pull request.",
1859
+ task,
1860
+ });
1524
1861
  });
1862
+
1863
+ function overviewTable(briefing: Briefing, title: string): string {
1864
+ const titleCell = title.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
1865
+ const prType = briefing.prType.replaceAll("-", " ").replace(/^./, (char) => char.toUpperCase());
1866
+ const riskLevel = briefing.riskLevel
1867
+ .replaceAll("-", " ")
1868
+ .replace(/^./, (char) => char.toUpperCase());
1869
+ const riskSummary = briefing.riskSummary.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
1870
+ return [
1871
+ "| Change | Type | Risk | Risk summary |",
1872
+ "| --- | --- | --- | --- |",
1873
+ \`| \${titleCell} | \${prType} | \${riskLevel} | \${riskSummary} |\`,
1874
+ ].join("\\n");
1875
+ }
1876
+
1877
+ function changeMapTable(changeMap: Briefing["changeMap"]): string {
1878
+ if (changeMap.length === 0) {
1879
+ return [
1880
+ "| Area | Files | Change |",
1881
+ "| --- | --- | --- |",
1882
+ "| - | - | No changed areas summarized. |",
1883
+ ].join("\\n");
1884
+ }
1885
+ return [
1886
+ "| Area | Files | Change |",
1887
+ "| --- | --- | --- |",
1888
+ ...changeMap.map((item) => {
1889
+ const area = item.area.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
1890
+ const files = item.files.join("<br>").replaceAll("\\n", " ").replaceAll("|", "\\\\|");
1891
+ const change = item.change.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
1892
+ return \`| \${area} | \${files} | \${change} |\`;
1893
+ }),
1894
+ ].join("\\n");
1895
+ }
1896
+
1897
+ function notableFilesTable(files: Briefing["notableFiles"]): string {
1898
+ if (files.length === 0) {
1899
+ return [
1900
+ "| File | Why it matters |",
1901
+ "| --- | --- |",
1902
+ "| - | No notable files called out. |",
1903
+ ].join("\\n");
1904
+ }
1905
+ return [
1906
+ "| File | Why it matters |",
1907
+ "| --- | --- |",
1908
+ ...files.map((file) => {
1909
+ const filePath = file.path.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
1910
+ const reason = file.reason.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
1911
+ return \`| \${filePath} | \${reason} |\`;
1912
+ }),
1913
+ ].join("\\n");
1914
+ }
1915
+
1916
+ function diagramBlock(diagramMermaid: string | undefined): string {
1917
+ const diagram = diagramMermaid?.trim();
1918
+ if (!diagram) {
1919
+ return "";
1920
+ }
1921
+ const fence = markdownFenceFor(diagram);
1922
+ return [
1923
+ "<details>",
1924
+ "<summary>Flow diagram</summary>",
1925
+ "",
1926
+ \`\${fence}mermaid\`,
1927
+ diagram,
1928
+ fence,
1929
+ "",
1930
+ "</details>",
1931
+ ].join("\\n");
1932
+ }
1933
+
1934
+ function markdownFenceFor(value: string): string {
1935
+ const longestBacktickRun = Math.max(
1936
+ 0,
1937
+ ...[...value.matchAll(/\`+/g)].map((match) => match[0].length),
1938
+ );
1939
+ return "\`".repeat(Math.max(3, longestBacktickRun + 1));
1940
+ }
1525
1941
  `
1526
1942
  };
1527
1943
  //#endregion
@@ -1531,7 +1947,8 @@ const prHygieneRecipe = {
1531
1947
  title: "PR Hygiene",
1532
1948
  description: "Danger-style PR hygiene checks for tests, docs, lockfiles, and size.",
1533
1949
  sourceTools: ["Danger JS"],
1534
- configTs: `import { definePipr } from "@usepipr/sdk";
1950
+ configTs: `import { definePipr, z } from "@usepipr/sdk";
1951
+ import type { ReviewFinding } from "@usepipr/sdk";
1535
1952
 
1536
1953
  export default definePipr((pipr) => {
1537
1954
  const model = pipr.model({
@@ -1541,18 +1958,60 @@ export default definePipr((pipr) => {
1541
1958
  options: { thinking: "medium" },
1542
1959
  });
1543
1960
 
1961
+ pipr.config({ publication: { maxInlineComments: 5 } });
1962
+
1963
+ const hygienePolicySchema = z.enum([
1964
+ "tests",
1965
+ "docs",
1966
+ "lockfiles",
1967
+ "generated-files",
1968
+ "change-size",
1969
+ ]);
1970
+
1971
+ const policyCheckSchema = z.strictObject({
1972
+ policy: hygienePolicySchema,
1973
+ status: z.enum(["pass", "attention", "not-applicable"]),
1974
+ evidence: z.string(),
1975
+ });
1976
+
1977
+ const hygieneFindingSchema = z.strictObject({
1978
+ title: z.string(),
1979
+ policy: hygienePolicySchema,
1980
+ body: z.string(),
1981
+ path: z.string(),
1982
+ rangeId: z.string(),
1983
+ side: z.enum(["RIGHT", "LEFT"]),
1984
+ startLine: z.number().int().positive(),
1985
+ endLine: z.number().int().positive(),
1986
+ suggestedFix: z.string().optional(),
1987
+ });
1988
+
1989
+ type PolicyCheck = z.infer<typeof policyCheckSchema>;
1990
+ type HygieneFinding = z.infer<typeof hygieneFindingSchema>;
1991
+
1992
+ const hygieneOutput = pipr.schema({
1993
+ id: "review/pr-hygiene",
1994
+ schema: z.strictObject({
1995
+ summary: z.string(),
1996
+ checks: z.array(policyCheckSchema),
1997
+ findings: z.array(hygieneFindingSchema),
1998
+ }),
1999
+ });
2000
+
1544
2001
  const hygiene = pipr.agent({
1545
2002
  name: "pr-hygiene",
1546
2003
  model,
1547
2004
  instructions: \`
1548
- Review pull request hygiene. Check whether behavior changes include tests,
1549
- public API changes include docs or changelog notes, lockfile changes match manifests,
1550
- and the PR size is reasonable. Return concise actionable findings.
1551
- \`,
1552
- output: pipr.schemas.review,
1553
- prompt: (input: { manifest: unknown; changedFiles: unknown }) => pipr.prompt\`
1554
- \${pipr.section("Changed files", pipr.json(input.changedFiles, { maxCharacters: 20000 }))}
2005
+ Review pull request hygiene, not code correctness. Evaluate tests, docs,
2006
+ lockfiles, generated files, and change size. Return one policy check per
2007
+ relevant policy. Return inline findings only for concrete hygiene gaps
2008
+ that maintainers can act on in the changed lines.
1555
2009
  \`,
2010
+ output: hygieneOutput,
2011
+ tools: pipr.tools.readOnly,
2012
+ retry: { invalidOutput: 1, transientFailure: 1 },
2013
+ timeout: "6m",
2014
+ prompt: () => "Check this pull request for repository hygiene and merge readiness.",
1556
2015
  });
1557
2016
 
1558
2017
  const task = pipr.task({
@@ -1563,10 +2022,34 @@ export default definePipr((pipr) => {
1563
2022
  ctx.log.info(\`Checking PR hygiene for \${changedFiles.length} changed file(s).\`);
1564
2023
  const manifest = await ctx.change.diffManifest({ compressed: true, maxPreviewLines: 80 });
1565
2024
  const result = await ctx.pi.run(hygiene, { manifest, changedFiles });
2025
+ const inlineFindings: ReviewFinding[] = result.findings.map((finding) => {
2026
+ const policy = finding.policy
2027
+ .replaceAll("-", " ")
2028
+ .replace(/^./, (char) => char.toUpperCase());
2029
+ return {
2030
+ body: \`**\${policy}:** \${finding.title}. \${finding.body}\`,
2031
+ path: finding.path,
2032
+ rangeId: finding.rangeId,
2033
+ side: finding.side,
2034
+ startLine: finding.startLine,
2035
+ endLine: finding.endLine,
2036
+ ...(finding.suggestedFix ? { suggestedFix: finding.suggestedFix } : {}),
2037
+ };
2038
+ });
1566
2039
  ctx.check.pass("PR hygiene review completed.");
1567
2040
  await ctx.comment({
1568
- main: result.summary.body,
1569
- inlineFindings: result.inlineFindings,
2041
+ main: [
2042
+ result.summary,
2043
+ "",
2044
+ "## Policy Checks",
2045
+ "",
2046
+ policyTable(result.checks),
2047
+ "",
2048
+ "## Selected Findings",
2049
+ "",
2050
+ findingsTable(result.findings),
2051
+ ].join("\\n"),
2052
+ inlineFindings,
1570
2053
  });
1571
2054
  },
1572
2055
  });
@@ -1574,6 +2057,51 @@ export default definePipr((pipr) => {
1574
2057
  pipr.on.changeRequest({ actions: ["opened", "updated", "reopened", "ready"], task });
1575
2058
  pipr.command({ pattern: "@pipr hygiene", permission: "write", task });
1576
2059
  });
2060
+
2061
+ function policyTable(checks: PolicyCheck[]): string {
2062
+ if (checks.length === 0) {
2063
+ return [
2064
+ "| Policy | Status | Evidence |",
2065
+ "| --- | --- | --- |",
2066
+ "| - | Not applicable | No policy checks were relevant. |",
2067
+ ].join("\\n");
2068
+ }
2069
+ return [
2070
+ "| Policy | Status | Evidence |",
2071
+ "| --- | --- | --- |",
2072
+ ...checks.map((check) => {
2073
+ const policy = check.policy
2074
+ .replaceAll("-", " ")
2075
+ .replace(/^./, (char) => char.toUpperCase());
2076
+ const status = check.status
2077
+ .replaceAll("-", " ")
2078
+ .replace(/^./, (char) => char.toUpperCase());
2079
+ const evidence = check.evidence.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
2080
+ return \`| \${policy} | \${status} | \${evidence} |\`;
2081
+ }),
2082
+ ].join("\\n");
2083
+ }
2084
+
2085
+ function findingsTable(findings: HygieneFinding[]): string {
2086
+ if (findings.length === 0) {
2087
+ return [
2088
+ "| Policy | Finding |",
2089
+ "| --- | --- |",
2090
+ "| - | No selected hygiene findings. |",
2091
+ ].join("\\n");
2092
+ }
2093
+ return [
2094
+ "| Policy | Finding |",
2095
+ "| --- | --- |",
2096
+ ...findings.map((finding) => {
2097
+ const policy = finding.policy
2098
+ .replaceAll("-", " ")
2099
+ .replace(/^./, (char) => char.toUpperCase());
2100
+ const title = finding.title.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
2101
+ return \`| \${policy} | \${title} |\`;
2102
+ }),
2103
+ ].join("\\n");
2104
+ }
1577
2105
  `
1578
2106
  };
1579
2107
  //#endregion
@@ -1582,8 +2110,9 @@ const qualityGateRecipe = {
1582
2110
  id: "quality-gate",
1583
2111
  title: "Quality Gate",
1584
2112
  description: "Required review check that fails on blocking correctness and test risks.",
1585
- sourceTools: ["SonarQube"],
1586
- configTs: `import { definePipr } from "@usepipr/sdk";
2113
+ sourceTools: ["SonarQube", "Snyk"],
2114
+ configTs: `import { definePipr, z } from "@usepipr/sdk";
2115
+ import type { CommentableRange, DiffManifest, ReviewFinding } from "@usepipr/sdk";
1587
2116
 
1588
2117
  export default definePipr((pipr) => {
1589
2118
  const model = pipr.model({
@@ -1616,21 +2145,467 @@ export default definePipr((pipr) => {
1616
2145
  },
1617
2146
  });
1618
2147
 
1619
- pipr.review({
1620
- id: "quality-gate",
2148
+ const blockerSchema = z.strictObject({
2149
+ title: z.string(),
2150
+ category: z.enum(["correctness", "security", "reliability", "test-coverage"]),
2151
+ impact: z.string(),
2152
+ body: z.string(),
2153
+ path: z.string(),
2154
+ rangeId: z.string(),
2155
+ side: z.enum(["RIGHT", "LEFT"]),
2156
+ startLine: z.number().int().positive(),
2157
+ endLine: z.number().int().positive(),
2158
+ suggestedFix: z.string().optional(),
2159
+ });
2160
+
2161
+ type QualityBlocker = z.infer<typeof blockerSchema>;
2162
+
2163
+ const qualityGateOutput = pipr.schema({
2164
+ id: "review/quality-gate",
2165
+ schema: z.strictObject({
2166
+ summary: z.string(),
2167
+ blockers: z.array(blockerSchema),
2168
+ }),
2169
+ });
2170
+
2171
+ const reviewer = pipr.agent({
2172
+ name: "quality-gate",
1621
2173
  model,
1622
2174
  instructions: \`
1623
- Act as a merge quality gate. Report only blocking correctness,
1624
- security, reliability, or test coverage issues that must prevent merge.
1625
- If no blocking issue exists, state that no blocking issue exists.
2175
+ Act as a merge quality gate. Report only blocking correctness, security,
2176
+ reliability, or test coverage issues that must prevent merge. A blocker
2177
+ must have a concrete changed-code range and an impact that maintainers can
2178
+ verify. If no blocking issue exists, return an empty blockers array.
1626
2179
  \`,
2180
+ output: qualityGateOutput,
2181
+ tools: pipr.tools.readOnly,
2182
+ retry: { invalidOutput: 1, transientFailure: 1 },
2183
+ timeout: "7m",
2184
+ prompt: () => "Run the required quality gate for this pull request.",
2185
+ });
2186
+
2187
+ const task = pipr.task({
2188
+ name: "quality-gate",
1627
2189
  check: { enabled: true, name: "quality gate", required: true },
1628
- comment: (result) => ({
1629
- main: \`## Quality Gate\\n\\n\${result.summary.body}\`,
1630
- inlineFindings: result.inlineFindings,
2190
+ async run(ctx) {
2191
+ const manifest = await ctx.change.diffManifest({ compressed: true });
2192
+ const result = await ctx.pi.run(reviewer, { manifest });
2193
+ const commentableBlockers = result.blockers.filter((blocker) =>
2194
+ commentableRangeForFinding(blocker, manifest) !== undefined,
2195
+ );
2196
+ const droppedBlockerCount = result.blockers.length - commentableBlockers.length;
2197
+ const inlineFindings: ReviewFinding[] = commentableBlockers.map((blocker) => {
2198
+ const category = blocker.category
2199
+ .replaceAll("-", " ")
2200
+ .replace(/^./, (char) => char.toUpperCase());
2201
+ return {
2202
+ body: \`**\${category} blocker:** \${blocker.title}. \${blocker.body}\`,
2203
+ path: blocker.path,
2204
+ rangeId: blocker.rangeId,
2205
+ side: blocker.side,
2206
+ startLine: blocker.startLine,
2207
+ endLine: blocker.endLine,
2208
+ ...(blocker.suggestedFix ? { suggestedFix: blocker.suggestedFix } : {}),
2209
+ };
2210
+ });
2211
+
2212
+ if (commentableBlockers.length > 0) {
2213
+ const issueNoun = commentableBlockers.length === 1 ? "issue" : "issues";
2214
+ ctx.check.fail(\`\${commentableBlockers.length} blocking quality \${issueNoun} found.\`);
2215
+ } else {
2216
+ ctx.check.pass("No blocking quality issues found.");
2217
+ }
2218
+
2219
+ await ctx.comment({
2220
+ main: [
2221
+ statusTable(commentableBlockers),
2222
+ "",
2223
+ droppedBlockersNote(droppedBlockerCount),
2224
+ "",
2225
+ result.summary,
2226
+ "",
2227
+ "## Blocking Findings",
2228
+ "",
2229
+ blockersTable(commentableBlockers),
2230
+ "",
2231
+ "## Category Breakdown",
2232
+ "",
2233
+ categoryBreakdownTable(commentableBlockers),
2234
+ ].join("\\n"),
2235
+ inlineFindings,
2236
+ });
2237
+ },
2238
+ });
2239
+
2240
+ pipr.on.changeRequest({ actions: ["opened", "updated", "reopened", "ready"], task });
2241
+ pipr.command({ pattern: "@pipr quality", permission: "write", task });
2242
+ });
2243
+
2244
+ type FindingAnchor = Pick<ReviewFinding, "path" | "rangeId" | "side" | "startLine" | "endLine">;
2245
+
2246
+ function commentableRangeForFinding(
2247
+ finding: FindingAnchor,
2248
+ manifest: DiffManifest,
2249
+ ): CommentableRange | undefined {
2250
+ for (const file of manifest.files) {
2251
+ const range = file.commentableRanges.find((candidate) => candidate.id === finding.rangeId);
2252
+ if (!range) {
2253
+ continue;
2254
+ }
2255
+ return finding.rangeId === range.id &&
2256
+ finding.path === range.path &&
2257
+ finding.side === range.side &&
2258
+ finding.startLine <= finding.endLine &&
2259
+ finding.startLine >= range.startLine &&
2260
+ finding.endLine <= range.endLine
2261
+ ? range
2262
+ : undefined;
2263
+ }
2264
+ return undefined;
2265
+ }
2266
+
2267
+ function statusTable(blockers: QualityBlocker[]): string {
2268
+ return [
2269
+ "| Status | Blocking findings | Categories |",
2270
+ "| --- | ---: | --- |",
2271
+ \`| \${blockers.length === 0 ? "Pass" : "Fail"} | \${blockers.length} | \${categorySummary(
2272
+ blockers,
2273
+ )} |\`,
2274
+ ].join("\\n");
2275
+ }
2276
+
2277
+ function blockersTable(blockers: QualityBlocker[]): string {
2278
+ if (blockers.length === 0) {
2279
+ return [
2280
+ "| Category | Title | Impact |",
2281
+ "| --- | --- | --- |",
2282
+ "| - | No blocking findings. | - |",
2283
+ ].join("\\n");
2284
+ }
2285
+ return [
2286
+ "| Category | Title | Impact |",
2287
+ "| --- | --- | --- |",
2288
+ ...blockers.map((blocker) => {
2289
+ const category = blocker.category
2290
+ .replaceAll("-", " ")
2291
+ .replace(/^./, (char) => char.toUpperCase());
2292
+ const title = blocker.title.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
2293
+ const impact = blocker.impact.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
2294
+ return \`| \${category} | \${title} | \${impact} |\`;
2295
+ }),
2296
+ ].join("\\n");
2297
+ }
2298
+
2299
+ function droppedBlockersNote(count: number): string {
2300
+ if (count === 0) {
2301
+ return "";
2302
+ }
2303
+ const blockerNoun = count === 1 ? "blocker" : "blockers";
2304
+ const verb = count === 1 ? "was" : "were";
2305
+ const pronoun = count === 1 ? "it does" : "they do";
2306
+ return [
2307
+ "<sub>",
2308
+ count,
2309
+ " model-reported ",
2310
+ blockerNoun,
2311
+ " ",
2312
+ verb,
2313
+ " ignored because ",
2314
+ pronoun,
2315
+ " not match a commentable diff range.",
2316
+ "</sub>",
2317
+ ].join("");
2318
+ }
2319
+
2320
+ function categoryBreakdownTable(blockers: QualityBlocker[]): string {
2321
+ const counts = categoryCounts(blockers);
2322
+ if (counts.length === 0) {
2323
+ return [
2324
+ "| Category | Count |",
2325
+ "| --- | ---: |",
2326
+ "| - | 0 |",
2327
+ ].join("\\n");
2328
+ }
2329
+ return [
2330
+ "| Category | Count |",
2331
+ "| --- | ---: |",
2332
+ ...counts.map(([category, count]) => {
2333
+ const categoryLabel = category
2334
+ .replaceAll("-", " ")
2335
+ .replace(/^./, (char) => char.toUpperCase());
2336
+ return \`| \${categoryLabel} | \${count} |\`;
2337
+ }),
2338
+ ].join("\\n");
2339
+ }
2340
+
2341
+ function categorySummary(blockers: QualityBlocker[]): string {
2342
+ const counts = categoryCounts(blockers);
2343
+ if (counts.length === 0) {
2344
+ return "None";
2345
+ }
2346
+ return counts
2347
+ .map(([category, count]) => {
2348
+ const categoryLabel = category
2349
+ .replaceAll("-", " ")
2350
+ .replace(/^./, (char) => char.toUpperCase());
2351
+ return \`\${categoryLabel} (\${count})\`;
2352
+ })
2353
+ .join(", ");
2354
+ }
2355
+
2356
+ function categoryCounts(blockers: QualityBlocker[]): Array<[string, number]> {
2357
+ const counts = new Map<string, number>();
2358
+ for (const blocker of blockers) {
2359
+ counts.set(blocker.category, (counts.get(blocker.category) ?? 0) + 1);
2360
+ }
2361
+ return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
2362
+ }
2363
+
2364
+ `
2365
+ };
2366
+ //#endregion
2367
+ //#region src/config/recipes/rich-review.ts
2368
+ const structuredReviewRecipe = {
2369
+ id: "rich-review",
2370
+ title: "Structured Review",
2371
+ description: "General pull request review with severity and category metadata.",
2372
+ sourceTools: [
2373
+ "CodeRabbit",
2374
+ "Qodo Merge",
2375
+ "Greptile"
2376
+ ],
2377
+ configTs: `import { definePipr, z } from "@usepipr/sdk";
2378
+ import type { ReviewFinding } from "@usepipr/sdk";
2379
+
2380
+ type CategorizedFinding = {
2381
+ title: string;
2382
+ severity: "critical" | "high" | "medium" | "low" | "nit";
2383
+ category:
2384
+ | "correctness"
2385
+ | "security"
2386
+ | "reliability"
2387
+ | "performance"
2388
+ | "test-coverage"
2389
+ | "maintainability"
2390
+ | "documentation";
2391
+ rationale: string;
2392
+ body: string;
2393
+ path: string;
2394
+ rangeId: string;
2395
+ side: "RIGHT" | "LEFT";
2396
+ startLine: number;
2397
+ endLine: number;
2398
+ suggestedFix?: string;
2399
+ };
2400
+
2401
+ type ReviewSummary = {
2402
+ headline: string;
2403
+ changeSummary: string[];
2404
+ riskLevel: "low" | "medium" | "high";
2405
+ riskSummary: string;
2406
+ reviewerFocus: string[];
2407
+ };
2408
+
2409
+ const categorizedFindingSchema = z.strictObject({
2410
+ title: z.string(),
2411
+ severity: z.enum(["critical", "high", "medium", "low", "nit"]),
2412
+ category: z.enum([
2413
+ "correctness",
2414
+ "security",
2415
+ "reliability",
2416
+ "performance",
2417
+ "test-coverage",
2418
+ "maintainability",
2419
+ "documentation",
2420
+ ]),
2421
+ rationale: z.string(),
2422
+ body: z.string(),
2423
+ path: z.string(),
2424
+ rangeId: z.string(),
2425
+ side: z.enum(["RIGHT", "LEFT"]),
2426
+ startLine: z.number().int().positive(),
2427
+ endLine: z.number().int().positive(),
2428
+ suggestedFix: z.string().optional(),
2429
+ });
2430
+
2431
+ const reviewSummarySchema = z.strictObject({
2432
+ headline: z.string(),
2433
+ changeSummary: z.array(z.string()).min(1).max(4),
2434
+ riskLevel: z.enum(["low", "medium", "high"]),
2435
+ riskSummary: z.string(),
2436
+ reviewerFocus: z.array(z.string()).max(4),
2437
+ });
2438
+
2439
+ export default definePipr((pipr) => {
2440
+ const model = pipr.model({
2441
+ provider: "deepseek",
2442
+ model: "deepseek-v4-pro",
2443
+ apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
2444
+ options: { thinking: "high" },
2445
+ });
2446
+
2447
+ pipr.config({ publication: { maxInlineComments: 8 } });
2448
+
2449
+ const reviewOutput = pipr.schema({
2450
+ id: "review/categorized-findings",
2451
+ schema: z.strictObject({
2452
+ summary: reviewSummarySchema,
2453
+ findings: z.array(categorizedFindingSchema),
1631
2454
  }),
1632
2455
  });
2456
+
2457
+ const reviewer = pipr.agent({
2458
+ name: "reviewer",
2459
+ model,
2460
+ instructions: \`
2461
+ Review the pull request diff for correctness, security, reliability,
2462
+ performance, test coverage, maintainability, and documentation risks.
2463
+ Return only actionable findings that target valid diff ranges. Assign
2464
+ severity by merge impact: critical and high for merge-blocking defects,
2465
+ medium for important follow-up, low for minor actionable improvements,
2466
+ and nit only for tiny but concrete issues. Include suggestedFix only when
2467
+ there is an exact replacement for the selected range.
2468
+
2469
+ Make summary maintainer-facing and scannable: one concrete headline, one
2470
+ to four behavior-focused change bullets, a risk level with rationale, and
2471
+ reviewer focus only for useful human follow-up. Put actionable defects in
2472
+ findings, not only in summary.
2473
+ \`,
2474
+ output: reviewOutput,
2475
+ tools: pipr.tools.readOnly,
2476
+ retry: { invalidOutput: 1, transientFailure: 1 },
2477
+ timeout: "10m",
2478
+ prompt: () => "Review this change with severity and category metadata.",
2479
+ });
2480
+
2481
+ const task = pipr.task({
2482
+ name: "review",
2483
+ async run(ctx) {
2484
+ const manifest = await ctx.change.diffManifest({ compressed: true });
2485
+ const result = await ctx.pi.run(reviewer, { manifest });
2486
+ const inlineFindings: ReviewFinding[] = result.findings.map((finding) => {
2487
+ const severity = finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1);
2488
+ const category = finding.category.replaceAll("-", " ");
2489
+ return {
2490
+ body: \`**\${severity} \${category}:** \${finding.title}. \${finding.body}\`,
2491
+ path: finding.path,
2492
+ rangeId: finding.rangeId,
2493
+ side: finding.side,
2494
+ startLine: finding.startLine,
2495
+ endLine: finding.endLine,
2496
+ ...(finding.suggestedFix ? { suggestedFix: finding.suggestedFix } : {}),
2497
+ };
2498
+ });
2499
+ await ctx.comment({
2500
+ main: [
2501
+ "## Summary",
2502
+ "",
2503
+ \`**\${result.summary.headline}**\`,
2504
+ "",
2505
+ summaryTable(result.summary, result.findings.length),
2506
+ "",
2507
+ "## What Changed",
2508
+ "",
2509
+ bulletList(result.summary.changeSummary, "No changed behavior summarized."),
2510
+ "",
2511
+ "## Reviewer Focus",
2512
+ "",
2513
+ bulletList(result.summary.reviewerFocus, "No special reviewer focus."),
2514
+ "",
2515
+ "## Findings",
2516
+ "",
2517
+ findingsTable(result.findings),
2518
+ ...(result.findings.length > 0 ? ["", findingRationalesBlock(result.findings)] : []),
2519
+ ].join("\\n"),
2520
+ inlineFindings,
2521
+ });
2522
+ },
2523
+ });
2524
+
2525
+ pipr.on.changeRequest({ actions: ["opened", "updated", "reopened", "ready"], task });
2526
+ pipr.command({ pattern: "@pipr review", permission: "write", task });
1633
2527
  });
2528
+
2529
+ function summaryTable(summary: ReviewSummary, findingCount: number): string {
2530
+ return [
2531
+ "| Outcome | Risk | Risk summary |",
2532
+ "| --- | --- | --- |",
2533
+ \`| \${findingOutcome(findingCount)} | \${labelValue(summary.riskLevel)} | \${tableCell(
2534
+ summary.riskSummary,
2535
+ )} |\`,
2536
+ ].join("\\n");
2537
+ }
2538
+
2539
+ function findingOutcome(findingCount: number): string {
2540
+ if (findingCount === 0) {
2541
+ return "No findings";
2542
+ }
2543
+ return findingCount === 1 ? "1 finding" : \`\${findingCount} findings\`;
2544
+ }
2545
+
2546
+ function findingsTable(findings: CategorizedFinding[]): string {
2547
+ if (findings.length === 0) {
2548
+ return [
2549
+ "| Severity | Category | Title |",
2550
+ "| --- | --- | --- |",
2551
+ "| - | - | No findings. |",
2552
+ ].join("\\n");
2553
+ }
2554
+ return [
2555
+ "| Severity | Category | Title |",
2556
+ "| --- | --- | --- |",
2557
+ ...findings.map((finding) => {
2558
+ const severity = finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1);
2559
+ const category = finding.category.replaceAll("-", " ").replaceAll("|", "\\\\|");
2560
+ const title = finding.title.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
2561
+ return \`| \${severity} | \${category} | \${title} |\`;
2562
+ }),
2563
+ ].join("\\n");
2564
+ }
2565
+
2566
+ function findingRationalesBlock(findings: CategorizedFinding[]): string {
2567
+ if (findings.length === 0) {
2568
+ return "";
2569
+ }
2570
+ return [
2571
+ "<details>",
2572
+ "<summary>Finding rationales</summary>",
2573
+ "",
2574
+ findings
2575
+ .map((finding, index) =>
2576
+ [
2577
+ \`### \${index + 1}. \${finding.title}\`,
2578
+ "",
2579
+ \`**Severity:** \${labelValue(finding.severity)}\`,
2580
+ \`**Category:** \${labelValue(finding.category)}\`,
2581
+ "",
2582
+ finding.rationale,
2583
+ ].join("\\n"),
2584
+ )
2585
+ .join("\\n\\n"),
2586
+ "",
2587
+ "</details>",
2588
+ ].join("\\n");
2589
+ }
2590
+
2591
+ function bulletList(items: string[], emptyText: string): string {
2592
+ if (items.length === 0) {
2593
+ return emptyText;
2594
+ }
2595
+ return items.map((item) => \`- \${lineText(item)}\`).join("\\n");
2596
+ }
2597
+
2598
+ function labelValue(value: string): string {
2599
+ return value.replaceAll("-", " ").replace(/^./, (char) => char.toUpperCase());
2600
+ }
2601
+
2602
+ function lineText(value: string): string {
2603
+ return value.replaceAll("\\n", " ").trim();
2604
+ }
2605
+
2606
+ function tableCell(value: string): string {
2607
+ return lineText(value).replaceAll("|", "\\\\|");
2608
+ }
1634
2609
  `
1635
2610
  };
1636
2611
  //#endregion
@@ -1647,15 +2622,25 @@ const securitySastRecipe = {
1647
2622
  configTs: `import { definePipr } from "@usepipr/sdk";
1648
2623
  import type { ReviewFinding } from "@usepipr/sdk";
1649
2624
 
2625
+ type SecuritySummary = {
2626
+ headline: string;
2627
+ riskLevel: "low" | "medium" | "high";
2628
+ riskSummary: string;
2629
+ reviewerFocus: string[];
2630
+ };
2631
+
2632
+ type SecurityRisk = {
2633
+ title: string;
2634
+ category: "auth" | "injection" | "secret" | "crypto" | "data-exposure" | "other";
2635
+ severity: "low" | "medium" | "high" | "critical";
2636
+ rationale: string;
2637
+ finding?: ReviewFinding;
2638
+ };
2639
+
1650
2640
  type SecurityReview = {
1651
- summary: string;
1652
- risks: Array<{
1653
- title: string;
1654
- category: "auth" | "injection" | "secret" | "crypto" | "data-exposure" | "other";
1655
- severity: "low" | "medium" | "high" | "critical";
1656
- rationale: string;
1657
- finding?: ReviewFinding;
1658
- }>;
2641
+ summary: SecuritySummary;
2642
+ risks: SecurityRisk[];
2643
+ diagramMermaid?: string;
1659
2644
  };
1660
2645
 
1661
2646
  export default definePipr((pipr) => {
@@ -1673,7 +2658,20 @@ export default definePipr((pipr) => {
1673
2658
  additionalProperties: false,
1674
2659
  required: ["summary", "risks"],
1675
2660
  properties: {
1676
- summary: { type: "string" },
2661
+ summary: {
2662
+ type: "object",
2663
+ additionalProperties: false,
2664
+ required: ["headline", "riskLevel", "riskSummary", "reviewerFocus"],
2665
+ properties: {
2666
+ headline: { type: "string" },
2667
+ riskLevel: { type: "string", enum: ["low", "medium", "high"] },
2668
+ riskSummary: { type: "string" },
2669
+ reviewerFocus: {
2670
+ type: "array",
2671
+ items: { type: "string" },
2672
+ },
2673
+ },
2674
+ },
1677
2675
  risks: {
1678
2676
  type: "array",
1679
2677
  items: {
@@ -1705,6 +2703,7 @@ export default definePipr((pipr) => {
1705
2703
  },
1706
2704
  },
1707
2705
  },
2706
+ diagramMermaid: { type: "string" },
1708
2707
  },
1709
2708
  },
1710
2709
  });
@@ -1716,6 +2715,11 @@ export default definePipr((pipr) => {
1716
2715
  Review for exploitable security issues only. Focus on auth bypasses,
1717
2716
  injection, unsafe deserialization, secret exposure, cryptography misuse,
1718
2717
  authorization gaps, and data exposure. Do not report hypothetical style issues.
2718
+ Make summary maintainer-facing and scannable with a concrete headline,
2719
+ risk level, risk rationale, and only useful security follow-up. Set
2720
+ diagramMermaid only when a high or critical risk has a concrete source-to-sink
2721
+ path and a small Mermaid flowchart clarifies it. Do not include Markdown
2722
+ fences in diagramMermaid.
1719
2723
  \`,
1720
2724
  output: securityOutput,
1721
2725
  tools: pipr.tools.readOnly,
@@ -1731,14 +2735,40 @@ export default definePipr((pipr) => {
1731
2735
  async run(ctx) {
1732
2736
  const manifest = await ctx.change.diffManifest({ compressed: true });
1733
2737
  const result = await ctx.pi.run(security, { manifest });
1734
- const inlineFindings = result.risks.flatMap((risk) => (risk.finding ? [risk.finding] : []));
1735
- if (result.risks.some((risk) => risk.severity === "critical" || risk.severity === "high")) {
2738
+ const inlineFindings: ReviewFinding[] = result.risks.flatMap((risk) =>
2739
+ risk.finding ? [risk.finding] : [],
2740
+ );
2741
+ const hasHighOrCriticalRisk = result.risks.some(isHighOrCriticalRisk);
2742
+ const hasConcreteHighOrCriticalRisk = result.risks.some(
2743
+ (risk) => isHighOrCriticalRisk(risk) && risk.finding,
2744
+ );
2745
+ if (hasHighOrCriticalRisk) {
1736
2746
  ctx.check.fail("High or critical security risk found.");
1737
2747
  } else {
1738
2748
  ctx.check.pass("No high or critical security risks found.");
1739
2749
  }
1740
2750
  await ctx.comment({
1741
- main: \`## Security SAST\\n\\n\${result.summary}\`,
2751
+ main: [
2752
+ "## Summary",
2753
+ "",
2754
+ \`**\${result.summary.headline}**\`,
2755
+ "",
2756
+ securityStatusTable(result.summary, result.risks),
2757
+ "",
2758
+ result.summary.riskSummary,
2759
+ "",
2760
+ "## Reviewer Focus",
2761
+ "",
2762
+ bulletList(result.summary.reviewerFocus, "No special security follow-up."),
2763
+ "",
2764
+ "## Security Risks",
2765
+ "",
2766
+ securityRisksTable(result.risks),
2767
+ ...(result.risks.length > 0 ? ["", riskRationalesBlock(result.risks)] : []),
2768
+ ...(hasConcreteHighOrCriticalRisk && result.diagramMermaid?.trim()
2769
+ ? ["", attackPathDiagramBlock(result.diagramMermaid, hasConcreteHighOrCriticalRisk)]
2770
+ : []),
2771
+ ].join("\\n"),
1742
2772
  inlineFindings,
1743
2773
  });
1744
2774
  },
@@ -1747,6 +2777,122 @@ export default definePipr((pipr) => {
1747
2777
  pipr.on.changeRequest({ actions: ["opened", "updated", "reopened", "ready"], task });
1748
2778
  pipr.command({ pattern: "@pipr security", permission: "write", task });
1749
2779
  });
2780
+
2781
+ function securityStatusTable(summary: SecuritySummary, risks: SecurityRisk[]): string {
2782
+ return [
2783
+ "| Status | Summary risk | Max severity | Risks |",
2784
+ "| --- | --- | --- | ---: |",
2785
+ \`| \${risks.some(isHighOrCriticalRisk) ? "Fail" : "Pass"} | \${labelValue(
2786
+ summary.riskLevel,
2787
+ )} | \${maxSeverity(risks)} | \${risks.length} |\`,
2788
+ ].join("\\n");
2789
+ }
2790
+
2791
+ function securityRisksTable(risks: SecurityRisk[]): string {
2792
+ if (risks.length === 0) {
2793
+ return [
2794
+ "| Severity | Category | Title |",
2795
+ "| --- | --- | --- |",
2796
+ "| - | - | No security risks found. |",
2797
+ ].join("\\n");
2798
+ }
2799
+ return [
2800
+ "| Severity | Category | Title |",
2801
+ "| --- | --- | --- |",
2802
+ ...risks.map(
2803
+ (risk) =>
2804
+ \`| \${labelValue(risk.severity)} | \${tableCell(risk.category)} | \${tableCell(risk.title)} |\`,
2805
+ ),
2806
+ ].join("\\n");
2807
+ }
2808
+
2809
+ function riskRationalesBlock(risks: SecurityRisk[]): string {
2810
+ if (risks.length === 0) {
2811
+ return "";
2812
+ }
2813
+ return [
2814
+ "<details>",
2815
+ "<summary>Risk rationales</summary>",
2816
+ "",
2817
+ risks
2818
+ .map((risk, index) =>
2819
+ [
2820
+ \`### \${index + 1}. \${risk.title}\`,
2821
+ "",
2822
+ \`**Severity:** \${labelValue(risk.severity)}\`,
2823
+ \`**Category:** \${labelValue(risk.category)}\`,
2824
+ "",
2825
+ risk.rationale,
2826
+ ].join("\\n"),
2827
+ )
2828
+ .join("\\n\\n"),
2829
+ "",
2830
+ "</details>",
2831
+ ].join("\\n");
2832
+ }
2833
+
2834
+ function attackPathDiagramBlock(
2835
+ diagramMermaid: string | undefined,
2836
+ hasConcreteHighOrCriticalRisk: boolean,
2837
+ ): string {
2838
+ const diagram = diagramMermaid?.trim();
2839
+ if (!diagram || !hasConcreteHighOrCriticalRisk) {
2840
+ return "";
2841
+ }
2842
+ const fence = markdownFenceFor(diagram);
2843
+ return [
2844
+ "<details>",
2845
+ "<summary>Attack path diagram</summary>",
2846
+ "",
2847
+ \`\${fence}mermaid\`,
2848
+ diagram,
2849
+ fence,
2850
+ "",
2851
+ "</details>",
2852
+ ].join("\\n");
2853
+ }
2854
+
2855
+ function maxSeverity(risks: SecurityRisk[]): string {
2856
+ const order: SecurityRisk["severity"][] = ["low", "medium", "high", "critical"];
2857
+ const severity = risks.reduce<SecurityRisk["severity"] | undefined>((current, risk) => {
2858
+ if (!current || order.indexOf(risk.severity) > order.indexOf(current)) {
2859
+ return risk.severity;
2860
+ }
2861
+ return current;
2862
+ }, undefined);
2863
+ return severity ? labelValue(severity) : "None";
2864
+ }
2865
+
2866
+ function isHighOrCriticalRisk(risk: SecurityRisk): boolean {
2867
+ return risk.severity === "high" || risk.severity === "critical";
2868
+ }
2869
+
2870
+ function bulletList(items: string[], emptyText: string): string {
2871
+ if (items.length === 0) {
2872
+ return emptyText;
2873
+ }
2874
+ return items.map((item) => \`- \${lineText(item)}\`).join("\\n");
2875
+ }
2876
+
2877
+ function labelValue(value: string): string {
2878
+ return value.replaceAll("-", " ").replace(/^./, (char) => char.toUpperCase());
2879
+ }
2880
+
2881
+ function lineText(value: string): string {
2882
+ return value.replaceAll("\\n", " ").trim();
2883
+ }
2884
+
2885
+ function tableCell(value: string): string {
2886
+ return lineText(value).replaceAll("|", "\\\\|");
2887
+ }
2888
+
2889
+ function markdownFenceFor(value: string): string {
2890
+ const longestBacktickRun = Math.max(
2891
+ 0,
2892
+ ...[...value.matchAll(/\`+/g)].map((match) => match[0].length),
2893
+ );
2894
+ return "\`".repeat(Math.max(3, longestBacktickRun + 1));
2895
+ }
1750
2896
  `
1751
2897
  };
1752
2898
  //#endregion
@@ -1754,6 +2900,8 @@ export default definePipr((pipr) => {
1754
2900
  const supportedOfficialInitRecipes = [
1755
2901
  "default-review",
1756
2902
  "bug-hunter",
2903
+ "rich-review",
2904
+ "fix-suggestions",
1757
2905
  "security-sast",
1758
2906
  "quality-gate",
1759
2907
  "diff-diagnostics",
@@ -1769,6 +2917,8 @@ const supportedOfficialInitRecipes = [
1769
2917
  const officialInitRecipeRegistry = {
1770
2918
  "default-review": defaultReviewRecipe,
1771
2919
  "bug-hunter": bugHunterRecipe,
2920
+ "rich-review": structuredReviewRecipe,
2921
+ "fix-suggestions": fixSuggestionsRecipe,
1772
2922
  "security-sast": securitySastRecipe,
1773
2923
  "quality-gate": qualityGateRecipe,
1774
2924
  "diff-diagnostics": diffDiagnosticsRecipe,
@@ -1804,8 +2954,8 @@ function isOfficialInitRecipeId(recipe) {
1804
2954
  //#endregion
1805
2955
  //#region src/config/init.ts
1806
2956
  const supportedOfficialInitAdapters = ["github"];
1807
- const defaultWorkflowActionRef = "somus/pipr@v0.3.1";
1808
- const defaultSdkVersion = "0.3.1";
2957
+ const defaultWorkflowActionRef = "somus/pipr@v0.3.3";
2958
+ const defaultSdkVersion = "0.3.3";
1809
2959
  const defaultTypesBunVersion = "1.3.14";
1810
2960
  function resolveOfficialInitAdapters(adapters) {
1811
2961
  if (adapters === void 0) return [...supportedOfficialInitAdapters];
@@ -3890,7 +5040,19 @@ function buildRepairPrompt(options) {
3890
5040
  }
3891
5041
  //#endregion
3892
5042
  //#region package.json
3893
- var version = "0.3.1";
5043
+ var version = "0.3.3";
5044
+ //#endregion
5045
+ //#region src/review/comment-branding.ts
5046
+ const piprLogoUrl = "https://pipr.run/images/pipr/pipr-mark.svg";
5047
+ const piprRepositoryUrl = "https://github.com/somus/pipr";
5048
+ const mainCommentTitle = `# <img src="${piprLogoUrl}" width="22" height="22" alt=""> Pipr Review`;
5049
+ const mainCommentTitles = new Set([
5050
+ "# pipr Review",
5051
+ "# Pipr Review",
5052
+ mainCommentTitle
5053
+ ]);
5054
+ const escapedRepositoryUrl = piprRepositoryUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5055
+ const mainCommentAttributionPattern = new RegExp(`^<sub>Review generated by \\[Pipr\\]\\(${escapedRepositoryUrl}\\) for commit \`[^\`]+\`\\.</sub>$`);
3894
5056
  //#endregion
3895
5057
  //#region src/review/inline-publication-policy.ts
3896
5058
  const maxSuggestedFixSelectedLines = 12;
@@ -4280,7 +5442,8 @@ function buildPublicationPlan(options) {
4280
5442
  mainComment: renderMainComment({
4281
5443
  event: options.event,
4282
5444
  reviewState,
4283
- main: options.main
5445
+ main: options.main,
5446
+ reviewedHeadSha: metadata.reviewedHeadSha
4284
5447
  }),
4285
5448
  mainMarker: mainCommentMarker,
4286
5449
  changeNumber: options.event.change.number,
@@ -4364,26 +5527,39 @@ function renderMainComment(options) {
4364
5527
  reviewState: options.reviewState
4365
5528
  }),
4366
5529
  "",
4367
- "# Pipr Review",
5530
+ mainCommentTitle,
4368
5531
  "",
4369
5532
  redactPotentialSecrets(options.main),
5533
+ "",
5534
+ `<sub>Review generated by [Pipr](${piprRepositoryUrl}) for commit \`${options.reviewedHeadSha.slice(0, 7)}\`.</sub>`,
4370
5535
  ""
4371
5536
  ].join("\n");
4372
5537
  }
4373
5538
  function renderInlineBody(finding, findingId, reviewedHeadSha) {
5539
+ const findingBody = startsWithStructuredMarkdown(finding.body) ? finding.body : [
5540
+ "**Issue**",
5541
+ "",
5542
+ finding.body
5543
+ ].join("\n");
4374
5544
  return [
4375
5545
  renderInlineFindingMarker(findingId, reviewedHeadSha),
4376
- finding.body,
4377
- finding.suggestedFix ? `\n${renderSuggestedChange(finding.suggestedFix)}` : ""
5546
+ findingBody,
5547
+ finding.suggestedFix ? [
5548
+ "**Suggested change**",
5549
+ "",
5550
+ renderSuggestedChange(finding.suggestedFix)
5551
+ ].join("\n") : ""
4378
5552
  ].filter(Boolean).join("\n");
4379
5553
  }
5554
+ function startsWithStructuredMarkdown(value) {
5555
+ const body = value.trimStart();
5556
+ return /^#{1,6}\s/.test(body) || /^>/.test(body) || /^(\d+[.)]|[-*+])\s/.test(body) || /^\|/.test(body) || /^(```|~~~)/.test(body) || /^<\s*[a-z][\w:-]*(\s|>|\/>)/i.test(body) || /^\*\*[^*\n]+\*\*/.test(body);
5557
+ }
4380
5558
  function renderSuggestedChange(suggestedFix) {
4381
- const fence = "`".repeat(Math.max(3, longestBacktickRun(suggestedFix) + 1));
5559
+ const longestBacktickRun = Math.max(0, ...[...suggestedFix.matchAll(/`+/g)].map((match) => match[0].length));
5560
+ const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
4382
5561
  return `${fence}suggestion\n${suggestedFix}${suggestedFix.endsWith("\n") ? "" : "\n"}${fence}`;
4383
5562
  }
4384
- function longestBacktickRun(value) {
4385
- return Math.max(0, ...[...value.matchAll(/`+/g)].map((match) => match[0].length));
4386
- }
4387
5563
  //#endregion
4388
5564
  //#region src/review/comment-publishing.ts
4389
5565
  function buildCommentPublishingPlan(options) {
@@ -4771,9 +5947,9 @@ function priorReviewForTask(priorMainComment, priorReviewState) {
4771
5947
  };
4772
5948
  }
4773
5949
  function visibleMainComment(body) {
4774
- const lines = body.split("\n").filter((line) => !line.startsWith("<!-- pipr:main-comment "));
5950
+ const lines = body.split("\n").filter((line) => !line.startsWith("<!-- pipr:main-comment ") && !mainCommentAttributionPattern.test(line));
4775
5951
  while (lines[0] === "") lines.shift();
4776
- if (lines[0] === "# pipr Review" || lines[0] === "# Pipr Review") lines.shift();
5952
+ if (lines[0] && mainCommentTitles.has(lines[0])) lines.shift();
4777
5953
  while (lines[0] === "") lines.shift();
4778
5954
  return lines.join("\n").trim();
4779
5955
  }
@@ -7376,4 +8552,4 @@ async function runActionCommandWithDependencies(options) {
7376
8552
  //#endregion
7377
8553
  export { runInspectCommand as a, PublicationError as c, listOfficialInitRecipes as d, supportedOfficialInitRecipes as f, piThinkingLevels as g, piRequiredCliFlags as h, runInitCommand as i, isPublishableSuggestedFixSelection as l, piReadOnlyToolNames as m, runActionCommandWithDependencies as n, runLocalReviewCommand as o, piBuiltinToolNames as p, runDryRunCommand as r, runValidateCommand as s, runActionCommand as t, supportedOfficialInitAdapters as u };
7378
8554
 
7379
- //# sourceMappingURL=commands-BNxl37V6.mjs.map
8555
+ //# sourceMappingURL=commands-C1sVsbEj.mjs.map