co-maintainer 0.5.0 → 0.5.1-beta.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.
Files changed (177) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -3
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/anthropic.d.ts +55 -0
  5. package/dist/src/ai/anthropic.js +262 -0
  6. package/dist/src/ai/batch.d.ts +2 -2
  7. package/dist/src/ai/batch.js +1 -1
  8. package/dist/src/ai/estimate.d.ts +4 -0
  9. package/dist/src/ai/estimate.js +17 -2
  10. package/dist/src/ai/mermaid_loop.js +7 -4
  11. package/dist/src/ai/openai.d.ts +45 -0
  12. package/dist/src/ai/openai.js +206 -0
  13. package/dist/src/ai/opencode.d.ts +10 -0
  14. package/dist/src/ai/opencode.js +76 -0
  15. package/dist/src/ai/openrouter.d.ts +23 -7
  16. package/dist/src/ai/openrouter.js +39 -23
  17. package/dist/src/ai/pricing.js +1 -1
  18. package/dist/src/ai/provider.d.ts +9 -0
  19. package/dist/src/ai/provider.js +42 -3
  20. package/dist/src/ai/verify.d.ts +9 -1
  21. package/dist/src/ai/verify.js +81 -3
  22. package/dist/src/cli/args.d.ts +1 -2
  23. package/dist/src/cli/args.js +38 -26
  24. package/dist/src/cli/commands/config.d.ts +1 -1
  25. package/dist/src/cli/commands/config.js +10 -4
  26. package/dist/src/cli/commands/probe.js +3 -11
  27. package/dist/src/cli/commands/registry.d.ts +1 -9
  28. package/dist/src/cli/commands/registry.js +75 -10
  29. package/dist/src/cli/commands/review.js +18 -13
  30. package/dist/src/cli/commands/rollback.d.ts +1 -0
  31. package/dist/src/cli/commands/rollback.js +42 -0
  32. package/dist/src/cli/commands/serve.js +2 -3
  33. package/dist/src/cli/commands/set.js +71 -7
  34. package/dist/src/cli/commands/view.js +10 -9
  35. package/dist/src/cli/error.d.ts +4 -4
  36. package/dist/src/cli/error.js +4 -4
  37. package/dist/src/cli/main.d.ts +4 -4
  38. package/dist/src/cli/main.js +13 -8
  39. package/dist/src/cli/prompt.js +1 -1
  40. package/dist/src/cli/review_args.d.ts +9 -3
  41. package/dist/src/cli/review_args.js +28 -5
  42. package/dist/src/cli/review_github.d.ts +31 -0
  43. package/dist/src/cli/review_github.js +128 -0
  44. package/dist/src/cli/review_result.d.ts +27 -16
  45. package/dist/src/cli/review_result.js +7 -7
  46. package/dist/src/config.d.ts +28 -6
  47. package/dist/src/config.js +15 -1
  48. package/dist/src/git/default_branch.d.ts +1 -1
  49. package/dist/src/git/default_branch.js +1 -1
  50. package/dist/src/github/app.js +2 -2
  51. package/dist/src/github/app_manifest.d.ts +1 -7
  52. package/dist/src/github/app_manifest.js +2 -7
  53. package/dist/src/github/client.d.ts +4 -1
  54. package/dist/src/github/client.js +14 -2
  55. package/dist/src/github/gh.js +10 -7
  56. package/dist/src/github/pat.js +4 -4
  57. package/dist/src/github/permissions.d.ts +36 -0
  58. package/dist/src/github/permissions.js +334 -0
  59. package/dist/src/knowledge/facts.js +1 -1
  60. package/dist/src/knowledge/guide.d.ts +3 -3
  61. package/dist/src/knowledge/guide.js +5 -5
  62. package/dist/src/knowledge/probe.js +1 -2
  63. package/dist/src/knowledge/sections.d.ts +3 -3
  64. package/dist/src/knowledge/sections.js +3 -3
  65. package/dist/src/knowledge/skill.d.ts +1 -1
  66. package/dist/src/knowledge/skill.js +4 -4
  67. package/dist/src/knowledge/synthesis.d.ts +1 -1
  68. package/dist/src/knowledge/synthesis.js +5 -5
  69. package/dist/src/knowledge/types.d.ts +1 -1
  70. package/dist/src/local/codegraph_exclude.d.ts +1 -1
  71. package/dist/src/local/codegraph_exclude.js +1 -1
  72. package/dist/src/local/codegraph_prepare.js +1 -1
  73. package/dist/src/local/git_ops.d.ts +1 -1
  74. package/dist/src/local/git_ops.js +1 -1
  75. package/dist/src/local/git_parse.d.ts +2 -2
  76. package/dist/src/local/git_parse.js +2 -2
  77. package/dist/src/local/git_revision.d.ts +1 -1
  78. package/dist/src/local/git_revision.js +1 -1
  79. package/dist/src/local/git_untracked.d.ts +1 -1
  80. package/dist/src/local/git_untracked.js +1 -1
  81. package/dist/src/local/review_local.js +17 -14
  82. package/dist/src/local/review_lock.d.ts +1 -1
  83. package/dist/src/local/review_lock.js +1 -1
  84. package/dist/src/pr/checkout.js +2 -2
  85. package/dist/src/pr/findings.js +1 -1
  86. package/dist/src/pr/findings_json.d.ts +2 -2
  87. package/dist/src/pr/findings_json.js +1 -1
  88. package/dist/src/pr/github_revision.js +1 -1
  89. package/dist/src/pr/review_copy.d.ts +2 -2
  90. package/dist/src/pr/review_copy.js +2 -2
  91. package/dist/src/pr/reviewer.d.ts +6 -0
  92. package/dist/src/pr/reviewer.js +49 -39
  93. package/dist/src/remote/client.d.ts +4 -0
  94. package/dist/src/remote/client.js +84 -23
  95. package/dist/src/remote/fixtures.js +1 -1
  96. package/dist/src/remote/http.d.ts +10 -1
  97. package/dist/src/remote/http.js +15 -3
  98. package/dist/src/remote/schema.d.ts +3 -3
  99. package/dist/src/remote/schema.js +3 -3
  100. package/dist/src/remote/server/body.d.ts +1 -1
  101. package/dist/src/remote/server/body.js +1 -1
  102. package/dist/src/remote/server/byok_keys.d.ts +15 -0
  103. package/dist/src/remote/server/byok_keys.js +40 -0
  104. package/dist/src/remote/server/guides.js +2 -2
  105. package/dist/src/remote/server/routes.js +8 -1
  106. package/dist/src/remote/validate.js +15 -1
  107. package/dist/src/review/blocking.d.ts +2 -2
  108. package/dist/src/review/blocking.js +2 -2
  109. package/dist/src/review/carry_over.d.ts +2 -2
  110. package/dist/src/review/carry_over.js +2 -2
  111. package/dist/src/review/guides.js +1 -1
  112. package/dist/src/server/api/remote_tokens.js +2 -0
  113. package/dist/src/server/api/repos.js +51 -0
  114. package/dist/src/server/api/settings.js +10 -0
  115. package/dist/src/server/errors.d.ts +1 -1
  116. package/dist/src/server/http.js +1 -1
  117. package/dist/src/server/pages/activity.d.ts +2 -0
  118. package/dist/src/server/pages/activity.js +18 -4
  119. package/dist/src/server/pages/add_repo.js +61 -1
  120. package/dist/src/server/pages/analytics.js +18 -8
  121. package/dist/src/server/pages/cost.d.ts +21 -0
  122. package/dist/src/server/pages/cost.js +72 -0
  123. package/dist/src/server/pages/pr.js +5 -4
  124. package/dist/src/server/pages/repo.js +3 -2
  125. package/dist/src/server/pages/repo_prs.js +3 -2
  126. package/dist/src/server/pages/repo_remote.js +3 -2
  127. package/dist/src/server/pages/repo_settings.js +326 -12
  128. package/dist/src/server/pages/router.js +3 -2
  129. package/dist/src/server/pages/settings.js +50 -6
  130. package/dist/src/server/pages/styles.d.ts +1 -1
  131. package/dist/src/server/pages/styles.js +1 -1
  132. package/dist/src/services/credentials.js +8 -11
  133. package/dist/src/services/dashboard.d.ts +22 -9
  134. package/dist/src/services/dashboard.js +16 -2
  135. package/dist/src/services/probe.js +1 -1
  136. package/dist/src/services/remote_review.js +142 -35
  137. package/dist/src/services/remote_review_abort.d.ts +5 -1
  138. package/dist/src/services/remote_review_abort.js +9 -1
  139. package/dist/src/services/review.d.ts +1 -1
  140. package/dist/src/services/review.js +19 -5
  141. package/dist/src/services/review_policy.d.ts +116 -0
  142. package/dist/src/services/review_policy.js +489 -0
  143. package/dist/src/services/setup.d.ts +4 -3
  144. package/dist/src/services/setup.js +18 -16
  145. package/dist/src/services/setup_checklist.js +2 -2
  146. package/dist/src/services/webhook.d.ts +23 -0
  147. package/dist/src/services/webhook.js +141 -34
  148. package/dist/src/store/app_db.d.ts +5 -0
  149. package/dist/src/store/app_db.js +69 -13
  150. package/dist/src/store/backup.d.ts +31 -0
  151. package/dist/src/store/backup.js +158 -0
  152. package/dist/src/store/migrations.d.ts +1 -1
  153. package/dist/src/store/migrations.js +21 -1
  154. package/dist/src/store/remote_review_inputs.d.ts +3 -0
  155. package/dist/src/store/remote_review_inputs.js +3 -3
  156. package/dist/src/store/remote_tokens.d.ts +1 -1
  157. package/dist/src/store/remote_tokens.js +1 -1
  158. package/dist/src/store/repos.d.ts +2 -2
  159. package/dist/src/store/repos.js +1 -1
  160. package/dist/src/store/reviews.d.ts +21 -13
  161. package/dist/src/store/reviews.js +60 -15
  162. package/dist/src/store/rows.d.ts +10 -0
  163. package/dist/src/tools/codegraph.d.ts +4 -4
  164. package/dist/src/tools/codegraph.js +5 -5
  165. package/dist/src/tools/codegraph_exec.d.ts +1 -1
  166. package/dist/src/tools/codegraph_exec.js +1 -1
  167. package/dist/src/types.d.ts +12 -6
  168. package/dist/src/util/cost.d.ts +52 -0
  169. package/dist/src/util/cost.js +70 -0
  170. package/dist/src/util/log.d.ts +1 -1
  171. package/dist/src/util/log.js +2 -2
  172. package/dist/src/util/redact.js +2 -0
  173. package/dist/src/util/run_summary.d.ts +7 -2
  174. package/dist/src/util/run_summary.js +28 -2
  175. package/dist/src/util/time.d.ts +1 -1
  176. package/dist/src/util/time.js +1 -1
  177. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { bars, count, delta, duration, empty, html, layout, money, text, } from "./layout.js";
