basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7import { promises as fsp } from 'node:fs';8import * as path from 'node:path';9import { ProxyAgent } from 'undici';10 11import {12 getGitHubRepoInfoAsync,13 getGitRepoRootAsync,14 getLatestGitHubRelease,15 isGitHubRepositoryAsync,16} from '../utils/gitUtils.js';17import { createDebugLogger } from '@qwen-code/qwen-code-core';18import { writeStderrLine } from '../utils/stdioHelpers.js';19 20const debugLogger = createDebugLogger('SETUP_GITHUB');21 22export const GITHUB_WORKFLOW_PATHS = [23 'qwen-dispatch/qwen-dispatch.yml',24 'qwen-assistant/qwen-invoke.yml',25 'issue-triage/qwen-triage.yml',26 'issue-triage/qwen-scheduled-triage.yml',27 'pr-review/qwen-review.yml',28];29 30const GITIGNORE_ENTRIES = ['.qwen/', 'gha-creds-*.json'];31export const MAX_WORKFLOW_DOWNLOAD_BYTES = 5 * 1024 * 1024;32 33export type GithubSetupGitignoreStatus =34 | 'created'35 | 'updated'36 | 'unchanged'37 | 'failed'38 | 'skipped';39 40export interface GithubSetupWriteMetadata {41 sizeBytes: number;42}43 44export interface SetupGithubFileOps {45 assertCanWrite?(): void;46 ensureWorkflowDirectory(gitRepoRoot: string): Promise<void>;47 writeTextFile(48 gitRepoRoot: string,49 relativePath: string,50 content: string,51 ): Promise<GithubSetupWriteMetadata>;52 readTextFile(53 gitRepoRoot: string,54 relativePath: string,55 ): Promise<string | undefined>;56}57 58export interface GithubSetupWorkflowResult {59 sourcePath: string;60 path: string;61 status: 'written' | 'failed';62 sizeBytes?: number;63 error?: string;64}65 66export interface GithubSetupGitignoreResult {67 path: '.gitignore';68 status: GithubSetupGitignoreStatus;69 added?: string[];70 error?: string;71}72 73export interface SetupGithubResult {74 kind: 'github_setup';75 workspaceCwd: string;76 gitRepoRoot: string;77 releaseTag: string;78 readmeUrl: string;79 secretsUrl?: string;80 workflows: GithubSetupWorkflowResult[];81 gitignore: GithubSetupGitignoreResult;82 warnings: string[];83 partial?: boolean;84}85 86export interface SetupGithubOptions {87 cwd?: string;88 workspaceRoot?: string;89 proxy?: string;90 abortSignal?: AbortSignal;91 fetchImpl?: typeof fetch;92 fileOps?: SetupGithubFileOps;93}94 95export class SetupGithubError extends Error {96 readonly code: string;97 readonly status: number;98 readonly partial: boolean;99 readonly partialResult?: SetupGithubResult;100 101 constructor(102 code: string,103 message: string,104 status: number,105 partialResult?: SetupGithubResult,106 ) {107 super(message);108 this.name = 'SetupGithubError';109 this.code = code;110 this.status = status;111 this.partial = partialResult !== undefined;112 this.partialResult = partialResult;113 }114}115 116const nodeFileOps: SetupGithubFileOps = {117 assertCanWrite(): void {},118 119 async ensureWorkflowDirectory(gitRepoRoot: string): Promise<void> {120 await fsp.mkdir(path.join(gitRepoRoot, '.github', 'workflows'), {121 recursive: true,122 });123 },124 125 async writeTextFile(126 gitRepoRoot: string,127 relativePath: string,128 content: string,129 ): Promise<GithubSetupWriteMetadata> {130 const target = path.join(gitRepoRoot, relativePath);131 await fsp.writeFile(target, content, { mode: 0o644 });132 return { sizeBytes: Buffer.byteLength(content, 'utf8') };133 },134 135 async readTextFile(136 gitRepoRoot: string,137 relativePath: string,138 ): Promise<string | undefined> {139 try {140 return await fsp.readFile(path.join(gitRepoRoot, relativePath), 'utf8');141 } catch (error) {142 if ((error as NodeJS.ErrnoException).code === 'ENOENT') {143 return undefined;144 }145 throw error;146 }147 },148};149 150export async function setupGithub(151 options: SetupGithubOptions = {},152): Promise<SetupGithubResult> {153 const cwd = options.cwd ?? process.cwd();154 const fileOps = options.fileOps ?? nodeFileOps;155 156 if (!(await isGitHubRepositoryAsync({ cwd }))) {157 throw new SetupGithubError(158 'github_repository_not_found',159 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',160 400,161 );162 }163 164 let gitRepoRoot: string;165 try {166 gitRepoRoot = await getGitRepoRootAsync({ cwd });167 } catch (error) {168 debugLogger.debug('Failed to get git repo root:', error);169 throw new SetupGithubError(170 'github_repository_not_found',171 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',172 400,173 );174 }175 176 if (options.workspaceRoot) {177 const [gitRootReal, workspaceReal] = await Promise.all([178 realpathOrResolve(gitRepoRoot),179 realpathOrResolve(options.workspaceRoot),180 ]);181 if (gitRootReal !== workspaceReal) {182 throw new SetupGithubError(183 'github_git_root_mismatch',184 'The Git repository root must match the daemon workspace root.',185 400,186 );187 }188 }189 190 fileOps.assertCanWrite?.();191 192 let releaseTag: string;193 try {194 releaseTag = await getLatestGitHubRelease(options.proxy);195 } catch (error) {196 writeStderrLine(197 `qwen setup-github: failed to determine latest qwen-code-action release: ${198 error instanceof Error ? error.message : String(error)199 }`,200 );201 debugLogger.debug(202 'Failed to determine latest qwen-code-action release:',203 error,204 );205 throw new SetupGithubError(206 'github_release_lookup_failed',207 'Unable to determine the latest qwen-code-action release on GitHub.',208 502,209 );210 }211 212 const readmeUrl = `https://github.com/QwenLM/qwen-code-action/blob/${releaseTag}/README.md#quick-start`;213 const secretsUrl = await resolveSecretsUrl(cwd);214 const downloads = await downloadWorkflows({215 releaseTag,216 proxy: options.proxy,217 abortSignal: options.abortSignal,218 fetchImpl: options.fetchImpl ?? fetch,219 });220 221 const result: SetupGithubResult = {222 kind: 'github_setup',223 workspaceCwd: options.workspaceRoot ?? gitRepoRoot,224 gitRepoRoot,225 releaseTag,226 readmeUrl,227 ...(secretsUrl ? { secretsUrl } : {}),228 workflows: [],229 gitignore: { path: '.gitignore', status: 'skipped' },230 warnings: [],231 };232 233 result.gitignore = await updateGitignore(gitRepoRoot, fileOps);234 if (result.gitignore.status === 'failed') {235 result.warnings.push('Failed to update .gitignore.');236 }237 238 try {239 await fileOps.ensureWorkflowDirectory(gitRepoRoot);240 for (const workflow of downloads) {241 const relativePath = path.posix.join(242 '.github',243 'workflows',244 path.posix.basename(workflow.sourcePath),245 );246 try {247 const write = await fileOps.writeTextFile(248 gitRepoRoot,249 relativePath,250 workflow.content,251 );252 result.workflows.push({253 sourcePath: workflow.sourcePath,254 path: relativePath,255 status: 'written',256 sizeBytes: write.sizeBytes,257 });258 } catch (error) {259 result.partial = true;260 result.workflows.push({261 sourcePath: workflow.sourcePath,262 path: relativePath,263 status: 'failed',264 error: error instanceof Error ? error.message : String(error),265 });266 throw new SetupGithubError(267 'github_workflow_write_failed',268 `Unable to write ${relativePath}.`,269 500,270 result,271 );272 }273 }274 } catch (error) {275 if (error instanceof SetupGithubError) throw error;276 throw new SetupGithubError(277 'github_workflow_write_failed',278 'Unable to create .github/workflows.',279 500,280 result,281 );282 }283 284 return result;285}286 287export async function updateGitignore(288 gitRepoRoot: string,289 fileOps: SetupGithubFileOps = nodeFileOps,290): Promise<GithubSetupGitignoreResult> {291 try {292 const existingContent = await fileOps.readTextFile(293 gitRepoRoot,294 '.gitignore',295 );296 if (existingContent === undefined) {297 const content = GITIGNORE_ENTRIES.join('\n') + '\n';298 await fileOps.writeTextFile(gitRepoRoot, '.gitignore', content);299 return {300 path: '.gitignore',301 status: 'created',302 added: [...GITIGNORE_ENTRIES],303 };304 }305 306 const missingEntries = GITIGNORE_ENTRIES.filter(307 (entry) =>308 !existingContent309 .split(/\r?\n/)310 .some((line) => line.split('#')[0].trim() === entry),311 );312 if (missingEntries.length === 0) {313 return { path: '.gitignore', status: 'unchanged' };314 }315 316 const nextContent =317 existingContent + '\n' + missingEntries.join('\n') + '\n';318 await fileOps.writeTextFile(gitRepoRoot, '.gitignore', nextContent);319 return {320 path: '.gitignore',321 status: 'updated',322 added: missingEntries,323 };324 } catch (error) {325 debugLogger.debug('Failed to update .gitignore:', error);326 return {327 path: '.gitignore',328 status: 'failed',329 error: error instanceof Error ? error.message : String(error),330 };331 }332}333 334async function downloadWorkflows(options: {335 releaseTag: string;336 proxy?: string;337 abortSignal?: AbortSignal;338 fetchImpl: typeof fetch;339}): Promise<Array<{ sourcePath: string; content: string }>> {340 const internalAbort = new AbortController();341 try {342 const dispatcher = options.proxy343 ? new ProxyAgent(options.proxy)344 : undefined;345 return await Promise.all(346 GITHUB_WORKFLOW_PATHS.map(async (workflow) => {347 const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${options.releaseTag}/examples/workflows/${workflow}`;348 const response = await options.fetchImpl(endpoint, {349 method: 'GET',350 dispatcher,351 signal: AbortSignal.any([352 AbortSignal.timeout(30_000),353 internalAbort.signal,354 ...(options.abortSignal ? [options.abortSignal] : []),355 ]),356 } as RequestInit);357 358 if (!response.ok) {359 throw new Error(360 `Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`,361 );362 }363 return {364 sourcePath: workflow,365 content: await readResponseTextWithLimit(response, workflow),366 };367 }),368 );369 } catch (error) {370 internalAbort.abort();371 const message = error instanceof Error ? error.message : String(error);372 debugLogger.debug('Failed to download qwen-code-action workflows:', error);373 throw new SetupGithubError(374 'github_workflow_download_failed',375 `Unable to download qwen-code-action workflows from GitHub. ${message}`,376 502,377 );378 }379}380 381async function readResponseTextWithLimit(382 response: Response,383 sourcePath: string,384): Promise<string> {385 const contentLength = response.headers.get('content-length');386 if (contentLength !== null) {387 const parsedLength = Number(contentLength);388 if (389 Number.isFinite(parsedLength) &&390 parsedLength > MAX_WORKFLOW_DOWNLOAD_BYTES391 ) {392 throw new Error(393 `${sourcePath} exceeds download limit of ${MAX_WORKFLOW_DOWNLOAD_BYTES} bytes`,394 );395 }396 }397 398 if (!response.body) return '';399 400 const reader = response.body.getReader();401 const chunks: Uint8Array[] = [];402 let totalBytes = 0;403 try {404 while (true) {405 const { done, value } = await reader.read();406 if (done) break;407 if (!value) continue;408 totalBytes += value.byteLength;409 if (totalBytes > MAX_WORKFLOW_DOWNLOAD_BYTES) {410 await reader.cancel().catch(() => {});411 throw new Error(412 `${sourcePath} exceeds download limit of ${MAX_WORKFLOW_DOWNLOAD_BYTES} bytes`,413 );414 }415 chunks.push(value);416 }417 } finally {418 reader.releaseLock();419 }420 421 const body = new Uint8Array(totalBytes);422 let offset = 0;423 for (const chunk of chunks) {424 body.set(chunk, offset);425 offset += chunk.byteLength;426 }427 return new TextDecoder().decode(body);428}429 430async function resolveSecretsUrl(cwd: string): Promise<string | undefined> {431 try {432 const repoInfo = await getGitHubRepoInfoAsync({ cwd });433 return `https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`;434 } catch {435 return undefined;436 }437}438 439async function realpathOrResolve(input: string): Promise<string> {440 try {441 return await fsp.realpath(input);442 } catch {443 return path.resolve(input);444 }445}446 