My company recently replaced our two headed monster of SourceGear's Vault and Intuit's Track-It! with Microsoft's Team Foundation Server "all-in-one" solution. While this has been a relatively smooth transition there were a couple of features creeping up that TFS didn't support out of the box (emailing developers when a work item is assigned, allowing users to email bug requests, etc.). Luckily, Microsoft built TFS with extensiblity in mind. Granted, the documentation on how to get some real-life problems solved is quite a bit lacking, but it is do-able.
The main resources available were bits and pieces of info scattered across blogs that needed to be congealed together to get me a working product. One of the major sticky points was accessing, and saving to, a custom field in the work item type. There were plenty of resources that said you flat out couldn't do it, but it seemed silly to require a unique reference name if you can't actually reference. Luckily, my stubborness paid off.
Below, I will detail the major steps needed to accomplish this small feat. Is my app and associated code perfect? No, but you will get the idea of how to get this done.
To get you acquainted, here are screenshots of my app:
Here is the main form (not much to it). Basically, the I just display a log of what happened (checking Outlook folder, creating work item, etc.) so that the users has some idea of what is going onwith the app since it just runs minimzed in the system tray.
Options form (yeah, I know it is sloppy to not keep a list, but I had to fight scope-creep somehow):
Anyways, here is the code that does all of the heavy-lifting. I will detail certain parts later on in this post.
/// <summary>
/// Wrapper method to call methods to actually process the emails.
/// </summary>
/// <param name="folderName">The name of the Outlook email folder to process.</param>
/// <param name="lastRunDateTime">Date and time when we ran last. Used to filter out "processed" emails.</param>
/// <param name="projectName">String holding the TFS project name to save the work items to.</param>
/// <param name="tfsServerName">String holding the TFS server name to save the work items to.</param>
/// <param name="workItemType">String holding the work item type to save the email as.</param>
private void CheckEmailInFolderForProject(string folderName, DateTime lastRunDateTime, string projectName, string tfsServerName, string workItemType)
{
try
{
//Get ready to work
this.Cursor = Cursors.WaitCursor;
MAPIFolder folder = null;
//Set up hook-in to outlook
Microsoft.Office.Interop.Outlook.Application objOutlook = new Microsoft.Office.Interop.Outlook.ApplicationClass();
Microsoft.Office.Interop.Outlook.NameSpace objOutlookNamespace = objOutlook.GetNamespace("MAPI");
objOutlookNamespace.Logon("", "", false, true);
//Get the folder and process emails
if (GetFolder(objOutlookNamespace.Folders, folderName, ref folder))
ProcessEmailsInFolder(folder, lastRunDateTime, projectName, tfsServerName, workItemType);
else
this.lstHistory.Items.Add("Folder (" + folderName + ") not found.");
}
catch (System.Exception ex)
{
MessageBox.Show(ex.ToString());
}
finally
{
this.Cursor = Cursors.Default;
this.toolStripStatusLastCheckLabel.Text = "Last Check Time: " + DateTime.Now;
}
}
/// <summary>
/// Recursively search Outlook for a folder name.
/// </summary>
/// <param name="folders">MAPI folder array to search.</param>
/// <param name="folderName">The folder name we are searching for.</param>
/// <param name="returnFolder">Reference field used to return MAPI folder.</param>
/// <returns>Boolean representing if the specified folder was found.</returns>
private bool GetFolder(Folders folders, string folderName, ref MAPIFolder returnFolder)
{
bool matchFound = false;
foreach (MAPIFolder folder in folders)
{
//Only process if no match found yet
if (!matchFound)
{
//Did we find it?
if (folder.Name == folderName)
{
returnFolder = folder;
matchFound = true;
break;
}
else
{
//Recurisve call if folders are found
if (folder.Folders.Count > 0)
matchFound = GetFolder(folder.Folders, folderName, ref returnFolder);
}
}
else
break;
}
return matchFound;
}
/// <summary>
/// Loop through all non-processed items in specified folder and load into TFS.
/// </summary>
/// <param name="folder">MAPI folder to search for and process.</param>
/// <param name="lastRunDateTime">Only process items recieved after this datetime.</param>
/// <param name="projectName">String holding the TFS project name to save the work items to.</param>
/// <param name="tfsServerName">String holding the TFS server name to save the work items to.</param>
/// <param name="workItemType">String holding the work item type to save the email as.</param>
private void ProcessEmailsInFolder(MAPIFolder folder, DateTime lastRunDateTime, string projectName, string tfsServerName, string workItemType)
{
//Initialize string and get form setup
string listOutputString = string.Empty;
int toBeProcessedCounter = 0;
int chunk = 0;
int processedCounter = 0;
lstHistory.Items.Add("=======Import Starting for '" + projectName + "' project and '" + workItemType + "' work item type at " + System.DateTime.Now.ToString() + "=======");
//Get and sort the emails by received date
Microsoft.Office.Interop.Outlook.Items myItems = folder.Items;
myItems.Sort("[ReceivedTime]", true);
//Prepare status bar
foreach (MailItem email in myItems)
if (email.ReceivedTime > lastRunDateTime)
++toBeProcessedCounter;
else
break;
//Set up the progress bar logic
if (toBeProcessedCounter > 0)
{
chunk = 100 / toBeProcessedCounter;
toolStripProgressBar.Step = chunk;
}
foreach (MailItem email in myItems)
{
//Make sure it came in after our last processing time
if (email.ReceivedTime > lastRunDateTime)
{
//Add work item to TFS
if (AddItemToTFS(tfsServerName, projectName, workItemType, email))
{
listOutputString = email.Subject + " from " + email.SenderName + " processed at " + System.DateTime.Now;
}
else
{
listOutputString = "FAILURE: " + email.Subject + " from " + email.SenderName + " FAILED at " + System.DateTime.Now;
}
//Update the screen
lstHistory.Items.Add(listOutputString);
toolStripProgressBar.PerformStep();
++processedCounter;
}
else
break;
}
//Update settings file
Properties.Settings.Default.LastRunDateTime = System.DateTime.Now;
Properties.Settings.Default.Save();
//Update the screen
toolStripStatusLastCheckLabel.Text += " - " + processedCounter + " records processed.";
toolStripProgressBar.Value = 0;
lstHistory.Items.Add("=================Import Complete: " + Properties.Settings.Default.LastRunDateTime.ToString() + "=================");
}
/// <summary>
/// Method used to save the passed in fields into TFS.
/// </summary>
/// <param name="TFSServer">String designating which server TFS is located on.</param>
/// <param name="TFSProject">String identifying TFS project to be used when loading items.</param>
/// <param name="workItemType">String identifying the type of work item to save.</param>
/// <param name="email">Outlook email object used to populate Work Item fields and contains attachments.</param>
/// <returns>Boolean denoting if the save was successful.</returns>
private bool AddItemToTFS(string TFSServer, string TFSProject, string workItemType, MailItem email)
{
try
{
//Connect to the TFS server/project
TeamFoundationServer tfs = new TeamFoundationServer(TFSServer);
WorkItemStore wis = (WorkItemStore)tfs.GetService(typeof(WorkItemStore));
Project teamProject = wis.Projects[TFSProject];
//Get the type of bug we need to work with
WorkItemType witBug = teamProject.WorkItemTypes[workItemType];
//Make sure we got something back.
if (witBug != null)
{
//Set standard field(s)
WorkItem workItem = new WorkItem(witBug);
workItem.Description = email.Body;
workItem.Title = email.Subject;
//Set custom field(s)
FieldCollection fields = workItem.Fields;
fields["My.WorkItems.Fields.Requestor"].Value = email.SenderName;
//Add attachments, if any
for (int i = 0; i <= email.Attachments.Count; i++)
{
//To prevent array index errors since attachment array is 1 based
if (i == 0)
continue;
string filePathAndName = string.Empty;
//Since we are dealing with IO operations, wrap in Try/Catch in case of
//permission issues or who knows what else...
try
{
//Save email attachment to temp directory (note funky non-zero-based indexing)
filePathAndName = "C:\\Temp\\" + email.Attachments[1].FileName;
email.Attachments[i].SaveAsFile(filePathAndName);
//Create and save the TFS attachment
Microsoft.TeamFoundation.WorkItemTracking.Client.Attachment attachment = new Microsoft.TeamFoundation.WorkItemTracking.Client.Attachment(filePathAndName, "Email Attachment");
workItem.Attachments.Add(attachment);
//We have to save after each addition if we want to easily delete the file when we are done.
//Saving after the delete fails because the save even then tries to reference that file after deletion.
workItem.Save();
}
catch (SystemException ex)
{
throw ex;
}
finally
{
//Cleanup the left-overs...
if (System.IO.File.Exists(filePathAndName))
System.IO.File.Delete(filePathAndName);
}
}
return true;
}
else
{
NullReferenceException ex = new NullReferenceException("Work item type " + workItemType + " not found.");
throw ex;
}
}
catch
{
return false;
}
}
Here are some of the highlights of things I really battled with.
1. Dealing with a custom field in a work item type, which many blogs and forums seemed to think was impossible.
First off, I had to create a new field in the work item type and add that field to the display within VS.Net 2005 (MSDN has a good bit of info on doing this. You can start here). Here is the defintion of the field I added (Ignore the "My" naming convention, which I truly dispise. I did this to protect the innocent).
<FIELD name="Requestor" refname="My.WorkItems.Fields.Requestor" type="String" reportable="detail">
<HELPTEXT>Name of the user who submitted this issue.</HELPTEXT>
</FIELD>
Next, I had to add it to the screen so that we could actually see this new info (I just dropped it on the "Details" tab of the screen).
<Control Type="FieldControl" FieldName="My.WorkItems.Fields.Requestor" Label="Requestor:" LabelPosition="Left" />
Then, the truly undocumented part came. Actually assigning data to the field programmatically. I think it was one of those things that is simplier than everyone thought, since many forums/blogs say it can't be done. I will give you the lead up (connectiing to TFS, creating a new work item of the proper type, etc. ... but the last two lines are where the magic happens.
//Connect to the TFS server/project
TeamFoundationServer tfs = new TeamFoundationServer(TFSServer);
WorkItemStore wis = (WorkItemStore)tfs.GetService(typeof(WorkItemStore));
Project teamProject = wis.Projects[TFSProject];
//Get the type of bug we need to work with
WorkItemType witBug = teamProject.WorkItemTypes[workItemType];
//Make sure we got something back.
if (witBug != null)
{
//Set standard field(s)
WorkItem workItem = new WorkItem(witBug);
workItem.Description = email.Body;
workItem.Title = email.Subject;
//Set custom field(s)
FieldCollection fields = workItem.Fields;
fields["My.WorkItems.Fields.Requestor"].Value = email.SenderName;
2. Attachments to email submissions. Our users generally attach a screenshot with each submission, so this part had to get figured out for the first pass. Connecting to the Outlook folder and reading the email itself was pretty simple with the help of Google (here is a pretty good resource), but the getting to the attachments and tacking them onto a TFS work item proved to be a bit trickier. Here is what I came up with.
//Add attachments, if any
for (int i = 0; i <= email.Attachments.Count; i++)
{
//To prevent array index errors since attachment array is 1 based
if (i == 0)
continue;
string filePathAndName = string.Empty;
//Since we are dealing with IO operations, wrap in Try/Catch in case of
//permission issues or who knows what else...
try
{
//Save email attachment to temp directory (note funky non-zero-based indexing)
filePathAndName = "C:\\Temp\\" + email.Attachments[1].FileName;
email.Attachments[i].SaveAsFile(filePathAndName);
//Create and save the TFS attachment
Microsoft.TeamFoundation.WorkItemTracking.Client.Attachment attachment = new Microsoft.TeamFoundation.WorkItemTracking.Client.Attachment(filePathAndName, "Email Attachment");
workItem.Attachments.Add(attachment);
//We have to save after each addition if we want to easily delete the file when we are done.
//Saving after the delete fails because the save even then tries to reference that file after deletion.
workItem.Save();
}
catch (SystemException ex)
{
throw ex;
}
finally
{
//Cleanup the left-overs...
if (System.IO.File.Exists(filePathAndName))
System.IO.File.Delete(filePathAndName);
}
}
3. Minimizing the app to the system tray. Granted, this is not new news or impossible to find suggestions for on the web, but this one seemed like the simplest solution.
The steps involved are adding a notify icon to the form (simple enough).
Add a little logic the form's resize event:
private void MainForm_Resize(object sender, EventArgs e)
{
if (FormWindowState.Minimized == WindowState)
Hide();
}
Then, to get the window to return to normal when double-clicking the notify icon, or via the context menu, I just added this code (which gets called from those two events).
private void RestoreMinimizedWindow()
{
Show();
WindowState = FormWindowState.Normal;
}
Again, there are a million ways to do this out there, but this one seemed to be the least painful to me.
That should just about cover it. Let me know if you have any questions about any of this, and I will try to get back to you ASAP.
1 comments:
I had completely forgotten about the 'My's. Oh how I miss them...
Post a Comment