2
+ import { formatCostTotal } from "./cost.js";
2
3
  import { listJobs } from "../../store/jobs.js";
3
4
  export function renderAnalytics(username, range, data) {
4
5
  const failed = listJobs({ status: "failed" }).slice(0, 5);
@@ -19,7 +20,7 @@ export function renderAnalytics(username, range, data) {
19
20
  : "0"}</td>
20
21
  <td class="num">${count(row.tokensIn + row.tokensOut)}</td>
21
22
  <td class="num">${duration(row.avgDurationMs)}</td>
22
- <td class="num">${money(row.cost)}</td></tr>`)
23
+ <td class="num">${formatCostTotal(row)}</td></tr>`)
23
24
  .join("")}
24
25
  </tbody>
25
26
  </table>`;
@@ -36,8 +37,12 @@ export function renderAnalytics(username, range, data) {
36
37
  <td class="num">${count(row.tokensIn)}</td>
37
38
  <td class="num">${count(row.tokensOut)}</td>
38
39
  <td class="num">${duration(row.avgDurationMs)}</td>
39
- <td class="num">${money(row.reviews ? row.cost / row.reviews : 0)}</td>
40
- <td class="num">${money(row.cost)}</td></tr>`)
40
+ <td class="num">${row.knownCount
41
+ ? money(row.cost / row.knownCount)
42
+ : row.unknownCount
43
+ ? "unknown"
44
+ : money(0)}</td>
45
+ <td class="num">${formatCostTotal(row)}</td></tr>`)
41
46
  .join("")}
42
47
  </tbody>
43
48
  </table>`;
