Friday, April 11, 2008

SVN pre-commit hook in VBScript

Subversion’s hook scripts provide a powerful and flexible way to associate actions with repository events. For example, the pre-commit hook allows you to check and possibly abort a transaction before it actually gets committed. This entry describes how to write such a hook in VBScript® language and test whether the committer has supplied a commit message and that the message contains a valid issue id. The issue tracking system used for integration is JIRA®.

Creating Hook Script

When you install Subversion it also installs sample hook scripts. These sample scripts are located in REPOS_PATH\hooks directory. For example, the pre-commit template is in PATH_TO_REPOS/hooks/pre-commit.tmpl. These templates contain instructions on what the hook script does and what parameters it can expect.
Tip
The hook file must be an executable. Windows uses file extensions to determine whether or not a program is executable, so you would need to supply a program whose basename is the name of the hook, and whose extension is one of the special extensions recognized by Windows for executable programs, such as .exe or .com for programs, and .bat for batch files.

The pre-commit Hook

The pre-commit hook gives you an opportunity to catch the transaction just before it becomes a revision. Typically, this hook is used to protect against commits that are disallowed due to content or location (for example, your organisation might require that all commits to a certain branch include a ticket number from the bug tracker, or that the incoming log message is non-empty). Subversion passes this hook two parameters:
  1. The path to the root of the repository
  2. The transaction identifier
The pre-commit can fail the transaction by printing an informative message to standard error and returning non-zero value. A return code of zero allows the transaction to complete successfully.

The Hook Script

The hook script described here enforces following rules.
  1. Non empty commit message.
  2. Commit message must begin with an Issue Id followed by a space and hyphen
  3. The Issue id specified must be a valid issue in Issue System(JIRA)
  4. The Issue must have been assigned to the committer
<package>
<job id="hPreCommit">
<reference object="WScript.Shell"/>
<reference object="Microsoft.XMLHTTP"/>
<reference object="Scripting.Dictionary"/>
<script language="VBScript">
'*******************************************************************************
'* NAME       : pre-commit.vbs
'* AUTHOR     : Prasad P. Khandekar
'* CREATED ON : April 09, 2008 16:02
'* COPYRIGHT  : © 2008, Prasad P. Khandekar
'* PURPOSE    : Script to check whether the commit is accompanied by a commit
'*              message and that the message contains the valid open JIRA issue
'*              id. The script is passed two parameters namely
'*
'*              [1] REPOS-PATH   (the path to this repository)
'*              [2] TXN-NAME     (the name of the txn about to be committed)
'*******************************************************************************
Option Explicit
Public Const vbSpace = " "
Public Const SVNLOOK_PATH = "<PATH TO SVNLOOK.EXE FILE>"
Public Const JIRA_USER = "<JIR_AUSER>"
Public Const JIRA_PASS = "<JIRA_PASSOWRD>"
Public Const JIRA_HOST = "<JIRA_HOSTNAME_OR_IP>"
Public Const JIRA_ENDPOINT = "http://JIRA_HOSTNAME_OR_IP/JIRA/rpc/soap/jirasoapservice-v2?wsdl"

Private retVal
Private strRepoPath
Private strRevision
Private colArgs
Public wshShell

Set wshShell = WScript.CreateObject("WScript.Shell")
Set colArgs = WScript.Arguments
If (colArgs.Count < 2) Then
    wshShell.LogEvent 4, "Repository path and txn name are missing!"
    WScript.StdErr.WriteLine("Repository path and commit transaction id are missing!")
    Set colArgs = Nothing
    Set wshShell = Nothing
    WScript.Quit(1)
End If

strRepoPath = colArgs(0)
strRevision = colArgs(1)
Set colArgs = Nothing

wshShell.LogEvent 4, "Repository : " & strRepoPath & ", Revision : " & strRevision
retVal = doMain(strRepoPath, strRevision)
WScript.Quit(retVal)

