Breakdance: Automating parts of our SDLC

At WorkTango, a pull request (PR) moves through a predictable lifecycle:

  1. Developer opens a PR against the main branch (master, in our case)
  2. CI runs (tests, linting, type checking)
  3. At least one approving review required (more for certain files via CODEOWNERS)
  4. All status checks must pass
  5. When ready, the developer adds a GitHub label (breakdance) to request auto-merge
  6. On merge: commits are squashed into a single commit
  7. Commit title = PR title (must contain a Jira ticket, e.g., WT-12345)
  8. Commit description = extracted from the PR body’s “Commit Message” section

This keeps our git history clean: one commit per PR, meaningful messages, traceable to Jira.

The challenge was automating steps 5 through 8 reliably, with visibility into what’s happening.

Mergify handled this well for years. But in 2025, we wanted something we could see inside, tweak to our exact workflow, and didn’t cost anything beyond the CI minutes we were already paying for.

So we replaced it with a script and a GitHub Action. At WorkTango, we call it Breakdance.

The name is on-brand for us, but it’s also a reference to how the merge process works. One pull request steps into the circle at a time. It does its thing: CI runs, checks pass, merge happens. Meanwhile, everyone else waits their turn. Then, the next PR steps in. One at a time, each getting their moment.

Why we left Mergify

Mergify is a solid product. It auto-merged our PRs for years. Configuration was declarative and mostly intuitive. Support was responsive when we had questions.

So why move on?

We weren’t using the advanced features. Mergify has merge queues, automatic rebasing, smart scheduling, conditional rules. We used… automatic rebasing and labels.

We wanted to see inside the box. When something didn’t merge, the debugging experience was: check the Mergify dashboard, squint at logs, wonder if it was a race condition or a config issue.

We wanted to customize. Our PR title validation and commit message extraction relied on their template system. It worked, but we had ideas that didn’t fit their config model.

CI time is cheap. GitHub Actions minutes are essentially free for our usage. The Mergify subscription wasn’t expensive, but free is hard to beat when you’re not using premium features.

Ultimately, we only really needed a script that runs after a PR’s status checks pass, updates the PR if it’s behind our main branch, merges if it’s ready, and posts a comment if there’s a problem. That’s it.

Not a process worth paying to automate, at least at our scale.

How it works

When triggered, Breakdance processes all labeled PRs. The flow:

flowchart TD
    A[Fetch all open PRs] --> B[Filter to labeled PRs]
    B --> C{For each PR}
    C --> D{Is Draft?}
    D -->|Yes| E[Remove label, skip]
    D -->|No| F{Valid title?}
    F -->|No| G[Post error comment]
    F -->|Yes| H{Valid body?}
    H -->|No| I[Post error comment]
    H -->|Yes| J{Mergeable state?}
    J -->|behind| K[Rebase branch, skip]
    J -->|dirty| L[Post conflict error]
    J -->|blocked| M[Skip - wait for CI/reviews]
    J -->|clean/unstable| N[Extract commit message]
    N --> O[Squash merge to master]

When title or body validation fails, we post a comment explaining exactly what’s wrong—with a hidden HTML signature (<!-- breakdance-bot -->) so we can find and update our own comments later.

We then extract the ## Commit Message section from the PR body. The PR with the title “WT-12345 - Add the thing” with the following body:

1
2
3
4
## WT-12345 - Add the thing
{{ title }}

This PR adds the thing that does the stuff.

Becomes the following commit message:

1
2
3
WT-12345 - Add the thing

This PR adds the thing that does the stuff.

Every destructive action checks a dryRun flag to iterate against this on real PRs in production before going live. We caught several bugs without breaking anything. HIGHLY recommend you do the same.

The thing that usually scares people is the automatic rebasing. But it’s not scary when we only do it when a PR is ready to merge, and the rebase is clean. If it’s a dirty rebase, we force the engineer to rectify it before we can merge.

Triggering

