├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── action.php ├── bin ├── build └── create_icons.php ├── composer.json ├── curl.php ├── icon.png ├── icons ├── actions.png ├── branch.png ├── clone.png ├── codespaces.png ├── commits.png ├── dashboard.png ├── discussions.png ├── file.png ├── fork.png ├── gists.png ├── graphs.png ├── issue.png ├── logout.png ├── milestone.png ├── mirror.png ├── notifications.png ├── organization.png ├── private-fork.png ├── private-mirror.png ├── private-repo.png ├── project.png ├── pull-request.png ├── pulse.png ├── releases.png ├── repo.png ├── search.png ├── settings.png ├── stars.png ├── update.png ├── user.png └── wiki.png ├── info.plist ├── item.php ├── package.json ├── screenshot.png ├── search.php ├── server.php └── workflow.php /.gitignore: -------------------------------------------------------------------------------- 1 | /.php-cs-fixer.cache 2 | 3 | /composer.lock 4 | /vendor/ 5 | 6 | /yarn.lock 7 | /node_modules/ 8 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 5 | ->setRules([ 6 | '@Symfony' => true, 7 | '@Symfony:risky' => true, 8 | 'empty_loop_body' => ['style' => 'semicolon'], 9 | 'method_argument_space' => false, 10 | 'modernize_strpos' => false, 11 | 'native_constant_invocation' => false, 12 | 'no_superfluous_phpdoc_tags' => false, 13 | 'psr_autoloading' => false, 14 | 'no_useless_else' => true, 15 | ]) 16 | ->setFinder((new PhpCsFixer\Finder())->in(__DIR__)) 17 | ; 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Version 1.9.1 – 2024-12-05 5 | -------------------------- 6 | 7 | ### Bugfix 8 | 9 | * Fix for PHP 8.4 (@romantech) 10 | * Fix for branch names containing special chars like `#` 11 | 12 | 13 | Version 1.9 – 2024-02-04 14 | ------------------------ 15 | 16 | ### Features 17 | 18 | * Better support for Alfred 5 19 | * `gh my stars` opens `/?tab=stars` instead of `/stars` 20 | 21 | 22 | Version 1.8.1 – 2022-10-16 23 | -------------------------- 24 | 25 | ### Bugfix 26 | 27 | * Fixed version number in workflow 28 | 29 | 30 | Version 1.8 – 2022-10-15 31 | ------------------------ 32 | 33 | ### Features 34 | 35 | * New command `gh user/repo dev` opening repo in Visual Studio Code (Codespaces) 36 | * New command `gh user/repo discussions` 37 | * New command `gh my repos new` 38 | * Search issues by title (`gh user/repo #search`) 39 | * Archived repos with prefix `[Archived]` in subtitle 40 | * Performance improvement 41 | 42 | ### Bugfixes 43 | 44 | * Fix result display for non-PR issue IDs (@tobias-grasse) 45 | * Fix git clone url, use new `x-github-client://` scheme 46 | 47 | 48 | Version 1.7.1 – 2022-01-01 49 | -------------------------- 50 | 51 | ### Bugfixes 52 | 53 | * Fix deprecation warning when using PHP 8.1 54 | 55 | 56 | Version 1.7 – 2021-10-26 57 | ------------------------ 58 | 59 | ### Features 60 | 61 | * Support for macOS 12 Montery: PHP is no longer pre-installed, you must install it by yourself via [Homebrew](https://brew.sh) (`brew install php`) 62 | * Better support for Alfred 4 63 | * new command `gh user/repo actions` (@Attsun1031) 64 | * command `gh user/repo new issue` lands on issue template selector page (@riastrad) 65 | 66 | 67 | Version 1.6.2 – 2018-02-13 68 | -------------------------- 69 | 70 | ### Bugfixes 71 | 72 | * Api pagination didn't work correctly anymore (missing results from page > 2) 73 | 74 | 75 | Version 1.6.1 – 2017-09-23 76 | -------------------------- 77 | 78 | ### Bugfixes 79 | 80 | * Support for macOS 10.13 High Sierra 81 | * Commit search results had wrong urls on GitHub Enterprise (@beparker) 82 | 83 | 84 | Version 1.6 – 2017-05-07 85 | ------------------------ 86 | 87 | ### Features 88 | 89 | * new command `gh user/repo projects` (@dagio) 90 | * new command `gh my pulls review requested` (@AeroEchelon) 91 | * better sorting for issues (most recently updated on top) and commits (most recent on top) (@danielma) 92 | 93 | ### Bugfixes 94 | 95 | * On macOS 10.12.5 Beta URLs didn't opened in browser anymore 96 | 97 | 98 | Version 1.5 – 2016-12-13 99 | ------------------------ 100 | 101 | ### Features 102 | 103 | * new commands for searching repos and users globally in GitHub (`gh s repo` and `gh s @user`) 104 | * new command `gh my repos` (@jacobkossman) 105 | * new command `gh > delete database` 106 | * source repos with higher priority than forks 107 | 108 | ### Bugfixes 109 | 110 | * in some situations private repos were missing (@lxynox) 111 | * after saving GitHub Enterprise url the workflow didn't reopen correctly 112 | * updated user sub commands ("Activity" tab does not exist any more on GitHub) 113 | 114 | 115 | Version 1.4.1 – 2016-22-07 116 | -------------------------- 117 | 118 | * fixed reading environment variables (important for hotkey support) 119 | 120 | 121 | Version 1.4 – 2016-22-07 122 | ------------------------ 123 | 124 | * Hotkey support 125 | * use native update mechanism of Alfred (to keep your hotkeys) 126 | * new command `gh user/repo releases` (@altern8tif) 127 | * cache warmup after login 128 | * lower cpu usage in multi curl 129 | * fixed autocomplete values in GitHub Enterprise 130 | 131 | 132 | Version 1.3 – 2016-17-07 133 | ------------------------ 134 | 135 | **Important:** This is the last version for Alfred 2. 136 | 137 | * Disabled updates in Alfred 2 138 | * Updates in Alfred 3 are loaded from new location (GitHub releases) 139 | 140 | 141 | Version 1.2 – 2016-04-17 142 | ------------------------ 143 | 144 | ### Features 145 | 146 | * New sub commands for `gh my issues/pull`: `created`, `assigned` and `mentioned` 147 | * New help command: `gh > help` 148 | * Longer cache lifetime 149 | 150 | 151 | Version 1.1 – 2015-01-10 152 | ------------------------ 153 | 154 | ### Features 155 | 156 | * GitHub Enterprise support (use `ghe`) 157 | * Commit search (`gh user/repo *hash`) 158 | 159 | ### Bugfixes 160 | 161 | * A space after `gh` is required to avoid confusion when using commands of other workflows like `ghost` 162 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Gregor Harlan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | GitHub Workflow for [Alfred](http://www.alfredapp.com) 2 | ============================ 3 | 4 | You can search through GitHub (`gh`) and your GitHub Enterprise instance (`ghe`). 5 | 6 | **[DOWNLOAD](https://github.com/gharlan/alfred-github-workflow/releases)** 7 | 8 | ![Workflow Screenshot](screenshot.png) 9 | 10 | Setup 11 | ----- 12 | 13 | This workflow requires PHP, which is no longer pre-installed since macOS 12 Montery. 14 | You can install it via [Homebrew](https://brew.sh) (`brew install php`). 15 | 16 | ### For github.com 17 | 18 | In Alfred type (`gh > login`) to authenticate against your account. The login uses OAuth, so you do not have to enter your credentials. 19 | 20 | ### For github enterprise 21 | 22 | 1. In Alfred type (`ghe > url https://github.mycompany.com`) 23 | 2. Create a new Personal Access Token (`ghe > generate token` or `https://github.mycompany.com/settings/applications`). It only needs access to your repos. Copy this token to your clipboard. 24 | 3. In Alfred type (`ghe > login `) 25 | 4. You can now `ghe your_enterprise_repo_name` 26 | 27 | ### Access to private repositories in organizations 28 | 29 | Organizations must approve this app, otherwise private repositories of that organization can not be accessed. Access can be requested [here](https://github.com/settings/connections/applications/2d4f43826cb68e11c17c). 30 | 31 | Key Combinations 32 | ---------------- 33 | 34 | Key Combination | Action 35 | ---------------------- | ------ 36 | `enter` | Open entry in default browser 37 | `cmd` + `c` | Copy URL of the entry 38 | `cmd` + `enter` | Paste URL to front most app 39 | `shift` or `cmd` + `y` | Open URL in QuickLook 40 | 41 | Commands 42 | -------- 43 | 44 | To search through your GitHub Enterprise instance replace `gh` by `ghe`. 45 | 46 | ### Repo commands 47 | 48 | * `gh user/repo` 49 | * `gh user/repo #123` 50 | * `gh user/repo @branch` 51 | * `gh user/repo *commit` 52 | * `gh user/repo /path/to/file` 53 | * `gh user/repo actions` 54 | * `gh user/repo admin` 55 | * `gh user/repo clone` 56 | * `gh user/repo dev` 57 | * `gh user/repo discussions` 58 | * `gh user/repo graphs` 59 | * `gh user/repo issues` 60 | * `gh user/repo milestones` 61 | * `gh user/repo network` 62 | * `gh user/repo new issue` 63 | * `gh user/repo new pull` 64 | * `gh user/repo projects` 65 | * `gh user/repo pulls` 66 | * `gh user/repo pulse` 67 | * `gh user/repo releases` 68 | * `gh user/repo wiki` 69 | * `gh user/repo projects` 70 | 71 | ### User commands 72 | 73 | * `gh @user` 74 | * `gh @user overview` 75 | * `gh @user repositories` 76 | * `gh @user stars` 77 | * `gh @user gists` 78 | 79 | ### Search commands 80 | 81 | * `gh s repo` 82 | * `gh s @user` 83 | 84 | ### "My" commands 85 | 86 | * `gh my dashboard` 87 | * `gh my notifications` 88 | * `gh my profile` 89 | * `gh my issues` 90 | * `gh my issues created` 91 | * `gh my issues assigned` 92 | * `gh my issues mentioned` 93 | * `gh my pulls` 94 | * `gh my pulls created` 95 | * `gh my pulls assigned` 96 | * `gh my pulls mentioned` 97 | * `gh my pulls review requested` 98 | * `gh my repos` 99 | * `gh my repos new` 100 | * `gh my settings` 101 | * `gh my stars` 102 | * `gh my gists` 103 | 104 | ### Workflow commands 105 | 106 | * `gh > login` 107 | * `gh > logout` 108 | * `gh > delete cache` 109 | * `gh > delete database` 110 | * `gh > update` 111 | * `gh > activate autoupdate` 112 | * `gh > deactivate autoupdate` 113 | * `gh > help` 114 | * `gh > changelog` 115 | * `ghe > url` (GitHub Enterprise only) 116 | * `ghe > generate token` (GitHub Enterprise only) 117 | * `ghe > enterprise reset` (GitHub Enterprise only) 118 | -------------------------------------------------------------------------------- /action.php: -------------------------------------------------------------------------------- 1 | ' !== $query[0] && 0 !== strpos($query, 'e >')) { 8 | if ('.git' == substr($query, -4)) { 9 | $query = 'x-github-client://openRepo/'.substr($query, 0, -4); 10 | } 11 | exec('open '.$query); 12 | 13 | return; 14 | } 15 | 16 | $enterprise = 0 === strpos($query, 'e '); 17 | if ($enterprise) { 18 | $query = substr($query, 2); 19 | } 20 | $parts = explode(' ', $query); 21 | 22 | Workflow::init($enterprise); 23 | 24 | switch ($parts[1]) { 25 | case 'enterprise-url': 26 | Workflow::setConfig('enterprise_url', rtrim($parts[2], '/')); 27 | exec('osascript -e "tell application \"Alfred\" to search \"ghe \""'); 28 | break; 29 | 30 | case 'enterprise-reset': 31 | Workflow::removeConfig('enterprise_url'); 32 | Workflow::removeConfig('enterprise_access_token'); 33 | Workflow::deleteCache(); 34 | break; 35 | 36 | case 'login': 37 | if (isset($parts[2]) && $parts[2]) { 38 | Workflow::setAccessToken($parts[2]); 39 | echo 'Successfully logged in'; 40 | } elseif (!$enterprise) { 41 | Workflow::startServer(); 42 | $state = version_compare(PHP_VERSION, '5.4', '<') ? 'm' : ''; 43 | $url = Workflow::getBaseUrl().'/login/oauth/authorize?client_id=2d4f43826cb68e11c17c&scope=repo&state='.$state; 44 | exec('open '.escapeshellarg($url)); 45 | } 46 | break; 47 | 48 | case 'logout': 49 | Workflow::removeAccessToken(); 50 | Workflow::deleteCache(); 51 | echo 'Successfully logged out'; 52 | break; 53 | 54 | case 'delete-cache': 55 | Workflow::deleteCache(); 56 | echo 'Successfully deleted cache'; 57 | break; 58 | 59 | case 'delete-database': 60 | Workflow::deleteDatabase(); 61 | echo 'Successfully deleted database'; 62 | break; 63 | 64 | case 'refresh-cache': 65 | $curl = new Curl(); 66 | foreach (explode(',', $parts[2]) as $url) { 67 | Workflow::requestCache($url, $curl, null, false, 0, false); 68 | } 69 | $curl->execute(); 70 | Workflow::cleanCache(); 71 | break; 72 | 73 | case 'activate-autoupdate': 74 | Workflow::setConfig('autoupdate', 1); 75 | echo 'Activated auto updating'; 76 | break; 77 | 78 | case 'deactivate-autoupdate': 79 | Workflow::setConfig('autoupdate', 0); 80 | echo 'Deactivated auto updating'; 81 | break; 82 | 83 | case 'update': 84 | $release = json_decode(Workflow::request('https://api.github.com/repos/gharlan/alfred-github-workflow/releases/latest')); 85 | if (!isset($release->assets[0]->browser_download_url)) { 86 | echo 'Update failed'; 87 | exit; 88 | } 89 | $response = Workflow::request($release->assets[0]->browser_download_url, null, null, false); 90 | if (!$response) { 91 | echo 'Update failed'; 92 | exit; 93 | } 94 | $path = getenv('alfred_workflow_data').'/github.alfredworkflow'; 95 | file_put_contents($path, $response); 96 | exec('open '.escapeshellarg($path)); 97 | break; 98 | } 99 | -------------------------------------------------------------------------------- /bin/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | addFile($dir.'/'.$file, $file); 39 | } 40 | foreach (glob($dir.'/icons/*.png') as $path) { 41 | $zip->addFile($path, 'icons/'.basename($path)); 42 | } 43 | 44 | $zip->compressFiles(Phar::GZ); 45 | 46 | $workflow = $dir.'/github.alfredworkflow'; 47 | if (file_exists($workflow)) { 48 | unlink($workflow); 49 | } 50 | rename($zipFile, $workflow); 51 | -------------------------------------------------------------------------------- /bin/create_icons.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | [ 7 | 'mark-github-16' => 'github', 8 | 9 | 'repo-24' => 'repo', 10 | 'repo-forked-24' => 'fork', 11 | 'mirror-24' => 'mirror', 12 | 13 | 'person-24' => 'user', 14 | 'organization-24' => 'organization', 15 | 'star-24' => 'stars', 16 | 'logo-gist-16' => 'gists', 17 | 18 | 'issue-opened-24' => 'issue', 19 | 'git-pull-request-24' => 'pull-request', 20 | 'comment-discussion-24' => 'discussions', 21 | 'milestone-24' => 'milestone', 22 | 'play-24' => 'actions', 23 | 'codespaces-24' => 'codespaces', 24 | 'file-24' => 'file', 25 | 'graph-24' => 'graphs', 26 | 'pulse-24' => 'pulse', 27 | 'project-24' => 'project', 28 | 'book-24' => 'wiki', 29 | 'git-commit-24' => 'commits', 30 | 'git-branch-24' => 'branch', 31 | 'repo-clone-16' => 'clone', 32 | 'tag-24' => 'releases', 33 | 34 | 'megaphone-24' => 'dashboard', 35 | 'gear-24' => 'settings', 36 | 'bell-24' => 'notifications', 37 | 38 | 'search-24' => 'search', 39 | 40 | 'download-24' => 'update', 41 | 'sign-out-24' => 'logout', 42 | ], 43 | '#e9dba5' => [ 44 | 'repo-24' => 'private-repo', 45 | 'repo-forked-24' => 'private-fork', 46 | 'mirror-24' => 'private-mirror', 47 | ], 48 | ]; 49 | 50 | $dir = __DIR__.'/../icons/'; 51 | 52 | $baseImg = new Imagick(); 53 | $baseImg->newImage(256, 256, new ImagickPixel('transparent')); 54 | $baseImg->setImageFormat('png'); 55 | 56 | $draw = new ImagickDraw(); 57 | $draw->setFillColor('#444444'); 58 | $draw->roundRectangle(0, 0, 256, 256, 50, 50); 59 | $baseImg->drawImage($draw); 60 | 61 | foreach ($icons as $color => $set) { 62 | foreach ($set as $svgName => $name) { 63 | $img = clone $baseImg; 64 | 65 | $file = file_get_contents(__DIR__.'/../node_modules/@primer/octicons/build/svg/'.$svgName.'.svg'); 66 | $file = str_replace('setBackgroundColor(new ImagickPixel('transparent')); 72 | $svg->readImageBlob($png); 73 | 74 | $x = (int) ((256 - $svg->getImageWidth()) / 2); 75 | $y = (int) ((256 - $svg->getImageHeight()) / 2); 76 | 77 | $img->compositeImage($svg, Imagick::COMPOSITE_DEFAULT, $x, $y); 78 | $img->writeImage($dir.$name.'.png'); 79 | } 80 | } 81 | 82 | rename($dir.'github.png', __DIR__.'/../icon.png'); 83 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require-dev": { 3 | "friendsofphp/php-cs-fixer": "^3.48" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /curl.php: -------------------------------------------------------------------------------- 1 | requests[$request->url] = $request; 15 | if ($this->running) { 16 | $this->addHandle($request); 17 | } 18 | } 19 | 20 | public function execute() 21 | { 22 | $this->running = true; 23 | if (!is_resource(self::$multiHandle)) { 24 | self::$multiHandle = curl_multi_init(); 25 | } 26 | 27 | foreach ($this->requests as $request) { 28 | $this->addHandle($request); 29 | } 30 | 31 | $finish = false; 32 | $running = true; 33 | do { 34 | $finish = !$running; 35 | while (CURLM_CALL_MULTI_PERFORM == $execrun = curl_multi_exec(self::$multiHandle, $running)); 36 | 37 | if (CURLM_OK != $execrun) { 38 | break; 39 | } 40 | while ($done = curl_multi_info_read(self::$multiHandle)) { 41 | $ch = $done['handle']; 42 | $info = curl_getinfo($ch); 43 | $url = self::getHeader($info['request_header'], 'X-Url'); 44 | $request = $this->requests[$url]; 45 | $rawResponse = curl_multi_getcontent($ch); 46 | if (preg_match("@^HTTP/\\d\\.\\d 200 Connection established\r\n\r\n@i", $rawResponse)) { 47 | list(, $header, $body) = explode("\r\n\r\n", $rawResponse, 3); 48 | } else { 49 | list($header, $body) = explode("\r\n\r\n", $rawResponse, 2); 50 | } 51 | $response = new CurlResponse(); 52 | $response->request = $request; 53 | $response->status = $info['http_code']; 54 | $headerNames = [ 55 | 'etag' => 'ETag', 56 | 'contentType' => 'Content-Type', 57 | 'link' => 'Link', 58 | ]; 59 | foreach ($headerNames as $key => $name) { 60 | $response->$key = self::getHeader($header, $name); 61 | } 62 | if (200 == $response->status) { 63 | $response->content = $body; 64 | } 65 | $callback = $request->callback; 66 | $callback($response); 67 | curl_multi_remove_handle(self::$multiHandle, $ch); 68 | curl_close($ch); 69 | } 70 | if ($running || !$finish) { 71 | if (-1 === curl_multi_select(self::$multiHandle, 1)) { 72 | usleep(250); 73 | } 74 | } 75 | } while ($running || !$finish); 76 | 77 | $this->running = false; 78 | 79 | return true; 80 | } 81 | 82 | private function addHandle(CurlRequest $request) 83 | { 84 | $defaultOptions = [ 85 | CURLOPT_HEADER => true, 86 | CURLOPT_RETURNTRANSFER => true, 87 | CURLOPT_USERAGENT => 'alfred-github-workflow', 88 | CURLOPT_FOLLOWLOCATION => true, 89 | CURLOPT_MAXREDIRS => 5, 90 | CURLOPT_ENCODING => '', 91 | CURLINFO_HEADER_OUT => true, 92 | ]; 93 | if ($this->debug) { 94 | $defaultOptions[CURLOPT_PROXY] = 'localhost'; 95 | $defaultOptions[CURLOPT_PROXYPORT] = 8888; 96 | $defaultOptions[CURLOPT_SSL_VERIFYPEER] = 0; 97 | } 98 | 99 | $ch = curl_init(); 100 | $options = $defaultOptions; 101 | $options[CURLOPT_URL] = $request->url; 102 | $header = []; 103 | $header[] = 'X-Url: '.$request->url; 104 | if ($request->token) { 105 | $header[] = 'Authorization: token '.$request->token; 106 | } 107 | if ($request->etag) { 108 | $header[] = 'If-None-Match: '.$request->etag; 109 | } 110 | $options[CURLOPT_HTTPHEADER] = $header; 111 | curl_setopt_array($ch, $options); 112 | curl_multi_add_handle(self::$multiHandle, $ch); 113 | } 114 | 115 | public static function getHeader($header, $key) 116 | { 117 | if (preg_match('/^'.preg_quote($key, '/').': (\V*)/mi', $header, $match)) { 118 | return $match[1]; 119 | } 120 | 121 | return null; 122 | } 123 | } 124 | 125 | class CurlRequest 126 | { 127 | public $url; 128 | public $etag; 129 | public $token; 130 | public $callback; 131 | 132 | public function __construct($url, $etag, $token, $callback) 133 | { 134 | $this->url = $url; 135 | $this->etag = $etag; 136 | $this->token = $token; 137 | $this->callback = $callback; 138 | } 139 | } 140 | 141 | class CurlResponse 142 | { 143 | /** @var CurlRequest */ 144 | public $request; 145 | public $status; 146 | public $contentType; 147 | public $etag; 148 | public $link; 149 | public $content; 150 | } 151 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icon.png -------------------------------------------------------------------------------- /icons/actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/actions.png -------------------------------------------------------------------------------- /icons/branch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/branch.png -------------------------------------------------------------------------------- /icons/clone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/clone.png -------------------------------------------------------------------------------- /icons/codespaces.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/codespaces.png -------------------------------------------------------------------------------- /icons/commits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/commits.png -------------------------------------------------------------------------------- /icons/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/dashboard.png -------------------------------------------------------------------------------- /icons/discussions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/discussions.png -------------------------------------------------------------------------------- /icons/file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/file.png -------------------------------------------------------------------------------- /icons/fork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/fork.png -------------------------------------------------------------------------------- /icons/gists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/gists.png -------------------------------------------------------------------------------- /icons/graphs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/graphs.png -------------------------------------------------------------------------------- /icons/issue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/issue.png -------------------------------------------------------------------------------- /icons/logout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/logout.png -------------------------------------------------------------------------------- /icons/milestone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/milestone.png -------------------------------------------------------------------------------- /icons/mirror.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/mirror.png -------------------------------------------------------------------------------- /icons/notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/notifications.png -------------------------------------------------------------------------------- /icons/organization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/organization.png -------------------------------------------------------------------------------- /icons/private-fork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/private-fork.png -------------------------------------------------------------------------------- /icons/private-mirror.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/private-mirror.png -------------------------------------------------------------------------------- /icons/private-repo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/private-repo.png -------------------------------------------------------------------------------- /icons/project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/project.png -------------------------------------------------------------------------------- /icons/pull-request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/pull-request.png -------------------------------------------------------------------------------- /icons/pulse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/pulse.png -------------------------------------------------------------------------------- /icons/releases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/releases.png -------------------------------------------------------------------------------- /icons/repo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/repo.png -------------------------------------------------------------------------------- /icons/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/search.png -------------------------------------------------------------------------------- /icons/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/settings.png -------------------------------------------------------------------------------- /icons/stars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/stars.png -------------------------------------------------------------------------------- /icons/update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/update.png -------------------------------------------------------------------------------- /icons/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/user.png -------------------------------------------------------------------------------- /icons/wiki.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/icons/wiki.png -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | de.gh01.alfred.github 7 | category 8 | Internet 9 | connections 10 | 11 | 14CA19E2-6328-4201-905E-A751E2BA47B5 12 | 13 | 14 | destinationuid 15 | ABE8DD6E-CD29-4E70-87CF-1382A0446009 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | 29045171-6618-4FA4-BAB0-39C10422CF31 25 | 26 | 27 | destinationuid 28 | 67ADBB8D-C705-4981-BB9B-7C9238BEFF2E 29 | modifiers 30 | 0 31 | modifiersubtext 32 | 33 | vitoclose 34 | 35 | 36 | 37 | 94B48881-907F-4752-AAA0-234EA30CFC18 38 | 39 | 40 | destinationuid 41 | FC76BC27-8BCB-4BEE-AD42-EAC2D9B01F0F 42 | modifiers 43 | 0 44 | modifiersubtext 45 | 46 | vitoclose 47 | 48 | 49 | 50 | 95CD1A63-A0C6-4458-9817-9C6B1A90C827 51 | 52 | 53 | destinationuid 54 | 29045171-6618-4FA4-BAB0-39C10422CF31 55 | modifiers 56 | 0 57 | modifiersubtext 58 | 59 | vitoclose 60 | 61 | 62 | 63 | destinationuid 64 | 9715D8FA-0199-450F-99B8-64796B5AC6CB 65 | modifiers 66 | 1048576 67 | modifiersubtext 68 | Copy and paste URL 69 | vitoclose 70 | 71 | 72 | 73 | 9715D8FA-0199-450F-99B8-64796B5AC6CB 74 | 75 | 76 | destinationuid 77 | 87F10E6D-697C-47CE-BD78-E7174F5DF815 78 | modifiers 79 | 0 80 | modifiersubtext 81 | 82 | vitoclose 83 | 84 | 85 | 86 | ABE8DD6E-CD29-4E70-87CF-1382A0446009 87 | 88 | 89 | destinationuid 90 | 95CD1A63-A0C6-4458-9817-9C6B1A90C827 91 | modifiers 92 | 0 93 | modifiersubtext 94 | 95 | vitoclose 96 | 97 | 98 | 99 | DAA505B9-F86C-4AF8-818B-8F614F01485E 100 | 101 | 102 | destinationuid 103 | 94B48881-907F-4752-AAA0-234EA30CFC18 104 | modifiers 105 | 0 106 | modifiersubtext 107 | 108 | vitoclose 109 | 110 | 111 | 112 | FC76BC27-8BCB-4BEE-AD42-EAC2D9B01F0F 113 | 114 | 115 | destinationuid 116 | 29045171-6618-4FA4-BAB0-39C10422CF31 117 | modifiers 118 | 0 119 | modifiersubtext 120 | 121 | vitoclose 122 | 123 | 124 | 125 | destinationuid 126 | 9715D8FA-0199-450F-99B8-64796B5AC6CB 127 | modifiers 128 | 1048576 129 | modifiersubtext 130 | Copy and paste URL 131 | vitoclose 132 | 133 | 134 | 135 | 136 | createdby 137 | Gregor Harlan 138 | description 139 | GitHub for Alfred 140 | disabled 141 | 142 | name 143 | GitHub 144 | objects 145 | 146 | 147 | config 148 | 149 | action 150 | 0 151 | argument 152 | 0 153 | focusedappvariable 154 | 155 | focusedappvariablename 156 | 157 | hotkey 158 | 0 159 | hotmod 160 | 0 161 | hotstring 162 | 163 | leftcursor 164 | 165 | modsmode 166 | 2 167 | relatedAppsMode 168 | 0 169 | 170 | type 171 | alfred.workflow.trigger.hotkey 172 | uid 173 | DAA505B9-F86C-4AF8-818B-8F614F01485E 174 | version 175 | 2 176 | 177 | 178 | config 179 | 180 | alfredfiltersresults 181 | 182 | alfredfiltersresultsmatchmode 183 | 0 184 | argumenttreatemptyqueryasnil 185 | 186 | argumenttrimmode 187 | 0 188 | argumenttype 189 | 1 190 | escaping 191 | 100 192 | keyword 193 | gh 194 | queuedelaycustom 195 | 3 196 | queuedelayimmediatelyinitially 197 | 198 | queuedelaymode 199 | 0 200 | queuemode 201 | 1 202 | runningsubtext 203 | Loading results… 204 | script 205 | <?php 206 | 207 | require 'search.php'; 208 | 209 | Search::run('github', $argv[1], getenv('hotkey')); 210 | 211 | echo Workflow::getItemsAsXml(); 212 | scriptargtype 213 | 1 214 | scriptfile 215 | 216 | subtext 217 | Search or type a command 218 | title 219 | gh... 220 | type 221 | 1 222 | withspace 223 | 224 | 225 | type 226 | alfred.workflow.input.scriptfilter 227 | uid 228 | FC76BC27-8BCB-4BEE-AD42-EAC2D9B01F0F 229 | version 230 | 3 231 | 232 | 233 | config 234 | 235 | concurrently 236 | 237 | escaping 238 | 100 239 | script 240 | <?php 241 | 242 | require 'action.php'; 243 | scriptargtype 244 | 1 245 | scriptfile 246 | 247 | type 248 | 1 249 | 250 | type 251 | alfred.workflow.action.script 252 | uid 253 | 29045171-6618-4FA4-BAB0-39C10422CF31 254 | version 255 | 2 256 | 257 | 258 | config 259 | 260 | lastpathcomponent 261 | 262 | onlyshowifquerypopulated 263 | 264 | removeextension 265 | 266 | text 267 | {query} 268 | title 269 | GitHub 270 | 271 | type 272 | alfred.workflow.output.notification 273 | uid 274 | 67ADBB8D-C705-4981-BB9B-7C9238BEFF2E 275 | version 276 | 1 277 | 278 | 279 | config 280 | 281 | argument 282 | {query} 283 | passthroughargument 284 | 285 | variables 286 | 287 | hotkey 288 | 1 289 | 290 | 291 | type 292 | alfred.workflow.utility.argument 293 | uid 294 | 94B48881-907F-4752-AAA0-234EA30CFC18 295 | version 296 | 1 297 | 298 | 299 | config 300 | 301 | action 302 | 0 303 | argument 304 | 0 305 | focusedappvariable 306 | 307 | focusedappvariablename 308 | 309 | hotkey 310 | 0 311 | hotmod 312 | 0 313 | hotstring 314 | 315 | leftcursor 316 | 317 | modsmode 318 | 2 319 | relatedAppsMode 320 | 0 321 | 322 | type 323 | alfred.workflow.trigger.hotkey 324 | uid 325 | 14CA19E2-6328-4201-905E-A751E2BA47B5 326 | version 327 | 2 328 | 329 | 330 | config 331 | 332 | alfredfiltersresults 333 | 334 | alfredfiltersresultsmatchmode 335 | 0 336 | argumenttreatemptyqueryasnil 337 | 338 | argumenttrimmode 339 | 0 340 | argumenttype 341 | 1 342 | escaping 343 | 100 344 | keyword 345 | ghe 346 | queuedelaycustom 347 | 3 348 | queuedelayimmediatelyinitially 349 | 350 | queuedelaymode 351 | 0 352 | queuemode 353 | 1 354 | runningsubtext 355 | Loading results… 356 | script 357 | <?php 358 | 359 | require 'search.php'; 360 | 361 | Search::run('enterprise', $argv[1], getenv('hotkey')); 362 | 363 | echo Workflow::getItemsAsXml(); 364 | scriptargtype 365 | 1 366 | scriptfile 367 | 368 | subtext 369 | Search or type a command (GitHub Enterprise) 370 | title 371 | ghe... 372 | type 373 | 1 374 | withspace 375 | 376 | 377 | type 378 | alfred.workflow.input.scriptfilter 379 | uid 380 | 95CD1A63-A0C6-4458-9817-9C6B1A90C827 381 | version 382 | 3 383 | 384 | 385 | config 386 | 387 | autopaste 388 | 389 | clipboardtext 390 | {query} 391 | ignoredynamicplaceholders 392 | 393 | transient 394 | 395 | 396 | type 397 | alfred.workflow.output.clipboard 398 | uid 399 | 87F10E6D-697C-47CE-BD78-E7174F5DF815 400 | version 401 | 3 402 | 403 | 404 | config 405 | 406 | argument 407 | {query} 408 | passthroughargument 409 | 410 | variables 411 | 412 | hotkey 413 | 1 414 | 415 | 416 | type 417 | alfred.workflow.utility.argument 418 | uid 419 | ABE8DD6E-CD29-4E70-87CF-1382A0446009 420 | version 421 | 1 422 | 423 | 424 | config 425 | 426 | inputstring 427 | {query} 428 | matchcasesensitive 429 | 430 | matchmode 431 | 2 432 | matchstring 433 | ^\w+:// 434 | 435 | type 436 | alfred.workflow.utility.filter 437 | uid 438 | 9715D8FA-0199-450F-99B8-64796B5AC6CB 439 | version 440 | 1 441 | 442 | 443 | readme 444 | Changelog 445 | ========= 446 | 447 | Version 1.9.1 – 2024-12-05 448 | -------------------------- 449 | 450 | ### Bugfix 451 | 452 | * Fix for PHP 8.4 (@romantech) 453 | * Fix for branch names containing special chars like `#` 454 | 455 | 456 | Version 1.9 – 2024-02-04 457 | ------------------------ 458 | 459 | ### Features 460 | 461 | * Better support for Alfred 5 462 | * `gh my stars` opens `/<username>?tab=stars` instead of `/stars` 463 | 464 | 465 | Version 1.8.1 – 2022-10-16 466 | -------------------------- 467 | 468 | ### Bugfix 469 | 470 | * Fixed version number in workflow 471 | 472 | 473 | Version 1.8 – 2022-10-15 474 | ------------------------ 475 | 476 | ### Features 477 | 478 | * New command `gh user/repo dev` opening repo in Visual Studio Code (Codespaces) 479 | * New command `gh user/repo discussions` 480 | * New command `gh my repos new` 481 | * Search issues by title (`gh user/repo #search`) 482 | * Archived repos with prefix `[Archived]` in subtitle 483 | * Performance improvement 484 | 485 | ### Bugfixes 486 | 487 | * Fix result display for non-PR issue IDs (@tobias-grasse) 488 | * Fix git clone url, use new `x-github-client://` scheme 489 | 490 | 491 | Version 1.7.1 – 2022-01-01 492 | -------------------------- 493 | 494 | ### Bugfixes 495 | 496 | * Fix deprecation warning when using PHP 8.1 497 | 498 | 499 | Version 1.7 – 2021-10-26 500 | ------------------------ 501 | 502 | ### Features 503 | 504 | * Support for macOS 12 Montery: PHP is no longer pre-installed, you must install it by yourself via [Homebrew](https://brew.sh) (`brew install php`) 505 | * Better support for Alfred 4 506 | * new command `gh user/repo actions` (@Attsun1031) 507 | * command `gh user/repo new issue` lands on issue template selector page (@riastrad) 508 | 509 | 510 | Version 1.6.2 – 2018-02-13 511 | -------------------------- 512 | 513 | ### Bugfixes 514 | 515 | * Api pagination didnt work correctly anymore (missing results from page > 2) 516 | 517 | 518 | Version 1.6.1 – 2017-09-23 519 | -------------------------- 520 | 521 | ### Bugfixes 522 | 523 | * Support for macOS 10.13 High Sierra 524 | * Commit search results had wrong urls on GitHub Enterprise (@beparker) 525 | 526 | 527 | Version 1.6 – 2017-05-07 528 | ------------------------ 529 | 530 | ### Features 531 | 532 | * new command `gh user/repo projects` (@dagio) 533 | * new command `gh my pulls review requested` (@AeroEchelon) 534 | * better sorting for issues (most recently updated on top) and commits (most recent on top) (@danielma) 535 | 536 | ### Bugfixes 537 | 538 | * On macOS 10.12.5 Beta URLs didnt opened in browser anymore 539 | 540 | 541 | Version 1.5 – 2016-12-13 542 | ------------------------ 543 | 544 | ### Features 545 | 546 | * new commands for searching repos and users globally in GitHub (`gh s repo` and `gh s @user`) 547 | * new command `gh my repos` (@jacobkossman) 548 | * new command `gh > delete database` 549 | * source repos with higher priority than forks 550 | 551 | ### Bugfixes 552 | 553 | * in some situations private repos were missing (@lxynox) 554 | * after saving GitHub Enterprise url the workflow didn't reopen correctly 555 | * updated user sub commands (Activity tab does not exist any more on GitHub) 556 | 557 | 558 | Version 1.4.1 – 2016-22-07 559 | -------------------------- 560 | 561 | * fixed reading environment variables (important for hotkey support) 562 | 563 | 564 | Version 1.4 – 2016-22-07 565 | ------------------------ 566 | 567 | * Hotkey support 568 | * use native update mechanism of Alfred (to keep your hotkeys) 569 | * new command `gh user/repo releases` (@altern8tif) 570 | * cache warmup after login 571 | * lower cpu usage in multi curl 572 | * fixed autocomplete values in GitHub Enterprise 573 | 574 | 575 | Version 1.3 – 2016-17-07 576 | ------------------------ 577 | 578 | **Important:** This is the last version for Alfred 2. 579 | 580 | * Disabled updates in Alfred 2 581 | * Updates in Alfred 3 are loaded from new location (GitHub releases) 582 | 583 | 584 | Version 1.2 – 2016-04-17 585 | ------------------------ 586 | 587 | ### Features 588 | 589 | * New sub commands for `gh my issues/pull`: `created`, `assigned` and `mentioned` 590 | * New help command: `gh > help` 591 | * Longer cache lifetime 592 | 593 | 594 | Version 1.1 – 2015-01-10 595 | ------------------------ 596 | 597 | ### Features 598 | 599 | * GitHub Enterprise support (use `ghe`) 600 | * Commit search (`gh user/repo *hash`) 601 | 602 | ### Bugfixes 603 | 604 | * A space after `gh` is required to avoid confusion when using commands of other workflows like `ghost` 605 | 606 | uidata 607 | 608 | 14CA19E2-6328-4201-905E-A751E2BA47B5 609 | 610 | colorindex 611 | 9 612 | note 613 | GitHub Enterprise 614 | xpos 615 | 30 616 | ypos 617 | 230 618 | 619 | 29045171-6618-4FA4-BAB0-39C10422CF31 620 | 621 | xpos 622 | 600 623 | ypos 624 | 40 625 | 626 | 67ADBB8D-C705-4981-BB9B-7C9238BEFF2E 627 | 628 | xpos 629 | 790 630 | ypos 631 | 40 632 | 633 | 87F10E6D-697C-47CE-BD78-E7174F5DF815 634 | 635 | xpos 636 | 790 637 | ypos 638 | 230 639 | 640 | 94B48881-907F-4752-AAA0-234EA30CFC18 641 | 642 | colorindex 643 | 12 644 | xpos 645 | 220 646 | ypos 647 | 70 648 | 649 | 95CD1A63-A0C6-4458-9817-9C6B1A90C827 650 | 651 | colorindex 652 | 9 653 | note 654 | GitHub Enterprise 655 | xpos 656 | 330 657 | ypos 658 | 230 659 | 660 | 9715D8FA-0199-450F-99B8-64796B5AC6CB 661 | 662 | xpos 663 | 680 664 | ypos 665 | 260 666 | 667 | ABE8DD6E-CD29-4E70-87CF-1382A0446009 668 | 669 | colorindex 670 | 9 671 | xpos 672 | 220 673 | ypos 674 | 260 675 | 676 | DAA505B9-F86C-4AF8-818B-8F614F01485E 677 | 678 | colorindex 679 | 12 680 | note 681 | github.com 682 | xpos 683 | 30 684 | ypos 685 | 40 686 | 687 | FC76BC27-8BCB-4BEE-AD42-EAC2D9B01F0F 688 | 689 | colorindex 690 | 12 691 | note 692 | github.com 693 | xpos 694 | 330 695 | ypos 696 | 40 697 | 698 | 699 | variables 700 | 701 | hotkey 702 | 0 703 | 704 | version 705 | 1.9.1 706 | webaddress 707 | https://github.com/gharlan/alfred-github-workflow 708 | 709 | 710 | -------------------------------------------------------------------------------- /item.php: -------------------------------------------------------------------------------- 1 | randomUid = true; 28 | 29 | return $this; 30 | } 31 | 32 | public function prefix($prefix, $onlyTitle = true) 33 | { 34 | $this->prefix = $prefix; 35 | $this->prefixOnlyTitle = $onlyTitle; 36 | 37 | return $this; 38 | } 39 | 40 | public function title($title) 41 | { 42 | $this->title = $title; 43 | 44 | return $this; 45 | } 46 | 47 | public function comparator($comparator) 48 | { 49 | $this->comparator = $comparator; 50 | 51 | return $this; 52 | } 53 | 54 | public function subtitle($subtitle) 55 | { 56 | $this->subtitle = $subtitle; 57 | 58 | return $this; 59 | } 60 | 61 | public function icon($icon) 62 | { 63 | $this->icon = $icon; 64 | 65 | return $this; 66 | } 67 | 68 | public function arg($arg) 69 | { 70 | $this->arg = $arg; 71 | 72 | return $this; 73 | } 74 | 75 | public function valid($valid, $add = '…') 76 | { 77 | $this->valid = (bool) $valid; 78 | $this->add = $add; 79 | 80 | return $this; 81 | } 82 | 83 | public function autocomplete($autocomplete = true) 84 | { 85 | $this->autocomplete = $autocomplete; 86 | 87 | return $this; 88 | } 89 | 90 | public function prio($prio) 91 | { 92 | $this->prio = $prio; 93 | 94 | return $this; 95 | } 96 | 97 | public function match($query) 98 | { 99 | $comparator = strtolower($this->comparator ?: $this->title); 100 | $query = strtolower($query); 101 | if (!$this->prefixOnlyTitle && 0 === stripos($query, $this->prefix)) { 102 | $query = substr($query, strlen($this->prefix)); 103 | } 104 | $this->sameChars = 0; 105 | $queryLength = strlen($query); 106 | for ($i = 0, $k = 0; $i < $queryLength; ++$i, $k++) { 107 | for (; isset($comparator[$k]) && $comparator[$k] !== $query[$i]; ++$k); 108 | 109 | if (!isset($comparator[$k])) { 110 | return false; 111 | } 112 | if ($i === $k) { 113 | ++$this->sameChars; 114 | } 115 | } 116 | $this->missingChars = strlen($comparator) - $queryLength; 117 | 118 | return true; 119 | } 120 | 121 | public function compare(self $another) 122 | { 123 | if ($this->sameChars != $another->sameChars) { 124 | return $this->sameChars < $another->sameChars ? 1 : -1; 125 | } 126 | if ($this->prio != $another->prio) { 127 | return $this->prio < $another->prio ? 1 : -1; 128 | } 129 | 130 | return $this->missingChars > $another->missingChars ? 1 : -1; 131 | } 132 | 133 | /** 134 | * @param self[] $items 135 | * 136 | * @return string 137 | */ 138 | public static function toXml(array $items, $enterprise, $hotkey, $baseUrl) 139 | { 140 | $xml = new SimpleXMLElement(''); 141 | $prefix = $hotkey ? '' : ' '; 142 | foreach ($items as $item) { 143 | $c = $xml->addChild('item'); 144 | $title = $item->prefix.$item->title; 145 | $c->addAttribute('uid', $item->randomUid ? md5(time().$title) : md5($title)); 146 | if ($item->icon && file_exists(__DIR__.'/icons/'.$item->icon.'.png')) { 147 | $c->addChild('icon', 'icons/'.$item->icon.'.png'); 148 | } else { 149 | $c->addChild('icon', 'icon.png'); 150 | } 151 | if ($item->arg) { 152 | $arg = $item->arg; 153 | if ('/' === $arg[0]) { 154 | $arg = $baseUrl.$arg; 155 | } elseif (false === strpos($arg, '://')) { 156 | $arg = ($enterprise ? 'e ' : '').$arg; 157 | } 158 | $c->addAttribute('arg', $arg); 159 | } 160 | if ($item->autocomplete) { 161 | if (is_string($item->autocomplete)) { 162 | $autocomplete = $item->autocomplete; 163 | } elseif (null !== $item->comparator) { 164 | $autocomplete = $item->comparator; 165 | } else { 166 | $autocomplete = $item->title; 167 | } 168 | $c->addAttribute('autocomplete', $prefix.($item->prefixOnlyTitle ? $autocomplete : $item->prefix.$autocomplete)); 169 | } 170 | if (!$item->valid) { 171 | $c->addAttribute('valid', 'no'); 172 | $title .= $item->add; 173 | } 174 | $c->addChild('title', htmlspecialchars($title)); 175 | if ($item->subtitle) { 176 | $c->addChild('subtitle', htmlspecialchars($item->subtitle)); 177 | } 178 | } 179 | 180 | return $xml->asXML(); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@primer/octicons": "^17.5" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gharlan/alfred-github-workflow/f8d7629fd9c026e7739ee582aa9a3c18d952ae16/screenshot.png -------------------------------------------------------------------------------- /search.php: -------------------------------------------------------------------------------- 1 | ' == $query[0]) { 54 | self::addSystemCommands(); 55 | Workflow::sortItems(); 56 | 57 | return; 58 | } 59 | 60 | $isSearch = 's' === $parts[0] && isset($parts[1]); 61 | $isUser = isset($query[0]) && '@' == $query[0]; 62 | $isRepo = false; 63 | $queryUser = null; 64 | if ($isUser) { 65 | $queryUser = ltrim($parts[0], '@'); 66 | } elseif (!$isSearch && false !== $pos = strpos($parts[0], '/')) { 67 | $queryUser = substr($parts[0], 0, $pos); 68 | $isRepo = true; 69 | } 70 | 71 | if ('my' == $parts[0] && isset($parts[1])) { 72 | self::addMyCommands(); 73 | } elseif ($isSearch && strlen($query) > 5 && '@' !== substr($parts[1], 0, 1)) { 74 | self::addRepoSearchCommands(); 75 | } elseif ($isSearch && strlen($query) > 6 && '@' === substr($parts[1], 0, 1)) { 76 | self::addUserSearchCommands(); 77 | } elseif ($isUser && isset($parts[1])) { 78 | self::addUserSubCommands($queryUser); 79 | } elseif (!$isUser && $isRepo && isset($parts[1])) { 80 | self::addRepoSubCommands(); 81 | } else { 82 | self::addDefaultCommands($isSearch, $isUser, $isRepo, $queryUser); 83 | } 84 | 85 | Workflow::sortItems(); 86 | 87 | if (!$query) { 88 | return; 89 | } 90 | 91 | if (!$isSearch && !$isUser && !isset($parts[1])) { 92 | Workflow::addItem(Item::create() 93 | ->title('s '.$query) 94 | ->subtitle('Search repo (in alfred workflow)') 95 | ->comparator($query) 96 | ->autocomplete('s '.$query) 97 | ->icon('repo') 98 | ->valid(false) 99 | ); 100 | } 101 | 102 | if (!$isSearch && !$isRepo && !isset($parts[1])) { 103 | $title = 's @'.ltrim($query, '@'); 104 | Workflow::addItem(Item::create() 105 | ->title($title) 106 | ->subtitle('Search user (in alfred workflow)') 107 | ->comparator($query) 108 | ->autocomplete($title) 109 | ->icon('user') 110 | ->valid(false) 111 | ); 112 | } 113 | 114 | if (!$isUser && $isRepo && isset($parts[1])) { 115 | $repoQuery = substr($query, strlen($parts[0]) + 1); 116 | Workflow::addItem(Item::create() 117 | ->title("Search '$parts[0]' for '$repoQuery'") 118 | ->icon('search') 119 | ->arg('/'.$parts[0].'/search?q='.urlencode($repoQuery)) 120 | ->autocomplete(false) 121 | ); 122 | } 123 | 124 | $path = $isUser ? $queryUser : 'search?q='.urlencode($query); 125 | $name = self::$enterprise ? 'GitHub Enterprise' : 'GitHub'; 126 | Workflow::addItem(Item::create() 127 | ->title("Search $name for '$query'") 128 | ->icon('search') 129 | ->arg('/'.$path) 130 | ->autocomplete(false) 131 | ); 132 | } 133 | 134 | private static function addEmptyQueryCommand() 135 | { 136 | Workflow::addItem(Item::create() 137 | ->title(self::$enterprise ? 'ghe' : 'gh') 138 | ->subtitle('Search or type a command'.(self::$enterprise ? ' (GitHub Enterprise)' : '')) 139 | ->comparator('') 140 | ->valid(false) 141 | ); 142 | } 143 | 144 | private static function addUpdateCommands() 145 | { 146 | $cmds = [ 147 | 'update' => 'There is an update for this Alfred workflow', 148 | 'deactivate autoupdate' => 'Deactivate auto updating this Alfred Workflow', 149 | ]; 150 | foreach ($cmds as $cmd => $desc) { 151 | Workflow::addItem(Item::create() 152 | ->title('> '.$cmd) 153 | ->subtitle($desc) 154 | ->icon($cmd) 155 | ->arg('> '.str_replace(' ', '-', $cmd)) 156 | ->randomUid() 157 | ); 158 | } 159 | 160 | Workflow::addItem(Item::create() 161 | ->title('> changelog') 162 | ->subtitle('View the changelog') 163 | ->icon('file') 164 | ->arg('https://github.com/gharlan/alfred-github-workflow/blob/main/CHANGELOG.md') 165 | ->randomUid() 166 | ); 167 | } 168 | 169 | private static function addEnterpriseUrlCommand() 170 | { 171 | $url = null; 172 | if (count(self::$parts) > 1 && '>' == self::$parts[0] && 'url' == self::$parts[1] && isset(self::$parts[2])) { 173 | $url = self::$parts[2]; 174 | } 175 | Workflow::addItem(Item::create() 176 | ->title('> url '.$url) 177 | ->subtitle('Set the GitHub Enterprise URL') 178 | ->arg('> enterprise-url '.$url) 179 | ->valid((bool) $url, '') 180 | ->randomUid() 181 | ); 182 | } 183 | 184 | private static function addLoginCommands() 185 | { 186 | Workflow::removeAccessToken(); 187 | $token = null; 188 | if (count(self::$parts) > 1 && '>' == self::$parts[0] && 'login' == self::$parts[1] && isset(self::$parts[2])) { 189 | $token = self::$parts[2]; 190 | } 191 | if (!$token && !self::$enterprise) { 192 | Workflow::addItem(Item::create() 193 | ->title('> login') 194 | ->subtitle('Generate OAuth access token') 195 | ->arg('> login') 196 | ->randomUid() 197 | ); 198 | } 199 | Workflow::addItem(Item::create() 200 | ->title('> login '.$token) 201 | ->subtitle('Save access token') 202 | ->arg('> login '.$token) 203 | ->valid((bool) $token, '') 204 | ->randomUid() 205 | ); 206 | if (!$token && self::$enterprise) { 207 | Workflow::addItem(Item::create() 208 | ->title('> generate token') 209 | ->subtitle('Generate a new access token') 210 | ->arg('/settings/applications') 211 | ->randomUid() 212 | ); 213 | Workflow::addItem(Item::create() 214 | ->title('> enterprise reset') 215 | ->subtitle('Reset the GitHub Enterprise URL') 216 | ->arg('> enterprise-reset') 217 | ->randomUid() 218 | ); 219 | } 220 | } 221 | 222 | private static function addDefaultCommands($isSearch, $isUser, $isRepo, $queryUser) 223 | { 224 | $users = []; 225 | $repos = []; 226 | 227 | $curl = new Curl(); 228 | 229 | if (!$isSearch && !$isUser) { 230 | $getRepos = function ($url, $prio) use ($curl, &$repos) { 231 | Workflow::requestApi($url, $curl, function ($urlRepos) use (&$repos, $prio) { 232 | foreach ($urlRepos as $repo) { 233 | $repo->score = 300 + $prio + ($repo->fork ? 0 : 10); 234 | $repos[$repo->id] = $repo; 235 | } 236 | }); 237 | }; 238 | if ($isRepo) { 239 | if ($queryUser != self::$user->login) { 240 | $urls = ['/users/'.$queryUser.'/repos', '/orgs/'.$queryUser.'/repos']; 241 | } else { 242 | $urls = ['/user/repos']; 243 | } 244 | } else { 245 | Workflow::requestApi('/user/orgs', $curl, function ($orgs) use ($getRepos) { 246 | foreach ($orgs as $org) { 247 | $getRepos('/orgs/'.$org->login.'/repos', 0); 248 | } 249 | }); 250 | $urls = ['/user/starred', '/user/subscriptions', '/user/repos']; 251 | } 252 | foreach ($urls as $prio => $url) { 253 | $getRepos($url, $prio + 1); 254 | } 255 | } 256 | 257 | if (!$isSearch && !$isRepo) { 258 | Workflow::requestApi('/user/following', $curl, function ($urlUsers) use (&$users) { 259 | $users = $urlUsers; 260 | }); 261 | } 262 | 263 | $curl->execute(); 264 | 265 | self::addRepos($repos); 266 | 267 | foreach ($users as $user) { 268 | Workflow::addItemIfMatches(Item::create() 269 | ->prefix('@', false) 270 | ->title($user->login.' ') 271 | ->subtitle($user->type) 272 | ->arg($user->html_url) 273 | ->icon(lcfirst($user->type)) 274 | ->prio(200) 275 | ); 276 | } 277 | 278 | Workflow::addItemIfMatches(Item::create() 279 | ->title('s '.substr(self::$query, 2, 4)) 280 | ->subtitle('Search repo or @user (min 4 chars)') 281 | ->prio(110) 282 | ->valid(false) 283 | ); 284 | 285 | Workflow::addItemIfMatches(Item::create() 286 | ->title('my ') 287 | ->subtitle('Dashboard, settings, and more') 288 | ->prio(100) 289 | ->valid(false) 290 | ); 291 | } 292 | 293 | private static function addRepoSearchCommands() 294 | { 295 | $q = substr(self::$query, 2); 296 | $repos = Workflow::requestApi('/search/repositories?q='.urlencode($q), null, null, true); 297 | 298 | self::addRepos($repos, 's '); 299 | } 300 | 301 | private static function addUserSearchCommands() 302 | { 303 | $q = substr(self::$query, 3); 304 | $users = Workflow::requestApi('/search/users?q='.urlencode($q), null, null, true); 305 | 306 | self::addUsers($users, 's @'); 307 | } 308 | 309 | private static function addRepos($repos, $comparatorPrefix = '') 310 | { 311 | foreach ($repos as $repo) { 312 | $icon = 'repo'; 313 | if ($repo->fork) { 314 | $icon = 'fork'; 315 | } elseif ($repo->mirror_url) { 316 | $icon = 'mirror'; 317 | } 318 | if ($repo->private) { 319 | $icon = 'private-'.$icon; 320 | } 321 | Workflow::addItemIfMatches(Item::create() 322 | ->title($repo->full_name.' ') 323 | ->comparator($comparatorPrefix.$repo->full_name) 324 | ->autocomplete($repo->full_name.' ') 325 | ->subtitle(($repo->archived ? '[Archived] ' : '').$repo->description) 326 | ->icon($icon) 327 | ->arg('/'.$repo->full_name) 328 | ->prio($repo->score) 329 | ); 330 | } 331 | } 332 | 333 | private static function addUsers($users, $comparatorPrefix = '') 334 | { 335 | foreach ($users as $user) { 336 | Workflow::addItemIfMatches(Item::create() 337 | ->prefix('@', false) 338 | ->title($user->login.' ') 339 | ->comparator($comparatorPrefix.$user->login) 340 | ->autocomplete($user->login.' ') 341 | ->subtitle($user->type) 342 | ->arg($user->html_url) 343 | ->icon(lcfirst($user->type)) 344 | ->prio(isset($user->score) ? $user->score : 200) 345 | ); 346 | } 347 | } 348 | 349 | private static function addRepoSubCommands() 350 | { 351 | $parts = self::$parts; 352 | if (isset($parts[1][0]) && in_array($parts[1][0], ['#', '@', '*', '/'])) { 353 | switch ($parts[1][0]) { 354 | case '*': 355 | $commits = Workflow::requestApi('/repos/'.$parts[0].'/commits'); 356 | foreach ($commits as $commit) { 357 | Workflow::addItemIfMatches(Item::create() 358 | ->title($commit->commit->message) 359 | ->comparator($parts[0].' *'.$commit->sha) 360 | ->subtitle($commit->commit->author->date.' ('.$commit->sha.')') 361 | ->icon('commits') 362 | ->arg('/'.$parts[0].'/commit/'.$commit->sha) 363 | ->prio(strtotime($commit->commit->author->date)) 364 | ); 365 | } 366 | break; 367 | case '@': 368 | $branches = Workflow::requestApi('/repos/'.$parts[0].'/branches'); 369 | foreach ($branches as $branch) { 370 | Workflow::addItemIfMatches(Item::create() 371 | ->title('@'.$branch->name) 372 | ->comparator($parts[0].' @'.$branch->name) 373 | ->subtitle($branch->commit->sha) 374 | ->icon('branch') 375 | ->arg('/'.$parts[0].'/tree/'.str_replace('%2F', '/', urlencode($branch->name))) 376 | ); 377 | } 378 | break; 379 | case '/': 380 | $repo = Workflow::requestApi('/repos/'.$parts[0]); 381 | $files = Workflow::requestApi('/repos/'.$parts[0].'/git/trees/'.$repo->default_branch.'?recursive=1'); 382 | foreach ($files->tree as $file) { 383 | if ('blob' === $file->type) { 384 | Workflow::addItemIfMatches(Item::create() 385 | ->title(basename($file->path)) 386 | ->subtitle('/'.$file->path) 387 | ->comparator($parts[0].' /'.$file->path) 388 | ->icon('file') 389 | ->arg('/'.$parts[0].'/blob/'.$repo->default_branch.'/'.$file->path) 390 | ); 391 | } 392 | } 393 | break; 394 | case '#': 395 | $issues = Workflow::requestApi('/repos/'.$parts[0].'/issues?sort=updated&state=all'); 396 | foreach ($issues as $issue) { 397 | Workflow::addItemIfMatches(Item::create() 398 | ->title($issue->title) 399 | ->comparator($parts[0].' #'.$issue->number.' '.$issue->title) 400 | ->subtitle('#'.$issue->number) 401 | ->icon(isset($issue->pull_request) ? 'pull-request' : 'issue') 402 | ->arg($issue->html_url) 403 | ->prio(strtotime($issue->updated_at)) 404 | ); 405 | } 406 | break; 407 | } 408 | } else { 409 | $subs = [ 410 | 'actions' => ['Show Github Actions'], 411 | 'admin' => ['Manage this repo', 'settings'], 412 | 'discussions' => ['Show discussions'], 413 | 'graphs' => ['All the graphs'], 414 | 'issues ' => ['List, show and create issues', 'issue'], 415 | 'milestones' => ['View milestones', 'milestone'], 416 | 'network' => ['See the network', 'graphs'], 417 | 'projects' => ['View projects', 'project'], 418 | 'pulls' => ['Show open pull requests', 'pull-request'], 419 | 'pulse' => ['See recent activity'], 420 | 'wiki' => ['Pull up the wiki'], 421 | 'commits' => ['View commit history'], 422 | 'releases' => ['See latest releases'], 423 | ]; 424 | foreach ($subs as $key => $sub) { 425 | Workflow::addItemIfMatches(Item::create() 426 | ->title($parts[0].' '.$key) 427 | ->subtitle($sub[0]) 428 | ->icon(isset($sub[1]) ? $sub[1] : $key) 429 | ->arg('/'.$parts[0].'/'.$key) 430 | ); 431 | } 432 | Workflow::addItemIfMatches(Item::create() 433 | ->title($parts[0].' new issue') 434 | ->subtitle('Create new issue') 435 | ->icon('issue') 436 | ->arg('/'.$parts[0].'/issues/new/choose?source=c') 437 | ); 438 | Workflow::addItemIfMatches(Item::create() 439 | ->title($parts[0].' new pull') 440 | ->subtitle('Create new pull request') 441 | ->icon('pull-request') 442 | ->arg('/'.$parts[0].'/pull/new?source=c') 443 | ); 444 | if (empty($parts[1])) { 445 | $subs = [ 446 | '#' => ['Show a specific issue / pull request', 'issue'], 447 | '@' => ['Show a specific branch', 'branch'], 448 | '*' => ['Show a specific commit', 'commits'], 449 | '/' => ['Show a blob', 'file'], 450 | ]; 451 | foreach ($subs as $key => $sub) { 452 | Workflow::addItemIfMatches(Item::create() 453 | ->title($parts[0].' '.$key) 454 | ->subtitle($sub[0]) 455 | ->icon($sub[1]) 456 | ->arg($key.' '.$parts[0]) 457 | ->valid(false) 458 | ); 459 | } 460 | } 461 | Workflow::addItemIfMatches(Item::create() 462 | ->title($parts[0].' dev') 463 | ->subtitle('Open repo with Visual Studio Code in browser') 464 | ->icon('codespaces') 465 | ->arg('https://github.dev/'.$parts[0]) 466 | ); 467 | Workflow::addItemIfMatches(Item::create() 468 | ->title($parts[0].' clone') 469 | ->subtitle('Clone this repo') 470 | ->icon('clone') 471 | ->arg('/'.$parts[0].'.git') 472 | ); 473 | } 474 | } 475 | 476 | private static function addUserSubCommands($queryUser) 477 | { 478 | $subs = [ 479 | 'overview' => [$queryUser, "View $queryUser's overview", 'user'], 480 | 'repositories' => [$queryUser.'?tab=repositories', "View $queryUser's repositories", 'repo'], 481 | 'stars' => [$queryUser.'?tab=stars', "View $queryUser's stars"], 482 | ]; 483 | $prio = count($subs) + 2; 484 | foreach ($subs as $key => $sub) { 485 | Workflow::addItemIfMatches(Item::create() 486 | ->title('@'.$queryUser.' '.$key) 487 | ->subtitle($sub[1]) 488 | ->icon(isset($sub[2]) ? $sub[2] : $key) 489 | ->arg('/'.$sub[0]) 490 | ->prio($prio--) 491 | ); 492 | } 493 | Workflow::addItemIfMatches(Item::create() 494 | ->title('@'.$queryUser.' gists') 495 | ->subtitle("View $queryUser's' gists") 496 | ->icon('gists') 497 | ->arg(Workflow::getGistUrl().'/'.$queryUser) 498 | ->prio(2) 499 | ); 500 | 501 | Workflow::addItemIfMatches(Item::create() 502 | ->title($queryUser.'/') 503 | ->comparator('@'.$queryUser.' ') 504 | ->autocomplete($queryUser.'/') 505 | ->subtitle("View $queryUser's' repositories (in alfred workflow)") 506 | ->icon('repo') 507 | ->prio(1) 508 | ->valid(false) 509 | ); 510 | } 511 | 512 | private static function addMyCommands() 513 | { 514 | $parts = self::$parts; 515 | if (isset($parts[2]) && in_array($parts[1], ['pulls', 'issues'])) { 516 | $icon = 'pulls' === $parts[1] ? 'pull-request' : 'issue'; 517 | $items = $icon.'s'; 518 | $subs = [ 519 | 'created' => [$parts[1], 'View your '.$items], 520 | 'assigned' => [$parts[1].'/assigned', 'View your assigned '.$items], 521 | 'mentioned' => [$parts[1].'/mentioned', 'View '.$items.' that mentioned you'], 522 | ]; 523 | if ('pulls' === $parts[1]) { 524 | $subs['review requested'] = [$parts[1].'/review-requested', 'View '.$items.' that require review']; 525 | } 526 | foreach ($subs as $key => $sub) { 527 | Workflow::addItemIfMatches(Item::create() 528 | ->title('my '.$parts[1].' '.$key) 529 | ->subtitle($sub[1]) 530 | ->icon($icon) 531 | ->arg('/'.$sub[0]) 532 | ->prio(1) 533 | ); 534 | } 535 | 536 | return; 537 | } elseif (isset($parts[2]) && 'repos' === $parts[1]) { 538 | Workflow::addItemIfMatches(Item::create() 539 | ->title('my '.$parts[1].' ') 540 | ->subtitle('View your repos') 541 | ->icon('repo') 542 | ->arg('/'.self::$user->login.'?tab=repositories') 543 | ->prio(1) 544 | ); 545 | Workflow::addItemIfMatches(Item::create() 546 | ->title('my '.$parts[1].' new') 547 | ->subtitle('Create new repo') 548 | ->icon('repo') 549 | ->arg('/new') 550 | ->prio(1) 551 | ); 552 | 553 | return; 554 | } 555 | 556 | $myPages = [ 557 | 'dashboard' => ['', 'View your dashboard'], 558 | 'pulls ' => ['pulls', 'View your pull requests', 'pull-request'], 559 | 'issues ' => ['issues', 'View your issues', 'issue'], 560 | 'stars' => [self::$user->login.'?tab=stars', 'View your starred repositories'], 561 | 'profile' => [self::$user->login, 'View your public user profile', 'user'], 562 | 'settings' => ['settings', 'View or edit your account settings'], 563 | 'notifications' => ['notifications', 'View all your notifications'], 564 | ]; 565 | foreach ($myPages as $key => $my) { 566 | Workflow::addItemIfMatches(Item::create() 567 | ->title('my '.$key) 568 | ->subtitle($my[1]) 569 | ->icon(isset($my[2]) ? $my[2] : rtrim($key)) 570 | ->arg('/'.$my[0]) 571 | ->prio(1) 572 | ); 573 | } 574 | Workflow::addItemIfMatches(Item::create() 575 | ->title('my gists') 576 | ->subtitle('View your gists') 577 | ->icon('gists') 578 | ->arg(Workflow::getGistUrl().'/'.self::$user->login) 579 | ->prio(1) 580 | ); 581 | 582 | Workflow::addItemIfMatches(Item::create() 583 | ->title('my repos ') 584 | ->subtitle('View your repos') 585 | ->icon('repo') 586 | ->arg('/'.self::$user->login.'?tab=repositories') 587 | ->prio(1) 588 | ); 589 | } 590 | 591 | private static function addSystemCommands() 592 | { 593 | $cmds = [ 594 | 'delete cache' => 'Delete GitHub Cache', 595 | 'logout' => 'Log out this workflow', 596 | 'delete database' => 'Delete database (contains login, config and cache)', 597 | 'update' => 'Update this Alfred workflow', 598 | ]; 599 | if (Workflow::getConfig('autoupdate', true)) { 600 | $cmds['deactivate autoupdate'] = 'Deactivate auto updating this Alfred Workflow'; 601 | } else { 602 | $cmds['activate autoupdate'] = 'Activate auto updating this Alfred Workflow'; 603 | } 604 | if (self::$enterprise) { 605 | $cmds['enterprise reset'] = 'Reset the GitHub Enterprise URL'; 606 | } 607 | foreach ($cmds as $cmd => $desc) { 608 | Workflow::addItemIfMatches(Item::create() 609 | ->title('> '.$cmd) 610 | ->subtitle($desc) 611 | ->icon($cmd) 612 | ->arg('> '.str_replace(' ', '-', $cmd)) 613 | ); 614 | } 615 | 616 | $cmds = [ 617 | 'help' => 'readme', 618 | 'changelog' => 'changelog', 619 | ]; 620 | foreach ($cmds as $cmd => $file) { 621 | Workflow::addItemIfMatches(Item::create() 622 | ->title('> '.$cmd) 623 | ->subtitle('View the '.$file) 624 | ->icon('file') 625 | ->arg('https://github.com/gharlan/alfred-github-workflow/blob/main/'.strtoupper($file).'.md') 626 | ); 627 | } 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /server.php: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | 24 | alfred-github-workflow 25 | 56 | 57 | 58 |
59 | 60 |