@@ -51,7 +56,7 @@ export function renderAnalytics(username, range, data) {
51
56
  .map((row) => `<tr><td>${text(row.tokenName)}</td>
52
57
  <td class="num">${row.reviews}</td>
53
58
  <td class="num">${row.findings}</td>
54
- <td class="num">${money(row.cost)}</td></tr>`)
59
+ <td class="num">${formatCostTotal(row)}</td></tr>`)
55
60
  .join("")}
56
61
  </tbody>
57
62
  </table>`;
@@ -77,6 +82,11 @@ export function renderAnalytics(username, range, data) {
77
82
  cost: 0,
78
83
  reviews: 0,
79
84
  findings: 0,
85
+ knownCount: 0,
86
+ unknownCount: 0,
87
+ byokUsd: 0,
88
+ byokKnownCount: 0,
89
+ byokUnknownCount: 0,
80
90
  });
81
91
  const looks = failed.length === 0
82
92
  ? empty("Nothing stands out", "Failed jobs will be listed here.")
@@ -107,7 +117,7 @@ export function renderAnalytics(username, range, data) {
107
117
  <div class="kfig wrap4">
108
118
  <div><div class="k">Pull requests reviewed</div><div class="big">${data.totals.pullRequests}</div>${delta(data.change.pullRequests, data.days)}</div>
109
119
  <div><div class="k">Findings</div><div class="big">${data.totals.findings}</div>${delta(data.change.findings, data.days)}</div>
110
- <div><div class="k">Cost</div><div class="big">${money(data.totals.cost)}</div>${delta(data.change.cost, data.days, true)}</div>
120
+ <div><div class="k">Cost</div><div class="big">${formatCostTotal(data.totals)}</div>${delta(data.change.cost, data.days, true)}</div>
111
121
  <div><div class="k">Tokens</div><div class="big">${count(data.totals.tokensIn + data.totals.tokensOut)}</div><div class="trend">${count(data.totals.tokensIn)} in and ${count(data.totals.tokensOut)} out</div></div>
112
122
  <div><div class="k">Average review</div><div class="big">${duration(data.totals.avgDurationMs)}</div></div>
113
123
  <div><div class="k">Rounds per pull request</div><div class="big">${data.totals.pullRequests
@@ -121,11 +131,11 @@ export function renderAnalytics(username, range, data) {
121
131
  <div class="hd"><h2>Cost per day</h2></div>
122
132
  <div class="bd">
123
133
  ${bars(data.byDay.map((row) => ({
124
- label: `${row.day} ${money(row.cost)} over ${row.reviews} reviews`,
134
+ label: `${row.day} ${formatCostTotal(row)} over ${row.reviews} reviews`,
125
135
  value: row.cost,
126
136
  })))}
127
- <p class="muted" style="margin:10px 0 0">${busiest.cost
128
- ? `Busiest day was ${text(busiest.day)} at ${money(busiest.cost)}`
137
+ <p class="muted" style="margin:10px 0 0">${busiest.cost > 0 || busiest.unknownCount > 0
138
+ ? `Busiest day was ${text(busiest.day)} at ${formatCostTotal(busiest)}`
129
139
  : "No spend in this range"}</p>
130
140
  </div>
131
141
  </div>
@@ -0,0 +1,21 @@
1
+ import type { CostTotals } from "../../store/reviews.ts";
2
+ export type CostRow = {
3
+ cost: number | null;
4
+ /** `null` on a row written before this column existed. */
5
+ cost_status?: "known" | "unknown" | null;
6
+ cost_note?: string | null;
7
+ billed_to?: "server" | "byok" | null;
8
+ };
9
+ /** One review's cost cell: the dollar amount, or "unknown" with the reason
10
+ * as a tooltip. A row from before `cost_status` existed falls back to the
11
+ * same rule the SQL aggregates use: a recorded cost is known, a missing one
12
+ * is not. */
13
+ export declare function formatCost(row: CostRow): string;
14
+ /** A figure summed from several reviews, as `$1.23 · 4 reviews with unknown
15
+ * cost`. Plain text, safe wherever HTML cannot go (a `title` attribute, the
16
+ * settings page's own JSON). BYOK never sits inside the main amount. */
17
+ export declare function formatCostTotal(totals: CostTotals): string;
18
+ /** Folds a handful of reviews (a pull request's rounds) into the same shape
19
+ * `reviewStats` returns, so one review list and one SQL aggregate render
20
+ * through the same `formatCostTotal`. */
21
+ export declare function totalOfRows(rows: CostRow[]): CostTotals;
@@ -0,0 +1,72 @@
1
+ /** How a review's cost renders on the dashboard. A missing cost
2
+ * is never a silent `$0.00`: a single review says why in a tooltip, a total
3
+ * names how many of its reviews are missing one. */
4
+ import { costReasonText } from "../../util/cost.js";
5
+ import { escapeHtml, money } from "./layout.js";
6
+ function statusOf(row) {
7
+ return row.cost_status ?? (row.cost !== null ? "known" : "unknown");
8
+ }
9
+ /** One review's cost cell: the dollar amount, or "unknown" with the reason
10
+ * as a tooltip. A row from before `cost_status` existed falls back to the
11
+ * same rule the SQL aggregates use: a recorded cost is known, a missing one
12
+ * is not. */
13
+ export function formatCost(row) {
14
+ if (statusOf(row) === "known")
15
+ return money(row.cost ?? 0);
16
+ const reason = row.cost_note ?? "recorded_before_0_5_1";
17
+ return `<span class="cost-unknown" tabindex="0" title="${escapeHtml(costReasonText(reason))}">unknown</span>`;
18
+ }
19
+ function reviewsWord(n) {
20
+ return `${n} review${n === 1 ? "" : "s"} with unknown cost`;
21
+ }
22
+ /** A figure summed from several reviews, as `$1.23 · 4 reviews with unknown
23
+ * cost`. Plain text, safe wherever HTML cannot go (a `title` attribute, the
24
+ * settings page's own JSON). BYOK never sits inside the main amount. */
25
+ export function formatCostTotal(totals) {
26
+ let main;
27
+ if (totals.knownCount === 0 && totals.unknownCount > 0) {
28
+ main = reviewsWord(totals.unknownCount);
29
+ }
30
+ else {
31
+ main = money(totals.cost);
32
+ if (totals.unknownCount > 0)
33
+ main += ` · ${reviewsWord(totals.unknownCount)}`;
34
+ }
35
+ if (totals.byokKnownCount === 0 && totals.byokUnknownCount === 0)
36
+ return main;
37
+ const byok = totals.byokKnownCount === 0
38
+ ? reviewsWord(totals.byokUnknownCount)
39
+ : money(totals.byokUsd);
40
+ return `${main} + ${byok} BYOK`;
41
+ }
42
+ /** Folds a handful of reviews (a pull request's rounds) into the same shape
43
+ * `reviewStats` returns, so one review list and one SQL aggregate render
44
+ * through the same `formatCostTotal`. */
45
+ export function totalOfRows(rows) {
46
+ const totals = {
47
+ cost: 0,
48
+ knownCount: 0,
49
+ unknownCount: 0,
50
+ byokUsd: 0,
51
+ byokKnownCount: 0,
52
+ byokUnknownCount: 0,
53
+ };
54
+ for (const row of rows) {
55
+ const known = statusOf(row) === "known";
56
+ if (row.billed_to === "byok") {
57
+ if (known) {
58
+ totals.byokUsd += row.cost ?? 0;
59
+ totals.byokKnownCount++;
60
+ }
61
+ else
62
+ totals.byokUnknownCount++;
63
+ }
64
+ else if (known) {
65
+ totals.cost += row.cost ?? 0;
66
+ totals.knownCount++;
67
+ }
68
+ else
69
+ totals.unknownCount++;
70
+ }
71
+ return totals;
72
+ }
@@ -1,7 +1,8 @@
1
- import { empty, html, layout, markdown, money, repoNav, skSlot, text, when, } from "./layout.js";
1
+ import { empty, html, layout, markdown, repoNav, skSlot, text, when, } from "./layout.js";
2
+ import { formatCost, formatCostTotal, totalOfRows } from "./cost.js";
2
3
  export function renderPr(username, fullName, data) {
3
4
  const n = data.prNumber;
4
- const totalCost = data.reviews.reduce((sum, review) => sum + Number(review.cost ?? 0), 0);
5
+ const totalCost = totalOfRows(data.reviews);
5
6
  const stateLabel = (state) => {
6
7
  if (state === "closed")
7
8
  return "Closed";
@@ -64,7 +65,7 @@ export function renderPr(username, fullName, data) {
64
65
  ? "Changes since the last review"
65
66
  : "Whole pull request")}</td>
66
67
  <td class="num">${review.findings_count}</td>
67
- <td class="num">${money(Number(review.cost ?? 0))}</td></tr>`)
68
+ <td class="num">${formatCost(review)}</td></tr>`)
68
69
  .join("")}
69
70
  </tbody>
70
71
  </table>`;
@@ -95,7 +96,7 @@ export function renderPr(username, fullName, data) {
95
96
  </div>
96
97
  <div class="card">
97
98
  <div class="hd"><h2>Reviews</h2>
98
- <span class="muted" style="margin-left:auto;font-size:13px">${money(totalCost)} total</span></div>
99
+ <span class="muted" style="margin-left:auto;font-size:13px">${formatCostTotal(totalCost)} total</span></div>
99
100
  <div class="bd flush">${reviewsTable}</div>
100
101
  </div>
101
102
  </div>
@@ -1,4 +1,5 @@
1
- import { capped, empty, html, layout, money, repoNav, skSlot, text, when, } from "./layout.js";
1
+ import { capped, empty, html, layout, repoNav, skSlot, text, when, } from "./layout.js";
2
+ import { formatCostTotal } from "./cost.js";
2
3
  import { COMPARE_FILE_CAP, MAX_COMMITS_COUNTED } from "../../services/drift.js";
3
4
  import { webhookReachabilityProblem } from "../../util/webhook_reachability.js";
4
5
  import { lastDeliveryForRepo } from "../../store/deliveries.js";
@@ -72,7 +73,7 @@ export function renderRepo(username, data, webhookUrl) {
72
73
  <div class="kfig">
73
74
  <div><div class="k">Pull requests reviewed</div><div class="big">${stats.pullRequests}</div></div>
74
75
  <div><div class="k">Findings</div><div class="big">${stats.findings}</div></div>
75
- <div><div class="k">Cost</div><div class="big">${money(stats.cost)}</div></div>
76
+ <div><div class="k">Cost</div><div class="big">${formatCostTotal(stats)}</div></div>
76
77
  </div>
77
78
  </div>
78
79
  </div>
@@ -1,4 +1,5 @@
1
- import { empty, html, layout, money, repoNav, skSlot, text, when, } from "./layout.js";
1
+ import { empty, html, layout, repoNav, skSlot, text, when } from "./layout.js";
2
+ import { formatCostTotal } from "./cost.js";
2
3
  export function renderRepoPulls(username, fullName, data, open) {
3
4
  const start = (data.page - 1) * data.per + 1;
4
5
  const end = Math.min(data.page * data.per, data.total);
@@ -24,7 +25,7 @@ export function renderRepoPulls(username, fullName, data, open) {
24
25
  .map((item) => `<tr><td><a href="/repos/${text(fullName)}/pulls/${item.pr_number}">#${item.pr_number}</a></td>
25
26
  <td class="muted">${when(item.last_reviewed)}</td>
26
27
  <td class="num">${item.findings}</td>
27
- <td class="num">${money(item.cost)}</td></tr>`)
28
+ <td class="num">${formatCostTotal(item)}</td></tr>`)
28
29
  .join("")}
29
30
  </tbody>
30
31
  </table>${pager}`;
@@ -1,4 +1,5 @@
1
- import { empty, html, layout, money, repoNav, text, when } from "./layout.js";
1
+ import { empty, html, layout, repoNav, text, when } from "./layout.js";
2
+ import { formatCost } from "./cost.js";
2
3
  export function renderRepoRemote(username, fullName, data) {
3
4
  const rows = data.items.length === 0
4
5
  ? empty("No remote reviews yet", "Developers run co-maintainer review --remote with a token from Settings.")
@@ -12,7 +13,7 @@ export function renderRepoRemote(username, fullName, data) {
12
13
  <td>${text(review.token_name ?? "none")}</td>
13
14
  <td>${text(review.status)}</td>
14
15
  <td class="num">${review.findings_count ?? 0}</td>
15
- <td class="num">${money(review.cost ?? 0)}</td>
16
+ <td class="num">${formatCost(review)}</td>
16
17
  <td class="muted">${when(review.created_at)}</td>
17
18
  </tr>`)
