Skip to content
Merged
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
14 changes: 5 additions & 9 deletions .github/aw/safe-outputs-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ description: Safe-output reference for issue, discussion, comment, and pull requ

```yaml
safe-outputs:
env:
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
jira-create-issue:
max: 1
jira-update-issue:
Expand All @@ -29,18 +25,18 @@ description: Safe-output reference for issue, discussion, comment, and pull requ
| `jira-add-comment` | `jira_add_comment` | `issue_key`, `body` |
| `jira-add-label` | `jira_add_label` | `issue_key`, `label` |

Use the Jira-prefixed tool whenever the target is Jira. Unprefixed issue, comment, and label tools target GitHub. Description and comment strings are converted to ADF internally. Label addition is additive and preserves existing labels. Each Jira output supports `max` and `staged`; staged mode sends no HTTP request and does not require credentials.
Use the Jira-prefixed tool whenever the target is Jira. Unprefixed issue, comment, and label tools target GitHub. The compiler supplies `JIRA_BASE_URL` from `vars.JIRA_BASE_URL` and `JIRA_USER_EMAIL` and `JIRA_API_TOKEN` from same-named secrets; `safe-outputs.env` may override them. Description and comment strings are converted to ADF internally. Label addition is additive and preserves existing labels. Each Jira output supports `max` and `staged`; staged mode sends no HTTP request and does not require credentials.

Jira update, comment, and label operations require a known issue key. Same-run references to an issue created by `jira_create_issue` are not supported. The initial integration does not provide transitions, assignments, custom fields, label removal, JQL, bulk operations, or arbitrary REST calls.

- **[Experimental]** Linear operations use a dedicated `linear-token:` credential and run through the Linear GraphQL API:
- **[Experimental]** Linear operations use the `LINEAR_API_KEY` secret and run through the Linear GraphQL API:

```yaml
safe-outputs:
linear-token: ${{ secrets.LINEAR_API_KEY }}
linear-create-issue:
max: 1
team-id: "TEAM_ID"
project-id: "PROJECT_ID"
linear-add-comment:
max: 1
linear-update-issue:
Expand All @@ -51,11 +47,11 @@ description: Safe-output reference for issue, discussion, comment, and pull requ

| Frontmatter | Tool | Agent inputs |
|---|---|---|
| `linear-create-issue` | `linear_create_issue` | `title`, `body` (team taken from config `team-id`) |
| `linear-create-issue` | `linear_create_issue` | `title`, `body` (team and optional project taken from config) |
| `linear-add-comment` | `linear_add_comment` | `body` (target issue) |
| `linear-update-issue` | `linear_update_issue` | `title`/`body`, gated by the matching `title:`/`body:` config flags |

`linear-token:` is a top-level `safe-outputs:` field, not nested under `env:`. Each output supports `max` and `staged`. Only fields explicitly enabled in `update-issue` config (`title`, `body`) can be changed by the agent.
`linear-token:` optionally overrides the `LINEAR_API_KEY` secret and is a top-level `safe-outputs:` field, not nested under `env:`. `linear-create-issue.project-id` optionally fixes created issues to a trusted Linear project identifier from its URL or model UUID. Each output supports `max` and `staged`. Only fields explicitly enabled in `update-issue` config (`title`, `body`) can be changed by the agent.

- **[Experimental]** Azure DevOps work-item operations are namespaced `ado-*` and rely on an Azure DevOps MCP server (configured separately under `mcp-servers:`/`tools:`) for the underlying connection and credentials:

Expand Down
1,816 changes: 1,816 additions & 0 deletions .github/workflows/smoke-issues.lock.yml

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions .github/workflows/smoke-issues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
private: true
emoji: "🧪"
name: Smoke Issues
description: Create a daily haiku issue in Linear and Jira through safe outputs
on:
schedule: daily
workflow_dispatch:
permissions:
contents: read
actions: read
engine: copilot
safe-outputs:
linear-create-issue:
team-id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d"
project-id: "810f57a7e383"
max: 1
jira-create-issue:
max: 1
timeout-minutes: 5
---

# Smoke Issues

Generate one original haiku about code, automation, or workflows using a 5-7-5 syllable pattern.

Create exactly two issues containing the same haiku and the workflow run URL:

1. Use `linear_create_issue` to create one issue in the configured Linear project.
2. Use `jira_create_issue` to create one `Task` in Jira project `KAN`.

Use `Smoke Issues — ${{ github.run_id }}` as both issue titles. Include this run URL in both issue bodies:
`${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`.

Do not create GitHub issues or use any other write tools.
17 changes: 13 additions & 4 deletions actions/setup/js/linear_create_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const { isStagedMode } = require("./safe_output_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs");

