Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
34 changes: 24 additions & 10 deletions .github/workflows/add-to-project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ name: Add to bitácora
# GraphQL pool to the ACCOUNT (not the token), so an
# interactive session can drain it and the reset is up to
# an hour away. Retrying would burn the job and still
# lose. bitacora-reconcile.yml heals the drop instead.
# transient -> retry with backoff (5xx, network, secondary limit).
# lose. mlorentedev/dotfiles' bitacora-reconcile.yml heals the drop instead.
# transient -> retry with backoff (5xx, network, secondary limit;
# a secondary limit waits a minute, as GitHub asks).
# anything else -> red, loudly.
#
# Both event types now go through the same GraphQL mutation. The old issues path
Expand Down Expand Up @@ -61,8 +62,8 @@ jobs:
# Dependabot branch the actor is that human, the secrets are there, and the
# job runs. A branch-name or author-string rule would have neither property.
#
# Not a silent drop: bitacora-reconcile.yml adds every open issue and PR to
# the board daily, which is the healing path this workflow's header already
# Not a silent drop: bitacora-reconcile.yml (in mlorentedev/dotfiles, daily,
# every repository) adds every open issue and PR to the board, which is the healing path this workflow's header already
# names for the rate-limited case.
if: >
(github.event_name == 'issues' || github.event.pull_request.head.repo.fork == false)
Expand All @@ -83,10 +84,14 @@ jobs:
# only that case.
RATE_LIMITED=10

# fd 3 is the step's stdout. gh_graphql's notices go there, so a caller
# that captures the function's output gets the response alone (#3160).
exec 3>&1

# gh_graphql <args...> — one GraphQL call with the three-way handling
# described in the header. Prints the response on success.
gh_graphql() {
local attempt=1 max=3 out
local attempt=1 max=3 out wait
while :; do
if out=$(gh api graphql "$@" 2>&1); then
printf '%s' "$out"
Expand All @@ -98,14 +103,21 @@ jobs:
return "$RATE_LIMITED"
;;
*"secondary rate limit"*|*"abuse detection"*|*"was submitted too quickly"*|\
*"HTTP 50"*|*"timeout"*|*"Connection reset"*|*"EOF"*)
*"HTTP 50"*|*"timeout"*|*"Connection reset"*|*"EOF"*|\
*"connection refused"*|*"no such host"*)
if [ "$attempt" -ge "$max" ]; then
printf '::error::transient failure persisted after %d attempts\n' "$max"
printf '::error::transient failure persisted after %d attempts\n' "$max" >&3
printf '%s\n' "$out" >&2
return 1
fi
printf '::warning::transient failure (attempt %d/%d), retrying\n' "$attempt" "$max"
sleep $(( attempt * 5 ))
# GitHub asks for at least a minute before retrying a secondary
# limit; a 5xx or a network blip backs off 5 s, then 10 s (#3160).
case "$out" in
*"secondary rate limit"*|*"abuse detection"*|*"was submitted too quickly"*) wait=60 ;;
*) wait=$(( attempt * 5 )) ;;
esac
printf '::warning::transient failure (attempt %d/%d), retrying in %ds\n' "$attempt" "$max" "$wait" >&3
sleep "$wait"
attempt=$(( attempt + 1 ))
;;
*)
Expand All @@ -126,6 +138,8 @@ jobs:
query { user(login: "mlorentedev") { projectV2(number: 1) { id } } }
' --jq '.data.user.projectV2.id') || rc=$?

# --jq prints `null` for a missing project: that is no id either.
[ "${PROJECT_ID}" = null ] && PROJECT_ID=
if [ "$rc" -eq 0 ] && [ -n "${PROJECT_ID}" ]; then
# $project and $contentId are GraphQL variables, not shell ones.
# shellcheck disable=SC2016
Expand All @@ -151,7 +165,7 @@ jobs:
# here would only add a red run nobody reads to a drop nobody sees.
printf '%s%s\n' \
'::warning::primary GraphQL pool exhausted (billed per account, not per token) — ' \
'this item is NOT on the board; bitacora-reconcile.yml will add it within a day'
'this item is NOT on the board; mlorentedev/dotfiles bitacora-reconcile.yml will add it within a day'
;;
*)
printf '%s%s\n' \
Expand Down
82 changes: 70 additions & 12 deletions .github/workflows/bitacora-status.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,16 @@ name: Bitácora status — In Progress on assign
# Uses actions/github-script for the Projects v2 GraphQL (the `gh project` CLI
# fails with "unknown owner type" under a fine-grained PAT).
#
# The guard and the write both go by id, so renaming the field or an option
# cannot make the guard read a parked issue as unset (dotfiles#3160 item 4).
# Every GraphQL call retries a transient failure; one blip used to leave the
# issue where it was (item 3).
#
# Canonical IDs (re-list: gh project field-list 1 --owner mlorentedev --format json):
# Project PVT_kwHOAM7xJs4BZ6GY
# Status PVTSSF_lAHOAM7xJs4BZ6GYzhU1dtI (option "In Progress" = 6c133cc8)
# Full reference: vault 00_meta/runbooks/bitacora-project-setup.md §2 / §5 / §7.
# Status PVTSSF_lAHOAM7xJs4BZ6GYzhU1dtI (options "Backlog" = f65b1174, "In Progress" = 6c133cc8)
# This file is copied to every repository by mlorentedev/dotfiles scripts/bitacora-rollout.sh;
# change it there.

