code:
<?php
/*
 *	Github webhook In-game PR Announcer and Changelog Generator for /tg/Station13
 *	Author: MrStonedOne
 *	For documentation on the changelog generator see https://tgstation13.org/phpBB/viewtopic.php?f=5&t=5157
 *	To hide prs from being announced in game, place a [s] in front of the title
 *	All runtime errors are echo'ed to the webhook's logs in github
 *  Events to be sent via GitHub webhook: Pull Requests, Pushes
 *  Any other Event will result in a 404 returned to the webhook.
 */

/**CREDITS:
 * GitHub webhook handler template.
 *
 * @see  https://developer.github.com/webhooks/
 * @author  Miloslav Hula (https://github.com/milo)
 */

define('S_LINK_EMBED', 1<<0);
define('S_MENTIONS', 1<<1);
define('S_MARKDOWN', 1<<2);
define('S_HTML_COMMENTS', 1<<3);

define('F_UNVALIDATED_USER', 1<<0);
define('F_SECRET_PR', 1<<1);

//CONFIGS ARE IN SECRET.PHP, THESE ARE JUST DEFAULTS!

$hookSecret = '08ajh0qj93209qj90jfq932j32r';
$apiKey = '209ab8d879c0f987d06a09b9d879c0f987d06a09b9d8787d0a089c';
$repoOwnerAndName = "tgstation/tgstation";
$servers = array();
$enable_live_tracking = true;
$path_to_script = 'tools/WebhookProcessor/github_webhook_processor.php';
$tracked_branch = "master";
$maintainer_team_id = 133041;
$validation = "org";
$validation_count = 1;
$tracked_branch = 'master';
$require_changelogs = false;
$discordWebHooks = array();

require_once 'secret.php';

