Wednesday, April 3, 2019

Creating and Updating Jira Issues with PowerShell

In attempting to find a way to programmatically manipulate Jira, I discovered that the information out there on the Internet is somewhat scattered. I couldn't find one central location that gave me everything I needed to move forward. After a period of trial and error, I managed to piece together the disparate parts into a PowerShell solution.

This is my basic function for getting a user's name and password.

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

function IdentifyUser
    {
    # Prompt user for logon credentials.
    $script:MyCredential = Get-Credential -Message "Please enter your network user ID and password:" -UserName $env:UserName
    if ($script:MyCredential)
        {
        $script:attemptConnection = $true
        $script:username = $script:MyCredential.Username
        $script:password = $script:MyCredential.GetNetworkCredential().password
        }
    else
        {
        Write-Host ""
        Write-Host $script:equalsLine
        Write-Host "Logon canceled by user."
        Write-Host $script:equalsLine
        }
    }  #IdentifyUser


This function contains many of the fields associated with a Jira issue. This is where values would be altered for creating or updating a specific case.

function InitializeVariables
    {
    $script:target = "https://[your Jira base URL]"
    $script:projectKey = "[your Jira project key]";

#===================================
#   Available variables/Jira fields
#-----------------------------------

    $script:createOrUpdate = "update"
    $script:issueKey = "$script:projectKey-1" # this is ignored if creating an issue

    $script:acceptanceCriteria = "If you build it, they will come."
    $script:assignee = "[a Jira user name]"
    $script:description = "This issue was created from PowerShell using Jira REST API."
    $script:dueDate = "2020-01-01"
    $script:issueType = "Task"
    $script:label_1 = "Development"
    $script:label_2 = "Request"
    $script:priority = "Lowest"
    $script:summary = "Jira workflow examination"

#===================================

    $script:attemptConnection = $false
    $script:equalsLine = "=" * 130

    if ($script:createOrUpdate.ToLower() -eq "create")
        {
        $script:method = "POST"
        $script:requestUri = "$script:target/jira/rest/api/latest/issue"
        }
    elseif ($script:createOrUpdate.ToLower() -eq "update")
        {
        $script:method = "PUT"
        $script:requestUri = "$script:target/jira/rest/api/latest/issue/$script:issueKey"
        }
    else
        {
        $script:method = "[ error ]"
        $script:requestUri = "[ error ]"
        }
    }  #InitializeVariables


Here I create the JSON structure containing the field values and submit it to my Jira instance. The REST method invoked is POST for creating an issue, and PUT for updating one (this has been established in the InitializeVariables function.).

function PerformOperation
    {
    if ($script:attemptConnection)
        {
        $bodyString = @{
            update = @{
            }

            fields=@{

            project=@{key="$script:projectKey"} # can be omitted when updating an issue; or it can just "change" it to the value it already has

            #assignee = @{name = "$script:assignee"}
            description = "$script:description"
            duedate = "$script:dueDate"
            issuetype = @{name = "$script:issueType"}
            labels =  [string[]]"$script:label_1"
            priority = @{name = "$script:priority"}
            summary = "$script:summary"
            
            # comment out a line to omit it from item creation/update

            }}

        $bodyJSON = $bodyString | ConvertTo-Json -Depth 2 #-Compress

        try {
            $basicAuth = "Basic " + [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("$($script:Username):$script:password"))
            $headers = @{
                "Authorization" = $basicAuth
                "Content-Type"="application/json"
                }

            $response = Invoke-RestMethod -Uri $script:requestUri -Method $script:method -Headers $headers -Body $bodyJSON -UseBasicParsing

            if ($script:createOrUpdate.ToLower() -eq "create")
                {
                Write-Output "ID: $($response.id)"
                Write-Output "Key: $($response.key)"
                Write-Output "Self: $($response.self)"    
                }
            }
        catch {
            Write-Warning "Remote Server Response: $($_.Exception.Message)"
            Write-Output "Status Code: $($_.Exception.Response.StatusCode)"
            }
        }
    }  #PerformOperation


All that's left is to call these functions in sequence.

InitializeVariables
IdentifyUser
PerformOperation

Thursday, February 21, 2019

Discovering Active Directory groups with PowerShell

I wrote a PowerShell program that reads in a comma-separated list of user names, looks them up in the Windows Active Directory, and then outputs a comma-separated file listing each user's groups, with one group per line.

In most programs I write, you'll probably find an InitializeVariables function. While some variables sometimes need to be set at the top of the program, this function will usually hold the majority of my variables' starting values.


function InitializeVariables
    {
    $script:input_file = ""
    $script:output_file = ""
    $script:not_found_file = ""

    # Set the input + output files, depending on the presence of invocation parameters.
    if (-not $script:in)
        {
        $script:input_file = $script:default_input_file
        }
    else
        {
        $script:input_file = $script:in
        }

    if (-not $script:out)
        {
        $script:output_file = $script:default_output_file
        }
    else
        {
        $script:output_file = $script:out
        }

    if (-not $script:lost)
        {
        $script:not_found_file = $script:default_not_found_file
        }
    else
        {
        $script:not_found_file = $script:lost
        }
    }  #InitializeVariables

In the interest of flexibility, I give the user the option of overwriting the output of previous runs or retaining them with a timestamp.


