├── .gitignore ├── README.md ├── composer.json ├── composer.lock ├── db └── .gitkeep ├── index.php ├── lib └── RedmineCommand │ ├── AbstractCommand.php │ ├── CmdHelp.php │ ├── CmdShow.php │ ├── CmdUnknown.php │ ├── CommandFactory.php │ ├── Configuration.php │ ├── SlackResult.php │ ├── Util.php │ ├── Validator.php │ └── commands_definition.json └── logs └── .gitkeep /.gitignore: -------------------------------------------------------------------------------- 1 | # .gitignore 2 | /vendor/ 3 | /logs/*.txt 4 | index-dev.php 5 | /.settings/* 6 | /.buildpath 7 | /.project 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redmine Command 2 | 3 | A simple Redmine slack integration to manage issues. 4 | 5 | Uses 6 | * One Slack "Slash Commands" and one "Incoming WebHooks" integration (see Install). 7 | * [php-redmine-api](https://github.com/kbsali/php-redmine-api) 8 | * [KLogger](https://github.com/katzgrau/KLogger) 9 | 10 | How does it work? 11 | * It installs as a PHP application on your web server (using composer). 12 | * Through a "Slash Commands" Slack integration, it receives requests. 13 | * It communicates with your redmine installation to gather (or update) data. 14 | * Posts the results to an "Incoming WebHooks" Slack integration in the originator's channel or private group (yeah, private group!). 15 | 16 | ## Current Features 17 | 18 | The current stable release implements an extensible architecture that support easy implementation of future commands. 19 | Commands list: 20 | * show . Shows all the details in a redmine issue(s). 21 | * ussage (from slack): /redmine show issue_numbers 22 | * example: /redmine show 1 2 10 23 | * help . Shows the help data from every registered command. 24 | * ussage: /redmine help 25 | 26 | ## TODO 27 | 28 | * Move all strings variables to a global definitions file. 29 | * Implement more commands. The current work in progress centers around creating issues. 30 | 31 | ## Requirements 32 | 33 | * PHP >= 5.4 with cURL extension, 34 | * "Enable REST web service" on your redmine settings (Administration > Settings > Authentication) 35 | * Your "API access key" from your profile page. 36 | * Slack integrations (see install). 37 | 38 | ## Install 39 | 40 | ### On Slack 41 | 42 | * Create a new "Slash Commands" integration with the following data: 43 | * Command: /redmine (or whatever you like) 44 | * URL: the URL pointing to the index.php of your redmine-command install 45 | * Method: POST 46 | * Token: copy this token, we'll need it later. 47 | 48 | * Create a new "Incoming WebHooks" slack integration: 49 | * Post to Channel: Pick one, but this will be ignored by redmine-command. 50 | * Webhook URL: copy this URL, we'll need it later. 51 | * Descriptive Label, Customize Name, Customize Icon: whatever you like. 52 | 53 | * Go to [Slack API](https://api.slack.com/) and copy the authentication token for your team. 54 | 55 | * Go to your profile page on Redmine and copy your "API access key". 56 | 57 | ### On your web server 58 | 59 | Install [composer](http://getcomposer.org/download/) in a folder of your preference (should be accessible from your web server) then run: 60 | ```bash 61 | $ php composer.phar require digitalicagroup/redmine-command:~0.1 62 | $ cp vendor/digitalicagroup/redmine-command/index.php . 63 | ``` 64 | The last line copies index.php from the package with the configuration you need to modify. 65 | 66 | Edit index.php and add the following configuration parameters: 67 | ```php 68 | /** 69 | * token sent by slack (from your "Slash Commands" integration). 70 | */ 71 | $config->token = "vuLKJlkjdsflkjLKJLKJlkjd"; 72 | 73 | /** 74 | * URL of the Incoming WebHook slack integration. 75 | */ 76 | $config->slack_webhook_url = "https://hooks.slack.com/services/LKJDFKLJFD/DFDFSFDDSFDS/sdlfkjdlkfjLKJLKJKLJO"; 77 | 78 | /** 79 | * Slack API authentication token for your team. 80 | */ 81 | $config->slack_api_token = "xoxp-98475983759834-38475984579843-34985793845"; 82 | 83 | /** 84 | * Base URL of redmine installation. 85 | */ 86 | $config->redmine_url = "https://your/redmine/install"; 87 | 88 | /** 89 | * Redmine API key. 90 | */ 91 | $config->redmine_api_key = "0d089u4sldkfjfljlksdjffj43099034j"; 92 | 93 | /** 94 | * Log level threshold. The default is DEBUG. 95 | * If you are done testing or installing in production environment, 96 | * uncomment this line. 97 | */ 98 | //$config->log_level = LogLevel::WARNING; 99 | 100 | /** 101 | * logs folder, make sure the invoker have write permission. 102 | */ 103 | $config->log_dir = "/srv/api/redmine-command/logs"; 104 | ``` 105 | 106 | Make sure you give write permissions to the log_dir folder. 107 | 108 | ## Troubleshooting 109 | 110 | This is a list of common errors: 111 | * "I see some errors about permissions in the apache error log". 112 | * The process running redmine-command (usually the web server) needs write permissions to the folder configured in you $config->log_dir parameter. 113 | * For example, if you are running apache, that folder group must be assigned to www-data and its write permission for groups must be turned on. 114 | * "I followed the steps and nothing happens, nothing in web server error log and nothing in the app log". 115 | * If you see nothing in the logs (and have the debug level setted), may be the app is dying in the process of validating the slack token. redmine-command validates that the request matches with the configured token or the app dies at the very beginning. 116 | * "There is no error in the web server error log, I see some output in the app log (with the debug log level), but i get nothing in my channel/group". 117 | * Check in the app log for the strings "[DEBUG] Util: group found!" or "[DEBUG] Util: channel found!" . If you can't see those strings, check if your slack authentication token for your team is from an user that have access to the private group you are writing from. 118 | * I just developed a new command but I am getting a class not found error on CommandFactory. 119 | * Every time you add a new command (hence a new class), you must update the composer autoloader. just type: 120 | * php composer.phar update 121 | 122 | ## Contribute 123 | 124 | If you want to add aditional commands, your are welcome to contribute. All you need to do is extend the AbstractCommand class, and add a new entry to the commands_definition.json file. (You can see CmdShow.php for an example of what a command must do). 125 | 126 | All commands are received through the same "Slash Command Integration", so the first word after the /redmine must be the command trigger. The next words are splited by one or more spaces and passed to the command that triggered. 127 | 128 | The active development is done under the unstable branch. And the last stable release candidate is in the master branch. 129 | 130 | ## About Digitalica 131 | 132 | We are a small firm focusing on mobile apps development (iOS, Android) and we are passionate about new technologies and ways that helps us work better. 133 | * This project homepage: [RedmineCommand](https://github.com/digitalicagroup/redmine-command) 134 | * Digitalica homepage: [digitalicagroup.com](http://digitalicagroup.com) 135 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "digitalicagroup/redmine-command", 3 | "description": "Redmine-Slack integration to manage issues", 4 | "homepage": "https://github.com/digitalicagroup/redmine-command", 5 | "type": "project", 6 | "require": { 7 | "php": ">=5.4", 8 | "ext-curl": "*", 9 | "katzgrau/klogger": "1.0.*", 10 | "kbsali/redmine-api": "~1.0" 11 | }, 12 | "license": "GPL-3.0+", 13 | "authors": [ 14 | { 15 | "name": "Luis Augusto Peña Pereira", 16 | "email": "luis@digitalicagroup.com" 17 | } 18 | ], 19 | "autoload": { 20 | "psr-4": { 21 | "RedmineCommand\\": "lib/" 22 | }, 23 | "classmap": ["lib/"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", 5 | "This file is @generated automatically" 6 | ], 7 | "hash": "4a92ac82e73533dd54b7f61943697f5d", 8 | "packages": [ 9 | { 10 | "name": "katzgrau/klogger", 11 | "version": "1.0.0", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/katzgrau/klogger.git", 15 | "reference": "46cdd92a9b4a8443120cc955bf831450cb274813" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/katzgrau/klogger/zipball/46cdd92a9b4a8443120cc955bf831450cb274813", 20 | "reference": "46cdd92a9b4a8443120cc955bf831450cb274813", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "php": ">=5.3", 25 | "psr/log": "1.0.0" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "4.0.*" 29 | }, 30 | "type": "library", 31 | "autoload": { 32 | "psr-4": { 33 | "Katzgrau\\KLogger\\": "src/" 34 | }, 35 | "classmap": [ 36 | "src/" 37 | ] 38 | }, 39 | "notification-url": "https://packagist.org/downloads/", 40 | "license": [ 41 | "MIT" 42 | ], 43 | "authors": [ 44 | { 45 | "name": "Dan Horrigan", 46 | "email": "dan@dhorrigan.com", 47 | "homepage": "http://dhorrigan.com", 48 | "role": "Lead Developer" 49 | }, 50 | { 51 | "name": "Kenny Katzgrau", 52 | "email": "katzgrau@gmail.com" 53 | } 54 | ], 55 | "description": "A Simple Logging Class", 56 | "keywords": [ 57 | "logging" 58 | ], 59 | "time": "2014-03-20 02:36:36" 60 | }, 61 | { 62 | "name": "kbsali/redmine-api", 63 | "version": "v1.5.1", 64 | "source": { 65 | "type": "git", 66 | "url": "https://github.com/kbsali/php-redmine-api.git", 67 | "reference": "9c4a3b6948a67b40a3129a66940ea93e75208053" 68 | }, 69 | "dist": { 70 | "type": "zip", 71 | "url": "https://api.github.com/repos/kbsali/php-redmine-api/zipball/9c4a3b6948a67b40a3129a66940ea93e75208053", 72 | "reference": "9c4a3b6948a67b40a3129a66940ea93e75208053", 73 | "shasum": "" 74 | }, 75 | "require": { 76 | "ext-curl": "*", 77 | "php": ">=5.4" 78 | }, 79 | "require-dev": { 80 | "phpunit/phpunit": "~4.0" 81 | }, 82 | "type": "library", 83 | "autoload": { 84 | "psr-0": { 85 | "Redmine": "lib/" 86 | } 87 | }, 88 | "notification-url": "https://packagist.org/downloads/", 89 | "license": [ 90 | "MIT" 91 | ], 92 | "authors": [ 93 | { 94 | "name": "Kevin Saliou", 95 | "email": "kevin@saliou.name", 96 | "homepage": "http://kevin.saliou.name" 97 | } 98 | ], 99 | "description": "Redmine API client", 100 | "homepage": "https://github.com/kbsali/php-redmine-api", 101 | "keywords": [ 102 | "api", 103 | "redmine" 104 | ], 105 | "time": "2014-11-05 08:24:30" 106 | }, 107 | { 108 | "name": "psr/log", 109 | "version": "1.0.0", 110 | "source": { 111 | "type": "git", 112 | "url": "https://github.com/php-fig/log.git", 113 | "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b" 114 | }, 115 | "dist": { 116 | "type": "zip", 117 | "url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278b", 118 | "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b", 119 | "shasum": "" 120 | }, 121 | "type": "library", 122 | "autoload": { 123 | "psr-0": { 124 | "Psr\\Log\\": "" 125 | } 126 | }, 127 | "notification-url": "https://packagist.org/downloads/", 128 | "license": [ 129 | "MIT" 130 | ], 131 | "authors": [ 132 | { 133 | "name": "PHP-FIG", 134 | "homepage": "http://www.php-fig.org/" 135 | } 136 | ], 137 | "description": "Common interface for logging libraries", 138 | "keywords": [ 139 | "log", 140 | "psr", 141 | "psr-3" 142 | ], 143 | "time": "2012-12-21 11:40:51" 144 | } 145 | ], 146 | "packages-dev": [], 147 | "aliases": [], 148 | "minimum-stability": "stable", 149 | "stability-flags": [], 150 | "prefer-stable": false, 151 | "platform": { 152 | "php": ">=5.4", 153 | "ext-curl": "*" 154 | }, 155 | "platform-dev": [] 156 | } 157 | -------------------------------------------------------------------------------- /db/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digitalicagroup/redmine-command/efe2302ad9f66d01c04503159ac614e6bb7cacf4/db/.gitkeep -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | token = "vuLKJlkjdsflkjLKJLKJlkjd"; 19 | 20 | /** 21 | * URL of the Incoming WebHook slack integration. 22 | */ 23 | $config->slack_webhook_url = "https://hooks.slack.com/services/LKJDFKLJFD/DFDFSFDDSFDS/sdlfkjdlkfjLKJLKJKLJO"; 24 | 25 | /** 26 | * Slack API authentication token for your team. 27 | */ 28 | $config->slack_api_token = "xoxp-98475983759834-38475984579843-34985793845"; 29 | 30 | /** 31 | * Base URL of redmine installation. 32 | */ 33 | $config->redmine_url = "https://your/redmine/install"; 34 | 35 | /** 36 | * Redmine API key. 37 | */ 38 | $config->redmine_api_key = "0d089u4sldkfjfljlksdjffj43099034j"; 39 | 40 | /** 41 | * Log level threshold. 42 | * The default is DEBUG. 43 | * If you are done testing or installing in production environment, 44 | * uncomment this line. 45 | */ 46 | // $config->log_level = LogLevel::WARNING; 47 | 48 | /** 49 | * logs folder, make sure the invoker have write permission. 50 | */ 51 | $config->log_dir = "/srv/api/redmine-command/logs"; 52 | 53 | /** 54 | * Database folder, used by some commands to store user related temporal information. 55 | * Make sure the invoker have write permission. 56 | */ 57 | $config->db_dir = "/srv/api/redmine-command/db"; 58 | 59 | /** 60 | * This is to prevent redmine-command entry point to be called outside slack. 61 | * If you want it to be called from anywhere, comment the following 3 lines: 62 | */ 63 | 64 | if (! RedmineCommand\Validator::validate ( $_POST, $config )) { 65 | die (); 66 | } 67 | 68 | /** 69 | * Entry point execution. 70 | */ 71 | $command = RedmineCommand\CommandFactory::create ( $_POST, $config ); 72 | $command->execute (); 73 | $command->post (); 74 | -------------------------------------------------------------------------------- /lib/RedmineCommand/AbstractCommand.php: -------------------------------------------------------------------------------- 1 | 14 | * 15 | */ 16 | abstract class AbstractCommand { 17 | /** 18 | * Reference to $_POST parameters. 19 | */ 20 | protected $post; 21 | 22 | /** 23 | * Configuration parameters. 24 | * 25 | * @var RedmineCommand\Configuraton 26 | */ 27 | protected $config; 28 | 29 | /** 30 | * Logger facility. 31 | * 32 | * @var Katzgrau\KLogger\Logger 33 | */ 34 | protected $log; 35 | 36 | /** 37 | * Array of input parameters to the command. 38 | * This array does not contains the word referencing 39 | * the command itself, only the strings after that 40 | * first word. 41 | * 42 | * @var array of strings 43 | */ 44 | protected $cmd; 45 | 46 | /** 47 | * Boolean to post (or not) the response to the originator's 48 | * channel or group. 49 | * 50 | * @var bool 51 | */ 52 | protected $response_to_source_channel; 53 | 54 | /** 55 | * Result of executing the command. 56 | * 57 | * @var RedmineCommand\SlackResult 58 | */ 59 | private $result; 60 | 61 | /** 62 | * Construtor. 63 | * 64 | * @param array $post 65 | * Reference to $_POST parameters. 66 | * @param RedmineCommand\Configuration $config 67 | * Configuration parameters. 68 | * @param array $arr 69 | * String array containing this command input parameters. 70 | */ 71 | public function __construct($post, $config, $arr = array()) { 72 | $this->log = new Logger ( $config->log_dir, $config->log_level ); 73 | $this->post = $post; 74 | $this->config = $config; 75 | $this->cmd = $arr; 76 | $this->response_to_source_channel = true; 77 | $this->result = new SlackResult (); 78 | } 79 | 80 | /** 81 | * Function to be implemented by command subclasses. 82 | * Should return a proper instance of SlackResult class. 83 | */ 84 | abstract protected function executeImpl(); 85 | 86 | /** 87 | * Executes this command, and returns a new SlackResult instance. 88 | * TODO move channel_id string to global config. 89 | * 90 | * @return \RedmineCommand\SlackResult 91 | */ 92 | public function execute() { 93 | $this->log->debug ( "AbstractCommand (" . get_class ( $this ) . "): command array: {" . implode ( ",", $this->cmd ) . "}" ); 94 | $this->result = $this->executeImpl (); 95 | 96 | if ($this->response_to_source_channel) { 97 | $this->log->debug ( "AbstractCommand (" . get_class ( $this ) . "): requesting channel name for channel: " . $this->post ["channel_id"] ); 98 | $this->result->setChannel ( Util::getChannelName ( $this->config, $this->post ["channel_id"] ) ); 99 | } 100 | return $this->result; 101 | } 102 | 103 | /** 104 | * Function to set whether or not to post results to original channel or group. 105 | * If false, no response will be posted to the Incoming WebHook. 106 | * 107 | * @param bool $bool 108 | */ 109 | public function setResponseToSourceChannel($bool) { 110 | $this->response_to_source_channel = $bool; 111 | } 112 | 113 | /** 114 | * Post the SlackResult json representation to the Slack Incoming WebHook. 115 | */ 116 | public function post() { 117 | $json = $this->result->toJson (); 118 | $this->log->debug ( "AbstractCommand (" . get_class ( $this ) . "): response json: $json" ); 119 | $result = Util::post ( $this->config->slack_webhook_url, $json ); 120 | if (! $result) { 121 | $log->error ( "AbstractCommand: Error sending json: $json to slack hook: " . $this->config->slack_webhook_url ); 122 | } 123 | return $result; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /lib/RedmineCommand/CmdHelp.php: -------------------------------------------------------------------------------- 1 | 10 | * 11 | */ 12 | class CmdHelp extends AbstractCommand { 13 | protected function executeImpl() { 14 | $log = $this->log; 15 | $result = new SlackResult (); 16 | $result->setText ( "redmine-command help" ); 17 | $att = new SlackResultAttachment (); 18 | $att->setTitle ( "Available Commands:" ); 19 | $att->setFallback ( "Available Commands:" ); 20 | 21 | $fields = array (); 22 | $help_data = CommandFactory::getHelpData (); 23 | if ($help_data == null) { 24 | $log->error ( "CmdHelp: Error loading help data, check commands_definition.json format or file permissions" ); 25 | } else { 26 | $help_keys = array_keys ($help_data); 27 | foreach ( $help_keys as $key ) { 28 | $fields [] = SlackResultAttachmentField::withAttributes ( $key, $help_data[$key], false ); 29 | } 30 | } 31 | usort ( $fields, array ( 32 | "RedmineCommand\SlackResultAttachmentField", 33 | "compare" 34 | ) ); 35 | $att->setFieldsArray ( $fields ); 36 | $result->setAttachmentsArray ( array ( 37 | $att 38 | ) ); 39 | return $result; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/RedmineCommand/CmdShow.php: -------------------------------------------------------------------------------- 1 | 18 | * 19 | */ 20 | class CmdShow extends AbstractCommand { 21 | /** 22 | * Factory method to be implemented from \RedmineCommand\AbstractCommand . 23 | * 24 | * 25 | * Must return an instance of \RedmineCommand\SlackResult . 26 | * 27 | * @see \RedmineCommand\AbstractCommand::executeImpl() 28 | * @return \RedmineCommand\SlackResult 29 | */ 30 | protected function executeImpl() { 31 | $log = $this->log; 32 | $result = new SlackResult (); 33 | 34 | $log->debug ( "CmdShow: Issues Id: " . implode ( ",", $this->cmd ) ); 35 | 36 | $client = new Client ( $this->config->redmine_url, $this->config->redmine_api_key ); 37 | 38 | $resultText = "[requested by " . $this->post ["user_name"] . "]"; 39 | if (empty ( $this->cmd )) { 40 | $resultText .= " Issue number required!"; 41 | } else { 42 | $resultText .= " Issue Details: "; 43 | } 44 | 45 | // Fetching issues and adding them as slack attachments 46 | $attachments = array (); 47 | $attachmentUnknown = null; 48 | foreach ( $this->cmd as $issueId ) { 49 | $log->debug ( "CmdShow: calling Redmine api for issue id #$issueId" ); 50 | $issue = $client->api ( 'issue' )->show ( ( int ) $issueId ); 51 | $attachment = new SlackResultAttachment (); 52 | if (! is_array ( $issue )) { 53 | if (strcmp ( $issue, "Syntax error" ) == 0) { 54 | if ($attachmentUnknown == null) { 55 | $attachmentUnknown = new SlackResultAttachment (); 56 | $attachmentUnknown->setTitle ( "Unknown Issues:" ); 57 | $attachmentUnknown->setText ( "" ); 58 | } 59 | $log->debug ( "CmdShow: #$issueId issue unknown!" ); 60 | $attachmentUnknown->setText ( $attachmentUnknown->getText () . " $issueId" ); 61 | } 62 | } else { 63 | $log->debug ( "CmdShow: #$issueId issue found!" ); 64 | $attachment = Util::convertIssueToAttachment ( $this->config->getRedmineIssuesUrl (), $issueId, $issue ); 65 | $attachments [] = $attachment; 66 | } 67 | } 68 | $result->setText ( $resultText ); 69 | if ($attachmentUnknown != null) { 70 | $attachments [] = $attachmentUnknown; 71 | } 72 | $result->setAttachmentsArray ( $attachments ); 73 | return $result; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/RedmineCommand/CmdUnknown.php: -------------------------------------------------------------------------------- 1 | 10 | * 11 | */ 12 | class CmdUnknown extends AbstractCommand { 13 | protected function executeImpl() { 14 | $result = new SlackResult (); 15 | $result->setText ( 'Unknown Command' ); 16 | $this->log->debug ( "CmdUnknown: Executing CmdUnknown" ); 17 | return $result; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/RedmineCommand/CommandFactory.php: -------------------------------------------------------------------------------- 1 | 13 | * 14 | */ 15 | class CommandFactory { 16 | protected static $classes = null; 17 | protected static $help_data = null; 18 | 19 | /** 20 | * Factory method to create command instances. 21 | * New commands should be added to commands_definition.json 22 | * 23 | * @param array $post 24 | * Reference to $_POST 25 | * @param \RedmineCommand\Configuration $config 26 | * Configuration instance with parameters. 27 | * @return \RedmineCommand\AbstractCommand Returns an instance of an AbstractCommand subclass. 28 | */ 29 | public static function create($post, $config) { 30 | $cmd = new CmdUnknown ( $post, $config ); 31 | $log = new Logger ( $config->log_dir, $config->log_level ); 32 | $log->debug ( "CommandFactory: post received (json encoded): " . json_encode ( $post ) ); 33 | 34 | // checking if commands definitions have been loaded 35 | if (self::$classes == null || self::$help_data == null) { 36 | $result = self::reloadDefinitions (); 37 | if ($result) { 38 | $log->debug ( "CommandFactory: commands_definition.json loaded" ); 39 | } else { 40 | $log->error ( "CommandFactory: Error loading commands_definition.json, check json format or file permissions." ); 41 | } 42 | } 43 | // TODO move strings parameter 'text' to global definition 44 | if (isset ( $post ['text'] ) && self::$classes != null) { 45 | $log->debug ( "CommandFactory: text received: " . $post ['text'] ); 46 | // parsing inputs by space 47 | $input = preg_split ( "/[\s]+/", $post ['text'] ); 48 | // the first word represents the command 49 | if (in_array ( $input [0], array_keys ( self::$classes ) )) { 50 | $class = self::$classes [$input [0]]; 51 | array_shift ( $input ); 52 | $cmd = new $class ( $post, $config, $input ); 53 | } 54 | } 55 | return $cmd; 56 | } 57 | 58 | /** 59 | * Read command definitions from commands_definition.json. 60 | * 61 | * @return boolean returns false if json could not be loaded, true otherwise. 62 | */ 63 | public static function reloadDefinitions() { 64 | $result = false; 65 | $json = json_decode(preg_replace('/.+?({.+}).+/','$1',utf8_encode(file_get_contents ( __DIR__."/commands_definition.json" ))), true); 66 | if ($json != null) { 67 | self::$classes = array (); 68 | self::$help_data = array (); 69 | foreach ( $json ["commands"] as $command ) { 70 | self::$classes [$command ["trigger"]] = $command ["class"]; 71 | self::$help_data [$command ["help_title"]] = $command ["help_text"]; 72 | } 73 | $result = true; 74 | } 75 | return $result; 76 | } 77 | 78 | public static function getHelpData() { 79 | if (self::$help_data == null) { 80 | self::reloadDefinitions(); 81 | } 82 | return self::$help_data; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/RedmineCommand/Configuration.php: -------------------------------------------------------------------------------- 1 | token = null; 26 | $this->slack_webhook_url = null; 27 | $this->api_channels_info_url = URL_CHANNELS_INFO; 28 | $this->api_groups_list_url = URL_GROUPS_LIST; 29 | $this->slack_api_token = null; 30 | $this->redmine_url = null; 31 | $this->redmine_api_key = null; 32 | $this->redmine_url_issues = URL_ISSUES; 33 | $this->log_level = LogLevel::DEBUG; 34 | $this->default_channel = null; 35 | $this->log_dir = "../../logs"; 36 | $this->db_dir = "../../db"; 37 | } 38 | 39 | public function getRedmineIssuesUrl () { 40 | return rtrim($this->redmine_url, '/') . $this->redmine_url_issues; 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /lib/RedmineCommand/SlackResult.php: -------------------------------------------------------------------------------- 1 | 22 | * 23 | */ 24 | abstract class AbstractArray { 25 | /** 26 | * Internal array to be wrapped. 27 | * 28 | * @var array an associative array of strings that can be converted to a json. 29 | */ 30 | protected $a; 31 | 32 | /** 33 | * Constructor. 34 | * Initializes the internal array 35 | */ 36 | public function __construct() { 37 | $this->a = array (); 38 | } 39 | 40 | /** 41 | * Returns the internal array. 42 | * 43 | * @return array 44 | */ 45 | public function toArray() { 46 | return $this->a; 47 | } 48 | 49 | /** 50 | * Returns the json representation of the internal array. 51 | * 52 | * @return string 53 | */ 54 | public function toJson() { 55 | return json_encode ( $this->a ); 56 | } 57 | 58 | /** 59 | * Stores a child array with the $key key. 60 | * 61 | * @param string $key 62 | * @param array $objs_array 63 | * array of \RedmineCommand\AbstractArray (subclasses) instances. 64 | */ 65 | public function setArray($key, $objs_array) { 66 | $this->a [$key] = array (); 67 | foreach ( $objs_array as $obj ) { 68 | $this->a [$key] [] = $obj->toArray (); 69 | } 70 | } 71 | 72 | /** 73 | * Getter method for a specific value referenced by the given key. 74 | * @param string $key 75 | */ 76 | public function getValue ($key) { 77 | if (isset ($this->a[$key])) { 78 | return $this->a[$key]; 79 | } else { 80 | return NULL; 81 | } 82 | } 83 | } 84 | 85 | /** 86 | * Class to define accessor methods to the parent json element present 87 | * in a payload to the slack incoming webhook. 88 | * Each payload consists of one instance of SlackResult. 89 | * In a SlackResult instance, an array of SlackResultAttachment 90 | * can be stored to represent "attachments" in a message to slack. 91 | * In a SlackResultAttachment, an array of SlackResultAttachmentField 92 | * can be stored to represent "fields" in the given attachment of a message to slack. 93 | * 94 | * @author Luis Augusto Peña Pereira 95 | * 96 | */ 97 | class SlackResult extends AbstractArray { 98 | public function __construct() { 99 | parent::__construct (); 100 | $this->a [R_MRKDWN] = true; 101 | } 102 | public function setText($text) { 103 | $this->a [R_TEXT] = $text; 104 | } 105 | public function setMrkdwn($mrkdwn) { 106 | $this->a [R_MRKDWN] = $mrkdwn; 107 | } 108 | public function setAttachmentsArray($att) { 109 | $this->setArray ( R_ATT, $att ); 110 | } 111 | public function setChannel($channel) { 112 | $this->a [R_CHANNEL] = $channel; 113 | } 114 | } 115 | 116 | /** 117 | * Class to be used as an attachment in a message to slack. 118 | * 119 | * @author Luis Augusto Peña Pereira 120 | * 121 | */ 122 | class SlackResultAttachment extends AbstractArray { 123 | public function __construct() { 124 | parent::__construct (); 125 | $this->a [R_MRKDWN_IN] = array ( 126 | R_PRETEXT, 127 | R_TEXT, 128 | R_TITLE, 129 | R_FALLBACK, 130 | R_FIELDS 131 | ); 132 | } 133 | public function setTitle($title) { 134 | $this->a [R_TITLE] = $title; 135 | } 136 | public function setFallback($fallback) { 137 | $this->a [R_FALLBACK] = $fallback; 138 | } 139 | public function setPretext($pretext) { 140 | $this->a [R_PRETEXT] = $pretext; 141 | } 142 | public function setText($text) { 143 | $this->a [R_TEXT] = $text; 144 | } 145 | public function getText() { 146 | return $this->a [R_TEXT]; 147 | } 148 | public function setMrkdwnArray($arr) { 149 | $this->a [R_MRKDWN_IN] = $arr; 150 | } 151 | public function setFieldsArray($fields) { 152 | $this->setArray ( R_FIELDS, $fields ); 153 | } 154 | } 155 | 156 | /** 157 | * Class to be used as field in an attachment. 158 | * 159 | * @author Luis Augusto Peña Pereira 160 | * 161 | */ 162 | class SlackResultAttachmentField extends AbstractArray { 163 | public static function withAttributes ($title, $value, $isShort = true) { 164 | $instance = new self(); 165 | $instance->setTitle ($title); 166 | $instance->setValue ($value); 167 | $instance->setShort ($isShort); 168 | return $instance; 169 | } 170 | public function setTitle($title) { 171 | $this->a [R_TITLE] = $title; 172 | } 173 | public function setValue($value) { 174 | $this->a [R_VALUE] = $value; 175 | } 176 | public function setShort($isShort) { 177 | $this->a [R_SHORT] = $isShort; 178 | } 179 | public static function compare($a, $b) { 180 | $al = strtolower ( $a->getValue(R_TITLE) ); 181 | $bl = strtolower ( $b->getValue(R_TITLE) ); 182 | if ($al == $bl) { 183 | return 0; 184 | } 185 | return ($al > $bl) ? + 1 : - 1; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /lib/RedmineCommand/Util.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | */ 13 | class Util { 14 | /** 15 | * Function to post a payload to a url using cURL. 16 | * 17 | * @param string $url 18 | * @param string $payload 19 | * @param string $contentType 20 | * @return mixed the result of the curl execution. 21 | */ 22 | public static function post($url, $payload, $contentType = 'Content-Type: application/json') { 23 | // TODO move constants to global configuration file 24 | $ch = curl_init ( $url ); 25 | curl_setopt ( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); 26 | curl_setopt ( $ch, CURLOPT_POSTFIELDS, $payload ); 27 | curl_setopt ( $ch, CURLOPT_RETURNTRANSFER, true ); 28 | curl_setopt ( $ch, CURLOPT_HTTPHEADER, array ( 29 | $contentType, 30 | 'Content-Length: ' . strlen ( $payload ) 31 | ) ); 32 | $result = curl_exec ( $ch ); 33 | return $result; 34 | } 35 | 36 | /** 37 | * Locates (using slack api) the channel name of a given channel id. 38 | * 39 | * @param \RedmineCommand\Configuration $config 40 | * @param string $channelId 41 | * slack channel id to be found. 42 | * @return string 43 | */ 44 | public static function getChannelName($config, $channelId) { 45 | // TODO move constants to global configuration file 46 | $log = new Logger ( $config->log_dir, $config->log_level ); 47 | $channel = ''; 48 | $api_channels_info_url = $config->api_channels_info_url; 49 | $api_groups_list_url = $config->api_groups_list_url; 50 | $slack_api_token = $config->slack_api_token; 51 | // Querying channels info service first 52 | $payload = array ( 53 | "token" => $slack_api_token, 54 | "channel" => $channelId 55 | ); 56 | $log->debug ( "Util: going to invoke channels.info: $api_channels_info_url with payload: " . http_build_query ( $payload ) ); 57 | 58 | $result = self::post ( $api_channels_info_url, http_build_query ( $payload ), 'multipart/form-data' ); 59 | if (! $result) { 60 | $log->error ( "Util: Error sending: " . http_build_query ( $payload ) . " to channels info service: $api_channels_info_url" ); 61 | } 62 | $result = json_decode ( $result, true ); 63 | if ($result ["ok"]) { 64 | // Channel found! 65 | $channel = $result ["channel"] ["name"]; 66 | $log->debug ( "Util: channel found!: " . $channel ); 67 | } else { 68 | // Querying groups list service 69 | $log->debug ( "Util: going to invoke groups.list: $api_groups_list_url with payload: " . http_build_query ( $payload ) ); 70 | $payload = array ( 71 | "token" => $slack_api_token 72 | ); 73 | $result = self::post ( $api_groups_list_url, http_build_query ( $payload ), 'multipart/form-data' ); 74 | if (! $result) { 75 | $log->error ( "Util: Error sending: " . http_build_query ( $payload ) . " to groups list service: $api_channels_info_url" ); 76 | } 77 | $result = json_decode ( $result, true ); 78 | if ($result ["ok"]) { 79 | // look for group 80 | foreach ( $result ["groups"] as $group ) { 81 | if (strcmp ( $group ["id"], $channelId ) == 0) { 82 | $channel = $group ["name"]; 83 | $log->debug ( "Util: group found!: " . $channel ); 84 | break; 85 | } 86 | } 87 | } 88 | } 89 | return "#" . $channel; 90 | } 91 | 92 | /** 93 | * Converts an array representation of an issue (as returned by the php-redmine-api) to an 94 | * instance of SlackResultAttachment to be used in messages sent to a slack incoming webhook. 95 | * 96 | * @param string $redmine_issues_url 97 | * @param string $issue_id 98 | * @param array $issue 99 | * @return \RedmineCommand\SlackResultAttachment 100 | * @see \RedmineCommand\SlackResultAttachment 101 | * @link https://github.com/kbsali/php-redmine-api 102 | */ 103 | public static function convertIssueToAttachment($redmine_issues_url, $issue_id, $issue) { 104 | $attachment = new SlackResultAttachment (); 105 | $attachment->setTitle ( "#" . $issue_id . " " . $issue ['issue'] ['subject'] ); 106 | $attTitle = "[<" . $redmine_issues_url . $issue_id . "|" . $issue ['issue'] ['tracker'] ['name'] . " #" . $issue_id . ">]"; 107 | $attachment->setPretext ( $attTitle ); 108 | $attachment->setTitle ( $issue ['issue'] ['subject'] ); 109 | $attachment->setText ( $issue ["issue"] ["description"] ); 110 | $fixed_version = "None"; 111 | if (isset ( $issue ["issue"] ["fixed_version"] ["name"] )) { 112 | $fixed_version = $issue ["issue"] ["fixed_version"] ["name"]; 113 | } 114 | $estimated_hours = "None"; 115 | if (isset ( $issue ["issue"] ["estimated_hours"] )) { 116 | $estimated_hours = $issue ["issue"] ["estimated_hours"]; 117 | } 118 | $assigned_to = "None"; 119 | if (isset ( $issue ["issue"] ["assigned_to"] ["name"] )) { 120 | $assigned_to = $issue ["issue"] ["assigned_to"] ["name"]; 121 | } 122 | $fields = array (); 123 | $fields [] = self::createField ( "Project", $issue ["issue"] ["project"] ["name"] ); 124 | $fields [] = self::createField ( "Version", $fixed_version ); 125 | $fields [] = self::createField ( "Status", $issue ["issue"] ["status"] ["name"] ); 126 | $fields [] = self::createField ( "Priority", $issue ["issue"] ["priority"] ["name"] ); 127 | $fields [] = self::createField ( "Assigned To", $assigned_to ); 128 | $fields [] = self::createField ( "Author", $issue ["issue"] ["author"] ["name"] ); 129 | $fields [] = self::createField ( "Start Date", $issue ["issue"] ["start_date"] ); 130 | $fields [] = self::createField ( "Estimated Hours", $estimated_hours ); 131 | $fields [] = self::createField ( "Done Ratio", $issue ["issue"] ["done_ratio"] . "%" ); 132 | $fields [] = self::createField ( "Spent Hours", $issue ["issue"] ["spent_hours"] ); 133 | $fields [] = self::createField ( "Created On", $issue ["issue"] ["created_on"] ); 134 | $fields [] = self::createField ( "Updated On", $issue ["issue"] ["updated_on"] ); 135 | $attachment->setFieldsArray ( $fields ); 136 | return $attachment; 137 | } 138 | protected static function createField($title, $value, $short = true) { 139 | $field = new SlackResultAttachmentField (); 140 | $field->setTitle ( $title ); 141 | $field->setValue ( $value ); 142 | $field->setShort ( $short ); 143 | return $field; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /lib/RedmineCommand/Validator.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | */ 13 | class Validator { 14 | /** 15 | * It return whether or not a configured token matches with the token 16 | * received (from slack) in the $_POST parameters. 17 | * 18 | * @param array $post 19 | * reference to the $_POST parameters. 20 | * @param \RedmineCommand\Configuration $configuration 21 | * @return boolean 22 | */ 23 | public static function validate($post, $configuration) { 24 | // TODO move constants to global configuration 25 | $log = new Logger ( $configuration->log_dir, $configuration->log_level ); 26 | $token = $configuration->token; 27 | $result = false; 28 | if ($token != null && isset ( $post ['token'] )) { 29 | if (strcmp ( $token, $post ['token'] ) == 0) 30 | $result = true; 31 | } 32 | return $result; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/RedmineCommand/commands_definition.json: -------------------------------------------------------------------------------- 1 | { 2 | "commands": [ 3 | { 4 | "trigger": "show", 5 | "class": "RedmineCommand\\CmdShow", 6 | "help_title": "show ", 7 | "help_text": "Shows values for a list of issue numbers (space separated)." 8 | }, 9 | { 10 | "trigger": "help", 11 | "class": "RedmineCommand\\CmdHelp", 12 | "help_title": "help", 13 | "help_text": "Shows this help." 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digitalicagroup/redmine-command/efe2302ad9f66d01c04503159ac614e6bb7cacf4/logs/.gitkeep --------------------------------------------------------------------------------