61 | alfred-github-workflow is ready.
62 | Have fun. 63 |

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /workflow.php: -------------------------------------------------------------------------------- 1 | setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 56 | 57 | if (!$exists) { 58 | self::createTables(); 59 | } 60 | 61 | if (self::$enterprise) { 62 | self::$baseUrl = self::getConfig('enterprise_url'); 63 | self::$apiUrl = self::$baseUrl ? self::$baseUrl.'/api/v3' : null; 64 | self::$gistUrl = self::$baseUrl ? self::$baseUrl.'/gist' : null; 65 | } 66 | 67 | self::$debug = getenv('alfred_debug') && defined('STDERR'); 68 | 69 | register_shutdown_function([__CLASS__, 'shutdown']); 70 | } 71 | 72 | public static function shutdown() 73 | { 74 | if (self::$refreshUrls) { 75 | $urls = implode(',', array_keys(self::$refreshUrls)); 76 | exec(escapeshellarg(PHP_BINARY).' action.php "> refresh-cache '.$urls.'" > /dev/null 2>&1 &'); 77 | self::log('refreshing cache in background for %s', $urls); 78 | } 79 | } 80 | 81 | public static function setConfig($key, $value) 82 | { 83 | self::getStatement('REPLACE INTO config VALUES(?, ?)')->execute([$key, $value]); 84 | } 85 | 86 | public static function getConfig($key, $default = null) 87 | { 88 | $stmt = self::getStatement('SELECT value FROM config WHERE key = ?'); 89 | $stmt->execute([$key]); 90 | $value = $stmt->fetchColumn(); 91 | 92 | return false !== $value ? $value : $default; 93 | } 94 | 95 | public static function removeConfig($key) 96 | { 97 | self::getStatement('DELETE FROM config WHERE key = ?')->execute([$key]); 98 | } 99 | 100 | public static function getBaseUrl() 101 | { 102 | return self::$baseUrl; 103 | } 104 | 105 | public static function getApiUrl($path = null) 106 | { 107 | $url = self::$apiUrl; 108 | 109 | if ($path) { 110 | $paramStart = false === strpos($path, '?') ? '?' : '&'; 111 | $url .= $path.$paramStart.'per_page=100'; 112 | } 113 | 114 | return $url; 115 | } 116 | 117 | public static function getGistUrl() 118 | { 119 | return self::$gistUrl; 120 | } 121 | 122 | public static function setAccessToken($token) 123 | { 124 | self::setConfig(self::$enterprise ? 'enterprise_access_token' : 'access_token', $token); 125 | } 126 | 127 | public static function getAccessToken() 128 | { 129 | return self::getConfig(self::$enterprise ? 'enterprise_access_token' : 'access_token'); 130 | } 131 | 132 | public static function removeAccessToken() 133 | { 134 | self::removeConfig(self::$enterprise ? 'enterprise_access_token' : 'access_token'); 135 | } 136 | 137 | public static function request(string $url, ?Curl $curl = null, $callback = null, bool $withAuthorization = true) 138 | { 139 | self::log('loading content for %s', $url); 140 | 141 | $return = false; 142 | $returnValue = null; 143 | if (!$curl) { 144 | $curl = new Curl(); 145 | $return = true; 146 | $callback = function ($content) use (&$returnValue) { 147 | $returnValue = $content; 148 | }; 149 | } 150 | 151 | $token = $withAuthorization ? self::getAccessToken() : null; 152 | $curl->add(new CurlRequest($url, null, $token, function (CurlResponse $response) use ($callback) { 153 | if (is_callable($callback) && isset($response->content)) { 154 | $callback($response->content); 155 | } 156 | })); 157 | 158 | if ($return) { 159 | $curl->execute(); 160 | } 161 | 162 | return $returnValue; 163 | } 164 | 165 | /** 166 | * @param string $url 167 | * @param Curl $curl 168 | * @param callable $callback 169 | * @param bool $firstPageOnly 170 | * @param int $maxAge 171 | * @param bool $refreshInBackground 172 | * 173 | * @return mixed 174 | */ 175 | public static function requestCache(string $url, ?Curl $curl = null, $callback = null, bool $firstPageOnly = false, int $maxAge = self::DEFAULT_CACHE_MAX_AGE, bool $refreshInBackground = true) 176 | { 177 | $return = false; 178 | $returnValue = null; 179 | if (!$curl) { 180 | $curl = new Curl(); 181 | $return = true; 182 | $callback = function ($content) use (&$returnValue) { 183 | $returnValue = $content; 184 | }; 185 | } 186 | 187 | $stmt = self::getStatement('SELECT * FROM request_cache WHERE url = ?'); 188 | $stmt->execute([$url]); 189 | $stmt->bindColumn('timestamp', $timestamp); 190 | $stmt->bindColumn('etag', $etag); 191 | $stmt->bindColumn('content', $content); 192 | $stmt->bindColumn('refresh', $refresh); 193 | $stmt->fetch(PDO::FETCH_BOUND); 194 | 195 | $shouldRefresh = $timestamp < time() - 60 * $maxAge; 196 | $refreshInBackground = $refreshInBackground && null !== $content; 197 | 198 | if ($shouldRefresh && $refreshInBackground && $refresh < time() - 3 * 60) { 199 | self::getStatement('UPDATE request_cache SET refresh = ? WHERE url = ?')->execute([time(), $url]); 200 | self::$refreshUrls[$url] = true; 201 | } 202 | 203 | if (!$shouldRefresh || $refreshInBackground) { 204 | self::log('using cached content for %s', $url); 205 | $content = json_decode($content); 206 | 207 | if (!$firstPageOnly) { 208 | $stmt = self::getStatement('SELECT url, content FROM request_cache WHERE parent = ? ORDER BY `timestamp` DESC'); 209 | while ($stmt->execute([$url]) && $data = $stmt->fetchObject()) { 210 | $content = array_merge($content, json_decode($data->content)); 211 | $url = $data->url; 212 | } 213 | } 214 | 215 | if (is_callable($callback)) { 216 | $callback($content); 217 | } 218 | 219 | return $returnValue; 220 | } 221 | 222 | $responses = []; 223 | 224 | $handleResponse = function (CurlResponse $response, $content, $parent = null) use (&$handleResponse, $curl, &$responses, $stmt, $callback, $firstPageOnly) { 225 | $url = $response->request->url; 226 | if ($response && in_array($response->status, [200, 304])) { 227 | $checkNext = false; 228 | if (304 == $response->status) { 229 | $response->content = $content; 230 | $checkNext = true; 231 | } elseif (false === stripos($response->contentType, 'json')) { 232 | $response->content = json_encode($response->content); 233 | } 234 | $response->content = json_decode($response->content); 235 | if (isset($response->content->items)) { 236 | $response->content = $response->content->items; 237 | } 238 | $responses[] = $response->content; 239 | self::getStatement('REPLACE INTO request_cache VALUES(?, ?, ?, ?, 0, ?)') 240 | ->execute([$url, time(), $response->etag, json_encode($response->content), $parent]); 241 | 242 | if ($firstPageOnly) { 243 | // do nothing 244 | } elseif ($checkNext || $response->link && preg_match('/<([^<>]+)>; rel="next"/U', $response->link, $match)) { 245 | $stmt = self::getStatement('SELECT * FROM request_cache WHERE parent = ?'); 246 | $stmt->execute([$url]); 247 | if ($checkNext) { 248 | $stmt->bindColumn('url', $nextUrl); 249 | } else { 250 | $nextUrl = $match[1]; 251 | } 252 | $stmt->bindColumn('etag', $etag); 253 | $stmt->bindColumn('content', $content); 254 | $stmt->fetch(PDO::FETCH_BOUND); 255 | if ($nextUrl) { 256 | $curl->add(new CurlRequest($nextUrl, $etag, self::getAccessToken(), function (CurlResponse $response) use ($handleResponse, $url, $content) { 257 | $handleResponse($response, $content, $url); 258 | })); 259 | 260 | return; 261 | } 262 | } else { 263 | self::getStatement('DELETE FROM request_cache WHERE parent = ?')->execute([$url]); 264 | } 265 | } else { 266 | self::getStatement('DELETE FROM request_cache WHERE url = ?')->execute([$url]); 267 | $url = null; 268 | } 269 | 270 | if (is_callable($callback)) { 271 | if (empty($responses)) { 272 | $callback([]); 273 | 274 | return; 275 | } 276 | if (1 === count($responses)) { 277 | $callback($responses[0]); 278 | 279 | return; 280 | } 281 | $callback(array_reduce($responses, function ($content, $response) { 282 | return array_merge($content, $response); 283 | }, [])); 284 | } 285 | }; 286 | 287 | self::log('loading content for %s', $url); 288 | $curl->add(new CurlRequest($url, $etag, self::getAccessToken(), function (CurlResponse $response) use (&$responses, $handleResponse , $content) { 289 | $handleResponse($response, $content); 290 | })); 291 | 292 | if ($return) { 293 | $curl->execute(); 294 | } 295 | 296 | return $returnValue; 297 | } 298 | 299 | public static function requestApi(string $url, ?Curl $curl = null, $callback = null, bool $firstPageOnly = false, int $maxAge = self::DEFAULT_CACHE_MAX_AGE) 300 | { 301 | $url = self::getApiUrl($url); 302 | 303 | return self::requestCache($url, $curl, $callback, $firstPageOnly, $maxAge); 304 | } 305 | 306 | public static function cleanCache() 307 | { 308 | self::$db->exec('DELETE FROM request_cache WHERE timestamp < '.(time() - 100 * 24 * 60 * 60)); 309 | } 310 | 311 | public static function deleteCache() 312 | { 313 | self::$db->exec('DELETE FROM request_cache'); 314 | } 315 | 316 | public static function cacheWarmup() 317 | { 318 | $paths = ['/user', '/user/orgs', '/user/starred', '/user/subscriptions', '/user/repos', '/user/following']; 319 | foreach ($paths as $path) { 320 | self::$refreshUrls[self::getApiUrl($path)] = true; 321 | } 322 | } 323 | 324 | public static function startServer() 325 | { 326 | if (version_compare(PHP_VERSION, '5.4', '>=')) { 327 | self::stopServer(); 328 | shell_exec(sprintf( 329 | 'alfred_workflow_data=%s %s -d variables_order=EGPCS -S localhost:2233 server.php > /dev/null 2>&1 & echo $! >> %s', 330 | escapeshellarg(getenv('alfred_workflow_data')), 331 | escapeshellarg(PHP_BINARY), 332 | escapeshellarg(self::$filePids) 333 | )); 334 | } 335 | } 336 | 337 | public static function stopServer() 338 | { 339 | if (file_exists(self::$filePids)) { 340 | $pids = file(self::$filePids); 341 | foreach ($pids as $pid) { 342 | shell_exec('kill -9 '.$pid); 343 | } 344 | unlink(self::$filePids); 345 | } 346 | } 347 | 348 | public static function checkUpdate() 349 | { 350 | if (self::VERSION !== self::getConfig('version')) { 351 | self::setConfig('version', self::VERSION); 352 | } 353 | if (!self::getConfig('autoupdate', 1)) { 354 | return false; 355 | } 356 | $release = self::requestCache('https://api.github.com/repos/gharlan/alfred-github-workflow/releases/latest', null, null, true, 1440); 357 | if (!$release) { 358 | return false; 359 | } 360 | $version = ltrim($release->tag_name, 'v'); 361 | 362 | return version_compare($version, self::VERSION) > 0; 363 | } 364 | 365 | private static function createTables() 366 | { 367 | self::$db->exec(' 368 | CREATE TABLE config ( 369 | key TEXT PRIMARY KEY NOT NULL, 370 | value TEXT 371 | ) WITHOUT ROWID 372 | '); 373 | 374 | self::$db->exec(' 375 | CREATE TABLE request_cache ( 376 | url TEXT PRIMARY KEY NOT NULL, 377 | timestamp INTEGER NOT NULL, 378 | etag TEXT, 379 | content TEXT, 380 | refresh INTEGER, 381 | parent TEXT 382 | ) WITHOUT ROWID 383 | '); 384 | self::$db->exec('CREATE INDEX parent_url ON request_cache(parent) WHERE parent IS NOT NULL'); 385 | } 386 | 387 | public static function deleteDatabase() 388 | { 389 | self::closeCursors(); 390 | self::$db = null; 391 | unlink(self::$fileDb); 392 | } 393 | 394 | public static function addItemIfMatches(Item $item) 395 | { 396 | if ($item->match(self::$query)) { 397 | self::$items[] = $item; 398 | } 399 | } 400 | 401 | public static function addItem(Item $item) 402 | { 403 | self::$items[] = $item; 404 | } 405 | 406 | public static function sortItems() 407 | { 408 | usort(self::$items, function (Item $a, Item $b) { 409 | return $a->compare($b); 410 | }); 411 | } 412 | 413 | public static function getItemsAsXml() 414 | { 415 | return Item::toXml(self::$items, self::$enterprise, self::$hotkey, self::getBaseUrl()); 416 | } 417 | 418 | public static function log($msg) 419 | { 420 | if (self::$debug) { 421 | fwrite(STDERR, "\n".call_user_func_array('sprintf', func_get_args())); 422 | } 423 | } 424 | 425 | /** 426 | * @param string $query 427 | * 428 | * @return PDOStatement 429 | */ 430 | public static function getStatement($query) 431 | { 432 | if (!isset(self::$statements[$query])) { 433 | self::$statements[$query] = self::$db->prepare($query); 434 | } 435 | 436 | return self::$statements[$query]; 437 | } 438 | 439 | protected static function closeCursors() 440 | { 441 | foreach (self::$statements as $statement) { 442 | $statement->closeCursor(); 443 | } 444 | self::$statements = []; 445 | } 446 | } 447 | --------------------------------------------------------------------------------