@skelm/integrations 0.4.1 → 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 +54 -0
- package/dist/github-pr-trigger.d.ts +107 -0
- package/dist/github-pr-trigger.js +170 -0
- package/dist/github.d.ts +113 -2
- package/dist/github.js +231 -20
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/ms-graph.d.ts +19 -0
- package/dist/ms-graph.js +84 -0
- package/dist/slack.d.ts +13 -3
- package/dist/slack.js +22 -8
- package/package.json +4 -4
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(
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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(
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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>;
|
package/dist/ms-graph.js
ADDED
|
@@ -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
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
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(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
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>",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"exports": {
|
|
28
28
|
".": {
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
|
-
"
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"clean": "rm -rf dist tsconfig.tsbuildinfo"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@skelm/core": "^0.4.
|
|
50
|
-
"@skelm/integration-sdk": "^0.4.
|
|
49
|
+
"@skelm/core": "^0.4.3",
|
|
50
|
+
"@skelm/integration-sdk": "^0.4.3",
|
|
51
51
|
"zod": "^4.4.2"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|