@opentag/github 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,542 @@
1
+ // src/pull-request.ts
2
+ function buildPullRequestBody(result) {
3
+ const lines = ["## Summary", "", result.summary];
4
+ if (result.changedFiles?.length) {
5
+ lines.push("", "## Changed Files");
6
+ for (const file of result.changedFiles) {
7
+ lines.push(`- \`${file}\``);
8
+ }
9
+ }
10
+ if (result.verification?.length) {
11
+ lines.push("", "## Verification");
12
+ for (const check of result.verification) {
13
+ lines.push(`- \`${check.command}\`: ${check.outcome}`);
14
+ }
15
+ }
16
+ return lines.join("\n");
17
+ }
18
+ async function createPullRequestViaFetch(input, fetchImpl = fetch) {
19
+ const response = await fetchImpl(`https://api.github.com/repos/${input.owner}/${input.repo}/pulls`, {
20
+ method: "POST",
21
+ headers: {
22
+ accept: "application/vnd.github+json",
23
+ authorization: `Bearer ${input.token}`,
24
+ "content-type": "application/json",
25
+ "x-github-api-version": "2022-11-28"
26
+ },
27
+ body: JSON.stringify({
28
+ title: input.title,
29
+ body: input.body,
30
+ head: input.head,
31
+ base: input.base
32
+ })
33
+ });
34
+ if (!response.ok) {
35
+ throw new Error(`create pull request failed: ${response.status} ${await response.text()}`);
36
+ }
37
+ const body = await response.json();
38
+ if (!body.html_url) {
39
+ throw new Error("create pull request response did not include html_url");
40
+ }
41
+ return body.html_url;
42
+ }
43
+
44
+ // src/apply.ts
45
+ function labelFromIntent(intent) {
46
+ const value = intent.params?.["label"];
47
+ return typeof value === "string" && value.length > 0 ? value : void 0;
48
+ }
49
+ function labelsFromIntent(intent) {
50
+ const value = intent.params?.["labels"];
51
+ if (!Array.isArray(value)) return void 0;
52
+ const labels = value.filter((label) => typeof label === "string" && label.length > 0);
53
+ return labels.length > 0 ? labels : void 0;
54
+ }
55
+ function assigneeFromIntent(intent) {
56
+ const value = intent.params?.["assignee"];
57
+ return typeof value === "string" && value.length > 0 ? value : void 0;
58
+ }
59
+ function assigneesFromIntent(intent) {
60
+ const value = intent.params?.["assignees"];
61
+ if (!Array.isArray(value)) return void 0;
62
+ const assignees = value.filter((assignee) => typeof assignee === "string" && assignee.length > 0);
63
+ return assignees.length > 0 ? assignees : void 0;
64
+ }
65
+ function reviewersFromIntent(intent) {
66
+ const reviewer = intent.params?.["reviewer"];
67
+ const reviewers = intent.params?.["reviewers"];
68
+ const values = [
69
+ ...typeof reviewer === "string" ? [reviewer] : [],
70
+ ...Array.isArray(reviewers) ? reviewers : []
71
+ ].filter((value) => typeof value === "string" && value.length > 0);
72
+ return values.length > 0 ? [...new Set(values)] : void 0;
73
+ }
74
+ function teamReviewersFromIntent(intent) {
75
+ const reviewer = intent.params?.["teamReviewer"];
76
+ const reviewers = intent.params?.["teamReviewers"] ?? intent.params?.["team_reviewers"];
77
+ const values = [
78
+ ...typeof reviewer === "string" ? [reviewer] : [],
79
+ ...Array.isArray(reviewers) ? reviewers : []
80
+ ].filter((value) => typeof value === "string" && value.length > 0);
81
+ return values.length > 0 ? [...new Set(values)] : void 0;
82
+ }
83
+ function stringParam(intent, ...keys) {
84
+ for (const key of keys) {
85
+ const value = intent.params?.[key];
86
+ if (typeof value === "string" && value.length > 0) return value;
87
+ }
88
+ return void 0;
89
+ }
90
+ function stringArrayParam(intent, key) {
91
+ const value = intent.params?.[key];
92
+ if (!Array.isArray(value)) return [];
93
+ return value.filter((item) => typeof item === "string" && item.length > 0);
94
+ }
95
+ function verificationLinesFromIntent(intent) {
96
+ const value = intent.params?.["verification"];
97
+ if (!Array.isArray(value)) return [];
98
+ return value.map((item) => {
99
+ if (!item || typeof item !== "object" || Array.isArray(item)) return void 0;
100
+ const command = item["command"];
101
+ const outcome = item["outcome"];
102
+ return typeof command === "string" && typeof outcome === "string" ? `- \`${command}\`: ${outcome}` : void 0;
103
+ }).filter((line) => Boolean(line));
104
+ }
105
+ function pullRequestBodyFromIntent(intent) {
106
+ const explicitBody = stringParam(intent, "body");
107
+ const changedFiles = stringArrayParam(intent, "changedFiles");
108
+ const risks = stringArrayParam(intent, "risks");
109
+ const verification = verificationLinesFromIntent(intent);
110
+ const executorConditions = stringArrayParam(intent, "executorConditions");
111
+ const lines = explicitBody ? [explicitBody] : ["## Summary", "", intent.summary];
112
+ if (changedFiles.length > 0) {
113
+ lines.push("", "## Changed Files", ...changedFiles.map((file) => `- \`${file}\``));
114
+ }
115
+ if (risks.length > 0) {
116
+ lines.push("", "## Risks", ...risks.map((risk) => `- ${risk}`));
117
+ }
118
+ if (verification.length > 0) {
119
+ lines.push("", "## Verification", ...verification);
120
+ }
121
+ if (executorConditions.length > 0) {
122
+ lines.push("", "## Executor Conditions", ...executorConditions.map((condition) => `- ${condition}`));
123
+ }
124
+ return lines.join("\n");
125
+ }
126
+ function mappedValueFromIntent(intent) {
127
+ const key = intent.domain === "status" ? "status" : "priority";
128
+ const value = intent.params?.[key] ?? intent.params?.["value"];
129
+ return typeof value === "string" && value.length > 0 ? value : void 0;
130
+ }
131
+ function labelMappingForIntent(input) {
132
+ const semanticValue = mappedValueFromIntent(input.intent);
133
+ if (!semanticValue) return void 0;
134
+ const mapping = input.mappings.find(
135
+ (candidate) => candidate.adapter === "github" && candidate.domain === input.intent.domain && candidate.strategy === "label"
136
+ );
137
+ const label = mapping?.values[semanticValue];
138
+ if (!label || !mapping) return void 0;
139
+ return {
140
+ label,
141
+ removeLabels: Object.values(mapping.values).filter((mappedLabel) => mappedLabel !== label)
142
+ };
143
+ }
144
+ async function githubJson(input) {
145
+ const response = await input.fetchImpl(`https://api.github.com/repos/${input.target.owner}/${input.target.repo}${input.path}`, {
146
+ method: input.method,
147
+ headers: {
148
+ accept: "application/vnd.github+json",
149
+ authorization: `Bearer ${input.target.token}`,
150
+ "content-type": "application/json",
151
+ "x-github-api-version": "2022-11-28"
152
+ },
153
+ ...input.body ? { body: JSON.stringify(input.body) } : {}
154
+ });
155
+ if (!response.ok && !(input.okStatuses ?? []).includes(response.status)) {
156
+ throw new Error(`${input.method} ${input.path} failed: ${response.status} ${await response.text()}`);
157
+ }
158
+ return `https://github.com/${input.target.owner}/${input.target.repo}/issues/${input.target.issueNumber}`;
159
+ }
160
+ async function githubJsonBody(input) {
161
+ const response = await input.fetchImpl(`https://api.github.com/repos/${input.target.owner}/${input.target.repo}${input.path}`, {
162
+ method: input.method,
163
+ headers: {
164
+ accept: "application/vnd.github+json",
165
+ authorization: `Bearer ${input.target.token}`,
166
+ "content-type": "application/json",
167
+ "x-github-api-version": "2022-11-28"
168
+ }
169
+ });
170
+ if (!response.ok && !(input.okStatuses ?? []).includes(response.status)) {
171
+ throw new Error(`${input.method} ${input.path} failed: ${response.status} ${await response.text()}`);
172
+ }
173
+ return await response.json();
174
+ }
175
+ function requestedReviewerLogins(response) {
176
+ return new Set((response.users ?? []).map((user) => user.login).filter((login) => typeof login === "string"));
177
+ }
178
+ function requestedTeamReviewerNames(response) {
179
+ return new Set(
180
+ (response.teams ?? []).flatMap((team) => [team.slug, team.name]).filter((value) => typeof value === "string" && value.length > 0)
181
+ );
182
+ }
183
+ function compileGitHubIssueMutationIntent(intent, options = {}) {
184
+ if (intent.action === "create_pull_request") {
185
+ const head = stringParam(intent, "head", "branch");
186
+ if (!head) {
187
+ return {
188
+ ok: false,
189
+ outcome: {
190
+ intentId: intent.intentId,
191
+ outcome: "failed",
192
+ message: "create_pull_request requires params.head or params.branch."
193
+ }
194
+ };
195
+ }
196
+ return {
197
+ ok: true,
198
+ intentId: intent.intentId,
199
+ operation: {
200
+ kind: "create_pull_request",
201
+ intentId: intent.intentId,
202
+ title: stringParam(intent, "title") ?? intent.summary,
203
+ body: pullRequestBodyFromIntent(intent),
204
+ head,
205
+ base: stringParam(intent, "base", "baseBranch") ?? "main"
206
+ }
207
+ };
208
+ }
209
+ if (intent.action === "request_review" || intent.domain === "review") {
210
+ if (options.targetKind !== "pull_request") {
211
+ return {
212
+ ok: false,
213
+ outcome: {
214
+ intentId: intent.intentId,
215
+ outcome: "unsupported",
216
+ message: "GitHub review requests require a pull request target."
217
+ }
218
+ };
219
+ }
220
+ const reviewers = reviewersFromIntent(intent);
221
+ const teamReviewers = teamReviewersFromIntent(intent);
222
+ if (!reviewers?.length && !teamReviewers?.length) {
223
+ return {
224
+ ok: false,
225
+ outcome: {
226
+ intentId: intent.intentId,
227
+ outcome: "failed",
228
+ message: "request_review requires params.reviewer, params.reviewers, or params.teamReviewers."
229
+ }
230
+ };
231
+ }
232
+ return {
233
+ ok: true,
234
+ intentId: intent.intentId,
235
+ operation: {
236
+ kind: "request_review",
237
+ intentId: intent.intentId,
238
+ reviewers: reviewers ?? [],
239
+ ...teamReviewers?.length ? { teamReviewers } : {}
240
+ }
241
+ };
242
+ }
243
+ if (intent.domain === "status") {
244
+ const mapped = labelMappingForIntent({ intent, mappings: options.mappings ?? [] });
245
+ if (mapped) {
246
+ return { ok: true, intentId: intent.intentId, operation: { kind: "replace_mapped_label", intentId: intent.intentId, ...mapped } };
247
+ }
248
+ return {
249
+ ok: false,
250
+ outcome: {
251
+ intentId: intent.intentId,
252
+ outcome: "unsupported",
253
+ message: "GitHub status writes require an explicit Project field or label mapping policy."
254
+ }
255
+ };
256
+ }
257
+ if (intent.domain === "priority") {
258
+ const mapped = labelMappingForIntent({ intent, mappings: options.mappings ?? [] });
259
+ if (mapped) {
260
+ return { ok: true, intentId: intent.intentId, operation: { kind: "replace_mapped_label", intentId: intent.intentId, ...mapped } };
261
+ }
262
+ return {
263
+ ok: false,
264
+ outcome: {
265
+ intentId: intent.intentId,
266
+ outcome: "unsupported",
267
+ message: "GitHub priority writes require an explicit label or Project field mapping policy."
268
+ }
269
+ };
270
+ }
271
+ if (intent.domain !== "labels" && intent.domain !== "assignee") {
272
+ return {
273
+ ok: false,
274
+ outcome: {
275
+ intentId: intent.intentId,
276
+ outcome: "unsupported",
277
+ message: `GitHub apply supports labels and assignee only, not ${intent.domain}.`
278
+ }
279
+ };
280
+ }
281
+ if (intent.domain === "assignee") {
282
+ if (intent.action === "set_assignee") {
283
+ const assignee = assigneeFromIntent(intent);
284
+ return assignee ? { ok: true, intentId: intent.intentId, operation: { kind: "set_assignees", intentId: intent.intentId, assignees: [assignee] } } : { ok: false, outcome: { intentId: intent.intentId, outcome: "failed", message: "set_assignee requires params.assignee." } };
285
+ }
286
+ if (intent.action === "set_assignees") {
287
+ const assignees = assigneesFromIntent(intent);
288
+ return assignees ? { ok: true, intentId: intent.intentId, operation: { kind: "set_assignees", intentId: intent.intentId, assignees } } : { ok: false, outcome: { intentId: intent.intentId, outcome: "failed", message: "set_assignees requires params.assignees." } };
289
+ }
290
+ if (intent.action === "add_assignee") {
291
+ const assignee = assigneeFromIntent(intent);
292
+ return assignee ? { ok: true, intentId: intent.intentId, operation: { kind: "add_assignee", intentId: intent.intentId, assignee } } : { ok: false, outcome: { intentId: intent.intentId, outcome: "failed", message: "add_assignee requires params.assignee." } };
293
+ }
294
+ if (intent.action === "remove_assignee") {
295
+ const assignee = assigneeFromIntent(intent);
296
+ return assignee ? { ok: true, intentId: intent.intentId, operation: { kind: "remove_assignee", intentId: intent.intentId, assignee } } : { ok: false, outcome: { intentId: intent.intentId, outcome: "failed", message: "remove_assignee requires params.assignee." } };
297
+ }
298
+ return {
299
+ ok: false,
300
+ outcome: {
301
+ intentId: intent.intentId,
302
+ outcome: "unsupported",
303
+ message: `GitHub apply does not support assignee action ${intent.action}.`
304
+ }
305
+ };
306
+ }
307
+ if (intent.action === "add_label") {
308
+ const label = labelFromIntent(intent);
309
+ return label ? { ok: true, intentId: intent.intentId, operation: { kind: "add_label", intentId: intent.intentId, label } } : { ok: false, outcome: { intentId: intent.intentId, outcome: "failed", message: "add_label requires params.label." } };
310
+ }
311
+ if (intent.action === "remove_label") {
312
+ const label = labelFromIntent(intent);
313
+ return label ? { ok: true, intentId: intent.intentId, operation: { kind: "remove_label", intentId: intent.intentId, label } } : { ok: false, outcome: { intentId: intent.intentId, outcome: "failed", message: "remove_label requires params.label." } };
314
+ }
315
+ if (intent.action === "set_labels") {
316
+ const labels = labelsFromIntent(intent);
317
+ return labels ? { ok: true, intentId: intent.intentId, operation: { kind: "set_labels", intentId: intent.intentId, labels } } : { ok: false, outcome: { intentId: intent.intentId, outcome: "failed", message: "set_labels requires params.labels." } };
318
+ }
319
+ return {
320
+ ok: false,
321
+ outcome: {
322
+ intentId: intent.intentId,
323
+ outcome: "unsupported",
324
+ message: `GitHub apply does not support labels action ${intent.action}.`
325
+ }
326
+ };
327
+ }
328
+ function compileGitHubIssueMutationIntents(intents, options = {}) {
329
+ return intents.map((intent) => compileGitHubIssueMutationIntent(intent, options));
330
+ }
331
+ function createGitHubIssueMutationCompiler(options = {}) {
332
+ return {
333
+ adapter: "github",
334
+ compile(intent) {
335
+ const compilation = compileGitHubIssueMutationIntent(intent, options);
336
+ if (!compilation.ok) {
337
+ return {
338
+ ok: false,
339
+ adapter: "github",
340
+ outcome: compilation.outcome
341
+ };
342
+ }
343
+ return {
344
+ ok: true,
345
+ adapter: "github",
346
+ intentId: compilation.intentId,
347
+ operation: compilation.operation
348
+ };
349
+ }
350
+ };
351
+ }
352
+ async function applyGitHubIssueMutationOperation(input) {
353
+ const fetchImpl = input.fetchImpl ?? fetch;
354
+ try {
355
+ if (input.operation.kind === "create_pull_request") {
356
+ const externalUri2 = await createPullRequestViaFetch(
357
+ {
358
+ token: input.target.token,
359
+ owner: input.target.owner,
360
+ repo: input.target.repo,
361
+ title: input.operation.title,
362
+ body: input.operation.body,
363
+ head: input.operation.head,
364
+ base: input.operation.base
365
+ },
366
+ fetchImpl
367
+ );
368
+ return { intentId: input.operation.intentId, outcome: "applied", externalUri: externalUri2 };
369
+ }
370
+ if (input.operation.kind === "request_review") {
371
+ const pullRequestNumber = input.target.pullRequestNumber;
372
+ if (typeof pullRequestNumber !== "number") {
373
+ return {
374
+ intentId: input.operation.intentId,
375
+ outcome: "failed",
376
+ message: "request_review requires target.pullRequestNumber."
377
+ };
378
+ }
379
+ const requested = await githubJsonBody({
380
+ target: input.target,
381
+ fetchImpl,
382
+ method: "GET",
383
+ path: `/pulls/${pullRequestNumber}/requested_reviewers`
384
+ });
385
+ const existingReviewers = requestedReviewerLogins(requested);
386
+ const existingTeamReviewers = requestedTeamReviewerNames(requested);
387
+ const reviewers = input.operation.reviewers.filter((reviewer) => !existingReviewers.has(reviewer));
388
+ const teamReviewers = input.operation.teamReviewers?.filter((reviewer) => !existingTeamReviewers.has(reviewer));
389
+ if (reviewers.length === 0 && (!teamReviewers || teamReviewers.length === 0)) {
390
+ return {
391
+ intentId: input.operation.intentId,
392
+ outcome: "applied",
393
+ externalUri: `https://github.com/${input.target.owner}/${input.target.repo}/pull/${pullRequestNumber}`,
394
+ message: "Requested reviewers were already present; skipped GitHub notification retry."
395
+ };
396
+ }
397
+ await githubJson({
398
+ target: input.target,
399
+ fetchImpl,
400
+ method: "POST",
401
+ path: `/pulls/${pullRequestNumber}/requested_reviewers`,
402
+ body: {
403
+ ...reviewers.length ? { reviewers } : {},
404
+ ...teamReviewers?.length ? { team_reviewers: teamReviewers } : {}
405
+ }
406
+ });
407
+ return {
408
+ intentId: input.operation.intentId,
409
+ outcome: "applied",
410
+ externalUri: `https://github.com/${input.target.owner}/${input.target.repo}/pull/${pullRequestNumber}`
411
+ };
412
+ }
413
+ const issueNumber = input.target.issueNumber;
414
+ if (typeof issueNumber !== "number") {
415
+ return {
416
+ intentId: input.operation.intentId,
417
+ outcome: "failed",
418
+ message: "GitHub issue mutation requires target.issueNumber."
419
+ };
420
+ }
421
+ if (input.operation.kind === "set_assignees") {
422
+ const externalUri2 = await githubJson({
423
+ target: input.target,
424
+ fetchImpl,
425
+ method: "PATCH",
426
+ path: `/issues/${issueNumber}`,
427
+ body: { assignees: input.operation.assignees }
428
+ });
429
+ return { intentId: input.operation.intentId, outcome: "applied", externalUri: externalUri2 };
430
+ }
431
+ if (input.operation.kind === "add_assignee") {
432
+ const externalUri2 = await githubJson({
433
+ target: input.target,
434
+ fetchImpl,
435
+ method: "POST",
436
+ path: `/issues/${issueNumber}/assignees`,
437
+ body: { assignees: [input.operation.assignee] }
438
+ });
439
+ return { intentId: input.operation.intentId, outcome: "applied", externalUri: externalUri2 };
440
+ }
441
+ if (input.operation.kind === "remove_assignee") {
442
+ const externalUri2 = await githubJson({
443
+ target: input.target,
444
+ fetchImpl,
445
+ method: "DELETE",
446
+ path: `/issues/${issueNumber}/assignees`,
447
+ body: { assignees: [input.operation.assignee] }
448
+ });
449
+ return { intentId: input.operation.intentId, outcome: "applied", externalUri: externalUri2 };
450
+ }
451
+ if (input.operation.kind === "replace_mapped_label") {
452
+ for (const label of input.operation.removeLabels) {
453
+ await githubJson({
454
+ target: input.target,
455
+ fetchImpl,
456
+ method: "DELETE",
457
+ path: `/issues/${issueNumber}/labels/${encodeURIComponent(label)}`,
458
+ okStatuses: [200, 404]
459
+ });
460
+ }
461
+ const externalUri2 = await githubJson({
462
+ target: input.target,
463
+ fetchImpl,
464
+ method: "POST",
465
+ path: `/issues/${issueNumber}/labels`,
466
+ body: { labels: [input.operation.label] }
467
+ });
468
+ return { intentId: input.operation.intentId, outcome: "applied", externalUri: externalUri2 };
469
+ }
470
+ if (input.operation.kind === "add_label") {
471
+ const externalUri2 = await githubJson({
472
+ target: input.target,
473
+ fetchImpl,
474
+ method: "POST",
475
+ path: `/issues/${issueNumber}/labels`,
476
+ body: { labels: [input.operation.label] }
477
+ });
478
+ return { intentId: input.operation.intentId, outcome: "applied", externalUri: externalUri2 };
479
+ }
480
+ if (input.operation.kind === "remove_label") {
481
+ const externalUri2 = await githubJson({
482
+ target: input.target,
483
+ fetchImpl,
484
+ method: "DELETE",
485
+ path: `/issues/${issueNumber}/labels/${encodeURIComponent(input.operation.label)}`
486
+ });
487
+ return { intentId: input.operation.intentId, outcome: "applied", externalUri: externalUri2 };
488
+ }
489
+ const externalUri = await githubJson({
490
+ target: input.target,
491
+ fetchImpl,
492
+ method: "PUT",
493
+ path: `/issues/${issueNumber}/labels`,
494
+ body: { labels: input.operation.labels }
495
+ });
496
+ return { intentId: input.operation.intentId, outcome: "applied", externalUri };
497
+ } catch (error) {
498
+ return {
499
+ intentId: input.operation.intentId,
500
+ outcome: "failed",
501
+ error: error instanceof Error ? error.message : String(error)
502
+ };
503
+ }
504
+ }
505
+ async function applyGitHubIssueMutationIntent(input) {
506
+ const compiled = compileGitHubIssueMutationIntent(input.intent, {
507
+ ...input.mappings ? { mappings: input.mappings } : {},
508
+ ...input.targetKind ? { targetKind: input.targetKind } : {}
509
+ });
510
+ if (!compiled.ok) return compiled.outcome;
511
+ return applyGitHubIssueMutationOperation({
512
+ target: input.target,
513
+ operation: compiled.operation,
514
+ ...input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}
515
+ });
516
+ }
517
+ async function applyGitHubIssueMutationIntents(input) {
518
+ const outcomes = [];
519
+ for (const intent of input.intents) {
520
+ outcomes.push(
521
+ await applyGitHubIssueMutationIntent({
522
+ target: input.target,
523
+ intent,
524
+ ...input.mappings ? { mappings: input.mappings } : {},
525
+ ...input.targetKind ? { targetKind: input.targetKind } : {},
526
+ ...input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}
527
+ })
528
+ );
529
+ }
530
+ return outcomes;
531
+ }
532
+
533
+ // src/ingress.ts
534
+ import { createHmac, randomUUID, timingSafeEqual } from "crypto";
535
+ import { serve } from "@hono/node-server";
536
+ import { createOpenTagClient } from "@opentag/client";
537
+ import { parseThreadActionCommand } from "@opentag/core";
538
+ import { Hono } from "hono";
539
+
1
540
  // src/normalize.ts