Function doMain(strPath, strRev)
    Dim strLog, strAuthor, strJiraId
    Dim intPos
    Dim char

    strLog = runCMD(SVNLOOK_PATH & " log " & strPath & " --transaction " & strRev)
    strAuthor = runCMD(SVNLOOK_PATH & " author " & strPath & " --transaction " & strRev)
    If (IsNull(strAuthor) Or IsEmpty(strAuthor)) Then
        wshShell.LogEvent 4, "Unable to find author of the commit transaction [" & _
                            strRev & "] in repository '" & strPath & "'!"
        WScript.StdErr.WriteLine("Unable to find author of the commit transaction [" & _
                                    strRev & "] in repository '" & strPath & "'!")
        doMain = 2
        Exit Function
    End If

    If (IsNull(strLog) Or IsEmpty(strLog)) Then
        wshShell.LogEvent 4, "Commit message not specified for commit transaction [" & _
                                strRev & "] in repository '" & strPath & "'!"
        WScript.StdErr.WriteLine("Commit message not specified for commit transaction [" & _
                                    strRev & "] in repository '" & strPath & "'!")
        doMain = 3
        Exit Function
    End If

    strJiraId = ""
    For intPos = 1 To Len(strLog)
        char = Mid(strLog, intPos, 1)
        If (char = vbSpace) Then Exit For
        strJiraId = strJiraId & char
    Next

    If (IsNull(strJiraId) Or IsEmpty(strJiraId)) Then
        wshShell.LogEvent 4, "Jira Issue id is not specifed in commit message!"
        WScript.StdErr.WriteLine("Jira Issue id is not specifed in commit message!")
        doMain = 4
        Exit Function
    End If

    If (checkJIRA(strJiraId, strAuthor)) Then
        doMain = 0
    Else
        wshShell.LogEvent 4, "Issue with Id '" & strJiraId & "' does not exists or is not open!"
        doMain = 5
    End If
End Function

Function runCMD(strRunCmd)
    Dim strOut
    Dim objExec

    wshShell.LogEvent 4, "Running Command : " & strRunCmd
    Set objExec = wshShell.Exec(strRunCmd)

    Do While objExec.Status = 0
        WScript.Sleep 100
    Loop

    strOut = objExec.StdOut.ReadAll()
    Set objExec = Nothing
    wshShell.LogEvent 4, "Output of run command : " & strOut
    strOut = Trim(strOut)
    If (Len(strOut) = 0) Then
        runCMD = Empty
    Else
        runCMD = strOut
    End If
End Function

Function checkJIRA(strId, strAuthor)
    Dim ws
    Dim strTok

    Set ws = new WebService
    ws.Url = JIRA_ENDPOINT

    strTok = doLogin(ws)
    If (IsNull(strTok) Or IsEmpty(strTok)) Then
        wshShell.LogEvent 4, "Unable to login to JIRA!"
        WScript.StdErr.WriteLine("Unable to login to JIRA!")
        checkJIRA = False
    Else
        ws.Parameters.Clear
        checkJIRA = getIssue(ws, strTok, strId, strAuthor)
        doLogout ws, strTok
    End If
    Set ws = Nothing
End Function

Function doLogin(ws)
    Dim xmlDoc
    Dim domNode

    ' Login and get the token.
    ws.Method = "login"
    ws.Parameters.Add "in0", JIRA_USER
    ws.Parameters.Add "in1", JIRA_PASS
    ws.execute
    Set xmlDoc = ws.Response
    Set domNode = xmlDoc.selectSingleNode("/soapenv:Envelope/soapenv:Body/ns1:loginResponse/loginReturn")
    If Not (domNode Is Nothing) Then
        doLogin = domNode.text
        Set domNode = Nothing
    End If
    Set xmlDoc = Nothing
End Function

Sub doLogout(ws, strTok)
    ws.Method = "logout"
    ws.Parameters.Add "in0", strTok
    ws.execute