on:
issues:
Expand All @@ -45,12 +51,52 @@ jobs:
script: |
const projectId = 'PVT_kwHOAM7xJs4BZ6GY';
const statusFieldId = 'PVTSSF_lAHOAM7xJs4BZ6GYzhU1dtI';
const backlogId = 'f65b1174';
const inProgressId = '6c133cc8';
const contentId = context.payload.issue.node_id;
const { number, node_id: contentId } = context.payload.issue;

// A transient failure (5xx, network, rate limit) is retried; anything
// else, or the third failure, fails the run. A 403 is a rate limit only
// when GitHub says so: otherwise it is a missing permission. A GraphQL
// error arrives with HTTP 200, so its type decides.
async function withRetry(call) {
for (let attempt = 1; ; attempt++) {
try {
return await call();
} catch (err) {
const status = err.status ?? err.response?.status;
const headers = err.response?.headers ?? {};
const after = Number(headers['retry-after']) || 0;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const limited = status === 429
|| (status === 403 && (after > 0 || /rate limit/i.test(err.message)))
|| (err.errors ?? []).some((e) => e.type === 'RATE_LIMITED');
const transient = limited || status >= 500 || (status === undefined && !err.errors);
if (attempt === 3 || !transient) throw err;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// GitHub asks for retry-after when given, then the primary limit's
// reset time, else at least a minute. A wait past five minutes
// would outlast the job: fail now, and the reconciler heals it.
const reset = Number(headers['x-ratelimit-reset']) || 0;
const untilReset = reset ? Math.ceil(reset - Date.now() / 1000) : 0;
const wait = limited ? Math.max(after, untilReset, 60) : attempt * 5;
if (wait > 300) throw err;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
core.warning(`transient failure (attempt ${attempt}/3, ${status ?? err.message}); retrying in ${wait}s`);
await new Promise((r) => setTimeout(r, wait * 1000));
}
}
}
const graphql = (query, vars) => withRetry(() => github.graphql(query, vars));

// The event carries the state at assignment time; the issue may have
// closed while this run sat in the queue (dotfiles#3160 item 8).
const { data: live } = await withRetry(() => github.rest.issues.get({ ...context.repo, issue_number: number }));
if (live.state !== 'open') {
core.info(`Issue #${number} is ${live.state}; left off the board`);
return;
}

// addProjectV2ItemById is idempotent: returns the existing item id
// if the issue is already on the board.
const added = await github.graphql(
const added = await graphql(
`mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
item { id }
Expand All @@ -60,25 +106,37 @@ jobs:
);
const itemId = added.addProjectV2ItemById.item.id;

const current = await github.graphql(
const current = await graphql(
`query($itemId: ID!) {
node(id: $itemId) {
... on ProjectV2Item {
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { name }
fieldValues(first: 100) {
pageInfo { hasNextPage }
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
optionId name
field { ... on ProjectV2SingleSelectField { id } }
}
}
}
}
}
}`,
{ itemId },
);
const status = current.node?.fieldValueByName?.name;
if (status && status !== 'Backlog') {
core.info(`Issue #${context.payload.issue.number} is "${status}"; left as is`);
const values = current.node?.fieldValues;
const status = (values?.nodes ?? []).find((v) => v?.field?.id === statusFieldId);
// A project holds at most 50 fields, so one page of 100 is all of them.
// Should that change, an unseen Status must not read as unset.
if (!status && values?.pageInfo?.hasNextPage) {
throw new Error(`Issue #${number}: Status not in the first 100 field values; not moving it`);
}
if (status && status.optionId !== backlogId) {
core.info(`Issue #${number} is "${status.name}"; left as is`);
return;
}

await github.graphql(
await graphql(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
`mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId, itemId: $itemId, fieldId: $fieldId,
Expand All @@ -88,4 +146,4 @@ jobs:
{ projectId, itemId, fieldId: statusFieldId, optionId: inProgressId },
);

core.info(`Moved issue #${context.payload.issue.number} → In Progress (item ${itemId})`);
core.info(`Moved issue #${number} → In Progress (item ${itemId})`);
Loading