@shipfox/api-integration-spi 0.2.2 → 1.0.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-spi",
3
3
  "license": "MIT",
4
- "version": "0.2.2",
4
+ "version": "1.0.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -18,7 +18,7 @@
18
18
  }
19
19
  },
20
20
  "dependencies": {
21
- "@shipfox/api-integration-core-dto": "9.0.2"
21
+ "@shipfox/api-integration-core-dto": "12.2.0"
22
22
  },
23
23
  "imports": {
24
24
  "#*": "./dist/*"
@@ -1,9 +1,58 @@
1
1
  import {
2
2
  buildProviderRepositoryId,
3
3
  IntegrationProviderError,
4
+ isValidGitObjectId,
5
+ isValidGitRefName,
6
+ isValidTriggerRef,
4
7
  parseProviderRepositoryId,
5
8
  } from './contracts.js';
6
9
 
10
+ describe('git ref names', () => {
11
+ it.each([
12
+ 'refs/heads/main',
13
+ 'refs/heads/feature/review',
14
+ 'refs/heads/foo./bar',
15
+ 'refs/heads/foo/-bar',
16
+ 'refs/pull/17/head',
17
+ ])('accepts %s', (ref) => {
18
+ expect(isValidGitRefName(ref)).toBe(true);
19
+ });
20
+
21
+ it.each([
22
+ '',
23
+ 'HEAD',
24
+ 'main',
25
+ '-main',
26
+ 'refs/heads/foo bar',
27
+ 'refs/heads/foo..bar',
28
+ 'refs/heads/foo.lock',
29
+ 'refs/heads/.foo',
30
+ 'refs/heads/foo.',
31
+ 'refs/heads/foo@{bar',
32
+ ])('rejects %s', (ref) => {
33
+ expect(isValidGitRefName(ref)).toBe(false);
34
+ });
35
+
36
+ it.each(['refs/tags/-evil', 'refs/heads/feature/-evil'])('accepts safe trigger ref %s', (ref) => {
37
+ expect(isValidTriggerRef(ref)).toBe(true);
38
+ });
39
+ });
40
+
41
+ describe('git object ids', () => {
42
+ it.each(['a'.repeat(40), 'b'.repeat(64)])('accepts a full object id', (value) => {
43
+ expect(isValidGitObjectId(value)).toBe(true);
44
+ });
45
+
46
+ it.each([
47
+ 'a',
48
+ 'abcdef1234567890',
49
+ '0'.repeat(40),
50
+ 'g'.repeat(40),
51
+ ])('rejects an invalid object id', (value) => {
52
+ expect(isValidGitObjectId(value)).toBe(false);
53
+ });
54
+ });
55
+
7
56
  describe('provider repository identifiers', () => {
8
57
  it('prefixes provider-owned identifiers', () => {
9
58
  const result = buildProviderRepositoryId('github', '42');
package/src/contracts.ts CHANGED
@@ -2,6 +2,9 @@ export type IntegrationProviderKind = string;
2
2
  export type IntegrationCapability = 'source_control' | 'agent_tools';
3
3
  export type IntegrationConnectionLifecycleStatus = 'active' | 'disabled' | 'error';
4
4
 
5
+ const GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
6
+ const ZERO_GIT_OBJECT_ID_PATTERN = /^0+$/;
7
+
5
8
  export interface IntegrationConnection<
6
9
  ProviderKind extends IntegrationProviderKind = IntegrationProviderKind,
7
10
  > {
@@ -110,6 +113,14 @@ export interface CreateCheckoutSpecInput<
110
113
  permissions?: CheckoutPermissions | undefined;
111
114
  }
112
115
 
116
+ export interface TriggerReference {
117
+ externalRepositoryId: string;
118
+ ref: string;
119
+ commit: string;
120
+ /** Provider handle of whoever caused the event, when the payload names one. */
121
+ actor: string | null;
122
+ }
123
+
113
124
  export interface SourceControlProvider<
114
125
  Connection extends IntegrationConnection = IntegrationConnection,
115
126
  > {
@@ -117,6 +128,7 @@ export interface SourceControlProvider<
117
128
  resolveRepository(input: ResolveRepositoryInput<Connection>): Promise<RepositorySnapshot>;
118
129
  listFiles(input: ListFilesInput<Connection>): Promise<FilePage>;
119
130
  fetchFile(input: FetchFileInput<Connection>): Promise<FileSnapshot>;
131
+ resolveTriggerReference(payload: unknown): TriggerReference | null;
120
132
  createCheckoutSpec?(input: CreateCheckoutSpecInput<Connection>): Promise<CheckoutSpec>;
121
133
  }
122
134
 
@@ -293,3 +305,58 @@ export function parseProviderRepositoryId(
293
305
  }
294
306
  return value;
295
307
  }
308
+
309
+ /** Checks the constraints enforced by `git check-ref-format`. */
310
+ export function isValidGitRefName(ref: string): boolean {
311
+ if (!ref || ref === '@' || ref.startsWith('-')) return false;
312
+ if (
313
+ !ref.includes('/') ||
314
+ ref.startsWith('/') ||
315
+ ref.endsWith('/') ||
316
+ ref.includes('//') ||
317
+ ref.includes('..') ||
318
+ ref.includes('@{') ||
319
+ ref.endsWith('.')
320
+ ) {
321
+ return false;
322
+ }
323
+ if (
324
+ [...ref].some((character) => {
325
+ const code = character.codePointAt(0) ?? 0;
326
+ return code <= 0x20 || code === 0x7f || '~^:?*[]\\'.includes(character);
327
+ })
328
+ ) {
329
+ return false;
330
+ }
331
+
332
+ const components = ref.split('/');
333
+ return components.every(
334
+ (component) =>
335
+ component.length > 0 && !component.startsWith('.') && !component.endsWith('.lock'),
336
+ );
337
+ }
338
+
339
+ export function isRecord(value: unknown): value is Record<string, unknown> {
340
+ return typeof value === 'object' && value !== null;
341
+ }
342
+
343
+ export function asRecord(value: unknown): Record<string, unknown> | null {
344
+ return isRecord(value) ? value : null;
345
+ }
346
+
347
+ export function nonEmptyString(value: unknown): string | null {
348
+ return typeof value === 'string' && value.length > 0 ? value : null;
349
+ }
350
+
351
+ export function positiveInteger(value: unknown): number | null {
352
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
353
+ }
354
+
355
+ /** Validates a provider ref before it is passed to a git operation. */
356
+ export function isValidTriggerRef(ref: string): boolean {
357
+ return isValidGitRefName(ref);
358
+ }
359
+
360
+ export function isValidGitObjectId(value: string): boolean {
361
+ return GIT_OBJECT_ID_PATTERN.test(value) && !ZERO_GIT_OBJECT_ID_PATTERN.test(value);
362
+ }
package/src/ports.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  IntegrationEventReceivedEvent,
3
3
  SourcePushPayload,
4
+ SourceRepositoryIdentity,
4
5
  } from '@shipfox/api-integration-core-dto';
5
6
  import type {IntegrationConnection, IntegrationConnectionLifecycleStatus} from '#contracts.js';
6
7
 
@@ -37,6 +38,20 @@ export type PublishSourcePushFn = (params: {
37
38
  push: SourcePushPayload;
38
39
  }) => Promise<{published: boolean}>;
39
40
 
41
+ export type PublishSourceRepositoryUpdatedFn = (params: {
42
+ tx: IntegrationTx;
43
+ provider: string;
44
+ source: string;
45
+ workspaceId: string;
46
+ connectionId: string;
47
+ connectionName: string;
48
+ deliveryId: string;
49
+ receivedAt: string;
50
+ rawPayload: unknown;
51
+ event: string;
52
+ repositories: SourceRepositoryIdentity[];
53
+ }) => Promise<{published: boolean}>;
54
+
40
55
  export type RecordDeliveryOnlyFn = (params: {
41
56
  tx: IntegrationTx;
42
57
  provider: string;