# Options
```toml
model = "flash"
```
# Description
Fetches PR details and diffs from GitHub using `gh` CLI and saves them as Markdown files in the workbench data directory.
```sh
aip run dev/agents/fetch-pr.aip -i "my-workbench:212"
aip run dev/agents/fetch-pr.aip -i "new-workbenchs:214,215,216" -i "my-other:212"
```
JSON-relaxed input is also supported:
```sh
aip run dev/agents/fetch-pr.aip -i "{ wb: 'my-workbench', prs: [212, 213], output: 'desc' }"
aip run dev/agents/fetch-pr.aip -i "{ wb: 'v06-providers', from_pr: 195, status: 'close', output: 'desc', labels: ['NEW-ADAPTER'] }"
```
Shape:
```typescript
{
wb: string, // workbench name
prs?: number | number[], // comma-separated prs.
from_pr?: number, // will download from this pr, included
status?: "close" | "open", // default both
output?: "desc" | "patch", // default both
labels?: string | string[], // any of match labels
}
```
# Before All
```lua
local function normalize_pr_id(value, field_name)
local text = aip.text.trim(tostring(value_or(value, "")))
local number_value = tonumber(text)
if not number_value or number_value < 1 or number_value ~= math.floor(number_value) then
error("Invalid " .. field_name .. ". Expected a positive PR number (got: " .. tostring(value) .. ")")
end
return tostring(math.floor(number_value))
end
local function normalize_output(value)
local output = aip.text.trim(string.lower(tostring(value_or(value, ""))))
if output == "" or output == "both" or output == "all" then
return true, true
end
if output == "desc" or output == "description" then
return true, false
end
if output == "patch" or output == "diff" then
return false, true
end
error("Invalid output. Expected 'desc' or 'patch' (got: " .. tostring(value) .. ")")
end
local function normalize_status(value)
local status = aip.text.trim(string.lower(tostring(value_or(value, ""))))
if status == "" or status == "both" or status == "all" then
return "all"
end
if status == "open" then
return "open"
end
if status == "close" or status == "closed" then
return "closed"
end
error("Invalid status. Expected 'close' or 'open' (got: " .. tostring(value) .. ")")
end
local function add_pr_input(new_inputs, wb_name, pr_id, output_desc, output_patch)
table.insert(new_inputs, {
name = wb_name,
pr = pr_id,
output_desc = output_desc,
output_patch = output_patch,
})
end
local function add_classic_inputs(new_inputs, input_str)
local wb_name, pr_list_str = aip.text.split_first(input_str, ":")
if not wb_name or not pr_list_str then
error("Invalid input format. Expected 'WORKBENCH_NAME:PR_ID1,PR_ID2,...' (got: " .. tostring(input_str) .. ")")
end
wb_name = aip.text.trim(wb_name)
local remainder = pr_list_str
while remainder and remainder ~= "" do
local part, next_rem = aip.text.split_first(remainder, ",")
local pr_id = aip.text.trim(part or remainder)
if pr_id ~= "" then
add_pr_input(new_inputs, wb_name, normalize_pr_id(pr_id, "pr"), true, true)
end
remainder = next_rem
if not part then break end
end
end
local function match_labels(pr_labels, label_filters)
if not label_filters or #label_filters == 0 then
return true
end
for _, filter in ipairs(label_filters) do
local filter_lower = string.lower(filter)
for _, pr_label in ipairs(pr_labels) do
if string.lower(pr_label) == filter_lower then
return true
end
end
end
return false
end
local function fetch_prs_from(from_pr, status, label_filters)
local pr_res = aip.cmd.exec("gh", {"pr", "list", "--state", status, "--limit", "1000", "--json", "number,labels"})
if pr_res.error or pr_res.exit ~= 0 then
error("Failed to list PRs with gh: " .. (pr_res.stderr or pr_res.error or "unknown error"))
end
local prs = aip.json.parse(pr_res.stdout or "")
if not prs or prs.error then
error("Failed to parse PR list JSON: " .. (prs and prs.error or "empty response"))
end
local from_pr_num = tonumber(from_pr) or 1
local pr_ids = {}
if type(prs) == "table" then
for _, pr in ipairs(prs) do
local pr_number = nil
local pr_labels = {}
if type(pr) == "table" then
pr_number = tonumber(pr.number)
local labels = value_or(pr.labels, {})
if type(labels) == "table" then
for _, label in ipairs(labels) do
local label_name = ""
if type(label) == "table" then
label_name = aip.text.trim(tostring(value_or(label.name, "")))
else
label_name = aip.text.trim(tostring(value_or(label, "")))
end
if label_name ~= "" then
table.insert(pr_labels, label_name)
end
end
end
else
pr_number = tonumber(pr)
end
if pr_number and pr_number >= from_pr_num then
if not label_filters or match_labels(pr_labels, label_filters) then
table.insert(pr_ids, tostring(math.floor(pr_number)))
end
end
end
end
table.sort(pr_ids, function(a, b)
return tonumber(a) < tonumber(b)
end)
return pr_ids
end
local function add_json_inputs(new_inputs, input_str)
local function normalize_labels(value)
if value == nil then
return nil
end
local labels = {}
if type(value) == "table" then
for _, val in ipairs(value) do
local label = aip.text.trim(tostring(val))
if label ~= "" then
table.insert(labels, label)
end
end
else
local label = aip.text.trim(tostring(value))
if label ~= "" then
table.insert(labels, label)
end
end
return labels
end
local cfg = aip.json.parse(input_str)
if not cfg or cfg.error then
error("Invalid JSON-relaxed PR input: " .. (cfg and cfg.error or "empty response"))
end
if type(cfg) ~= "table" then
error("Invalid JSON-relaxed PR input. Expected an object.")
end
local wb_name = aip.text.trim(tostring(value_or(cfg.wb, "")))
if wb_name == "" then
error("Invalid JSON-relaxed PR input. Missing 'wb'.")
end
local output_desc, output_patch = normalize_output(cfg.output)
local status = normalize_status(cfg.status)
local label_filters = normalize_labels(cfg.labels)
local pr_ids = {}
local seen_pr_ids = {}
local function add_pr_id(value)
local pr_id = normalize_pr_id(value, "pr")
if not seen_pr_ids[pr_id] then
table.insert(pr_ids, pr_id)
seen_pr_ids[pr_id] = true
end
end
if cfg.prs ~= nil then
if type(cfg.prs) == "table" then
for _, pr_id in ipairs(cfg.prs) do
add_pr_id(pr_id)
end
else
add_pr_id(cfg.prs)
end
end
if cfg.from_pr ~= nil then
local from_pr = normalize_pr_id(cfg.from_pr, "from_pr")
for _, pr_id in ipairs(fetch_prs_from(from_pr, status, label_filters)) do
if not seen_pr_ids[pr_id] then
table.insert(pr_ids, pr_id)
seen_pr_ids[pr_id] = true
end
end
elseif label_filters ~= nil then
for _, pr_id in ipairs(fetch_prs_from(nil, status, label_filters)) do
if not seen_pr_ids[pr_id] then
table.insert(pr_ids, pr_id)
seen_pr_ids[pr_id] = true
end
end
end
if cfg.prs == nil and cfg.from_pr == nil and label_filters == nil then
error("Invalid JSON-relaxed PR input. Expected 'prs', 'from_pr', or 'labels'.")
end
if #pr_ids == 0 then
print("No PRs found matching the specified filters.")
end
for _, pr_id in ipairs(pr_ids) do
add_pr_input(new_inputs, wb_name, pr_id, output_desc, output_patch)
end
end
local new_inputs = {}
for _, input_str in ipairs(inputs) do
local input_text = aip.text.trim(tostring(input_str))
if string.sub(input_text, 1, 1) == "{" and string.sub(input_text, -1) == "}" then
add_json_inputs(new_inputs, input_text)
else
add_classic_inputs(new_inputs, input_str)
end
end
return aip.flow.before_all_response({
inputs = new_inputs,
options = {
input_concurrency = 8
}
})
```
# Data
```lua
local pr_id = input.pr
local wb_name = input.name
local output_desc = input.output_desc ~= false
local output_patch = input.output_patch ~= false
aip.task.pin("workbench", {
label = "Workbench",
content = wb_name
})
local pr_res = aip.cmd.exec("gh", {"pr", "view", pr_id, "--json", "title,url,body,comments,author,createdAt,headRefName,headRepository,headRepositoryOwner,labels"})
if pr_res.error or pr_res.exit ~= 0 then
error("Failed to fetch PR " .. pr_id .. " details with gh: " .. (pr_res.stderr or pr_res.error or "unknown error"))
end
local pr = aip.json.parse(pr_res.stdout or "")
if not pr or pr.error then
error("Failed to parse PR " .. pr_id .. " details JSON: " .. (pr and pr.error or "empty response"))
end
local function string_value(value)
value = value_or(value, "")
if type(value) == "string" then
return value
end
return tostring(value)
end
local title = aip.text.trim(string_value(pr.title))
if title == "" then
error("Failed to fetch PR " .. pr_id .. " title with gh: empty title")
end
local url = aip.text.trim(string_value(pr.url))
local author = "unknown"
if type(pr.author) == "table" then
author = aip.text.trim(string_value(pr.author.login))
else
author = aip.text.trim(string_value(pr.author))
end
if author == "" then
author = "unknown"
end
local created_at = aip.text.trim(string_value(pr.createdAt))
local label_names = {}
local labels = value_or(pr.labels, {})
if type(labels) == "table" then
for _, label in ipairs(labels) do
local label_name = ""
if type(label) == "table" then
label_name = aip.text.trim(string_value(label.name))
else
label_name = aip.text.trim(string_value(label))
end
if label_name ~= "" then
table.insert(label_names, label_name)
end
end
end
local labels_text = "_none_"
if #label_names > 0 then
labels_text = table.concat(label_names, ", ")
end
local head_ref = aip.text.trim(string_value(pr.headRefName))
local head_repo_name = ""
if type(pr.headRepository) == "table" then
head_repo_name = aip.text.trim(string_value(pr.headRepository.name))
end
local head_repo_owner = ""
if type(pr.headRepositoryOwner) == "table" then
head_repo_owner = aip.text.trim(string_value(pr.headRepositoryOwner.login))
end
local source = head_repo_owner .. ":" .. head_ref
local git_remote_url = "git@github.com:" .. head_repo_owner .. "/" .. head_repo_name .. ".git"
local words = {}
for word in string.gmatch(string.lower(title), "%w+") do
table.insert(words, word)
if #words == 3 then
break
end
end
while #words < 3 do
table.insert(words, "pr")
end
local title_slug = table.concat(words, "-")
local data_dir = aip.path.join(".workbench", {wb_name, "data"})
local patch_file_path = nil
local description_file_path = nil
aip.file.ensure_dir(data_dir)
local header_lines = {
"# PR " .. pr_id .. " - " .. title,
"labels: " .. labels_text,
"url: " .. url,
"author: " .. author,
"time: " .. created_at,
"source: " .. source,
"",
"```bash",
"git fetch " .. git_remote_url .. " " .. head_ref,
"git switch -c pr-" .. head_ref .. " FETCH_HEAD",
"```",
}
local common_header = table.concat(header_lines, "\n")
if output_patch then
local diff_res = aip.cmd.exec("gh", {"pr", "diff", pr_id})
if diff_res.error or diff_res.exit ~= 0 then
error("Failed to fetch PR " .. pr_id .. " diff with gh: " .. (diff_res.stderr or diff_res.error or "unknown error"))
end
patch_file_path = aip.path.join(data_dir, "pr-" .. pr_id .. "-patch-" .. title_slug .. ".md")
local patch_content = common_header .. "\n\n" .. aip.text.trim_end(diff_res.stdout or "") .. "\n"
aip.file.save(patch_file_path, patch_content, {single_trailing_newline = true})
end
if output_desc then
description_file_path = aip.path.join(data_dir, "pr-" .. pr_id .. "-description-" .. title_slug .. ".md")
local body = aip.text.trim_end(string_value(pr.body))
if body == "" then
body = "_No description provided._"
end
local description_lines = {
common_header,
"",
"---",
"**Description:**",
"",
body,
}
local comments = value_or(pr.comments, {})
if type(comments) == "table" and #comments > 0 then
for _, comment in ipairs(comments) do
local c_author = "unknown"
if type(comment.author) == "table" then
c_author = aip.text.trim(string_value(comment.author.login))
else
c_author = aip.text.trim(string_value(comment.author))
end
if c_author == "" then
c_author = "unknown"
end
local c_created_at = aip.text.trim(string_value(comment.createdAt))
local c_body = aip.text.trim_end(string_value(comment.body))
if c_body == "" then
c_body = "_No comment body._"
end
table.insert(description_lines, "")
table.insert(description_lines, "---")
local comment_title = "**Comment from " .. c_author
if c_created_at ~= "" then
comment_title = comment_title .. " at " .. c_created_at
end
comment_title = comment_title .. "**"
table.insert(description_lines, comment_title)
table.insert(description_lines, "")
table.insert(description_lines, c_body)
end
end
local description_content = table.concat(description_lines, "\n") .. "\n"
aip.file.save(description_file_path, description_content, {single_trailing_newline = true})
end
local result = {
pr_id = pr_id,
}
if patch_file_path then
result.patch_path = patch_file_path
end
if description_file_path then
result.description_path = description_file_path
end
return result
```
# Output
```lua
-- Repository is inferred from Cargo.toml context.
if type(data) == "table" and data.pr_id then
local saved_lines = {}
if data.patch_path then
table.insert(saved_lines, "- " .. data.patch_path)
end
if data.description_path then
table.insert(saved_lines, "- " .. data.description_path)
end
print("Successfully saved PR #" .. data.pr_id .. "\n" .. table.concat(saved_lines, "\n"))
end
return data
```
# After All
```lua
local lines = {}
for _, out in ipairs(outputs) do
if type(out) == "table" and out.pr_id then
table.insert(lines, "PR " .. out.pr_id)
if out.description_path then
table.insert(lines, "- " .. out.description_path)
end
if out.patch_path then
table.insert(lines, "- " .. out.patch_path)
end
end
end
if #lines > 0 then
aip.run.pin("prs-fetched", {
label = "PRs fetched",
content = table.concat(lines, "\n")
})
end
```