//CONFIG END
function log_error($msg) {
	echo htmlSpecialChars($msg);
	file_put_contents('htwebhookerror.log', '['.date(DATE_ATOM).'] '.$msg.PHP_EOL, FILE_APPEND);
}
set_error_handler(function($severity, $message, $file, $line) {
	throw new \ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(function($e) {
	header('HTTP/1.1 500 Internal Server Error');
	log_error('Error on line ' . $e->getLine() . ': ' . $e->getMessage());
	die();
});
$rawPost = NULL;
if (!$hookSecret || $hookSecret == '08ajh0qj93209qj90jfq932j32r')
	throw new \Exception("Hook secret is required and can not be default");
if (!isset($_SERVER['HTTP_X_HUB_SIGNATURE'])) {
	throw new \Exception("HTTP header 'X-Hub-Signature' is missing.");
} elseif (!extension_loaded('hash')) {
	throw new \Exception("Missing 'hash' extension to check the secret code validity.");
}
list($algo, $hash) = explode('=', $_SERVER['HTTP_X_HUB_SIGNATURE'], 2) + array('', '');
if (!in_array($algo, hash_algos(), TRUE)) {
	throw new \Exception("Hash algorithm '$algo' is not supported.");
}
$rawPost = file_get_contents('php://input');
if ($hash !== hash_hmac($algo, $rawPost, $hookSecret)) {
	throw new \Exception('Hook secret does not match.');
}

$contenttype = null;
//apache and nginx/fastcgi/phpfpm call this two different things.
if (!isset($_SERVER['HTTP_CONTENT_TYPE'])) {
	if (!isset($_SERVER['CONTENT_TYPE']))
		throw new \Exception("Missing HTTP 'Content-Type' header.");
	else
		$contenttype = $_SERVER['CONTENT_TYPE'];
} else {
	$contenttype = $_SERVER['HTTP_CONTENT_TYPE'];
}
if (!isset($_SERVER['HTTP_X_GITHUB_EVENT'])) {
	throw new \Exception("Missing HTTP 'X-Github-Event' header.");
}
switch ($contenttype) {
	case 'application/json':
		$json = $rawPost ?: file_get_contents('php://input');
		break;
	case 'application/x-www-form-urlencoded':
		$json = $_POST['payload'];
		break;
	default:
		throw new \Exception("Unsupported content type: $contenttype");
}
# Payload structure depends on triggered event
# https://developer.github.com/v3/activity/events/types/
$payload = json_decode($json, true);

switch (strtolower($_SERVER['HTTP_X_GITHUB_EVENT'])) {
	case 'ping':
		echo 'pong';
		break;
	case 'pull_request':
		handle_pr($payload);
		break;
	case 'pull_request_review':
		if($payload['action'] == 'submitted'){
			$lower_state = strtolower($payload['review']['state']);
			if(($lower_state == 'approved' || $lower_state == 'changes_requested') && is_maintainer($payload, $payload['review']['user']['login']))
				remove_ready_for_review($payload);
		}
		break;
	default:
		header('HTTP/1.0 404 Not Found');
		echo "Event:$_SERVER[HTTP_X_GITHUB_EVENT] Payload:\n";
		print_r($payload); # For debug only. Can be found in GitHub hook log.
		die();
}

function apisend($url, $method = 'GET', $content = null, $authorization = null) {
	if (is_array($content))
		$content = json_encode($content);

	$headers = array();
	$headers[] = 'Content-type: application/json';
	if ($authorization)
		$headers[] = 'Authorization: ' . $authorization;

	$scontext = array('http' => array(
		'method'		=> $method,
		'header'		=> implode("\r\n", $headers),
		'ignore_errors' => true,
		'user_agent' 	=> 'tgstation13.org-Github-Automation-Tools'
	));

	if ($content)
		$scontext['http']['content'] = $content;

	return file_get_contents($url, false, stream_context_create($scontext));

}

function github_apisend($url, $method = 'GET', $content = NULL) {
	global $apiKey;
	return apisend($url, $method, $content, 'token ' . $apiKey);
}

function discord_webhook_send($webhook, $content) {
	return apisend($webhook, 'POST', $content);
}

function validate_user($payload) {
	global $validation, $validation_count;
	$query = array();
	if (empty($validation))
		$validation = 'org';
	switch (strtolower($validation)) {
		case 'disable':
			return TRUE;
		case 'repo':
			$query['repo'] = $payload['pull_request']['base']['repo']['full_name'];
			break;
		default:
			$query['user'] = $payload['pull_request']['base']['repo']['owner']['login'];
			break;
	}
	$query['author'] = $payload['pull_request']['user']['login'];
	$query['is'] = 'merged';
	$querystring = '';
	foreach($query as $key => $value)
		$querystring .= ($querystring == '' ? '' : '+') . urlencode($key) . ':' . urlencode($value);
	$res = github_apisend('https://api.github.com/search/issues?q='.$querystring);
	$res = json_decode($res, TRUE);
	return $res['total_count'] >= (int)$validation_count;

}

function get_labels($payload){
	$url = $payload['pull_request']['issue_url'] . '/labels';
	$existing_labels = json_decode(github_apisend($url), true);
	$existing = array();
	foreach((array) $existing_labels as $label)
		$existing[] = $label['name'];
	return $existing;
}

function check_tag_and_replace($payload, $title_tag, $label, &$array_to_add_label_to){
	$title = $payload['pull_request']['title'];
	if(stripos($title, $title_tag) !== FALSE){
		$array_to_add_label_to[] = $label;
		return true;
	}
	return false;
}

function set_labels($payload, $labels, $remove) {
	$existing = get_labels($payload);
	$tags = array();

	$tags = array_merge($labels, $existing);
	$tags = array_unique($tags);
	if($remove) {
		$tags = array_diff($tags, $remove);
	}

	$final = array();
	foreach($tags as $t)
		$final[] = $t;

	$url = $payload['pull_request']['issue_url'] . '/labels';
	echo github_apisend($url, 'PUT', $final);
}

//rip bs-12
function tag_pr($payload, $opened) {
	//get the mergeable state
	$url = $payload['pull_request']['url'];
	$payload['pull_request'] = json_decode(github_apisend($url), TRUE);
	if($payload['pull_request']['mergeable'] == null) {
		//STILL not ready. Give it a bit, then try one more time
		sleep(10);
		$payload['pull_request'] = json_decode(github_apisend($url), TRUE);
	}

	$tags = array();
	$title = $payload['pull_request']['title'];
	if($opened) {	//you only have one shot on these ones so as to not annoy maintainers
		$tags = checkchangelog($payload, false);

		if(strpos(strtolower($title), 'refactor') !== FALSE)
			$tags[] = 'Refactor';

		if(strpos(strtolower($title), 'revert') !== FALSE)
			$tags[] = 'Revert';
		if(strpos(strtolower($title), 'removes') !== FALSE)
			$tags[] = 'Removal';
	}

	$remove = array('Test Merge Candidate');

	$mergeable = $payload['pull_request']['mergeable'];
	if($mergeable === TRUE)	//only look for the false value
		$remove[] = 'Merge Conflict';
	else if ($mergeable === FALSE)
		$tags[] = 'Merge Conflict';

	$treetags = array('_maps' => 'Map Edit', 'tools' => 'Tools', 'SQL' => 'SQL', '.github' => 'GitHub');
	$addonlytags = array('icons' => 'Sprites', 'sound' => 'Sound', 'config' => 'Config Update', 'code/controllers/configuration/entries' => 'Config Update', 'tgui' => 'UI');
	foreach($treetags as $tree => $tag)
		if(has_tree_been_edited($payload, $tree))
			$tags[] = $tag;
		else
			$remove[] = $tag;
	foreach($addonlytags as $tree => $tag)
		if(has_tree_been_edited($payload, $tree))
			$tags[] = $tag;

	check_tag_and_replace($payload, '[dnm]', 'Do Not Merge', $tags);

	return array($tags, $remove);
}

function remove_ready_for_review($payload, $labels = null){
	if($labels == null)
		$labels = get_labels($payload);
	$index = array_search('Needs Review', $labels);
	if($index !== FALSE)
		unset($labels[$index]);
	$url = $payload['pull_request']['issue_url'] . '/labels';
	github_apisend($url, 'PUT', $labels);
}

function dismiss_review($payload, $id, $reason){
	$content = array('message' => $reason);
	github_apisend($payload['pull_request']['url'] . '/reviews/' . $id . '/dismissals', 'PUT', $content);
}

function get_reviews($payload){
	return json_decode(github_apisend($payload['pull_request']['url'] . '/reviews'), true);
}

function check_dismiss_changelog_review($payload){
	global $require_changelogs;
	global $no_changelog;

	if(!$require_changelogs)
		return;

	if(!$no_changelog)
		checkchangelog($payload, false);

	$review_message = 'Your changelog for this PR is either malformed or non-existent. Please create one to document your changes.';

	$reviews = get_reviews($payload);
	if($no_changelog){
		//check and see if we've already have this review
		foreach($reviews as $R)
			if($R['body'] == $review_message && strtolower($R['state']) == 'changes_requested')
				return;
		//otherwise make it ourself
		github_apisend($payload['pull_request']['url'] . '/reviews', 'POST', array('body' => $review_message, 'event' => 'REQUEST_CHANGES'));
	}
	else
		//kill previous reviews
		foreach($reviews as $R)
			if($R['body'] == $review_message && strtolower($R['state']) == 'changes_requested')
				dismiss_review($payload, $R['id'], 'Changelog added/fixed.');
}

function handle_pr($payload) {
	global $no_changelog;
	$action = 'opened';
	$validated = validate_user($payload);
	switch ($payload["action"]) {
		case 'opened':
			list($labels, $remove) = tag_pr($payload, true);
			set_labels($payload, $labels, $remove);
			if($no_changelog)
				check_dismiss_changelog_review($payload);
			break;
		case 'edited':
			check_dismiss_changelog_review($payload);
		case 'synchronize':
			list($labels, $remove) = tag_pr($payload, false);
			set_labels($payload, $labels, $remove);
			return;
		case 'reopened':
			$action = $payload['action'];
			break;
		case 'closed':
			if (!$payload['pull_request']['merged']) {
				$action = 'closed';
			}
			else {
				$action = 'merged';
				auto_update($payload);
				checkchangelog($payload, true);
				$validated = TRUE; //pr merged events always get announced.
			}
			break;
		default:
			return;
	}

	$pr_flags = 0;
	if (strpos(strtolower($payload['pull_request']['title']), '[s]') !== false) {
		$pr_flags |= F_SECRET_PR;
	}
	if (!$validated) {
		$pr_flags |= F_UNVALIDATED_USER;
	}
	discord_announce($action, $payload, $pr_flags);
	game_announce($action, $payload, $pr_flags);

}

function filter_announce_targets($targets, $owner, $repo, $action, $pr_flags) {
	foreach ($targets as $i=>$target) {
		if (isset($target['exclude_events']) && in_array($action, array_map('strtolower', $target['exclude_events']))) {
			unset($targets[$i]);
			continue;
		}

		if (isset($target['announce_secret']) && $target['announce_secret']) {
			if (!($pr_flags & F_SECRET_PR) && $target['announce_secret'] === 'only') {
				unset($targets[$i]);
				continue;
			}
		} else if ($pr_flags & F_SECRET_PR) {
			unset($targets[$i]);
			continue;
		}

		if (isset($target['announce_unvalidated']) && $target['announce_unvalidated']) {
			if (!($pr_flags & F_UNVALIDATED_USER) && $target['announce_unvalidated'] === 'only') {
				unset($targets[$i]);
				continue;
			}
		} else if ($pr_flags & F_UNVALIDATED_USER) {
			unset($targets[$i]);
			continue;
		}

		$wildcard = false;
		if (isset($target['include_repos'])) {
			foreach ($target['include_repos'] as $match_string) {
				$owner_repo_pair = explode('/', strtolower($match_string));
				if (count($owner_repo_pair) != 2) {
					log_error('Bad include repo: `'. $match_string.'`');
					continue;
				}
				if (strtolower($owner) == $owner_repo_pair[0]) {
					if (strtolower($repo) == $owner_repo_pair[1])
						continue 2; //don't parse excludes when we have an exact include match
					if ($owner_repo_pair[1] == '*') {
						$wildcard = true;
						continue; //do parse excludes when we have a wildcard match (but check the other entries for exact matches first)
					}
				}
			}
			if (!$wildcard) {
				unset($targets[$i]);
				continue;
			}
		}

		if (isset($target['exclude_repos']))
			foreach ($target['exclude_repos'] as $match_string) {
				$owner_repo_pair = explode('/', strtolower($match_string));
				if (count($owner_repo_pair) != 2) {
					log_error('Bad exclude repo: `'. $match_string.'`');
					continue;
				}
				if (strtolower($owner) == $owner_repo_pair[0]) {
					if (strtolower($repo) == $owner_repo_pair[1]) {
						unset($targets[$i]);
						continue 2;
					}
					if ($owner_repo_pair[1] == '*') {
						if ($wildcard)
							log_error('Identical wildcard include and exclude: `'.$match_string.'`. Excluding.');
						unset($targets[$i]);
						continue 2;
					}
				}
			}
	}
	return $targets;
}

function game_announce($action, $payload, $pr_flags) {
	global $servers;

	$msg = '['.$payload['pull_request']['base']['repo']['full_name'].'] Pull Request '.$action.' by '.htmlSpecialChars($payload['sender']['login']).': '.htmlSpecialChars('#'.$payload['pull_request']['number'].' '.$payload['pull_request']['user']['login'].' - '.$payload['pull_request']['title']).'';

	$game_servers = filter_announce_targets($servers, $payload['pull_request']['base']['repo']['owner']['login'], $payload['pull_request']['base']['repo']['name'], $action, $pr_flags);

	$msg = '?announce='.urlencode($msg).'&payload='.urlencode(json_encode($payload));

	foreach ($game_servers as $serverid => $server) {
		$server_message = $msg;
		if (isset($server['comskey']))
			$server_message .= '&key='.urlencode($server['comskey']);
		game_server_send($server['address'], $server['port'], $server_message);
	}

}

function discord_announce($action, $payload, $pr_flags) {
	global $discordWebHooks;
	$color;
	switch ($action) {
		case 'reopened':
		case 'opened':
			$color = 0x2cbe4e;
			break;
		case 'closed':
			$color = 0xcb2431;
			break;
		case 'merged':
			$color = 0x6f42c1;
			break;
		default:
			return;
	}
	$data = array(
		'username' => 'GitHub',
		'avatar_url' => $payload['pull_request']['base']['user']['avatar_url'],
	);

	$content = 'Pull Request #'.$payload['pull_request']['number'].' *'.$action.'* by '.discord_sanitize($payload['sender']['login'])."\n".discord_sanitize($payload['pull_request']['user']['login']).' - __**'.discord_sanitize($payload['pull_request']['title']).'**__'."\n".'<'.$payload['pull_request']['html_url'].'>';

	$embeds = array(
			array(
				'title' => '__**'.discord_sanitize($payload['pull_request']['title'], S_MARKDOWN).'**__',
				'description' => discord_sanitize(str_replace(array("\r\n", "\n"), array(' ', ' '), substr($payload['pull_request']['body'], 0, 320)), S_HTML_COMMENTS),
				'url' => $payload['pull_request']['html_url'],
				'color' => $color,
				'author' => array(
					'name' => discord_sanitize($payload['pull_request']['user']['login'], S_MARKDOWN),
					'url' => $payload['pull_request']['user']['html_url'],
					'icon_url' => $payload['pull_request']['user']['avatar_url']
				),
				'footer' => array(
					'text' => '#'.$payload['pull_request']['number'].' '.discord_sanitize($payload['pull_request']['base']['repo']['full_name'], S_MARKDOWN).' '.discord_sanitize($payload['pull_request']['head']['ref'], S_MARKDOWN).' -> '.discord_sanitize($payload['pull_request']['base']['ref'], S_MARKDOWN),
					'icon_url' => $payload['pull_request']['base']['user']['avatar_url']
				)
			)
	);
	$discordWebHook_targets = filter_announce_targets($discordWebHooks, $payload['pull_request']['base']['repo']['owner']['login'], $payload['pull_request']['base']['repo']['name'], $action, $pr_flags);
	foreach ($discordWebHook_targets as $discordWebHook) {
		$sending_data = $data;
		if (isset($discordWebHook['embed']) && $discordWebHook['embed']) {
			$sending_data['embeds'] = $embeds;
			if (!isset($discordWebHook['no_text']) || !$discordWebHook['no_text'])
				$sending_data['content'] = $content;
		} else {
			$sending_data['content'] = $content;
		}
		discord_webhook_send($discordWebHook['url'], $sending_data);
	}

}

function discord_sanitize($text, $flags = S_MENTIONS|S_LINK_EMBED|S_MARKDOWN) {
	if ($flags & S_MARKDOWN)
		$text = str_ireplace(array('\\', '*', '_', '~', '`', '|'), (array('\\\\', '\\*', '\\_', '\\~', '\\`', '\\|')), $text);

	if ($flags & S_HTML_COMMENTS)
		$text = preg_replace('//Uis', '', $text);

	if ($flags & S_MENTIONS)
		$text = str_ireplace(array('@everyone', '@here', '<@'), array('`@everyone`', '`@here`', '@<'), $text);

	if ($flags & S_LINK_EMBED)
		$text = preg_replace("/((https?|ftp|byond)\:\/\/)([a-z0-9-.]*)\.([a-z]{2,3})(\:[0-9]{2,5})?(\/(?:[a-z0-9+\$_-]\.?)+)*\/?(\?[a-z+&\$_.-][a-z0-9;:@&%=+\/\$_.-]*)?(#[a-z_.-][a-z0-9+\$_.-]*)?/mi", '<$0>', $text);

	return $text;
}

//creates a comment on the payload issue
function create_comment($payload, $comment){
	github_apisend($payload['pull_request']['comments_url'], 'POST', json_encode(array('body' => $comment)));
}

//returns the payload issue's labels as a flat array
function get_pr_labels_array($payload){
	$url = $payload['pull_request']['issue_url'] . '/labels';
	$issue = json_decode(github_apisend($url), true);
	$result = array();
	foreach($issue as $l)
		$result[] = $l['name'];
	return $result;
}

function is_maintainer($payload, $author){
	global $maintainer_team_id;
	$repo_is_org = $payload['pull_request']['base']['repo']['owner']['type'] == 'Organization';
	if($maintainer_team_id == null || !$repo_is_org) {
		$collaburl = str_replace('{/collaborator}', '/' . $author, $payload['pull_request']['base']['repo']['collaborators_url']) . '/permission';
		$perms = json_decode(github_apisend($collaburl), true);
		$permlevel = $perms['permission'];
		return $permlevel == 'admin' || $permlevel == 'write';
	}
	else {
		$check_url = 'https://api.github.com/teams/' . $maintainer_team_id . '/memberships/' . $author;
		$result = json_decode(github_apisend($check_url), true);
		return isset($result['state']) && $result['state'] == 'active';
	}
}

$github_diff = null;

function get_diff($payload) {
	global $github_diff;
	if ($github_diff === null && $payload['pull_request']['diff_url']) {
		//go to the diff url
		$url = $payload['pull_request']['diff_url'];
		$github_diff = file_get_contents($url);
	}
	return $github_diff;
}

function auto_update($payload){
	global $enable_live_tracking;
	global $path_to_script;
	global $repoOwnerAndName;
	global $tracked_branch;
	global $github_diff;
	if(!$enable_live_tracking || !has_tree_been_edited($payload, $path_to_script) || $payload['pull_request']['base']['ref'] != $tracked_branch)
		return;

	get_diff($payload);
	$content = file_get_contents('https://raw.githubusercontent.com/' . $repoOwnerAndName . '/' . $tracked_branch . '/'. $path_to_script);
	$content_diff = "### Diff not available. :slightly_frowning_face:";
	if($github_diff && preg_match('/(diff --git a\/' . preg_quote($path_to_script, '/') . '.+?)(?:\Rdiff|$)/s', $github_diff, $matches)) {
		$script_diff = $matches[1];
		if($script_diff) {
			$content_diff = "``" . "`DIFF\n" . $script_diff ."\n``" . "`";
		}
	}
	create_comment($payload, "Edit detected. Self updating... \nHere are my changes:\n\n" . $content_diff . "\n\nHere is my new code:\n\n``" . "`HTML+PHP\n" . $content . "\n``" . '`\n');

	$code_file = fopen(basename($path_to_script), 'w');
	fwrite($code_file, $content);
	fclose($code_file);
}

function has_tree_been_edited($payload, $tree){
	global $github_diff;
	get_diff($payload);
	//find things in the _maps/map_files tree
	//e.g. diff --git a/_maps/map_files/Cerestation/cerestation.dmm b/_maps/map_files/Cerestation/cerestation.dmm
	return ($github_diff !== FALSE) && (preg_match('/^diff --git a\/' . preg_quote($tree, '/') . '/m', $github_diff) !== 0);
}

$no_changelog = false;
function checkchangelog($payload, $compile = true) {
	global $no_changelog;
	if (!isset($payload['pull_request']) || !isset($payload['pull_request']['body'])) {
		return;
	}
	if (!isset($payload['pull_request']['user']) || !isset($payload['pull_request']['user']['login'])) {
		return;
	}
	$body = $payload['pull_request']['body'];

	$tags = array();

	if(preg_match('/(?i)(fix|fixes|fixed|resolve|resolves|resolved)\s*#[0-9]+/',$body))	//github autoclose syntax
		$tags[] = 'Fix';

	$body = str_replace("\r\n", "\n", $body);
	$body = explode("\n", $body);

	$username = $payload['pull_request']['user']['login'];
	$incltag = false;
	$changelogbody = array();
	$currentchangelogblock = array();
	$foundcltag = false;
	foreach ($body as $line) {
		$line = trim($line);
		if (substr($line,0,4) == ':cl:' || substr($line,0,1) == '??') {
			$incltag = true;
			$foundcltag = true;
			$pos = strpos($line, " ");
			if ($pos) {
				$tmp = substr($line, $pos+1);
				if (trim($tmp) != 'optional name here')
					$username = $tmp;
			}
			continue;
		} else if (substr($line,0,5) == '/:cl:' || substr($line,0,6) == '/ :cl:' || substr($line,0,5) == ':/cl:' || substr($line,0,5) == '/??' || substr($line,0,6) == '/ ??' ) {
			$incltag = false;
			$changelogbody = array_merge($changelogbody, $currentchangelogblock);
			continue;
		}
		if (!$incltag)
			continue;

		$firstword = explode(' ', $line)[0];
		$pos = strpos($line, " ");
		$item = '';
		if ($pos) {
			$firstword = trim(substr($line, 0, $pos));
			$item = trim(substr($line, $pos+1));
		} else {
			$firstword = $line;
		}

		if (!strlen($firstword)) {
			if (count($currentchangelogblock) <= 0)
				continue;
			$currentchangelogblock[count($currentchangelogblock)-1]['body'] .= "\n";
			continue;
		}
		//not a prefix line.
		//so we add it to the last changelog entry as a separate line
		if (!strlen($firstword) || $firstword[strlen($firstword)-1] != ':') {
			if (count($currentchangelogblock) <= 0)
				continue;
			$currentchangelogblock[count($currentchangelogblock)-1]['body'] .= "\n".$line;
			continue;
		}
		$cltype = strtolower(substr($firstword, 0, -1));
		switch ($cltype) {
			case 'fix':
			case 'fixes':
			case 'bugfix':
				if($item != 'fixed a few things') {
					$tags[] = 'Fix';
					$currentchangelogblock[] = array('type' => 'bugfix', 'body' => $item);
				}
				break;
			case 'qol':
				if($item != 'made something easier to use') {
					$tags[] = 'Quality of Life';
					$currentchangelogblock[] = array('type' => 'qol', 'body' => $item);
				}
				break;
			case 'soundadd':
				if($item != 'added a new sound thingy') {
					$tags[] = 'Sound';
					$currentchangelogblock[] = array('type' => 'soundadd', 'body' => $item);
				}
				break;
			case 'sounddel':
				if($item != 'removed an old sound thingy') {
					$tags[] = 'Sound';
					$tags[] = 'Removal';
					$currentchangelogblock[] = array('type' => 'sounddel', 'body' => $item);
				}
				break;
			case 'add':
			case 'adds':
			case 'rscadd':
				if($item != 'Added new mechanics or gameplay changes' && $item != 'Added more things') {
					$tags[] = 'Mechanic';
					$currentchangelogblock[] = array('type' => 'rscadd', 'body' => $item);
				}
				break;
			case 'del':
			case 'dels':
			case 'rscdel':
				if($item != 'Removed old things') {
					$tags[] = 'Removal';
					$currentchangelogblock[] = array('type' => 'rscdel', 'body' => $item);
				}
				break;
			case 'imageadd':
				if($item != 'added some icons and images') {
					$tags[] = 'Sprites';
					$currentchangelogblock[] = array('type' => 'imageadd', 'body' => $item);
				}
				break;
			case 'imagedel':
				if($item != 'deleted some icons and images') {
					$tags[] = 'Sprites';
					$tags[] = 'Removal';
					$currentchangelogblock[] = array('type' => 'imagedel', 'body' => $item);
				}
				break;
			case 'typo':
			case 'spellcheck':
				if($item != 'fixed a few typos') {
					$tags[] = 'Grammar and Formatting';
					$currentchangelogblock[] = array('type' => 'spellcheck', 'body' => $item);
				}
				break;
			case 'balance':
			case 'rebalance':
				if($item != 'rebalanced something'){
					$tags[] = 'Balance/Rebalance';
					$currentchangelogblock[] = array('type' => 'balance', 'body' => $item);
				}
				break;
			case 'code_imp':
			case 'code':
				if($item != 'changed some code'){
					$tags[] = 'Code Improvement';
					$currentchangelogblock[] = array('type' => 'code_imp', 'body' => $item);
				}
				break;
			case 'expansion':
				if($item != 'Expands content of an existing feature'){
					$tags[] = 'Content Expansion';
					$currentchangelogblock[] = array('type' => 'expansion', 'body' => $item);
				}
				break;
			case 'refactor':
				if($item != 'refactored some code'){
					$tags[] = 'Refactor';
					$currentchangelogblock[] = array('type' => 'refactor', 'body' => $item);
				}
				break;
			case 'config':
				if($item != 'changed some config setting'){
					$tags[] = 'Config Update';
					$currentchangelogblock[] = array('type' => 'config', 'body' => $item);
				}
				break;
			case 'admin':
				if($item != 'messed with admin stuff'){
					$tags[] = 'Administration';
					$currentchangelogblock[] = array('type' => 'admin', 'body' => $item);
				}
				break;
			case 'server':
				if($item != 'something server ops should know')
					$currentchangelogblock[] = array('type' => 'server', 'body' => $item);
				break;
			default:
				//we add it to the last changelog entry as a separate line
				if (count($currentchangelogblock) > 0)
					$currentchangelogblock[count($currentchangelogblock)-1]['body'] .= "\n".$line;
				break;
		}
	}

	if(!count($changelogbody))
		$no_changelog = true;

	if ($no_changelog || !$compile)
		return $tags;

	$file = 'author: "'.trim(str_replace(array("\\", '"'), array("\\\\", "\\\""), $username)).'"'."\n";
	$file .= "delete-after: True\n";
	$file .= "changes: \n";
	foreach ($changelogbody as $changelogitem) {
		$type = $changelogitem['type'];
		$body = trim(str_replace(array("\\", '"'), array("\\\\", "\\\""), $changelogitem['body']));
		$file .= '  - '.$type.': "'.$body.'"';
		$file .= "\n";
	}
	$content = array (
		'branch' 	=> $payload['pull_request']['base']['ref'],
		'message' 	=> 'Automatic changelog generation for PR #'.$payload['pull_request']['number'].' [ci skip]',
		'content' 	=> base64_encode($file)
	);

	$filename = '/html/changelogs/AutoChangeLog-pr-'.$payload['pull_request']['number'].'.yml';
	echo github_apisend($payload['pull_request']['base']['repo']['url'].'/contents'.$filename, 'PUT', $content);
}

function game_server_send($addr, $port, $str) {
	// All queries must begin with a question mark (ie "?players")
	if($str[0] != '?') $str = ('?' . $str);

	/* --- Prepare a packet to send to the server (based on a reverse-engineered packet structure) --- */
	$query = "\x00\x83" . pack('n', strlen($str) + 6) . "\x00\x00\x00\x00\x00" . $str . "\x00";

	/* --- Create a socket and connect it to the server --- */
	$server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP) or exit("ERROR");
	socket_set_option($server, SOL_SOCKET, SO_SNDTIMEO, array('sec' => 2, 'usec' => 0)); //sets connect and send timeout to 2 seconds
	if(!socket_connect($server,$addr,$port)) {
		return "ERROR: Connection failed";
	}

	/* --- Send bytes to the server. Loop until all bytes have been sent --- */
	$bytestosend = strlen($query);
	$bytessent = 0;
	while ($bytessent < $bytestosend) {
		//echo $bytessent.'
';
		$result = socket_write($server,substr($query,$bytessent),$bytestosend-$bytessent);
		//echo 'Sent '.$result.' bytes
';
		if ($result===FALSE)
			return "ERROR: " . socket_strerror(socket_last_error());
		$bytessent += $result;
	}

	/* --- Idle for a while until recieved bytes from game server --- */
	$result = socket_read($server, 10000, PHP_BINARY_READ);
	socket_close($server); // we don't need this anymore

	if($result != "") {
		if($result[0] == "\x00" || $result[1] == "\x83") { // make sure it's the right packet format

			// Actually begin reading the output:
			$sizebytes = unpack('n', $result[2] . $result[3]); // array size of the type identifier and content
			$size = $sizebytes[1] - 1; // size of the string/floating-point (minus the size of the identifier byte)

			if($result[4] == "\x2a") { // 4-byte big-endian floating-point
				$unpackint = unpack('f', $result[5] . $result[6] . $result[7] . $result[8]); // 4 possible bytes: add them up together, unpack them as a floating-point
				return $unpackint[1];
			}
			else if($result[4] == "\x06") { // ASCII string
				$unpackstr = ""; // result string
				$index = 5; // string index

				while($size > 0) { // loop through the entire ASCII string
					$size--;
					$unpackstr .= $result[$index]; // add the string position to return string
					$index++;
				}
				return $unpackstr;
			}
		}
	}
	return "";
}
?>


code:
<?php
//This file contains things that should not be touched by the automatic live tracker

//Github lets you have it sign the message with a secret that you can validate. This prevents people from faking events.
//This var should match the secret you configured for this webhook on github.
//This is required as otherwise somebody could trick the script into leaking the api key.
$hookSecret = '08ajh0qj93209qj90jfq932j32r';

//Api key for pushing changelogs.
//This requires the public_repo (or repo for private repositories) and read:org permissions
$apiKey = '209ab8d879c0f987d06a09b9d879c0f987d06a09b9d8787d0a089c';

//Used to prevent potential RCEs
$repoOwnerAndName = "tgstation/tgstation";

//Auto update settings
$enable_live_tracking = true;	//auto update this file from the repository
$path_to_script = 'tools/WebhookProcessor/github_webhook_processor.php';
$tracked_branch = "master";

//PR balance settings.
$trackPRBalance = true;	//set this to false to disable PR balance tracking
$prBalanceJson = '';	//Set this to the path you'd like the writable pr balance file to be stored, not setting it writes it to the working directory
$startingPRBalance = 5;	//Starting balance for never before seen users
//team 133041: tgstation/commit-access
$maintainer_team_id = 133041;	//org team id that is exempt from PR balance system, setting this to null will use anyone with write access to the repo. Get from https://api.github.com/orgs/:org/teams

//anti-spam measures. Don't announce PRs in game to people unless they've gotten a pr merged before
//options are:
//	"repo" - user has to have a pr merged in the repo before.
//	"org" - user has to have a pr merged in any repo in the organization (for repos owned directly by users, this applies to any repo directly owned by the same user.)
//	"disable" - disables.
//defaults to org if left blank or given invalid values.
//This can also be ignored on a per webhook or per game server bases.
$validation = "org";

//how many merged prs must they have under the rules above to have their pr announced to the game servers.
$validation_count = 1;

//enforce changelogs on PRs
$require_changelogs = false;

/*
 * Announcement Settings.
 * 	Allows you to announce prs to discord webhooks or the game servers
 */

/* Common configs:
The following config items can be added to both game server and discord announcement endpoints. Simply replace the $servers part with $discordWebHooks:

include_repos - List of repos in owner/repo format to send to this endpoint. (defaults to all repos if not defined)
	* can be given in place of repo to match all repos under an organization
$servers[$configitem]['include_repos'][] = "tgstation/*";

exclude_repos - List of repos in owner/repo format to not send to this endpoint.
	* can be given in place of repo to match all repos under an organization
$servers[$configitem]['exclude_repos'][] = 'tgstation/TerraGov-Marine-Corps';
$servers[$configitem]['exclude_repos'][] = 'tgstation/tgstation13.org';

exclude_events - List of events to not announce, values: opened, closed, reopened, or merged
$servers[$configitem]['exclude_events'][] = 'closed';
$servers[$configitem]['exclude_events'][] = 'reopened';

announce_secret - Announce secret/security prs that have a [s] in front of the title? Defaults to no.
	Can also be set to 'only' to only announce secret prs.
$servers[$configitem]['announce_secret'] = false;
$servers[$configitem]['announce_secret'] = 'only';

announce_unvalidated - Announce prs by unvalidated users (see the validation setting above)? Defaults to no.
	Can also be set to 'only' to only announce prs by unvalidated users.
$servers[$configitem]['announce_unvalidated'] = false;

//Note: the same webhook or game server can be given in mutiple announce endpoints with different settings, allowing you to say, have embeds only show on prs to certain repos by excluding the repo in an endpoint with embed = false, and including the repo in an endpoint with embed = true true. This could also be used to only block closed and reopened events on prs by unvalidated users.

//Game servers to announce PRs to.
/*
$configitem = -1;//ignore me

//Game Server Start
$servers[++$configitem] = array();
$servers[$configitem]['address'] = 'game.tgstation13.org';
$servers[$configitem]['port'] = '1337';
$servers[$configitem]['comskey'] = '89aj90cq2fm0amc90832mn9rm90';
//Game Server End

//Game Server Start
$servers[++$configitem] = array();
$servers[$configitem]['address'] = 'game.tgstation13.org';
$servers[$configitem]['port'] = '2337';
$servers[$configitem]['comskey'] = '89aj90cq2fm0amc90832mn9rm90';
//Game Server End

unset($configitem);//ignore
*/

//discord webhooks to announce PRs to.
/*
$configitem = -1;//ignore me

//Discord Webhook Start
$discordWebHooks[++$configitem] = array();

// Webhook Url (you can get this from discord via the webhook setting menu of the server or a channel.)
$discordWebHooks[$configitem]['url'] = 'https://discord.com/api/webhooks/538933489920245771/xaoYtVuype-P1rb_uthQLkh_C4iVL3sjtIvFEp7rsfhbBs8tDsSJgE0a9MNWJaoSPBPK';

// show an embed with more info?
$discordWebHooks[$configitem]['embed'] = true;

// if the above is true, don't include the text portion before the embed.
//	 (This option is not advised as it's not compatible with users who disable embeds).
$discordWebHooks[$configitem]['no_text'] = false;
//Discord Webhook End

//Discord Webhook Start
$discordWebHooks[++$configitem] = array();

// Webhook Url (you can get this from discord via the webhook setting menu of the server or a channel.)
$discordWebHooks[$configitem]['url'] = 'https://discord.com/api/webhooks/538933686956064769/q0uDel7S6eutvRIyEwsuZo_ppzAoxqUNeU2PRChYVsYoJmmn2f2YYSDoMjy9FhhXKqpI';

// show an embed with more info?
$discordWebHooks[$configitem]['embed'] = true;

// if the above is true, don't include the text portion before the embed.
//	 (This option is not advised as it's not compatible with users who disable embeds).
$discordWebHooks[$configitem]['no_text'] = false;
//Discord Webhook End
*/

unset($configitem); //ignore

repo_name: AnalWerewolf/sundown

license: mit


code:
#!/bin/sh
# bootstrap/node
#
# Node-finding script for all `sh` environments, including Linux, MSYS2,
# Git for Windows, and GitHub Desktop. Invokable from CLI or automation.
#
# If a node.exe installed by `node_.ps1` is present, it will be used.
# Otherwise, this script requires a system `node` to be provided.
set -e

# Convenience variables
Bootstrap="$(dirname "$0")"
Cache="$Bootstrap/.cache"
if [ "$TG_BOOTSTRAP_CACHE" ]; then
	Cache="$TG_BOOTSTRAP_CACHE"
fi
OldPWD="$PWD"
cd "$Bootstrap/../.."
. ./dependencies.sh  # sets NODE_VERSION_PRECISE
cd "$OldPWD"
NodeVersion="$NODE_VERSION_PRECISE"
NodeFullVersion="node-v$NodeVersion-win-x64"
NodeDir="$Cache/$NodeFullVersion"
NodeExe="$NodeDir/node.exe"
is_vendored="1"

# If a bootstrapped Node is not present, search on $PATH.
if [ "$(uname)" = "Linux" ] || [ ! -f "$NodeExe" ]; then
	if [ "$TG_BOOTSTRAP_NODE_LINUX" ]; then
		NodeFullVersion="node-v$NodeVersion-linux-x64"
		NodeDir="$Cache/$NodeFullVersion/bin"
		NodeExe="$NodeDir/node"

		if [ ! -f "$NodeExe" ]; then
			mkdir -p "$Cache"
			Archive="$(realpath "$Cache/node-v$NodeVersion.tar.gz")"
			curl "https://nodejs.org/download/release/v$NodeVersion/$NodeFullVersion.tar.gz" -o "$Archive"
			(cd "$Cache" && tar xf "$Archive")
		fi
	elif command -v node >/dev/null 2>&1; then
		NodeExe="node"
		is_vendored="0"
	else
		echo
		if command -v apt-get >/dev/null 2>&1; then
			# Ubuntu advice
			echo "Please install Node using your system's package manager:"
			echo "    sudo apt-get install nodejs"
		elif uname | grep -q MSYS; then
			# MSYS2 (not packaged) or Git for Windows advice
			echo "Please run bootstrap/node.bat instead of bootstrap/node once"
			echo "to install Node automatically, or install it from https://nodejs.org/"
		elif command -v pacman >/dev/null 2>&1; then
			# Arch advice
			echo "Please install Node using your system's package manager:"
			echo "    sudo pacman -S nodejs"
		else
			# Generic advice
			echo "Please install Node from https://nodejs.org/ or using your system's package manager."
		fi
		echo
		exit 1
	fi
fi

# Invoke Node with all command-line arguments
if [ "$is_vendored" = "1" ]; then
	PATH="$(readlink -f "$NodeDir"):$PATH"
	echo "Using vendored Node $("$NodeExe" --version)"
else
	echo "Using system-wide Node $("$NodeExe" --version)"
fi
exec "$NodeExe" "$@"

path: tools/bootstrap/node


code:
@echo off
where node.exe >nul 2>nul
if %errorlevel% == 0 (
	echo | set /p printed_str="Using system-wide Node "
	call node.exe --version
	call node.exe %*
	goto exit_with_last_error_level
)
call powershell -NoLogo -ExecutionPolicy Bypass -File "%~dp0\node_.ps1" Download-Node
for /f "tokens=* USEBACKQ" %%s in (`
	call powershell -NoLogo -ExecutionPolicy Bypass -File "%~dp0\node_.ps1" Get-Path
`) do (
	set "PATH=%%s;%PATH%"
)
where node.exe >nul 2>nul
if %errorlevel% == 0 (
	echo | set /p printed_str="Using vendored Node "
	call node.exe --version
	call node.exe %*
	goto exit_with_last_error_level
)
echo "node.bat: Failed to bootstrap Node!"
%COMSPEC% /c exit 1

:exit_with_last_error_level
if not %errorlevel% == 0 %COMSPEC% /c exit %errorlevel% >nul


code:
## bootstrap/node_.ps1
## Downloads a Node version to a cache directory and invokes it.

$ErrorActionPreference = "Stop"

function Extract-Variable {
	param([string] $Path, [string] $Key)
	foreach ($Line in Get-Content $Path) {
		if ($Line.StartsWith("export $Key=")) {
			return $Line.Substring("export $Key=".Length)
		}
	}
	throw "Couldn't find value for $Key in $Path"
}

function Download-Node {
	if (Test-Path $NodeTarget -PathType Leaf) {
		return
	}
	Write-Output "Downloading Node v$NodeVersion (may take a while)"
	New-Item $NodeTargetDir -ItemType Directory -ErrorAction silentlyContinue | Out-Null
	$WebClient = New-Object Net.WebClient
	$WebClient.DownloadFile($NodeSource, $NodeTarget)
}

## Convenience variables
$BaseDir = Split-Path $script:MyInvocation.MyCommand.Path
$Cache = "$BaseDir\.cache"
if ($Env:TG_BOOTSTRAP_CACHE) {
	$Cache = $Env:TG_BOOTSTRAP_CACHE
}
$NodeVersion = Extract-Variable -Path "$BaseDir\..\..\dependencies.sh" -Key "NODE_VERSION_PRECISE"
$NodeSource = "https://nodejs.org/download/release/v$NodeVersion/win-x86/node.exe"
$NodeTargetDir = "$Cache\node-v$NodeVersion"
$NodeTarget = "$NodeTargetDir\node.exe"

## Just print the path and exit
if ($Args.length -eq 1 -and $Args[0] -eq "Get-Path") {
	Write-Output "$NodeTargetDir"
	exit 0
}

## Just download node and exit
if ($Args.length -eq 1 -and $Args[0] -eq "Download-Node") {
	Download-Node
	exit 0
}

## Download node
Download-Node

## Set PATH so that recursive calls find it
$Env:PATH = "$NodeTargetDir;$ENV:Path"

## Invoke Node with all command-line arguments
$ErrorActionPreference = "Continue"
& "$NodeTarget" @Args
exit $LastExitCode


code:
#!/bin/sh
# bootstrap/python
#
# Python-finding script for all `sh` environments, including Linux, MSYS2,
# Git for Windows, and GitHub Desktop. Invokable from CLI or automation.
#
# If a python.exe installed by `python_.ps1` is present, it will be used.
# Otherwise, this script requires a system `python3` and `pip` to be provided,
# and will create a standard virtualenv in which to install `requirements.txt`.
set -e

# Convenience variables
Bootstrap="$(dirname "$0")"
Sdk="$(dirname "$Bootstrap")"
Cache="$Bootstrap/.cache"
if [ "$TG_BOOTSTRAP_CACHE" ]; then
	Cache="$TG_BOOTSTRAP_CACHE"
fi
OldPWD="$PWD"
cd "$Bootstrap/../.."
. ./dependencies.sh  # sets PYTHON_VERSION
cd "$OldPWD"
PythonVersion="$PYTHON_VERSION"
PythonDir="$Cache/python-$PythonVersion"
PythonExe="$PythonDir/python.exe"
Log="$Cache/last-command.log"

# If a portable Python for Windows is not present, search on $PATH.
if [ "$(uname)" = "Linux" ] || [ ! -f "$PythonExe" ]; then
	# Strip the "App Execution Aliases" from $PATH. Even if the user installed
	# Python using the Windows Store on purpose, these aliases always generate
	# "Permission denied" errors when sh.exe tries to invoke them.
	PATH=$(echo "$PATH" | tr ":" "\n" | grep -v "AppData/Local/Microsoft/WindowsApps" | tr "\n" ":")

	# Try to find a Python executable.
	if command -v python3 >/dev/null 2>&1; then
		PythonExe=python3
	elif command -v python >/dev/null 2>&1; then
		PythonExe=python
	elif command -v py >/dev/null 2>&1; then
		PythonExe="py -3"
	else
		echo
		if command -v apt-get >/dev/null 2>&1; then
			echo "Please install Python using your system's package manager:"
			echo "    sudo apt-get install python3 python3-pip"
		elif [ "$(uname -o)" = "Msys" ]; then
			echo "Please run tools/bootstrap/python.bat instead of tools/bootstrap/python once to"
			echo "install Python automatically, or install it from https://www.python.org/downloads/"
			# TODO: give MSYS pacman advice?
		elif command -v pacman >/dev/null 2>&1; then
			echo "Please install Python using your system's package manager:"
			echo "    sudo pacman -S python python-pip"
		else
			echo "Please install Python from https://www.python.org/downloads/ or using your system's package manager."
		fi
		echo
		exit 1
	fi

	# Create a venv and activate it
	PythonDir="$Cache/venv"
	if [ ! -d "$PythonDir" ]; then
		echo "Creating virtualenv..."
		"$PythonExe" -m venv "$PythonDir"
	fi
	if [ -f "$PythonDir/bin/python" ]; then
		PythonExe="$PythonDir/bin/python"
	elif [ -f "$PythonDir/scripts/python3.exe" ]; then
		PythonExe="$PythonDir/scripts/python3.exe";
	else
		echo "bootstrap/python failed to find the python executable inside its virtualenv"
		exit 1
	fi
fi

# Use pip to install our requirements
if [ ! -f "$PythonDir/requirements.txt" ] || [ "$(b2sum < "$Sdk/requirements.txt")" != "$(b2sum < "$PythonDir/requirements.txt")" ]; then
	echo "Updating dependencies..."
	"$PythonExe" -m pip install -U wheel
	"$PythonExe" -m pip install -U pip -r "$Sdk/requirements.txt"
	cp "$Sdk/requirements.txt" "$PythonDir/requirements.txt"
	echo "---"
fi

# Verify version and deduce the path separator
PythonMajor=${PythonVersion%%.*}
PythonMinor=${PythonVersion#*.}
PythonMinor=${PythonMinor%.*}
PATHSEP=$("$PythonExe" - "$PythonMajor" "$PythonMinor" <<'EOF'
import sys, os
if sys.version_info.major != int(sys.argv[1]) or sys.version_info.minor < int(sys.argv[2]):
	print("Error: Python ", sys.argv[1], ".", sys.argv[2], " or later is required, but you have:\n", sys.version, sep="", file=sys.stderr)
	exit(1)
print(os.pathsep)
EOF
)

# Cheap shell function if tee.exe is not available
if ! command -v tee >/dev/null 2>&1; then
	tee() {
		# Fudge: assume $1 is always "-a"
		while read -r line; do
			echo "$line" >> "$2"
			echo "$line"
		done
	}
fi

# Invoke python with all command-line arguments
export PYTHONPATH="$Sdk$PATHSEP${PYTHONPATH:-}"
mkdir -p "$Cache"
printf '%s\n' "$PythonExe" "$@" > "$Log"
printf -- '---\n' >> "$Log"
exec 4>&1
exitstatus=$({ { set +e; "$PythonExe" -u "$@" 2>&1 3>&-; printf %s $? >&3; } 4>&- | tee -a "$Log" 1>&4; } 3>&1)
exec 4>&-
exit "$exitstatus"

path: tools/bootstrap/python


code:
@echo off
call powershell.exe -NoLogo -ExecutionPolicy Bypass -File "%~dp0\python_.ps1" %*


code:
python36.zip
.
..\..\..

# Uncomment to run site.main() automatically
import site


code:
# bootstrap/python_.ps1
#
# Python bootstrapping script for Windows.
#
# Automatically downloads a portable edition of a pinned Python version to
# a cache directory, installs Pip, installs `requirements.txt`, and then invokes
# Python.
#
# The underscore in the name is so that typing `bootstrap/python` into
# PowerShell finds the `.bat` file first, which ensures this script executes
# regardless of ExecutionPolicy.
$host.ui.RawUI.WindowTitle = "starting :: python $args"
$ErrorActionPreference = "Stop"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Add-Type -AssemblyName System.IO.Compression.FileSystem

function ExtractVersion {
	param([string] $Path, [string] $Key)
	foreach ($Line in Get-Content $Path) {
		if ($Line.StartsWith("export $Key=")) {
			return $Line.Substring("export $Key=".Length)
		}
	}
	throw "Couldn't find value for $Key in $Path"
}

# Convenience variables
$Bootstrap = Split-Path $script:MyInvocation.MyCommand.Path
$Tools = Split-Path $Bootstrap
$Cache = "$Bootstrap/.cache"
if ($Env:TG_BOOTSTRAP_CACHE) {
	$Cache = $Env:TG_BOOTSTRAP_CACHE
}
$PythonVersion = ExtractVersion -Path "$Bootstrap/../../dependencies.sh" -Key "PYTHON_VERSION"
$PythonDir = "$Cache/python-$PythonVersion"
$PythonExe = "$PythonDir/python.exe"
$Log = "$Cache/last-command.log"

# Download and unzip a portable version of Python
if (!(Test-Path $PythonExe -PathType Leaf)) {
	$host.ui.RawUI.WindowTitle = "Downloading Python $PythonVersion..."
	New-Item $Cache -ItemType Directory -ErrorAction silentlyContinue | Out-Null

	$Archive = "$Cache/python-$PythonVersion-embed.zip"
	Invoke-WebRequest `
		"https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-embed-amd64.zip" `
		-OutFile $Archive `
		-ErrorAction Stop

	[System.IO.Compression.ZipFile]::ExtractToDirectory($Archive, $PythonDir)

	# Copy a ._pth file without "import site" commented, so pip will work
	Copy-Item "$Bootstrap/python36._pth" $PythonDir `
		-ErrorAction Stop

	Remove-Item $Archive
}

# Install pip
if (!(Test-Path "$PythonDir/Scripts/pip.exe")) {
	$host.ui.RawUI.WindowTitle = "Downloading Pip..."

	Invoke-WebRequest "https://bootstrap.pypa.io/get-pip.py" `
		-OutFile "$Cache/get-pip.py" `
		-ErrorAction Stop

	& $PythonExe "$Cache/get-pip.py" --no-warn-script-location
	if ($LASTEXITCODE -ne 0) {
		exit $LASTEXITCODE
	}

	Remove-Item "$Cache/get-pip.py" `
		-ErrorAction Stop
}

# Use pip to install our requirements
if (!(Test-Path "$PythonDir/requirements.txt") -or ((Get-FileHash "$Tools/requirements.txt").hash -ne (Get-FileHash "$PythonDir/requirements.txt").hash)) {
	$host.ui.RawUI.WindowTitle = "Updating dependencies..."

	& $PythonExe -m pip install -U pip -r "$Tools/requirements.txt"
	if ($LASTEXITCODE -ne 0) {
		exit $LASTEXITCODE
	}

	Copy-Item "$Tools/requirements.txt" "$PythonDir/requirements.txt"
	Write-Output "`n---`n"
}

# Invoke python with all command-line arguments
Write-Output $PythonExe | Out-File -Encoding utf8 $Log
[System.String]::Join([System.Environment]::NewLine, $args) | Out-File -Encoding utf8 -Append $Log
Write-Output "---" | Out-File -Encoding utf8 -Append $Log
$host.ui.RawUI.WindowTitle = "python $args"
$ErrorActionPreference = "Continue"
& $PythonExe -u $args 2>&1 | ForEach-Object {
	$str = "$_"
	if ($_.GetType() -eq [System.Management.Automation.ErrorRecord]) {
		$str = $str.TrimEnd("`r`n")
	}
	$str | Out-File -Encoding utf8 -Append $Log
	$str | Out-Host
}
exit $LastExitCode


code:
# http://editorconfig.org
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true


code:
# /tg/station build script

This build script is the recommended way to compile the game, including not only the DM code but also the JavaScript and any other dependencies.

- VSCode:
  a) Press `Ctrl+Shift+B` to build.
  b) Press `F5` to build and run with debugger attached.
- Windows:
  a) Double-click `BUILD.bat` in the repository root to build (will wait for a key press before it closes).
  b) Double-click `tools/build/build.bat` to build (will exit as soon as it finishes building).
- Linux:
  a) Run `tools/build/build` from the repository root.

The script will skip build steps whose inputs have not changed since the last run.

## Getting list of available targets

You can get a list of all targets that you can build by running the following command:

```
tools/build/build --help
```

## Dependencies

- On Windows, `build.bat` will automatically install a private (vendored) copy of Node.
- On Linux, install Node using your package manager or from <https://nodejs.org/en/download/>.
- On Linux, unless using tgs4 or later you will need to compile rust-g on the server and obtain a .so file, for instructions see https://github.com/tgstation/rust-g

## Why?

We used to include compiled versions of the tgui JavaScript code in the Git repository so that the project could be compiled using BYOND only. These pre-compiled files tended to have merge conflicts for no good reason. Using a build script lets us avoid this problem, while keeping builds convenient for people who are not modifying tgui.

This build script is based on [Juke Build](https://github.com/stylemistake/juke-build) - follow the link to read the documentation for the project and understand how it works and how to contribute.


code:
#!/bin/sh
set -e
cd "$(dirname "$0")"
exec ../bootstrap/node build.js "$@"

path: tools/build/build


code:
@echo off
"%~dp0\..\bootstrap\node.bat" --experimental-modules "%~dp0\build.js" %*


code:
#!/usr/bin/env node
/**
 * Build script for /tg/station 13 codebase.
 *
 * This script uses Juke Build, read the docs here:
 * https://github.com/stylemistake/juke-build
 */

import fs from 'fs';
import Juke from './juke/index.js';
import { DreamDaemon, DreamMaker } from './lib/byond.js';
import { yarn } from './lib/yarn.js';

Juke.chdir('../..', import.meta.url);
Juke.setup({ file: import.meta.url }).then((code) => {
  // We're using the currently available quirk in Juke Build, which
  // prevents it from exiting on Windows, to wait on errors.
  if (code !== 0 && process.argv.includes('--wait-on-error')) {
    Juke.logger.error('Please inspect the error and close the window.');
    return;
  }
  process.exit(code);
});

const DME_NAME = 'tgstation';

export const DefineParameter = new Juke.Parameter({
  type: 'string[]',
  alias: 'D',
});

export const PortParameter = new Juke.Parameter({
  type: 'string',
  alias: 'p',
});

export const CiParameter = new Juke.Parameter({ type: 'boolean' });

export const WarningParameter = new Juke.Parameter({
  type: 'string[]',
  alias: 'W',
});

export const DmMapsIncludeTarget = new Juke.Target({
  executes: async () => {
    const folders = [
      ...Juke.glob('_maps/RandomRuins/**/*.dmm'),
      ...Juke.glob('_maps/RandomZLevels/**/*.dmm'),
      ...Juke.glob('_maps/shuttles/**/*.dmm'),
      ...Juke.glob('_maps/templates/**/*.dmm'),
    ];
    const content = folders
      .map((file) => file.replace('_maps/', ''))
      .map((file) => `#include "${file}"`)
      .join('\n') + '\n';
    fs.writeFileSync('_maps/templates.dm', content);
  },
});