const LINEAR_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const LINEAR_PROJECT_ID_PATTERN = /^(?:[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;

const LINEAR_CREATE_ISSUE = `mutation LinearCreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
Expand All @@ -21,9 +24,13 @@ const LINEAR_CREATE_ISSUE = `mutation LinearCreateIssue($input: IssueCreateInput

async function main(config = {}) {
const teamId = config.team_id;
if (typeof teamId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(teamId)) {
if (typeof teamId !== "string" || !LINEAR_UUID_PATTERN.test(teamId)) {
throw new Error(`${ERR_CONFIG}: linear_create_issue requires a valid configured team ID`);
}
const projectId = config.project_id;
if (projectId !== undefined && (typeof projectId !== "string" || !LINEAR_PROJECT_ID_PATTERN.test(projectId))) {
throw new Error(`${ERR_CONFIG}: linear_create_issue requires a valid configured project ID`);
}

return async function handleLinearCreateIssue(item) {
if (typeof item?.title !== "string" || !item.title.trim()) {
Expand All @@ -47,9 +54,11 @@ async function main(config = {}) {
return { success: true, staged: true, title };
}

const data = await linearGraphQL(LINEAR_CREATE_ISSUE, {
input: { teamId, title, description },
});
const input = { teamId, title, description };
if (projectId) {
input.projectId = projectId;
}
const data = await linearGraphQL(LINEAR_CREATE_ISSUE, { input });
const payload = data?.issueCreate;
if (payload?.success !== true || !payload.issue) {
throw new Error(`${ERR_API}: Linear issueCreate did not return a successful issue`);
Expand Down
7 changes: 6 additions & 1 deletion actions/setup/js/linear_safe_outputs.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("Linear safe outputs", () => {

it("posts fixed GraphQL documents with variables and raw API-key authorization", async () => {
fetch.mockResolvedValue(response({ data: { issueCreate: { success: true, issue: { id: "id", identifier: "ENG-1", title: "Safe title" } } } }));
const handler = await createIssue({ team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d" });
const handler = await createIssue({ team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d", project_id: "810f57a7e383" });
await handler({ title: "Safe title", body: "Detailed hello to @user" });

expect(fetch).toHaveBeenCalledWith(
Expand All @@ -46,6 +46,7 @@ describe("Linear safe outputs", () => {
expect(request.query).not.toContain("Safe title");
expect(request.variables.input).toEqual({
teamId: "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
projectId: "810f57a7e383",
title: "Safe title",
description: "Detailed hello to `@user`",
});
Expand Down Expand Up @@ -78,6 +79,10 @@ describe("Linear safe outputs", () => {
expect(fetch).not.toHaveBeenCalled();
});

it("rejects malformed configured project IDs", async () => {
await expect(createIssue({ team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d", project_id: "not-a-project" })).rejects.toThrow("valid configured project ID");
});

it("rejects HTTP, malformed JSON, GraphQL, and unsuccessful mutation responses", async () => {
fetch.mockResolvedValueOnce(response({}, 429));
await expect(linearGraphQL("query Fixed { viewer { id } }", {})).rejects.toThrow("rate limit exceeded");
Expand Down
3 changes: 3 additions & 0 deletions actions/setup/js/redact_secrets.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ const BUILT_IN_PATTERNS = [

// Anthropic tokens
{ name: "Anthropic API Key", pattern: /sk-ant-api03-[a-zA-Z0-9_-]{95}/g },

// Linear tokens
{ name: "Linear API Key", pattern: /lin_api_[0-9A-Za-z]{40}/g },
];

/**
Expand Down
14 changes: 14 additions & 0 deletions actions/setup/js/redact_secrets.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,20 @@ describe("redact_secrets.cjs", () => {
});
});

describe("Linear tokens", () => {
it("should redact Linear API keys", async () => {
const testFile = path.join(tempDir, "test.txt");
const linearKey = "lin_api_" + "C".repeat(40);
fs.writeFileSync(testFile, `Linear Key: ${linearKey}`);
process.env.GH_AW_SECRET_NAMES = "";
const modifiedScript = redactScript.replace('findFiles("/tmp/gh-aw", targetExtensions)', `findFiles("${tempDir.replace(/\\/g, "\\\\")}", targetExtensions)`);
await eval(`(async () => { ${modifiedScript}; await main(); })()`);
const redacted = fs.readFileSync(testFile, "utf8");
expect(redacted).toBe("Linear Key: ***REDACTED***");
Comment on lines +439 to +446
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Linear API Key"));
});
});

describe("combined built-in and custom secrets", () => {
it("should redact both built-in patterns and custom secrets", async () => {
const testFile = path.join(tempDir, "test.txt");
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"name": "linear_create_issue",
"description": "Use this to create an issue in the Linear team fixed by safe-outputs.linear-create-issue.team-id. The Linear credential and team ID are trusted workflow configuration and are not agent inputs.",
"description": "Use this to create an issue in the Linear team fixed by safe-outputs.linear-create-issue.team-id and optional project fixed by project-id. The Linear credential, team ID, and project ID are trusted workflow configuration and are not agent inputs.",
"inputSchema": {
"type": "object",
"required": ["title", "body"],
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -8718,6 +8718,10 @@ safe-outputs:
# Trusted Linear team model UUID.
team-id: "example-value"

# Optional trusted Linear project identifier from a project URL or model UUID.
# (optional)
project-id: "example-value"

# Maximum number of Linear issues to create (default: 1).
# (optional)
# Accepted formats:
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ safe-outputs:
linear-token: ${{ secrets.LINEAR_API_KEY }}
linear-create-issue:
team-id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d"
project-id: "810f57a7e383"
max: 1
linear-add-comment:
target: "ENG-123"
Expand All @@ -139,7 +140,7 @@ safe-outputs:
body: true
```

`team-id` is the Linear team model UUID, available through Linear's model UUID tooling or API. Comment and update targets are fixed trusted configuration and accept either a Linear issue model UUID or shorthand identifier such as `ENG-123`. Updates replace only the enabled `title` and `body` fields. All agent-provided titles, descriptions, and comments use standard Safe Outputs sanitization.
`team-id` is the Linear team model UUID, available through Linear's model UUID tooling or API. Optional `project-id` fixes new issues to a trusted project and accepts either the 12-character identifier from a Linear project URL or its model UUID. Comment and update targets are fixed trusted configuration and accept either a Linear issue model UUID or shorthand identifier such as `ENG-123`. Updates replace only the enabled `title` and `body` fields. All agent-provided titles, descriptions, and comments use standard Safe Outputs sanitization.

### System Types (Auto-Enabled)

Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/specs/safe-outputs-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -2754,12 +2754,13 @@ This section provides complete definitions for all remaining safe output types.

- `linear-token`: REQUIRED trusted secret expression containing a Linear personal API key
- `linear-create-issue.team-id`: REQUIRED Linear team model UUID
- `linear-create-issue.project-id`: OPTIONAL trusted Linear project URL identifier or model UUID
- `linear-create-issue.max`: Operation limit (default: 1)
- `linear-create-issue.staged`: Staged mode override

**MCP Tool**: `linear_create_issue`

The MCP input object MUST require `title` and `body`, MUST reject additional properties, and MUST limit them to 128 and 65,000 characters respectively. The body MUST contain at least 20 characters. The trusted team UUID and credential MUST NOT be MCP inputs.
The MCP input object MUST require `title` and `body`, MUST reject additional properties, and MUST limit them to 128 and 65,000 characters respectively. The body MUST contain at least 20 characters. The trusted team UUID, optional project identifier, and credential MUST NOT be MCP inputs.

**Operational Semantics**:

Expand Down
3 changes: 3 additions & 0 deletions pkg/constants/tool_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package constants
const (
LinearMCPReadOnlyURL = "https://mcp.linear.app/mcp/readonly"
LinearMCPDefaultTokenExpr = "${{ secrets.LINEAR_API_KEY }}"
JiraBaseURLExpr = "${{ vars.JIRA_BASE_URL }}"
JiraUserEmailExpr = "${{ secrets.JIRA_USER_EMAIL }}"
JiraAPITokenExpr = "${{ secrets.JIRA_API_TOKEN }}"
)

// AllowedExpressions contains the GitHub Actions expressions that can be used in workflow markdown content
Expand Down
20 changes: 12 additions & 8 deletions pkg/parser/schema_linear_safe_outputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ func TestMainWorkflowSchemaLinearSafeOutputs(t *testing.T) {
"on": "push",
"engine": "copilot",
"safe-outputs": map[string]any{
"linear-token": "${{ secrets.LINEAR_API_KEY }}",
"linear-create-issue": map[string]any{
"team-id": "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
"max": 1,
"team-id": "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
"project-id": "810f57a7e383",
"max": 1,
},
"linear-add-comment": map[string]any{
"target": "ENG-123",
Expand All @@ -35,16 +35,20 @@ func TestMainWorkflowSchemaLinearSafeOutputs(t *testing.T) {
safeOutputs map[string]any
}{
{
name: "missing token",
name: "missing team ID",
safeOutputs: map[string]any{
"linear-create-issue": map[string]any{"team-id": "9cfb482a-81e3-4154-b5b9-2c805e70a02d"},
"linear-token": "${{ secrets.LINEAR_API_KEY }}",
"linear-create-issue": map[string]any{},
},
},
{
name: "missing team ID",
name: "malformed project ID",
safeOutputs: map[string]any{
"linear-token": "${{ secrets.LINEAR_API_KEY }}",
"linear-create-issue": map[string]any{},
"linear-token": "${{ secrets.LINEAR_API_KEY }}",
"linear-create-issue": map[string]any{
"team-id": "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
"project-id": "not-a-project",
},
},
},
{
Expand Down
15 changes: 5 additions & 10 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7623,6 +7623,11 @@
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"description": "Trusted Linear team model UUID."
},
"project-id": {
"type": "string",
"pattern": "^(?:[0-9a-fA-F]{12}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$",
"description": "Optional trusted Linear project identifier from a project URL or model UUID."
},
"max": {
"oneOf": [
{ "type": "integer", "minimum": 1, "maximum": 100 },
Expand Down Expand Up @@ -12650,16 +12655,6 @@
"description": "Enable AI agents to replace one label with another on GitHub issues or pull requests in a single atomic operation. Ideal for maintaining label-based state machines (e.g. transitioning issues through workflow states)."
}
},
"allOf": [
{
"if": {
"anyOf": [{ "required": ["linear-create-issue"] }, { "required": ["linear-add-comment"] }, { "required": ["linear-update-issue"] }]
},
"then": {
"required": ["linear-token"]
}
}
],
"additionalProperties": false
},
"secret-masking": {
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_safe_outputs_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ func (c *Compiler) appendHandlerManagerStep(data *WorkflowData, state *safeOutpu
return err
}
handlerManagerSteps = injectLinearTokenIntoProcessorStep(handlerManagerSteps, data.SafeOutputs)
handlerManagerSteps = injectJiraCredentialsIntoProcessorStep(handlerManagerSteps, data.SafeOutputs)
state.steps = append(state.steps, handlerManagerSteps...)
state.safeOutputStepNames = append(state.safeOutputStepNames, "process_safe_outputs")
addHandlerManagerOutputs(data, state.outputs)
Expand Down
26 changes: 25 additions & 1 deletion pkg/workflow/jira.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package workflow

import "github.com/github/gh-aw/pkg/logger"
import (
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
)

var jiraSafeOutputsLog = logger.New("workflow:jira_safe_outputs")

Expand Down Expand Up @@ -35,3 +38,24 @@ func hasAnyJiraSafeOutputEnabled(config *SafeOutputsConfig) bool {
config.JiraAddComment != nil ||
config.JiraAddLabel != nil
}

var jiraSafeOutputDefaultEnv = map[string]string{
"JIRA_BASE_URL": constants.JiraBaseURLExpr,
"JIRA_USER_EMAIL": constants.JiraUserEmailExpr,
"JIRA_API_TOKEN": constants.JiraAPITokenExpr,
}

func injectJiraCredentialsIntoProcessorStep(steps []string, config *SafeOutputsConfig) []string {
if config == nil || !hasAnyJiraSafeOutputEnabled(config) {
return steps
}

env := make(map[string]string, len(jiraSafeOutputDefaultEnv))
for name, defaultValue := range jiraSafeOutputDefaultEnv {
env[name] = defaultValue
if value := config.Env[name]; value != "" {
env[name] = value
}
}
return injectProcessorStepEnv(steps, env)
}
28 changes: 28 additions & 0 deletions pkg/workflow/jira_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package workflow

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -61,3 +62,30 @@ func TestJiraSafeOutputsCountAsNonBuiltin(t *testing.T) {
assert.True(t, hasAnySafeOutputEnabled(&SafeOutputsConfig{JiraAddComment: &JiraSafeOutputConfig{}}))
assert.True(t, hasNonBuiltinSafeOutputsEnabled(&SafeOutputsConfig{JiraAddComment: &JiraSafeOutputConfig{}}))
}

func TestJiraCredentialsAreAddedOnlyToProcessorStep(t *testing.T) {
config := &SafeOutputsConfig{
JiraCreateIssue: &JiraSafeOutputConfig{},
Env: map[string]string{
"JIRA_BASE_URL": "https://example.atlassian.net",
"OTHER": "value",
},
}
steps := make([]string, 3, 8)
copy(steps, []string{
" - name: Process Safe Outputs\n",
" env:\n",
" with:\n",
})

injectedSteps := injectJiraCredentialsIntoProcessorStep(steps, config)
rendered := strings.Join(injectedSteps, "")
assert.Contains(t, rendered, "JIRA_BASE_URL: https://example.atlassian.net")
assert.Contains(t, rendered, "JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}")
assert.Contains(t, rendered, "JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}")
assert.Equal(t, " with:\n", injectedSteps[len(injectedSteps)-1])

customSteps := []string{}
NewCompiler().addCustomSafeOutputEnvVars(&customSteps, &WorkflowData{SafeOutputs: config})
assert.Equal(t, " OTHER: value\n", strings.Join(customSteps, ""))
}
Loading
Loading