@usepipr/runtime 0.3.0 → 0.3.2

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,397 @@ 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} |\`;
1631
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
+ const categorizedFindingSchema = z.strictObject({
2402
+ title: z.string(),
2403
+ severity: z.enum(["critical", "high", "medium", "low", "nit"]),
2404
+ category: z.enum([
2405
+ "correctness",
2406
+ "security",
2407
+ "reliability",
2408
+ "performance",
2409
+ "test-coverage",
2410
+ "maintainability",
2411
+ "documentation",
2412
+ ]),
2413
+ rationale: z.string(),
2414
+ body: z.string(),
2415
+ path: z.string(),
2416
+ rangeId: z.string(),
2417
+ side: z.enum(["RIGHT", "LEFT"]),
2418
+ startLine: z.number().int().positive(),
2419
+ endLine: z.number().int().positive(),
2420
+ suggestedFix: z.string().optional(),
2421
+ });
2422
+
2423
+ export default definePipr((pipr) => {
2424
+ const model = pipr.model({
2425
+ provider: "deepseek",
2426
+ model: "deepseek-v4-pro",
2427
+ apiKey: pipr.secret({ name: "DEEPSEEK_API_KEY" }),
2428
+ options: { thinking: "high" },
1632
2429
  });
2430
+
2431
+ pipr.config({ publication: { maxInlineComments: 8 } });
2432
+
2433
+ const reviewOutput = pipr.schema({
2434
+ id: "review/categorized-findings",
2435
+ schema: z.strictObject({
2436
+ summary: z.string(),
2437
+ findings: z.array(categorizedFindingSchema),
2438
+ }),
2439
+ });
2440
+
2441
+ const reviewer = pipr.agent({
2442
+ name: "reviewer",
2443
+ model,
2444
+ instructions: \`
2445
+ Review the pull request diff for correctness, security, reliability,
2446
+ performance, test coverage, maintainability, and documentation risks.
2447
+ Return only actionable findings that target valid diff ranges. Assign
2448
+ severity by merge impact: critical and high for merge-blocking defects,
2449
+ medium for important follow-up, low for minor actionable improvements,
2450
+ and nit only for tiny but concrete issues. Include suggestedFix only when
2451
+ there is an exact replacement for the selected range.
2452
+ \`,
2453
+ output: reviewOutput,
2454
+ tools: pipr.tools.readOnly,
2455
+ retry: { invalidOutput: 1, transientFailure: 1 },
2456
+ timeout: "10m",
2457
+ prompt: () => "Review this change with severity and category metadata.",
2458
+ });
2459
+
2460
+ const task = pipr.task({
2461
+ name: "review",
2462
+ async run(ctx) {
2463
+ const manifest = await ctx.change.diffManifest({ compressed: true });
2464
+ const result = await ctx.pi.run(reviewer, { manifest });
2465
+ const inlineFindings: ReviewFinding[] = result.findings.map((finding) => {
2466
+ const severity = finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1);
2467
+ const category = finding.category.replaceAll("-", " ");
2468
+ return {
2469
+ body: \`**\${severity} \${category}:** \${finding.title}. \${finding.body}\`,
2470
+ path: finding.path,
2471
+ rangeId: finding.rangeId,
2472
+ side: finding.side,
2473
+ startLine: finding.startLine,
2474
+ endLine: finding.endLine,
2475
+ ...(finding.suggestedFix ? { suggestedFix: finding.suggestedFix } : {}),
2476
+ };
2477
+ });
2478
+ await ctx.comment({
2479
+ main: [
2480
+ result.summary,
2481
+ "",
2482
+ "## Findings",
2483
+ "",
2484
+ findingsTable(result.findings),
2485
+ "",
2486
+ "<details>",
2487
+ "<summary>Finding rationales</summary>",
2488
+ "",
2489
+ findingRationales(result.findings),
2490
+ "",
2491
+ "</details>",
2492
+ ].join("\\n"),
2493
+ inlineFindings,
2494
+ });
2495
+ },
2496
+ });
2497
+
2498
+ pipr.on.changeRequest({ actions: ["opened", "updated", "reopened", "ready"], task });
2499
+ pipr.command({ pattern: "@pipr review", permission: "write", task });
1633
2500
  });
2501
+
2502
+ function findingsTable(findings: CategorizedFinding[]): string {
2503
+ if (findings.length === 0) {
2504
+ return [
2505
+ "| Severity | Category | Title |",
2506
+ "| --- | --- | --- |",
2507
+ "| - | - | No findings. |",
2508
+ ].join("\\n");
2509
+ }
2510
+ return [
2511
+ "| Severity | Category | Title |",
2512
+ "| --- | --- | --- |",
2513
+ ...findings.map((finding) => {
2514
+ const severity = finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1);
2515
+ const category = finding.category.replaceAll("-", " ").replaceAll("|", "\\\\|");
2516
+ const title = finding.title.replaceAll("\\n", " ").replaceAll("|", "\\\\|");
2517
+ return \`| \${severity} | \${category} | \${title} |\`;
2518
+ }),
2519
+ ].join("\\n");
2520
+ }
2521
+
2522
+ function findingRationales(findings: CategorizedFinding[]): string {
2523
+ if (findings.length === 0) {
2524
+ return "No findings.";
2525
+ }
2526
+ return findings
2527
+ .map((finding, index) =>
2528
+ [
2529
+ \`### \${index + 1}. \${finding.title}\`,
2530
+ "",
2531
+ \`**Severity:** \${finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1)}\`,
2532
+ \`**Category:** \${finding.category.replaceAll("-", " ")}\`,
2533
+ "",
2534
+ finding.rationale,
2535
+ ].join("\\n"),
2536
+ )
2537
+ .join("\\n\\n");
2538
+ }
1634
2539
  `
