Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ runs:
WORKING_BRANCH: ${{ steps.prepare.outputs.WORKING_BRANCH }}
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }}
IS_NEW_BRANCH: ${{ steps.prepare.outputs.IS_NEW_BRANCH }}
ENTITY_TITLE: ${{ steps.prepare.outputs.ENTITY_TITLE }}
WORKING_DIR: ${{ inputs.junie_work_dir }}
APP_TOKEN: ${{ inputs.junie_api_key }}
CODE_REVIEW_FEEDBACK_API_BASE_URL: ${{ inputs.code_review_feedback_api_base_url }}
Expand Down
2 changes: 2 additions & 0 deletions src/constants/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export const OUTPUT_VARS = {
EJ_MCP_CONFIG: "EJ_MCP_CONFIG",
CUSTOM_JUNIE_ARGS: "CUSTOM_JUNIE_ARGS",

ENTITY_TITLE: "ENTITY_TITLE",

// Exception handling
EXCEPTION: "EXCEPTION",

Expand Down
13 changes: 11 additions & 2 deletions src/constants/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,17 @@ ${issueId ? `- 🔗 **Issue:** Fixes: #${issueId}` : ""}
${junieBody}
`

export const PR_TITLE_TEMPLATE = (junieTitle: string) =>
`[Junie]: ${junieTitle}`
export const PR_TITLE_PREFIX = "[Junie]: "

export const PR_TITLE_TEMPLATE = (junieTitle: string) => {
let title = junieTitle.trim()

while (title.toLowerCase().startsWith(PR_TITLE_PREFIX.toLowerCase().trim())) {
title = title.slice(PR_TITLE_PREFIX.trim().length).trim()
}

return `${PR_TITLE_PREFIX}${title}`
}

export const COMMIT_MESSAGE_TEMPLATE = (junieTitle: string, issueId?: number, actor?: string, actorEmail?: string) => {
const baseMessage = `${issueId ? `[issue-${issueId}]\n\n` : ""}${junieTitle}`;
Expand Down
42 changes: 36 additions & 6 deletions src/entrypoints/handle-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ import * as fs from "node:fs";
import type {CliOutput} from "../github/junie/types/junie";
import {fetchCodeReviewFeedbackLink} from "../utils/code-review-feedback-link";
import {formatJunieErrors, formatJunieExitCodeNote, resolveJunieOutputFile} from "../utils/junie-failure";
import {resolvePrTitle} from "../utils/pr-title";

// Prefers the title resolved by the prepare step, falling back to the event payload.
function getTriggeringEntityTitle(context: JunieExecutionContext): string | undefined {
const fromPrepare = process.env[OUTPUT_VARS.ENTITY_TITLE];
if (fromPrepare && fromPrepare.trim() !== "") {
return fromPrepare;
}

const payload = context.payload as {
issue?: { title?: string };
pull_request?: { title?: string };
issueSummary?: string;
issueTitle?: string;
};

return payload.pull_request?.title
|| payload.issue?.title
|| payload.issueSummary
|| payload.issueTitle;
}

export enum ActionType {
WRITE_COMMENT = 'WRITE_COMMENT',
Expand Down Expand Up @@ -100,23 +121,30 @@ export async function handleResults() {
exportJunieSessionOutputs(sessionId);
await exportCodeReviewFeedbackLink(context, sessionId, actionToDo);
// Sanitize Junie's output to prevent token leakage and self-triggering
const rawTitle = junieJsonOutput.taskName || (isResolveConflict ? `Resolve conflicts for ${context.entityNumber} PR` : 'Junie finished task successfully')
const defaultTitle = isResolveConflict
? `Resolve conflicts for ${context.entityNumber} PR`
: 'Junie finished task successfully'
const rawTitle = isResolveConflict
? (junieJsonOutput.taskName || defaultTitle)
: resolvePrTitle(junieJsonOutput.taskName, getTriggeringEntityTitle(context), defaultTitle)
const rawBody = junieJsonOutput.result
const triggerPhrase = context.inputs.triggerPhrase

// Sanitize and truncate to prevent ARG_MAX issues
const title = truncateOutput(sanitizeJunieOutput(rawTitle, triggerPhrase), OUTPUT_SIZE_LIMITS.TITLE)
const body = truncateOutput(sanitizeJunieOutput(rawBody, triggerPhrase), OUTPUT_SIZE_LIMITS.SUMMARY)
let issueId
const rawCommitTitle = junieJsonOutput.taskName?.trim() || rawTitle
const commitTitle = truncateOutput(sanitizeJunieOutput(rawCommitTitle, triggerPhrase), OUTPUT_SIZE_LIMITS.TITLE)
let issueId: number | undefined
if (isTriggeredByUserInteraction(context)) {
issueId = context.entityNumber
}

// Add co-author only for user-triggered events (issues, PRs, comments)
// For system-triggered events (schedule, workflow_dispatch), skip co-author
const addCoAuthor = isTriggeredByUserInteraction(context);
const commitMessage = COMMIT_MESSAGE_TEMPLATE(
title,
const buildCommitMessage = (subject: string) => COMMIT_MESSAGE_TEMPLATE(
subject,
issueId,
addCoAuthor ? context.actor : undefined,
addCoAuthor ? context.actorEmail : undefined
Expand All @@ -131,14 +159,16 @@ export async function handleResults() {
title,
body,
durationMs,
commitMessage,
buildCommitMessage(title),
prTitle,
prBody);
break;
case ActionType.COMMIT_AND_PUSH:
exportResultsOutputs(title, body, durationMs, buildCommitMessage(title));
break;
case ActionType.COMMIT_CHANGES:
case ActionType.PUSH:
exportResultsOutputs(title, body, durationMs, commitMessage);
exportResultsOutputs(title, body, durationMs, buildCommitMessage(commitTitle));
break;
case ActionType.WRITE_COMMENT:
case ActionType.NOTHING:
Expand Down
118 changes: 116 additions & 2 deletions src/github/junie/junie-tasks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import {
isCodeReviewEvent,
isFixCIEvent,
isIssueCommentEvent,
isIssuesEvent,
isJiraWorkflowDispatchEvent,
isMinorFixEvent,
isPullRequestEvent,
isPullRequestReviewCommentEvent,
isPullRequestReviewEvent,
JunieExecutionContext
isPushEvent,
isTriggeredByUserInteraction,
isYouTrackWorkflowDispatchEvent,
JiraIssuePayload,
JunieExecutionContext,
YouTrackIssuePayload
} from "../context";
import * as core from "@actions/core";
import * as fs from "node:fs";
Expand All @@ -19,6 +27,94 @@ import {FetchedData} from "../api/queries";
import {CliInput} from "./types/junie";
import {generateMcpToolsPrompt} from "../../mcp/mcp-prompts";
import {junieArgsToString} from "../../utils/junie-args-parser";
import {addGitExcludePatterns, AGENT_ARTIFACT_PATTERNS} from "../../utils/git-exclude";

export function shouldRunInGoalMode(context: JunieExecutionContext): boolean {
if (isMinorFixEvent(context)) {
return false;
}

return (
(isTriggeredByUserInteraction(context) && !isPushEvent(context)) ||
isFixCIEvent(context) ||
isJiraWorkflowDispatchEvent(context) ||
isYouTrackWorkflowDispatchEvent(context) ||
Boolean(context.inputs.prompt)
);
}

const PUBLISHING_POLICY_NOTE =
"\n\nPublishing policy (must be followed exactly):\n" +
"- Do NOT push to the remote, and do NOT create or update a pull request. The workflow " +
"stages, commits, pushes and opens the pull request itself once the task is done.\n" +
"- If this run targets an existing pull request, its title, description, labels, reviewers " +
"and every other field must be left exactly as they are. Do NOT rename, retitle, reword or " +
"otherwise edit the pull request: only the code changes are yours to make, and the workflow " +
"adds your summary on its own.\n" +
"- Do NOT create git worktrees. Work in the checkout you were given and leave the changes there.";

function buildBranchPolicyNote(branchInfo: BranchInfo, context: JunieExecutionContext): string {
const branch = branchInfo.workingBranch;

const stayPut =
"\n\nBranch policy (must be followed exactly):\n" +
`- Branch '${branch}' is checked out and is the only place your changes belong. Commit them ` +
"there. Do NOT create, switch to or rename a branch.\n";

if (branchInfo.isNewBranch) {
return stayPut +
`- The workflow created '${branch}' for this run and opens a new pull request from it once ` +
"you finish. Do NOT open that pull request yourself.";
}

if (context.isPR) {
return stayPut +
`- '${branch}' is the branch of the pull request this run targets. The workflow pushes your ` +
"commits straight onto it, so do NOT open a new pull request under any circumstances.";
}

return stayPut +
"- The workflow decides what to publish once you finish. Do NOT open a pull request.";
}

const PLAN_ARTIFACT_NOTE =
"\n\nDo not stage or commit your plan file or any other scratch file you create while " +
"working. Only the actual code changes the task calls for belong in the commit.";

const SUMMARY_FORMAT_NOTE =
"\n\nFinal summary format (it is published as the pull request description):\n" +
"- A few sentences: what changed and why, nothing else.\n" +
"- Plain prose or a short bullet list. No headings, no per-step report.\n" +
"- Never paste code, diffs, file contents, command output or logs into it.";

const TITLE_FORMAT_NOTE =
"\n\nTask name (the workflow titles the pull request from the issue or pull request that " +
"triggered this run; your name is used only when there is no such issue or pull request, " +
"so it still has to read like a title):\n" +
"- It names the CHANGE, not your work on it. Whenever you report a task name — at any " +
"point, from any step or sub-agent — give the name of the overall change this run delivers. " +
"Never name the step you just finished, the step you are in, or the stage of your own " +
"process. The reviewer sees only this one line and knows nothing about how you work.\n" +
"- Write what a developer would put on the pull request: the change or the business value " +
"it delivers, e.g. 'Add export functionality to users module' or " +
"'Fix NPE in payment processing'.\n" +
"- One short line, no trailing period.\n" +
"- Banned outright. The title must never contain 'Step', 'Stage', 'Review', " +
"'Implementation', 'Validation', 'Validation Completeness', 'Deliverables', " +
"'Task execution', 'Orchestrated', 'Final report', or any other word describing your " +
"internal process rather than the code. 'Review Step 1 Implementation and Validation " +
"Completeness' is exactly the kind of title that must never be produced.\n" +
"- Do NOT prefix it with '[Junie]:' yourself: the workflow adds that.";

function getExternalTrackerTitle(context: JunieExecutionContext): string | undefined {
if (isJiraWorkflowDispatchEvent(context)) {
return (context.payload as JiraIssuePayload).issueSummary;
}
if (isYouTrackWorkflowDispatchEvent(context)) {
return (context.payload as YouTrackIssuePayload).issueTitle;
}
return undefined;
}

function getTriggerTime(context: JunieExecutionContext): string | undefined {
if (isIssueCommentEvent(context)) {
Expand Down Expand Up @@ -62,6 +158,14 @@ export async function prepareJunieTask(
fetchedData = await fetcher.fetchIssueData(owner, repo, context.entityNumber, triggerTime);
}

// Resolved here (where the entity is already fetched) for the results step to title the PR.
const entityTitle = fetchedData.pullRequest?.title
|| fetchedData.issue?.title
|| getExternalTrackerTitle(context);
if (entityTitle) {
core.setOutput(OUTPUT_VARS.ENTITY_TITLE, entityTitle);
}

const promptResult = await formatter.generatePrompt(context, fetchedData, branchInfo, context.inputs.attachGithubContextToCustomPrompt, isDefaultToken);
let promptText = promptResult.prompt;
customJunieArgs = promptResult.customJunieArgs;
Expand All @@ -85,12 +189,22 @@ export async function prepareJunieTask(
description: promptText,
diffCommand
}
} else if (shouldRunInGoalMode(context)) {
console.log("Running this task in goal mode (orchestrated)");

// Keep the agent's own artifacts out of the commit the action builds later.
addGitExcludePatterns(AGENT_ARTIFACT_PATTERNS);

junieCLITask.orchestratedTask = {
task: promptText + PUBLISHING_POLICY_NOTE + buildBranchPolicyNote(branchInfo, context) +
PLAN_ARTIFACT_NOTE + SUMMARY_FORMAT_NOTE + TITLE_FORMAT_NOTE
};
} else {
junieCLITask.task = promptText;
}
}

if (!junieCLITask.task && !junieCLITask.mergeTask && !junieCLITask.codeReviewTask) {
if (!junieCLITask.task && !junieCLITask.orchestratedTask && !junieCLITask.mergeTask && !junieCLITask.codeReviewTask) {
throw new Error("No task was created. Please check your inputs.");
}

Expand Down
5 changes: 5 additions & 0 deletions src/github/junie/types/junie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ export interface CliOutput {
duration_ms?: number;
}

export interface OrchestratedTask {
task: string;
}

export interface CliInput {
task?: string;
orchestratedTask?: OrchestratedTask;
mergeTask?: MergeTask;
codeReviewTask?: CodeReview;
}
77 changes: 77 additions & 0 deletions src/utils/git-exclude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {execSync} from "child_process";
import * as fs from "node:fs";
import * as path from "node:path";

// Agent scratch files written into the checkout; must never be committed.
export const AGENT_ARTIFACT_PATTERNS = [
".junie/plans/",
".junie/memory/",
];

const EXCLUDE_HEADER = "# junie-github-action: agent artifacts, not part of the task result";

// Uses --git-common-dir so linked worktrees resolve to the shared info/exclude.
function resolveGitCommonDir(cwd?: string): string | undefined {
try {
const gitDir = execSync("git rev-parse --git-common-dir", {
encoding: "utf-8",
cwd,
stdio: ["ignore", "pipe", "pipe"],
}).trim();

if (!gitDir) {
return undefined;
}

return path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd ?? process.cwd(), gitDir);
} catch (error) {
console.warn(`Could not locate the git directory: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}