export const DmTarget = new Juke.Target({
  parameters: [DefineParameter],
  dependsOn: ({ get }) => [
    get(DefineParameter).includes('ALL_MAPS') && DmMapsIncludeTarget,
  ],
  inputs: [
    '_maps/map_files/generic/**',
    'code/**',
    'goon/**',
    'html/**',
    'icons/**',
    'interface/**',
    `${DME_NAME}.dme`,
  ],
  outputs: [
    `${DME_NAME}.dmb`,
    `${DME_NAME}.rsc`,
  ],
  executes: async ({ get }) => {
    await DreamMaker(`${DME_NAME}.dme`, {
      defines: ['CBT', ...get(DefineParameter)],
      warningsAsErrors: get(WarningParameter).includes('error'),
    });
  },
});

export const DmTestTarget = new Juke.Target({
  parameters: [DefineParameter],
  dependsOn: ({ get }) => [
    get(DefineParameter).includes('ALL_MAPS') && DmMapsIncludeTarget,
  ],
  executes: async ({ get }) => {
    fs.copyFileSync(`${DME_NAME}.dme`, `${DME_NAME}.test.dme`);
    await DreamMaker(`${DME_NAME}.test.dme`, {
      defines: ['CBT', 'CIBUILDING', ...get(DefineParameter)],
      warningsAsErrors: get(WarningParameter).includes('error'),
    });
    Juke.rm('data/logs/ci', { recursive: true });
    await DreamDaemon(
      `${DME_NAME}.test.dmb`,
      '-close', '-trusted', '-verbose',
      '-params', 'log-directory=ci'
    );
    Juke.rm('*.test.*');
    try {
      const cleanRun = fs.readFileSync('data/logs/ci/clean_run.lk', 'utf-8');
      console.log(cleanRun);
    }
    catch (err) {
      Juke.logger.error('Test run was not clean, exiting');
      throw new Juke.ExitCode(1);
    }
  },
});