Most of our CI jobs run in CircleCI, but Breakdance runs in GitHub Actions for security reasons.

Our CircleCI jobs are read-only by design and cannot modify the remote in any way (including GitHub things like labels). That’s a deliberate boundary erected by our SRE team that we didn’t want to compromise.

So we set up a handoff: after all checks pass, CircleCI makes an API call to trigger the Breakdance script in GitHub Actions. Most PRs merge within seconds of CI going green.

We also have a scheduled fallback—every 10 minutes during business hours, and hourly overnight. If something goes wrong with the API trigger, the worst case is a short delay.

Was it worth it?

So far so good. It’s been a year of reliably merging PRs for us.

We gained full visibility into why a PR did or didn’t merge; customization we couldn’t get from a config file; zero cost beyond CI minutes; about 500 lines of TypeScript we fully understand and can modify as needed; and one fewer third-party tool involved in our SDLC.

What we gave up: merge queues (we don’t use them) and Mergify’s nice dashboard.

Should you build your own?

If you’re not using advanced features, you want full control, and CI time is cheap, then probably yes. The whole thing took a few hours to build and has been essentially maintenance-free for almost a year now.

If you need merge queues or sophisticated scheduling, or your team doesn’t have bandwidth to own another tool, stick with a provider like Mergify. It’s fine software.


There’s also a middle ground: GitHub’s native auto merge feature and merge queues exists now. If you just need “merge when ready,” that might be enough without any code.

Other version control tools like GitLab and Bitbucket have similar features.

The code

In TypeScript (~500 lines), with proper interfaces and types:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
/**
 * Breakdance: Auto-merge PRs with a specific label when ready.
 *
 * - Removes label from draft PRs
 * - Updates PRs that are behind the base branch
 * - Merges PRs with all checks passing
 * - Validates PR title and body format
 */

import { Octokit } from "@octokit/rest";

const BOT_SIGNATURE = "<!-- breakdance-bot -->";

// =============================================================================
// MAIN FUNCTION
// =============================================================================

export async function breakdance({
  dryRun = true,
  owner,
  repo,
  mergeLabel,
  githubToken,
  titleValidator = defaultPRTitleValidator,
  bodyValidator = defaultPRBodyValidator,
  logger = console,
  extractCommitMessage = defaultExtractCommitMessage,
}: BreakdanceOptions): Promise<void> {
  if (dryRun) {
    logger.warn("Running in dry-run mode - no changes will be made");
  }

  owner = owner ?? process.env.GITHUB_REPOSITORY?.split("/")[0];
  repo = repo ?? process.env.GITHUB_REPOSITORY?.split("/")[1];
  githubToken = githubToken ?? process.env.GITHUB_TOKEN;

  if (!owner || !repo || !githubToken) {
    throw new Error("Missing required configuration: owner, repo, or token");
  }

  const octokit = new Octokit({ auth: githubToken });
  const labels = Array.isArray(mergeLabel) ? mergeLabel : [mergeLabel];

  // Fetch and filter PRs
  const pulls = await getAllPullRequests({ octokit, owner, repo, logger });
  const labeledPulls = pulls.filter((pull) =>
    pull.labels.some((label) => labels.includes(label.name))
  );

  logger.debug(`Found ${labeledPulls.length} PRs with matching labels`);

  for (const pull of labeledPulls) {
    // Skip drafts (and remove label)
    if (pull.draft) {
      logger.warn(`PR #${pull.number} is a draft, removing label`);
      if (!dryRun) {
        for (const label of labels) {
          await octokit.issues.removeLabel({
            owner,
            repo,
            issue_number: pull.number,
            name: label,
          });
        }
      }
      continue;
    }

    // Validate title
    const titleValidation = titleValidator({ title: pull.title });
    if (!titleValidation.isValid) {
      logger.warn(`PR #${pull.number} failed title validation`);
      await postValidationError({
        octokit, owner, repo,
        prNumber: pull.number,
        message: titleValidation.message,
        dryRun, logger,
      });
      continue;
    }

    // Validate body
    const bodyValidation = bodyValidator({ body: pull.body || "" });
    if (!bodyValidation.isValid) {
      logger.warn(`PR #${pull.number} failed body validation`);
      await postValidationError({
        octokit, owner, repo,
        prNumber: pull.number,
        message: bodyValidation.message,
        dryRun, logger,
      });
      continue;
    }

    // Check mergeable state
    const { data: pullDetails } = await octokit.pulls.get({
      owner,
      repo,
      pull_number: pull.number,
    });

    if (pullDetails.mergeable_state === "behind") {
      logger.warn(`PR #${pull.number} is behind, updating branch`);
      if (!dryRun) {
        await octokit.pulls.updateBranch({
          owner,
          repo,
          pull_number: pull.number,
          update_method: "merge",
        });
      }
      continue;
    }

    if (pullDetails.mergeable_state === "dirty") {
      await postValidationError({
        octokit, owner, repo,
        prNumber: pull.number,
        message: "This PR has merge conflicts. Please resolve them.",
        dryRun, logger,
      });
      continue;
    }

    if (pullDetails.mergeable_state !== "clean" &&
        pullDetails.mergeable_state !== "unstable") {
      logger.warn(`PR #${pull.number} not mergeable: ${pullDetails.mergeable_state}`);
      continue;
    }

    // Extract commit message and merge
    const commitMessage = await extractCommitMessage({
      description: pull.body || "",
      prNumber: pull.number,
      prTitle: pull.title,
    });

    if (!dryRun) {
      await octokit.pulls.merge({
        owner,
        repo,
        pull_number: pull.number,
        commit_title: commitMessage,
        merge_method: "squash",
      });
      logger.log(`Successfully merged PR #${pull.number}`);
    }
  }
}

