@pepys/source-github 0.0.0-stage → 0.1.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/README.md +32 -2
- package/package.json +33 -4
- package/src/api/serve-api.ts +8 -0
- package/src/consumers/github-deploy.ts +199 -0
- package/src/consumers/github-pull-request.ts +275 -0
- package/src/consumers/github-ref.ts +88 -0
- package/src/consumers/github-release.ts +125 -0
- package/src/families/build-family-types.ts +16 -0
- package/src/families/deployment-family.ts +15 -0
- package/src/families/issue-family.ts +38 -0
- package/src/families/pull-request-family.ts +42 -0
- package/src/families/pull-request-review-family.ts +18 -0
- package/src/families/ref-family.ts +14 -0
- package/src/families/release-family.ts +28 -0
- package/src/families/workflow-run-family.ts +18 -0
- package/src/generated/github-deploy-boundary.ts +308 -0
- package/src/generated/github-deploy.sql +24 -0
- package/src/generated/github-pull-request-boundary.ts +286 -0
- package/src/generated/github-pull-request.sql +41 -0
- package/src/generated/github-ref-boundary.ts +149 -0
- package/src/generated/github-ref.sql +8 -0
- package/src/generated/github-release-boundary.ts +251 -0
- package/src/generated/github-release.sql +32 -0
- package/src/github-deploy-consumer.ts +40 -0
- package/src/github-plugin.ts +15 -0
- package/src/github-pull-request-consumer.ts +56 -0
- package/src/github-pull-request-schema-path.ts +6 -0
- package/src/github-ref-consumer.ts +23 -0
- package/src/github-release-consumer.ts +43 -0
- package/src/github-settings.ts +15 -0
- package/src/github-source.ts +56 -0
- package/src/index.ts +13 -0
- package/src/ingest/build-github-fact.ts +55 -0
- package/src/ingest/github-app/build-app-jwt.ts +20 -0
- package/src/ingest/github-app/find-app-jwt.ts +17 -0
- package/src/ingest/github-app/github-api-url.ts +1 -0
- package/src/ingest/github-app/pick-pull-failure.ts +11 -0
- package/src/ingest/github-app/read-installation-token.ts +43 -0
- package/src/ingest/github-app/request-github-api.ts +75 -0
- package/src/ingest/github-event-rules.ts +324 -0
- package/src/ingest/read-github-delivery.ts +41 -0
- package/src/ingest/recovery/pull-failed-deliveries.ts +100 -0
- package/src/ingest/resync/collect-resync-targets.ts +33 -0
- package/src/ingest/resync/pull-marked-objects.ts +130 -0
- package/src/ingest/serve-ingest.ts +8 -0
- package/src/queries/github-delivery-consumers.ts +13 -0
- package/src/queries/pull-request-delivery-query.ts +49 -0
- package/src/queries/select-pull-request-delivery.ts +155 -0
- package/src/utils/build-body-hash.ts +34 -0
- package/src/utils/deploy-signals.ts +5 -0
- package/src/utils/find-field.ts +19 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { PendingFact, PullContext, Pulled } from '@pepys/kit';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { buildGithubFact } from '../build-github-fact.ts';
|
|
4
|
+
import { findAppJWT } from '../github-app/find-app-jwt.ts';
|
|
5
|
+
import { pickPullFailure } from '../github-app/pick-pull-failure.ts';
|
|
6
|
+
import { readInstallationToken } from '../github-app/read-installation-token.ts';
|
|
7
|
+
import type { InstallationToken } from '../github-app/read-installation-token.ts';
|
|
8
|
+
import { requestGithubAPI } from '../github-app/request-github-api.ts';
|
|
9
|
+
import { collectResyncTargets } from './collect-resync-targets.ts';
|
|
10
|
+
import type { ResyncTarget } from './collect-resync-targets.ts';
|
|
11
|
+
|
|
12
|
+
// The resync pull: each marked pull request and release, read again from the API as one fact in the
|
|
13
|
+
// shape of its webhook body. The fact comes after the deliveries, so it wins a tie of times and sets
|
|
14
|
+
// the row to GitHub's state.
|
|
15
|
+
export async function pullMarkedObjects(
|
|
16
|
+
_cursor: string | null,
|
|
17
|
+
context: PullContext,
|
|
18
|
+
): Promise<Pulled> {
|
|
19
|
+
const targets = collectResyncTargets(context.marks);
|
|
20
|
+
|
|
21
|
+
if (targets.length === 0) {
|
|
22
|
+
return { kind: 'batch', batch: { facts: [] }, cursor: 'resynced', isDone: true };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const appJWT = findAppJWT(context.env, Date.now());
|
|
26
|
+
|
|
27
|
+
if (appJWT === undefined) {
|
|
28
|
+
return { kind: 'rejected', reason: 'the GitHub App private key cannot sign a token' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const tokens = new Map<string, InstallationToken>();
|
|
32
|
+
|
|
33
|
+
const facts: PendingFact[] = [];
|
|
34
|
+
|
|
35
|
+
for (const target of targets) {
|
|
36
|
+
const token = await readCachedToken(tokens, target.repository, appJWT);
|
|
37
|
+
|
|
38
|
+
if (token.kind === 'failed') {
|
|
39
|
+
return pickPullFailure(token.status);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (token.kind === 'not-installed') {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const read = await readObject(target, token.token);
|
|
47
|
+
|
|
48
|
+
if (read.kind === 'failed') {
|
|
49
|
+
return pickPullFailure(read.status);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (read.kind === 'fact') {
|
|
53
|
+
facts.push(read.fact);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { kind: 'batch', batch: { facts }, cursor: 'resynced', isDone: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// one installation token per repository in one run
|
|
61
|
+
async function readCachedToken(
|
|
62
|
+
tokens: Map<string, InstallationToken>,
|
|
63
|
+
repository: string,
|
|
64
|
+
appJWT: string,
|
|
65
|
+
): Promise<InstallationToken> {
|
|
66
|
+
const known = tokens.get(repository);
|
|
67
|
+
|
|
68
|
+
if (known !== undefined) {
|
|
69
|
+
return known;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const token = await readInstallationToken(repository, appJWT);
|
|
73
|
+
|
|
74
|
+
tokens.set(repository, token);
|
|
75
|
+
|
|
76
|
+
return token;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const repositorySchema = z.looseObject({ id: z.number().int(), full_name: z.string() });
|
|
80
|
+
|
|
81
|
+
type ReadObject =
|
|
82
|
+
| { kind: 'fact'; fact: PendingFact }
|
|
83
|
+
| { kind: 'none' }
|
|
84
|
+
| { kind: 'failed'; status: number };
|
|
85
|
+
|
|
86
|
+
// An object the API answers 404 for is left alone. GitHub deletes no pull request, and shows a draft
|
|
87
|
+
// release only to a token that can push, so a 404 cannot prove a delete. A deleted release's own
|
|
88
|
+
// `deleted` delivery sets its tombstone.
|
|
89
|
+
async function readObject(target: ResyncTarget, token: string): Promise<ReadObject> {
|
|
90
|
+
const answer = await requestGithubAPI(target.path, token);
|
|
91
|
+
|
|
92
|
+
if (answer.status === 404) {
|
|
93
|
+
return { kind: 'none' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (answer.status !== 200) {
|
|
97
|
+
return { kind: 'failed', status: answer.status };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (target.kind === 'pull_request') {
|
|
101
|
+
return toReadObject(
|
|
102
|
+
buildGithubFact(
|
|
103
|
+
'pull_request',
|
|
104
|
+
{ action: 'resynced', pull_request: answer.body },
|
|
105
|
+
'observation',
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const repository = await requestGithubAPI(`/repos/${target.repository}`, token);
|
|
111
|
+
|
|
112
|
+
const parsed = repositorySchema.safeParse(repository.body);
|
|
113
|
+
|
|
114
|
+
if (repository.status !== 200 || !parsed.success) {
|
|
115
|
+
return { kind: 'failed', status: repository.status };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// only the two fields the release consumer reads, so the hash moves with the release alone
|
|
119
|
+
const body = {
|
|
120
|
+
action: 'resynced',
|
|
121
|
+
release: answer.body,
|
|
122
|
+
repository: { id: parsed.data.id, full_name: parsed.data.full_name },
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return toReadObject(buildGithubFact('release', body, 'observation'));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toReadObject(fact: PendingFact | 'ignored' | 'invalid'): ReadObject {
|
|
129
|
+
return typeof fact === 'string' ? { kind: 'none' } : { kind: 'fact', fact };
|
|
130
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { serveIngest } from '@pepys/ingest';
|
|
2
|
+
import { defineDeployment } from '@pepys/kit';
|
|
3
|
+
import { githubPlugin } from '../github-plugin.ts';
|
|
4
|
+
|
|
5
|
+
await serveIngest({
|
|
6
|
+
deployment: defineDeployment({ name: 'github', plugins: [githubPlugin] }),
|
|
7
|
+
environment: process.env,
|
|
8
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { githubDeployConsumer } from '../github-deploy-consumer.ts';
|
|
2
|
+
import { githubPullRequestConsumer } from '../github-pull-request-consumer.ts';
|
|
3
|
+
import { githubRefConsumer } from '../github-ref-consumer.ts';
|
|
4
|
+
import { githubReleaseConsumer } from '../github-release-consumer.ts';
|
|
5
|
+
|
|
6
|
+
// The consumers whose tables the delivery query reads. A query of another plug-in that calls the
|
|
7
|
+
// query module passes these in its `reads`.
|
|
8
|
+
export const githubDeliveryConsumers = [
|
|
9
|
+
githubPullRequestConsumer,
|
|
10
|
+
githubReleaseConsumer,
|
|
11
|
+
githubRefConsumer,
|
|
12
|
+
githubDeployConsumer,
|
|
13
|
+
] as const;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defineQuery } from '@pepys/kit';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { deploySignals } from '../utils/deploy-signals.ts';
|
|
4
|
+
import { githubDeliveryConsumers } from './github-delivery-consumers.ts';
|
|
5
|
+
import {
|
|
6
|
+
parsePullRequestDeliveries,
|
|
7
|
+
selectPullRequestDelivery,
|
|
8
|
+
} from './select-pull-request-delivery.ts';
|
|
9
|
+
|
|
10
|
+
const pullRequestsSchema = z.array(z.string()).max(200);
|
|
11
|
+
const nameSchema = z.string().min(1);
|
|
12
|
+
const releaseSchema = z.object({ id: z.string(), tag: z.string(), at: z.string() });
|
|
13
|
+
const signalSchema = z.enum(deploySignals);
|
|
14
|
+
const deploySchema = z.object({ id: z.string(), at: z.string(), signal: signalSchema });
|
|
15
|
+
|
|
16
|
+
const deliverySchema = z.object({
|
|
17
|
+
pullRequest: z.string(),
|
|
18
|
+
mergedAt: z.string().nullable(),
|
|
19
|
+
release: releaseSchema.nullable(),
|
|
20
|
+
deploy: deploySchema.nullable(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const pullRequestDeliveryQuery = defineQuery({
|
|
24
|
+
name: 'github_pull_request_delivery',
|
|
25
|
+
description:
|
|
26
|
+
'Returns, for each pull request, when it merged, the first release of its repository that contains it, and the first deploy to one environment of a commit that pins such a release. With a target, only a deploy of that target counts. A pull request with no row is left out.',
|
|
27
|
+
input: z.object({
|
|
28
|
+
pullRequests: pullRequestsSchema,
|
|
29
|
+
environment: nameSchema,
|
|
30
|
+
target: nameSchema.optional(),
|
|
31
|
+
}),
|
|
32
|
+
output: z.array(deliverySchema),
|
|
33
|
+
reads: githubDeliveryConsumers,
|
|
34
|
+
handler: async (call) => {
|
|
35
|
+
const [first, ...rest] = call.input.pullRequests;
|
|
36
|
+
|
|
37
|
+
if (first === undefined) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = await selectPullRequestDelivery({
|
|
42
|
+
pullRequests: [first, ...rest],
|
|
43
|
+
environment: call.input.environment,
|
|
44
|
+
target: call.input.target,
|
|
45
|
+
}).execute(call.db);
|
|
46
|
+
|
|
47
|
+
return parsePullRequestDeliveries(result.rows);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import type { RawBuilder } from 'kysely';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { deploySignals } from '../utils/deploy-signals.ts';
|
|
5
|
+
import type { DeploySignal } from '../utils/deploy-signals.ts';
|
|
6
|
+
|
|
7
|
+
interface DeliveryOptions {
|
|
8
|
+
// at least one
|
|
9
|
+
pullRequests: readonly [string, ...string[]];
|
|
10
|
+
environment: string;
|
|
11
|
+
|
|
12
|
+
// Absent: a deploy of any target, and of none, counts. Given: only a deploy of that target.
|
|
13
|
+
target?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The statement that ties pull requests to releases and deploys. The rules it holds are in the
|
|
17
|
+
// "Delivery query" section of the plug-ins design doc. Run it with `.execute(db)`, and turn its rows
|
|
18
|
+
// into deliveries with the parse function below.
|
|
19
|
+
export function selectPullRequestDelivery(options: DeliveryOptions): RawBuilder<unknown> {
|
|
20
|
+
return sql`select
|
|
21
|
+
pull_request.id as "pullRequest",
|
|
22
|
+
pull_request.merged_at as "mergedAt",
|
|
23
|
+
release.id as "releaseID",
|
|
24
|
+
release.tag_name as "releaseTag",
|
|
25
|
+
release.published_at as "releaseAt",
|
|
26
|
+
deploy.id as "deployID",
|
|
27
|
+
deploy.deployed_at as "deployAt",
|
|
28
|
+
deploy.signal as "deploySignal"
|
|
29
|
+
from github_pull_request as pull_request
|
|
30
|
+
left join github_release as release on release.id = ${selectFirstReleaseID()}
|
|
31
|
+
left join github_deploy as deploy on deploy.id = ${selectFirstDeployID(options)}
|
|
32
|
+
where pull_request.id in (${sql.join(options.pullRequests)})
|
|
33
|
+
order by pull_request.id`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function selectFirstReleaseID(): RawBuilder<unknown> {
|
|
37
|
+
const releaseTime = formatCommitTime(
|
|
38
|
+
'candidate.repository_id',
|
|
39
|
+
'candidate.target_commitish',
|
|
40
|
+
'candidate.created_at',
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return sql`(select candidate.id from github_release as candidate
|
|
44
|
+
where ${formatContains('candidate')}
|
|
45
|
+
order by ${releaseTime}, candidate.id
|
|
46
|
+
limit 1)`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The commit time of a sha of a repository, or the fallback time. The three names are columns.
|
|
50
|
+
function formatCommitTime(repository: string, sha: string, fallback: string): RawBuilder<unknown> {
|
|
51
|
+
return sql`julianday(coalesce((select commit_row.committed_at from github_commit as commit_row
|
|
52
|
+
where commit_row.repository_id = ${sql.ref(repository)}
|
|
53
|
+
and commit_row.sha = ${sql.ref(sha)}), ${sql.ref(fallback)}))`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The release under `alias` contains `pull_request`.
|
|
57
|
+
function formatContains(alias: string): RawBuilder<unknown> {
|
|
58
|
+
const release = sql.table(alias);
|
|
59
|
+
|
|
60
|
+
const releaseTime = formatCommitTime(
|
|
61
|
+
`${alias}.repository_id`,
|
|
62
|
+
`${alias}.target_commitish`,
|
|
63
|
+
`${alias}.created_at`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const mergeTime = formatCommitTime(
|
|
67
|
+
'pull_request.repository_id',
|
|
68
|
+
'pull_request.merge_commit_sha',
|
|
69
|
+
'pull_request.merged_at',
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return sql`(pull_request.merged_at is not null
|
|
73
|
+
and ${release}.repository_id = pull_request.repository_id
|
|
74
|
+
and ${release}.published_at is not null
|
|
75
|
+
and ${release}.is_draft = 0
|
|
76
|
+
and ${release}.deleted_at is null
|
|
77
|
+
and ${releaseTime} >= ${mergeTime})`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function selectFirstDeployID(options: DeliveryOptions): RawBuilder<unknown> {
|
|
81
|
+
const target = options.target ?? null;
|
|
82
|
+
|
|
83
|
+
const deployTime = formatCommitTime(
|
|
84
|
+
'carrier.repository_id',
|
|
85
|
+
'carrier.sha',
|
|
86
|
+
'carrier.deployed_at',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const pinnerMergeTime = formatCommitTime(
|
|
90
|
+
'pinner.repository_id',
|
|
91
|
+
'pinner.merge_commit_sha',
|
|
92
|
+
'pinner.merged_at',
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return sql`(select carrier.id from github_deploy as carrier
|
|
96
|
+
join github_pull_request as pinner
|
|
97
|
+
on pinner.repository_id = carrier.repository_id and pinner.merged_at is not null
|
|
98
|
+
join github_release_bump as bump on bump.pull_request_id = pinner.id
|
|
99
|
+
join github_release as pinned
|
|
100
|
+
on pinned.repository_full_name = bump.release_repository
|
|
101
|
+
and pinned.tag_name = bump.release_tag
|
|
102
|
+
where ${formatContains('pinned')}
|
|
103
|
+
and carrier.environment = ${options.environment}
|
|
104
|
+
and (${target} is null or carrier.target = ${target})
|
|
105
|
+
and carrier.signal = (case when exists (
|
|
106
|
+
select 1 from github_deploy as tagged
|
|
107
|
+
where tagged.repository_id = carrier.repository_id
|
|
108
|
+
and tagged.environment = carrier.environment
|
|
109
|
+
and (${target} is null or tagged.target = ${target})
|
|
110
|
+
and tagged.signal = 'tag'
|
|
111
|
+
) then 'tag' else 'deployment_status' end)
|
|
112
|
+
and ${deployTime} >= ${pinnerMergeTime}
|
|
113
|
+
order by julianday(carrier.deployed_at), carrier.id
|
|
114
|
+
limit 1)`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface PullRequestDelivery {
|
|
118
|
+
pullRequest: string;
|
|
119
|
+
mergedAt: string | null;
|
|
120
|
+
release: { id: string; tag: string; at: string } | null;
|
|
121
|
+
deploy: { id: string; at: string; signal: DeploySignal } | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const textSchema = z.string().nullable();
|
|
125
|
+
|
|
126
|
+
// One row per pull request, as the statement selects it.
|
|
127
|
+
const deliveryRowSchema = z.object({
|
|
128
|
+
pullRequest: z.string(),
|
|
129
|
+
mergedAt: textSchema,
|
|
130
|
+
releaseID: textSchema,
|
|
131
|
+
releaseTag: textSchema,
|
|
132
|
+
releaseAt: textSchema,
|
|
133
|
+
deployID: textSchema,
|
|
134
|
+
deployAt: textSchema,
|
|
135
|
+
deploySignal: z.enum(deploySignals).nullable(),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const deliveryRowsSchema = z.array(deliveryRowSchema);
|
|
139
|
+
|
|
140
|
+
// The rows of the statement as deliveries. A row of another shape is a bug in the statement, so the
|
|
141
|
+
// parse throws.
|
|
142
|
+
export function parsePullRequestDeliveries(rows: unknown): PullRequestDelivery[] {
|
|
143
|
+
return deliveryRowsSchema.parse(rows).map((row) => ({
|
|
144
|
+
pullRequest: row.pullRequest,
|
|
145
|
+
mergedAt: row.mergedAt,
|
|
146
|
+
release:
|
|
147
|
+
row.releaseID === null || row.releaseTag === null || row.releaseAt === null
|
|
148
|
+
? null
|
|
149
|
+
: { id: row.releaseID, tag: row.releaseTag, at: row.releaseAt },
|
|
150
|
+
deploy:
|
|
151
|
+
row.deployID === null || row.deployAt === null || row.deploySignal === null
|
|
152
|
+
? null
|
|
153
|
+
: { id: row.deployID, at: row.deployAt, signal: row.deploySignal },
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// The SHA-256 of the canonical JSON of a value: keys sorted at every depth, no white space. Two
|
|
4
|
+
// bodies that parse to one JSON value give one hash, however each was spaced or ordered.
|
|
5
|
+
export function buildBodyHash(value: unknown): string {
|
|
6
|
+
return createHash('sha256').update(formatCanonicalJSON(value)).digest('hex');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatCanonicalJSON(value: unknown): string {
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
return `[${value.map((item: unknown) => formatCanonicalJSON(item)).join(',')}]`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (typeof value === 'object' && value !== null) {
|
|
15
|
+
// code-unit order, as JSON canonical forms sort keys, and never the locale's order
|
|
16
|
+
const entries = Object.entries(value)
|
|
17
|
+
.toSorted(([left], [right]) => compareCodeUnits(left, right))
|
|
18
|
+
.map(
|
|
19
|
+
([key, item]: [string, unknown]) => `${JSON.stringify(key)}:${formatCanonicalJSON(item)}`,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
return `{${entries.join(',')}}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compareCodeUnits(left: string, right: string): number {
|
|
29
|
+
if (left === right) {
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return left < right ? -1 : 1;
|
|
34
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The two signals that report a deploy, in the order a query trusts them: the tag rows of one
|
|
2
|
+
// repository and environment when it has any, and its deployment status rows otherwise.
|
|
3
|
+
export const deploySignals = ['tag', 'deployment_status'] as const;
|
|
4
|
+
|
|
5
|
+
export type DeploySignal = (typeof deploySignals)[number];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The value at a path of keys inside a parsed JSON value, or undefined when a step is no object or
|
|
2
|
+
// has no such key. A consumer reads a delivery body with it and no schema library.
|
|
3
|
+
export function findField(value: unknown, ...path: readonly string[]): unknown {
|
|
4
|
+
let current = value;
|
|
5
|
+
|
|
6
|
+
for (const key of path) {
|
|
7
|
+
if (typeof current !== 'object' || current === null || Array.isArray(current)) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (!Object.hasOwn(current, key)) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
current = Reflect.get(current, key);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return current;
|
|
19
|
+
}
|