export const YarnTarget = new Juke.Target({
  parameters: [CiParameter],
  inputs: [
    'tgui/.yarn/+(cache|releases|plugins|sdks)/**/*',
    'tgui/**/package.json',
    'tgui/yarn.lock',
  ],
  outputs: [
    'tgui/.yarn/install-target',
  ],
  executes: ({ get }) => yarn('install', get(CiParameter) && '--immutable'),
});

export const TgFontTarget = new Juke.Target({
  dependsOn: [YarnTarget],
  inputs: [
    'tgui/.yarn/install-target',
    'tgui/packages/tgfont/**/*.+(js|cjs|svg)',
    'tgui/packages/tgfont/package.json',
  ],
  outputs: [
    'tgui/packages/tgfont/dist/tgfont.css',
    'tgui/packages/tgfont/dist/tgfont.eot',
    'tgui/packages/tgfont/dist/tgfont.woff2',
  ],
  executes: () => yarn('tgfont:build'),
});

export const TguiTarget = new Juke.Target({
  dependsOn: [YarnTarget],
  inputs: [
    'tgui/.yarn/install-target',
    'tgui/webpack.config.js',
    'tgui/**/package.json',
    'tgui/packages/**/*.+(js|cjs|ts|tsx|scss)',
  ],
  outputs: [
    'tgui/public/tgui.bundle.css',
    'tgui/public/tgui.bundle.js',
    'tgui/public/tgui-panel.bundle.css',
    'tgui/public/tgui-panel.bundle.js',
  ],
  executes: () => yarn('tgui:build'),
});

