fix(ci): address production blame approval review
This commit is contained in:
1 parent
9cee793b90
commit
794b658401
12 files changed
+201
-52
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md
|
||||
2026-09-11-production-blame-approval-weight.md: 047525734865866acc107508ca577e68b71e443c
|
||||
2026-09-11-production-blame-approval-weight.zh.md: 520c20b5361a150ca3919f5956fd3eef1a860e70
|
||||
2026-09-11-production-blame-approval-weight.md: d9a223c2f332cc110ddd8f6a3f592febe0072987
|
||||
2026-09-11-production-blame-approval-weight.zh.md: f4dfc9d4fc5cefcccadb2785f58893ec92043ba7
|
||||
@@ -12,11 +12,11 @@ A fixed one-point reviewer weight does not reflect authorship of the code a pull
|
||||
|
||||
The [approval policy](../../../../.github/review-ownership/README.md) scales a one-point approval by `min(2, 1 + 4 × ownedLines / totalLines)` over changed old production lines. Ownership of 0% gives one point, 12.5% gives 1.5 points, and 25% or more gives two points. Scores are not rounded before comparison with the two-point success threshold. The merge base supplies both classification and blame, while GitHub associates blame commits with reviewer accounts. Unlinked authors remain in the denominator. Additions have no prior owner and contribute no lines; an empty denominator produces no boost.
|
||||
|
||||
The publisher marks the head pending before evaluation, so an interrupted history fetch cannot preserve an earlier success. It reads complete Git history without checking out PR code. A maintained lexer separates comments from code across the repository’s source languages. Author lookups batch commits and all reviewers share one measurement. Existing [pending-status semantics](2026-09-09-blocked-weighted-approvals-remain-pending.md) and [review-event validation](2026-09-10-approval-review-workflow-identity.md) remain independent requirements.
|
||||
The publisher marks the head pending before dependency setup and evaluation, so an interrupted history fetch cannot preserve an earlier success. It uses the live base branch and exact reviewed head, reads complete Git history without checking out PR code, and skips attribution when base points or blockers already decide the result. A maintained lexer separates comments from code across the repository’s source languages. Author lookups batch commits and all reviewers share one measurement. Existing [pending-status semantics](2026-09-09-blocked-weighted-approvals-remain-pending.md) and [review-event validation](2026-09-10-approval-review-workflow-identity.md) remain independent requirements.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A hard 50% cutoff.** A continuous weight reflects partial ownership instead of assigning the same weight to 0% and 49%.
|
||||
**A hard cutoff.** Linear weighting distinguishes partial ownership below 25%. The 25% cap lets a reviewer responsible for one quarter of the changed old code satisfy the two-point requirement; additional ownership does not increase their vote beyond that requirement.
|
||||
|
||||
**Directory-weighted ownership.** Code elsewhere in a changed directory does not establish ownership of the lines under review.
|
||||
|
||||
@@ -26,7 +26,7 @@ The publisher marks the head pending before evaluation, so an interrupted histor
|
||||
|
||||
## Consequences
|
||||
|
||||
History fetching dominates cold execution. On 2026-09-11, local measurements of PR heads `23c77d10aef787df9dfc52b3bd3803122e2ba5bc` and `06be7d3900fd80d28374a5b42442091c55e3f542` against bases `16bf879af2033737b55b24b3896017a388fa974a` and `128bc0784cf9019aba9f5eb0e01865d28b54f241` count 73 and 535 old production lines across 4 and 14 files. Three local runs take 0.31–0.32 seconds and 0.87–0.93 seconds; one batched author query adds approximately one second per PR. A fresh single-branch bare clone of master takes 47 seconds on the same host. These are host observations, not CI time guarantees.
|
||||
History fetching dominates cold execution. Complete history is required before selecting the merge base: a shallow apparent ancestor can exclude changes from the denominator, so shallow classification cannot safely decide that blame is unnecessary. On 2026-09-11, local measurements of PRs #3969 and #3977 count 73 and 535 old production lines across 4 and 14 files. Three local runs take 0.31–0.32 seconds and 0.87–0.93 seconds; one batched author query adds approximately one second per PR. A fresh single-branch bare clone of master takes 47 seconds on the same host. These are host observations, not CI time guarantees.
|
||||
|
||||
Blame measures last-touch authorship, not review quality or semantic expertise. The production classifier excludes unsupported source locations and file extensions; adding shipped source outside its inventory requires extending that classifier. Lexer classification is lexical rather than a semantic test of executable behavior. Evaluation errors retain the error status instead of silently awarding or withholding the boost.
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ Status: implemented
|
||||
|
||||
[审批策略](../../../../.github/review-ownership/README.md) 按变更旧生产代码行的归属比例,以 `min(2, 1 + 4 × ownedLines / totalLines)` 调整一分批准的权重。归属比例为 0% 时计一分,12.5% 时计 1.5 分,25% 及以上时计两分。分数在与两分通过线比较前不做舍入。合并基点同时提供代码分类和 blame 依据,GitHub 将归属提交关联到评审者账号。无法关联账号的作者仍计入分母。新增行没有原作者,不计入行数;分母为空时不提升权重。
|
||||
|
||||
发布器在评估前将头提交标记为待定,避免历史拉取中断后保留此前的成功状态。它读取完整 Git 历史,不检出 PR 代码。维护中的词法分析器区分仓库各源码语言中的注释与代码。作者查询按提交批量执行,所有评审者共用一次统计。现有的[待定状态语义](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md)和[评审事件验证](2026-09-10-approval-review-workflow-identity.zh.md)仍是独立要求。
|
||||
发布器在依赖安装和评估前将头提交标记为待定,避免历史拉取中断后保留此前的成功状态。它使用实时 base 分支和被评审的精确 head,读取完整 Git 历史,不检出 PR 代码;基础分数或阻塞评审已决定结果时跳过归属计算。维护中的词法分析器区分仓库各源码语言中的注释与代码。作者查询按提交批量执行,所有评审者共用一次统计。现有的[待定状态语义](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md)和[评审事件验证](2026-09-10-approval-review-workflow-identity.zh.md)仍是独立要求。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**50% 硬阈值。** 连续权重反映部分归属,不将 0% 与 49% 赋予相同权重。
|
||||
**硬阈值。** 线性权重区分 25% 以下的部分归属。25% 封顶让负责四分之一变更旧代码的评审者满足两分要求;更多归属不会使其票数超过该要求。
|
||||
|
||||
**目录加权归属。** 变更目录中其他代码的归属不能证明待评审代码行的归属。
|
||||
|
||||
@@ -26,7 +26,7 @@ Status: implemented
|
||||
|
||||
## Consequences
|
||||
|
||||
冷启动的主要开销是拉取历史。2026-09-11,在本机分别用基点 `16bf879af2033737b55b24b3896017a388fa974a` 和 `128bc0784cf9019aba9f5eb0e01865d28b54f241` 测量 PR 头提交 `23c77d10aef787df9dfc52b3bd3803122e2ba5bc` 和 `06be7d3900fd80d28374a5b42442091c55e3f542`,得到 4 和 14 个文件中的 73 和 535 行旧生产代码。三次本地运行分别耗时 0.31–0.32 秒和 0.87–0.93 秒;每个 PR 的一次批量作者查询额外耗时约一秒。同一主机上,全新单分支裸克隆 master 耗时 47 秒。这些是本机观测值,不是 CI 耗时保证。
|
||||
冷启动的主要开销是拉取历史。选择合并基点前必须具备完整历史:浅历史中看似共同祖先的提交可能使部分变更被排除出分母,因此不能据此安全判定无需 blame。2026-09-11,在本机测量 PR #3969 和 #3977,得到 4 和 14 个文件中的 73 和 535 行旧生产代码。三次本地运行分别耗时 0.31–0.32 秒和 0.87–0.93 秒;每个 PR 的一次批量作者查询额外耗时约一秒。同一主机上,全新单分支裸克隆 master 耗时 47 秒。这些是本机观测值,不是 CI 耗时保证。
|
||||
|
||||
Blame 衡量最后修改者归属,不代表评审质量或语义上的专业能力。生产代码分类器排除未支持的源码位置和扩展名;在其清单之外添加交付源码时,必须扩展分类器。词法分类不判断代码是否在语义上可执行。评估错误保留错误状态,不静默授予或取消加权。
|
||||
|
||||
|
||||
@@ -15,23 +15,23 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes
|
||||
|
||||
## Approval scoring
|
||||
|
||||
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher first marks the head pending so an interrupted history fetch cannot leave a previous success in place. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status.
|
||||
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status.
|
||||
|
||||
Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author and reviewers without write permission do not count.
|
||||
|
||||
A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the PR base and head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs each eligible reviewer’s owned and total line counts.
|
||||
A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when base approval points already meet the threshold or a blocking review exists. Displayed scores use at most two decimal places; the decision uses the unrounded score. The curve endpoints come from the policy’s default and required points.
|
||||
|
||||
Production source means supported code files under `src/` in `packages/`, `apps/`, `python/`, and `native/`, plus the Desktop renderer, Python interpreter scripts, and committed runtime/packer launchers. The [classifier](blame-production.py) excludes documentation, tests, fixtures, snapshots, test support, examples, generated source, dependencies, vendored code, declarations, comments, and blank lines. Pygments lexers distinguish comments from strings; mixed code/comment lines count, as do C preprocessor directives. Classification uses the old path and content, so changes to the PR’s file locations or generated headers cannot remove old lines from the denominator. Pure renames have no changed lines; renames with edits use the old path for blame.
|
||||
Production source means supported code files under `src/` in `packages/`, `apps/`, `python/`, and `native/`, plus the Desktop renderer, Python interpreter scripts, and committed runtime/packer launchers. The [classifier](blame-production.py) excludes documentation, tests, fixtures, snapshots, test support (including `src/testing/` and `src/testing.ts`), examples, generated source, dependencies, vendored code, declarations, comments, and blank lines. Pygments lexers distinguish comments from strings; mixed code/comment lines count, as do C preprocessor directives. Classification uses the old path and content, so changes to the PR’s file locations or generated headers cannot remove old lines from the denominator. Pure renames have no changed lines; renames with edits use the old path for blame.
|
||||
|
||||
Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
|
||||
|
||||
The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
|
||||
The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Pull-request and review events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
|
||||
|
||||
<a id="security"></a>
|
||||
|
||||
## Security
|
||||
|
||||
The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. When an eligible approval has a one-point weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
|
||||
All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, base points are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
|
||||
|
||||
Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
/** GitHub account syntax shared by policy and commit-author validation. */
|
||||
export const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u
|
||||
const SHA = /^[0-9a-f]{40}$/u
|
||||
|
||||
/**
|
||||
@@ -38,7 +40,7 @@ export async function resolveBlameAuthors(measurement, repository, api) {
|
||||
if (!author || !Object.hasOwn(author, 'user')) throw new Error('GitHub returned no blame commit author')
|
||||
if (author.user === null) continue
|
||||
const login = author.user.login
|
||||
if (typeof login !== 'string' || !/^[A-Za-z0-9-]+(?:\[bot\])?$/u.test(login)) {
|
||||
if (typeof login !== 'string' || !LOGIN.test(login)) {
|
||||
throw new Error('GitHub returned an invalid blame author login')
|
||||
}
|
||||
const key = login.toLowerCase()
|
||||
@@ -56,9 +58,11 @@ export async function resolveBlameAuthors(measurement, repository, api) {
|
||||
*/
|
||||
export async function productionOwnership(pull, api) {
|
||||
const current = await api(`/repos/${pull.repository}/pulls/${pull.number}`)
|
||||
if (current.head?.sha !== pull.headSha || !SHA.test(current.base?.sha ?? '')) {
|
||||
throw new Error('pull request changed or has no valid base SHA')
|
||||
if (current.head?.sha !== pull.headSha || !SHA.test(pull.headSha) || typeof current.base?.ref !== 'string') {
|
||||
throw new Error('pull request changed or has no valid base branch')
|
||||
}
|
||||
const baseRef = `refs/heads/${current.base.ref}`
|
||||
await exec('git', ['check-ref-format', baseRef])
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (!token) throw new Error('GITHUB_TOKEN is not set')
|
||||
const server = process.env.GITHUB_SERVER_URL ?? 'https://github.com'
|
||||
@@ -72,10 +76,11 @@ export async function productionOwnership(pull, api) {
|
||||
const { stdout: shallow } = await exec('git', ['rev-parse', '--is-shallow-repository'])
|
||||
await exec('git', [
|
||||
'fetch', '--no-tags', ...(shallow.trim() === 'true' ? ['--unshallow'] : []),
|
||||
`${server}/${pull.repository}.git`, current.base.sha, `refs/pull/${pull.number}/head`,
|
||||
`${server}/${pull.repository}.git`, baseRef, pull.headSha,
|
||||
], { env: environment, maxBuffer: 16 * 1024 * 1024 })
|
||||
const { stdout: baseSha } = await exec('git', ['rev-parse', 'FETCH_HEAD'])
|
||||
const { stdout } = await exec('python3', [
|
||||
fileURLToPath(new URL('blame-production.py', import.meta.url)), current.base.sha, pull.headSha,
|
||||
fileURLToPath(new URL('blame-production.py', import.meta.url)), baseSha.trim(), pull.headSha,
|
||||
], { maxBuffer: 16 * 1024 * 1024 })
|
||||
return resolveBlameAuthors(JSON.parse(stdout), pull.repository, api)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ test('combines commit author accounts and retains unlinked authors in the denomi
|
||||
}, 'owner/repo', async (path, { body }) => {
|
||||
assert.equal(path, '/graphql')
|
||||
assert.deepEqual(body.variables, { owner: 'owner', name: 'repo' })
|
||||
assert.match(body.query, /c0: object/u)
|
||||
assert.equal(body.query, `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${[1, 2, 3].map((number, index) => `c${index}: object(oid: "${sha(number)}") { ... on Commit { author { user { login } } } }`).join('\n')} } }`)
|
||||
return { data: { repository: {
|
||||
c0: { author: { user: { login: 'Writer' } } },
|
||||
c1: { author: { user: { login: 'writer' } } },
|
||||
@@ -26,8 +26,13 @@ test('batches author lookup and skips zero-line changes', async () => {
|
||||
let calls = 0
|
||||
await resolveBlameAuthors({
|
||||
totalLines: 51, commitLines: Object.fromEntries(Array.from({ length: 51 }, (_, index) => [sha(index), 1])),
|
||||
}, 'owner/repo', async () => {
|
||||
}, 'owner/repo', async (path, { body }) => {
|
||||
const offset = calls * 50
|
||||
const length = calls++ === 0 ? 50 : 1
|
||||
for (let index = 0; index < length; index++) {
|
||||
assert.ok(body.query.includes(`c${index}: object(oid: "${sha(offset + index)}") { ... on Commit { author { user { login } } } }`))
|
||||
}
|
||||
assert.equal((body.query.match(/object\(oid:/gu) ?? []).length, length)
|
||||
return { data: { repository: Object.fromEntries(Array.from({ length }, (_, index) =>
|
||||
[`c${index}`, { author: { user: null } }])) } }
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ from pygments.token import Comment, Literal
|
||||
EXCLUDED = frozenset((
|
||||
'vendor', 'node_modules', 'dist', 'lib', 'build', 'coverage', 'target',
|
||||
'test', 'tests', '__tests__', 'fixture', 'fixtures', 'snapshot', 'snapshots',
|
||||
'test-support', 'support', 'examples', 'docs', 'gen', 'generated', 'generated-effect',
|
||||
'testing', 'test-support', 'support', 'examples', 'docs', 'gen', 'generated', 'generated-effect',
|
||||
))
|
||||
EXTENSIONS = frozenset(('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss', '.py', '.c', '.h', '.cpp', '.hpp', '.rs', '.html'))
|
||||
SHIPPED_ROOTS = ('apps/desktop/renderer/', 'packages/experimental/code-runtime-python/py/')
|
||||
@@ -43,18 +43,23 @@ def production_path(path):
|
||||
shipped
|
||||
and not EXCLUDED.intersection(name.parts)
|
||||
and not re.search(r'\.(?:test|spec|e2e|gen|generated|d)\.', name.name)
|
||||
and name.stem != 'testing'
|
||||
and name.suffix in EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
def code_lines(path, source):
|
||||
"""Return physical lines containing non-comment, nonblank tokens, including mixed lines."""
|
||||
if GENERATED.search(source[:1000]):
|
||||
return set()
|
||||
lexer = get_lexer_for_filename(path, stripnl=False, ensurenl=False)
|
||||
lines = set()
|
||||
line = 1
|
||||
leading = True
|
||||
for kind, value in lex(source, lexer):
|
||||
if leading and (kind in Comment or kind in Literal.String.Doc):
|
||||
if GENERATED.search(value):
|
||||
return set()
|
||||
elif value.strip():
|
||||
leading = False
|
||||
for index, fragment in enumerate(value.split('\n')):
|
||||
if index:
|
||||
line += 1
|
||||
@@ -71,7 +76,7 @@ def changed_files(repo, base, head):
|
||||
index = 0
|
||||
while index < len(fields) - 1:
|
||||
metadata = fields[index].decode('ascii').split()
|
||||
path = fields[index + 1].decode('utf-8')
|
||||
path = os.fsdecode(fields[index + 1])
|
||||
index += 2
|
||||
if metadata[4].startswith(('R', 'C')):
|
||||
index += 1
|
||||
@@ -92,7 +97,7 @@ def measure(repo, base, head):
|
||||
counts = Counter()
|
||||
files = 0
|
||||
for path, old_blob, new_blob in changed_files(repo, merge_base, head):
|
||||
source = git(repo, 'cat-file', 'blob', old_blob).decode('utf-8')
|
||||
source = git(repo, 'cat-file', 'blob', old_blob).decode('utf-8', errors='replace')
|
||||
eligible = code_lines(path, source)
|
||||
if not eligible:
|
||||
continue
|
||||
@@ -100,7 +105,7 @@ def measure(repo, base, head):
|
||||
changed = eligible
|
||||
else:
|
||||
patch = git(repo, 'diff', '--no-ext-diff', '--no-textconv', '--text', '--unified=0',
|
||||
old_blob, new_blob, '--').decode('utf-8')
|
||||
old_blob, new_blob, '--').decode('utf-8', errors='replace')
|
||||
changed = set()
|
||||
for start, length in re.findall(r'^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@', patch, re.M):
|
||||
start = int(start)
|
||||
@@ -110,7 +115,7 @@ def measure(repo, base, head):
|
||||
files += 1
|
||||
# One blame per file covers all changed ranges; unchanged intervening lines never count.
|
||||
blame = git(repo, 'blame', '--no-textconv', '--line-porcelain', '-L', f'{min(changed)},{max(changed)}',
|
||||
merge_base, '--', path).decode('utf-8')
|
||||
merge_base, '--', path).decode('utf-8', errors='replace')
|
||||
attributed = 0
|
||||
for commit, line in re.findall(r'^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$', blame, re.M):
|
||||
if int(line) in changed:
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { productionOwnership } from './blame-ownership.mjs'
|
||||
import { productionOwnership, LOGIN } from './blame-ownership.mjs'
|
||||
|
||||
const API_VERSION = '2026-03-10'
|
||||
const MAX_PULL_REQUEST_REVIEWS = 3_000
|
||||
@@ -12,7 +12,6 @@ const PAGE_SIZE = 100
|
||||
const STATUS_CONTEXT = 'weighted approval'
|
||||
const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
|
||||
const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
|
||||
const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u
|
||||
|
||||
class GitHubApiError extends Error {
|
||||
constructor(message, status) {
|
||||
@@ -162,13 +161,16 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
|
||||
})
|
||||
}
|
||||
}
|
||||
if (approvals.some(approval => approval.points === 1)) {
|
||||
const unboostedPoints = approvals.reduce((sum, approval) => sum + approval.points, 0)
|
||||
if (blockers.length === 0 && unboostedPoints < policy.requiredPoints
|
||||
&& approvals.some(approval => approval.points === policy.defaultPoints)) {
|
||||
const ownership = await getOwnership(pull, api)
|
||||
for (const approval of approvals) {
|
||||
if (approval.points !== 1) continue
|
||||
if (approval.points !== policy.defaultPoints) continue
|
||||
const ownedLines = ownership.reviewerLines[approval.login.toLowerCase()] ?? 0
|
||||
approval.ownership = { ownedLines, totalLines: ownership.totalLines }
|
||||
if (ownership.totalLines > 0) approval.points = Math.min(2, 1 + 4 * ownedLines / ownership.totalLines)
|
||||
if (ownership.totalLines > 0) approval.points = Math.min(policy.requiredPoints, policy.defaultPoints
|
||||
+ (policy.requiredPoints - policy.defaultPoints) * 4 * ownedLines / ownership.totalLines)
|
||||
}
|
||||
}
|
||||
approvals.sort((left, right) => left.login.localeCompare(right.login, 'en'))
|
||||
@@ -176,7 +178,7 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
|
||||
ignoredReviewers.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
const points = approvals.reduce((total, approval) => {
|
||||
const next = total + approval.points
|
||||
if (!Number.isFinite(next) || next > Number.MAX_SAFE_INTEGER) throw new Error('approval points exceed the safe integer range')
|
||||
if (!Number.isFinite(next) || next > Number.MAX_SAFE_INTEGER) throw new Error('approval points must be finite and at most Number.MAX_SAFE_INTEGER')
|
||||
return next
|
||||
}, 0)
|
||||
if (blockers.length > 0) {
|
||||
@@ -191,7 +193,7 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
|
||||
blockers,
|
||||
ignoredReviewers,
|
||||
state,
|
||||
`${points}/${policy.requiredPoints} approval points`,
|
||||
`${Number(points.toFixed(2))}/${policy.requiredPoints} approval points`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -220,6 +222,17 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a previous success before dependency installation, or report its failure.
|
||||
* @param {{event: unknown, api: (path: string, options: object) => Promise<unknown>, runUrl: string, phase: string}} options Publication inputs.
|
||||
* @returns {Promise<void>} Completion of the status write.
|
||||
*/
|
||||
export async function publishApprovalPhase({ event, api, runUrl, phase }) {
|
||||
if (!['pending', 'error'].includes(phase)) throw new Error('invalid approval setup phase')
|
||||
await publishStatus(api, pullRequestFromEvent(event), phase,
|
||||
phase === 'pending' ? 'Preparing approval evaluation.' : 'Approval setup or evaluation failed.', runUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the reviewed pull request from a completed run of the review-event workflow file.
|
||||
* @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Trusted workflow inputs.
|
||||
@@ -373,6 +386,11 @@ async function main() {
|
||||
}
|
||||
event = resolved
|
||||
}
|
||||
const phase = process.argv[2]
|
||||
if (phase) {
|
||||
await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
|
||||
return
|
||||
}
|
||||
await runApprovalCheck({
|
||||
event,
|
||||
policySource,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
listPullRequestReviews,
|
||||
parseApprovalPolicy,
|
||||
runApprovalCheck,
|
||||
publishApprovalPhase,
|
||||
} from './check-approval.mjs'
|
||||
|
||||
const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
|
||||
@@ -185,8 +186,8 @@ test('accepts two one-point approvals and ignores reviews without write access',
|
||||
assert.equal(result.state, 'success')
|
||||
assert.equal(result.points, 2)
|
||||
assert.deepEqual(result.approvals, [
|
||||
{ login: 'writer-a', points: 1, ownership: { ownedLines: 0, totalLines: 0 } },
|
||||
{ login: 'writer-b', points: 1, ownership: { ownedLines: 0, totalLines: 0 } },
|
||||
{ login: 'writer-a', points: 1 },
|
||||
{ login: 'writer-b', points: 1 },
|
||||
])
|
||||
assert.deepEqual(result.ignoredReviewers, ['reader'])
|
||||
})
|
||||
@@ -360,7 +361,7 @@ test('sends authenticated JSON and escapes an API error body', async () => {
|
||||
await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u)
|
||||
})
|
||||
|
||||
for (const [ownedLines, totalLines, expectedPoints] of [[0, 100, 1], [1, 8, 1.5], [24, 100, 1.96], [1, 4, 2], [25, 100, 2], [26, 100, 2], [100, 100, 2], [0, 0, 1]]) {
|
||||
for (const [ownedLines, totalLines, expectedPoints] of [[9, 100, 1.3599999999999999], [0, 100, 1], [1, 8, 1.5], [24, 100, 1.96], [1, 4, 2], [25, 100, 2], [26, 100, 2], [100, 100, 2], [0, 0, 1]]) {
|
||||
test(`scores ${ownedLines}/${totalLines} old production lines as ${expectedPoints} points`, async () => {
|
||||
let measurements = 0
|
||||
const result = await evaluateApproval({
|
||||
@@ -374,13 +375,14 @@ for (const [ownedLines, totalLines, expectedPoints] of [[0, 100, 1], [1, 8, 1.5]
|
||||
: { permission: 'write' },
|
||||
})
|
||||
assert.equal(result.points, expectedPoints)
|
||||
assert.equal(result.description, `${Number(expectedPoints.toFixed(2))}/2 approval points.`)
|
||||
assert.equal(result.state, expectedPoints === 2 ? 'success' : 'pending')
|
||||
assert.equal(measurements, 1)
|
||||
assert.deepEqual(result.approvals[0].ownership, { ownedLines, totalLines })
|
||||
})
|
||||
}
|
||||
|
||||
test('shares one measurement across reviewers without overriding a change request', async () => {
|
||||
test('does not fetch ownership when a change request blocks approval', async () => {
|
||||
let measurements = 0
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(), policySource,
|
||||
@@ -392,8 +394,8 @@ test('shares one measurement across reviewers without overriding a change reques
|
||||
? [review('first', 'APPROVED'), review('second', 'APPROVED'), review('blocker', 'CHANGES_REQUESTED')]
|
||||
: { permission: 'write' },
|
||||
})
|
||||
assert.equal(measurements, 1)
|
||||
assert.equal(result.points, 4)
|
||||
assert.equal(measurements, 0)
|
||||
assert.equal(result.points, 2)
|
||||
assert.equal(result.state, 'pending')
|
||||
})
|
||||
|
||||
@@ -439,3 +441,38 @@ test('revokes a previous success before starting expensive attribution', async (
|
||||
})
|
||||
assert.deepEqual(states, ['pending', 'success'])
|
||||
})
|
||||
|
||||
for (const reviewers of [['first', 'second'], ['turtle1999', 'first']]) {
|
||||
test(`does not fetch ownership for sufficient approvals: ${reviewers}`, async () => {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(), policySource,
|
||||
getOwnership: async () => { throw new Error('unnecessary lookup') },
|
||||
api: async path => path.includes('/reviews?')
|
||||
? reviewers.map(login => review(login, 'APPROVED')) : { permission: 'write' },
|
||||
})
|
||||
assert.equal(result.state, 'success')
|
||||
})
|
||||
}
|
||||
|
||||
test('uses policy endpoints and formats only the displayed score', async () => {
|
||||
const result = await evaluateApproval({
|
||||
event: pullRequestEvent(),
|
||||
policySource: JSON.stringify({ requiredPoints: 5, defaultPoints: 2, reviewerPoints: {} }),
|
||||
getOwnership: async () => ({ totalLines: 100, reviewerLines: { writer: 9 } }),
|
||||
api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
|
||||
})
|
||||
assert.equal(result.points, 3.08)
|
||||
assert.equal(result.description, '3.08/5 approval points.')
|
||||
})
|
||||
|
||||
test('publishes setup phases without evaluating or installing dependencies', async () => {
|
||||
const states = []
|
||||
const options = {
|
||||
event: pullRequestEvent(), runUrl: 'https://github.example/run/1',
|
||||
api: async (path, { body }) => { assert.match(path, /\/statuses\//u); states.push(body.state) },
|
||||
}
|
||||
await publishApprovalPhase({ ...options, phase: 'pending' })
|
||||
await publishApprovalPhase({ ...options, phase: 'error' })
|
||||
assert.deepEqual(states, ['pending', 'error'])
|
||||
await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u)
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Exercise production-line attribution against isolated real Git histories."""
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
@@ -58,7 +60,9 @@ class ProductionBlameTest(unittest.TestCase):
|
||||
for path in ('docs/a.ts', 'vendor/src/a.ts', 'packages/a/tests/a.ts',
|
||||
'packages/a/src/a.spec.ts', 'packages/support/a/src/a.ts',
|
||||
'packages/a/src/generated/a.ts', 'packages/a/src/a.d.ts',
|
||||
'packages/a/src/a.md', 'scripts/a.ts'):
|
||||
'packages/a/src/a.md', 'scripts/a.ts',
|
||||
'packages/core/tools/src/testing.ts',
|
||||
'packages/session/session-persistence-jsonl/src/testing/generation.ts'):
|
||||
self.assertFalse(blame.production_path(path), path)
|
||||
self.assertTrue(blame.production_path('packages/a/src/a.ts'))
|
||||
for path in ('python/sdk-runtime/runtime-bootstrap.mjs', 'apps/desktop/renderer/startup.js',
|
||||
@@ -122,37 +126,89 @@ class ProductionBlameTest(unittest.TestCase):
|
||||
|
||||
def test_publisher_fetches_history_without_checking_out_pr_code(self):
|
||||
self.write('packages/a/src/a.ts', 'const old = 1\n')
|
||||
self.write('packages/a/src/base.ts', 'const base = 1\n')
|
||||
base = self.commit()
|
||||
self.write('packages/a/src/a.ts', 'throw new Error("PR code must not run")\n')
|
||||
head = self.commit()
|
||||
branch = self.commit()
|
||||
self.git('checkout', '--detach', base)
|
||||
self.write('packages/a/src/base.ts', 'const base = 2\n')
|
||||
advanced = self.commit()
|
||||
self.git('checkout', '--detach', branch)
|
||||
self.git('merge', '--no-edit', advanced)
|
||||
head = self.git('rev-parse', 'HEAD')
|
||||
remote = self.root / 'remote' / 'owner' / 'repo.git'
|
||||
remote.parent.mkdir(parents=True)
|
||||
self.git('clone', '--bare', str(self.root), str(remote))
|
||||
subprocess.run(['git', '-C', str(remote), 'update-ref', 'refs/heads/trusted', base], check=True)
|
||||
subprocess.run(['git', '-C', str(remote), 'update-ref', 'refs/heads/trusted', advanced], check=True)
|
||||
subprocess.run(['git', '-C', str(remote), 'symbolic-ref', 'HEAD', 'refs/heads/trusted'], check=True)
|
||||
subprocess.run(['git', '-C', str(remote), 'update-ref', 'refs/pull/42/head', head], check=True)
|
||||
subprocess.run(['git', '-C', str(remote), 'update-ref', 'refs/pull/42/head', advanced], check=True)
|
||||
checkout = self.root / 'checkout'
|
||||
self.git('clone', '--depth=1', remote.as_uri(), str(checkout))
|
||||
wrappers = self.root / 'wrappers'
|
||||
wrappers.mkdir()
|
||||
trace = self.root / 'git-environment.json'
|
||||
git_wrapper = wrappers / 'git'
|
||||
git_wrapper.write_text(f"#!{sys.executable}\nimport json, os, sys\n"
|
||||
f"if 'fetch' in sys.argv: open({str(trace)!r}, 'w').write(json.dumps({{key: value for key, value in os.environ.items() if key.startswith('GIT_CONFIG_')}}))\n"
|
||||
f"os.execv({shutil.which('git')!r}, ['git', *sys.argv[1:]])\n")
|
||||
git_wrapper.chmod(0o755)
|
||||
module = Path(__file__).with_name('blame-ownership.mjs').resolve().as_uri()
|
||||
program = f"""
|
||||
import {{ productionOwnership }} from {json.dumps(module)};
|
||||
const result = await productionOwnership({{repository:'owner/repo', number:42, headSha:{json.dumps(head)}}},
|
||||
async path => path === '/graphql'
|
||||
? {{data:{{repository:{{c0:{{author:{{user:{{login:'writer'}}}}}}}}}}}}
|
||||
: {{base:{{sha:{json.dumps(base)}}},head:{{sha:{json.dumps(head)}}}}});
|
||||
: {{base:{{sha:{json.dumps(base)},ref:'trusted'}},head:{{sha:{json.dumps(head)}}}}});
|
||||
console.log(JSON.stringify(result));
|
||||
"""
|
||||
result = subprocess.run(['node', '--input-type=module', '-e', program], cwd=checkout,
|
||||
check=True, capture_output=True, text=True,
|
||||
env={**os.environ, 'GITHUB_TOKEN': 'fixture-token',
|
||||
'GITHUB_SERVER_URL': (self.root / 'remote').as_uri(),
|
||||
'PATH': str(Path(sys.executable).parent) + os.pathsep + os.environ['PATH']})
|
||||
'PATH': str(wrappers) + os.pathsep + str(Path(sys.executable).parent) + os.pathsep + os.environ['PATH']})
|
||||
self.assertEqual(json.loads(result.stdout), {'totalLines': 1, 'reviewerLines': {'writer': 1}})
|
||||
self.assertEqual(subprocess.check_output(['git', '-C', str(checkout), 'rev-parse', 'HEAD'], text=True).strip(), base)
|
||||
self.assertEqual(subprocess.check_output(['git', '-C', str(checkout), 'rev-parse', 'HEAD'], text=True).strip(), advanced)
|
||||
fetch_environment = json.loads(trace.read_text())
|
||||
self.assertEqual(fetch_environment['GIT_CONFIG_COUNT'], '1')
|
||||
self.assertEqual(fetch_environment['GIT_CONFIG_KEY_0'],
|
||||
f"http.{(self.root / 'remote').as_uri()}/.extraheader")
|
||||
self.assertEqual(fetch_environment['GIT_CONFIG_VALUE_0'],
|
||||
'AUTHORIZATION: basic ' + base64.b64encode(b'x-access-token:fixture-token').decode())
|
||||
config = (checkout / '.git' / 'config').read_text()
|
||||
self.assertNotIn('fixture-token', config)
|
||||
self.assertNotIn('AUTHORIZATION', config)
|
||||
|
||||
def test_merge_forward_excludes_base_only_edits(self):
|
||||
path = 'packages/a/src/a.ts'
|
||||
self.write(path, 'const a = 1\n')
|
||||
self.write('packages/a/src/base.ts', 'const base = 1\n')
|
||||
fork = self.commit()
|
||||
self.write(path, 'const a = 2\n')
|
||||
branch = self.commit()
|
||||
self.git('checkout', '--detach', fork)
|
||||
self.write('packages/a/src/base.ts', 'const base = 2\n')
|
||||
advanced = self.commit()
|
||||
self.git('checkout', '--detach', branch)
|
||||
self.git('merge', '--no-edit', advanced)
|
||||
result = blame.measure(str(self.root), advanced, self.git('rev-parse', 'HEAD'))
|
||||
self.assertEqual(result['mergeBase'], advanced)
|
||||
self.assertEqual(result['commitLines'], {fork: 1})
|
||||
|
||||
def test_non_utf8_blobs_and_commit_metadata_keep_old_lines(self):
|
||||
path = 'packages/a/src/a.ts'
|
||||
self.write(path, 'const a = "old"\n')
|
||||
(self.root / path).write_bytes(b'const a = "\xff"\n')
|
||||
self.git('add', '.')
|
||||
subprocess.run(['git', '-C', str(self.root), '-c', 'i18n.commitEncoding=ISO-8859-1',
|
||||
'commit', '-q', '-F', '-'], input=b'metadata \xff', check=True, capture_output=True)
|
||||
base = self.git('rev-parse', 'HEAD')
|
||||
(self.root / path).write_bytes(b'\0new \xfe\n')
|
||||
self.assertEqual(blame.measure(str(self.root), base, self.commit())['commitLines'], {base: 1})
|
||||
|
||||
def test_generated_words_in_code_do_not_exclude_handwritten_files(self):
|
||||
self.assertEqual(blame.code_lines('a.ts', 'const text = "generated by an agent"\n'), {1})
|
||||
self.assertEqual(blame.code_lines('a.ts', 'const a = 1\n// do not edit user data\n'), {1})
|
||||
|
||||
def test_rejects_shallow_history(self):
|
||||
self.write('packages/a/src/a.ts', 'const a = 1\n')
|
||||
base = self.commit()
|
||||
|
||||
@@ -2,7 +2,7 @@ name: weighted-approval
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, edited]
|
||||
workflow_run:
|
||||
workflows: [weighted-approval-review-event]
|
||||
types: [completed]
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
statuses: write
|
||||
|
||||
concurrency:
|
||||
group: weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }}
|
||||
group: weighted-approval-${{ github.event.pull_request.number && format('weighted-approval-review-event:{0}', github.event.pull_request.number) || github.event.workflow_run.display_title }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -30,7 +30,14 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- uses: actions/[email protected]
|
||||
- name: Revoke previous approval status
|
||||
id: revoke
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs pending
|
||||
# SECURITY: dependency setup runs in the status-writing job.
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: pip
|
||||
@@ -42,3 +49,9 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs
|
||||
- name: Publish approval setup failure
|
||||
if: failure() && steps.revoke.outcome == 'success'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs error
|
||||
@@ -917,7 +917,7 @@ describe('Weighted approval workflow', () => {
|
||||
|
||||
expect(publisher.name).toBe('weighted-approval')
|
||||
expect(Object.keys(publisher.on)).toEqual(['pull_request_target', 'workflow_run'])
|
||||
expect(pullRequest.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review', 'converted_to_draft'])
|
||||
expect(pullRequest.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review', 'converted_to_draft', 'edited'])
|
||||
expect(workflowRun).toEqual({ workflows: ['weighted-approval-review-event'], types: ['completed'] })
|
||||
expect(reviewEvent.name).toBe('weighted-approval-review-event')
|
||||
expect(reviewEvent['run-name']).toBe('weighted-approval-review-event:${{ github.event.pull_request.number }}')
|
||||
@@ -930,7 +930,7 @@ describe('Weighted approval workflow', () => {
|
||||
statuses: 'write',
|
||||
})
|
||||
expect(publisher.concurrency).toEqual({
|
||||
group: 'weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }}',
|
||||
group: "weighted-approval-${{ github.event.pull_request.number && format('weighted-approval-review-event:{0}', github.event.pull_request.number) || github.event.workflow_run.display_title }}",
|
||||
'cancel-in-progress': false,
|
||||
})
|
||||
expect(job).toMatchObject({
|
||||
@@ -946,6 +946,16 @@ describe('Weighted approval workflow', () => {
|
||||
'persist-credentials': false,
|
||||
},
|
||||
})
|
||||
const setupIndex = steps.findIndex(step => typeof step.uses === 'string' && step.uses.startsWith('actions/setup-python@'))
|
||||
expect(steps[setupIndex]?.uses).toBe('actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1')
|
||||
const revokeIndex = steps.findIndex(step => step.id === 'revoke')
|
||||
expect(revokeIndex).toBeGreaterThan(steps.indexOf(checkout!))
|
||||
expect(revokeIndex).toBeLessThan(setupIndex)
|
||||
expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs pending')
|
||||
expect(steps.at(-1)).toMatchObject({
|
||||
if: "failure() && steps.revoke.outcome == 'success'",
|
||||
run: 'node .github/review-ownership/check-approval.mjs error',
|
||||
})
|
||||
const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'python-sdk')
|
||||
expect(pythonJob.steps).toContainEqual({
|
||||
name: 'Test production blame scoring',
|
||||
|
||||
Reference in new issue
Block a user