function CleanOutputFiles
    {
    # Remove existing output files, first preserving them if instructed.

    $overwrite_time = Get-Date -Format "yyyyMMdd-HHmmss"

    if ($script:preserve_previous_output -and (Test-Path -LiteralPath $script:output_file))
        {
        $old_output_file = $script:output_file.Replace(".csv", "-$overwrite_time.csv")
        Rename-Item $script:output_file $old_output_file
        }
    elseif (Test-Path -LiteralPath $script:output_file)
        {
        Remove-Item $script:output_file
        }

    if ($script:preserve_previous_output -and (Test-Path -LiteralPath $script:not_found_file))
        {
        $old_not_found_file = $script:not_found_file.Replace(".txt", "-$overwrite_time.txt")
        Rename-Item $script:not_found_file $old_not_found_file
        }
    elseif (Test-Path -LiteralPath $script:not_found_file)
        {
        Remove-Item $script:not_found_file
        }
    }  #CleanOutputFiles

A fairly standard reading of the input file.


function ReadUserList
    {
    $script:user_list = Get-Content $script:input_file
    if (-not $script:suppress_screen_output)
        {
        Write-Host ""
        Write-Host "user_list is" $script:user_list.count "lines long."
        }
    }  #ReadUserList

This is the meat of the program. The users are stored internally in a multidimensional arraylist--an arraylist whose members are arraylists--as a method of keeping users together with their groups, even if at some point in the future I add sorting functionality.


function CreateUserGroupList
    {
    # Create the main/outer arraylist that will hold user arraylists.
    $script:user_group_list = New-Object System.Collections.ArrayList

    forEach ($user in $script:user_list)
        {
        # Create an arraylist for each user.
        $single_user = New-Object System.Collections.ArrayList

        # The first element in a user's arraylist is the user's name.
        [void]$single_user.Add("$user")

        # Add the user's arraylist as an element of the main arraylist.
        [void]$script:user_group_list.Add($single_user)
        }

    if ($script:strip_header_from_input)
        {
        [void]$script:user_group_list.Remove($script:user_group_list[0])
        }

    if (-not $script:suppress_screen_output)
        {
        Write-Host "user_group_list is" $script:user_group_list.Count "items long" -NoNewline

        if ($script:strip_header_from_input)
            {
            Write-Host " (the header line has been stripped)."
            Write-Host ""
            }
        else
            {
            Write-Host "."
            Write-Host ""
            }
        }

    for($loopCounter = 0; $loopCounter -lt $script:user_group_list.Count; $loopCounter++)
        {
        try
            {
            $AD_entry = (New-Object System.DirectoryServices.DirectorySearcher("(&(objectCategory=User)(samAccountName=$($script:user_group_list[$loopCounter][0])))")).FindOne().GetDirectoryEntry().memberOf

            forEach ($line in $AD_entry)
                {
                # Extract the Common Name from each line, which is structured as:
                #   CN=[Common Name],OU=[Organizational Unit],OU=[Organizational Unit],DC=[Domain Component],[Domain Component],[Domain Component]
                [void]$script:user_group_list[$loopCounter].Add($line.Substring(3, $line.IndexOf(",") - 3))
                }

            if (-not $script:suppress_screen_output)
                {
                $user_number = $loopCounter + 1
                $user_name = $script:user_group_list[$loopCounter][0]
                $group_count = $script:user_group_list[$loopCounter].Count - 1

                $output_line = "${user_number}: $user_name, $group_count groups."
                Write-Host $output_line
                Write-Host ""
                }
            }  #try

        catch [System.Management.Automation.RuntimeException]
            {
            # The user was not found in the Active Directory search.

            if (-not $script:suppress_screen_output)
                {
                $user_number = $loopCounter + 1
                $user_name = $script:user_group_list[$loopCounter][0]

                $output_line = "${user_number}: $user_name was not found."
                Write-Host $output_line
                Write-Host ""
                }

            Write-Output $script:user_group_list[$loopCounter][0] >> $script:not_found_file
            }  #catch
        }  #loopCounter
    }  #CreateUserGroupList

Constructing the output file. In this case, the specs required that each line start with a user name, followed by one user group name.


function WriteOutputFile
    {
    if (-not $script:suppress_screen_output)
        {
        Write-Host ""
        Write-Host "Writing output file..."
        Write-Host ""
        }

    forEach($user_group in $script:user_group_list)
        {
        for($loopCounter = 1; $loopCounter -lt $user_group.Count; $loopCounter++)
            {
            # Each line in the output file will be formatted as:
            #   [user name], [one user group]
            $output_line = $user_group[0] + "," + $user_group[$loopCounter]
            Write-Output $output_line >> $script:output_file
            }
        }
    }  #WriteOutputFile

Here is the structure of the remainder of the program, beginning with (optional) parameters and concluding with the trail of function calls that do the actual work of the program


param (
    [string]$in,
    [string]$out,
    [string]$lost
)

#********************************************************************************
#
# This program can be invoked with or without file name parameters:
#
# powershell -File AD_user_groups.ps1
# powershell -File AD_user_groups.ps1 myinput.csv myoutput.csv not_found.txt
# powershell -File AD_user_groups.ps1 -in myinput.csv -out myoutput.csv -lost not_found.txt
# powershell -File AD_user_groups.ps1 -in myinput.csv
# powershell -File AD_user_groups.ps1 -out myoutput.csv
# powershell -File AD_user_groups.ps1 -lost not_found.txt
#
# A default file will be used if a file name is not specified.
#
#********************************************************************************

# Set to $true to rename previous output files before overwriting them.
$script:preserve_previous_output = $true

# Set to $true if the input file contains a header row.
$script:strip_header_from_input = $true

# Set to $false to see program progress on the screen.
$script:suppress_screen_output = $true

# These are the file names that will be used if file names are not provided as program parameters.
$script:default_input_file = "$PSScriptRoot\users.csv"
$script:default_output_file = "$PSScriptRoot\user_groups.csv"
$script:default_not_found_file = "$PSScriptRoot\users_not_found.txt"

#********************************************************************************


# Function definitions appear here.


#********************************************************************************


InitializeVariables
CleanOutputFiles
ReadUserList
CreateUserGroupList
WriteOutputFile