Skip to content
76 changes: 76 additions & 0 deletions .mux/actions/github/ensureIssueLabels.js
Original file line number Diff line number Diff line change
@@ -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;
103 changes: 103 additions & 0 deletions .mux/actions/github/findIssueComment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const {
commentAuthorLogin,
currentUserLogin,
inputObject,
listComments,
optionalString,
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(),
expectedAuthor: mux.schema.optional(mux.schema.string()),
requireAuthenticatedAuthor: mux.schema.optional(mux.schema.boolean()),
requiredBodyIncludes: 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,
};

async function expectedAuthor(input, ctx) {
const explicit = optionalString(input.expectedAuthor);
if (explicit) return explicit;
if (input.requireAuthenticatedAuthor === false) return '';
return await currentUserLogin(ctx);
}

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 author = await expectedAuthor(input, ctx);
if (input.requireAuthenticatedAuthor !== false && !author) {
return {
found: false,
reason: 'authenticated-author-unavailable',
url: null,
commentId: null,
updatedAt: null,
};
}

const comments = await listComments(ctx, parts.owner, parts.repo, number, {
limit: 'all',
});
const match = comments
.slice()
.reverse()
.find(
(comment) =>
(!author || commentAuthorLogin(comment) === author) &&
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,
};
}
120 changes: 120 additions & 0 deletions .mux/actions/github/getIssueAutomationState.js
Original file line number Diff line number Diff line change
@@ -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.optional(mux.schema.string()),
markerKey: mux.schema.optional(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),
})),
};
}
Loading
Loading