pi-ghwebhook 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.
@@ -2,7 +2,7 @@ import { readFile, access } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
- import type { GhWebhookConfig } from "../types/schema.js";
5
+ import type { GhWebhookWorkflow } from "../types/schema.js";
6
6
 
7
7
  const execFileAsync = promisify(execFile);
8
8
 
@@ -23,14 +23,14 @@ async function fileExists(path: string): Promise<boolean> {
23
23
 
24
24
  export async function loadProjectConfig(
25
25
  cwd: string,
26
- ): Promise<GhWebhookConfig | null> {
26
+ ): Promise<GhWebhookWorkflow | null> {
27
27
  for (const relPath of CONFIG_CANDIDATES) {
28
28
  const fullPath = join(cwd, relPath);
29
29
  if (await fileExists(fullPath)) {
30
30
  try {
31
31
  const content = await readFile(fullPath, "utf8");
32
- const parsed = JSON.parse(content) as GhWebhookConfig;
33
- if (parsed && typeof parsed === "object" && parsed.version === 1) {
32
+ const parsed = JSON.parse(content) as GhWebhookWorkflow;
33
+ if (parsed && typeof parsed === "object" && parsed.on && parsed.jobs) {
34
34
  return parsed;
35
35
  }
36
36
  } catch (err) {
package/src/index.ts CHANGED
@@ -30,7 +30,7 @@ export default function ghWebhookExtension(pi: ExtensionAPI): void {
30
30
  pi.on("session_start", async (_event, ctx) => {
31
31
  // 1. 工作区探测:若当前项目无 .pi/ghwebhook.json,则静默休眠,零资源占用
32
32
  const config = await loadProjectConfig(ctx.cwd);
33
- if (!config || !config.enabled) {
33
+ if (!config) {
34
34
  return;
35
35
  }
36
36
 
@@ -51,8 +51,8 @@ export default function ghWebhookExtension(pi: ExtensionAPI): void {
51
51
  return;
52
52
  }
53
53
 
54
- // 3. 构建表驱动事件总线
55
- const bus = new WebhookEventBus(config.subscriptions);
54
+ // 3. 构建 GitHub Actions Workflow 事件总线
55
+ const bus = new WebhookEventBus(config);
56
56
 
57
57
  // 4. 启动随机端口本地回环服务器
58
58
  loopback = new LoopbackServer({
@@ -80,7 +80,7 @@ export default function ghWebhookExtension(pi: ExtensionAPI): void {
80
80
  return;
81
81
  }
82
82
 
83
- // 5. 托管 gh webhook forward 长连接子进程(默认全量监听 --events=*)
83
+ // 5. 托管 gh webhook forward 长连接子进程(全量监听)
84
84
  forwarder = new GhForwarder({
85
85
  repo,
86
86
  url: loopback.getUrl(),
@@ -93,8 +93,9 @@ export default function ghWebhookExtension(pi: ExtensionAPI): void {
93
93
  forwarder.start();
94
94
 
95
95
  if (ctx.hasUI) {
96
+ const displayName = config.name ? `[${config.name}]` : "[pi-ghwebhook]";
96
97
  ctx.ui.notify(
97
- `[pi-ghwebhook] 已监听 ${repo} (${currentBranch}) GitHub 事件流 (端口: ${loopback.getPort()})`,
98
+ `${displayName} 已监听 ${repo} (${currentBranch}) 事件流 (端口: ${loopback.getPort()})`,
98
99
  "info",
99
100
  );
100
101
  }
@@ -1,91 +1,50 @@
1
1
  import { Type, type Static } from "typebox";
2
2
 
3
- export const FilterSchema = Type.Object(
3
+ export const EventTriggerSchema = Type.Object(
4
4
  {
5
- branch: Type.Optional(
6
- Type.String({ description: "Target branch name or '@current'" }),
5
+ types: Type.Optional(
6
+ Type.Array(Type.String(), { description: "Event activity types" }),
7
7
  ),
8
- conclusion: Type.Optional(
9
- Type.String({
10
- description: "Conclusion filter (e.g. 'failure', 'success')",
11
- }),
12
- ),
13
- workflow: Type.Optional(
14
- Type.String({ description: "Workflow name filter (e.g. 'CI')" }),
15
- ),
16
- state: Type.Optional(
17
- Type.String({
18
- description:
19
- "Review or PR state filter (e.g. 'changes_requested', 'approved')",
8
+ branches: Type.Optional(
9
+ Type.Array(Type.String(), {
10
+ description: "Branch filters, supports '@current' and globs",
20
11
  }),
21
12
  ),
22
- senders: Type.Optional(
23
- Type.Array(Type.String(), { description: "Allowed sender logins" }),
24
- ),
25
- isBot: Type.Optional(
26
- Type.Boolean({
27
- description: "Filter for bot (true) or human (false) senders",
28
- }),
29
- ),
30
- match: Type.Optional(
31
- Type.Record(
32
- Type.String(),
33
- Type.Union([Type.String(), Type.Number(), Type.Boolean()]),
34
- {
35
- description:
36
- "Arbitrary field matching on payload (dot-separated path)",
37
- },
38
- ),
39
- ),
40
- },
41
- { additionalProperties: false },
42
- );
43
-
44
- export type FilterConfig = Static<typeof FilterSchema>;
45
-
46
- export const ActionSchema = Type.Object(
47
- {
48
- prompt: Type.String({
49
- description: "Template string to dispatch to agent",
50
- }),
51
- deliverAs: Type.Optional(
52
- Type.Union([Type.Literal("steer"), Type.Literal("followUp")], {
53
- description:
54
- "Delivery urgency: steer interrupts turn, followUp queues next",
55
- }),
13
+ workflows: Type.Optional(
14
+ Type.Array(Type.String(), { description: "Workflow names filter" }),
56
15
  ),
57
16
  },
58
- { additionalProperties: false },
17
+ { additionalProperties: true },
59
18
  );
60
19
 
61
- export type ActionConfig = Static<typeof ActionSchema>;
20
+ export type EventTriggerConfig = Static<typeof EventTriggerSchema>;
62
21
 
63
- export const SubscriptionRuleSchema = Type.Object(
22
+ export const JobSchema = Type.Object(
64
23
  {
65
- event: Type.Optional(
24
+ name: Type.Optional(Type.String({ description: "Optional job name" })),
25
+ if: Type.Optional(
66
26
  Type.String({
67
- description:
68
- "Upstream GitHub Webhook event identifier (e.g. 'workflow_run.completed', 'push', 'issues.opened')",
27
+ description: "Condition expression (e.g. \"conclusion == 'failure'\")",
69
28
  }),
70
29
  ),
71
- topic: Type.Optional(
72
- Type.String({
30
+ prompt: Type.String({ description: "Template string sent to Pi Agent" }),
31
+ deliverAs: Type.Optional(
32
+ Type.Union([Type.Literal("steer"), Type.Literal("followUp")], {
73
33
  description:
74
- "Alias for 'event' (e.g. 'workflow_run.completed' or 'github.workflow_run.completed')",
34
+ "Delivery urgency: steer interrupts turn, followUp queues next",
35
+ default: "followUp",
75
36
  }),
76
37
  ),
77
- filters: Type.Optional(FilterSchema),
78
- action: ActionSchema,
79
38
  },
80
39
  { additionalProperties: false },
81
40
  );
82
41
 
83
- export type SubscriptionRule = Static<typeof SubscriptionRuleSchema>;
42
+ export type JobConfig = Static<typeof JobSchema>;
84
43
 
85
- export const ConfigSchema = Type.Object(
44
+ export const WorkflowSchema = Type.Object(
86
45
  {
87
- version: Type.Literal(1, { description: "Schema version" }),
88
- enabled: Type.Boolean({ default: true }),
46
+ $schema: Type.Optional(Type.String()),
47
+ name: Type.Optional(Type.String({ description: "Workflow display name" })),
89
48
  repo: Type.Optional(
90
49
  Type.String({
91
50
  default: "auto",
@@ -95,11 +54,20 @@ export const ConfigSchema = Type.Object(
95
54
  secret: Type.Optional(
96
55
  Type.String({ description: "Optional Webhook HMAC secret" }),
97
56
  ),
98
- subscriptions: Type.Array(SubscriptionRuleSchema, {
99
- description: "List of declarative webhook event subscriptions",
100
- }),
57
+ on: Type.Union(
58
+ [
59
+ Type.String(),
60
+ Type.Array(Type.String()),
61
+ Type.Record(
62
+ Type.String(),
63
+ Type.Union([Type.Null(), EventTriggerSchema]),
64
+ ),
65
+ ],
66
+ { description: "GitHub event triggers specification" },
67
+ ),
68
+ jobs: Type.Record(Type.String(), JobSchema, { description: "Jobs map" }),
101
69
  },
102
70
  { additionalProperties: false },
103
71
  );
104
72
 
105
- export type GhWebhookConfig = Static<typeof ConfigSchema>;
73
+ export type GhWebhookWorkflow = Static<typeof WorkflowSchema>;
package/src/bus/guards.ts DELETED
@@ -1,118 +0,0 @@
1
- import type { WebhookPayload } from "../types/payload.js";
2
- import type { FilterConfig } from "../types/schema.js";
3
-
4
- export interface FilterContext {
5
- currentBranch: string;
6
- payload: WebhookPayload;
7
- }
8
-
9
- export type GuardPredicate = (
10
- expectedValue: unknown,
11
- ctx: FilterContext,
12
- ) => boolean;
13
-
14
- function resolvePayloadBranch(payload: WebhookPayload): string | undefined {
15
- return (
16
- payload.workflow_run?.head_branch ??
17
- payload.pull_request?.head?.ref ??
18
- payload.check_suite?.head_branch ??
19
- payload.workflow_job?.head_branch ??
20
- (typeof payload.ref === "string"
21
- ? payload.ref.replace("refs/heads/", "")
22
- : undefined)
23
- );
24
- }
25
-
26
- function resolvePayloadSender(payload: WebhookPayload): string | undefined {
27
- return (
28
- payload.sender?.login ??
29
- payload.review?.user?.login ??
30
- payload.comment?.user?.login
31
- );
32
- }
33
-
34
- function getNestedValue(obj: Record<string, any>, path: string): unknown {
35
- const parts = path.split(".");
36
- let current: any = obj;
37
- for (const part of parts) {
38
- if (current == null || typeof current !== "object") return undefined;
39
- current = current[part];
40
- }
41
- return current;
42
- }
43
-
44
- const branchGuard: GuardPredicate = (expected, { currentBranch, payload }) => {
45
- const target = expected === "@current" ? currentBranch : expected;
46
- const actualBranch = resolvePayloadBranch(payload);
47
- return typeof target === "string" && actualBranch === target;
48
- };
49
-
50
- const conclusionGuard: GuardPredicate = (expected, { payload }) => {
51
- const actual =
52
- payload.workflow_run?.conclusion ??
53
- payload.check_suite?.conclusion ??
54
- payload.workflow_job?.conclusion;
55
- return typeof expected === "string" && actual === expected;
56
- };
57
-
58
- const workflowGuard: GuardPredicate = (expected, { payload }) => {
59
- const workflowName =
60
- payload.workflow_run?.name ?? payload.workflow_job?.workflow_name;
61
- return typeof expected === "string" && workflowName === expected;
62
- };
63
-
64
- const stateGuard: GuardPredicate = (expected, { payload }) => {
65
- const actual = payload.review?.state ?? payload.pull_request?.state;
66
- return typeof expected === "string" && actual === expected;
67
- };
68
-
69
- const sendersGuard: GuardPredicate = (allowedList, { payload }) => {
70
- if (!Array.isArray(allowedList)) return true;
71
- const sender = resolvePayloadSender(payload);
72
- return typeof sender === "string" && allowedList.includes(sender);
73
- };
74
-
75
- const isBotGuard: GuardPredicate = (expected, { payload }) => {
76
- const senderType = payload.review?.user?.type ?? payload.sender?.type;
77
- const login = payload.review?.user?.login ?? payload.sender?.login ?? "";
78
- const isBot = senderType === "Bot" || login.endsWith("[bot]");
79
- return isBot === Boolean(expected);
80
- };
81
-
82
- const matchGuard: GuardPredicate = (matchObj, { payload }) => {
83
- if (!matchObj || typeof matchObj !== "object") return true;
84
- for (const [path, expected] of Object.entries(
85
- matchObj as Record<string, unknown>,
86
- )) {
87
- const actual = getNestedValue(payload, path);
88
- if (actual !== expected) return false;
89
- }
90
- return true;
91
- };
92
-
93
- export const GUARDS: ReadonlyMap<keyof FilterConfig, GuardPredicate> = new Map([
94
- ["branch", branchGuard],
95
- ["conclusion", conclusionGuard],
96
- ["workflow", workflowGuard],
97
- ["state", stateGuard],
98
- ["senders", sendersGuard],
99
- ["isBot", isBotGuard],
100
- ["match", matchGuard],
101
- ]);
102
-
103
- export function evaluateGuards(
104
- filters: FilterConfig | undefined,
105
- ctx: FilterContext,
106
- ): boolean {
107
- if (!filters) return true;
108
-
109
- for (const [key, value] of Object.entries(filters)) {
110
- if (value === undefined) continue;
111
- const guard = GUARDS.get(key as keyof FilterConfig);
112
- if (guard && !guard(value, ctx)) {
113
- return false;
114
- }
115
- }
116
-
117
- return true;
118
- }
@@ -1,32 +0,0 @@
1
- import type { WebhookPayload } from "../types/payload.js";
2
-
3
- /**
4
- * Normalizes an event identifier by stripping optional "github." prefix.
5
- * e.g. "github.workflow_run.completed" -> "workflow_run.completed"
6
- */
7
- export function normalizeEventKey(key: string): string {
8
- const trimmed = key.trim();
9
- return trimmed.startsWith("github.") ? trimmed.slice(7) : trimmed;
10
- }
11
-
12
- /**
13
- * Resolves candidate event keys from an incoming GitHub Webhook event and payload.
14
- * Upstream convention:
15
- * 1. If payload has action: `${event}.${action}` (e.g. "workflow_run.completed", "pull_request.opened")
16
- * 2. Wildcard/base event: `${event}` (e.g. "workflow_run", "push", "issues")
17
- */
18
- export function resolveIncomingEventKeys(
19
- event: string,
20
- payload: WebhookPayload,
21
- ): string[] {
22
- const normalizedEvent = normalizeEventKey(event);
23
- const action =
24
- typeof payload.action === "string" ? payload.action.trim() : undefined;
25
-
26
- const keys: string[] = [];
27
- if (action) {
28
- keys.push(`${normalizedEvent}.${action}`);
29
- }
30
- keys.push(normalizedEvent);
31
- return keys;
32
- }