diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..63a49ec1622972a2a7a0413b1928e42b219035e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.cache/ +/.idea/ diff --git a/bin/glr b/bin/glr new file mode 100755 index 0000000000000000000000000000000000000000..04eb259d8e8420e6054f7e9dd38278a024bfc64f --- /dev/null +++ b/bin/glr @@ -0,0 +1,60 @@ +#!/usr/bin/env pogo --dl=_SCRIPTDIR_/../.cache/glr +<?php +namespace Clippy; + +// About: Publish a release on Gitlab +// Usage: ./glr upload <project-url> <version> <asset-files>* +// Example: ./glr https://lab.civicrm.org/foo/bar 1.2.3 foobar-1.2.3.zip + +require_once pogo_script_dir() . '/../src/clippy.php'; + +#!require { mnapoli/silly: ~1.7, php: '>=7.0', guzzlehttp/guzzle: ~6.0 } +use Symfony\Component\Console\Style\SymfonyStyle; + +function labProject($project, $io) { + assertThat(preg_match(';https?://[^/]+/[^/]+/[^/]+;', $project), "Project URL should match pattern: https:///DOMAIN/OWNER/REPO"); + list ($scheme, , $host, $owner, $repo) = explode('/', $project); + + $lab = new \GuzzleHttp\Client([ + 'base_uri' => "{$scheme}//{$host}/api/v4/projects/{$owner}%2F{$repo}/", + 'headers' => [ + 'PRIVATE-TOKEN' => assertVal(cred('PRIVATE_TOKEN', $host, $io), 'Missing PRIVATE_TOKEN'), + ], + ]); + return $lab; +} + +$app = new \Silly\Application(); +$app->command('upload projectUrl verNum assets*', function ($projectUrl, $verNum, $assets, SymfonyStyle $io) { + $labProject = labProject($projectUrl, $io); + assertThat(preg_match('/^\d[0-9a-z\.\-\+]*$/', $verNum)); + $io->writeln(sprintf("<info>Release project <comment>%s</comment> at version <comment>%s</comment>:\n<comment> * %s</comment></info>", $projectUrl, $verNum, implode("\n * ", $assets))); + + $existingAssets = fromJSON($labProject->get('releases/' . urlencode($verNum) . '/assets/links')); + $existingAssets = index(['name'], $existingAssets); + + foreach ($assets as $asset) { + assertThat(file_exists($asset), "File $asset does not exist"); + $upload = fromJSON($labProject->post('uploads', [ + 'multipart' => [ + ['name' => 'file', 'contents' => fopen($asset, 'r')], + ], + ])); + $io->writeln("<info>Created new upload:</info>\n" . toJSON($upload)); + + if (isset($existingAssets[basename($asset)])) { + $delete = fromJSON($labProject->delete('releases/' . urlencode($verNum) . '/assets/links/' . $existingAssets[basename($asset)]['id'])); + $io->writeln("<info>Deleted old upload:</info>\n" . toJSON($delete)); + // Should we also delete the previous upload? Is that possible? + } + + $release = fromJSON($labProject->post('releases/' . urlencode($verNum) . '/assets/links', [ + 'form_params' => [ + 'name' => basename($asset), + 'url' => joinUrl($projectUrl, $upload['url']), + ], + ])); + $io->writeln("<info>Updated release:</info>\n" . toJSON($release)); + } +});; +$app->run(); diff --git a/src/clippy.php b/src/clippy.php new file mode 100644 index 0000000000000000000000000000000000000000..c6f2fdf9a6be66a9ab42f9a43fb7cc6ddaacea0f --- /dev/null +++ b/src/clippy.php @@ -0,0 +1,177 @@ +<?php +namespace Clippy; + +use Symfony\Component\Console\Style\SymfonyStyle; + +// ----------------------------------------------------------------------------- +// Assertions + +/** + * Assert that $bool is true. + * + * @param bool $bool + * @param string $msg + * @throws \Exception + */ +function assertThat($bool, $msg = '') { + if (!$bool) { + throw new \Exception($msg ? $msg : 'Assertion failed'); + } +} + +/** + * Assert that $value has an actual value (not null or empty-string) + * + * @param mixed $value + * @param string $msg + * @return mixed + * The approved value + * @throws \Exception + */ +function assertVal($value, $msg) { + if ($value === NULL || $value === '') { + throw new \Exception($msg ? $msg : 'Missing expected value'); + } + return $value; +} + +function fail($msg) { + throw new \Exception($msg ? $msg : 'Assertion failed'); +} + +// ----------------------------------------------------------------------------- +// IO utilities + +/** + * Combine all elements of part, in order, to form a string - using path delimiters. + * Duplicate delimiters are trimmed. + * + * @param array $parts + * A list of strings and/or arrays. + * @return string + */ +function joinPath(...$parts) { + $path = []; + foreach ($parts as $part) { + if (is_array($part)) { + $path = array_merge($path, $part); + } + else { + $path[] = $part; + } + } + $result = implode(DIRECTORY_SEPARATOR, $parts); + $both = "[\\/]"; + return preg_replace(";{$both}{$both}+;", '/', $result); +} + +/** + * Combine all elements of part, in order, to form a string - using URL delimiters. + * Duplicate delimiters are trimmed. + * + * @param array $parts + * A list of strings and/or arrays. + * @return string + */ +function joinUrl(...$parts) { + $path = []; + foreach ($parts as $part) { + if (is_array($part)) { + $path = array_merge($path, $part); + } + else { + $path[] = $part; + } + } + $result = implode('/', $parts); + return preg_replace(';//+;', '/', $result); +} + +function toJSON($data) { + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +} + +function fromJSON($data) { + #!require psr/http-message: * + if ($data instanceof \Psr\Http\Message\ResponseInterface) { + $data = $data->getBody()->getContents(); + } + assertThat(is_string($data)); + $result = json_decode($data, 1); + assertThat($result !== NULL || $data === 'null', sprintf("JSON parse error:\n----\n%s\n----\n", $data)); + return $result; +} + +// ----------------------------------------------------------------------------- +// Array utilities + +/** + * Builds an array-tree which indexes the records in an array. + * + * @param string[] $keys + * Properties by which to index. + * @param object|array $records + * + * @return array + * Multi-dimensional array, with one layer for each key. + */ +function index($keys, $records) { + $final_key = array_pop($keys); + + $result = []; + foreach ($records as $record) { + $node = &$result; + foreach ($keys as $key) { + if (is_array($record)) { + $keyvalue = isset($record[$key]) ? $record[$key] : NULL; + } + else { + $keyvalue = isset($record->{$key}) ? $record->{$key} : NULL; + } + if (isset($node[$keyvalue]) && !is_array($node[$keyvalue])) { + $node[$keyvalue] = []; + } + $node = &$node[$keyvalue]; + } + if (is_array($record)) { + $node[$record[$final_key]] = $record; + } + else { + $node[$record->{$final_key}] = $record; + } + } + return $result; +} + +// ----------------------------------------------------------------------------- +// Higher level services + +/** + * @param string $name + * Environment variable + * @param string $context + * @param \Symfony\Component\Console\Style\SymfonyStyle|NULL $io + * @return mixed|null|string + */ +function cred($name, $context = 'default', SymfonyStyle $io = NULL) { + if (getenv($name)) { + return getenv($name); + } + $storage = joinPath(getenv('HOME'), '.config', 'clippy-cred', urlencode($context) . '.json'); + if (file_exists($storage)) { + $data = fromJSON(file_get_contents($storage)); + if (isset($data[$name])) { + return $data[$name]; + } + } + if ($io) { + if ($storage) { + $io->note("Credential $name not found in environment"); + $io->note("Credential $name not found in $storage"); + } + $pass = $io->askHidden(sprintf('Please enter credential %s for %s:', $name, $context)); + // TODO save + return $pass; + } + return NULL; +}