diff --git a/dist/index.js b/dist/index.js index 01d7eaf..ab97832 100644 --- a/dist/index.js +++ b/dist/index.js @@ -27049,6 +27049,97 @@ var require_graphology_traversal = __commonJS({ } }); +// node_modules/extend/index.js +var require_extend = __commonJS({ + "node_modules/extend/index.js"(exports2, module2) { + "use strict"; + var hasOwn = Object.prototype.hasOwnProperty; + var toStr = Object.prototype.toString; + var defineProperty = Object.defineProperty; + var gOPD = Object.getOwnPropertyDescriptor; + var isArray = function isArray2(arr) { + if (typeof Array.isArray === "function") { + return Array.isArray(arr); + } + return toStr.call(arr) === "[object Array]"; + }; + var isPlainObject2 = function isPlainObject3(obj) { + if (!obj || toStr.call(obj) !== "[object Object]") { + return false; + } + var hasOwnConstructor = hasOwn.call(obj, "constructor"); + var hasIsPrototypeOf = obj.constructor && obj.constructor.prototype && hasOwn.call(obj.constructor.prototype, "isPrototypeOf"); + if (obj.constructor && !hasOwnConstructor && !hasIsPrototypeOf) { + return false; + } + var key; + for (key in obj) { + } + return typeof key === "undefined" || hasOwn.call(obj, key); + }; + var setProperty = function setProperty2(target, options) { + if (defineProperty && options.name === "__proto__") { + defineProperty(target, options.name, { + enumerable: true, + configurable: true, + value: options.newValue, + writable: true + }); + } else { + target[options.name] = options.newValue; + } + }; + var getProperty = function getProperty2(obj, name) { + if (name === "__proto__") { + if (!hasOwn.call(obj, name)) { + return void 0; + } else if (gOPD) { + return gOPD(obj, name).value; + } + } + return obj[name]; + }; + module2.exports = function extend2() { + var options, name, src, copy, copyIsArray, clone; + var target = arguments[0]; + var i = 1; + var length = arguments.length; + var deep = false; + if (typeof target === "boolean") { + deep = target; + target = arguments[1] || {}; + i = 2; + } + if (target == null || typeof target !== "object" && typeof target !== "function") { + target = {}; + } + for (; i < length; ++i) { + options = arguments[i]; + if (options != null) { + for (name in options) { + src = getProperty(target, name); + copy = getProperty(options, name); + if (target !== copy) { + if (deep && copy && (isPlainObject2(copy) || (copyIsArray = isArray(copy)))) { + if (copyIsArray) { + copyIsArray = false; + clone = src && isArray(src) ? src : []; + } else { + clone = src && isPlainObject2(src) ? src : {}; + } + setProperty(target, { name, newValue: extend2(deep, clone, copy) }); + } else if (typeof copy !== "undefined") { + setProperty(target, { name, newValue: copy }); + } + } + } + } + } + return target; + }; + } +}); + // node_modules/graphology-dag/has-cycle.js var require_has_cycle = __commonJS({ "node_modules/graphology-dag/has-cycle.js"(exports2, module2) { @@ -27251,97 +27342,6 @@ var require_graphology_dag = __commonJS({ } }); -// node_modules/extend/index.js -var require_extend = __commonJS({ - "node_modules/extend/index.js"(exports2, module2) { - "use strict"; - var hasOwn = Object.prototype.hasOwnProperty; - var toStr = Object.prototype.toString; - var defineProperty = Object.defineProperty; - var gOPD = Object.getOwnPropertyDescriptor; - var isArray = function isArray2(arr) { - if (typeof Array.isArray === "function") { - return Array.isArray(arr); - } - return toStr.call(arr) === "[object Array]"; - }; - var isPlainObject2 = function isPlainObject3(obj) { - if (!obj || toStr.call(obj) !== "[object Object]") { - return false; - } - var hasOwnConstructor = hasOwn.call(obj, "constructor"); - var hasIsPrototypeOf = obj.constructor && obj.constructor.prototype && hasOwn.call(obj.constructor.prototype, "isPrototypeOf"); - if (obj.constructor && !hasOwnConstructor && !hasIsPrototypeOf) { - return false; - } - var key; - for (key in obj) { - } - return typeof key === "undefined" || hasOwn.call(obj, key); - }; - var setProperty = function setProperty2(target, options) { - if (defineProperty && options.name === "__proto__") { - defineProperty(target, options.name, { - enumerable: true, - configurable: true, - value: options.newValue, - writable: true - }); - } else { - target[options.name] = options.newValue; - } - }; - var getProperty = function getProperty2(obj, name) { - if (name === "__proto__") { - if (!hasOwn.call(obj, name)) { - return void 0; - } else if (gOPD) { - return gOPD(obj, name).value; - } - } - return obj[name]; - }; - module2.exports = function extend2() { - var options, name, src, copy, copyIsArray, clone; - var target = arguments[0]; - var i = 1; - var length = arguments.length; - var deep = false; - if (typeof target === "boolean") { - deep = target; - target = arguments[1] || {}; - i = 2; - } - if (target == null || typeof target !== "object" && typeof target !== "function") { - target = {}; - } - for (; i < length; ++i) { - options = arguments[i]; - if (options != null) { - for (name in options) { - src = getProperty(target, name); - copy = getProperty(options, name); - if (target !== copy) { - if (deep && copy && (isPlainObject2(copy) || (copyIsArray = isArray(copy)))) { - if (copyIsArray) { - copyIsArray = false; - clone = src && isArray(src) ? src : []; - } else { - clone = src && isPlainObject2(src) ? src : {}; - } - setProperty(target, { name, newValue: extend2(deep, clone, copy) }); - } else if (typeof copy !== "undefined") { - setProperty(target, { name, newValue: copy }); - } - } - } - } - } - return target; - }; - } -}); - // node_modules/toml/lib/parser.js var require_parser = __commonJS({ "node_modules/toml/lib/parser.js"(exports2, module2) { @@ -31095,8 +31095,7 @@ var github2 = __toESM(require_github()); var core = __toESM(require_core()); var github = __toESM(require_github()); var import_graphology = __toESM(require_graphology_cjs()); -var import_graphology_traversal = __toESM(require_graphology_traversal()); -var import_graphology_dag = __toESM(require_graphology_dag()); +var import_graphology_traversal2 = __toESM(require_graphology_traversal()); // node_modules/mdast-util-to-string/lib/index.js var emptyOptions = {}; @@ -43207,14 +43206,125 @@ function remarkGfm(options) { toMarkdownExtensions.push(gfmToMarkdown(settings)); } -// src/remark.ts +// src/renderer.ts +var import_graphology_dag = __toESM(require_graphology_dag()); +var import_graphology_traversal = __toESM(require_graphology_traversal()); +var ANCHOR = ""; +var PULL_REQUEST_NODE_REGEX = /#\d+ :point_left:/; var remark2 = remark().use(remarkGfm).data("settings", { bullet: "-" }); +function renderVisualization(graph, terminatingRefs) { + const lines = []; + const rootRef = (0, import_graphology_dag.topologicalSort)(graph)[0]; + (0, import_graphology_traversal.dfsFromNode)( + graph, + rootRef, + (_, stackNode, depth) => { + if (!stackNode.shouldPrint) + return; + const tabSize = depth * 2; + const indentation = new Array(tabSize).fill(" ").join(""); + let line = indentation; + if (stackNode.type === "orphan-branch") { + line += `- \`${stackNode.ref}\` - :warning: No PR associated with branch`; + } + if (stackNode.type === "perennial" && terminatingRefs.includes(stackNode.ref)) { + line += `- \`${stackNode.ref}\``; + } + if (stackNode.type === "pull-request") { + line += `- #${stackNode.number}`; + } + if (stackNode.isCurrent) { + line += " :point_left:"; + } + if (depth === 0) { + line += ` ${ANCHOR}`; + } + lines.push(line); + }, + { mode: "directed" } + ); + return lines.join("\n"); +} +function injectVisualization(visualization, content3) { + const contentAst = remark2.parse(content3); + const visualizationAst = remark2.parse(visualization); + const standaloneAnchorIndex = contentAst.children.findIndex( + (node2) => node2.type === "html" && node2.value === ANCHOR + ); + if (standaloneAnchorIndex >= 0) { + removeUnanchoredBranchStack(contentAst); + contentAst.children.splice(standaloneAnchorIndex, 1, ...visualizationAst.children); + return remark2.stringify(contentAst); + } + const inlineAnchorIndex = findInlineAnchor(contentAst); + const isMissingAnchor = inlineAnchorIndex === -1; + if (isMissingAnchor) { + removeUnanchoredBranchStack(contentAst); + contentAst.children.push(...visualizationAst.children); + return remark2.stringify(contentAst); + } + contentAst.children.splice(inlineAnchorIndex, 1, ...visualizationAst.children); + return remark2.stringify(contentAst); +} +function removeUnanchoredBranchStack(descriptionAst) { + const branchStackIndex = descriptionAst.children.findIndex( + function matchesBranchStackHeuristic(node2) { + if (node2.type !== "list") { + return false; + } + const child = node2.children[0]; + if (node2.children.length !== 1 || !child) { + return false; + } + const result = containsPullRequestNode(child); + return result; + } + ); + if (branchStackIndex === -1) { + return; + } + descriptionAst.children.splice(branchStackIndex, 1); +} +function containsPullRequestNode(listItem2) { + return listItem2.children.some((node2) => { + if (node2.type === "list" && node2.children.length > 0) { + return node2.children.some(containsPullRequestNode); + } + if (node2.type !== "paragraph") { + return false; + } + const result = node2.children.some( + (child) => child.type === "text" && PULL_REQUEST_NODE_REGEX.test(child.value) + ); + return result; + }); +} +function findInlineAnchor(descriptionAst) { + return descriptionAst.children.findIndex((node2) => { + if (node2.type !== "list") { + return; + } + return node2.children.some(containsAnchor); + }); +} +function containsAnchor(listItem2) { + return listItem2.children.some((node2) => { + if (node2.type === "list") { + return node2.children.some(containsAnchor); + } + if (node2.type !== "paragraph") { + return false; + } + const result = node2.children.some( + (child) => child.type === "html" && child.value === ANCHOR + ); + return result; + }); +} // src/main.ts -var ANCHOR = ""; -var PULL_REQUEST_NODE_REGEX = /#\d+ :point_left:/; async function main({ octokit, currentPullRequest, @@ -43288,18 +43398,15 @@ async function main({ try { core.startGroup(`PR #${stackNode.number}`); const stackGraph2 = getStackGraph(stackNode, repoGraph); - const output = getOutput(stackGraph2, terminatingRefs); - core.info("--- Output ---"); + const visualization = renderVisualization(stackGraph2, terminatingRefs); + core.info("--- Visualization ---"); core.info(""); - output.split("\n").forEach(core.info); + visualization.split("\n").forEach(core.info); core.info(""); - core.info("--- End output ---"); + core.info("--- End visualization ---"); core.info(""); let description = stackNode.body ?? ""; - description = updateDescription({ - description, - output - }); + description = injectVisualization(visualization, description); core.info("--- Updated description ---"); core.info(""); description.split("\n").forEach(core.info); @@ -43342,7 +43449,7 @@ async function main({ function getStackGraph(pullRequest, repoGraph) { const stackGraph = repoGraph.copy(); stackGraph.setNodeAttribute(pullRequest.head.ref, "isCurrent", true); - (0, import_graphology_traversal.bfsFromNode)( + (0, import_graphology_traversal2.bfsFromNode)( stackGraph, pullRequest.head.ref, (ref, attributes) => { @@ -43351,7 +43458,7 @@ function getStackGraph(pullRequest, repoGraph) { }, { mode: "inbound" } ); - (0, import_graphology_traversal.dfsFromNode)( + (0, import_graphology_traversal2.dfsFromNode)( stackGraph, pullRequest.head.ref, (ref) => { @@ -43366,118 +43473,6 @@ function getStackGraph(pullRequest, repoGraph) { }); return stackGraph; } -function getOutput(graph, terminatingRefs) { - const lines = []; - const rootRef = (0, import_graphology_dag.topologicalSort)(graph)[0]; - (0, import_graphology_traversal.dfsFromNode)( - graph, - rootRef, - (_, stackNode, depth) => { - if (!stackNode.shouldPrint) - return; - const tabSize = depth * 2; - const indentation = new Array(tabSize).fill(" ").join(""); - let line = indentation; - if (stackNode.type === "orphan-branch") { - line += `- \`${stackNode.ref}\` - :warning: No PR associated with branch`; - } - if (stackNode.type === "perennial" && terminatingRefs.includes(stackNode.ref)) { - line += `- \`${stackNode.ref}\``; - } - if (stackNode.type === "pull-request") { - line += `- #${stackNode.number}`; - } - if (stackNode.isCurrent) { - line += " :point_left:"; - } - if (depth === 0) { - line += ` ${ANCHOR}`; - } - lines.push(line); - }, - { mode: "directed" } - ); - return lines.join("\n"); -} -function updateDescription({ - description, - output -}) { - const descriptionAst = remark2.parse(description); - const outputAst = remark2.parse(output); - const standaloneAnchorIndex = descriptionAst.children.findIndex( - (node2) => node2.type === "html" && node2.value === ANCHOR - ); - if (standaloneAnchorIndex >= 0) { - removeUnanchoredBranchStack(descriptionAst); - descriptionAst.children.splice(standaloneAnchorIndex, 1, ...outputAst.children); - return remark2.stringify(descriptionAst); - } - const inlineAnchorIndex = findInlineAnchor(descriptionAst); - const isMissingAnchor = inlineAnchorIndex === -1; - if (isMissingAnchor) { - removeUnanchoredBranchStack(descriptionAst); - descriptionAst.children.push(...outputAst.children); - return remark2.stringify(descriptionAst); - } - descriptionAst.children.splice(inlineAnchorIndex, 1, ...outputAst.children); - return remark2.stringify(descriptionAst); -} -function removeUnanchoredBranchStack(descriptionAst) { - const branchStackIndex = descriptionAst.children.findIndex( - function matchesBranchStackHeuristic(node2) { - if (node2.type !== "list") { - return false; - } - const child = node2.children[0]; - if (node2.children.length !== 1 || !child) { - return false; - } - const result = containsPullRequestNode(child); - return result; - } - ); - if (branchStackIndex === -1) { - return; - } - descriptionAst.children.splice(branchStackIndex, 1); -} -function containsPullRequestNode(listItem2) { - return listItem2.children.some((node2) => { - if (node2.type === "list" && node2.children.length > 0) { - return node2.children.some(containsPullRequestNode); - } - if (node2.type !== "paragraph") { - return false; - } - const result = node2.children.some( - (child) => child.type === "text" && PULL_REQUEST_NODE_REGEX.test(child.value) - ); - return result; - }); -} -function findInlineAnchor(descriptionAst) { - return descriptionAst.children.findIndex((node2) => { - if (node2.type !== "list") { - return; - } - return node2.children.some(containsAnchor); - }); -} -function containsAnchor(listItem2) { - return listItem2.children.some((node2) => { - if (node2.type === "list") { - return node2.children.some(containsAnchor); - } - if (node2.type !== "paragraph") { - return false; - } - const result = node2.children.some( - (child) => child.type === "html" && child.value === ANCHOR - ); - return result; - }); -} // src/inputs.ts var core2 = __toESM(require_core()); diff --git a/src/main.test.ts b/src/main.test.ts index afc7b27..580c7a8 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,193 +1,12 @@ import { describe, it, beforeEach, expect, vi } from 'vitest' import { DirectedGraph } from 'graphology' -import { getOutput, getStackGraph, updateDescription } from './main' +import { getStackGraph } from './main' import type { PullRequest, StackNodeAttributes } from './types' beforeEach(() => { vi.unstubAllEnvs() }) -describe('updateDescription', () => { - describe('when standalone anchor is present', () => { - describe('when previous stack exists', () => { - it('should delete previous stack & replace anchor with updated stack', () => { - const description = [ - '', - '', - '- `main`', - ' - \\#1 :point\\_left:', - ].join('\n') - const output = ['- `main` ', ' - \\#2 :point\\_left:'].join( - '\n' - ) - - const actual = updateDescription({ description, output }) - const expected = [ - '- `main` ', - ' - \\#2 :point\\_left:', - '', - ].join('\n') - - expect(actual).toBe(expected) - }) - }) - - describe('when stack is missing', () => { - it('should replace standalone anchor with updated stack', () => { - const description = ['', ''].join('\n') - const output = ['- main ', ' - \\#2 :point\\_left:'].join( - '\n' - ) - - const actual = updateDescription({ description, output }) - const expected = [ - '- main ', - ' - \\#2 :point\\_left:', - '', - ].join('\n') - - expect(actual).toEqual(expected) - }) - }) - }) - - describe('when inline anchor is present', () => { - it('should replace inline anchor with updated stack', () => { - const description = [ - '- `main` ', - ' - \\#1 :point_left:', - '', - ].join('\n') - const output = ['- `main` ', ' - \\#2 :point\\_left:'].join( - '\n' - ) - - const actual = updateDescription({ description, output }) - const expected = [ - '- `main` ', - ' - \\#2 :point\\_left:', - '', - ].join('\n') - - expect(actual).toBe(expected) - }) - }) - - describe('when anchor is missing', () => { - describe('when previous stack exists', () => { - it('should replace previous stack with updated stack', () => { - const description = ['- `main`', ' - \\#1 :point\\_left:', ''].join('\n') - const output = ['- `main` ', ' - \\#2 :point\\_left:'].join( - '\n' - ) - - const actual = updateDescription({ description, output }) - const expected = [ - '- `main` ', - ' - \\#2 :point\\_left:', - '', - ].join('\n') - - expect(actual).toBe(expected) - }) - }) - }) - - it('should not delete parts of the description below itself when there is another list', () => { - const description = [ - '', - '', - '## More Description', - '', - `There may be things here we don't want to overwrite.`, - '', - '- [ ] including', - '- [ ] something', - '- [ ] like a', - '- [ ] checklist', - '', - ].join('\n') - const output = ['- main ', ' - \\#1 :point\\_left:'].join('\n') - - const actual = updateDescription({ description, output }) - const expected = [ - '- main ', - ' - \\#1 :point\\_left:', - '', - '## More Description', - '', - `There may be things here we don't want to overwrite.`, - '', - '- [ ] including', - '- [ ] something', - '- [ ] like a', - '- [ ] checklist', - '', - ].join('\n') - - expect(actual).toEqual(expected) - }) - - it('should not replace any list directly succeeding the stack comment', () => { - const description = [ - '', - '', - '- [ ] this checklist', - ' - [ ] is going to be alright but with an asterisk to start', - '', - '## More Description', - '', - ].join('\n') - const output = ['- main ', ' - \\#1 :point\\_left:'].join('\n') - - const actual = updateDescription({ description, output }) - const expected = [ - '- main ', - ' - \\#1 :point\\_left:', - '', - '* [ ] this checklist', - ' - [ ] is going to be alright but with an asterisk to start', - '', - '## More Description', - '', - ].join('\n') - - expect(actual).toEqual(expected) - }) - - it('should correctly update pull request body when the comment is inline and there is a succeeding list', () => { - const description = [ - '## Description', - '', - '## Stack', - '', - '- main ', - ' - \\#1 :point\\_left:', - '', - '* my list', - ' - should survive', - '', - ].join('\n') - const output = ['- main ', ' - \\#2 :point\\_left:'].join('\n') - - const actual = updateDescription({ description, output }) - const expected = [ - '## Description', - '', - '## Stack', - '', - '- main ', - ' - \\#2 :point\\_left:', - '', - '* my list', - ' - should survive', - '', - ].join('\n') - - expect(actual).toEqual(expected) - }) -}) - describe('getStackGraph', () => { it('should construct the stack graph correctly', () => { const pullRequest: PullRequest = { @@ -257,53 +76,3 @@ describe('getStackGraph', () => { expect(stackGraph.nodes()).toStrictEqual(['main', 'pr-1', 'pr-2']) }) }) - -describe('getOutput', () => { - it('should produce the expected output', () => { - const pullRequest1: PullRequest = { - number: 1, - base: { - ref: 'main', - }, - head: { - ref: 'pr-1', - }, - state: 'TODO', - body: 'pr 1 body', - } - const pullRequest2: PullRequest = { - number: 2, - base: { - ref: 'pr-1', - }, - head: { - ref: 'pr-2', - }, - state: 'TODO', - body: 'pr 2 body', - } - const repoGraph = new DirectedGraph() - repoGraph.mergeNode('main', { type: 'perennial', ref: 'main' }) - repoGraph.mergeNode('pr-1', { - type: 'pull-request', - ...pullRequest1, - }) - repoGraph.mergeNode('pr-2', { - type: 'pull-request', - ...pullRequest2, - }) - repoGraph.mergeDirectedEdge('main', 'pr-1') - repoGraph.mergeDirectedEdge('pr-1', 'pr-2') - - const stackGraph = getStackGraph(pullRequest1, repoGraph) - - const output = getOutput(stackGraph, ['main']) - const expected = [ - '- `main` ', - ' - #1 :point_left:', - ' - #2', - ].join('\n') - - expect(output).toBe(expected) - }) -}) diff --git a/src/main.ts b/src/main.ts index 50f137c..e403a38 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,13 +2,8 @@ import * as core from '@actions/core' import * as github from '@actions/github' import { DirectedGraph } from 'graphology' import { bfsFromNode, dfsFromNode } from 'graphology-traversal' -import { topologicalSort } from 'graphology-dag' -import type { ListItem, Root } from 'mdast' import type { PullRequest, Context, StackNodeAttributes } from './types' -import { remark } from './remark' - -const ANCHOR = '' -export const PULL_REQUEST_NODE_REGEX = /#\d+ :point_left:/ +import { renderVisualization, injectVisualization } from './renderer' export async function main({ octokit, @@ -105,20 +100,17 @@ export async function main({ core.startGroup(`PR #${stackNode.number}`) const stackGraph = getStackGraph(stackNode, repoGraph) - const output = getOutput(stackGraph, terminatingRefs) + const visualization = renderVisualization(stackGraph, terminatingRefs) - core.info('--- Output ---') + core.info('--- Visualization ---') core.info('') - output.split('\n').forEach(core.info) + visualization.split('\n').forEach(core.info) core.info('') - core.info('--- End output ---') + core.info('--- End visualization ---') core.info('') let description = stackNode.body ?? '' - description = updateDescription({ - description, - output, - }) + description = injectVisualization(visualization, description) core.info('--- Updated description ---') core.info('') @@ -199,159 +191,3 @@ export function getStackGraph( return stackGraph } - -export function getOutput( - graph: DirectedGraph, - terminatingRefs: string[] -) { - const lines: string[] = [] - - // `dfs` is bugged and doesn't traverse in topological order. - // `dfsFromNode` does, so we'll do the topological sort ourselves - // start traversal from the root. - const rootRef = topologicalSort(graph)[0] - - dfsFromNode( - graph, - rootRef, - (_, stackNode, depth) => { - if (!stackNode.shouldPrint) return - - const tabSize = depth * 2 - const indentation = new Array(tabSize).fill(' ').join('') - - let line = indentation - - if (stackNode.type === 'orphan-branch') { - line += `- \`${stackNode.ref}\` - :warning: No PR associated with branch` - } - - if (stackNode.type === 'perennial' && terminatingRefs.includes(stackNode.ref)) { - line += `- \`${stackNode.ref}\`` - } - - if (stackNode.type === 'pull-request') { - line += `- #${stackNode.number}` - } - - if (stackNode.isCurrent) { - line += ' :point_left:' - } - - if (depth === 0) { - line += ` ${ANCHOR}` - } - - lines.push(line) - }, - { mode: 'directed' } - ) - - return lines.join('\n') -} - -export function updateDescription({ - description, - output, -}: { - description: string - output: string -}) { - const descriptionAst = remark.parse(description) - const outputAst = remark.parse(output) - - const standaloneAnchorIndex = descriptionAst.children.findIndex( - (node) => node.type === 'html' && node.value === ANCHOR - ) - - if (standaloneAnchorIndex >= 0) { - removeUnanchoredBranchStack(descriptionAst) - - descriptionAst.children.splice(standaloneAnchorIndex, 1, ...outputAst.children) - return remark.stringify(descriptionAst) - } - - const inlineAnchorIndex = findInlineAnchor(descriptionAst) - - const isMissingAnchor = inlineAnchorIndex === -1 - if (isMissingAnchor) { - removeUnanchoredBranchStack(descriptionAst) - - descriptionAst.children.push(...outputAst.children) - return remark.stringify(descriptionAst) - } - - descriptionAst.children.splice(inlineAnchorIndex, 1, ...outputAst.children) - return remark.stringify(descriptionAst) -} - -function removeUnanchoredBranchStack(descriptionAst: Root) { - const branchStackIndex = descriptionAst.children.findIndex( - function matchesBranchStackHeuristic(node) { - if (node.type !== 'list') { - return false - } - - const child = node.children[0] - if (node.children.length !== 1 || !child) { - return false - } - - const result = containsPullRequestNode(child) - - return result - } - ) - - if (branchStackIndex === -1) { - return - } - - descriptionAst.children.splice(branchStackIndex, 1) -} - -function containsPullRequestNode(listItem: ListItem) { - return listItem.children.some((node) => { - if (node.type === 'list' && node.children.length > 0) { - return node.children.some(containsPullRequestNode) - } - - if (node.type !== 'paragraph') { - return false - } - - const result = node.children.some( - (child) => child.type === 'text' && PULL_REQUEST_NODE_REGEX.test(child.value) - ) - - return result - }) -} - -function findInlineAnchor(descriptionAst: Root): number { - return descriptionAst.children.findIndex((node) => { - if (node.type !== 'list') { - return - } - - return node.children.some(containsAnchor) - }) -} - -function containsAnchor(listItem: ListItem): boolean { - return listItem.children.some((node) => { - if (node.type === 'list') { - return node.children.some(containsAnchor) - } - - if (node.type !== 'paragraph') { - return false - } - - const result = node.children.some( - (child) => child.type === 'html' && child.value === ANCHOR - ) - - return result - }) -} diff --git a/src/remark.ts b/src/remark.ts deleted file mode 100644 index ca96a22..0000000 --- a/src/remark.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { remark as createRemark } from 'remark' -import gfm from 'remark-gfm' - -export const remark = createRemark().use(gfm).data('settings', { - bullet: '-', -}) diff --git a/src/renderer.test.ts b/src/renderer.test.ts new file mode 100644 index 0000000..4feedeb --- /dev/null +++ b/src/renderer.test.ts @@ -0,0 +1,236 @@ +import { DirectedGraph } from 'graphology' +import { describe, it, expect } from 'vitest' +import { getStackGraph } from './main' +import { injectVisualization, renderVisualization } from './renderer' +import type { PullRequest, StackNodeAttributes } from './types' + +describe('getOutput', () => { + it('should produce the expected output', () => { + const pullRequest1: PullRequest = { + number: 1, + base: { + ref: 'main', + }, + head: { + ref: 'pr-1', + }, + state: 'TODO', + body: 'pr 1 body', + } + const pullRequest2: PullRequest = { + number: 2, + base: { + ref: 'pr-1', + }, + head: { + ref: 'pr-2', + }, + state: 'TODO', + body: 'pr 2 body', + } + const repoGraph = new DirectedGraph() + repoGraph.mergeNode('main', { type: 'perennial', ref: 'main' }) + repoGraph.mergeNode('pr-1', { + type: 'pull-request', + ...pullRequest1, + }) + repoGraph.mergeNode('pr-2', { + type: 'pull-request', + ...pullRequest2, + }) + repoGraph.mergeDirectedEdge('main', 'pr-1') + repoGraph.mergeDirectedEdge('pr-1', 'pr-2') + + const stackGraph = getStackGraph(pullRequest1, repoGraph) + + const visualization = renderVisualization(stackGraph, ['main']) + const expected = [ + '- `main` ', + ' - #1 :point_left:', + ' - #2', + ].join('\n') + + expect(visualization).toBe(expected) + }) +}) + +describe('applyVisualization', () => { + describe('when standalone anchor is present', () => { + describe('when previous stack exists', () => { + it('should delete previous stack & replace anchor with updated stack', () => { + const description = [ + '', + '', + '- `main`', + ' - \\#1 :point\\_left:', + ].join('\n') + const output = ['- `main` ', ' - \\#2 :point\\_left:'].join( + '\n' + ) + + const actual = injectVisualization(output, description) + const expected = [ + '- `main` ', + ' - \\#2 :point\\_left:', + '', + ].join('\n') + + expect(actual).toBe(expected) + }) + }) + + describe('when stack is missing', () => { + it('should replace standalone anchor with updated stack', () => { + const description = ['', ''].join('\n') + const output = ['- main ', ' - \\#2 :point\\_left:'].join( + '\n' + ) + + const actual = injectVisualization(output, description) + const expected = [ + '- main ', + ' - \\#2 :point\\_left:', + '', + ].join('\n') + + expect(actual).toEqual(expected) + }) + }) + }) + + describe('when inline anchor is present', () => { + it('should replace inline anchor with updated stack', () => { + const description = [ + '- `main` ', + ' - \\#1 :point_left:', + '', + ].join('\n') + const output = ['- `main` ', ' - \\#2 :point\\_left:'].join( + '\n' + ) + + const actual = injectVisualization(output, description) + const expected = [ + '- `main` ', + ' - \\#2 :point\\_left:', + '', + ].join('\n') + + expect(actual).toBe(expected) + }) + }) + + describe('when anchor is missing', () => { + describe('when previous stack exists', () => { + it('should replace previous stack with updated stack', () => { + const description = ['- `main`', ' - \\#1 :point\\_left:', ''].join('\n') + const output = ['- `main` ', ' - \\#2 :point\\_left:'].join( + '\n' + ) + + const actual = injectVisualization(output, description) + const expected = [ + '- `main` ', + ' - \\#2 :point\\_left:', + '', + ].join('\n') + + expect(actual).toBe(expected) + }) + }) + }) + + it('should not delete parts of the description below itself when there is another list', () => { + const description = [ + '', + '', + '## More Description', + '', + `There may be things here we don't want to overwrite.`, + '', + '- [ ] including', + '- [ ] something', + '- [ ] like a', + '- [ ] checklist', + '', + ].join('\n') + const output = ['- main ', ' - \\#1 :point\\_left:'].join('\n') + + const actual = injectVisualization(output, description) + const expected = [ + '- main ', + ' - \\#1 :point\\_left:', + '', + '## More Description', + '', + `There may be things here we don't want to overwrite.`, + '', + '- [ ] including', + '- [ ] something', + '- [ ] like a', + '- [ ] checklist', + '', + ].join('\n') + + expect(actual).toEqual(expected) + }) + + it('should not replace any list directly succeeding the stack comment', () => { + const description = [ + '', + '', + '- [ ] this checklist', + ' - [ ] is going to be alright but with an asterisk to start', + '', + '## More Description', + '', + ].join('\n') + const output = ['- main ', ' - \\#1 :point\\_left:'].join('\n') + + const actual = injectVisualization(output, description) + const expected = [ + '- main ', + ' - \\#1 :point\\_left:', + '', + '* [ ] this checklist', + ' - [ ] is going to be alright but with an asterisk to start', + '', + '## More Description', + '', + ].join('\n') + + expect(actual).toEqual(expected) + }) + + it('should correctly update pull request body when the comment is inline and there is a succeeding list', () => { + const description = [ + '## Description', + '', + '## Stack', + '', + '- main ', + ' - \\#1 :point\\_left:', + '', + '* my list', + ' - should survive', + '', + ].join('\n') + const output = ['- main ', ' - \\#2 :point\\_left:'].join('\n') + + const actual = injectVisualization(output, description) + const expected = [ + '## Description', + '', + '## Stack', + '', + '- main ', + ' - \\#2 :point\\_left:', + '', + '* my list', + ' - should survive', + '', + ].join('\n') + + expect(actual).toEqual(expected) + }) +}) diff --git a/src/renderer.ts b/src/renderer.ts new file mode 100644 index 0000000..b9b03da --- /dev/null +++ b/src/renderer.ts @@ -0,0 +1,164 @@ +import type { DirectedGraph } from 'graphology' +import { remark as createRemark } from 'remark' +import gfm from 'remark-gfm' +import { topologicalSort } from 'graphology-dag' +import { dfsFromNode } from 'graphology-traversal' +import type { ListItem, Root } from 'mdast' +import { type StackNodeAttributes } from './types' + +export const ANCHOR = '' +export const PULL_REQUEST_NODE_REGEX = /#\d+ :point_left:/ + +export const remark = createRemark().use(gfm).data('settings', { + bullet: '-', +}) + +export function renderVisualization( + graph: DirectedGraph, + terminatingRefs: string[] +) { + const lines: string[] = [] + + // `dfs` is bugged and doesn't traverse in topological order. + // `dfsFromNode` does, so we'll do the topological sort ourselves + // start traversal from the root. + const rootRef = topologicalSort(graph)[0] + + dfsFromNode( + graph, + rootRef, + (_, stackNode, depth) => { + if (!stackNode.shouldPrint) return + + const tabSize = depth * 2 + const indentation = new Array(tabSize).fill(' ').join('') + + let line = indentation + + if (stackNode.type === 'orphan-branch') { + line += `- \`${stackNode.ref}\` - :warning: No PR associated with branch` + } + + if (stackNode.type === 'perennial' && terminatingRefs.includes(stackNode.ref)) { + line += `- \`${stackNode.ref}\`` + } + + if (stackNode.type === 'pull-request') { + line += `- #${stackNode.number}` + } + + if (stackNode.isCurrent) { + line += ' :point_left:' + } + + if (depth === 0) { + line += ` ${ANCHOR}` + } + + lines.push(line) + }, + { mode: 'directed' } + ) + + return lines.join('\n') +} + +export function injectVisualization(visualization: string, content: string) { + const contentAst = remark.parse(content) + const visualizationAst = remark.parse(visualization) + + const standaloneAnchorIndex = contentAst.children.findIndex( + (node) => node.type === 'html' && node.value === ANCHOR + ) + + if (standaloneAnchorIndex >= 0) { + removeUnanchoredBranchStack(contentAst) + + contentAst.children.splice(standaloneAnchorIndex, 1, ...visualizationAst.children) + return remark.stringify(contentAst) + } + + const inlineAnchorIndex = findInlineAnchor(contentAst) + + const isMissingAnchor = inlineAnchorIndex === -1 + if (isMissingAnchor) { + removeUnanchoredBranchStack(contentAst) + + contentAst.children.push(...visualizationAst.children) + return remark.stringify(contentAst) + } + + contentAst.children.splice(inlineAnchorIndex, 1, ...visualizationAst.children) + return remark.stringify(contentAst) +} + +function removeUnanchoredBranchStack(descriptionAst: Root) { + const branchStackIndex = descriptionAst.children.findIndex( + function matchesBranchStackHeuristic(node) { + if (node.type !== 'list') { + return false + } + + const child = node.children[0] + if (node.children.length !== 1 || !child) { + return false + } + + const result = containsPullRequestNode(child) + + return result + } + ) + + if (branchStackIndex === -1) { + return + } + + descriptionAst.children.splice(branchStackIndex, 1) +} + +function containsPullRequestNode(listItem: ListItem) { + return listItem.children.some((node) => { + if (node.type === 'list' && node.children.length > 0) { + return node.children.some(containsPullRequestNode) + } + + if (node.type !== 'paragraph') { + return false + } + + const result = node.children.some( + (child) => child.type === 'text' && PULL_REQUEST_NODE_REGEX.test(child.value) + ) + + return result + }) +} + +function findInlineAnchor(descriptionAst: Root): number { + return descriptionAst.children.findIndex((node) => { + if (node.type !== 'list') { + return + } + + return node.children.some(containsAnchor) + }) +} + +function containsAnchor(listItem: ListItem): boolean { + return listItem.children.some((node) => { + if (node.type === 'list') { + return node.children.some(containsAnchor) + } + + if (node.type !== 'paragraph') { + return false + } + + const result = node.children.some( + (child) => child.type === 'html' && child.value === ANCHOR + ) + + return result + }) +}