diff --git a/README.md b/README.md index c01006e..2da0286 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,18 @@ slack-integration A place where I put some integration scripts for the popular Slack messaging platform. - #Rally Bot -We use this to query our Rally instance for defects, tasks and user stories. I have it configured to respond to a /rallyme slash command in Slack. +Pushes notifications from Rally and allows users to fetch ticket details. Notifications are sent whenever: + +1. a new defect, user story, or test case is created +2. a user story changes state + +The bot uses a combination of Rally's _state_ and _ready_ fields to track the progress of user stories. When a story's state is set to "In-Progress" and the ready field is checked, rallybot will announce that the story is ready for testing. When the QA team has completed testing, they may either: 1) uncheck the Ready flag to have rallybot announce that the story needs work, or 2) set the story to "Completed" to notify the Product Owner that it is ready for review. + +> **Note**: As soon as all of a story's tasks are completed, Rally will automatically set the story's state to "Completed". So be sure to leave at least one task open in order to correctly track stories with defects. + +The bot can also be configured to respond to a /rallyme slash command to query our Rally instance for defects, tasks and user stories. E.g.: /rallyme DE12345 diff --git a/scripts/config/rallycron.conf.php b/scripts/config/rallycron.conf.php new file mode 100644 index 0000000..bbd2816 --- /dev/null +++ b/scripts/config/rallycron.conf.php @@ -0,0 +1,4 @@ +text, $payload->attachments); - + if($result=='Invalid channel specified'){ die("Sorry, the rallyme command can't post messages to your private chat.\n"); } - + if($result!="ok"){ print_r($result."\n"); print_r(json_encode($payload)); @@ -71,11 +72,11 @@ function postit($channel_name, $payload, $attachments){ global $config, $slackCommand; return slack_incoming_hook_post_with_attachments( - $config['slack']['hook'], - $config['rally']['botname'], - $slackCommand->ChannelName, - $config['rally']['boticon'], - $payload, + $config['slack']['hook'], + $config['rally']['botname'], + $slackCommand->ChannelName, + $config['rally']['boticon'], + $payload, $attachments); } @@ -169,7 +170,7 @@ function GetDefectPayload($ref) array_push($fields,$firstattachment); global $slackCommand; - + $userlink = BuildUserLink($slackCommand->UserName); $user_message = "Ok, {$userlink}, here's the defect you requested."; @@ -265,7 +266,7 @@ function GetRequirementPayload($ref) if($blocked) array_push($fields, MakeField("blocked",$blockedreason,true)); - + array_push($fields, MakeField("description",$short_description,false)); if($firstattachment!=null) @@ -310,6 +311,66 @@ function CallAPI($uri) return $object; } +/** + * Returns an abstract notion of a story's current state based on a combination + * of its ScheduleState and Ready fields and revision history. + * + * @param object $Story + * @param object $lastCronTime + * + * @return string[] Array containing story's state label and name of the user + * that updated the story to this state + */ +function FetchStoryStateChangeInfo($Story, $lastCronTime) +{ + $state = ''; + $fact_table = array(); //revision messages that must appear/not appear since last cron run to verify state + + switch ($Story->ScheduleState) { //make hypothesis about state based on story's ScheduleState/Ready fields + + case 'Completed': + $state = 'acceptance-ready'; + $fact_table[1] = 'SCHEDULE STATE changed'; + break; + + case 'In-Progress': + if ($Story->Ready) { + $state = 'verification-ready'; + $fact_table[1] = 'READY changed from [false] to [true]'; + + } else { + $state = 'needs-work'; + $fact_table[0] = 'SCHEDULE STATE changed'; //array index 0 indicates fact that must not appear + $fact_table[1] = 'READY changed from [true] to [false]'; + } + break; + + default: + return NULL; //don't report other states + } + + $query_url = $Story->RevisionHistory->_ref . '/Revisions?query=(CreationDate+>+' . $lastCronTime . ')&fetch=CreationDate,Description,User'; + $results = CallAPI($query_url); + $results = $results->QueryResult->Results; + + $is_verified = FALSE; //parse latest revision messages to verify state + $updated_by = '' //return the name of the user that made the change + + foreach ($results as $Revision) { + if (isset($fact_table[0]) && (strpos($Revision->Description, $fact_table[0]) !== FALSE)){ + return NULL; //stop parsing if the negative fact has appeared + } + if (strpos($Revision->Description, $fact_table[1]) !== FALSE) { //keep looking for negative fact + $is_verified = TRUE; + $updated_by = $Revision->User->_refObjectName; + } + } + + if (!$is_verified) { + return NULL; //don't report stories with unconfirmed state changes + } + return array($state, $updated_by); +} function GetProjectID($projectref) { diff --git a/scripts/include/rallycron.inc.php b/scripts/include/rallycron.inc.php new file mode 100644 index 0000000..a1f59d8 --- /dev/null +++ b/scripts/include/rallycron.inc.php @@ -0,0 +1,147 @@ ++' . $since . '))&fetch=CreationDate,FormattedID,LastUpdateDate,Owner,Ready,RevisionHistory,ScheduleState,SubmittedBy&order=LastUpdateDate+asc&pagesize=200'; + + $results = CallAPI($query_url); + $results = $results->QueryResult->Results; + + $project_url = $RALLY_URL . '#/' . $RALLY_PROJECT_ID; + + $items = array(); + foreach ($results as $Artifact) { + + $user = ''; + switch ($type = $Artifact->_type) { + case 'Defect': + $path = '/detail/defect/'; + $user = $Artifact->SubmittedBy->_refObjectName; + break; + case 'HierarchicalRequirement': + $type = 'User Story'; + $path = '/detail/userstory/'; + break; + case 'TestCase': + $type = 'Test Case'; + $path = '/detail/testcase/'; + break; + default: + continue 2; //don't display other artifact types + } + if (empty($user) && isset($Artifact->Owner)) { + $user = $Artifact->Owner->_refObjectName; + } + + //was the artifact just created? + $lastUpDate = date_create_from_format($RALLY_TIMESTAMP_FORMAT, $Artifact->LastUpdateDate)->getTimestamp(); + $creationDate = date_create_from_format($RALLY_TIMESTAMP_FORMAT, $Artifact->CreationDate)->getTimestamp(); + + if (($lastUpDate - $creationDate) < 2) { //assume items updated within 1 sec haven't changed state + $items[] = array( //report newly-created artifacts + 'type' => $type, + 'title' => $Artifact->_refObjectName, + 'url' => $project_url . $path . basename($Artifact->_ref), + 'user' => $user, + 'id' => $Artifact->FormattedID + ); + + } elseif ($type == 'User Story') { //track progress of user stories + + $state_info = FetchStoryStateChangeInfo($Artifact, $since); //see rally library file + if (is_null($state_info)) { + continue; //skip stories that haven't changed state + } + + $items[] = array( + 'type' => $type, + 'title' => $Artifact->_refObjectName, + 'url' => $project_url . $path . basename($Artifact->_ref), + 'user' => $state_info[1], + 'id' => $Artifact->FormattedID, + 'state' => $state_info[0] //presence of this key indicates state-change notification + ); + } + } + return $items; +} + +function SendRallyUpdateNotifications($items) +{ + global $SLACK_CHANNEL_FOR_RALLY_PROJECT; + $success = TRUE; + + foreach ($items as $item) { + $item['title'] = SanitizeText($item['title']); + $item['title'] = TruncateText($item['title'], 300); + $slug = l($item['title'], $item['url']); + + //display a state-change notification as a message attachment + if (isset($item['state'])) { + switch ($item['state']) { + case 'verification-ready': + $item['state'] = ' is ready for QA'; + $color = '#F29513'; //github orange + break; + case 'needs-work': + $item['state'] = ' needs additional work'; + $color = '#D84A63'; //paletton-suggested red + break; + case 'acceptance-ready': + $item['state'] = ' is ready for acceptance'; + $color = '#6CC644'; //github green + } + $item['state'] = $item['id'] . $item['state']; + + $pretext = em($item['type'] . ' updated by ' . $item['user']); + $text = ''; + $fields = array(MakeField($item['state'], $slug)); + $fallback = $item['type'] . ' ' . $item['state']; + + //display a link to the new artifact as a message attachment + } else { + $pretext = em('New ' . $item['type'] . ' added by ' . $item['user']); + $text = ''; + $color = '#6CC644'; //github green + $fields = array(MakeField($item['id'], $slug)); + $fallback = $item['type'] . ' ' . $item['id'] . ' added by ' . $item['user']; + } + + $message = MakeAttachment($pretext, $text, $color, $fields, $fallback); + $success = sendIncomingWebHookMessage($SLACK_CHANNEL_FOR_RALLY_PROJECT, '', $message) && $success; + } + + return $success; +} + +function SendIncomingWebHookMessage($channel, $payload, $attachments) +{ + global $config; + + //allow bot to display formatted attachment text + $attachments->mrkdwn_in = ['pretext', 'text', 'title', 'fields']; + + $reply = slack_incoming_hook_post_with_attachments( + $config['slack']['hook'], + $config['rally']['botname'], + $channel, + $config['rally']['boticon'], + $payload, + $attachments + ); + + $success = ($reply == 'ok'); + if (!$success) { + trigger_error('Unable to send Incoming WebHook message: ' . $reply); + } + return $success; +} + diff --git a/scripts/include/slack.php b/scripts/include/slack.php index 38639a2..c8d1be3 100644 --- a/scripts/include/slack.php +++ b/scripts/include/slack.php @@ -29,11 +29,35 @@ function BuildSlashCommand($request) return $cmd; } +//text-formatting functions + +function SanitizeText($text) +{ + $text = strtr($text, array('
' => '\n', '
' => '\n', '

