@qawolf/ci-sdk 0.10.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +74 -0
  3. package/dist/cjs/index.js +17 -0
  4. package/dist/cjs/lib/api/ci-greenlight.js +50 -0
  5. package/dist/cjs/lib/api/deploy.js +85 -0
  6. package/dist/cjs/lib/api/index.js +18 -0
  7. package/dist/cjs/lib/api-types.js +2 -0
  8. package/dist/cjs/lib/sdk/dependencies.js +2 -0
  9. package/dist/cjs/lib/sdk/domain/attempt-deploy.js +37 -0
  10. package/dist/cjs/lib/sdk/domain/poll-ci-greenlight.js +107 -0
  11. package/dist/cjs/lib/sdk/index.js +16 -0
  12. package/dist/cjs/lib/sdk/job/default.js +11 -0
  13. package/dist/cjs/lib/sdk/log/default.js +14 -0
  14. package/dist/cjs/lib/utils.js +18 -0
  15. package/dist/cjs/package.json +1 -0
  16. package/dist/esm/index.js +1 -0
  17. package/dist/esm/lib/api/ci-greenlight.js +46 -0
  18. package/dist/esm/lib/api/deploy.js +81 -0
  19. package/dist/esm/lib/api/index.js +2 -0
  20. package/dist/esm/lib/api-types.js +1 -0
  21. package/dist/esm/lib/sdk/dependencies.js +1 -0
  22. package/dist/esm/lib/sdk/domain/attempt-deploy.js +33 -0
  23. package/dist/esm/lib/sdk/domain/poll-ci-greenlight.js +103 -0
  24. package/dist/esm/lib/sdk/index.js +12 -0
  25. package/dist/esm/lib/sdk/job/default.js +8 -0
  26. package/dist/esm/lib/sdk/log/default.js +11 -0
  27. package/dist/esm/lib/utils.js +12 -0
  28. package/dist/esm/package.json +1 -0
  29. package/dist/types/index.d.ts +2 -0
  30. package/dist/types/lib/api/ci-greenlight.d.ts +60 -0
  31. package/dist/types/lib/api/deploy.d.ts +67 -0
  32. package/dist/types/lib/api/index.d.ts +2 -0
  33. package/dist/types/lib/api-types.d.ts +4 -0
  34. package/dist/types/lib/sdk/dependencies.d.ts +28 -0
  35. package/dist/types/lib/sdk/domain/attempt-deploy.d.ts +4 -0
  36. package/dist/types/lib/sdk/domain/poll-ci-greenlight.d.ts +17 -0
  37. package/dist/types/lib/sdk/index.d.ts +8 -0
  38. package/dist/types/lib/sdk/job/default.d.ts +2 -0
  39. package/dist/types/lib/sdk/log/default.d.ts +2 -0
  40. package/dist/types/lib/utils.d.ts +6 -0
  41. package/package.json +34 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # v0.10.0
