From 74f78531f59fae2eb5d4a183f5fd26f467346bc6 Mon Sep 17 00:00:00 2001 From: michelle0927 Date: Tue, 17 Dec 2024 16:07:15 -0500 Subject: [PATCH 1/5] circleci init --- .../actions/add-comment/add-comment.mjs | 41 ++++ .../actions/create-item/create-item.mjs | 41 ++++ .../actions/update-item/update-item.mjs | 33 +++ components/circleci/circleci.app.mjs | 175 ++++++++++++++- .../new-comment-instant.mjs | 163 ++++++++++++++ .../new-item-instant/new-item-instant.mjs | 128 +++++++++++ .../updated-item-instant.mjs | 206 ++++++++++++++++++ 7 files changed, 785 insertions(+), 2 deletions(-) create mode 100644 components/circleci/actions/add-comment/add-comment.mjs create mode 100644 components/circleci/actions/create-item/create-item.mjs create mode 100644 components/circleci/actions/update-item/update-item.mjs create mode 100644 components/circleci/sources/new-comment-instant/new-comment-instant.mjs create mode 100644 components/circleci/sources/new-item-instant/new-item-instant.mjs create mode 100644 components/circleci/sources/updated-item-instant/updated-item-instant.mjs diff --git a/components/circleci/actions/add-comment/add-comment.mjs b/components/circleci/actions/add-comment/add-comment.mjs new file mode 100644 index 0000000000000..ba042362be5d0 --- /dev/null +++ b/components/circleci/actions/add-comment/add-comment.mjs @@ -0,0 +1,41 @@ +import circleci from "../../circleci.app.mjs"; +import { axios } from "@pipedream/platform"; + +export default { + key: "circleci-add-comment", + name: "Add Comment", + description: "Adds a comment to an existing item. [See the documentation]().", + version: "0.0.{{ts}}", + type: "action", + props: { + circleci, + itemId: { + propDefinition: [ + circleci, + "itemId", + ], + }, + commentContent: { + propDefinition: [ + circleci, + "commentContent", + ], + }, + userDetails: { + propDefinition: [ + circleci, + "userDetails", + ], + optional: true, + }, + }, + async run({ $ }) { + const response = await this.circleci.addComment({ + itemId: this.itemId, + commentContent: this.commentContent, + userDetails: this.userDetails, + }); + $.export("$summary", `Added comment to item ${this.itemId}`); + return response; + }, +}; diff --git a/components/circleci/actions/create-item/create-item.mjs b/components/circleci/actions/create-item/create-item.mjs new file mode 100644 index 0000000000000..d969bf44c5c1b --- /dev/null +++ b/components/circleci/actions/create-item/create-item.mjs @@ -0,0 +1,41 @@ +import circleci from "../../circleci.app.mjs"; +import { axios } from "@pipedream/platform"; + +export default { + key: "circleci-create-item", + name: "Create Item", + description: "Creates a new item in the CircleCI app. [See the documentation]()", + version: "0.0.{{ts}}", + type: "action", + props: { + circleci, + title: { + propDefinition: [ + circleci, + "title", + ], + }, + content: { + propDefinition: [ + circleci, + "content", + ], + }, + metadata: { + propDefinition: [ + circleci, + "metadata", + ], + optional: true, + }, + }, + async run({ $ }) { + const response = await this.circleci.createItem({ + title: this.title, + content: this.content, + metadata: this.metadata, + }); + $.export("$summary", `Created item "${this.title}"`); + return response; + }, +}; diff --git a/components/circleci/actions/update-item/update-item.mjs b/components/circleci/actions/update-item/update-item.mjs new file mode 100644 index 0000000000000..771977e57b6dc --- /dev/null +++ b/components/circleci/actions/update-item/update-item.mjs @@ -0,0 +1,33 @@ +import circleci from "../../circleci.app.mjs"; + +export default { + key: "circleci-update-item", + name: "Update Item", + description: "Updates an existing item in CircleCI. [See the documentation]()", + version: "0.0.{{ts}}", + type: "action", + props: { + circleci, + itemId: { + propDefinition: [ + circleci, + "itemId", + ], + }, + updateFields: { + propDefinition: [ + circleci, + "updateFields", + ], + optional: false, + }, + }, + async run({ $ }) { + const response = await this.circleci.updateItem({ + itemId: this.itemId, + updateFields: this.updateFields, + }); + $.export("$summary", `Updated item with ID ${this.itemId}`); + return response; + }, +}; diff --git a/components/circleci/circleci.app.mjs b/components/circleci/circleci.app.mjs index 83f7f387fef73..a1ffed8413d29 100644 --- a/components/circleci/circleci.app.mjs +++ b/components/circleci/circleci.app.mjs @@ -1,11 +1,182 @@ +import { axios } from "@pipedream/platform"; + export default { type: "app", app: "circleci", - propDefinitions: {}, + version: "0.0.{{ts}}", + propDefinitions: { + // Required for creating a new item + title: { + type: "string", + label: "Title", + description: "The title of the item", + }, + content: { + type: "string", + label: "Content", + description: "The content of the item", + }, + // Required for updating an existing item + itemId: { + type: "string", + label: "Item ID", + description: "The unique identifier of the item", + }, + // Required for adding a comment + commentContent: { + type: "string", + label: "Comment Content", + description: "The content of the comment", + }, + // Optional for creating a new item + metadata: { + type: "string", + label: "Metadata", + description: "Optional metadata as JSON string", + optional: true, + }, + // Optional for updating an existing item + updateFields: { + type: "string", + label: "Fields to Update", + description: "Fields to update in the item as JSON string", + optional: true, + }, + // Optional for adding a comment to an existing item + userDetails: { + type: "string", + label: "User Details", + description: "Optional user details as JSON string", + optional: true, + }, + // Optional filters for new item creation events + newItemType: { + type: "string", + label: "Item Type", + description: "Filter by item type", + optional: true, + }, + newItemStatus: { + type: "string", + label: "Status", + description: "Filter by status", + optional: true, + }, + // Optional filters for item update events + updatedItemFields: { + type: "string", + label: "Updated Fields", + description: "Filter by updated fields", + optional: true, + }, + updatedItemType: { + type: "string", + label: "Item Type", + description: "Filter by item type", + optional: true, + }, + // Optional filters for comment addition events + commentItemType: { + type: "string", + label: "Item Type", + description: "Filter by item type", + optional: true, + }, + commentUser: { + type: "string", + label: "User", + description: "Filter by user", + optional: true, + }, + }, methods: { - // this.$auth contains connected account data + // This method logs the authentication keys authKeys() { console.log(Object.keys(this.$auth)); }, + // Base URL for CircleCI API + _baseUrl() { + return "https://circleci.com/api/v2"; + }, + // Common method to make API requests + async _makeRequest(opts = {}) { + const { + $ = this, method = "GET", path = "/", headers, ...otherOpts + } = opts; + return axios($, { + method, + url: this._baseUrl() + path, + headers: { + ...headers, + Authorization: `Bearer ${this.$auth.api_key}`, + }, + ...otherOpts, + }); + }, + // Create a new item + async createItem(opts = {}) { + const { + title, content, metadata, ...otherOpts + } = opts; + const data = { + title, + content, + }; + if (metadata) { + try { + data.metadata = JSON.parse(metadata); + } catch (error) { + throw new Error("Invalid JSON for metadata"); + } + } + return this._makeRequest({ + method: "POST", + path: "/items", + data, + ...otherOpts, + }); + }, + // Update an existing item + async updateItem(opts = {}) { + const { + itemId, updateFields, ...otherOpts + } = opts; + const data = {}; + if (updateFields) { + try { + Object.assign(data, JSON.parse(updateFields)); + } catch (error) { + throw new Error("Invalid JSON for updateFields"); + } + } + return this._makeRequest({ + method: "PUT", + path: `/items/${itemId}`, + data, + ...otherOpts, + }); + }, + // Add a comment to an existing item + async addComment(opts = {}) { + const { + itemId, commentContent, userDetails, ...otherOpts + } = opts; + const data = { + content: commentContent, + }; + if (userDetails) { + try { + data.user = JSON.parse(userDetails); + } catch (error) { + throw new Error("Invalid JSON for userDetails"); + } + } + return this._makeRequest({ + method: "POST", + path: `/items/${itemId}/comments`, + data, + ...otherOpts, + }); + }, }, }; diff --git a/components/circleci/sources/new-comment-instant/new-comment-instant.mjs b/components/circleci/sources/new-comment-instant/new-comment-instant.mjs new file mode 100644 index 0000000000000..de062b30fc6e5 --- /dev/null +++ b/components/circleci/sources/new-comment-instant/new-comment-instant.mjs @@ -0,0 +1,163 @@ +import circleci from "../../circleci.app.mjs"; +import { axios } from "@pipedream/platform"; +import crypto from "crypto"; + +export default { + key: "circleci-new-comment-instant", + name: "New Comment Added", + description: "Emit new event when a comment is added to an existing item. [See the documentation]()", + version: "0.0.{{ts}}", + type: "source", + dedupe: "unique", + props: { + circleci: { + type: "app", + app: "circleci", + }, + http: { + type: "$.interface.http", + customResponse: true, + }, + db: "$.service.db", + commentItemType: { + propDefinition: [ + "circleci", + "commentItemType", + ], + optional: true, + }, + commentUser: { + propDefinition: [ + "circleci", + "commentUser", + ], + optional: true, + }, + signingSecret: { + type: "string", + label: "Signing Secret", + description: "Secret used to validate webhook signatures", + secret: true, + }, + verifyTls: { + type: "boolean", + label: "Verify TLS", + description: "Whether to enforce TLS certificate verification", + default: true, + }, + webhookName: { + type: "string", + label: "Webhook Name", + description: "Name of the webhook", + }, + }, + methods: { + _getWebhookId() { + return this.db.get("webhookId"); + }, + _setWebhookId(id) { + this.db.set("webhookId", id); + }, + _verifySignature(payload, signature) { + const computedSignature = crypto.createHmac("sha256", this.signingSecret) + .update(JSON.stringify(payload)) + .digest("hex"); + return computedSignature === signature; + }, + _applyFilters(event) { + const { + commentItemType, commentUser, + } = this; + if (commentItemType && event.itemType !== commentItemType) { + return false; + } + if (commentUser && event.user !== commentUser) { + return false; + } + return true; + }, + }, + hooks: { + async deploy() { + const comments = await this.circleci.listComments({ + paginate: true, + max: 50, + }); + for (const comment of comments) { + this.$emit(comment, { + id: comment.id || comment.ts || Date.now().toString(), + summary: `New comment by ${comment.user} on item ${comment.itemId}`, + ts: comment.timestamp + ? Date.parse(comment.timestamp) + : Date.now(), + }); + } + }, + async activate() { + const webhook = await this.circleci.createWebhook({ + name: this.webhookName, + events: [ + "comment-added", + ], + url: this.http.endpoint, + verify_tls: this.verifyTls, + signing_secret: this.signingSecret, + }); + this._setWebhookId(webhook.id); + }, + async deactivate() { + const webhookId = this._getWebhookId(); + if (webhookId) { + await this.circleci.deleteWebhook({ + webhookId, + }); + this.db.delete("webhookId"); + } + }, + }, + async run(event) { + const signature = event.headers["X-CircleCI-Signature"]; + const payload = event.body; + + if (!this._verifySignature(payload, signature)) { + this.http.respond({ + status: 401, + body: "Unauthorized", + }); + return; + } + + if (payload.type !== "comment-added") { + this.http.respond({ + status: 200, + body: "OK", + }); + return; + } + + if (!this._applyFilters(payload)) { + this.http.respond({ + status: 200, + body: "OK", + }); + return; + } + + const id = payload.commentId || payload.id || Date.now().toString(); + const summary = `New comment added by ${payload.user} to ${payload.itemType}`; + const ts = payload.timestamp + ? Date.parse(payload.timestamp) + : Date.now(); + + this.$emit(payload, { + id, + summary, + ts, + }); + + this.http.respond({ + status: 200, + body: "OK", + }); + }, +}; diff --git a/components/circleci/sources/new-item-instant/new-item-instant.mjs b/components/circleci/sources/new-item-instant/new-item-instant.mjs new file mode 100644 index 0000000000000..0eeb119a946de --- /dev/null +++ b/components/circleci/sources/new-item-instant/new-item-instant.mjs @@ -0,0 +1,128 @@ +import circleci from "../../circleci.app.mjs"; +import crypto from "crypto"; +import { axios } from "@pipedream/platform"; + +export default { + key: "circleci-new-item-instant", + name: "New Item Instant", + description: "Emit new event when a new item is created in CircleCI. [See the documentation]()", + version: "0.0.{{ts}}", + type: "source", + dedupe: "unique", + props: { + circleci: { + type: "app", + app: "circleci", + }, + http: { + type: "$.interface.http", + customResponse: true, + }, + db: "$.service.db", + newItemType: { + propDefinition: [ + circleci, + "newItemType", + ], + optional: true, + }, + newItemStatus: { + propDefinition: [ + circleci, + "newItemStatus", + ], + optional: true, + }, + }, + hooks: { + async activate() { + const webhookUrl = this.http.endpoint; + const signingSecret = crypto.randomBytes(20).toString("hex"); + const events = [ + "item-created", + ]; + const data = { + name: "Pipedream Webhook", + events, + url: webhookUrl, + verify_tls: true, + signing_secret: signingSecret, + scope: {}, + }; + const response = await this.circleci._makeRequest({ + method: "POST", + path: "/outbound_webhooks", + data, + }); + const webhookId = response.id; + await this.db.set("webhookId", webhookId); + await this.db.set("signingSecret", signingSecret); + }, + async deactivate() { + const webhookId = await this.db.get("webhookId"); + if (webhookId) { + await this.circleci._makeRequest({ + method: "DELETE", + path: `/outbound_webhooks/${webhookId}`, + }); + await this.db.delete("webhookId"); + await this.db.delete("signingSecret"); + } + }, + async deploy() { + const params = { + limit: 50, + }; + if (this.newItemType) { + params.type = this.newItemType; + } + if (this.newItemStatus) { + params.status = this.newItemStatus; + } + const response = await this.circleci._makeRequest({ + method: "GET", + path: "/items", + params, + }); + const items = response.items; + if (items && items.length > 0) { + for (const item of items.reverse()) { + this.$emit(item, { + id: item.id || Date.now(), + summary: `New item: ${item.title}`, + ts: Date.parse(item.created_at) || Date.now(), + }); + } + } + }, + }, + async run(event) { + const signature = + event.headers["x-signature"] || event.headers["X-Signature"]; + const rawBody = event.body_raw || event.body; + const signingSecret = await this.db.get("signingSecret"); + const computedSignature = crypto + .createHmac("sha256", signingSecret) + .update(rawBody) + .digest("hex"); + if (computedSignature !== signature) { + await this.http.respond({ + status: 401, + body: "Unauthorized", + }); + return; + } + const data = event.body; + if (this.newItemType && data.type !== this.newItemType) { + return; + } + if (this.newItemStatus && data.status !== this.newItemStatus) { + return; + } + this.$emit(data, { + id: data.id || Date.now(), + summary: `New item: ${data.title}`, + ts: Date.parse(data.created_at) || Date.now(), + }); + }, +}; diff --git a/components/circleci/sources/updated-item-instant/updated-item-instant.mjs b/components/circleci/sources/updated-item-instant/updated-item-instant.mjs new file mode 100644 index 0000000000000..85b03c8ba14fb --- /dev/null +++ b/components/circleci/sources/updated-item-instant/updated-item-instant.mjs @@ -0,0 +1,206 @@ +import circleci from "../../circleci.app.mjs"; +import { axios } from "@pipedream/platform"; +import crypto from "crypto"; + +export default { + key: "circleci-updated-item-instant", + name: "Updated Item", + description: "Emit new event when an existing item is updated. [See the documentation]().", + version: "0.0.{{{{ts}}}}", + type: "source", + dedupe: "unique", + props: { + circleci: { + type: "app", + app: "circleci", + }, + http: { + type: "$.interface.http", + customResponse: true, + }, + db: "$.service.db", + updatedItemFields: { + propDefinition: [ + "circleci", + "updatedItemFields", + ], + optional: true, + }, + updatedItemType: { + propDefinition: [ + "circleci", + "updatedItemType", + ], + optional: true, + }, + }, + hooks: { + async activate() { + // Generate a signing secret + const signingSecret = crypto.randomBytes(32).toString("hex"); + + // Get the Pipedream HTTP endpoint URL + const url = this.http.endpoint; + + try { + // Create an outbound webhook in CircleCI + const webhook = await this.circleci._makeRequest({ + method: "POST", + path: "/outbound_webhooks", + data: { + name: "Pipedream Updated Item Webhook", + events: [ + "item-updated", + ], + url: url, + verify_tls: true, + signing_secret: signingSecret, + scope: {}, // Adjust scope as needed + }, + }); + + // Save the webhook ID and signing secret to the database + await this.db.set("webhookId", webhook.id); + await this.db.set("signingSecret", signingSecret); + } catch (error) { + console.error("Error creating webhook:", error); + throw new Error("Failed to create CircleCI webhook."); + } + }, + async deactivate() { + const webhookId = await this.db.get("webhookId"); + + if (webhookId) { + try { + // Delete the outbound webhook in CircleCI + await this.circleci._makeRequest({ + method: "DELETE", + path: `/outbound_webhooks/${webhookId}`, + }); + + // Remove webhook details from the database + await this.db.delete("webhookId"); + await this.db.delete("signingSecret"); + } catch (error) { + console.error("Error deleting webhook:", error); + throw new Error("Failed to delete CircleCI webhook."); + } + } + }, + async deploy() { + try { + // Fetch the last 50 updated items from CircleCI + const items = await this.circleci._makeRequest({ + method: "GET", + path: "/items?updated=true&limit=50", + }); + + // Emit each item event from oldest to newest + for (const item of items.reverse()) { + this.$emit(item, { + id: item.id || `${item.id}-${Date.now()}`, + summary: `Item updated: ${item.title}`, + ts: Date.parse(item.updated_at) || Date.now(), + }); + } + } catch (error) { + console.error("Error fetching updated items:", error); + throw new Error("Failed to fetch updated items from CircleCI."); + } + }, + }, + async run(event) { + const signature = event.headers["x-signature"] || event.headers["X-Signature"]; + const signingSecret = await this.db.get("signingSecret"); + + if (!signature || !signingSecret) { + await this.http.respond({ + status: 400, + body: "Missing signature or signing secret.", + }); + return; + } + + // Compute the HMAC SHA256 signature + const computedSignature = crypto + .createHmac("sha256", signingSecret) + .update(event.body) + .digest("hex"); + + // Compare the computed signature with the received signature + if (computedSignature !== signature) { + await this.http.respond({ + status: 401, + body: "Unauthorized", + }); + return; + } + + let data; + try { + data = JSON.parse(event.body); + } catch (error) { + console.error("Error parsing JSON:", error); + await this.http.respond({ + status: 400, + body: "Invalid JSON format.", + }); + return; + } + + // Verify that the event is an item update + if (data.event !== "item-updated") { + await this.http.respond({ + status: 200, + body: "Event not relevant.", + }); + return; + } + + // Apply optional filters for updated fields + if (this.updatedItemFields) { + let updatedFields; + try { + updatedFields = JSON.parse(this.updatedItemFields); + } catch (error) { + console.error("Error parsing updatedItemFields:", error); + await this.http.respond({ + status: 400, + body: "Invalid updatedItemFields format.", + }); + return; + } + + const intersection = updatedFields.filter((field) => data.updated_fields.includes(field)); + if (intersection.length === 0) { + await this.http.respond({ + status: 200, + body: "No matching updated fields.", + }); + return; + } + } + + // Apply optional filter for item type + if (this.updatedItemType && data.item_type !== this.updatedItemType) { + await this.http.respond({ + status: 200, + body: "Item type does not match filter.", + }); + return; + } + + // Emit the event + this.$emit(data, { + id: data.item_id || `${data.item_id}-${Date.now()}`, + summary: `Item updated: ${data.title}`, + ts: Date.parse(data.updated_at) || Date.now(), + }); + + // Respond to acknowledge receipt + await this.http.respond({ + status: 200, + body: "OK", + }); + }, +}; From c89498ab4a2431e04ed62840760745db57962111 Mon Sep 17 00:00:00 2001 From: michelle0927 Date: Wed, 18 Dec 2024 13:57:42 -0500 Subject: [PATCH 2/5] new components --- .../actions/add-comment/add-comment.mjs | 41 --- .../actions/create-item/create-item.mjs | 41 --- .../get-job-artifacts/get-job-artifacts.mjs | 54 +++ .../actions/rerun-workflow/rerun-workflow.mjs | 79 +++++ .../trigger-pipeline/trigger-pipeline.mjs | 82 +++++ .../actions/update-item/update-item.mjs | 33 -- components/circleci/circleci.app.mjs | 315 ++++++++++-------- components/circleci/package.json | 19 ++ components/circleci/sources/common/base.mjs | 116 +++++++ .../new-comment-instant.mjs | 163 --------- .../new-item-instant/new-item-instant.mjs | 128 ------- .../new-job-completed-instant.mjs | 24 ++ .../new-job-completed-instant/test-event.mjs | 82 +++++ .../new-workflow-completed-instant.mjs | 24 ++ .../test-event.mjs | 75 +++++ .../updated-item-instant.mjs | 206 ------------ 16 files changed, 727 insertions(+), 755 deletions(-) delete mode 100644 components/circleci/actions/add-comment/add-comment.mjs delete mode 100644 components/circleci/actions/create-item/create-item.mjs create mode 100644 components/circleci/actions/get-job-artifacts/get-job-artifacts.mjs create mode 100644 components/circleci/actions/rerun-workflow/rerun-workflow.mjs create mode 100644 components/circleci/actions/trigger-pipeline/trigger-pipeline.mjs delete mode 100644 components/circleci/actions/update-item/update-item.mjs create mode 100644 components/circleci/package.json create mode 100644 components/circleci/sources/common/base.mjs delete mode 100644 components/circleci/sources/new-comment-instant/new-comment-instant.mjs delete mode 100644 components/circleci/sources/new-item-instant/new-item-instant.mjs create mode 100644 components/circleci/sources/new-job-completed-instant/new-job-completed-instant.mjs create mode 100644 components/circleci/sources/new-job-completed-instant/test-event.mjs create mode 100644 components/circleci/sources/new-workflow-completed-instant/new-workflow-completed-instant.mjs create mode 100644 components/circleci/sources/new-workflow-completed-instant/test-event.mjs delete mode 100644 components/circleci/sources/updated-item-instant/updated-item-instant.mjs diff --git a/components/circleci/actions/add-comment/add-comment.mjs b/components/circleci/actions/add-comment/add-comment.mjs deleted file mode 100644 index ba042362be5d0..0000000000000 --- a/components/circleci/actions/add-comment/add-comment.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import circleci from "../../circleci.app.mjs"; -import { axios } from "@pipedream/platform"; - -export default { - key: "circleci-add-comment", - name: "Add Comment", - description: "Adds a comment to an existing item. [See the documentation]().", - version: "0.0.{{ts}}", - type: "action", - props: { - circleci, - itemId: { - propDefinition: [ - circleci, - "itemId", - ], - }, - commentContent: { - propDefinition: [ - circleci, - "commentContent", - ], - }, - userDetails: { - propDefinition: [ - circleci, - "userDetails", - ], - optional: true, - }, - }, - async run({ $ }) { - const response = await this.circleci.addComment({ - itemId: this.itemId, - commentContent: this.commentContent, - userDetails: this.userDetails, - }); - $.export("$summary", `Added comment to item ${this.itemId}`); - return response; - }, -}; diff --git a/components/circleci/actions/create-item/create-item.mjs b/components/circleci/actions/create-item/create-item.mjs deleted file mode 100644 index d969bf44c5c1b..0000000000000 --- a/components/circleci/actions/create-item/create-item.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import circleci from "../../circleci.app.mjs"; -import { axios } from "@pipedream/platform"; - -export default { - key: "circleci-create-item", - name: "Create Item", - description: "Creates a new item in the CircleCI app. [See the documentation]()", - version: "0.0.{{ts}}", - type: "action", - props: { - circleci, - title: { - propDefinition: [ - circleci, - "title", - ], - }, - content: { - propDefinition: [ - circleci, - "content", - ], - }, - metadata: { - propDefinition: [ - circleci, - "metadata", - ], - optional: true, - }, - }, - async run({ $ }) { - const response = await this.circleci.createItem({ - title: this.title, - content: this.content, - metadata: this.metadata, - }); - $.export("$summary", `Created item "${this.title}"`); - return response; - }, -}; diff --git a/components/circleci/actions/get-job-artifacts/get-job-artifacts.mjs b/components/circleci/actions/get-job-artifacts/get-job-artifacts.mjs new file mode 100644 index 0000000000000..434cba3175dd3 --- /dev/null +++ b/components/circleci/actions/get-job-artifacts/get-job-artifacts.mjs @@ -0,0 +1,54 @@ +import circleci from "../../circleci.app.mjs"; + +export default { + key: "circleci-get-job-artifacts", + name: "Get Job Artifacts", + description: "Retrieves a job's artifacts. [See the documentation](https://circleci.com/docs/api/v2/index.html#tag/Job/operation/getJobArtifacts).", + version: "0.0.1", + type: "action", + props: { + circleci, + projectSlug: { + propDefinition: [ + circleci, + "projectSlug", + ], + }, + pipelineId: { + propDefinition: [ + circleci, + "pipelineId", + (c) => ({ + projectSlug: c.projectSlug, + }), + ], + }, + workflowId: { + propDefinition: [ + circleci, + "workflowId", + (c) => ({ + pipelineId: c.pipelineId, + }), + ], + }, + jobNumber: { + propDefinition: [ + circleci, + "jobNumber", + (c) => ({ + workflowId: c.workflowId, + }), + ], + }, + }, + async run({ $ }) { + const response = await this.circleci.getJobArtifacts({ + $, + projectSlug: this.projectSlug, + jobNumber: this.jobNumber, + }); + $.export("$summary", `Successfully retrieved artifacts for job number: ${this.jobNumber}`); + return response; + }, +}; diff --git a/components/circleci/actions/rerun-workflow/rerun-workflow.mjs b/components/circleci/actions/rerun-workflow/rerun-workflow.mjs new file mode 100644 index 0000000000000..9f2c4d59dc6d1 --- /dev/null +++ b/components/circleci/actions/rerun-workflow/rerun-workflow.mjs @@ -0,0 +1,79 @@ +import circleci from "../../circleci.app.mjs"; + +export default { + key: "circleci-rerun-workflow", + name: "Rerun Workflow", + description: "Reruns the specified workflow. [See the documentation](https://circleci.com/docs/api/v2/index.html#tag/Workflow/operation/rerunWorkflow)", + version: "0.0.1", + type: "action", + props: { + circleci, + projectSlug: { + propDefinition: [ + circleci, + "projectSlug", + ], + }, + pipelineId: { + propDefinition: [ + circleci, + "pipelineId", + (c) => ({ + projectSlug: c.projectSlug, + }), + ], + }, + workflowId: { + propDefinition: [ + circleci, + "workflowId", + (c) => ({ + pipelineId: c.pipelineId, + }), + ], + }, + enableSsh: { + type: "boolean", + label: "Enable SSH", + description: "Whether to enable SSH access for the triggering user on the newly-rerun job. Requires the jobs parameter to be used and so is mutually exclusive with the from_failed parameter.", + optional: true, + }, + fromFailed: { + type: "boolean", + label: "From Failed", + description: "Whether to rerun the workflow from the failed job. Mutually exclusive with the jobs parameter.", + optional: true, + }, + jobIds: { + propDefinition: [ + circleci, + "jobIds", + (c) => ({ + workflowId: c.workflowId, + }), + ], + }, + sparseTree: { + type: "boolean", + label: "Sparse Tree", + description: "", + optional: true, + }, + }, + async run({ $ }) { + const response = await this.circleci.rerunWorkflow({ + $, + workflowId: this.workflowId, + data: { + enable_ssh: this.enableSsh, + from_failed: this.from_failed, + jobs: typeof this.jobIds === "string" + ? JSON.parse(this.jobIds) + : this.jobIds, + sparse_tree: this.sparseTree, + }, + }); + $.export("$summary", `Successfully started a rerun of workflow with ID: ${this.workflowId}`); + return response; + }, +}; diff --git a/components/circleci/actions/trigger-pipeline/trigger-pipeline.mjs b/components/circleci/actions/trigger-pipeline/trigger-pipeline.mjs new file mode 100644 index 0000000000000..f3ee384c92c2c --- /dev/null +++ b/components/circleci/actions/trigger-pipeline/trigger-pipeline.mjs @@ -0,0 +1,82 @@ +import circleci from "../../circleci.app.mjs"; + +export default { + key: "circleci-trigger-pipeline", + name: "Trigger a Pipeline", + description: "Trigger a pipeline given a pipeline definition ID. Supports all integrations except GitLab. [See the documentation](https://circleci.com/docs/api/v2/index.html#tag/Pipeline/operation/triggerPipelineRun)", + version: "0.0.1", + type: "action", + props: { + circleci, + projectSlug: { + propDefinition: [ + circleci, + "projectSlug", + ], + }, + definitionId: { + type: "string", + label: "Definition ID", + description: "The unique ID for the pipeline definition. This can be found in the page Project Settings > Pipelines.", + }, + configBranch: { + type: "string", + label: "Config Branch", + description: "The branch that should be used to fetch the config file. Note that branch and tag are mutually exclusive. To trigger a pipeline for a PR by number use pull//head for the PR ref or pull//merge for the merge ref (GitHub only)", + optional: true, + }, + configTag: { + type: "string", + label: "Config Tag", + description: "The tag that should be used to fetch the config file. The commit that this tag points to is used for the pipeline. Note that branch and tag are mutually exclusive.", + optional: true, + }, + checkoutBranch: { + type: "string", + label: "Checkout Branch", + description: "The branch that should be used to check out code on a checkout step. Note that branch and tag are mutually exclusive. To trigger a pipeline for a PR by number use pull//head for the PR ref or pull//merge for the merge ref (GitHub only)", + optional: true, + }, + checkoutTag: { + type: "string", + label: "Checkout Tag", + description: "The tag that should be used to check out code on a checkout step. The commit that this tag points to is used for the pipeline. Note that branch and tag are mutually exclusive.", + optional: true, + }, + parameters: { + type: "object", + label: "Parameters", + description: "An object containing pipeline parameters and their values. Pipeline parameters have the following size limits: 100 max entries, 128 maximum key length, 512 maximum value length.", + optional: true, + }, + }, + async run({ $ }) { + const response = await this.circleci.triggerPipeline({ + $, + projectSlug: this.projectSlug, + data: { + definition_id: this.definitionId, + config: this.configBranch || this.configTag + ? { + branch: this.configBranch, + tag: this.configTag, + } + : undefined, + checkout: this.checkoutBranch || this.checkoutTag + ? { + branch: this.checkoutBranch, + tag: this.checkoutTag, + } + : undefined, + parameters: typeof this.parameters === "string" + ? JSON.parse(this.parameters) + : this.parameters, + }, + }); + + if (response?.id) { + $.export("$summary", `Successfully triggered pipeline with ID: ${response.id}`); + } + return response; + }, +}; diff --git a/components/circleci/actions/update-item/update-item.mjs b/components/circleci/actions/update-item/update-item.mjs deleted file mode 100644 index 771977e57b6dc..0000000000000 --- a/components/circleci/actions/update-item/update-item.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import circleci from "../../circleci.app.mjs"; - -export default { - key: "circleci-update-item", - name: "Update Item", - description: "Updates an existing item in CircleCI. [See the documentation]()", - version: "0.0.{{ts}}", - type: "action", - props: { - circleci, - itemId: { - propDefinition: [ - circleci, - "itemId", - ], - }, - updateFields: { - propDefinition: [ - circleci, - "updateFields", - ], - optional: false, - }, - }, - async run({ $ }) { - const response = await this.circleci.updateItem({ - itemId: this.itemId, - updateFields: this.updateFields, - }); - $.export("$summary", `Updated item with ID ${this.itemId}`); - return response; - }, -}; diff --git a/components/circleci/circleci.app.mjs b/components/circleci/circleci.app.mjs index a1ffed8413d29..b44cefa1746ad 100644 --- a/components/circleci/circleci.app.mjs +++ b/components/circleci/circleci.app.mjs @@ -3,179 +3,208 @@ import { axios } from "@pipedream/platform"; export default { type: "app", app: "circleci", - version: "0.0.{{ts}}", propDefinitions: { - // Required for creating a new item - title: { + pipelineId: { type: "string", - label: "Title", - description: "The title of the item", - }, - content: { - type: "string", - label: "Content", - description: "The content of the item", - }, - // Required for updating an existing item - itemId: { - type: "string", - label: "Item ID", - description: "The unique identifier of the item", - }, - // Required for adding a comment - commentContent: { - type: "string", - label: "Comment Content", - description: "The content of the comment", - }, - // Optional for creating a new item - metadata: { - type: "string", - label: "Metadata", - description: "Optional metadata as JSON string", - optional: true, - }, - // Optional for updating an existing item - updateFields: { - type: "string", - label: "Fields to Update", - description: "Fields to update in the item as JSON string", - optional: true, - }, - // Optional for adding a comment to an existing item - userDetails: { - type: "string", - label: "User Details", - description: "Optional user details as JSON string", - optional: true, - }, - // Optional filters for new item creation events - newItemType: { - type: "string", - label: "Item Type", - description: "Filter by item type", - optional: true, - }, - newItemStatus: { - type: "string", - label: "Status", - description: "Filter by status", - optional: true, - }, - // Optional filters for item update events - updatedItemFields: { + label: "Pipeline ID", + description: "The identifier of a pipeline", + async options({ + projectSlug, prevContext, + }) { + const { + items, nextPageToken, + } = await this.listPipelines({ + projectSlug, + params: prevContext?.next + ? { + "page-token": prevContext.next, + } + : {}, + }); + return { + options: items?.map(({ id }) => id) || [], + context: { + next: nextPageToken, + }, + }; + }, + }, + workflowId: { type: "string", - label: "Updated Fields", - description: "Filter by updated fields", - optional: true, - }, - updatedItemType: { + label: "Workflow ID", + description: "The identifier of a workflow", + async options({ + pipelineId, prevContext, + }) { + const { + items, nextPageToken, + } = await this.listPipelineWorkflows({ + pipelineId, + params: prevContext?.next + ? { + "page-token": prevContext.next, + } + : {}, + }); + return { + options: items?.map(({ + id: value, name: label, + }) => ({ + value, + label, + })) || [], + context: { + next: nextPageToken, + }, + }; + }, + }, + jobNumber: { type: "string", - label: "Item Type", - description: "Filter by item type", + label: "Job Number", + description: "The job number of a job", + async options({ + workflowId, prevContext, + }) { + const { + items, nextPageToken, + } = await this.listWorkflowJobs({ + workflowId, + params: prevContext?.next + ? { + "page-token": prevContext.next, + } + : {}, + }); + return { + options: items?.map(({ + job_number: value, name: label, + }) => ({ + value, + label, + })) || [], + context: { + next: nextPageToken, + }, + }; + }, + }, + jobIds: { + type: "string[]", + label: "Jobs", + description: "A list of job IDs to rerun", optional: true, - }, - // Optional filters for comment addition events - commentItemType: { + async options({ + workflowId, prevContext, + }) { + const { + items, nextPageToken, + } = await this.listWorkflowJobs({ + workflowId, + params: prevContext?.next + ? { + "page-token": prevContext.next, + } + : {}, + }); + return { + options: items?.map(({ + id: value, name: label, + }) => ({ + value, + label, + })) || [], + context: { + next: nextPageToken, + }, + }; + }, + }, + projectSlug: { type: "string", - label: "Item Type", - description: "Filter by item type", - optional: true, - }, - commentUser: { - type: "string", - label: "User", - description: "Filter by user", - optional: true, + label: "Project Slug", + description: "Project slug in the form `vcs-slug/org-name/repo-name` (found in Project Settings)", }, }, methods: { - // This method logs the authentication keys - authKeys() { - console.log(Object.keys(this.$auth)); - }, - // Base URL for CircleCI API _baseUrl() { return "https://circleci.com/api/v2"; }, - // Common method to make API requests async _makeRequest(opts = {}) { const { - $ = this, method = "GET", path = "/", headers, ...otherOpts + $ = this, + path, + ...otherOpts } = opts; return axios($, { - method, - url: this._baseUrl() + path, + url: `${this._baseUrl()}${path}`, headers: { - ...headers, - Authorization: `Bearer ${this.$auth.api_key}`, + "Circle-Token": this.$auth.token, }, ...otherOpts, }); }, - // Create a new item - async createItem(opts = {}) { - const { - title, content, metadata, ...otherOpts - } = opts; - const data = { - title, - content, - }; - if (metadata) { - try { - data.metadata = JSON.parse(metadata); - } catch (error) { - throw new Error("Invalid JSON for metadata"); - } - } + createWebhook(opts = {}) { return this._makeRequest({ method: "POST", - path: "/items", - data, - ...otherOpts, + path: "/webhook", + ...opts, }); }, - // Update an existing item - async updateItem(opts = {}) { - const { - itemId, updateFields, ...otherOpts - } = opts; - const data = {}; - if (updateFields) { - try { - Object.assign(data, JSON.parse(updateFields)); - } catch (error) { - throw new Error("Invalid JSON for updateFields"); - } - } + deleteWebhook(webhookId) { return this._makeRequest({ - method: "PUT", - path: `/items/${itemId}`, - data, - ...otherOpts, + method: "DELETE", + path: `/webhook/${webhookId}`, }); }, - // Add a comment to an existing item - async addComment(opts = {}) { - const { - itemId, commentContent, userDetails, ...otherOpts - } = opts; - const data = { - content: commentContent, - }; - if (userDetails) { - try { - data.user = JSON.parse(userDetails); - } catch (error) { - throw new Error("Invalid JSON for userDetails"); - } - } + listPipelines({ + projectSlug, ...opts + }) { + return this._makeRequest({ + path: `/project/${projectSlug}/pipeline/mine`, + ...opts, + }); + }, + listPipelineWorkflows({ + pipelineId, ...opts + }) { + return this._makeRequest({ + path: `/pipeline/${pipelineId}/workflow`, + ...opts, + }); + }, + listWorkflowJobs({ + workflowId, ...opts + }) { + return this._makeRequest({ + path: `/workflow/${workflowId}/job`, + ...opts, + }); + }, + triggerPipeline({ + projectSlug, ...opts + }) { return this._makeRequest({ method: "POST", - path: `/items/${itemId}/comments`, - data, - ...otherOpts, + path: `/project/${projectSlug}/pipeline/run`, + ...opts, + }); + }, + getJobArtifacts({ + projectSlug, jobNumber, ...opts + }) { + return this._makeRequest({ + path: `/project/${projectSlug}/${jobNumber}/artifacts`, + ...opts, + }); + }, + rerunWorkflow({ + workflowId, ...opts + }) { + return this._makeRequest({ + method: "POST", + path: `/workflow/${workflowId}/rerun`, + ...opts, }); }, }, diff --git a/components/circleci/package.json b/components/circleci/package.json new file mode 100644 index 0000000000000..7066275ff6b20 --- /dev/null +++ b/components/circleci/package.json @@ -0,0 +1,19 @@ +{ + "name": "@pipedream/circleci", + "version": "0.0.1", + "description": "Pipedream CircleCI Components", + "main": "circleci.app.mjs", + "keywords": [ + "pipedream", + "circleci" + ], + "homepage": "https://pipedream.com/apps/circleci", + "author": "Pipedream Msupport@pipedream.com> (https://pipedream.com/)", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@pipedream/platform": "^3.0.3", + "uuid": "^11.0.3" + } +} diff --git a/components/circleci/sources/common/base.mjs b/components/circleci/sources/common/base.mjs new file mode 100644 index 0000000000000..406c256f58f52 --- /dev/null +++ b/components/circleci/sources/common/base.mjs @@ -0,0 +1,116 @@ +import circleci from "../../circleci.app.mjs"; +import { v4 as uuidv4 } from "uuid"; +import crypto from "crypto"; + +export default { + props: { + circleci, + db: "$.service.db", + http: { + type: "$.interface.http", + customResponse: true, + }, + name: { + type: "string", + label: "Webhook Name", + description: "Name of the webhook", + }, + projectId: { + type: "string", + label: "Project ID", + description: "The identifier of a project. Can be found in Project Settings -> Overview", + }, + }, + hooks: { + async activate() { + const secret = uuidv4(); + const { id } = await this.circleci.createWebhook({ + data: { + "name": this.name, + "events": this.getEvents(), + "url": this.http.endpoint, + "verify-tls": true, + "signing-secret": secret, + "scope": { + "id": this.projectId, + "type": "project", + }, + }, + }); + this._setHookId(id); + this._setSigningSecret(secret); + }, + async deactivate() { + const webhookId = this._getHookId(); + if (webhookId) { + await this.circleci.deleteWebhook(webhookId); + } + }, + }, + methods: { + _getHookId() { + return this.db.get("hookId"); + }, + _setHookId(hookId) { + this.db.set("hookId", hookId); + }, + _getSigningSecret() { + return this.db.get("signingSecret"); + }, + _setSigningSecret(secret) { + this.db.set("signingSecret", secret); + }, + verifySignature({ + headers, body, + }) { + const secret = this._getSigningSecret(); + const signatureFromHeader = headers["circleci-signature"] + .split(",") + .reduce((acc, pair) => { + const [ + key, + value, + ] = pair.split("="); + acc[key] = value; + return acc; + }, {}).v1; + + const validSignature = crypto + .createHmac("sha256", secret) + .update(JSON.stringify(body), "utf8") + .digest("hex"); + + return crypto.timingSafeEqual( + Buffer.from(validSignature, "hex"), + Buffer.from(signatureFromHeader, "hex"), + ); + }, + generateMeta(event) { + return { + id: event.id, + summary: this.getSummary(event), + ts: Date.parse(event.happened_at), + }; + }, + getEvents() { + throw new Error("getEvents is not implemented"); + }, + getSummary() { + throw new Error("getSummary is not implemented"); + }, + }, + async run(event) { + this.http.respond({ + status: 200, + }); + + if (!this.verifySignature(event)) { + console.log("Invalid webhook signature"); + return; + } + + const { body } = event; + const meta = this.generateMeta(body); + this.$emit(body, meta); + }, +}; diff --git a/components/circleci/sources/new-comment-instant/new-comment-instant.mjs b/components/circleci/sources/new-comment-instant/new-comment-instant.mjs deleted file mode 100644 index de062b30fc6e5..0000000000000 --- a/components/circleci/sources/new-comment-instant/new-comment-instant.mjs +++ /dev/null @@ -1,163 +0,0 @@ -import circleci from "../../circleci.app.mjs"; -import { axios } from "@pipedream/platform"; -import crypto from "crypto"; - -export default { - key: "circleci-new-comment-instant", - name: "New Comment Added", - description: "Emit new event when a comment is added to an existing item. [See the documentation]()", - version: "0.0.{{ts}}", - type: "source", - dedupe: "unique", - props: { - circleci: { - type: "app", - app: "circleci", - }, - http: { - type: "$.interface.http", - customResponse: true, - }, - db: "$.service.db", - commentItemType: { - propDefinition: [ - "circleci", - "commentItemType", - ], - optional: true, - }, - commentUser: { - propDefinition: [ - "circleci", - "commentUser", - ], - optional: true, - }, - signingSecret: { - type: "string", - label: "Signing Secret", - description: "Secret used to validate webhook signatures", - secret: true, - }, - verifyTls: { - type: "boolean", - label: "Verify TLS", - description: "Whether to enforce TLS certificate verification", - default: true, - }, - webhookName: { - type: "string", - label: "Webhook Name", - description: "Name of the webhook", - }, - }, - methods: { - _getWebhookId() { - return this.db.get("webhookId"); - }, - _setWebhookId(id) { - this.db.set("webhookId", id); - }, - _verifySignature(payload, signature) { - const computedSignature = crypto.createHmac("sha256", this.signingSecret) - .update(JSON.stringify(payload)) - .digest("hex"); - return computedSignature === signature; - }, - _applyFilters(event) { - const { - commentItemType, commentUser, - } = this; - if (commentItemType && event.itemType !== commentItemType) { - return false; - } - if (commentUser && event.user !== commentUser) { - return false; - } - return true; - }, - }, - hooks: { - async deploy() { - const comments = await this.circleci.listComments({ - paginate: true, - max: 50, - }); - for (const comment of comments) { - this.$emit(comment, { - id: comment.id || comment.ts || Date.now().toString(), - summary: `New comment by ${comment.user} on item ${comment.itemId}`, - ts: comment.timestamp - ? Date.parse(comment.timestamp) - : Date.now(), - }); - } - }, - async activate() { - const webhook = await this.circleci.createWebhook({ - name: this.webhookName, - events: [ - "comment-added", - ], - url: this.http.endpoint, - verify_tls: this.verifyTls, - signing_secret: this.signingSecret, - }); - this._setWebhookId(webhook.id); - }, - async deactivate() { - const webhookId = this._getWebhookId(); - if (webhookId) { - await this.circleci.deleteWebhook({ - webhookId, - }); - this.db.delete("webhookId"); - } - }, - }, - async run(event) { - const signature = event.headers["X-CircleCI-Signature"]; - const payload = event.body; - - if (!this._verifySignature(payload, signature)) { - this.http.respond({ - status: 401, - body: "Unauthorized", - }); - return; - } - - if (payload.type !== "comment-added") { - this.http.respond({ - status: 200, - body: "OK", - }); - return; - } - - if (!this._applyFilters(payload)) { - this.http.respond({ - status: 200, - body: "OK", - }); - return; - } - - const id = payload.commentId || payload.id || Date.now().toString(); - const summary = `New comment added by ${payload.user} to ${payload.itemType}`; - const ts = payload.timestamp - ? Date.parse(payload.timestamp) - : Date.now(); - - this.$emit(payload, { - id, - summary, - ts, - }); - - this.http.respond({ - status: 200, - body: "OK", - }); - }, -}; diff --git a/components/circleci/sources/new-item-instant/new-item-instant.mjs b/components/circleci/sources/new-item-instant/new-item-instant.mjs deleted file mode 100644 index 0eeb119a946de..0000000000000 --- a/components/circleci/sources/new-item-instant/new-item-instant.mjs +++ /dev/null @@ -1,128 +0,0 @@ -import circleci from "../../circleci.app.mjs"; -import crypto from "crypto"; -import { axios } from "@pipedream/platform"; - -export default { - key: "circleci-new-item-instant", - name: "New Item Instant", - description: "Emit new event when a new item is created in CircleCI. [See the documentation]()", - version: "0.0.{{ts}}", - type: "source", - dedupe: "unique", - props: { - circleci: { - type: "app", - app: "circleci", - }, - http: { - type: "$.interface.http", - customResponse: true, - }, - db: "$.service.db", - newItemType: { - propDefinition: [ - circleci, - "newItemType", - ], - optional: true, - }, - newItemStatus: { - propDefinition: [ - circleci, - "newItemStatus", - ], - optional: true, - }, - }, - hooks: { - async activate() { - const webhookUrl = this.http.endpoint; - const signingSecret = crypto.randomBytes(20).toString("hex"); - const events = [ - "item-created", - ]; - const data = { - name: "Pipedream Webhook", - events, - url: webhookUrl, - verify_tls: true, - signing_secret: signingSecret, - scope: {}, - }; - const response = await this.circleci._makeRequest({ - method: "POST", - path: "/outbound_webhooks", - data, - }); - const webhookId = response.id; - await this.db.set("webhookId", webhookId); - await this.db.set("signingSecret", signingSecret); - }, - async deactivate() { - const webhookId = await this.db.get("webhookId"); - if (webhookId) { - await this.circleci._makeRequest({ - method: "DELETE", - path: `/outbound_webhooks/${webhookId}`, - }); - await this.db.delete("webhookId"); - await this.db.delete("signingSecret"); - } - }, - async deploy() { - const params = { - limit: 50, - }; - if (this.newItemType) { - params.type = this.newItemType; - } - if (this.newItemStatus) { - params.status = this.newItemStatus; - } - const response = await this.circleci._makeRequest({ - method: "GET", - path: "/items", - params, - }); - const items = response.items; - if (items && items.length > 0) { - for (const item of items.reverse()) { - this.$emit(item, { - id: item.id || Date.now(), - summary: `New item: ${item.title}`, - ts: Date.parse(item.created_at) || Date.now(), - }); - } - } - }, - }, - async run(event) { - const signature = - event.headers["x-signature"] || event.headers["X-Signature"]; - const rawBody = event.body_raw || event.body; - const signingSecret = await this.db.get("signingSecret"); - const computedSignature = crypto - .createHmac("sha256", signingSecret) - .update(rawBody) - .digest("hex"); - if (computedSignature !== signature) { - await this.http.respond({ - status: 401, - body: "Unauthorized", - }); - return; - } - const data = event.body; - if (this.newItemType && data.type !== this.newItemType) { - return; - } - if (this.newItemStatus && data.status !== this.newItemStatus) { - return; - } - this.$emit(data, { - id: data.id || Date.now(), - summary: `New item: ${data.title}`, - ts: Date.parse(data.created_at) || Date.now(), - }); - }, -}; diff --git a/components/circleci/sources/new-job-completed-instant/new-job-completed-instant.mjs b/components/circleci/sources/new-job-completed-instant/new-job-completed-instant.mjs new file mode 100644 index 0000000000000..cd907c4b068c4 --- /dev/null +++ b/components/circleci/sources/new-job-completed-instant/new-job-completed-instant.mjs @@ -0,0 +1,24 @@ +import common from "../common/base.mjs"; +import sampleEmit from "./test-event.mjs"; + +export default { + ...common, + key: "circleci-new-job-completed-instant", + name: "New Job Completed (Instant)", + description: "Emit new event when a job is completed in CircleCI.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getEvents() { + return [ + "job-completed", + ]; + }, + getSummary(event) { + return `Job Completed: ${event.job.name}`; + }, + }, + sampleEmit, +}; diff --git a/components/circleci/sources/new-job-completed-instant/test-event.mjs b/components/circleci/sources/new-job-completed-instant/test-event.mjs new file mode 100644 index 0000000000000..46d16fff09a71 --- /dev/null +++ b/components/circleci/sources/new-job-completed-instant/test-event.mjs @@ -0,0 +1,82 @@ +export default { + "happened_at": "2024-12-18T18:51:31.379158Z", + "pipeline": { + "id": "5c8229b0-194a-4942-bddd-2b6a40c5ab35", + "number": 5, + "created_at": "2024-12-18T17:23:29.629Z", + "trigger": { + "type": "api" + }, + "trigger_parameters": { + "github_app": { + "commit_author_name": "", + "owner": "", + "full_ref": "refs/heads/circleci-project-setup", + "forced": "false", + "user_username": "", + "branch": "circleci-project-setup", + "commit_title": "CircleCI Commit", + "repo_url": "", + "ref": "circleci-project-setup", + "repo_name": "", + "commit_author_email": "", + "commit_sha": "9e2e4bed59d2e6c51d00e0e0b49f1b79ff146ab1" + }, + "git": { + "commit_author_name": "", + "repo_owner": "", + "branch": "circleci-project-setup", + "commit_message": "CircleCI Commit", + "repo_url": "", + "ref": "refs/heads/circleci-project-setup", + "author_avatar_url": "", + "checkout_url": "", + "author_login": "", + "repo_name": "", + "commit_author_email": "", + "checkout_sha": "9e2e4bed59d2e6c51d00e0e0b49f1b79ff146ab1", + "default_branch": "master" + }, + "circleci": { + "event_time": "2024-12-18 17:23:29.361740256 +0000 UTC", + "provider_actor_id": "864c50f3-d0b6-4929-a524-036d3cd4e23f", + "actor_id": "864c50f3-d0b6-4929-a524-036d3cd4e23f", + "event_type": "create pipeline run api", + "trigger_type": "github_app" + }, + "webhook": { + "body": "{}" + } + } + }, + "webhook": { + "id": "6973c364-58bc-43f4-9074-1f3ea720fb6c", + "name": "" + }, + "type": "job-completed", + "organization": { + "id": "48c30eda-389d-48a8-ac64-7091f80c69df", + "name": "" + }, + "workflow": { + "id": "c5aa9edf-4de9-43fd-b50e-2bfd75e32cb1", + "name": "say-hello-workflow", + "created_at": "2024-12-18T18:51:06.137Z", + "stopped_at": "2024-12-18T18:51:31.300Z", + "url": "https://app.circleci.com/pipelines/circleci/9z8PamRuKaKWrW91o7Kqvn/AHWysFUuUfBgs6VeUqm6tg/5/workflows/c5aa9edf-4de9-43fd-b50e-2bfd75e32cb1" + }, + "project": { + "id": "4b309fd6-d103-401a-bee5-1de19651d969", + "name": "", + "slug": "circleci/9z8PamRuKaKWrW91o7Kqvn/AHWysFUuUfBgs6VeUqm6tg" + }, + "id": "1c457f7f-5b15-341b-9b86-6370d1357f61", + "job": { + "status": "success", + "id": "00f06c85-7476-4a75-ba64-7daa608dc61e", + "name": "say-hello", + "number": 12, + "started_at": "2024-12-18T18:51:09.357Z", + "stopped_at": "2024-12-18T18:51:31.300Z" + } +} \ No newline at end of file diff --git a/components/circleci/sources/new-workflow-completed-instant/new-workflow-completed-instant.mjs b/components/circleci/sources/new-workflow-completed-instant/new-workflow-completed-instant.mjs new file mode 100644 index 0000000000000..f4296e6964d06 --- /dev/null +++ b/components/circleci/sources/new-workflow-completed-instant/new-workflow-completed-instant.mjs @@ -0,0 +1,24 @@ +import common from "../common/base.mjs"; +import sampleEmit from "./test-event.mjs"; + +export default { + ...common, + key: "circleci-new-workflow-completed-instant", + name: "New Workflow Completed (Instant)", + description: "Emit new event when a workflow is completed in CircleCI.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getEvents() { + return [ + "workflow-completed", + ]; + }, + getSummary(event) { + return `Workflow Completed: ${event.workflow.name}`; + }, + }, + sampleEmit, +}; diff --git a/components/circleci/sources/new-workflow-completed-instant/test-event.mjs b/components/circleci/sources/new-workflow-completed-instant/test-event.mjs new file mode 100644 index 0000000000000..6f57ea3d3fbd0 --- /dev/null +++ b/components/circleci/sources/new-workflow-completed-instant/test-event.mjs @@ -0,0 +1,75 @@ +export default { + "type": "workflow-completed", + "id": "ab1491fb-c4ae-3496-a958-c248b4732020", + "happened_at": "2024-12-18T18:44:38.000100Z", + "webhook": { + "id": "7d0a502e-0880-414a-8951-97ba4c361aea", + "name": "" + }, + "workflow": { + "id": "7acc6aa9-aac0-40ac-8cf1-48fca8d8455d", + "name": "say-hello-workflow", + "created_at": "2024-12-18T18:44:20.682Z", + "stopped_at": "2024-12-18T18:44:37.867Z", + "url": "", + "status": "success" + }, + "pipeline": { + "id": "5c8229b0-194a-4942-bddd-2b6a40c5ab35", + "number": 5, + "created_at": "2024-12-18T17:23:29.629Z", + "trigger": { + "type": "api" + }, + "trigger_parameters": { + "github_app": { + "commit_author_name": "", + "owner": "", + "full_ref": "refs/heads/circleci-project-setup", + "forced": "false", + "user_username": "", + "branch": "circleci-project-setup", + "commit_title": "CircleCI Commit", + "repo_url": "", + "ref": "circleci-project-setup", + "repo_name": "", + "commit_author_email": "", + "commit_sha": "9e2e4bed59d2e6c51d00e0e0b49f1b79ff146ab1" + }, + "git": { + "commit_author_name": "", + "repo_owner": "", + "branch": "circleci-project-setup", + "commit_message": "CircleCI Commit", + "repo_url": "", + "ref": "refs/heads/circleci-project-setup", + "author_avatar_url": "", + "checkout_url": "", + "author_login": "", + "repo_name": "", + "commit_author_email": "", + "checkout_sha": "9e2e4bed59d2e6c51d00e0e0b49f1b79ff146ab1", + "default_branch": "master" + }, + "circleci": { + "event_time": "2024-12-18 17:23:29.361740256 +0000 UTC", + "provider_actor_id": "864c50f3-d0b6-4929-a524-036d3cd4e23f", + "actor_id": "864c50f3-d0b6-4929-a524-036d3cd4e23f", + "event_type": "create pipeline run api", + "trigger_type": "github_app" + }, + "webhook": { + "body": "{}" + } + } + }, + "project": { + "id": "4b309fd6-d103-401a-bee5-1de19651d969", + "name": "", + "slug": "circleci/9z8PamRuKaKWrW91o7Kqvn/AHWysFUuUfBgs6VeUqm6tg" + }, + "organization": { + "id": "48c30eda-389d-48a8-ac64-7091f80c69df", + "name": "" + } +} \ No newline at end of file diff --git a/components/circleci/sources/updated-item-instant/updated-item-instant.mjs b/components/circleci/sources/updated-item-instant/updated-item-instant.mjs deleted file mode 100644 index 85b03c8ba14fb..0000000000000 --- a/components/circleci/sources/updated-item-instant/updated-item-instant.mjs +++ /dev/null @@ -1,206 +0,0 @@ -import circleci from "../../circleci.app.mjs"; -import { axios } from "@pipedream/platform"; -import crypto from "crypto"; - -export default { - key: "circleci-updated-item-instant", - name: "Updated Item", - description: "Emit new event when an existing item is updated. [See the documentation]().", - version: "0.0.{{{{ts}}}}", - type: "source", - dedupe: "unique", - props: { - circleci: { - type: "app", - app: "circleci", - }, - http: { - type: "$.interface.http", - customResponse: true, - }, - db: "$.service.db", - updatedItemFields: { - propDefinition: [ - "circleci", - "updatedItemFields", - ], - optional: true, - }, - updatedItemType: { - propDefinition: [ - "circleci", - "updatedItemType", - ], - optional: true, - }, - }, - hooks: { - async activate() { - // Generate a signing secret - const signingSecret = crypto.randomBytes(32).toString("hex"); - - // Get the Pipedream HTTP endpoint URL - const url = this.http.endpoint; - - try { - // Create an outbound webhook in CircleCI - const webhook = await this.circleci._makeRequest({ - method: "POST", - path: "/outbound_webhooks", - data: { - name: "Pipedream Updated Item Webhook", - events: [ - "item-updated", - ], - url: url, - verify_tls: true, - signing_secret: signingSecret, - scope: {}, // Adjust scope as needed - }, - }); - - // Save the webhook ID and signing secret to the database - await this.db.set("webhookId", webhook.id); - await this.db.set("signingSecret", signingSecret); - } catch (error) { - console.error("Error creating webhook:", error); - throw new Error("Failed to create CircleCI webhook."); - } - }, - async deactivate() { - const webhookId = await this.db.get("webhookId"); - - if (webhookId) { - try { - // Delete the outbound webhook in CircleCI - await this.circleci._makeRequest({ - method: "DELETE", - path: `/outbound_webhooks/${webhookId}`, - }); - - // Remove webhook details from the database - await this.db.delete("webhookId"); - await this.db.delete("signingSecret"); - } catch (error) { - console.error("Error deleting webhook:", error); - throw new Error("Failed to delete CircleCI webhook."); - } - } - }, - async deploy() { - try { - // Fetch the last 50 updated items from CircleCI - const items = await this.circleci._makeRequest({ - method: "GET", - path: "/items?updated=true&limit=50", - }); - - // Emit each item event from oldest to newest - for (const item of items.reverse()) { - this.$emit(item, { - id: item.id || `${item.id}-${Date.now()}`, - summary: `Item updated: ${item.title}`, - ts: Date.parse(item.updated_at) || Date.now(), - }); - } - } catch (error) { - console.error("Error fetching updated items:", error); - throw new Error("Failed to fetch updated items from CircleCI."); - } - }, - }, - async run(event) { - const signature = event.headers["x-signature"] || event.headers["X-Signature"]; - const signingSecret = await this.db.get("signingSecret"); - - if (!signature || !signingSecret) { - await this.http.respond({ - status: 400, - body: "Missing signature or signing secret.", - }); - return; - } - - // Compute the HMAC SHA256 signature - const computedSignature = crypto - .createHmac("sha256", signingSecret) - .update(event.body) - .digest("hex"); - - // Compare the computed signature with the received signature - if (computedSignature !== signature) { - await this.http.respond({ - status: 401, - body: "Unauthorized", - }); - return; - } - - let data; - try { - data = JSON.parse(event.body); - } catch (error) { - console.error("Error parsing JSON:", error); - await this.http.respond({ - status: 400, - body: "Invalid JSON format.", - }); - return; - } - - // Verify that the event is an item update - if (data.event !== "item-updated") { - await this.http.respond({ - status: 200, - body: "Event not relevant.", - }); - return; - } - - // Apply optional filters for updated fields - if (this.updatedItemFields) { - let updatedFields; - try { - updatedFields = JSON.parse(this.updatedItemFields); - } catch (error) { - console.error("Error parsing updatedItemFields:", error); - await this.http.respond({ - status: 400, - body: "Invalid updatedItemFields format.", - }); - return; - } - - const intersection = updatedFields.filter((field) => data.updated_fields.includes(field)); - if (intersection.length === 0) { - await this.http.respond({ - status: 200, - body: "No matching updated fields.", - }); - return; - } - } - - // Apply optional filter for item type - if (this.updatedItemType && data.item_type !== this.updatedItemType) { - await this.http.respond({ - status: 200, - body: "Item type does not match filter.", - }); - return; - } - - // Emit the event - this.$emit(data, { - id: data.item_id || `${data.item_id}-${Date.now()}`, - summary: `Item updated: ${data.title}`, - ts: Date.parse(data.updated_at) || Date.now(), - }); - - // Respond to acknowledge receipt - await this.http.respond({ - status: 200, - body: "OK", - }); - }, -}; From 72bacfeab364d486cecffb71f4865adab189022a Mon Sep 17 00:00:00 2001 From: michelle0927 Date: Wed, 18 Dec 2024 13:59:35 -0500 Subject: [PATCH 3/5] pnpm-lock.yaml --- pnpm-lock.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39ba56f95f156..abd1daede4df5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1754,6 +1754,15 @@ importers: specifier: ^1.5.1 version: 1.6.6 + components/circleci: + dependencies: + '@pipedream/platform': + specifier: ^3.0.3 + version: 3.0.3 + uuid: + specifier: ^11.0.3 + version: 11.0.3 + components/cisco_meraki: dependencies: '@pipedream/platform': From afd0d59e3cd131d9a6fc954aa5472afe83966dce Mon Sep 17 00:00:00 2001 From: michelle0927 Date: Wed, 18 Dec 2024 14:13:01 -0500 Subject: [PATCH 4/5] updates --- components/circleci/actions/rerun-workflow/rerun-workflow.mjs | 2 +- components/circleci/sources/common/base.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/components/circleci/actions/rerun-workflow/rerun-workflow.mjs b/components/circleci/actions/rerun-workflow/rerun-workflow.mjs index 9f2c4d59dc6d1..a997d763cd744 100644 --- a/components/circleci/actions/rerun-workflow/rerun-workflow.mjs +++ b/components/circleci/actions/rerun-workflow/rerun-workflow.mjs @@ -66,7 +66,7 @@ export default { workflowId: this.workflowId, data: { enable_ssh: this.enableSsh, - from_failed: this.from_failed, + from_failed: this.fromFailed, jobs: typeof this.jobIds === "string" ? JSON.parse(this.jobIds) : this.jobIds, diff --git a/components/circleci/sources/common/base.mjs b/components/circleci/sources/common/base.mjs index 406c256f58f52..031b792f7be4e 100644 --- a/components/circleci/sources/common/base.mjs +++ b/components/circleci/sources/common/base.mjs @@ -63,6 +63,9 @@ export default { verifySignature({ headers, body, }) { + if (!headers["circleci-signature"]) { + return false; + } const secret = this._getSigningSecret(); const signatureFromHeader = headers["circleci-signature"] .split(",") From 7a69c4869b6166d7af54cbf3e06ad832bd308db4 Mon Sep 17 00:00:00 2001 From: michelle0927 Date: Thu, 19 Dec 2024 11:03:58 -0500 Subject: [PATCH 5/5] add alert prop --- .../circleci/actions/trigger-pipeline/trigger-pipeline.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/components/circleci/actions/trigger-pipeline/trigger-pipeline.mjs b/components/circleci/actions/trigger-pipeline/trigger-pipeline.mjs index f3ee384c92c2c..2d531baeaacfd 100644 --- a/components/circleci/actions/trigger-pipeline/trigger-pipeline.mjs +++ b/components/circleci/actions/trigger-pipeline/trigger-pipeline.mjs @@ -8,6 +8,11 @@ export default { type: "action", props: { circleci, + alert: { + type: "alert", + alertType: "info", + content: "Supports all integrations except GitLab.", + }, projectSlug: { propDefinition: [ circleci,