2
541
  import { parseOpenTagMention } from "@opentag/core";
3
542
  function permissionsForIntent(intent) {
@@ -29,9 +568,77 @@ function permissionsForIntent(intent) {
29
568
  }
30
569
  return permissions;
31
570
  }
571
+ function permissionsForPullRequestReviewCommentIntent(intent) {
572
+ const permissions = permissionsForIntent(intent);
573
+ if (intent === "review") {
574
+ permissions.push({
575
+ scope: "pr:update",
576
+ reason: "request reviewers on the source pull request after explicit approval"
577
+ });
578
+ }
579
+ return permissions;
580
+ }
581
+ function contextPointersForCommand(command, privateRepo) {
582
+ const visibility = privateRepo ? "private" : "public";
583
+ const context = [];
584
+ for (const reference of command.parsed?.references ?? []) {
585
+ if (reference.kind === "url") {
586
+ context.push({
587
+ kind: "url",
588
+ uri: reference.uri,
589
+ visibility,
590
+ title: reference.title ?? "Command URL reference"
591
+ });
592
+ continue;
593
+ }
594
+ if (reference.kind === "file" || reference.kind === "path" || reference.kind === "line" || reference.kind === "range") {
595
+ context.push({
596
+ kind: "file",
597
+ uri: reference.uri,
598
+ ...reference.line ? { line: reference.line } : {},
599
+ ...reference.startLine ? { startLine: reference.startLine } : {},
600
+ ...reference.endLine ? { endLine: reference.endLine } : {},
601
+ visibility,
602
+ title: referenceTitle(reference)
603
+ });
604
+ }
605
+ }
606
+ return context;
607
+ }
608
+ function referenceTitle(reference) {
609
+ return reference.title ?? "Command file reference";
610
+ }
611
+ function githubWorkItem(input) {
612
+ return {
613
+ provider: "github",
614
+ kind: input.kind,
615
+ externalId: `${input.owner}/${input.repo}#${input.number}`,
616
+ uri: input.uri,
617
+ ownerContainer: {
618
+ provider: "github",
619
+ id: `${input.owner}/${input.repo}`,
620
+ uri: `https://github.com/${input.owner}/${input.repo}`
621
+ }
622
+ };
623
+ }
624
+ function commandMetadata(command) {
625
+ if (!command.parsed) return {};
626
+ return {
627
+ commandParser: command.parsed.version,
628
+ commandDiagnostics: command.parsed.diagnostics,
629
+ ...command.parsed.approval ? { approval: command.parsed.approval } : {},
630
+ ...command.parsed.network ? { network: command.parsed.network } : {}
631
+ };
632
+ }
32
633
  function normalizeGitHubIssueComment(input) {
33
634
  const mention = parseOpenTagMention(input.commentBody);
34
635
  if (!mention.matched) return null;
636
+ const command = {
637
+ rawText: mention.rawText,
638
+ intent: mention.intent,
639
+ args: mention.args,
640
+ ...mention.parsed ? { parsed: mention.parsed } : {}
641
+ };
35
642
  return {
36
643
  id: `evt_github_comment_${input.id}`,
37
644
  source: "github",
@@ -44,35 +651,44 @@ function normalizeGitHubIssueComment(input) {
44
651
  },
45
652
  target: {
46
653
  mention: "@opentag",
47
- agentId: "opentag"
48
- },
49
- command: {
50
- rawText: mention.rawText,
51
- intent: mention.intent,
52
- args: mention.args
654
+ agentId: "opentag",
655
+ ...mention.parsed?.executorHint ? { executorHint: mention.parsed.executorHint } : {}
53
656
  },
657
+ command,
54
658
  context: [
55
659
  {
56
- kind: "github.issue",
660
+ provider: "github",
661
+ kind: "issue",
57
662
  uri: input.issueUrl,
58
663
  visibility: input.private ? "private" : "public"
59
664
  },
60
665
  {
61
- kind: "github.comment",
666
+ provider: "github",
667
+ kind: "comment",
62
668
  uri: input.commentUrl,
63
669
  visibility: input.private ? "private" : "public"
64
- }
670
+ },
671
+ ...contextPointersForCommand(command, input.private)
65
672
  ],
673
+ workItem: githubWorkItem({
674
+ owner: input.owner,
675
+ repo: input.repo,
676
+ kind: "issue",
677
+ number: input.issueNumber,
678
+ uri: input.issueUrl
679
+ }),
66
680
  permissions: permissionsForIntent(mention.intent),
67
681
  callback: {
68
682
  provider: "github",
69
683
  uri: input.apiCommentsUrl,
70
- threadKey: `${input.owner}/${input.repo}`
684
+ threadKey: `${input.owner}/${input.repo}#${input.issueNumber}`
71
685
  },
72
686
  metadata: {
687
+ repoProvider: "github",
73
688
  owner: input.owner,
74
689
  repo: input.repo,
75
690
  issueNumber: input.issueNumber,
691
+ ...commandMetadata(command),
76
692
  ...typeof input.installationId === "number" ? { installationId: input.installationId } : {}
77
693
  }
78
694
  };
