Skip to content

Add Fluent DSL Equivalents for Core Task Types (set, wait, switch, for, fork, raise, conditional) #120

Add Fluent DSL Equivalents for Core Task Types (set, wait, switch, for, fork, raise, conditional)

Add Fluent DSL Equivalents for Core Task Types (set, wait, switch, for, fork, raise, conditional) #120

#
# Copyright 2021-Present The Serverless Workflow Specification Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
name: Sync Issues to Target GitHub Project
on:
issues:
types: [opened, closed]
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (vars.PSYNC_ENABLED != 'false' && vars.PSYNC_ENABLED != 'off')
steps:
- name: Parse target project
id: parse
run: |
VALUE="${{ vars.PSYNC_TARGET }}"
if ! [[ "$VALUE" =~ ^[^:]+:[0-9]+$ ]]; then
echo "Error: PSYNC_TARGET value '$VALUE' is invalid. Expected format: org:project_number (e.g. my-org:1)"
exit 1
fi
ORG="${VALUE%%:*}"
NUMBER="${VALUE##*:}"
echo "org=$ORG" >> "$GITHUB_OUTPUT"
echo "number=$NUMBER" >> "$GITHUB_OUTPUT"
- name: Check author filter
id: author_filter
if: github.event_name == 'issues'
run: |
FILTER="${{ vars.PSYNC_AUTHORS_FILTER }}"
if [ -z "$FILTER" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
AUTHOR="${{ github.event.issue.user.login }}"
if echo "$FILTER" | tr ',' '\n' | xargs -I{} echo {} | xargs | tr ' ' '\n' | grep -qx "$AUTHOR"; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "Issue author '$AUTHOR' is not in PSYNC_AUTHORS_FILTER — skipping."
echo "allowed=false" >> "$GITHUB_OUTPUT"
fi
- name: Get project ID
id: project
if: github.event_name == 'workflow_dispatch' || (vars.PSYNC_ENABLED != 'false' && vars.PSYNC_ENABLED != 'off' && steps.author_filter.outputs.allowed != 'false')
env:
GH_TOKEN: ${{ secrets.PSYNC_PAT }}
run: |
PROJECT_ID=$(gh api graphql -f query='
query($org: String!, $number: Int!) {
organization(login: $org) {
projectV2(number: $number) {
id
}
}
}' \
-f org="${{ steps.parse.outputs.org }}" \
-F number=${{ steps.parse.outputs.number }} \
--jq '.data.organization.projectV2.id')
if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then
echo "Error: could not resolve project ID for '${{ steps.parse.outputs.org }}' project number ${{ steps.parse.outputs.number }}. Check PSYNC_TARGET and that PSYNC_PAT has access to the target org's project."
exit 1
fi
echo "id=$PROJECT_ID" >> "$GITHUB_OUTPUT"
- name: Write apply-fields helper
if: github.event_name == 'workflow_dispatch' || (github.event.action == 'opened' && vars.PSYNC_ENABLED != 'false' && vars.PSYNC_ENABLED != 'off' && steps.author_filter.outputs.allowed != 'false')
run: |
cat > "$RUNNER_TEMP/psync_apply_fields.sh" << 'PSYNC_HELPER_EOF'
# apply_fields ITEM_ID ISSUE_NUMBER
# Required env: PSYNC_PROJECT_ID, PSYNC_FIELDS_JSON (nodes array), PSYNC_INITIAL_VALUES, PSYNC_REPO
apply_fields() {
local ITEM_ID="$1"
local ISSUE_NUMBER="$2"
while IFS= read -r PAIR; do
PAIR=$(echo "$PAIR" | xargs)
[ -z "$PAIR" ] && continue
KEY=$(echo "$PAIR" | cut -d= -f1 | xargs)
VALUE=$(echo "$PAIR" | cut -d= -f2- | xargs)
if [ "$KEY" = "Assignees" ]; then
ASSIGNEES_JSON=$(echo "$VALUE" | tr ' ' '\n' | jq -R . | jq -s '[.[] | select(length > 0)]')
[ "$(echo "$ASSIGNEES_JSON" | jq 'length')" -eq 0 ] && continue
echo "{\"assignees\": $ASSIGNEES_JSON}" | \
gh api "repos/$PSYNC_REPO/issues/$ISSUE_NUMBER/assignees" \
--method POST --input -
else
FIELD_ID=$(echo "$PSYNC_FIELDS_JSON" | jq -r \
--arg name "$KEY" \
'.[] | select(.name == $name) | .id // empty' | head -1)
[ -z "$FIELD_ID" ] && echo "Warning: field '$KEY' not found in project fields. Available fields: $(echo "$PSYNC_FIELDS_JSON" | jq -r '[.[] | .name // empty] | join(", ")'). Skipping." && continue
DATA_TYPE=$(echo "$PSYNC_FIELDS_JSON" | jq -r \
--arg name "$KEY" \
'.[] | select(.name == $name) | .dataType // empty' | head -1)
OPTION_ID=$(echo "$PSYNC_FIELDS_JSON" | jq -r \
--arg name "$KEY" --arg val "$VALUE" \
'.[] | select(.name == $name) | .options[]? | select(.name == $val) | .id // empty')
echo "Debug: field='$KEY' value='$VALUE' field_id='$FIELD_ID' data_type='${DATA_TYPE:-single-select}' option_id='${OPTION_ID:-none}'"
if [ -n "$OPTION_ID" ]; then
gh api graphql -f query='
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) { projectV2Item { id } }
}' \
-f projectId="$PSYNC_PROJECT_ID" -f itemId="$ITEM_ID" \
-f fieldId="$FIELD_ID" -f optionId="$OPTION_ID"
elif [ "$DATA_TYPE" = "TEXT" ]; then
gh api graphql -f query='
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { text: $text }
}) { projectV2Item { id } }
}' \
-f projectId="$PSYNC_PROJECT_ID" -f itemId="$ITEM_ID" \
-f fieldId="$FIELD_ID" -f text="$VALUE"
else
echo "Warning: field '$KEY' has unsupported type '${DATA_TYPE:-single-select}' — only TEXT and single-select fields are supported. Skipping."
fi
fi
done < <(echo "$PSYNC_INITIAL_VALUES" | tr ',' '\n')
}
PSYNC_HELPER_EOF
- name: Import existing repo issues
if: github.event_name == 'workflow_dispatch' && (vars.PSYNC_IMPORT_EXISTING) == 'true'
env:
GH_TOKEN: ${{ secrets.PSYNC_PAT }}
run: |
PROJECT_ID="${{ steps.project.outputs.id }}"
INITIAL_VALUES="${{ vars.PSYNC_INITIAL_VALUES }}"
# Fetch project fields once if initial values are configured
FIELDS_JSON="[]"
if [ -n "$INITIAL_VALUES" ]; then
FIELDS_JSON=$(gh api graphql -f query='
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options { id name }
}
... on ProjectV2Field {
id
name
dataType
}
}
}
}
}
}' \
-f projectId="$PROJECT_ID" \
--jq '.data.node.fields.nodes')
fi
# Source shared apply-fields helper
# shellcheck source=/dev/null
source "$RUNNER_TEMP/psync_apply_fields.sh"
export PSYNC_PROJECT_ID="$PROJECT_ID"
export PSYNC_FIELDS_JSON="$FIELDS_JSON"
export PSYNC_INITIAL_VALUES="$INITIAL_VALUES"
export PSYNC_REPO="${{ github.repository }}"
# Fetch all open repo issues (node_id + number + author)
REPO_ISSUES=$(gh api "repos/${{ github.repository }}/issues" \
--paginate | jq -s '[ .[][] | select(.pull_request == null) | {node_id, number, author: .user.login} ]')
# Filter by author if PSYNC_AUTHORS_FILTER is set
AUTHORS_FILTER="${{ vars.PSYNC_AUTHORS_FILTER }}"
if [ -n "$AUTHORS_FILTER" ]; then
AUTHORS_JSON=$(echo "$AUTHORS_FILTER" | tr ',' '\n' | xargs -I{} echo {} | jq -R . | jq -s '.')
REPO_ISSUES=$(echo "$REPO_ISSUES" | jq --argjson authors "$AUTHORS_JSON" \
'[ .[] | select(.author | IN($authors[])) ]')
fi
# Fetch all existing project content node IDs (paginated)
PROJECT_IDS=""
CURSOR=""
while true; do
if [ -z "$CURSOR" ]; then
PAGE=$(gh api graphql -f query='
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100) {
pageInfo { hasNextPage endCursor }
nodes { content { ... on Issue { id } } }
}
}
}
}' \
-f projectId="$PROJECT_ID")
else
PAGE=$(gh api graphql -f query='
query($projectId: ID!, $cursor: String!) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { content { ... on Issue { id } } }
}
}
}
}' \
-f projectId="$PROJECT_ID" -f cursor="$CURSOR")
fi
PROJECT_IDS="$PROJECT_IDS"$'\n'"$(echo "$PAGE" | jq -r '.data.node.items.nodes[].content.id // empty')"
HAS_NEXT=$(echo "$PAGE" | jq -r '.data.node.items.pageInfo.hasNextPage')
[ "$HAS_NEXT" != "true" ] && break
CURSOR=$(echo "$PAGE" | jq -r '.data.node.items.pageInfo.endCursor')
done
PROJECT_IDS_JSON=$(echo "$PROJECT_IDS" | grep -v '^$' | jq -R . | jq -s '.') || PROJECT_IDS_JSON='[]'
# Find issues not yet in the project
MISSING=$(echo "$REPO_ISSUES" | jq -c \
--argjson existing "$PROJECT_IDS_JSON" \
'.[] | select(.node_id | IN($existing[]) | not)')
if [ -z "$MISSING" ]; then
echo "No new issues to import."
exit 0
fi
IMPORTED=0
FAILED=0
while IFS= read -r ISSUE; do
NODE_ID=$(echo "$ISSUE" | jq -r '.node_id')
ISSUE_NUMBER=$(echo "$ISSUE" | jq -r '.number')
ITEM_ID=$(gh api graphql -f query='
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item { id }
}
}' \
-f projectId="$PROJECT_ID" \
-f contentId="$NODE_ID" \
--jq '.data.addProjectV2ItemById.item.id') || ITEM_ID=""
if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then
echo "Warning: failed to add issue #$ISSUE_NUMBER"
FAILED=$((FAILED + 1))
continue
fi
[ -n "$INITIAL_VALUES" ] && apply_fields "$ITEM_ID" "$ISSUE_NUMBER"
IMPORTED=$((IMPORTED + 1))
echo "Imported issue #$ISSUE_NUMBER"
done <<< "$MISSING"
echo "Done. Imported: $IMPORTED, Failed: $FAILED"
- name: Add issue to project
id: add_item
if: github.event.action == 'opened' && vars.PSYNC_ENABLED != 'false' && vars.PSYNC_ENABLED != 'off' && steps.author_filter.outputs.allowed != 'false'
env:
GH_TOKEN: ${{ secrets.PSYNC_PAT }}
run: |
ITEM_ID=$(gh api graphql -f query='
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item { id }
}
}' \
-f projectId="${{ steps.project.outputs.id }}" \
-f contentId="${{ github.event.issue.node_id }}" \
--jq '.data.addProjectV2ItemById.item.id' || true)
if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then
echo "Issue already exists in project or add failed — searching for existing item ID."
CURSOR=""
while true; do
if [ -z "$CURSOR" ]; then
PAGE_RESULT=$(gh api graphql -f query='
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100) {
pageInfo { hasNextPage endCursor }
nodes {
id
content { ... on Issue { id } }
}
}
}
}
}' \
-f projectId="${{ steps.project.outputs.id }}")
else
PAGE_RESULT=$(gh api graphql -f query='
query($projectId: ID!, $cursor: String!) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
content { ... on Issue { id } }
}
}
}
}
}' \
-f projectId="${{ steps.project.outputs.id }}" \
-f cursor="$CURSOR")
fi
ITEM_ID=$(echo "$PAGE_RESULT" | jq -r \
--arg issueId "${{ github.event.issue.node_id }}" \
'.data.node.items.nodes[] | select(.content.id == $issueId) | .id')
[ -n "$ITEM_ID" ] && break
HAS_NEXT=$(echo "$PAGE_RESULT" | jq -r '.data.node.items.pageInfo.hasNextPage')
[ "$HAS_NEXT" != "true" ] && break
CURSOR=$(echo "$PAGE_RESULT" | jq -r '.data.node.items.pageInfo.endCursor')
done
if [ -z "$ITEM_ID" ]; then
echo "Error: failed to add issue to project and could not find an existing item — the PAT may lack access."
exit 1
fi
echo "Found existing project item: $ITEM_ID"
fi
echo "item_id=$ITEM_ID" >> "$GITHUB_OUTPUT"
- name: Set initial field values
if: github.event.action == 'opened' && vars.PSYNC_ENABLED != 'false' && vars.PSYNC_ENABLED != 'off' && steps.author_filter.outputs.allowed != 'false'
env:
GH_TOKEN: ${{ secrets.PSYNC_PAT }}
run: |
INITIAL_VALUES="${{ vars.PSYNC_INITIAL_VALUES }}"
[ -z "$INITIAL_VALUES" ] && exit 0
PSYNC_FIELDS_JSON=$(gh api graphql -f query='
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options { id name }
}
... on ProjectV2Field {
id
name
dataType
}
}
}
}
}
}' \
-f projectId="${{ steps.project.outputs.id }}" \
--jq '.data.node.fields.nodes')
export PSYNC_PROJECT_ID="${{ steps.project.outputs.id }}"
export PSYNC_FIELDS_JSON
export PSYNC_INITIAL_VALUES="$INITIAL_VALUES"
export PSYNC_REPO="${{ github.repository }}"
# shellcheck source=/dev/null
source "$RUNNER_TEMP/psync_apply_fields.sh"
apply_fields "${{ steps.add_item.outputs.item_id }}" "${{ github.event.issue.number }}"
- name: Get item ID and close Status option ID
id: find_item
if: github.event.action == 'closed' && vars.PSYNC_ENABLED != 'false' && vars.PSYNC_ENABLED != 'off' && steps.author_filter.outputs.allowed != 'false'
env:
GH_TOKEN: ${{ secrets.PSYNC_PAT }}
run: |
CLOSE_STATUS="${{ vars.PSYNC_CLOSE_STATUS }}"
CLOSE_STATUS="${CLOSE_STATUS:-Done}"
# Fetch Status field info once
FIELDS_RESULT=$(gh api graphql -f query='
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options { id name }
}
}
}
}
}
}' \
-f projectId="${{ steps.project.outputs.id }}")
FIELD_ID=$(echo "$FIELDS_RESULT" | jq -r \
'.data.node.fields.nodes[] | select(.name == "Status") | .id // empty')
if [ -z "$FIELD_ID" ]; then
echo "Error: Status field not found in the target project. Ensure the project has a single-select field named exactly 'Status' (case-sensitive)."
exit 1
fi
CLOSE_OPTION_ID=$(echo "$FIELDS_RESULT" | jq -r \
--arg status "$CLOSE_STATUS" \
'.data.node.fields.nodes[] | select(.name == "Status") | .options[] | select(.name == $status) | .id // empty')
if [ -z "$CLOSE_OPTION_ID" ]; then
echo "Error: Status option '$CLOSE_STATUS' not found in the target project. Check PSYNC_CLOSE_STATUS (default: Done) — value must match a Status option exactly (case-sensitive)."
exit 1
fi
# Paginate items until the matching issue is found
ITEM_ID=""
CURSOR=""
while true; do
if [ -z "$CURSOR" ]; then
PAGE_RESULT=$(gh api graphql -f query='
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100) {
pageInfo { hasNextPage endCursor }
nodes {
id
content { ... on Issue { id } }
}
}
}
}
}' \
-f projectId="${{ steps.project.outputs.id }}")
else
PAGE_RESULT=$(gh api graphql -f query='
query($projectId: ID!, $cursor: String!) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
content { ... on Issue { id } }
}
}
}
}
}' \
-f projectId="${{ steps.project.outputs.id }}" \
-f cursor="$CURSOR")
fi
ITEM_ID=$(echo "$PAGE_RESULT" | jq -r \
--arg issueId "${{ github.event.issue.node_id }}" \
'.data.node.items.nodes[] | select(.content.id == $issueId) | .id')
[ -n "$ITEM_ID" ] && break
HAS_NEXT=$(echo "$PAGE_RESULT" | jq -r '.data.node.items.pageInfo.hasNextPage')
[ "$HAS_NEXT" != "true" ] && break
CURSOR=$(echo "$PAGE_RESULT" | jq -r '.data.node.items.pageInfo.endCursor')
done
# If not found, add the issue now (handles race where closed fires before opened)
if [ -z "$ITEM_ID" ]; then
echo "Issue not yet in project — adding it now before setting close Status."
ITEM_ID=$(gh api graphql -f query='
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
item { id }
}
}' \
-f projectId="${{ steps.project.outputs.id }}" \
-f contentId="${{ github.event.issue.node_id }}" \
--jq '.data.addProjectV2ItemById.item.id')
if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then
echo "Error: failed to add issue to project. Cannot set close Status."
exit 1
fi
fi
echo "item_id=$ITEM_ID" >> "$GITHUB_OUTPUT"
echo "field_id=$FIELD_ID" >> "$GITHUB_OUTPUT"
echo "close_option_id=$CLOSE_OPTION_ID" >> "$GITHUB_OUTPUT"
- name: Set item close Status
if: github.event.action == 'closed' && vars.PSYNC_ENABLED != 'false' && vars.PSYNC_ENABLED != 'off' && steps.author_filter.outputs.allowed != 'false'
env:
GH_TOKEN: ${{ secrets.PSYNC_PAT }}
run: |
ITEM_ID="${{ steps.find_item.outputs.item_id }}"
OPTION_ID="${{ steps.find_item.outputs.close_option_id }}"
gh api graphql -f query='
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) {
projectV2Item { id }
}
}' \
-f projectId="${{ steps.project.outputs.id }}" \
-f itemId="$ITEM_ID" \
-f fieldId="${{ steps.find_item.outputs.field_id }}" \
-f optionId="$OPTION_ID"