Course menuForge Kitchen

Level 2 · Lesson 4

Print the menu of record

After the tickets, write one Confluence page that lists them, and comment its link on each ticket, once.

About 30 min6 steps

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

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.

Editmanifest.yml

Read 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

Step 2: Write the menu of record

Atlassian’s own products · House systems

Confluence page · the menu of record

The menu of record is one Confluence page per menu, written by the app. It lists every dish ticket as a live Jira card, then the original notes. Because the prep cook records the page in its checkpoint, a retry never writes a second one.

In the kitchen The printed menu that says what was decided and where each dish ticket is.

After the tickets, the consumer writes one Confluence page that lists them.After the tickets, the prep cook prints the menu of record.

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.

New filesrc/kitchen/menuPage.js

A 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

Step 3: A way to leave a note on a ticket

A small helper adds a comment to a work item as the app:

New filesrc/kitchen/comment.js

A 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()

Step 4: Publish once, then link every ticket

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:

Editsrc/worker.jsChange 1 of 2

Bring 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
Editsrc/worker.jsChange 2 of 2

After 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”

Step 5: Sign the licence, and open in Confluence

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:

RunTerminal
npm run build:ui
forge deploy --approve MAJOR_VERSION_RULE
forge install --upgrade
forge install

For 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

Step 6: Send a menu and read the page

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

Compare with yours: download the code after this lesson.

Type at least two letters.