Level 2 · Lesson 4
Print the menu of record
Level 2 · Lesson 4After the tickets, write one Confluence page that lists them, and comment its link on each ticket, once.
Behind, or starting here? Download the code as it should be before this lesson.
Tickets say what to cook. The kitchen also needs one place that says what was decided at the meeting and where each dish ticket is. That is a Confluence page, written by the prep cook after the tickets.
Step 1: Keys to the menu book
Locked until the step before it is done.
Writing a page takes two Confluence scopes: one to look up the space, one to create pages in it. Add them now; the code that needs them comes next.
manifest.ymlRead spaces and write pages in Confluence.
- read:jira-work |
- write:jira-work |
- storage:app |
Added: - read:space:confluence |
Added: - write:page:confluence |
Why: Scope · Confluence page
Stuck? I added a scope or a supplier host
Next step: next
Step 2: Write the menu of record
Locked until the step before it is done.
A new file finds the space’s id from its key, then creates the page through Confluence’s REST API. The body is Confluence’s storage format, a kind of XHTML, so every piece of text goes through escapeHtml first.
src/kitchen/menuPage.jsA new file: find the space, then create one page listing every dish ticket.
Added: import api, { route } from '@forge/api'; |
Added: import { escapeHtml } from '../lib/html'; |
Added: |
Added: /** Looks up a Confluence space's id from its key. Pass api.asApp() or api.asUser(). */ |
Added: export async function findSpaceId(client, spaceKey) { |
Added: const response = await client.requestConfluence(route`/wiki/api/v2/spaces?keys=${spaceKey}&limit=1`); |
Added: if (!response.ok) throw new Error(`Confluence answered ${response.status} looking for space ${spaceKey}.`); |
Added: const data = await response.json(); |
Added: const id = String(data.results?.[0]?.id ?? ''); |
Added: if (!/^\d+$/.test(id)) throw new Error(`No Confluence space with key ${spaceKey}.`); |
Added: return id; |
Added: } |
Added: |
Added: // Storage format is XHTML. The jira macro shows each ticket as a live card. |
Added: function render(menu, tickets) { |
Added: const dishes = tickets |
Added: .map((t) => `<li><ac:structured-macro ac:name="jira"><ac:parameter ac:name="key">${escapeHtml(t.key)}</ac:parameter></ac:structured-macro> ${escapeHtml(t.dish)}</li>`) |
Added: .join(''); |
Added: const people = menu.attendees?.length ? `<p>At the meeting: ${escapeHtml(menu.attendees.join(', '))}</p>` : ''; |
Added: const notes = escapeHtml(menu.notes).replace(/\n/g, '<br/>'); |
Added: return `<p>Agreed at the menu meeting “${escapeHtml(menu.title)}”.</p>${people}<h2>Dishes</h2><ul>${dishes}</ul><h2>Notes</h2><p>${notes}</p>`; |
Added: } |
Added: |
Added: /** The menu of record: one page per menu, in the space from settings, on the restaurant's badge. */ |
Added: export async function createMenuPage({ spaceKey, menuId, menu, tickets }) { |
Added: const spaceId = await findSpaceId(api.asApp(), spaceKey); |
Added: const response = await api.asApp().requestConfluence(route`/wiki/api/v2/pages`, { |
Added: method: 'POST', |
Added: headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, |
Added: body: JSON.stringify({ |
Added: spaceId, |
Added: status: 'current', |
Added: title: `Menu of record: ${menu.title} (${menuId.slice(0, 8)})`, |
Added: body: { representation: 'storage', value: render(menu, tickets) }, |
Added: }), |
Added: }); |
Added: if (!response.ok) throw new Error(`Confluence answered ${response.status}: ${await response.text()}`); |
Added: const page = await response.json(); |
Added: return { id: String(page.id), title: page.title, url: `${page._links?.base ?? ''}${page._links?.webui ?? ''}` }; |
Added: } |
Why: Confluence page · Product REST APIs
Stuck? The tickets appear, but no Confluence page
Next step: next
Step 3: A way to leave a note on a ticket
Locked until the step before it is done.
A small helper adds a comment to a work item as the app:
src/kitchen/comment.jsA new file: comment on a work item on the restaurant’s badge.
Added: import api, { route } from '@forge/api'; |
Added: |
Added: /** Adds a comment (an ADF document) to a work item on the restaurant's own badge. */ |
Added: export async function comment(issueKey, body) { |
Added: const response = await api.asApp().requestJira(route`/rest/api/3/issue/${issueKey}/comment`, { |
Added: method: 'POST', |
Added: headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, |
Added: body: JSON.stringify({ body }), |
Added: }); |
Added: if (!response.ok) throw new Error(`Jira answered ${response.status} commenting on ${issueKey}.`); |
Added: } |
Why: asApp()
Next step: next
Step 4: Publish once, then link every ticket
Locked until the step before it is done.
After the tickets, the prep cook writes the page, if a space is set, and then comments the link on each ticket. Both steps are checkpointed:
src/worker.jsChange 1 of 2Bring in the page, the comment and the settings.
import { getMenu, saveMenu } from './menus'; |
import { createTicket } from './kitchen/createTicket'; |
import { splitMenu } from './kitchen/splitMenu'; |
Added: import { createMenuPage } from './kitchen/menuPage'; |
Added: import { comment } from './kitchen/comment'; |
Added: import { withLink } from './lib/adf'; |
Added: import { settings } from './lib/config'; |
|
// The prep cook. Forge runs this once per job slip on the menu-jobs rail, after |
// submitMenu has already returned. A slip can arrive more than once, so every |
src/worker.jsChange 2 of 2After the tickets: write the page once, then comment on each ticket once.
} |
tickets.push({ key: checkpoint.created[index], dish: dish.dish }); |
} |
Removed: await save('done', { tickets }); |
Added: const { spaceKey } = settings(); |
Added: if (spaceKey && !checkpoint.page) { |
Added: await save('publishing', { tickets }); |
Added: checkpoint.page = await createMenuPage({ spaceKey, menuId, menu, tickets }); |
Added: await save('publishing', { tickets }); |
Added: } |
Added: checkpoint.commented = checkpoint.commented ?? {}; |
Added: for (const ticket of checkpoint.page ? tickets : []) { |
Added: if (checkpoint.commented[ticket.key]) continue; |
Added: await comment(ticket.key, withLink('On the menu of record:', checkpoint.page.url)); |
Added: checkpoint.commented[ticket.key] = true; |
Added: await save('publishing', { tickets }); |
Added: } |
Added: await save('done', { tickets, page: checkpoint.page ?? null }); |
} catch (error) { |
console.error(`Prep cook: menu ${menuId} failed:`, error); |
await save('failed', { error: error.message }); |
Why: Confluence page · Checkpoint
Stuck? The tickets appear, but no Confluence page · My menu stays “waiting” or ends “failed”
Next step: next
Step 5: Sign the licence, and open in Confluence
Locked until the step before it is done.
New scopes mean a major version. And the app has only been installed on Jira so far; to write pages, it needs an installation on Confluence on the same site, once:
npm run build:ui
forge deploy --approve MAJOR_VERSION_RULE
forge install --upgrade
forge installFor the last command, choose Confluence and your developer site. From now on, a major version needs forge install --upgrade for both installations; the CLI asks which one to upgrade.
Stuck? The app now writes to Confluence (Level 2) · Deploy stops after a permission change
Next step: next
Step 6: Send a menu and read the page
Locked until the step before it is done.
Send a menu from the desk. When it is done, the desk offers to open the page in Confluence. It lists every new dish ticket as a live card, with the notes below. Open any of those tickets: its comments include a link back to the page.
Stuck? The tickets appear, but no Confluence page
Next step: next
Compare with yours: download the code after this lesson.