18
19
  .join("")}
@@ -1,6 +1,22 @@
1
1
  import { html, layout, repoNav, skSlot, text } from "./layout.js";
2
2
  import { parseRemakeCron } from "../../services/remake_cron.js";
3
3
  import { nextCronRun } from "../../util/cron.js";
4
+ import { policySource } from "../../services/webhook.js";
5
+ import { ASSOCIATIONS, TEMPLATES, TEMPLATE_INFO, storedPolicyValue, withDefaults, } from "../../services/review_policy.js";
6
+ const ASSOCIATION_LABEL = {
7
+ OWNER: "Owner",
8
+ MEMBER: "Member",
9
+ COLLABORATOR: "Collaborator",
10
+ CONTRIBUTOR: "Earlier contributor",
11
+ FIRST_TIME_CONTRIBUTOR: "First time contributor",
12
+ FIRST_TIMER: "First timer",
13
+ NONE: "No relation to the repository",
14
+ };
15
+ /** JSON for an inline script: `<` is escaped so a rule name can never close
16
+ * the script tag. */
17
+ function scriptJson(value) {
18
+ return JSON.stringify(value).replace(/</g, "\\u003c");
19
+ }
4
20
  function cronHint(repo, expression) {
5
21
  const notBuilt = repo.knowledge_built_at
6
22
  ? ""
@@ -21,6 +37,30 @@ function cronHint(repo, expression) {
21
37
  }
22
38
  export function renderRepoSettings(username, repo, config) {
23
39
  const name = repo.full_name;
40
+ const source = policySource(repo);
41
+ const stored = storedPolicyValue(source.policy);
42
+ const current = source.legacy
43
+ ? "legacy"
44
+ : typeof stored === "string"
45
+ ? stored
46
+ : "custom";
47
+ const templates = Object.fromEntries(Object.keys(TEMPLATES).map((key) => [key, withDefaults(TEMPLATES[key])]));
48
+ const policyData = {
49
+ current,
50
+ legacy: source.legacy,
51
+ policy: source.legacy ? templates.everyone : source.policy,
52
+ templates,
53
+ info: TEMPLATE_INFO,
54
+ associations: ASSOCIATIONS.map((value) => ({
55
+ value,
56
+ label: ASSOCIATION_LABEL[value],
57
+ })),
58
+ };
59
+ const policyOptions = [
60
+ ...TEMPLATE_INFO.map((item) => `<option value="${text(item.name)}"${current === item.name ? " selected" : ""}>${text(item.label)}</option>`),
61
+ `<option value="custom"${current === "custom" ? " selected" : ""}>Custom rules</option>`,
62
+ `<option value="legacy"${current === "legacy" ? " selected" : ""}>Simple switches</option>`,
63
+ ].join("");
24
64
  const skip = repo.skip_drafts && repo.skip_bots
25
65
  ? "both"
26
66
  : repo.skip_drafts
@@ -37,11 +77,57 @@ export function renderRepoSettings(username, repo, config) {
37
77
  <div class="crumbs"><a href="/">Repositories</a> /
38
78
  <a href="/repos/${text(name)}">${text(name)}</a> / Settings</div>
39
79
  <div class="pagehead"><div><h1>Settings</h1></div></div>
80
+ <div class="card" data-async id="policy-card">
81
+ ${skSlot()}
82
+ <div class="hd"><h2>Who gets a review</h2></div>
83
+ <div class="bd">
84
+ <div class="field wide">
85
+ <label for="policy-template">Policy</label>
86
+ <select id="policy-template">${policyOptions}</select>
87
+ <div class="hint" id="policy-desc"></div>
88
+ </div>
89
+ <div id="policy-body">
90
+ <div class="notice info"><div class="txt"><b>In short</b>
91
+ <ul id="policy-summary" aria-live="polite" style="margin:6px 0 0 18px;padding:0"></ul></div></div>
92
+ <p id="policy-problem" class="muted" role="alert" hidden></p>
93
+ <details id="policy-details" style="margin-top:14px">
94
+ <summary>Edit the rules</summary>
95
+ <p class="muted">The first rule that matches a pull request decides. If none matches, the last setting below decides.</p>
96
+ <div id="rules"></div>
97
+ <button class="btn sm" type="button" id="add-rule">Add rule</button>
98
+ <div class="field" style="margin-top:14px">
99
+ <label for="p-default">Any other pull request</label>
100
+ <select id="p-default">
101
+ <option value="review">Is reviewed automatically</option>
102
+ <option value="on-request">Waits for a maintainer request</option>
103
+ <option value="skip">Is not reviewed</option>
104
+ </select>
105
+ </div>
106
+ <div class="two">
107
+ <div class="field"><label for="p-label">Request label</label><input id="p-label"></div>
108
+ <div class="field"><label for="p-command">Request comment</label><input id="p-command"></div>
109
+ <div class="field"><label for="p-scope">A request covers</label>
110
+ <select id="p-scope">
111
+ <option value="head">Only the commit it was made on</option>
112
+ <option value="pull-request">Every later push to the pull request</option>
113
+ </select></div>
114
+ <div class="field"><label for="p-max">Reviews per pull request</label>
115
+ <input id="p-max" placeholder="No limit"></div>
116
+ </div>
117
+ <fieldset id="p-requesters" class="ticks">
118
+ <legend>Who can ask for a review</legend>
119
+ </fieldset>
120
+ </details>
121
+ </div>
122
+ </div>
123
+ <div class="ft"><button class="primary" id="save-policy">Save</button></div>
124
+ </div>
40
125
  <div class="card" data-async>
41
126
  ${skSlot()}
42
- <div class="hd"><h2>Automatic review</h2></div>
127
+ <div class="hd"><h2>${source.legacy ? "Automatic review" : "How reviews run"}</h2></div>
43
128
  <div class="bd">
44
- <div style="display:flex;align-items:center;gap:14px;margin-bottom:22px">
129
+ ${source.legacy
130
+ ? `<div id="switches"><div style="display:flex;align-items:center;gap:14px;margin-bottom:22px">
45
131
  <button class="tg${repo.auto_review === 1 ? " on" : ""}" id="auto"></button>
46
132
  <div>Review pull requests as they are pushed</div>
47
133
  </div>
@@ -53,7 +139,8 @@ export function renderRepoSettings(username, repo, config) {
53
139
  <option value="bots"${skip === "bots" ? " selected" : ""}>Bots only</option>
54
140
  <option value="none"${skip === "none" ? " selected" : ""}>Nothing</option>
55
141
  </select>
56
- </div>
142
+ </div></div>`
143
+ : ""}
57
144
  <div class="field">
58
145
  <label>Review</label>
59
146
  <select id="scope">
@@ -111,6 +198,228 @@ export function renderRepoSettings(username, repo, config) {
111
198
  </div>
112
199
  <script>
113
200
  var repo = ${JSON.stringify(name)};
201
+ var P = ${scriptJson(policyData)};
202
+ var state = null;
203
+ var previewTimer = null;
204
+ function el(tag, props, kids) {
205
+ var node = document.createElement(tag);
206
+ Object.keys(props || {}).forEach(function(key) {
207
+ if (key === "text") node.textContent = props[key];
208
+ else node.setAttribute(key, props[key]);
209
+ });
210
+ (kids || []).forEach(function(kid) { node.appendChild(kid); });
211
+ return node;
212
+ }
213
+ function tri(v) { return v === true ? "yes" : v === false ? "no" : "any"; }
214
+ function unTri(v) { return v === "yes" ? true : v === "no" ? false : undefined; }
215
+ function csv(text) {
216
+ return text.split(",").map(function(x) { return x.trim(); }).filter(Boolean);
217
+ }
218
+ function fromPolicy(policy) {
219
+ return {
220
+ rules: (policy.rules || []).map(function(rule) {
221
+ var w = rule.when || {};
222
+ var lines = w.changedLines || {};
223
+ return {
224
+ name: rule.name || "", action: rule.action,
225
+ association: (w.association || []).slice(),
226
+ fork: tri(w.fork), draft: tri(w.draft), bot: tri(w.bot),
227
+ labels: (w.labels || []).join(", "),
228
+ branches: (w.targetBranch || []).join(", "),
229
+ min: lines.min === undefined ? "" : String(lines.min),
230
+ max: lines.max === undefined ? "" : String(lines.max)
231
+ };
232
+ }),
233
+ def: policy.default, label: policy.requestLabel,
234
+ command: policy.requestCommand,
235
+ requesters: policy.requesters.slice(), scope: policy.approvalScope,
236
+ max: policy.maxRounds === undefined ? "" : String(policy.maxRounds)
237
+ };
238
+ }
239
+ function toPolicy() {
240
+ return {
241
+ rules: state.rules.map(function(r) {
242
+ var when = {};
243
+ if (r.association.length) when.association = r.association;
244
+ if (unTri(r.fork) !== undefined) when.fork = unTri(r.fork);
245
+ if (unTri(r.draft) !== undefined) when.draft = unTri(r.draft);
246
+ if (unTri(r.bot) !== undefined) when.bot = unTri(r.bot);
247
+ if (csv(r.labels).length) when.labels = csv(r.labels);
248
+ if (csv(r.branches).length) when.targetBranch = csv(r.branches);
249
+ if (r.min.trim() !== "" || r.max.trim() !== "") {
250
+ when.changedLines = {};
251
+ if (r.min.trim() !== "") when.changedLines.min = Number(r.min);
252
+ if (r.max.trim() !== "") when.changedLines.max = Number(r.max);
253
+ }
254
+ var out = { when: when, action: r.action };
255
+ if (r.name.trim()) out.name = r.name.trim();
256
+ return out;
257
+ }),
258
+ default: state.def, requestLabel: state.label,
259
+ requestCommand: state.command, requesters: state.requesters,
260
+ approvalScope: state.scope,
261
+ maxRounds: state.max.trim() === "" ? undefined : Number(state.max)
262
+ };
263
+ }
264
+ function markCustom() {
265
+ var select = document.getElementById("policy-template");
266
+ if (select.value !== "legacy") select.value = "custom";
267
+ describe();
268
+ queuePreview();
269
+ }
270
+ function describe() {
271
+ var value = document.getElementById("policy-template").value;
272
+ var text = "";
273
+ if (value === "legacy") text = "The three switches below decide: automatic review on or off, and skipping drafts and bots.";
274
+ else if (value === "custom") text = "Your own rules, edited below.";
275
+ else P.info.forEach(function(item) { if (item.name === value) text = item.description; });
276
+ document.getElementById("policy-desc").textContent = text;
277
+ document.getElementById("policy-body").hidden = value === "legacy";
278
+ }
279
+ function queuePreview() {
280
+ clearTimeout(previewTimer);
281
+ previewTimer = setTimeout(preview, 250);
282
+ }
283
+ async function preview() {
284
+ var list = document.getElementById("policy-summary");
285
+ var problem = document.getElementById("policy-problem");
286
+ var saveBtn = document.getElementById("save-policy");
287
+ if (document.getElementById("policy-template").value === "legacy") return;
288
+ try {
289
+ var data = await api("POST", "/api/repos/policy-preview", { policy: toPolicy() });
290
+ list.replaceChildren();
291
+ data.summary.forEach(function(line) {
292
+ list.appendChild(el("li", { text: line }));
293
+ });
294
+ problem.hidden = true;
295
+ saveBtn.disabled = false;
296
+ } catch (error) {
297
+ problem.textContent = "This policy cannot be saved yet: " + error.message;
298
+ problem.hidden = false;
299
+ saveBtn.disabled = true;
300
+ }
301
+ }
302
+ function field(id, labelText, control) {
303
+ return el("div", { "class": "field" }, [el("label", { "for": id, text: labelText }), control]);
304
+ }
305
+ function tickList(container, name, values, onChange) {
306
+ P.associations.forEach(function(a) {
307
+ var id = name + a.value;
308
+ var box = el("input", { type: "checkbox", id: id });
309
+ box.checked = values().indexOf(a.value) !== -1;
310
+ box.addEventListener("change", function() {
311
+ var next = P.associations.map(function(x) { return x.value; }).filter(function(v) {
312
+ return v === a.value ? box.checked : values().indexOf(v) !== -1;
313
+ });
314
+ onChange(next);
315
+ markCustom();
316
+ });
317
+ container.appendChild(el("label", { "for": id }, [box, el("span", { text: a.label })]));
318
+ });
319
+ }
320
+ function renderRules() {
321
+ var box = document.getElementById("rules");
322
+ box.replaceChildren();
323
+ state.rules.forEach(function(rule, i) {
324
+ var pre = "r" + i + "-";
325
+ var bind = function(node, key) {
326
+ node.addEventListener("input", function() { rule[key] = node.value; markCustom(); });
327
+ return node;
328
+ };
329
+ var pick = function(id, key, options) {
330
+ var node = el("select", { id: id }, options.map(function(o) {
331
+ return el("option", { value: o[0], text: o[1] });
332
+ }));
333
+ node.value = rule[key];
334
+ node.addEventListener("change", function() { rule[key] = node.value; markCustom(); });
335
+ return node;
336
+ };
337
+ var move = function(text, label, to, disabled) {
338
+ var btn = el("button", { "class": "btn sm", type: "button", text: text, "aria-label": label });
339
+ btn.disabled = disabled;
340
+ btn.addEventListener("click", function() {
341
+ state.rules.splice(to, 0, state.rules.splice(i, 1)[0]);
342
+ renderRules(); markCustom();
343
+ });
344
+ return btn;
345
+ };
346
+ var del = el("button", { "class": "btn sm", type: "button", text: "Remove", "aria-label": "Remove rule " + (i + 1) });
347
+ del.addEventListener("click", function() {
348
+ state.rules.splice(i, 1);
349
+ renderRules(); markCustom();
350
+ });
351
+ var head = el("div", { style: "display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap" }, [
352
+ el("strong", { text: "Rule " + (i + 1), style: "align-self:center" }),
353
+ field(pre + "name", "Name (optional)", bind(el("input", { id: pre + "name", value: rule.name }), "name")),
354
+ field(pre + "action", "Then", pick(pre + "action", "action", [
355
+ ["review", "Review it"], ["on-request", "Wait for a request"], ["skip", "Do not review it"]
356
+ ]))
357
+ ]);
358
+ var authors = el("fieldset", { "class": "ticks" }, [
359
+ el("legend", { text: "When the author is (none ticked means anyone)" })
360
+ ]);
361
+ tickList(authors, pre + "a-", function() { return rule.association; }, function(next) { rule.association = next; });
362
+ var yesNo = [["any", "Either"], ["yes", "Yes"], ["no", "No"]];
363
+ var flags = el("div", { "class": "two" }, [
364
+ field(pre + "fork", "Opened from a fork", pick(pre + "fork", "fork", yesNo)),
365
+ field(pre + "draft", "Is a draft", pick(pre + "draft", "draft", yesNo)),
366
+ field(pre + "bot", "Is by a bot", pick(pre + "bot", "bot", yesNo)),
367
+ field(pre + "labels", "Has one of these labels (comma separated)", bind(el("input", { id: pre + "labels", value: rule.labels }), "labels")),
368
+ field(pre + "branches", "Targets one of these branches (comma separated)", bind(el("input", { id: pre + "branches", value: rule.branches }), "branches")),
369
+ field(pre + "min", "Changes at least this many lines", bind(el("input", { id: pre + "min", value: rule.min }), "min")),
370
+ field(pre + "max", "Changes at most this many lines", bind(el("input", { id: pre + "max", value: rule.max }), "max"))
371
+ ]);
372
+ box.appendChild(el("div", { "class": "rule" }, [
373
+ head, authors, flags,
374
+ el("div", { style: "display:flex;gap:8px;margin-top:8px" }, [
375
+ move("Move up", "Move rule " + (i + 1) + " up", i - 1, i === 0),
376
+ move("Move down", "Move rule " + (i + 1) + " down", i + 1, i === state.rules.length - 1),
377
+ del
378
+ ])
379
+ ]));
380
+ });
381
+ }
382
+ function renderSettings() {
383
+ document.getElementById("p-default").value = state.def;
384
+ document.getElementById("p-label").value = state.label;
385
+ document.getElementById("p-command").value = state.command;
386
+ document.getElementById("p-scope").value = state.scope;
387
+ document.getElementById("p-max").value = state.max;
388
+ var box = document.getElementById("p-requesters");
389
+ box.querySelectorAll("label").forEach(function(n) { n.remove(); });
390
+ tickList(box, "p-req-", function() { return state.requesters; }, function(next) { state.requesters = next; });
391
+ }
392
+ function loadState(policy) {
393
+ state = fromPolicy(policy);
394
+ renderRules();
395
+ renderSettings();
396
+ }
397
+ [["p-default", "def"], ["p-label", "label"], ["p-command", "command"], ["p-scope", "scope"], ["p-max", "max"]].forEach(function(pair) {
398
+ var node = document.getElementById(pair[0]);
399
+ node.addEventListener(node.tagName === "SELECT" ? "change" : "input", function() {
400
+ state[pair[1]] = node.value;
401
+ markCustom();
402
+ });
403
+ });
404
+ document.getElementById("add-rule").addEventListener("click", function() {
405
+ state.rules.push({ name: "", action: "skip", association: [], fork: "any", draft: "any", bot: "any", labels: "", branches: "", min: "", max: "" });
406
+ renderRules(); markCustom();
407
+ var last = document.getElementById("r" + (state.rules.length - 1) + "-name");
408
+ if (last) last.focus();
409
+ });
410
+ document.getElementById("policy-template").addEventListener("change", function() {
411
+ if (P.templates[this.value]) loadState(P.templates[this.value]);
412
+ describe();
413
+ queuePreview();
414
+ });
415
+ document.getElementById("save-policy").addEventListener("click", function() {
416
+ var value = document.getElementById("policy-template").value;
417
+ save(this, { reviewPolicy: value === "legacy" ? "legacy" : toPolicy() });
418
+ });
419
+ loadState(P.policy);
420
+ describe();
421
+ if (P.current !== "legacy") queuePreview();
422
+
114
423
  function save(btn, body) {
115
424
  run(btn, btn.closest("[data-async]"), async function() {
116
425
  await api("PATCH", "/api/repos/" + repo, body);
@@ -118,18 +427,23 @@ function save(btn, body) {
118
427
  });
119
428
  }
120
429
  document.getElementById("save-auto").addEventListener("click", function() {
121
- var skip = document.getElementById("skip").value;
122
- save(this, {
123
- autoReview: document.getElementById("auto").classList.contains("on"),
124
- skipDrafts: skip === "both" || skip === "drafts",
125
- skipBots: skip === "both" || skip === "bots",
430
+ var body = {
126
431
  reviewScope: document.getElementById("scope").value,
127
432
  useCodegraph: document.getElementById("codegraph").classList.contains("on")
128
- });
129
- });
130
- document.getElementById("auto").addEventListener("click", function() {
131
- this.classList.toggle("on");
433
+ };
434
+ if (document.getElementById("auto")) {
435
+ var skip = document.getElementById("skip").value;
436
+ body.autoReview = document.getElementById("auto").classList.contains("on");
437
+ body.skipDrafts = skip === "both" || skip === "drafts";
438
+ body.skipBots = skip === "both" || skip === "bots";
439
+ }
440
+ save(this, body);
132
441
  });
442
+ if (document.getElementById("auto")) {
443
+ document.getElementById("auto").addEventListener("click", function() {
444
+ this.classList.toggle("on");
445
+ });
446
+ }
133
447
  document.getElementById("codegraph").addEventListener("click", function() {
134
448
  this.classList.toggle("on");
135
449
  });
@@ -2,7 +2,8 @@ import { clearOauthStateCookieHeader, clearSessionCookieHeader, createSession, l
2
2
  import { githubAuthorizeUrl, githubLoginFromCode } from "../../github/oauth.js";
3
3
  import { handleClient, handleLogo, handleStyles } from "../assets.js";
4
4
  import { activityFeed, listReposForHome, prDetail, repoKnowledge, RepoNotFound, repoOverview, repoPulls, repoRemoteReviews, runningJobs, skippedDeliveries, statsForRange, } from "../../services/dashboard.js";
5
- import { escapeHtml, html, money } from "./layout.js";
5
+ import { escapeHtml, html } from "./layout.js";
6
+ import { formatCostTotal } from "./cost.js";
6
7
  import { readConfig, resolveAppPrivateKey, writeUserConfig, } from "../../config.js";
7
8
  import { appNameProblem, beginManifestState, buildAppManifest, consumeManifestState, convertManifest, } from "../../github/app_manifest.js";
8
9
  import { renderHome, renderLogin } from "./home.js";
@@ -210,7 +211,7 @@ function toHomeRow(item) {
210
211
  knowledgeAt,
211
212
  autoOn: item.repo.auto_review === 1,
212
213
  reviews: String(item.stats.reviews),
213
- cost: money(item.stats.cost),
214
+ cost: formatCostTotal(item.stats),
214
215
  };
215
216
  }
216
217
  function requireRepo(fullName) {