xaizek / fragile (License: AGPLv3+) (since 2018-12-07)
Simple lightweight CI, attempting to be somewhat Unix-like in its philosophy.
<root> / daemon.php (ea2cee1dd0c427fc1d8b4292ac49e92eb8d42e79) (5,791B) (mode 100644) [raw]
<?php
// Copyright (C) 2015 xaizek <xaizek@openmailbox.org>
//
// fragile is free software: you can redistribute it and/or modify it under the
// terms of the GNU Affero General Public License as published by the Free
// Software Foundation, version 3.
//
// fragile is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
// A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
// details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.

require_once __DIR__ . '/classes/Builds.php';
require_once __DIR__ . '/classes/Buildset.php';
require_once __DIR__ . '/config.php';

// TODO: maybe mark all "running" builds as failed

// TODO: maybe extract Builders and Builder classes

if (!putenv('FRAGILE_REPO=' . REPO_PATH)) {
    die("Failed to set FRAGILE_REPO environment variable\n");
}

prepareRepository();
serve();

/**
 * @brief Clones repository if it doesn't exist yet.
 */
function prepareRepository()
{
    if (!createPath(REPO_PATH)) {
        return;
    }

    system(__DIR__ . "/vcs/clone '" . REPO_URL . "'", $retval);
    if ($retval != 0) {
        delTree(REPO_PATH);
        die("Failed to clone repository\n");
    }
}

/**
 * @brief Removes subtree.
 *
 * @param dir Directory path to remove.
 *
 * @returns Result of rmdir().
 */
function delTree($dir)
{
    $files = array_diff(scandir($dir), ['.', '..']);
    foreach ($files as $file) {
        (is_dir("$dir/$file")) ? delTree("$dir/$file") : unlink("$dir/$file");
    }
    return rmdir($dir);
}

/**
 * @brief Performs infinite loop of running builds.
 */
function serve()
{
    while (true) {
        $builds = Builds::getPendingBuilds();
        while (empty($builds)) {
            sleep(DAEMON_TIMEOUT);
            $builds = Builds::getPendingBuilds();
        }

        runBuilds($builds);
    }
}

/**
 * @brief Executes all pending builds.
 *
 * @param builds List of builds to run.
 */
function runBuilds($builds)
{
    $builders = [];
    foreach ($builds as $build) {
        $builders[$build->buildername] = $build;
    }
    // sort builders by their name
    uksort($builders, "Builds::builderCmp");

    foreach ($builders as $build) {
        $buildset = Buildset::get($build->buildset);

        if (!putenv('FRAGILE_REF=' . $buildset->name)) {
            die("Failed to set FRAGILE_REF environment variable\n");
        }

        // checkout revision
        system(__DIR__ . "/vcs/checkout '" . $build->revision . "'", $retval);
        if ($retval != 0) {
            $build->setResult('ERROR', "Failed to checkout revision\n",
                $retval);
            continue;
        }

        runBuild($build);
    }
}

/**
 * @brief Executes a build managing its status and output information.
 *
 * @param build Build to perform.
 */
function runBuild($build)
{
    // TODO: measure and record execution time of the build
    // TODO: record time and date of the build

    $build->setStatus('running');

    $rawOutput = '';

    $buildPath = BUILDS_PATH . "/$build->buildername";
    createPath($buildPath);

    $handle = popen("cd $buildPath && "
                  . BUILDERS_PATH . '/' . $build->buildername . ' 2>&1', 'r');
    while (!feof($handle)) {
        $rawOutput .= fgets($handle);
        // TODO: append output to database record every N lines (e.g. 100)
    }
    $exitcode = pclose($handle);

    $output = makeReport($rawOutput);
    $build->setResult(($exitcode == 0) ? 'OK' : 'FAIL', $output, $exitcode);
}

/**
 * @brief Creates directory if it doesn't exist.
 *
 * @param path Path to create.
 *
 * @returns @c true if it was created, otherwise @c false is returned.
 */
function createPath($path)
{
    if (is_dir($path)) {
        return false;
    }

    if (!mkdir($path, 0700, true)) {
        die("Failed to create directory: $path\n");
    }
    return true;
}

/**
 * @brief Formats output to produce build report.
 *
 * Result consists of two parts separated by double newline symbol.  The first
 * parts contains index of errors and warnings, the second one is formatted
 * output.
 *
 * @param rawOutput Output from builder script as is.
 *
 * @returns Multiline build report.
 */
function makeReport($rawOutput)
{
    $errors = [];
    $warnings = [];

    $input = preg_split('/\n/', $rawOutput);
    $output = [];
    $msgnum = 1;
    foreach ($input as $line) {
        $re = '/^(.*)(error|warning|Error|Warning|ERROR|WARNING|ERROR SUMMARY)(:\s+)(.*)$/';
        preg_match($re, $line, $matches);
        if (sizeof($matches) == 0 || $matches[4] == '0') {
            array_push($output, $line);
            continue;
        }

        $anchor = "<a name='m$msgnum'/>";
        $link = "<a href='#m$msgnum'>" . htmlentities($matches[4]) . "</a>";

        if (strcasecmp($matches[2], 'error') == 0) {
            array_push($errors, $link);
            $style = 'error';
        } else {
            array_push($warnings, $link);
            $style = 'warning';
        }

        $line = "$matches[1]"
              . "<span class='$style-title'>$matches[2]</span>"
              . "$matches[3]"
              . "<span class='$style-msg'>$matches[4]</span>";

        array_push($output, $anchor . $line);

        ++$msgnum;
    }

    $header = '';
    if (sizeof($errors) != 0) {
        $header .= "Errors:<ol><li>";
        $header .= join("</li><li>", $errors);
        $header .= "</li></ol>\n";
    }
    if (sizeof($warnings) != 0) {
        $header .= "Warnings:<ol><li>";
        $header .= join("</li><li>", $warnings);
        $header .= "</li></ol>\n";
    }

    return $header . "\n\n" . join("\n", $output);
}

?>
Hints

Before first commit, do not forget to setup your git environment:
git config --global user.name "your_name_here"
git config --global user.email "your@email_here"

Clone this repository using HTTP(S):
git clone https://code.reversed.top/user/xaizek/fragile

Clone this repository using ssh (do not forget to upload a key first):
git clone ssh://rocketgit@code.reversed.top/user/xaizek/fragile

You are allowed to anonymously push to this repository.
This means that your pushed commits will automatically be transformed into a pull request:
... clone the repository ...
... make some changes and some commits ...
git push origin master