@@ -80,6 +696,12 @@ function normalizeGitHubIssueComment(input) {
80
696
  function normalizeGitHubPullRequestReviewComment(input) {
81
697
  const mention = parseOpenTagMention(input.commentBody);
82
698
  if (!mention.matched) return null;
699
+ const command = {
700
+ rawText: mention.rawText,
701
+ intent: mention.intent,
702
+ args: mention.args,
703
+ ...mention.parsed ? { parsed: mention.parsed } : {}
704
+ };
83
705
  return {
84
706
  id: `evt_github_pr_review_comment_${input.id}`,
85
707
  source: "github",
@@ -92,84 +714,327 @@ function normalizeGitHubPullRequestReviewComment(input) {
92
714
  },
93
715
  target: {
94
716
  mention: "@opentag",
95
- agentId: "opentag"
96
- },
97
- command: {
98
- rawText: mention.rawText,
99
- intent: mention.intent,
100
- args: mention.args
717
+ agentId: "opentag",
718
+ ...mention.parsed?.executorHint ? { executorHint: mention.parsed.executorHint } : {}
101
719
  },
720
+ command,
102
721
  context: [
103
722
  {
104
- kind: "github.pull_request",
723
+ provider: "github",
724
+ kind: "pull_request",
105
725
  uri: input.pullRequestUrl,
106
726
  visibility: input.private ? "private" : "public"
107
727
  },
108
728
  {
109
- kind: "github.comment",
729
+ provider: "github",
730
+ kind: "comment",
110
731
  uri: input.commentUrl,
111
732
  visibility: input.private ? "private" : "public"
112
- }
733
+ },
734
+ ...contextPointersForCommand(command, input.private)
113
735
  ],
114
- permissions: permissionsForIntent(mention.intent),
736
+ workItem: githubWorkItem({
737
+ owner: input.owner,
738
+ repo: input.repo,
739
+ kind: "pull_request",
740
+ number: input.pullRequestNumber,
741
+ uri: input.pullRequestUrl
742
+ }),
743
+ permissions: permissionsForPullRequestReviewCommentIntent(mention.intent),
115
744
  callback: {
116
745
  provider: "github",
117
746
  uri: input.apiCommentsUrl,
118
747
  threadKey: `${input.owner}/${input.repo}#${input.pullRequestNumber}`
119
748
  },
120
749
  metadata: {
750
+ repoProvider: "github",
121
751
  owner: input.owner,
122
752
  repo: input.repo,
123
753
  pullRequestNumber: input.pullRequestNumber,
754
+ ...commandMetadata(command),
124
755
  ...typeof input.installationId === "number" ? { installationId: input.installationId } : {}
125
756
  }
126
757
  };
127
758
  }
