/** * @license * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { parse } from 'yaml'; describe('main CI failure issue workflow', () => { const workflow = readFileSync( '.github/workflows/main-ci-failure-issue.yml', 'utf8', ); const yml = parse(workflow); const jobs = yml.jobs; it('opens an autofix-ready issue only for failed main CI runs', () => { expect(workflow).toContain('workflow_run:'); expect(workflow).toContain("workflows: ['E2E Tests', 'SDK Python']"); expect(workflow).not.toContain("'Qwen Code CI'"); expect(workflow).toContain("types: ['completed']"); expect(workflow).toContain("github.repository == 'QwenLM/qwen-code'"); expect(workflow).toContain( "github.event.workflow_run.conclusion == 'failure'", ); expect(workflow).toContain( "github.event.workflow_run.head_branch == 'main'", ); expect(workflow).toContain("github.event.workflow_run.event == 'push'"); }); it('creates an issue that the existing autofix worker can pick up', () => { expect(workflow).toContain("issues: 'write'"); expect(workflow).toContain('CI_DEV_BOT_PAT'); expect(workflow).toContain( 'AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || \'qwen-code-dev-bot\' }}"', ); expect(workflow).toContain("BUG_LABEL: 'type/bug'"); expect(workflow).toContain( "READY_FOR_AGENT_LABEL: 'status/ready-for-agent'", ); expect(workflow).toContain("AUTOFIX_APPROVED_LABEL: 'autofix/approved'"); expect(workflow).toContain('gh issue edit "$1"'); expect(workflow).toContain( '--add-label "${BUG_LABEL},${READY_FOR_AGENT_LABEL},${AUTOFIX_APPROVED_LABEL}"', ); expect(workflow).toContain('--add-assignee "${AUTOFIX_BOT}"'); expect(workflow).toContain('apply_autofix_route "${issue_url}"'); }); it('deduplicates by failing test and includes run context', () => { // The dedupe key is the failing test, not the commit: a standing red used to // open one issue per merge. The markers themselves live in the helper. expect(workflow).toContain('main-failure-signature.mjs'); expect(workflow).toContain('searchMarkers'); // The failing tests are read from the triggering run's failed-job logs, so // the dedupe key is recovered even when the run reported no test result. expect(workflow).toContain('actions/runs/${WORKFLOW_RUN_ID}/jobs'); expect(workflow).toContain('actions/jobs/${job_id}/logs'); expect(workflow).toContain('gh issue list'); expect(workflow).toContain('gh issue create'); expect(workflow).toContain('apply_autofix_route "${EXISTING_ISSUE}"'); expect(workflow).toContain('${WORKFLOW_RUN_URL}'); expect(workflow).toContain('${HEAD_SHA}'); }); it('re-reads an existing issue so recorded recurrences survive the update', () => { expect(workflow).toContain('gh issue view "${existing_issue}"'); expect(workflow).toContain('--existing "${existing_body}"'); }); it('uses a random heredoc delimiter for the multiline body output', () => { // A constant delimiter lets issue-body prose (which the autofix agent // writes into) end the heredoc early and inject fresh GITHUB_OUTPUT keys. expect(workflow).toContain('openssl rand -hex 16'); expect(workflow).toContain('echo "body<<${delim}"'); expect(workflow).toContain('echo "${delim}"'); expect(workflow).not.toContain('body< JSON.stringify(job).includes('CI_DEV_BOT_PAT'), ); it('keeps the bot PAT in a job that runs no repository code', () => { // The job that can write as the bot must not check out or execute anything // from the repository; it only consumes strings produced elsewhere. expect(privilegedJobs).toHaveLength(1); for (const [name, job] of privilegedJobs) { const rendered = JSON.stringify(job); expect(rendered, name).not.toContain('actions/checkout'); expect(rendered, name).not.toContain('main-failure-signature.mjs'); expect(job.permissions, name).toEqual({ issues: 'write' }); } }); it('pins the analyze checkout and drops persist-credentials', () => { // The read-only analyze job does check out the repo (it runs the helper), // so pin it to a SHA rather than a mutable tag and never leave the workflow // token on the runner. const checkout = jobs.analyze.steps.find((step) => String(step.uses ?? '').startsWith('actions/checkout'), ); expect(checkout).toBeDefined(); expect(checkout.uses).toMatch(/^actions\/checkout@[0-9a-f]{40}$/); expect(checkout.with['persist-credentials']).toBe(false); }); it('keeps the log analysis away from the bot PAT and from write scopes', () => { const analyze = jobs.analyze; expect(JSON.stringify(analyze)).not.toContain('CI_DEV_BOT_PAT'); // Reading job logs needs `actions: read`; nothing here needs write. expect(analyze.permissions).toEqual({ actions: 'read', contents: 'read', issues: 'read', }); expect(privilegedJobs[0][1].needs).toBe('analyze'); }); });