In my previous post, I integrated a power apps code review tool with Power Platform Pipelines. The tool that the Power CAT team had built was amazing and gave me some ideas on how I could handle doing reviews of Power Automate flows.
Building off of the last process, I have added three flows. 1.2.2 that will start the flows check process, 1.2.2.1 that will check for blank descriptions, and 1.2.2.2 that checks for default names
Β
π‘ The column FinalScore is a PowerFX column that adds the two scores then divides them by two
('Description Score' + 'Default Name Score') / 2
All three tables are added to an MDA for management
Review Scorings
There are six Review Scores in the table
Review Rules
Two review rules are added into the table
The default action names will have a list of default action names and have a score of Low
The blank descriptions rule will be set to null and a score of informational
1.2.2 - Start Flow Review
- A manual trigger that calls for the solution version and the solution name followed by three variables
- varCloudFlows - Array
- varCounter - Integer
- varFlowDesctiptionsArray - Array
- A Scope with three actions
- The first action will list all rows from the solutions table in the development environment using a FetchXML query and the SolutionName from the trigger
<fetch> <entity name="solution"> <attribute name="friendlyname" /> <attribute name="solutionid" /> <attribute name="uniquename" /> <filter> <condition attribute="uniquename" operator="eq" value="@{triggerBody()?['text']}" /> </filter> </entity> </fetch>
- Next will be another list all rows from the solution components table in development using a FetchXML
<fetch> <entity name="solutioncomponent"> <attribute name="objectid" /> <filter type="and"> <condition attribute="solutionid" operator="eq" value="@{outputs('List_rows_from_selected_environment_-_Solutions')?['body/value']?[0]?['solutionid']}" /> </filter> <filter> <condition attribute="componenttype" operator="eq" value="29" /> </filter> </entity> </fetch>
π‘ I am filtering the component type by 29 (workflows)
- For each workflow a get row by id in the process table from the development environment
- A condition that checks if the process is a modern cloud flow
π‘
- If it is a modern flow, then it is added to the array varCloudFlows and increments the counter by 1
- The first action will list all rows from the solutions table in the development environment using a FetchXML query and the SolutionName from the trigger
- Two child flows are then triggered
- After the 1.2.2.1 a scope is added with three actions
- The Parse JSON will convert a string that is returned by the 1.2.2.1 flow back to an array
{ "type": "array", "items": { "type": "object", "properties": { "ProcessGUID": { "type": "object", "properties": { "@@odata.type": { "type": "string" }, "@@odata.id": { "type": "string" }, "@@odata.etag": { "type": "string" }, "@@odata.editLink": { "type": "string" }, "objectid@odata.type": { "type": "string" }, "objectid": { "type": "string" }, "solutioncomponentid@odata.type": { "type": "string" }, "solutioncomponentid": { "type": "string" } } }, "ProcessName": { "type": "string" }, "ProcessDescription": {}, "DescriptionScore": { "type": "number" } }, "required": [ "ProcessGUID", "ProcessName", "ProcessDescription", "DescriptionScore" ] } }
- For each of the items out of the Parse JSON an item is added to the array varFlowDesctiptionsArray
π‘ For the Process Description an if expression is used. If it is blank then Description Missing is added
if(empty(item()?['ProcessDescription']), 'Description Missing', item()?['ProcessDescription'])
- This array is then converted to a HTML table using Automatic column headers
- The last two action are a add a new row to Code Reviews and respond to a flow
- The add new row will be configured as follows
π‘ This add is optional. If you do not want the outputs to be stored in both the pipeline environment and the production environment you can skip this.
- The last step will be to respond to the parent flow
{
"type": "Response",
"kind": "PowerApp",
"inputs": {
"schema": {
"type": "object",
"properties": {
"defaultnamescore": {
"title": "DefaultNameScore",
"type": "number",
"x-ms-content-hint": "NUMBER",
"x-ms-dynamically-added": true
},
"defaultnamedescriptions": {
"title": "DefaultNameDescriptions",
"type": "string",
"x-ms-content-hint": "TEXT",
"x-ms-dynamically-added": true
},
"descriptionscore": {
"title": "DescriptionScore",
"type": "number",
"x-ms-content-hint": "NUMBER",
"x-ms-dynamically-added": true
},
"descriptionsoutput": {
"title": "Descriptionsoutput",
"type": "string",
"x-ms-content-hint": "TEXT",
"x-ms-dynamically-added": true
}
},
"additionalProperties": {}
},
"statusCode": 200,
"body": {
"defaultnamescore": "@body('Run_a_Child_Flow_1.2.2.2')?['defaultnamescore']",
"defaultnamedescriptions": "@{body('Run_a_Child_Flow_1.2.2.2')?['defaultnameactions']}",
"descriptionscore": "@body('Run_a_Child_Flow_-_1.2.2.1')?['score']",
"descriptionsoutput": "@{body('Run_a_Child_Flow_-_1.2.2.1')?['descriptions']}"
}
},
"runAfter": {
"Add_a_new_row_-_Code_Reviews": [
"Succeeded"
]
},
"metadata": {
"operationMetadataId": "18d71153-94ce-447e-94a9-a8468d37cadd"
}
}
1.2.2.1 - Flow Blank Descriptions Check
- A manual trigger followed by a Parse JSON and two variables
{ "type": "array", "items": { "type": "object", "properties": { "ProcessGUID": { "type": "string" }, "ProcessName": { "type": "string" }, "ProcessClientData": { "type": "string" }, "ProcessDescription": {} }, "required": [ "ProcessGUID", "ProcessName", "ProcessClientData", "ProcessDescription" ] } }
- A scope with two actions is next
- The first will list the rows and find the rule for blank descriptions and gets itsβ subsequent score using FetchXML
<fetch> <entity name="andy_powerautomatereviewrules"> <filter> <condition attribute="andy_rulename" operator="eq" value="Description is blank" /> </filter> <link-entity name="andy_codereviewscoring" from="andy_codereviewscoringid" to="andy_score" alias="ScoreOutput"> <attribute name="andy_rating" /> <attribute name="andy_scorename" /> </link-entity> </entity> </fetch>
- For workflow the description will be checked if it is blank
- If its blank it will be added to the variable varDescScores
{ "type": "AppendToArrayVariable", "inputs": { "name": "varDescScores", "value": { "ProcessGUID": "@items('Apply_to_each_-_Check_if_workflow_is_blank')?['ProcessGUID']", "ProcessName": "@items('Apply_to_each_-_Check_if_workflow_is_blank')?['ProcessName']", "ProcessDescription": "@items('Apply_to_each_-_Check_if_workflow_is_blank')?['ProcessDescription']", "DescriptionScore": "@outputs('List_rows_-_Blank_Review_Rule')?['body/value']?[0]?['ScoreOutput.andy_rating']" } }, "metadata": { "operationMetadataId": "00665194-289c-4c09-a229-46824ca3a74c" } }
- Then the score is added to the variable varDescScore
- If there is a description it will do the same thing, expect for the score f
- The first will list the rows and find the rule for blank descriptions and gets itsβ subsequent score using FetchXML
- It will then respond to the parent flow with the overall score and descriptions array
- The Score will divide the sum of all the scores by the amount of flows (Counter)
div(variables('varDescScore'),triggerBody()?['number'])
1.2.2.2 - Default Name Check
This flow was one of the hardest flows I have made. To get the names of the actions out of the flow was a brain teaser. Each of the actions names are stored within the client data column of the process table. It is stored as an array, but one that proved difficult to access the elements I wanted.
- A manual trigger followed by 4 variables and a parse json is added
- varDefault Names - Array
- varDefaultNameCounter - Interger
- varDefaultNameScore - Float
- varDefaultNamesScores - Array
- Parse JSON
{ "type": "array", "items": { "type": "object", "properties": { "ProcessGUID": { "type": "string" }, "ProcessName": { "type": "string" }, "ProcessClientData": { "type": "string" }, "ProcessDescription": {} }, "required": [ "ProcessGUID", "ProcessName", "ProcessClientData", "ProcessDescription" ] } }
- A scope with four actions is then added
- The first action lists all the rules and the scores using a FetchXML query
<fetch> <entity name="andy_powerautomatereviewrules"> <attribute name="andy_rule" /> <filter> <condition attribute="andy_rulename" operator="eq" value="Default Action Names" /> </filter> <link-entity name="andy_codereviewscoring" from="andy_codereviewscoringid" to="andy_score" alias="ScoreOutput"> <attribute name="andy_rating" /> <attribute name="andy_scorename" /> </link-entity> </entity> </fetch>
- The second parses the JSON for the rules
outputs('List_rows_-_Get_Default_Name_Rules')?['body/value']?[0]?['andy_rule']
{ "type": "array", "items": { "type": "string" } }
- Each rule is then added to the array variable varDefaultNames
- A for loop is added for each workflow
body('Parse_JSON_-_CloudFlows')
- The first step in the loop is to parse the JSON of the client data
{ "type": "object", "properties": { "properties": { "type": "object", "properties": { "connectionReferences": { "type": "object", "properties": {} }, "definition": { "type": "object", "properties": { "$schema": { "type": "string" }, "contentVersion": { "type": "string" }, "parameters": { "type": "object", "properties": { "$authentication": { "type": "object", "properties": { "defaultValue": { "type": "object", "properties": {} }, "type": { "type": "string" } } } } }, "triggers": { "type": "object", "properties": { "manual": { "type": "object", "properties": { "metadata": { "type": "object", "properties": { "operationMetadataId": { "type": "string" } } }, "type": { "type": "string" }, "kind": { "type": "string" }, "inputs": { "type": "object", "properties": { "schema": { "type": "object", "properties": { "type": { "type": "string" }, "properties": { "type": "object", "properties": { "text": { "type": "object", "properties": { "title": { "type": "string" }, "type": { "type": "string" }, "x-ms-dynamically-added": { "type": "boolean" }, "description": { "type": "string" }, "x-ms-content-hint": { "type": "string" } } }, "file": { "type": "object", "properties": { "title": { "type": "string" }, "type": { "type": "string" }, "x-ms-dynamically-added": { "type": "boolean" }, "description": { "type": "string" }, "x-ms-content-hint": { "type": "string" }, "properties": { "type": "object", "properties": { "name": { "type": "object", "properties": { "type": { "type": "string" } } }, "contentBytes": { "type": "object", "properties": { "type": { "type": "string" }, "format": { "type": "string" } } } } } } } } }, "required": { "type": "array", "items": { "type": "string" } } } } } } } } } }, "actions": { "type": "object", "properties": { "Compose": { "type": "object", "properties": { "runAfter": { "type": "object", "properties": {} }, "metadata": { "type": "object", "properties": { "operationMetadataId": { "type": "string" } } }, "type": { "type": "string" }, "inputs": { "type": "string" } } }, "Respond_to_a_Power_App_or_flow": { "type": "object", "properties": { "runAfter": { "type": "object", "properties": { "Compose": { "type": "array", "items": { "type": "string" } } } }, "metadata": { "type": "object", "properties": { "operationMetadataId": { "type": "string" } } }, "type": { "type": "string" }, "kind": { "type": "string" }, "inputs": { "type": "object", "properties": { "statusCode": { "type": "integer" }, "body": { "type": "object", "properties": { "data": { "type": "string" } } }, "schema": { "type": "object", "properties": { "type": { "type": "string" }, "properties": { "type": "object", "properties": { "data": { "type": "object", "properties": { "title": { "type": "string" }, "x-ms-dynamically-added": { "type": "boolean" }, "type": { "type": "string" } } } } } } } } } } } } } } }, "templateName": { "type": "string" } } }, "schemaVersion": { "type": "string" } } }
- Then to clean the output into a useable array. This will all be stored within a scope action
- The Compose will remove all β@β from the output and replace it with a βatβ. This is needed for when we convert it with xpath.
replace(string(body('Parse_JSON_-_Client_Data')?['properties']?['definition']?['actions']), '@', 'at_')
- The string is then converted into a JSON
json(outputs('Compose_-_Clean_to_String'))
- The output is then converted to an array
- The Compose will remove all β@β from the output and replace it with a βatβ. This is needed for when we convert it with xpath.
- An for loop of each action is then added
π‘ Yes I know that this is a nested for loop, and thats not great. But sometimes is inevitable.
xpath(xml(outputs('Compose_-_Create_Array')),'/data/*')
π‘ For a great read on xpath check out Tom Rihaβs blog. I could not have done it without it. How to extract value from XML using Power Automate flow
- The first step will check to see if the name is a default name
contains(variables('varDefaultNames'), xpath(item(), 'name(/*)'))
- The second increments the variable varDefaultNameCounter by 1
- The next is a condition that checks if the output of the compare is true
- If true or false two actions will occur
- If True the variable varDefaultNameScores will be appended
{ "ProcessGUID": @{items('Apply_to_each_-_Default_Names')?['ProcessGUID']}, "ProcessName": @{items('Apply_to_each_-_Default_Names')?['ProcessName']}, "Action": @{xpath(item(),'name(/*)')}, "DefaultNameScore": @{outputs('List_rows_-_Get_Default_Name_Rules')?['body/value']?[0]?['ScoreOutput.andy_rating']} }
- If True the variable varDefaultNameScore will be incremented
outputs('List_rows_-_Get_Default_Name_Rules')?['body/value']?[0]?['ScoreOutput.andy_rating']
- If False the variable varDefaultNameScores will be appended
{ "ProcessGUID": @{items('Apply_to_each_-_Default_Names')?['ProcessGUID']}, "ProcessName": @{items('Apply_to_each_-_Default_Names')?['ProcessName']}, "Action": @{xpath(item(),'name(/*)')}, "DefaultNameScore": 1 }
- If False the variable varDefaultNameScore will be incremented
- If True the variable varDefaultNameScores will be appended
- The first step will check to see if the name is a default name
- The first step in the loop is to parse the JSON of the client data
- The first action lists all the rules and the scores using a FetchXML query
- Finally the flow will be closed out with a response
div(variables('varDefaultNameScore'), variables('varDefaultNameCounter'))
string(variables('varDefaultNameScores'))
1.2 - When a push to test happens flow updates
- Back in the parent flow that was built in the previous post, add the 1.2.2 flow to run alongside the 1.2.1 flow.
- Two actions are added. A new variable varFlowDesctiptionsArray is added and a scope to format the output
- Within the scope are two other scopes
- The Scope Descriptions will have three Actions in it
- The Parse JSON will parse the description output
{ "type": "array", "items": { "type": "object", "properties": { "ProcessGUID": { "type": "object", "properties": { "@@odata.type": { "type": "string" }, "@@odata.id": { "type": "string" }, "@@odata.etag": { "type": "string" }, "@@odata.editLink": { "type": "string" }, "objectid@odata.type": { "type": "string" }, "objectid": { "type": "string" }, "solutioncomponentid@odata.type": { "type": "string" }, "solutioncomponentid": { "type": "string" } } }, "ProcessName": { "type": "string" }, "ProcessDescription": {}, "DescriptionScore": { "type": "number" } }, "required": [ "ProcessGUID", "ProcessName", "ProcessDescription", "DescriptionScore" ] } }
- For each description the variable varFlowDesctiptionsArray is appeneded
if(empty(item()?['ProcessDescription']), 'Description Missing', item()?['ProcessDescription'])
- An HTML table is then created usign the variable varFlowDesctiptionsArray
- The Parse JSON will parse the description output
- The Default Names Scope will have three actions
- The Compose will create the JSON out of the output
json()body('Run_a_Child_Flow_-_1.2.2')?['defaultnamedescriptions'])
- The Select will create the table structure
{ "type": "Select", "inputs": { "from": "", "select": "{n "Flow Name": ,n "Action Name": ,n "Action Score": n}" }, "runAfter": { "Compose_-_Create_JSON": [ "SUCCEEDED" ] }, "metadata": { "operationMetadataId": "43e873e4-512d-4890-b495-a4eac9d66236" } }
- The Create HTML table will create the actual HTML table from the output of the select
- The Compose will create the JSON out of the output
- These updates are then all added to the history table within production
{
"type": "OpenApiConnection",
"inputs": {
"parameters": {
"organization": "https://andworx-production.crm.dynamics.com",
"entityName": "andy_powerplatformsolutionshistories",
"item/andy_solutionversion": "@outputs('Get_a_row_by_ID_-_Deployment_Artifacts')?['body/artifactversion']",
"item/andy_appreviewscore": "@body('Run_a_Child_Flow_-_1.2.1')?['appreviewscore']",
"item/andy_automatedefaultnamearrayoutput": "@body('Run_a_Child_Flow_-_1.2.2')?['defaultnamedescriptions']",
"item/andy_automatedefaultnamescore": "@body('Run_a_Child_Flow_-_1.2.2')?['defaultnamescore']",
"item/andy_automatedefaultnametableoutput": "@body('Create_HTML_table_-_Default_Names')",
"item/andy_automatedescriptionscore": "@body('Run_a_Child_Flow_-_1.2.2')?['descriptionscore']",
"item/andy_flowdescriptionsarrayoutput": "@body('Run_a_Child_Flow_-_1.2.2')?['defaultnamedescriptions']",
"item/andy_flowdescriptionstableoutput": "@body('Create_HTML_table_-_varFlowDesctiptionsArray')",
"item/andy_linktoreport": "https://apps.powerapps.com/play/@{parameters('Power Apps Review App ID (andy_PowerAppsReviewAppID)')}?reviewid=@{body('Run_a_Child_Flow_-_1.2.1')?['solutionreview']}",
"item/andy_Solution@odata.bind": "andy_powerplatformsolutionstrackings(@{outputs('List_rows_from_selected_environment_-_Solution_Tracking')?['body/value']?[0]?['andy_powerplatformsolutionstrackingid']})"
},
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps",
"connection": "shared_commondataserviceforapps",
"operationId": "CreateRecordWithOrganization"
}
},
"runAfter": {
"List_rows_from_selected_environment_-_Solution_Tracking": [
"Succeeded"
]
},
"metadata": {
"operationMetadataId": "4cbf99ac-60fd-4696-ac0d-f7c112f48930"
}
}
Final Report
Putting it all together will give us a snapshot into how our solution currently looks
π‘ The tables are built using a bit of JavaScript and a iframe from the html table created earlier
π‘ The app is loaded using an iframe and JavaScript from the link that was created earlier