2
+
3
+ Initial release.
4
+
5
+ ## Supported Endpoints
6
+
7
+ - `/api/deploy_success`
8
+ - `/api/v0/ci-greenlight/[root-run-id]`
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # QAWolf CI SDK
2
+
3
+ > :warning: This is an experimental package. Please report any
4
+ > issues you encounter to your support channel. It should still
5
+ > provide a better experience than using our internal GraphQL
6
+ > API directly.
7
+
8
+ This package provides a TypeScript (CSM and ESM compatible) SDK
9
+ to interact with the QAWolf Customer-facing API.
10
+
11
+ ## Example: Trigger Run on Deployment
12
+
13
+ ```ts
14
+ import { type DeployConfig, makeQaWolfSdk } from "@qawolf/ci-sdk";
15
+
16
+ // Edit this to your needs.
17
+ // See documentation here: https://qawolf.notion.site/cfe5a209256448a5bcd825bcf2d87e34
18
+ const deployConfig: DeployConfig = {
19
+ branch: undefined,
20
+ commitUrl: undefined,
21
+ deduplicationKey: undefined,
22
+ deploymentType: undefined,
23
+ deploymentUrl: undefined,
24
+ hostingService: undefined,
25
+ sha: undefined,
26
+ variables: undefined,
27
+ };
28
+
29
+ const { attemptDeploy } = makeQaWolfSdk({
30
+ apiKey: "qawolf_xxxxx",
31
+ });
32
+
33
+ (async () => {
34
+ const runId = await attemptDeploy(deployConfig);
35
+ // Store the runId as an output of the job to be used in a CI-greenlight job.
36
+ // This will depend on the CI platform you are using.
37
+ })();
38
+ ```
39
+
40
+ ## Example: Poll for CI Greenlight Status
41
+
42
+ ```ts
43
+ import { makeQaWolfSdk } from "@qawolf/ci-sdk";
44
+
45
+ const { pollCiGreenlightStatus } = makeQaWolfSdk({
46
+ apiKey: "qawolf_xxxxx",
47
+ });
48
+
49
+ (async () => {
50
+ // Retrieve runId from the previous job.
51
+ await pollCiGreenlightStatus({
52
+ runId,
53
+ // Optional: Callback to be called when the run stage changes.
54
+ // See https://qawolf.notion.site/dc42843313c246c8bf2a3a314ceb2c61 for
55
+ // documentation on these run stages.
56
+ onRunStageChanged: (current, previous) => {
57
+ console.log(current, previous);
58
+ },
59
+ });
60
+ })();
61
+ ```
62
+
63
+ ## Versioning
64
+
65
+ This package follows the [SemVer](https://semver.org/) versioning scheme. Additional notes:
66
+
67
+ - Versions below 1.0.0 still follow this scheme.
68
+ - We recommend depending on the `^` range operator for this package, as it will not introduce breaking changes and guarantee
69
+ an up-to-date API usage version.
70
+ - We will provide a changelog for each release, which will be available in the [CHANGELOG.md](./CHANGELOG.md) file.
71
+ - This package major version will be bumped when an API breaking change is introduced. This won't
72
+ happen too often, and we will reach out to you and give advance notice when it does.
73
+ - Only top-level exports are considered part of the public API and covered by SemVer.
74
+ - Addition of new fields in the API response types are not considered breaking changes.
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./lib/sdk/index.js"), exports);
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchCiGreenlightStatus = void 0;
4
+ async function fetchCiGreenlightStatus({ serviceBase, apiKey }, { runId, }, { fetch: localFetch }) {
5
+ try {
6
+ const resp = await localFetch(new URL(`/api/v0/ci-greenlight/${encodeURIComponent(runId)}`, serviceBase), {
7
+ headers: {
8
+ Authorization: `Bearer ${apiKey}`,
9
+ },
10
+ });
11
+ if (resp.ok) {
12
+ const greenlightStatus = (await resp.json());
13
+ return {
14
+ greenlightStatus,
15
+ httpStatus: resp.status,
16
+ ok: true,
17
+ };
18
+ }
19
+ if (resp.status === 404) {
20
+ return {
21
+ canRetry: true,
22
+ errorType: "not-found",
23
+ httpStatus: resp.status,
24
+ ok: false,
25
+ };
26
+ }
27
+ if (resp.status >= 500) {
28
+ return {
29
+ canRetry: true,
30
+ errorType: "server",
31
+ httpStatus: resp.status,
32
+ ok: false,
33
+ };
34
+ }
35
+ return {
36
+ canRetry: false,
37
+ httpStatus: resp.status,
38
+ ok: false,
39
+ };
40
+ }
41
+ catch (e) {
42
+ return {
43
+ canRetry: true,
44
+ errorType: "network",
45
+ httpStatus: 0,
46
+ ok: false,
47
+ };
48
+ }
49
+ }
50
+ exports.fetchCiGreenlightStatus = fetchCiGreenlightStatus;
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.postDeploySuccess = void 0;
4
+ async function postDeploySuccess({ apiKey, serviceBase }, { branch, commitUrl, deduplicationKey, deploymentType, deploymentUrl, hostingService, sha, variables, }, { fetch: localFetch }) {
5
+ try {
6
+ const response = await localFetch(new URL("/api/webhooks/deploy_success", serviceBase), {
7
+ body: JSON.stringify({
8
+ branch,
9
+ commit_url: commitUrl,
10
+ deduplication_key: deduplicationKey,
11
+ deployment_type: deploymentType,
12
+ deployment_url: deploymentUrl,
13
+ hosting_service: hostingService,
14
+ sha,
15
+ variables,
16
+ }),
17
+ headers: {
18
+ Authorization: `Bearer ${apiKey}`,
19
+ "Content-Type": "application/json",
20
+ },
21
+ method: "POST",
22
+ });
23
+ if (response.ok) {
24
+ const { results } = (await response.json());
25
+ const failedTrigger = results.find((t) => !!t.failure_reason);
26
+ const successfulTrigger = results.find((t) => !!t.created_suite_id);
27
+ if (successfulTrigger == null && failedTrigger == null) {
28
+ return {
29
+ failureReason: "no-matched-trigger",
30
+ success: false,
31
+ };
32
+ }
33
+ if (failedTrigger) {
34
+ return {
35
+ details: failedTrigger.failure_reason,
36
+ failureReason: "run-could-not-be-created",
37
+ success: false,
38
+ };
39
+ }
40
+ if (!successfulTrigger) {
41
+ return {
42
+ failureReason: "no-matched-trigger",
43
+ success: false,
44
+ };
45
+ }
46
+ const runId = successfulTrigger.created_suite_id;
47
+ return { runId, success: true };
48
+ }
49
+ else {
50
+ if (response.status >= 500) {
51
+ return {
52
+ failureReason: "server-error",
53
+ httpStatus: response.status,
54
+ success: false,
55
+ };
56
+ }
57
+ if (response.status === 401) {
58
+ return {
59
+ failureReason: "unauthorized",
60
+ httpStatus: response.status,
61
+ success: false,
62
+ };
63
+ }
64
+ if (response.status === 403) {
65
+ return {
66
+ failureReason: "forbidden",
67
+ httpStatus: response.status,
68
+ success: false,
69
+ };
70
+ }
71
+ return {
72
+ failureReason: "unrecoverable-error",
73
+ httpStatus: response.status,
74
+ success: false,
75
+ };
76
+ }
77
+ }
78
+ catch (e) {
79
+ return {
80
+ failureReason: "network-error",
81
+ success: false,
82
+ };
83
+ }
84
+ }
85
+ exports.postDeploySuccess = postDeploySuccess;
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./ci-greenlight.js"), exports);
18
+ __exportStar(require("./deploy.js"), exports);
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.attemptDeploy = void 0;
4
+ const deploy_js_1 = require("../../api/deploy.js");
5
+ const utils_js_1 = require("../../utils.js");
6
+ async function attemptDeploy(deps, apiConfig, config) {
7
+ const { job, log } = deps;
8
+ const responseState = await (0, deploy_js_1.postDeploySuccess)(apiConfig, config, deps);
9
+ if (!responseState.success) {
10
+ if (responseState.failureReason === "no-matched-trigger") {
11
+ log.error(`🚫 No trigger matched the request, aborting. Please contact support.`);
12
+ }
13
+ else if (responseState.failureReason === "run-could-not-be-created") {
14
+ log.error(`🚫 Run could not be created, aborting. Reason: "${responseState.details}". Please contact support.`);
15
+ }
16
+ else if (responseState.failureReason === "network-error")
17
+ log.error(`🚫 Network error from deploy_success, aborting.`);
18
+ else if (responseState.failureReason === "server-error")
19
+ log.error(`🚫 Server error from deploy_success (status ${responseState.httpStatus}), aborting. Please contact support.`);
20
+ else if (responseState.failureReason === "unrecoverable-error") {
21
+ log.error(`🚫 Unrecoverable error from deploy_success (status ${responseState.httpStatus}), aborting. Please contact support.`);
22
+ }
23
+ else if (responseState.failureReason === "forbidden") {
24
+ log.error(`🚫 Forbidden error from deploy_success (status ${responseState.httpStatus}), aborting. Please contact support.`);
25
+ }
26
+ else if (responseState.failureReason === "unauthorized") {
27
+ log.error(`🚫 Unauthorized error from deploy_success (status ${responseState.httpStatus}), aborting. Please contact support.`);
28
+ }
29
+ else {
30
+ (0, utils_js_1.assertType)(responseState);
31
+ }
32
+ return job.abort();
33
+ }
34
+ log.info(`✅ Run created with ID: ${responseState.runId}`);
35
+ return responseState.runId;
36
+ }
37
+ exports.attemptDeploy = attemptDeploy;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pollCiGreenlightStatus = void 0;
4
+ const utils_js_1 = require("../../utils.js");
5
+ const ci_greenlight_js_1 = require("../../api/ci-greenlight.js");
6
+ const safeDefaults = {
7
+ maxRetries: 10,
8
+ retryInterval: 10 * 1000,
9
+ runInProgressInterval: 30 * 1000,
10
+ runUnderReviewInterval: 60 * 1000,
11
+ };
12
+ async function pollCiGreenlightStatus(deps, apiConfig, { runId, onRunStageChanged: onRunStageChanged = () => void 0, ...pollConfig }) {
13
+ let retries = 0;
14
+ const { job, log } = deps;
15
+ const { maxRetries = safeDefaults.maxRetries, retryInterval = safeDefaults.retryInterval, runInProgressInterval = safeDefaults.runInProgressInterval, runUnderReviewInterval = safeDefaults.runUnderReviewInterval, } = pollConfig;
16
+ let currentStatus = undefined;
17
+ while (true) {
18
+ const responseState = await (0, ci_greenlight_js_1.fetchCiGreenlightStatus)(apiConfig, {
19
+ runId,
20
+ }, deps);
21
+ if (!responseState.ok && !responseState.canRetry) {
22
+ log.error(`🚫 Unrecoverable error (status ${responseState.httpStatus}) from CI greenlight, aborting.`);
23
+ return job.abort();
24
+ }
25
+ if (!responseState.ok) {
26
+ // This is a non-OK retryable status.
27
+ retries += 1;
28
+ if (retries > maxRetries) {
29
+ log.error(`🚫 Unrecoverable error ${responseState.httpStatus
30
+ ? `(status ${responseState.httpStatus})`
31
+ : ""} from CI greenlight, aborting after ${maxRetries} attempts.`);
32
+ return job.abort();
33
+ }
34
+ const { errorType, httpStatus } = responseState;
35
+ const retryLabel = `${httpStatus ? `(status ${httpStatus})` : ""}, will try again... (${retries}/${maxRetries})`;
36
+ if (errorType === "network")
37
+ log.warn(`❌ Network error ${retryLabel}`);
38
+ else if (errorType === "not-found")
39
+ log.info(`⏳ Run not found ${retryLabel}`);
40
+ else if (errorType === "server")
41
+ log.warn(`❌ Server error ${retryLabel}`);
42
+ await (0, utils_js_1.sleep)(retryInterval);
43
+ continue;
44
+ }
45
+ // responseState.ok === true
46
+ retries = 0;
47
+ const { greenlightStatus } = responseState;
48
+ const previousStatus = currentStatus;
49
+ currentStatus = greenlightStatus;
50
+ if (!previousStatus ||
51
+ previousStatus.runStage !== greenlightStatus.runStage)
52
+ try {
53
+ await onRunStageChanged(greenlightStatus, previousStatus);
54
+ }
55
+ catch (e) {
56
+ log.error(`Error in onRunStageChanged callback`, e);
57
+ }
58
+ const urlInfo = `See ${greenlightStatus.relevantRunUrl}.`;
59
+ const { greenlight, relevantRunId, relevantRunUrl, relevantRunWithBugsUrl, rootRunId, rootRunUrl, runStage, } = greenlightStatus;
60
+ if (rootRunId !== relevantRunId) {
61
+ log.info(`Root run was superseded:
62
+ - Root: ${rootRunUrl}
63
+ - Superseding: ${relevantRunUrl}`);
64
+ }
65
+ if (runStage === "completed") {
66
+ const { blockingBugsCount, blockingBugUrls = [], nonBlockingBugsCount, nonBlockingBugUrls = [], } = greenlightStatus;
67
+ const blockingBugList = blockingBugUrls
68
+ .map((url) => `\n• ${url}`)
69
+ .join("");
70
+ const nonBlockingBugList = nonBlockingBugUrls
71
+ .map((url) => `\n• ${url}`)
72
+ .join("");
73
+ const bugsListInText = `${blockingBugUrls.length > 0 ? `\nBLOCKING BUGS:${blockingBugList}` : ""}${nonBlockingBugUrls.length > 0
74
+ ? `\nNON BLOCKING BUGS:${nonBlockingBugList}`
75
+ : ""}`;
76
+ if (greenlight) {
77
+ log.info(`✅ Run passed and no blocking bugs found ${nonBlockingBugsCount > 0
78
+ ? ` and ${nonBlockingBugsCount} non-blocking bug${(0, utils_js_1.pluralize)(nonBlockingBugsCount)} found`
79
+ : ""}.\n${urlInfo}`);
80
+ return;
81
+ }
82
+ else {
83
+ log.warn(`❌ Run failed and ${blockingBugsCount} blocking bug${(0, utils_js_1.pluralize)(blockingBugsCount)} found${nonBlockingBugsCount
84
+ ? ` and ${nonBlockingBugsCount} non-blocking bug${(0, utils_js_1.pluralize)(nonBlockingBugsCount)}`
85
+ : ""}.\nSee ${relevantRunWithBugsUrl}\n` +
86
+ "You may review the priority of certain bugs to mark these non-blocking and retry the job.\n" +
87
+ bugsListInText);
88
+ return job.fail();
89
+ }
90
+ }
91
+ if (runStage === "initializing") {
92
+ log.info(`⏳ Run is in progress... ${urlInfo}`);
93
+ await (0, utils_js_1.sleep)(runInProgressInterval);
94
+ }
95
+ else if (runStage === "underReview") {
96
+ log.info(`🕵️ Run is under review... ${urlInfo}`);
97
+ await (0, utils_js_1.sleep)(runUnderReviewInterval);
98
+ }
99
+ else if (runStage === "canceled") {
100
+ log.info(`🚫 Run was canceled. Aborting... Please contact support.\n${urlInfo}`);
101
+ return job.abort();
102
+ }
103
+ else
104
+ throw new Error(`Unexpected run stage: ${runStage}`);
105
+ }
106
+ }
107
+ exports.pollCiGreenlightStatus = pollCiGreenlightStatus;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.makeQaWolfSdk = void 0;
4
+ const attempt_deploy_js_1 = require("./domain/attempt-deploy.js");
5
+ const default_js_1 = require("./job/default.js");
6
+ const default_js_2 = require("./log/default.js");
7
+ const poll_ci_greenlight_js_1 = require("./domain/poll-ci-greenlight.js");
8
+ function makeQaWolfSdk({ apiKey, serviceBase = "https://app.qawolf.com/", }, { job = default_js_1.defaultJobDriver, log = default_js_2.defaultLogDriver, fetch = globalThis.fetch, } = {}) {
9
+ const deps = { job, log, fetch };
10
+ const apiConfig = { apiKey, serviceBase };
11
+ return {
12
+ attemptDeploy: attempt_deploy_js_1.attemptDeploy.bind(null, deps, apiConfig),
13
+ pollCiGreenlightStatus: poll_ci_greenlight_js_1.pollCiGreenlightStatus.bind(null, deps, apiConfig),
14
+ };
15
+ }
16
+ exports.makeQaWolfSdk = makeQaWolfSdk;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultJobDriver = void 0;
4
+ exports.defaultJobDriver = {
5
+ fail() {
6
+ process.exit(1);
7
+ },
8
+ abort() {
9
+ process.exit(1);
10
+ },
11
+ };
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultLogDriver = void 0;
4
+ exports.defaultLogDriver = {
5
+ info(message) {
6
+ console.log(message);
7
+ },
8
+ warn(message) {
9
+ console.warn(message);
10
+ },
11
+ error(message) {
12
+ console.error(message);
13
+ },
14
+ };
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertType = exports.pluralize = exports.sleep = void 0;
4
+ async function sleep(ms) {
5
+ return new Promise((resolve) => setTimeout(resolve, ms));
6
+ }
7
+ exports.sleep = sleep;
8
+ function pluralize(count) {
9
+ return count === 1 ? "" : "s";
10
+ }
11
+ exports.pluralize = pluralize;
12
+ /**
13
+ * Statically verify that provided value is assignable to a desired type
14
+ */
15
+ function assertType(_value) {
16
+ // noop
17
+ }
18
+ exports.assertType = assertType;
@@ -0,0 +1 @@
1
+ {"type": "commonjs"}
@@ -0,0 +1 @@
1
+ export * from "./lib/sdk/index.js";
@@ -0,0 +1,46 @@
1
+ export async function fetchCiGreenlightStatus({ serviceBase, apiKey }, { runId, }, { fetch: localFetch }) {
2
+ try {
3
+ const resp = await localFetch(new URL(`/api/v0/ci-greenlight/${encodeURIComponent(runId)}`, serviceBase), {
4
+ headers: {
5
+ Authorization: `Bearer ${apiKey}`,
6
+ },
7
+ });
8
+ if (resp.ok) {
9
+ const greenlightStatus = (await resp.json());
10
+ return {
11
+ greenlightStatus,
12
+ httpStatus: resp.status,
13
+ ok: true,
14
+ };
15
+ }
16
+ if (resp.status === 404) {
17
+ return {
18
+ canRetry: true,
19
+ errorType: "not-found",
20
+ httpStatus: resp.status,
21
+ ok: false,
22
+ };
23
+ }
24
+ if (resp.status >= 500) {
25
+ return {
26
+ canRetry: true,
27
+ errorType: "server",
28
+ httpStatus: resp.status,
29
+ ok: false,
30
+ };
31
+ }
32
+ return {
33
+ canRetry: false,
34
+ httpStatus: resp.status,
35
+ ok: false,
36
+ };
37
+ }
38
+ catch (e) {
39
+ return {
40
+ canRetry: true,
41
+ errorType: "network",
42
+ httpStatus: 0,
43
+ ok: false,
44
+ };
45
+ }
46
+ }
@@ -0,0 +1,81 @@
1
+ export async function postDeploySuccess({ apiKey, serviceBase }, { branch, commitUrl, deduplicationKey, deploymentType, deploymentUrl, hostingService, sha, variables, }, { fetch: localFetch }) {
2
+ try {
3
+ const response = await localFetch(new URL("/api/webhooks/deploy_success", serviceBase), {
4
+ body: JSON.stringify({
5
+ branch,
6
+ commit_url: commitUrl,
7
+ deduplication_key: deduplicationKey,
8
+ deployment_type: deploymentType,
9
+ deployment_url: deploymentUrl,
10
+ hosting_service: hostingService,
11
+ sha,
12
+ variables,
13
+ }),
14
+ headers: {
15
+ Authorization: `Bearer ${apiKey}`,
16
+ "Content-Type": "application/json",
17
+ },
18
+ method: "POST",
19
+ });
20
+ if (response.ok) {
21
+ const { results } = (await response.json());
22
+ const failedTrigger = results.find((t) => !!t.failure_reason);
23
+ const successfulTrigger = results.find((t) => !!t.created_suite_id);
24
+ if (successfulTrigger == null && failedTrigger == null) {
25
+ return {
26
+ failureReason: "no-matched-trigger",
27
+ success: false,
28
+ };
29
+ }
30
+ if (failedTrigger) {
31
+ return {
32
+ details: failedTrigger.failure_reason,
33
+ failureReason: "run-could-not-be-created",
34
+ success: false,
35
+ };
36
+ }
37
+ if (!successfulTrigger) {
38
+ return {
39
+ failureReason: "no-matched-trigger",
40
+ success: false,
41
+ };
42
+ }
43
+ const runId = successfulTrigger.created_suite_id;
44
+ return { runId, success: true };
45
+ }
46
+ else {
47
+ if (response.status >= 500) {
48
+ return {
49
+ failureReason: "server-error",
50
+ httpStatus: response.status,
51
+ success: false,
52
+ };
53
+ }
54
+ if (response.status === 401) {
55
+ return {
56
+ failureReason: "unauthorized",
57
+ httpStatus: response.status,
58
+ success: false,
59
+ };
60
+ }
61
+ if (response.status === 403) {
62
+ return {
63
+ failureReason: "forbidden",
64
+ httpStatus: response.status,
65
+ success: false,
66
+ };
67
+ }
68
+ return {
69
+ failureReason: "unrecoverable-error",
70
+ httpStatus: response.status,
71
+ success: false,
72
+ };
73
+ }
74
+ }
75
+ catch (e) {
76
+ return {
77
+ failureReason: "network-error",
78
+ success: false,
79
+ };
80
+ }
81
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./ci-greenlight.js";
2
+ export * from "./deploy.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { postDeploySuccess } from "../../api/deploy.js";
2
+ import { assertType } from "../../utils.js";
3
+ export async function attemptDeploy(deps, apiConfig, config) {
4
+ const { job, log } = deps;
5
+ const responseState = await postDeploySuccess(apiConfig, config, deps);
6
+ if (!responseState.success) {
7
+ if (responseState.failureReason === "no-matched-trigger") {
8
+ log.error(`🚫 No trigger matched the request, aborting. Please contact support.`);
9
+ }
10
+ else if (responseState.failureReason === "run-could-not-be-created") {
11
+ log.error(`🚫 Run could not be created, aborting. Reason: "${responseState.details}". Please contact support.`);
12
+ }
13
+ else if (responseState.failureReason === "network-error")
14
+ log.error(`🚫 Network error from deploy_success, aborting.`);
15
+ else if (responseState.failureReason === "server-error")
16
+ log.error(`🚫 Server error from deploy_success (status ${responseState.httpStatus}), aborting. Please contact support.`);
17
+ else if (responseState.failureReason === "unrecoverable-error") {
18
+ log.error(`🚫 Unrecoverable error from deploy_success (status ${responseState.httpStatus}), aborting. Please contact support.`);
19
+ }
20
+ else if (responseState.failureReason === "forbidden") {
21
+ log.error(`🚫 Forbidden error from deploy_success (status ${responseState.httpStatus}), aborting. Please contact support.`);
22
+ }
23
+ else if (responseState.failureReason === "unauthorized") {
24
+ log.error(`🚫 Unauthorized error from deploy_success (status ${responseState.httpStatus}), aborting. Please contact support.`);
25
+ }
26
+ else {
27
+ assertType(responseState);
28
+ }
29
+ return job.abort();
30
+ }
31
+ log.info(`✅ Run created with ID: ${responseState.runId}`);
32
+ return responseState.runId;
33
+ }
@@ -0,0 +1,103 @@
1
+ import { sleep, pluralize } from "../../utils.js";
2
+ import { fetchCiGreenlightStatus, } from "../../api/ci-greenlight.js";
3
+ const safeDefaults = {
4
+ maxRetries: 10,
5
+ retryInterval: 10 * 1000,
6
+ runInProgressInterval: 30 * 1000,
7
+ runUnderReviewInterval: 60 * 1000,
8
+ };
9
+ export async function pollCiGreenlightStatus(deps, apiConfig, { runId, onRunStageChanged: onRunStageChanged = () => void 0, ...pollConfig }) {
10
+ let retries = 0;
11
+ const { job, log } = deps;
12
+ const { maxRetries = safeDefaults.maxRetries, retryInterval = safeDefaults.retryInterval, runInProgressInterval = safeDefaults.runInProgressInterval, runUnderReviewInterval = safeDefaults.runUnderReviewInterval, } = pollConfig;
13
+ let currentStatus = undefined;
14
+ while (true) {
15
+ const responseState = await fetchCiGreenlightStatus(apiConfig, {
16
+ runId,
17
+ }, deps);
18
+ if (!responseState.ok && !responseState.canRetry) {
19
+ log.error(`🚫 Unrecoverable error (status ${responseState.httpStatus}) from CI greenlight, aborting.`);
20
+ return job.abort();
21
+ }
22
+ if (!responseState.ok) {
23
+ // This is a non-OK retryable status.
24
+ retries += 1;
25
+ if (retries > maxRetries) {
26
+ log.error(`🚫 Unrecoverable error ${responseState.httpStatus
27
+ ? `(status ${responseState.httpStatus})`
28
+ : ""} from CI greenlight, aborting after ${maxRetries} attempts.`);
29
+ return job.abort();
30
+ }
31
+ const { errorType, httpStatus } = responseState;
32
+ const retryLabel = `${httpStatus ? `(status ${httpStatus})` : ""}, will try again... (${retries}/${maxRetries})`;
33
+ if (errorType === "network")
34
+ log.warn(`❌ Network error ${retryLabel}`);
35
+ else if (errorType === "not-found")
36
+ log.info(`⏳ Run not found ${retryLabel}`);
37
+ else if (errorType === "server")
38
+ log.warn(`❌ Server error ${retryLabel}`);
39
+ await sleep(retryInterval);
40
+ continue;
41
+ }
42
+ // responseState.ok === true
43
+ retries = 0;
44
+ const { greenlightStatus } = responseState;
45
+ const previousStatus = currentStatus;
46
+ currentStatus = greenlightStatus;
47
+ if (!previousStatus ||
48
+ previousStatus.runStage !== greenlightStatus.runStage)
49
+ try {
50
+ await onRunStageChanged(greenlightStatus, previousStatus);
51
+ }
52
+ catch (e) {
53
+ log.error(`Error in onRunStageChanged callback`, e);
54
+ }
55
+ const urlInfo = `See ${greenlightStatus.relevantRunUrl}.`;
56
+ const { greenlight, relevantRunId, relevantRunUrl, relevantRunWithBugsUrl, rootRunId, rootRunUrl, runStage, } = greenlightStatus;
57
+ if (rootRunId !== relevantRunId) {
58
+ log.info(`Root run was superseded:
59
+ - Root: ${rootRunUrl}
60
+ - Superseding: ${relevantRunUrl}`);
61
+ }
62
+ if (runStage === "completed") {
63
+ const { blockingBugsCount, blockingBugUrls = [], nonBlockingBugsCount, nonBlockingBugUrls = [], } = greenlightStatus;
64
+ const blockingBugList = blockingBugUrls
65
+ .map((url) => `\n• ${url}`)
66
+ .join("");
67
+ const nonBlockingBugList = nonBlockingBugUrls
68
+ .map((url) => `\n• ${url}`)
69
+ .join("");
70
+ const bugsListInText = `${blockingBugUrls.length > 0 ? `\nBLOCKING BUGS:${blockingBugList}` : ""}${nonBlockingBugUrls.length > 0
71
+ ? `\nNON BLOCKING BUGS:${nonBlockingBugList}`
72
+ : ""}`;
73
+ if (greenlight) {
74
+ log.info(`✅ Run passed and no blocking bugs found ${nonBlockingBugsCount > 0
75
+ ? ` and ${nonBlockingBugsCount} non-blocking bug${pluralize(nonBlockingBugsCount)} found`
76
+ : ""}.\n${urlInfo}`);
77
+ return;
78
+ }
79
+ else {
80
+ log.warn(`❌ Run failed and ${blockingBugsCount} blocking bug${pluralize(blockingBugsCount)} found${nonBlockingBugsCount
81
+ ? ` and ${nonBlockingBugsCount} non-blocking bug${pluralize(nonBlockingBugsCount)}`
82
+ : ""}.\nSee ${relevantRunWithBugsUrl}\n` +
83
+ "You may review the priority of certain bugs to mark these non-blocking and retry the job.\n" +
84
+ bugsListInText);
85
+ return job.fail();
86
+ }
87
+ }
88
+ if (runStage === "initializing") {
89
+ log.info(`⏳ Run is in progress... ${urlInfo}`);
90
+ await sleep(runInProgressInterval);
91
+ }
92
+ else if (runStage === "underReview") {
93
+ log.info(`🕵️ Run is under review... ${urlInfo}`);
94
+ await sleep(runUnderReviewInterval);
95
+ }
96
+ else if (runStage === "canceled") {
97
+ log.info(`🚫 Run was canceled. Aborting... Please contact support.\n${urlInfo}`);
98
+ return job.abort();
99
+ }
100
+ else
101
+ throw new Error(`Unexpected run stage: ${runStage}`);
102
+ }
103
+ }
@@ -0,0 +1,12 @@
1
+ import { attemptDeploy } from "./domain/attempt-deploy.js";
2
+ import { defaultJobDriver } from "./job/default.js";
3
+ import { defaultLogDriver } from "./log/default.js";
4
+ import { pollCiGreenlightStatus } from "./domain/poll-ci-greenlight.js";
5
+ export function makeQaWolfSdk({ apiKey, serviceBase = "https://app.qawolf.com/", }, { job = defaultJobDriver, log = defaultLogDriver, fetch = globalThis.fetch, } = {}) {
6
+ const deps = { job, log, fetch };
7
+ const apiConfig = { apiKey, serviceBase };
8
+ return {
9
+ attemptDeploy: attemptDeploy.bind(null, deps, apiConfig),
10
+ pollCiGreenlightStatus: pollCiGreenlightStatus.bind(null, deps, apiConfig),
11
+ };
12
+ }
@@ -0,0 +1,8 @@
1
+ export const defaultJobDriver = {
2
+ fail() {
3
+ process.exit(1);
4
+ },
5
+ abort() {
6
+ process.exit(1);
7
+ },
8
+ };
@@ -0,0 +1,11 @@
1
+ export const defaultLogDriver = {
2
+ info(message) {
3
+ console.log(message);
4
+ },
5
+ warn(message) {
6
+ console.warn(message);
7
+ },
8
+ error(message) {
9
+ console.error(message);
10
+ },
11
+ };
@@ -0,0 +1,12 @@
1
+ export async function sleep(ms) {
2
+ return new Promise((resolve) => setTimeout(resolve, ms));
3
+ }
4
+ export function pluralize(count) {
5
+ return count === 1 ? "" : "s";
6
+ }
7
+ /**
8
+ * Statically verify that provided value is assignable to a desired type
9
+ */
10
+ export function assertType(_value) {
11
+ // noop
12
+ }
@@ -0,0 +1 @@
1
+ {"type": "module"}
@@ -0,0 +1,2 @@
1
+ export * from "./lib/sdk/index.js";
2
+ export type { CiGreenlightStatus, CiGreenlightStatusBase, DeployConfig, DeployStatus, } from "./lib/api/index.js";
@@ -0,0 +1,60 @@
1
+ import { ApiConfig } from "../api-types.js";
2
+ import { SdkDependencies } from "../sdk/dependencies.js";
3
+ export type CiGreenlightStatusBase = {
4
+ relevantRunId: string;
5
+ relevantRunUrl: string;
6
+ relevantRunWithBugsUrl: string;
7
+ rootRunId: string;
8
+ rootRunUrl: string;
9
+ workflowsDisabledAfterRunCount: number;
10
+ workflowsInRunCount: number;
11
+ workflowsUnderInvestigationCount: number;
12
+ };
13
+ /**
14
+ * The status of a CI greenlight run. See the API documentation here for
15
+ * more details: https://qawolf.notion.site/dc42843313c246c8bf2a3a314ceb2c61
16
+ */
17
+ export type CiGreenlightStatus = CiGreenlightStatusBase & ({
18
+ blockingBugsCount: number;
19
+ blockingBugUrls: string[];
20
+ greenlight: boolean;
21
+ nonBlockingBugsCount: number;
22
+ nonBlockingBugUrls: string[];
23
+ runStage: "completed";
24
+ } | {
25
+ greenlight: false;
26
+ runStage: "initializing" | "underReview" | "canceled";
27
+ });
28
+ export declare function fetchCiGreenlightStatus({ serviceBase, apiKey }: ApiConfig, { runId, }: {
29
+ runId: string;
30
+ }, { fetch: localFetch }: SdkDependencies): Promise<{
31
+ readonly greenlightStatus: CiGreenlightStatus;
32
+ readonly httpStatus: number;
33
+ readonly ok: true;
34
+ readonly canRetry?: never;
35
+ readonly errorType?: never;
36
+ } | {
37
+ readonly canRetry: true;
38
+ readonly errorType: "not-found";
39
+ readonly httpStatus: 404;
40
+ readonly ok: false;
41
+ readonly greenlightStatus?: never;
42
+ } | {
43
+ readonly canRetry: true;
44
+ readonly errorType: "server";
45
+ readonly httpStatus: number;
46
+ readonly ok: false;
47
+ readonly greenlightStatus?: never;
48
+ } | {
49
+ readonly canRetry: false;
50
+ readonly httpStatus: number;
51
+ readonly ok: false;
52
+ readonly greenlightStatus?: never;
53
+ readonly errorType?: never;
54
+ } | {
55
+ readonly canRetry: true;
56
+ readonly errorType: "network";
57
+ readonly httpStatus: 0;
58
+ readonly ok: false;
59
+ readonly greenlightStatus?: never;
60
+ }>;
@@ -0,0 +1,67 @@
1
+ import { ApiConfig } from "../api-types.js";
2
+ import { SdkDependencies } from "../sdk/dependencies.js";
3
+ export type DeployConfig = {
4
+ branch: string | undefined;
5
+ commitUrl: string | undefined;
6
+ deduplicationKey: string | undefined;
7
+ deploymentType: string | undefined;
8
+ deploymentUrl: string | undefined;
9
+ hostingService: string | undefined;
10
+ sha: string | undefined;
11
+ variables: Record<string, string> | undefined;
12
+ };
13
+ export type DeployStatus = {
14
+ results: Array<{
15
+ created_suite_id?: string;
16
+ failure_reason?: string;
17
+ }>;
18
+ };
19
+ export declare function postDeploySuccess({ apiKey, serviceBase }: ApiConfig, { branch, commitUrl, deduplicationKey, deploymentType, deploymentUrl, hostingService, sha, variables, }: Partial<DeployConfig>, { fetch: localFetch }: SdkDependencies): Promise<{
20
+ readonly failureReason: "no-matched-trigger";
21
+ readonly success: false;
22
+ readonly details?: never;
23
+ readonly runId?: never;
24
+ readonly httpStatus?: never;
25
+ } | {
26
+ readonly details: string;
27
+ readonly failureReason: "run-could-not-be-created";
28
+ readonly success: false;
29
+ readonly runId?: never;
30
+ readonly httpStatus?: never;
31
+ } | {
32
+ readonly runId: string;
33
+ readonly success: true;
34
+ readonly failureReason?: never;
35
+ readonly details?: never;
36
+ readonly httpStatus?: never;
37
+ } | {
38
+ readonly failureReason: "server-error";
39
+ readonly httpStatus: number;
40
+ readonly success: false;
41
+ readonly details?: never;
42
+ readonly runId?: never;
43
+ } | {
44
+ readonly failureReason: "unauthorized";
45
+ readonly httpStatus: 401;
46
+ readonly success: false;
47
+ readonly details?: never;
48
+ readonly runId?: never;
49
+ } | {
50
+ readonly failureReason: "forbidden";
51
+ readonly httpStatus: 403;
52
+ readonly success: false;
53
+ readonly details?: never;
54
+ readonly runId?: never;
55
+ } | {
56
+ readonly failureReason: "unrecoverable-error";
57
+ readonly httpStatus: number;
58
+ readonly success: false;
59
+ readonly details?: never;
60
+ readonly runId?: never;
61
+ } | {
62
+ readonly failureReason: "network-error";
63
+ readonly success: false;
64
+ readonly details?: never;
65
+ readonly runId?: never;
66
+ readonly httpStatus?: never;
67
+ }>;
@@ -0,0 +1,2 @@
1
+ export * from "./ci-greenlight.js";
2
+ export * from "./deploy.js";
@@ -0,0 +1,4 @@
1
+ export type ApiConfig = {
2
+ apiKey: string;
3
+ serviceBase: string;
4
+ };
@@ -0,0 +1,28 @@
1
+ export interface JobDriver {
2
+ /**
3
+ * Called only when job completed and failed.
4
+ * **This method must exit the process or throw**.
5
+ */
6
+ fail(): Promise<never>;
7
+ /**
8
+ * Called anywhere the job cannot continue and has to
9
+ * be aborted. **This method must exit the process or throw**.
10
+ *
11
+ * The main difference with "fail" is its semantics. For
12
+ * jobs performing tests, "fail" is called when the job
13
+ * completed and the tests yield a negative result.
14
+ * "abort" is called when the job could not complete.
15
+ */
16
+ abort(): Promise<never>;
17
+ }
18
+ export interface LogDriver {
19
+ info(...messages: unknown[]): void;
20
+ warn(...messages: unknown[]): void;
21
+ error(...messages: unknown[]): void;
22
+ }
23
+ export type FetchDriver = typeof fetch;
24
+ export type SdkDependencies = {
25
+ job: JobDriver;
26
+ log: LogDriver;
27
+ fetch: FetchDriver;
28
+ };
@@ -0,0 +1,4 @@
1
+ import { DeployConfig } from "../../api/deploy.js";
2
+ import { SdkDependencies } from "../dependencies.js";
3
+ import { ApiConfig } from "../../api-types.js";
4
+ export declare function attemptDeploy(deps: SdkDependencies, apiConfig: ApiConfig, config: Partial<DeployConfig>): Promise<string>;
@@ -0,0 +1,17 @@
1
+ import { CiGreenlightStatus } from "../../api/ci-greenlight.js";
2
+ import { ApiConfig } from "../../api-types.js";
3
+ import { SdkDependencies } from "../dependencies.js";
4
+ export type PollConfig = {
5
+ maxRetries: number;
6
+ retryInterval: number;
7
+ runInProgressInterval: number;
8
+ runUnderReviewInterval: number;
9
+ };
10
+ export type PollCiGreenlightParams = {
11
+ runId: string;
12
+ /**
13
+ * Callback to be called when the lifecycle stage of the run changes.
14
+ */
15
+ onRunStageChanged?: (current: CiGreenlightStatus, previous: CiGreenlightStatus | undefined) => unknown | Promise<unknown>;
16
+ } & Partial<PollConfig>;
17
+ export declare function pollCiGreenlightStatus(deps: SdkDependencies, apiConfig: ApiConfig, { runId, onRunStageChanged: onRunStageChanged, ...pollConfig }: PollCiGreenlightParams): Promise<undefined>;
@@ -0,0 +1,8 @@
1
+ import { SdkDependencies } from "./dependencies.js";
2
+ export declare function makeQaWolfSdk({ apiKey, serviceBase, }: {
3
+ apiKey: string;
4
+ serviceBase?: string;
5
+ }, { job, log, fetch, }?: Partial<SdkDependencies>): {
6
+ attemptDeploy: (config: Partial<import("../api/deploy.js").DeployConfig>) => Promise<string>;
7
+ pollCiGreenlightStatus: (args_0: import("./domain/poll-ci-greenlight.js").PollCiGreenlightParams) => Promise<undefined>;
8
+ };
@@ -0,0 +1,2 @@
1
+ import { type JobDriver } from "../dependencies.js";
2
+ export declare const defaultJobDriver: JobDriver;
@@ -0,0 +1,2 @@
1
+ import { LogDriver } from "../dependencies.js";
2
+ export declare const defaultLogDriver: LogDriver;
@@ -0,0 +1,6 @@
1
+ export declare function sleep(ms: number): Promise<unknown>;
2
+ export declare function pluralize(count: number): "" | "s";
3
+ /**
4
+ * Statically verify that provided value is assignable to a desired type
5
+ */
6
+ export declare function assertType<Type>(_value: Type): void;
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@qawolf/ci-sdk",
3
+ "version": "0.10.0",
4
+ "description": "A simple SDK for interacting with QAWolf in CI scripts.",
5
+ "type": "module",
6
+ "private": false,
7
+ "exports": {
8
+ "require": "./dist/cjs/index.js",
9
+ "import": "./dist/esm/index.js",
10
+ "default": "./dist/esm/index.js",
11
+ "types": "./dist/types/index.d.ts"
12
+ },
13
+ "main": "cjs/index.js",
14
+ "scripts": {
15
+ "build": "./scripts/build.sh",
16
+ "test": "jest",
17
+ "prepare": "npm run build"
18
+ },
19
+ "keywords": [],
20
+ "author": "Jules Sam. Randolph<jules@qawolf.com>",
21
+ "license": "MIT",
22
+ "engines": {
23
+ "node": ">= 16"
24
+ },
25
+ "devDependencies": {
26
+ "@jest/globals": "^29.7.0",
27
+ "@tsconfig/strictest": "^2.0.5",
28
+ "@types/node": "^20.12.2",
29
+ "jest": "^29.7.0",
30
+ "ts-jest": "^29.1.2",
31
+ "ts-node": "^10.9.2",
32
+ "typescript": "^5.4.3"
33
+ }
34
+ }