' => '\n')); + return html_entity_decode(strip_tags($text), ENT_HTML401 | ENT_COMPAT, 'UTF-8'); +} + +function l($text, $url) +{ + return '<' . $url . '|' . $text . '>'; +} + +function em($text) +{ + return '_' . $text . '_'; +} + +function b($text) +{ + return '*' . $text . '*'; +} + +//posting functions function slack_incoming_hook_post($uri, $user, $channel, $icon, $emoji, $payload){ - + $data = array( - "text" => $payload, + "text" => $payload, "channel" => "#".$channel, "username"=>$user ); @@ -53,18 +77,17 @@ function slack_incoming_hook_post($uri, $user, $channel, $icon, $emoji, $payload return curl_post($uri, $data_string); } - - function slack_incoming_hook_post_with_attachments($uri, $user, $channel, $icon, $payload, $attachments){ $data = array( - "text" => $payload, + "text" => $payload, "channel" => "#".$channel, "username"=>$user, "icon_url"=>$icon, "attachments"=>array($attachments)); $data_string = "payload=" . json_encode($data, JSON_HEX_AMP|JSON_HEX_APOS|JSON_NUMERIC_CHECK|JSON_PRETTY_PRINT); + $data_string = strtr($data_string, array('\\\\n' => '\n')); //unescape slashes in newline characters mylog('sent.txt',$data_string); return curl_post($uri, $data_string); } diff --git a/scripts/rallycron.php b/scripts/rallycron.php new file mode 100644 index 0000000..87e9c8a --- /dev/null +++ b/scripts/rallycron.php @@ -0,0 +1,10 @@ +