@herdctl/core 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/__tests__/agent.test.js +31 -13
- package/dist/config/__tests__/agent.test.js.map +1 -1
- package/dist/config/__tests__/merge.test.js +9 -2
- package/dist/config/__tests__/merge.test.js.map +1 -1
- package/dist/config/__tests__/schema.test.js +350 -1
- package/dist/config/__tests__/schema.test.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +3 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/schema.d.ts +828 -24
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +118 -6
- package/dist/config/schema.js.map +1 -1
- package/dist/fleet-manager/__tests__/coverage.test.js +11 -332
- package/dist/fleet-manager/__tests__/coverage.test.js.map +1 -1
- package/dist/fleet-manager/__tests__/errors.test.js +1 -49
- package/dist/fleet-manager/__tests__/errors.test.js.map +1 -1
- package/dist/fleet-manager/__tests__/integration.test.js +109 -0
- package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
- package/dist/fleet-manager/__tests__/reload.test.js +1 -1
- package/dist/fleet-manager/__tests__/reload.test.js.map +1 -1
- package/dist/fleet-manager/config-reload.d.ts +164 -0
- package/dist/fleet-manager/config-reload.d.ts.map +1 -0
- package/dist/fleet-manager/config-reload.js +445 -0
- package/dist/fleet-manager/config-reload.js.map +1 -0
- package/dist/fleet-manager/context.d.ts +76 -0
- package/dist/fleet-manager/context.d.ts.map +1 -0
- package/dist/fleet-manager/context.js +11 -0
- package/dist/fleet-manager/context.js.map +1 -0
- package/dist/fleet-manager/errors.d.ts +0 -25
- package/dist/fleet-manager/errors.d.ts.map +1 -1
- package/dist/fleet-manager/errors.js +0 -38
- package/dist/fleet-manager/errors.js.map +1 -1
- package/dist/fleet-manager/event-emitters.d.ts +123 -0
- package/dist/fleet-manager/event-emitters.d.ts.map +1 -0
- package/dist/fleet-manager/event-emitters.js +136 -0
- package/dist/fleet-manager/event-emitters.js.map +1 -0
- package/dist/fleet-manager/event-types.d.ts +0 -15
- package/dist/fleet-manager/event-types.d.ts.map +1 -1
- package/dist/fleet-manager/fleet-manager.d.ts +40 -653
- package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
- package/dist/fleet-manager/fleet-manager.js +95 -1720
- package/dist/fleet-manager/fleet-manager.js.map +1 -1
- package/dist/fleet-manager/index.d.ts +13 -2
- package/dist/fleet-manager/index.d.ts.map +1 -1
- package/dist/fleet-manager/index.js +19 -6
- package/dist/fleet-manager/index.js.map +1 -1
- package/dist/fleet-manager/job-control.d.ts +64 -0
- package/dist/fleet-manager/job-control.d.ts.map +1 -0
- package/dist/fleet-manager/job-control.js +296 -0
- package/dist/fleet-manager/job-control.js.map +1 -0
- package/dist/fleet-manager/log-streaming.d.ts +171 -0
- package/dist/fleet-manager/log-streaming.d.ts.map +1 -0
- package/dist/fleet-manager/log-streaming.js +503 -0
- package/dist/fleet-manager/log-streaming.js.map +1 -0
- package/dist/fleet-manager/schedule-executor.d.ts +63 -0
- package/dist/fleet-manager/schedule-executor.d.ts.map +1 -0
- package/dist/fleet-manager/schedule-executor.js +209 -0
- package/dist/fleet-manager/schedule-executor.js.map +1 -0
- package/dist/fleet-manager/schedule-management.d.ts +71 -0
- package/dist/fleet-manager/schedule-management.d.ts.map +1 -0
- package/dist/fleet-manager/schedule-management.js +171 -0
- package/dist/fleet-manager/schedule-management.js.map +1 -0
- package/dist/fleet-manager/status-queries.d.ts +105 -0
- package/dist/fleet-manager/status-queries.d.ts.map +1 -0
- package/dist/fleet-manager/status-queries.js +247 -0
- package/dist/fleet-manager/status-queries.js.map +1 -0
- package/dist/fleet-manager/types.d.ts +0 -39
- package/dist/fleet-manager/types.d.ts.map +1 -1
- package/dist/runner/__tests__/job-executor.test.js +206 -1
- package/dist/runner/__tests__/job-executor.test.js.map +1 -1
- package/dist/runner/job-executor.d.ts +9 -0
- package/dist/runner/job-executor.d.ts.map +1 -1
- package/dist/runner/job-executor.js +78 -4
- package/dist/runner/job-executor.js.map +1 -1
- package/dist/runner/types.d.ts +2 -0
- package/dist/runner/types.d.ts.map +1 -1
- package/dist/scheduler/__tests__/cron.test.d.ts +2 -0
- package/dist/scheduler/__tests__/cron.test.d.ts.map +1 -0
- package/dist/scheduler/__tests__/cron.test.js +867 -0
- package/dist/scheduler/__tests__/cron.test.js.map +1 -0
- package/dist/scheduler/__tests__/scheduler.test.js +164 -5
- package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
- package/dist/scheduler/cron.d.ts +126 -0
- package/dist/scheduler/cron.d.ts.map +1 -0
- package/dist/scheduler/cron.js +390 -0
- package/dist/scheduler/cron.js.map +1 -0
- package/dist/scheduler/errors.d.ts +81 -1
- package/dist/scheduler/errors.d.ts.map +1 -1
- package/dist/scheduler/errors.js +81 -6
- package/dist/scheduler/errors.js.map +1 -1
- package/dist/scheduler/index.d.ts +1 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +2 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/schedule-runner.d.ts +2 -2
- package/dist/scheduler/schedule-runner.d.ts.map +1 -1
- package/dist/scheduler/schedule-runner.js +20 -8
- package/dist/scheduler/schedule-runner.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts +4 -4
- package/dist/scheduler/scheduler.d.ts.map +1 -1
- package/dist/scheduler/scheduler.js +86 -20
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/types.d.ts +1 -1
- package/dist/scheduler/types.d.ts.map +1 -1
- package/dist/state/schemas/job-metadata.d.ts +2 -2
- package/package.json +33 -8
- package/.turbo/turbo-build.log +0 -4
- package/.turbo/turbo-test.log +0 -219
- package/.turbo/turbo-typecheck.log +0 -4
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/coverage-final.json +0 -51
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -251
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/config/index.html +0 -191
- package/coverage/src/config/index.ts.html +0 -442
- package/coverage/src/config/interpolate.ts.html +0 -652
- package/coverage/src/config/loader.ts.html +0 -1501
- package/coverage/src/config/merge.ts.html +0 -823
- package/coverage/src/config/parser.ts.html +0 -1213
- package/coverage/src/config/schema.ts.html +0 -1123
- package/coverage/src/fleet-manager/errors.ts.html +0 -2326
- package/coverage/src/fleet-manager/event-types.ts.html +0 -1219
- package/coverage/src/fleet-manager/fleet-manager.ts.html +0 -7030
- package/coverage/src/fleet-manager/index.html +0 -206
- package/coverage/src/fleet-manager/index.ts.html +0 -469
- package/coverage/src/fleet-manager/job-manager.ts.html +0 -2074
- package/coverage/src/fleet-manager/job-queue.ts.html +0 -2479
- package/coverage/src/fleet-manager/types.ts.html +0 -2602
- package/coverage/src/index.html +0 -116
- package/coverage/src/index.ts.html +0 -181
- package/coverage/src/runner/errors.ts.html +0 -1006
- package/coverage/src/runner/index.html +0 -191
- package/coverage/src/runner/index.ts.html +0 -256
- package/coverage/src/runner/job-executor.ts.html +0 -1429
- package/coverage/src/runner/message-processor.ts.html +0 -1150
- package/coverage/src/runner/sdk-adapter.ts.html +0 -658
- package/coverage/src/runner/types.ts.html +0 -559
- package/coverage/src/scheduler/errors.ts.html +0 -388
- package/coverage/src/scheduler/index.html +0 -206
- package/coverage/src/scheduler/index.ts.html +0 -244
- package/coverage/src/scheduler/interval.ts.html +0 -652
- package/coverage/src/scheduler/schedule-runner.ts.html +0 -1411
- package/coverage/src/scheduler/schedule-state.ts.html +0 -718
- package/coverage/src/scheduler/scheduler.ts.html +0 -1795
- package/coverage/src/scheduler/types.ts.html +0 -733
- package/coverage/src/state/directory.ts.html +0 -736
- package/coverage/src/state/errors.ts.html +0 -376
- package/coverage/src/state/fleet-state.ts.html +0 -937
- package/coverage/src/state/index.html +0 -221
- package/coverage/src/state/index.ts.html +0 -322
- package/coverage/src/state/job-metadata.ts.html +0 -1420
- package/coverage/src/state/job-output.ts.html +0 -1033
- package/coverage/src/state/schemas/fleet-state.ts.html +0 -445
- package/coverage/src/state/schemas/index.html +0 -176
- package/coverage/src/state/schemas/index.ts.html +0 -286
- package/coverage/src/state/schemas/job-metadata.ts.html +0 -628
- package/coverage/src/state/schemas/job-output.ts.html +0 -616
- package/coverage/src/state/schemas/session-info.ts.html +0 -361
- package/coverage/src/state/session.ts.html +0 -844
- package/coverage/src/state/types.ts.html +0 -262
- package/coverage/src/state/utils/atomic.ts.html +0 -748
- package/coverage/src/state/utils/index.html +0 -146
- package/coverage/src/state/utils/index.ts.html +0 -103
- package/coverage/src/state/utils/reads.ts.html +0 -1621
- package/coverage/src/work-sources/adapters/github.ts.html +0 -3583
- package/coverage/src/work-sources/adapters/index.html +0 -131
- package/coverage/src/work-sources/adapters/index.ts.html +0 -277
- package/coverage/src/work-sources/errors.ts.html +0 -298
- package/coverage/src/work-sources/index.html +0 -176
- package/coverage/src/work-sources/index.ts.html +0 -529
- package/coverage/src/work-sources/manager.ts.html +0 -1324
- package/coverage/src/work-sources/registry.ts.html +0 -619
- package/coverage/src/work-sources/types.ts.html +0 -568
- package/dist/fleet-manager/__tests__/event-helpers.test.d.ts +0 -7
- package/dist/fleet-manager/__tests__/event-helpers.test.d.ts.map +0 -1
- package/dist/fleet-manager/__tests__/event-helpers.test.js +0 -368
- package/dist/fleet-manager/__tests__/event-helpers.test.js.map +0 -1
- package/src/config/__tests__/agent.test.ts +0 -864
- package/src/config/__tests__/interpolate.test.ts +0 -644
- package/src/config/__tests__/loader.test.ts +0 -784
- package/src/config/__tests__/merge.test.ts +0 -751
- package/src/config/__tests__/parser.test.ts +0 -533
- package/src/config/__tests__/schema.test.ts +0 -873
- package/src/config/index.ts +0 -119
- package/src/config/interpolate.ts +0 -189
- package/src/config/loader.ts +0 -472
- package/src/config/merge.ts +0 -246
- package/src/config/parser.ts +0 -376
- package/src/config/schema.ts +0 -346
- package/src/fleet-manager/__tests__/coverage.test.ts +0 -2869
- package/src/fleet-manager/__tests__/errors.test.ts +0 -660
- package/src/fleet-manager/__tests__/event-helpers.test.ts +0 -448
- package/src/fleet-manager/__tests__/integration.test.ts +0 -1209
- package/src/fleet-manager/__tests__/job-control.test.ts +0 -283
- package/src/fleet-manager/__tests__/job-manager.test.ts +0 -869
- package/src/fleet-manager/__tests__/job-queue.test.ts +0 -401
- package/src/fleet-manager/__tests__/reload.test.ts +0 -751
- package/src/fleet-manager/__tests__/status-queries.test.ts +0 -595
- package/src/fleet-manager/__tests__/trigger.test.ts +0 -601
- package/src/fleet-manager/errors.ts +0 -747
- package/src/fleet-manager/event-types.ts +0 -378
- package/src/fleet-manager/fleet-manager.ts +0 -2315
- package/src/fleet-manager/index.ts +0 -128
- package/src/fleet-manager/job-manager.ts +0 -663
- package/src/fleet-manager/job-queue.ts +0 -798
- package/src/fleet-manager/types.ts +0 -839
- package/src/index.ts +0 -32
- package/src/runner/__tests__/errors.test.ts +0 -382
- package/src/runner/__tests__/job-executor.test.ts +0 -1708
- package/src/runner/__tests__/message-processor.test.ts +0 -960
- package/src/runner/__tests__/sdk-adapter.test.ts +0 -626
- package/src/runner/errors.ts +0 -307
- package/src/runner/index.ts +0 -57
- package/src/runner/job-executor.ts +0 -448
- package/src/runner/message-processor.ts +0 -355
- package/src/runner/sdk-adapter.ts +0 -191
- package/src/runner/types.ts +0 -158
- package/src/scheduler/__tests__/errors.test.ts +0 -159
- package/src/scheduler/__tests__/interval.test.ts +0 -515
- package/src/scheduler/__tests__/schedule-runner.test.ts +0 -798
- package/src/scheduler/__tests__/schedule-state.test.ts +0 -671
- package/src/scheduler/__tests__/scheduler.test.ts +0 -1280
- package/src/scheduler/errors.ts +0 -101
- package/src/scheduler/index.ts +0 -53
- package/src/scheduler/interval.ts +0 -189
- package/src/scheduler/schedule-runner.ts +0 -442
- package/src/scheduler/schedule-state.ts +0 -211
- package/src/scheduler/scheduler.ts +0 -570
- package/src/scheduler/types.ts +0 -216
- package/src/state/__tests__/directory.test.ts +0 -595
- package/src/state/__tests__/fleet-state.test.ts +0 -868
- package/src/state/__tests__/job-metadata-schema.test.ts +0 -414
- package/src/state/__tests__/job-metadata.test.ts +0 -831
- package/src/state/__tests__/job-output.test.ts +0 -856
- package/src/state/__tests__/session-schema.test.ts +0 -378
- package/src/state/__tests__/session.test.ts +0 -604
- package/src/state/directory.ts +0 -217
- package/src/state/errors.ts +0 -97
- package/src/state/fleet-state.ts +0 -284
- package/src/state/index.ts +0 -79
- package/src/state/job-metadata.ts +0 -445
- package/src/state/job-output.ts +0 -316
- package/src/state/schemas/__tests__/job-output.test.ts +0 -338
- package/src/state/schemas/fleet-state.ts +0 -120
- package/src/state/schemas/index.ts +0 -67
- package/src/state/schemas/job-metadata.ts +0 -181
- package/src/state/schemas/job-output.ts +0 -177
- package/src/state/schemas/session-info.ts +0 -92
- package/src/state/session.ts +0 -253
- package/src/state/types.ts +0 -59
- package/src/state/utils/__tests__/atomic.test.ts +0 -723
- package/src/state/utils/__tests__/reads.test.ts +0 -1071
- package/src/state/utils/atomic.ts +0 -221
- package/src/state/utils/index.ts +0 -6
- package/src/state/utils/reads.ts +0 -512
- package/src/work-sources/__tests__/github.test.ts +0 -1800
- package/src/work-sources/__tests__/manager.test.ts +0 -529
- package/src/work-sources/__tests__/registry.test.ts +0 -477
- package/src/work-sources/__tests__/types.test.ts +0 -479
- package/src/work-sources/adapters/github.ts +0 -1166
- package/src/work-sources/adapters/index.ts +0 -64
- package/src/work-sources/errors.ts +0 -71
- package/src/work-sources/index.ts +0 -148
- package/src/work-sources/manager.ts +0 -413
- package/src/work-sources/registry.ts +0 -178
- package/src/work-sources/types.ts +0 -161
- package/tsconfig.json +0 -9
- package/vitest.config.ts +0 -19
|
@@ -1,1800 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
GitHubWorkSourceAdapter,
|
|
4
|
-
GitHubAPIError,
|
|
5
|
-
GitHubAuthError,
|
|
6
|
-
createGitHubAdapter,
|
|
7
|
-
extractRateLimitInfo,
|
|
8
|
-
isRateLimitResponse,
|
|
9
|
-
calculateBackoffDelay,
|
|
10
|
-
type GitHubWorkSourceConfig,
|
|
11
|
-
type GitHubIssue,
|
|
12
|
-
type RateLimitInfo,
|
|
13
|
-
type RetryOptions,
|
|
14
|
-
} from "../adapters/github.js";
|
|
15
|
-
|
|
16
|
-
// =============================================================================
|
|
17
|
-
// Test Fixtures
|
|
18
|
-
// =============================================================================
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Create a mock GitHub issue
|
|
22
|
-
*/
|
|
23
|
-
function createMockIssue(overrides: Partial<GitHubIssue> = {}): GitHubIssue {
|
|
24
|
-
return {
|
|
25
|
-
number: 1,
|
|
26
|
-
title: "Test Issue",
|
|
27
|
-
body: "Test issue body",
|
|
28
|
-
html_url: "https://github.com/owner/repo/issues/1",
|
|
29
|
-
state: "open",
|
|
30
|
-
labels: [{ name: "ready" }],
|
|
31
|
-
created_at: "2024-01-15T10:00:00Z",
|
|
32
|
-
updated_at: "2024-01-15T12:00:00Z",
|
|
33
|
-
assignee: null,
|
|
34
|
-
assignees: [],
|
|
35
|
-
milestone: null,
|
|
36
|
-
user: { login: "testuser" },
|
|
37
|
-
...overrides,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Create a default adapter config
|
|
43
|
-
*/
|
|
44
|
-
function createConfig(
|
|
45
|
-
overrides: Partial<GitHubWorkSourceConfig> = {}
|
|
46
|
-
): GitHubWorkSourceConfig {
|
|
47
|
-
return {
|
|
48
|
-
type: "github",
|
|
49
|
-
owner: "testowner",
|
|
50
|
-
repo: "testrepo",
|
|
51
|
-
token: "test-token",
|
|
52
|
-
...overrides,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Mock fetch response helper
|
|
58
|
-
*/
|
|
59
|
-
function mockFetchResponse(
|
|
60
|
-
data: unknown,
|
|
61
|
-
options: { status?: number; headers?: Record<string, string> } = {}
|
|
62
|
-
) {
|
|
63
|
-
const { status = 200, headers = {} } = options;
|
|
64
|
-
return Promise.resolve({
|
|
65
|
-
ok: status >= 200 && status < 300,
|
|
66
|
-
status,
|
|
67
|
-
statusText: status === 200 ? "OK" : "Error",
|
|
68
|
-
json: () => Promise.resolve(data),
|
|
69
|
-
headers: {
|
|
70
|
-
get: (name: string) => headers[name] ?? null,
|
|
71
|
-
},
|
|
72
|
-
} as Response);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Helper to get a mock call safely with type assertions
|
|
77
|
-
*/
|
|
78
|
-
interface MockCallInfo {
|
|
79
|
-
url: string;
|
|
80
|
-
method: string;
|
|
81
|
-
body: string | undefined;
|
|
82
|
-
headers: Record<string, string>;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function getMockCall(
|
|
86
|
-
mockFetch: ReturnType<typeof vi.fn>,
|
|
87
|
-
index: number
|
|
88
|
-
): MockCallInfo {
|
|
89
|
-
const call = mockFetch.mock.calls[index];
|
|
90
|
-
if (!call) {
|
|
91
|
-
throw new Error(`Mock call at index ${index} not found`);
|
|
92
|
-
}
|
|
93
|
-
const [url, init] = call as [string, RequestInit | undefined];
|
|
94
|
-
return {
|
|
95
|
-
url,
|
|
96
|
-
method: init?.method ?? "GET",
|
|
97
|
-
body: typeof init?.body === "string" ? init.body : undefined,
|
|
98
|
-
headers: (init?.headers as Record<string, string>) ?? {},
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// =============================================================================
|
|
103
|
-
// Test Setup
|
|
104
|
-
// =============================================================================
|
|
105
|
-
|
|
106
|
-
describe("GitHubWorkSourceAdapter", () => {
|
|
107
|
-
let originalFetch: typeof global.fetch;
|
|
108
|
-
let mockFetch: ReturnType<typeof vi.fn<typeof global.fetch>>;
|
|
109
|
-
|
|
110
|
-
beforeEach(() => {
|
|
111
|
-
originalFetch = global.fetch;
|
|
112
|
-
mockFetch = vi.fn<typeof global.fetch>();
|
|
113
|
-
global.fetch = mockFetch;
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
afterEach(() => {
|
|
117
|
-
global.fetch = originalFetch;
|
|
118
|
-
vi.restoreAllMocks();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// ===========================================================================
|
|
122
|
-
// Constructor Tests
|
|
123
|
-
// ===========================================================================
|
|
124
|
-
|
|
125
|
-
describe("constructor", () => {
|
|
126
|
-
it("uses default labels when not configured", () => {
|
|
127
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
128
|
-
expect(adapter.type).toBe("github");
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("uses custom labels when configured", () => {
|
|
132
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
133
|
-
createConfig({
|
|
134
|
-
labels: {
|
|
135
|
-
ready: "custom-ready",
|
|
136
|
-
in_progress: "custom-wip",
|
|
137
|
-
},
|
|
138
|
-
})
|
|
139
|
-
);
|
|
140
|
-
expect(adapter.type).toBe("github");
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("uses default exclude_labels when not configured", () => {
|
|
144
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
145
|
-
// Default exclude_labels are ["blocked", "wip"]
|
|
146
|
-
expect(adapter.type).toBe("github");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("uses custom exclude_labels when configured", () => {
|
|
150
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
151
|
-
createConfig({
|
|
152
|
-
exclude_labels: ["on-hold", "needs-review"],
|
|
153
|
-
})
|
|
154
|
-
);
|
|
155
|
-
expect(adapter.type).toBe("github");
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// ===========================================================================
|
|
160
|
-
// fetchAvailableWork Tests
|
|
161
|
-
// ===========================================================================
|
|
162
|
-
|
|
163
|
-
describe("fetchAvailableWork", () => {
|
|
164
|
-
it("fetches issues with the ready label", async () => {
|
|
165
|
-
const mockIssues = [createMockIssue({ number: 1 }), createMockIssue({ number: 2 })];
|
|
166
|
-
mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
|
|
167
|
-
|
|
168
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
169
|
-
const result = await adapter.fetchAvailableWork();
|
|
170
|
-
|
|
171
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
172
|
-
expect.stringContaining("/repos/testowner/testrepo/issues"),
|
|
173
|
-
expect.objectContaining({
|
|
174
|
-
method: "GET",
|
|
175
|
-
headers: expect.objectContaining({
|
|
176
|
-
Authorization: "Bearer test-token",
|
|
177
|
-
}),
|
|
178
|
-
})
|
|
179
|
-
);
|
|
180
|
-
expect(result.items).toHaveLength(2);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("filters by ready label in query params", async () => {
|
|
184
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
185
|
-
|
|
186
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
187
|
-
await adapter.fetchAvailableWork();
|
|
188
|
-
|
|
189
|
-
const callUrl = mockFetch.mock.calls[0][0] as string;
|
|
190
|
-
expect(callUrl).toContain("labels=ready");
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("uses custom ready label", async () => {
|
|
194
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
195
|
-
|
|
196
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
197
|
-
createConfig({
|
|
198
|
-
labels: { ready: "agent-ready" },
|
|
199
|
-
})
|
|
200
|
-
);
|
|
201
|
-
await adapter.fetchAvailableWork();
|
|
202
|
-
|
|
203
|
-
const callUrl = mockFetch.mock.calls[0][0] as string;
|
|
204
|
-
expect(callUrl).toContain("labels=agent-ready");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("sorts by creation date ascending (oldest first)", async () => {
|
|
208
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
209
|
-
|
|
210
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
211
|
-
await adapter.fetchAvailableWork();
|
|
212
|
-
|
|
213
|
-
const callUrl = mockFetch.mock.calls[0][0] as string;
|
|
214
|
-
expect(callUrl).toContain("sort=created");
|
|
215
|
-
expect(callUrl).toContain("direction=asc");
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("excludes issues with exclude_labels", async () => {
|
|
219
|
-
const mockIssues = [
|
|
220
|
-
createMockIssue({ number: 1, labels: [{ name: "ready" }] }),
|
|
221
|
-
createMockIssue({ number: 2, labels: [{ name: "ready" }, { name: "blocked" }] }),
|
|
222
|
-
createMockIssue({ number: 3, labels: [{ name: "ready" }, { name: "wip" }] }),
|
|
223
|
-
];
|
|
224
|
-
mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
|
|
225
|
-
|
|
226
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
227
|
-
const result = await adapter.fetchAvailableWork();
|
|
228
|
-
|
|
229
|
-
// Only issue 1 should be returned (2 has blocked, 3 has wip)
|
|
230
|
-
expect(result.items).toHaveLength(1);
|
|
231
|
-
expect(result.items[0].externalId).toBe("1");
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("excludes issues with custom exclude_labels", async () => {
|
|
235
|
-
const mockIssues = [
|
|
236
|
-
createMockIssue({ number: 1, labels: [{ name: "ready" }] }),
|
|
237
|
-
createMockIssue({ number: 2, labels: [{ name: "ready" }, { name: "on-hold" }] }),
|
|
238
|
-
];
|
|
239
|
-
mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
|
|
240
|
-
|
|
241
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
242
|
-
createConfig({
|
|
243
|
-
exclude_labels: ["on-hold"],
|
|
244
|
-
})
|
|
245
|
-
);
|
|
246
|
-
const result = await adapter.fetchAvailableWork();
|
|
247
|
-
|
|
248
|
-
expect(result.items).toHaveLength(1);
|
|
249
|
-
expect(result.items[0].externalId).toBe("1");
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it("excludes issues with in_progress label by default", async () => {
|
|
253
|
-
const mockIssues = [
|
|
254
|
-
createMockIssue({ number: 1, labels: [{ name: "ready" }] }),
|
|
255
|
-
createMockIssue({
|
|
256
|
-
number: 2,
|
|
257
|
-
labels: [{ name: "ready" }, { name: "agent-working" }],
|
|
258
|
-
}),
|
|
259
|
-
];
|
|
260
|
-
mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
|
|
261
|
-
|
|
262
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
263
|
-
const result = await adapter.fetchAvailableWork();
|
|
264
|
-
|
|
265
|
-
expect(result.items).toHaveLength(1);
|
|
266
|
-
expect(result.items[0].externalId).toBe("1");
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it("includes claimed issues when includeClaimed is true", async () => {
|
|
270
|
-
const mockIssues = [
|
|
271
|
-
createMockIssue({ number: 1, labels: [{ name: "ready" }] }),
|
|
272
|
-
createMockIssue({
|
|
273
|
-
number: 2,
|
|
274
|
-
labels: [{ name: "ready" }, { name: "agent-working" }],
|
|
275
|
-
}),
|
|
276
|
-
];
|
|
277
|
-
mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
|
|
278
|
-
|
|
279
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
280
|
-
const result = await adapter.fetchAvailableWork({ includeClaimed: true });
|
|
281
|
-
|
|
282
|
-
expect(result.items).toHaveLength(2);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it("applies additional label filters", async () => {
|
|
286
|
-
const mockIssues = [
|
|
287
|
-
createMockIssue({ number: 1, labels: [{ name: "ready" }, { name: "bug" }] }),
|
|
288
|
-
createMockIssue({ number: 2, labels: [{ name: "ready" }, { name: "feature" }] }),
|
|
289
|
-
];
|
|
290
|
-
mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
|
|
291
|
-
|
|
292
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
293
|
-
const result = await adapter.fetchAvailableWork({ labels: ["bug"] });
|
|
294
|
-
|
|
295
|
-
expect(result.items).toHaveLength(1);
|
|
296
|
-
expect(result.items[0].externalId).toBe("1");
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it("supports pagination with limit", async () => {
|
|
300
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
301
|
-
|
|
302
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
303
|
-
await adapter.fetchAvailableWork({ limit: 10 });
|
|
304
|
-
|
|
305
|
-
const callUrl = mockFetch.mock.calls[0][0] as string;
|
|
306
|
-
expect(callUrl).toContain("per_page=10");
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it("caps limit at 100 (GitHub API max)", async () => {
|
|
310
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
311
|
-
|
|
312
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
313
|
-
await adapter.fetchAvailableWork({ limit: 200 });
|
|
314
|
-
|
|
315
|
-
const callUrl = mockFetch.mock.calls[0][0] as string;
|
|
316
|
-
expect(callUrl).toContain("per_page=100");
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("supports pagination with cursor", async () => {
|
|
320
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
321
|
-
|
|
322
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
323
|
-
await adapter.fetchAvailableWork({ cursor: "2" });
|
|
324
|
-
|
|
325
|
-
const callUrl = mockFetch.mock.calls[0][0] as string;
|
|
326
|
-
expect(callUrl).toContain("page=2");
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it("extracts nextCursor from Link header", async () => {
|
|
330
|
-
mockFetch.mockReturnValue(
|
|
331
|
-
mockFetchResponse([createMockIssue()], {
|
|
332
|
-
headers: {
|
|
333
|
-
Link: '<https://api.github.com/repos/owner/repo/issues?page=2>; rel="next", <https://api.github.com/repos/owner/repo/issues?page=5>; rel="last"',
|
|
334
|
-
},
|
|
335
|
-
})
|
|
336
|
-
);
|
|
337
|
-
|
|
338
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
339
|
-
const result = await adapter.fetchAvailableWork();
|
|
340
|
-
|
|
341
|
-
expect(result.nextCursor).toBe("2");
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it("returns undefined nextCursor when no more pages", async () => {
|
|
345
|
-
mockFetch.mockReturnValue(mockFetchResponse([createMockIssue()]));
|
|
346
|
-
|
|
347
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
348
|
-
const result = await adapter.fetchAvailableWork();
|
|
349
|
-
|
|
350
|
-
expect(result.nextCursor).toBeUndefined();
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it("filters by priority when specified", async () => {
|
|
354
|
-
const mockIssues = [
|
|
355
|
-
createMockIssue({ number: 1, labels: [{ name: "ready" }, { name: "critical" }] }),
|
|
356
|
-
createMockIssue({ number: 2, labels: [{ name: "ready" }] }),
|
|
357
|
-
createMockIssue({ number: 3, labels: [{ name: "ready" }, { name: "low" }] }),
|
|
358
|
-
];
|
|
359
|
-
mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
|
|
360
|
-
|
|
361
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
362
|
-
const result = await adapter.fetchAvailableWork({ priority: ["critical", "high"] });
|
|
363
|
-
|
|
364
|
-
expect(result.items).toHaveLength(1);
|
|
365
|
-
expect(result.items[0].externalId).toBe("1");
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
it("maps issue to WorkItem correctly", async () => {
|
|
369
|
-
const mockIssue = createMockIssue({
|
|
370
|
-
number: 42,
|
|
371
|
-
title: "Fix bug in login",
|
|
372
|
-
body: "The login form is broken",
|
|
373
|
-
html_url: "https://github.com/owner/repo/issues/42",
|
|
374
|
-
labels: [{ name: "ready" }, { name: "bug" }, { name: "high" }],
|
|
375
|
-
assignee: { login: "dev1" },
|
|
376
|
-
assignees: [{ login: "dev1" }, { login: "dev2" }],
|
|
377
|
-
milestone: { title: "v1.0", number: 1 },
|
|
378
|
-
user: { login: "reporter" },
|
|
379
|
-
created_at: "2024-01-10T09:00:00Z",
|
|
380
|
-
updated_at: "2024-01-12T14:30:00Z",
|
|
381
|
-
});
|
|
382
|
-
mockFetch.mockReturnValue(mockFetchResponse([mockIssue]));
|
|
383
|
-
|
|
384
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
385
|
-
const result = await adapter.fetchAvailableWork();
|
|
386
|
-
|
|
387
|
-
expect(result.items).toHaveLength(1);
|
|
388
|
-
const item = result.items[0];
|
|
389
|
-
expect(item.id).toBe("github-42");
|
|
390
|
-
expect(item.source).toBe("github");
|
|
391
|
-
expect(item.externalId).toBe("42");
|
|
392
|
-
expect(item.title).toBe("Fix bug in login");
|
|
393
|
-
expect(item.description).toBe("The login form is broken");
|
|
394
|
-
expect(item.priority).toBe("high");
|
|
395
|
-
expect(item.labels).toEqual(["ready", "bug", "high"]);
|
|
396
|
-
expect(item.url).toBe("https://github.com/owner/repo/issues/42");
|
|
397
|
-
expect(item.metadata).toEqual({
|
|
398
|
-
state: "open",
|
|
399
|
-
assignee: "dev1",
|
|
400
|
-
assignees: ["dev1", "dev2"],
|
|
401
|
-
milestone: { title: "v1.0", number: 1 },
|
|
402
|
-
author: "reporter",
|
|
403
|
-
});
|
|
404
|
-
expect(item.createdAt).toEqual(new Date("2024-01-10T09:00:00Z"));
|
|
405
|
-
expect(item.updatedAt).toEqual(new Date("2024-01-12T14:30:00Z"));
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
it("handles null body in issue", async () => {
|
|
409
|
-
const mockIssue = createMockIssue({ body: null });
|
|
410
|
-
mockFetch.mockReturnValue(mockFetchResponse([mockIssue]));
|
|
411
|
-
|
|
412
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
413
|
-
const result = await adapter.fetchAvailableWork();
|
|
414
|
-
|
|
415
|
-
expect(result.items[0].description).toBe("");
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
it("throws GitHubAPIError when missing owner/repo config", async () => {
|
|
419
|
-
const adapter = new GitHubWorkSourceAdapter({ type: "github" });
|
|
420
|
-
|
|
421
|
-
await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
|
|
422
|
-
await expect(adapter.fetchAvailableWork()).rejects.toThrow(
|
|
423
|
-
"GitHub adapter requires 'owner' and 'repo' configuration"
|
|
424
|
-
);
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
it("throws GitHubAPIError on API error", async () => {
|
|
428
|
-
mockFetch.mockReturnValue(
|
|
429
|
-
mockFetchResponse({ message: "Not Found" }, { status: 404 })
|
|
430
|
-
);
|
|
431
|
-
|
|
432
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
433
|
-
|
|
434
|
-
await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
it("handles network errors gracefully", async () => {
|
|
438
|
-
mockFetch.mockRejectedValue(new Error("Network error"));
|
|
439
|
-
|
|
440
|
-
// Disable retries to test immediate error handling
|
|
441
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
442
|
-
createConfig({ retry: { maxRetries: 0 } })
|
|
443
|
-
);
|
|
444
|
-
|
|
445
|
-
await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
|
|
446
|
-
await expect(adapter.fetchAvailableWork()).rejects.toThrow("Network error");
|
|
447
|
-
});
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
// ===========================================================================
|
|
451
|
-
// Priority Inference Tests
|
|
452
|
-
// ===========================================================================
|
|
453
|
-
|
|
454
|
-
describe("priority inference", () => {
|
|
455
|
-
it.each([
|
|
456
|
-
[["critical"], "critical"],
|
|
457
|
-
[["p0"], "critical"],
|
|
458
|
-
[["urgent"], "critical"],
|
|
459
|
-
[["high"], "high"],
|
|
460
|
-
[["p1"], "high"],
|
|
461
|
-
[["important"], "high"],
|
|
462
|
-
[["low"], "low"],
|
|
463
|
-
[["p3"], "low"],
|
|
464
|
-
[["enhancement"], "medium"],
|
|
465
|
-
[[], "medium"],
|
|
466
|
-
])("infers priority %s as %s", async (labels, expectedPriority) => {
|
|
467
|
-
const mockIssue = createMockIssue({
|
|
468
|
-
labels: [{ name: "ready" }, ...labels.map((name) => ({ name }))],
|
|
469
|
-
});
|
|
470
|
-
mockFetch.mockReturnValue(mockFetchResponse([mockIssue]));
|
|
471
|
-
|
|
472
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
473
|
-
const result = await adapter.fetchAvailableWork();
|
|
474
|
-
|
|
475
|
-
expect(result.items[0].priority).toBe(expectedPriority);
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
it("handles case-insensitive priority labels", async () => {
|
|
479
|
-
const mockIssue = createMockIssue({
|
|
480
|
-
labels: [{ name: "ready" }, { name: "CRITICAL" }],
|
|
481
|
-
});
|
|
482
|
-
mockFetch.mockReturnValue(mockFetchResponse([mockIssue]));
|
|
483
|
-
|
|
484
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
485
|
-
const result = await adapter.fetchAvailableWork();
|
|
486
|
-
|
|
487
|
-
expect(result.items[0].priority).toBe("critical");
|
|
488
|
-
});
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
// ===========================================================================
|
|
492
|
-
// claimWork Tests
|
|
493
|
-
// ===========================================================================
|
|
494
|
-
|
|
495
|
-
describe("claimWork", () => {
|
|
496
|
-
it("adds in_progress label and removes ready label", async () => {
|
|
497
|
-
const mockIssue = createMockIssue({
|
|
498
|
-
number: 5,
|
|
499
|
-
labels: [{ name: "ready" }],
|
|
500
|
-
});
|
|
501
|
-
const updatedIssue = createMockIssue({
|
|
502
|
-
number: 5,
|
|
503
|
-
labels: [{ name: "agent-working" }],
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
mockFetch
|
|
507
|
-
.mockReturnValueOnce(mockFetchResponse(mockIssue)) // GET issue
|
|
508
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 200 })) // POST labels
|
|
509
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE ready label
|
|
510
|
-
.mockReturnValueOnce(mockFetchResponse(updatedIssue)); // GET updated issue
|
|
511
|
-
|
|
512
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
513
|
-
const result = await adapter.claimWork("github-5");
|
|
514
|
-
|
|
515
|
-
expect(result.success).toBe(true);
|
|
516
|
-
expect(result.workItem).toBeDefined();
|
|
517
|
-
expect(result.workItem?.id).toBe("github-5");
|
|
518
|
-
|
|
519
|
-
// Verify the API calls
|
|
520
|
-
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
521
|
-
|
|
522
|
-
// Check POST to add label
|
|
523
|
-
const addLabelCall = getMockCall(mockFetch, 1);
|
|
524
|
-
expect(addLabelCall.url).toContain("/issues/5/labels");
|
|
525
|
-
expect(addLabelCall.method).toBe("POST");
|
|
526
|
-
expect(JSON.parse(addLabelCall.body!)).toEqual({
|
|
527
|
-
labels: ["agent-working"],
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
// Check DELETE to remove ready label
|
|
531
|
-
const removeLabelCall = getMockCall(mockFetch, 2);
|
|
532
|
-
expect(removeLabelCall.url).toContain("/issues/5/labels/ready");
|
|
533
|
-
expect(removeLabelCall.method).toBe("DELETE");
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
it("returns already_claimed when issue has in_progress label", async () => {
|
|
537
|
-
const mockIssue = createMockIssue({
|
|
538
|
-
number: 5,
|
|
539
|
-
labels: [{ name: "ready" }, { name: "agent-working" }],
|
|
540
|
-
});
|
|
541
|
-
mockFetch.mockReturnValueOnce(mockFetchResponse(mockIssue));
|
|
542
|
-
|
|
543
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
544
|
-
const result = await adapter.claimWork("github-5");
|
|
545
|
-
|
|
546
|
-
expect(result.success).toBe(false);
|
|
547
|
-
expect(result.reason).toBe("already_claimed");
|
|
548
|
-
expect(result.message).toContain("already claimed");
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
it("returns invalid_state when issue is closed", async () => {
|
|
552
|
-
const mockIssue = createMockIssue({
|
|
553
|
-
number: 5,
|
|
554
|
-
state: "closed",
|
|
555
|
-
});
|
|
556
|
-
mockFetch.mockReturnValueOnce(mockFetchResponse(mockIssue));
|
|
557
|
-
|
|
558
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
559
|
-
const result = await adapter.claimWork("github-5");
|
|
560
|
-
|
|
561
|
-
expect(result.success).toBe(false);
|
|
562
|
-
expect(result.reason).toBe("invalid_state");
|
|
563
|
-
expect(result.message).toContain("closed");
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
it("returns not_found when issue does not exist", async () => {
|
|
567
|
-
mockFetch.mockReturnValueOnce(
|
|
568
|
-
mockFetchResponse({ message: "Not Found" }, { status: 404 })
|
|
569
|
-
);
|
|
570
|
-
|
|
571
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
572
|
-
const result = await adapter.claimWork("github-999");
|
|
573
|
-
|
|
574
|
-
expect(result.success).toBe(false);
|
|
575
|
-
expect(result.reason).toBe("not_found");
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
it("returns permission_denied on 403 error", async () => {
|
|
579
|
-
mockFetch.mockReturnValueOnce(
|
|
580
|
-
mockFetchResponse({ message: "Forbidden" }, { status: 403 })
|
|
581
|
-
);
|
|
582
|
-
|
|
583
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
584
|
-
const result = await adapter.claimWork("github-5");
|
|
585
|
-
|
|
586
|
-
expect(result.success).toBe(false);
|
|
587
|
-
expect(result.reason).toBe("permission_denied");
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
it("returns source_error on other API errors", async () => {
|
|
591
|
-
mockFetch.mockReturnValueOnce(
|
|
592
|
-
mockFetchResponse({ message: "Server Error" }, { status: 500 })
|
|
593
|
-
);
|
|
594
|
-
|
|
595
|
-
// Disable retries to test immediate error handling
|
|
596
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
597
|
-
createConfig({ retry: { maxRetries: 0 } })
|
|
598
|
-
);
|
|
599
|
-
const result = await adapter.claimWork("github-5");
|
|
600
|
-
|
|
601
|
-
expect(result.success).toBe(false);
|
|
602
|
-
expect(result.reason).toBe("source_error");
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
it("throws on invalid work item ID format", async () => {
|
|
606
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
607
|
-
|
|
608
|
-
await expect(adapter.claimWork("invalid-id")).rejects.toThrow(
|
|
609
|
-
GitHubAPIError
|
|
610
|
-
);
|
|
611
|
-
await expect(adapter.claimWork("invalid-id")).rejects.toThrow(
|
|
612
|
-
'Invalid work item ID format: "invalid-id"'
|
|
613
|
-
);
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
it("uses custom in_progress label", async () => {
|
|
617
|
-
const mockIssue = createMockIssue({ number: 5 });
|
|
618
|
-
const updatedIssue = createMockIssue({ number: 5 });
|
|
619
|
-
|
|
620
|
-
mockFetch
|
|
621
|
-
.mockReturnValueOnce(mockFetchResponse(mockIssue))
|
|
622
|
-
.mockReturnValueOnce(mockFetchResponse(undefined))
|
|
623
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
|
|
624
|
-
.mockReturnValueOnce(mockFetchResponse(updatedIssue));
|
|
625
|
-
|
|
626
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
627
|
-
createConfig({
|
|
628
|
-
labels: { in_progress: "custom-wip" },
|
|
629
|
-
})
|
|
630
|
-
);
|
|
631
|
-
await adapter.claimWork("github-5");
|
|
632
|
-
|
|
633
|
-
const addLabelCall = getMockCall(mockFetch, 1);
|
|
634
|
-
expect(JSON.parse(addLabelCall.body!)).toEqual({
|
|
635
|
-
labels: ["custom-wip"],
|
|
636
|
-
});
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
|
|
640
|
-
// ===========================================================================
|
|
641
|
-
// completeWork Tests
|
|
642
|
-
// ===========================================================================
|
|
643
|
-
|
|
644
|
-
describe("completeWork", () => {
|
|
645
|
-
it("posts comment and closes issue on success outcome", async () => {
|
|
646
|
-
mockFetch
|
|
647
|
-
.mockReturnValueOnce(mockFetchResponse({ id: 1 })) // POST comment
|
|
648
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE label
|
|
649
|
-
.mockReturnValueOnce(mockFetchResponse({})); // PATCH issue
|
|
650
|
-
|
|
651
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
652
|
-
await adapter.completeWork("github-5", {
|
|
653
|
-
outcome: "success",
|
|
654
|
-
summary: "Fixed the bug",
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
// Verify comment was posted
|
|
658
|
-
const commentCall = getMockCall(mockFetch, 0);
|
|
659
|
-
expect(commentCall.url).toContain("/issues/5/comments");
|
|
660
|
-
expect(commentCall.method).toBe("POST");
|
|
661
|
-
const commentBody = JSON.parse(commentCall.body!).body;
|
|
662
|
-
expect(commentBody).toContain("✅");
|
|
663
|
-
expect(commentBody).toContain("success");
|
|
664
|
-
expect(commentBody).toContain("Fixed the bug");
|
|
665
|
-
|
|
666
|
-
// Verify issue was closed
|
|
667
|
-
const closeCall = getMockCall(mockFetch, 2);
|
|
668
|
-
expect(closeCall.url).toContain("/issues/5");
|
|
669
|
-
expect(closeCall.method).toBe("PATCH");
|
|
670
|
-
expect(JSON.parse(closeCall.body!)).toEqual({
|
|
671
|
-
state: "closed",
|
|
672
|
-
state_reason: "completed",
|
|
673
|
-
});
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
it("posts comment but does not close on failure outcome", async () => {
|
|
677
|
-
mockFetch
|
|
678
|
-
.mockReturnValueOnce(mockFetchResponse({ id: 1 })) // POST comment
|
|
679
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })); // DELETE label
|
|
680
|
-
|
|
681
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
682
|
-
await adapter.completeWork("github-5", {
|
|
683
|
-
outcome: "failure",
|
|
684
|
-
summary: "Could not fix the bug",
|
|
685
|
-
error: "Compilation error",
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
// Should only have 2 calls (comment + delete label), no close
|
|
689
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
690
|
-
|
|
691
|
-
const failureCommentCall = getMockCall(mockFetch, 0);
|
|
692
|
-
const failureCommentBody = JSON.parse(failureCommentCall.body!).body;
|
|
693
|
-
expect(failureCommentBody).toContain("❌");
|
|
694
|
-
expect(failureCommentBody).toContain("failure");
|
|
695
|
-
expect(failureCommentBody).toContain("Compilation error");
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
it("posts comment but does not close on partial outcome", async () => {
|
|
699
|
-
mockFetch
|
|
700
|
-
.mockReturnValueOnce(mockFetchResponse({ id: 1 }))
|
|
701
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }));
|
|
702
|
-
|
|
703
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
704
|
-
await adapter.completeWork("github-5", {
|
|
705
|
-
outcome: "partial",
|
|
706
|
-
summary: "Partially completed",
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
710
|
-
|
|
711
|
-
const partialCommentCall = getMockCall(mockFetch, 0);
|
|
712
|
-
const partialCommentBody = JSON.parse(partialCommentCall.body!).body;
|
|
713
|
-
expect(partialCommentBody).toContain("⚠️");
|
|
714
|
-
expect(partialCommentBody).toContain("partial");
|
|
715
|
-
});
|
|
716
|
-
|
|
717
|
-
it("includes details in comment when provided", async () => {
|
|
718
|
-
mockFetch
|
|
719
|
-
.mockReturnValueOnce(mockFetchResponse({ id: 1 }))
|
|
720
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
|
|
721
|
-
.mockReturnValueOnce(mockFetchResponse({}));
|
|
722
|
-
|
|
723
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
724
|
-
await adapter.completeWork("github-5", {
|
|
725
|
-
outcome: "success",
|
|
726
|
-
summary: "Fixed the bug",
|
|
727
|
-
details: "Changed the validation logic in login.ts",
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
const detailsCommentCall = getMockCall(mockFetch, 0);
|
|
731
|
-
const detailsCommentBody = JSON.parse(detailsCommentCall.body!).body;
|
|
732
|
-
expect(detailsCommentBody).toContain("### Details");
|
|
733
|
-
expect(detailsCommentBody).toContain("Changed the validation logic");
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
it("includes artifacts in comment when provided", async () => {
|
|
737
|
-
mockFetch
|
|
738
|
-
.mockReturnValueOnce(mockFetchResponse({ id: 1 }))
|
|
739
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
|
|
740
|
-
.mockReturnValueOnce(mockFetchResponse({}));
|
|
741
|
-
|
|
742
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
743
|
-
await adapter.completeWork("github-5", {
|
|
744
|
-
outcome: "success",
|
|
745
|
-
summary: "Created files",
|
|
746
|
-
artifacts: ["src/new-file.ts", "tests/new-file.test.ts"],
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
const artifactsCommentCall = getMockCall(mockFetch, 0);
|
|
750
|
-
const artifactsCommentBody = JSON.parse(artifactsCommentCall.body!).body;
|
|
751
|
-
expect(artifactsCommentBody).toContain("### Artifacts");
|
|
752
|
-
expect(artifactsCommentBody).toContain("src/new-file.ts");
|
|
753
|
-
expect(artifactsCommentBody).toContain("tests/new-file.test.ts");
|
|
754
|
-
});
|
|
755
|
-
|
|
756
|
-
it("removes in_progress label", async () => {
|
|
757
|
-
mockFetch
|
|
758
|
-
.mockReturnValueOnce(mockFetchResponse({ id: 1 }))
|
|
759
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
|
|
760
|
-
.mockReturnValueOnce(mockFetchResponse({}));
|
|
761
|
-
|
|
762
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
763
|
-
await adapter.completeWork("github-5", {
|
|
764
|
-
outcome: "success",
|
|
765
|
-
summary: "Done",
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
const deleteCall = getMockCall(mockFetch, 1);
|
|
769
|
-
expect(deleteCall.url).toContain("/labels/agent-working");
|
|
770
|
-
expect(deleteCall.method).toBe("DELETE");
|
|
771
|
-
});
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
// ===========================================================================
|
|
775
|
-
// releaseWork Tests
|
|
776
|
-
// ===========================================================================
|
|
777
|
-
|
|
778
|
-
describe("releaseWork", () => {
|
|
779
|
-
it("removes in_progress label and adds ready label", async () => {
|
|
780
|
-
mockFetch
|
|
781
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE in_progress
|
|
782
|
-
.mockReturnValueOnce(mockFetchResponse([{ name: "ready" }])); // POST ready
|
|
783
|
-
|
|
784
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
785
|
-
const result = await adapter.releaseWork("github-5");
|
|
786
|
-
|
|
787
|
-
expect(result.success).toBe(true);
|
|
788
|
-
|
|
789
|
-
// Verify DELETE call
|
|
790
|
-
const deleteCall = getMockCall(mockFetch, 0);
|
|
791
|
-
expect(deleteCall.url).toContain("/labels/agent-working");
|
|
792
|
-
expect(deleteCall.method).toBe("DELETE");
|
|
793
|
-
|
|
794
|
-
// Verify POST call
|
|
795
|
-
const postCall = getMockCall(mockFetch, 1);
|
|
796
|
-
expect(postCall.url).toContain("/issues/5/labels");
|
|
797
|
-
expect(postCall.method).toBe("POST");
|
|
798
|
-
expect(JSON.parse(postCall.body!)).toEqual({ labels: ["ready"] });
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
it("adds comment when addComment is true", async () => {
|
|
802
|
-
mockFetch
|
|
803
|
-
.mockReturnValueOnce(mockFetchResponse({ id: 1 })) // POST comment
|
|
804
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE label
|
|
805
|
-
.mockReturnValueOnce(mockFetchResponse([{ name: "ready" }])); // POST ready
|
|
806
|
-
|
|
807
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
808
|
-
const result = await adapter.releaseWork("github-5", {
|
|
809
|
-
addComment: true,
|
|
810
|
-
reason: "Agent timed out",
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
expect(result.success).toBe(true);
|
|
814
|
-
|
|
815
|
-
const releaseCommentCall = getMockCall(mockFetch, 0);
|
|
816
|
-
expect(releaseCommentCall.url).toContain("/issues/5/comments");
|
|
817
|
-
const releaseCommentBody = JSON.parse(releaseCommentCall.body!).body;
|
|
818
|
-
expect(releaseCommentBody).toContain("Work Released");
|
|
819
|
-
expect(releaseCommentBody).toContain("Agent timed out");
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
it("does not add comment when addComment is false", async () => {
|
|
823
|
-
mockFetch
|
|
824
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
|
|
825
|
-
.mockReturnValueOnce(mockFetchResponse([{ name: "ready" }]));
|
|
826
|
-
|
|
827
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
828
|
-
await adapter.releaseWork("github-5", {
|
|
829
|
-
addComment: false,
|
|
830
|
-
reason: "Agent timed out",
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
// Should only have 2 calls (delete label + add label)
|
|
834
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
835
|
-
const noCommentCall = getMockCall(mockFetch, 0);
|
|
836
|
-
expect(noCommentCall.url).toContain("/labels/");
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
it("returns failure on API error", async () => {
|
|
840
|
-
mockFetch
|
|
841
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
|
|
842
|
-
.mockRejectedValueOnce(new Error("Network error"));
|
|
843
|
-
|
|
844
|
-
// Disable retries to test immediate error handling
|
|
845
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
846
|
-
createConfig({ retry: { maxRetries: 0 } })
|
|
847
|
-
);
|
|
848
|
-
const result = await adapter.releaseWork("github-5");
|
|
849
|
-
|
|
850
|
-
expect(result.success).toBe(false);
|
|
851
|
-
expect(result.message).toContain("Network error");
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
it("respects cleanup_on_failure: true (default)", async () => {
|
|
855
|
-
mockFetch
|
|
856
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE in_progress
|
|
857
|
-
.mockReturnValueOnce(mockFetchResponse([{ name: "ready" }])); // POST ready
|
|
858
|
-
|
|
859
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
860
|
-
const result = await adapter.releaseWork("github-5");
|
|
861
|
-
|
|
862
|
-
expect(result.success).toBe(true);
|
|
863
|
-
|
|
864
|
-
// Should have 2 calls: DELETE in_progress + POST ready
|
|
865
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
866
|
-
const postCall = getMockCall(mockFetch, 1);
|
|
867
|
-
expect(postCall.url).toContain("/issues/5/labels");
|
|
868
|
-
expect(postCall.method).toBe("POST");
|
|
869
|
-
expect(JSON.parse(postCall.body!)).toEqual({ labels: ["ready"] });
|
|
870
|
-
});
|
|
871
|
-
|
|
872
|
-
it("respects cleanup_on_failure: false (skips re-adding ready label)", async () => {
|
|
873
|
-
mockFetch.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })); // DELETE in_progress
|
|
874
|
-
|
|
875
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
876
|
-
createConfig({ cleanup_on_failure: false })
|
|
877
|
-
);
|
|
878
|
-
const result = await adapter.releaseWork("github-5");
|
|
879
|
-
|
|
880
|
-
expect(result.success).toBe(true);
|
|
881
|
-
|
|
882
|
-
// Should only have 1 call: DELETE in_progress (no POST ready)
|
|
883
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
884
|
-
const deleteCall = getMockCall(mockFetch, 0);
|
|
885
|
-
expect(deleteCall.url).toContain("/labels/agent-working");
|
|
886
|
-
expect(deleteCall.method).toBe("DELETE");
|
|
887
|
-
});
|
|
888
|
-
|
|
889
|
-
it("respects cleanup_on_failure: true when explicitly set", async () => {
|
|
890
|
-
mockFetch
|
|
891
|
-
.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE in_progress
|
|
892
|
-
.mockReturnValueOnce(mockFetchResponse([{ name: "ready" }])); // POST ready
|
|
893
|
-
|
|
894
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
895
|
-
createConfig({ cleanup_on_failure: true })
|
|
896
|
-
);
|
|
897
|
-
const result = await adapter.releaseWork("github-5");
|
|
898
|
-
|
|
899
|
-
expect(result.success).toBe(true);
|
|
900
|
-
|
|
901
|
-
// Should have 2 calls: DELETE in_progress + POST ready
|
|
902
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
903
|
-
});
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
// ===========================================================================
|
|
907
|
-
// getWork Tests
|
|
908
|
-
// ===========================================================================
|
|
909
|
-
|
|
910
|
-
describe("getWork", () => {
|
|
911
|
-
it("fetches and returns work item", async () => {
|
|
912
|
-
const mockIssue = createMockIssue({
|
|
913
|
-
number: 10,
|
|
914
|
-
title: "Test Issue",
|
|
915
|
-
});
|
|
916
|
-
mockFetch.mockReturnValueOnce(mockFetchResponse(mockIssue));
|
|
917
|
-
|
|
918
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
919
|
-
const result = await adapter.getWork("github-10");
|
|
920
|
-
|
|
921
|
-
expect(result).toBeDefined();
|
|
922
|
-
expect(result?.id).toBe("github-10");
|
|
923
|
-
expect(result?.title).toBe("Test Issue");
|
|
924
|
-
|
|
925
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
926
|
-
expect.stringContaining("/repos/testowner/testrepo/issues/10"),
|
|
927
|
-
expect.any(Object)
|
|
928
|
-
);
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
it("returns undefined when issue not found", async () => {
|
|
932
|
-
mockFetch.mockReturnValueOnce(
|
|
933
|
-
mockFetchResponse({ message: "Not Found" }, { status: 404 })
|
|
934
|
-
);
|
|
935
|
-
|
|
936
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
937
|
-
const result = await adapter.getWork("github-999");
|
|
938
|
-
|
|
939
|
-
expect(result).toBeUndefined();
|
|
940
|
-
});
|
|
941
|
-
|
|
942
|
-
it("throws on other API errors", async () => {
|
|
943
|
-
mockFetch.mockReturnValueOnce(
|
|
944
|
-
mockFetchResponse({ message: "Server Error" }, { status: 500 })
|
|
945
|
-
);
|
|
946
|
-
|
|
947
|
-
// Disable retries to test immediate error handling
|
|
948
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
949
|
-
createConfig({ retry: { maxRetries: 0 } })
|
|
950
|
-
);
|
|
951
|
-
|
|
952
|
-
await expect(adapter.getWork("github-5")).rejects.toThrow(GitHubAPIError);
|
|
953
|
-
});
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
// ===========================================================================
|
|
957
|
-
// GitHubAPIError Tests
|
|
958
|
-
// ===========================================================================
|
|
959
|
-
|
|
960
|
-
describe("GitHubAPIError", () => {
|
|
961
|
-
it("has correct name and properties", () => {
|
|
962
|
-
const error = new GitHubAPIError("Test error", {
|
|
963
|
-
statusCode: 404,
|
|
964
|
-
endpoint: "/repos/test",
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
expect(error.name).toBe("GitHubAPIError");
|
|
968
|
-
expect(error.message).toBe("Test error");
|
|
969
|
-
expect(error.statusCode).toBe(404);
|
|
970
|
-
expect(error.endpoint).toBe("/repos/test");
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
it("preserves cause", () => {
|
|
974
|
-
const cause = new Error("Original error");
|
|
975
|
-
const error = new GitHubAPIError("Wrapped error", { cause });
|
|
976
|
-
|
|
977
|
-
expect(error.cause).toBe(cause);
|
|
978
|
-
});
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
// ===========================================================================
|
|
982
|
-
// createGitHubAdapter Factory Tests
|
|
983
|
-
// ===========================================================================
|
|
984
|
-
|
|
985
|
-
describe("createGitHubAdapter", () => {
|
|
986
|
-
it("creates a GitHubWorkSourceAdapter instance", () => {
|
|
987
|
-
const adapter = createGitHubAdapter(createConfig());
|
|
988
|
-
|
|
989
|
-
expect(adapter).toBeInstanceOf(GitHubWorkSourceAdapter);
|
|
990
|
-
expect(adapter.type).toBe("github");
|
|
991
|
-
});
|
|
992
|
-
|
|
993
|
-
it("passes config to adapter", () => {
|
|
994
|
-
const adapter = createGitHubAdapter(
|
|
995
|
-
createConfig({
|
|
996
|
-
owner: "myorg",
|
|
997
|
-
repo: "myrepo",
|
|
998
|
-
})
|
|
999
|
-
);
|
|
1000
|
-
|
|
1001
|
-
expect(adapter.type).toBe("github");
|
|
1002
|
-
});
|
|
1003
|
-
});
|
|
1004
|
-
|
|
1005
|
-
// ===========================================================================
|
|
1006
|
-
// Token Handling Tests
|
|
1007
|
-
// ===========================================================================
|
|
1008
|
-
|
|
1009
|
-
describe("token handling", () => {
|
|
1010
|
-
it("uses token from config", async () => {
|
|
1011
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
1012
|
-
|
|
1013
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1014
|
-
createConfig({ token: "config-token" })
|
|
1015
|
-
);
|
|
1016
|
-
await adapter.fetchAvailableWork();
|
|
1017
|
-
|
|
1018
|
-
const tokenCall = getMockCall(mockFetch, 0);
|
|
1019
|
-
expect(tokenCall.headers.Authorization).toBe("Bearer config-token");
|
|
1020
|
-
});
|
|
1021
|
-
|
|
1022
|
-
it("uses GITHUB_TOKEN env var when no config token", async () => {
|
|
1023
|
-
const originalEnv = process.env.GITHUB_TOKEN;
|
|
1024
|
-
process.env.GITHUB_TOKEN = "env-token";
|
|
1025
|
-
|
|
1026
|
-
try {
|
|
1027
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
1028
|
-
|
|
1029
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1030
|
-
createConfig({ token: undefined })
|
|
1031
|
-
);
|
|
1032
|
-
await adapter.fetchAvailableWork();
|
|
1033
|
-
|
|
1034
|
-
const envTokenCall = getMockCall(mockFetch, 0);
|
|
1035
|
-
expect(envTokenCall.headers.Authorization).toBe("Bearer env-token");
|
|
1036
|
-
} finally {
|
|
1037
|
-
if (originalEnv !== undefined) {
|
|
1038
|
-
process.env.GITHUB_TOKEN = originalEnv;
|
|
1039
|
-
} else {
|
|
1040
|
-
delete process.env.GITHUB_TOKEN;
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
});
|
|
1044
|
-
|
|
1045
|
-
it("makes unauthenticated request when no token available", async () => {
|
|
1046
|
-
const originalEnv = process.env.GITHUB_TOKEN;
|
|
1047
|
-
delete process.env.GITHUB_TOKEN;
|
|
1048
|
-
|
|
1049
|
-
try {
|
|
1050
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
1051
|
-
|
|
1052
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1053
|
-
createConfig({ token: undefined })
|
|
1054
|
-
);
|
|
1055
|
-
await adapter.fetchAvailableWork();
|
|
1056
|
-
|
|
1057
|
-
const noTokenCall = getMockCall(mockFetch, 0);
|
|
1058
|
-
expect(noTokenCall.headers.Authorization).toBeUndefined();
|
|
1059
|
-
} finally {
|
|
1060
|
-
if (originalEnv !== undefined) {
|
|
1061
|
-
process.env.GITHUB_TOKEN = originalEnv;
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
});
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
// ===========================================================================
|
|
1068
|
-
// Custom API Base URL Tests
|
|
1069
|
-
// ===========================================================================
|
|
1070
|
-
|
|
1071
|
-
describe("custom API base URL", () => {
|
|
1072
|
-
it("uses default api.github.com when not configured", async () => {
|
|
1073
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
1074
|
-
|
|
1075
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
1076
|
-
await adapter.fetchAvailableWork();
|
|
1077
|
-
|
|
1078
|
-
const defaultUrlCall = getMockCall(mockFetch, 0);
|
|
1079
|
-
expect(defaultUrlCall.url.startsWith("https://api.github.com")).toBe(true);
|
|
1080
|
-
});
|
|
1081
|
-
|
|
1082
|
-
it("uses custom API base URL for GitHub Enterprise", async () => {
|
|
1083
|
-
mockFetch.mockReturnValue(mockFetchResponse([]));
|
|
1084
|
-
|
|
1085
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1086
|
-
createConfig({
|
|
1087
|
-
apiBaseUrl: "https://github.mycompany.com/api/v3",
|
|
1088
|
-
})
|
|
1089
|
-
);
|
|
1090
|
-
await adapter.fetchAvailableWork();
|
|
1091
|
-
|
|
1092
|
-
const customUrlCall = getMockCall(mockFetch, 0);
|
|
1093
|
-
expect(customUrlCall.url.startsWith("https://github.mycompany.com/api/v3")).toBe(true);
|
|
1094
|
-
});
|
|
1095
|
-
});
|
|
1096
|
-
|
|
1097
|
-
// ===========================================================================
|
|
1098
|
-
// Rate Limit Handling Tests
|
|
1099
|
-
// ===========================================================================
|
|
1100
|
-
|
|
1101
|
-
describe("rate limit handling", () => {
|
|
1102
|
-
it("extracts rate limit info from response headers", async () => {
|
|
1103
|
-
const mockIssues = [createMockIssue()];
|
|
1104
|
-
mockFetch.mockReturnValue(
|
|
1105
|
-
mockFetchResponse(mockIssues, {
|
|
1106
|
-
headers: {
|
|
1107
|
-
"X-RateLimit-Limit": "5000",
|
|
1108
|
-
"X-RateLimit-Remaining": "4999",
|
|
1109
|
-
"X-RateLimit-Reset": "1700000000",
|
|
1110
|
-
"X-RateLimit-Resource": "core",
|
|
1111
|
-
},
|
|
1112
|
-
})
|
|
1113
|
-
);
|
|
1114
|
-
|
|
1115
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
1116
|
-
await adapter.fetchAvailableWork();
|
|
1117
|
-
|
|
1118
|
-
expect(adapter.lastRateLimitInfo).toEqual({
|
|
1119
|
-
limit: 5000,
|
|
1120
|
-
remaining: 4999,
|
|
1121
|
-
reset: 1700000000,
|
|
1122
|
-
resource: "core",
|
|
1123
|
-
});
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
it("triggers rate limit warning when remaining is below threshold", async () => {
|
|
1127
|
-
const warningCallback = vi.fn();
|
|
1128
|
-
const mockIssues = [createMockIssue()];
|
|
1129
|
-
mockFetch.mockReturnValue(
|
|
1130
|
-
mockFetchResponse(mockIssues, {
|
|
1131
|
-
headers: {
|
|
1132
|
-
"X-RateLimit-Limit": "5000",
|
|
1133
|
-
"X-RateLimit-Remaining": "50",
|
|
1134
|
-
"X-RateLimit-Reset": "1700000000",
|
|
1135
|
-
"X-RateLimit-Resource": "core",
|
|
1136
|
-
},
|
|
1137
|
-
})
|
|
1138
|
-
);
|
|
1139
|
-
|
|
1140
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1141
|
-
createConfig({
|
|
1142
|
-
rateLimitWarning: {
|
|
1143
|
-
warningThreshold: 100,
|
|
1144
|
-
onWarning: warningCallback,
|
|
1145
|
-
},
|
|
1146
|
-
})
|
|
1147
|
-
);
|
|
1148
|
-
await adapter.fetchAvailableWork();
|
|
1149
|
-
|
|
1150
|
-
expect(warningCallback).toHaveBeenCalledWith({
|
|
1151
|
-
limit: 5000,
|
|
1152
|
-
remaining: 50,
|
|
1153
|
-
reset: 1700000000,
|
|
1154
|
-
resource: "core",
|
|
1155
|
-
});
|
|
1156
|
-
});
|
|
1157
|
-
|
|
1158
|
-
it("does not trigger warning when remaining is above threshold", async () => {
|
|
1159
|
-
const warningCallback = vi.fn();
|
|
1160
|
-
const mockIssues = [createMockIssue()];
|
|
1161
|
-
mockFetch.mockReturnValue(
|
|
1162
|
-
mockFetchResponse(mockIssues, {
|
|
1163
|
-
headers: {
|
|
1164
|
-
"X-RateLimit-Limit": "5000",
|
|
1165
|
-
"X-RateLimit-Remaining": "150",
|
|
1166
|
-
"X-RateLimit-Reset": "1700000000",
|
|
1167
|
-
"X-RateLimit-Resource": "core",
|
|
1168
|
-
},
|
|
1169
|
-
})
|
|
1170
|
-
);
|
|
1171
|
-
|
|
1172
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1173
|
-
createConfig({
|
|
1174
|
-
rateLimitWarning: {
|
|
1175
|
-
warningThreshold: 100,
|
|
1176
|
-
onWarning: warningCallback,
|
|
1177
|
-
},
|
|
1178
|
-
})
|
|
1179
|
-
);
|
|
1180
|
-
await adapter.fetchAvailableWork();
|
|
1181
|
-
|
|
1182
|
-
expect(warningCallback).not.toHaveBeenCalled();
|
|
1183
|
-
});
|
|
1184
|
-
|
|
1185
|
-
it("detects rate limit error from 403 with remaining=0", async () => {
|
|
1186
|
-
mockFetch.mockReturnValue(
|
|
1187
|
-
mockFetchResponse({ message: "API rate limit exceeded" }, {
|
|
1188
|
-
status: 403,
|
|
1189
|
-
headers: {
|
|
1190
|
-
"X-RateLimit-Limit": "5000",
|
|
1191
|
-
"X-RateLimit-Remaining": "0",
|
|
1192
|
-
"X-RateLimit-Reset": "1700000000",
|
|
1193
|
-
"X-RateLimit-Resource": "core",
|
|
1194
|
-
},
|
|
1195
|
-
})
|
|
1196
|
-
);
|
|
1197
|
-
|
|
1198
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1199
|
-
createConfig({
|
|
1200
|
-
retry: { maxRetries: 0 }, // Disable retries for this test
|
|
1201
|
-
})
|
|
1202
|
-
);
|
|
1203
|
-
|
|
1204
|
-
try {
|
|
1205
|
-
await adapter.fetchAvailableWork();
|
|
1206
|
-
expect.fail("Should have thrown");
|
|
1207
|
-
} catch (error) {
|
|
1208
|
-
expect(error).toBeInstanceOf(GitHubAPIError);
|
|
1209
|
-
const apiError = error as GitHubAPIError;
|
|
1210
|
-
expect(apiError.isRateLimitError).toBe(true);
|
|
1211
|
-
expect(apiError.statusCode).toBe(403);
|
|
1212
|
-
expect(apiError.rateLimitInfo).toEqual({
|
|
1213
|
-
limit: 5000,
|
|
1214
|
-
remaining: 0,
|
|
1215
|
-
reset: 1700000000,
|
|
1216
|
-
resource: "core",
|
|
1217
|
-
});
|
|
1218
|
-
}
|
|
1219
|
-
});
|
|
1220
|
-
|
|
1221
|
-
it("detects rate limit error from 429 status", async () => {
|
|
1222
|
-
mockFetch.mockReturnValue(
|
|
1223
|
-
mockFetchResponse({ message: "Too Many Requests" }, { status: 429 })
|
|
1224
|
-
);
|
|
1225
|
-
|
|
1226
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1227
|
-
createConfig({
|
|
1228
|
-
retry: { maxRetries: 0 },
|
|
1229
|
-
})
|
|
1230
|
-
);
|
|
1231
|
-
|
|
1232
|
-
try {
|
|
1233
|
-
await adapter.fetchAvailableWork();
|
|
1234
|
-
expect.fail("Should have thrown");
|
|
1235
|
-
} catch (error) {
|
|
1236
|
-
expect(error).toBeInstanceOf(GitHubAPIError);
|
|
1237
|
-
const apiError = error as GitHubAPIError;
|
|
1238
|
-
expect(apiError.isRateLimitError).toBe(true);
|
|
1239
|
-
expect(apiError.statusCode).toBe(429);
|
|
1240
|
-
}
|
|
1241
|
-
});
|
|
1242
|
-
});
|
|
1243
|
-
|
|
1244
|
-
// ===========================================================================
|
|
1245
|
-
// Retry Logic Tests
|
|
1246
|
-
// ===========================================================================
|
|
1247
|
-
|
|
1248
|
-
describe("retry logic", () => {
|
|
1249
|
-
it("retries on rate limit error with exponential backoff", async () => {
|
|
1250
|
-
const mockIssues = [createMockIssue()];
|
|
1251
|
-
|
|
1252
|
-
// First call fails with rate limit, second succeeds
|
|
1253
|
-
mockFetch
|
|
1254
|
-
.mockReturnValueOnce(
|
|
1255
|
-
mockFetchResponse({ message: "Rate limit exceeded" }, {
|
|
1256
|
-
status: 403,
|
|
1257
|
-
headers: {
|
|
1258
|
-
"X-RateLimit-Limit": "5000",
|
|
1259
|
-
"X-RateLimit-Remaining": "0",
|
|
1260
|
-
"X-RateLimit-Reset": String(Math.floor(Date.now() / 1000) + 1),
|
|
1261
|
-
},
|
|
1262
|
-
})
|
|
1263
|
-
)
|
|
1264
|
-
.mockReturnValueOnce(mockFetchResponse(mockIssues));
|
|
1265
|
-
|
|
1266
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1267
|
-
createConfig({
|
|
1268
|
-
retry: {
|
|
1269
|
-
maxRetries: 1,
|
|
1270
|
-
baseDelayMs: 10, // Short delay for tests
|
|
1271
|
-
maxDelayMs: 100,
|
|
1272
|
-
},
|
|
1273
|
-
})
|
|
1274
|
-
);
|
|
1275
|
-
|
|
1276
|
-
const result = await adapter.fetchAvailableWork();
|
|
1277
|
-
|
|
1278
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
1279
|
-
expect(result.items).toHaveLength(1);
|
|
1280
|
-
});
|
|
1281
|
-
|
|
1282
|
-
it("retries on network errors", async () => {
|
|
1283
|
-
const mockIssues = [createMockIssue()];
|
|
1284
|
-
|
|
1285
|
-
// First call fails with network error, second succeeds
|
|
1286
|
-
mockFetch
|
|
1287
|
-
.mockRejectedValueOnce(new Error("Network connection failed"))
|
|
1288
|
-
.mockReturnValueOnce(mockFetchResponse(mockIssues));
|
|
1289
|
-
|
|
1290
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1291
|
-
createConfig({
|
|
1292
|
-
retry: {
|
|
1293
|
-
maxRetries: 1,
|
|
1294
|
-
baseDelayMs: 10,
|
|
1295
|
-
},
|
|
1296
|
-
})
|
|
1297
|
-
);
|
|
1298
|
-
|
|
1299
|
-
const result = await adapter.fetchAvailableWork();
|
|
1300
|
-
|
|
1301
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
1302
|
-
expect(result.items).toHaveLength(1);
|
|
1303
|
-
});
|
|
1304
|
-
|
|
1305
|
-
it("retries on 5xx server errors", async () => {
|
|
1306
|
-
const mockIssues = [createMockIssue()];
|
|
1307
|
-
|
|
1308
|
-
mockFetch
|
|
1309
|
-
.mockReturnValueOnce(
|
|
1310
|
-
mockFetchResponse({ message: "Internal Server Error" }, { status: 500 })
|
|
1311
|
-
)
|
|
1312
|
-
.mockReturnValueOnce(mockFetchResponse(mockIssues));
|
|
1313
|
-
|
|
1314
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1315
|
-
createConfig({
|
|
1316
|
-
retry: {
|
|
1317
|
-
maxRetries: 1,
|
|
1318
|
-
baseDelayMs: 10,
|
|
1319
|
-
},
|
|
1320
|
-
})
|
|
1321
|
-
);
|
|
1322
|
-
|
|
1323
|
-
const result = await adapter.fetchAvailableWork();
|
|
1324
|
-
|
|
1325
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
1326
|
-
expect(result.items).toHaveLength(1);
|
|
1327
|
-
});
|
|
1328
|
-
|
|
1329
|
-
it("does not retry on 404 errors", async () => {
|
|
1330
|
-
mockFetch.mockReturnValue(
|
|
1331
|
-
mockFetchResponse({ message: "Not Found" }, { status: 404 })
|
|
1332
|
-
);
|
|
1333
|
-
|
|
1334
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1335
|
-
createConfig({
|
|
1336
|
-
retry: { maxRetries: 3, baseDelayMs: 10 },
|
|
1337
|
-
})
|
|
1338
|
-
);
|
|
1339
|
-
|
|
1340
|
-
await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
|
|
1341
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
1342
|
-
});
|
|
1343
|
-
|
|
1344
|
-
it("does not retry on 401 unauthorized errors", async () => {
|
|
1345
|
-
mockFetch.mockReturnValue(
|
|
1346
|
-
mockFetchResponse({ message: "Bad credentials" }, { status: 401 })
|
|
1347
|
-
);
|
|
1348
|
-
|
|
1349
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1350
|
-
createConfig({
|
|
1351
|
-
retry: { maxRetries: 3, baseDelayMs: 10 },
|
|
1352
|
-
})
|
|
1353
|
-
);
|
|
1354
|
-
|
|
1355
|
-
await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
|
|
1356
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
1357
|
-
});
|
|
1358
|
-
|
|
1359
|
-
it("gives up after max retries", async () => {
|
|
1360
|
-
mockFetch.mockReturnValue(
|
|
1361
|
-
mockFetchResponse({ message: "Server Error" }, { status: 500 })
|
|
1362
|
-
);
|
|
1363
|
-
|
|
1364
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1365
|
-
createConfig({
|
|
1366
|
-
retry: {
|
|
1367
|
-
maxRetries: 2,
|
|
1368
|
-
baseDelayMs: 10,
|
|
1369
|
-
},
|
|
1370
|
-
})
|
|
1371
|
-
);
|
|
1372
|
-
|
|
1373
|
-
await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
|
|
1374
|
-
// Initial attempt + 2 retries = 3 total calls
|
|
1375
|
-
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
1376
|
-
});
|
|
1377
|
-
|
|
1378
|
-
it("respects custom retry configuration", async () => {
|
|
1379
|
-
mockFetch.mockReturnValue(
|
|
1380
|
-
mockFetchResponse({ message: "Server Error" }, { status: 500 })
|
|
1381
|
-
);
|
|
1382
|
-
|
|
1383
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1384
|
-
createConfig({
|
|
1385
|
-
retry: {
|
|
1386
|
-
maxRetries: 5,
|
|
1387
|
-
baseDelayMs: 5,
|
|
1388
|
-
},
|
|
1389
|
-
})
|
|
1390
|
-
);
|
|
1391
|
-
|
|
1392
|
-
await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
|
|
1393
|
-
expect(mockFetch).toHaveBeenCalledTimes(6); // 1 + 5 retries
|
|
1394
|
-
});
|
|
1395
|
-
});
|
|
1396
|
-
|
|
1397
|
-
// ===========================================================================
|
|
1398
|
-
// 404 Error Handling Tests
|
|
1399
|
-
// ===========================================================================
|
|
1400
|
-
|
|
1401
|
-
describe("404 error handling", () => {
|
|
1402
|
-
it("handles 404 gracefully in getWork (returns undefined)", async () => {
|
|
1403
|
-
mockFetch.mockReturnValue(
|
|
1404
|
-
mockFetchResponse({ message: "Not Found" }, { status: 404 })
|
|
1405
|
-
);
|
|
1406
|
-
|
|
1407
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
1408
|
-
const result = await adapter.getWork("github-999");
|
|
1409
|
-
|
|
1410
|
-
expect(result).toBeUndefined();
|
|
1411
|
-
});
|
|
1412
|
-
|
|
1413
|
-
it("handles 404 gracefully in claimWork (returns not_found)", async () => {
|
|
1414
|
-
mockFetch.mockReturnValue(
|
|
1415
|
-
mockFetchResponse({ message: "Not Found" }, { status: 404 })
|
|
1416
|
-
);
|
|
1417
|
-
|
|
1418
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
1419
|
-
const result = await adapter.claimWork("github-999");
|
|
1420
|
-
|
|
1421
|
-
expect(result.success).toBe(false);
|
|
1422
|
-
expect(result.reason).toBe("not_found");
|
|
1423
|
-
});
|
|
1424
|
-
|
|
1425
|
-
it("throws 404 in fetchAvailableWork (indicates config error)", async () => {
|
|
1426
|
-
mockFetch.mockReturnValue(
|
|
1427
|
-
mockFetchResponse({ message: "Not Found" }, { status: 404 })
|
|
1428
|
-
);
|
|
1429
|
-
|
|
1430
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
1431
|
-
|
|
1432
|
-
await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
|
|
1433
|
-
});
|
|
1434
|
-
});
|
|
1435
|
-
|
|
1436
|
-
// ===========================================================================
|
|
1437
|
-
// PAT Validation Tests
|
|
1438
|
-
// ===========================================================================
|
|
1439
|
-
|
|
1440
|
-
describe("validateToken", () => {
|
|
1441
|
-
it("validates token with required scopes", async () => {
|
|
1442
|
-
mockFetch.mockReturnValue(
|
|
1443
|
-
mockFetchResponse({ login: "testuser" }, {
|
|
1444
|
-
headers: {
|
|
1445
|
-
"X-OAuth-Scopes": "repo, user",
|
|
1446
|
-
"X-RateLimit-Limit": "5000",
|
|
1447
|
-
"X-RateLimit-Remaining": "4999",
|
|
1448
|
-
"X-RateLimit-Reset": "1700000000",
|
|
1449
|
-
},
|
|
1450
|
-
})
|
|
1451
|
-
);
|
|
1452
|
-
|
|
1453
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
1454
|
-
const result = await adapter.validateToken();
|
|
1455
|
-
|
|
1456
|
-
expect(result.valid).toBe(true);
|
|
1457
|
-
expect(result.scopes).toContain("repo");
|
|
1458
|
-
});
|
|
1459
|
-
|
|
1460
|
-
it("throws GitHubAuthError when required scopes are missing", async () => {
|
|
1461
|
-
mockFetch.mockReturnValue(
|
|
1462
|
-
mockFetchResponse({ login: "testuser" }, {
|
|
1463
|
-
headers: {
|
|
1464
|
-
"X-OAuth-Scopes": "user, read:org",
|
|
1465
|
-
"X-RateLimit-Limit": "5000",
|
|
1466
|
-
"X-RateLimit-Remaining": "4999",
|
|
1467
|
-
"X-RateLimit-Reset": "1700000000",
|
|
1468
|
-
},
|
|
1469
|
-
})
|
|
1470
|
-
);
|
|
1471
|
-
|
|
1472
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
1473
|
-
|
|
1474
|
-
try {
|
|
1475
|
-
await adapter.validateToken();
|
|
1476
|
-
expect.fail("Should have thrown");
|
|
1477
|
-
} catch (error) {
|
|
1478
|
-
expect(error).toBeInstanceOf(GitHubAuthError);
|
|
1479
|
-
const authError = error as GitHubAuthError;
|
|
1480
|
-
expect(authError.missingScopes).toContain("repo");
|
|
1481
|
-
expect(authError.foundScopes).toContain("user");
|
|
1482
|
-
}
|
|
1483
|
-
});
|
|
1484
|
-
|
|
1485
|
-
it("throws GitHubAuthError when token is missing", async () => {
|
|
1486
|
-
const originalEnv = process.env.GITHUB_TOKEN;
|
|
1487
|
-
delete process.env.GITHUB_TOKEN;
|
|
1488
|
-
|
|
1489
|
-
try {
|
|
1490
|
-
const adapter = new GitHubWorkSourceAdapter(
|
|
1491
|
-
createConfig({ token: undefined })
|
|
1492
|
-
);
|
|
1493
|
-
|
|
1494
|
-
await expect(adapter.validateToken()).rejects.toThrow(GitHubAuthError);
|
|
1495
|
-
} finally {
|
|
1496
|
-
if (originalEnv !== undefined) {
|
|
1497
|
-
process.env.GITHUB_TOKEN = originalEnv;
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
});
|
|
1501
|
-
|
|
1502
|
-
it("throws GitHubAuthError on 401 unauthorized", async () => {
|
|
1503
|
-
mockFetch.mockReturnValue(
|
|
1504
|
-
mockFetchResponse({ message: "Bad credentials" }, {
|
|
1505
|
-
status: 401,
|
|
1506
|
-
headers: {
|
|
1507
|
-
"X-OAuth-Scopes": "",
|
|
1508
|
-
},
|
|
1509
|
-
})
|
|
1510
|
-
);
|
|
1511
|
-
|
|
1512
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
1513
|
-
|
|
1514
|
-
await expect(adapter.validateToken()).rejects.toThrow(GitHubAuthError);
|
|
1515
|
-
});
|
|
1516
|
-
|
|
1517
|
-
it("updates rate limit info during validation", async () => {
|
|
1518
|
-
mockFetch.mockReturnValue(
|
|
1519
|
-
mockFetchResponse({ login: "testuser" }, {
|
|
1520
|
-
headers: {
|
|
1521
|
-
"X-OAuth-Scopes": "repo",
|
|
1522
|
-
"X-RateLimit-Limit": "5000",
|
|
1523
|
-
"X-RateLimit-Remaining": "4500",
|
|
1524
|
-
"X-RateLimit-Reset": "1700000000",
|
|
1525
|
-
},
|
|
1526
|
-
})
|
|
1527
|
-
);
|
|
1528
|
-
|
|
1529
|
-
const adapter = new GitHubWorkSourceAdapter(createConfig());
|
|
1530
|
-
await adapter.validateToken();
|
|
1531
|
-
|
|
1532
|
-
expect(adapter.lastRateLimitInfo?.remaining).toBe(4500);
|
|
1533
|
-
});
|
|
1534
|
-
});
|
|
1535
|
-
|
|
1536
|
-
// ===========================================================================
|
|
1537
|
-
// GitHubAPIError Enhanced Tests
|
|
1538
|
-
// ===========================================================================
|
|
1539
|
-
|
|
1540
|
-
describe("GitHubAPIError enhanced features", () => {
|
|
1541
|
-
it("isRetryable returns true for rate limit errors", () => {
|
|
1542
|
-
const error = new GitHubAPIError("Rate limited", {
|
|
1543
|
-
statusCode: 403,
|
|
1544
|
-
isRateLimitError: true,
|
|
1545
|
-
});
|
|
1546
|
-
|
|
1547
|
-
expect(error.isRetryable()).toBe(true);
|
|
1548
|
-
});
|
|
1549
|
-
|
|
1550
|
-
it("isRetryable returns true for network errors (no status code)", () => {
|
|
1551
|
-
const error = new GitHubAPIError("Network error");
|
|
1552
|
-
|
|
1553
|
-
expect(error.isRetryable()).toBe(true);
|
|
1554
|
-
});
|
|
1555
|
-
|
|
1556
|
-
it("isRetryable returns true for 5xx errors", () => {
|
|
1557
|
-
const error500 = new GitHubAPIError("Server error", { statusCode: 500 });
|
|
1558
|
-
const error502 = new GitHubAPIError("Bad gateway", { statusCode: 502 });
|
|
1559
|
-
const error503 = new GitHubAPIError("Service unavailable", { statusCode: 503 });
|
|
1560
|
-
|
|
1561
|
-
expect(error500.isRetryable()).toBe(true);
|
|
1562
|
-
expect(error502.isRetryable()).toBe(true);
|
|
1563
|
-
expect(error503.isRetryable()).toBe(true);
|
|
1564
|
-
});
|
|
1565
|
-
|
|
1566
|
-
it("isRetryable returns false for 4xx errors (except rate limit)", () => {
|
|
1567
|
-
const error401 = new GitHubAPIError("Unauthorized", { statusCode: 401 });
|
|
1568
|
-
const error403 = new GitHubAPIError("Forbidden", { statusCode: 403, isRateLimitError: false });
|
|
1569
|
-
const error404 = new GitHubAPIError("Not found", { statusCode: 404 });
|
|
1570
|
-
|
|
1571
|
-
expect(error401.isRetryable()).toBe(false);
|
|
1572
|
-
expect(error403.isRetryable()).toBe(false);
|
|
1573
|
-
expect(error404.isRetryable()).toBe(false);
|
|
1574
|
-
});
|
|
1575
|
-
|
|
1576
|
-
it("isNotFound returns true for 404 errors", () => {
|
|
1577
|
-
const error = new GitHubAPIError("Not found", { statusCode: 404 });
|
|
1578
|
-
|
|
1579
|
-
expect(error.isNotFound()).toBe(true);
|
|
1580
|
-
});
|
|
1581
|
-
|
|
1582
|
-
it("isPermissionDenied returns true for 403 without rate limit", () => {
|
|
1583
|
-
const error = new GitHubAPIError("Forbidden", { statusCode: 403, isRateLimitError: false });
|
|
1584
|
-
|
|
1585
|
-
expect(error.isPermissionDenied()).toBe(true);
|
|
1586
|
-
});
|
|
1587
|
-
|
|
1588
|
-
it("isPermissionDenied returns false for rate limit 403", () => {
|
|
1589
|
-
const error = new GitHubAPIError("Rate limited", { statusCode: 403, isRateLimitError: true });
|
|
1590
|
-
|
|
1591
|
-
expect(error.isPermissionDenied()).toBe(false);
|
|
1592
|
-
});
|
|
1593
|
-
|
|
1594
|
-
it("getTimeUntilReset returns time in ms", () => {
|
|
1595
|
-
const futureReset = Math.floor(Date.now() / 1000) + 60; // 60 seconds from now
|
|
1596
|
-
const error = new GitHubAPIError("Rate limited", {
|
|
1597
|
-
statusCode: 403,
|
|
1598
|
-
isRateLimitError: true,
|
|
1599
|
-
rateLimitInfo: {
|
|
1600
|
-
limit: 5000,
|
|
1601
|
-
remaining: 0,
|
|
1602
|
-
reset: futureReset,
|
|
1603
|
-
resource: "core",
|
|
1604
|
-
},
|
|
1605
|
-
});
|
|
1606
|
-
|
|
1607
|
-
const timeUntilReset = error.getTimeUntilReset();
|
|
1608
|
-
expect(timeUntilReset).toBeDefined();
|
|
1609
|
-
expect(timeUntilReset).toBeGreaterThan(50000); // Should be close to 60000
|
|
1610
|
-
expect(timeUntilReset).toBeLessThanOrEqual(60000);
|
|
1611
|
-
});
|
|
1612
|
-
|
|
1613
|
-
it("rateLimitResetAt is set correctly", () => {
|
|
1614
|
-
const resetTimestamp = 1700000000;
|
|
1615
|
-
const error = new GitHubAPIError("Rate limited", {
|
|
1616
|
-
rateLimitInfo: {
|
|
1617
|
-
limit: 5000,
|
|
1618
|
-
remaining: 0,
|
|
1619
|
-
reset: resetTimestamp,
|
|
1620
|
-
resource: "core",
|
|
1621
|
-
},
|
|
1622
|
-
});
|
|
1623
|
-
|
|
1624
|
-
expect(error.rateLimitResetAt).toEqual(new Date(resetTimestamp * 1000));
|
|
1625
|
-
});
|
|
1626
|
-
});
|
|
1627
|
-
|
|
1628
|
-
// ===========================================================================
|
|
1629
|
-
// GitHubAuthError Tests
|
|
1630
|
-
// ===========================================================================
|
|
1631
|
-
|
|
1632
|
-
describe("GitHubAuthError", () => {
|
|
1633
|
-
it("calculates missing scopes correctly", () => {
|
|
1634
|
-
const error = new GitHubAuthError("Missing scopes", {
|
|
1635
|
-
foundScopes: ["user", "read:org"],
|
|
1636
|
-
requiredScopes: ["repo", "user"],
|
|
1637
|
-
});
|
|
1638
|
-
|
|
1639
|
-
expect(error.missingScopes).toEqual(["repo"]);
|
|
1640
|
-
expect(error.foundScopes).toEqual(["user", "read:org"]);
|
|
1641
|
-
expect(error.requiredScopes).toEqual(["repo", "user"]);
|
|
1642
|
-
});
|
|
1643
|
-
|
|
1644
|
-
it("has correct name", () => {
|
|
1645
|
-
const error = new GitHubAuthError("Test", {
|
|
1646
|
-
foundScopes: [],
|
|
1647
|
-
requiredScopes: ["repo"],
|
|
1648
|
-
});
|
|
1649
|
-
|
|
1650
|
-
expect(error.name).toBe("GitHubAuthError");
|
|
1651
|
-
});
|
|
1652
|
-
});
|
|
1653
|
-
|
|
1654
|
-
// ===========================================================================
|
|
1655
|
-
// Utility Function Tests
|
|
1656
|
-
// ===========================================================================
|
|
1657
|
-
|
|
1658
|
-
describe("extractRateLimitInfo", () => {
|
|
1659
|
-
it("extracts all rate limit headers", () => {
|
|
1660
|
-
const headers = new Headers({
|
|
1661
|
-
"X-RateLimit-Limit": "5000",
|
|
1662
|
-
"X-RateLimit-Remaining": "4999",
|
|
1663
|
-
"X-RateLimit-Reset": "1700000000",
|
|
1664
|
-
"X-RateLimit-Resource": "core",
|
|
1665
|
-
});
|
|
1666
|
-
|
|
1667
|
-
const info = extractRateLimitInfo(headers);
|
|
1668
|
-
|
|
1669
|
-
expect(info).toEqual({
|
|
1670
|
-
limit: 5000,
|
|
1671
|
-
remaining: 4999,
|
|
1672
|
-
reset: 1700000000,
|
|
1673
|
-
resource: "core",
|
|
1674
|
-
});
|
|
1675
|
-
});
|
|
1676
|
-
|
|
1677
|
-
it("returns undefined when headers are missing", () => {
|
|
1678
|
-
const headers = new Headers();
|
|
1679
|
-
|
|
1680
|
-
const info = extractRateLimitInfo(headers);
|
|
1681
|
-
|
|
1682
|
-
expect(info).toBeUndefined();
|
|
1683
|
-
});
|
|
1684
|
-
|
|
1685
|
-
it("defaults resource to core when not provided", () => {
|
|
1686
|
-
const headers = new Headers({
|
|
1687
|
-
"X-RateLimit-Limit": "5000",
|
|
1688
|
-
"X-RateLimit-Remaining": "4999",
|
|
1689
|
-
"X-RateLimit-Reset": "1700000000",
|
|
1690
|
-
});
|
|
1691
|
-
|
|
1692
|
-
const info = extractRateLimitInfo(headers);
|
|
1693
|
-
|
|
1694
|
-
expect(info?.resource).toBe("core");
|
|
1695
|
-
});
|
|
1696
|
-
});
|
|
1697
|
-
|
|
1698
|
-
describe("isRateLimitResponse", () => {
|
|
1699
|
-
it("returns true for 403 with remaining=0", () => {
|
|
1700
|
-
const response = {
|
|
1701
|
-
status: 403,
|
|
1702
|
-
headers: {
|
|
1703
|
-
get: (name: string) => name === "X-RateLimit-Remaining" ? "0" : null,
|
|
1704
|
-
},
|
|
1705
|
-
} as unknown as Response;
|
|
1706
|
-
|
|
1707
|
-
expect(isRateLimitResponse(response)).toBe(true);
|
|
1708
|
-
});
|
|
1709
|
-
|
|
1710
|
-
it("returns true for 429 status", () => {
|
|
1711
|
-
const response = {
|
|
1712
|
-
status: 429,
|
|
1713
|
-
headers: {
|
|
1714
|
-
get: () => null,
|
|
1715
|
-
},
|
|
1716
|
-
} as unknown as Response;
|
|
1717
|
-
|
|
1718
|
-
expect(isRateLimitResponse(response)).toBe(true);
|
|
1719
|
-
});
|
|
1720
|
-
|
|
1721
|
-
it("returns false for 403 with remaining > 0", () => {
|
|
1722
|
-
const response = {
|
|
1723
|
-
status: 403,
|
|
1724
|
-
headers: {
|
|
1725
|
-
get: (name: string) => name === "X-RateLimit-Remaining" ? "100" : null,
|
|
1726
|
-
},
|
|
1727
|
-
} as unknown as Response;
|
|
1728
|
-
|
|
1729
|
-
expect(isRateLimitResponse(response)).toBe(false);
|
|
1730
|
-
});
|
|
1731
|
-
|
|
1732
|
-
it("returns false for non-403/429 status", () => {
|
|
1733
|
-
const response = {
|
|
1734
|
-
status: 404,
|
|
1735
|
-
headers: {
|
|
1736
|
-
get: () => null,
|
|
1737
|
-
},
|
|
1738
|
-
} as unknown as Response;
|
|
1739
|
-
|
|
1740
|
-
expect(isRateLimitResponse(response)).toBe(false);
|
|
1741
|
-
});
|
|
1742
|
-
});
|
|
1743
|
-
|
|
1744
|
-
describe("calculateBackoffDelay", () => {
|
|
1745
|
-
const options: Required<RetryOptions> = {
|
|
1746
|
-
maxRetries: 3,
|
|
1747
|
-
baseDelayMs: 1000,
|
|
1748
|
-
maxDelayMs: 30000,
|
|
1749
|
-
jitterFactor: 0,
|
|
1750
|
-
};
|
|
1751
|
-
|
|
1752
|
-
it("calculates exponential backoff", () => {
|
|
1753
|
-
expect(calculateBackoffDelay(0, options)).toBe(1000);
|
|
1754
|
-
expect(calculateBackoffDelay(1, options)).toBe(2000);
|
|
1755
|
-
expect(calculateBackoffDelay(2, options)).toBe(4000);
|
|
1756
|
-
expect(calculateBackoffDelay(3, options)).toBe(8000);
|
|
1757
|
-
});
|
|
1758
|
-
|
|
1759
|
-
it("respects max delay", () => {
|
|
1760
|
-
const smallMaxOptions = { ...options, maxDelayMs: 3000 };
|
|
1761
|
-
|
|
1762
|
-
expect(calculateBackoffDelay(0, smallMaxOptions)).toBe(1000);
|
|
1763
|
-
expect(calculateBackoffDelay(1, smallMaxOptions)).toBe(2000);
|
|
1764
|
-
expect(calculateBackoffDelay(2, smallMaxOptions)).toBe(3000); // Capped
|
|
1765
|
-
expect(calculateBackoffDelay(3, smallMaxOptions)).toBe(3000); // Still capped
|
|
1766
|
-
});
|
|
1767
|
-
|
|
1768
|
-
it("uses rate limit reset time when provided", () => {
|
|
1769
|
-
const resetMs = 5000;
|
|
1770
|
-
const delay = calculateBackoffDelay(0, options, resetMs);
|
|
1771
|
-
|
|
1772
|
-
// Should be resetMs + 1000 buffer
|
|
1773
|
-
expect(delay).toBe(6000);
|
|
1774
|
-
});
|
|
1775
|
-
|
|
1776
|
-
it("caps rate limit reset delay at maxDelayMs", () => {
|
|
1777
|
-
const resetMs = 60000; // 60 seconds
|
|
1778
|
-
const delay = calculateBackoffDelay(0, options, resetMs);
|
|
1779
|
-
|
|
1780
|
-
expect(delay).toBe(30000); // maxDelayMs
|
|
1781
|
-
});
|
|
1782
|
-
|
|
1783
|
-
it("adds jitter when jitterFactor > 0", () => {
|
|
1784
|
-
const jitterOptions = { ...options, jitterFactor: 0.1 };
|
|
1785
|
-
|
|
1786
|
-
// Run multiple times to verify jitter adds variance
|
|
1787
|
-
const delays = Array.from({ length: 10 }, () =>
|
|
1788
|
-
calculateBackoffDelay(0, jitterOptions)
|
|
1789
|
-
);
|
|
1790
|
-
|
|
1791
|
-
// Base delay is 1000, with 10% jitter range is 1000-1100
|
|
1792
|
-
expect(Math.min(...delays)).toBeGreaterThanOrEqual(1000);
|
|
1793
|
-
expect(Math.max(...delays)).toBeLessThanOrEqual(1100);
|
|
1794
|
-
|
|
1795
|
-
// Should have some variance (not all the same)
|
|
1796
|
-
const uniqueDelays = new Set(delays);
|
|
1797
|
-
expect(uniqueDelays.size).toBeGreaterThan(1);
|
|
1798
|
-
});
|
|
1799
|
-
});
|
|
1800
|
-
});
|