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
9 changes: 8 additions & 1 deletion .archon/commands/maintainer-standup.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ Fields: `current_dev_sha`, `prior_dev_sha`, `current_branch`, `is_dirty`, `pull_
$gh-data.output
```

Fields: `gh_handle`, `since_date`, `all_open_prs`, `review_requested`, `authored_by_me`, `issues_assigned`, `recent_unlabeled_issues`, `recently_closed_prs`, `recently_closed_issues`, `my_recent_commits`.
Fields: `gh_handle`, `since_date`, `all_open_prs`, `review_requested`, `authored_by_me`, `issues_assigned`, `recent_unlabeled_issues`, `recently_closed_prs`, `recently_closed_issues`, `my_recent_commits`, `replies_since_last_run`.

`replies_since_last_run` is an array of `{ number, kind, comments }` grouping contributor replies on PRs and issues since the last run. `kind` is one of `issue` / `pr_conversation` / `pr_review`; the maintainer's own comments are filtered out. Use this as the source for the **"Replies waiting on you"** brief section (see Phase 3).

### Local context (direction doc, maintainer profile, prior state, recent briefs)

Expand Down Expand Up @@ -112,6 +114,11 @@ A maintainer-ready markdown brief. Adapt sections — omit empty ones, add other
- **Issue #N** — [title] — closed
- (Omit section if nothing resolved.)

## Replies waiting on you
- **PR #N** — @author replied (N comments since last run): [one-line excerpt of latest comment]. [URL]
- **Issue #N** — @author commented: [excerpt]. [URL]
- (Sort by recency; surface inline-review-comment kinds first since they usually need a code-level response. Omit section if `replies_since_last_run` is empty.)

## P1 — Do today
- **PR #N** — [title] ([+X/-Y]) — [why P1, e.g. "ready to merge, awaiting your review"]
- **Issue #N** — [title] — [why P1]
Expand Down
129 changes: 129 additions & 0 deletions .archon/scripts/maintainer-standup-gh-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,134 @@ if (ghHandle) {
}
}

// ── Replies since last run (contributor comments on PRs/issues) ──
// Fetches all conversation + inline review comments since the last run,
// filters out the maintainer's own comments, and groups by PR/issue number.
// Lets the synthesizer surface "@author replied on PR #N" items for the
// maintainer to triage today.
//
// GitHub endpoints:
// - /repos/{o}/{r}/issues/comments conversation comments on PRs and issues
// (same endpoint; issue_url disambiguates)
// - /repos/{o}/{r}/pulls/comments inline code-review comments
// Both accept ?since=ISO8601.
type GhComment = {
user?: { login?: string };
created_at?: string;
body?: string;
html_url?: string;
issue_url?: string;
pull_request_url?: string;
};

type GroupedReply = {
number: number;
kind: 'issue' | 'pr_conversation' | 'pr_review';
comments: {
author: string;
created_at: string;
body_excerpt: string;
url: string;
}[];
};

function ownerRepo(): { owner: string; repo: string } | null {
try {
const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
stdio: ['ignore', 'pipe', 'pipe'],
})
.toString()
.trim();
// ssh: git@github.com:owner/repo.git ; https: https://github.com/owner/repo.git
const m = url.match(/[:/]([^:/]+)\/([^/]+?)(?:\.git)?$/);
if (!m) return null;
return { owner: m[1], repo: m[2] };
} catch {
return null;
}
}

function extractNumber(url: string | undefined): number | null {
if (!url) return null;
const m = url.match(/\/(?:issues|pulls)\/(\d+)$/);
return m ? Number(m[1]) : null;
}

const repliesByNumber: Record<number, GroupedReply> = {};
const repoIds = ownerRepo();

