#!/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];
    }
}

?>