Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Post state-change notifications to Slack #8

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions scripts/config/rallycron.conf.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
$CRON_INTERVAL = 61; //seconds between cron runs; pad for script run time and latency
$RALLY_PROJECT_ID = REPLACE_ME;
$SLACK_CHANNEL_FOR_RALLY_PROJECT = 'REPLACE ME'; //do not include hash symbol
81 changes: 71 additions & 10 deletions scripts/include/rally.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?
//rally commands

$RALLY_URL = 'https://rally1.rallydev.com/';
$RALLY_TIMESTAMP_FORMAT = 'Y-m-d\TH:i:s.u\Z';

function HandleItem($slackCommand, $rallyFormattedId)
{
Expand Down Expand Up @@ -53,11 +54,11 @@ function HandleStory($id, $channel_name)
$payload = GetRequirementPayload($ref);

$result = postit($channel_name, $payload->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));
Expand All @@ -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);
}

Expand Down Expand Up @@ -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.";

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
{
Expand Down
147 changes: 147 additions & 0 deletions scripts/include/rallycron.inc.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php
require('curl.php');
require('slack.config.php');
require('slack.php');
require('rallyme.config.php');
require('rally.php');

function FetchUpdatedRallyArtifacts($since)
{
global $RALLY_URL, $RALLY_PROJECT_ID, $RALLY_TIMESTAMP_FORMAT;

$api_url = $RALLY_URL . 'slm/webservice/v2.0/';
$query_url = $api_url . 'artifact?query=((Project.ObjectID+%3D+' . $RALLY_PROJECT_ID . ')AND(LastUpdateDate+>+' . $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;
}

33 changes: 28 additions & 5 deletions scripts/include/slack.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,35 @@ function BuildSlashCommand($request)
return $cmd;
}

//text-formatting functions

function SanitizeText($text)
{
$text = strtr($text, array('<br />' => '\n', '<div>' => '\n', '<p>' => '\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
);
Expand All @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions scripts/rallycron.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
require('config/rallycron.conf.php');
require('include/rallycron.inc.php');

date_default_timezone_set('UTC');
$since = date($RALLY_TIMESTAMP_FORMAT, time() - $CRON_INTERVAL);

if ($items = FetchUpdatedRallyArtifacts($since)) {
$result = SendRallyUpdateNotifications($items);
}