1635
2540
  };
1636
2541
  //#endregion
@@ -1738,7 +2643,7 @@ export default definePipr((pipr) => {
1738
2643
  ctx.check.pass("No high or critical security risks found.");
1739
2644
  }
1740
2645
  await ctx.comment({
1741
- main: \`## Security SAST\\n\\n\${result.summary}\`,
2646
+ main: result.summary,
1742
2647
  inlineFindings,
1743
2648
  });
1744
2649
  },
@@ -1754,6 +2659,8 @@ export default definePipr((pipr) => {
1754
2659
  const supportedOfficialInitRecipes = [
1755
2660
  "default-review",
1756
2661
  "bug-hunter",
2662
+ "rich-review",
2663
+ "fix-suggestions",
1757
2664
  "security-sast",
1758
2665
  "quality-gate",
1759
2666
  "diff-diagnostics",
@@ -1769,6 +2676,8 @@ const supportedOfficialInitRecipes = [
1769
2676
  const officialInitRecipeRegistry = {
1770
2677
  "default-review": defaultReviewRecipe,
1771
2678
  "bug-hunter": bugHunterRecipe,
2679
+ "rich-review": structuredReviewRecipe,
2680
+ "fix-suggestions": fixSuggestionsRecipe,
1772
2681
  "security-sast": securitySastRecipe,
1773
2682
  "quality-gate": qualityGateRecipe,
1774
2683
  "diff-diagnostics": diffDiagnosticsRecipe,
@@ -1804,8 +2713,8 @@ function isOfficialInitRecipeId(recipe) {
1804
2713
  //#endregion
1805
2714
  //#region src/config/init.ts
1806
2715
  const supportedOfficialInitAdapters = ["github"];
1807
- const defaultWorkflowActionRef = "somus/pipr@v0.3.0";
1808
- const defaultSdkVersion = "0.3.0";
2716
+ const defaultWorkflowActionRef = "somus/pipr@v0.3.2";
2717
+ const defaultSdkVersion = "0.3.2";
1809
2718
  const defaultTypesBunVersion = "1.3.14";
1810
2719
  function resolveOfficialInitAdapters(adapters) {
1811
2720
  if (adapters === void 0) return [...supportedOfficialInitAdapters];
@@ -3242,6 +4151,12 @@ function killProcessGroup(child, signal) {
3242
4151
  }
3243
4152
  }
3244
4153
  //#endregion
4154
+ //#region src/shared/redaction.ts
4155
+ const secretLikeTokenPattern = /\b[A-Za-z0-9][A-Za-z0-9_.:/+=-]*(?:secret|token|api[_-]?key|apikey)[A-Za-z0-9_.:/+=-]{8,}\b/gi;
4156
+ function redactPotentialSecrets(value) {
4157
+ return value.replace(secretLikeTokenPattern, "[redacted secret]");
4158
+ }
4159
+ //#endregion
3245
4160
  //#region src/shared/logging.ts
3246
4161
  const sensitiveEnvNamePattern = /(TOKEN|SECRET|PASSWORD|PASS|KEY|AUTH|CREDENTIAL|COOKIE)/i;
3247
4162
  function createRuntimeActionLog(options) {
@@ -3305,7 +4220,7 @@ function addSecret(secrets, value) {
3305
4220
  function redact(message, secrets) {
3306
4221
  let redacted = message;
3307
4222
  for (const secret of secrets) redacted = redacted.split(secret).join("***");
3308
- return redacted;
4223
+ return redactPotentialSecrets(redacted);
3309
4224
  }
3310
4225
  function compactFields(fields, secrets) {
3311
4226
  const compact = {};
@@ -3863,8 +4778,10 @@ function parseAgentOutput(output, agent) {
3863
4778
  }
3864
4779
  function jsonPayloadCandidates(output) {
3865
4780
  const trimmed = output.trim();
3866
- const match = /^```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/i.exec(output.trim());
4781
+ const match = /^```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/i.exec(trimmed);
3867
4782
  if (match?.[1]) return [match[1].trim()];
4783
+ const embeddedMatches = [...trimmed.matchAll(/```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```/gi)];
4784
+ if (embeddedMatches.length === 1 && embeddedMatches[0]?.[1]) return [trimmed, embeddedMatches[0][1].trim()];
3868
4785
  return [trimmed];
3869
4786
  }
3870
4787
  function buildRepairPrompt(options) {
@@ -3882,7 +4799,19 @@ function buildRepairPrompt(options) {
3882
4799
  }
3883
4800
  //#endregion
3884
4801
  //#region package.json
3885
- var version = "0.3.0";
4802
+ var version = "0.3.2";
4803
+ //#endregion
4804
+ //#region src/review/comment-branding.ts
4805
+ const piprLogoUrl = "https://pipr.run/apple-touch-icon.png";
4806
+ const piprRepositoryUrl = "https://github.com/somus/pipr";
4807
+ const mainCommentTitle = `# <img src="${piprLogoUrl}" width="22" height="22" alt=""> Pipr Review`;
4808
+ const mainCommentTitles = new Set([
4809
+ "# pipr Review",
4810
+ "# Pipr Review",
4811
+ mainCommentTitle
4812
+ ]);
4813
+ const escapedRepositoryUrl = piprRepositoryUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4814
+ const mainCommentAttributionPattern = new RegExp(`^<sub>Review generated by \\[Pipr\\]\\(${escapedRepositoryUrl}\\) for commit \`[^\`]+\`\\.</sub>$`);
3886
4815
  //#endregion
3887
4816
  //#region src/review/inline-publication-policy.ts
3888
4817
  const maxSuggestedFixSelectedLines = 12;
@@ -4257,7 +5186,6 @@ const publicationPlanSchema = z.strictObject({
4257
5186
  });
4258
5187
  const maxInlineFindingBodyCharacters = 700;
4259
5188
  const maxInlineFindingBodyLines = 4;
4260
- const secretLikeTokenPattern = /\b[A-Za-z0-9][A-Za-z0-9_.:/+=-]*(?:secret|token|api[_-]?key|apikey)[A-Za-z0-9_.:/+=-]{8,}\b/gi;
4261
5189
  function buildPublicationPlan(options) {
4262
5190
  const reviewState = options.reviewState ?? buildPriorReviewState({
4263
5191
  findings: options.inlineItems.map((item) => item.finding),
@@ -4273,7 +5201,8 @@ function buildPublicationPlan(options) {
4273
5201
  mainComment: renderMainComment({
4274
5202
  event: options.event,
4275
5203
  reviewState,
4276
- main: options.main
5204
+ main: options.main,
5205
+ reviewedHeadSha: metadata.reviewedHeadSha
4277
5206
  }),
4278
5207
  mainMarker: mainCommentMarker,
4279
5208
  changeNumber: options.event.change.number,
@@ -4357,29 +5286,39 @@ function renderMainComment(options) {
4357
5286
  reviewState: options.reviewState
4358
5287
  }),
4359
5288
  "",
4360
- "# Pipr Review",
5289
+ mainCommentTitle,
4361
5290
  "",
4362
5291
  redactPotentialSecrets(options.main),
5292
+ "",
5293
+ `<sub>Review generated by [Pipr](${piprRepositoryUrl}) for commit \`${options.reviewedHeadSha.slice(0, 7)}\`.</sub>`,
4363
5294
  ""
4364
5295
  ].join("\n");
4365
5296
  }
4366
5297
  function renderInlineBody(finding, findingId, reviewedHeadSha) {
5298
+ const findingBody = startsWithStructuredMarkdown(finding.body) ? finding.body : [
5299
+ "**Issue**",
5300
+ "",
5301
+ finding.body
5302
+ ].join("\n");
4367
5303
  return [
4368
5304
  renderInlineFindingMarker(findingId, reviewedHeadSha),
4369
- finding.body,
4370
- finding.suggestedFix ? `\n${renderSuggestedChange(finding.suggestedFix)}` : ""
5305
+ findingBody,
5306
+ finding.suggestedFix ? [
5307
+ "**Suggested change**",
5308
+ "",
5309
+ renderSuggestedChange(finding.suggestedFix)
5310
+ ].join("\n") : ""
4371
5311
  ].filter(Boolean).join("\n");
4372
5312
  }
5313
+ function startsWithStructuredMarkdown(value) {
5314
+ const body = value.trimStart();
5315
+ 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);
5316
+ }
4373
5317
  function renderSuggestedChange(suggestedFix) {
4374
- const fence = "`".repeat(Math.max(3, longestBacktickRun(suggestedFix) + 1));
5318
+ const longestBacktickRun = Math.max(0, ...[...suggestedFix.matchAll(/`+/g)].map((match) => match[0].length));
5319
+ const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
4375
5320
  return `${fence}suggestion\n${suggestedFix}${suggestedFix.endsWith("\n") ? "" : "\n"}${fence}`;
4376
5321
  }
4377
- function longestBacktickRun(value) {
4378
- return Math.max(0, ...[...value.matchAll(/`+/g)].map((match) => match[0].length));
4379
- }
4380
- function redactPotentialSecrets(value) {
4381
- return value.replace(secretLikeTokenPattern, "[redacted secret]");
4382
- }
4383
5322
  //#endregion
4384
5323
  //#region src/review/comment-publishing.ts
4385
5324
  function buildCommentPublishingPlan(options) {
@@ -4767,9 +5706,9 @@ function priorReviewForTask(priorMainComment, priorReviewState) {
4767
5706
  };
4768
5707
  }
4769
5708
  function visibleMainComment(body) {
4770
- const lines = body.split("\n").filter((line) => !line.startsWith("<!-- pipr:main-comment "));
5709
+ const lines = body.split("\n").filter((line) => !line.startsWith("<!-- pipr:main-comment ") && !mainCommentAttributionPattern.test(line));
4771
5710
  while (lines[0] === "") lines.shift();
4772
- if (lines[0] === "# pipr Review" || lines[0] === "# Pipr Review") lines.shift();
5711
+ if (lines[0] && mainCommentTitles.has(lines[0])) lines.shift();
4773
5712
  while (lines[0] === "") lines.shift();
4774
5713
  return lines.join("\n").trim();
4775
5714
  }
@@ -6150,9 +7089,10 @@ async function publishInlineCommentItem(options) {
6150
7089
  error: error instanceof Error ? error.message : String(error)
6151
7090
  };
6152
7091
  }
7092
+ const publicationLocation = inlinePublicationLocationFromGitHub(location);
6153
7093
  if (inlinePublicationDecision({
6154
7094
  marker: options.item.marker,
6155
- location: inlinePublicationLocationFromGitHub(location),
7095
+ location: publicationLocation,
6156
7096
  existing: options.existing
6157
7097
  }) === "skip") return { status: "skipped" };
6158
7098
  try {
@@ -6163,6 +7103,7 @@ async function publishInlineCommentItem(options) {
6163
7103
  ...location
6164
7104
  });
6165
7105
  options.existing.markers.add(options.item.marker);
7106
+ options.existing.locations.push(publicationLocation);
6166
7107
  return { status: "posted" };
6167
7108
  } catch (error) {
6168
7109
  return {
@@ -7370,4 +8311,4 @@ async function runActionCommandWithDependencies(options) {
7370
8311
  //#endregion
7371
8312
  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 };
7372
8313
 
7373
- //# sourceMappingURL=commands-CYh8Au5C.mjs.map
8314
+ //# sourceMappingURL=commands-CQg9QVZt.mjs.map