// =============================================================================
// PAGINATION
// =============================================================================

async function getAllPullRequests({
  octokit, owner, repo, logger,
}: {
  octokit: Octokit;
  owner: string;
  repo: string;
  logger: Logger;
}) {
  const allPulls = [];
  let page = 1;

  while (page < 100) {
    logger.debug(`Fetching page ${page} of pull requests...`);
    const { data: pulls } = await octokit.pulls.list({
      owner,
      repo,
      state: "open",
      sort: "created",
      direction: "asc",
      per_page: 100,
      page,
    });

    allPulls.push(...pulls);
    if (pulls.length < 100) break;
    page++;
  }

  return allPulls;
}

// =============================================================================
// VALIDATION
// =============================================================================

function defaultPRTitleValidator({ title }: ValidatePRTitleParams): ValidationResult {
  const hasTicketNumber = /[A-Z]+-\d+/.test(title);
  return {
    isValid: hasTicketNumber,
    message: hasTicketNumber
      ? "Title format is valid"
      : "PR title must contain a ticket number (e.g., WT-12345)",
  };
}

function defaultPRBodyValidator({ body }: ValidatePRBodyParams): ValidationResult {
  if (!body) {
    return { isValid: false, message: "PR body is empty." };
  }

  const lines = body.split(/\r?\n/);
  const commitSectionIndex = lines.findIndex((line) =>
    line.includes("## Commit Message")
  );

  if (commitSectionIndex === -1) {
    return { isValid: false, message: "PR must have a '## Commit Message' section." };
  }

  const nextHeadingIndex = lines.findIndex((line, i) => {
    if (i <= commitSectionIndex) return false;
    return line.trim().startsWith("# ") || line.trim().startsWith("## ");
  });

  const endIndex = nextHeadingIndex === -1 ? lines.length : nextHeadingIndex;
  const commitSection = lines.slice(commitSectionIndex + 1, endIndex).join("\n").trim();

  if (commitSection.length < 10) {
    return { isValid: false, message: "Commit message too short." };
  }

  if (!commitSection.includes("{{ title }}")) {
    return { isValid: false, message: "Commit message must include {{ title }}." };
  }

  return { isValid: true, message: "Valid" };
}

