218 lines
7.9 KiB
TypeScript
218 lines
7.9 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import * as core from '@actions/core';
|
|
import { Octokit, RestEndpointMethodTypes } from '@octokit/rest';
|
|
import { WebClient } from '@slack/web-api';
|
|
import * as storage from 'azure-storage';
|
|
import { WritableStreamBuffer } from 'stream-buffers';
|
|
|
|
(async () => {
|
|
const actionUrl = core.getInput('workflow_run_url');
|
|
const url = actionUrl || 'https://api.github.com/repos/microsoft/vscode/actions/runs/503514090';
|
|
console.log(url);
|
|
const parts = url.split('/');
|
|
const owner = parts[parts.length - 5];
|
|
const repo = parts[parts.length - 4];
|
|
const runId = parseInt(parts[parts.length - 1], 10);
|
|
if (actionUrl) {
|
|
await handleNotification(owner, repo, runId);
|
|
} else {
|
|
const results = await buildComplete(owner, repo, runId);
|
|
for (const message of [...results.logMessages, ...results.messages]) {
|
|
console.log(message);
|
|
}
|
|
}
|
|
})()
|
|
.then(null, console.error);
|
|
|
|
const testChannels = ['bot-log', 'bot-test-log'];
|
|
|
|
async function handleNotification(owner: string, repo: string, runId: number) {
|
|
|
|
const results = await buildComplete(owner, repo, runId);
|
|
if (results.logMessages.length || results.messages.length) {
|
|
|
|
const web = new WebClient(process.env.SLACK_TOKEN);
|
|
const memberships = await listAllMemberships(web);
|
|
const memberTestChannels = memberships.filter(m => testChannels.indexOf(m.name) !== -1);
|
|
|
|
for (const message of results.logMessages) {
|
|
for (const testChannel of memberTestChannels) {
|
|
await web.chat.postMessage({
|
|
text: message,
|
|
link_names: true,
|
|
channel: testChannel.id,
|
|
});
|
|
}
|
|
}
|
|
for (const message of results.messages) {
|
|
for (const channel of memberships) {
|
|
await web.chat.postMessage({
|
|
text: message,
|
|
link_names: true,
|
|
channel: channel.id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function buildComplete(owner: string, repo: string, runId: number) {
|
|
console.log(`buildComplete: https://github.com/${owner}/${repo}/actions/runs/${runId}`);
|
|
const auth = `token ${process.env.GITHUB_TOKEN}`;
|
|
const octokit = new Octokit({ auth });
|
|
const buildResult = (await octokit.actions.getWorkflowRun({
|
|
owner,
|
|
repo,
|
|
run_id: runId,
|
|
})).data;
|
|
if (buildResult.head_branch !== 'master' && !buildResult.head_branch?.startsWith('release/')) {
|
|
console.error('Private branch. Terminating.')
|
|
return { logMessages: [], messages: [] };
|
|
}
|
|
|
|
// const buildQuery = `${buildsApiUrl}?$top=10&maxTime=${buildResult.finishTime}&definitions=${buildResult.definition.id}&branchName=${buildResult.sourceBranch}&resultFilter=${results.join(',')}&api-version=5.0-preview.4`;
|
|
|
|
const buildResults = (await octokit.actions.listWorkflowRuns({
|
|
owner,
|
|
repo,
|
|
workflow_id: buildResult.workflow_id,
|
|
branch: buildResult.head_branch || undefined,
|
|
per_page: 5, // More returns 502s.
|
|
})).data.workflow_runs
|
|
.filter(run => run.status === 'completed');
|
|
|
|
const currentBuildIndex = buildResults.findIndex(build => build.id === buildResult.id);
|
|
if (currentBuildIndex === -1) {
|
|
console.error('Build not on first page. Terminating.')
|
|
console.error(buildResults.map(({ id, status, conclusion }) => ({ id, status, conclusion })));
|
|
return { logMessages: [], messages: [] };
|
|
}
|
|
const slicedResults = buildResults.slice(currentBuildIndex, currentBuildIndex + 2);
|
|
const builds = slicedResults
|
|
.map<Build>((build, i, array) => ({
|
|
data: build,
|
|
previousSourceVersion: i < array.length - 1 ? array[i + 1].head_sha : undefined,
|
|
authors: [],
|
|
buildHtmlUrl: build.html_url,
|
|
changesHtmlUrl: '',
|
|
}));
|
|
const logMessages = builds.slice(0, 1)
|
|
.map(build => `Id: ${build.data.id} | Branch: ${build.data.head_branch} | Conclusion: ${build.data.conclusion} | Created: ${build.data.created_at} | Updated: ${build.data.updated_at}`);
|
|
const transitionedBuilds = builds.filter((build, i, array) => i < array.length - 1 && transitioned(build, array[i + 1]));
|
|
await Promise.all(transitionedBuilds
|
|
.map(async build => {
|
|
if (build.previousSourceVersion) {
|
|
const cmp = await compareCommits(octokit, owner, repo, build.previousSourceVersion, build.data.head_sha);
|
|
const commits = cmp.data.commits;
|
|
const authors = new Set<string>([
|
|
...commits.map((c: any) => c.author.login),
|
|
...commits.map((c: any) => c.committer.login),
|
|
]);
|
|
authors.delete('web-flow'); // GitHub Web UI committer
|
|
build.authors = [...authors];
|
|
build.changesHtmlUrl = `https://github.com/${owner}/${repo}/compare/${build.previousSourceVersion.substr(0, 7)}...${build.data.head_sha.substr(0, 7)}`; // Shorter than: cmp.data.html_url
|
|
}
|
|
}));
|
|
const vscode = repo === 'vscode';
|
|
const name = vscode ? `VS Code ${buildResult.name} Build` : buildResult.name;
|
|
// TBD: `Requester: ${vstsToSlackUser(build.requester, build.degraded)}${pingBenForSmokeTests && releaseBuild && build.result === 'partiallySucceeded' ? ' | Ping: @bpasero' : ''}`
|
|
const accounts = await readAccounts();
|
|
const githubAccountMap = githubToAccounts(accounts);
|
|
const messages = transitionedBuilds.map(build => `${name}
|
|
Result: ${build.data.conclusion} | Branch: ${build.data.head_branch} | Authors: ${githubToSlackUsers(githubAccountMap, build.authors, build.degraded).sort().join(', ') || `None (rebuild)`}
|
|
Build: ${build.buildHtmlUrl}
|
|
Changes: ${build.changesHtmlUrl}`);
|
|
return { logMessages, messages };
|
|
}
|
|
|
|
const conclusions = ['success', 'failure']
|
|
|
|
function transitioned(newer: Build, older: Build) {
|
|
const newerResult = newer.data.conclusion || 'success';
|
|
const olderResult = older.data.conclusion || 'success';
|
|
if (newerResult === olderResult) {
|
|
return false;
|
|
}
|
|
if (conclusions.indexOf(newerResult) > conclusions.indexOf(olderResult)) {
|
|
newer.degraded = true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function compareCommits(octokit: Octokit, owner: string, repo: string, base: string, head: string) {
|
|
return octokit.repos.compareCommits({ owner, repo, base, head });
|
|
}
|
|
|
|
function githubToSlackUsers(githubToAccounts: Record<string, Accounts>, githubUsers: string[], at?: boolean) {
|
|
return githubUsers.map(g => githubToAccounts[g] ? `${at ? '@' : ''}${githubToAccounts[g].slack}` : g);
|
|
}
|
|
|
|
interface Accounts {
|
|
github: string;
|
|
slack: string;
|
|
vsts: string;
|
|
}
|
|
|
|
function githubToAccounts(accounts: Accounts[]) {
|
|
return accounts.reduce((m, e) => {
|
|
m[e.github] = e;
|
|
return m;
|
|
}, <Record<string, Accounts>>{});
|
|
}
|
|
|
|
async function readAccounts() {
|
|
const connectionString = process.env.BUILD_CHAT_STORAGE_CONNECTION_STRING;
|
|
if (!connectionString) {
|
|
console.error('Connection string missing.');
|
|
return [];
|
|
}
|
|
const buf = await readFile(connectionString, 'config', '/', 'accounts.json');
|
|
return JSON.parse(buf.toString()) as Accounts[];
|
|
}
|
|
|
|
async function readFile(connectionString: string, share: string, directory: string, filename: string) {
|
|
return new Promise<Buffer>((resolve, reject) => {
|
|
const stream = new WritableStreamBuffer()
|
|
const fileService = storage.createFileService(connectionString);
|
|
fileService.getFileToStream(share, directory, filename, stream, err => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
const contents = stream.getContents();
|
|
if (contents) {
|
|
resolve(contents);
|
|
} else {
|
|
reject(new Error('No content'));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
interface AllChannels {
|
|
channels: {
|
|
id: string;
|
|
name: string;
|
|
is_member: boolean;
|
|
}[];
|
|
}
|
|
|
|
async function listAllMemberships(web: WebClient) {
|
|
const groups = await web.conversations.list({ types: 'public_channel,private_channel' }) as unknown as AllChannels;
|
|
return groups.channels
|
|
.filter(c => c.is_member);
|
|
}
|
|
|
|
interface Build {
|
|
data: RestEndpointMethodTypes['actions']['getWorkflowRun']['response']['data'];
|
|
previousSourceVersion: string | undefined;
|
|
authors: string[];
|
|
buildHtmlUrl: string;
|
|
changesHtmlUrl: string;
|
|
degraded?: boolean;
|
|
}
|