#!/usr/bin/php -qC
<?php
# encoding: utf-8
# api: cli
# title: webhook
# description: to be run from fossil backoffice/after-receive hooks
# version: 0.2
# doc: https://fossil-scm.org/home/doc/trunk/www/hooks.md
#
# Meant to transform commits into common webhook JSON formats,
# and send them off. Does a litte preprocessing and expansion
# of the commit list, adds raw/ urls to ease payload processing.
#
# Hook syntax:
# fossil-webhook '%R' --ping 'http://service.rest/api?token=123'
#
# Parameters:
# %R becomes repository filename (REQUIRED)
# --fsl fossil-style payload (default)
# --git crafts a github-style payload
# --ping basic ping
# --if:wiki test artifact list for type (check-in, wiki, file)
# https:// webhook service endpoints (REQUIRED; can be multiple)
# +tok=123 inject additional parameters into payload
# +Tok:Bear add HTTP request headers
#
# The %R parameter can also be fed in using the env variable:
# FOSSIL_REPOSITORY='%R' fossil-webhook --fsl 'https://target.rest/'
#
#-- init
$argv = $_SERVER["argv"];
$cfg = [
"start" => microtime(TRUE),
"dbg" => preg_grep("~^-+(de?bu?g|du?mp)$~i", $argv),
"git" => preg_grep("~^-+gi?t?$~i", $argv),
"ping" => preg_grep("~^-+pi?n?g?$~i", $argv),
"help" => preg_grep("~^-+he?l?p?$~i", $argv),
"repo" => empty($_SERVER["FOSSIL_REPOSITORY"])
? current(preg_grep("~^/\w+.+\.(fsl|fossil|sqlite)$~", $argv))
: $_SERVER["FOSSIL_REPOSITORY"],
"add" => preg_grep("~^\++[\w.-]+[:=].+$~", $argv),
"if" => preg_grep("~^-+if:[\w-]+$~i", $argv),
"service_urls" => preg_grep("~^https?://.+$~i", $argv),
"stdin" => fread(STDIN, 1<<20),
#e9ce272e806c47a10e3d4cf570549a1d33133133 file src/code.c
#1db32c3ff6c5969191d575c65916f6322b833313 wiki HomePage
#313908390381390813913813931931830938aaaa check-in to trunk by user on 2222-11-30 00:00
"json" => TRUE, #!preg_grep("-multipart")
"user" => null,
];
// basic parameter checks
if (empty($cfg["repo"]) || empty($cfg["service_urls"])) {
die("%R or url missing");
}
// actually just does a plain string lookup in stdin
elseif ($cfg["if"]) {
if_checks($cfg["if"]);
}
// dependent options
$cfg += [
"basename" => get_basename(),
"baseurl" => get_baseurl(),
"title" => get_config("project-title"),
];
#-- payload types
if ($cfg["help"]) {
die("Usage as hook:\n fossil-webhook %R --git http://service.rest/TOKEN/ETC\n");
}
elseif ($cfg["git"]) {
$request = git_request();
}
elseif ($cfg["ping"]) {
$request = basic_ping();
}
else {
$request = fossil_request();
}
#-- and send
add_params($request);
if ($cfg["dbg"]) {
die(json_encode($request, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
}
else {
send($request, extra_headers());
}
/**
* Process +add=paramval arguments,
* simply adding them to $request[] top-level.
*
*/
function add_params(&$request) {
global $cfg;
#-- +add=params?
foreach ($cfg["add"] as $add) {
preg_match("~^\++([\w-.]+)[=](.+)$~", $add, $kv);
$request[$kv[1]] = $kv[2];
}
}
/**
* Process +Http:Headers arguments,
* collecting them for CURL request.
*
*/
function extra_headers() {
global $cfg;
$headers = [];
#-- +Ttoken:params?
foreach ($cfg["add"] as $add) {
preg_match("~^\++([\w-]+)[:](.+)$~", $add, $kv);
$headers[] = "$kv[1]: $kv[2]";
}
return $headers;
}
/**
* Sending
*
*/
function send($request, $headers=[]) {
global $cfg;
if ($cfg["json"]) {
$request = json_encode($request, JSON_PRETTY_PRINT);
$headers[] = "Content-Type: application/json";
}
#-- send query
foreach ($cfg["service_urls"] as $url) {
$c = curl_init();
curl_setopt_array($c, [
CURLOPT_URL => $url,
CURLOPT_POST => 1,
CURLOPT_POSTFIELDS => $request,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_VERBOSE => 1,
]);
$c->exec();
}
}
/**
* Define a fossil-esque webhook payload.
* (Not much resemblence, shortened attributtes, no payload:{} wrapping, etc.)
*
*/
function fossil_request() {
global $cfg;
$url = get_baseurl();
return [
'$type' => "webhook",
'$class' => "vcs fossil",
'$ver' => "0.2",
"action" => main_action($cfg["stdin"]),
"fossil" => get_config("server-code"),
"url" => $url,
"project" => [
"name" => $cfg["basename"],
"title" => get_config("project-name"),
"description" => get_config("project-description"),
"size" => filesize($cfg["repo"]),
"id" => get_config("project-code"),
"changes" => implode("\n", array_column(db("SELECT comment FROM event ORDER BY mtime DESC LIMIT 10"), "comment")),
],
"api" => [
"stat" => "$url/json/stat",
"timeline" => "$url/json/timeline/checkin",
"dir" => "$url/json/dir",
"trees" => "$url/ext/trees",
"branch" => "$url/json/branch",
"version" => "$url/json/version",
"report" => "$url/json/report",
"query" => "$url/json/query?sql={sql}",
"wiki_list" => "$url/json/wiki/list",
"wiki_get" => "$url/json/wiki/get/{name}",
"changelog" => "$url/ext/changelog",
],
"artifacts" => array_map(
function($row) use($url) {
return expand_artifact($row, $url);
},
get_artifacts($cfg["stdin"])
),
"user" => $cfg["user"],
"timestamp" => time(),
"procTimeMs" => 1000 * (microtime(TRUE) - $cfg["start"]),
];
}
/**
* More basic payload
*
*/
function basic_ping() {
global $cfg;
$url = get_baseurl();
return [
'$type' => "webhook",
'$class' => "ping basic",
"action" => main_action($cfg["stdin"]),
"url" => $url,
"name" => $cfg["basename"],
"stdin" => $cfg["stdin"],
"timestamp" => time(),
];
}
/**
* Simulate GitHub-style webhook.
* (Unlikely that we can fill in all the minutae.)
*
*/
function git_request() {
global $cfg;
# prepare values
$user = get_main_user();
$url = $cfg["baseurl"];
$basename = $cfg["basename"];
$rep_id = hexdec(substr(get_config("project-code", "12FFFF"), 0, 6));
$art = get_artifacts($cfg["stdin"]);
$sender = get_user($art[0]["uuid"]);
# assemble
return [
"action" => "edited",
"rule" => [
"id" => crc32($cfg["stdin"]),
"repository_id" => $rep_id,
"name" => $basename,
"created_at" => iso8601(mtime_event("MIN")),
"updated_at" => iso8601(mtime_event("MAX")),
"pull_request_reviews_enforcement_level" => "off",
"required_approving_review_count" => 0,
"dismiss_stale_reviews_on_push" => false,
"require_code_owner_review" => false,
"authorized_dismissal_actors_only" => false,
"ignore_approvals_from_contributors" => false,
"required_status_checks" => [
0 => "basic-CI",
],
"required_status_checks_enforcement_level" => "non_admins",
"strict_required_status_checks_policy" => false,
"signature_requirement_enforcement_level" => "off",
"linear_history_requirement_enforcement_level" => "off",
"admin_enforced" => false,
"allow_force_pushes_enforcement_level" => "off",
"allow_deletions_enforcement_level" => "off",
"merge_queue_enforcement_level" => "off",
"required_deployments_enforcement_level" => "off",
"required_conversation_resolution_level" => "off",
"authorized_actors_only" => true,
"authorized_actor_names" => [
0 => $user,
],
],
"changes" => [
"authorized_actors_only" => [
"from" => false,
],
"authorized_actor_names" => [
"from" => [],
],
],
"repository" => [
"id" => $rep_id,
"node_id" => base64_encode($url),
"name" => $cfg["basename"],
"full_name" => $cfg["basename"],
"private" => false,
"owner" => [
"login" => $user,
"id" => crc32($user),
"node_id" => base64_encode($user),
"avatar_url" => "$url/logo",
"gravatar_id" => "",
"url" => "$url/json/user/get/$user",
"html_url" => "$url/timeline?u=$user",
"followers_url" => NULL,
"following_url" => NULL,
"gists_url" => "$url/unversioned",
"starred_url" => NULL,
"subscriptions_url" => NULL,
"organizations_url" => NULL,
"repos_url" => dirname($url),
"events_url" => "$url/timeline",
"received_events_url" => "$url/json/timeline",
"type" => "Organization",
"site_admin" => $user==$sender,
],
"html_url" => "$url",
"description" => get_config("project-description"),
"fork" => false,
"url" => "$url",
"forks_url" => "$url",
"keys_url" => NULL,
"collaborators_url" => NULL,
"teams_url" => NULL,
"hooks_url" => "$url/ext/hooks",
"issue_events_url" => "$url/json/report/list",
"events_url" => "$url/json/timeline/event",
"assignees_url" => NULL,
"branches_url" => "$url/json/branch/list",
"tags_url" => "$url/json/tag/list",
"blobs_url" => "$url/uri-list", # json/artifact-list?
"git_tags_url" => "$url/json/tag/list",
"git_refs_url" => NULL,
"trees_url" => "$url/ext/trees",
"statuses_url" => "$url/json/status",
"languages_url" => NULL,
"stargazers_url" => NULL,
"contributors_url" => "$url/json/user/list",
"subscribers_url" => "$url/json/user/list",
"subscription_url" => "$url/json/login",
"commits_url" => "$url/json/timeline/checkin",
"git_commits_url" => "$url/json/timeline/checkin",
"comments_url" => "$url/json/forum/list",
"issue_comment_url" => "$url/json/report/list",
"contents_url" => "$url/ext/raw/{+path}",
"compare_url" => "$url/json/diff?v1={base}&v2={head}",
"merges_url" => NULL,
"archive_url" => "$url/zip",
"downloads_url" => "$url/zip",
"issues_url" => "url/json/report/list",
"pulls_url" => NULL,
"milestones_url" => NULL,
"notifications_url" => NULL,
"labels_url" => NULL,
"releases_url" => "$url/ext/project.json",
"deployments_url" => NULL,
"created_at" => iso8601(mtime_event("MIN")),
"updated_at" => iso8601(mtime_event("MAX")),
"pushed_at" => iso8601(mtime_event("MAX", "WHERE type='ci'")), # ERR??
"git_url" => NULL, #"git://github.com/octo-org/octo-repo.git",
"fossil_url" => "$url/xfer", # NON-STANDARD ;>
"ssh_url" => "$url",
"clone_url" => "$url",
"svn_url" => NULL,
"size" => filesize($cfg["repo"]) >> 10,
"stargazers_count" => 0,
"watchers_count" => 0,
"language" => language_count(),
"has_issues" => true,
"has_projects" => false,
"has_downloads" => true,
"has_wiki" => false,
"has_pages" => true,
"forks_count" => 0,
"mirror_url" => NULL,
"archived" => false,
"disabled" => false,
"open_issues_count" => $tickets = intval(db("SELECT COUNT(1) as c FROM ticket WHERE status!='Closed'")[0]["c"]),
"license" => NULL,
"forks" => 0,
"open_issues" => $tickets,
"watchers" => 0,
"default_branch" => "trunk",
],
"organization" => [
"login" => "$basename",
"id" => crc32($basename),
"node_id" => base64_encode($basename),
"url" => "$url",
"repos_url" => "$url",
"events_url" => NULL,
"hooks_url" => NULL,
"issues_url" => NULL,
"members_url" => NULL,
"public_members_url" => NULL,
"avatar_url" => NULL,
"description" => get_config("project-name"),
],
"sender" => [
"login" => $sender,
"id" => crc32($sender),
"node_id" => base64_encode($sender),
"avatar_url" => "$url/logo", # do we have a user PHOTO lookup?
"gravatar_id" => "",
"url" => "$url/json/user/get/$sender",
"html_url" => "$url/timeline?u=$sender",
"followers_url" => NULL,
"following_url" => NULL,
"gists_url" => NULL,
"starred_url" => NULL,
"subscriptions_url" => NULL,
"organizations_url" => NULL,
"repos_url" => NULL,
"events_url" => NULL,
"received_events_url" => NULL,
"type" => "User",
"site_admin" => $sender==$user,
],
];
# do...
}
/**
* Database query shorthand. (Using active fossil repository.)
*
* @param string $sql Query with placeholders
* @param array $params Bound parameters
* @param bool $fetch Immediate ->fetchAll()
* @return array|PDOStatement|PDO
*/
function db($sql="", $params=[], $fetch=TRUE) {
static $db;
global $cfg;
if (empty($db)) {
if (!preg_match("~^/\w[/\w.-]+\w\.(fs?l?|fossil|sqlite)$~", $cfg["repo"])) {
die("db(): FOSSIL_REPOSITORY doesn't look right. Abort.");
}
#$db = new PDO("sqlite::memory:");
$db = new PDO("sqlite:$cfg[repo]");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
}
if ($params) {
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $fetch ? $stmt->fetchAll(PDO::FETCH_ASSOC) : $stmt;
}
elseif ($sql) {
return $db->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
else {
return $db;
}
}
/**
* Query fossil `config` table.
*
* @param string $name Option
* @param array $default Fallback
* @return string
*/
function get_config($name, $default="") {
$r = db("SELECT value FROM config WHERE name=?", [$name]);
return $r ? $r[0]["value"] : $default;
}
#-- find public access url (or most probable at least)
function get_baseurl() {
$urls = array_column(db("SELECT SUBSTR(name,9,200) AS url FROM config WHERE name LIKE 'baseurl:%'"), "url");
if ($best = preg_grep("~//127\.|//localhost|:\d+/~i", $urls, PREG_GREP_INVERT)) {
return array_values($best)[0];
}
elseif ($best = preg_grep("~//127\.|//localhost~i", $urls, PREG_GREP_INVERT)) {
return array_values($best)[0];
}
// could alternatively try sync-with:*, but that might leak sensitive peers
else {
return $urls[0];
}
}
#-- artifact user + size
function get_blob_info($uuid) {
$r = db("
SELECT login, size
FROM blob
LEFT JOIN rcvfrom ON blob.rcvid=rcvfrom.rcvid
LEFT JOIN user ON user.uid=rcvfrom.uid
WHERE uuid = ?",
[$uuid]
);
return $r ? $r[0] : [];
}
#-- artifact owner
function get_user($uuid) {
$r = get_blob_info($uuid);
return $r ? $r["login"] : null;
}
#-- primary user
function get_main_user() {
return db("
SELECT user, COUNT(type) AS cnt
FROM event
WHERE type='ci'
GROUP BY user
ORDER BY cnt DESC"
)[0]["user"];
}
#-- basename.fossil
function get_basename() {
global $cfg;
return preg_replace("/\.\w+$/", "", basename($cfg["repo"]));
}
#-- split STDIN into rows
function get_artifacts($stdin) {
preg_match_all("/^(\w+)\s+([\w-]+)(?:\s+(.+))?$/m", $stdin, $rows, PREG_SET_ORDER);
$rows = array_map(
function($row) {
return [
"uuid" => $row[1],
"type" => $row[2],
"comment" => $row[3],
];
},
$rows
);
return $rows;
}
/**
* Expand artifact uuids.
*
* Adds access urls (raw/ and json/ API) to simplify webhook consumption.
* And fill in some basic attributes depending on type. (No need to turn
* into a full push. Though perhaps `wiki` artifacts could be sent along.)
*
*/
function expand_artifact($row, $url, $q="urlencode") {
global $cfg;
switch ($row["type"]) {
case "attachment":
case "file":
$row["name"] = $row["comment"];
$row["url_raw"] = "$url/raw/$row[uuid]?at={$q($row['name'])}";
$row["url_json"] = "$url/json/artifact/{$q($row['uuid'])}";
$row["url_finfo"] = "$url/json/finfo?name={$q($row['name'])}";
break;
case "wiki":
$row["name"] = trim($row["comment"], '"');
if (preg_match('/^"(.+)"$/', $row["comment"], $uu)) {
$row["name"] = $uu[1]; // more exact quote trimming
}
$row["url_raw"] = "$url/raw/{$q($row['uuid'])}?at={$q($row['uuid'])}"; # ERR: this still contains the artifact header
$row["url_json"] = "$url/json/wiki/get/{$q($row['name'])}";
$row["url_web"] = "$url/wiki/{$q($row['name'])}";
break;
case "check-in":
$row["url_json"] = "$url/json/artifact/{$q($row['uuid'])}";
break;
case "tag":
# `tag 123a31091fff0`
# would need artifact lookup for `T +sym-1.0.0 xxxxxxxxxxxxxxx`
case "attachment-control":
case "referenced":
default:
break;
}
if ($blob = get_blob_info($row["uuid"])) {
$cfg["user"] = $blob["user"];
$row += $blob;
}
return $row;
}
#-- check-in or file, or other artifact types
function main_action($stdin) {
foreach (["check-in", "file", "attachment", "wiki", "referenced", "tag"] as $t) {
if (preg_match("/^\w+\s$t\\b/m", $stdin)) {
return $t;
}
}
return "after-receive";
}
#-- datetime format
function iso8601($t) {
return strftime("%Y-%m-%dT%H:%M:%SZ", $t);
}
#-- get earliest or last `event` timestamp
function mtime_event($FN="MIN", $WHERE="") {
return db("SELECT strftime('%s', $FN(mtime)) AS dt FROM event $WHERE LIMIT 1")[0]["dt"];
}
#-- tests --if:type or --if:filename presence in stdin
function if_checks($if_list) {
global $cfg;
foreach ($if_list as $if) {
preg_match("/if:(.+)/", $if, $if) and $if=$if[1];
if (!preg_match("/\\b$if\\b/", $cfg["stdin"])) {
die("--if: no match for '$if'");
}
}
}
#-- select filename extension
function language_count($ext=[]) {
foreach (db("SELECT name FROM filename") as $row) {
if (preg_match("/\.(\w+)$/", $row["name"], $uu)) {
@$ext[$uu[1]] += 1;
}
}
if ($ext) {
arsort($ext);
return array_keys($ext)[0];
}
}
?>