if (repoIds && lastRunAt) {
const openPrNumbers = new Set(
(allOpenPrs as Array<{ number?: number }>)
.map((p) => p.number)
.filter((n): n is number => typeof n === 'number'),
);

const addComment = (
num: number,
kind: GroupedReply['kind'],
c: GhComment,
fallbackUrl: string,
): void => {
const author = c.user?.login;
if (!author) return;
if (ghHandle && author.toLowerCase() === ghHandle.toLowerCase()) return;
// Skip GitHub bots — coderabbitai, codex-connector, dependabot, etc. The
// "[bot]" suffix is the canonical GitHub convention for bot accounts and
// is reliable across all bot integrations. Maintainer wants human replies
// worth responding to, not the constant churn of automated review tooling.
if (author.endsWith('[bot]')) return;
if (!repliesByNumber[num]) repliesByNumber[num] = { number: num, kind, comments: [] };
// Upgrade kind toward pr_review (most actionable) when both arrive on the same PR.
if (kind === 'pr_review') repliesByNumber[num].kind = 'pr_review';
repliesByNumber[num].comments.push({
author,
created_at: c.created_at ?? '',
body_excerpt: (c.body ?? '').slice(0, 240).replace(/\s+/g, ' ').trim(),
url: c.html_url ?? fallbackUrl,
});
};

// /issues/comments covers PR + issue conversations under one endpoint.
// Disambiguate by checking whether the parsed number is an open PR.
const issueComments = parseJson<GhComment[]>(
exec('gh', [
'api',
`repos/${repoIds.owner}/${repoIds.repo}/issues/comments?since=${lastRunAt}&per_page=100`,
'--paginate',
]),
[],
);
for (const c of issueComments) {
const num = extractNumber(c.issue_url);
if (!num) continue;
const kind: GroupedReply['kind'] = openPrNumbers.has(num) ? 'pr_conversation' : 'issue';
addComment(num, kind, c, c.issue_url ?? '');
}
Comment on lines +239 to +285
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Closed PR replies will be mislabeled as issues.

openPrNumbers only covers currently open PRs, so a reply on a PR that was closed after the last run will fall through to kind: 'issue'. That drops the PR context from the new "Replies waiting on you" section exactly when the conversation is still actionable.

🔧 Suggested fix
-  const openPrNumbers = new Set(
-    (allOpenPrs as Array<{ number?: number }>)
-      .map((p) => p.number)
-      .filter((n): n is number => typeof n === 'number'),
-  );
+  const prNumbers = new Set<number>();
+  // Populate this from a source that includes closed PRs too, not just open ones.
@@
-    const kind: GroupedReply['kind'] = openPrNumbers.has(num) ? 'pr_conversation' : 'issue';
+    const kind: GroupedReply['kind'] = prNumbers.has(num) ? 'pr_conversation' : 'issue';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.archon/scripts/maintainer-standup-gh-data.ts around lines 239 - 285, The
code currently labels comments as 'pr_conversation' only if the PR is currently
open (openPrNumbers), which mislabels replies on PRs closed since last run;
instead, for each comment determine whether the issue number is a pull request
by fetching the issue metadata (e.g., call GH API for
repos/{owner}/{repo}/issues/{num}) and check for the pull_request field, then
set kind to 'pr_conversation' when pull_request exists (otherwise 'issue');
update the loop that processes issueComments to call this check (referencing
extractNumber, issueComments and addComment) and fall back to the openPrNumbers
check only if you want to avoid extra API calls.


// /pulls/comments are inline code-review comments — most specific signal,
// usually need a code-level response.
const reviewComments = parseJson<GhComment[]>(
exec('gh', [
'api',
`repos/${repoIds.owner}/${repoIds.repo}/pulls/comments?since=${lastRunAt}&per_page=100`,
'--paginate',
]),
[],
);
for (const c of reviewComments) {
const num = extractNumber(c.pull_request_url);
if (!num) continue;
addComment(num, 'pr_review', c, c.pull_request_url ?? '');
}
}

const repliesSinceLastRun = Object.values(repliesByNumber).sort((a, b) => {
const aLatest = a.comments[a.comments.length - 1]?.created_at ?? '';
const bLatest = b.comments[b.comments.length - 1]?.created_at ?? '';
return bLatest.localeCompare(aLatest); // newest first
Comment on lines +304 to +307
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Sort each group by created_at before deriving recency.

comments is built by appending issue comments first and review comments later, so comments[comments.length - 1] is not guaranteed to be the newest reply. That can misorder the final section and surface an old excerpt as the "latest" comment.

🛠️ Suggested fix
-const repliesSinceLastRun = Object.values(repliesByNumber).sort((a, b) => {
-  const aLatest = a.comments[a.comments.length - 1]?.created_at ?? '';
-  const bLatest = b.comments[b.comments.length - 1]?.created_at ?? '';
+const repliesSinceLastRun = Object.values(repliesByNumber)
+  .map((reply) => ({
+    ...reply,
+    comments: [...reply.comments].sort((a, b) => a.created_at.localeCompare(b.created_at)),
+  }))
+  .sort((a, b) => {
+    const aLatest = a.comments[a.comments.length - 1]?.created_at ?? '';
+    const bLatest = b.comments[b.comments.length - 1]?.created_at ?? '';
   return bLatest.localeCompare(aLatest); // newest first
-});
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const repliesSinceLastRun = Object.values(repliesByNumber).sort((a, b) => {
const aLatest = a.comments[a.comments.length - 1]?.created_at ?? '';
const bLatest = b.comments[b.comments.length - 1]?.created_at ?? '';
return bLatest.localeCompare(aLatest); // newest first
const repliesSinceLastRun = Object.values(repliesByNumber)
.map((reply) => ({
...reply,
comments: [...reply.comments].sort((a, b) => a.created_at.localeCompare(b.created_at)),
}))
.sort((a, b) => {
const aLatest = a.comments[a.comments.length - 1]?.created_at ?? '';
const bLatest = b.comments[b.comments.length - 1]?.created_at ?? '';
return bLatest.localeCompare(aLatest); // newest first
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.archon/scripts/maintainer-standup-gh-data.ts around lines 304 - 307, The
sort is using each group's last array element as the "latest"
(repliesSinceLastRun) but comments arrays are appended in mixed order; instead,
for each group in repliesByNumber compute the actual newest created_at by
sorting that group's comments by comment.created_at (or scanning to find the max
created_at) and use that value for the comparison. Update the comparator that
builds repliesSinceLastRun to derive aLatest and bLatest from the sorted/max
created_at of a.comments and b.comments (reference: repliesByNumber,
repliesSinceLastRun, and the comments[].created_at field).

});

console.log(
JSON.stringify({
gh_handle: ghHandle,
Expand All @@ -191,5 +319,6 @@ console.log(
recently_closed_prs: recentlyClosedPrs,
recently_closed_issues: recentlyClosedIssues,
my_recent_commits: myRecentCommits,
replies_since_last_run: repliesSinceLastRun,
}),
);
Loading