#!/usr/bin/php -qC
<?php
# encoding: utf-8
# api: cli
# title: webhook
# description: to be run from fossil backoffice/after-receive hooks
# version: 0.1
# doc: https://fossil-scm.org/home/doc/trunk/www/hooks.md
#
# Meant to transform commits into common webhook JSON formats,
# and then send them off.
#
# Hook syntax:
# fossil-webhook '%R' '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)
# httpt:// webhook service endpoint (REQUIRED)
# +tok=123 inject additional parameters
#
#-- 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" => current(preg_grep("~^/\w+.+\.(fsl|fossil|sqlite)$~", $argv)),
"add" => preg_grep("~^\+[\w.-]+[:=].+$~", $argv),
"if" => preg_grep("~^-+if:[\w-]+$~i", $argv),
"url" => current(preg_grep("~^https?://.+$~i", $argv)),
"artifacts" => 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,
"user" => null,
];
if (empty($cfg["repo"]) || empty($cfg["url"])) {
die("%R or url missing");
}
if ($cfg["if"] and preg_match("/if:(.+)/", $cfg["if"][0], $uu) and !preg_match("/\\b$uu[1]\\b/", $cfg["artificts"])) {
die("--if: no match");
}
$cfg += [
"basename" => preg_replace("~\.\w+$~", "", basename($cfg["repo"])),
"baseurl" => get_baseurl(),
"title" => get_config("project-title"),
];
#-- operation flag
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)); }
send($request);
/**
* 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];
}
}
/**
* Sending
*
*/
function send($request) {
global $cfg;
#-- send query
$c = curl_init($cfg["url"]);
curl_setopt_array($c, [
CURLOPT_POST => 1,
CURLOPT_POSTFIELDS => $cfg["json"] ? json_encode($request, JSON_PRETTY_PRINT) : $request,
CURLOPT_VERBOSE => 1,
]);
$c->exec();
}
/**
* Define a fossil-style webhook payload.
*
*/
function fossil_request() {
global $cfg;
$url = get_baseurl();
return [
'$type' => "webhook",
'$class' => "vcs fossil",
'$ver' => "0.1",
"action" => main_action($cfg["artifacts"]),
"fossil" => get_config("server-code"),
"url" => $url,
"project" => [
"name" => get_basename(),
"title" => get_config("project-name"),
"description" => get_config("project-description"),
"size" => filesize($cfg["repo"]),
"id" => get_config("project-code"),
"url_stat" => "$url/json/stat",
"url_timeline" => "$url/json/timeline/checkin",
"url_dir" => "$url/json/dir",
"url_trees" => "$url/ext/trees",
],
"artifacts" => array_map(
function($row) use($url) { return expand_artifact($row, $url); },
get_artifacts($cfg["artifacts"])
),
"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" => "edited",
"url" => $url,
"name" => get_basename(),
"stdin" => $cfg["artifacts"],
"timestamp" => time(),
];
}
/**
* Simulate GitHub-style webhook.
* (Unlikely that we can fill in all the minutae.)
*
*/
function git_request() {
global $cfg;
return [
'action' => 'edited',
'rule' => [
'id' => crc32($cfg["artifacts"]),
'repository_id' => $rep_id = hexdec(substr(get_config("project-code", "12FFFF"), 0, 6)),
'name' => $basename = get_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 = get_main_user(),
],
],
'changes' => [
'authorized_actors_only' => [
'from' => false,
],
'authorized_actor_names' => [
'from' => [
],
],
],
'repository' => [
'id' => $rep_id,
'node_id' => 'MDEwOlJlcG9zaXRvcnkxNzI3MzA1MQ==',
'name' => 'octo-repo',
'full_name' => 'octo-org/octo-repo',
'private' => true,
'owner' => [
'login' => 'octo-org',
'id' => 6811672,
'node_id' => 'MDEyOk9yZ2FuaXphdGlvbjY4MTE2NzI=',
'avatar_url' => 'https://avatars.githubusercontent.com/u/6811672?v=4',
'gravatar_id' => '',
'url' => 'https://api.github.com/users/octo-org',
'html_url' => 'https://github.com/octo-org',
'followers_url' => 'https://api.github.com/users/octo-org/followers',
'following_url' => 'https://api.github.com/users/octo-org/following{/other_user}',
'gists_url' => 'https://api.github.com/users/octo-org/gists{/gist_id}',
'starred_url' => 'https://api.github.com/users/octo-org/starred{/owner}{/repo}',
'subscriptions_url' => 'https://api.github.com/users/octo-org/subscriptions',
'organizations_url' => 'https://api.github.com/users/octo-org/orgs',
'repos_url' => 'https://api.github.com/users/octo-org/repos',
'events_url' => 'https://api.github.com/users/octo-org/events{/privacy}',
'received_events_url' => 'https://api.github.com/users/octo-org/received_events',
'type' => 'Organization',
'site_admin' => false,
],
'html_url' => 'https://github.com/octo-org/octo-repo',
'description' => 'My first repo on GitHub!',
'fork' => false,
'url' => 'https://api.github.com/repos/octo-org/octo-repo',
'forks_url' => 'https://api.github.com/repos/octo-org/octo-repo/forks',
'keys_url' => 'https://api.github.com/repos/octo-org/octo-repo/keys{/key_id}',
'collaborators_url' => 'https://api.github.com/repos/octo-org/octo-repo/collaborators{/collaborator}',
'teams_url' => 'https://api.github.com/repos/octo-org/octo-repo/teams',
'hooks_url' => 'https://api.github.com/repos/octo-org/octo-repo/hooks',
'issue_events_url' => 'https://api.github.com/repos/octo-org/octo-repo/issues/events{/number}',
'events_url' => 'https://api.github.com/repos/octo-org/octo-repo/events',
'assignees_url' => 'https://api.github.com/repos/octo-org/octo-repo/assignees{/user}',
'branches_url' => 'https://api.github.com/repos/octo-org/octo-repo/branches{/branch}',
'tags_url' => 'https://api.github.com/repos/octo-org/octo-repo/tags',
'blobs_url' => 'https://api.github.com/repos/octo-org/octo-repo/git/blobs{/sha}',
'git_tags_url' => 'https://api.github.com/repos/octo-org/octo-repo/git/tags{/sha}',
'git_refs_url' => 'https://api.github.com/repos/octo-org/octo-repo/git/refs{/sha}',
'trees_url' => 'https://api.github.com/repos/octo-org/octo-repo/git/trees{/sha}',
'statuses_url' => 'https://api.github.com/repos/octo-org/octo-repo/statuses/{sha}',
'languages_url' => 'https://api.github.com/repos/octo-org/octo-repo/languages',
'stargazers_url' => 'https://api.github.com/repos/octo-org/octo-repo/stargazers',
'contributors_url' => 'https://api.github.com/repos/octo-org/octo-repo/contributors',
'subscribers_url' => 'https://api.github.com/repos/octo-org/octo-repo/subscribers',
'subscription_url' => 'https://api.github.com/repos/octo-org/octo-repo/subscription',
'commits_url' => 'https://api.github.com/repos/octo-org/octo-repo/commits{/sha}',
'git_commits_url' => 'https://api.github.com/repos/octo-org/octo-repo/git/commits{/sha}',
'comments_url' => 'https://api.github.com/repos/octo-org/octo-repo/comments{/number}',
'issue_comment_url' => 'https://api.github.com/repos/octo-org/octo-repo/issues/comments{/number}',
'contents_url' => 'https://api.github.com/repos/octo-org/octo-repo/contents/{+path}',
'compare_url' => 'https://api.github.com/repos/octo-org/octo-repo/compare/{base}...{head}',
'merges_url' => 'https://api.github.com/repos/octo-org/octo-repo/merges',
'archive_url' => 'https://api.github.com/repos/octo-org/octo-repo/{archive_format}{/ref}',
'downloads_url' => 'https://api.github.com/repos/octo-org/octo-repo/downloads',
'issues_url' => 'https://api.github.com/repos/octo-org/octo-repo/issues{/number}',
'pulls_url' => 'https://api.github.com/repos/octo-org/octo-repo/pulls{/number}',
'milestones_url' => 'https://api.github.com/repos/octo-org/octo-repo/milestones{/number}',
'notifications_url' => 'https://api.github.com/repos/octo-org/octo-repo/notifications{?since,all,participating}',
'labels_url' => 'https://api.github.com/repos/octo-org/octo-repo/labels{/name}',
'releases_url' => 'https://api.github.com/repos/octo-org/octo-repo/releases{/id}',
'deployments_url' => 'https://api.github.com/repos/octo-org/octo-repo/deployments',
'created_at' => '2014-02-28T02:42:51Z',
'updated_at' => '2021-03-11T14:54:13Z',
'pushed_at' => '2021-03-11T14:54:10Z',
'git_url' => 'git://github.com/octo-org/octo-repo.git',
'ssh_url' => 'org-6811672@github.com:octo-org/octo-repo.git',
'clone_url' => 'https://github.com/octo-org/octo-repo.git',
'svn_url' => 'https://github.com/octo-org/octo-repo',
'homepage' => '',
'size' => 300,
'stargazers_count' => 0,
'watchers_count' => 0,
'language' => 'C',
'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' => 39,
'license' => NULL,
'forks' => 0,
'open_issues' => 39,
'watchers' => 0,
'default_branch' => 'trunk',
],
'organization' => [
'login' => 'octo-org',
'id' => 6811672,
'node_id' => 'MDEyOk9yZ2FuaXphdGlvbjY4MTE2NzI=',
'url' => 'https://api.github.com/orgs/octo-org',
'repos_url' => 'https://api.github.com/orgs/octo-org/repos',
'events_url' => 'https://api.github.com/orgs/octo-org/events',
'hooks_url' => 'https://api.github.com/orgs/octo-org/hooks',
'issues_url' => 'https://api.github.com/orgs/octo-org/issues',
'members_url' => 'https://api.github.com/orgs/octo-org/members{/member}',
'public_members_url' => 'https://api.github.com/orgs/octo-org/public_members{/member}',
'avatar_url' => 'https://avatars.githubusercontent.com/u/6811672?v=4',
'description' => 'Working better together!',
],
'sender' => [
'login' => $user,
'id' => crc32($user),
'node_id' => base64_encode($user),
'avatar_url' => 'https://avatars1.githubusercontent.com/u/21031067?v=4',
'gravatar_id' => '',
'url' => 'https://api.github.com/users/Codertocat',
'html_url' => 'https://github.com/Codertocat',
'followers_url' => 'https://api.github.com/users/Codertocat/followers',
'following_url' => 'https://api.github.com/users/Codertocat/following{/other_user}',
'gists_url' => 'https://api.github.com/users/Codertocat/gists{/gist_id}',
'starred_url' => 'https://api.github.com/users/Codertocat/starred{/owner}{/repo}',
'subscriptions_url' => 'https://api.github.com/users/Codertocat/subscriptions',
'organizations_url' => 'https://api.github.com/users/Codertocat/orgs',
'repos_url' => 'https://api.github.com/users/Codertocat/repos',
'events_url' => 'https://api.github.com/users/Codertocat/events{/privacy}',
'received_events_url' => 'https://api.github.com/users/Codertocat/received_events',
'type' => 'User',
'site_admin' => false,
],
];
# 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;
}
#-- public access url
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("~localhost|:\d+/~i", $urls, PREG_GREP_INVERT)) {
return array_values($best)[0];
}
elseif ($best = preg_grep("~:\d+/~i", $urls, PREG_GREP_INVERT)) {
return array_values($best)[0];
}
else {
return $urls[0];
}
}
#-- artifact owner
function get_user($uuid) {
$r = db("
SELECT login
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]["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($art) {
preg_match_all("/^(\w+)\s+([\w-]+)(?:\s+(.+))?$/m", $art, $rows, PREG_SET_ORDER);
$rows = array_map(
function($row) {
return [
"uuid" => $row[1],
"type" => $row[2],
"comment" => $row[3],
];
},
$rows
);
return $rows;
}
#-- turn uuid into appropriate url
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['name'])}";
break;
case "wiki":
$row["name"] = $row["comment"];
$row["url_web"] = "$url/wiki/{$q($row['name'])}";
$row["url_json"] = "$url/json/wiki/get/{$q($row['name'])}";
break;
case "check-in":
break;
case "attachment-control":
case "tag":
case "referenced":
default:
break;
}
$cfg["user"] = $row["user"] = get_user($row["uuid"]);
return $row;
}
#-- check-in or file, or other artifact types
function main_action($art) {
foreach (["check-in", "file", "attachment", "wiki", "referenced", "tag"] as $t) {
if (preg_match("/^\w+\s$t\\b/m", $art)) {
return $t;
}
}
return "after-receive";
}
function iso8601($t) {
return strftime("%Y-%m-%dT%H:%M:%SZ", $t);
}
function mtime_event($FN="MIN") {
return db("SELECT strftime('%s', $FN(mtime)) AS dt FROM event LIMIT 1")[0]["dt"];
}
?>