From 7de8ed82bc793f232ea4106543da82085689f20f Mon Sep 17 00:00:00 2001 From: Itay Maman <94941+imaman@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:46:40 +0300 Subject: [PATCH 01/10] nit --- src/dcc-cli.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/dcc-cli.ts b/src/dcc-cli.ts index 53d677f..7b76906 100644 --- a/src/dcc-cli.ts +++ b/src/dcc-cli.ts @@ -1,4 +1,6 @@ #!/usr/bin/env node + +// A import * as fs from 'fs' import * as path from 'path' import * as os from 'os' From 4e515b981e690dfda2fb5f599982241c41f1e1d3 Mon Sep 17 00:00:00 2001 From: Itay Maman <94941+imaman@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:48:14 +0300 Subject: [PATCH 02/10] b --- src/dcc-cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dcc-cli.ts b/src/dcc-cli.ts index 7b76906..3f40830 100644 --- a/src/dcc-cli.ts +++ b/src/dcc-cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -// A +// B import * as fs from 'fs' import * as path from 'path' import * as os from 'os' From 42dc51aae474bbee30e4db60c66ddba3daae33b8 Mon Sep 17 00:00:00 2001 From: Itay Maman <94941+imaman@users.noreply.github.com> Date: Thu, 7 May 2026 23:36:32 +0300 Subject: [PATCH 03/10] A --- src/dcc-cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dcc-cli.ts b/src/dcc-cli.ts index 3f40830..7b76906 100644 --- a/src/dcc-cli.ts +++ b/src/dcc-cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -// B +// A import * as fs from 'fs' import * as path from 'path' import * as os from 'os' From b92f390d228fab1c7c4cececc9b6d99bf68700c7 Mon Sep 17 00:00:00 2001 From: Itay Maman <94941+imaman@users.noreply.github.com> Date: Thu, 7 May 2026 23:37:38 +0300 Subject: [PATCH 04/10] del --- src/dcc-cli.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/dcc-cli.ts b/src/dcc-cli.ts index 7b76906..53d677f 100644 --- a/src/dcc-cli.ts +++ b/src/dcc-cli.ts @@ -1,6 +1,4 @@ #!/usr/bin/env node - -// A import * as fs from 'fs' import * as path from 'path' import * as os from 'os' From 29e6f98092d6eff18cb6cb1b64d51ebfd2fb1ca7 Mon Sep 17 00:00:00 2001 From: Itay Maman <94941+imaman@users.noreply.github.com> Date: Thu, 7 May 2026 23:53:25 +0300 Subject: [PATCH 05/10] fix --- src/dcc-cli.ts | 2 +- src/gql.ts | 31 +++++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/dcc-cli.ts b/src/dcc-cli.ts index 53d677f..74ca0e3 100644 --- a/src/dcc-cli.ts +++ b/src/dcc-cli.ts @@ -32,7 +32,7 @@ const octoKit = new Octokit({ auth: token }) const gitOps = new GitOps(simpleGit()) const githubOps = new GithubOps(octoKit, gitOps, parsed.prLabels ?? []) -const graphqlOps = new GraphqlOps(parsed, gitOps) +const graphqlOps = new GraphqlOps(parsed, gitOps, octoKit) function print(...args: string[]) { logger.info(args.join(' ')) diff --git a/src/gql.ts b/src/gql.ts index 0dd3110..dd64953 100644 --- a/src/gql.ts +++ b/src/gql.ts @@ -1,6 +1,7 @@ import { GitOps } from './git-ops.js' import { createTokenAuth } from '@octokit/auth-token' import * as octokit from '@octokit/graphql' +import { Octokit } from '@octokit/rest' import { logger } from './logger.js' import { DccConfig } from './dcc-config.js' @@ -23,7 +24,7 @@ export interface CurrentPrInfo { export class GraphqlOps { private readonly authedGraphql - constructor(private readonly dccConfig: DccConfig, private readonly gitOps: GitOps) { + constructor(private readonly dccConfig: DccConfig, private readonly gitOps: GitOps, private readonly kit: Octokit) { const auth = createTokenAuth(dccConfig.token) this.authedGraphql = octokit.graphql.defaults({ @@ -33,6 +34,30 @@ export class GraphqlOps { }) } + private async getRulesetRequiredChecks(owner: string, name: string, branch: string): Promise { + try { + const resp = await this.kit.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { + owner, + repo: name, + branch, + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rules = (resp.data as any[]) ?? [] + const contexts: string[] = [] + for (const rule of rules) { + if (rule?.type !== 'required_status_checks') continue + const list = rule?.parameters?.required_status_checks ?? [] + for (const c of list) { + if (typeof c?.context === 'string') contexts.push(c.context) + } + } + return contexts + } catch (err) { + logger.silly(`getRulesetRequiredChecks failed: ${err}`) + return [] + } + } + async enableAutoMerge(pr: CurrentPrInfo): Promise { const m = ` mutation MyMutation { @@ -201,9 +226,11 @@ export class GraphqlOps { const mainBranch = await this.gitOps.mainBranch() const protectionRules = repository?.branchProtectionRules?.nodes ?? [] - const requiredChecks = protectionRules + const fromProtection = protectionRules .filter(rule => rule.requiresStatusChecks && rule.matchingRefs.nodes.some(ref => ref.name === mainBranch)) .flatMap(rule => rule.requiredStatusCheckContexts) + const fromRulesets = await this.getRulesetRequiredChecks(repo.owner, repo.name, mainBranch) + const requiredChecks = [...new Set([...fromProtection, ...fromRulesets])] let openUrl: string let url: string From 6f67b8447dde143bd16b9fbdb534f4fea6cc3f5e Mon Sep 17 00:00:00 2001 From: Itay Maman <94941+imaman@users.noreply.github.com> Date: Thu, 7 May 2026 23:57:53 +0300 Subject: [PATCH 06/10] zod --- src/gql.ts | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/gql.ts b/src/gql.ts index dd64953..25739b8 100644 --- a/src/gql.ts +++ b/src/gql.ts @@ -2,9 +2,24 @@ import { GitOps } from './git-ops.js' import { createTokenAuth } from '@octokit/auth-token' import * as octokit from '@octokit/graphql' import { Octokit } from '@octokit/rest' +import { z } from 'zod' import { logger } from './logger.js' import { DccConfig } from './dcc-config.js' +const BranchRulesSchema = z.array( + z + .object({ + type: z.string(), + parameters: z + .object({ + required_status_checks: z.array(z.object({ context: z.string() }).passthrough()).optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(), +) + type MergeabilityStatus = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' export interface CurrentPrInfo { id: string @@ -41,18 +56,14 @@ export class GraphqlOps { repo: name, branch, }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rules = (resp.data as any[]) ?? [] - const contexts: string[] = [] - for (const rule of rules) { - if (rule?.type !== 'required_status_checks') continue - const list = rule?.parameters?.required_status_checks ?? [] - for (const c of list) { - if (typeof c?.context === 'string') contexts.push(c.context) - } - } - return contexts + const rules = BranchRulesSchema.parse(resp.data) + return rules + .filter(r => r.type === 'required_status_checks') + .flatMap(r => r.parameters?.required_status_checks ?? []) + .map(c => c.context) } catch (err) { + // Token may lack the scope to read rulesets, or the host may be an older GHE without this endpoint. + // Degrade to "no ruleset-derived required checks" so callers fall back to branchProtectionRules only. logger.silly(`getRulesetRequiredChecks failed: ${err}`) return [] } From c239a07f3f83a42685aae54247ab9fafafcad98c Mon Sep 17 00:00:00 2001 From: Itay Maman <94941+imaman@users.noreply.github.com> Date: Fri, 8 May 2026 00:03:10 +0300 Subject: [PATCH 07/10] split --- src/dcc-cli.ts | 12 ++++++++---- src/github-ops.ts | 36 ++++++++++++++++++++++++++++++++++++ src/gql.ts | 42 ++---------------------------------------- 3 files changed, 46 insertions(+), 44 deletions(-) diff --git a/src/dcc-cli.ts b/src/dcc-cli.ts index 74ca0e3..aca4457 100644 --- a/src/dcc-cli.ts +++ b/src/dcc-cli.ts @@ -32,7 +32,7 @@ const octoKit = new Octokit({ auth: token }) const gitOps = new GitOps(simpleGit()) const githubOps = new GithubOps(octoKit, gitOps, parsed.prLabels ?? []) -const graphqlOps = new GraphqlOps(parsed, gitOps, octoKit) +const graphqlOps = new GraphqlOps(parsed, gitOps) function print(...args: string[]) { logger.info(args.join(' ')) @@ -177,7 +177,9 @@ async function submit() { } const checks = await githubOps.getChecks(pr?.number) - const isRequired = (c: Check) => pr.requiredChecks.includes(c.name) + const fromRulesets = await githubOps.getRulesetRequiredChecks(await gitOps.mainBranch()) + const required = new Set([...pr.requiredChecks, ...fromRulesets]) + const isRequired = (c: Check) => required.has(c.name) const printChecksWithRequired = (list: Check[]) => { for (const c of list) { printCheck(c, isRequired(c)) @@ -262,6 +264,8 @@ async function status() { print('No PR was created for this branch') } else { const checks: Check[] = await githubOps.getChecks(pr?.number) + const fromRulesets = await githubOps.getRulesetRequiredChecks(await gitOps.mainBranch()) + const required = new Set([...pr.requiredChecks, ...fromRulesets]) print(`PR #${pr.number}: ${pr.title}`) print(pr.url) if (pr.lastCommit) { @@ -280,9 +284,9 @@ async function status() { const orderOfCheck = (c: Check) => c.tag === 'PASSING' ? 0 : c.tag === 'PENDING' ? 1 : c.tag === 'FAILING' ? 2 : shouldNeverHappen(c) for (const c of checks.sort((a, b) => orderOfCheck(a) - orderOfCheck(b))) { - printCheck(c, pr.requiredChecks.includes(c.name)) + printCheck(c, required.has(c.name)) } - const numRequired = checks.filter(c => pr.requiredChecks.includes(c.name)).length + const numRequired = checks.filter(c => required.has(c.name)).length print(` (${numRequired}/${checks.length} are required 🔒)`) print() } diff --git a/src/github-ops.ts b/src/github-ops.ts index 0a1cec9..60a6277 100644 --- a/src/github-ops.ts +++ b/src/github-ops.ts @@ -1,7 +1,22 @@ import { GitOps } from './git-ops.js' import { Octokit } from '@octokit/rest' +import { z } from 'zod' import { logger } from './logger.js' +const BranchRulesSchema = z.array( + z + .object({ + type: z.string(), + parameters: z + .object({ + required_status_checks: z.array(z.object({ context: z.string() }).passthrough()).optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(), +) + export type Check = | { tag: 'FAILING' @@ -44,6 +59,27 @@ export class GithubOps { return [...pending, ...passing, ...failing] } + async getRulesetRequiredChecks(branch: string): Promise { + const r = await this.gitOps.getRepo() + try { + const resp = await this.kit.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { + owner: r.owner, + repo: r.name, + branch, + }) + const rules = BranchRulesSchema.parse(resp.data) + return rules + .filter(rule => rule.type === 'required_status_checks') + .flatMap(rule => rule.parameters?.required_status_checks ?? []) + .map(c => c.context) + } catch (err) { + // Token may lack the scope to read rulesets, or the host may be an older GHE without this endpoint. + // Degrade to "no ruleset-derived required checks" so callers fall back to branchProtectionRules only. + logger.silly(`getRulesetRequiredChecks failed: ${err}`) + return [] + } + } + async merge(prNumber: number): Promise { const r = await this.gitOps.getRepo() await this.kit.pulls.merge({ owner: r.owner, repo: r.name, pull_number: prNumber, merge_method: 'squash' }) diff --git a/src/gql.ts b/src/gql.ts index 25739b8..0dd3110 100644 --- a/src/gql.ts +++ b/src/gql.ts @@ -1,25 +1,9 @@ import { GitOps } from './git-ops.js' import { createTokenAuth } from '@octokit/auth-token' import * as octokit from '@octokit/graphql' -import { Octokit } from '@octokit/rest' -import { z } from 'zod' import { logger } from './logger.js' import { DccConfig } from './dcc-config.js' -const BranchRulesSchema = z.array( - z - .object({ - type: z.string(), - parameters: z - .object({ - required_status_checks: z.array(z.object({ context: z.string() }).passthrough()).optional(), - }) - .passthrough() - .optional(), - }) - .passthrough(), -) - type MergeabilityStatus = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' export interface CurrentPrInfo { id: string @@ -39,7 +23,7 @@ export interface CurrentPrInfo { export class GraphqlOps { private readonly authedGraphql - constructor(private readonly dccConfig: DccConfig, private readonly gitOps: GitOps, private readonly kit: Octokit) { + constructor(private readonly dccConfig: DccConfig, private readonly gitOps: GitOps) { const auth = createTokenAuth(dccConfig.token) this.authedGraphql = octokit.graphql.defaults({ @@ -49,26 +33,6 @@ export class GraphqlOps { }) } - private async getRulesetRequiredChecks(owner: string, name: string, branch: string): Promise { - try { - const resp = await this.kit.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { - owner, - repo: name, - branch, - }) - const rules = BranchRulesSchema.parse(resp.data) - return rules - .filter(r => r.type === 'required_status_checks') - .flatMap(r => r.parameters?.required_status_checks ?? []) - .map(c => c.context) - } catch (err) { - // Token may lack the scope to read rulesets, or the host may be an older GHE without this endpoint. - // Degrade to "no ruleset-derived required checks" so callers fall back to branchProtectionRules only. - logger.silly(`getRulesetRequiredChecks failed: ${err}`) - return [] - } - } - async enableAutoMerge(pr: CurrentPrInfo): Promise { const m = ` mutation MyMutation { @@ -237,11 +201,9 @@ export class GraphqlOps { const mainBranch = await this.gitOps.mainBranch() const protectionRules = repository?.branchProtectionRules?.nodes ?? [] - const fromProtection = protectionRules + const requiredChecks = protectionRules .filter(rule => rule.requiresStatusChecks && rule.matchingRefs.nodes.some(ref => ref.name === mainBranch)) .flatMap(rule => rule.requiredStatusCheckContexts) - const fromRulesets = await this.getRulesetRequiredChecks(repo.owner, repo.name, mainBranch) - const requiredChecks = [...new Set([...fromProtection, ...fromRulesets])] let openUrl: string let url: string From a1e3019e76c36d77cfa8a060a0d55e63d0d2758c Mon Sep 17 00:00:00 2001 From: Itay Maman <94941+imaman@users.noreply.github.com> Date: Fri, 8 May 2026 00:15:18 +0300 Subject: [PATCH 08/10] refactoe --- src/dcc-cli.ts | 6 ++---- src/github-ops.ts | 5 +++++ src/gql.ts | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/dcc-cli.ts b/src/dcc-cli.ts index aca4457..813ac98 100644 --- a/src/dcc-cli.ts +++ b/src/dcc-cli.ts @@ -177,8 +177,7 @@ async function submit() { } const checks = await githubOps.getChecks(pr?.number) - const fromRulesets = await githubOps.getRulesetRequiredChecks(await gitOps.mainBranch()) - const required = new Set([...pr.requiredChecks, ...fromRulesets]) + const required = await githubOps.getAllRequiredChecks(await gitOps.mainBranch(), pr) const isRequired = (c: Check) => required.has(c.name) const printChecksWithRequired = (list: Check[]) => { for (const c of list) { @@ -264,8 +263,7 @@ async function status() { print('No PR was created for this branch') } else { const checks: Check[] = await githubOps.getChecks(pr?.number) - const fromRulesets = await githubOps.getRulesetRequiredChecks(await gitOps.mainBranch()) - const required = new Set([...pr.requiredChecks, ...fromRulesets]) + const required = await githubOps.getAllRequiredChecks(await gitOps.mainBranch(), pr) print(`PR #${pr.number}: ${pr.title}`) print(pr.url) if (pr.lastCommit) { diff --git a/src/github-ops.ts b/src/github-ops.ts index 60a6277..d4c75d9 100644 --- a/src/github-ops.ts +++ b/src/github-ops.ts @@ -59,6 +59,11 @@ export class GithubOps { return [...pending, ...passing, ...failing] } + async getAllRequiredChecks(branch: string, pr: { protectionRequiredChecks: string[] }): Promise> { + const fromRulesets = await this.getRulesetRequiredChecks(branch) + return new Set([...pr.protectionRequiredChecks, ...fromRulesets]) + } + async getRulesetRequiredChecks(branch: string): Promise { const r = await this.gitOps.getRepo() try { diff --git a/src/gql.ts b/src/gql.ts index 0dd3110..fbdb866 100644 --- a/src/gql.ts +++ b/src/gql.ts @@ -12,7 +12,7 @@ export interface CurrentPrInfo { mergeabilityStatus: MergeabilityStatus url: string openUrl: string - requiredChecks: string[] + protectionRequiredChecks: string[] lastCommit?: { message: string abbreviatedOid?: string @@ -201,7 +201,7 @@ export class GraphqlOps { const mainBranch = await this.gitOps.mainBranch() const protectionRules = repository?.branchProtectionRules?.nodes ?? [] - const requiredChecks = protectionRules + const protectionRequiredChecks = protectionRules .filter(rule => rule.requiresStatusChecks && rule.matchingRefs.nodes.some(ref => ref.name === mainBranch)) .flatMap(rule => rule.requiredStatusCheckContexts) @@ -222,7 +222,7 @@ export class GraphqlOps { mergeabilityStatus, url, openUrl, - requiredChecks, + protectionRequiredChecks, lastCommit: commit && { message: commit?.message, abbreviatedOid: commit?.abbreviatedOid, From 37bef8f8bfcac9066d2ccfaadc355cb70a7bd297 Mon Sep 17 00:00:00 2001 From: Itay Maman <94941+imaman@users.noreply.github.com> Date: Fri, 8 May 2026 00:16:42 +0300 Subject: [PATCH 09/10] 404 --- src/github-ops.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/github-ops.ts b/src/github-ops.ts index d4c75d9..9788a4c 100644 --- a/src/github-ops.ts +++ b/src/github-ops.ts @@ -78,9 +78,10 @@ export class GithubOps { .flatMap(rule => rule.parameters?.required_status_checks ?? []) .map(c => c.context) } catch (err) { - // Token may lack the scope to read rulesets, or the host may be an older GHE without this endpoint. - // Degrade to "no ruleset-derived required checks" so callers fall back to branchProtectionRules only. - logger.silly(`getRulesetRequiredChecks failed: ${err}`) + const status = (err as { status?: number })?.status + if (status !== 404) { + logger.warn(`Failed to fetch ruleset required checks (status=${status ?? 'unknown'}): ${err}`) + } return [] } } From d8dae0ecc293c174e552c3a4edb95a170de80986 Mon Sep 17 00:00:00 2001 From: Itay Maman <94941+imaman@users.noreply.github.com> Date: Fri, 8 May 2026 00:18:04 +0300 Subject: [PATCH 10/10] dedup --- src/dcc-cli.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/dcc-cli.ts b/src/dcc-cli.ts index 813ac98..b5ca306 100644 --- a/src/dcc-cli.ts +++ b/src/dcc-cli.ts @@ -176,8 +176,10 @@ async function submit() { return } - const checks = await githubOps.getChecks(pr?.number) - const required = await githubOps.getAllRequiredChecks(await gitOps.mainBranch(), pr) + const [checks, required] = await Promise.all([ + githubOps.getChecks(pr.number), + gitOps.mainBranch().then(b => githubOps.getAllRequiredChecks(b, pr)), + ]) const isRequired = (c: Check) => required.has(c.name) const printChecksWithRequired = (list: Check[]) => { for (const c of list) { @@ -262,8 +264,10 @@ async function status() { if (!pr) { print('No PR was created for this branch') } else { - const checks: Check[] = await githubOps.getChecks(pr?.number) - const required = await githubOps.getAllRequiredChecks(await gitOps.mainBranch(), pr) + const [checks, required] = await Promise.all([ + githubOps.getChecks(pr.number), + gitOps.mainBranch().then(b => githubOps.getAllRequiredChecks(b, pr)), + ]) print(`PR #${pr.number}: ${pr.title}`) print(pr.url) if (pr.lastCommit) {