github-issue-tower-defence-management 1.52.0 → 1.52.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/CHANGELOG.md +7 -0
- package/bin/adapter/repositories/GraphqlProjectRepository.js +31 -9
- package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/repositories/GraphqlProjectRepository.fetchProjectId.test.ts +251 -0
- package/src/adapter/repositories/GraphqlProjectRepository.ts +54 -25
- package/types/adapter/repositories/GraphqlProjectRepository.d.ts +2 -0
- package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [1.52.1](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/compare/v1.52.0...v1.52.1) (2026-05-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **core:** harden fetchProjectId for user-owned projects, errors-only responses, and memoize project IDs ([4a7d641](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/commit/4a7d641ab4c57f335d4f87266a4446b348904030))
|
|
7
|
+
|
|
1
8
|
# [1.52.0](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/compare/v1.51.0...v1.52.0) (2026-05-22)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -7,9 +7,12 @@ exports.GraphqlProjectRepository = void 0;
|
|
|
7
7
|
const ky_1 = __importDefault(require("ky"));
|
|
8
8
|
const BaseGitHubRepository_1 = require("./BaseGitHubRepository");
|
|
9
9
|
const utils_1 = require("./utils");
|
|
10
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
10
11
|
class GraphqlProjectRepository extends BaseGitHubRepository_1.BaseGitHubRepository {
|
|
11
12
|
constructor() {
|
|
12
13
|
super(...arguments);
|
|
14
|
+
this.projectIdCache = new Map();
|
|
15
|
+
this.fetchProjectIdFailedAt = new Map();
|
|
13
16
|
this.extractProjectFromUrl = (projectUrl) => {
|
|
14
17
|
const url = new URL(projectUrl);
|
|
15
18
|
const path = url.pathname.split('/');
|
|
@@ -18,6 +21,15 @@ class GraphqlProjectRepository extends BaseGitHubRepository_1.BaseGitHubReposito
|
|
|
18
21
|
return { owner, projectNumber };
|
|
19
22
|
};
|
|
20
23
|
this.fetchProjectId = async (login, projectNumber) => {
|
|
24
|
+
const cacheKey = `${login}:${projectNumber}`;
|
|
25
|
+
const cached = this.projectIdCache.get(cacheKey);
|
|
26
|
+
if (cached) {
|
|
27
|
+
return cached;
|
|
28
|
+
}
|
|
29
|
+
const failedAt = this.fetchProjectIdFailedAt.get(cacheKey);
|
|
30
|
+
if (failedAt !== undefined && Date.now() - failedAt < ONE_HOUR_MS) {
|
|
31
|
+
throw new Error(`fetchProjectId for ${login}/${projectNumber} is in backoff after a recent failure`);
|
|
32
|
+
}
|
|
21
33
|
const graphqlQuery = {
|
|
22
34
|
query: `query GetProjectID($login: String!, $number: Int!) {
|
|
23
35
|
organization(login: $login) {
|
|
@@ -38,15 +50,23 @@ class GraphqlProjectRepository extends BaseGitHubRepository_1.BaseGitHubReposito
|
|
|
38
50
|
number: projectNumber,
|
|
39
51
|
},
|
|
40
52
|
};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
let response;
|
|
54
|
+
try {
|
|
55
|
+
response = await ky_1.default
|
|
56
|
+
.post('https://api.github.com/graphql', {
|
|
57
|
+
json: graphqlQuery,
|
|
58
|
+
headers: {
|
|
59
|
+
Authorization: `Bearer ${this.ghToken}`,
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
.json();
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
this.fetchProjectIdFailedAt.set(cacheKey, Date.now());
|
|
66
|
+
throw new Error(`fetchProjectId network error for ${login}/${projectNumber}: ${String(error)}`);
|
|
67
|
+
}
|
|
49
68
|
if (!response.data) {
|
|
69
|
+
this.fetchProjectIdFailedAt.set(cacheKey, Date.now());
|
|
50
70
|
const errorMessages = response.errors
|
|
51
71
|
? response.errors.map((e) => e.message).join('; ')
|
|
52
72
|
: 'no data field in response';
|
|
@@ -55,8 +75,10 @@ class GraphqlProjectRepository extends BaseGitHubRepository_1.BaseGitHubReposito
|
|
|
55
75
|
const projectId = response.data.organization?.projectV2?.id ||
|
|
56
76
|
response.data.user?.projectV2?.id;
|
|
57
77
|
if (!projectId) {
|
|
58
|
-
|
|
78
|
+
this.fetchProjectIdFailedAt.set(cacheKey, Date.now());
|
|
79
|
+
throw new Error(`fetchProjectId: project not found for ${login}/${projectNumber}`);
|
|
59
80
|
}
|
|
81
|
+
this.projectIdCache.set(cacheKey, projectId);
|
|
60
82
|
return projectId;
|
|
61
83
|
};
|
|
62
84
|
this.findProjectIdByUrl = async (projectUrl) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"GraphqlProjectRepository.js","sourceRoot":"","sources":["../../../src/adapter/repositories/GraphqlProjectRepository.ts"],"names":[],"mappings":";;;;;;AAAA,4CAAoB;AACpB,iEAA8D;AAG9D,mCAA6C;AAE7C,MAAa,wBACX,SAAQ,2CAAoB;IAD9B;;
|
|
1
|
+
{"version":3,"file":"GraphqlProjectRepository.js","sourceRoot":"","sources":["../../../src/adapter/repositories/GraphqlProjectRepository.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
const mockPost = jest.fn();
|
|
2
|
+
|
|
3
|
+
jest.mock('ky', () => ({
|
|
4
|
+
default: {
|
|
5
|
+
post: mockPost,
|
|
6
|
+
get: jest.fn(),
|
|
7
|
+
put: jest.fn(),
|
|
8
|
+
patch: jest.fn(),
|
|
9
|
+
delete: jest.fn(),
|
|
10
|
+
extend: jest.fn(),
|
|
11
|
+
create: jest.fn(),
|
|
12
|
+
stop: jest.fn(),
|
|
13
|
+
},
|
|
14
|
+
__esModule: true,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { GraphqlProjectRepository } from './GraphqlProjectRepository';
|
|
18
|
+
import { LocalStorageRepository } from './LocalStorageRepository';
|
|
19
|
+
|
|
20
|
+
const mockJsonResponse = <T>(data: T) => ({
|
|
21
|
+
json: jest.fn().mockResolvedValue(data),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('GraphqlProjectRepository.fetchProjectId', () => {
|
|
25
|
+
const localStorageRepository = new LocalStorageRepository();
|
|
26
|
+
let repository: GraphqlProjectRepository;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
jest.useFakeTimers();
|
|
30
|
+
mockPost.mockReset();
|
|
31
|
+
repository = new GraphqlProjectRepository(localStorageRepository, '');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
jest.useRealTimers();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('user-owned project', () => {
|
|
39
|
+
it('should resolve project ID from user owner when organization returns null', async () => {
|
|
40
|
+
mockPost.mockReturnValueOnce(
|
|
41
|
+
mockJsonResponse({
|
|
42
|
+
data: {
|
|
43
|
+
organization: null,
|
|
44
|
+
user: {
|
|
45
|
+
projectV2: {
|
|
46
|
+
id: 'PVT_user123',
|
|
47
|
+
databaseId: 999,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const result = await repository.fetchProjectId('some-user', 1);
|
|
55
|
+
|
|
56
|
+
expect(result).toBe('PVT_user123');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should resolve project ID from organization owner when user returns null', async () => {
|
|
60
|
+
mockPost.mockReturnValueOnce(
|
|
61
|
+
mockJsonResponse({
|
|
62
|
+
data: {
|
|
63
|
+
organization: {
|
|
64
|
+
projectV2: {
|
|
65
|
+
id: 'PVT_org456',
|
|
66
|
+
databaseId: 111,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
user: null,
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const result = await repository.fetchProjectId('some-org', 2);
|
|
75
|
+
|
|
76
|
+
expect(result).toBe('PVT_org456');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('memoization', () => {
|
|
81
|
+
it('should return cached project ID on subsequent calls without re-fetching', async () => {
|
|
82
|
+
mockPost.mockReturnValueOnce(
|
|
83
|
+
mockJsonResponse({
|
|
84
|
+
data: {
|
|
85
|
+
organization: null,
|
|
86
|
+
user: {
|
|
87
|
+
projectV2: {
|
|
88
|
+
id: 'PVT_cached',
|
|
89
|
+
databaseId: 777,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const first = await repository.fetchProjectId('owner', 10);
|
|
97
|
+
const second = await repository.fetchProjectId('owner', 10);
|
|
98
|
+
|
|
99
|
+
expect(first).toBe('PVT_cached');
|
|
100
|
+
expect(second).toBe('PVT_cached');
|
|
101
|
+
expect(mockPost).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should use separate cache entries for different owner+projectNumber combinations', async () => {
|
|
105
|
+
mockPost
|
|
106
|
+
.mockReturnValueOnce(
|
|
107
|
+
mockJsonResponse({
|
|
108
|
+
data: {
|
|
109
|
+
organization: null,
|
|
110
|
+
user: { projectV2: { id: 'PVT_A', databaseId: 1 } },
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
.mockReturnValueOnce(
|
|
115
|
+
mockJsonResponse({
|
|
116
|
+
data: {
|
|
117
|
+
organization: null,
|
|
118
|
+
user: { projectV2: { id: 'PVT_B', databaseId: 2 } },
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const resultA = await repository.fetchProjectId('ownerA', 1);
|
|
124
|
+
const resultB = await repository.fetchProjectId('ownerB', 1);
|
|
125
|
+
|
|
126
|
+
expect(resultA).toBe('PVT_A');
|
|
127
|
+
expect(resultB).toBe('PVT_B');
|
|
128
|
+
expect(mockPost).toHaveBeenCalledTimes(2);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('errors-only response hardening', () => {
|
|
133
|
+
it('should throw a clear error when response has no data field', async () => {
|
|
134
|
+
mockPost.mockReturnValueOnce(
|
|
135
|
+
mockJsonResponse({
|
|
136
|
+
errors: [{ message: 'Could not resolve to a User' }],
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await expect(repository.fetchProjectId('bad-owner', 1)).rejects.toThrow(
|
|
141
|
+
'Could not resolve to a User',
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should throw a clear error when data is null', async () => {
|
|
146
|
+
mockPost.mockReturnValueOnce(
|
|
147
|
+
mockJsonResponse({
|
|
148
|
+
data: null,
|
|
149
|
+
errors: [{ message: 'secondary rate limit' }],
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
await expect(
|
|
154
|
+
repository.fetchProjectId('rate-limited', 1),
|
|
155
|
+
).rejects.toThrow('secondary rate limit');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should throw a clear error when data has no project in either org or user', async () => {
|
|
159
|
+
mockPost.mockReturnValueOnce(
|
|
160
|
+
mockJsonResponse({
|
|
161
|
+
data: {
|
|
162
|
+
organization: null,
|
|
163
|
+
user: null,
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
repository.fetchProjectId('no-project-owner', 99),
|
|
170
|
+
).rejects.toThrow('project not found');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should throw a clear error when network call fails', async () => {
|
|
174
|
+
mockPost.mockReturnValueOnce({
|
|
175
|
+
json: jest.fn().mockRejectedValue(new Error('network failure')),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await expect(repository.fetchProjectId('owner', 1)).rejects.toThrow(
|
|
179
|
+
'network failure',
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('backoff after failure', () => {
|
|
185
|
+
it('should not re-call GraphQL within 1 hour after a failure', async () => {
|
|
186
|
+
mockPost.mockReturnValueOnce(
|
|
187
|
+
mockJsonResponse({
|
|
188
|
+
errors: [{ message: 'auth failure' }],
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await expect(repository.fetchProjectId('owner', 5)).rejects.toThrow();
|
|
193
|
+
|
|
194
|
+
await expect(repository.fetchProjectId('owner', 5)).rejects.toThrow(
|
|
195
|
+
'backoff',
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
expect(mockPost).toHaveBeenCalledTimes(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should retry after 1 hour backoff has elapsed', async () => {
|
|
202
|
+
mockPost
|
|
203
|
+
.mockReturnValueOnce(
|
|
204
|
+
mockJsonResponse({
|
|
205
|
+
errors: [{ message: 'temporary error' }],
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
.mockReturnValueOnce(
|
|
209
|
+
mockJsonResponse({
|
|
210
|
+
data: {
|
|
211
|
+
organization: null,
|
|
212
|
+
user: { projectV2: { id: 'PVT_recovered', databaseId: 42 } },
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await expect(repository.fetchProjectId('owner', 7)).rejects.toThrow();
|
|
218
|
+
|
|
219
|
+
jest.advanceTimersByTime(60 * 60 * 1000 + 1);
|
|
220
|
+
|
|
221
|
+
const result = await repository.fetchProjectId('owner', 7);
|
|
222
|
+
|
|
223
|
+
expect(result).toBe('PVT_recovered');
|
|
224
|
+
expect(mockPost).toHaveBeenCalledTimes(2);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should apply backoff independently per owner+projectNumber', async () => {
|
|
228
|
+
mockPost
|
|
229
|
+
.mockReturnValueOnce(
|
|
230
|
+
mockJsonResponse({
|
|
231
|
+
errors: [{ message: 'error for owner A' }],
|
|
232
|
+
}),
|
|
233
|
+
)
|
|
234
|
+
.mockReturnValueOnce(
|
|
235
|
+
mockJsonResponse({
|
|
236
|
+
data: {
|
|
237
|
+
organization: null,
|
|
238
|
+
user: { projectV2: { id: 'PVT_B', databaseId: 2 } },
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
await expect(repository.fetchProjectId('ownerA', 1)).rejects.toThrow();
|
|
244
|
+
|
|
245
|
+
const resultB = await repository.fetchProjectId('ownerB', 1);
|
|
246
|
+
|
|
247
|
+
expect(resultB).toBe('PVT_B');
|
|
248
|
+
expect(mockPost).toHaveBeenCalledTimes(2);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -4,6 +4,8 @@ import { ProjectRepository } from '../../domain/usecases/adapter-interfaces/Proj
|
|
|
4
4
|
import { FieldOption, Project } from '../../domain/entities/Project';
|
|
5
5
|
import { normalizeFieldName } from './utils';
|
|
6
6
|
|
|
7
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
8
|
+
|
|
7
9
|
export class GraphqlProjectRepository
|
|
8
10
|
extends BaseGitHubRepository
|
|
9
11
|
implements
|
|
@@ -16,6 +18,9 @@ export class GraphqlProjectRepository
|
|
|
16
18
|
| 'updateStatusList'
|
|
17
19
|
>
|
|
18
20
|
{
|
|
21
|
+
private readonly projectIdCache = new Map<string, string>();
|
|
22
|
+
private readonly fetchProjectIdFailedAt = new Map<string, number>();
|
|
23
|
+
|
|
19
24
|
extractProjectFromUrl = (
|
|
20
25
|
projectUrl: string,
|
|
21
26
|
): {
|
|
@@ -32,6 +37,17 @@ export class GraphqlProjectRepository
|
|
|
32
37
|
login: string,
|
|
33
38
|
projectNumber: number,
|
|
34
39
|
): Promise<string> => {
|
|
40
|
+
const cacheKey = `${login}:${projectNumber}`;
|
|
41
|
+
const cached = this.projectIdCache.get(cacheKey);
|
|
42
|
+
if (cached) {
|
|
43
|
+
return cached;
|
|
44
|
+
}
|
|
45
|
+
const failedAt = this.fetchProjectIdFailedAt.get(cacheKey);
|
|
46
|
+
if (failedAt !== undefined && Date.now() - failedAt < ONE_HOUR_MS) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`fetchProjectId for ${login}/${projectNumber} is in backoff after a recent failure`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
35
51
|
const graphqlQuery = {
|
|
36
52
|
query: `query GetProjectID($login: String!, $number: Int!) {
|
|
37
53
|
organization(login: $login) {
|
|
@@ -53,32 +69,41 @@ export class GraphqlProjectRepository
|
|
|
53
69
|
},
|
|
54
70
|
};
|
|
55
71
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
let response: {
|
|
73
|
+
data?: {
|
|
74
|
+
organization?: {
|
|
75
|
+
projectV2?: {
|
|
76
|
+
id: string;
|
|
77
|
+
databaseId: number;
|
|
78
|
+
} | null;
|
|
79
|
+
} | null;
|
|
80
|
+
user?: {
|
|
81
|
+
projectV2?: {
|
|
82
|
+
id: string;
|
|
83
|
+
databaseId: number;
|
|
84
|
+
} | null;
|
|
85
|
+
} | null;
|
|
86
|
+
} | null;
|
|
87
|
+
errors?: { message: string }[];
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
response = await ky
|
|
91
|
+
.post('https://api.github.com/graphql', {
|
|
92
|
+
json: graphqlQuery,
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${this.ghToken}`,
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
.json();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
this.fetchProjectIdFailedAt.set(cacheKey, Date.now());
|
|
100
|
+
throw new Error(
|
|
101
|
+
`fetchProjectId network error for ${login}/${projectNumber}: ${String(error)}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
80
104
|
|
|
81
105
|
if (!response.data) {
|
|
106
|
+
this.fetchProjectIdFailedAt.set(cacheKey, Date.now());
|
|
82
107
|
const errorMessages = response.errors
|
|
83
108
|
? response.errors.map((e) => e.message).join('; ')
|
|
84
109
|
: 'no data field in response';
|
|
@@ -90,8 +115,12 @@ export class GraphqlProjectRepository
|
|
|
90
115
|
response.data.organization?.projectV2?.id ||
|
|
91
116
|
response.data.user?.projectV2?.id;
|
|
92
117
|
if (!projectId) {
|
|
93
|
-
|
|
118
|
+
this.fetchProjectIdFailedAt.set(cacheKey, Date.now());
|
|
119
|
+
throw new Error(
|
|
120
|
+
`fetchProjectId: project not found for ${login}/${projectNumber}`,
|
|
121
|
+
);
|
|
94
122
|
}
|
|
123
|
+
this.projectIdCache.set(cacheKey, projectId);
|
|
95
124
|
return projectId;
|
|
96
125
|
};
|
|
97
126
|
findProjectIdByUrl = async (
|
|
@@ -2,6 +2,8 @@ import { BaseGitHubRepository } from './BaseGitHubRepository';
|
|
|
2
2
|
import { ProjectRepository } from '../../domain/usecases/adapter-interfaces/ProjectRepository';
|
|
3
3
|
import { FieldOption, Project } from '../../domain/entities/Project';
|
|
4
4
|
export declare class GraphqlProjectRepository extends BaseGitHubRepository implements Pick<ProjectRepository, 'getProject' | 'findProjectIdByUrl' | 'getByUrl' | 'updateStoryList' | 'updateStatusList'> {
|
|
5
|
+
private readonly projectIdCache;
|
|
6
|
+
private readonly fetchProjectIdFailedAt;
|
|
5
7
|
extractProjectFromUrl: (projectUrl: string) => {
|
|
6
8
|
owner: string;
|
|
7
9
|
projectNumber: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"GraphqlProjectRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/GraphqlProjectRepository.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,4DAA4D,CAAC;AAC/F,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"GraphqlProjectRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/GraphqlProjectRepository.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,4DAA4D,CAAC;AAC/F,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,+BAA+B,CAAC;AAKrE,qBAAa,wBACX,SAAQ,oBACR,YACE,IAAI,CACF,iBAAiB,EACf,YAAY,GACZ,oBAAoB,GACpB,UAAU,GACV,iBAAiB,GACjB,kBAAkB,CACrB;IAEH,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA6B;IAC5D,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAA6B;IAEpE,qBAAqB,GACnB,YAAY,MAAM,KACjB;QACD,KAAK,EAAE,MAAM,CAAC;QACd,aAAa,EAAE,MAAM,CAAC;KACvB,CAMC;IACF,cAAc,GACZ,OAAO,MAAM,EACb,eAAe,MAAM,KACpB,OAAO,CAAC,MAAM,CAAC,CAsFhB;IACF,kBAAkB,GAChB,YAAY,MAAM,KACjB,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAG9B;IACF,UAAU,GAAU,WAAW,OAAO,CAAC,IAAI,CAAC,KAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CA2NpE;IACF,QAAQ,GAAU,KAAK,MAAM,KAAG,OAAO,CAAC,OAAO,CAAC,CAU9C;IACF,eAAe,GACb,SAAS,OAAO,EAChB,cAAc,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG;QACvC,EAAE,EAAE,WAAW,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;KAC9B,CAAC,EAAE,KACH,OAAO,CAAC,WAAW,EAAE,CAAC,CA+CvB;IACF,gBAAgB,GACd,SAAS,OAAO,EAChB,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG;QACxC,EAAE,EAAE,WAAW,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;KAC9B,CAAC,EAAE,KACH,OAAO,CAAC,WAAW,EAAE,CAAC,CA4CvB;CACH"}
|