@knpkv/atlassian-common 1.3.0 → 1.5.0
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 +72 -0
- package/README.md +1 -1
- package/dist/Brand.d.ts +2 -2
- package/dist/Brand.d.ts.map +1 -1
- package/dist/Brand.js.map +1 -1
- package/dist/Hash.d.ts.map +1 -1
- package/dist/Hash.js +2 -2
- package/dist/Hash.js.map +1 -1
- package/dist/ast/Document.d.ts +3 -19
- package/dist/ast/Document.d.ts.map +1 -1
- package/dist/ast/Document.js +4 -3
- package/dist/ast/Document.js.map +1 -1
- package/dist/ast/MacroNode.d.ts +3 -3
- package/dist/attachments.d.ts.map +1 -1
- package/dist/attachments.js +2 -6
- package/dist/attachments.js.map +1 -1
- package/dist/auth/OAuthEndpoints.d.ts +13 -0
- package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
- package/dist/auth/OAuthEndpoints.js +24 -3
- package/dist/auth/OAuthEndpoints.js.map +1 -1
- package/dist/auth/OAuthErrors.d.ts +22 -0
- package/dist/auth/OAuthErrors.d.ts.map +1 -1
- package/dist/auth/OAuthErrors.js +1 -1
- package/dist/auth/OAuthErrors.js.map +1 -1
- package/dist/auth/OAuthOperations.d.ts +8 -2
- package/dist/auth/OAuthOperations.d.ts.map +1 -1
- package/dist/auth/OAuthOperations.js +18 -8
- package/dist/auth/OAuthOperations.js.map +1 -1
- package/dist/auth/OAuthResponseSchemas.d.ts +11 -0
- package/dist/auth/OAuthResponseSchemas.d.ts.map +1 -1
- package/dist/auth/OAuthResponseSchemas.js +11 -0
- package/dist/auth/OAuthResponseSchemas.js.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/config/AuthProfiles.d.ts +140 -175
- package/dist/config/AuthProfiles.d.ts.map +1 -1
- package/dist/config/AuthProfiles.js +2 -1
- package/dist/config/AuthProfiles.js.map +1 -1
- package/dist/config/ConfigPaths.d.ts.map +1 -1
- package/dist/config/ConfigPaths.js +2 -2
- package/dist/config/ProfileManager.d.ts +14 -3
- package/dist/config/ProfileManager.d.ts.map +1 -1
- package/dist/config/ProfileManager.js +33 -5
- package/dist/config/ProfileManager.js.map +1 -1
- package/dist/config/TokenStorage.d.ts.map +1 -1
- package/dist/config/TokenStorage.js +2 -1
- package/dist/config/TokenStorage.js.map +1 -1
- package/dist/serializers/MarkdownSerializer.d.ts.map +1 -1
- package/dist/serializers/MarkdownSerializer.js.map +1 -1
- package/package.json +8 -8
- package/src/Brand.ts +5 -5
- package/src/Hash.ts +2 -2
- package/src/ast/Document.ts +5 -4
- package/src/attachments.ts +2 -1
- package/src/auth/OAuthEndpoints.ts +25 -3
- package/src/auth/OAuthErrors.ts +23 -1
- package/src/auth/OAuthOperations.ts +33 -20
- package/src/auth/OAuthResponseSchemas.ts +12 -0
- package/src/auth/index.ts +1 -0
- package/src/bin.ts +5 -3
- package/src/config/AuthProfiles.ts +4 -2
- package/src/config/ConfigPaths.ts +3 -3
- package/src/config/ProfileManager.ts +42 -6
- package/src/config/TokenStorage.ts +4 -2
- package/test/ApiUpdateWorkflows.test.ts +356 -0
- package/test/OAuthEndpoints.test.ts +23 -0
- package/test/OAuthOperations.test.ts +2 -2
- package/test/ProfileManager.test.ts +61 -0
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import * as Clock from "effect/Clock"
|
|
22
22
|
import * as Effect from "effect/Effect"
|
|
23
|
+
import { HttpClient, HttpClientRequest } from "effect/http"
|
|
23
24
|
import * as Schema from "effect/Schema"
|
|
24
|
-
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
25
25
|
import { type OAuthConfig, type OAuthToken } from "../config/OAuthSchemas.js"
|
|
26
26
|
import { unsafeCurrentTimeMillis } from "../internal/legacyWallClock.js"
|
|
27
27
|
import { ME_URL, RESOURCES_URL, REVOKE_URL, TOKEN_URL } from "./OAuthEndpoints.js"
|
|
@@ -29,6 +29,7 @@ import { OAuthError } from "./OAuthErrors.js"
|
|
|
29
29
|
import {
|
|
30
30
|
type AccessibleResource,
|
|
31
31
|
AccessibleResourceSchema,
|
|
32
|
+
TokenErrorSchema,
|
|
32
33
|
type TokenResponse,
|
|
33
34
|
TokenResponseSchema,
|
|
34
35
|
type UserInfo,
|
|
@@ -49,6 +50,15 @@ export interface ExchangeCodeOptions {
|
|
|
49
50
|
readonly codeVerifier?: string | undefined
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
interface AuthorizationCodeTokenBody {
|
|
54
|
+
readonly grant_type: string
|
|
55
|
+
readonly client_id: string
|
|
56
|
+
readonly client_secret: string
|
|
57
|
+
readonly code: string
|
|
58
|
+
readonly redirect_uri: string
|
|
59
|
+
code_verifier?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
/**
|
|
53
63
|
* Exchange authorization code for tokens.
|
|
54
64
|
*
|
|
@@ -58,10 +68,10 @@ export const exchangeCodeForTokens = (
|
|
|
58
68
|
code: string,
|
|
59
69
|
config: OAuthConfig,
|
|
60
70
|
options: ExchangeCodeOptions
|
|
61
|
-
)
|
|
71
|
+
) =>
|
|
62
72
|
Effect.gen(function*() {
|
|
63
73
|
const httpClient = yield* HttpClient.HttpClient
|
|
64
|
-
const tokenBody:
|
|
74
|
+
const tokenBody: AuthorizationCodeTokenBody = {
|
|
65
75
|
grant_type: "authorization_code",
|
|
66
76
|
client_id: config.clientId,
|
|
67
77
|
client_secret: config.clientSecret,
|
|
@@ -79,9 +89,7 @@ export const exchangeCodeForTokens = (
|
|
|
79
89
|
if (response.status >= 400) {
|
|
80
90
|
const text = yield* response.text
|
|
81
91
|
yield* Effect.logDebug(`Token exchange failed (${response.status}): ${text}`)
|
|
82
|
-
return yield*
|
|
83
|
-
new OAuthError({ step: "token", cause: `HTTP ${response.status}` })
|
|
84
|
-
)
|
|
92
|
+
return yield* new OAuthError({ step: "token", cause: `HTTP ${response.status}` })
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
const body = yield* response.json
|
|
@@ -113,9 +121,7 @@ export const getAccessibleResources = (
|
|
|
113
121
|
if (response.status >= 400) {
|
|
114
122
|
const text = yield* response.text
|
|
115
123
|
yield* Effect.logDebug(`Accessible resources failed (${response.status}): ${text}`)
|
|
116
|
-
return yield*
|
|
117
|
-
new OAuthError({ step: "resources", cause: `HTTP ${response.status}` })
|
|
118
|
-
)
|
|
124
|
+
return yield* new OAuthError({ step: "resources", cause: `HTTP ${response.status}` })
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
const body = yield* response.json
|
|
@@ -147,9 +153,7 @@ export const getUserInfo = (
|
|
|
147
153
|
if (response.status >= 400) {
|
|
148
154
|
const text = yield* response.text
|
|
149
155
|
yield* Effect.logDebug(`User info failed (${response.status}): ${text}`)
|
|
150
|
-
return yield*
|
|
151
|
-
new OAuthError({ step: "user-info", cause: `HTTP ${response.status}` })
|
|
152
|
-
)
|
|
156
|
+
return yield* new OAuthError({ step: "user-info", cause: `HTTP ${response.status}` })
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
const body = yield* response.json
|
|
@@ -192,9 +196,20 @@ export const refreshToken = (
|
|
|
192
196
|
const text = yield* response.text.pipe(
|
|
193
197
|
Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
|
|
194
198
|
)
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
// The OAuth `error` code is what distinguishes "this grant is spent" from
|
|
200
|
+
// "your request was malformed", and callers key credential deletion off
|
|
201
|
+
// it. A body that is not JSON, or not shaped like an OAuth error, simply
|
|
202
|
+
// leaves it absent — which callers read as "no verdict", not "rejected".
|
|
203
|
+
const errorCode = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(TokenErrorSchema))(text).pipe(
|
|
204
|
+
Effect.map((decoded) => decoded.error),
|
|
205
|
+
Effect.catch(() => Effect.succeed(undefined))
|
|
197
206
|
)
|
|
207
|
+
return yield* new OAuthError({
|
|
208
|
+
step: "refresh",
|
|
209
|
+
cause: `HTTP ${response.status}: ${text}`,
|
|
210
|
+
status: response.status,
|
|
211
|
+
...(!(errorCode === undefined) && { errorCode })
|
|
212
|
+
})
|
|
198
213
|
}
|
|
199
214
|
|
|
200
215
|
const body = yield* response.json.pipe(
|
|
@@ -243,12 +258,10 @@ export const revokeToken = (
|
|
|
243
258
|
)
|
|
244
259
|
|
|
245
260
|
if (response.status >= 400) {
|
|
246
|
-
return yield*
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
})
|
|
251
|
-
)
|
|
261
|
+
return yield* new OAuthError({
|
|
262
|
+
step: "revoke",
|
|
263
|
+
cause: `Token revocation failed with status ${response.status}`
|
|
264
|
+
})
|
|
252
265
|
}
|
|
253
266
|
})
|
|
254
267
|
|
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as Schema from "effect/Schema"
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* OAuth 2.0 error response body (RFC 6749 §5.2).
|
|
16
|
+
*
|
|
17
|
+
* Only `error` is required; providers vary on the rest, so nothing else is
|
|
18
|
+
* modelled — the code is the part callers act on.
|
|
19
|
+
*
|
|
20
|
+
* @category Schema
|
|
21
|
+
*/
|
|
22
|
+
export const TokenErrorSchema = Schema.Struct({
|
|
23
|
+
error: Schema.String
|
|
24
|
+
})
|
|
25
|
+
|
|
14
26
|
/**
|
|
15
27
|
* Schema for OAuth2 token response from Atlassian.
|
|
16
28
|
*
|
package/src/auth/index.ts
CHANGED
package/src/bin.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node"
|
|
3
|
+
import { Argument as Args, Command } from "effect/cli"
|
|
3
4
|
import * as Console from "effect/Console"
|
|
4
5
|
import * as Effect from "effect/Effect"
|
|
5
6
|
import * as Layer from "effect/Layer"
|
|
6
7
|
import * as Stdio from "effect/Stdio"
|
|
7
|
-
import { Argument as Args, Command } from "effect/unstable/cli"
|
|
8
8
|
import pkg from "../package.json" with { type: "json" }
|
|
9
9
|
import {
|
|
10
10
|
HomeDirectoryLive,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
useProfileForAllTools
|
|
16
16
|
} from "./config/index.js"
|
|
17
17
|
|
|
18
|
-
const profileArg = Args.
|
|
18
|
+
const profileArg = Args.String("profile").pipe(
|
|
19
19
|
Args.withDescription("Profile ID, name, site URL, cloud ID, or account ID")
|
|
20
20
|
)
|
|
21
21
|
|
|
@@ -106,7 +106,9 @@ const program = Effect.gen(function*() {
|
|
|
106
106
|
HomeDirectoryLive
|
|
107
107
|
)
|
|
108
108
|
),
|
|
109
|
-
Effect.catch((error:
|
|
109
|
+
Effect.catch(<UnparsedInput>(error: UnparsedInput) =>
|
|
110
|
+
Console.error(String(error)).pipe(Effect.andThen(Effect.fail(error)))
|
|
111
|
+
)
|
|
110
112
|
)
|
|
111
113
|
|
|
112
114
|
NodeRuntime.runMain(program, { disableErrorReporting: true })
|
|
@@ -71,9 +71,11 @@ export type AuthProfilesFile = Schema.Schema.Type<typeof AuthProfilesFileSchema>
|
|
|
71
71
|
|
|
72
72
|
const emptyProfiles = (): AuthProfilesFile => ({ profiles: [] })
|
|
73
73
|
|
|
74
|
-
const
|
|
74
|
+
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))
|
|
75
|
+
|
|
76
|
+
const parseJsonOrNull = (content: string): Schema.Json | null => {
|
|
75
77
|
try {
|
|
76
|
-
return
|
|
78
|
+
return decodeJson(content)
|
|
77
79
|
} catch {
|
|
78
80
|
return null
|
|
79
81
|
}
|
|
@@ -61,8 +61,8 @@ export class HomeDirectoryTag extends Context.Service<
|
|
|
61
61
|
HomeDirectory
|
|
62
62
|
>()("@knpkv/atlassian-common/HomeDirectory") {}
|
|
63
63
|
|
|
64
|
-
const HomeConfig = Config.option(Config.
|
|
65
|
-
Config.orElse(() => Config.option(Config.
|
|
64
|
+
const HomeConfig = Config.option(Config.String("HOME")).pipe(
|
|
65
|
+
Config.orElse(() => Config.option(Config.String("USERPROFILE")))
|
|
66
66
|
)
|
|
67
67
|
|
|
68
68
|
/**
|
|
@@ -84,7 +84,7 @@ export const HomeDirectoryLive: Layer.Layer<HomeDirectoryTag> = Layer.succeed(
|
|
|
84
84
|
}
|
|
85
85
|
)
|
|
86
86
|
|
|
87
|
-
const XdgConfigHome = Config.option(Config.
|
|
87
|
+
const XdgConfigHome = Config.option(Config.String("XDG_CONFIG_HOME"))
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* XDG config directory for Atlassian tools.
|
|
@@ -7,11 +7,11 @@ import * as Clock from "effect/Clock"
|
|
|
7
7
|
import * as Data from "effect/Data"
|
|
8
8
|
import * as Effect from "effect/Effect"
|
|
9
9
|
import * as FileSystem from "effect/FileSystem"
|
|
10
|
+
import type { HttpClient } from "effect/http"
|
|
10
11
|
import * as Path from "effect/Path"
|
|
11
12
|
import type * as PlatformError from "effect/PlatformError"
|
|
12
13
|
import * as Schema from "effect/Schema"
|
|
13
|
-
import
|
|
14
|
-
import type { OAuthError } from "../auth/OAuthErrors.js"
|
|
14
|
+
import { OAuthError } from "../auth/OAuthErrors.js"
|
|
15
15
|
import { refreshToken } from "../auth/OAuthOperations.js"
|
|
16
16
|
import {
|
|
17
17
|
type AuthProfile,
|
|
@@ -51,6 +51,17 @@ export const JIRA_PROPOSAL_REQUIRED_SCOPES: ReadonlyArray<string> = [
|
|
|
51
51
|
"offline_access"
|
|
52
52
|
]
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Scopes a Confluence profile must hold to be considered valid.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately page-only. The CLI also requests the folder and CQL-search scopes
|
|
58
|
+
* (`CONFLUENCE_FOLDER_SCOPES`), but they are not listed here: this constant also
|
|
59
|
+
* gates control-center's per-site scope check, and a profile that can read and
|
|
60
|
+
* write pages is legitimately valid for everything except `folder`/`search`.
|
|
61
|
+
* The cost is that `atlassian profiles doctor` reports a pre-folder profile as
|
|
62
|
+
* valid and those two commands then fail with 401/403 until the OAuth app is
|
|
63
|
+
* updated and the user logs in again — see the confluence-to-markdown README.
|
|
64
|
+
*/
|
|
54
65
|
export const CONFLUENCE_REQUIRED_SCOPES: ReadonlyArray<string> = [
|
|
55
66
|
"read:page:confluence",
|
|
56
67
|
"write:page:confluence",
|
|
@@ -216,7 +227,7 @@ export const useProfileForAllTools = (
|
|
|
216
227
|
).find((profile): profile is AuthProfile =>
|
|
217
228
|
profile !== null
|
|
218
229
|
)
|
|
219
|
-
if (
|
|
230
|
+
if (selected === undefined) return yield* new ProfileNotFoundError({ selector })
|
|
220
231
|
|
|
221
232
|
yield* Effect.forEach(stores, ([tool, store]) => {
|
|
222
233
|
const matching = findProfile(store.profiles, selector) ?? findProfile(store.profiles, selected.id)
|
|
@@ -260,6 +271,9 @@ export const migrateLegacyProfiles = (
|
|
|
260
271
|
return yield* inspectAllToolProfiles(tools)
|
|
261
272
|
})
|
|
262
273
|
|
|
274
|
+
/** Matches `JiraAuth`'s refresh deadline; see the rotation comment below. */
|
|
275
|
+
const REFRESH_TIMEOUT = "30 seconds"
|
|
276
|
+
|
|
263
277
|
/**
|
|
264
278
|
* Refresh expired active profiles.
|
|
265
279
|
*
|
|
@@ -284,10 +298,32 @@ export const refreshActiveProfiles = (
|
|
|
284
298
|
if (!active || !isTokenExpiredAt(active.token, nowMs, 0)) return
|
|
285
299
|
const config = yield* loadOAuthConfig(storeName)
|
|
286
300
|
if (!config) {
|
|
287
|
-
return yield*
|
|
301
|
+
return yield* new MissingOAuthConfigError({ authStoreName: storeName, profileId: active.id })
|
|
288
302
|
}
|
|
289
|
-
|
|
290
|
-
|
|
303
|
+
// Rotating refresh tokens: the grant consumes the stored token
|
|
304
|
+
// server-side and the response carries its replacement, so an interrupt
|
|
305
|
+
// between the two spends the credential without persisting what
|
|
306
|
+
// replaced it — the next refresh 4xxs and the user is silently logged
|
|
307
|
+
// out. Keep the grant and the persist atomic, as `JiraAuth` does.
|
|
308
|
+
// The deadline is inside the region on purpose: an uninterruptible
|
|
309
|
+
// region with no bound of its own absorbs SIGINT/SIGTERM entirely
|
|
310
|
+
// (`runMain`'s handlers only interrupt the main fiber), so `atlassian
|
|
311
|
+
// auth refresh` would stop answering Ctrl-C against a stalled token
|
|
312
|
+
// endpoint. A deadline forked inside the region is still interruptible,
|
|
313
|
+
// so it does bound this. Failing here leaves the stored token in place
|
|
314
|
+
// — a refresh that never answered says nothing about its validity.
|
|
315
|
+
yield* Effect.uninterruptible(
|
|
316
|
+
Effect.gen(function*() {
|
|
317
|
+
const refreshed = yield* refreshToken(active.token, config).pipe(
|
|
318
|
+
Effect.timeout(REFRESH_TIMEOUT),
|
|
319
|
+
Effect.catchTag(
|
|
320
|
+
"TimeoutError",
|
|
321
|
+
() => Effect.fail(new OAuthError({ step: "refresh", cause: `no response within ${REFRESH_TIMEOUT}` }))
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
yield* saveProfileToken(storeName, refreshed)
|
|
325
|
+
})
|
|
326
|
+
)
|
|
291
327
|
}))
|
|
292
328
|
return yield* inspectAllToolProfiles(tools)
|
|
293
329
|
})
|
|
@@ -28,9 +28,11 @@ import { type HomeDirectoryError, type HomeDirectoryTag } from "./ConfigPaths.js
|
|
|
28
28
|
import { ensureConfigDir, getAuthPath, getOAuthConfigPath, writeSecureFile } from "./ConfigPaths.js"
|
|
29
29
|
import { type OAuthConfig, OAuthConfigSchema, type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
|
|
30
30
|
|
|
31
|
-
const
|
|
31
|
+
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))
|
|
32
|
+
|
|
33
|
+
const parseJsonOrNull = (content: string): Schema.Json | null => {
|
|
32
34
|
try {
|
|
33
|
-
return
|
|
35
|
+
return decodeJson(content)
|
|
34
36
|
} catch {
|
|
35
37
|
return null
|
|
36
38
|
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
2
|
+
import { expect, layer } from "@effect/vitest"
|
|
3
|
+
import * as Array from "effect/Array"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as FileSystem from "effect/FileSystem"
|
|
6
|
+
import * as Path from "effect/Path"
|
|
7
|
+
import * as Predicate from "effect/Predicate"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
import { parse } from "yaml"
|
|
10
|
+
|
|
11
|
+
const isRecord = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & Record<string, Schema.Json> =>
|
|
12
|
+
Predicate.isObjectOrArray(value) && value !== null && !Array.isArray(value)
|
|
13
|
+
|
|
14
|
+
const buildCommands = (source: string): ReadonlyArray<string> => {
|
|
15
|
+
const workflow: unknown = parse(source)
|
|
16
|
+
if (!isRecord(workflow) || !isRecord(workflow.jobs)) return []
|
|
17
|
+
|
|
18
|
+
return Object.values(workflow.jobs).flatMap((job) => {
|
|
19
|
+
if (!isRecord(job) || !Array.isArray(job.steps)) return []
|
|
20
|
+
return job.steps.flatMap((step) => {
|
|
21
|
+
if (!isRecord(step) || !Predicate.isString(step.name) || !step.name.startsWith("Build ")) return []
|
|
22
|
+
return Predicate.isString(step.run) ? [step.run] : []
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const dependencyClosureDiagnostics = (
|
|
28
|
+
source: string,
|
|
29
|
+
expectedConsumers: ReadonlyArray<string>
|
|
30
|
+
): ReadonlyArray<string> => {
|
|
31
|
+
const commands = buildCommands(source)
|
|
32
|
+
return expectedConsumers.flatMap((consumer) => {
|
|
33
|
+
const dependencyClosedFilter = `--filter ${consumer}...`
|
|
34
|
+
return commands.some((command) => command.includes(dependencyClosedFilter) && /\bbuild\b/u.test(command))
|
|
35
|
+
? []
|
|
36
|
+
: [`API update workflow must build ${consumer} with its workspace dependency closure`]
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PackageManifest = Schema.Struct({
|
|
41
|
+
name: Schema.String,
|
|
42
|
+
private: Schema.optional(Schema.Boolean),
|
|
43
|
+
exports: Schema.optional(Schema.Json)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Public generated subpaths or generated export targets declare the contract;
|
|
47
|
+
// package names and consumer dependencies do not.
|
|
48
|
+
const exposesGeneratedContract = (exports: Schema.Json): boolean => {
|
|
49
|
+
if (Predicate.isString(exports)) return /(?:^|\/)generated(?:\/|$)/u.test(exports)
|
|
50
|
+
if (Array.isArray(exports)) return exports.some(exposesGeneratedContract)
|
|
51
|
+
if (!isRecord(exports)) return false
|
|
52
|
+
return Object.entries(exports).some(([name, target]) =>
|
|
53
|
+
/^\.\/generated(?:\/|$)/u.test(name) || exposesGeneratedContract(target)
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const patchGuidanceDiagnostics = (
|
|
58
|
+
source: string,
|
|
59
|
+
generatedClientNames: ReadonlySet<string>
|
|
60
|
+
): ReadonlyArray<string> => {
|
|
61
|
+
const workflow: unknown = parse(source)
|
|
62
|
+
if (!isRecord(workflow) || !isRecord(workflow.jobs)) return []
|
|
63
|
+
|
|
64
|
+
return Object.values(workflow.jobs).flatMap((job) => {
|
|
65
|
+
if (!isRecord(job) || !Array.isArray(job.steps)) return []
|
|
66
|
+
const steps = job.steps.filter(isRecord)
|
|
67
|
+
const bodies = steps.flatMap((step) =>
|
|
68
|
+
isRecord(step.with) && Predicate.isString(step.with.body) ? [step.with.body.replace(/\s+/gu, " ")] : []
|
|
69
|
+
)
|
|
70
|
+
const hasUnchangedContractGuidance = bodies.some((body) => /\bpublic contract is unchanged\b/iu.test(body))
|
|
71
|
+
const guidanceDiagnostics = bodies.flatMap((body) =>
|
|
72
|
+
body.split(/[.!?]/u).flatMap((sentence) =>
|
|
73
|
+
/\bpatch (?:is appropriate|(?:only )?when)\b/iu.test(sentence)
|
|
74
|
+
&& !/\bpublic contract is unchanged\b/iu.test(sentence)
|
|
75
|
+
? ["Patch release guidance must require an unchanged public contract"]
|
|
76
|
+
: []
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
const defaultDiagnostics = steps.flatMap((step) => {
|
|
80
|
+
if (!Predicate.isString(step.run) || !/cat\s+>\s+\.changeset\//u.test(step.run)) return []
|
|
81
|
+
const heredocs = [...step.run.matchAll(
|
|
82
|
+
/cat\s+>\s+\.changeset\/[^\s]+\.md\s+<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1(?:\n|$)/gu
|
|
83
|
+
)]
|
|
84
|
+
if (heredocs.length === 0) return ["Generated changeset heredoc could not be inspected"]
|
|
85
|
+
return heredocs.flatMap((heredoc) => {
|
|
86
|
+
const frontmatter = heredoc[2]?.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u)?.[1]
|
|
87
|
+
if (frontmatter === undefined) return ["Generated changeset must contain release frontmatter"]
|
|
88
|
+
const releases: unknown = parse(frontmatter)
|
|
89
|
+
if (!isRecord(releases)) return ["Generated changeset must contain release frontmatter"]
|
|
90
|
+
return Object.entries(releases).flatMap(([name, release]) =>
|
|
91
|
+
generatedClientNames.has(name) && release === "patch" && !hasUnchangedContractGuidance
|
|
92
|
+
? [`Generated client ${name} defaults to patch without unchanged-contract guidance`]
|
|
93
|
+
: []
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
return [...guidanceDiagnostics, ...defaultDiagnostics]
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Generated changesets must not reuse a fixed path: the workflow regenerates the PR branch
|
|
102
|
+
// from the base, so a fixed name overwrites a still-pending changeset from an earlier update.
|
|
103
|
+
const changesetPathDiagnostics = (source: string): ReadonlyArray<string> =>
|
|
104
|
+
[...source.matchAll(/cat\s+>\s+(\.changeset\/[^\s]+\.md)/gu)].flatMap(([, changesetPath]) =>
|
|
105
|
+
changesetPath !== undefined && changesetPath.includes("${GITHUB_RUN_ID}")
|
|
106
|
+
? []
|
|
107
|
+
: [`Generated changeset ${changesetPath ?? ""} must be unique per workflow run`]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const releaseWorkflow = (name: string, release: string, guidance = "Review the generated API.") => `
|
|
111
|
+
jobs:
|
|
112
|
+
update:
|
|
113
|
+
steps:
|
|
114
|
+
- name: Create changeset
|
|
115
|
+
run: |
|
|
116
|
+
cat > .changeset/api-update.md <<'CHANGESET'
|
|
117
|
+
---
|
|
118
|
+
"${name}": ${release}
|
|
119
|
+
---
|
|
120
|
+
Update generated API schemas.
|
|
121
|
+
CHANGESET
|
|
122
|
+
- name: Create pull request
|
|
123
|
+
with:
|
|
124
|
+
body: ${guidance}
|
|
125
|
+
`
|
|
126
|
+
|
|
127
|
+
const inspectApiUpdateWorkflows = Effect.fn("ApiUpdateWorkflows.inspectApiUpdateWorkflows")(function*(root: string) {
|
|
128
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
129
|
+
const path = yield* Path.Path
|
|
130
|
+
const packagesRoot = path.join(root, "packages")
|
|
131
|
+
const generatedClientNames = new Set<string>()
|
|
132
|
+
for (const directory of yield* fileSystem.readDirectory(packagesRoot)) {
|
|
133
|
+
if (["vendor", "generated", "node_modules"].includes(directory)) continue
|
|
134
|
+
const packageRoot = path.join(packagesRoot, directory)
|
|
135
|
+
if ((yield* fileSystem.stat(packageRoot)).type !== "Directory") continue
|
|
136
|
+
const manifestPath = path.join(packageRoot, "package.json")
|
|
137
|
+
if (!(yield* fileSystem.exists(manifestPath))) continue
|
|
138
|
+
const manifest = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PackageManifest))(
|
|
139
|
+
yield* fileSystem.readFileString(manifestPath)
|
|
140
|
+
)
|
|
141
|
+
if (manifest.private !== true && manifest.exports !== undefined && exposesGeneratedContract(manifest.exports)) {
|
|
142
|
+
generatedClientNames.add(manifest.name)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const workflowRoot = path.join(root, ".github/workflows")
|
|
146
|
+
const diagnostics: Array<string> = []
|
|
147
|
+
for (const name of (yield* fileSystem.readDirectory(workflowRoot)).toSorted()) {
|
|
148
|
+
if (!/-api-update\.ya?ml$/u.test(name)) continue
|
|
149
|
+
const source = yield* fileSystem.readFileString(path.join(workflowRoot, name))
|
|
150
|
+
for (const diagnostic of patchGuidanceDiagnostics(source, generatedClientNames)) {
|
|
151
|
+
diagnostics.push(`${name}: ${diagnostic}`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return diagnostics
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const loadWorkflow = Effect.fn("ApiUpdateWorkflows.loadWorkflow")(function*(name: string) {
|
|
158
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
159
|
+
const path = yield* Path.Path
|
|
160
|
+
const workflowPath = yield* path.fromFileUrl(new URL(`../../../.github/workflows/${name}`, import.meta.url))
|
|
161
|
+
return yield* fileSystem.readFileString(workflowPath)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
layer(NodeServices.layer)("API update workflow build closure", (it) => {
|
|
165
|
+
const fixtureClients = new Set(["@knpkv/jira-api-client"])
|
|
166
|
+
|
|
167
|
+
it.effect("discovers a fourth API workflow and its exported generated contract", () =>
|
|
168
|
+
Effect.gen(function*() {
|
|
169
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
170
|
+
const path = yield* Path.Path
|
|
171
|
+
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "api-update-discovery-" })
|
|
172
|
+
const workflowRoot = path.join(root, ".github/workflows")
|
|
173
|
+
yield* fileSystem.makeDirectory(workflowRoot, { recursive: true })
|
|
174
|
+
for (const name of ["clockify", "jira", "confluence", "tempo"]) {
|
|
175
|
+
const packageRoot = path.join(root, "packages", name)
|
|
176
|
+
yield* fileSystem.makeDirectory(packageRoot, { recursive: true })
|
|
177
|
+
yield* fileSystem.writeFileString(
|
|
178
|
+
path.join(packageRoot, "package.json"),
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
name: `@fixture/${name}`,
|
|
181
|
+
exports: { "./api": { types: "./dist/generated/Api.d.ts", import: "./dist/generated/Api.js" } }
|
|
182
|
+
})
|
|
183
|
+
)
|
|
184
|
+
yield* fileSystem.writeFileString(
|
|
185
|
+
path.join(workflowRoot, `${name}-api-update.yml`),
|
|
186
|
+
releaseWorkflow(`@fixture/${name}`, name === "tempo" ? "patch" : "minor")
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
for (
|
|
190
|
+
const [directory, manifest] of [
|
|
191
|
+
["private-client", {
|
|
192
|
+
name: "@fixture/private-client",
|
|
193
|
+
private: true,
|
|
194
|
+
exports: { "./generated": "./dist/Api.js" }
|
|
195
|
+
}],
|
|
196
|
+
["vendor", { name: "@fixture/vendor-client", exports: { "./generated": "./dist/Api.js" } }],
|
|
197
|
+
["generated", { name: "@fixture/generated-package", exports: { "./generated": "./dist/Api.js" } }],
|
|
198
|
+
["consumer", {
|
|
199
|
+
name: "@fixture/consumer",
|
|
200
|
+
exports: { ".": "./dist/index.js" },
|
|
201
|
+
dependencies: { "@fixture/tempo": "workspace:*" }
|
|
202
|
+
}]
|
|
203
|
+
] satisfies ReadonlyArray<readonly [string, Schema.Json]>
|
|
204
|
+
) {
|
|
205
|
+
const packageRoot = path.join(root, "packages", directory)
|
|
206
|
+
yield* fileSystem.makeDirectory(packageRoot, { recursive: true })
|
|
207
|
+
yield* fileSystem.writeFileString(path.join(packageRoot, "package.json"), JSON.stringify(manifest))
|
|
208
|
+
const decoded = yield* Schema.decodeUnknownEffect(PackageManifest)(manifest)
|
|
209
|
+
yield* fileSystem.writeFileString(
|
|
210
|
+
path.join(workflowRoot, `${directory}-api-update.yml`),
|
|
211
|
+
releaseWorkflow(decoded.name, "patch")
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
expect(yield* inspectApiUpdateWorkflows(root)).toEqual([
|
|
215
|
+
"tempo-api-update.yml: Generated client @fixture/tempo defaults to patch without unchanged-contract guidance"
|
|
216
|
+
])
|
|
217
|
+
yield* fileSystem.writeFileString(
|
|
218
|
+
path.join(workflowRoot, "tempo-api-update.yml"),
|
|
219
|
+
releaseWorkflow("@fixture/tempo", "minor")
|
|
220
|
+
)
|
|
221
|
+
expect(yield* inspectApiUpdateWorkflows(root)).toEqual([])
|
|
222
|
+
}))
|
|
223
|
+
|
|
224
|
+
it("recognizes generated export subpaths and conditional targets without guessing package names", () => {
|
|
225
|
+
expect(exposesGeneratedContract({ "./generated/v1": "./dist/Api.js" })).toBe(true)
|
|
226
|
+
expect(exposesGeneratedContract({ ".": { import: [null, "./dist/generated/Api.js"] } })).toBe(true)
|
|
227
|
+
expect(exposesGeneratedContract({ ".": "./dist/generated-client.js" })).toBe(false)
|
|
228
|
+
expect(exposesGeneratedContract({ ".": "./dist/index.js" })).toBe(false)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("rejects compatible-only patch guidance and accepts unchanged-contract guidance", () => {
|
|
232
|
+
const workflow = (guidance: string) => `
|
|
233
|
+
jobs:
|
|
234
|
+
update:
|
|
235
|
+
steps:
|
|
236
|
+
- name: Create pull request
|
|
237
|
+
with:
|
|
238
|
+
body: ${guidance}
|
|
239
|
+
`
|
|
240
|
+
expect(patchGuidanceDiagnostics(
|
|
241
|
+
workflow(
|
|
242
|
+
"Patch is appropriate only after confirming every public contract remains compatible."
|
|
243
|
+
),
|
|
244
|
+
fixtureClients
|
|
245
|
+
)).toEqual(["Patch release guidance must require an unchanged public contract"])
|
|
246
|
+
expect(patchGuidanceDiagnostics(
|
|
247
|
+
workflow(
|
|
248
|
+
"Patch is appropriate only when the generated public contract is unchanged."
|
|
249
|
+
),
|
|
250
|
+
fixtureClients
|
|
251
|
+
)).toEqual([])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("rejects an unqualified generated-client patch default", () => {
|
|
255
|
+
expect(
|
|
256
|
+
patchGuidanceDiagnostics(
|
|
257
|
+
releaseWorkflow("@knpkv/jira-api-client", "patch", "Review the generated API."),
|
|
258
|
+
fixtureClients
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
.toEqual(["Generated client @knpkv/jira-api-client defaults to patch without unchanged-contract guidance"])
|
|
262
|
+
expect(
|
|
263
|
+
patchGuidanceDiagnostics(
|
|
264
|
+
releaseWorkflow("@knpkv/jira-api-client", "minor", "Review the generated API."),
|
|
265
|
+
fixtureClients
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
.toEqual([])
|
|
269
|
+
expect(patchGuidanceDiagnostics(
|
|
270
|
+
releaseWorkflow(
|
|
271
|
+
"@knpkv/jira-api-client",
|
|
272
|
+
"patch",
|
|
273
|
+
"Patch is appropriate only when the generated public contract is unchanged."
|
|
274
|
+
),
|
|
275
|
+
fixtureClients
|
|
276
|
+
)).toEqual([])
|
|
277
|
+
expect(
|
|
278
|
+
patchGuidanceDiagnostics(
|
|
279
|
+
releaseWorkflow("@fixture/private-client", "patch", "Apply the JSON patch."),
|
|
280
|
+
fixtureClients
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
.toEqual([])
|
|
284
|
+
expect(
|
|
285
|
+
patchGuidanceDiagnostics(
|
|
286
|
+
releaseWorkflow("@knpkv/jira-clockify", "patch", "Consumer dependency update."),
|
|
287
|
+
fixtureClients
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
.toEqual([])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it.effect("checks every discovered API-update workflow in the repository", () =>
|
|
294
|
+
Effect.gen(function*() {
|
|
295
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
296
|
+
const path = yield* Path.Path
|
|
297
|
+
const root = yield* path.fromFileUrl(new URL("../../../", import.meta.url))
|
|
298
|
+
expect(yield* fileSystem.readDirectory(path.join(root, ".github/workflows"))).toEqual(expect.arrayContaining([
|
|
299
|
+
"clockify-api-update.yml",
|
|
300
|
+
"jira-api-update.yml",
|
|
301
|
+
"confluence-api-update.yml"
|
|
302
|
+
]))
|
|
303
|
+
expect(yield* inspectApiUpdateWorkflows(root)).toEqual([])
|
|
304
|
+
}))
|
|
305
|
+
|
|
306
|
+
it("rejects a fixed generated changeset path and accepts a per-run path", () => {
|
|
307
|
+
expect(changesetPathDiagnostics("run: cat > .changeset/clockify-api-spec-update.md <<'CHANGESET'")).toEqual([
|
|
308
|
+
"Generated changeset .changeset/clockify-api-spec-update.md must be unique per workflow run"
|
|
309
|
+
])
|
|
310
|
+
expect(
|
|
311
|
+
changesetPathDiagnostics("run: cat > .changeset/clockify-api-spec-update-${GITHUB_RUN_ID}.md <<'CHANGESET'")
|
|
312
|
+
).toEqual([])
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it.effect("writes every generated changeset to a per-run path", () =>
|
|
316
|
+
Effect.gen(function*() {
|
|
317
|
+
for (const name of ["clockify-api-update.yml", "jira-api-update.yml", "confluence-api-update.yml"]) {
|
|
318
|
+
const source = yield* loadWorkflow(name)
|
|
319
|
+
expect(source).toMatch(/cat\s+>\s+\.changeset\//u)
|
|
320
|
+
expect(changesetPathDiagnostics(source)).toEqual([])
|
|
321
|
+
}
|
|
322
|
+
}))
|
|
323
|
+
|
|
324
|
+
it("rejects a bare consumer build and accepts a dependency-closed build", () => {
|
|
325
|
+
const invalid = `
|
|
326
|
+
jobs:
|
|
327
|
+
update:
|
|
328
|
+
steps:
|
|
329
|
+
- name: Build generated client and consumer
|
|
330
|
+
run: pnpm --filter @knpkv/confluence-to-markdown build
|
|
331
|
+
`
|
|
332
|
+
const valid = `
|
|
333
|
+
jobs:
|
|
334
|
+
update:
|
|
335
|
+
steps:
|
|
336
|
+
- name: Build generated client and consumer
|
|
337
|
+
run: pnpm --filter @knpkv/confluence-to-markdown... build
|
|
338
|
+
`
|
|
339
|
+
|
|
340
|
+
expect(dependencyClosureDiagnostics(invalid, ["@knpkv/confluence-to-markdown"])).toEqual([
|
|
341
|
+
"API update workflow must build @knpkv/confluence-to-markdown with its workspace dependency closure"
|
|
342
|
+
])
|
|
343
|
+
expect(dependencyClosureDiagnostics(valid, ["@knpkv/confluence-to-markdown"])).toEqual([])
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it.effect("builds every API consumer with its workspace dependencies", () =>
|
|
347
|
+
Effect.gen(function*() {
|
|
348
|
+
const confluence = yield* loadWorkflow("confluence-api-update.yml")
|
|
349
|
+
const jira = yield* loadWorkflow("jira-api-update.yml")
|
|
350
|
+
const clockify = yield* loadWorkflow("clockify-api-update.yml")
|
|
351
|
+
|
|
352
|
+
expect(dependencyClosureDiagnostics(confluence, ["@knpkv/confluence-to-markdown"])).toEqual([])
|
|
353
|
+
expect(dependencyClosureDiagnostics(jira, ["@knpkv/jira-cli", "@knpkv/jira-clockify"])).toEqual([])
|
|
354
|
+
expect(dependencyClosureDiagnostics(clockify, ["@knpkv/jira-clockify"])).toEqual([])
|
|
355
|
+
}))
|
|
356
|
+
})
|