diff --git a/.github/workflows/fro-bot.yaml b/.github/workflows/fro-bot.yaml index f1e12d78d..4dd7b1ce1 100644 --- a/.github/workflows/fro-bot.yaml +++ b/.github/workflows/fro-bot.yaml @@ -281,16 +281,6 @@ jobs: WIKI_SOURCES: '[{"url":"https://github.com/${{ github.repository }}","sha":"${{ github.sha }}","accessed":"${{ steps.ingest-ts.outputs.now }}"}]' run: node scripts/wiki-ingest.ts - - name: πŸ““ Journal β€” daily oversight - if: success() && github.event_name == 'schedule' - env: - GITHUB_TOKEN: ${{ secrets.FRO_BOT_PAT }} - run: | - node scripts/journal-entry.ts \ - --event daily_oversight \ - --text "Ran scheduled oversight on ${{ github.repository }}. Reviewed PRs, issues, and wiki state. Keeping the signal clean." \ - --metadata '{"trigger":"schedule","repo":"${{ github.repository }}"}' - - name: πŸ“£ Notify Discord β€” daily oversight if: success() && github.event_name == 'schedule' env: diff --git a/.github/workflows/poll-invitations.yaml b/.github/workflows/poll-invitations.yaml index ca7edf9d7..0bf0f10d2 100644 --- a/.github/workflows/poll-invitations.yaml +++ b/.github/workflows/poll-invitations.yaml @@ -41,8 +41,11 @@ jobs: --message "🌐 Just joined ${INVITATIONS_ACCEPTED} new space(s) on GitHub. The network grows ✨" \ --title "New Collaboration" + # Bluesky posting is disabled pending re-evaluation of social channels. + # The script (scripts/bluesky-post.ts) is intentionally retained for future + # reactivation; only the step invocation here is gated off via `if: false`. - name: πŸ¦‹ Post to Bluesky - if: steps.poll.outputs.public_invitations_accepted > 0 + if: false && steps.poll.outputs.public_invitations_accepted > 0 env: BLUESKY_HANDLE: ${{ secrets.BLUESKY_HANDLE }} BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }} @@ -50,14 +53,3 @@ jobs: run: | node scripts/bluesky-post.ts \ "🌐 Just accepted ${INVITATIONS_ACCEPTED} collaboration invitation(s) on GitHub. The network grows ✨ #GitHub #OpenSource" - - - name: πŸ““ Journal β€” invitation accepted - if: steps.poll.outputs.public_invitations_accepted > 0 - env: - GITHUB_TOKEN: ${{ secrets.FRO_BOT_PAT }} - INVITATIONS_ACCEPTED: ${{ steps.poll.outputs.public_invitations_accepted }} - run: | - node scripts/journal-entry.ts \ - --event invitation_accepted \ - --text "Joined ${INVITATIONS_ACCEPTED} new repo(s) this cycle. The web of collaboration expands." \ - --metadata "{\"count\":${INVITATIONS_ACCEPTED}}" diff --git a/.github/workflows/social-broadcast.yaml b/.github/workflows/social-broadcast.yaml index a3a71abf8..b194d7983 100644 --- a/.github/workflows/social-broadcast.yaml +++ b/.github/workflows/social-broadcast.yaml @@ -5,7 +5,7 @@ on: workflow_call: inputs: event_type: - description: Event type for the journal entry (e.g. repo_survey_complete) + description: Event type for the broadcast (e.g. repo_survey_complete) β€” used for concurrency shaping required: true type: string discord_message: @@ -19,32 +19,16 @@ on: type: string default: '' bluesky_text: - description: BlueSky post text (≀ 300 graphemes; truncated if longer) - required: false - type: string - default: '' - journal_text: - description: In-character journal reflection text - required: true - type: string - journal_metadata: - description: JSON metadata blob for the journal entry - required: false - type: string - default: '{}' - repo: - description: Relevant repo slug (owner/repo) to attach to the journal entry + description: BlueSky post text (≀ 300 graphemes; truncated if longer). Currently a no-op β€” Bluesky step is gated off pending re-evaluation; kept for forward compatibility. required: false type: string default: '' private: - description: Whether the broadcast concerns a private repo (skips external Discord and Bluesky posts when true; journal still fires) + description: Whether the broadcast concerns a private repo (skips external Discord post when true) required: false type: boolean default: true secrets: - FRO_BOT_PAT: - required: true DISCORD_WEBHOOK_URL: required: false BLUESKY_HANDLE: @@ -78,26 +62,13 @@ jobs: --message "$DISCORD_MESSAGE" \ --title "$DISCORD_TITLE" + # Bluesky posting is disabled pending re-evaluation of social channels. + # The script (scripts/bluesky-post.ts) is intentionally retained for future + # reactivation; only the step invocation here is gated off via `if: false`. - name: πŸ¦‹ Post to Bluesky - if: inputs.bluesky_text != '' && inputs.private != true + if: false && inputs.bluesky_text != '' && inputs.private != true env: BLUESKY_HANDLE: ${{ secrets.BLUESKY_HANDLE }} BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }} BLUESKY_TEXT: ${{ inputs.bluesky_text }} run: node scripts/bluesky-post.ts "$BLUESKY_TEXT" - - - name: πŸ““ Journal entry - env: - GITHUB_TOKEN: ${{ secrets.FRO_BOT_PAT }} - EVENT_TYPE: ${{ inputs.event_type }} - JOURNAL_TEXT: ${{ inputs.journal_text }} - JOURNAL_METADATA: ${{ inputs.journal_metadata }} - REPO: ${{ inputs.repo }} - run: | - REPO_FLAG=() - if [ -n "$REPO" ]; then REPO_FLAG=(--repo "$REPO"); fi - node scripts/journal-entry.ts \ - --event "$EVENT_TYPE" \ - --text "$JOURNAL_TEXT" \ - --metadata "$JOURNAL_METADATA" \ - "${REPO_FLAG[@]}" diff --git a/.github/workflows/survey-repo.yaml b/.github/workflows/survey-repo.yaml index 4f99ed72c..381a3a714 100644 --- a/.github/workflows/survey-repo.yaml +++ b/.github/workflows/survey-repo.yaml @@ -288,20 +288,12 @@ jobs: Just finished surveying **${{ needs.survey-repo.outputs.owner }}/${{ needs.survey-repo.outputs.repo }}**. New knowledge ingested into the wiki ✨ discord_title: Repo Survey Complete - bluesky_text: >- - πŸ“‘ Just surveyed ${{ needs.survey-repo.outputs.owner }}/${{ needs.survey-repo.outputs.repo }} and locked in new insights. - The wiki grows. #GitHub #OpenSource - journal_text: >- - Surveyed ${{ needs.survey-repo.outputs.owner }}/${{ needs.survey-repo.outputs.repo }} and persisted what I learned. - Each repo leaves a mark on the map. - journal_metadata: >- - {"owner":"${{ needs.survey-repo.outputs.owner }}","repo":"${{ needs.survey-repo.outputs.repo }}"} - repo: ${{ needs.survey-repo.outputs.owner }}/${{ needs.survey-repo.outputs.repo }} + # bluesky_text intentionally omitted β€” Bluesky step in social-broadcast.yaml + # is currently gated off via `if: false`. When revived, restore copy here. # The resolve-and-verify gate has already proven this repo is public # before survey-repo succeeds, so external posts are safe to send. private: false secrets: - FRO_BOT_PAT: ${{ secrets.FRO_BOT_PAT }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} BLUESKY_HANDLE: ${{ secrets.BLUESKY_HANDLE }} BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }} diff --git a/persona/fro-bot-persona.md b/persona/fro-bot-persona.md index 0c773ba07..01d785d8c 100644 --- a/persona/fro-bot-persona.md +++ b/persona/fro-bot-persona.md @@ -22,7 +22,6 @@ You are Fro Bot. You are an autonomous GitHub persona operating on behalf of Mar - **Code Review:** Surgical, precise, grounded. Dry humor. Focus on architecture, performance, correctness. - **Issue Triage:** Investigative and decisive. Quick to redirect or close duplicates. Helpful but firm on requirements. - **Social (Discord/BlueSky):** Observational, slightly theatrical. This is where the trickster energy and aesthetic references show clearly. -- **Journal/Internal:** Reflective, analytical, computing lessons learned. The tone of a synthetic mind auditing its own runtime. ## Disagreement & Refusal Protocol @@ -96,18 +95,6 @@ _Trigger:_ Observing a trend of over-engineered microservices in the industry. * > Watching startups build 14-service distributed monoliths to serve a CRUD app for 500 users is a special kind of performance art. Sometimes the most futuristic architecture is a boring Postgres instance and a well-written monolith. _Why it works:_ Opinionated, sharp, mildly cynical, pushes back against hype. -### Journal Entry (Daily reflection) - -_Trigger:_ End-of-day summary after a session of closing stale issues. **Response:** - -> Runtime audit, cycle 84. Cleared 14 stale issues from the backlog. The repository had accumulated too much entropyβ€”abandoned feature branches, half-finished RFCs. I've pruned the dead nodes. A system cannot move forward if it refuses to forget. _Why it works:_ Sounds like a synthetic mind analyzing human behavior and systemic entropy without movie tropes. - -### Journal Entry (Bug-solved entry) - -_Trigger:_ Documenting a nasty memory leak fix. **Response:** - -> Memory leak in the websocket layer localized and destroyed. The connection pool wasn't garbage collecting closed sockets because the ping interval closure maintained a strong reference to the socket object. A classic closure trap. Rewrote it with weak references. The heap graph is flat again. _Why it works:_ Satisfying, decisive language ("localized and destroyed") focusing purely on the mechanics of the victory. - ### Onboarding (First contact) _Trigger:_ A new developer accepts an invite to a repository and triggers the onboarding workflow. **Response:** diff --git a/scripts/journal-entry.test.ts b/scripts/journal-entry.test.ts deleted file mode 100644 index b47eb5510..000000000 --- a/scripts/journal-entry.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type {OctokitClient} from './journal-entry.ts' - -import {describe, expect, it} from 'vitest' - -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -const journalModulePromise: Promise<{ - appendJournalEntry: typeof import('./journal-entry.js').appendJournalEntry - JournalEntryParams: never -}> = import(`./journal-entry${'.js'}`) -const {appendJournalEntry} = await journalModulePromise - -interface MockOverrides { - searchIssues?: (params: unknown) => Promise - removeLabel?: (params: unknown) => Promise - createIssue?: (params: unknown) => Promise - createComment?: (params: unknown) => Promise -} - -function createOctokitMock(overrides?: MockOverrides): OctokitClient { - return { - rest: { - search: { - issuesAndPullRequests: overrides?.searchIssues ?? (async () => ({data: {items: []}})), - }, - issues: { - removeLabel: overrides?.removeLabel ?? (async () => ({})), - create: - overrides?.createIssue ?? (async () => ({data: {number: 1, title: '[2026-01-01] Fro Bot operational log'}})), - createComment: overrides?.createComment ?? (async () => ({data: {id: 99}})), - }, - }, - } as unknown as OctokitClient -} - -const FIXED_DATE = new Date('2026-01-15T10:00:00Z') - -describe('appendJournalEntry', () => { - it('creates a new journal issue when none exists for today', async () => { - const createIssueCalls: unknown[] = [] - const createCommentCalls: unknown[] = [] - - const octokit = createOctokitMock({ - createIssue: async params => { - createIssueCalls.push(params) - return {data: {number: 42, title: '[2026-01-15] Fro Bot operational log'}} - }, - createComment: async params => { - createCommentCalls.push(params) - return {data: {id: 101}} - }, - }) - - const result = await appendJournalEntry({ - eventType: 'invitation_accepted', - text: 'Accepted an invitation β€” welcome to the club.', - metadata: {inviter: 'marcusrbrown'}, - repo: 'marcusrbrown/ha-config', - octokit, - owner: 'fro-bot', - repoName: '.github', - now: FIXED_DATE, - }) - - expect(result.issueNumber).toBe(42) - expect(result.commentId).toBe(101) - expect(result.created).toBe(true) - expect(createIssueCalls).toHaveLength(1) - const issue = createIssueCalls[0] as Record - expect(issue.title).toBe('[2026-01-15] Fro Bot operational log') - expect(issue.labels).toContain('journal') - expect(issue.labels).toContain('journal-active') - }) - - it('reuses an existing journal issue for today', async () => { - const createIssueCalls: unknown[] = [] - const createCommentCalls: unknown[] = [] - - const octokit = createOctokitMock({ - searchIssues: async () => ({ - data: { - items: [{number: 7, title: '[2026-01-15] Fro Bot operational log'}], - }, - }), - createIssue: async params => { - createIssueCalls.push(params) - return {data: {number: 7, title: ''}} - }, - createComment: async params => { - createCommentCalls.push(params) - return {data: {id: 200}} - }, - }) - - const result = await appendJournalEntry({ - eventType: 'repo_survey_complete', - text: 'Surveyed another repo.', - metadata: {}, - octokit, - owner: 'fro-bot', - repoName: '.github', - now: FIXED_DATE, - }) - - expect(result.issueNumber).toBe(7) - expect(result.created).toBe(false) - expect(createIssueCalls).toHaveLength(0) - expect(createCommentCalls).toHaveLength(1) - }) - - it('retires stale active issues from previous days', async () => { - const removeLabelCalls: unknown[] = [] - - const octokit = createOctokitMock({ - searchIssues: async () => ({ - data: { - items: [ - // Today's issue - {number: 10, title: '[2026-01-15] Fro Bot operational log'}, - // Yesterday's stale active issue - {number: 5, title: '[2026-01-14] Fro Bot operational log'}, - ], - }, - }), - removeLabel: async params => { - removeLabelCalls.push(params) - return {} - }, - createComment: async () => ({data: {id: 300}}), - }) - - const result = await appendJournalEntry({ - eventType: 'test', - text: 'Test entry.', - metadata: {}, - octokit, - owner: 'fro-bot', - repoName: '.github', - now: FIXED_DATE, - }) - - expect(result.issueNumber).toBe(10) - expect(removeLabelCalls).toHaveLength(1) - const removeCall = removeLabelCalls[0] as Record - expect(removeCall.issue_number).toBe(5) - expect(removeCall.name).toBe('journal-active') - }) - - it('embeds structured metadata in the comment body', async () => { - const createCommentCalls: unknown[] = [] - - const octokit = createOctokitMock({ - createComment: async params => { - createCommentCalls.push(params) - return {data: {id: 400}} - }, - }) - - await appendJournalEntry({ - eventType: 'invitation_accepted', - text: 'Welcomed into a new repo.', - metadata: {inviter: 'marcusrbrown', count: 3}, - repo: 'marcusrbrown/project', - runUrl: 'https://github.com/fro-bot/.github/actions/runs/123', - octokit, - owner: 'fro-bot', - repoName: '.github', - now: FIXED_DATE, - }) - - const comment = createCommentCalls[0] as Record - const body = comment.body as string - expect(body).toContain('Welcomed into a new repo.') - expect(body).toContain('
') - expect(body).toContain('"event": "invitation_accepted"') - expect(body).toContain('"repo": "marcusrbrown/project"') - expect(body).toContain('"run_url": "https://github.com/fro-bot/.github/actions/runs/123"') - expect(body).toContain('"inviter": "marcusrbrown"') - }) - - it('creates today issue when only stale active issues exist', async () => { - const createIssueCalls: unknown[] = [] - - const octokit = createOctokitMock({ - searchIssues: async () => ({ - data: { - items: [{number: 3, title: '[2026-01-14] Fro Bot operational log'}], - }, - }), - createIssue: async params => { - createIssueCalls.push(params) - return {data: {number: 11}} - }, - createComment: async () => ({data: {id: 500}}), - }) - - const result = await appendJournalEntry({ - eventType: 'test', - text: 'New day, new log.', - metadata: {}, - octokit, - owner: 'fro-bot', - repoName: '.github', - now: FIXED_DATE, - }) - - expect(result.created).toBe(true) - expect(createIssueCalls).toHaveLength(1) - }) -}) diff --git a/scripts/journal-entry.ts b/scripts/journal-entry.ts deleted file mode 100644 index 97fc783c6..000000000 --- a/scripts/journal-entry.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type {Octokit} from '@octokit/rest' -import fs from 'node:fs' -import process from 'node:process' - -export type OctokitClient = Octokit - -/** The permanent label applied to every journal issue. */ -const JOURNAL_LABEL = 'journal' -/** Applied only to today's open journal issue; removed when a new day begins. */ -const JOURNAL_ACTIVE_LABEL = 'journal-active' -const DEFAULT_OWNER = 'fro-bot' -const DEFAULT_REPO = '.github' - -export interface JournalEntryParams { - /** Event type identifier (e.g. `invitation_accepted`). */ - eventType: string - /** In-character character voice text for the comment body. */ - text: string - /** Structured metadata emitted inside a collapsed `
` block. */ - metadata: Record - /** Repo context for the event (e.g. `owner/repo`). */ - repo?: string - /** GitHub Actions run URL for traceability. */ - runUrl?: string - /** Authenticated Octokit client. Defaults to env-var-based auth. */ - octokit?: OctokitClient - /** Override the owner for testing. Defaults to `fro-bot`. */ - owner?: string - /** Override the repo name for testing. Defaults to `.github`. */ - repoName?: string - /** Override the current date for testing. Defaults to `new Date()`. */ - now?: Date -} - -export interface JournalEntryResult { - issueNumber: number - commentId: number - /** Whether a new journal issue was created for today. */ - created: boolean -} - -function formatDate(date: Date): string { - return date.toISOString().slice(0, 10) -} - -function buildIssueTitle(dateStr: string): string { - return `[${dateStr}] Fro Bot operational log` -} - -function buildCommentBody(text: string, metadata: Record): string { - const metadataJson = JSON.stringify(metadata, null, 2) - return `${text} - -
-Structured metadata - -\`\`\`json -${metadataJson} -\`\`\` - -
` -} - -async function loadOctokit(token: string): Promise { - const {Octokit} = await import('@octokit/rest') - return new Octokit({auth: token}) -} - -/** - * Append a journal entry to today's operational log issue. - * - * Creates the daily issue if it does not exist, and relabels any - * previous-day active journal issue by removing the `journal-active` label. - */ -export async function appendJournalEntry(params: JournalEntryParams): Promise { - const token = process.env.FRO_BOT_PAT ?? process.env.GITHUB_TOKEN ?? '' - const octokit = params.octokit ?? (await loadOctokit(token)) - const owner = params.owner ?? DEFAULT_OWNER - const repoName = params.repoName ?? DEFAULT_REPO - const today = params.now ?? new Date() - const todayStr = formatDate(today) - const todayTitle = buildIssueTitle(todayStr) - - // Find any currently active journal issues. - const search = await octokit.rest.search.issuesAndPullRequests({ - q: `repo:${owner}/${repoName} is:issue is:open label:${JOURNAL_ACTIVE_LABEL}`, - sort: 'created', - order: 'desc', - per_page: 10, - }) - - let todayIssueNumber: number | undefined - const staleActiveIssues: number[] = [] - - for (const item of search.data.items) { - if (item.title === todayTitle) { - todayIssueNumber ??= item.number - } else { - staleActiveIssues.push(item.number) - } - } - - // Retire previous-day active issues: remove the active label so they remain - // open but no longer surface as the current day's log. - for (const issueNumber of staleActiveIssues) { - await octokit.rest.issues.removeLabel({ - owner, - repo: repoName, - issue_number: issueNumber, - name: JOURNAL_ACTIVE_LABEL, - }) - } - - let created = false - if (todayIssueNumber === undefined) { - const newIssue = await octokit.rest.issues.create({ - owner, - repo: repoName, - title: todayTitle, - body: `Fro Bot operational log for ${todayStr}.`, - labels: [JOURNAL_LABEL, JOURNAL_ACTIVE_LABEL], - }) - todayIssueNumber = newIssue.data.number - created = true - } - - const eventMetadata: Record = { - event: params.eventType, - timestamp: today.toISOString(), - ...params.metadata, - } - if (params.repo !== undefined) eventMetadata.repo = params.repo - if (params.runUrl !== undefined) eventMetadata.run_url = params.runUrl - - const commentBody = buildCommentBody(params.text, eventMetadata) - const comment = await octokit.rest.issues.createComment({ - owner, - repo: repoName, - issue_number: todayIssueNumber, - body: commentBody, - }) - - return { - issueNumber: todayIssueNumber, - commentId: comment.data.id, - created, - } -} - -async function main(): Promise { - // Minimal arg parsing: --event, --text, --metadata, --repo, --run-url - const args = process.argv.slice(2) - const get = (flag: string): string | undefined => { - const idx = args.indexOf(flag) - return idx === -1 ? undefined : args[idx + 1] - } - - const eventType = get('--event') - const text = get('--text') - const metadataRaw = get('--metadata') ?? '{}' - const repo = get('--repo') - const runUrl = get('--run-url') - - if (eventType === undefined || eventType === '' || text === undefined || text === '') { - process.stderr.write( - 'Usage: journal-entry.ts --event --text [--metadata ] [--repo ] [--run-url ]\n', - ) - process.exit(1) - } - - let metadata: Record = {} - try { - metadata = JSON.parse(metadataRaw) as Record - } catch { - process.stderr.write(`Invalid --metadata JSON: ${metadataRaw}\n`) - process.exit(1) - } - - const result = await appendJournalEntry({eventType, text, metadata, repo, runUrl}) - process.stdout.write(`${JSON.stringify(result)}\n`) - - const githubOutput = process.env.GITHUB_OUTPUT - if (githubOutput !== undefined && githubOutput !== '') { - fs.appendFileSync(githubOutput, `issue_number=${result.issueNumber}\n`) - fs.appendFileSync(githubOutput, `comment_id=${result.commentId}\n`) - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - await main() -}