// =============================================================================
// COMMENT MANAGEMENT
// =============================================================================

const commentCache = new Map<string, IssueComment[]>();

async function getAllPRComments({
  octokit, owner, repo, prNumber,
}: {
  octokit: Octokit;
  owner: string;
  repo: string;
  prNumber: number;
}): Promise<IssueComment[]> {
  const cacheKey = `${owner}/${repo}/${prNumber}`;
  if (commentCache.has(cacheKey)) {
    return commentCache.get(cacheKey)!;
  }

  const allComments: IssueComment[] = [];
  let page = 1;

  while (page < 10) {
    const { data: comments } = await octokit.issues.listComments({
      owner,
      repo,
      issue_number: prNumber,
      per_page: 100,
      page,
    });
    allComments.push(...comments);
    if (comments.length < 100) break;
    page++;
  }

  commentCache.set(cacheKey, allComments);
  return allComments;
}

async function removePreviousBotComments({
  octokit, owner, repo, prNumber, dryRun, logger,
}: RemovePreviousBotCommentsParams): Promise<void> {
  const comments = await getAllPRComments({ octokit, owner, repo, prNumber });
  const botComments = comments.filter((c) => c.body?.includes(BOT_SIGNATURE));

  for (const comment of botComments) {
    if (!dryRun) {
      await octokit.issues.deleteComment({ owner, repo, comment_id: comment.id });
    } else {
      logger.warn(`[DRY RUN] Would delete comment ${comment.id}`);
    }
  }
}

async function postValidationError({
  octokit, owner, repo, prNumber, message, dryRun, logger,
}: PostValidationErrorParams): Promise<void> {
  const commentBody = `⚠️ Breakdance Validation Error\n\n${message}\n\n${BOT_SIGNATURE}`;

  if (!dryRun) {
    const comments = await getAllPRComments({ octokit, owner, repo, prNumber });

    // Skip if identical comment exists
    if (comments.some((c) => c.body === commentBody)) {
      logger.debug(`Skipping duplicate comment on PR #${prNumber}`);
      return;
    }

    await removePreviousBotComments({ octokit, owner, repo, prNumber, dryRun, logger });
    await octokit.issues.createComment({ owner, repo, issue_number: prNumber, body: commentBody });
  } else {
    logger.warn(`[DRY RUN] Would post comment on PR #${prNumber}`);
  }
}

// =============================================================================
// COMMIT MESSAGE EXTRACTION
// =============================================================================

function defaultExtractCommitMessage({
  description, prNumber, prTitle,
}: ExtractCommitMessageParams): string {
  const section = description
    .split("##")
    .find((s) => s.trim().startsWith("Commit Message"));

  if (!section) return prTitle;

  return section
    .replace("Commit Message", "")
    .trim()
    .replace("{{ number }}", String(prNumber))
    .replace("{{ title }}", prTitle);
}

// =============================================================================
// TYPES
// =============================================================================

type IssueComment = { id: number; body?: string };

interface Logger {
  log: (message: string, ...args: unknown[]) => void;
  error: (message: string, ...args: unknown[]) => void;
  warn: (message: string, ...args: unknown[]) => void;
  debug: (message: string, ...args: unknown[]) => void;
}

interface ValidationResult {
  isValid: boolean;
  message: string;
}

interface ValidatePRTitleParams { title: string }
interface ValidatePRBodyParams { body: string }

interface RemovePreviousBotCommentsParams {
  octokit: Octokit;
  owner: string;
  repo: string;
  prNumber: number;
  dryRun: boolean;
  logger: Logger;
}

interface PostValidationErrorParams extends RemovePreviousBotCommentsParams {
  message: string;
}

interface ExtractCommitMessageParams {
  description: string;
  prNumber: number;
  prTitle: string;
}

