From c58b1bd647821b82a8b59bdac820fc8977dddcf5 Mon Sep 17 00:00:00 2001 From: Mason Huang Date: Tue, 14 Apr 2026 23:46:03 +0800 Subject: [PATCH] fix(skills): preserve discussion comment replies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SKILL.md | 8 +- .../scripts/secret-scanning.mjs | 239 ++++++++++++------ 2 files changed, 161 insertions(+), 86 deletions(-) diff --git a/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md b/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md index 7af2a1ceda7..1d21fb5d18b 100644 --- a/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md +++ b/.agents/skills/openclaw-secret-scanning-maintainer/SKILL.md @@ -61,6 +61,7 @@ The `fetch-content` output includes: - `issue_number` / `pr_number`: where it is - `edit_history_count`: number of existing edits - `type`: location type for routing +- For `discussion_comment`, it also includes `comment_node_id`, `discussion_node_id`, and `reply_to_node_id` when the original comment was a reply. ### Location type routing @@ -118,10 +119,10 @@ For discussion comments (uses GraphQL): node secret-scanning.mjs delete-discussion-comment # Recreate with redacted content -node secret-scanning.mjs recreate-discussion-comment +node secret-scanning.mjs recreate-discussion-comment [REPLY_TO_NODE_ID] ``` -The `fetch-content` output for `discussion_comment` includes `comment_node_id` and `discussion_node_id` for these commands. +The `fetch-content` output for `discussion_comment` includes `comment_node_id` and `discussion_node_id` for these commands. When the original discussion comment was a reply, it also includes `reply_to_node_id`; pass that optional third argument so the redacted replacement stays in the original thread. The recreated comment should follow this format: @@ -154,11 +155,12 @@ Cannot clean. Notify author to delete branch or force-push (for unmerged PRs). ## Step 5: Notify ```bash -node secret-scanning.mjs notify +node secret-scanning.mjs notify [REPLY_TO_NODE_ID] ``` - For non-discussion types, `` is the issue/PR number. - For `discussion_comment`, `` is the `discussion_node_id` returned by `fetch-content`. +- For reply-style `discussion_comment` locations, pass the optional `reply_to_node_id` from `fetch-content` so the notification stays in the same thread. Secret types are comma-separated: `"Discord Bot Token,Feishu App Secret"` diff --git a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs index 1ec116eb8f9..2acf4754284 100644 --- a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs +++ b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs @@ -42,6 +42,145 @@ function ghGraphQL(query, options = {}) { return gh(["api", "graphql", "-f", `query=${query}`], options); } +function escapeGraphQLString(value) { + return String(value) + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n"); +} + +function formatGraphQLAfterClause(cursor) { + return cursor ? `, after: "${escapeGraphQLString(cursor)}"` : ""; +} + +function findDiscussionCommentNode(nodes, discussionCommentDbId) { + return ( + nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null + ); +} + +function fetchDiscussionReplyPage(commentNodeId, cursor) { + const afterClause = formatGraphQLAfterClause(cursor); + return ghGraphQL(`{ + node(id: "${escapeGraphQLString(commentNodeId)}") { + ... on DiscussionComment { + replies(first: 100${afterClause}) { + pageInfo { hasNextPage endCursor } + nodes { + id + databaseId + author { login } + body + url + replyTo { id } + userContentEdits(first: 50) { + totalCount + } + } + } + } + } + }}`); +} + +function fetchDiscussionComment(discussionNumber, discussionCommentDbId) { + const [owner, name] = REPO.split("/"); + let discussionId = null; + let cursor = null; + let hasNextPage = true; + + while (hasNextPage) { + const afterClause = formatGraphQLAfterClause(cursor); + const gql = ghGraphQL( + `{ + repository(owner: "${owner}", name: "${name}") { + discussion(number: ${discussionNumber}) { + id + comments(first: 50${afterClause}) { + pageInfo { hasNextPage endCursor } + nodes { + id + databaseId + author { login } + body + url + replyTo { id } + userContentEdits(first: 50) { + totalCount + } + replies(first: 100) { + pageInfo { hasNextPage endCursor } + nodes { + id + databaseId + author { login } + body + url + replyTo { id } + userContentEdits(first: 50) { + totalCount + } + } + } + } + } + } + } + }`, + { allowFailure: true }, + ); + + const discussion = gql?.data?.repository?.discussion; + if (!discussion) + fail( + `Discussion #${discussionNumber} not found — it may have been deleted. The alert cannot be processed via this skill.`, + ); + + discussionId = discussion.id; + + for (const topLevelComment of discussion.comments.nodes) { + if (String(topLevelComment.databaseId) === String(discussionCommentDbId)) { + return { discussionId, comment: topLevelComment }; + } + + let reply = findDiscussionCommentNode(topLevelComment.replies.nodes, discussionCommentDbId); + let replyCursor = topLevelComment.replies.pageInfo.endCursor; + let hasMoreReplies = topLevelComment.replies.pageInfo.hasNextPage; + + while (!reply && hasMoreReplies) { + const replyPage = fetchDiscussionReplyPage(topLevelComment.id, replyCursor); + const replies = replyPage?.data?.node?.replies; + if (!replies) fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`); + + reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId); + hasMoreReplies = replies.pageInfo.hasNextPage; + replyCursor = replies.pageInfo.endCursor; + } + + if (reply) return { discussionId, comment: reply }; + } + + hasNextPage = discussion.comments.pageInfo.hasNextPage; + cursor = discussion.comments.pageInfo.endCursor; + } + + return { discussionId, comment: null }; +} + +function createDiscussionComment(discussionNodeId, body, replyToNodeId) { + const replyToClause = replyToNodeId + ? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"` + : ""; + const result = ghGraphQL( + `mutation { addDiscussionComment(input: { discussionId: "${escapeGraphQLString(discussionNodeId)}"${replyToClause}, body: "${escapeGraphQLString(body)}" }) { comment { id url } } }`, + ); + if (result?.errors) { + fail(`Failed to create discussion comment: ${JSON.stringify(result.errors)}`); + } + return result?.data?.addDiscussionComment?.comment; +} + // ─── Commands ─────────────────────────────────────────────────────────────── /** @@ -94,62 +233,15 @@ function cmdFetchContent(locationJson) { const details = location.details; if (type === "discussion_comment") { - // Discussion comments can only be operated via GraphQL const commentUrl = details.discussion_comment_url; if (!commentUrl) fail("No discussion_comment_url in location details"); - // Extract discussion number and comment id from URL - // Format: https://github.com/owner/repo/discussions/123#discussioncomment-456 const urlMatch = commentUrl.match(/discussions\/(\d+)#discussioncomment-(\d+)/); if (!urlMatch) fail(`Cannot parse discussion comment URL: ${commentUrl}`); const discussionNumber = urlMatch[1]; const discussionCommentDbId = urlMatch[2]; - // Fetch discussion comment via GraphQL with pagination - const [owner, name] = REPO.split("/"); - let comment = null; - let discussionId = null; - let cursor = null; - let hasNextPage = true; - - while (hasNextPage && !comment) { - const afterClause = cursor ? `, after: "${cursor}"` : ""; - const gql = ghGraphQL( - `{ - repository(owner: "${owner}", name: "${name}") { - discussion(number: ${discussionNumber}) { - id - url - comments(first: 50${afterClause}) { - pageInfo { hasNextPage endCursor } - nodes { - id - databaseId - author { login } - body - url - } - } - } - } - }`, - { allowFailure: true }, - ); - - const discussion = gql?.data?.repository?.discussion; - if (!discussion) - fail( - `Discussion #${discussionNumber} not found — it may have been deleted. The alert cannot be processed via this skill.`, - ); - - discussionId = discussion.id; - comment = discussion.comments.nodes.find( - (c) => String(c.databaseId) === discussionCommentDbId, - ); - hasNextPage = discussion.comments.pageInfo.hasNextPage; - cursor = discussion.comments.pageInfo.endCursor; - } - + const { discussionId, comment } = fetchDiscussionComment(discussionNumber, discussionCommentDbId); if (!comment) fail( `Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`, @@ -164,10 +256,12 @@ function cmdFetchContent(locationJson) { type, comment_node_id: comment.id, discussion_node_id: discussionId, + reply_to_node_id: comment.replyTo?.id ?? null, discussion_number: Number(discussionNumber), discussion_comment_db_id: Number(discussionCommentDbId), author: comment.author?.login, html_url: comment.url || commentUrl, + edit_history_count: comment.userContentEdits?.totalCount ?? 0, body_file: bodyFile, }, null, @@ -375,28 +469,16 @@ function cmdDeleteDiscussionComment(nodeId) { } /** - * recreate-discussion-comment + * recreate-discussion-comment [reply-to-node-id] * Create a new discussion comment via GraphQL. */ -function cmdRecreateDiscussionComment(discussionNodeId, bodyFile) { +function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId) { if (!discussionNodeId || !bodyFile) - fail("Usage: recreate-discussion-comment "); + fail("Usage: recreate-discussion-comment [reply-to-node-id]"); if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`); const body = fs.readFileSync(bodyFile, "utf8"); - // Escape for GraphQL string literal - const escapedBody = body - .replace(/\\/g, "\\\\") - .replace(/"/g, '\\"') - .replace(/\r/g, "\\r") - .replace(/\n/g, "\\n"); - const result = ghGraphQL( - `mutation { addDiscussionComment(input: { discussionId: "${discussionNodeId}", body: "${escapedBody}" }) { comment { id url } } }`, - ); - if (result?.errors) { - fail(`Failed to create discussion comment: ${JSON.stringify(result.errors)}`); - } - const newComment = result?.data?.addDiscussionComment?.comment; + const newComment = createDiscussionComment(discussionNodeId, body, replyToNodeId); console.log( JSON.stringify({ ok: true, @@ -433,13 +515,15 @@ function cmdRecreateComment(issueNumber, bodyFile) { } /** - * notify + * notify [reply-to-node-id] * Post a notification comment with the correct template for the location type. * target = issue/PR number for non-discussion types, discussion node ID for discussion_comment. */ -function cmdNotify(target, author, locationType, secretTypes) { +function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) { if (!target || !author || !locationType || !secretTypes) { - fail("Usage: notify "); + fail( + "Usage: notify [reply-to-node-id]", + ); } const types = secretTypes.split(",").map((s) => s.trim()); @@ -487,18 +571,7 @@ function cmdNotify(target, author, locationType, secretTypes) { // Discussion comments must be notified via GraphQL if (locationType === "discussion_comment") { - const escapedBody = body - .replace(/\\/g, "\\\\") - .replace(/"/g, '\\"') - .replace(/\r/g, "\\r") - .replace(/\n/g, "\\n"); - const result = ghGraphQL( - `mutation { addDiscussionComment(input: { discussionId: "${target}", body: "${escapedBody}" }) { comment { id url } } }`, - ); - if (result?.errors) { - fail(`Failed to post discussion notification: ${JSON.stringify(result.errors)}`); - } - const newComment = result?.data?.addDiscussionComment?.comment; + const newComment = createDiscussionComment(target, body, replyToNodeId); console.log( JSON.stringify({ ok: true, @@ -665,8 +738,8 @@ const commands = { "delete-comment": () => cmdDeleteComment(args[0]), "delete-discussion-comment": () => cmdDeleteDiscussionComment(args[0]), "recreate-comment": () => cmdRecreateComment(args[0], args[1]), - "recreate-discussion-comment": () => cmdRecreateDiscussionComment(args[0], args[1]), - notify: () => cmdNotify(args[0], args[1], args[2], args[3]), + "recreate-discussion-comment": () => cmdRecreateDiscussionComment(args[0], args[1], args[2]), + notify: () => cmdNotify(args[0], args[1], args[2], args[3], args[4]), resolve: () => cmdResolve(args[0], args[1], args[2]), "list-open": () => cmdListOpen(), summary: () => cmdSummary(args[0]), @@ -684,8 +757,8 @@ if (!command || !commands[command]) { " delete-comment Delete a comment", " delete-discussion-comment Delete a discussion comment (GraphQL)", " recreate-comment Create replacement comment", - " recreate-discussion-comment Create discussion comment (GraphQL)", - " notify Post notification", + " recreate-discussion-comment [reply-to-node-id] Create discussion comment (GraphQL)", + " notify [reply-to-node-id] Post notification", " resolve [resolution] [comment] Close alert", " list-open List open alerts", " summary Print formatted summary",