export const TguiEslintTarget = new Juke.Target({
  parameters: [CiParameter],
  dependsOn: [YarnTarget],
  executes: ({ get }) => yarn('tgui:lint', !get(CiParameter) && '--fix'),
});

export const TguiSonarTarget = new Juke.Target({
  dependsOn: [YarnTarget],
  executes: () => yarn('tgui:sonar'),
});

export const TguiTscTarget = new Juke.Target({
  dependsOn: [YarnTarget],
  executes: () => yarn('tgui:tsc'),
});

export const TguiTestTarget = new Juke.Target({
  parameters: [CiParameter],
  dependsOn: [YarnTarget],
  executes: ({ get }) => yarn(`tgui:test-${get(CiParameter) ? 'ci' : 'simple'}`),
});

export const TguiLintTarget = new Juke.Target({
  dependsOn: [YarnTarget, TguiEslintTarget, TguiTscTarget],
});

export const TguiDevTarget = new Juke.Target({
  dependsOn: [YarnTarget],
  executes: ({ args }) => yarn('tgui:dev', ...args),
});

export const TguiAnalyzeTarget = new Juke.Target({
  dependsOn: [YarnTarget],
  executes: () => yarn('tgui:analyze'),
});

export const TguiBenchTarget = new Juke.Target({
  dependsOn: [YarnTarget],
  executes: () => yarn('tgui:bench'),
});

