{ "kind": "open-flow-workflow", "version": 1, "name": "GitHub User Profile to Slack", "content": { "modelVersion": 14, "document": { "bindings": {}, "graph": { "nodes": { "n_b15abb7bb2c5bd2f": { "name": "Receive a GitHub profile request", "kind": "webhook", "bodyFields": [ { "jsonSchema": {}, "nullable": true, "handle": "text" }, { "jsonSchema": {}, "nullable": true, "handle": "channel_id" } ], "method": "POST", "options": { "responseData": "Request accepted", "responseHeaders": { "Content-Type": "text/plain; charset=utf-8" }, "responseStatusCode": 200 } }, "n_6a0a3c609efb6bc8": { "inputs": { "body": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_b15abb7bb2c5bd2f", "output": "body" } ] } }, "name": "Prepare the bounded GitHub query", "kind": "task", "task": { "inputs": [ { "jsonSchema": { "type": "object", "additionalProperties": true }, "nullable": false, "handle": "body" } ], "outputs": [ { "jsonSchema": { "type": "string" }, "nullable": false, "handle": "username" }, { "jsonSchema": { "type": "string", "minLength": 1 }, "nullable": false, "handle": "channelId" }, { "jsonSchema": { "type": "object", "additionalProperties": true }, "nullable": false, "handle": "request" } ], "name": "Prepare the bounded GitHub query", "moduleId": "n_9d576838a4cf7893", "capabilities": [ { "kind": "connector", "mode": "independent", "actions": [] } ] } }, "n_24a5b0154106a01e": { "inputs": { "endpoint": { "kind": "value", "value": "/graphql" }, "body": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_6a0a3c609efb6bc8", "output": "request" } ] } }, "name": "Read the GitHub profile", "kind": "task", "task": { "inputs": [ { "jsonSchema": { "type": "string" }, "nullable": false, "handle": "endpoint" }, { "jsonSchema": {}, "nullable": true, "handle": "body" } ], "outputs": [ { "jsonSchema": {}, "nullable": true, "handle": "body" }, { "jsonSchema": { "type": "integer" }, "nullable": false, "handle": "statusCode" }, { "jsonSchema": { "type": "object", "additionalProperties": { "type": "string" } }, "nullable": false, "handle": "headers" }, { "jsonSchema": { "const": "base64" }, "nullable": true, "handle": "bodyEncoding" } ], "name": "Read the GitHub profile", "executor": { "providerId": "github", "method": "POST", "query": { "mode": "fields", "names": [] }, "headers": { "mode": "fields", "names": [] }, "bodyFormat": "json", "kind": "connector-proxy" } } }, "n_e41c056a02bd41c8": { "inputs": { "body": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_24a5b0154106a01e", "output": "body" } ] }, "statusCode": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_24a5b0154106a01e", "output": "statusCode" } ] }, "bodyEncoding": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_24a5b0154106a01e", "output": "bodyEncoding" } ] }, "username": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_6a0a3c609efb6bc8", "output": "username" } ] } }, "name": "Collect the matching profile emails", "kind": "task", "task": { "inputs": [ { "jsonSchema": {}, "nullable": true, "handle": "body" }, { "jsonSchema": { "type": "integer" }, "nullable": false, "handle": "statusCode" }, { "jsonSchema": { "const": "base64" }, "nullable": true, "handle": "bodyEncoding" }, { "jsonSchema": { "type": "string" }, "nullable": false, "handle": "username" } ], "outputs": [ { "jsonSchema": { "type": "object", "additionalProperties": true }, "nullable": false, "handle": "profile" }, { "jsonSchema": { "type": "array", "items": { "type": "string" } }, "nullable": false, "handle": "emails" }, { "jsonSchema": { "type": "array", "items": { "type": "array", "items": { "type": "object", "additionalProperties": true } } }, "nullable": false, "handle": "parts" }, { "jsonSchema": { "type": "integer", "minimum": 0 }, "nullable": false, "handle": "pullRequestsScanned" }, { "jsonSchema": { "type": "integer", "minimum": 0 }, "nullable": false, "handle": "commitsScanned" } ], "name": "Collect the matching profile emails", "moduleId": "n_2014401874d96597", "capabilities": [ { "kind": "connector", "mode": "independent", "actions": [] } ] } }, "n_8a7a059531c7af90": { "inputs": { "parts": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_e41c056a02bd41c8", "output": "parts" } ] }, "channelId": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_6a0a3c609efb6bc8", "output": "channelId" } ] }, "previousState": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_8a7a059531c7af90", "output": "state" } ] }, "messageTs": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_d9d1e9b1e4c59eb4", "output": "ts" } ] }, "messageChannelId": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_d9d1e9b1e4c59eb4", "output": "channelId" } ] } }, "name": "Prepare the next profile attachment", "kind": "task", "task": { "inputs": [ { "jsonSchema": { "type": "array", "items": { "type": "array", "items": { "type": "object", "additionalProperties": true } } }, "nullable": false, "handle": "parts" }, { "jsonSchema": { "type": "string", "minLength": 1 }, "nullable": false, "handle": "channelId" }, { "jsonSchema": { "type": "object", "additionalProperties": true }, "nullable": true, "handle": "previousState" }, { "jsonSchema": { "type": "string" }, "nullable": true, "handle": "messageTs" }, { "jsonSchema": { "type": "string" }, "nullable": true, "handle": "messageChannelId" } ], "outputs": [ { "jsonSchema": { "type": "object", "properties": { "index": { "type": "integer", "minimum": 0 }, "receipts": { "type": "array", "items": { "type": "object", "additionalProperties": true } } }, "required": [ "index", "receipts" ], "additionalProperties": false }, "nullable": false, "handle": "state" }, { "jsonSchema": { "type": "boolean" }, "nullable": false, "handle": "more" }, { "jsonSchema": { "type": "array", "items": { "type": "object", "properties": {}, "additionalProperties": true, "description": "A Slack legacy attachment object. Prefer blocks for new messages when possible." }, "minItems": 1, "description": "Slack legacy attachments to include in the message." }, "nullable": true, "handle": "attachments" }, { "jsonSchema": { "type": "string", "minLength": 1 }, "nullable": false, "handle": "channelId" }, { "jsonSchema": { "type": "integer", "minimum": 0 }, "nullable": false, "handle": "count" } ], "name": "Prepare the next profile attachment", "moduleId": "n_72ea91468dd3d5fc", "capabilities": [ { "kind": "connector", "mode": "independent", "actions": [] } ] } }, "n_493f1807d1a29096": { "inputs": {}, "name": "More profile text to post", "kind": "condition", "matchMode": "first", "cases": [ { "output": "more", "groups": [ { "expressions": [ { "left": { "kind": "source", "source": { "kind": "node", "nodeId": "n_8a7a059531c7af90", "output": "more" } }, "operator": "==", "right": { "kind": "value", "value": true } } ] } ] } ] }, "n_d9d1e9b1e4c59eb4": { "inputs": { "channelId": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_8a7a059531c7af90", "output": "channelId" } ] }, "attachments": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_8a7a059531c7af90", "output": "attachments" } ] } }, "name": "Post a profile attachment", "kind": "task", "task": { "inputs": [ { "description": "The Slack conversation or channel ID.", "jsonSchema": { "type": "string", "minLength": 1, "description": "The Slack conversation or channel ID." }, "nullable": false, "handle": "channelId" }, { "description": "Plain text message content. When blocks are provided, Slack uses this as notification and accessibility fallback text.", "jsonSchema": { "type": "string", "description": "Plain text message content. When blocks are provided, Slack uses this as notification and accessibility fallback text." }, "nullable": true, "handle": "text", "value": null }, { "description": "Slack Block Kit blocks to render in the message.", "jsonSchema": { "type": "array", "items": { "type": "object", "properties": {}, "additionalProperties": true, "description": "A Slack Block Kit block object. Pass the block exactly as Slack documents it." }, "minItems": 1, "description": "Slack Block Kit blocks to render in the message." }, "nullable": true, "handle": "blocks", "value": null }, { "description": "Slack legacy attachments to include in the message.", "jsonSchema": { "type": "array", "items": { "type": "object", "properties": {}, "additionalProperties": true, "description": "A Slack legacy attachment object. Prefer blocks for new messages when possible." }, "minItems": 1, "description": "Slack legacy attachments to include in the message." }, "nullable": true, "handle": "attachments", "value": null }, { "description": "Whether Slack should unfurl links in the message.", "jsonSchema": { "type": "boolean", "description": "Whether Slack should unfurl links in the message." }, "nullable": true, "handle": "unfurlLinks", "value": null }, { "description": "Whether Slack should unfurl media in the message.", "jsonSchema": { "type": "boolean", "description": "Whether Slack should unfurl media in the message." }, "nullable": true, "handle": "unfurlMedia", "value": null }, { "description": "Slack message metadata to attach to the message.", "jsonSchema": { "type": "object", "properties": {}, "additionalProperties": true, "description": "Slack message metadata to attach to the message." }, "nullable": true, "handle": "metadata", "value": null } ], "outputs": [ { "description": "The timestamp identifier of the posted message.", "jsonSchema": { "type": "string", "description": "The timestamp identifier of the posted message." }, "nullable": false, "handle": "ts" }, { "description": "The channel ID where the message was posted.", "jsonSchema": { "type": "string", "description": "The channel ID where the message was posted." }, "nullable": false, "handle": "channelId" } ], "name": "Post a profile attachment", "executor": { "kind": "connector", "action": "slack.post_message" } } }, "n_c253af5c2dc7fc0f": { "name": "Profile and confirmed Slack posts", "kind": "outputs", "outputDefs": [ { "jsonSchema": { "type": "object", "additionalProperties": true }, "nullable": false, "handle": "profile" }, { "jsonSchema": { "type": "array", "items": { "type": "string" } }, "nullable": false, "handle": "emails" }, { "jsonSchema": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "nullable": false, "handle": "receipts" }, { "jsonSchema": { "type": "integer", "minimum": 0 }, "nullable": false, "handle": "count" }, { "jsonSchema": { "type": "integer", "minimum": 0 }, "nullable": false, "handle": "pullRequestsScanned" }, { "jsonSchema": { "type": "integer", "minimum": 0 }, "nullable": false, "handle": "commitsScanned" } ], "outputMappings": { "profile": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_e41c056a02bd41c8", "output": "profile" } ] }, "emails": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_e41c056a02bd41c8", "output": "emails" } ] }, "receipts": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_8a7a059531c7af90", "output": "state", "field": "receipts" } ] }, "count": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_8a7a059531c7af90", "output": "count" } ] }, "pullRequestsScanned": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_e41c056a02bd41c8", "output": "pullRequestsScanned" } ] }, "commitsScanned": { "kind": "sources", "sources": [ { "kind": "node", "nodeId": "n_e41c056a02bd41c8", "output": "commitsScanned" } ] } } } }, "edges": [ { "source": "n_b15abb7bb2c5bd2f", "target": "n_6a0a3c609efb6bc8" }, { "source": "n_6a0a3c609efb6bc8", "target": "n_24a5b0154106a01e" }, { "source": "n_24a5b0154106a01e", "target": "n_e41c056a02bd41c8" }, { "source": "n_e41c056a02bd41c8", "target": "n_8a7a059531c7af90" }, { "source": "n_8a7a059531c7af90", "target": "n_493f1807d1a29096" }, { "source": "n_493f1807d1a29096", "target": "n_d9d1e9b1e4c59eb4", "sourceHandle": "more" }, { "source": "n_d9d1e9b1e4c59eb4", "target": "n_8a7a059531c7af90" }, { "source": "n_493f1807d1a29096", "target": "n_c253af5c2dc7fc0f", "sourceHandle": "otherwise" } ] } }, "modules": { "n_9d576838a4cf7893": { "name": "Prepare the bounded GitHub query", "imports": [], "source": "export default function githubUserQuery({ body }) {\n if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Expected a JSON object containing text and channel_id');\n const username = body.text, channelId = body.channel_id;\n if (typeof username !== 'string' || !/^[a-z\\d](?:[a-z\\d-]{0,37}[a-z\\d])?$/i.test(username)) throw new Error('text must be one GitHub username, without whitespace');\n if (typeof channelId !== 'string' || !/^[CDG][A-Z0-9]{2,31}$/.test(channelId)) throw new Error('channel_id must identify a Slack conversation accessible to the selected connection');\n const query = 'query LookupGitHubUser($login: String!) { user(login: $login) { name company location avatarUrl email pullRequests(last: 25) { edges { node { commits(last: 25) { nodes { commit { author { email name } } } } } } } } }';\n return { username, channelId, request: { query, variables: { login: username }, operationName: 'LookupGitHubUser' } };\n}\n" }, "n_2014401874d96597": { "name": "Collect the matching profile emails", "imports": [], "source": "export default function githubUserProfile({ body, statusCode, bodyEncoding, username }) {\n if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) throw new Error('GitHub profile query failed with HTTP ' + statusCode);\n if (bodyEncoding != null || !body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Expected a JSON GitHub GraphQL response');\n if (body.errors != null && (!Array.isArray(body.errors) || body.errors.length)) throw new Error('GitHub returned GraphQL errors; partial profile data is not posted');\n const profile = body.data?.user;\n if (!profile || typeof profile !== 'object' || Array.isArray(profile)) throw new Error('GitHub did not return the requested user');\n for (const key of ['name', 'company', 'location']) if (profile[key] != null && typeof profile[key] !== 'string') throw new Error('Invalid GitHub profile ' + key);\n if (typeof profile.email !== 'string' || typeof profile.avatarUrl !== 'string' || !/^https:\\/\\/[^\\s/?#@<>\"'\\\\]+(?:[/?#][^\\s<>\"'\\\\]*)?$/.test(profile.avatarUrl)) throw new Error('Invalid GitHub profile email or avatar URL');\n const edges = profile.pullRequests?.edges;\n if (!Array.isArray(edges) || edges.length > 25) throw new Error('Expected at most 25 pull requests');\n const candidates = profile.email ? [profile.email] : []; let commitsScanned = 0;\n for (const edge of edges) {\n const nodes = edge?.node?.commits?.nodes;\n if (!Array.isArray(nodes) || nodes.length > 25) throw new Error('Expected at most 25 commits per pull request');\n for (const node of nodes) {\n commitsScanned++;\n if (!node?.commit || typeof node.commit !== 'object') throw new Error('Invalid GitHub pull-request commit');\n const author = node.commit.author;\n if (author == null) continue;\n if (typeof author !== 'object' || (author.name != null && typeof author.name !== 'string') || (author.email != null && typeof author.email !== 'string')) throw new Error('Invalid GitHub commit author');\n const matches = typeof author.name === 'string' && ((typeof profile.name === 'string' && profile.name.length > 0 && author.name.includes(profile.name)) || author.name === username);\n if (matches && author.email) candidates.push(author.email);\n }\n }\n const emails = [...new Set(candidates)].filter(email => !/^[^@]+@(?:users\\.)?noreply\\.github\\.com$/i.test(email));\n const escape = text => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');\n const text = 'Name: ' + (profile.name ?? '') + '\\nEmail: ' + emails.join(', ') + '\\nCompany: ' + (profile.company ?? '') + '\\nLocation: ' + (profile.location ?? '');\n const chunks = []; let chunk = '';\n for (const character of text) { const escaped = escape(character); if (chunk.length + escaped.length > 3800) { chunks.push(chunk); chunk = ''; } chunk += escaped; }\n if (chunk) chunks.push(chunk);\n const title = 'GitHub Details for: ' + username;\n const parts = chunks.map((content, index) => [{ title: chunks.length > 1 ? title + ' (' + (index + 1) + '/' + chunks.length + ')' : title, text: content, fallback: title, mrkdwn_in: [], ...(index === 0 ? { thumb_url: profile.avatarUrl } : {}) }]);\n return { profile, emails, parts, pullRequestsScanned: edges.length, commitsScanned };\n}\n" }, "n_72ea91468dd3d5fc": { "name": "Prepare the next profile attachment", "imports": [], "source": "export default function githubUserProgress({ parts, channelId, previousState, messageTs, messageChannelId }) {\n if (!Array.isArray(parts) || !parts.length || typeof channelId !== 'string' || !/^[CDG][A-Z0-9]{2,31}$/.test(channelId)) throw new Error('Expected the profile attachments and Slack conversation');\n let state;\n if (previousState == null) state = { index: 0, receipts: [] };\n else {\n if (!Number.isInteger(previousState.index) || previousState.index < 1 || previousState.index > parts.length || !Array.isArray(previousState.receipts) || previousState.receipts.length !== previousState.index - 1) throw new Error('Invalid profile notification progress');\n if (typeof messageTs !== 'string' || !/^\\d+\\.\\d+$/.test(messageTs) || messageChannelId !== channelId) throw new Error('Slack did not confirm the requested channel; a message may already exist');\n state = { index: previousState.index, receipts: [...previousState.receipts, { ts: messageTs, channelId: messageChannelId }] };\n }\n const attachments = parts[state.index];\n if (attachments == null) return { state, more: false, attachments: parts[0], channelId, count: state.receipts.length };\n if (!Array.isArray(attachments) || attachments.length !== 1 || typeof attachments[0].text !== 'string' || attachments[0].text.length > 3800) throw new Error('Invalid profile attachment part');\n return { state: { index: state.index + 1, receipts: state.receipts }, more: true, attachments, channelId, count: state.receipts.length };\n}\n" } } }, "presentation": { "designer": { "version": 1, "flow": { "direction": "vertical" } } } }