128
759
 
129
- // src/pull-request.ts
130
- function buildPullRequestBody(result) {
131
- const lines = ["## Summary", "", result.summary];
132
- if (result.changedFiles?.length) {
133
- lines.push("", "## Changed Files");
134
- for (const file of result.changedFiles) {
135
- lines.push(`- \`${file}\``);
136
- }
760
+ // src/ingress.ts
761
+ function computeGitHubSignature(input) {
762
+ const digest = createHmac("sha256", input.webhookSecret).update(input.rawBody).digest("hex");
763
+ return `sha256=${digest}`;
764
+ }
765
+ function verifyGitHubSignature(input) {
766
+ const expected = computeGitHubSignature(input);
767
+ const expectedBuffer = Buffer.from(expected);
768
+ const actualBuffer = Buffer.from(input.signature);
769
+ return expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer);
770
+ }
771
+ function parseJsonPayload(rawBody) {
772
+ try {
773
+ return JSON.parse(rawBody);
774
+ } catch {
775
+ return null;
137
776
  }
138
- if (result.verification?.length) {
139
- lines.push("", "## Verification");
140
- for (const check of result.verification) {
141
- lines.push(`- \`${check.command}\`: ${check.outcome}`);
142
- }
777
+ }
778
+ async function handleIssueCommentCreated(input) {
779
+ if (input.payload.action && input.payload.action !== "created") return;
780
+ if (parseThreadActionCommand(input.payload.comment.body) && input.submitThreadAction) {
781
+ await input.submitThreadAction({
782
+ id: `approval_github_comment_${input.payload.comment.id}`,
783
+ rawText: input.payload.comment.body,
784
+ actor: {
785
+ provider: "github",
786
+ providerUserId: String(input.payload.sender.id),
787
+ handle: input.payload.sender.login
788
+ },
789
+ callback: {
790
+ provider: "github",
791
+ uri: input.payload.issue.comments_url,
792
+ threadKey: `${input.payload.repository.owner.login}/${input.payload.repository.name}#${input.payload.issue.number}`
793
+ },
794
+ metadata: {
795
+ repoProvider: "github",
796
+ owner: input.payload.repository.owner.login,
797
+ repo: input.payload.repository.name,
798
+ issueNumber: input.payload.issue.number,
799
+ commentUrl: input.payload.comment.html_url
800
+ }
801
+ });
802
+ return;
803
+ }
804
+ const event = normalizeGitHubIssueComment({
805
+ id: String(input.payload.comment.id),
806
+ commentBody: input.payload.comment.body,
807
+ commentUrl: input.payload.comment.html_url,
808
+ apiCommentsUrl: input.payload.issue.comments_url,
809
+ issueUrl: input.payload.issue.html_url,
810
+ issueNumber: input.payload.issue.number,
811
+ owner: input.payload.repository.owner.login,
812
+ repo: input.payload.repository.name,
813
+ actorId: input.payload.sender.id,
814
+ actorLogin: input.payload.sender.login,
815
+ private: input.payload.repository.private,
816
+ receivedAt: input.now(),
817
+ ...input.payload.installation ? { installationId: input.payload.installation.id } : {}
818
+ });
819
+ if (event) {
820
+ await input.createRun(event);
143
821
  }
144
- return lines.join("\n");
145
822
  }
146
- async function createPullRequestViaFetch(input, fetchImpl = fetch) {
147
- const response = await fetchImpl(`https://api.github.com/repos/${input.owner}/${input.repo}/pulls`, {
148
- method: "POST",
149
- headers: {
150
- accept: "application/vnd.github+json",
151
- authorization: `Bearer ${input.token}`,
152
- "content-type": "application/json",
153
- "x-github-api-version": "2022-11-28"
154
- },
155
- body: JSON.stringify({
156
- title: input.title,
157
- body: input.body,
158
- head: input.head,
159
- base: input.base
160
- })
823
+ async function handlePullRequestReviewCommentCreated(input) {
824
+ if (input.payload.action && input.payload.action !== "created") return;
825
+ const owner = input.payload.repository.owner.login;
826
+ const repo = input.payload.repository.name;
827
+ if (parseThreadActionCommand(input.payload.comment.body) && input.submitThreadAction) {
828
+ await input.submitThreadAction({
829
+ id: `approval_github_pr_review_comment_${input.payload.comment.id}`,
830
+ rawText: input.payload.comment.body,
831
+ actor: {
832
+ provider: "github",
833
+ providerUserId: String(input.payload.sender.id),
834
+ handle: input.payload.sender.login
835
+ },
836
+ callback: {
837
+ provider: "github",
838
+ uri: `https://api.github.com/repos/${owner}/${repo}/issues/${input.payload.pull_request.number}/comments`,
839
+ threadKey: `${owner}/${repo}#${input.payload.pull_request.number}`
840
+ },
841
+ metadata: {
842
+ repoProvider: "github",
843
+ owner,
844
+ repo,
845
+ pullRequestNumber: input.payload.pull_request.number,
846
+ commentUrl: input.payload.comment.html_url
847
+ }
848
+ });
849
+ return;
850
+ }
851
+ const event = normalizeGitHubPullRequestReviewComment({
852
+ id: String(input.payload.comment.id),
853
+ commentBody: input.payload.comment.body,
854
+ commentUrl: input.payload.comment.html_url,
855
+ pullRequestUrl: input.payload.pull_request.html_url,
856
+ apiCommentsUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${input.payload.pull_request.number}/comments`,
857
+ owner,
858
+ repo,
859
+ pullRequestNumber: input.payload.pull_request.number,
860
+ actorId: input.payload.sender.id,
861
+ actorLogin: input.payload.sender.login,
862
+ private: input.payload.repository.private,
863
+ receivedAt: input.now(),
864
+ ...input.payload.installation ? { installationId: input.payload.installation.id } : {}
161
865
  });
162
- if (!response.ok) {
163
- throw new Error(`create pull request failed: ${response.status} ${await response.text()}`);
866
+ if (event) {
867
+ await input.createRun(event);
164
868
  }
165
- const body = await response.json();
166
- if (!body.html_url) {
167
- throw new Error("create pull request response did not include html_url");
869
+ }
870
+ function createGitHubWebhookApp(input) {
871
+ const app = new Hono();
872
+ const webhookPath = input.webhookPath ?? "/github/webhooks";
873
+ if (!webhookPath.startsWith("/")) {
874
+ throw new Error("GitHub webhook path must start with /.");
168
875
  }
169
- return body.html_url;
876
+ app.post(webhookPath, async (c) => {
877
+ const signature = c.req.header("x-hub-signature-256");
878
+ if (!signature) {
879
+ return c.json({ error: "missing_signature_header" }, 401);
880
+ }
881
+ const rawBody = await c.req.text();
882
+ if (!verifyGitHubSignature({ webhookSecret: input.webhookSecret, rawBody, signature })) {
883
+ return c.json({ error: "invalid_signature" }, 401);
884
+ }
885
+ const eventName = c.req.header("x-github-event");
886
+ const payload = parseJsonPayload(rawBody);
887
+ if (!payload || typeof payload !== "object") {
888
+ return c.json({ error: "invalid_json" }, 400);
889
+ }
890
+ if (eventName === "ping") {
891
+ return c.json({ ok: true });
892
+ }
893
+ if (eventName === "issue_comment") {
894
+ await handleIssueCommentCreated({
895
+ payload,
896
+ createRun: input.createRun,
897
+ ...input.submitThreadAction ? { submitThreadAction: input.submitThreadAction } : {},
898
+ now: input.now
899
+ });
900
+ return c.json({ ok: true });
901
+ }
902
+ if (eventName === "pull_request_review_comment") {
903
+ await handlePullRequestReviewCommentCreated({
904
+ payload,
905
+ createRun: input.createRun,
906
+ ...input.submitThreadAction ? { submitThreadAction: input.submitThreadAction } : {},
907
+ now: input.now
908
+ });
909
+ return c.json({ ok: true });
910
+ }
911
+ return c.json({ ok: true, ignored: "unsupported_event" });
912
+ });
913
+ return app;
914
+ }
915
+ function startGitHubIngress(config) {
916
+ const dispatcherClient = createOpenTagClient({
917
+ dispatcherUrl: config.dispatcherUrl,
918
+ ...config.dispatcherToken ? { pairingToken: config.dispatcherToken } : {}
919
+ });
920
+ const port = config.port ?? 3e3;
921
+ const hostname = config.hostname ?? "127.0.0.1";
922
+ const webhookPath = config.webhookPath ?? "/github/webhooks";
923
+ const server = serve({
924
+ fetch: createGitHubWebhookApp({
925
+ webhookSecret: config.webhookSecret,
926
+ webhookPath,
927
+ async createRun(event) {
928
+ const runId = `run_${randomUUID()}`;
929
+ const created = await dispatcherClient.createRun({ runId, event });
930
+ return created.outcome === "run_created" ? { runId: created.run.id } : {};
931
+ },
932
+ async submitThreadAction(action) {
933
+ await dispatcherClient.submitThreadAction(action);
934
+ },
935
+ now: () => (/* @__PURE__ */ new Date()).toISOString()
936
+ }).fetch,
937
+ port,
938
+ hostname
939
+ });
940
+ return {
941
+ url: `http://${hostname}:${port}`,
942
+ webhookPath,
943
+ server,
944
+ close() {
945
+ return new Promise((resolve, reject) => {
946
+ server.close((error) => {
947
+ if (error) {
948
+ reject(error);
949
+ return;
950
+ }
951
+ resolve();
952
+ });
953
+ });
954
+ }
955
+ };
170
956
  }
171
957
 
172
958
  // src/render.ts
959
+ import { suggestedActionCandidatesFromResult } from "@opentag/core";
960
+ function nextActionSummary(result) {
961
+ if (!result.nextAction) return void 0;
962
+ if (typeof result.nextAction === "string") return result.nextAction;
963
+ return result.nextAction.summary;
964
+ }
965
+ function stringParam2(params, key) {
966
+ const value = params?.[key];
967
+ return typeof value === "string" && value.length > 0 ? value : void 0;
968
+ }
969
+ function stringArrayParam2(params, key) {
970
+ const value = params?.[key];
971
+ if (!Array.isArray(value)) return [];
972
+ return value.filter((item) => typeof item === "string" && item.length > 0);
973
+ }
974
+ function renderVerificationParams(params) {
975
+ const value = params?.["verification"];
976
+ if (!Array.isArray(value)) return [];
977
+ return value.map((item) => {
978
+ if (!item || typeof item !== "object" || Array.isArray(item)) return void 0;
979
+ const command = item["command"];
980
+ const outcome = item["outcome"];
981
+ return typeof command === "string" && typeof outcome === "string" ? ` - \`${command}\`: ${outcome}` : void 0;
982
+ }).filter((line) => Boolean(line));
983
+ }
984
+ function renderSuggestedActionDetails(params, action) {
985
+ if (action !== "create_pull_request") return [];
986
+ const lines = [];
987
+ const title = stringParam2(params, "title");
988
+ const head = stringParam2(params, "head") ?? stringParam2(params, "branch");
989
+ const base = stringParam2(params, "base") ?? stringParam2(params, "baseBranch");
990
+ const changedFiles = stringArrayParam2(params, "changedFiles");
991
+ const risks = stringArrayParam2(params, "risks");
992
+ const verification = renderVerificationParams(params);
993
+ if (title) lines.push(` Title: ${title}`);
994
+ if (head || base) lines.push(` Branch: \`${head ?? "unknown"}\` -> \`${base ?? "main"}\``);
995
+ if (changedFiles.length > 0) lines.push(` Changed files: ${changedFiles.map((file) => `\`${file}\``).join(", ")}`);
996
+ if (risks.length > 0) {
997
+ lines.push(" Risks:");
998
+ for (const risk of risks) {
999
+ lines.push(` - ${risk}`);
1000
+ }
1001
+ }
1002
+ if (verification.length > 0) {
1003
+ lines.push(" Verification:");
1004
+ lines.push(...verification);
1005
+ }
1006
+ return lines;
1007
+ }
1008
+ function renderSuggestedActions(result) {
1009
+ const candidates = suggestedActionCandidatesFromResult(result);
1010
+ if (candidates.length === 0) return [];
1011
+ const lines = ["Suggested actions:"];
1012
+ for (const candidate of candidates) {
1013
+ lines.push(
1014
+ "",
1015
+ `${candidate.index}. **${candidate.intent.summary}**`,
1016
+ ` Intent: \`${candidate.intent.action}\` (\`${candidate.intent.domain}\`)`,
1017
+ ` Proposal: \`${candidate.proposalId}\``,
1018
+ ` Intent ID: \`${candidate.intent.intentId}\``
1019
+ );
1020
+ lines.push(...renderSuggestedActionDetails(candidate.intent.params, candidate.intent.action));
1021
+ if (candidate.proposalPreconditions?.length) {
1022
+ lines.push(" Preconditions:");
1023
+ for (const precondition of candidate.proposalPreconditions) {
1024
+ lines.push(` - ${precondition}`);
1025
+ }
1026
+ }
1027
+ }
1028
+ lines.push(
1029
+ "",
1030
+ "Reply with:",
1031
+ "- `approve 1` to record approval without applying yet",
1032
+ "- `apply 1` or `apply all` to apply supported actions",
1033
+ "- `continue 1` to continue with a follow-up run",
1034
+ "- `reject 1` to reject an action"
1035
+ );
1036
+ return lines;
1037
+ }
173
1038
  function renderAcknowledgement(runId) {
174
1039
  return `OpenTag picked this up. Run: \`${runId}\``;
175
1040
  }
@@ -184,18 +1049,33 @@ function renderFinalResult(result) {
184
1049
  lines.push(`- \`${check.command}\`: ${check.outcome}`);
185
1050
  }
186
1051
  }
187
- if (result.nextAction) {
188
- lines.push("", `Next action: ${result.nextAction}`);
1052
+ const nextAction = nextActionSummary(result);
1053
+ if (nextAction) {
1054
+ lines.push("", `Next action: ${nextAction}`);
1055
+ }
1056
+ const suggestedActions = renderSuggestedActions(result);
1057
+ if (suggestedActions.length > 0) {
1058
+ lines.push("", ...suggestedActions);
189
1059
  }
190
1060
  return lines.join("\n");
191
1061
  }
192
1062
  export {
1063
+ applyGitHubIssueMutationIntent,
1064
+ applyGitHubIssueMutationIntents,
1065
+ applyGitHubIssueMutationOperation,
193
1066
  buildPullRequestBody,
1067
+ compileGitHubIssueMutationIntent,
1068
+ compileGitHubIssueMutationIntents,
1069
+ computeGitHubSignature,
1070
+ createGitHubIssueMutationCompiler,
1071
+ createGitHubWebhookApp,
194
1072
  createPullRequestViaFetch,
195
1073
  normalizeGitHubIssueComment,
196
1074
  normalizeGitHubPullRequestReviewComment,
197
1075
  renderAcknowledgement,
198
1076
  renderFinalResult,
199
- renderProgress
1077
+ renderProgress,
1078
+ startGitHubIngress,
1079
+ verifyGitHubSignature
200
1080
  };
201
1081
  //# sourceMappingURL=index.js.map