export const TestTarget = new Juke.Target({
  dependsOn: [DmTestTarget, TguiTestTarget],
});

export const LintTarget = new Juke.Target({
  dependsOn: [TguiLintTarget],
});

export const BuildTarget = new Juke.Target({
  dependsOn: [TguiTarget, TgFontTarget, DmTarget],
});

export const ServerTarget = new Juke.Target({
  dependsOn: [BuildTarget],
  executes: async ({ get }) => {
    const port = get(PortParameter) || '1337';
    await DreamDaemon(`${DME_NAME}.dmb`, port, '-trusted');
  },
});

export const AllTarget = new Juke.Target({
  dependsOn: [TestTarget, LintTarget, BuildTarget],
});

export const TguiCleanTarget = new Juke.Target({
  executes: async () => {
    Juke.rm('tgui/public/.tmp', { recursive: true });
    Juke.rm('tgui/public/*.map');
    Juke.rm('tgui/public/*.{chunk,bundle,hot-update}.*');
    Juke.rm('tgui/packages/tgfont/dist', { recursive: true });
    Juke.rm('tgui/.yarn/{cache,unplugged,webpack}', { recursive: true });
    Juke.rm('tgui/.yarn/build-state.yml');
    Juke.rm('tgui/.yarn/install-state.gz');
    Juke.rm('tgui/.yarn/install-target');
    Juke.rm('tgui/.pnp.*');
  },
});

