@skelm/integrations 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,60 @@ export default pipeline({
49
49
  })
50
50
  ```
51
51
 
52
+ ## GitHub REST helpers
53
+
54
+ For PR-review and webhook-management workflows the package also exports
55
+ standalone REST helpers that take a plain `GitHubAuth` and don't require an
56
+ integration instance. They are the recommended path for PR-review pipelines
57
+ that previously shelled out to the `gh` CLI.
58
+
59
+ ```ts
60
+ import {
61
+ postPullRequestReview,
62
+ postIssueComment,
63
+ registerWebhook,
64
+ deleteWebhook,
65
+ getAuthenticatedUser,
66
+ GitHubApiError,
67
+ } from '@skelm/integrations'
68
+
69
+ const auth = { token: process.env.GITHUB_TOKEN! }
70
+
71
+ // Post a review against a PR.
72
+ await postPullRequestReview({
73
+ auth,
74
+ owner: 'octo',
75
+ repo: 'demo',
76
+ number: 42,
77
+ event: 'REQUEST_CHANGES', // 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'
78
+ body: 'See inline comments.',
79
+ comments: [{ path: 'src/x.ts', line: 12, body: 'nit: rename' }],
80
+ })
81
+
82
+ // Drop a follow-up issue comment.
83
+ await postIssueComment({ auth, owner: 'octo', repo: 'demo', number: 42, body: 'pong' })
84
+
85
+ // Register a webhook (returns the hook id for later cleanup).
86
+ const hook = await registerWebhook({
87
+ auth, owner: 'octo', repo: 'demo',
88
+ url: 'https://gateway.example/hooks/gh',
89
+ secret: process.env.GITHUB_WEBHOOK_SECRET,
90
+ events: ['pull_request', 'issue_comment', 'pull_request_review'],
91
+ })
92
+ await deleteWebhook({ auth, owner: 'octo', repo: 'demo', hookId: hook.id })
93
+ ```
94
+
95
+ Failed calls raise `GitHubApiError` with `status`, `method`, `path`, and the
96
+ parsed response body. The helpers warn to stderr when GitHub's
97
+ `X-RateLimit-Remaining` drops below 10 % of the quota so operators see
98
+ throttling coming before it bites.
99
+
100
+ The `GitHubIntegration` class wires the same helpers through
101
+ `performHealthCheck` (`GET /user`), `setupWebhook` (`POST /hooks`),
102
+ `cleanupWebhook` (`DELETE /hooks/:id`), and `sendNotification` (routes to
103
+ `POST /issues/:n/comments` by default, or to `POST /pulls/:n/reviews` when
104
+ `options.kind === 'pr-review'`).
105
+
52
106
  ## Built-in connectors
53
107
 
54
108
  | Connector | Status | Trigger types |
@@ -0,0 +1,107 @@
1
+ export type GitHubPrEventKind = 'opened' | 'synchronize' | 'reopened' | 'closed' | 'review_requested' | 'commented' | 'submitted';
2
+ export interface GitHubPrTriggerSpec {
3
+ /** Trigger id. Used by the coordinator's registration. */
4
+ readonly id: string;
5
+ /** Workflow id this trigger fires. */
6
+ readonly workflowId: string;
7
+ /** Webhook path the gateway should listen on (e.g. `/hooks/github/prs`). */
8
+ readonly path: string;
9
+ /**
10
+ * Optional HMAC shared secret. When set, the helper verifies GitHub's
11
+ * `x-hub-signature-256` header against an HMAC of the raw body. Mismatches
12
+ * raise a permission denial; missing headers raise an error.
13
+ */
14
+ readonly secret?: string;
15
+ /** Subset of GitHub PR-related event kinds to forward. Default: every kind below. */
16
+ readonly events?: readonly GitHubPrEventKind[];
17
+ /** Filters applied to the normalized payload before firing the pipeline. */
18
+ readonly filter?: {
19
+ readonly dropBotAuthors?: boolean;
20
+ readonly repos?: readonly string[];
21
+ };
22
+ /**
23
+ * Dedupe TTL in milliseconds. Defaults to 24 h (matches GitHub's
24
+ * redelivery window). The header is fixed to `X-GitHub-Delivery`.
25
+ */
26
+ readonly dedupeTtlMs?: number;
27
+ }
28
+ export interface GitHubPrPayload {
29
+ /** Normalized event kind. */
30
+ readonly kind: GitHubPrEventKind;
31
+ /** PR identity + key fields needed for diff/review pipelines. */
32
+ readonly pr: {
33
+ readonly owner: string;
34
+ readonly repo: string;
35
+ readonly number: number;
36
+ readonly headSha: string;
37
+ readonly baseSha: string;
38
+ readonly author: string;
39
+ readonly labels: readonly string[];
40
+ };
41
+ /** Author classification. PR-review agents typically skip bot-authored PRs. */
42
+ readonly authorIsBot: boolean;
43
+ /** Original GitHub event name (e.g. 'pull_request', 'issue_comment', …). */
44
+ readonly githubEvent: string;
45
+ /** Original GitHub action (e.g. 'opened', 'synchronize', …). */
46
+ readonly action: string;
47
+ /** Raw webhook payload, in case the pipeline needs fields beyond the normalized ones. */
48
+ readonly raw: unknown;
49
+ }
50
+ /**
51
+ * Translate a GitHub webhook delivery into the normalized payload, or null
52
+ * when the delivery is not PR-related or fails the spec's filter.
53
+ *
54
+ * `githubEvent` is the value of the `x-github-event` header
55
+ * (`pull_request`, `issue_comment`, `pull_request_review`,
56
+ * `pull_request_review_comment`). The function inspects `body.action` and
57
+ * the PR fields to map onto `GitHubPrEventKind`.
58
+ */
59
+ export declare function normalizeGitHubPrEvent(githubEvent: string, body: unknown, spec?: Pick<GitHubPrTriggerSpec, 'events' | 'filter'>): GitHubPrPayload | null;
60
+ /**
61
+ * Verify GitHub's `x-hub-signature-256` HMAC against the raw body. The
62
+ * comparison itself is constant-time (`timingSafeEqual`), but the *bytes*
63
+ * passed as `rawBody` must match what GitHub signed — and a parsed-then-
64
+ * re-stringified JSON body generally won't, because JSON serialization
65
+ * isn't round-trip-stable (whitespace, key ordering, unicode escapes).
66
+ * For exact verification operators should configure a raw-body shim and
67
+ * pass the original request bytes here.
68
+ */
69
+ export declare function verifyGitHubSignature(rawBody: string, signature: string, secret: string): boolean;
70
+ /**
71
+ * Minimal coordinator surface the helper needs. Matches the public methods
72
+ * of `gateway.managers.triggers` (TriggerCoordinator) so callers can pass
73
+ * the manager directly without a wrapper type.
74
+ */
75
+ export interface GitHubPrTriggerCoordinator {
76
+ register(spec: {
77
+ kind: 'webhook';
78
+ id: string;
79
+ workflowId: string;
80
+ path: string;
81
+ method?: string;
82
+ secret?: string;
83
+ dedupe?: {
84
+ header: string;
85
+ ttlMs?: number;
86
+ };
87
+ }, overlap?: 'skip' | 'queue' | 'cancel', options?: {
88
+ input?: unknown;
89
+ }): unknown;
90
+ }
91
+ /**
92
+ * Translate a declarative `GitHubPrTriggerSpec` into a registered webhook
93
+ * trigger on the coordinator. Returns a `normalize()` function the caller
94
+ * (typically the gateway's dispatcher) can apply to incoming webhook
95
+ * payloads to produce the `GitHubPrPayload` the pipeline expects as input.
96
+ *
97
+ * The dispatcher is responsible for routing the normalized payload to the
98
+ * pipeline's run; integrators usually do this by registering a
99
+ * `pre-dispatch` hook that calls `normalize()` on the raw webhook envelope
100
+ * captured in `payload.body`.
101
+ */
102
+ export declare function registerGitHubPrTrigger(coordinator: GitHubPrTriggerCoordinator, spec: GitHubPrTriggerSpec): {
103
+ normalize(rawWebhookPayload: {
104
+ body: unknown;
105
+ headers: Record<string, string>;
106
+ }): GitHubPrPayload | null;
107
+ };
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `github-pr` trigger primitive.
3
+ *
4
+ * Wraps the gateway's `webhook` trigger + dedupe + integration event mapping
5
+ * into a single declarative shape: a pipeline file declares
6
+ *
7
+ * triggers: [{ kind: 'github-pr', path: '/hooks/gh-pr', events: [...], filter: {...} }]
8
+ *
9
+ * and `registerGitHubPrTrigger()` translates that into a webhook trigger on
10
+ * the coordinator with `X-GitHub-Delivery` dedupe enabled and an onFire
11
+ * wrapper that normalizes the GitHub webhook payload into a stable
12
+ * `GitHubPrPayload` shape before dispatching the pipeline run.
13
+ *
14
+ * Filtering (dropBotAuthors, repos allowlist, event kinds) is applied
15
+ * pre-dispatch; rejected deliveries respond 200 (so GitHub doesn't retry)
16
+ * but don't fire the pipeline.
17
+ */
18
+ import { createHmac, timingSafeEqual } from 'node:crypto';
19
+ const ALL_EVENT_KINDS = [
20
+ 'opened',
21
+ 'synchronize',
22
+ 'reopened',
23
+ 'closed',
24
+ 'review_requested',
25
+ 'commented',
26
+ 'submitted',
27
+ ];
28
+ /**
29
+ * Translate a GitHub webhook delivery into the normalized payload, or null
30
+ * when the delivery is not PR-related or fails the spec's filter.
31
+ *
32
+ * `githubEvent` is the value of the `x-github-event` header
33
+ * (`pull_request`, `issue_comment`, `pull_request_review`,
34
+ * `pull_request_review_comment`). The function inspects `body.action` and
35
+ * the PR fields to map onto `GitHubPrEventKind`.
36
+ */
37
+ export function normalizeGitHubPrEvent(githubEvent, body, spec) {
38
+ const b = body;
39
+ if (b === null || typeof b !== 'object')
40
+ return null;
41
+ const action = typeof b.action === 'string' ? b.action : '';
42
+ // Map (event, action) → kind. Reject events that aren't PR-related.
43
+ const kind = mapKind(githubEvent, action);
44
+ if (kind === null)
45
+ return null;
46
+ // The PR object lives under different paths depending on the event:
47
+ // - pull_request: b.pull_request
48
+ // - pull_request_review: b.pull_request
49
+ // - pull_request_review_comment: b.pull_request
50
+ // - issue_comment (on a PR): b.issue (with b.issue.pull_request set)
51
+ const pr = b.pull_request ?? extractPrFromIssue(b);
52
+ if (pr === undefined)
53
+ return null;
54
+ const head = pr.head ?? {};
55
+ const base = pr.base ?? {};
56
+ const baseRepo = base.repo ?? {};
57
+ const owner = baseRepo.owner?.login ?? '';
58
+ const repo = baseRepo.name ?? '';
59
+ const number = pr.number;
60
+ const headSha = head.sha ?? '';
61
+ const baseSha = base.sha ?? '';
62
+ const user = pr.user ?? {};
63
+ const author = user.login ?? '';
64
+ const authorIsBot = user.type === 'Bot';
65
+ const labelsRaw = Array.isArray(pr.labels) ? pr.labels : [];
66
+ const labels = labelsRaw.map((l) => l.name ?? '').filter((s) => s !== '');
67
+ const payload = {
68
+ kind,
69
+ pr: { owner, repo, number, headSha, baseSha, author, labels },
70
+ authorIsBot,
71
+ githubEvent,
72
+ action,
73
+ raw: body,
74
+ };
75
+ if (!passesFilter(payload, spec))
76
+ return null;
77
+ return payload;
78
+ }
79
+ function mapKind(githubEvent, action) {
80
+ if (githubEvent === 'pull_request') {
81
+ if (action === 'opened')
82
+ return 'opened';
83
+ if (action === 'synchronize')
84
+ return 'synchronize';
85
+ if (action === 'reopened')
86
+ return 'reopened';
87
+ if (action === 'closed')
88
+ return 'closed';
89
+ if (action === 'review_requested')
90
+ return 'review_requested';
91
+ return null;
92
+ }
93
+ if (githubEvent === 'pull_request_review' && action === 'submitted')
94
+ return 'submitted';
95
+ if (githubEvent === 'pull_request_review_comment')
96
+ return 'commented';
97
+ if (githubEvent === 'issue_comment')
98
+ return 'commented';
99
+ return null;
100
+ }
101
+ function extractPrFromIssue(body) {
102
+ const issue = body.issue;
103
+ if (issue === undefined)
104
+ return undefined;
105
+ if (issue.pull_request === undefined)
106
+ return undefined;
107
+ return issue;
108
+ }
109
+ function passesFilter(payload, spec) {
110
+ const events = spec?.events ?? ALL_EVENT_KINDS;
111
+ if (!events.includes(payload.kind))
112
+ return false;
113
+ const filter = spec?.filter;
114
+ if (filter?.dropBotAuthors === true && payload.authorIsBot)
115
+ return false;
116
+ if (filter?.repos !== undefined && filter.repos.length > 0) {
117
+ const slug = `${payload.pr.owner}/${payload.pr.repo}`;
118
+ if (!filter.repos.includes(slug))
119
+ return false;
120
+ }
121
+ return true;
122
+ }
123
+ /**
124
+ * Verify GitHub's `x-hub-signature-256` HMAC against the raw body. The
125
+ * comparison itself is constant-time (`timingSafeEqual`), but the *bytes*
126
+ * passed as `rawBody` must match what GitHub signed — and a parsed-then-
127
+ * re-stringified JSON body generally won't, because JSON serialization
128
+ * isn't round-trip-stable (whitespace, key ordering, unicode escapes).
129
+ * For exact verification operators should configure a raw-body shim and
130
+ * pass the original request bytes here.
131
+ */
132
+ export function verifyGitHubSignature(rawBody, signature, secret) {
133
+ if (!signature.startsWith('sha256='))
134
+ return false;
135
+ const provided = signature.slice('sha256='.length);
136
+ const computed = createHmac('sha256', secret).update(rawBody).digest('hex');
137
+ if (provided.length !== computed.length)
138
+ return false;
139
+ return timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(computed, 'hex'));
140
+ }
141
+ /**
142
+ * Translate a declarative `GitHubPrTriggerSpec` into a registered webhook
143
+ * trigger on the coordinator. Returns a `normalize()` function the caller
144
+ * (typically the gateway's dispatcher) can apply to incoming webhook
145
+ * payloads to produce the `GitHubPrPayload` the pipeline expects as input.
146
+ *
147
+ * The dispatcher is responsible for routing the normalized payload to the
148
+ * pipeline's run; integrators usually do this by registering a
149
+ * `pre-dispatch` hook that calls `normalize()` on the raw webhook envelope
150
+ * captured in `payload.body`.
151
+ */
152
+ export function registerGitHubPrTrigger(coordinator, spec) {
153
+ coordinator.register({
154
+ kind: 'webhook',
155
+ id: spec.id,
156
+ workflowId: spec.workflowId,
157
+ path: spec.path,
158
+ method: 'POST',
159
+ ...(spec.secret !== undefined && { secret: spec.secret }),
160
+ dedupe: { header: 'X-GitHub-Delivery', ttlMs: spec.dedupeTtlMs ?? 24 * 60 * 60 * 1000 },
161
+ });
162
+ return {
163
+ normalize(rawWebhookPayload) {
164
+ const event = rawWebhookPayload.headers['x-github-event'] ?? rawWebhookPayload.headers['X-GitHub-Event'];
165
+ if (typeof event !== 'string' || event === '')
166
+ return null;
167
+ return normalizeGitHubPrEvent(event, rawWebhookPayload.body, spec);
168
+ },
169
+ };
170
+ }
package/dist/github.d.ts CHANGED
@@ -1,15 +1,126 @@
1
+ /**
2
+ * Error raised by the GitHub REST helpers when an API call fails with a
3
+ * non-2xx status. `status` is the HTTP status code; `body` is the parsed
4
+ * JSON body if present, otherwise the raw text.
5
+ */
6
+ export declare class GitHubApiError extends Error {
7
+ readonly status: number;
8
+ readonly method: string;
9
+ readonly path: string;
10
+ readonly body: unknown;
11
+ readonly name = "GitHubApiError";
12
+ constructor(status: number, method: string, path: string, body: unknown);
13
+ }
14
+ /** Credentials accepted by the standalone REST helpers. */
15
+ export interface GitHubAuth {
16
+ readonly token: string;
17
+ readonly apiBase?: string;
18
+ }
19
+ interface GitHubRequest {
20
+ readonly auth: GitHubAuth;
21
+ readonly method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
22
+ readonly path: string;
23
+ readonly body?: unknown;
24
+ /** Per-request timeout in ms; default 30s. A hung GitHub edge cannot
25
+ * hold a gateway request handler indefinitely. */
26
+ readonly timeoutMs?: number;
27
+ }
28
+ /**
29
+ * Make a JSON-bodied GitHub REST call. Returns the parsed JSON response;
30
+ * throws `GitHubApiError` on non-2xx responses. Warns to stderr when the
31
+ * remaining rate-limit budget drops below 10 % so operators see throttling
32
+ * coming before it bites.
33
+ */
34
+ export declare function githubFetch<T = unknown>(req: GitHubRequest): Promise<T>;
35
+ /** Verify a credential by calling `GET /user`. Returns true on 200. */
36
+ export declare function getAuthenticatedUser(auth: GitHubAuth): Promise<{
37
+ login: string;
38
+ id: number;
39
+ }>;
40
+ export interface RegisterWebhookParams {
41
+ readonly auth: GitHubAuth;
42
+ readonly owner: string;
43
+ readonly repo: string;
44
+ readonly url: string;
45
+ readonly secret?: string;
46
+ readonly events?: readonly string[];
47
+ readonly active?: boolean;
48
+ }
49
+ export interface GitHubHook {
50
+ readonly id: number;
51
+ readonly url: string;
52
+ }
53
+ /** Register a webhook on `owner/repo`. Returns the created hook's id + url. */
54
+ export declare function registerWebhook(params: RegisterWebhookParams): Promise<GitHubHook>;
55
+ export interface DeleteWebhookParams {
56
+ readonly auth: GitHubAuth;
57
+ readonly owner: string;
58
+ readonly repo: string;
59
+ readonly hookId: number;
60
+ }
61
+ export declare function deleteWebhook(params: DeleteWebhookParams): Promise<void>;
62
+ export interface PostIssueCommentParams {
63
+ readonly auth: GitHubAuth;
64
+ readonly owner: string;
65
+ readonly repo: string;
66
+ readonly number: number;
67
+ readonly body: string;
68
+ }
69
+ export declare function postIssueComment(params: PostIssueCommentParams): Promise<{
70
+ id: number;
71
+ htmlUrl: string;
72
+ }>;
73
+ export interface PullRequestReviewComment {
74
+ readonly path: string;
75
+ readonly body: string;
76
+ /** Single-line comment (line in the diff hunk). */
77
+ readonly line?: number;
78
+ /** Side of the diff: 'LEFT' for the base, 'RIGHT' for the head (default). */
79
+ readonly side?: 'LEFT' | 'RIGHT';
80
+ /** For multi-line comments: starting line in the same side. */
81
+ readonly start_line?: number;
82
+ readonly start_side?: 'LEFT' | 'RIGHT';
83
+ }
84
+ export interface PostPullRequestReviewParams {
85
+ readonly auth: GitHubAuth;
86
+ readonly owner: string;
87
+ readonly repo: string;
88
+ readonly number: number;
89
+ readonly event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT';
90
+ readonly body?: string;
91
+ readonly comments?: readonly PullRequestReviewComment[];
92
+ /** Commit SHA the review applies to. Defaults to the PR's current head. */
93
+ readonly commitId?: string;
94
+ }
95
+ /**
96
+ * Post a pull request review (the call PR-review agents most need).
97
+ *
98
+ * `event` maps to GitHub's review action: APPROVE / REQUEST_CHANGES /
99
+ * COMMENT. Body is the summary; per-line `comments` are inline review
100
+ * comments anchored to the diff.
101
+ */
102
+ export declare function postPullRequestReview(params: PostPullRequestReviewParams): Promise<{
103
+ id: number;
104
+ htmlUrl: string;
105
+ }>;
1
106
  /**
2
107
  * GitHub integration for skelm pipelines.
3
108
  *
4
109
  * Supports:
5
110
  * - Issue/PR triggers
6
- * - Webhook event handling
111
+ * - Webhook event handling (real REST: POST /repos/:owner/:repo/hooks)
7
112
  * - Repository polling
8
- * - Notifications via issue/PR comments
113
+ * - Notifications via issue/PR comments and PR reviews
114
+ *
115
+ * For PR-review pipelines, prefer the standalone `postPullRequestReview()`,
116
+ * `postIssueComment()`, and `registerWebhook()` helpers — they accept a
117
+ * `GitHubAuth` directly and do not require an integration instance.
9
118
  */
10
119
  export declare const GitHubIntegration: import("@skelm/integration-sdk").IntegrationClass<{
11
120
  token: string;
12
121
  ownerId: string;
13
122
  repoName: string;
123
+ apiBase?: string | undefined;
14
124
  }>;
15
125
  export type GitHubIntegrationType = InstanceType<typeof GitHubIntegration>;
126
+ export {};
package/dist/github.js CHANGED
@@ -1,18 +1,166 @@
1
1
  import { defineIntegration } from '@skelm/integration-sdk';
2
2
  import { z } from 'zod';
3
+ const DEFAULT_API_BASE = 'https://api.github.com';
4
+ const SKELM_USER_AGENT = 'skelm-integrations/1.0';
3
5
  const githubCredentialsSchema = z.object({
4
6
  token: z.string().min(1, 'GitHub token is required'),
5
7
  ownerId: z.string().min(1, 'GitHub ownerId is required'),
6
8
  repoName: z.string().min(1, 'GitHub repoName is required'),
9
+ apiBase: z.string().url().optional(),
7
10
  });
11
+ /**
12
+ * Error raised by the GitHub REST helpers when an API call fails with a
13
+ * non-2xx status. `status` is the HTTP status code; `body` is the parsed
14
+ * JSON body if present, otherwise the raw text.
15
+ */
16
+ export class GitHubApiError extends Error {
17
+ status;
18
+ method;
19
+ path;
20
+ body;
21
+ name = 'GitHubApiError';
22
+ constructor(status, method, path, body) {
23
+ super(`GitHub ${method} ${path} failed with ${status}: ${formatBody(body)}`);
24
+ this.status = status;
25
+ this.method = method;
26
+ this.path = path;
27
+ this.body = body;
28
+ }
29
+ }
30
+ function formatBody(body) {
31
+ if (typeof body === 'string')
32
+ return body;
33
+ if (body && typeof body === 'object' && 'message' in body) {
34
+ return String(body.message);
35
+ }
36
+ try {
37
+ return JSON.stringify(body);
38
+ }
39
+ catch {
40
+ return String(body);
41
+ }
42
+ }
43
+ const DEFAULT_GITHUB_TIMEOUT_MS = 30_000;
44
+ /**
45
+ * Make a JSON-bodied GitHub REST call. Returns the parsed JSON response;
46
+ * throws `GitHubApiError` on non-2xx responses. Warns to stderr when the
47
+ * remaining rate-limit budget drops below 10 % so operators see throttling
48
+ * coming before it bites.
49
+ */
50
+ export async function githubFetch(req) {
51
+ const base = req.auth.apiBase ?? DEFAULT_API_BASE;
52
+ const url = `${base}${req.path}`;
53
+ const headers = {
54
+ Accept: 'application/vnd.github+json',
55
+ Authorization: `Bearer ${req.auth.token}`,
56
+ 'User-Agent': SKELM_USER_AGENT,
57
+ 'X-GitHub-Api-Version': '2022-11-28',
58
+ };
59
+ if (req.body !== undefined)
60
+ headers['Content-Type'] = 'application/json';
61
+ const init = {
62
+ method: req.method,
63
+ headers,
64
+ signal: AbortSignal.timeout(req.timeoutMs ?? DEFAULT_GITHUB_TIMEOUT_MS),
65
+ ...(req.body !== undefined && { body: JSON.stringify(req.body) }),
66
+ };
67
+ const res = await fetch(url, init);
68
+ const remaining = Number(res.headers.get('x-ratelimit-remaining'));
69
+ const limit = Number(res.headers.get('x-ratelimit-limit'));
70
+ if (Number.isFinite(remaining) && Number.isFinite(limit) && limit > 0) {
71
+ if (remaining / limit < 0.1) {
72
+ process.stderr.write(`[github-integration] rate limit warning: ${remaining}/${limit} remaining for ${req.method} ${req.path}\n`);
73
+ }
74
+ }
75
+ const text = await res.text();
76
+ const parsed = text.length > 0 ? safeJsonParse(text) : undefined;
77
+ if (!res.ok) {
78
+ throw new GitHubApiError(res.status, req.method, req.path, parsed ?? text);
79
+ }
80
+ return parsed;
81
+ }
82
+ function safeJsonParse(text) {
83
+ try {
84
+ return JSON.parse(text);
85
+ }
86
+ catch {
87
+ return text;
88
+ }
89
+ }
90
+ /** Verify a credential by calling `GET /user`. Returns true on 200. */
91
+ export async function getAuthenticatedUser(auth) {
92
+ return await githubFetch({ auth, method: 'GET', path: '/user' });
93
+ }
94
+ /** Register a webhook on `owner/repo`. Returns the created hook's id + url. */
95
+ export async function registerWebhook(params) {
96
+ const body = {
97
+ name: 'web',
98
+ active: params.active ?? true,
99
+ events: params.events ?? ['*'],
100
+ config: {
101
+ url: params.url,
102
+ content_type: 'json',
103
+ ...(params.secret !== undefined && { secret: params.secret }),
104
+ },
105
+ };
106
+ const hook = await githubFetch({
107
+ auth: params.auth,
108
+ method: 'POST',
109
+ path: `/repos/${params.owner}/${params.repo}/hooks`,
110
+ body,
111
+ });
112
+ return { id: hook.id, url: hook.url };
113
+ }
114
+ export async function deleteWebhook(params) {
115
+ await githubFetch({
116
+ auth: params.auth,
117
+ method: 'DELETE',
118
+ path: `/repos/${params.owner}/${params.repo}/hooks/${params.hookId}`,
119
+ });
120
+ }
121
+ export async function postIssueComment(params) {
122
+ const res = await githubFetch({
123
+ auth: params.auth,
124
+ method: 'POST',
125
+ path: `/repos/${params.owner}/${params.repo}/issues/${params.number}/comments`,
126
+ body: { body: params.body },
127
+ });
128
+ return { id: res.id, htmlUrl: res.html_url };
129
+ }
130
+ /**
131
+ * Post a pull request review (the call PR-review agents most need).
132
+ *
133
+ * `event` maps to GitHub's review action: APPROVE / REQUEST_CHANGES /
134
+ * COMMENT. Body is the summary; per-line `comments` are inline review
135
+ * comments anchored to the diff.
136
+ */
137
+ export async function postPullRequestReview(params) {
138
+ const body = {
139
+ event: params.event,
140
+ ...(params.body !== undefined && { body: params.body }),
141
+ ...(params.commitId !== undefined && { commit_id: params.commitId }),
142
+ ...(params.comments !== undefined && { comments: params.comments }),
143
+ };
144
+ const res = await githubFetch({
145
+ auth: params.auth,
146
+ method: 'POST',
147
+ path: `/repos/${params.owner}/${params.repo}/pulls/${params.number}/reviews`,
148
+ body,
149
+ });
150
+ return { id: res.id, htmlUrl: res.html_url };
151
+ }
8
152
  /**
9
153
  * GitHub integration for skelm pipelines.
10
154
  *
11
155
  * Supports:
12
156
  * - Issue/PR triggers
13
- * - Webhook event handling
157
+ * - Webhook event handling (real REST: POST /repos/:owner/:repo/hooks)
14
158
  * - Repository polling
15
- * - Notifications via issue/PR comments
159
+ * - Notifications via issue/PR comments and PR reviews
160
+ *
161
+ * For PR-review pipelines, prefer the standalone `postPullRequestReview()`,
162
+ * `postIssueComment()`, and `registerWebhook()` helpers — they accept a
163
+ * `GitHubAuth` directly and do not require an integration instance.
16
164
  */
17
165
  export const GitHubIntegration = defineIntegration({
18
166
  id: 'github',
@@ -24,25 +172,51 @@ export const GitHubIntegration = defineIntegration({
24
172
  canSendNotifications: true,
25
173
  },
26
174
  credentialsSchema: githubCredentialsSchema,
27
- async validateCredentials(creds) {
28
- // Warn on unexpected token formats but don't hard-fail — fine-grained
29
- // tokens don't share the ghp_/gho_ prefixes.
30
- const { token } = creds;
31
- if (!token.startsWith('ghp_') && !token.startsWith('gho_') && !token.startsWith('github_')) {
32
- console.warn('GitHub token does not match expected patterns (ghp_/gho_/github_)');
33
- }
175
+ async validateCredentials(_creds) {
176
+ // Token-prefix sniffing produced false positives (fine-grained tokens,
177
+ // GitHub Apps, enterprise issuers all use shapes that do not match
178
+ // ghp_/gho_/github_). Validation now defers to performHealthCheck,
179
+ // which makes a real API call.
34
180
  },
35
181
  async performHealthCheck(creds) {
36
- // In production: call GET /user or GET /repos/:owner/:repo
37
- return typeof creds.token === 'string' && creds.token.length > 0;
182
+ try {
183
+ const auth = {
184
+ token: creds.token,
185
+ ...(creds.apiBase !== undefined && { apiBase: creds.apiBase }),
186
+ };
187
+ await getAuthenticatedUser(auth);
188
+ return true;
189
+ }
190
+ catch (err) {
191
+ if (err instanceof GitHubApiError)
192
+ return false;
193
+ throw err;
194
+ }
38
195
  },
39
- async setupWebhook(_creds, config, webhook) {
40
- // In production: POST /repos/:owner/:repo/hooks
41
- console.log(`GitHub webhook would be registered at ${webhook.path} for ${config.name}`);
196
+ async setupWebhook(creds, config, webhook) {
197
+ const auth = {
198
+ token: creds.token,
199
+ ...(creds.apiBase !== undefined && { apiBase: creds.apiBase }),
200
+ };
201
+ const hook = await registerWebhook({
202
+ auth,
203
+ owner: creds.ownerId,
204
+ repo: creds.repoName,
205
+ url: webhook.path,
206
+ ...(webhook.secret !== undefined && { secret: webhook.secret }),
207
+ events: webhook.events ?? ['*'],
208
+ });
209
+ webhook.hookId = hook.id;
42
210
  },
43
- async cleanupWebhook() {
44
- // In production: DELETE /repos/:owner/:repo/hooks/:hook_id
45
- console.log('GitHub webhook would be unregistered');
211
+ async cleanupWebhook(creds, _config, webhook) {
212
+ const hookId = webhook.hookId;
213
+ if (hookId === undefined)
214
+ return;
215
+ const auth = {
216
+ token: creds.token,
217
+ ...(creds.apiBase !== undefined && { apiBase: creds.apiBase }),
218
+ };
219
+ await deleteWebhook({ auth, owner: creds.ownerId, repo: creds.repoName, hookId });
46
220
  },
47
221
  async eventToRunInput(event, creds) {
48
222
  const { event: eventType, payload } = event;
@@ -70,8 +244,45 @@ export const GitHubIntegration = defineIntegration({
70
244
  }
71
245
  return null;
72
246
  },
73
- async sendNotification(message, options) {
74
- // In production: POST /repos/:owner/:repo/issues/:issue_number/comments
75
- console.log(`GitHub notification: ${message}`, options);
247
+ /**
248
+ * Route a notification to the right REST call based on `options.kind`:
249
+ * - kind: 'issue-comment' (default) — POST /issues/:n/comments
250
+ * - kind: 'pr-review' — POST /pulls/:n/reviews (use `event`, optional `comments[]`)
251
+ *
252
+ * Both kinds require `options.number`. `pr-review` also accepts
253
+ * `event` ('APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', default COMMENT) and
254
+ * an optional `comments` array of inline review comments.
255
+ */
256
+ async sendNotification(message, options, creds) {
257
+ const opts = (options ?? {});
258
+ const number = typeof opts.number === 'number' ? opts.number : undefined;
259
+ if (number === undefined) {
260
+ throw new Error('GitHub sendNotification requires options.number (issue or PR number)');
261
+ }
262
+ const auth = {
263
+ token: creds.token,
264
+ ...(creds.apiBase !== undefined && { apiBase: creds.apiBase }),
265
+ };
266
+ const owner = typeof opts.owner === 'string' ? opts.owner : creds.ownerId;
267
+ const repo = typeof opts.repo === 'string' ? opts.repo : creds.repoName;
268
+ const kind = typeof opts.kind === 'string' ? opts.kind : 'issue-comment';
269
+ if (kind === 'pr-review') {
270
+ const eventRaw = typeof opts.event === 'string' ? opts.event : 'COMMENT';
271
+ const event = eventRaw === 'APPROVE' || eventRaw === 'REQUEST_CHANGES' ? eventRaw : 'COMMENT';
272
+ const comments = Array.isArray(opts.comments)
273
+ ? opts.comments
274
+ : undefined;
275
+ await postPullRequestReview({
276
+ auth,
277
+ owner,
278
+ repo,
279
+ number,
280
+ event,
281
+ body: message,
282
+ ...(comments !== undefined && { comments }),
283
+ });
284
+ return;
285
+ }
286
+ await postIssueComment({ auth, owner, repo, number, body: message });
76
287
  },
77
288
  });
package/dist/index.d.ts CHANGED
@@ -8,7 +8,9 @@
8
8
  export type { RunInput, IntegrationConfig, WebhookConfig, RateLimitConfig, IntegrationCapabilities, Integration, GitHubConfig, GitHubWebhookEvent, GitHubIssueTrigger, SlackConfig, SlackWebhookEvent, JiraConfig, JiraIssueTrigger, IMAPConfig, EmailTrigger, TelegramConfig, TelegramWebhookEvent, TelegramMessageTrigger, } from '@skelm/integration-sdk';
9
9
  export { IntegrationBase, defineIntegration, createIntegrationPlugin, } from '@skelm/integration-sdk';
10
10
  export type { DefineIntegrationOptions, IntegrationClass } from '@skelm/integration-sdk';
11
- export { GitHubIntegration } from './github.js';
11
+ export { GitHubIntegration, GitHubApiError, githubFetch, getAuthenticatedUser, registerWebhook, deleteWebhook, postIssueComment, postPullRequestReview, type GitHubAuth, type GitHubHook, type RegisterWebhookParams, type DeleteWebhookParams, type PostIssueCommentParams, type PostPullRequestReviewParams, type PullRequestReviewComment, } from './github.js';
12
+ export { registerGitHubPrTrigger, normalizeGitHubPrEvent, verifyGitHubSignature, type GitHubPrEventKind, type GitHubPrPayload, type GitHubPrTriggerSpec, type GitHubPrTriggerCoordinator, } from './github-pr-trigger.js';
13
+ export { MsGraphIntegration, getMsGraphValidationToken, verifyMsGraphClientState, type MsGraphIntegrationType, } from './ms-graph.js';
12
14
  export { SlackIntegration, verifySlackSignature } from './slack.js';
13
15
  export { TelegramIntegration, telegramUpdateToInput, type CreateTelegramTriggerSourceOptions, type TelegramGetUpdatesOptions, type TelegramMessageInput, type TelegramSendMessageOptions, type TelegramTriggerSource, } from './telegram.js';
14
16
  export { IntegrationRegistry } from './registry.js';
package/dist/index.js CHANGED
@@ -7,7 +7,9 @@
7
7
  */
8
8
  export { IntegrationBase, defineIntegration, createIntegrationPlugin, } from '@skelm/integration-sdk';
9
9
  // Built-in integration implementations
10
- export { GitHubIntegration } from './github.js';
10
+ export { GitHubIntegration, GitHubApiError, githubFetch, getAuthenticatedUser, registerWebhook, deleteWebhook, postIssueComment, postPullRequestReview, } from './github.js';
11
+ export { registerGitHubPrTrigger, normalizeGitHubPrEvent, verifyGitHubSignature, } from './github-pr-trigger.js';
12
+ export { MsGraphIntegration, getMsGraphValidationToken, verifyMsGraphClientState, } from './ms-graph.js';
11
13
  export { SlackIntegration, verifySlackSignature } from './slack.js';
12
14
  export { TelegramIntegration, telegramUpdateToInput, } from './telegram.js';
13
15
  // Integration registry
@@ -0,0 +1,19 @@
1
+ export declare const MsGraphIntegration: import("@skelm/integration-sdk").IntegrationClass<{
2
+ tenantId: string;
3
+ clientId: string;
4
+ clientState: string;
5
+ }>;
6
+ /**
7
+ * Pull the Microsoft Graph subscription validation token from a request URL
8
+ * if present. Graph sends a GET (or POST) to the webhook URL with a
9
+ * `validationToken` query parameter and expects the raw token echoed back
10
+ * within 10 seconds with `Content-Type: text/plain`.
11
+ */
12
+ export declare function getMsGraphValidationToken(url: string): string | null;
13
+ /**
14
+ * Verify that a Graph change notification's embedded `clientState` matches
15
+ * the expected secret. Notifications without a `clientState` are rejected;
16
+ * Graph always includes one when the subscription was created with it set.
17
+ */
18
+ export declare function verifyMsGraphClientState(notification: unknown, expected: string): boolean;
19
+ export type MsGraphIntegrationType = InstanceType<typeof MsGraphIntegration>;
@@ -0,0 +1,84 @@
1
+ import { defineIntegration } from '@skelm/integration-sdk';
2
+ import { z } from 'zod';
3
+ /**
4
+ * Microsoft Graph integration for skelm pipelines.
5
+ *
6
+ * Built around the Graph change-notification webhook flow:
7
+ * 1. Graph posts a one-shot validation request with `?validationToken=…`;
8
+ * the gateway echoes the token verbatim within 10 seconds. See
9
+ * `getMsGraphValidationToken()`.
10
+ * 2. After the subscription is live, Graph POSTs change notifications
11
+ * whose body is `{ value: [{ subscriptionId, clientState, resource,
12
+ * changeType, resourceData, ... }] }`. Verify the embedded
13
+ * `clientState` matches the secret stored at subscription time using
14
+ * `verifyMsGraphClientState()`.
15
+ *
16
+ * The integration declares `canReceiveWebhooks` but does not register the
17
+ * subscription with Graph itself — that's deployment-specific and best done
18
+ * by the pipeline owning the lifecycle.
19
+ */
20
+ const msGraphCredentialsSchema = z.object({
21
+ /** Azure AD tenant id (used by the pipeline for outbound Graph calls). */
22
+ tenantId: z.string().min(1, 'Microsoft Graph tenantId is required'),
23
+ /** Azure AD app/client id. */
24
+ clientId: z.string().min(1, 'Microsoft Graph clientId is required'),
25
+ /**
26
+ * Shared `clientState` value the subscription was created with. Echoed
27
+ * back by Graph on every notification; rejecting mismatches blocks
28
+ * spoofed callers from your webhook URL.
29
+ */
30
+ clientState: z.string().min(1, 'Microsoft Graph clientState is required'),
31
+ });
32
+ export const MsGraphIntegration = defineIntegration({
33
+ id: 'ms-graph',
34
+ name: 'Microsoft Graph',
35
+ capabilities: {
36
+ canTrigger: true,
37
+ canReceiveWebhooks: true,
38
+ canPoll: false,
39
+ canSendNotifications: false,
40
+ },
41
+ credentialsSchema: msGraphCredentialsSchema,
42
+ async performHealthCheck(creds) {
43
+ return (typeof creds.tenantId === 'string' &&
44
+ creds.tenantId.length > 0 &&
45
+ typeof creds.clientId === 'string' &&
46
+ creds.clientId.length > 0);
47
+ },
48
+ async eventToRunInput(event, creds) {
49
+ const body = event;
50
+ if (!Array.isArray(body.value) || body.value.length === 0)
51
+ return null;
52
+ const valid = body.value.filter((n) => verifyMsGraphClientState(n, creds.clientState));
53
+ if (valid.length === 0)
54
+ return null;
55
+ return { notifications: valid };
56
+ },
57
+ });
58
+ /**
59
+ * Pull the Microsoft Graph subscription validation token from a request URL
60
+ * if present. Graph sends a GET (or POST) to the webhook URL with a
61
+ * `validationToken` query parameter and expects the raw token echoed back
62
+ * within 10 seconds with `Content-Type: text/plain`.
63
+ */
64
+ export function getMsGraphValidationToken(url) {
65
+ try {
66
+ const parsed = new URL(url, 'http://127.0.0.1');
67
+ const token = parsed.searchParams.get('validationToken');
68
+ return token === null || token === '' ? null : token;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /**
75
+ * Verify that a Graph change notification's embedded `clientState` matches
76
+ * the expected secret. Notifications without a `clientState` are rejected;
77
+ * Graph always includes one when the subscription was created with it set.
78
+ */
79
+ export function verifyMsGraphClientState(notification, expected) {
80
+ if (notification === null || typeof notification !== 'object')
81
+ return false;
82
+ const cs = notification.clientState;
83
+ return typeof cs === 'string' && cs === expected;
84
+ }
package/dist/slack.d.ts CHANGED
@@ -13,8 +13,18 @@ export declare const SlackIntegration: import("@skelm/integration-sdk").Integrat
13
13
  channelId?: string | undefined;
14
14
  }>;
15
15
  /**
16
- * Verify a Slack webhook signature.
17
- * Call this in your webhook handler before processing the event.
16
+ * Verify a Slack webhook signature. Computes the expected `v0=` HMAC-SHA256
17
+ * over `v0:<timestamp>:<rawBody>` using `signingSecret` and compares it to
18
+ * `signature` in constant time. The caller is responsible for rejecting
19
+ * stale timestamps (Slack's recommended replay window is 5 minutes).
20
+ *
21
+ * **BREAKING (vs. the pre-0.5 stub):** argument order changed to
22
+ * `(rawBody, signature, timestamp, secret)` — commonly-tampered values
23
+ * lead, matching how the gateway calls this from `control-routes.ts`. The
24
+ * pre-0.5 stub took `(signingSecret, timestamp, body, signature)` and
25
+ * always returned `true` regardless of inputs; callers using the old
26
+ * order will now silently get `false`. All four params are `string`, so
27
+ * TypeScript cannot catch the migration — audit your call sites.
18
28
  */
19
- export declare function verifySlackSignature(signingSecret: string, timestamp: string, body: string, signature: string): boolean;
29
+ export declare function verifySlackSignature(rawBody: string, signature: string, timestamp: string, secret: string): boolean;
20
30
  export type SlackIntegrationType = InstanceType<typeof SlackIntegration>;
package/dist/slack.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
1
2
  import { defineIntegration } from '@skelm/integration-sdk';
2
3
  import { z } from 'zod';
3
4
  const slackCredentialsSchema = z.object({
@@ -90,13 +91,26 @@ export const SlackIntegration = defineIntegration({
90
91
  },
91
92
  });
92
93
  /**
93
- * Verify a Slack webhook signature.
94
- * Call this in your webhook handler before processing the event.
94
+ * Verify a Slack webhook signature. Computes the expected `v0=` HMAC-SHA256
95
+ * over `v0:<timestamp>:<rawBody>` using `signingSecret` and compares it to
96
+ * `signature` in constant time. The caller is responsible for rejecting
97
+ * stale timestamps (Slack's recommended replay window is 5 minutes).
98
+ *
99
+ * **BREAKING (vs. the pre-0.5 stub):** argument order changed to
100
+ * `(rawBody, signature, timestamp, secret)` — commonly-tampered values
101
+ * lead, matching how the gateway calls this from `control-routes.ts`. The
102
+ * pre-0.5 stub took `(signingSecret, timestamp, body, signature)` and
103
+ * always returned `true` regardless of inputs; callers using the old
104
+ * order will now silently get `false`. All four params are `string`, so
105
+ * TypeScript cannot catch the migration — audit your call sites.
95
106
  */
96
- export function verifySlackSignature(signingSecret, timestamp, body, signature) {
97
- // In production: use crypto.createHmac('sha256', signingSecret)
98
- console.log(`Signature verification: timestamp=${timestamp}, signature=${signature}`);
99
- void signingSecret;
100
- void body;
101
- return true;
107
+ export function verifySlackSignature(rawBody, signature, timestamp, secret) {
108
+ const expected = `v0=${createHmac('sha256', secret)
109
+ .update(`v0:${timestamp}:${rawBody}`)
110
+ .digest('hex')}`;
111
+ const left = Buffer.from(signature, 'utf8');
112
+ const right = Buffer.from(expected, 'utf8');
113
+ if (left.length !== right.length)
114
+ return false;
115
+ return timingSafeEqual(left, right);
102
116
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skelm/integrations",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Third-party integrations for skelm pipelines",
5
5
  "license": "MIT",
6
6
  "author": "Scott Glover <scottgl@gmail.com>",
@@ -46,8 +46,8 @@
46
46
  "clean": "rm -rf dist tsconfig.tsbuildinfo"
47
47
  },
48
48
  "dependencies": {
49
- "@skelm/core": "^0.4.2",
50
- "@skelm/integration-sdk": "^0.4.2",
49
+ "@skelm/core": "^0.4.3",
50
+ "@skelm/integration-sdk": "^0.4.3",
51
51
  "zod": "^4.4.2"
52
52
  },
53
53
  "devDependencies": {