Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/shared/package.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"name": "@azure-tools/specs-shared",
"private": "true",
Expand All @@ -14,6 +14,7 @@
"./readme": "./src/readme.js",
"./sdk-types": "./src/sdk-types.js",
"./sleep": "./src/sleep.js",
"./sort": "./src/sort.js",
"./spec-model-error": "./src/spec-model-error.js",
"./spec-model": "./src/spec-model.js",
"./swagger": "./src/swagger.js",
Expand Down
45 changes: 45 additions & 0 deletions .github/shared/src/sort.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @ts-check

/**
* Returns a comparator that compares values by a date string in ascending order.
* Throws if the value returned by getDate() is null, undefined, or cannot be
* parsed as a date.
*
* @template T
* @param {(item: T) => string} getDate
* @returns {(a: T, b: T) => number}
*/
export function byDate(getDate) {
return (a, b) => {
// Sort ascending to match JS default
return parseDate(getDate(a)) - parseDate(getDate(b));
};
}

/**
* Parses a string to a date, throwing if null, undefined, or cannot be parsed.
*
* @param {string} s
* @returns {number}
*/
function parseDate(s) {
// Date.parse() returns NaN for null, undefined, or strings that cannot be parsed.
const parsed = Date.parse(s);

if (Number.isNaN(parsed)) {
throw new Error(`Unable to parse '${s}' to a valid date`);
}

return parsed;
}

/**
* Inverts a comparator function.
*
* @template T
* @param {(a: T, b: T) => number} comparator
* @returns {(a: T, b: T) => number}
*/
export function invert(comparator) {
return (a, b) => -comparator(a, b);
}
35 changes: 35 additions & 0 deletions .github/shared/test/sort.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// @ts-check

import { describe, expect, it } from "vitest";
import { byDate, invert } from "../src/sort.js";

describe("byDate", () => {
const input = [{ foo: "2025-01-01" }, { foo: "2023-01-01" }, { foo: "2024-01-01" }];

it("ascending by default", () => {
input.sort(byDate((s) => s.foo));

// Value `undefined` always sorts to the end
expect(input).toEqual([{ foo: "2023-01-01" }, { foo: "2024-01-01" }, { foo: "2025-01-01" }]);
});

it("descending with invert()", () => {
input.sort(invert(byDate((s) => s.foo)));

// Value `undefined` always sorts to the end
expect(input).toEqual([{ foo: "2025-01-01" }, { foo: "2024-01-01" }, { foo: "2023-01-01" }]);
});

it.each([null, undefined, "invalid"])("invalid input: %s", (i) => {
/** @type {{foo: string | null | undefined}[]} */
const input = [{ foo: "2025-01-01" }, { foo: "2024-01-01" }];
const comparator = byDate((i) => i.foo);

// Ensure base case doesn't throw
input.sort(comparator);
expect(input).toEqual([{ foo: "2024-01-01" }, { foo: "2025-01-01" }]);

input[0].foo = i;
expect(() => input.sort(comparator)).toThrowError(`Unable to parse '${i}' to a valid date`);
});
});
5 changes: 4 additions & 1 deletion .github/workflows/_reusable-verify-run-status.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Prefix with "~" to sort last in Actions list
name: ~Templates - Verify Run Status

Expand All @@ -8,9 +8,11 @@
description: Name of the check run to verify
required: true
type: string
commit_status_name:
description: Name of the commit status to verify
type: string
workflow_name:
description: Name of the workflow to verify
required: true
type: string

permissions:
Expand Down Expand Up @@ -41,4 +43,5 @@
return await verifyRunStatus({ github, context, core });
env:
CHECK_RUN_NAME: ${{ inputs.check_run_name }}
COMMIT_STATUS_NAME: ${{ inputs.commit_status_name }}
WORKFLOW_NAME: ${{ inputs.workflow_name }}
87 changes: 87 additions & 0 deletions .github/workflows/src/github.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
// @ts-check

import { byDate, invert } from "../../shared/src/sort.js";

/**
* @typedef {import('@octokit/plugin-rest-endpoint-methods').RestEndpointMethodTypes} RestEndpointMethodTypes
* @typedef {RestEndpointMethodTypes["checks"]["listForRef"]["response"]["data"]["check_runs"]} CheckRuns
* @typedef {RestEndpointMethodTypes["actions"]["listWorkflowRunsForRepo"]["response"]["data"]["workflow_runs"]} WorkflowRuns
* @typedef {RestEndpointMethodTypes["repos"]["listCommitStatusesForRef"]["response"]["data"]} CommitStatuses
*/

export const PER_PAGE_MAX = 100;

/**
Expand Down Expand Up @@ -133,3 +143,80 @@
throw new Error(`Failed to write to the GitHub Actions summary: ${error}`);
}
}

/**
* Returns the check with the given checkRunName for the given ref.
* @param {import('github-script').AsyncFunctionArguments['github']} github
* @param {import('github-script').AsyncFunctionArguments['context']} context
* @param {string} checkRunName
* @param {string} ref
* @returns {Promise<CheckRuns>}
*/
export async function getCheckRuns(github, context, checkRunName, ref) {
const result = await github.paginate(github.rest.checks.listForRef, {
...context.repo,
ref: ref,
check_name: checkRunName,
status: "completed",
per_page: PER_PAGE_MAX,
});

/* v8 ignore next */
return result.sort(
invert(
byDate((run) => {
if (run.completed_at === null) {
// completed_at should never be null because status is "completed"
throw new Error(`Unexpected value of run.completed_at: '${run.completed_at}'`);
} else {
return run.completed_at;
}
}),
),
);
}

/**
* Returns the check with the given checkRunName for the given ref.
* @param {import('github-script').AsyncFunctionArguments['github']} github
* @param {import('github-script').AsyncFunctionArguments['context']} context
* @param {string} commitStatusName
* @param {string} ref
* @returns {Promise<CommitStatuses>}
*/
export async function getCommitStatuses(github, context, commitStatusName, ref) {
const result = await github.paginate(github.rest.repos.listCommitStatusesForRef, {
...context.repo,
ref: ref,
per_page: PER_PAGE_MAX,
});

return result
.filter(
(status) =>
// Property "context" is case-insensitive
status.context.toLowerCase() === commitStatusName.toLowerCase(),
)
.sort(invert(byDate((status) => status.updated_at)));
}

/**
* Returns the workflow run with the given workflowName for the given ref.
* @param {import('github-script').AsyncFunctionArguments['github']} github
* @param {import('github-script').AsyncFunctionArguments['context']} context
* @param {string} workflowName
* @param {string} ref
* @returns {Promise<WorkflowRuns>}
*/
export async function getWorkflowRuns(github, context, workflowName, ref) {
const result = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
...context.repo,
head_sha: ref,
status: "completed",
per_page: PER_PAGE_MAX,
});

return result
.filter((run) => run.name === workflowName)
.sort(invert(byDate((run) => run.updated_at)));
}
Loading