export const CleanTarget = new Juke.Target({
  dependsOn: [TguiCleanTarget],
  executes: async () => {
    Juke.rm('*.{dmb,rsc}');
    Juke.rm('*.mdme*');
    Juke.rm('*.m.*');
    Juke.rm('_maps/templates.dm');
  },
});

/**
 * Removes more junk at the expense of much slower initial builds.
 */
export const CleanAllTarget = new Juke.Target({
  dependsOn: [CleanTarget],
  executes: async () => {
    Juke.logger.info('Cleaning up data/logs');
    Juke.rm('data/logs', { recursive: true });
    Juke.logger.info('Cleaning up global yarn cache');
    await yarn('cache', 'clean', '--all');
  },
});

/**
 * Prepends the defines to the .dme.
 * Does not clean them up, as this is intended for TGS which
 * clones new copies anyway.
 */
const prependDefines = (...defines) => {
  const dmeContents = fs.readFileSync(`${DME_NAME}.dme`);
  const textToWrite = defines.map(define => `#define ${define}\n`);
  fs.writeFileSync(`${DME_NAME}.dme`, `${textToWrite}\n${dmeContents}`);
};

export const TgsTarget = new Juke.Target({
  dependsOn: [TguiTarget, TgFontTarget],
  executes: async () => {
    Juke.logger.info('Prepending TGS define');
    prependDefines('TGS');
  },
});

const TGS_MODE = process.env.CBT_BUILD_MODE === 'TGS';

export default TGS_MODE ? TgsTarget : BuildTarget;


code:
// Generated by dts-bundle-generator v5.9.0

/// <reference types="node" />

import _chalk from 'chalk';
import { SpawnOptionsWithoutStdio } from 'child_process';
import EventEmitter from 'events';