End Sub

Function getIssue(ws, strTok, strId, strAuthor)
    Dim domNode
    Dim child
    Dim xmlDoc
    Dim buff

    ws.Method = "getIssue"
    ws.Parameters.Add "in0", strTok
    ws.Parameters.Add "in1", strId
    ws.execute

    Set xmlDoc = ws.Response
    Set domNode = xmlDoc.selectSingleNode("/soapenv:Envelope/soapenv:Body/ns1:getIssueResponse")
    If Not (domNode Is Nothing) Then
        Set domNode = xmlDoc.selectSingleNode("/soapenv:Envelope/soapenv:Body/multiRef[@id='id0']/assignee")
        If (domNode.text = strAuthor) Then
            getIssue = True
        Else
            WScript.StdErr.WriteLine("Issue " & strId & " is not assigned to " & strAuthor)
            getIssue = False
        End If
    Else
        buff = ""
        Set domNode = xmlDoc.selectSingleNode("/soapenv:Envelope/soapenv:Body/soapenv:Fault")
        For Each child In domNode.childNodes
            If (child.baseName = "faultcode") Then
                buff = buff & child.text & " - "
            ElseIf (child.baseName = "faultstring") Then
                buff = buff & child.text
            End If
        Next
        WScript.StdErr.WriteLine(buff)
        getIssue = False
    End If
    Set domNode = Nothing
    Set xmlDoc = Nothing
End Function


Class WebService
    Private m_strUrl
    Private m_strMethod
    Private m_xmlResponse
    Private m_objParams

    Public Property Get Url
        Url = m_strUrl
    End Property

    Public Property Let Url(strUrl)
        m_strUrl = strUrl
    End Property

    Public Property Get Method
        Method = m_strMethod
    End Property

    Public Property Let Method(strMethod)
        m_strMethod = strMethod
    End Property

    Public Property Get Response
        Set Response = m_xmlResponse
    End Property

    Public Property Get Parameters
        Set Parameters = m_objParams
    End Property

    Public Function execute()
        Dim xmlhttp

        Set xmlhttp = CreateObject("Microsoft.XMLHTTP")
        xmlhttp.open "POST", m_strUrl & "/" & m_strMethod, False
        xmlhttp.setRequestHeader "Content-Type", "text/xml; charset=utf-8"
        xmlhttp.setRequestHeader "SOAPAction", m_strUrl & "/" & m_strMethod
        xmlhttp.send m_objParams.toString(m_strUrl, m_strMethod)
        Set m_xmlResponse = xmlhttp.responseXML
        Set xmlhttp = Nothing
    End Function

    Private Sub Class_Initialize()
        Set m_objParams = New wsParameters
    End Sub

    Private Sub Class_Terminate()
        Set m_objParams = Nothing
    End Sub
End Class