interface BreakdanceOptions {
  dryRun?: boolean;
  owner?: string;
  repo?: string;
  mergeLabel: string | string[];
  githubToken?: string;
  titleValidator?: (params: ValidatePRTitleParams) => ValidationResult;
  bodyValidator?: (params: ValidatePRBodyParams) => ValidationResult;
  logger?: Logger;
  extractCommitMessage?: (params: ExtractCommitMessageParams) => string | Promise<string>;
}

And, in Vanilla JavaScript (~200 lines), if you prefer:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
/**
 * Breakdance: Auto-merge PRs with a specific label when ready.
 * No dependencies version (Vanilla Node.js)
 */

const https = require('https');

const BOT_SIGNATURE = "<!-- breakdance-bot -->";

// =============================================================================
// MAIN LOGIC
// =============================================================================

async function breakdance({
  dryRun = true,
  owner = process.env.GITHUB_REPOSITORY?.split("/")[0],
  repo = process.env.GITHUB_REPOSITORY?.split("/")[1],
  mergeLabel,
  githubToken = process.env.GITHUB_TOKEN,
  titleValidator = defaultPRTitleValidator,
  bodyValidator = defaultPRBodyValidator,
  logger = console,
  extractCommitMessage = defaultExtractCommitMessage,
} = {}) {
  if (dryRun) logger.warn("Running in dry-run mode - no changes will be made");

  if (!owner || !repo || !githubToken) {
    throw new Error("Missing configuration: owner, repo, or token");
  }

  const labels = Array.isArray(mergeLabel) ? mergeLabel : [mergeLabel];

  // 1. Fetch PRs
  const pulls = await getAllPullRequests(owner, repo, githubToken, logger);
  const labeledPulls = pulls.filter((pull) =>
    pull.labels.some((l) => labels.includes(l.name))
  );

  logger.log(`Found ${labeledPulls.length} PRs with matching labels`);

  for (const pull of labeledPulls) {
    // Skip drafts
    if (pull.draft) {
      logger.warn(`PR #${pull.number} is a draft, removing label`);
      if (!dryRun) {
        for (const labelName of labels) {
          try {
            await request(`/repos/${owner}/${repo}/issues/${pull.number}/labels/${labelName}`, 'DELETE', null, githubToken);
          } catch (e) { /* ignore if label already gone */ }
        }
      }
      continue;
    }

    // Validation
    const titleVal = titleValidator({ title: pull.title });
    if (!titleVal.isValid) {
      await postValidationError({ owner, repo, prNumber: pull.number, message: titleVal.message, dryRun, logger, githubToken });
      continue;
    }

    const bodyVal = bodyValidator({ body: pull.body || "" });
    if (!bodyVal.isValid) {
      await postValidationError({ owner, repo, prNumber: pull.number, message: bodyVal.message, dryRun, logger, githubToken });
      continue;
    }

    // Check Mergeability
    const pullDetails = await request(`/repos/${owner}/${repo}/pulls/${pull.number}`, 'GET', null, githubToken);

    if (pullDetails.mergeable_state === "behind") {
      logger.warn(`PR #${pull.number} is behind, updating branch`);
      if (!dryRun) {
        await request(`/repos/${owner}/${repo}/pulls/${pull.number}/update-branch`, 'PUT', {}, githubToken);
      }
      continue;
    }

    if (pullDetails.mergeable_state === "dirty") {
      await postValidationError({ owner, repo, prNumber: pull.number, message: "Merge conflicts detected.", dryRun, logger, githubToken });
      continue;
    }

    if (pullDetails.mergeable_state !== "clean" && pullDetails.mergeable_state !== "unstable") {
      logger.warn(`PR #${pull.number} state: ${pullDetails.mergeable_state}. Skipping.`);
      continue;
    }

    // Merge
    const commitMessage = await extractCommitMessage({
      description: pull.body || "",
      prNumber: pull.number,
      prTitle: pull.title,
    });

    if (!dryRun) {
      await request(`/repos/${owner}/${repo}/pulls/${pull.number}/merge`, 'PUT', {
        commit_title: commitMessage,
        merge_method: "squash",
      }, githubToken);
      logger.log(`Successfully merged PR #${pull.number}`);
    } else {
      logger.log(`[DRY RUN] Would merge PR #${pull.number} with message: ${commitMessage}`);
    }
  }
}