/**
 * Change the current working directory of the Node.js process.
 *
 * Second argument is a file (or directory), relative to which chdir will be
 * performed. This is usually `import.meta.url`.
 */
export declare const chdir: (directory: string, relativeTo?: string | undefined) => void;
export declare const logger: {
	log: (...args: unknown[]) => void;
	error: (...args: unknown[]) => void;
	action: (...args: unknown[]) => void;
	warn: (...args: unknown[]) => void;
	info: (...args: unknown[]) => void;
	debug: (...args: unknown[]) => void;
};
export declare type ParameterType = (string | string[] | number | number[] | boolean | boolean[]);
export declare type StringType = ("string" | "string[]" | "number" | "number[]" | "boolean" | "boolean[]");
export declare type TypeByString<T extends StringType> = (T extends "string" ? string : T extends "string[]" ? string[] : T extends "number" ? number : T extends "number[]" ? number[] : T extends "boolean" ? boolean : T extends "boolean[]" ? boolean[] : never);
export declare type ParameterConfig<T extends StringType> = {
	/**
	 * Parameter name, as it would be used in CLI.
	 */
	name?: string;
	/**
	 * Parameter type, one of:
	 * - `string`
	 * - `string[]`
	 * - `number`
	 * - `number[]`
	 * - `boolean`
	 * - `boolean[]`
	 */
	type: T;
	/**
	 * Short flag for use in CLI, can only be a single character.
	 */
	alias?: string;
};
export interface Parameter<T extends ParameterType = ParameterType> {
	type: StringType;
	name?: string;
	alias?: string;
	__internalType?: T;
	isString(): this is Parameter<string | string[]>;
	isNumber(): this is Parameter<number | number[]>;
	isBoolean(): this is Parameter<boolean | boolean[]>;
	isArray(): this is Parameter<string[] | number[] | boolean[]>;
	toKebabCase(): string | undefined;
	toConstCase(): string | undefined;
	toCamelCase(): string | undefined;
}
export declare type ParameterCtor = {
	new <T extends StringType>(config: ParameterConfig<T>): Parameter<TypeByString<T>>;
};
export declare const Parameter: ParameterCtor;
export declare type ParameterCreator = <T extends StringType>(config: ParameterConfig<T>) => Parameter<TypeByString<T>>;
export declare const createParameter: ParameterCreator;
export declare type ExecutionContext = {
	/** Get parameter value. */
	get: <T extends ParameterType>(parameter: Parameter<T>) => (T extends Array<unknown> ? T : T | null);
	args: string[];
};
export declare type BooleanLike = boolean | null | undefined;
export declare type WithExecutionContext<R> = (context: ExecutionContext) => R | Promise<R>;
export declare type WithOptionalExecutionContext<R> = R | WithExecutionContext<R>;
export declare type DependsOn = WithOptionalExecutionContext<(Target | BooleanLike)[]>;
export declare type ExecutesFn = WithExecutionContext<unknown>;
export declare type OnlyWhenFn = WithExecutionContext<BooleanLike>;
export declare type FileIo = WithOptionalExecutionContext<(string | BooleanLike)[]>;
export declare type TargetConfig = {
	/**
	 * Target name. This parameter is required.
	 */
	name?: string;
	/**
	 * Dependencies for this target. They will be ran before executing this
	 * target, and may run in parallel.
	 */
	dependsOn?: DependsOn;
	/**
	 * Function that is delegated to the execution engine for building this
	 * target. It is normally an async function, which accepts a single
	 * argument - execution context (contains `get` for interacting with
	 * parameters).
	 *
	 * @example
	 * executes: async ({ get }) => {
	 *   console.log(get(Parameter));
	 * },
	 */
	executes?: ExecutesFn;
	/**
	 * Files that are consumed by this target.
	 */
	inputs?: FileIo;
	/**
	 * Files that are produced by this target. Additionally, they are also
	 * touched every time target finishes executing in order to stop
	 * this target from re-running.
	 */
	outputs?: FileIo;
	/**
	 * Parameters that are local to this task. Can be retrieved via `get`
	 * in the executor function.
	 */
	parameters?: Parameter[];
	/**
	 * Target will run only when this function returns true. It accepts a
	 * single argument - execution context.
	 */
	onlyWhen?: OnlyWhenFn;
};
export declare class Target {
	name?: string;
	dependsOn: DependsOn;
	executes?: ExecutesFn;
	inputs: FileIo;
	outputs: FileIo;
	parameters: Parameter[];
	onlyWhen?: OnlyWhenFn;
	constructor(target: TargetConfig);
}
export declare type TargetCreator = (target: TargetConfig) => Target;
export declare const createTarget: TargetCreator;
export declare type RunnerConfig = {
	targets?: Target[];
	default?: Target;
	parameters?: Parameter[];
	singleTarget?: boolean;
};
export declare const runner: {
	config: RunnerConfig;
	targets: Target[];
	parameters: Parameter[];
	workers: Worker[];
	configure(config: RunnerConfig): void;
	start(): Promise<number>;
};
declare class Worker {
	readonly target: Target;
	readonly context: ExecutionContext;
	readonly dependsOn: Target[];
	dependencies: Set<Target>;
	generator?: AsyncGenerator;
	emitter: EventEmitter;
	hasFailed: boolean;
	constructor(target: Target, context: ExecutionContext, dependsOn: Target[]);
	resolveDependency(target: Target): void;
	rejectDependency(target: Target): void;
	start(): void;
	onFinish(fn: () => void): void;
	onFail(fn: () => void): void;
	private debugLog;
	private process;
}
export declare class ExitCode extends Error {
	code: number | null;
	signal: string | null;
	constructor(code: number | null, signal?: string | null);
}
export declare type ExecOptions = SpawnOptionsWithoutStdio & {
	/**
	 * If `true`, this exec call will not pipe its output to stdio.
	 * @default false
	 */
	silent?: boolean;
	/**
	 * Throw an exception on non-zero exit code.
	 * @default true
	 */
	throw?: boolean;
};
export declare type ExecReturn = {
	/** Exit code of the program. */
	code: number | null;
	/** Signal received by the program which caused it to exit. */
	signal: NodeJS.Signals | null;
	/** Output collected from `stdout` */
	stdout: string;
	/** Output collected from `stderr` */
	stderr: string;
	/** A combined output collected from `stdout` and `stderr`. */
	combined: string;
};
export declare const exec: (executable: string, args?: string[], options?: ExecOptions) => Promise<ExecReturn>;
/**
 * Unix style pathname pattern expansion.
 *
 * Perform a search matching a specified pattern according to the rules of
 * the `glob` npm package. Path can be either absolute or relative, and can
 * contain shell-style wildcards. Broken symlinks are included in the results
 * (as in the shell). Whether or not the results are sorted depends on the
 * file system.
 *
 * @returns A possibly empty list of file paths.
 */
export declare const glob: (globPath: string) => string[];
export declare type RmOptions = {
	/**
	 * If true, perform a recursive directory removal.
	 */
	recursive?: boolean;
	/**
	 * If true, exceptions will be ignored if file or directory does not exist.
	 */
	force?: boolean;
};
/**
 * Removes files and directories (synchronously). Supports globs.
 */
export declare const rm: (path: string, options?: RmOptions) => void;
export declare const chalk: _chalk.Chalk & _chalk.ChalkFunction & {
	supportsColor: false | _chalk.ColorSupport;
	Level: _chalk.Level;
	Color: ("black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" | "gray" | "grey" | "blackBright" | "redBright" | "greenBright" | "yellowBright" | "blueBright" | "magentaBright" | "cyanBright" | "whiteBright") | ("bgBlack" | "bgRed" | "bgGreen" | "bgYellow" | "bgBlue" | "bgMagenta" | "bgCyan" | "bgWhite" | "bgGray" | "bgGrey" | "bgBlackBright" | "bgRedBright" | "bgGreenBright" | "bgYellowBright" | "bgBlueBright" | "bgMagentaBright" | "bgCyanBright" | "bgWhiteBright");
	ForegroundColor: "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" | "gray" | "grey" | "blackBright" | "redBright" | "greenBright" | "yellowBright" | "blueBright" | "magentaBright" | "cyanBright" | "whiteBright";
	BackgroundColor: "bgBlack" | "bgRed" | "bgGreen" | "bgYellow" | "bgBlue" | "bgMagenta" | "bgCyan" | "bgWhite" | "bgGray" | "bgGrey" | "bgBlackBright" | "bgRedBright" | "bgGreenBright" | "bgYellowBright" | "bgBlueBright" | "bgMagentaBright" | "bgCyanBright" | "bgWhiteBright";
	Modifiers: "bold" | "reset" | "dim" | "italic" | "underline" | "inverse" | "hidden" | "strikethrough" | "visible";
	stderr: _chalk.Chalk & {
		supportsColor: false | _chalk.ColorSupport;
	};
};
export declare type SetupConfig = {
	file: string;
	/**
	 * If true, CLI will only accept a single target to run and will receive all
	 * passed arguments as is (not only flags).
	 */
	singleTarget?: boolean;
};
/**
 * Configures Juke Build and starts executing targets.
 *
 * @param config Juke Build configuration.
 * @returns Exit code of the whole runner process.
 */
export declare const setup: (config: SetupConfig) => Promise<number>;
export declare const sleep: (time: number) => Promise<unknown>;

export {};