/**
* Appends `patterns` to `.git/info/exclude` (best-effort) so the commit step's
* `git add .` skips untracked agent artifacts. Returns the newly added patterns.
*/
export function addGitExcludePatterns(patterns: string[], cwd?: string): string[] {
if (patterns.length === 0) {
return [];
}

const gitDir = resolveGitCommonDir(cwd);
if (!gitDir) {
console.warn("Skipping agent artifact excludes: not inside a git repository");
return [];
}

const excludePath = path.join(gitDir, "info", "exclude");

try {
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf-8") : "";
const existingPatterns = new Set(
existing.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#"))
);

const missing = patterns.filter(pattern => !existingPatterns.has(pattern));
if (missing.length === 0) {
console.log("Agent artifact excludes are already present");
return [];
}

fs.mkdirSync(path.dirname(excludePath), {recursive: true});

// Keep whatever the checkout already excluded and append below it.
const prefix = existing === "" || existing.endsWith("\n") ? existing : `${existing}\n`;
fs.writeFileSync(excludePath, `${prefix}${EXCLUDE_HEADER}\n${missing.join("\n")}\n`);
Comment on lines +64 to +66

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Keep whatever the checkout already excluded and append below it.
const prefix = existing === "" || existing.endsWith("\n") ? existing : `${existing}\n`;
fs.writeFileSync(excludePath, `${prefix}${EXCLUDE_HEADER}\n${missing.join("\n")}\n`);
// Keep whatever the checkout already excluded and append below it.
const prefix = existing === "" || existing.endsWith("\n") ? existing : `${existing}\n`;
const header = existing.includes(EXCLUDE_HEADER) ? "" : `${EXCLUDE_HEADER}\n`;
fs.writeFileSync(excludePath, `${prefix}${header}${missing.join("\n")}\n`);

Check if the header already exists before appending it to avoid duplication when patterns are added incrementally.


console.log(`Excluded agent artifacts from commits: ${missing.join(", ")}`);
return missing;
} catch (error) {
console.warn(
`Could not write ${excludePath}, agent artifacts may be committed: ` +
`${error instanceof Error ? error.message : String(error)}`
);
return [];
}
}
Loading
Loading