// =============================================================================
// GITHUB API CLIENT
// =============================================================================

async function request(path, method = 'GET', body = null, token) {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: 'api.github.com',
      port: 443,
      path,
      method,
      headers: {
        'Authorization': `token ${token}`,
        'User-Agent': 'node-js-http-client',
        'Accept': 'application/vnd.github.v3+json',
        'Content-Type': 'application/json'
      }
    };

    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => data += chunk);
      res.on('end', () => {
        if (res.statusCode >= 400) {
          return reject(new Error(`GitHub API Error: ${res.statusCode} - ${data}`));
        }
        resolve(data ? JSON.parse(data) : null);
      });
    });

    req.on('error', reject);
    if (body) req.write(JSON.stringify(body));
    req.end();
  });
}

// =============================================================================
// HELPERS
// =============================================================================

async function getAllPullRequests(owner, repo, token, logger) {
  const allPulls = [];
  let page = 1;
  while (page < 10) {
    const pulls = await request(`/repos/${owner}/${repo}/pulls?state=open&per_page=100&page=${page}`, 'GET', null, token);
    allPulls.push(...pulls);
    if (pulls.length < 100) break;
    page++;
  }
  return allPulls;
}

function defaultPRTitleValidator({ title }) {
  const hasTicket = /[A-Z]+-\d+/.test(title);
  return { isValid: hasTicket, message: hasTicket ? "" : "Title needs a ticket (e.g. WT-123)" };
}

function defaultPRBodyValidator({ body }) {
  if (!body) return { isValid: false, message: "Empty body" };
  const hasCommitSection = body.includes("## Commit Message");
  const hasTitleTag = body.includes("{{ title }}");
  if (!hasCommitSection) return { isValid: false, message: "Missing '## Commit Message' section" };
  if (!hasTitleTag) return { isValid: false, message: "Commit message must contain {{ title }}" };
  return { isValid: true, message: "" };
}

async function postValidationError({ owner, repo, prNumber, message, dryRun, logger, githubToken }) {
  const commentBody = `⚠️ Breakdance Validation Error\n\n${message}\n\n${BOT_SIGNATURE}`;
  if (dryRun) return logger.warn(`[DRY RUN] Would comment on #${prNumber}: ${message}`);

  const comments = await request(`/repos/${owner}/${repo}/issues/${prNumber}/comments`, 'GET', null, githubToken);
  
  if (comments.some(c => c.body === commentBody)) return; // Duplicate

  // Remove old bot comments
  const botComments = comments.filter(c => c.body.includes(BOT_SIGNATURE));
  for (const c of botComments) {
    await request(`/repos/${owner}/${repo}/issues/comments/${c.id}`, 'DELETE', null, githubToken);
  }

  await request(`/repos/${owner}/${repo}/issues/${prNumber}/comments`, 'POST', { body: commentBody }, githubToken);
}

function defaultExtractCommitMessage({ description, prNumber, prTitle }) {
  const lines = description.split(/\r?\n/);
  const idx = lines.findIndex(l => l.includes("## Commit Message"));
  if (idx === -1) return prTitle;

  const msg = lines.slice(idx + 1).join("\n").trim().split("##")[0].trim();
  return msg
    .replace("{{ number }}", String(prNumber))
    .replace("{{ title }}", prTitle);
}

// =============================================================================
// EXECUTION
// =============================================================================

// Example Usage:
// breakdance({ mergeLabel: 'automerge', dryRun: true }).catch(console.error);

module.exports = { breakdance };