Class wsParameters
    Private m_colParams

    Public Function toString(strUrl, strMethod)
        Dim param
        Dim buffer

        buffer = "<?xml version=""1.0"" encoding=""utf-8""?>" & _
                "<soap:Envelope xmlns:xsi=""http://www.w3.org/2001/XMLSchema-instance""" & _
                "    xmlns:xsd=""http://www.w3.org/2001/XMLSchema""" & _
                "    xmlns:soap=""http://schemas.xmlsoap.org/soap/envelope/"">" & _
                "    <soap:Body>" & _
                "        <" & strMethod & " xmlns=""" & strMethod & """>"
        For Each param In m_colParams.Items
            buffer = buffer & param.toString
        Next
        buffer = buffer & "</" & strMethod & "></soap:Body></soap:Envelope>"
        toString = buffer
    End Function

    Public Sub Clear
        Set m_colParams = Nothing
        Set m_colParams = CreateObject("Scripting.Dictionary")
    End Sub

    Public Sub Add(pKey, pValue)
        Dim param

        Set param = New wsParameter
        param.Key = pKey
        param.Value = pValue
        m_colParams.Add m_colParams.count + 1, param

        Set param = Nothing
    End Sub

    Public Function Item(nKey)
        Set Item = m_colParams.Item(nKey)
    End Function

    Public Function ExistsXKey(pKey)
        Dim param

        For Each param In m_colParams.Items
            If param.Key = pKey Then
                ExistsXKeyword = True
                Exit For
            End If
        Next
    End Function

    Public Sub Remove(nKey)
        m_colParams.Remove(nKey)
    End Sub

    Public Function Count()
        Count = m_colParams.count
    End Function

    Private Sub Class_Initialize()
        Set m_colParams = CreateObject("Scripting.Dictionary")
    End Sub

    Private Sub Class_Terminate()
        Set m_colParams = Nothing
    End Sub
End Class

Class wsParameter
    Private m_strKey
    Private m_strValue

    Public Property Get Key
        Key = m_strKey
    End Property

    Public Property Let Key(strKey)
        m_strKey = strKey
    End Property

    Public Property Get Value
        Value = m_strValue
    End Property

    Public Property Let Value(strVal)
        m_strValue = strVal
    End Property

    Public Function toString()
        toString = "<" & m_strKey & ">" & m_strValue & "</" & m_strKey & ">"
    End Function
End Class
</script>
</job>
</package>

JIRA® Integration

The integration with JIRA is done via web service using SOAP messages. This ofcourse requires that the xmlrpc plugin be enabled on JIRA server. The script makes use of MSXML.HTTP object to invoke the JIRA web service. The commit log message and the committer are found using the svnlook.exe program which comes with Subversion software. The web service invokation is done by class named WebService which is actually a adapatation of code published at CDYNE Wiki. (The code found on this site is good enough to make an POST request but fails to invoke a SOAP enabled web service.) The code calls following methods in JIRA web service.
  1. login - This method must be called to ensure that we get an authentication token. The token returned by this method is required in subsequent calls. This method takes following two parameters and upon successful logon returns the authentication token.
    1. in0 - The user name
    2. in1 - The plain text user password
  2. getIssue - This method is used to retrieve the issue details given it's id. This method takes following two parameters and returns issue details if the requesting user has view permissions and the issue with the supplied id exists.
    1. in0 - The authentication token
    2. in1 - The issue id
  3. logout - The method is used to logout the user. This method takes only one parameter namely the authentication token.
The actual error is conveyed back to the user by writing the error text on StdErr. This allows the committer to see why his/her commit is denied.

pre-commit.bat

As mentioned earlier the Subversion server is capable of calling the executable scripts only. Since the pre-commit hook is written in VBScript we need a driving script. So here is the driving script.
@ECHO OFF
SETLOCAL
SET PATH=C:\Windows;C:\Windows\system32;D:\SCC\SVN146\bin;
cscript.exe //NoLogo H:\SVNRepo\docman\hooks\pre-commit.wsf %1 %2
IF ERRORLEVEL 1 GOTO fail

:success
EXIT 0

:fail
EXIT 1

4 comments:

Unknown said...

Great script overall!

Found a bug. A fault is always raised during doLogout(ws, strTok).

The webservice parameters need to be reset since doLogout accepts only one parameter.


Sub doLogout(ws, strTok)
  ws.Parameters.Clear
  ws.Method = "logout"
  ws.Parameters.Add "in0", strTok
  ws.Execute
End Sub

RobBooke said...

Hey thanks for the post. I am having a problem communicating over https and have found the error I am getting is it is due to https. Any advice?
Thanks,
Rob

admin said...

Hello there,
Very interesting article. Could you please help me with my issues.
I need to check prior the directory size and informaing the user and also file extensions.
Give me some hints please

Kalpesh Soni said...

thanks for sharing

Disclaimer: The views expressed in this blog are my own and do not necessarily reflect the views of any former, current or future employers or employees of mine