diff --git a/.mux/actions/github/ensureIssueLabels.js b/.mux/actions/github/ensureIssueLabels.js new file mode 100644 index 00000000..5bd8cd5c --- /dev/null +++ b/.mux/actions/github/ensureIssueLabels.js @@ -0,0 +1,76 @@ +const { + getIssueView, + inputObject, + normalizeIssue, + repositoryFromInput, + requiredIssueNumber, + stringList, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'Idempotently add and remove GitHub issue labels', + effect: 'external', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + addLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + removeLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + changed: mux.schema.boolean(), + before: mux.schema.array(mux.schema.string()), + after: mux.schema.array(mux.schema.string()), + added: mux.schema.array(mux.schema.string()), + removed: mux.schema.array(mux.schema.string()), + }, + { additionalProperties: false }, + ), + permissions: [ + { kind: 'command', command: 'gh issue edit' }, + { kind: 'command', command: 'gh issue view' }, + ], + timeoutMs: 60000, +}; + +async function getLabelNames(ctx, repository, number) { + const issue = await getIssueView(ctx, repository, number, ['labels']); + return normalizeIssue(issue).labelNames; +} + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = repositoryFromInput(input); + const number = requiredIssueNumber(input.number); + const addLabels = stringList(input.addLabels); + const removeLabels = stringList(input.removeLabels); + const before = await getLabelNames(ctx, repository, number); + const missingAddLabels = addLabels.filter((label) => !before.includes(label)); + const presentRemoveLabels = removeLabels.filter((label) => + before.includes(label), + ); + if (missingAddLabels.length === 0 && presentRemoveLabels.length === 0) { + return { changed: false, before, after: before, added: [], removed: [] }; + } + const args = ['issue', 'edit', String(number)]; + if (repository) args.push('--repo', repository); + for (const label of missingAddLabels) args.push('--add-label', label); + for (const label of presentRemoveLabels) args.push('--remove-label', label); + await ctx.execChecked('gh', args); + const after = await getLabelNames(ctx, repository, number); + return { + changed: true, + before, + after, + added: missingAddLabels, + removed: presentRemoveLabels, + }; +} + +export const reconcile = execute; diff --git a/.mux/actions/github/findIssueComment.js b/.mux/actions/github/findIssueComment.js new file mode 100644 index 00000000..784d3fc8 --- /dev/null +++ b/.mux/actions/github/findIssueComment.js @@ -0,0 +1,79 @@ +const { + inputObject, + listComments, + requiredIssueNumber, + requiredRepository, + splitRepository, + stringList, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'Find the latest GitHub issue comment containing expected text', + effect: 'read', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + requiredBodyIncludes: mux.schema.optional( + mux.schema.array(mux.schema.string()), + ), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + found: mux.schema.boolean(), + reason: mux.schema.string(), + url: mux.schema.nullable(mux.schema.string()), + commentId: mux.schema.nullable( + mux.schema.union([mux.schema.integer(), mux.schema.string()]), + ), + updatedAt: mux.schema.nullable(mux.schema.string()), + }, + { additionalProperties: false }, + ), + permissions: [{ kind: 'command', command: 'gh api' }], + timeoutMs: 60000, +}; + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = requiredRepository(input); + const parts = splitRepository(repository); + const number = requiredIssueNumber(input.number); + const includes = stringList(input.requiredBodyIncludes); + if (includes.length === 0) { + throw new Error('requiredBodyIncludes must include at least one string'); + } + + const comments = await listComments(ctx, parts.owner, parts.repo, number); + const match = comments + .slice() + .reverse() + .find( + (comment) => + typeof comment.body === 'string' && + includes.every((text) => comment.body.includes(text)), + ); + + if (!match) { + return { + found: false, + reason: 'not-found', + url: null, + commentId: null, + updatedAt: null, + }; + } + + return { + found: true, + reason: '', + url: match.html_url || null, + commentId: match.id || null, + updatedAt: match.updated_at || null, + }; +} diff --git a/.mux/actions/github/getIssueAutomationState.js b/.mux/actions/github/getIssueAutomationState.js new file mode 100644 index 00000000..ac6a10ce --- /dev/null +++ b/.mux/actions/github/getIssueAutomationState.js @@ -0,0 +1,120 @@ +const { + getIssueView, + inputObject, + isMatchingMarker, + listComments, + markerStatus, + normalizeIssue, + optionalString, + requiredIssueNumber, + requiredRepository, + requiredString, + splitRepository, + stringList, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'Read GitHub issue automation marker comments and done labels', + effect: 'read', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + doneLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + ongoingLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + includeComments: mux.schema.optional(mux.schema.boolean()), + marker: mux.schema.string(), + markerKey: mux.schema.string(), + promptVersion: mux.schema.optional(mux.schema.string()), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + done: mux.schema.boolean(), + promptStarted: mux.schema.boolean(), + reportPosted: mux.schema.boolean(), + labelNames: mux.schema.array(mux.schema.string()), + markerComments: mux.schema.array( + mux.schema.object( + { + id: mux.schema.integer(), + url: mux.schema.nullable(mux.schema.string()), + status: mux.schema.string(), + }, + { additionalProperties: false }, + ), + ), + }, + { additionalProperties: false }, + ), + permissions: [ + { kind: 'command', command: 'gh api' }, + { kind: 'command', command: 'gh issue view' }, + ], + timeoutMs: 60000, +}; + +function labelsIncludeAll(labels, labelNames) { + return ( + labels.length > 0 && labels.every((label) => labelNames.includes(label)) + ); +} + +function labelsIncludeAny(labels, labelNames) { + return labels.some((label) => labelNames.includes(label)); +} + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = requiredRepository(input); + const number = requiredIssueNumber(input.number); + const doneLabels = stringList(input.doneLabels); + const ongoingLabels = stringList(input.ongoingLabels); + const includeComments = input.includeComments !== false; + const issuePromise = getIssueView(ctx, repository, number, ['labels']); + + if (!includeComments) { + const issue = await issuePromise; + const labelNames = normalizeIssue(issue).labelNames; + return { + done: labelsIncludeAll(doneLabels, labelNames), + promptStarted: labelsIncludeAny(ongoingLabels, labelNames), + reportPosted: false, + labelNames, + markerComments: [], + }; + } + + const parts = splitRepository(repository); + const marker = requiredString(input.marker, 'marker'); + const markerKey = requiredString(input.markerKey, 'markerKey'); + const promptVersion = optionalString(input.promptVersion) || 'v1'; + const [issue, comments] = await Promise.all([ + issuePromise, + listComments(ctx, parts.owner, parts.repo, number), + ]); + const labelNames = normalizeIssue(issue).labelNames; + const matching = comments.filter((comment) => + isMatchingMarker(comment.body, marker, markerKey, promptVersion), + ); + const statuses = matching + .map((comment) => markerStatus(comment.body)) + .filter(Boolean); + return { + done: labelsIncludeAll(doneLabels, labelNames), + promptStarted: + labelsIncludeAny(ongoingLabels, labelNames) || + statuses.includes('prompt-started'), + reportPosted: statuses.includes('report-posted'), + labelNames, + markerComments: matching.map((comment) => ({ + id: comment.id, + url: comment.html_url || null, + status: markerStatus(comment.body), + })), + }; +} diff --git a/.mux/actions/github/getIssueConversation.js b/.mux/actions/github/getIssueConversation.js new file mode 100644 index 00000000..bd4c4b5d --- /dev/null +++ b/.mux/actions/github/getIssueConversation.js @@ -0,0 +1,163 @@ +const { + boundedCharBudget, + boundedLimit, + getIssueView, + inputObject, + listComments, + normalizeIssue, + repositoryFromInput, + requiredIssueNumber, + splitRepository, + truncateText, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'Read a GitHub issue body and comments as markdown', + effect: 'read', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + maxComments: mux.schema.optional(mux.schema.integer()), + issueBodyCharBudget: mux.schema.optional(mux.schema.integer()), + bodyCharBudget: mux.schema.optional(mux.schema.integer()), + commentBodyCharBudget: mux.schema.optional(mux.schema.integer()), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + repository: mux.schema.nullable(mux.schema.string()), + number: mux.schema.integer(), + issue: mux.schema.object( + { + number: mux.schema.integer(), + safeId: mux.schema.string(), + title: mux.schema.string(), + url: mux.schema.string(), + state: mux.schema.string(), + body: mux.schema.string(), + author: mux.schema.nullable(mux.schema.string()), + createdAt: mux.schema.nullable(mux.schema.string()), + updatedAt: mux.schema.nullable(mux.schema.string()), + labelNames: mux.schema.array(mux.schema.string()), + }, + { additionalProperties: false }, + ), + conversationMarkdown: mux.schema.string(), + limits: mux.schema.object( + { + maxComments: mux.schema.integer(), + issueBodyBudget: mux.schema.integer(), + commentBodyBudget: mux.schema.integer(), + hasOmittedComments: mux.schema.boolean(), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, + ), + permissions: [ + { kind: 'command', command: 'gh issue view' }, + { kind: 'command', command: 'gh api' }, + ], + timeoutMs: 60000, +}; + +function repositoryPartsForComments(repository, issue) { + if (repository) return splitRepository(repository); + const match = + typeof issue.url === 'string' + ? issue.url.match( + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/\d+$/, + ) + : null; + if (!match) + throw new Error( + 'repository or a GitHub issue URL is required to read comments', + ); + return { owner: match[1], repo: match[2] }; +} + +function formatConversation(comments, commentBodyBudget, hasOmittedComments) { + const visibleComments = Array.isArray(comments) ? comments : []; + if (visibleComments.length === 0) return '(no issue comments)'; + const markdown = visibleComments + .map( + (comment) => + '### Comment by ' + + ((comment.author && comment.author.login) || 'unknown') + + '\n\n' + + truncateText(comment.body || '', commentBodyBudget), + ) + .join('\n\n---\n\n'); + return hasOmittedComments + ? markdown + '\n\n[omitted additional comments]' + : markdown; +} + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = repositoryFromInput(input); + const number = requiredIssueNumber(input.number); + const maxComments = boundedLimit(input.maxComments, 100); + const issueBodyBudget = boundedCharBudget( + input.issueBodyCharBudget ?? input.bodyCharBudget, + 10000, + ); + const commentBodyBudget = boundedCharBudget( + input.commentBodyCharBudget, + 10000, + ); + const issueFields = [ + 'number', + 'title', + 'url', + 'state', + 'body', + 'author', + 'labels', + ]; + let issue; + let comments; + if (repository) { + const parts = splitRepository(repository); + [issue, comments] = await Promise.all([ + getIssueView(ctx, repository, number, issueFields), + listComments(ctx, parts.owner, parts.repo, number, { + limit: maxComments + 1, + }), + ]); + } else { + issue = await getIssueView(ctx, repository, number, issueFields); + const parts = repositoryPartsForComments(repository, issue); + comments = await listComments(ctx, parts.owner, parts.repo, number, { + limit: maxComments + 1, + }); + } + const visibleComments = comments.slice(0, maxComments); + const hasOmittedComments = comments.length > visibleComments.length; + const normalizedIssue = normalizeIssue(issue); + return { + repository: repository || null, + number, + issue: { + ...normalizedIssue, + body: truncateText(normalizedIssue.body, issueBodyBudget), + }, + conversationMarkdown: formatConversation( + visibleComments, + commentBodyBudget, + hasOmittedComments, + ), + limits: { + maxComments, + issueBodyBudget, + commentBodyBudget, + hasOmittedComments, + }, + }; +} diff --git a/.mux/actions/github/listIssues.js b/.mux/actions/github/listIssues.js new file mode 100644 index 00000000..e705e34e --- /dev/null +++ b/.mux/actions/github/listIssues.js @@ -0,0 +1,119 @@ +const { + boundedCharBudget, + boundedLimit, + inputObject, + normalizeIssue, + optionalString, + repositoryFromInput, + stringList, + truncateText, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'List GitHub issues with reusable label/state filters', + effect: 'read', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + state: mux.schema.optional(mux.schema.string()), + includeLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + excludeLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + limit: mux.schema.optional(mux.schema.integer()), + includeBody: mux.schema.optional(mux.schema.boolean()), + bodyCharBudget: mux.schema.optional(mux.schema.integer()), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + repository: mux.schema.nullable(mux.schema.string()), + filters: mux.schema.object( + { + state: mux.schema.string(), + includeLabels: mux.schema.array(mux.schema.string()), + excludeLabels: mux.schema.array(mux.schema.string()), + limit: mux.schema.integer(), + includeBody: mux.schema.boolean(), + bodyCharBudget: mux.schema.integer(), + }, + { additionalProperties: false }, + ), + issues: mux.schema.array( + mux.schema.object( + { + number: mux.schema.integer(), + safeId: mux.schema.string(), + title: mux.schema.string(), + url: mux.schema.string(), + state: mux.schema.string(), + body: mux.schema.string(), + author: mux.schema.nullable(mux.schema.string()), + createdAt: mux.schema.nullable(mux.schema.string()), + updatedAt: mux.schema.nullable(mux.schema.string()), + labelNames: mux.schema.array(mux.schema.string()), + }, + { additionalProperties: false }, + ), + ), + }, + { additionalProperties: false }, + ), + permissions: [{ kind: 'command', command: 'gh issue list' }], + timeoutMs: 60000, +}; + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = repositoryFromInput(input); + const state = optionalString(input.state) || 'open'; + const includeLabels = stringList(input.includeLabels); + const excludeLabels = stringList(input.excludeLabels); + const limit = boundedLimit(input.limit, 1000); + const includeBody = input.includeBody === true; + const bodyCharBudget = boundedCharBudget(input.bodyCharBudget, 2000); + const jsonFields = + 'number,title,url,state,labels,author,createdAt,updatedAt' + + (includeBody ? ',body' : ''); + const fetchLimit = excludeLabels.length > 0 ? 1000 : limit; + const args = [ + 'issue', + 'list', + '--state', + state, + '--limit', + String(fetchLimit), + '--json', + jsonFields, + ]; + if (repository) args.push('--repo', repository); + for (const label of includeLabels) args.push('--label', label); + const issues = (await ctx.execJson('gh', args)) + .map(normalizeIssue) + .map((issue) => ({ + ...issue, + body: includeBody ? truncateText(issue.body, bodyCharBudget) : '', + })) + .filter((issue) => + includeLabels.every((label) => issue.labelNames.includes(label)), + ) + .filter((issue) => + excludeLabels.every((label) => !issue.labelNames.includes(label)), + ) + .sort((a, b) => a.number - b.number) + .slice(0, limit); + return { + repository: repository || null, + filters: { + state, + includeLabels, + excludeLabels, + limit, + includeBody, + bodyCharBudget, + }, + issues, + }; +} diff --git a/.mux/actions/github/verifyIssueCommentUrl.js b/.mux/actions/github/verifyIssueCommentUrl.js new file mode 100644 index 00000000..93972b76 --- /dev/null +++ b/.mux/actions/github/verifyIssueCommentUrl.js @@ -0,0 +1,98 @@ +const { + inputObject, + requiredIssueNumber, + requiredRepository, + requiredString, + splitRepository, + stringList, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: + 'Verify that a GitHub issue comment URL belongs to an issue and contains expected text', + effect: 'read', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + url: mux.schema.string(), + requiredBodyIncludes: mux.schema.optional( + mux.schema.array(mux.schema.string()), + ), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + verified: mux.schema.boolean(), + reason: mux.schema.string(), + missing: mux.schema.optional(mux.schema.array(mux.schema.string())), + url: mux.schema.optional(mux.schema.string()), + commentId: mux.schema.optional( + mux.schema.union([mux.schema.integer(), mux.schema.string()]), + ), + }, + { additionalProperties: false }, + ), + permissions: [{ kind: 'command', command: 'gh api' }], + timeoutMs: 60000, +}; + +function parseCommentUrl(url) { + const match = String(url || '').match( + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)#issuecomment-(\d+)$/, + ); + return match + ? { + owner: match[1], + repo: match[2], + number: Number(match[3]), + commentId: match[4], + } + : null; +} + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = requiredRepository(input); + const parts = splitRepository(repository); + const number = requiredIssueNumber(input.number); + const parsed = parseCommentUrl(requiredString(input.url, 'url')); + if ( + !parsed || + parsed.owner !== parts.owner || + parsed.repo !== parts.repo || + parsed.number !== number + ) { + return { verified: false, reason: 'comment-url-does-not-match-issue' }; + } + const comment = await ctx.execJson('gh', [ + 'api', + 'repos/' + + parts.owner + + '/' + + parts.repo + + '/issues/comments/' + + parsed.commentId, + ]); + const includes = stringList(input.requiredBodyIncludes); + const missing = includes.filter( + (text) => typeof comment.body !== 'string' || !comment.body.includes(text), + ); + if (missing.length > 0) + return { + verified: false, + reason: 'missing-required-body-text', + missing, + url: comment.html_url || input.url, + }; + return { + verified: true, + reason: '', + url: comment.html_url || input.url, + commentId: comment.id || parsed.commentId, + }; +} diff --git a/.mux/actions/project/context.js b/.mux/actions/project/context.js new file mode 100644 index 00000000..6fa1d589 --- /dev/null +++ b/.mux/actions/project/context.js @@ -0,0 +1,132 @@ +const path = require('path'); + +export const metadata = { + version: 1, + description: 'Resolve the current Mux project path and GitHub repository', + effect: 'read', + inputSchema: mux.schema.object({}, { additionalProperties: false }), + outputSchema: mux.schema.object( + { + cwd: mux.schema.nullable(mux.schema.string()), + gitRoot: mux.schema.nullable(mux.schema.string()), + projectPath: mux.schema.nullable(mux.schema.string()), + projectPathSource: mux.schema.string(), + repository: mux.schema.nullable(mux.schema.string()), + repositorySource: mux.schema.string(), + }, + { additionalProperties: false }, + ), + permissions: [ + { kind: 'command', command: 'pwd' }, + { kind: 'command', command: 'git rev-parse' }, + { kind: 'command', command: 'git remote get-url' }, + { kind: 'command', command: 'gh repo view' }, + ], + timeoutMs: 30000, +}; + +function cleanString(value) { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim() + : null; +} + +async function execStdout(ctx, command, args) { + try { + const result = await ctx.execChecked(command, args); + return cleanString(result.stdout); + } catch { + return null; + } +} + +async function repoFromGh(ctx) { + try { + const result = await ctx.execJson('gh', [ + 'repo', + 'view', + '--json', + 'nameWithOwner', + ]); + return cleanString(result && result.nameWithOwner); + } catch { + return null; + } +} + +function repoFromRemoteUrl(url) { + const text = cleanString(url); + if (!text) return null; + const match = text.match( + /(?:github\.com[:/])([^/]+)\/([^/.]+)(?:\.git)?(?:\/?$)/, + ); + return match ? match[1] + '/' + match[2] : null; +} + +function projectPathFromGitCommonDir(commonDir, baseDir) { + const text = cleanString(commonDir); + if (!text) return null; + const absolute = path.isAbsolute(text) + ? text + : path.resolve(baseDir || '.', text); + return path.basename(absolute) === '.git' ? path.dirname(absolute) : null; +} + +function chooseProjectPath(envProjectPath, commonProjectPath, gitRoot, cwd) { + if (envProjectPath) { + return { + projectPath: envProjectPath, + projectPathSource: 'MUX_PROJECT_PATH', + }; + } + if (commonProjectPath) { + return { + projectPath: commonProjectPath, + projectPathSource: 'git-common-dir', + }; + } + if (gitRoot) return { projectPath: gitRoot, projectPathSource: 'git-root' }; + if (cwd) return { projectPath: cwd, projectPathSource: 'cwd' }; + return { projectPath: null, projectPathSource: 'unresolved' }; +} + +export async function execute(_input, ctx) { + const cwd = cleanString(ctx.cwd) || (await execStdout(ctx, 'pwd', [])); + const gitRoot = await execStdout(ctx, 'git', [ + 'rev-parse', + '--show-toplevel', + ]); + const gitCommonDir = await execStdout(ctx, 'git', [ + 'rev-parse', + '--git-common-dir', + ]); + const envProjectPath = cleanString(process.env.MUX_PROJECT_PATH); + const commonProjectPath = projectPathFromGitCommonDir( + gitCommonDir, + gitRoot || cwd, + ); + const { projectPath, projectPathSource } = chooseProjectPath( + envProjectPath, + commonProjectPath, + gitRoot, + cwd, + ); + const repositoryFromGh = await repoFromGh(ctx); + const remoteUrl = repositoryFromGh + ? null + : await execStdout(ctx, 'git', ['remote', 'get-url', 'origin']); + const repositoryFromGit = repoFromRemoteUrl(remoteUrl); + + return { + cwd, + gitRoot, + projectPath, + projectPathSource, + repository: repositoryFromGh || repositoryFromGit, + repositorySource: repositoryFromGh + ? 'gh-repo-view' + : repositoryFromGit + ? 'git-origin' + : 'unresolved', + }; +} diff --git a/.mux/workflow-action-lib/github.cjs b/.mux/workflow-action-lib/github.cjs new file mode 100644 index 00000000..0050afc9 --- /dev/null +++ b/.mux/workflow-action-lib/github.cjs @@ -0,0 +1,174 @@ +function inputObject(input) { + return input != null && typeof input === 'object' && !Array.isArray(input) + ? input + : {}; +} + +function optionalString(value) { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function requiredString(value, name) { + const text = optionalString(value); + if (!text) throw new Error(name + ' must be a non-empty string'); + return text; +} + +function stringList(value) { + if (!Array.isArray(value)) return []; + return value + .filter((item) => typeof item === 'string' && item.trim().length > 0) + .map((item) => item.trim()); +} + +function repositoryFromInput(input) { + const repository = optionalString(input.repository); + if (repository) return repository; + const owner = optionalString(input.owner); + const repo = optionalString(input.repo); + return owner && repo ? owner + '/' + repo : undefined; +} + +function requiredRepository(input) { + const repository = repositoryFromInput(input); + if (!repository) throw new Error('repository or owner/repo is required'); + return repository; +} + +function splitRepository(repository) { + const parts = repository.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error('repository must use owner/repo format'); + } + return { owner: parts[0], repo: parts[1] }; +} + +function requiredIssueNumber(value) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error('number must be a positive integer issue number'); + } + return value; +} + +function boundedLimit(value, fallback) { + if (!Number.isInteger(value)) return fallback; + return Math.max(1, Math.min(value, 1000)); +} + +function boundedCharBudget(value, fallback) { + if (!Number.isInteger(value)) return fallback; + return Math.max(0, Math.min(value, 100000)); +} + +function truncateText(value, budget) { + const text = typeof value === 'string' ? value : ''; + if (text.length <= budget) return text; + return ( + text.slice(0, budget) + + '\n\n[truncated ' + + (text.length - budget) + + ' chars]' + ); +} + +function normalizeIssue(issue) { + const labelNames = Array.isArray(issue.labels) + ? issue.labels + .map((label) => (typeof label === 'string' ? label : label.name)) + .filter((name) => typeof name === 'string' && name.length > 0) + : []; + return { + number: issue.number, + safeId: 'issue-' + issue.number, + title: issue.title || '', + url: issue.url || '', + state: issue.state || '', + body: issue.body || '', + author: issue.author && issue.author.login ? issue.author.login : null, + createdAt: issue.createdAt || null, + updatedAt: issue.updatedAt || null, + labelNames, + }; +} + +function markerCommentNeedle(marker, markerKey, promptVersion) { + return ( + '